init: AIGC-Hub/AVCC 方案文档 + TCS-IPTV 内容可信锁定系统 MVP
- 方案文档: AVCC 体系建设、IPTV TCS 需求(0-req)/PRD(1-prd)/任务(2-task)/二三四期任务 - tcs-iptv: Go 后端(哈希SDK/MA码生成/可信数据空间mock/业务编排/HTTP API+HMAC鉴权) - web-console: React+AntD 监管大屏(角色工作台/全流程演示/监管片库) - 一剧一码+集级哈希, 集级下架/恢复, 全栈测试通过
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
// 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)})
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tcs-iptv/tcs/internal/chain"
|
||||
"github.com/tcs-iptv/tcs/internal/hash"
|
||||
"github.com/tcs-iptv/tcs/internal/httpx"
|
||||
"github.com/tcs-iptv/tcs/internal/macode"
|
||||
"github.com/tcs-iptv/tcs/internal/service"
|
||||
)
|
||||
|
||||
// testServer 组装完整 API 栈(鉴权 + 路由 + service + 内存链/号段)。
|
||||
func testServer(t *testing.T) (*httptest.Server, *httpx.MemoryKeyStore) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
ch := chain.NewMemoryChain()
|
||||
gen := macode.NewGenerator(macode.NewMemoryStore())
|
||||
require.NoError(t, gen.RegisterSegment(macode.Segment{
|
||||
IndustryNode: "8531", OrgNode: "4401",
|
||||
Category: macode.CategoryMicroDrama, Start: 1, End: 9999999, SeqWidth: 7,
|
||||
}))
|
||||
svc := service.New(ch, gen)
|
||||
h := NewHandler(svc)
|
||||
|
||||
keys := httpx.NewMemoryKeyStore()
|
||||
keys.Add("ak-regulator", "sk-regulator", string(chain.RoleRegulator))
|
||||
keys.Add("ak-reviewer", "sk-reviewer", string(chain.RoleReviewer))
|
||||
keys.Add("ak-cp", "sk-cp", string(chain.RoleCP))
|
||||
keys.Add("ak-operator", "sk-operator", string(chain.RoleOperator))
|
||||
|
||||
r := gin.New()
|
||||
v1 := r.Group("/api/v1", httpx.AuthMiddleware(keys))
|
||||
h.Register(v1)
|
||||
return httptest.NewServer(r), keys
|
||||
}
|
||||
|
||||
// signedCall 发起带 HMAC 签名的请求,返回状态码与解析后的响应。
|
||||
func signedCall(t *testing.T, base, apiKey, secret, method, path string, body any) (int, map[string]any) {
|
||||
t.Helper()
|
||||
var buf []byte
|
||||
if body != nil {
|
||||
buf, _ = json.Marshal(body)
|
||||
}
|
||||
signPath := "/api/v1" + path
|
||||
if i := indexByte(path, '?'); i >= 0 {
|
||||
signPath = "/api/v1" + path[:i]
|
||||
}
|
||||
sig := httpx.Sign(secret, method, signPath)
|
||||
|
||||
req, err := http.NewRequest(method, base+"/api/v1"+path, bytes.NewReader(buf))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Authorization", "TCS "+apiKey+":"+sig)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
var out map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
return resp.StatusCode, out
|
||||
}
|
||||
|
||||
func indexByte(s string, b byte) int {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == b {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func dataOf(m map[string]any) map[string]any {
|
||||
if d, ok := m["data"].(map[string]any); ok {
|
||||
return d
|
||||
}
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
// TestE2E_FullLifecycle 覆盖 MVP 全闭环:送审→发码→审核→入库→发布→注入→下架。
|
||||
func TestE2E_FullLifecycle(t *testing.T) {
|
||||
srv, _ := testServer(t)
|
||||
defer srv.Close()
|
||||
b := srv.URL
|
||||
|
||||
// 1) CP 送审
|
||||
st, resp := signedCall(t, b, "ak-cp", "sk-cp", "POST", "/content/register", map[string]any{
|
||||
"title": "示例微短剧", "episode_count": 24, "category": "WD",
|
||||
"file_sha256": "fh-e2e", "merkle_root": "mr-e2e", "perceptual_hash": "ph-e2e",
|
||||
"cp_media_id": "FS-77821", "cp_name": "飞翮信息",
|
||||
})
|
||||
require.Equal(t, http.StatusAccepted, st)
|
||||
reviewID := dataOf(resp)["review_id"].(string)
|
||||
ctid := dataOf(resp)["content_twin_id"].(string)
|
||||
require.NotEmpty(t, reviewID)
|
||||
|
||||
// 2) CSPS 合规审核(发码前)
|
||||
st, _ = signedCall(t, b, "ak-reviewer", "sk-reviewer", "POST", "/content/csps-result", map[string]any{
|
||||
"review_id": reviewID, "approved": true, "reviewer_id": "rv-1",
|
||||
})
|
||||
require.Equal(t, http.StatusOK, st)
|
||||
|
||||
// 3) 监管发码签发(审核通过后)
|
||||
st, resp = signedCall(t, b, "ak-regulator", "sk-regulator", "POST", "/content/issue", map[string]any{
|
||||
"review_id": reviewID, "issuer": "北京市广播电视局",
|
||||
})
|
||||
require.Equal(t, http.StatusOK, st)
|
||||
maCode := dataOf(resp)["ma_code"].(string)
|
||||
cert := dataOf(resp)["certificate"].(string)
|
||||
assert.True(t, macode.IsValid(maCode))
|
||||
|
||||
// 4) 入媒资库
|
||||
st, _ = signedCall(t, b, "ak-reviewer", "sk-reviewer", "POST", "/content/ingest", map[string]any{
|
||||
"ma_code": maCode, "content_twin_id": ctid, "media_asset_id": "MEDIA-001", "lib_name": "广东IPTV媒资库",
|
||||
})
|
||||
require.Equal(t, http.StatusOK, st)
|
||||
|
||||
// 5) 发布
|
||||
st, _ = signedCall(t, b, "ak-reviewer", "sk-reviewer", "POST", "/content/publish", map[string]any{
|
||||
"ma_code": maCode, "certificate": cert,
|
||||
})
|
||||
require.Equal(t, http.StatusOK, st)
|
||||
|
||||
// 6) CDN 注入(匹配)
|
||||
st, resp = signedCall(t, b, "ak-operator", "sk-operator", "POST", "/content/inject", map[string]any{
|
||||
"content_twin_id": ctid, "ma_code": maCode, "file_sha256": "fh-e2e",
|
||||
"operator_id": "CT-IPTV-GD", "cdn_endpoint": "cdn://ct-gd/vod/1",
|
||||
})
|
||||
require.Equal(t, http.StatusOK, st)
|
||||
assert.Equal(t, true, dataOf(resp)["allowed"])
|
||||
|
||||
// 7) 映射查询
|
||||
st, resp = signedCall(t, b, "ak-regulator", "sk-regulator", "GET", "/content/mappings?ma_code="+maCode, nil)
|
||||
require.Equal(t, http.StatusOK, st)
|
||||
mappings := dataOf(resp)["mappings"].([]any)
|
||||
assert.Len(t, mappings, 3) // cp + reviewer + operator
|
||||
|
||||
// 8) 监管下架
|
||||
st, resp = signedCall(t, b, "ak-regulator", "sk-regulator", "POST", "/content/takedown", map[string]any{
|
||||
"ma_code": maCode, "reason": "违规",
|
||||
})
|
||||
require.Equal(t, http.StatusOK, st)
|
||||
assert.NotEmpty(t, dataOf(resp)["cdn_endpoints"])
|
||||
}
|
||||
|
||||
// TestE2E_TamperRejected 版本篡改专项:注入篡改文件应被拒绝(需求7/15/18-AC4)。
|
||||
func TestE2E_TamperRejected(t *testing.T) {
|
||||
srv, _ := testServer(t)
|
||||
defer srv.Close()
|
||||
b := srv.URL
|
||||
|
||||
st, resp := signedCall(t, b, "ak-cp", "sk-cp", "POST", "/content/register", map[string]any{
|
||||
"title": "X", "episode_count": 1, "category": "WD",
|
||||
"file_sha256": "fh-orig", "merkle_root": "mr-orig",
|
||||
})
|
||||
require.Equal(t, http.StatusAccepted, st)
|
||||
reviewID := dataOf(resp)["review_id"].(string)
|
||||
ctid := dataOf(resp)["content_twin_id"].(string)
|
||||
|
||||
signedCall(t, b, "ak-reviewer", "sk-reviewer", "POST", "/content/csps-result", map[string]any{"review_id": reviewID, "approved": true})
|
||||
_, resp = signedCall(t, b, "ak-regulator", "sk-regulator", "POST", "/content/issue", map[string]any{
|
||||
"review_id": reviewID, "issuer": "x",
|
||||
})
|
||||
maCode := dataOf(resp)["ma_code"].(string)
|
||||
cert := dataOf(resp)["certificate"].(string)
|
||||
signedCall(t, b, "ak-reviewer", "sk-reviewer", "POST", "/content/ingest", map[string]any{"ma_code": maCode, "content_twin_id": ctid, "media_asset_id": "M", "lib_name": "L"})
|
||||
signedCall(t, b, "ak-reviewer", "sk-reviewer", "POST", "/content/publish", map[string]any{"ma_code": maCode, "certificate": cert})
|
||||
|
||||
// 篡改文件注入 → 拒绝
|
||||
st, _ = signedCall(t, b, "ak-operator", "sk-operator", "POST", "/content/inject", map[string]any{
|
||||
"content_twin_id": ctid, "ma_code": maCode, "file_sha256": "fh-TAMPERED",
|
||||
"operator_id": "OP", "cdn_endpoint": "cdn://x",
|
||||
})
|
||||
assert.Equal(t, http.StatusBadRequest, st, "篡改注入必须被拒")
|
||||
}
|
||||
|
||||
// TestE2E_VersionChangeLocatesEpisode 版本变更定位被篡改集(需求12-AC3)。
|
||||
func TestE2E_VersionChangeLocatesEpisode(t *testing.T) {
|
||||
srv, _ := testServer(t)
|
||||
defer srv.Close()
|
||||
b := srv.URL
|
||||
|
||||
st, resp := signedCall(t, b, "ak-cp", "sk-cp", "POST", "/content/register", map[string]any{
|
||||
"title": "多集剧", "episode_count": 3, "category": "WD",
|
||||
"file_sha256": "fh-multi", "merkle_root": "mr-multi",
|
||||
})
|
||||
require.Equal(t, http.StatusAccepted, st)
|
||||
reviewID := dataOf(resp)["review_id"].(string)
|
||||
ctid := dataOf(resp)["content_twin_id"].(string)
|
||||
signedCall(t, b, "ak-reviewer", "sk-reviewer", "POST", "/content/csps-result", map[string]any{"review_id": reviewID, "approved": true})
|
||||
signedCall(t, b, "ak-regulator", "sk-regulator", "POST", "/content/issue", map[string]any{"review_id": reviewID, "issuer": "x"})
|
||||
|
||||
old := []string{hash.SHA256Hex([]byte("ep1")), hash.SHA256Hex([]byte("ep2")), hash.SHA256Hex([]byte("ep3"))}
|
||||
neu := []string{hash.SHA256Hex([]byte("ep1")), hash.SHA256Hex([]byte("ep2-X")), hash.SHA256Hex([]byte("ep3"))}
|
||||
|
||||
st, resp = signedCall(t, b, "ak-reviewer", "sk-reviewer", "POST", "/content/version-change", map[string]any{
|
||||
"content_twin_id": ctid, "reason": "第2集替换",
|
||||
"prev_hash": "mr-multi", "new_hash": "mr-new",
|
||||
"old_segments": old, "new_segments": neu,
|
||||
})
|
||||
require.Equal(t, http.StatusOK, st)
|
||||
eps := dataOf(resp)["affected_episodes"].([]any)
|
||||
require.Len(t, eps, 1)
|
||||
assert.Equal(t, float64(2), eps[0], "应定位到第2集")
|
||||
}
|
||||
|
||||
// TestE2E_PermissionMatrix 权限矩阵:越权操作被拒(需求14)。
|
||||
func TestE2E_PermissionMatrix(t *testing.T) {
|
||||
srv, _ := testServer(t)
|
||||
defer srv.Close()
|
||||
b := srv.URL
|
||||
|
||||
// 准备一条已签发内容
|
||||
_, resp := signedCall(t, b, "ak-cp", "sk-cp", "POST", "/content/register", map[string]any{
|
||||
"title": "P", "episode_count": 1, "category": "WD", "file_sha256": "fh-perm", "merkle_root": "mr-perm",
|
||||
})
|
||||
reviewID := dataOf(resp)["review_id"].(string)
|
||||
|
||||
// 先过 CSPS 审核(否则发码会因未审核被拒,无法测到角色权限)
|
||||
signedCall(t, b, "ak-reviewer", "sk-reviewer", "POST", "/content/csps-result", map[string]any{"review_id": reviewID, "approved": true})
|
||||
|
||||
// CP 越权发码(签发仅监管主体)→ 403
|
||||
st, _ := signedCall(t, b, "ak-cp", "sk-cp", "POST", "/content/issue", map[string]any{"review_id": reviewID, "issuer": "x"})
|
||||
assert.Equal(t, http.StatusForbidden, st, "CP 不得发码")
|
||||
|
||||
// 正常签发
|
||||
_, resp = signedCall(t, b, "ak-regulator", "sk-regulator", "POST", "/content/issue", map[string]any{"review_id": reviewID, "issuer": "x"})
|
||||
maCode := dataOf(resp)["ma_code"].(string)
|
||||
|
||||
// 运营商越权下架 → 403
|
||||
st, _ = signedCall(t, b, "ak-operator", "sk-operator", "POST", "/content/takedown", map[string]any{"ma_code": maCode, "reason": "越权"})
|
||||
assert.Equal(t, http.StatusForbidden, st, "运营商不得发起下架")
|
||||
|
||||
// 无效签名 → 401
|
||||
req, _ := http.NewRequest("GET", b+"/api/v1/content/mappings?ma_code="+maCode, nil)
|
||||
req.Header.Set("Authorization", "TCS ak-regulator:badsig")
|
||||
r, _ := http.DefaultClient.Do(req)
|
||||
assert.Equal(t, http.StatusUnauthorized, r.StatusCode, "错误签名应 401")
|
||||
r.Body.Close()
|
||||
}
|
||||
|
||||
// TestE2E_TakedownLatency 下架时效:端到端应远快于分钟级(需求11/18-AC1)。
|
||||
func TestE2E_TakedownLatency(t *testing.T) {
|
||||
srv, _ := testServer(t)
|
||||
defer srv.Close()
|
||||
b := srv.URL
|
||||
|
||||
_, resp := signedCall(t, b, "ak-cp", "sk-cp", "POST", "/content/register", map[string]any{
|
||||
"title": "L", "episode_count": 1, "category": "WD", "file_sha256": "fh-lat", "merkle_root": "mr-lat",
|
||||
})
|
||||
reviewID := dataOf(resp)["review_id"].(string)
|
||||
signedCall(t, b, "ak-reviewer", "sk-reviewer", "POST", "/content/csps-result", map[string]any{"review_id": reviewID, "approved": true})
|
||||
_, resp = signedCall(t, b, "ak-regulator", "sk-regulator", "POST", "/content/issue", map[string]any{"review_id": reviewID, "issuer": "x"})
|
||||
maCode := dataOf(resp)["ma_code"].(string)
|
||||
|
||||
start := time.Now()
|
||||
st, _ := signedCall(t, b, "ak-regulator", "sk-regulator", "POST", "/content/takedown", map[string]any{"ma_code": maCode, "reason": "违规"})
|
||||
elapsed := time.Since(start)
|
||||
require.Equal(t, http.StatusOK, st)
|
||||
assert.Less(t, elapsed, time.Second, "下架端到端应在秒级内(目标分钟级)")
|
||||
}
|
||||
Reference in New Issue
Block a user