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:
selfrelease
2026-06-14 16:50:31 +08:00
commit a329d4906b
103 changed files with 20052 additions and 0 deletions
+388
View File
@@ -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)})
}
+271
View File
@@ -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, "下架端到端应在秒级内(目标分钟级)")
}