a329d4906b
- 方案文档: AVCC 体系建设、IPTV TCS 需求(0-req)/PRD(1-prd)/任务(2-task)/二三四期任务 - tcs-iptv: Go 后端(哈希SDK/MA码生成/可信数据空间mock/业务编排/HTTP API+HMAC鉴权) - web-console: React+AntD 监管大屏(角色工作台/全流程演示/监管片库) - 一剧一码+集级哈希, 集级下架/恢复, 全栈测试通过
389 lines
12 KiB
Go
389 lines
12 KiB
Go
// Package api 暴露 TCS-IPTV 的 HTTP 接口,串联 service 业务编排。
|
||
// 对应需求17(接口规范)与各业务需求。
|
||
package api
|
||
|
||
import (
|
||
"net/http"
|
||
|
||
"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) // 内容队列(待入库/待发布/待注入)
|
||
}
|
||
|
||
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)})
|
||
}
|