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,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