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
+153
View File
@@ -0,0 +1,153 @@
// Package macode 实现 MA 码生成服务(模式B:自行发码)。
// TCS 与 MA 发码机构合作获取「码段(号段)」与「备案规则」,在本地按规则原子发码。
// 对应需求:需求3(MA码签发)、需求16;与 ISO/IEC 15459 MA 标识体系对齐。
//
// MA 码结构(六段式,可由备案规则配置):
//
// MA.156.{industryNode}.{orgNode}/{category}/{yyyy}{sequence}
//
// 示例:MA.156.8531.4401/WD/20250000123
// - MA 固定前缀(标识体系根)
// - 156 国家码(中国)
// - 8531 行业节点(IPTV 视听内容,由发码机构分配)
// - 4401 机构节点(运营主体/省局,由发码机构分配)
// - WD 内容类目(WD=微短剧 / WJ=网络剧 / DY=网络电影 / DH=网络动画)
// - yyyy 年份
// - sequence 号段内递增序列(按位补零)
package macode
import (
"errors"
"fmt"
"sync"
"time"
)
// 错误定义。
var (
ErrSegmentExhausted = errors.New("macode: code segment exhausted")
ErrUnknownCategory = errors.New("macode: unknown content category")
ErrInvalidSegment = errors.New("macode: invalid segment range")
)
// 内容类目码。
const (
CategoryMicroDrama = "WD" // 网络微短剧
CategoryWebSeries = "WJ" // 网络剧
CategoryWebMovie = "DY" // 网络电影
CategoryAnimation = "DH" // 网络动画
)
// Segment 是 MA 发码机构分配给本运营主体的「码段(号段)」。
// 同一 (industryNode, orgNode, category) 下,序列在 [Start, End] 内递增分配。
type Segment struct {
IndustryNode string // 行业节点(发码机构分配)
OrgNode string // 机构节点(发码机构分配)
Category string // 适用内容类目
Start uint64 // 号段起始序列(含)
End uint64 // 号段结束序列(含)
SeqWidth int // 序列补零宽度,如 7 → 0000123
}
// Validate 校验号段合法性。
func (s Segment) Validate() error {
if s.IndustryNode == "" || s.OrgNode == "" || s.Category == "" {
return ErrInvalidSegment
}
if s.End < s.Start {
return ErrInvalidSegment
}
if s.SeqWidth <= 0 {
s.SeqWidth = 7
}
return nil
}
// AllocationStore 持久化号段游标,保证重启后不重号、并发下原子分配。
// MVP 提供内存实现;生产可用 PostgreSQL 行锁 / Redis INCR 实现。
type AllocationStore interface {
// Next 原子取得指定号段键的下一个序列值;超出 end 返回 ErrSegmentExhausted。
Next(segmentKey string, start, end uint64) (uint64, error)
}
// Generator MA 码生成器。
type Generator struct {
mu sync.RWMutex
segments map[string]Segment // category -> segment
store AllocationStore
clock func() time.Time
}
// NewGenerator 创建生成器。
func NewGenerator(store AllocationStore) *Generator {
return &Generator{
segments: make(map[string]Segment),
store: store,
clock: time.Now,
}
}
// RegisterSegment 登记一个码段(通常在与发码机构对接后配置)。
func (g *Generator) RegisterSegment(seg Segment) error {
if err := seg.Validate(); err != nil {
return err
}
if seg.SeqWidth <= 0 {
seg.SeqWidth = 7
}
g.mu.Lock()
defer g.mu.Unlock()
g.segments[seg.Category] = seg
return nil
}
// segmentKey 唯一标识一个号段(用于持久化游标)。
func segmentKey(s Segment) string {
return fmt.Sprintf("%s:%s:%s", s.IndustryNode, s.OrgNode, s.Category)
}
// Issued 一次发码结果。
type Issued struct {
MACode string `json:"ma_code"`
IndustryNode string `json:"industry_node"`
OrgNode string `json:"org_node"`
Category string `json:"category"`
Sequence uint64 `json:"sequence"`
Year int `json:"year"`
}
// Allocate 为指定类目原子分配并格式化一个全局唯一 MA 码。
func (g *Generator) Allocate(category string) (Issued, error) {
g.mu.RLock()
seg, ok := g.segments[category]
g.mu.RUnlock()
if !ok {
return Issued{}, ErrUnknownCategory
}
seq, err := g.store.Next(segmentKey(seg), seg.Start, seg.End)
if err != nil {
return Issued{}, err
}
year := g.clock().Year()
code := Format(seg, year, seq)
return Issued{
MACode: code,
IndustryNode: seg.IndustryNode,
OrgNode: seg.OrgNode,
Category: category,
Sequence: seq,
Year: year,
}, nil
}
// Format 按备案规则拼装 MA 码。
func Format(seg Segment, year int, seq uint64) string {
w := seg.SeqWidth
if w <= 0 {
w = 7
}
return fmt.Sprintf("MA.156.%s.%s/%s/%d%0*d",
seg.IndustryNode, seg.OrgNode, seg.Category, year, w, seq)
}
+135
View File
@@ -0,0 +1,135 @@
package macode
import (
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fixedTime 固定为 2025 年,便于断言年份段。
var fixedTime = time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC)
func newGen(t *testing.T) *Generator {
t.Helper()
g := NewGenerator(NewMemoryStore())
require.NoError(t, g.RegisterSegment(Segment{
IndustryNode: "8531", OrgNode: "4401", Category: CategoryMicroDrama,
Start: 1, End: 100, SeqWidth: 7,
}))
// 固定年份便于断言
g.clock = func() time.Time { return fixedTime }
return g
}
func TestAllocate_SequentialUnique(t *testing.T) {
g := newGen(t)
seen := make(map[string]bool)
for i := 0; i < 50; i++ {
issued, err := g.Allocate(CategoryMicroDrama)
require.NoError(t, err)
assert.False(t, seen[issued.MACode], "MA 码必须唯一: %s", issued.MACode)
seen[issued.MACode] = true
assert.True(t, IsValid(issued.MACode), "应符合格式: %s", issued.MACode)
}
assert.Len(t, seen, 50)
}
func TestAllocate_FormatCorrect(t *testing.T) {
g := newGen(t)
issued, err := g.Allocate(CategoryMicroDrama)
require.NoError(t, err)
assert.Equal(t, "MA.156.8531.4401/WD/20250000001", issued.MACode)
assert.Equal(t, uint64(1), issued.Sequence)
assert.Equal(t, 2025, issued.Year)
}
func TestAllocate_UnknownCategory(t *testing.T) {
g := newGen(t)
_, err := g.Allocate("XX")
assert.ErrorIs(t, err, ErrUnknownCategory)
}
func TestAllocate_SegmentExhausted(t *testing.T) {
g := NewGenerator(NewMemoryStore())
g.clock = func() time.Time { return fixedTime }
require.NoError(t, g.RegisterSegment(Segment{
IndustryNode: "8531", OrgNode: "4401", Category: CategoryWebMovie,
Start: 1, End: 2, SeqWidth: 5,
}))
_, err := g.Allocate(CategoryWebMovie)
require.NoError(t, err)
_, err = g.Allocate(CategoryWebMovie)
require.NoError(t, err)
_, err = g.Allocate(CategoryWebMovie) // 第3个超出 [1,2]
assert.ErrorIs(t, err, ErrSegmentExhausted)
}
func TestAllocate_ConcurrentNoDuplicate(t *testing.T) {
g := NewGenerator(NewMemoryStore())
g.clock = func() time.Time { return fixedTime }
require.NoError(t, g.RegisterSegment(Segment{
IndustryNode: "8531", OrgNode: "4401", Category: CategoryWebSeries,
Start: 1, End: 1000, SeqWidth: 7,
}))
var wg sync.WaitGroup
var mu sync.Mutex
seen := make(map[string]bool)
dup := 0
for i := 0; i < 200; i++ {
wg.Add(1)
go func() {
defer wg.Done()
issued, err := g.Allocate(CategoryWebSeries)
if err != nil {
return
}
mu.Lock()
if seen[issued.MACode] {
dup++
}
seen[issued.MACode] = true
mu.Unlock()
}()
}
wg.Wait()
assert.Equal(t, 0, dup, "并发分配不得出现重号")
assert.Len(t, seen, 200)
}
func TestParse_RoundTrip(t *testing.T) {
seg := Segment{IndustryNode: "8531", OrgNode: "4401", Category: CategoryMicroDrama, SeqWidth: 7}
code := Format(seg, 2025, 123)
assert.Equal(t, "MA.156.8531.4401/WD/20250000123", code)
p, err := Parse(code)
require.NoError(t, err)
assert.Equal(t, "MA", p.Root)
assert.Equal(t, "156", p.CountryCode)
assert.Equal(t, "8531", p.IndustryNode)
assert.Equal(t, "4401", p.OrgNode)
assert.Equal(t, "WD", p.Category)
assert.Equal(t, 2025, p.Year)
assert.Equal(t, uint64(123), p.Sequence)
}
func TestParse_Invalid(t *testing.T) {
bad := []string{
"", "MA.156", "MA.156.8531.4401/WD", "(京)网微剧审字(2025)第123号",
"MA.156.8531.4401/wd/20250000123", // 类目须大写两位
}
for _, b := range bad {
_, err := Parse(b)
assert.Error(t, err, "应判为非法: %q", b)
assert.False(t, IsValid(b))
}
}
func TestSegment_Validate(t *testing.T) {
assert.Error(t, Segment{}.Validate())
assert.Error(t, Segment{IndustryNode: "1", OrgNode: "2", Category: "WD", Start: 10, End: 5}.Validate())
assert.NoError(t, Segment{IndustryNode: "1", OrgNode: "2", Category: "WD", Start: 1, End: 5, SeqWidth: 7}.Validate())
}
+70
View File
@@ -0,0 +1,70 @@
package macode
import (
"fmt"
"regexp"
"strconv"
)
// Parsed 是解析后的 MA 码各段。
type Parsed struct {
Root string
CountryCode string
IndustryNode string
OrgNode string
Category string
Year int
Sequence uint64
}
// maCodePattern 匹配六段式 MA 码:
// MA.156.{industry}.{org}/{category}/{yyyy}{sequence}
var maCodePattern = regexp.MustCompile(
`^(MA)\.(\d{3})\.([0-9A-Za-z]+)\.([0-9A-Za-z]+)/([A-Z]{2})/(\d{4})(\d+)$`)
// Parse 将 MA 码字符串解析为结构化字段;格式非法返回错误(需求4 校验基础)。
func Parse(code string) (Parsed, error) {
m := maCodePattern.FindStringSubmatch(code)
if m == nil {
return Parsed{}, fmt.Errorf("macode: invalid format: %s", code)
}
year, _ := strconv.Atoi(m[6])
seq, err := strconv.ParseUint(m[7], 10, 64)
if err != nil {
return Parsed{}, fmt.Errorf("macode: invalid sequence: %w", err)
}
return Parsed{
Root: m[1],
CountryCode: m[2],
IndustryNode: m[3],
OrgNode: m[4],
Category: m[5],
Year: year,
Sequence: seq,
}, nil
}
// IsValid 仅校验格式合法性。
func IsValid(code string) bool {
return maCodePattern.MatchString(code)
}
// EpisodeSubID 生成集级子标识:{maCode}#E{NN}。
// 整剧用 MA 码,单集用子标识,便于按集验真/追更/下架。
func EpisodeSubID(maCode string, episode int) string {
return fmt.Sprintf("%s#E%02d", maCode, episode)
}
// episodeSubPattern 匹配集级子标识后缀。
var episodeSubPattern = regexp.MustCompile(`^(.+)#E(\d+)$`)
// ParseEpisodeSubID 拆解集级子标识,返回主 MA 码与集号。
// 若无 #E 后缀,episode 返回 0(表示整剧)。
func ParseEpisodeSubID(subID string) (maCode string, episode int) {
m := episodeSubPattern.FindStringSubmatch(subID)
if m == nil {
return subID, 0
}
ep, _ := strconv.Atoi(m[2])
return m[1], ep
}
+36
View File
@@ -0,0 +1,36 @@
package macode
import "sync"
// MemoryStore 是 AllocationStore 的内存实现(MVP / 测试)。
// 生产环境应替换为 PostgreSQL 行锁或 Redis INCR,保证多实例下原子且持久。
type MemoryStore struct {
mu sync.Mutex
cursors map[string]uint64 // segmentKey -> 已分配的最大序列
}
// NewMemoryStore 创建内存分配存储。
func NewMemoryStore() *MemoryStore {
return &MemoryStore{cursors: make(map[string]uint64)}
}
// Next 原子返回下一个序列:首次取 start,之后递增;超过 end 返回耗尽错误。
func (s *MemoryStore) Next(segmentKey string, start, end uint64) (uint64, error) {
s.mu.Lock()
defer s.mu.Unlock()
cur, ok := s.cursors[segmentKey]
var next uint64
if !ok {
next = start
} else {
next = cur + 1
}
if next > end {
return 0, ErrSegmentExhausted
}
s.cursors[segmentKey] = next
return next, nil
}
var _ AllocationStore = (*MemoryStore)(nil)
@@ -0,0 +1,44 @@
package macode
import (
"database/sql"
"fmt"
)
// PostgresStore 是 AllocationStore 的 PostgreSQL 实现。
// 通过行级原子 UPSERT + 返回值保证多实例下序列分配原子、持久、不重号。
// 解决 MemoryStore 重启丢号、多实例重号的问题(生产用)。
type PostgresStore struct {
db *sql.DB
}
// NewPostgresStore 创建基于 *sql.DB 的分配存储。
func NewPostgresStore(db *sql.DB) *PostgresStore {
return &PostgresStore{db: db}
}
// Next 原子获取下一个序列。
// 使用 INSERT ... ON CONFLICT DO UPDATE 的原子自增语义:
// - 首次:cursor = start
// - 之后:cursor = cursor + 1
//
// 单条 SQL 在行锁内完成读改写,并发安全;超过 end 返回耗尽错误。
func (s *PostgresStore) Next(segmentKey string, start, end uint64) (uint64, error) {
const q = `
INSERT INTO macode_cursor (segment_key, cursor)
VALUES ($1, $2)
ON CONFLICT (segment_key)
DO UPDATE SET cursor = macode_cursor.cursor + 1, updated_at = NOW()
RETURNING cursor;`
var next uint64
if err := s.db.QueryRow(q, segmentKey, start).Scan(&next); err != nil {
return 0, fmt.Errorf("macode: pg next: %w", err)
}
if next > end {
return 0, ErrSegmentExhausted
}
return next, nil
}
var _ AllocationStore = (*PostgresStore)(nil)
@@ -0,0 +1,129 @@
package macode
import (
"database/sql"
"fmt"
"os"
"sync"
"testing"
_ "github.com/lib/pq"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// 集成测试:需本地 PostgreSQL。通过 TCS_TEST_PG_DSN 提供连接串;未提供则跳过。
func openTestDB(t *testing.T) *sql.DB {
t.Helper()
dsn := os.Getenv("TCS_TEST_PG_DSN")
if dsn == "" {
dsn = "postgres://postgres@localhost:5432/tcs_iptv?sslmode=disable"
}
db, err := sql.Open("postgres", dsn)
if err != nil {
t.Skipf("跳过 PG 集成测试:%v", err)
}
if err := db.Ping(); err != nil {
t.Skipf("跳过 PG 集成测试(无法连接):%v", err)
}
return db
}
func cleanupKey(t *testing.T, db *sql.DB, key string) {
t.Helper()
_, _ = db.Exec("DELETE FROM macode_cursor WHERE segment_key = $1", key)
}
func TestPostgresStore_Sequential(t *testing.T) {
db := openTestDB(t)
defer db.Close()
store := NewPostgresStore(db)
key := "test:seq:WD"
cleanupKey(t, db, key)
defer cleanupKey(t, db, key)
for want := uint64(1); want <= 5; want++ {
got, err := store.Next(key, 1, 100)
require.NoError(t, err)
assert.Equal(t, want, got)
}
}
func TestPostgresStore_Exhausted(t *testing.T) {
db := openTestDB(t)
defer db.Close()
store := NewPostgresStore(db)
key := "test:exhaust:DY"
cleanupKey(t, db, key)
defer cleanupKey(t, db, key)
_, err := store.Next(key, 1, 2)
require.NoError(t, err)
_, err = store.Next(key, 1, 2)
require.NoError(t, err)
_, err = store.Next(key, 1, 2)
assert.ErrorIs(t, err, ErrSegmentExhausted)
}
func TestPostgresStore_ConcurrentNoDuplicate(t *testing.T) {
db := openTestDB(t)
defer db.Close()
store := NewPostgresStore(db)
key := "test:concurrent:WJ"
cleanupKey(t, db, key)
defer cleanupKey(t, db, key)
const n = 200
var wg sync.WaitGroup
var mu sync.Mutex
seen := make(map[uint64]bool)
dup := 0
errs := 0
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
v, err := store.Next(key, 1, 1000)
if err != nil {
mu.Lock()
errs++
mu.Unlock()
return
}
mu.Lock()
if seen[v] {
dup++
}
seen[v] = true
mu.Unlock()
}()
}
wg.Wait()
assert.Equal(t, 0, errs, "并发分配不应报错")
assert.Equal(t, 0, dup, "并发分配不得重号")
assert.Len(t, seen, n)
}
// TestPostgresStore_WithGenerator 验证 PG 存储与生成器联动产出唯一 MA 码。
func TestPostgresStore_WithGenerator(t *testing.T) {
db := openTestDB(t)
defer db.Close()
key := fmt.Sprintf("%s:%s:%s", "8531", "4401", CategoryAnimation)
cleanupKey(t, db, key)
defer cleanupKey(t, db, key)
g := NewGenerator(NewPostgresStore(db))
require.NoError(t, g.RegisterSegment(Segment{
IndustryNode: "8531", OrgNode: "4401",
Category: CategoryAnimation, Start: 1, End: 1000, SeqWidth: 7,
}))
seen := map[string]bool{}
for i := 0; i < 10; i++ {
issued, err := g.Allocate(CategoryAnimation)
require.NoError(t, err)
assert.False(t, seen[issued.MACode])
assert.True(t, IsValid(issued.MACode))
seen[issued.MACode] = true
}
}