// Package api 暴露 TCS-IPTV 的 HTTP 接口,串联 service 业务编排。 // 对应需求17(接口规范)与各业务需求。 package api import ( "net/http" "time" "github.com/gin-gonic/gin" "github.com/tcs-iptv/tcs/internal/chain" "github.com/tcs-iptv/tcs/internal/httpx" "github.com/tcs-iptv/tcs/internal/model" "github.com/tcs-iptv/tcs/internal/service" ) // Handler 持有业务服务。 type Handler struct { svc *service.Service } // NewHandler 创建 API 处理器。 func NewHandler(svc *service.Service) *Handler { return &Handler{svc: svc} } // Register 注册路由(受鉴权中间件保护的路由组由调用方组装)。 func (h *Handler) Register(rg *gin.RouterGroup) { rg.POST("/content/register", h.register) // CP 送审上链(需求2) rg.POST("/content/csps-result", h.cspsResult) // CSPS 合规审核(发码前,需求5) rg.POST("/content/issue", h.issue) // 审核通过后发码签发(需求3) rg.POST("/content/verify", h.verify) // 哈希验真(需求4/7) rg.POST("/content/transcoded", h.bindTranscoded) // 转码版绑定(需求5) rg.POST("/content/ingest", h.ingest) // 媒资库入库(需求6) rg.POST("/content/publish", h.publish) // 发布给运营商(需求6) rg.POST("/content/inject", h.inject) // CDN 注入校验(需求7) rg.POST("/content/version-change", h.versionChange) // 版本变更重审(需求12) rg.POST("/content/takedown", h.takedown) // 应急下架(需求11) rg.POST("/content/takedown-episode", h.takedownEpisode) // 集级下架(只下架某集) rg.POST("/content/restore", h.restore) // 恢复上架整剧 rg.POST("/content/restore-episode", h.restoreEpisode) // 恢复上架某集 rg.GET("/content/mappings", h.mappings) // 映射查询(需求11/17) rg.POST("/content/verify-episode", h.verifyEpisode) // 集级验真(一剧多集) rg.GET("/content/episodes", h.listEpisodes) // 列出集级哈希 rg.GET("/content/reviews", h.listReviews) // 送审待办队列(待审/待发码) rg.GET("/content/list", h.listContents) // 内容队列(待入库/待发布/待注入) rg.POST("/data/playback", h.reportPlayback) // 播放数据回传(需求9) rg.GET("/data/playback-summary", h.playbackSummary) // 按MA码聚合可信播放数据(需求9/21) rg.POST("/settlement/compute", h.computeSettlement) // 基于可信播放数据分账(需求21) } func roleOf(c *gin.Context) chain.Role { return chain.Role(httpx.RoleFromContext(c)) } // --- handlers --- type episodeHashReq struct { Episode int `json:"episode"` FileSHA256 string `json:"file_sha256"` MerkleRoot string `json:"merkle_root"` Perceptual string `json:"perceptual_hash"` Duration int `json:"duration"` Resolution string `json:"resolution"` } type registerReq struct { Title string `json:"title"` EpisodeCount int `json:"episode_count"` Category string `json:"category"` FileHash string `json:"file_sha256"` MerkleRoot string `json:"merkle_root"` Perceptual string `json:"perceptual_hash"` Episodes []episodeHashReq `json:"episodes"` CPMediaID string `json:"cp_media_id"` CPName string `json:"cp_name"` } func (h *Handler) register(c *gin.Context) { var req registerReq if err := c.ShouldBindJSON(&req); err != nil { httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error()) return } eps := make([]model.EpisodeHash, 0, len(req.Episodes)) for _, e := range req.Episodes { eps = append(eps, model.EpisodeHash{ Episode: e.Episode, FileSHA256: e.FileSHA256, MerkleRoot: e.MerkleRoot, Perceptual: e.Perceptual, Duration: e.Duration, Resolution: e.Resolution, }) } res, err := h.svc.SubmitForReview(service.Submission{ Title: req.Title, EpisodeCount: req.EpisodeCount, Category: req.Category, FileHash: req.FileHash, MerkleRoot: req.MerkleRoot, Perceptual: req.Perceptual, Episodes: eps, CPMediaID: req.CPMediaID, CPName: req.CPName, }) if err != nil { httpx.Error(c, http.StatusBadRequest, "REGISTER_FAILED", err.Error()) return } httpx.Accepted(c, res) } type issueReq struct { ReviewID string `json:"review_id"` Issuer string `json:"issuer"` } func (h *Handler) issue(c *gin.Context) { var req issueReq if err := c.ShouldBindJSON(&req); err != nil { httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error()) return } res, err := h.svc.ApproveAndIssue(roleOf(c), req.ReviewID, req.Issuer) if err != nil { httpx.Error(c, http.StatusForbidden, "ISSUE_FAILED", err.Error()) return } httpx.OK(c, res) } type verifyReq struct { MACode string `json:"ma_code"` FileHash string `json:"file_sha256"` } func (h *Handler) verify(c *gin.Context) { var req verifyReq if err := c.ShouldBindJSON(&req); err != nil { httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error()) return } res, err := h.svc.Verify(req.MACode, req.FileHash) if err != nil { // 验真不匹配也返回结果体,便于调用方据 match 处理 httpx.Error(c, http.StatusBadRequest, "VERIFY_MISMATCH", err.Error()) return } httpx.OK(c, res) } type cspsReq struct { ReviewID string `json:"review_id"` Approved bool `json:"approved"` ReviewerID string `json:"reviewer_id"` } func (h *Handler) cspsResult(c *gin.Context) { var req cspsReq if err := c.ShouldBindJSON(&req); err != nil { httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error()) return } if err := h.svc.ReviewCSPS(req.ReviewID, req.Approved, req.ReviewerID); err != nil { httpx.Error(c, http.StatusBadRequest, "CSPS_FAILED", err.Error()) return } httpx.OK(c, gin.H{"review_id": req.ReviewID, "approved": req.Approved}) } type transcodedReq struct { CTID string `json:"content_twin_id"` ParentFileHash string `json:"parent_file_hash"` TranscodedHash string `json:"transcoded_hash"` Format string `json:"format"` Resolution string `json:"resolution"` Version string `json:"version"` } func (h *Handler) bindTranscoded(c *gin.Context) { var req transcodedReq if err := c.ShouldBindJSON(&req); err != nil { httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error()) return } tx, err := h.svc.BindTranscoded(roleOf(c), req.CTID, req.ParentFileHash, req.TranscodedHash, req.Format, req.Resolution, req.Version) if err != nil { httpx.Error(c, http.StatusBadRequest, "TRANSCODE_BIND_FAILED", err.Error()) return } httpx.OK(c, gin.H{"tx_id": tx}) } type ingestReq struct { MACode string `json:"ma_code"` CTID string `json:"content_twin_id"` MediaAssetID string `json:"media_asset_id"` LibName string `json:"lib_name"` } func (h *Handler) ingest(c *gin.Context) { var req ingestReq if err := c.ShouldBindJSON(&req); err != nil { httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error()) return } if err := h.svc.IngestToLibrary(roleOf(c), req.MACode, req.CTID, req.MediaAssetID, req.LibName); err != nil { httpx.Error(c, http.StatusBadRequest, "INGEST_FAILED", err.Error()) return } httpx.OK(c, gin.H{"ma_code": req.MACode, "status": "in_library"}) } type publishReq struct { MACode string `json:"ma_code"` Certificate string `json:"certificate"` } func (h *Handler) publish(c *gin.Context) { var req publishReq if err := c.ShouldBindJSON(&req); err != nil { httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error()) return } if err := h.svc.PublishToOperator(service.PublishRequest{MACode: req.MACode, Certificate: req.Certificate}); err != nil { httpx.Error(c, http.StatusBadRequest, "PUBLISH_FAILED", err.Error()) return } httpx.OK(c, gin.H{"ma_code": req.MACode, "status": "published"}) } type injectReq struct { CTID string `json:"content_twin_id"` MACode string `json:"ma_code"` FileHash string `json:"file_sha256"` OperatorID string `json:"operator_id"` CDNEndpoint string `json:"cdn_endpoint"` } func (h *Handler) inject(c *gin.Context) { var req injectReq if err := c.ShouldBindJSON(&req); err != nil { httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error()) return } res, err := h.svc.InjectToCDN(roleOf(c), req.CTID, req.MACode, req.FileHash, req.OperatorID, req.CDNEndpoint) if err != nil { httpx.Error(c, http.StatusBadRequest, "INJECT_REJECTED", err.Error()) return } httpx.OK(c, res) } type versionChangeReq struct { CTID string `json:"content_twin_id"` Reason string `json:"reason"` PrevHash string `json:"prev_hash"` NewHash string `json:"new_hash"` OldSegments []string `json:"old_segments"` NewSegments []string `json:"new_segments"` } func (h *Handler) versionChange(c *gin.Context) { var req versionChangeReq if err := c.ShouldBindJSON(&req); err != nil { httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error()) return } episodes, err := h.svc.ReportVersionChange(req.CTID, req.Reason, req.PrevHash, req.NewHash, req.OldSegments, req.NewSegments) if err != nil { httpx.Error(c, http.StatusBadRequest, "VERSION_CHANGE_FAILED", err.Error()) return } httpx.OK(c, gin.H{"reaudit_required": true, "affected_episodes": episodes}) } type takedownReq struct { MACode string `json:"ma_code"` Reason string `json:"reason"` } func (h *Handler) takedown(c *gin.Context) { var req takedownReq if err := c.ShouldBindJSON(&req); err != nil { httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error()) return } res, err := h.svc.Takedown(roleOf(c), req.MACode, req.Reason) if err != nil { httpx.Error(c, http.StatusForbidden, "TAKEDOWN_FAILED", err.Error()) return } httpx.OK(c, res) } type takedownEpisodeReq struct { MACode string `json:"ma_code"` Episode int `json:"episode"` Reason string `json:"reason"` } func (h *Handler) takedownEpisode(c *gin.Context) { var req takedownEpisodeReq if err := c.ShouldBindJSON(&req); err != nil { httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error()) return } if err := h.svc.TakedownEpisode(roleOf(c), req.MACode, req.Episode, req.Reason); err != nil { httpx.Error(c, http.StatusForbidden, "TAKEDOWN_EPISODE_FAILED", err.Error()) return } httpx.OK(c, gin.H{"ma_code": req.MACode, "episode": req.Episode, "revoked": true}) } func (h *Handler) restore(c *gin.Context) { var req takedownReq if err := c.ShouldBindJSON(&req); err != nil { httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error()) return } if err := h.svc.Restore(roleOf(c), req.MACode); err != nil { httpx.Error(c, http.StatusForbidden, "RESTORE_FAILED", err.Error()) return } httpx.OK(c, gin.H{"ma_code": req.MACode, "status": "published"}) } func (h *Handler) restoreEpisode(c *gin.Context) { var req takedownEpisodeReq if err := c.ShouldBindJSON(&req); err != nil { httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error()) return } if err := h.svc.RestoreEpisode(roleOf(c), req.MACode, req.Episode); err != nil { httpx.Error(c, http.StatusForbidden, "RESTORE_EPISODE_FAILED", err.Error()) return } httpx.OK(c, gin.H{"ma_code": req.MACode, "episode": req.Episode, "revoked": false}) } func (h *Handler) mappings(c *gin.Context) { maCode := c.Query("ma_code") if maCode == "" { httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "缺少 ma_code") return } res, err := h.svc.QueryMappings(maCode) if err != nil { httpx.Error(c, http.StatusNotFound, "NOT_FOUND", err.Error()) return } httpx.OK(c, res) } type verifyEpisodeReq struct { MACode string `json:"ma_code"` Episode int `json:"episode"` FileHash string `json:"file_sha256"` } func (h *Handler) verifyEpisode(c *gin.Context) { var req verifyEpisodeReq if err := c.ShouldBindJSON(&req); err != nil { httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error()) return } res, err := h.svc.VerifyEpisode(req.MACode, req.Episode, req.FileHash) if err != nil { httpx.Error(c, http.StatusBadRequest, "VERIFY_MISMATCH", err.Error()) return } httpx.OK(c, res) } func (h *Handler) listEpisodes(c *gin.Context) { maCode := c.Query("ma_code") if maCode == "" { httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "缺少 ma_code") return } eps, err := h.svc.ListEpisodes(maCode) if err != nil { httpx.Error(c, http.StatusNotFound, "NOT_FOUND", err.Error()) return } httpx.OK(c, gin.H{"ma_code": maCode, "episodes": eps, "count": len(eps)}) } func (h *Handler) listReviews(c *gin.Context) { httpx.OK(c, gin.H{"reviews": h.svc.ListReviews(c.Query("status"))}) } func (h *Handler) listContents(c *gin.Context) { list, err := h.svc.ListContentsByStatus(c.Query("status")) if err != nil { httpx.Error(c, http.StatusInternalServerError, "INTERNAL_ERROR", err.Error()) return } httpx.OK(c, gin.H{"contents": list, "count": len(list)}) } // ---- 二期:播放数据回传与分账(需求9/21) ---- type playbackReq struct { PlatformID string `json:"platform_id"` Batch []struct { MACode string `json:"ma_code"` Episode int `json:"episode"` UserHash string `json:"user_hash"` EventType string `json:"event_type"` DurationSec int `json:"duration_sec"` RevenueCent int64 `json:"revenue_cent"` } `json:"batch"` } func (h *Handler) reportPlayback(c *gin.Context) { var req playbackReq if err := c.ShouldBindJSON(&req); err != nil { httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error()) return } events := make([]model.PlaybackEvent, 0, len(req.Batch)) for _, b := range req.Batch { events = append(events, model.PlaybackEvent{ MACode: b.MACode, Episode: b.Episode, PlatformID: req.PlatformID, UserHash: b.UserHash, EventType: model.PlaybackEventType(b.EventType), DurationSec: b.DurationSec, RevenueCent: b.RevenueCent, EventTime: time.Now(), }) } accepted, rejected := h.svc.ReportPlayback(events) httpx.OK(c, gin.H{"accepted": accepted, "rejected": rejected}) } func (h *Handler) playbackSummary(c *gin.Context) { maCode := c.Query("ma_code") if maCode == "" { httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "缺少 ma_code") return } httpx.OK(c, h.svc.PlaybackSummary(maCode)) } type settlementReq struct { MACode string `json:"ma_code"` Period string `json:"period"` } func (h *Handler) computeSettlement(c *gin.Context) { var req settlementReq if err := c.ShouldBindJSON(&req); err != nil { httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error()) return } st, err := h.svc.ComputeSettlement(req.MACode, req.Period) if err != nil { httpx.Error(c, http.StatusBadRequest, "SETTLEMENT_FAILED", err.Error()) return } httpx.OK(c, st) }