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
+95
View File
@@ -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
}
+396
View File
@@ -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)
+149
View File
@@ -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)
}