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,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)
|
||||
}
|
||||
Reference in New Issue
Block a user