init: AIGC-Hub/AVCC 方案文档 + TCS-IPTV 内容可信锁定系统 MVP

- 方案文档: AVCC 体系建设、IPTV TCS 需求(0-req)/PRD(1-prd)/任务(2-task)/二三四期任务
- tcs-iptv: Go 后端(哈希SDK/MA码生成/可信数据空间mock/业务编排/HTTP API+HMAC鉴权)
- web-console: React+AntD 监管大屏(角色工作台/全流程演示/监管片库)
- 一剧一码+集级哈希, 集级下架/恢复, 全栈测试通过
This commit is contained in:
selfrelease
2026-06-14 16:50:31 +08:00
commit a329d4906b
103 changed files with 20052 additions and 0 deletions
+176
View File
@@ -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)
}
// ---- 工作包9CDN 注入校验(需求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")
}
+127
View File
@@ -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)
}
+270
View File
@@ -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)
}
+111
View File
@@ -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)
// 模式BMA 码由系统按号段生成
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 码")
}