// 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) rg.GET("/content/provenance", h.provenance) // 全链路存证(需求22) rg.GET("/content/accountability", h.accountability) // 责任界定取证(需求22) rg.GET("/content/evidence", h.evidence) // 版权确权证据链(需求23) rg.POST("/content/infringe-match", h.infringeMatch) // 感知哈希侵权比对(需求23) rg.POST("/content/authorize", h.authorize) // 登记授权(需求25) rg.POST("/content/auth-check", h.authCheck) // 授权核验(需求25) rg.POST("/content/add-episodes", h.addEpisodes) // 追更新集(需求24) rg.POST("/content/cross-province", h.crossProvince) // 跨省复用准入(需求13) rg.POST("/terminal/verify-segment", h.terminalVerify) // 终端片段抽检(需求8) rg.POST("/content/bind-filing", h.bindFiling) // 备案/网标关联(三期A.1) rg.GET("/content/filing", h.queryFiling) // 查询备案关联 rg.GET("/regulatory/national-stats", h.nationalStats) // 全国监管统计(三期F.2) rg.GET("/regulatory/daily-report", h.dailyReport) // 监管数据上报日报(三期A.2) rg.GET("/admin/segments", h.listSegments) // 号段管理(三期B.1) } 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) } // ---- 二期:追责取证与确权举证(需求22/23) ---- func (h *Handler) provenance(c *gin.Context) { maCode := c.Query("ma_code") if maCode == "" { httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "缺少 ma_code") return } httpx.OK(c, gin.H{"ma_code": maCode, "trail": h.svc.Provenance(maCode)}) } func (h *Handler) accountability(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.Accountability(maCode)) } func (h *Handler) evidence(c *gin.Context) { maCode := c.Query("ma_code") if maCode == "" { httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "缺少 ma_code") return } ev, err := h.svc.CopyrightEvidence(maCode) if err != nil { httpx.Error(c, http.StatusNotFound, "NOT_FOUND", err.Error()) return } httpx.OK(c, ev) } type infringeReq struct { Perceptual string `json:"perceptual_hash"` High int `json:"high_threshold"` Medium int `json:"medium_threshold"` } func (h *Handler) infringeMatch(c *gin.Context) { var req infringeReq if err := c.ShouldBindJSON(&req); err != nil { httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error()) return } if req.High == 0 { req.High = 5 } if req.Medium == 0 { req.Medium = 10 } matches, err := h.svc.MatchInfringement(req.Perceptual, req.High, req.Medium) if err != nil { httpx.Error(c, http.StatusBadRequest, "MATCH_FAILED", err.Error()) return } httpx.OK(c, gin.H{"matches": matches, "count": len(matches)}) } // ---- 二期:授权链/追更/跨省/终端抽检(需求25/24/13/8) ---- type authorizeReq struct { MACode string `json:"ma_code"` Regions []string `json:"regions"` Platforms []string `json:"platforms"` ExpiryAt string `json:"expiry_at"` // RFC3339,空=长期 } func (h *Handler) authorize(c *gin.Context) { var req authorizeReq if err := c.ShouldBindJSON(&req); err != nil { httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error()) return } var expiry time.Time if req.ExpiryAt != "" { expiry, _ = time.Parse(time.RFC3339, req.ExpiryAt) } if err := h.svc.RecordAuthorization(req.MACode, req.Regions, req.Platforms, expiry); err != nil { httpx.Error(c, http.StatusBadRequest, "AUTHORIZE_FAILED", err.Error()) return } httpx.OK(c, gin.H{"ma_code": req.MACode, "authorized": true}) } type authCheckReq struct { MACode string `json:"ma_code"` Region string `json:"region"` Platform string `json:"platform"` } func (h *Handler) authCheck(c *gin.Context) { var req authCheckReq if err := c.ShouldBindJSON(&req); err != nil { httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error()) return } httpx.OK(c, h.svc.CheckAuthorization(req.MACode, req.Region, req.Platform)) } type addEpisodesReq struct { MACode string `json:"ma_code"` Episodes []struct { Episode int `json:"episode"` FileSHA256 string `json:"file_sha256"` MerkleRoot string `json:"merkle_root"` } `json:"episodes"` } func (h *Handler) addEpisodes(c *gin.Context) { var req addEpisodesReq 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}) } if err := h.svc.AddEpisodes(roleOf(c), req.MACode, eps); err != nil { httpx.Error(c, http.StatusBadRequest, "ADD_EPISODES_FAILED", err.Error()) return } httpx.OK(c, gin.H{"ma_code": req.MACode, "added": len(eps)}) } type crossProvinceReq struct { MACode string `json:"ma_code"` FileHash string `json:"file_sha256"` Province string `json:"province"` } func (h *Handler) crossProvince(c *gin.Context) { var req crossProvinceReq if err := c.ShouldBindJSON(&req); err != nil { httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error()) return } httpx.OK(c, h.svc.CrossProvinceAdmit(req.MACode, req.FileHash, req.Province)) } type terminalVerifyReq struct { MACode string `json:"ma_code"` Episode int `json:"episode"` SegHash string `json:"segment_hash"` } func (h *Handler) terminalVerify(c *gin.Context) { var req terminalVerifyReq if err := c.ShouldBindJSON(&req); err != nil { httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error()) return } ok, msg := h.svc.TerminalVerifySegment(req.MACode, req.Episode, req.SegHash) httpx.OK(c, gin.H{"ok": ok, "message": msg}) } // ---- 三期:备案对接/全国统计/监管上报/号段管理 ---- type bindFilingReq struct { MACode string `json:"ma_code"` LicenseNo string `json:"license_no"` FilingNo string `json:"filing_no"` } func (h *Handler) bindFiling(c *gin.Context) { var req bindFilingReq if err := c.ShouldBindJSON(&req); err != nil { httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error()) return } rec, err := h.svc.BindFiling(req.MACode, req.LicenseNo, req.FilingNo) if err != nil { httpx.Error(c, http.StatusBadRequest, "BIND_FILING_FAILED", err.Error()) return } httpx.OK(c, rec) } func (h *Handler) queryFiling(c *gin.Context) { maCode := c.Query("ma_code") rec, ok := h.svc.QueryFiling(maCode) if !ok { httpx.Error(c, http.StatusNotFound, "NOT_FOUND", "未关联备案") return } httpx.OK(c, rec) } func (h *Handler) nationalStats(c *gin.Context) { st, err := h.svc.NationalStats() if err != nil { httpx.Error(c, http.StatusInternalServerError, "INTERNAL_ERROR", err.Error()) return } httpx.OK(c, st) } func (h *Handler) dailyReport(c *gin.Context) { date := c.Query("date") if date == "" { date = time.Now().Format("2006-01-02") } rep, err := h.svc.DailyRegulatoryReport(date) if err != nil { httpx.Error(c, http.StatusInternalServerError, "INTERNAL_ERROR", err.Error()) return } httpx.OK(c, rep) } func (h *Handler) listSegments(c *gin.Context) { httpx.OK(c, gin.H{"segments": h.svc.ListSegments()}) }