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, "下架端到端应在秒级内(目标分钟级)")
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Package chain 定义可信数据空间(联盟链)的客户端抽象。
|
||||
// MVP 提供内存 mock 实现,使业务逻辑可在无真实 ChainMaker 网络时开发与测试;
|
||||
// 后续以 ChainMaker Go SDK 实现替换,接口不变。
|
||||
// 对应需求:需求16(智能合约方法)、需求3/4(签发与验真)、需求14(权限)。
|
||||
package chain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/tcs-iptv/tcs/internal/model"
|
||||
)
|
||||
|
||||
// Role 调用方角色,用于合约级权限控制(需求14)。
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleRegulator Role = "regulator" // 监管主体:唯一可 issueMA / 下架
|
||||
RoleReviewer Role = "reviewer" // 审核主体(CSPS/媒资库)
|
||||
RoleCP Role = "cp" // 内容提供商
|
||||
RoleOperator Role = "operator" // 运营商
|
||||
)
|
||||
|
||||
// 错误定义。
|
||||
var (
|
||||
ErrPermissionDenied = errors.New("chain: permission denied")
|
||||
ErrMANotIssued = errors.New("chain: MA not issued")
|
||||
ErrMAAlreadyIssued = errors.New("chain: MA already issued (1:1 binding immutable)")
|
||||
ErrHashExists = errors.New("chain: content hash already exists")
|
||||
ErrNotFound = errors.New("chain: not found")
|
||||
)
|
||||
|
||||
// IssueRequest 签发 MA 码并强绑定哈希包。
|
||||
type IssueRequest struct {
|
||||
MACode string
|
||||
ContentTwinID string
|
||||
MerkleRoot string
|
||||
FileHash string
|
||||
PerceptualHash string
|
||||
Episodes []model.EpisodeHash // 集级哈希(分集内容)
|
||||
Content model.Content
|
||||
}
|
||||
|
||||
// VerifyResult 哈希验真结果(需求4-AC4)。
|
||||
type VerifyResult struct {
|
||||
Valid bool `json:"valid"`
|
||||
MACode string `json:"ma_code"`
|
||||
BoundHash string `json:"bound_hash"`
|
||||
SubmittedHash string `json:"submitted_hash"`
|
||||
Match bool `json:"match"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// MappingsResult 映射查询结果(需求11/17)。
|
||||
type MappingsResult struct {
|
||||
MACode string `json:"ma_code"`
|
||||
Mappings []model.Mapping `json:"mappings"`
|
||||
CDNEndpoints []string `json:"cdn_endpoints"`
|
||||
}
|
||||
|
||||
// Client 是可信数据空间的统一访问接口。
|
||||
// 业务服务只依赖此接口,不感知底层是 mock 还是 ChainMaker。
|
||||
type Client interface {
|
||||
// IssueMA 签发 MA 码并与哈希包 1:1 强绑定(仅监管主体)。
|
||||
IssueMA(role Role, req IssueRequest) (txID string, err error)
|
||||
// RegisterHashBinding 追加哈希绑定(如转码版,建立父子关系)。
|
||||
RegisterHashBinding(role Role, b model.HashBinding) (txID string, err error)
|
||||
// RegisterMapping 注册三方编码映射(MA 必须已签发)。
|
||||
RegisterMapping(role Role, m model.Mapping) (txID string, err error)
|
||||
// VerifyHash 按 MA 码校验提交哈希是否与绑定哈希一致。
|
||||
VerifyHash(maCode, fileHash string) (VerifyResult, error)
|
||||
// VerifyEpisodeHash 按 MA 码+集号校验该集哈希。
|
||||
VerifyEpisodeHash(maCode string, episode int, fileHash string) (VerifyResult, error)
|
||||
// ListEpisodes 返回某 MA 码下的全部集级哈希绑定。
|
||||
ListEpisodes(maCode string) ([]model.HashBinding, error)
|
||||
// HashExists 判断内容哈希是否已存在(防换壳重发)。
|
||||
HashExists(fileHash string) (maCode string, exists bool)
|
||||
// QueryContent 查询内容主记录。
|
||||
QueryContent(maCode string) (model.Content, error)
|
||||
// ListContents 按状态列出内容(空状态返回全部)。
|
||||
ListContents(status string) ([]model.Content, error)
|
||||
// QueryMappings 查询 MA 码绑定的全部三方映射与 CDN 端点。
|
||||
QueryMappings(maCode string) (MappingsResult, error)
|
||||
// RecordVersionChange 记录版本变更(绑定断裂触发重审)。
|
||||
RecordVersionChange(vc model.VersionChange) (txID string, err error)
|
||||
// Revoke 下架(仅监管主体),返回受影响的映射。
|
||||
Revoke(role Role, maCode, reason string) (MappingsResult, error)
|
||||
// RevokeEpisode 集级下架(仅监管主体):只下架指定集,整剧其他集不受影响。
|
||||
RevokeEpisode(role Role, maCode string, episode int, reason string) error
|
||||
// Restore 恢复上架整剧(仅监管主体):下架状态恢复为流通中。
|
||||
Restore(role Role, maCode string) error
|
||||
// RestoreEpisode 恢复上架指定集(仅监管主体)。
|
||||
RestoreEpisode(role Role, maCode string, episode int) error
|
||||
// SetContentStatus 更新内容状态。
|
||||
SetContentStatus(maCode, status string) error
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tcs-iptv/tcs/internal/model"
|
||||
)
|
||||
|
||||
// MemoryChain 是 Client 的内存实现(MVP / 测试用)。
|
||||
// 严格执行合约级业务规则:签发权限、1:1 不可解绑、映射前置签发、防重复哈希。
|
||||
type MemoryChain struct {
|
||||
mu sync.RWMutex
|
||||
contents map[string]model.Content // maCode -> Content
|
||||
bindings map[string][]model.HashBinding // maCode -> bindings
|
||||
mappings map[string][]model.Mapping // maCode -> mappings
|
||||
versions map[string][]model.VersionChange
|
||||
hashIndex map[string]string // fileHash -> maCode(防换壳重发)
|
||||
txSeq int
|
||||
}
|
||||
|
||||
// NewMemoryChain 创建内存链客户端。
|
||||
func NewMemoryChain() *MemoryChain {
|
||||
return &MemoryChain{
|
||||
contents: make(map[string]model.Content),
|
||||
bindings: make(map[string][]model.HashBinding),
|
||||
mappings: make(map[string][]model.Mapping),
|
||||
versions: make(map[string][]model.VersionChange),
|
||||
hashIndex: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MemoryChain) nextTx(method string) string {
|
||||
m.txSeq++
|
||||
return fmt.Sprintf("tx-%s-%06d", method, m.txSeq)
|
||||
}
|
||||
|
||||
// IssueMA 仅监管主体可调用;MA 不可重复签发;哈希 1:1 强绑定不可解绑。
|
||||
func (m *MemoryChain) IssueMA(role Role, req IssueRequest) (string, error) {
|
||||
if role != RoleRegulator {
|
||||
return "", ErrPermissionDenied
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if _, ok := m.contents[req.MACode]; ok {
|
||||
return "", ErrMAAlreadyIssued
|
||||
}
|
||||
if existing, ok := m.hashIndex[req.FileHash]; ok {
|
||||
return "", fmt.Errorf("%w: bound to %s", ErrHashExists, existing)
|
||||
}
|
||||
|
||||
c := req.Content
|
||||
c.MACode = req.MACode
|
||||
c.ContentTwinID = req.ContentTwinID
|
||||
c.Status = model.StatusApproved
|
||||
if c.CreatedAt.IsZero() {
|
||||
c.CreatedAt = time.Now()
|
||||
}
|
||||
m.contents[req.MACode] = c
|
||||
|
||||
m.bindings[req.MACode] = []model.HashBinding{{
|
||||
ContentTwinID: req.ContentTwinID,
|
||||
HashType: model.HashFile,
|
||||
HashValue: req.FileHash,
|
||||
MerkleRoot: req.MerkleRoot,
|
||||
Version: "v1.0",
|
||||
CreatedBy: string(RoleRegulator),
|
||||
}}
|
||||
if req.PerceptualHash != "" {
|
||||
m.bindings[req.MACode] = append(m.bindings[req.MACode], model.HashBinding{
|
||||
ContentTwinID: req.ContentTwinID,
|
||||
HashType: model.HashPerceptual,
|
||||
HashValue: req.PerceptualHash,
|
||||
Version: "v1.0",
|
||||
CreatedBy: string(RoleRegulator),
|
||||
})
|
||||
}
|
||||
m.hashIndex[req.FileHash] = req.MACode
|
||||
|
||||
// 集级哈希绑定(分集内容):每集独立哈希,挂在同一 MA 码下。
|
||||
for _, ep := range req.Episodes {
|
||||
m.bindings[req.MACode] = append(m.bindings[req.MACode], model.HashBinding{
|
||||
ContentTwinID: req.ContentTwinID,
|
||||
HashType: model.HashFile,
|
||||
HashValue: ep.FileSHA256,
|
||||
MerkleRoot: ep.MerkleRoot,
|
||||
Episode: ep.Episode,
|
||||
Resolution: ep.Resolution,
|
||||
Duration: ep.Duration,
|
||||
Version: "v1.0",
|
||||
CreatedBy: string(RoleRegulator),
|
||||
})
|
||||
if ep.FileSHA256 != "" {
|
||||
if _, ok := m.hashIndex[ep.FileSHA256]; !ok {
|
||||
m.hashIndex[ep.FileSHA256] = req.MACode
|
||||
}
|
||||
}
|
||||
}
|
||||
return m.nextTx("issueMA"), nil
|
||||
}
|
||||
|
||||
// RegisterHashBinding 追加哈希绑定(如转码版)。MA 必须已签发。
|
||||
func (m *MemoryChain) RegisterHashBinding(role Role, b model.HashBinding) (string, error) {
|
||||
if role != RoleReviewer && role != RoleRegulator {
|
||||
return "", ErrPermissionDenied
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
maCode := m.maCodeByCTID(b.ContentTwinID)
|
||||
if maCode == "" {
|
||||
return "", ErrMANotIssued
|
||||
}
|
||||
m.bindings[maCode] = append(m.bindings[maCode], b)
|
||||
if b.HashType == model.HashFile || b.HashType == model.HashTranscoded {
|
||||
if _, ok := m.hashIndex[b.HashValue]; !ok {
|
||||
m.hashIndex[b.HashValue] = maCode
|
||||
}
|
||||
}
|
||||
return m.nextTx("registerHashBinding"), nil
|
||||
}
|
||||
|
||||
// RegisterMapping 注册三方编码映射;MA 必须已签发(需求16-AC3)。
|
||||
func (m *MemoryChain) RegisterMapping(role Role, mp model.Mapping) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
maCode := m.maCodeByCTID(mp.ContentTwinID)
|
||||
if maCode == "" {
|
||||
return "", ErrMANotIssued
|
||||
}
|
||||
m.mappings[maCode] = append(m.mappings[maCode], mp)
|
||||
return m.nextTx("registerMapping"), nil
|
||||
}
|
||||
|
||||
// VerifyHash 按 MA 码校验提交哈希。
|
||||
func (m *MemoryChain) VerifyHash(maCode, fileHash string) (VerifyResult, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
bs, ok := m.bindings[maCode]
|
||||
if !ok {
|
||||
return VerifyResult{Valid: false, MACode: maCode, SubmittedHash: fileHash}, ErrMANotIssued
|
||||
}
|
||||
for _, b := range bs {
|
||||
if b.HashType == model.HashFile || b.HashType == model.HashTranscoded {
|
||||
if b.HashValue == fileHash {
|
||||
return VerifyResult{
|
||||
Valid: true, MACode: maCode,
|
||||
BoundHash: b.HashValue, SubmittedHash: fileHash,
|
||||
Match: true, Version: b.Version,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
// 取首个文件哈希作为 bound 参考
|
||||
bound := ""
|
||||
for _, b := range bs {
|
||||
if b.HashType == model.HashFile {
|
||||
bound = b.HashValue
|
||||
break
|
||||
}
|
||||
}
|
||||
return VerifyResult{Valid: true, MACode: maCode, BoundHash: bound, SubmittedHash: fileHash, Match: false}, nil
|
||||
}
|
||||
|
||||
// HashExists 判断内容哈希是否已存在。
|
||||
func (m *MemoryChain) HashExists(fileHash string) (string, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
ma, ok := m.hashIndex[fileHash]
|
||||
return ma, ok
|
||||
}
|
||||
|
||||
// VerifyEpisodeHash 按 MA 码+集号校验该集哈希。
|
||||
func (m *MemoryChain) VerifyEpisodeHash(maCode string, episode int, fileHash string) (VerifyResult, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
bs, ok := m.bindings[maCode]
|
||||
if !ok {
|
||||
return VerifyResult{Valid: false, MACode: maCode, SubmittedHash: fileHash}, ErrMANotIssued
|
||||
}
|
||||
var bound string
|
||||
for _, b := range bs {
|
||||
if b.Episode == episode && (b.HashType == model.HashFile || b.HashType == model.HashTranscoded) {
|
||||
if bound == "" {
|
||||
bound = b.HashValue
|
||||
}
|
||||
if b.HashValue == fileHash {
|
||||
return VerifyResult{
|
||||
Valid: true, MACode: maCode,
|
||||
BoundHash: b.HashValue, SubmittedHash: fileHash,
|
||||
Match: true, Version: b.Version,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if bound == "" {
|
||||
return VerifyResult{Valid: false, MACode: maCode, SubmittedHash: fileHash}, ErrNotFound
|
||||
}
|
||||
return VerifyResult{Valid: true, MACode: maCode, BoundHash: bound, SubmittedHash: fileHash, Match: false}, nil
|
||||
}
|
||||
|
||||
// ListEpisodes 返回某 MA 码下的全部集级哈希绑定(episode > 0)。
|
||||
func (m *MemoryChain) ListEpisodes(maCode string) ([]model.HashBinding, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
bs, ok := m.bindings[maCode]
|
||||
if !ok {
|
||||
return nil, ErrMANotIssued
|
||||
}
|
||||
var out []model.HashBinding
|
||||
for _, b := range bs {
|
||||
if b.Episode > 0 {
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// QueryContent 查询内容主记录。
|
||||
func (m *MemoryChain) QueryContent(maCode string) (model.Content, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
c, ok := m.contents[maCode]
|
||||
if !ok {
|
||||
return model.Content{}, ErrNotFound
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// ListContents 按状态列出内容(空状态返回全部),附带整剧文件哈希便于演示。
|
||||
func (m *MemoryChain) ListContents(status string) ([]model.Content, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
var out []model.Content
|
||||
for ma, c := range m.contents {
|
||||
if status == "" || c.Status == status {
|
||||
// 附带整剧文件哈希(episode==0 的 file 绑定)
|
||||
for _, b := range m.bindings[ma] {
|
||||
if b.HashType == model.HashFile && b.Episode == 0 {
|
||||
c.FileHash = b.HashValue
|
||||
break
|
||||
}
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// QueryMappings 查询 MA 码绑定的全部映射与 CDN 端点。
|
||||
func (m *MemoryChain) QueryMappings(maCode string) (MappingsResult, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if _, ok := m.contents[maCode]; !ok {
|
||||
return MappingsResult{}, ErrNotFound
|
||||
}
|
||||
res := MappingsResult{MACode: maCode, Mappings: m.mappings[maCode]}
|
||||
for _, mp := range m.mappings[maCode] {
|
||||
if mp.CDNEndpoint != "" {
|
||||
res.CDNEndpoints = append(res.CDNEndpoints, mp.CDNEndpoint)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// RecordVersionChange 记录版本变更。
|
||||
func (m *MemoryChain) RecordVersionChange(vc model.VersionChange) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
maCode := m.maCodeByCTID(vc.ContentTwinID)
|
||||
if maCode == "" {
|
||||
return "", ErrMANotIssued
|
||||
}
|
||||
m.versions[maCode] = append(m.versions[maCode], vc)
|
||||
return m.nextTx("recordVersionChange"), nil
|
||||
}
|
||||
|
||||
// Revoke 下架,仅监管主体。
|
||||
func (m *MemoryChain) Revoke(role Role, maCode, reason string) (MappingsResult, error) {
|
||||
if role != RoleRegulator {
|
||||
return MappingsResult{}, ErrPermissionDenied
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
c, ok := m.contents[maCode]
|
||||
if !ok {
|
||||
return MappingsResult{}, ErrNotFound
|
||||
}
|
||||
c.Status = model.StatusRevoked
|
||||
m.contents[maCode] = c
|
||||
|
||||
res := MappingsResult{MACode: maCode, Mappings: m.mappings[maCode]}
|
||||
for _, mp := range m.mappings[maCode] {
|
||||
if mp.CDNEndpoint != "" {
|
||||
res.CDNEndpoints = append(res.CDNEndpoints, mp.CDNEndpoint)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// RevokeEpisode 集级下架:只下架指定集,整剧其他集不受影响(仅监管主体)。
|
||||
func (m *MemoryChain) RevokeEpisode(role Role, maCode string, episode int, reason string) error {
|
||||
if role != RoleRegulator {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
bs, ok := m.bindings[maCode]
|
||||
if !ok {
|
||||
return ErrMANotIssued
|
||||
}
|
||||
found := false
|
||||
for i := range bs {
|
||||
if bs[i].Episode == episode {
|
||||
bs[i].Revoked = true
|
||||
bs[i].RevokedReason = reason
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return ErrNotFound
|
||||
}
|
||||
m.bindings[maCode] = bs
|
||||
return nil
|
||||
}
|
||||
|
||||
// Restore 恢复上架整剧:下架状态恢复为流通中(仅监管主体)。
|
||||
func (m *MemoryChain) Restore(role Role, maCode string) error {
|
||||
if role != RoleRegulator {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
c, ok := m.contents[maCode]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
c.Status = model.StatusPublished
|
||||
m.contents[maCode] = c
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestoreEpisode 恢复上架指定集(仅监管主体)。
|
||||
func (m *MemoryChain) RestoreEpisode(role Role, maCode string, episode int) error {
|
||||
if role != RoleRegulator {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
bs, ok := m.bindings[maCode]
|
||||
if !ok {
|
||||
return ErrMANotIssued
|
||||
}
|
||||
found := false
|
||||
for i := range bs {
|
||||
if bs[i].Episode == episode {
|
||||
bs[i].Revoked = false
|
||||
bs[i].RevokedReason = ""
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return ErrNotFound
|
||||
}
|
||||
m.bindings[maCode] = bs
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetContentStatus 更新内容状态。
|
||||
func (m *MemoryChain) SetContentStatus(maCode, status string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
c, ok := m.contents[maCode]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
c.Status = status
|
||||
m.contents[maCode] = c
|
||||
return nil
|
||||
}
|
||||
|
||||
// maCodeByCTID 内部辅助:通过 CTID 反查 MA 码(调用方已持锁)。
|
||||
func (m *MemoryChain) maCodeByCTID(ctid string) string {
|
||||
for ma, c := range m.contents {
|
||||
if c.ContentTwinID == ctid {
|
||||
return ma
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var _ Client = (*MemoryChain)(nil)
|
||||
@@ -0,0 +1,149 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tcs-iptv/tcs/internal/model"
|
||||
)
|
||||
|
||||
func newIssued(t *testing.T) *MemoryChain {
|
||||
t.Helper()
|
||||
c := NewMemoryChain()
|
||||
_, err := c.IssueMA(RoleRegulator, IssueRequest{
|
||||
MACode: "(京)网微剧审字(2025)第123号",
|
||||
ContentTwinID: "ctid-001",
|
||||
MerkleRoot: "merkle-root-1",
|
||||
FileHash: "filehash-1",
|
||||
Content: model.Content{Title: "示例剧集", EpisodeCount: 24},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return c
|
||||
}
|
||||
|
||||
func TestIssueMA_OnlyRegulator(t *testing.T) {
|
||||
c := NewMemoryChain()
|
||||
_, err := c.IssueMA(RoleCP, IssueRequest{MACode: "MA-1", ContentTwinID: "ct-1", FileHash: "h1"})
|
||||
assert.ErrorIs(t, err, ErrPermissionDenied)
|
||||
|
||||
_, err = c.IssueMA(RoleReviewer, IssueRequest{MACode: "MA-1", ContentTwinID: "ct-1", FileHash: "h1"})
|
||||
assert.ErrorIs(t, err, ErrPermissionDenied)
|
||||
|
||||
_, err = c.IssueMA(RoleRegulator, IssueRequest{MACode: "MA-1", ContentTwinID: "ct-1", FileHash: "h1"})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestIssueMA_NoReissue(t *testing.T) {
|
||||
c := newIssued(t)
|
||||
// 同 MA 码重复签发被拒(1:1 不可解绑/不可覆盖)
|
||||
_, err := c.IssueMA(RoleRegulator, IssueRequest{
|
||||
MACode: "(京)网微剧审字(2025)第123号", ContentTwinID: "ctid-001", FileHash: "other",
|
||||
})
|
||||
assert.ErrorIs(t, err, ErrMAAlreadyIssued)
|
||||
}
|
||||
|
||||
func TestIssueMA_DuplicateHashRejected(t *testing.T) {
|
||||
c := newIssued(t)
|
||||
// 换壳重发:不同 MA 码但相同内容哈希 → 拒绝
|
||||
_, err := c.IssueMA(RoleRegulator, IssueRequest{
|
||||
MACode: "(沪)网微剧审字(2025)第999号", ContentTwinID: "ctid-002", FileHash: "filehash-1",
|
||||
})
|
||||
assert.ErrorIs(t, err, ErrHashExists)
|
||||
}
|
||||
|
||||
func TestVerifyHash_MatchAndMismatch(t *testing.T) {
|
||||
c := newIssued(t)
|
||||
|
||||
res, err := c.VerifyHash("(京)网微剧审字(2025)第123号", "filehash-1")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Match)
|
||||
assert.True(t, res.Valid)
|
||||
|
||||
res, err = c.VerifyHash("(京)网微剧审字(2025)第123号", "tampered-hash")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, res.Match)
|
||||
assert.Equal(t, "filehash-1", res.BoundHash)
|
||||
}
|
||||
|
||||
func TestVerifyHash_UnknownMA(t *testing.T) {
|
||||
c := NewMemoryChain()
|
||||
_, err := c.VerifyHash("no-such-ma", "h")
|
||||
assert.ErrorIs(t, err, ErrMANotIssued)
|
||||
}
|
||||
|
||||
func TestRegisterMapping_RequiresIssuedMA(t *testing.T) {
|
||||
c := NewMemoryChain()
|
||||
// CTID 未签发 → 拒绝
|
||||
_, err := c.RegisterMapping(RoleOperator, model.Mapping{
|
||||
ContentTwinID: "ctid-x", Party: model.PartyOperator, PartyID: "OP-1",
|
||||
})
|
||||
assert.ErrorIs(t, err, ErrMANotIssued)
|
||||
|
||||
c = newIssued(t)
|
||||
_, err = c.RegisterMapping(RoleOperator, model.Mapping{
|
||||
ContentTwinID: "ctid-001", Party: model.PartyOperator,
|
||||
PartyID: "CT-IPTV-008923", CDNEndpoint: "cdn://ct-gd/iptv/vod/008923",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
res, err := c.QueryMappings("(京)网微剧审字(2025)第123号")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, res.Mappings, 1)
|
||||
assert.Equal(t, []string{"cdn://ct-gd/iptv/vod/008923"}, res.CDNEndpoints)
|
||||
}
|
||||
|
||||
func TestTranscodedBinding_ParentChild(t *testing.T) {
|
||||
c := newIssued(t)
|
||||
_, err := c.RegisterHashBinding(RoleReviewer, model.HashBinding{
|
||||
ContentTwinID: "ctid-001",
|
||||
HashType: model.HashTranscoded,
|
||||
HashValue: "transcoded-h265-4k",
|
||||
ParentHash: "filehash-1",
|
||||
Version: "v1.0-h265",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 转码版哈希也能验真通过
|
||||
res, err := c.VerifyHash("(京)网微剧审字(2025)第123号", "transcoded-h265-4k")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Match)
|
||||
}
|
||||
|
||||
func TestRevoke_OnlyRegulator(t *testing.T) {
|
||||
c := newIssued(t)
|
||||
_, _ = c.RegisterMapping(RoleOperator, model.Mapping{
|
||||
ContentTwinID: "ctid-001", Party: model.PartyOperator,
|
||||
PartyID: "OP-1", CDNEndpoint: "cdn://x/y/z",
|
||||
})
|
||||
|
||||
_, err := c.Revoke(RoleOperator, "(京)网微剧审字(2025)第123号", "试图越权")
|
||||
assert.ErrorIs(t, err, ErrPermissionDenied)
|
||||
|
||||
res, err := c.Revoke(RoleRegulator, "(京)网微剧审字(2025)第123号", "违规")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, res.CDNEndpoints, "cdn://x/y/z")
|
||||
|
||||
ct, _ := c.QueryContent("(京)网微剧审字(2025)第123号")
|
||||
assert.Equal(t, model.StatusRevoked, ct.Status)
|
||||
}
|
||||
|
||||
func TestRecordVersionChange(t *testing.T) {
|
||||
c := newIssued(t)
|
||||
_, err := c.RecordVersionChange(model.VersionChange{
|
||||
ContentTwinID: "ctid-001", Version: "v2.0",
|
||||
ChangeReason: "片尾字幕修正", PrevHash: "filehash-1", NewHash: "filehash-2",
|
||||
ReauditRequired: true, AffectedEpisode: 24,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestHashExists(t *testing.T) {
|
||||
c := newIssued(t)
|
||||
ma, ok := c.HashExists("filehash-1")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "(京)网微剧审字(2025)第123号", ma)
|
||||
|
||||
_, ok = c.HashExists("unknown")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
// Config 保存服务运行所需的通用配置。
|
||||
// MVP 阶段从环境变量加载,缺省值适配本地开发。
|
||||
type Config struct {
|
||||
APIAddr string
|
||||
ChainAddr string
|
||||
HashAddr string
|
||||
PostgresDSN string
|
||||
RedisAddr string
|
||||
}
|
||||
|
||||
func getEnv(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// Load 从环境变量加载配置。
|
||||
func Load() Config {
|
||||
return Config{
|
||||
APIAddr: getEnv("TCS_API_ADDR", ":8080"),
|
||||
ChainAddr: getEnv("TCS_CHAIN_ADDR", ":8081"),
|
||||
HashAddr: getEnv("TCS_HASH_ADDR", ":8082"),
|
||||
PostgresDSN: getEnv("TCS_POSTGRES_DSN", "postgres://postgres@localhost:5432/tcs_iptv?sslmode=disable"),
|
||||
RedisAddr: getEnv("TCS_REDIS_ADDR", "localhost:6379"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package hash
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func writeTempFile(t *testing.T, data []byte) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "master.bin")
|
||||
require.NoError(t, os.WriteFile(p, data, 0o644))
|
||||
return p
|
||||
}
|
||||
|
||||
func TestSHA256Hex_Deterministic(t *testing.T) {
|
||||
a := SHA256Hex([]byte("hello"))
|
||||
b := SHA256Hex([]byte("hello"))
|
||||
assert.Equal(t, a, b)
|
||||
// 已知向量
|
||||
assert.Equal(t, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", a)
|
||||
}
|
||||
|
||||
func TestFileSHA256_MatchesBytes(t *testing.T) {
|
||||
data := []byte("the quick brown fox")
|
||||
p := writeTempFile(t, data)
|
||||
got, err := FileSHA256(p)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, SHA256Hex(data), got)
|
||||
}
|
||||
|
||||
func TestSegmentHashes_SmallSegments(t *testing.T) {
|
||||
// 25 字节,分段 10 → 3 段(10/10/5)
|
||||
data := []byte("0123456789ABCDEFGHIJ12345")
|
||||
p := writeTempFile(t, data)
|
||||
segs, err := SegmentHashes(p, 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, segs, 3)
|
||||
assert.Equal(t, SHA256Hex(data[0:10]), segs[0])
|
||||
assert.Equal(t, SHA256Hex(data[10:20]), segs[1])
|
||||
assert.Equal(t, SHA256Hex(data[20:25]), segs[2])
|
||||
}
|
||||
|
||||
func TestMerkleTree_RootStableAndChangesOnEdit(t *testing.T) {
|
||||
leaves := []string{
|
||||
SHA256Hex([]byte("ep1")),
|
||||
SHA256Hex([]byte("ep2")),
|
||||
SHA256Hex([]byte("ep3")),
|
||||
SHA256Hex([]byte("ep4")),
|
||||
}
|
||||
root1 := BuildMerkleTree(leaves).Root()
|
||||
root2 := BuildMerkleTree(leaves).Root()
|
||||
assert.Equal(t, root1, root2, "同样叶子根应一致")
|
||||
assert.NotEmpty(t, root1)
|
||||
|
||||
// 改第3集 → 根变化
|
||||
edited := append([]string(nil), leaves...)
|
||||
edited[2] = SHA256Hex([]byte("ep3-tampered"))
|
||||
root3 := BuildMerkleTree(edited).Root()
|
||||
assert.NotEqual(t, root1, root3, "篡改任一集,根必变")
|
||||
}
|
||||
|
||||
func TestMerkleTree_OddLeaves(t *testing.T) {
|
||||
leaves := []string{
|
||||
SHA256Hex([]byte("a")),
|
||||
SHA256Hex([]byte("b")),
|
||||
SHA256Hex([]byte("c")),
|
||||
}
|
||||
mt := BuildMerkleTree(leaves)
|
||||
assert.NotEmpty(t, mt.Root())
|
||||
}
|
||||
|
||||
func TestLocateChangedLeaves(t *testing.T) {
|
||||
old := []string{"h1", "h2", "h3", "h4"}
|
||||
neu := []string{"h1", "x2", "h3", "x4"}
|
||||
changed := LocateChangedLeaves(old, neu)
|
||||
assert.Equal(t, []int{1, 3}, changed, "应定位到第2集和第4集被改")
|
||||
}
|
||||
|
||||
func TestComputeFile_FullPackage(t *testing.T) {
|
||||
data := make([]byte, 25*1024) // 25KB
|
||||
for i := range data {
|
||||
data[i] = byte(i % 251)
|
||||
}
|
||||
p := writeTempFile(t, data)
|
||||
|
||||
pkg, err := ComputeFile(p, Options{SegmentSize: 10 * 1024})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, pkg.Validate())
|
||||
assert.Equal(t, int64(25*1024), pkg.FileSize)
|
||||
assert.Len(t, pkg.SegmentHashes, 3)
|
||||
assert.NotEmpty(t, pkg.MerkleRoot)
|
||||
assert.Equal(t, SHA256Hex(data), pkg.FileSHA256)
|
||||
}
|
||||
|
||||
func TestComputeFile_EmptyFileRejected(t *testing.T) {
|
||||
p := writeTempFile(t, []byte{})
|
||||
_, err := ComputeFile(p, Options{})
|
||||
assert.ErrorIs(t, err, ErrEmptyInput)
|
||||
}
|
||||
|
||||
func TestComputeFile_MissingFile(t *testing.T) {
|
||||
_, err := ComputeFile("/no/such/file.bin", Options{})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestHashPackage_ValidateMissingFields(t *testing.T) {
|
||||
assert.Error(t, (&HashPackage{MerkleRoot: "x"}).Validate()) // 缺 file_sha256
|
||||
assert.Error(t, (&HashPackage{FileSHA256: "x"}).Validate()) // 缺 merkle_root
|
||||
assert.NoError(t, (&HashPackage{FileSHA256: "a", MerkleRoot: "b"}).Validate())
|
||||
}
|
||||
|
||||
func TestPerceptualHash_IdenticalAndDifferent(t *testing.T) {
|
||||
// 全黑与全白图,aHash/dHash 应可区分
|
||||
black := make([][]uint8, 16)
|
||||
white := make([][]uint8, 16)
|
||||
grad := make([][]uint8, 16)
|
||||
for y := 0; y < 16; y++ {
|
||||
black[y] = make([]uint8, 16)
|
||||
white[y] = make([]uint8, 16)
|
||||
grad[y] = make([]uint8, 16)
|
||||
for x := 0; x < 16; x++ {
|
||||
white[y][x] = 255
|
||||
grad[y][x] = uint8(x * 16) // 水平渐变
|
||||
}
|
||||
}
|
||||
imgBlack := newGrayTestImage(black)
|
||||
imgWhite := newGrayTestImage(white)
|
||||
imgGrad := newGrayTestImage(grad)
|
||||
|
||||
// 同一图的哈希稳定
|
||||
assert.Equal(t, AHash(imgGrad), AHash(imgGrad))
|
||||
assert.Equal(t, DHash(imgGrad), DHash(imgGrad))
|
||||
|
||||
// 渐变图的 dHash 应与纯色不同
|
||||
assert.NotEqual(t, DHash(imgGrad), DHash(imgBlack))
|
||||
|
||||
// 汉明距离:渐变 vs 纯白 应 > 0
|
||||
d, err := HammingDistance(DHash(imgGrad), DHash(imgWhite))
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, d, 0)
|
||||
}
|
||||
|
||||
func TestHammingDistance_LengthMismatch(t *testing.T) {
|
||||
_, err := HammingDistance("ffff", "ffffffff")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package hash
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// MerkleTree 表示一棵基于 SHA-256 的 Merkle 树。
|
||||
// 叶子为各分段(或各集)的哈希,根用于整体内容的聚合锚定。
|
||||
// 对应需求:需求1-AC2、需求16-AC4(按集定位篡改)。
|
||||
type MerkleTree struct {
|
||||
Leaves []string // 叶子哈希(十六进制)
|
||||
Levels [][]string // 自底向上的各层,Levels[0] 为叶子层
|
||||
}
|
||||
|
||||
// BuildMerkleTree 由叶子哈希构建 Merkle 树。
|
||||
// 当某层节点数为奇数时,复制最后一个节点与自身配对(标准做法)。
|
||||
func BuildMerkleTree(leaves []string) *MerkleTree {
|
||||
mt := &MerkleTree{Leaves: append([]string(nil), leaves...)}
|
||||
if len(leaves) == 0 {
|
||||
mt.Levels = [][]string{{}}
|
||||
return mt
|
||||
}
|
||||
|
||||
level := append([]string(nil), leaves...)
|
||||
mt.Levels = [][]string{level}
|
||||
|
||||
for len(level) > 1 {
|
||||
next := make([]string, 0, (len(level)+1)/2)
|
||||
for i := 0; i < len(level); i += 2 {
|
||||
left := level[i]
|
||||
right := left // 奇数个时与自身配对
|
||||
if i+1 < len(level) {
|
||||
right = level[i+1]
|
||||
}
|
||||
next = append(next, hashPair(left, right))
|
||||
}
|
||||
mt.Levels = append(mt.Levels, next)
|
||||
level = next
|
||||
}
|
||||
return mt
|
||||
}
|
||||
|
||||
// Root 返回 Merkle 根哈希;空树返回空字符串。
|
||||
func (mt *MerkleTree) Root() string {
|
||||
if len(mt.Levels) == 0 {
|
||||
return ""
|
||||
}
|
||||
top := mt.Levels[len(mt.Levels)-1]
|
||||
if len(top) == 0 {
|
||||
return ""
|
||||
}
|
||||
return top[0]
|
||||
}
|
||||
|
||||
// hashPair 将两个十六进制哈希拼接后再次 SHA-256。
|
||||
func hashPair(left, right string) string {
|
||||
lb, _ := hex.DecodeString(left)
|
||||
rb, _ := hex.DecodeString(right)
|
||||
h := sha256.New()
|
||||
h.Write(lb)
|
||||
h.Write(rb)
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// LocateChangedLeaves 比较两组叶子哈希,返回发生变化的叶子索引。
|
||||
// 用于"定位被篡改的具体集"(需求12-AC3)。
|
||||
func LocateChangedLeaves(oldLeaves, newLeaves []string) []int {
|
||||
var changed []int
|
||||
max := len(oldLeaves)
|
||||
if len(newLeaves) > max {
|
||||
max = len(newLeaves)
|
||||
}
|
||||
for i := 0; i < max; i++ {
|
||||
var o, n string
|
||||
if i < len(oldLeaves) {
|
||||
o = oldLeaves[i]
|
||||
}
|
||||
if i < len(newLeaves) {
|
||||
n = newLeaves[i]
|
||||
}
|
||||
if o != n {
|
||||
changed = append(changed, i)
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package hash
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"math/bits"
|
||||
)
|
||||
|
||||
// 感知哈希用于跨格式/转码识别同一内容(需求1-AC3)。
|
||||
// MVP 实现 aHash(均值哈希)与 dHash(差值哈希),输入为已解码的视频代表帧图像。
|
||||
// 真实视频抽帧由上层(ffmpeg)完成,本包专注哈希算法以便独立测试。
|
||||
|
||||
const phashDim = 8 // 8x8 → 64-bit 哈希
|
||||
|
||||
// grayResize 将图像缩放为 w×h 的灰度矩阵(最近邻,零依赖)。
|
||||
func grayResize(img image.Image, w, h int) [][]float64 {
|
||||
b := img.Bounds()
|
||||
srcW, srcH := b.Dx(), b.Dy()
|
||||
out := make([][]float64, h)
|
||||
for y := 0; y < h; y++ {
|
||||
out[y] = make([]float64, w)
|
||||
for x := 0; x < w; x++ {
|
||||
sx := b.Min.X + x*srcW/w
|
||||
sy := b.Min.Y + y*srcH/h
|
||||
r, g, bb, _ := img.At(sx, sy).RGBA()
|
||||
// 转 8 位灰度(ITU-R 601 亮度)
|
||||
gray := 0.299*float64(r>>8) + 0.587*float64(g>>8) + 0.114*float64(bb>>8)
|
||||
out[y][x] = gray
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AHash 计算均值哈希(64-bit,十六进制 16 字符)。
|
||||
func AHash(img image.Image) string {
|
||||
m := grayResize(img, phashDim, phashDim)
|
||||
var sum float64
|
||||
for y := 0; y < phashDim; y++ {
|
||||
for x := 0; x < phashDim; x++ {
|
||||
sum += m[y][x]
|
||||
}
|
||||
}
|
||||
avg := sum / float64(phashDim*phashDim)
|
||||
|
||||
var hash uint64
|
||||
var bit uint
|
||||
for y := 0; y < phashDim; y++ {
|
||||
for x := 0; x < phashDim; x++ {
|
||||
if m[y][x] >= avg {
|
||||
hash |= 1 << bit
|
||||
}
|
||||
bit++
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%016x", hash)
|
||||
}
|
||||
|
||||
// DHash 计算差值哈希(64-bit)。对水平相邻像素比较亮度。
|
||||
func DHash(img image.Image) string {
|
||||
// 需要 (phashDim+1) 列以产生 phashDim 个差值
|
||||
m := grayResize(img, phashDim+1, phashDim)
|
||||
var hash uint64
|
||||
var bit uint
|
||||
for y := 0; y < phashDim; y++ {
|
||||
for x := 0; x < phashDim; x++ {
|
||||
if m[y][x] < m[y][x+1] {
|
||||
hash |= 1 << bit
|
||||
}
|
||||
bit++
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%016x", hash)
|
||||
}
|
||||
|
||||
// HammingDistance 计算两个等长十六进制哈希的汉明距离。
|
||||
// 距离越小越相似;用于版权比对与跨版本识别。
|
||||
func HammingDistance(a, b string) (int, error) {
|
||||
if len(a) != len(b) {
|
||||
return 0, fmt.Errorf("hash: length mismatch %d vs %d", len(a), len(b))
|
||||
}
|
||||
var va, vb uint64
|
||||
if _, err := fmt.Sscanf(a, "%x", &va); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := fmt.Sscanf(b, "%x", &vb); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return bits.OnesCount64(va ^ vb), nil
|
||||
}
|
||||
|
||||
// newGrayTestImage 是测试辅助:由灰度矩阵生成图像。
|
||||
func newGrayTestImage(gray [][]uint8) image.Image {
|
||||
h := len(gray)
|
||||
w := 0
|
||||
if h > 0 {
|
||||
w = len(gray[0])
|
||||
}
|
||||
img := image.NewGray(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
img.SetGray(x, y, color.Gray{Y: gray[y][x]})
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package hash
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// HashPackage 是哈希值包,对应需求1-AC5。
|
||||
// 仅包含哈希与元数据,绝不包含原始内容(需求20-AC2)。
|
||||
type HashPackage struct {
|
||||
FileSHA256 string `json:"file_sha256"`
|
||||
MerkleRoot string `json:"merkle_root"`
|
||||
SegmentHashes []string `json:"segment_hashes"`
|
||||
PerceptualHash string `json:"perceptual_hash,omitempty"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
SegmentSize int `json:"segment_size"`
|
||||
}
|
||||
|
||||
// Options 控制哈希计算行为。
|
||||
type Options struct {
|
||||
SegmentSize int // 分段大小;<=0 用默认
|
||||
PerceptualHash string // 上层已抽帧并算好的感知哈希(可选)
|
||||
}
|
||||
|
||||
// ComputeFile 对母版文件计算完整哈希值包(文件哈希 + 分段 Merkle)。
|
||||
// 感知哈希需上层先抽帧,再通过 opts.PerceptualHash 传入或单独调用 AHash/DHash。
|
||||
func ComputeFile(path string, opts Options) (*HashPackage, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hash: stat file: %w", err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, fmt.Errorf("hash: path is a directory: %s", path)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
return nil, ErrEmptyInput
|
||||
}
|
||||
|
||||
segSize := opts.SegmentSize
|
||||
if segSize <= 0 {
|
||||
segSize = DefaultSegmentSize
|
||||
}
|
||||
|
||||
fileHash, err := FileSHA256(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hash: file sha256: %w", err)
|
||||
}
|
||||
|
||||
segments, err := SegmentHashes(path, segSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hash: segment hashes: %w", err)
|
||||
}
|
||||
|
||||
tree := BuildMerkleTree(segments)
|
||||
|
||||
return &HashPackage{
|
||||
FileSHA256: fileHash,
|
||||
MerkleRoot: tree.Root(),
|
||||
SegmentHashes: segments,
|
||||
PerceptualHash: opts.PerceptualHash,
|
||||
FileSize: info.Size(),
|
||||
SegmentSize: segSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Validate 校验哈希值包的完整性(需求2-AC5:缺文件哈希/Merkle根则非法)。
|
||||
func (p *HashPackage) Validate() error {
|
||||
if p == nil {
|
||||
return fmt.Errorf("hash: nil package")
|
||||
}
|
||||
if p.FileSHA256 == "" {
|
||||
return fmt.Errorf("hash: missing file_sha256")
|
||||
}
|
||||
if p.MerkleRoot == "" {
|
||||
return fmt.Errorf("hash: missing merkle_root")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Package hash 实现 TCS-IPTV 的内容哈希核心:
|
||||
// 文件 SHA-256、分段 Merkle Tree、感知哈希。
|
||||
// 对应需求:需求1(母版哈希生成)。
|
||||
package hash
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// DefaultSegmentSize 是分段哈希的默认分段大小(10 MiB)。
|
||||
const DefaultSegmentSize = 10 * 1024 * 1024
|
||||
|
||||
// ErrEmptyInput 表示输入为空。
|
||||
var ErrEmptyInput = errors.New("hash: empty input")
|
||||
|
||||
// SHA256Hex 计算字节切片的 SHA-256,返回十六进制字符串。
|
||||
func SHA256Hex(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// FileSHA256 流式计算文件的整体 SHA-256,避免一次性载入大文件。
|
||||
func FileSHA256(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// SegmentHashes 按 segmentSize 分段计算文件各段的 SHA-256。
|
||||
// 用于构建 Merkle Tree 的叶子节点。
|
||||
func SegmentHashes(path string, segmentSize int) ([]string, error) {
|
||||
if segmentSize <= 0 {
|
||||
segmentSize = DefaultSegmentSize
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var segments []string
|
||||
buf := make([]byte, segmentSize)
|
||||
for {
|
||||
n, err := io.ReadFull(f, buf)
|
||||
if n > 0 {
|
||||
segments = append(segments, SHA256Hex(buf[:n]))
|
||||
}
|
||||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return segments, nil
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// 鉴权采用 API Key + HMAC-SHA256(需求17-AC4、需求20)。
|
||||
// 请求头:
|
||||
// Authorization: TCS {apiKey}:{signature}
|
||||
// X-TCS-Role: regulator | reviewer | cp | operator
|
||||
// signature = base64(HMAC-SHA256(apiSecret, method+"\n"+path))
|
||||
|
||||
// KeyStore 提供 apiKey -> (apiSecret, role) 的查询。MVP 用内存实现。
|
||||
type KeyStore interface {
|
||||
Lookup(apiKey string) (secret string, role string, ok bool)
|
||||
}
|
||||
|
||||
// MemoryKeyStore 内存密钥库。
|
||||
type MemoryKeyStore struct {
|
||||
keys map[string]struct {
|
||||
secret string
|
||||
role string
|
||||
}
|
||||
}
|
||||
|
||||
// NewMemoryKeyStore 创建并预置密钥。
|
||||
func NewMemoryKeyStore() *MemoryKeyStore {
|
||||
return &MemoryKeyStore{keys: map[string]struct {
|
||||
secret string
|
||||
role string
|
||||
}{}}
|
||||
}
|
||||
|
||||
// Add 注册一个密钥及其角色。
|
||||
func (m *MemoryKeyStore) Add(apiKey, secret, role string) {
|
||||
m.keys[apiKey] = struct {
|
||||
secret string
|
||||
role string
|
||||
}{secret, role}
|
||||
}
|
||||
|
||||
// Lookup 查询密钥。
|
||||
func (m *MemoryKeyStore) Lookup(apiKey string) (string, string, bool) {
|
||||
v, ok := m.keys[apiKey]
|
||||
return v.secret, v.role, ok
|
||||
}
|
||||
|
||||
// Sign 计算签名(供客户端/测试使用)。
|
||||
func Sign(secret, method, path string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(method + "\n" + path))
|
||||
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// 上下文键。
|
||||
const ctxRoleKey = "tcs_role"
|
||||
|
||||
// AuthMiddleware 校验 HMAC 签名,将角色写入上下文(需求14 权限基础)。
|
||||
func AuthMiddleware(store KeyStore) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authz := c.GetHeader("Authorization")
|
||||
if !strings.HasPrefix(authz, "TCS ") {
|
||||
Error(c, 401, "UNAUTHORIZED", "缺少或非法 Authorization 头")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
parts := strings.SplitN(strings.TrimPrefix(authz, "TCS "), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
Error(c, 401, "UNAUTHORIZED", "Authorization 格式应为 TCS {apiKey}:{signature}")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
apiKey, sig := parts[0], parts[1]
|
||||
secret, role, ok := store.Lookup(apiKey)
|
||||
if !ok {
|
||||
Error(c, 401, "UNAUTHORIZED", "未知 API Key")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
expected := Sign(secret, c.Request.Method, c.Request.URL.Path)
|
||||
if !hmac.Equal([]byte(expected), []byte(sig)) {
|
||||
Error(c, 401, "UNAUTHORIZED", "签名校验失败")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set(ctxRoleKey, role)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RoleFromContext 取出鉴权后的角色。
|
||||
func RoleFromContext(c *gin.Context) string {
|
||||
if v, ok := c.Get(ctxRoleKey); ok {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Resp 是统一响应结构。
|
||||
type Resp struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// OK 返回成功响应。
|
||||
func OK(c *gin.Context, data interface{}) {
|
||||
c.JSON(200, Resp{Code: "SUCCESS", Data: data})
|
||||
}
|
||||
|
||||
// Created 返回 201。
|
||||
func Created(c *gin.Context, data interface{}) {
|
||||
c.JSON(201, Resp{Code: "CREATED", Data: data})
|
||||
}
|
||||
|
||||
// Accepted 返回 202(异步任务已受理)。
|
||||
func Accepted(c *gin.Context, data interface{}) {
|
||||
c.JSON(202, Resp{Code: "ACCEPTED", Data: data})
|
||||
}
|
||||
|
||||
// Error 返回错误响应。
|
||||
func Error(c *gin.Context, status int, code, message string) {
|
||||
c.JSON(status, Resp{Code: code, Message: message})
|
||||
}
|
||||
|
||||
// Health 注册通用健康检查端点。
|
||||
func Health(r *gin.Engine, service string) {
|
||||
r.GET("/healthz", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok", "service": service})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Package macode 实现 MA 码生成服务(模式B:自行发码)。
|
||||
// TCS 与 MA 发码机构合作获取「码段(号段)」与「备案规则」,在本地按规则原子发码。
|
||||
// 对应需求:需求3(MA码签发)、需求16;与 ISO/IEC 15459 MA 标识体系对齐。
|
||||
//
|
||||
// MA 码结构(六段式,可由备案规则配置):
|
||||
//
|
||||
// MA.156.{industryNode}.{orgNode}/{category}/{yyyy}{sequence}
|
||||
//
|
||||
// 示例:MA.156.8531.4401/WD/20250000123
|
||||
// - MA 固定前缀(标识体系根)
|
||||
// - 156 国家码(中国)
|
||||
// - 8531 行业节点(IPTV 视听内容,由发码机构分配)
|
||||
// - 4401 机构节点(运营主体/省局,由发码机构分配)
|
||||
// - WD 内容类目(WD=微短剧 / WJ=网络剧 / DY=网络电影 / DH=网络动画)
|
||||
// - yyyy 年份
|
||||
// - sequence 号段内递增序列(按位补零)
|
||||
package macode
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 错误定义。
|
||||
var (
|
||||
ErrSegmentExhausted = errors.New("macode: code segment exhausted")
|
||||
ErrUnknownCategory = errors.New("macode: unknown content category")
|
||||
ErrInvalidSegment = errors.New("macode: invalid segment range")
|
||||
)
|
||||
|
||||
// 内容类目码。
|
||||
const (
|
||||
CategoryMicroDrama = "WD" // 网络微短剧
|
||||
CategoryWebSeries = "WJ" // 网络剧
|
||||
CategoryWebMovie = "DY" // 网络电影
|
||||
CategoryAnimation = "DH" // 网络动画
|
||||
)
|
||||
|
||||
// Segment 是 MA 发码机构分配给本运营主体的「码段(号段)」。
|
||||
// 同一 (industryNode, orgNode, category) 下,序列在 [Start, End] 内递增分配。
|
||||
type Segment struct {
|
||||
IndustryNode string // 行业节点(发码机构分配)
|
||||
OrgNode string // 机构节点(发码机构分配)
|
||||
Category string // 适用内容类目
|
||||
Start uint64 // 号段起始序列(含)
|
||||
End uint64 // 号段结束序列(含)
|
||||
SeqWidth int // 序列补零宽度,如 7 → 0000123
|
||||
}
|
||||
|
||||
// Validate 校验号段合法性。
|
||||
func (s Segment) Validate() error {
|
||||
if s.IndustryNode == "" || s.OrgNode == "" || s.Category == "" {
|
||||
return ErrInvalidSegment
|
||||
}
|
||||
if s.End < s.Start {
|
||||
return ErrInvalidSegment
|
||||
}
|
||||
if s.SeqWidth <= 0 {
|
||||
s.SeqWidth = 7
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AllocationStore 持久化号段游标,保证重启后不重号、并发下原子分配。
|
||||
// MVP 提供内存实现;生产可用 PostgreSQL 行锁 / Redis INCR 实现。
|
||||
type AllocationStore interface {
|
||||
// Next 原子取得指定号段键的下一个序列值;超出 end 返回 ErrSegmentExhausted。
|
||||
Next(segmentKey string, start, end uint64) (uint64, error)
|
||||
}
|
||||
|
||||
// Generator MA 码生成器。
|
||||
type Generator struct {
|
||||
mu sync.RWMutex
|
||||
segments map[string]Segment // category -> segment
|
||||
store AllocationStore
|
||||
clock func() time.Time
|
||||
}
|
||||
|
||||
// NewGenerator 创建生成器。
|
||||
func NewGenerator(store AllocationStore) *Generator {
|
||||
return &Generator{
|
||||
segments: make(map[string]Segment),
|
||||
store: store,
|
||||
clock: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterSegment 登记一个码段(通常在与发码机构对接后配置)。
|
||||
func (g *Generator) RegisterSegment(seg Segment) error {
|
||||
if err := seg.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if seg.SeqWidth <= 0 {
|
||||
seg.SeqWidth = 7
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.segments[seg.Category] = seg
|
||||
return nil
|
||||
}
|
||||
|
||||
// segmentKey 唯一标识一个号段(用于持久化游标)。
|
||||
func segmentKey(s Segment) string {
|
||||
return fmt.Sprintf("%s:%s:%s", s.IndustryNode, s.OrgNode, s.Category)
|
||||
}
|
||||
|
||||
// Issued 一次发码结果。
|
||||
type Issued struct {
|
||||
MACode string `json:"ma_code"`
|
||||
IndustryNode string `json:"industry_node"`
|
||||
OrgNode string `json:"org_node"`
|
||||
Category string `json:"category"`
|
||||
Sequence uint64 `json:"sequence"`
|
||||
Year int `json:"year"`
|
||||
}
|
||||
|
||||
// Allocate 为指定类目原子分配并格式化一个全局唯一 MA 码。
|
||||
func (g *Generator) Allocate(category string) (Issued, error) {
|
||||
g.mu.RLock()
|
||||
seg, ok := g.segments[category]
|
||||
g.mu.RUnlock()
|
||||
if !ok {
|
||||
return Issued{}, ErrUnknownCategory
|
||||
}
|
||||
|
||||
seq, err := g.store.Next(segmentKey(seg), seg.Start, seg.End)
|
||||
if err != nil {
|
||||
return Issued{}, err
|
||||
}
|
||||
|
||||
year := g.clock().Year()
|
||||
code := Format(seg, year, seq)
|
||||
return Issued{
|
||||
MACode: code,
|
||||
IndustryNode: seg.IndustryNode,
|
||||
OrgNode: seg.OrgNode,
|
||||
Category: category,
|
||||
Sequence: seq,
|
||||
Year: year,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Format 按备案规则拼装 MA 码。
|
||||
func Format(seg Segment, year int, seq uint64) string {
|
||||
w := seg.SeqWidth
|
||||
if w <= 0 {
|
||||
w = 7
|
||||
}
|
||||
return fmt.Sprintf("MA.156.%s.%s/%s/%d%0*d",
|
||||
seg.IndustryNode, seg.OrgNode, seg.Category, year, w, seq)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package macode
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fixedTime 固定为 2025 年,便于断言年份段。
|
||||
var fixedTime = time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
func newGen(t *testing.T) *Generator {
|
||||
t.Helper()
|
||||
g := NewGenerator(NewMemoryStore())
|
||||
require.NoError(t, g.RegisterSegment(Segment{
|
||||
IndustryNode: "8531", OrgNode: "4401", Category: CategoryMicroDrama,
|
||||
Start: 1, End: 100, SeqWidth: 7,
|
||||
}))
|
||||
// 固定年份便于断言
|
||||
g.clock = func() time.Time { return fixedTime }
|
||||
return g
|
||||
}
|
||||
|
||||
func TestAllocate_SequentialUnique(t *testing.T) {
|
||||
g := newGen(t)
|
||||
seen := make(map[string]bool)
|
||||
for i := 0; i < 50; i++ {
|
||||
issued, err := g.Allocate(CategoryMicroDrama)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, seen[issued.MACode], "MA 码必须唯一: %s", issued.MACode)
|
||||
seen[issued.MACode] = true
|
||||
assert.True(t, IsValid(issued.MACode), "应符合格式: %s", issued.MACode)
|
||||
}
|
||||
assert.Len(t, seen, 50)
|
||||
}
|
||||
|
||||
func TestAllocate_FormatCorrect(t *testing.T) {
|
||||
g := newGen(t)
|
||||
issued, err := g.Allocate(CategoryMicroDrama)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "MA.156.8531.4401/WD/20250000001", issued.MACode)
|
||||
assert.Equal(t, uint64(1), issued.Sequence)
|
||||
assert.Equal(t, 2025, issued.Year)
|
||||
}
|
||||
|
||||
func TestAllocate_UnknownCategory(t *testing.T) {
|
||||
g := newGen(t)
|
||||
_, err := g.Allocate("XX")
|
||||
assert.ErrorIs(t, err, ErrUnknownCategory)
|
||||
}
|
||||
|
||||
func TestAllocate_SegmentExhausted(t *testing.T) {
|
||||
g := NewGenerator(NewMemoryStore())
|
||||
g.clock = func() time.Time { return fixedTime }
|
||||
require.NoError(t, g.RegisterSegment(Segment{
|
||||
IndustryNode: "8531", OrgNode: "4401", Category: CategoryWebMovie,
|
||||
Start: 1, End: 2, SeqWidth: 5,
|
||||
}))
|
||||
_, err := g.Allocate(CategoryWebMovie)
|
||||
require.NoError(t, err)
|
||||
_, err = g.Allocate(CategoryWebMovie)
|
||||
require.NoError(t, err)
|
||||
_, err = g.Allocate(CategoryWebMovie) // 第3个超出 [1,2]
|
||||
assert.ErrorIs(t, err, ErrSegmentExhausted)
|
||||
}
|
||||
|
||||
func TestAllocate_ConcurrentNoDuplicate(t *testing.T) {
|
||||
g := NewGenerator(NewMemoryStore())
|
||||
g.clock = func() time.Time { return fixedTime }
|
||||
require.NoError(t, g.RegisterSegment(Segment{
|
||||
IndustryNode: "8531", OrgNode: "4401", Category: CategoryWebSeries,
|
||||
Start: 1, End: 1000, SeqWidth: 7,
|
||||
}))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
seen := make(map[string]bool)
|
||||
dup := 0
|
||||
for i := 0; i < 200; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
issued, err := g.Allocate(CategoryWebSeries)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
if seen[issued.MACode] {
|
||||
dup++
|
||||
}
|
||||
seen[issued.MACode] = true
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
assert.Equal(t, 0, dup, "并发分配不得出现重号")
|
||||
assert.Len(t, seen, 200)
|
||||
}
|
||||
|
||||
func TestParse_RoundTrip(t *testing.T) {
|
||||
seg := Segment{IndustryNode: "8531", OrgNode: "4401", Category: CategoryMicroDrama, SeqWidth: 7}
|
||||
code := Format(seg, 2025, 123)
|
||||
assert.Equal(t, "MA.156.8531.4401/WD/20250000123", code)
|
||||
|
||||
p, err := Parse(code)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "MA", p.Root)
|
||||
assert.Equal(t, "156", p.CountryCode)
|
||||
assert.Equal(t, "8531", p.IndustryNode)
|
||||
assert.Equal(t, "4401", p.OrgNode)
|
||||
assert.Equal(t, "WD", p.Category)
|
||||
assert.Equal(t, 2025, p.Year)
|
||||
assert.Equal(t, uint64(123), p.Sequence)
|
||||
}
|
||||
|
||||
func TestParse_Invalid(t *testing.T) {
|
||||
bad := []string{
|
||||
"", "MA.156", "MA.156.8531.4401/WD", "(京)网微剧审字(2025)第123号",
|
||||
"MA.156.8531.4401/wd/20250000123", // 类目须大写两位
|
||||
}
|
||||
for _, b := range bad {
|
||||
_, err := Parse(b)
|
||||
assert.Error(t, err, "应判为非法: %q", b)
|
||||
assert.False(t, IsValid(b))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSegment_Validate(t *testing.T) {
|
||||
assert.Error(t, Segment{}.Validate())
|
||||
assert.Error(t, Segment{IndustryNode: "1", OrgNode: "2", Category: "WD", Start: 10, End: 5}.Validate())
|
||||
assert.NoError(t, Segment{IndustryNode: "1", OrgNode: "2", Category: "WD", Start: 1, End: 5, SeqWidth: 7}.Validate())
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package macode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Parsed 是解析后的 MA 码各段。
|
||||
type Parsed struct {
|
||||
Root string
|
||||
CountryCode string
|
||||
IndustryNode string
|
||||
OrgNode string
|
||||
Category string
|
||||
Year int
|
||||
Sequence uint64
|
||||
}
|
||||
|
||||
// maCodePattern 匹配六段式 MA 码:
|
||||
// MA.156.{industry}.{org}/{category}/{yyyy}{sequence}
|
||||
var maCodePattern = regexp.MustCompile(
|
||||
`^(MA)\.(\d{3})\.([0-9A-Za-z]+)\.([0-9A-Za-z]+)/([A-Z]{2})/(\d{4})(\d+)$`)
|
||||
|
||||
// Parse 将 MA 码字符串解析为结构化字段;格式非法返回错误(需求4 校验基础)。
|
||||
func Parse(code string) (Parsed, error) {
|
||||
m := maCodePattern.FindStringSubmatch(code)
|
||||
if m == nil {
|
||||
return Parsed{}, fmt.Errorf("macode: invalid format: %s", code)
|
||||
}
|
||||
year, _ := strconv.Atoi(m[6])
|
||||
seq, err := strconv.ParseUint(m[7], 10, 64)
|
||||
if err != nil {
|
||||
return Parsed{}, fmt.Errorf("macode: invalid sequence: %w", err)
|
||||
}
|
||||
return Parsed{
|
||||
Root: m[1],
|
||||
CountryCode: m[2],
|
||||
IndustryNode: m[3],
|
||||
OrgNode: m[4],
|
||||
Category: m[5],
|
||||
Year: year,
|
||||
Sequence: seq,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IsValid 仅校验格式合法性。
|
||||
func IsValid(code string) bool {
|
||||
return maCodePattern.MatchString(code)
|
||||
}
|
||||
|
||||
// EpisodeSubID 生成集级子标识:{maCode}#E{NN}。
|
||||
// 整剧用 MA 码,单集用子标识,便于按集验真/追更/下架。
|
||||
func EpisodeSubID(maCode string, episode int) string {
|
||||
return fmt.Sprintf("%s#E%02d", maCode, episode)
|
||||
}
|
||||
|
||||
// episodeSubPattern 匹配集级子标识后缀。
|
||||
var episodeSubPattern = regexp.MustCompile(`^(.+)#E(\d+)$`)
|
||||
|
||||
// ParseEpisodeSubID 拆解集级子标识,返回主 MA 码与集号。
|
||||
// 若无 #E 后缀,episode 返回 0(表示整剧)。
|
||||
func ParseEpisodeSubID(subID string) (maCode string, episode int) {
|
||||
m := episodeSubPattern.FindStringSubmatch(subID)
|
||||
if m == nil {
|
||||
return subID, 0
|
||||
}
|
||||
ep, _ := strconv.Atoi(m[2])
|
||||
return m[1], ep
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package macode
|
||||
|
||||
import "sync"
|
||||
|
||||
// MemoryStore 是 AllocationStore 的内存实现(MVP / 测试)。
|
||||
// 生产环境应替换为 PostgreSQL 行锁或 Redis INCR,保证多实例下原子且持久。
|
||||
type MemoryStore struct {
|
||||
mu sync.Mutex
|
||||
cursors map[string]uint64 // segmentKey -> 已分配的最大序列
|
||||
}
|
||||
|
||||
// NewMemoryStore 创建内存分配存储。
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
return &MemoryStore{cursors: make(map[string]uint64)}
|
||||
}
|
||||
|
||||
// Next 原子返回下一个序列:首次取 start,之后递增;超过 end 返回耗尽错误。
|
||||
func (s *MemoryStore) Next(segmentKey string, start, end uint64) (uint64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
cur, ok := s.cursors[segmentKey]
|
||||
var next uint64
|
||||
if !ok {
|
||||
next = start
|
||||
} else {
|
||||
next = cur + 1
|
||||
}
|
||||
if next > end {
|
||||
return 0, ErrSegmentExhausted
|
||||
}
|
||||
s.cursors[segmentKey] = next
|
||||
return next, nil
|
||||
}
|
||||
|
||||
var _ AllocationStore = (*MemoryStore)(nil)
|
||||
@@ -0,0 +1,44 @@
|
||||
package macode
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// PostgresStore 是 AllocationStore 的 PostgreSQL 实现。
|
||||
// 通过行级原子 UPSERT + 返回值保证多实例下序列分配原子、持久、不重号。
|
||||
// 解决 MemoryStore 重启丢号、多实例重号的问题(生产用)。
|
||||
type PostgresStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewPostgresStore 创建基于 *sql.DB 的分配存储。
|
||||
func NewPostgresStore(db *sql.DB) *PostgresStore {
|
||||
return &PostgresStore{db: db}
|
||||
}
|
||||
|
||||
// Next 原子获取下一个序列。
|
||||
// 使用 INSERT ... ON CONFLICT DO UPDATE 的原子自增语义:
|
||||
// - 首次:cursor = start
|
||||
// - 之后:cursor = cursor + 1
|
||||
//
|
||||
// 单条 SQL 在行锁内完成读改写,并发安全;超过 end 返回耗尽错误。
|
||||
func (s *PostgresStore) Next(segmentKey string, start, end uint64) (uint64, error) {
|
||||
const q = `
|
||||
INSERT INTO macode_cursor (segment_key, cursor)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (segment_key)
|
||||
DO UPDATE SET cursor = macode_cursor.cursor + 1, updated_at = NOW()
|
||||
RETURNING cursor;`
|
||||
|
||||
var next uint64
|
||||
if err := s.db.QueryRow(q, segmentKey, start).Scan(&next); err != nil {
|
||||
return 0, fmt.Errorf("macode: pg next: %w", err)
|
||||
}
|
||||
if next > end {
|
||||
return 0, ErrSegmentExhausted
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
var _ AllocationStore = (*PostgresStore)(nil)
|
||||
@@ -0,0 +1,129 @@
|
||||
package macode
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 集成测试:需本地 PostgreSQL。通过 TCS_TEST_PG_DSN 提供连接串;未提供则跳过。
|
||||
func openTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TCS_TEST_PG_DSN")
|
||||
if dsn == "" {
|
||||
dsn = "postgres://postgres@localhost:5432/tcs_iptv?sslmode=disable"
|
||||
}
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
t.Skipf("跳过 PG 集成测试:%v", err)
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
t.Skipf("跳过 PG 集成测试(无法连接):%v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func cleanupKey(t *testing.T, db *sql.DB, key string) {
|
||||
t.Helper()
|
||||
_, _ = db.Exec("DELETE FROM macode_cursor WHERE segment_key = $1", key)
|
||||
}
|
||||
|
||||
func TestPostgresStore_Sequential(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
defer db.Close()
|
||||
store := NewPostgresStore(db)
|
||||
key := "test:seq:WD"
|
||||
cleanupKey(t, db, key)
|
||||
defer cleanupKey(t, db, key)
|
||||
|
||||
for want := uint64(1); want <= 5; want++ {
|
||||
got, err := store.Next(key, 1, 100)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresStore_Exhausted(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
defer db.Close()
|
||||
store := NewPostgresStore(db)
|
||||
key := "test:exhaust:DY"
|
||||
cleanupKey(t, db, key)
|
||||
defer cleanupKey(t, db, key)
|
||||
|
||||
_, err := store.Next(key, 1, 2)
|
||||
require.NoError(t, err)
|
||||
_, err = store.Next(key, 1, 2)
|
||||
require.NoError(t, err)
|
||||
_, err = store.Next(key, 1, 2)
|
||||
assert.ErrorIs(t, err, ErrSegmentExhausted)
|
||||
}
|
||||
|
||||
func TestPostgresStore_ConcurrentNoDuplicate(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
defer db.Close()
|
||||
store := NewPostgresStore(db)
|
||||
key := "test:concurrent:WJ"
|
||||
cleanupKey(t, db, key)
|
||||
defer cleanupKey(t, db, key)
|
||||
|
||||
const n = 200
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
seen := make(map[uint64]bool)
|
||||
dup := 0
|
||||
errs := 0
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
v, err := store.Next(key, 1, 1000)
|
||||
if err != nil {
|
||||
mu.Lock()
|
||||
errs++
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
if seen[v] {
|
||||
dup++
|
||||
}
|
||||
seen[v] = true
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
assert.Equal(t, 0, errs, "并发分配不应报错")
|
||||
assert.Equal(t, 0, dup, "并发分配不得重号")
|
||||
assert.Len(t, seen, n)
|
||||
}
|
||||
|
||||
// TestPostgresStore_WithGenerator 验证 PG 存储与生成器联动产出唯一 MA 码。
|
||||
func TestPostgresStore_WithGenerator(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
defer db.Close()
|
||||
key := fmt.Sprintf("%s:%s:%s", "8531", "4401", CategoryAnimation)
|
||||
cleanupKey(t, db, key)
|
||||
defer cleanupKey(t, db, key)
|
||||
|
||||
g := NewGenerator(NewPostgresStore(db))
|
||||
require.NoError(t, g.RegisterSegment(Segment{
|
||||
IndustryNode: "8531", OrgNode: "4401",
|
||||
Category: CategoryAnimation, Start: 1, End: 1000, SeqWidth: 7,
|
||||
}))
|
||||
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 10; i++ {
|
||||
issued, err := g.Allocate(CategoryAnimation)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, seen[issued.MACode])
|
||||
assert.True(t, IsValid(issued.MACode))
|
||||
seen[issued.MACode] = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Package model 定义 TCS-IPTV 的领域模型,
|
||||
// 对应需求16的四类核心数据结构与 CTID 双锚定模型。
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Party 三方角色标识。
|
||||
type Party string
|
||||
|
||||
const (
|
||||
PartyCP Party = "cp" // 内容提供商
|
||||
PartyReviewer Party = "reviewer" // 审核和监管部门(审核主体:CSPS/媒资库)
|
||||
PartyOperator Party = "operator" // 运营商
|
||||
)
|
||||
|
||||
// Content 内容主表(Content Registry)。
|
||||
type Content struct {
|
||||
ContentTwinID string `json:"content_twin_id"`
|
||||
MACode string `json:"ma_code"`
|
||||
MAType string `json:"ma_type"`
|
||||
Title string `json:"title"`
|
||||
EpisodeCount int `json:"episode_count"`
|
||||
Status string `json:"status"`
|
||||
Issuer string `json:"issuer"`
|
||||
IssueDate string `json:"issue_date"`
|
||||
FileHash string `json:"file_hash,omitempty"` // 传输便利:列表时附带整剧文件哈希(供运营商演示注入)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// HashType 哈希类型。
|
||||
type HashType string
|
||||
|
||||
const (
|
||||
HashFile HashType = "file_sha256"
|
||||
HashPerceptual HashType = "perceptual"
|
||||
HashTranscoded HashType = "transcoded"
|
||||
)
|
||||
|
||||
// HashBinding 哈希绑定(Hash Binding)。
|
||||
type HashBinding struct {
|
||||
ContentTwinID string `json:"content_twin_id"`
|
||||
HashType HashType `json:"hash_type"`
|
||||
HashValue string `json:"hash_value"`
|
||||
MerkleRoot string `json:"merkle_root"`
|
||||
Episode int `json:"episode"` // 集号;0 表示整剧/单体(非分集)
|
||||
FileFormat string `json:"file_format"`
|
||||
Resolution string `json:"resolution"`
|
||||
Duration int `json:"duration"`
|
||||
Version string `json:"version"`
|
||||
ParentHash string `json:"parent_hash"` // 转码版指向母版哈希
|
||||
Revoked bool `json:"revoked"` // 集级下架标记(true=该集已下架)
|
||||
RevokedReason string `json:"revoked_reason,omitempty"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
}
|
||||
|
||||
// EpisodeHash 单集哈希(送审时按集提交)。
|
||||
type EpisodeHash 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"`
|
||||
}
|
||||
|
||||
// Mapping 三方编码映射(Identity Mapping)。
|
||||
type Mapping struct {
|
||||
ContentTwinID string `json:"content_twin_id"`
|
||||
Party Party `json:"party"`
|
||||
PartyID string `json:"party_id"`
|
||||
PartyName string `json:"party_name"`
|
||||
CDNEndpoint string `json:"cdn_endpoint"`
|
||||
}
|
||||
|
||||
// VersionChange 版本变更(Version History)。
|
||||
type VersionChange struct {
|
||||
ContentTwinID string `json:"content_twin_id"`
|
||||
Version string `json:"version"`
|
||||
ChangeReason string `json:"change_reason"`
|
||||
PrevHash string `json:"prev_hash"`
|
||||
NewHash string `json:"new_hash"`
|
||||
ReauditRequired bool `json:"reaudit_required"`
|
||||
ReauditStatus string `json:"reaudit_status"`
|
||||
AffectedEpisode int `json:"affected_episode"`
|
||||
}
|
||||
|
||||
// 内容审核状态。
|
||||
const (
|
||||
StatusPending = "pending" // 待审
|
||||
StatusPreChecking = "pre_checking" // 预检中
|
||||
StatusReviewing = "reviewing" // CSPS 审核中
|
||||
StatusApproved = "approved" // 审核通过(待发码)
|
||||
StatusIssued = "issued" // 已发码(送审单完结)
|
||||
StatusRejected = "rejected" // 驳回
|
||||
StatusInLibrary = "in_library" // 已入媒资库
|
||||
StatusPublished = "published" // 已发布
|
||||
StatusRevoked = "revoked" // 已下架
|
||||
)
|
||||
@@ -0,0 +1,176 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tcs-iptv/tcs/internal/chain"
|
||||
"github.com/tcs-iptv/tcs/internal/hash"
|
||||
"github.com/tcs-iptv/tcs/internal/model"
|
||||
)
|
||||
|
||||
// ---- 工作包7:转码版哈希绑定(需求5) ----
|
||||
// 注:CSPS 合规审核已前移至发码前(service.ReviewCSPS),此处仅处理转码。
|
||||
|
||||
// BindTranscoded 绑定转码版哈希,与母版建立父子关系(需求5-AC3/AC4/AC5)。
|
||||
func (s *Service) BindTranscoded(role chain.Role, ctid, parentFileHash, transcodedHash, format, resolution, version string) (string, error) {
|
||||
if transcodedHash == "" {
|
||||
return "", ErrIncompleteHashPkg
|
||||
}
|
||||
return s.chain.RegisterHashBinding(role, model.HashBinding{
|
||||
ContentTwinID: ctid,
|
||||
HashType: model.HashTranscoded,
|
||||
HashValue: transcodedHash,
|
||||
ParentHash: parentFileHash,
|
||||
FileFormat: format,
|
||||
Resolution: resolution,
|
||||
Version: version,
|
||||
CreatedBy: string(role),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 工作包8:媒体资源库入库、发布与映射(需求6) ----
|
||||
|
||||
// IngestToLibrary 审核合格内容入媒资库,建立媒资编码映射(需求6-AC1/AC2/AC3)。
|
||||
func (s *Service) IngestToLibrary(role chain.Role, maCode, ctid, mediaAssetID, libName string) error {
|
||||
c, err := s.chain.QueryContent(maCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 门禁:未审核通过/未绑定 MA 码不得入库可发布状态
|
||||
if c.Status == model.StatusRejected || c.Status == model.StatusRevoked {
|
||||
return ErrNotApproved
|
||||
}
|
||||
if _, err := s.chain.RegisterMapping(role, model.Mapping{
|
||||
ContentTwinID: ctid,
|
||||
Party: model.PartyReviewer,
|
||||
PartyID: mediaAssetID,
|
||||
PartyName: libName,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.chain.SetContentStatus(maCode, model.StatusInLibrary)
|
||||
}
|
||||
|
||||
// PublishRequest 从媒资库向运营商发布的请求(需求6-AC4)。
|
||||
type PublishRequest struct {
|
||||
MACode string
|
||||
Certificate string // 必须携带 MA码+哈希证书
|
||||
}
|
||||
|
||||
// PublishToOperator 校验证书后将内容置为已发布(需求6-AC4/AC5、需求3-AC8)。
|
||||
func (s *Service) PublishToOperator(req PublishRequest) error {
|
||||
c, err := s.chain.QueryContent(req.MACode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Status != model.StatusInLibrary && c.Status != model.StatusPublished {
|
||||
return ErrNotApproved
|
||||
}
|
||||
// 发布必须携带证书(含 MA 码)
|
||||
if req.Certificate == "" || !certContainsMA(req.Certificate, req.MACode) {
|
||||
return ErrNoCertificate
|
||||
}
|
||||
return s.chain.SetContentStatus(req.MACode, model.StatusPublished)
|
||||
}
|
||||
|
||||
// ---- 工作包9:CDN 注入校验(需求7) ----
|
||||
|
||||
// InjectResult CDN 注入校验结果。
|
||||
type InjectResult struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
DistributionID string `json:"distribution_id,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// InjectToCDN 运营商注入 CDN 前校验哈希;匹配则放行并注册运营商映射(需求7-AC1~AC4)。
|
||||
func (s *Service) InjectToCDN(role chain.Role, ctid, maCode, injectFileHash, operatorID, cdnEndpoint string) (InjectResult, error) {
|
||||
// 内容须处于已发布状态
|
||||
c, err := s.chain.QueryContent(maCode)
|
||||
if err != nil {
|
||||
return InjectResult{}, err
|
||||
}
|
||||
if c.Status == model.StatusRevoked {
|
||||
return InjectResult{Allowed: false, Reason: "内容已下架"}, ErrNotApproved
|
||||
}
|
||||
|
||||
res, err := s.chain.VerifyHash(maCode, injectFileHash)
|
||||
if err != nil {
|
||||
return InjectResult{Allowed: false, Reason: err.Error()}, err
|
||||
}
|
||||
if !res.Match {
|
||||
// 不匹配:拒绝注入(需求7-AC3、需求15-AC2)
|
||||
return InjectResult{Allowed: false, Reason: "哈希不匹配,疑似篡改,拒绝注入并告警"}, ErrHashMismatch
|
||||
}
|
||||
|
||||
distID := s.nextID("DIST")
|
||||
if _, err := s.chain.RegisterMapping(role, model.Mapping{
|
||||
ContentTwinID: ctid,
|
||||
Party: model.PartyOperator,
|
||||
PartyID: operatorID,
|
||||
CDNEndpoint: cdnEndpoint,
|
||||
}); err != nil {
|
||||
return InjectResult{}, err
|
||||
}
|
||||
return InjectResult{Allowed: true, DistributionID: distID}, nil
|
||||
}
|
||||
|
||||
// ---- 工作包10:版本变更与重审(需求12) ----
|
||||
|
||||
// ReportVersionChange 上报内容变更:哈希变化判定绑定断裂,触发重审(需求12-AC1/AC2)。
|
||||
// 当提供 oldSegments/newSegments 时,定位被篡改的具体集(需求12-AC3)。
|
||||
func (s *Service) ReportVersionChange(ctid, reason, prevHash, newHash string, oldSegments, newSegments []string) ([]int, error) {
|
||||
var changedEpisodes []int
|
||||
affected := 0
|
||||
if len(oldSegments) > 0 || len(newSegments) > 0 {
|
||||
changedEpisodes = hash.LocateChangedLeaves(oldSegments, newSegments)
|
||||
if len(changedEpisodes) > 0 {
|
||||
affected = changedEpisodes[0] + 1 // 1-based 集号
|
||||
}
|
||||
}
|
||||
_, err := s.chain.RecordVersionChange(model.VersionChange{
|
||||
ContentTwinID: ctid,
|
||||
Version: "v-next",
|
||||
ChangeReason: reason,
|
||||
PrevHash: prevHash,
|
||||
NewHash: newHash,
|
||||
ReauditRequired: true,
|
||||
ReauditStatus: "pending",
|
||||
AffectedEpisode: affected,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 转为 1-based 集号返回
|
||||
episodes := make([]int, len(changedEpisodes))
|
||||
for i, idx := range changedEpisodes {
|
||||
episodes[i] = idx + 1
|
||||
}
|
||||
return episodes, nil
|
||||
}
|
||||
|
||||
// ---- 工作包14:违规应急下架(需求11) ----
|
||||
|
||||
// Takedown 监管主体一键下架:解析 MA 码绑定的三方编码与 CDN 端点(需求11-AC1/AC2/AC4)。
|
||||
func (s *Service) Takedown(role chain.Role, maCode, reason string) (chain.MappingsResult, error) {
|
||||
return s.chain.Revoke(role, maCode, reason)
|
||||
}
|
||||
|
||||
// TakedownEpisode 集级下架:只下架指定集,整剧其他集继续流通(仅监管主体)。
|
||||
func (s *Service) TakedownEpisode(role chain.Role, maCode string, episode int, reason string) error {
|
||||
return s.chain.RevokeEpisode(role, maCode, episode, reason)
|
||||
}
|
||||
|
||||
// Restore 恢复上架整剧(仅监管主体)。
|
||||
func (s *Service) Restore(role chain.Role, maCode string) error {
|
||||
return s.chain.Restore(role, maCode)
|
||||
}
|
||||
|
||||
// RestoreEpisode 恢复上架指定集(仅监管主体)。
|
||||
func (s *Service) RestoreEpisode(role chain.Role, maCode string, episode int) error {
|
||||
return s.chain.RestoreEpisode(role, maCode, episode)
|
||||
}
|
||||
|
||||
// certContainsMA 校验证书是否包含指定 MA 码。
|
||||
func certContainsMA(cert, maCode string) bool {
|
||||
return cert != "" && maCode != "" && strings.Contains(cert, maCode)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tcs-iptv/tcs/internal/chain"
|
||||
"github.com/tcs-iptv/tcs/internal/hash"
|
||||
)
|
||||
|
||||
// issueOne 完成一次"送审→CSPS审核→发码签发",返回 maCode、ctid、证书。
|
||||
func issueOne(t *testing.T, s *Service) (string, string, string) {
|
||||
t.Helper()
|
||||
sub, err := s.SubmitForReview(sampleSub())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.ReviewCSPS(sub.ReviewID, true, "reviewer-1")) // 审核在前
|
||||
issued, err := s.ApproveAndIssue(chain.RoleRegulator, sub.ReviewID, "北京市广播电视局")
|
||||
require.NoError(t, err)
|
||||
return issued.MACode, issued.ContentTwinID, issued.Certificate
|
||||
}
|
||||
|
||||
func TestCSPSAndTranscode(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, ctid, _ := issueOne(t, s)
|
||||
|
||||
_, err := s.BindTranscoded(chain.RoleReviewer, ctid, "filehash-abc",
|
||||
"transcoded-h265-4k", "H.265", "3840x2160", "v1.0-4k")
|
||||
require.NoError(t, err)
|
||||
|
||||
// 转码版也能验真通过
|
||||
res, err := s.Verify(maCode, "transcoded-h265-4k")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Match)
|
||||
}
|
||||
|
||||
func TestCSPSRejected(t *testing.T) {
|
||||
s := newService(t)
|
||||
sub, err := s.SubmitForReview(sampleSub())
|
||||
require.NoError(t, err)
|
||||
// CSPS 审核驳回 → 不得发码
|
||||
require.NoError(t, s.ReviewCSPS(sub.ReviewID, false, "reviewer-1"))
|
||||
_, err = s.ApproveAndIssue(chain.RoleRegulator, sub.ReviewID, "issuer")
|
||||
assert.ErrorIs(t, err, ErrNotApproved)
|
||||
}
|
||||
|
||||
func TestIssueRequiresCSPSApproval(t *testing.T) {
|
||||
s := newService(t)
|
||||
sub, err := s.SubmitForReview(sampleSub())
|
||||
require.NoError(t, err)
|
||||
// 未经 CSPS 审核直接发码 → 拒绝
|
||||
_, err = s.ApproveAndIssue(chain.RoleRegulator, sub.ReviewID, "issuer")
|
||||
assert.ErrorIs(t, err, ErrNotApproved)
|
||||
}
|
||||
|
||||
func TestIngestAndPublish(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, ctid, cert := issueOne(t, s)
|
||||
|
||||
require.NoError(t, s.IngestToLibrary(chain.RoleReviewer, maCode, ctid, "MEDIA-001", "广东IPTV媒资库"))
|
||||
|
||||
// 无证书发布被拒
|
||||
err := s.PublishToOperator(PublishRequest{MACode: maCode, Certificate: ""})
|
||||
assert.ErrorIs(t, err, ErrNoCertificate)
|
||||
|
||||
// 携带证书发布成功
|
||||
require.NoError(t, s.PublishToOperator(PublishRequest{MACode: maCode, Certificate: cert}))
|
||||
}
|
||||
|
||||
func TestInjectToCDN_MatchAndMismatch(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, ctid, cert := issueOne(t, s)
|
||||
require.NoError(t, s.IngestToLibrary(chain.RoleReviewer, maCode, ctid, "MEDIA-001", "媒资库"))
|
||||
require.NoError(t, s.PublishToOperator(PublishRequest{MACode: maCode, Certificate: cert}))
|
||||
|
||||
// 哈希匹配 → 允许注入
|
||||
res, err := s.InjectToCDN(chain.RoleOperator, ctid, maCode, "filehash-abc",
|
||||
"CT-IPTV-GD", "cdn://ct-gd/iptv/vod/008923")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Allowed)
|
||||
assert.NotEmpty(t, res.DistributionID)
|
||||
|
||||
// 哈希不匹配 → 拒绝注入
|
||||
res, err = s.InjectToCDN(chain.RoleOperator, ctid, maCode, "tampered-hash",
|
||||
"CT-IPTV-GD", "cdn://x")
|
||||
assert.ErrorIs(t, err, ErrHashMismatch)
|
||||
assert.False(t, res.Allowed)
|
||||
}
|
||||
|
||||
func TestInjectToCDN_RevokedBlocked(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, ctid, cert := issueOne(t, s)
|
||||
require.NoError(t, s.IngestToLibrary(chain.RoleReviewer, maCode, ctid, "MEDIA-001", "媒资库"))
|
||||
require.NoError(t, s.PublishToOperator(PublishRequest{MACode: maCode, Certificate: cert}))
|
||||
|
||||
// 下架后不得注入
|
||||
_, err := s.Takedown(chain.RoleRegulator, maCode, "违规")
|
||||
require.NoError(t, err)
|
||||
_, err = s.InjectToCDN(chain.RoleOperator, ctid, maCode, "filehash-abc", "OP", "cdn://x")
|
||||
assert.ErrorIs(t, err, ErrNotApproved)
|
||||
}
|
||||
|
||||
func TestTakedown_ResolvesMappings(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, ctid, cert := issueOne(t, s)
|
||||
require.NoError(t, s.IngestToLibrary(chain.RoleReviewer, maCode, ctid, "MEDIA-001", "媒资库"))
|
||||
require.NoError(t, s.PublishToOperator(PublishRequest{MACode: maCode, Certificate: cert}))
|
||||
_, _ = s.InjectToCDN(chain.RoleOperator, ctid, maCode, "filehash-abc", "CT-IPTV-GD", "cdn://ct-gd/vod/1")
|
||||
|
||||
// 非监管主体不得下架
|
||||
_, err := s.Takedown(chain.RoleOperator, maCode, "越权")
|
||||
assert.ErrorIs(t, err, chain.ErrPermissionDenied)
|
||||
|
||||
// 监管下架,解析出 CDN 端点
|
||||
res, err := s.Takedown(chain.RoleRegulator, maCode, "违规")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, res.CDNEndpoints, "cdn://ct-gd/vod/1")
|
||||
}
|
||||
|
||||
func TestReportVersionChange_LocatesEpisode(t *testing.T) {
|
||||
s := newService(t)
|
||||
_, ctid, _ := issueOne(t, s)
|
||||
|
||||
old := []string{
|
||||
hash.SHA256Hex([]byte("ep1")),
|
||||
hash.SHA256Hex([]byte("ep2")),
|
||||
hash.SHA256Hex([]byte("ep3")),
|
||||
}
|
||||
neu := []string{
|
||||
hash.SHA256Hex([]byte("ep1")),
|
||||
hash.SHA256Hex([]byte("ep2-tampered")),
|
||||
hash.SHA256Hex([]byte("ep3")),
|
||||
}
|
||||
episodes, err := s.ReportVersionChange(ctid, "第2集被替换", "root-old", "root-new", old, neu)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []int{2}, episodes, "应定位到第2集(1-based)")
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tcs-iptv/tcs/internal/chain"
|
||||
"github.com/tcs-iptv/tcs/internal/macode"
|
||||
"github.com/tcs-iptv/tcs/internal/model"
|
||||
)
|
||||
|
||||
// 24 集微短剧:一剧一 MA 码,每集独立哈希,可按集验真。
|
||||
func TestEpisodeLevel_OneSeriesOneCodeMultiEpisodeHash(t *testing.T) {
|
||||
s := newService(t)
|
||||
|
||||
eps := make([]model.EpisodeHash, 0, 24)
|
||||
for i := 1; i <= 24; i++ {
|
||||
eps = append(eps, model.EpisodeHash{
|
||||
Episode: i,
|
||||
FileSHA256: "ep-hash-" + string(rune('a'+i)),
|
||||
MerkleRoot: "ep-mr-" + string(rune('a'+i)),
|
||||
Duration: 180,
|
||||
})
|
||||
}
|
||||
sub := Submission{
|
||||
Title: "长安少年行", EpisodeCount: 24, Category: macode.CategoryMicroDrama,
|
||||
FileHash: "series-root-hash", MerkleRoot: "series-merkle-root",
|
||||
Episodes: eps,
|
||||
CPMediaID: "XAQJSL-2026-001", CPName: "西安曲江丝路文化传播有限公司",
|
||||
}
|
||||
r, err := s.SubmitForReview(sub)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.ReviewCSPS(r.ReviewID, true, "rv-1"))
|
||||
issued, err := s.ApproveAndIssue(chain.RoleRegulator, r.ReviewID, "陕西IPTV运营公司")
|
||||
require.NoError(t, err)
|
||||
|
||||
// 一剧一码
|
||||
assert.True(t, macode.IsValid(issued.MACode))
|
||||
|
||||
// 24 集哈希全部绑定在同一 MA 码下
|
||||
list, err := s.ListEpisodes(issued.MACode)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, list, 24)
|
||||
|
||||
// 按集验真:第 7 集正确哈希匹配
|
||||
res, err := s.VerifyEpisode(issued.MACode, 7, "ep-hash-"+string(rune('a'+7)))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Match)
|
||||
|
||||
// 第 7 集错误哈希 → 不匹配(疑似该集被替换)
|
||||
_, err = s.VerifyEpisode(issued.MACode, 7, "tampered-ep7")
|
||||
assert.ErrorIs(t, err, ErrHashMismatch)
|
||||
}
|
||||
|
||||
// 集级下架:只下架第3集,整剧其他集不受影响。
|
||||
func TestEpisodeTakedown(t *testing.T) {
|
||||
s := newService(t)
|
||||
|
||||
eps := []model.EpisodeHash{
|
||||
{Episode: 1, FileSHA256: "h1"}, {Episode: 2, FileSHA256: "h2"},
|
||||
{Episode: 3, FileSHA256: "h3"}, {Episode: 4, FileSHA256: "h4"},
|
||||
}
|
||||
sub := Submission{
|
||||
Title: "多集剧", EpisodeCount: 4, Category: macode.CategoryMicroDrama,
|
||||
FileHash: "series-h", MerkleRoot: "series-mr", Episodes: eps,
|
||||
}
|
||||
r, err := s.SubmitForReview(sub)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.ReviewCSPS(r.ReviewID, true, "rv-1"))
|
||||
issued, err := s.ApproveAndIssue(chain.RoleRegulator, r.ReviewID, "陕西IPTV运营公司")
|
||||
require.NoError(t, err)
|
||||
|
||||
// 运营商无权集级下架
|
||||
err = s.TakedownEpisode(chain.RoleOperator, issued.MACode, 3, "第3集违规")
|
||||
assert.ErrorIs(t, err, chain.ErrPermissionDenied)
|
||||
|
||||
// 监管下架第3集
|
||||
require.NoError(t, s.TakedownEpisode(chain.RoleRegulator, issued.MACode, 3, "第3集违规"))
|
||||
|
||||
list, err := s.ListEpisodes(issued.MACode)
|
||||
require.NoError(t, err)
|
||||
for _, b := range list {
|
||||
if b.Episode == 3 {
|
||||
assert.True(t, b.Revoked, "第3集应已下架")
|
||||
} else {
|
||||
assert.False(t, b.Revoked, "第%d集不应受影响", b.Episode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 集级子标识:MA码#E07 解析与生成。
|
||||
func TestEpisodeSubID(t *testing.T) {
|
||||
ma := "MA.156.8531.6101/WD/20260000001"
|
||||
sub := macode.EpisodeSubID(ma, 7)
|
||||
assert.Equal(t, "MA.156.8531.6101/WD/20260000001#E07", sub)
|
||||
|
||||
parsedMA, ep := macode.ParseEpisodeSubID(sub)
|
||||
assert.Equal(t, ma, parsedMA)
|
||||
assert.Equal(t, 7, ep)
|
||||
|
||||
// 无后缀 → 整剧(episode 0)
|
||||
parsedMA2, ep2 := macode.ParseEpisodeSubID(ma)
|
||||
assert.Equal(t, ma, parsedMA2)
|
||||
assert.Equal(t, 0, ep2)
|
||||
}
|
||||
|
||||
// 单体内容(电影,无分集):episodes 为空也能正常签发与整剧验真。
|
||||
func TestSingleContent_NoEpisodes(t *testing.T) {
|
||||
s := newService(t)
|
||||
sub := sampleSub()
|
||||
sub.Episodes = nil
|
||||
r, err := s.SubmitForReview(sub)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.ReviewCSPS(r.ReviewID, true, "rv-1"))
|
||||
issued, err := s.ApproveAndIssue(chain.RoleRegulator, r.ReviewID, "陕西IPTV运营公司")
|
||||
require.NoError(t, err)
|
||||
|
||||
list, err := s.ListEpisodes(issued.MACode)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, list, "单体内容无集级绑定")
|
||||
|
||||
// 整剧验真仍可用
|
||||
res, err := s.Verify(issued.MACode, sub.FileHash)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Match)
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// Package service 实现 TCS-IPTV 的业务编排,
|
||||
// 依赖 chain.Client(链)与哈希校验,串联送审→签发→验真→入库→发布→下架全流程。
|
||||
// 对应需求:需求2/3/4/5/6/7/11/12/15。
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tcs-iptv/tcs/internal/chain"
|
||||
"github.com/tcs-iptv/tcs/internal/macode"
|
||||
"github.com/tcs-iptv/tcs/internal/model"
|
||||
)
|
||||
|
||||
// 业务错误。
|
||||
var (
|
||||
ErrIncompleteHashPkg = errors.New("service: incomplete hash package")
|
||||
ErrDuplicateContent = errors.New("service: duplicate content (hash exists)")
|
||||
ErrHashMismatch = errors.New("service: hash mismatch (suspected version replacement)")
|
||||
ErrNotApproved = errors.New("service: content not approved")
|
||||
ErrNoCertificate = errors.New("service: missing MA code or hash certificate")
|
||||
ErrReauditPending = errors.New("service: reaudit pending, distribution blocked")
|
||||
)
|
||||
|
||||
// Submission 送审申报(需求2)。
|
||||
type Submission struct {
|
||||
Title string
|
||||
EpisodeCount int
|
||||
Category string // 内容类目(macode.CategoryXxx),决定发码号段
|
||||
FileHash string
|
||||
MerkleRoot string
|
||||
Perceptual string
|
||||
Episodes []model.EpisodeHash // 分集哈希(按集提交)
|
||||
CPMediaID string
|
||||
CPName string
|
||||
}
|
||||
|
||||
// SubmissionResult 送审受理结果。
|
||||
type SubmissionResult struct {
|
||||
ReviewID string `json:"review_id"`
|
||||
ContentTwinID string `json:"content_twin_id"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Service 业务编排器。
|
||||
type Service struct {
|
||||
chain chain.Client
|
||||
gen *macode.Generator
|
||||
mu sync.Mutex
|
||||
seqMu sync.Mutex
|
||||
seqs map[string]int // 按前缀独立计数(REV/ctid/DIST 各自从 1 递增)
|
||||
// reviewStore 暂存送审申报(MVP 内存;生产落 PG)
|
||||
reviews map[string]*reviewItem
|
||||
}
|
||||
|
||||
type reviewItem struct {
|
||||
ContentTwinID string
|
||||
Sub Submission
|
||||
Status string
|
||||
MACode string
|
||||
}
|
||||
|
||||
// New 创建业务服务。
|
||||
func New(c chain.Client, gen *macode.Generator) *Service {
|
||||
return &Service{chain: c, gen: gen, seqs: make(map[string]int), reviews: make(map[string]*reviewItem)}
|
||||
}
|
||||
|
||||
func (s *Service) nextID(prefix string) string {
|
||||
s.seqMu.Lock()
|
||||
defer s.seqMu.Unlock()
|
||||
s.seqs[prefix]++
|
||||
return fmt.Sprintf("%s-%s-%04d", prefix, time.Now().Format("20060102"), s.seqs[prefix])
|
||||
}
|
||||
|
||||
// SubmitForReview 处理 CP 送审申报(需求2)。
|
||||
// 校验哈希包完整性、拦截换壳重发,受理后返回送审流水号。
|
||||
func (s *Service) SubmitForReview(sub Submission) (SubmissionResult, error) {
|
||||
if sub.FileHash == "" || sub.MerkleRoot == "" {
|
||||
return SubmissionResult{}, ErrIncompleteHashPkg
|
||||
}
|
||||
// 防换壳重发(需求2-AC3、需求15-AC5)
|
||||
if maCode, exists := s.chain.HashExists(sub.FileHash); exists {
|
||||
return SubmissionResult{
|
||||
Status: "rejected",
|
||||
Message: fmt.Sprintf("内容哈希已存在,关联原 MA 码: %s", maCode),
|
||||
}, ErrDuplicateContent
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
reviewID := s.nextID("REV")
|
||||
ctid := s.nextID("ctid")
|
||||
s.reviews[reviewID] = &reviewItem{
|
||||
ContentTwinID: ctid,
|
||||
Sub: sub,
|
||||
Status: model.StatusPending,
|
||||
}
|
||||
return SubmissionResult{
|
||||
ReviewID: reviewID,
|
||||
ContentTwinID: ctid,
|
||||
Status: model.StatusPending,
|
||||
Message: "哈希已受理,待审核签发 MA 码",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IssueResult 签发结果。
|
||||
type IssueResult struct {
|
||||
MACode string `json:"ma_code"`
|
||||
ContentTwinID string `json:"content_twin_id"`
|
||||
TxID string `json:"tx_id"`
|
||||
Certificate string `json:"certificate"` // MA码+哈希证书(MVP 简化为字符串)
|
||||
}
|
||||
|
||||
// ReviewCSPS CSPS 合规审核(发码前)。审核通过后方可发码,体现"审过才发证发码"。
|
||||
// 对应需求5(CSPS审核)+ 需求3-AC2(审核通过后生成MA码)。
|
||||
func (s *Service) ReviewCSPS(reviewID string, approved bool, reviewerID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
item, ok := s.reviews[reviewID]
|
||||
if !ok {
|
||||
return fmt.Errorf("service: review %s not found", reviewID)
|
||||
}
|
||||
if approved {
|
||||
item.Status = model.StatusApproved
|
||||
} else {
|
||||
item.Status = model.StatusRejected
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApproveAndIssue 在 CSPS 审核通过后**生成 MA 码**并强绑定哈希(需求3,模式B 自行发码)。
|
||||
// 前置:该送审必须已通过 CSPS 审核(审过才发码)。
|
||||
// MA 码由 macode.Generator 按内容类目从号段中原子分配。仅监管主体可调用。
|
||||
func (s *Service) ApproveAndIssue(role chain.Role, reviewID, issuer string) (IssueResult, error) {
|
||||
s.mu.Lock()
|
||||
item, ok := s.reviews[reviewID]
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
return IssueResult{}, fmt.Errorf("service: review %s not found", reviewID)
|
||||
}
|
||||
// 审核门禁:未通过 CSPS 审核不得发码
|
||||
if item.Status == model.StatusRejected {
|
||||
return IssueResult{}, ErrNotApproved
|
||||
}
|
||||
if item.Status != model.StatusApproved {
|
||||
return IssueResult{}, fmt.Errorf("%w: 需先通过 CSPS 审核", ErrNotApproved)
|
||||
}
|
||||
|
||||
// 模式B:按类目自行发码
|
||||
issued, err := s.gen.Allocate(item.Sub.Category)
|
||||
if err != nil {
|
||||
return IssueResult{}, fmt.Errorf("service: allocate MA code: %w", err)
|
||||
}
|
||||
maCode := issued.MACode
|
||||
|
||||
txID, err := s.chain.IssueMA(role, chain.IssueRequest{
|
||||
MACode: maCode,
|
||||
ContentTwinID: item.ContentTwinID,
|
||||
MerkleRoot: item.Sub.MerkleRoot,
|
||||
FileHash: item.Sub.FileHash,
|
||||
PerceptualHash: item.Sub.Perceptual,
|
||||
Episodes: item.Sub.Episodes,
|
||||
Content: model.Content{
|
||||
Title: item.Sub.Title,
|
||||
EpisodeCount: item.Sub.EpisodeCount,
|
||||
MAType: item.Sub.Category,
|
||||
Issuer: issuer,
|
||||
IssueDate: time.Now().Format("2006-01-02"),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return IssueResult{}, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
item.Status = model.StatusIssued // 已发码,移出"待发码"队列
|
||||
item.MACode = maCode
|
||||
s.mu.Unlock()
|
||||
|
||||
// CP 注册本方映射
|
||||
_, _ = s.chain.RegisterMapping(role, model.Mapping{
|
||||
ContentTwinID: item.ContentTwinID,
|
||||
Party: model.PartyCP,
|
||||
PartyID: item.Sub.CPMediaID,
|
||||
PartyName: item.Sub.CPName,
|
||||
})
|
||||
|
||||
cert := fmt.Sprintf("CERT|%s|%s|%s", maCode, item.Sub.FileHash, item.Sub.MerkleRoot)
|
||||
return IssueResult{
|
||||
MACode: maCode,
|
||||
ContentTwinID: item.ContentTwinID,
|
||||
TxID: txID,
|
||||
Certificate: cert,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Verify 送审文件验真 / CDN 注入校验通用入口(需求4、需求7)。
|
||||
func (s *Service) Verify(maCode, fileHash string) (chain.VerifyResult, error) {
|
||||
res, err := s.chain.VerifyHash(maCode, fileHash)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
if !res.Match {
|
||||
return res, ErrHashMismatch
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// QueryMappings 查询 MA 码绑定的三方映射与 CDN 端点(需求11/17)。
|
||||
func (s *Service) QueryMappings(maCode string) (chain.MappingsResult, error) {
|
||||
return s.chain.QueryMappings(maCode)
|
||||
}
|
||||
|
||||
// VerifyEpisode 按集级子标识(MA码#E07)或 MA码+集号 验真单集。
|
||||
func (s *Service) VerifyEpisode(maCode string, episode int, fileHash string) (chain.VerifyResult, error) {
|
||||
res, err := s.chain.VerifyEpisodeHash(maCode, episode, fileHash)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
if !res.Match {
|
||||
return res, ErrHashMismatch
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ListEpisodes 列出某剧的全部集级哈希绑定。
|
||||
func (s *Service) ListEpisodes(maCode string) ([]model.HashBinding, error) {
|
||||
return s.chain.ListEpisodes(maCode)
|
||||
}
|
||||
|
||||
// ReviewSummary 送审待办摘要(发码前阶段)。
|
||||
type ReviewSummary struct {
|
||||
ReviewID string `json:"review_id"`
|
||||
ContentTwinID string `json:"content_twin_id"`
|
||||
Title string `json:"title"`
|
||||
Category string `json:"category"`
|
||||
EpisodeCount int `json:"episode_count"`
|
||||
Status string `json:"status"`
|
||||
CPName string `json:"cp_name"`
|
||||
MACode string `json:"ma_code"`
|
||||
}
|
||||
|
||||
// ListReviews 列出指定状态的送审待办(用于审核台/发码台队列)。
|
||||
// status 空则返回全部;常用 pending(待审)、approved(待发码)。
|
||||
func (s *Service) ListReviews(status string) []ReviewSummary {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var out []ReviewSummary
|
||||
for id, item := range s.reviews {
|
||||
if status != "" && item.Status != status {
|
||||
continue
|
||||
}
|
||||
out = append(out, ReviewSummary{
|
||||
ReviewID: id, ContentTwinID: item.ContentTwinID,
|
||||
Title: item.Sub.Title, Category: item.Sub.Category,
|
||||
EpisodeCount: item.Sub.EpisodeCount, Status: item.Status,
|
||||
CPName: item.Sub.CPName, MACode: item.MACode,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ListContentsByStatus 列出指定状态的内容(用于入库台/发布台/注入台队列)。
|
||||
// 常用 approved(待入库)、in_library(待发布)、published(待注入)。
|
||||
func (s *Service) ListContentsByStatus(status string) ([]model.Content, error) {
|
||||
return s.chain.ListContents(status)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tcs-iptv/tcs/internal/chain"
|
||||
"github.com/tcs-iptv/tcs/internal/macode"
|
||||
)
|
||||
|
||||
func newService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
gen := macode.NewGenerator(macode.NewMemoryStore())
|
||||
require.NoError(t, gen.RegisterSegment(macode.Segment{
|
||||
IndustryNode: "8531", OrgNode: "4401",
|
||||
Category: macode.CategoryMicroDrama, Start: 1, End: 100, SeqWidth: 7,
|
||||
}))
|
||||
return New(chain.NewMemoryChain(), gen)
|
||||
}
|
||||
|
||||
func sampleSub() Submission {
|
||||
return Submission{
|
||||
Title: "示例微短剧", EpisodeCount: 24, Category: macode.CategoryMicroDrama,
|
||||
FileHash: "filehash-abc", MerkleRoot: "merkle-abc", Perceptual: "phash-abc",
|
||||
CPMediaID: "FS-MEDIA-77821", CPName: "飞翮信息",
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmit_IncompleteHashRejected(t *testing.T) {
|
||||
s := newService(t)
|
||||
_, err := s.SubmitForReview(Submission{Title: "无哈希", Category: macode.CategoryMicroDrama})
|
||||
assert.ErrorIs(t, err, ErrIncompleteHashPkg)
|
||||
}
|
||||
|
||||
func TestSubmit_Success(t *testing.T) {
|
||||
s := newService(t)
|
||||
res, err := s.SubmitForReview(sampleSub())
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, res.ReviewID)
|
||||
assert.NotEmpty(t, res.ContentTwinID)
|
||||
assert.Equal(t, "pending", res.Status)
|
||||
}
|
||||
|
||||
func TestApproveAndIssue_GeneratesMACode(t *testing.T) {
|
||||
s := newService(t)
|
||||
sub, err := s.SubmitForReview(sampleSub())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.ReviewCSPS(sub.ReviewID, true, "rv-1"))
|
||||
|
||||
issued, err := s.ApproveAndIssue(chain.RoleRegulator, sub.ReviewID, "北京市广播电视局")
|
||||
require.NoError(t, err)
|
||||
// 模式B:MA 码由系统按号段生成
|
||||
assert.True(t, macode.IsValid(issued.MACode), "应生成合法 MA 码: %s", issued.MACode)
|
||||
assert.True(t, strings.HasPrefix(issued.MACode, "MA.156.8531.4401/WD/"), "前缀应匹配号段: %s", issued.MACode)
|
||||
assert.NotEmpty(t, issued.TxID)
|
||||
assert.Contains(t, issued.Certificate, issued.MACode)
|
||||
}
|
||||
|
||||
func TestApproveAndIssue_OnlyRegulator(t *testing.T) {
|
||||
s := newService(t)
|
||||
sub, _ := s.SubmitForReview(sampleSub())
|
||||
require.NoError(t, s.ReviewCSPS(sub.ReviewID, true, "rv-1")) // 先过审,才轮到校验角色
|
||||
_, err := s.ApproveAndIssue(chain.RoleCP, sub.ReviewID, "x")
|
||||
assert.ErrorIs(t, err, chain.ErrPermissionDenied)
|
||||
}
|
||||
|
||||
func TestSubmit_DuplicateRejectedAfterIssue(t *testing.T) {
|
||||
s := newService(t)
|
||||
sub, _ := s.SubmitForReview(sampleSub())
|
||||
require.NoError(t, s.ReviewCSPS(sub.ReviewID, true, "rv-1"))
|
||||
_, err := s.ApproveAndIssue(chain.RoleRegulator, sub.ReviewID, "issuer")
|
||||
require.NoError(t, err)
|
||||
|
||||
// 同哈希再次送审 → 换壳重发拦截
|
||||
_, err = s.SubmitForReview(sampleSub())
|
||||
assert.ErrorIs(t, err, ErrDuplicateContent)
|
||||
}
|
||||
|
||||
func TestVerify_MatchAndMismatch(t *testing.T) {
|
||||
s := newService(t)
|
||||
sub, _ := s.SubmitForReview(sampleSub())
|
||||
require.NoError(t, s.ReviewCSPS(sub.ReviewID, true, "rv-1"))
|
||||
issued, _ := s.ApproveAndIssue(chain.RoleRegulator, sub.ReviewID, "issuer")
|
||||
|
||||
res, err := s.Verify(issued.MACode, "filehash-abc")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Match)
|
||||
|
||||
_, err = s.Verify(issued.MACode, "tampered")
|
||||
assert.ErrorIs(t, err, ErrHashMismatch)
|
||||
}
|
||||
|
||||
func TestApproveAndIssue_TwoContentsUniqueCodes(t *testing.T) {
|
||||
s := newService(t)
|
||||
sub1, _ := s.SubmitForReview(sampleSub())
|
||||
require.NoError(t, s.ReviewCSPS(sub1.ReviewID, true, "rv-1"))
|
||||
i1, err := s.ApproveAndIssue(chain.RoleRegulator, sub1.ReviewID, "issuer")
|
||||
require.NoError(t, err)
|
||||
|
||||
sub2v := sampleSub()
|
||||
sub2v.FileHash = "filehash-def"
|
||||
sub2v.MerkleRoot = "merkle-def"
|
||||
sub2, _ := s.SubmitForReview(sub2v)
|
||||
require.NoError(t, s.ReviewCSPS(sub2.ReviewID, true, "rv-1"))
|
||||
i2, err := s.ApproveAndIssue(chain.RoleRegulator, sub2.ReviewID, "issuer")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, i1.MACode, i2.MACode, "两条内容应分配不同 MA 码")
|
||||
}
|
||||
Reference in New Issue
Block a user