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
+19
View File
@@ -0,0 +1,19 @@
# Go
/bin/
*.test
*.out
vendor/
# Env
.env
*.local
# Node / web-console
web-console/node_modules/
web-console/dist/
web-console/build/
# IDE
.idea/
.vscode/
.DS_Store
+39
View File
@@ -0,0 +1,39 @@
.PHONY: build test tidy run-api run-chain run-hash migrate db-check redis-check fmt vet
build:
go build ./...
test:
go test ./... -count=1
tidy:
go mod tidy
fmt:
gofmt -w .
vet:
go vet ./...
run-api:
go run ./cmd/api-svc
run-chain:
go run ./cmd/chain-svc
run-hash:
go run ./cmd/hash-api
# 本地依赖(直接使用本机已安装的 PostgreSQL / Redis,无需 Docker
# psql 已加入 PATHPostgres.app v16
PG ?= psql
PG_DSN ?= postgres://postgres@localhost:5432/tcs_iptv?sslmode=disable
migrate:
@for f in deploy/migrations/*.sql; do echo "applying $$f"; $(PG) "$(PG_DSN)" -f $$f; done
db-check:
$(PG) "$(PG_DSN)" -c "\dt"
redis-check:
redis-cli ping
+79
View File
@@ -0,0 +1,79 @@
# TCS-IPTV 内容可信锁定系统
> MA码(监管身份)+ 哈希码(技术指纹)双锚定,在 CP / 审核和监管部门 / 运营商 三方系统之上建立"可信身份映射层"。
>
> 上游文档:`../0-req-IPTV.md`(需求)、`../1-prd-IPTV.md`PRD)、`../2-task-IPTV.md`(任务)
## 技术栈
- 后端 / 链交互 / 哈希SDKGo 1.23 + Gin
- 智能合约:GoChainMaker 链原生)
- 联盟链:长安链 ChainMaker 2.x(国密)
- 数据库 / 缓存:PostgreSQL 16 / Redis 7.x
- 监管大屏:React 18 + Ant Design 5 + ECharts
## 工程结构
```
tcs-iptv/
├── cmd/ # 各服务入口
│ ├── api-svc/ # 业务后端(验真/签发/映射/下架/查询)
│ ├── chain-svc/ # 链交互服务(封装 ChainMaker SDK
│ └── hash-api/ # 哈希SDK 的 HTTP API
├── internal/ # 内部包
│ ├── hash/ # 哈希核心(SHA-256 / Merkle / 感知哈希)
│ ├── chain/ # 链客户端抽象(MVP 含 mock 实现)
│ ├── config/ # 配置加载
│ ├── httpx/ # 通用 HTTP / 鉴权中间件
│ └── model/ # 领域模型
├── contracts/ # ChainMaker Go 合约源码
│ └── tcs_registry/
├── deploy/ # 部署
│ ├── docker-compose.yml
│ └── migrations/ # 数据库迁移
├── web-console/ # 监管大屏(React
├── Makefile
└── go.mod
```
## 快速开始
> 本地直接使用已安装的 PostgreSQL / Redis,无需 Docker。
```bash
# 初始化数据库(数据库 tcs_iptv 需已创建)
make migrate
# 检查依赖
make db-check # 列出已建表
make redis-check # 应返回 PONG
# 构建全部服务
make build
# 运行测试
make test
# 启动哈希 API(示例)
make run-hash
```
环境变量(可选,缺省适配本地):
| 变量 | 默认值 |
|------|--------|
| TCS_POSTGRES_DSN | postgres://postgres@localhost:5432/tcs_iptv?sslmode=disable |
| TCS_REDIS_ADDR | localhost:6379 |
| TCS_API_ADDR | :8080 |
| TCS_CHAIN_ADDR | :8081 |
| TCS_HASH_ADDR | :8082 |
## 服务端口(默认)
| 服务 | 端口 |
|------|------|
| api-svc | 8080 |
| chain-svc | 8081 |
| hash-api | 8082 |
| PostgreSQL | 5432 |
| Redis | 6379 |
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"database/sql"
"log"
_ "github.com/lib/pq"
"github.com/gin-gonic/gin"
"github.com/tcs-iptv/tcs/internal/api"
"github.com/tcs-iptv/tcs/internal/chain"
"github.com/tcs-iptv/tcs/internal/config"
"github.com/tcs-iptv/tcs/internal/httpx"
"github.com/tcs-iptv/tcs/internal/macode"
"github.com/tcs-iptv/tcs/internal/service"
)
// newAllocationStore 优先使用 PostgreSQL(持久、防重号),不可用时回退内存。
func newAllocationStore(dsn string) macode.AllocationStore {
db, err := sql.Open("postgres", dsn)
if err == nil {
if pingErr := db.Ping(); pingErr == nil {
log.Printf("macode: 使用 PostgreSQL 号段存储")
return macode.NewPostgresStore(db)
}
}
log.Printf("macode: PostgreSQL 不可用,回退内存号段存储(仅开发用)")
return macode.NewMemoryStore()
}
func main() {
cfg := config.Load()
// 装配依赖:链(MVP 用内存 mock)+ MA 码生成器(登记号段)+ 业务服务
ch := chain.NewMemoryChain()
gen := macode.NewGenerator(newAllocationStore(cfg.PostgresDSN))
// 示例号段(生产由与发码机构对接后配置)
// 机构节点 6101 = 陕西(管理方:陕西IPTV运营公司);行业节点 8531 = IPTV视听内容
_ = gen.RegisterSegment(macode.Segment{
IndustryNode: "8531", OrgNode: "6101",
Category: macode.CategoryMicroDrama, Start: 1, End: 9999999, SeqWidth: 7,
})
_ = gen.RegisterSegment(macode.Segment{
IndustryNode: "8531", OrgNode: "6101",
Category: macode.CategoryWebSeries, Start: 1, End: 9999999, SeqWidth: 7,
})
_ = gen.RegisterSegment(macode.Segment{
IndustryNode: "8531", OrgNode: "6101",
Category: macode.CategoryWebMovie, Start: 1, End: 9999999, SeqWidth: 7,
})
svc := service.New(ch, gen)
h := api.NewHandler(svc)
// 鉴权密钥库(MVP 预置四角色示例密钥;生产从 Vault/DB 加载)
keys := httpx.NewMemoryKeyStore()
keys.Add("ak-regulator", "sk-regulator", string(chain.RoleRegulator))
keys.Add("ak-reviewer", "sk-reviewer", string(chain.RoleReviewer))
keys.Add("ak-cp", "sk-cp", string(chain.RoleCP))
keys.Add("ak-operator", "sk-operator", string(chain.RoleOperator))
r := gin.Default()
httpx.Health(r, "api-svc")
v1 := r.Group("/api/v1", httpx.AuthMiddleware(keys))
h.Register(v1)
log.Printf("api-svc listening on %s", cfg.APIAddr)
if err := r.Run(cfg.APIAddr); err != nil {
log.Fatalf("api-svc failed: %v", err)
}
}
+20
View File
@@ -0,0 +1,20 @@
package main
import (
"log"
"github.com/gin-gonic/gin"
"github.com/tcs-iptv/tcs/internal/config"
"github.com/tcs-iptv/tcs/internal/httpx"
)
func main() {
cfg := config.Load()
r := gin.Default()
httpx.Health(r, "chain-svc")
log.Printf("chain-svc listening on %s", cfg.ChainAddr)
if err := r.Run(cfg.ChainAddr); err != nil {
log.Fatalf("chain-svc failed: %v", err)
}
}
+20
View File
@@ -0,0 +1,20 @@
package main
import (
"log"
"github.com/gin-gonic/gin"
"github.com/tcs-iptv/tcs/internal/config"
"github.com/tcs-iptv/tcs/internal/httpx"
)
func main() {
cfg := config.Load()
r := gin.Default()
httpx.Health(r, "hash-api")
log.Printf("hash-api listening on %s", cfg.HashAddr)
if err := r.Run(cfg.HashAddr); err != nil {
log.Fatalf("hash-api failed: %v", err)
}
}
+53
View File
@@ -0,0 +1,53 @@
# tcs_registry — ChainMaker 智能合约(Go
可信数据空间的链上合约,实现 TCS-IPTV 的四类核心数据结构与合约方法。
`internal/chain``Client` 接口语义一一对应:MVP 用 `MemoryChain` 开发测试,
真实部署时由 chain-svc 通过 ChainMaker Go SDK 调用本合约。
## 合约方法(对应需求16-AC2)
| 方法 | 权限 | 说明 |
|------|------|------|
| `IssueMA(maCode, ctid, merkleRoot, fileHash, contentJSON)` | 仅监管节点 | 签发 MA 码并 1:1 强绑定哈希;不可重复、不可解绑 |
| `RegisterHashBinding(ctid, bindingJSON)` | 审核/监管 | 追加哈希绑定(转码版父子关系) |
| `RegisterMapping(ctid, party, partyID, cdnEndpoint)` | 三方 | 注册编码映射;MA 必须已签发 |
| `VerifyHash(maCode, fileHash) -> bool` | 任意 | 校验提交哈希与绑定哈希是否一致 |
| `QueryContent(maCode) -> json` | 任意 | 查询内容主记录 |
| `QueryMappings(maCode) -> json` | 任意 | 查询全部三方映射与 CDN 端点 |
| `RecordVersionChange(ctid, vcJSON)` | 审核/监管 | 记录版本变更,触发重审 |
| `Revoke(maCode, reason)` | 仅监管节点 | 下架,返回受影响映射 |
## 权限模型(对应需求14
合约内通过 `sender()` 的组织/角色证书判断调用方身份:
- `RoleRegulator`(监管主体):`IssueMA` / `Revoke` 唯一发起方
- `RoleReviewer`(审核主体/CSPS/媒资库):哈希绑定、版本变更
- `RoleCP`:送审时注册哈希、本方映射
- `RoleOperator`:注册本方映射、验真
## 国密
底层链使用长安链 ChainMaker(国密 SM2 签名 / SM3 哈希)。
内容哈希在链外用 SHA-256 计算(哈希SDK),链上仅存哈希值与映射,明文不入链(需求20-AC2)。
## 状态键设计(KV
```
content:{maCode} -> Content JSON
binding:{maCode}:{idx} -> HashBinding JSON
hashidx:{fileHash} -> maCode (防换壳重发)
mapping:{maCode}:{idx} -> Mapping JSON
version:{maCode}:{idx} -> VersionChange JSON
ctid2ma:{ctid} -> maCode
```
## 构建与部署(真实链,二期接入)
```bash
# 依赖 ChainMaker contract SDK
go mod init tcs_registry
# 编译为 wasm 或 docker-go 合约,按 ChainMaker 部署流程发布
```
> MVP 阶段:业务逻辑与规则已在 `internal/chain.MemoryChain` 完整实现并测试通过,
> 本合约为真实链落地的等价实现规格,二期搭建 ChainMaker 测试网后落地替换。
+85
View File
@@ -0,0 +1,85 @@
-- TCS-IPTV 初始化迁移
-- 说明:链上为权威数据源;PostgreSQL 存业务元数据与链上数据镜像,用于高效查询。
-- 对应需求:需求16(数据结构)、需求3/6/7(映射)、需求12(版本变更)
-- 注:CTID 概念列统一命名为 content_twin_id,避开 PostgreSQL 系统保留列名 ctid。
BEGIN;
-- 内容主表(Content Registry 镜像)
CREATE TABLE IF NOT EXISTS content_registry (
content_twin_id VARCHAR(64) PRIMARY KEY,
ma_code VARCHAR(128) NOT NULL UNIQUE,
ma_type VARCHAR(64),
title VARCHAR(256) NOT NULL,
episode_count INT DEFAULT 1,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
issuer VARCHAR(128),
issue_date DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_content_status ON content_registry(status);
-- 哈希绑定表(Hash Binding 镜像)
CREATE TABLE IF NOT EXISTS hash_binding (
id BIGSERIAL PRIMARY KEY,
content_twin_id VARCHAR(64) NOT NULL REFERENCES content_registry(content_twin_id),
hash_type VARCHAR(32) NOT NULL, -- file_sha256 / perceptual / transcoded
hash_value VARCHAR(128) NOT NULL,
merkle_root VARCHAR(128),
file_format VARCHAR(64),
resolution VARCHAR(32),
duration INT,
version VARCHAR(32) NOT NULL DEFAULT 'v1.0',
parent_hash VARCHAR(128), -- 转码版指向母版哈希(父子关系)
created_by VARCHAR(128),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_hash_ctid ON hash_binding(content_twin_id);
CREATE INDEX IF NOT EXISTS idx_hash_value ON hash_binding(hash_value);
-- 三方编码映射表(Identity Mapping 镜像)
CREATE TABLE IF NOT EXISTS identity_mapping (
id BIGSERIAL PRIMARY KEY,
content_twin_id VARCHAR(64) NOT NULL REFERENCES content_registry(content_twin_id),
party VARCHAR(32) NOT NULL, -- cp / reviewer / operator
party_id VARCHAR(128) NOT NULL,
party_name VARCHAR(128),
cdn_endpoint VARCHAR(256),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (content_twin_id, party, party_id)
);
CREATE INDEX IF NOT EXISTS idx_mapping_ctid ON identity_mapping(content_twin_id);
CREATE INDEX IF NOT EXISTS idx_mapping_party ON identity_mapping(party, party_id);
-- 版本变更表(Version History 镜像)
CREATE TABLE IF NOT EXISTS version_history (
id BIGSERIAL PRIMARY KEY,
content_twin_id VARCHAR(64) NOT NULL REFERENCES content_registry(content_twin_id),
version VARCHAR(32) NOT NULL,
change_reason TEXT,
prev_hash VARCHAR(128),
new_hash VARCHAR(128),
reaudit_required BOOLEAN NOT NULL DEFAULT TRUE,
reaudit_status VARCHAR(32) DEFAULT 'pending',
affected_episode INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_version_ctid ON version_history(content_twin_id);
-- 链交易记录(异步上链确认)
CREATE TABLE IF NOT EXISTS chain_tx (
id BIGSERIAL PRIMARY KEY,
content_twin_id VARCHAR(64),
tx_id VARCHAR(128) NOT NULL UNIQUE,
method VARCHAR(64) NOT NULL, -- issueMA / registerMapping / ...
status VARCHAR(16) NOT NULL DEFAULT 'pending', -- pending / confirmed / failed
block_height BIGINT,
payload_hash VARCHAR(128),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
confirmed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_tx_ctid ON chain_tx(content_twin_id);
CREATE INDEX IF NOT EXISTS idx_tx_status ON chain_tx(status);
COMMIT;
@@ -0,0 +1,14 @@
-- MA 码号段游标表(模式B 自行发码的原子分配)
-- 保证多实例/重启下序列不重号、不丢号。对应需求3-AC3/AC4。
BEGIN;
CREATE TABLE IF NOT EXISTS macode_cursor (
segment_key VARCHAR(128) PRIMARY KEY, -- {industryNode}:{orgNode}:{category}
cursor BIGINT NOT NULL, -- 已分配的最大序列
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE macode_cursor IS 'MA码号段分配游标,行级原子自增';
COMMIT;
@@ -0,0 +1,13 @@
-- 集级哈希粒度:一剧一 MA 码,每集独立哈希绑定(episode > 0)。
-- 对应需求3(集级粒度补齐)、需求12(按集定位)。
BEGIN;
ALTER TABLE hash_binding
ADD COLUMN IF NOT EXISTS episode INT NOT NULL DEFAULT 0; -- 0=整剧/单体,>0=具体集
CREATE INDEX IF NOT EXISTS idx_hash_episode ON hash_binding(content_twin_id, episode);
COMMENT ON COLUMN hash_binding.episode IS '集号:0表示整剧/单体内容,>0表示具体集';
COMMIT;
+40
View File
@@ -0,0 +1,40 @@
module github.com/tcs-iptv/tcs
go 1.23
require (
github.com/gin-gonic/gin v1.10.0
github.com/lib/pq v1.12.3
github.com/stretchr/testify v1.9.0
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+91
View File
@@ -0,0 +1,91 @@
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+388
View File
@@ -0,0 +1,388 @@
// Package api 暴露 TCS-IPTV 的 HTTP 接口,串联 service 业务编排。
// 对应需求17(接口规范)与各业务需求。
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/tcs-iptv/tcs/internal/chain"
"github.com/tcs-iptv/tcs/internal/httpx"
"github.com/tcs-iptv/tcs/internal/model"
"github.com/tcs-iptv/tcs/internal/service"
)
// Handler 持有业务服务。
type Handler struct {
svc *service.Service
}
// NewHandler 创建 API 处理器。
func NewHandler(svc *service.Service) *Handler {
return &Handler{svc: svc}
}
// Register 注册路由(受鉴权中间件保护的路由组由调用方组装)。
func (h *Handler) Register(rg *gin.RouterGroup) {
rg.POST("/content/register", h.register) // CP 送审上链(需求2
rg.POST("/content/csps-result", h.cspsResult) // CSPS 合规审核(发码前,需求5)
rg.POST("/content/issue", h.issue) // 审核通过后发码签发(需求3
rg.POST("/content/verify", h.verify) // 哈希验真(需求4/7
rg.POST("/content/transcoded", h.bindTranscoded) // 转码版绑定(需求5
rg.POST("/content/ingest", h.ingest) // 媒资库入库(需求6
rg.POST("/content/publish", h.publish) // 发布给运营商(需求6
rg.POST("/content/inject", h.inject) // CDN 注入校验(需求7
rg.POST("/content/version-change", h.versionChange) // 版本变更重审(需求12
rg.POST("/content/takedown", h.takedown) // 应急下架(需求11
rg.POST("/content/takedown-episode", h.takedownEpisode) // 集级下架(只下架某集)
rg.POST("/content/restore", h.restore) // 恢复上架整剧
rg.POST("/content/restore-episode", h.restoreEpisode) // 恢复上架某集
rg.GET("/content/mappings", h.mappings) // 映射查询(需求11/17
rg.POST("/content/verify-episode", h.verifyEpisode) // 集级验真(一剧多集)
rg.GET("/content/episodes", h.listEpisodes) // 列出集级哈希
rg.GET("/content/reviews", h.listReviews) // 送审待办队列(待审/待发码)
rg.GET("/content/list", h.listContents) // 内容队列(待入库/待发布/待注入)
}
func roleOf(c *gin.Context) chain.Role {
return chain.Role(httpx.RoleFromContext(c))
}
// --- handlers ---
type episodeHashReq struct {
Episode int `json:"episode"`
FileSHA256 string `json:"file_sha256"`
MerkleRoot string `json:"merkle_root"`
Perceptual string `json:"perceptual_hash"`
Duration int `json:"duration"`
Resolution string `json:"resolution"`
}
type registerReq struct {
Title string `json:"title"`
EpisodeCount int `json:"episode_count"`
Category string `json:"category"`
FileHash string `json:"file_sha256"`
MerkleRoot string `json:"merkle_root"`
Perceptual string `json:"perceptual_hash"`
Episodes []episodeHashReq `json:"episodes"`
CPMediaID string `json:"cp_media_id"`
CPName string `json:"cp_name"`
}
func (h *Handler) register(c *gin.Context) {
var req registerReq
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
return
}
eps := make([]model.EpisodeHash, 0, len(req.Episodes))
for _, e := range req.Episodes {
eps = append(eps, model.EpisodeHash{
Episode: e.Episode, FileSHA256: e.FileSHA256, MerkleRoot: e.MerkleRoot,
Perceptual: e.Perceptual, Duration: e.Duration, Resolution: e.Resolution,
})
}
res, err := h.svc.SubmitForReview(service.Submission{
Title: req.Title, EpisodeCount: req.EpisodeCount, Category: req.Category,
FileHash: req.FileHash, MerkleRoot: req.MerkleRoot, Perceptual: req.Perceptual,
Episodes: eps,
CPMediaID: req.CPMediaID, CPName: req.CPName,
})
if err != nil {
httpx.Error(c, http.StatusBadRequest, "REGISTER_FAILED", err.Error())
return
}
httpx.Accepted(c, res)
}
type issueReq struct {
ReviewID string `json:"review_id"`
Issuer string `json:"issuer"`
}
func (h *Handler) issue(c *gin.Context) {
var req issueReq
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
return
}
res, err := h.svc.ApproveAndIssue(roleOf(c), req.ReviewID, req.Issuer)
if err != nil {
httpx.Error(c, http.StatusForbidden, "ISSUE_FAILED", err.Error())
return
}
httpx.OK(c, res)
}
type verifyReq struct {
MACode string `json:"ma_code"`
FileHash string `json:"file_sha256"`
}
func (h *Handler) verify(c *gin.Context) {
var req verifyReq
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
return
}
res, err := h.svc.Verify(req.MACode, req.FileHash)
if err != nil {
// 验真不匹配也返回结果体,便于调用方据 match 处理
httpx.Error(c, http.StatusBadRequest, "VERIFY_MISMATCH", err.Error())
return
}
httpx.OK(c, res)
}
type cspsReq struct {
ReviewID string `json:"review_id"`
Approved bool `json:"approved"`
ReviewerID string `json:"reviewer_id"`
}
func (h *Handler) cspsResult(c *gin.Context) {
var req cspsReq
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
return
}
if err := h.svc.ReviewCSPS(req.ReviewID, req.Approved, req.ReviewerID); err != nil {
httpx.Error(c, http.StatusBadRequest, "CSPS_FAILED", err.Error())
return
}
httpx.OK(c, gin.H{"review_id": req.ReviewID, "approved": req.Approved})
}
type transcodedReq struct {
CTID string `json:"content_twin_id"`
ParentFileHash string `json:"parent_file_hash"`
TranscodedHash string `json:"transcoded_hash"`
Format string `json:"format"`
Resolution string `json:"resolution"`
Version string `json:"version"`
}
func (h *Handler) bindTranscoded(c *gin.Context) {
var req transcodedReq
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
return
}
tx, err := h.svc.BindTranscoded(roleOf(c), req.CTID, req.ParentFileHash,
req.TranscodedHash, req.Format, req.Resolution, req.Version)
if err != nil {
httpx.Error(c, http.StatusBadRequest, "TRANSCODE_BIND_FAILED", err.Error())
return
}
httpx.OK(c, gin.H{"tx_id": tx})
}
type ingestReq struct {
MACode string `json:"ma_code"`
CTID string `json:"content_twin_id"`
MediaAssetID string `json:"media_asset_id"`
LibName string `json:"lib_name"`
}
func (h *Handler) ingest(c *gin.Context) {
var req ingestReq
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
return
}
if err := h.svc.IngestToLibrary(roleOf(c), req.MACode, req.CTID, req.MediaAssetID, req.LibName); err != nil {
httpx.Error(c, http.StatusBadRequest, "INGEST_FAILED", err.Error())
return
}
httpx.OK(c, gin.H{"ma_code": req.MACode, "status": "in_library"})
}
type publishReq struct {
MACode string `json:"ma_code"`
Certificate string `json:"certificate"`
}
func (h *Handler) publish(c *gin.Context) {
var req publishReq
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
return
}
if err := h.svc.PublishToOperator(service.PublishRequest{MACode: req.MACode, Certificate: req.Certificate}); err != nil {
httpx.Error(c, http.StatusBadRequest, "PUBLISH_FAILED", err.Error())
return
}
httpx.OK(c, gin.H{"ma_code": req.MACode, "status": "published"})
}
type injectReq struct {
CTID string `json:"content_twin_id"`
MACode string `json:"ma_code"`
FileHash string `json:"file_sha256"`
OperatorID string `json:"operator_id"`
CDNEndpoint string `json:"cdn_endpoint"`
}
func (h *Handler) inject(c *gin.Context) {
var req injectReq
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
return
}
res, err := h.svc.InjectToCDN(roleOf(c), req.CTID, req.MACode, req.FileHash, req.OperatorID, req.CDNEndpoint)
if err != nil {
httpx.Error(c, http.StatusBadRequest, "INJECT_REJECTED", err.Error())
return
}
httpx.OK(c, res)
}
type versionChangeReq struct {
CTID string `json:"content_twin_id"`
Reason string `json:"reason"`
PrevHash string `json:"prev_hash"`
NewHash string `json:"new_hash"`
OldSegments []string `json:"old_segments"`
NewSegments []string `json:"new_segments"`
}
func (h *Handler) versionChange(c *gin.Context) {
var req versionChangeReq
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
return
}
episodes, err := h.svc.ReportVersionChange(req.CTID, req.Reason, req.PrevHash, req.NewHash, req.OldSegments, req.NewSegments)
if err != nil {
httpx.Error(c, http.StatusBadRequest, "VERSION_CHANGE_FAILED", err.Error())
return
}
httpx.OK(c, gin.H{"reaudit_required": true, "affected_episodes": episodes})
}
type takedownReq struct {
MACode string `json:"ma_code"`
Reason string `json:"reason"`
}
func (h *Handler) takedown(c *gin.Context) {
var req takedownReq
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
return
}
res, err := h.svc.Takedown(roleOf(c), req.MACode, req.Reason)
if err != nil {
httpx.Error(c, http.StatusForbidden, "TAKEDOWN_FAILED", err.Error())
return
}
httpx.OK(c, res)
}
type takedownEpisodeReq struct {
MACode string `json:"ma_code"`
Episode int `json:"episode"`
Reason string `json:"reason"`
}
func (h *Handler) takedownEpisode(c *gin.Context) {
var req takedownEpisodeReq
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
return
}
if err := h.svc.TakedownEpisode(roleOf(c), req.MACode, req.Episode, req.Reason); err != nil {
httpx.Error(c, http.StatusForbidden, "TAKEDOWN_EPISODE_FAILED", err.Error())
return
}
httpx.OK(c, gin.H{"ma_code": req.MACode, "episode": req.Episode, "revoked": true})
}
func (h *Handler) restore(c *gin.Context) {
var req takedownReq
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
return
}
if err := h.svc.Restore(roleOf(c), req.MACode); err != nil {
httpx.Error(c, http.StatusForbidden, "RESTORE_FAILED", err.Error())
return
}
httpx.OK(c, gin.H{"ma_code": req.MACode, "status": "published"})
}
func (h *Handler) restoreEpisode(c *gin.Context) {
var req takedownEpisodeReq
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
return
}
if err := h.svc.RestoreEpisode(roleOf(c), req.MACode, req.Episode); err != nil {
httpx.Error(c, http.StatusForbidden, "RESTORE_EPISODE_FAILED", err.Error())
return
}
httpx.OK(c, gin.H{"ma_code": req.MACode, "episode": req.Episode, "revoked": false})
}
func (h *Handler) mappings(c *gin.Context) {
maCode := c.Query("ma_code")
if maCode == "" {
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "缺少 ma_code")
return
}
res, err := h.svc.QueryMappings(maCode)
if err != nil {
httpx.Error(c, http.StatusNotFound, "NOT_FOUND", err.Error())
return
}
httpx.OK(c, res)
}
type verifyEpisodeReq struct {
MACode string `json:"ma_code"`
Episode int `json:"episode"`
FileHash string `json:"file_sha256"`
}
func (h *Handler) verifyEpisode(c *gin.Context) {
var req verifyEpisodeReq
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
return
}
res, err := h.svc.VerifyEpisode(req.MACode, req.Episode, req.FileHash)
if err != nil {
httpx.Error(c, http.StatusBadRequest, "VERIFY_MISMATCH", err.Error())
return
}
httpx.OK(c, res)
}
func (h *Handler) listEpisodes(c *gin.Context) {
maCode := c.Query("ma_code")
if maCode == "" {
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "缺少 ma_code")
return
}
eps, err := h.svc.ListEpisodes(maCode)
if err != nil {
httpx.Error(c, http.StatusNotFound, "NOT_FOUND", err.Error())
return
}
httpx.OK(c, gin.H{"ma_code": maCode, "episodes": eps, "count": len(eps)})
}
func (h *Handler) listReviews(c *gin.Context) {
httpx.OK(c, gin.H{"reviews": h.svc.ListReviews(c.Query("status"))})
}
func (h *Handler) listContents(c *gin.Context) {
list, err := h.svc.ListContentsByStatus(c.Query("status"))
if err != nil {
httpx.Error(c, http.StatusInternalServerError, "INTERNAL_ERROR", err.Error())
return
}
httpx.OK(c, gin.H{"contents": list, "count": len(list)})
}
+271
View File
@@ -0,0 +1,271 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tcs-iptv/tcs/internal/chain"
"github.com/tcs-iptv/tcs/internal/hash"
"github.com/tcs-iptv/tcs/internal/httpx"
"github.com/tcs-iptv/tcs/internal/macode"
"github.com/tcs-iptv/tcs/internal/service"
)
// testServer 组装完整 API 栈(鉴权 + 路由 + service + 内存链/号段)。
func testServer(t *testing.T) (*httptest.Server, *httpx.MemoryKeyStore) {
t.Helper()
gin.SetMode(gin.TestMode)
ch := chain.NewMemoryChain()
gen := macode.NewGenerator(macode.NewMemoryStore())
require.NoError(t, gen.RegisterSegment(macode.Segment{
IndustryNode: "8531", OrgNode: "4401",
Category: macode.CategoryMicroDrama, Start: 1, End: 9999999, SeqWidth: 7,
}))
svc := service.New(ch, gen)
h := NewHandler(svc)
keys := httpx.NewMemoryKeyStore()
keys.Add("ak-regulator", "sk-regulator", string(chain.RoleRegulator))
keys.Add("ak-reviewer", "sk-reviewer", string(chain.RoleReviewer))
keys.Add("ak-cp", "sk-cp", string(chain.RoleCP))
keys.Add("ak-operator", "sk-operator", string(chain.RoleOperator))
r := gin.New()
v1 := r.Group("/api/v1", httpx.AuthMiddleware(keys))
h.Register(v1)
return httptest.NewServer(r), keys
}
// signedCall 发起带 HMAC 签名的请求,返回状态码与解析后的响应。
func signedCall(t *testing.T, base, apiKey, secret, method, path string, body any) (int, map[string]any) {
t.Helper()
var buf []byte
if body != nil {
buf, _ = json.Marshal(body)
}
signPath := "/api/v1" + path
if i := indexByte(path, '?'); i >= 0 {
signPath = "/api/v1" + path[:i]
}
sig := httpx.Sign(secret, method, signPath)
req, err := http.NewRequest(method, base+"/api/v1"+path, bytes.NewReader(buf))
require.NoError(t, err)
req.Header.Set("Authorization", "TCS "+apiKey+":"+sig)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
var out map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
return resp.StatusCode, out
}
func indexByte(s string, b byte) int {
for i := 0; i < len(s); i++ {
if s[i] == b {
return i
}
}
return -1
}
func dataOf(m map[string]any) map[string]any {
if d, ok := m["data"].(map[string]any); ok {
return d
}
return map[string]any{}
}
// TestE2E_FullLifecycle 覆盖 MVP 全闭环:送审→发码→审核→入库→发布→注入→下架。
func TestE2E_FullLifecycle(t *testing.T) {
srv, _ := testServer(t)
defer srv.Close()
b := srv.URL
// 1) CP 送审
st, resp := signedCall(t, b, "ak-cp", "sk-cp", "POST", "/content/register", map[string]any{
"title": "示例微短剧", "episode_count": 24, "category": "WD",
"file_sha256": "fh-e2e", "merkle_root": "mr-e2e", "perceptual_hash": "ph-e2e",
"cp_media_id": "FS-77821", "cp_name": "飞翮信息",
})
require.Equal(t, http.StatusAccepted, st)
reviewID := dataOf(resp)["review_id"].(string)
ctid := dataOf(resp)["content_twin_id"].(string)
require.NotEmpty(t, reviewID)
// 2) CSPS 合规审核(发码前)
st, _ = signedCall(t, b, "ak-reviewer", "sk-reviewer", "POST", "/content/csps-result", map[string]any{
"review_id": reviewID, "approved": true, "reviewer_id": "rv-1",
})
require.Equal(t, http.StatusOK, st)
// 3) 监管发码签发(审核通过后)
st, resp = signedCall(t, b, "ak-regulator", "sk-regulator", "POST", "/content/issue", map[string]any{
"review_id": reviewID, "issuer": "北京市广播电视局",
})
require.Equal(t, http.StatusOK, st)
maCode := dataOf(resp)["ma_code"].(string)
cert := dataOf(resp)["certificate"].(string)
assert.True(t, macode.IsValid(maCode))
// 4) 入媒资库
st, _ = signedCall(t, b, "ak-reviewer", "sk-reviewer", "POST", "/content/ingest", map[string]any{
"ma_code": maCode, "content_twin_id": ctid, "media_asset_id": "MEDIA-001", "lib_name": "广东IPTV媒资库",
})
require.Equal(t, http.StatusOK, st)
// 5) 发布
st, _ = signedCall(t, b, "ak-reviewer", "sk-reviewer", "POST", "/content/publish", map[string]any{
"ma_code": maCode, "certificate": cert,
})
require.Equal(t, http.StatusOK, st)
// 6) CDN 注入(匹配)
st, resp = signedCall(t, b, "ak-operator", "sk-operator", "POST", "/content/inject", map[string]any{
"content_twin_id": ctid, "ma_code": maCode, "file_sha256": "fh-e2e",
"operator_id": "CT-IPTV-GD", "cdn_endpoint": "cdn://ct-gd/vod/1",
})
require.Equal(t, http.StatusOK, st)
assert.Equal(t, true, dataOf(resp)["allowed"])
// 7) 映射查询
st, resp = signedCall(t, b, "ak-regulator", "sk-regulator", "GET", "/content/mappings?ma_code="+maCode, nil)
require.Equal(t, http.StatusOK, st)
mappings := dataOf(resp)["mappings"].([]any)
assert.Len(t, mappings, 3) // cp + reviewer + operator
// 8) 监管下架
st, resp = signedCall(t, b, "ak-regulator", "sk-regulator", "POST", "/content/takedown", map[string]any{
"ma_code": maCode, "reason": "违规",
})
require.Equal(t, http.StatusOK, st)
assert.NotEmpty(t, dataOf(resp)["cdn_endpoints"])
}
// TestE2E_TamperRejected 版本篡改专项:注入篡改文件应被拒绝(需求7/15/18-AC4)。
func TestE2E_TamperRejected(t *testing.T) {
srv, _ := testServer(t)
defer srv.Close()
b := srv.URL
st, resp := signedCall(t, b, "ak-cp", "sk-cp", "POST", "/content/register", map[string]any{
"title": "X", "episode_count": 1, "category": "WD",
"file_sha256": "fh-orig", "merkle_root": "mr-orig",
})
require.Equal(t, http.StatusAccepted, st)
reviewID := dataOf(resp)["review_id"].(string)
ctid := dataOf(resp)["content_twin_id"].(string)
signedCall(t, b, "ak-reviewer", "sk-reviewer", "POST", "/content/csps-result", map[string]any{"review_id": reviewID, "approved": true})
_, resp = signedCall(t, b, "ak-regulator", "sk-regulator", "POST", "/content/issue", map[string]any{
"review_id": reviewID, "issuer": "x",
})
maCode := dataOf(resp)["ma_code"].(string)
cert := dataOf(resp)["certificate"].(string)
signedCall(t, b, "ak-reviewer", "sk-reviewer", "POST", "/content/ingest", map[string]any{"ma_code": maCode, "content_twin_id": ctid, "media_asset_id": "M", "lib_name": "L"})
signedCall(t, b, "ak-reviewer", "sk-reviewer", "POST", "/content/publish", map[string]any{"ma_code": maCode, "certificate": cert})
// 篡改文件注入 → 拒绝
st, _ = signedCall(t, b, "ak-operator", "sk-operator", "POST", "/content/inject", map[string]any{
"content_twin_id": ctid, "ma_code": maCode, "file_sha256": "fh-TAMPERED",
"operator_id": "OP", "cdn_endpoint": "cdn://x",
})
assert.Equal(t, http.StatusBadRequest, st, "篡改注入必须被拒")
}
// TestE2E_VersionChangeLocatesEpisode 版本变更定位被篡改集(需求12-AC3)。
func TestE2E_VersionChangeLocatesEpisode(t *testing.T) {
srv, _ := testServer(t)
defer srv.Close()
b := srv.URL
st, resp := signedCall(t, b, "ak-cp", "sk-cp", "POST", "/content/register", map[string]any{
"title": "多集剧", "episode_count": 3, "category": "WD",
"file_sha256": "fh-multi", "merkle_root": "mr-multi",
})
require.Equal(t, http.StatusAccepted, st)
reviewID := dataOf(resp)["review_id"].(string)
ctid := dataOf(resp)["content_twin_id"].(string)
signedCall(t, b, "ak-reviewer", "sk-reviewer", "POST", "/content/csps-result", map[string]any{"review_id": reviewID, "approved": true})
signedCall(t, b, "ak-regulator", "sk-regulator", "POST", "/content/issue", map[string]any{"review_id": reviewID, "issuer": "x"})
old := []string{hash.SHA256Hex([]byte("ep1")), hash.SHA256Hex([]byte("ep2")), hash.SHA256Hex([]byte("ep3"))}
neu := []string{hash.SHA256Hex([]byte("ep1")), hash.SHA256Hex([]byte("ep2-X")), hash.SHA256Hex([]byte("ep3"))}
st, resp = signedCall(t, b, "ak-reviewer", "sk-reviewer", "POST", "/content/version-change", map[string]any{
"content_twin_id": ctid, "reason": "第2集替换",
"prev_hash": "mr-multi", "new_hash": "mr-new",
"old_segments": old, "new_segments": neu,
})
require.Equal(t, http.StatusOK, st)
eps := dataOf(resp)["affected_episodes"].([]any)
require.Len(t, eps, 1)
assert.Equal(t, float64(2), eps[0], "应定位到第2集")
}
// TestE2E_PermissionMatrix 权限矩阵:越权操作被拒(需求14)。
func TestE2E_PermissionMatrix(t *testing.T) {
srv, _ := testServer(t)
defer srv.Close()
b := srv.URL
// 准备一条已签发内容
_, resp := signedCall(t, b, "ak-cp", "sk-cp", "POST", "/content/register", map[string]any{
"title": "P", "episode_count": 1, "category": "WD", "file_sha256": "fh-perm", "merkle_root": "mr-perm",
})
reviewID := dataOf(resp)["review_id"].(string)
// 先过 CSPS 审核(否则发码会因未审核被拒,无法测到角色权限)
signedCall(t, b, "ak-reviewer", "sk-reviewer", "POST", "/content/csps-result", map[string]any{"review_id": reviewID, "approved": true})
// CP 越权发码(签发仅监管主体)→ 403
st, _ := signedCall(t, b, "ak-cp", "sk-cp", "POST", "/content/issue", map[string]any{"review_id": reviewID, "issuer": "x"})
assert.Equal(t, http.StatusForbidden, st, "CP 不得发码")
// 正常签发
_, resp = signedCall(t, b, "ak-regulator", "sk-regulator", "POST", "/content/issue", map[string]any{"review_id": reviewID, "issuer": "x"})
maCode := dataOf(resp)["ma_code"].(string)
// 运营商越权下架 → 403
st, _ = signedCall(t, b, "ak-operator", "sk-operator", "POST", "/content/takedown", map[string]any{"ma_code": maCode, "reason": "越权"})
assert.Equal(t, http.StatusForbidden, st, "运营商不得发起下架")
// 无效签名 → 401
req, _ := http.NewRequest("GET", b+"/api/v1/content/mappings?ma_code="+maCode, nil)
req.Header.Set("Authorization", "TCS ak-regulator:badsig")
r, _ := http.DefaultClient.Do(req)
assert.Equal(t, http.StatusUnauthorized, r.StatusCode, "错误签名应 401")
r.Body.Close()
}
// TestE2E_TakedownLatency 下架时效:端到端应远快于分钟级(需求11/18-AC1)。
func TestE2E_TakedownLatency(t *testing.T) {
srv, _ := testServer(t)
defer srv.Close()
b := srv.URL
_, resp := signedCall(t, b, "ak-cp", "sk-cp", "POST", "/content/register", map[string]any{
"title": "L", "episode_count": 1, "category": "WD", "file_sha256": "fh-lat", "merkle_root": "mr-lat",
})
reviewID := dataOf(resp)["review_id"].(string)
signedCall(t, b, "ak-reviewer", "sk-reviewer", "POST", "/content/csps-result", map[string]any{"review_id": reviewID, "approved": true})
_, resp = signedCall(t, b, "ak-regulator", "sk-regulator", "POST", "/content/issue", map[string]any{"review_id": reviewID, "issuer": "x"})
maCode := dataOf(resp)["ma_code"].(string)
start := time.Now()
st, _ := signedCall(t, b, "ak-regulator", "sk-regulator", "POST", "/content/takedown", map[string]any{"ma_code": maCode, "reason": "违规"})
elapsed := time.Since(start)
require.Equal(t, http.StatusOK, st)
assert.Less(t, elapsed, time.Second, "下架端到端应在秒级内(目标分钟级)")
}
+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)
}
+33
View File
@@ -0,0 +1,33 @@
package config
import (
"os"
)
// Config 保存服务运行所需的通用配置。
// MVP 阶段从环境变量加载,缺省值适配本地开发。
type Config struct {
APIAddr string
ChainAddr string
HashAddr string
PostgresDSN string
RedisAddr string
}
func getEnv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// Load 从环境变量加载配置。
func Load() Config {
return Config{
APIAddr: getEnv("TCS_API_ADDR", ":8080"),
ChainAddr: getEnv("TCS_CHAIN_ADDR", ":8081"),
HashAddr: getEnv("TCS_HASH_ADDR", ":8082"),
PostgresDSN: getEnv("TCS_POSTGRES_DSN", "postgres://postgres@localhost:5432/tcs_iptv?sslmode=disable"),
RedisAddr: getEnv("TCS_REDIS_ADDR", "localhost:6379"),
}
}
+151
View File
@@ -0,0 +1,151 @@
package hash
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func writeTempFile(t *testing.T, data []byte) string {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, "master.bin")
require.NoError(t, os.WriteFile(p, data, 0o644))
return p
}
func TestSHA256Hex_Deterministic(t *testing.T) {
a := SHA256Hex([]byte("hello"))
b := SHA256Hex([]byte("hello"))
assert.Equal(t, a, b)
// 已知向量
assert.Equal(t, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", a)
}
func TestFileSHA256_MatchesBytes(t *testing.T) {
data := []byte("the quick brown fox")
p := writeTempFile(t, data)
got, err := FileSHA256(p)
require.NoError(t, err)
assert.Equal(t, SHA256Hex(data), got)
}
func TestSegmentHashes_SmallSegments(t *testing.T) {
// 25 字节,分段 10 → 3 段(10/10/5
data := []byte("0123456789ABCDEFGHIJ12345")
p := writeTempFile(t, data)
segs, err := SegmentHashes(p, 10)
require.NoError(t, err)
require.Len(t, segs, 3)
assert.Equal(t, SHA256Hex(data[0:10]), segs[0])
assert.Equal(t, SHA256Hex(data[10:20]), segs[1])
assert.Equal(t, SHA256Hex(data[20:25]), segs[2])
}
func TestMerkleTree_RootStableAndChangesOnEdit(t *testing.T) {
leaves := []string{
SHA256Hex([]byte("ep1")),
SHA256Hex([]byte("ep2")),
SHA256Hex([]byte("ep3")),
SHA256Hex([]byte("ep4")),
}
root1 := BuildMerkleTree(leaves).Root()
root2 := BuildMerkleTree(leaves).Root()
assert.Equal(t, root1, root2, "同样叶子根应一致")
assert.NotEmpty(t, root1)
// 改第3集 → 根变化
edited := append([]string(nil), leaves...)
edited[2] = SHA256Hex([]byte("ep3-tampered"))
root3 := BuildMerkleTree(edited).Root()
assert.NotEqual(t, root1, root3, "篡改任一集,根必变")
}
func TestMerkleTree_OddLeaves(t *testing.T) {
leaves := []string{
SHA256Hex([]byte("a")),
SHA256Hex([]byte("b")),
SHA256Hex([]byte("c")),
}
mt := BuildMerkleTree(leaves)
assert.NotEmpty(t, mt.Root())
}
func TestLocateChangedLeaves(t *testing.T) {
old := []string{"h1", "h2", "h3", "h4"}
neu := []string{"h1", "x2", "h3", "x4"}
changed := LocateChangedLeaves(old, neu)
assert.Equal(t, []int{1, 3}, changed, "应定位到第2集和第4集被改")
}
func TestComputeFile_FullPackage(t *testing.T) {
data := make([]byte, 25*1024) // 25KB
for i := range data {
data[i] = byte(i % 251)
}
p := writeTempFile(t, data)
pkg, err := ComputeFile(p, Options{SegmentSize: 10 * 1024})
require.NoError(t, err)
require.NoError(t, pkg.Validate())
assert.Equal(t, int64(25*1024), pkg.FileSize)
assert.Len(t, pkg.SegmentHashes, 3)
assert.NotEmpty(t, pkg.MerkleRoot)
assert.Equal(t, SHA256Hex(data), pkg.FileSHA256)
}
func TestComputeFile_EmptyFileRejected(t *testing.T) {
p := writeTempFile(t, []byte{})
_, err := ComputeFile(p, Options{})
assert.ErrorIs(t, err, ErrEmptyInput)
}
func TestComputeFile_MissingFile(t *testing.T) {
_, err := ComputeFile("/no/such/file.bin", Options{})
assert.Error(t, err)
}
func TestHashPackage_ValidateMissingFields(t *testing.T) {
assert.Error(t, (&HashPackage{MerkleRoot: "x"}).Validate()) // 缺 file_sha256
assert.Error(t, (&HashPackage{FileSHA256: "x"}).Validate()) // 缺 merkle_root
assert.NoError(t, (&HashPackage{FileSHA256: "a", MerkleRoot: "b"}).Validate())
}
func TestPerceptualHash_IdenticalAndDifferent(t *testing.T) {
// 全黑与全白图,aHash/dHash 应可区分
black := make([][]uint8, 16)
white := make([][]uint8, 16)
grad := make([][]uint8, 16)
for y := 0; y < 16; y++ {
black[y] = make([]uint8, 16)
white[y] = make([]uint8, 16)
grad[y] = make([]uint8, 16)
for x := 0; x < 16; x++ {
white[y][x] = 255
grad[y][x] = uint8(x * 16) // 水平渐变
}
}
imgBlack := newGrayTestImage(black)
imgWhite := newGrayTestImage(white)
imgGrad := newGrayTestImage(grad)
// 同一图的哈希稳定
assert.Equal(t, AHash(imgGrad), AHash(imgGrad))
assert.Equal(t, DHash(imgGrad), DHash(imgGrad))
// 渐变图的 dHash 应与纯色不同
assert.NotEqual(t, DHash(imgGrad), DHash(imgBlack))
// 汉明距离:渐变 vs 纯白 应 > 0
d, err := HammingDistance(DHash(imgGrad), DHash(imgWhite))
require.NoError(t, err)
assert.Greater(t, d, 0)
}
func TestHammingDistance_LengthMismatch(t *testing.T) {
_, err := HammingDistance("ffff", "ffffffff")
assert.Error(t, err)
}
+87
View File
@@ -0,0 +1,87 @@
package hash
import (
"crypto/sha256"
"encoding/hex"
)
// MerkleTree 表示一棵基于 SHA-256 的 Merkle 树。
// 叶子为各分段(或各集)的哈希,根用于整体内容的聚合锚定。
// 对应需求:需求1-AC2、需求16-AC4(按集定位篡改)。
type MerkleTree struct {
Leaves []string // 叶子哈希(十六进制)
Levels [][]string // 自底向上的各层,Levels[0] 为叶子层
}
// BuildMerkleTree 由叶子哈希构建 Merkle 树。
// 当某层节点数为奇数时,复制最后一个节点与自身配对(标准做法)。
func BuildMerkleTree(leaves []string) *MerkleTree {
mt := &MerkleTree{Leaves: append([]string(nil), leaves...)}
if len(leaves) == 0 {
mt.Levels = [][]string{{}}
return mt
}
level := append([]string(nil), leaves...)
mt.Levels = [][]string{level}
for len(level) > 1 {
next := make([]string, 0, (len(level)+1)/2)
for i := 0; i < len(level); i += 2 {
left := level[i]
right := left // 奇数个时与自身配对
if i+1 < len(level) {
right = level[i+1]
}
next = append(next, hashPair(left, right))
}
mt.Levels = append(mt.Levels, next)
level = next
}
return mt
}
// Root 返回 Merkle 根哈希;空树返回空字符串。
func (mt *MerkleTree) Root() string {
if len(mt.Levels) == 0 {
return ""
}
top := mt.Levels[len(mt.Levels)-1]
if len(top) == 0 {
return ""
}
return top[0]
}
// hashPair 将两个十六进制哈希拼接后再次 SHA-256。
func hashPair(left, right string) string {
lb, _ := hex.DecodeString(left)
rb, _ := hex.DecodeString(right)
h := sha256.New()
h.Write(lb)
h.Write(rb)
return hex.EncodeToString(h.Sum(nil))
}
// LocateChangedLeaves 比较两组叶子哈希,返回发生变化的叶子索引。
// 用于"定位被篡改的具体集"(需求12-AC3)。
func LocateChangedLeaves(oldLeaves, newLeaves []string) []int {
var changed []int
max := len(oldLeaves)
if len(newLeaves) > max {
max = len(newLeaves)
}
for i := 0; i < max; i++ {
var o, n string
if i < len(oldLeaves) {
o = oldLeaves[i]
}
if i < len(newLeaves) {
n = newLeaves[i]
}
if o != n {
changed = append(changed, i)
}
}
return changed
}
+106
View File
@@ -0,0 +1,106 @@
package hash
import (
"fmt"
"image"
"image/color"
"math/bits"
)
// 感知哈希用于跨格式/转码识别同一内容(需求1-AC3)。
// MVP 实现 aHash(均值哈希)与 dHash(差值哈希),输入为已解码的视频代表帧图像。
// 真实视频抽帧由上层(ffmpeg)完成,本包专注哈希算法以便独立测试。
const phashDim = 8 // 8x8 → 64-bit 哈希
// grayResize 将图像缩放为 w×h 的灰度矩阵(最近邻,零依赖)。
func grayResize(img image.Image, w, h int) [][]float64 {
b := img.Bounds()
srcW, srcH := b.Dx(), b.Dy()
out := make([][]float64, h)
for y := 0; y < h; y++ {
out[y] = make([]float64, w)
for x := 0; x < w; x++ {
sx := b.Min.X + x*srcW/w
sy := b.Min.Y + y*srcH/h
r, g, bb, _ := img.At(sx, sy).RGBA()
// 转 8 位灰度(ITU-R 601 亮度)
gray := 0.299*float64(r>>8) + 0.587*float64(g>>8) + 0.114*float64(bb>>8)
out[y][x] = gray
}
}
return out
}
// AHash 计算均值哈希(64-bit,十六进制 16 字符)。
func AHash(img image.Image) string {
m := grayResize(img, phashDim, phashDim)
var sum float64
for y := 0; y < phashDim; y++ {
for x := 0; x < phashDim; x++ {
sum += m[y][x]
}
}
avg := sum / float64(phashDim*phashDim)
var hash uint64
var bit uint
for y := 0; y < phashDim; y++ {
for x := 0; x < phashDim; x++ {
if m[y][x] >= avg {
hash |= 1 << bit
}
bit++
}
}
return fmt.Sprintf("%016x", hash)
}
// DHash 计算差值哈希(64-bit)。对水平相邻像素比较亮度。
func DHash(img image.Image) string {
// 需要 (phashDim+1) 列以产生 phashDim 个差值
m := grayResize(img, phashDim+1, phashDim)
var hash uint64
var bit uint
for y := 0; y < phashDim; y++ {
for x := 0; x < phashDim; x++ {
if m[y][x] < m[y][x+1] {
hash |= 1 << bit
}
bit++
}
}
return fmt.Sprintf("%016x", hash)
}
// HammingDistance 计算两个等长十六进制哈希的汉明距离。
// 距离越小越相似;用于版权比对与跨版本识别。
func HammingDistance(a, b string) (int, error) {
if len(a) != len(b) {
return 0, fmt.Errorf("hash: length mismatch %d vs %d", len(a), len(b))
}
var va, vb uint64
if _, err := fmt.Sscanf(a, "%x", &va); err != nil {
return 0, err
}
if _, err := fmt.Sscanf(b, "%x", &vb); err != nil {
return 0, err
}
return bits.OnesCount64(va ^ vb), nil
}
// newGrayTestImage 是测试辅助:由灰度矩阵生成图像。
func newGrayTestImage(gray [][]uint8) image.Image {
h := len(gray)
w := 0
if h > 0 {
w = len(gray[0])
}
img := image.NewGray(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.SetGray(x, y, color.Gray{Y: gray[y][x]})
}
}
return img
}
+78
View File
@@ -0,0 +1,78 @@
package hash
import (
"fmt"
"os"
)
// HashPackage 是哈希值包,对应需求1-AC5。
// 仅包含哈希与元数据,绝不包含原始内容(需求20-AC2)。
type HashPackage struct {
FileSHA256 string `json:"file_sha256"`
MerkleRoot string `json:"merkle_root"`
SegmentHashes []string `json:"segment_hashes"`
PerceptualHash string `json:"perceptual_hash,omitempty"`
FileSize int64 `json:"file_size"`
SegmentSize int `json:"segment_size"`
}
// Options 控制哈希计算行为。
type Options struct {
SegmentSize int // 分段大小;<=0 用默认
PerceptualHash string // 上层已抽帧并算好的感知哈希(可选)
}
// ComputeFile 对母版文件计算完整哈希值包(文件哈希 + 分段 Merkle)。
// 感知哈希需上层先抽帧,再通过 opts.PerceptualHash 传入或单独调用 AHash/DHash。
func ComputeFile(path string, opts Options) (*HashPackage, error) {
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("hash: stat file: %w", err)
}
if info.IsDir() {
return nil, fmt.Errorf("hash: path is a directory: %s", path)
}
if info.Size() == 0 {
return nil, ErrEmptyInput
}
segSize := opts.SegmentSize
if segSize <= 0 {
segSize = DefaultSegmentSize
}
fileHash, err := FileSHA256(path)
if err != nil {
return nil, fmt.Errorf("hash: file sha256: %w", err)
}
segments, err := SegmentHashes(path, segSize)
if err != nil {
return nil, fmt.Errorf("hash: segment hashes: %w", err)
}
tree := BuildMerkleTree(segments)
return &HashPackage{
FileSHA256: fileHash,
MerkleRoot: tree.Root(),
SegmentHashes: segments,
PerceptualHash: opts.PerceptualHash,
FileSize: info.Size(),
SegmentSize: segSize,
}, nil
}
// Validate 校验哈希值包的完整性(需求2-AC5:缺文件哈希/Merkle根则非法)。
func (p *HashPackage) Validate() error {
if p == nil {
return fmt.Errorf("hash: nil package")
}
if p.FileSHA256 == "" {
return fmt.Errorf("hash: missing file_sha256")
}
if p.MerkleRoot == "" {
return fmt.Errorf("hash: missing merkle_root")
}
return nil
}
+68
View File
@@ -0,0 +1,68 @@
// Package hash 实现 TCS-IPTV 的内容哈希核心:
// 文件 SHA-256、分段 Merkle Tree、感知哈希。
// 对应需求:需求1(母版哈希生成)。
package hash
import (
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"os"
)
// DefaultSegmentSize 是分段哈希的默认分段大小(10 MiB)。
const DefaultSegmentSize = 10 * 1024 * 1024
// ErrEmptyInput 表示输入为空。
var ErrEmptyInput = errors.New("hash: empty input")
// SHA256Hex 计算字节切片的 SHA-256,返回十六进制字符串。
func SHA256Hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
// FileSHA256 流式计算文件的整体 SHA-256,避免一次性载入大文件。
func FileSHA256(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// SegmentHashes 按 segmentSize 分段计算文件各段的 SHA-256。
// 用于构建 Merkle Tree 的叶子节点。
func SegmentHashes(path string, segmentSize int) ([]string, error) {
if segmentSize <= 0 {
segmentSize = DefaultSegmentSize
}
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var segments []string
buf := make([]byte, segmentSize)
for {
n, err := io.ReadFull(f, buf)
if n > 0 {
segments = append(segments, SHA256Hex(buf[:n]))
}
if err == io.EOF || err == io.ErrUnexpectedEOF {
break
}
if err != nil {
return nil, err
}
}
return segments, nil
}
+103
View File
@@ -0,0 +1,103 @@
package httpx
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"strings"
"github.com/gin-gonic/gin"
)
// 鉴权采用 API Key + HMAC-SHA256(需求17-AC4、需求20)。
// 请求头:
// Authorization: TCS {apiKey}:{signature}
// X-TCS-Role: regulator | reviewer | cp | operator
// signature = base64(HMAC-SHA256(apiSecret, method+"\n"+path))
// KeyStore 提供 apiKey -> (apiSecret, role) 的查询。MVP 用内存实现。
type KeyStore interface {
Lookup(apiKey string) (secret string, role string, ok bool)
}
// MemoryKeyStore 内存密钥库。
type MemoryKeyStore struct {
keys map[string]struct {
secret string
role string
}
}
// NewMemoryKeyStore 创建并预置密钥。
func NewMemoryKeyStore() *MemoryKeyStore {
return &MemoryKeyStore{keys: map[string]struct {
secret string
role string
}{}}
}
// Add 注册一个密钥及其角色。
func (m *MemoryKeyStore) Add(apiKey, secret, role string) {
m.keys[apiKey] = struct {
secret string
role string
}{secret, role}
}
// Lookup 查询密钥。
func (m *MemoryKeyStore) Lookup(apiKey string) (string, string, bool) {
v, ok := m.keys[apiKey]
return v.secret, v.role, ok
}
// Sign 计算签名(供客户端/测试使用)。
func Sign(secret, method, path string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(method + "\n" + path))
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}
// 上下文键。
const ctxRoleKey = "tcs_role"
// AuthMiddleware 校验 HMAC 签名,将角色写入上下文(需求14 权限基础)。
func AuthMiddleware(store KeyStore) gin.HandlerFunc {
return func(c *gin.Context) {
authz := c.GetHeader("Authorization")
if !strings.HasPrefix(authz, "TCS ") {
Error(c, 401, "UNAUTHORIZED", "缺少或非法 Authorization 头")
c.Abort()
return
}
parts := strings.SplitN(strings.TrimPrefix(authz, "TCS "), ":", 2)
if len(parts) != 2 {
Error(c, 401, "UNAUTHORIZED", "Authorization 格式应为 TCS {apiKey}:{signature}")
c.Abort()
return
}
apiKey, sig := parts[0], parts[1]
secret, role, ok := store.Lookup(apiKey)
if !ok {
Error(c, 401, "UNAUTHORIZED", "未知 API Key")
c.Abort()
return
}
expected := Sign(secret, c.Request.Method, c.Request.URL.Path)
if !hmac.Equal([]byte(expected), []byte(sig)) {
Error(c, 401, "UNAUTHORIZED", "签名校验失败")
c.Abort()
return
}
c.Set(ctxRoleKey, role)
c.Next()
}
}
// RoleFromContext 取出鉴权后的角色。
func RoleFromContext(c *gin.Context) string {
if v, ok := c.Get(ctxRoleKey); ok {
return fmt.Sprintf("%v", v)
}
return ""
}
+39
View File
@@ -0,0 +1,39 @@
package httpx
import (
"github.com/gin-gonic/gin"
)
// Resp 是统一响应结构。
type Resp struct {
Code string `json:"code"`
Message string `json:"message,omitempty"`
Data interface{} `json:"data,omitempty"`
}
// OK 返回成功响应。
func OK(c *gin.Context, data interface{}) {
c.JSON(200, Resp{Code: "SUCCESS", Data: data})
}
// Created 返回 201。
func Created(c *gin.Context, data interface{}) {
c.JSON(201, Resp{Code: "CREATED", Data: data})
}
// Accepted 返回 202(异步任务已受理)。
func Accepted(c *gin.Context, data interface{}) {
c.JSON(202, Resp{Code: "ACCEPTED", Data: data})
}
// Error 返回错误响应。
func Error(c *gin.Context, status int, code, message string) {
c.JSON(status, Resp{Code: code, Message: message})
}
// Health 注册通用健康检查端点。
func Health(r *gin.Engine, service string) {
r.GET("/healthz", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok", "service": service})
})
}
+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
}
}
+98
View File
@@ -0,0 +1,98 @@
// Package model 定义 TCS-IPTV 的领域模型,
// 对应需求16的四类核心数据结构与 CTID 双锚定模型。
package model
import "time"
// Party 三方角色标识。
type Party string
const (
PartyCP Party = "cp" // 内容提供商
PartyReviewer Party = "reviewer" // 审核和监管部门(审核主体:CSPS/媒资库)
PartyOperator Party = "operator" // 运营商
)
// Content 内容主表(Content Registry)。
type Content struct {
ContentTwinID string `json:"content_twin_id"`
MACode string `json:"ma_code"`
MAType string `json:"ma_type"`
Title string `json:"title"`
EpisodeCount int `json:"episode_count"`
Status string `json:"status"`
Issuer string `json:"issuer"`
IssueDate string `json:"issue_date"`
FileHash string `json:"file_hash,omitempty"` // 传输便利:列表时附带整剧文件哈希(供运营商演示注入)
CreatedAt time.Time `json:"created_at"`
}
// HashType 哈希类型。
type HashType string
const (
HashFile HashType = "file_sha256"
HashPerceptual HashType = "perceptual"
HashTranscoded HashType = "transcoded"
)
// HashBinding 哈希绑定(Hash Binding)。
type HashBinding struct {
ContentTwinID string `json:"content_twin_id"`
HashType HashType `json:"hash_type"`
HashValue string `json:"hash_value"`
MerkleRoot string `json:"merkle_root"`
Episode int `json:"episode"` // 集号;0 表示整剧/单体(非分集)
FileFormat string `json:"file_format"`
Resolution string `json:"resolution"`
Duration int `json:"duration"`
Version string `json:"version"`
ParentHash string `json:"parent_hash"` // 转码版指向母版哈希
Revoked bool `json:"revoked"` // 集级下架标记(true=该集已下架)
RevokedReason string `json:"revoked_reason,omitempty"`
CreatedBy string `json:"created_by"`
}
// EpisodeHash 单集哈希(送审时按集提交)。
type EpisodeHash struct {
Episode int `json:"episode"`
FileSHA256 string `json:"file_sha256"`
MerkleRoot string `json:"merkle_root"`
Perceptual string `json:"perceptual_hash"`
Duration int `json:"duration"`
Resolution string `json:"resolution"`
}
// Mapping 三方编码映射(Identity Mapping)。
type Mapping struct {
ContentTwinID string `json:"content_twin_id"`
Party Party `json:"party"`
PartyID string `json:"party_id"`
PartyName string `json:"party_name"`
CDNEndpoint string `json:"cdn_endpoint"`
}
// VersionChange 版本变更(Version History)。
type VersionChange struct {
ContentTwinID string `json:"content_twin_id"`
Version string `json:"version"`
ChangeReason string `json:"change_reason"`
PrevHash string `json:"prev_hash"`
NewHash string `json:"new_hash"`
ReauditRequired bool `json:"reaudit_required"`
ReauditStatus string `json:"reaudit_status"`
AffectedEpisode int `json:"affected_episode"`
}
// 内容审核状态。
const (
StatusPending = "pending" // 待审
StatusPreChecking = "pre_checking" // 预检中
StatusReviewing = "reviewing" // CSPS 审核中
StatusApproved = "approved" // 审核通过(待发码)
StatusIssued = "issued" // 已发码(送审单完结)
StatusRejected = "rejected" // 驳回
StatusInLibrary = "in_library" // 已入媒资库
StatusPublished = "published" // 已发布
StatusRevoked = "revoked" // 已下架
)
+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 码")
}
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# 端到端冒烟:送审→发码签发→CSPS→入库→发布→注入→下架
# 依赖:api-svc 运行在 :8080
set -e
BASE="http://localhost:8080/api/v1"
# HMAC 签名工具(与 httpx.Sign 一致:base64(HMAC-SHA256(secret, METHOD\nPATH))
sign() { # secret method path
printf '%s\n%s' "$2" "$3" | openssl dgst -sha256 -hmac "$1" -binary | base64
}
call() { # apiKey secret method path jsonBody
local key="$1" secret="$2" method="$3" path="$4" body="$5"
# 签名只用 path(不含 query),与 Go 端 c.Request.URL.Path 一致
local sigpath="/api/v1${path%%\?*}"
local sig; sig=$(sign "$secret" "$method" "$sigpath")
if [ "$method" = "GET" ]; then
curl -s -X GET "$BASE$path" -H "Authorization: TCS $key:$sig"
else
curl -s -X "$method" "$BASE$path" \
-H "Authorization: TCS $key:$sig" \
-H "Content-Type: application/json" -d "$body"
fi
}
echo "== 1) CP 送审 =="
REG=$(call ak-cp sk-cp POST /content/register \
'{"title":"示例微短剧","episode_count":24,"category":"WD","file_sha256":"fh-001","merkle_root":"mr-001","perceptual_hash":"ph-001","cp_media_id":"FS-77821","cp_name":"飞翮信息"}')
echo "$REG"
REVIEW_ID=$(echo "$REG" | sed -n 's/.*"review_id":"\([^"]*\)".*/\1/p')
CTID=$(echo "$REG" | sed -n 's/.*"content_twin_id":"\([^"]*\)".*/\1/p')
echo "review_id=$REVIEW_ID ctid=$CTID"
echo "== 2) 监管发码签发 =="
ISS=$(call ak-regulator sk-regulator POST /content/issue \
"{\"review_id\":\"$REVIEW_ID\",\"issuer\":\"北京市广播电视局\"}")
echo "$ISS"
MA=$(echo "$ISS" | sed -n 's/.*"ma_code":"\([^"]*\)".*/\1/p')
CERT=$(echo "$ISS" | sed -n 's/.*"certificate":"\([^"]*\)".*/\1/p')
echo "ma_code=$MA"
echo "== 3) CSPS 审核通过 =="
call ak-reviewer sk-reviewer POST /content/csps-result \
"{\"ma_code\":\"$MA\",\"approved\":true,\"reviewer_id\":\"rv-1\"}"; echo
echo "== 4) 入媒资库 =="
call ak-reviewer sk-reviewer POST /content/ingest \
"{\"ma_code\":\"$MA\",\"content_twin_id\":\"$CTID\",\"media_asset_id\":\"MEDIA-001\",\"lib_name\":\"广东IPTV媒资库\"}"; echo
echo "== 5) 发布给运营商 =="
call ak-reviewer sk-reviewer POST /content/publish \
"{\"ma_code\":\"$MA\",\"certificate\":\"$CERT\"}"; echo
echo "== 6) CDN 注入校验(匹配) =="
call ak-operator sk-operator POST /content/inject \
"{\"content_twin_id\":\"$CTID\",\"ma_code\":\"$MA\",\"file_sha256\":\"fh-001\",\"operator_id\":\"CT-IPTV-GD\",\"cdn_endpoint\":\"cdn://ct-gd/vod/1\"}"; echo
echo "== 7) CDN 注入校验(篡改,应拒绝) =="
call ak-operator sk-operator POST /content/inject \
"{\"content_twin_id\":\"$CTID\",\"ma_code\":\"$MA\",\"file_sha256\":\"tampered\",\"operator_id\":\"CT-IPTV-GD\",\"cdn_endpoint\":\"cdn://x\"}"; echo
echo "== 8) 映射查询 =="
call ak-regulator sk-regulator GET "/content/mappings?ma_code=$MA" ""; echo
echo "== 9) 运营商越权下架(应拒绝) =="
call ak-operator sk-operator POST /content/takedown \
"{\"ma_code\":\"$MA\",\"reason\":\"越权\"}"; echo
echo "== 10) 监管应急下架 =="
call ak-regulator sk-regulator POST /content/takedown \
"{\"ma_code\":\"$MA\",\"reason\":\"违规\"}"; echo
echo "== 完成 =="
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# 陕西 IPTV 场景演示数据:造若干条内容,跑通"送审→发码→审核→入库→发布→注入"。
#
# 参与方设定:
# 管理方(审核+监管):陕西IPTV运营公司(机构节点 6101)
# 内容提供商(CP):西安曲江丝路文化传播 / 陕文投艺达影视 / 西部电影集团(西影)
# 运营商:中国电信陕西(天翼高清) / 中国移动陕西(魔百和) / 中国联通陕西
set -e
BASE="http://localhost:8080/api/v1"
sign() { printf '%s\n%s' "$2" "$3" | openssl dgst -sha256 -hmac "$1" -binary | base64; }
call() { # key secret method path body
local key="$1" secret="$2" method="$3" path="$4" body="$5"
local sig; sig=$(sign "$secret" "$method" "/api/v1${path%%\?*}")
if [ "$method" = "GET" ]; then
curl -s -X GET "$BASE$path" -H "Authorization: TCS $key:$sig"
else
curl -s -X "$method" "$BASE$path" -H "Authorization: TCS $key:$sig" \
-H "Content-Type: application/json" -d "$body"
fi
}
field() { echo "$1" | sed -n "s/.*\"$2\":\"\([^\"]*\)\".*/\1/p"; }
# 一条内容完整流转:title category fhash cp_id cp_name op_id op_name cdn
flow() {
local title="$1" cat="$2" fh="$3" cpid="$4" cpname="$5" opid="$6" opname="$7" cdn="$8"
echo ">>> [$title] CP=$cpname"
local reg; reg=$(call ak-cp sk-cp POST /content/register \
"{\"title\":\"$title\",\"episode_count\":24,\"category\":\"$cat\",\"file_sha256\":\"$fh\",\"merkle_root\":\"mr-$fh\",\"perceptual_hash\":\"ph-$fh\",\"cp_media_id\":\"$cpid\",\"cp_name\":\"$cpname\"}")
local rid ctid; rid=$(field "$reg" review_id); ctid=$(field "$reg" content_twin_id)
# CSPS 合规审核(发码前)
call ak-reviewer sk-reviewer POST /content/csps-result \
"{\"review_id\":\"$rid\",\"approved\":true,\"reviewer_id\":\"sxiptv-审核员01\"}" >/dev/null
# 审核通过后发码签发
local iss; iss=$(call ak-regulator sk-regulator POST /content/issue \
"{\"review_id\":\"$rid\",\"issuer\":\"陕西IPTV运营公司\"}")
local ma cert; ma=$(field "$iss" ma_code); cert=$(field "$iss" certificate)
echo " MA码: $ma"
call ak-reviewer sk-reviewer POST /content/ingest \
"{\"ma_code\":\"$ma\",\"content_twin_id\":\"$ctid\",\"media_asset_id\":\"SXMEDIA-$fh\",\"lib_name\":\"陕西IPTV媒体资源库\"}" >/dev/null
call ak-reviewer sk-reviewer POST /content/publish \
"{\"ma_code\":\"$ma\",\"certificate\":\"$cert\"}" >/dev/null
local inj; inj=$(call ak-operator sk-operator POST /content/inject \
"{\"content_twin_id\":\"$ctid\",\"ma_code\":\"$ma\",\"file_sha256\":\"$fh\",\"operator_id\":\"$opid\",\"cdn_endpoint\":\"$cdn\"}")
echo " 运营商: $opname 注入: $(field "$inj" distribution_id)"
echo "$ma" >> /tmp/tcs_demo_macodes.txt
}
: > /tmp/tcs_demo_macodes.txt
flow "长安少年行" WD "fh-changan-001" \
"XAQJSL-2026-001" "西安曲江丝路文化传播有限公司" \
"CT-SX-IPTV" "中国电信陕西公司(天翼高清)" "cdn://ct-sx/iptv/vod/changan001"
flow "白鹿原·麦客" WJ "fh-bailuyuan-002" \
"SWTYD-2026-007" "陕文投艺达影视有限公司" \
"CM-SX-IPTV" "中国移动陕西公司(魔百和)" "cdn://cm-sx/iptv/vod/bailuyuan002"
flow "丝路驼铃" DY "fh-silu-003" \
"XIYING-2026-015" "西部电影集团(西影视频)" \
"CU-SX-IPTV" "中国联通陕西公司" "cdn://cu-sx/iptv/vod/silu003"
echo ""
echo "=== 已生成 MA 码(可复制到监管大屏查询)==="
cat /tmp/tcs_demo_macodes.txt
echo ""
echo "提示:在 http://localhost:5174 输入上述任一 MA 码查询全链路三方映射。"
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TCS-IPTV 监管大屏</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "tcs-iptv-console",
"private": true,
"version": "1.0.0",
"type": "module",
"description": "TCS-IPTV 监管大屏(全生命周期查询 + 应急下架 + 哈希验真)",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"antd": "^5.21.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.0"
}
}
+158
View File
@@ -0,0 +1,158 @@
import React, { useState } from 'react'
import {
Layout, Typography, Card, Input, Button, Space, Table, Tag,
message, Modal, Descriptions, Alert, Row, Col, Statistic, Tabs,
} from 'antd'
import { SearchOutlined, StopOutlined, CheckCircleOutlined } from '@ant-design/icons'
import { api } from './api.js'
import FlowDemo from './FlowDemo.jsx'
import RoleDesk from './RoleDesk.jsx'
const { Header, Content } = Layout
const { Title, Text } = Typography
const partyLabel = { cp: '内容提供商', reviewer: '审核和监管部门', operator: '运营商' }
const partyColor = { cp: 'green', reviewer: 'blue', operator: 'orange' }
function RegulatorConsole() {
const [maCode, setMaCode] = useState('')
const [loading, setLoading] = useState(false)
const [mappings, setMappings] = useState(null)
const [cdnEndpoints, setCdnEndpoints] = useState([])
const [verifyHash, setVerifyHash] = useState('')
const [verifyResult, setVerifyResult] = useState(null)
async function doQuery() {
if (!maCode) return message.warning('请输入 MA 码')
setLoading(true)
try {
const { status, data } = await api.mappings(maCode)
if (status === 200) {
setMappings(data.data.mappings || [])
setCdnEndpoints(data.data.cdn_endpoints || [])
message.success('查询成功')
} else {
setMappings(null); setCdnEndpoints([])
message.error(data.message || '查询失败')
}
} catch (e) { message.error('请求失败:' + e.message) }
setLoading(false)
}
async function doVerify() {
if (!maCode || !verifyHash) return message.warning('请输入 MA 码与文件哈希')
try {
const { status, data } = await api.verify(maCode, verifyHash)
setVerifyResult({ ok: status === 200, ...data })
} catch (e) { message.error('请求失败:' + e.message) }
}
function confirmTakedown() {
if (!maCode) return message.warning('请先输入 MA 码')
Modal.confirm({
title: '违规应急下架',
content: `确认对 ${maCode} 执行全网下架?该操作将解析三方编码并秒级同步。`,
okText: '确认下架', okType: 'danger', cancelText: '取消',
onOk: async () => {
const { status, data } = await api.takedown(maCode, '监管大屏手动下架')
if (status === 200) {
message.success('已下架,受影响 CDN: ' + (data.data.cdn_endpoints || []).join(', '))
doQuery()
} else {
message.error(data.message || '下架失败')
}
},
})
}
const columns = [
{ title: '角色', dataIndex: 'party', render: (p) => <Tag color={partyColor[p]}>{partyLabel[p] || p}</Tag> },
{ title: '本方编码', dataIndex: 'party_id' },
{ title: '名称', dataIndex: 'party_name', render: (v) => v || '-' },
{ title: 'CDN 端点', dataIndex: 'cdn_endpoint', render: (v) => v || '-' },
]
return (
<>
<Card title="按 MA 码查询全链路" style={{ marginBottom: 16 }}>
<Space.Compact style={{ width: '100%', maxWidth: 720 }}>
<Input
placeholder="如 MA.156.8531.6101/WD/20260000001"
value={maCode} onChange={(e) => setMaCode(e.target.value)}
onPressEnter={doQuery}
/>
<Button type="primary" icon={<SearchOutlined />} loading={loading} onClick={doQuery}>
查询
</Button>
<Button danger icon={<StopOutlined />} onClick={confirmTakedown}>
应急下架
</Button>
</Space.Compact>
</Card>
{mappings && (
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}><Card><Statistic title="三方映射数" value={mappings.length} /></Card></Col>
<Col span={6}><Card><Statistic title="CDN 端点数" value={cdnEndpoints.length} /></Card></Col>
</Row>
)}
{mappings && (
<Card title="三方编码映射" style={{ marginBottom: 16 }}>
<Table rowKey={(r, i) => i} columns={columns} dataSource={mappings} pagination={false} size="middle" />
</Card>
)}
<Card title="哈希验真">
<Space direction="vertical" style={{ width: '100%', maxWidth: 720 }}>
<Space.Compact style={{ width: '100%' }}>
<Input
placeholder="待校验文件哈希(file_sha256"
value={verifyHash} onChange={(e) => setVerifyHash(e.target.value)}
/>
<Button icon={<CheckCircleOutlined />} onClick={doVerify}>验真</Button>
</Space.Compact>
{verifyResult && (
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="结果">
{verifyResult.ok && verifyResult.data?.match
? <Tag color="green">匹配正版过审内容</Tag>
: <Tag color="red">不匹配疑似版本替换</Tag>}
</Descriptions.Item>
<Descriptions.Item label="绑定哈希">{verifyResult.data?.bound_hash || '-'}</Descriptions.Item>
<Descriptions.Item label="提交哈希">{verifyResult.data?.submitted_hash || verifyHash}</Descriptions.Item>
<Descriptions.Item label="消息">{verifyResult.message || '-'}</Descriptions.Item>
</Descriptions>
)}
</Space>
</Card>
</>
)
}
export default function App() {
return (
<Layout style={{ minHeight: '100vh' }}>
<Header style={{ background: '#1a237e', display: 'flex', alignItems: 'center' }}>
<Title level={3} style={{ color: '#fff', margin: 0 }}>TCS-IPTV 内容可信锁定系统</Title>
<Text style={{ color: '#b3c5ff', marginLeft: 16 }}>
陕西IPTV运营公司 · MA码+哈希双锚定
</Text>
</Header>
<Content style={{ padding: 24, background: '#f0f2f5' }}>
<Alert
type="warning" showIcon style={{ marginBottom: 16 }}
message="演示模式:以四角色密钥直连 api-svc。生产环境应改为控制台 BFF + 会话令牌,密钥不下发浏览器。"
/>
<Tabs
defaultActiveKey="desk"
items={[
{ key: 'desk', label: '角色工作台(多方协作)', children: <RoleDesk /> },
{ key: 'flow', label: '全流程演示(一键)', children: <FlowDemo /> },
{ key: 'console', label: '监管大屏', children: <RegulatorConsole /> },
]}
/>
</Content>
</Layout>
)
}
+276
View File
@@ -0,0 +1,276 @@
import React, { useState } from 'react'
import {
Card, Steps, Button, Space, Input, Select, Form, Tag, Timeline,
Descriptions, message, Row, Col, Typography, Divider, Alert, Table,
} from 'antd'
import {
PlayCircleOutlined, RedoOutlined, StopOutlined, SafetyCertificateOutlined,
} from '@ant-design/icons'
import { api } from './api.js'
const { Text, Paragraph } = Typography
const roleColor = { cp: 'green', regulator: 'red', reviewer: 'blue', operator: 'orange' }
const roleLabel = { cp: '内容提供商', regulator: '监管主体', reviewer: '审核/媒资', operator: '运营商' }
// 七步流水线定义(审核在前,发码在后——审过才发证发码)
const STEPS = [
{ key: 'register', title: 'CP 送审', role: 'cp', desc: '原片送 CSPS 既有审核渠道 + 哈希包上链(链上不存原片)' },
{ key: 'csps', title: 'CSPS 合规审核', role: 'reviewer', desc: '基于原片审画面/台词/声音;验真送审哈希=上链哈希(审播一致)' },
{ key: 'issue', title: '审核通过·发码签发', role: 'regulator', desc: '审过才发码:按号段生成 MA 码,1:1 强绑定哈希' },
{ key: 'ingest', title: '媒资库入库', role: 'reviewer', desc: '审合格入库,建立媒资编码映射' },
{ key: 'publish', title: '发布给运营商', role: 'reviewer', desc: '携 MA 码+哈希证书发布' },
{ key: 'inject', title: 'CDN 注入校验', role: 'operator', desc: '注入前哈希比对,匹配放行(防偷换)' },
]
export default function FlowDemo() {
const [form] = Form.useForm()
const [current, setCurrent] = useState(0)
const [running, setRunning] = useState(false)
const [logs, setLogs] = useState([])
const [ctx, setCtx] = useState({}) // reviewID/ctid/maCode/cert/fileHash/episodeHashes
const [done, setDone] = useState(false)
const [episodes, setEpisodes] = useState([]) // 集级哈希列表
const [epVerify, setEpVerify] = useState({}) // {episode: 'match'|'mismatch'}
function addLog(role, title, ok, detail) {
setLogs((prev) => [...prev, { role, title, ok, detail, t: new Date().toLocaleTimeString() }])
}
function reset() {
setCurrent(0); setLogs([]); setCtx({}); setDone(false); setRunning(false)
setEpisodes([]); setEpVerify({})
}
// 执行单步,返回是否成功
async function runStep(idx, shared) {
const step = STEPS[idx]
const v = form.getFieldsValue()
const fileHash = shared.fileHash
let res
switch (step.key) {
case 'register':
// 一剧一码 + 集级哈希:按集数生成每集独立哈希
shared.episodeHashes = []
const epArr = []
const epCount = Number(v.episodes) || 1
for (let i = 1; i <= epCount; i++) {
const eh = `${fileHash}-E${i}`
shared.episodeHashes.push({ episode: i, hash: eh })
epArr.push({ episode: i, file_sha256: eh, merkle_root: `mr-${eh}` })
}
res = await api.register({
title: v.title, episode_count: epCount, category: v.category,
file_sha256: fileHash, merkle_root: 'mr-' + fileHash, perceptual_hash: 'ph-' + fileHash,
episodes: epArr,
cp_media_id: v.cpId, cp_name: v.cpName,
})
if (res.ok) { shared.reviewID = res.data.data.review_id; shared.ctid = res.data.data.content_twin_id }
addLog(step.role, step.title, res.ok, res.ok ? `流水号 ${shared.reviewID}${epCount}集,每集独立哈希)` : res.data.message)
break
case 'issue':
res = await api.issue({ review_id: shared.reviewID, issuer: '陕西IPTV运营公司' })
if (res.ok) { shared.maCode = res.data.data.ma_code; shared.cert = res.data.data.certificate }
addLog(step.role, step.title, res.ok, res.ok ? `MA码 ${shared.maCode}` : res.data.message)
break
case 'csps':
res = await api.csps({ review_id: shared.reviewID, approved: true, reviewer_id: 'sxiptv-审核01' })
addLog(step.role, step.title, res.ok, res.ok ? '审核通过(发码前置)' : res.data.message)
break
case 'ingest':
res = await api.ingest({
ma_code: shared.maCode, content_twin_id: shared.ctid,
media_asset_id: 'SXMEDIA-' + fileHash, lib_name: '陕西IPTV媒体资源库',
})
addLog(step.role, step.title, res.ok, res.ok ? '已入媒资库' : res.data.message)
break
case 'publish':
res = await api.publish({ ma_code: shared.maCode, certificate: shared.cert })
addLog(step.role, step.title, res.ok, res.ok ? '已发布' : res.data.message)
break
case 'inject':
res = await api.inject({
content_twin_id: shared.ctid, ma_code: shared.maCode, file_sha256: fileHash,
operator_id: v.opId, cdn_endpoint: v.cdn,
})
addLog(step.role, step.title, res.ok,
res.ok ? `注入成功 ${res.data.data.distribution_id}` : res.data.message)
break
default:
res = { ok: false }
}
return res.ok
}
// 一键全流程
async function runAll() {
setRunning(true); setLogs([]); setDone(false); setCurrent(0)
const shared = { fileHash: 'fh-' + Date.now().toString(36) }
for (let i = 0; i < STEPS.length; i++) {
setCurrent(i)
const ok = await runStep(i, shared)
if (!ok) {
message.error(`${i + 1} 步「${STEPS[i].title}」失败,流程中断`)
setRunning(false); setCtx(shared)
return
}
await new Promise((r) => setTimeout(r, 500)) // 放慢便于演示观看
}
setCurrent(STEPS.length)
setCtx(shared); setDone(true); setRunning(false)
message.success('全流程跑通:审过即锁定,锁定即通行')
await loadEpisodes(shared.maCode)
}
async function loadEpisodes(maCode) {
const res = await api.episodes(maCode)
if (res.ok) setEpisodes(res.data.data.episodes || [])
}
// 按集验真:correct=true 用正确哈希,false 用篡改哈希
async function verifyEp(ep, correct) {
const realHash = (ctx.episodeHashes || []).find((e) => e.episode === ep)?.hash
const submit = correct ? realHash : 'TAMPERED-' + realHash
const res = await api.verifyEpisode(ctx.maCode, ep, submit)
const matched = res.ok && res.data.data?.match
setEpVerify((prev) => ({ ...prev, [ep]: matched ? 'match' : 'mismatch' }))
if (matched) message.success(`${ep}集验真通过`)
else message.warning(`${ep}集不匹配(疑似该集被替换)`)
}
async function doTakedown() {
const res = await api.takedown(ctx.maCode, '监管演示下架')
if (res.ok) {
addLog('regulator', '违规应急下架', true,
`秒级下架,受影响 CDN: ${(res.data.data.cdn_endpoints || []).join(', ')}`)
message.success('已全网下架')
} else message.error(res.data.message)
}
async function doTamperTest() {
const res = await api.inject({
content_twin_id: ctx.ctid, ma_code: ctx.maCode, file_sha256: 'TAMPERED-' + ctx.fileHash,
operator_id: 'OP-X', cdn_endpoint: 'cdn://x',
})
addLog('operator', '篡改注入测试', !res.ok, res.ok ? '⚠️ 异常:篡改竟通过' : '✅ 已拒绝:' + res.data.message)
if (!res.ok) message.success('篡改内容被正确拦截')
}
return (
<Row gutter={16}>
<Col span={9}>
<Card title="演示参数" size="small" style={{ marginBottom: 16 }}>
<Form form={form} layout="vertical" size="small" initialValues={{
title: '长安少年行', category: 'WD', episodes: 6,
cpId: 'XAQJSL-2026-001', cpName: '西安曲江丝路文化传播有限公司',
opId: 'CT-SX-IPTV', cdn: 'cdn://ct-sx/iptv/vod/changan001',
}}>
<Form.Item label="作品标题" name="title"><Input /></Form.Item>
<Row gutter={8}>
<Col span={12}>
<Form.Item label="类目" name="category">
<Select options={[
{ value: 'WD', label: '微短剧' }, { value: 'WJ', label: '网络剧' },
{ value: 'DY', label: '网络电影' }, { value: 'DH', label: '网络动画' },
]} />
</Form.Item>
</Col>
<Col span={12}><Form.Item label="集数" name="episodes"><Input type="number" /></Form.Item></Col>
</Row>
<Form.Item label="内容提供商 (CP)" name="cpName"><Input /></Form.Item>
<Form.Item label="CP 媒资编码" name="cpId"><Input /></Form.Item>
<Form.Item label="运营商编码" name="opId"><Input /></Form.Item>
<Form.Item label="CDN 端点" name="cdn"><Input /></Form.Item>
</Form>
<Space>
<Button type="primary" icon={<PlayCircleOutlined />} loading={running} onClick={runAll}>
一键全流程
</Button>
<Button icon={<RedoOutlined />} onClick={reset} disabled={running}>重置</Button>
</Space>
</Card>
</Col>
<Col span={15}>
<Card title="内容生命周期流水线" style={{ marginBottom: 16 }}>
<Steps
direction="vertical" size="small" current={current}
status={running ? 'process' : done ? 'finish' : 'wait'}
items={STEPS.map((s) => ({
title: <Space>{s.title}<Tag color={roleColor[s.role]}>{roleLabel[s.role]}</Tag></Space>,
description: s.desc,
}))}
/>
</Card>
{done && (
<Card title={<Space><SafetyCertificateOutlined />赋码结果双锚定</Space>} style={{ marginBottom: 16 }}>
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="MA 码(监管锚点)"><Text strong copyable>{ctx.maCode}</Text></Descriptions.Item>
<Descriptions.Item label="文件哈希(技术锚点)">{ctx.fileHash}</Descriptions.Item>
<Descriptions.Item label="CTID(机器主键)">{ctx.ctid}</Descriptions.Item>
</Descriptions>
<Divider />
<Space>
<Button onClick={doTamperTest}>篡改注入测试应拒绝</Button>
<Button danger icon={<StopOutlined />} onClick={doTakedown}>违规应急下架</Button>
</Space>
</Card>
)}
{done && episodes.length > 0 && (
<Card
title={<Space>集级面板<Tag color="purple">一剧一码 · {episodes.length} 集独立哈希</Tag></Space>}
style={{ marginBottom: 16 }}
extra={<Text type="secondary">集级子标识{ctx.maCode}#E01 </Text>}
>
<Table
size="small" rowKey="episode" pagination={false}
dataSource={episodes}
columns={[
{ title: '集号', dataIndex: 'episode', width: 70, render: (e) => <Tag> {e} </Tag> },
{ title: '集级子标识', render: (_, r) => <Text code>{`${ctx.maCode}#E${String(r.episode).padStart(2, '0')}`}</Text> },
{ title: '该集哈希', dataIndex: 'hash_value', ellipsis: true },
{
title: '验真状态', width: 110, render: (_, r) => {
const st = epVerify[r.episode]
if (st === 'match') return <Tag color="green">匹配</Tag>
if (st === 'mismatch') return <Tag color="red">不匹配</Tag>
return <Tag>未验</Tag>
},
},
{
title: '按集验真', width: 200, render: (_, r) => (
<Space>
<Button size="small" onClick={() => verifyEp(r.episode, true)}>正确</Button>
<Button size="small" danger onClick={() => verifyEp(r.episode, false)}>模拟篡改</Button>
</Space>
),
},
]}
/>
</Card>
)}
<Card title="执行日志" size="small">
{logs.length === 0
? <Alert type="info" message='点击「一键全流程」开始演示' />
: <Timeline items={logs.map((l) => ({
color: l.ok ? 'green' : 'red',
children: (
<Space direction="vertical" size={0}>
<Space>
<Tag color={roleColor[l.role]}>{roleLabel[l.role]}</Tag>
<Text strong>{l.title}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{l.t}</Text>
</Space>
<Text type={l.ok ? 'success' : 'danger'}>{l.detail}</Text>
</Space>
),
}))} />
}
</Card>
</Col>
</Row>
)
}
+430
View File
@@ -0,0 +1,430 @@
import React, { useState, useEffect } from 'react'
import {
Card, Tabs, Table, Button, Space, Tag, Form, Input, Select, message,
Typography, Empty, Badge, Modal, Descriptions, Segmented,
} from 'antd'
import { ReloadOutlined, SendOutlined, StopOutlined } from '@ant-design/icons'
import { call, api } from './api.js'
const { Text } = Typography
const statusMeta = {
approved: { label: '待入库', color: 'green' },
in_library: { label: '在库待发布', color: 'cyan' },
published: { label: '流通中', color: 'blue' },
revoked: { label: '已下架', color: 'red' },
}
const catLabel = { WD: '微短剧', WJ: '网络剧', DY: '网络电影', DH: '网络动画' }
// 各角色工作台共享的刷新钩子:通过 bump 触发全局重拉
function useTick() {
const [tick, setTick] = useState(0)
return [tick, () => setTick((t) => t + 1)]
}
// ============ CP 工作台 ============
function CPDesk({ onChanged }) {
const [form] = Form.useForm()
const [submitting, setSubmitting] = useState(false)
async function submit() {
const v = await form.validateFields()
setSubmitting(true)
const fh = 'fh-' + Date.now().toString(36)
const epCount = Number(v.episodes) || 1
const episodes = []
for (let i = 1; i <= epCount; i++) {
episodes.push({ episode: i, file_sha256: `${fh}-E${i}`, merkle_root: `mr-${fh}-E${i}` })
}
const res = await call('cp', 'POST', '/content/register', {
title: v.title, episode_count: epCount, category: v.category,
file_sha256: fh, merkle_root: 'mr-' + fh, perceptual_hash: 'ph-' + fh,
episodes, cp_media_id: v.cpId, cp_name: v.cpName,
})
setSubmitting(false)
if (res.ok) {
message.success(`送审成功,流水号 ${res.data.data.review_id}(原片走审核渠道,哈希上链)`)
onChanged()
} else message.error(res.data.message || '送审失败')
}
return (
<Card title="内容提供商工作台 · 送审申报" size="small">
<Form form={form} layout="inline" initialValues={{
title: '长安少年行', category: 'WD', episodes: 6,
cpId: 'XAQJSL-2026-001', cpName: '西安曲江丝路文化传播有限公司',
}}>
<Form.Item label="作品" name="title" rules={[{ required: true }]}><Input style={{ width: 160 }} /></Form.Item>
<Form.Item label="类目" name="category">
<Select style={{ width: 110 }} options={Object.entries(catLabel).map(([v, l]) => ({ value: v, label: l }))} />
</Form.Item>
<Form.Item label="集数" name="episodes"><Input type="number" style={{ width: 80 }} /></Form.Item>
<Form.Item label="CP" name="cpName"><Input style={{ width: 220 }} /></Form.Item>
<Form.Item name="cpId" hidden><Input /></Form.Item>
<Form.Item>
<Button type="primary" icon={<SendOutlined />} loading={submitting} onClick={submit}>
送审原片送审核 + 哈希上链
</Button>
</Form.Item>
</Form>
<Text type="secondary" style={{ fontSize: 12 }}>
送审后到审核监管工作台"待审队列"领取审核
</Text>
</Card>
)
}
// ============ 审核监管工作台 ============
function ReviewerDesk({ tick, onChanged }) {
const [pending, setPending] = useState([])
const [toIssue, setToIssue] = useState([])
const [toIngest, setToIngest] = useState([])
const [toPublish, setToPublish] = useState([])
async function load() {
const [p, i, ing, pub] = await Promise.all([
call('reviewer', 'GET', '/content/reviews?status=pending'),
call('regulator', 'GET', '/content/reviews?status=approved'),
call('reviewer', 'GET', '/content/list?status=approved'),
call('reviewer', 'GET', '/content/list?status=in_library'),
])
setPending(p.data?.data?.reviews || [])
setToIssue(i.data?.data?.reviews || [])
setToIngest(ing.data?.data?.contents || [])
setToPublish(pub.data?.data?.contents || [])
}
useEffect(() => { load() }, [tick])
async function review(reviewID, approved) {
const res = await call('reviewer', 'POST', '/content/csps-result', { review_id: reviewID, approved, reviewer_id: 'sxiptv-审核01' })
if (res.ok) { message.success(approved ? 'CSPS 审核通过' : '已驳回'); onChanged() }
else message.error(res.data.message)
}
async function issue(reviewID) {
const res = await call('regulator', 'POST', '/content/issue', { review_id: reviewID, issuer: '陕西IPTV运营公司' })
if (res.ok) { message.success(`发码成功:${res.data.data.ma_code}`); onChanged() }
else message.error(res.data.message)
}
async function ingest(r) {
const res = await call('reviewer', 'POST', '/content/ingest', { ma_code: r.ma_code, content_twin_id: r.content_twin_id, media_asset_id: 'SXMEDIA-' + r.ma_code.slice(-4), lib_name: '陕西IPTV媒体资源库' })
if (res.ok) { message.success('已入媒资库'); onChanged() }
else message.error(res.data.message)
}
async function publish(r) {
const cert = `CERT|${r.ma_code}|x|x` // 演示证书;真实证书在发码时返回
const res = await call('reviewer', 'POST', '/content/publish', { ma_code: r.ma_code, certificate: cert })
if (res.ok) { message.success('已发布给运营商'); onChanged() }
else message.error(res.data.message + '(提示:发布需发码时的原始证书,演示可用全流程页)')
}
const reviewCols = [
{ title: '流水号', dataIndex: 'review_id' },
{ title: '作品', dataIndex: 'title' },
{ title: '类目', dataIndex: 'category', render: (c) => <Tag>{catLabel[c] || c}</Tag> },
{ title: 'CP', dataIndex: 'cp_name', ellipsis: true },
{ title: '操作', render: (_, r) => (
<Space>
<Button size="small" type="primary" onClick={() => review(r.review_id, true)}>审核通过</Button>
<Button size="small" danger onClick={() => review(r.review_id, false)}>驳回</Button>
</Space>
) },
]
const issueCols = [
{ title: '流水号', dataIndex: 'review_id' },
{ title: '作品', dataIndex: 'title' },
{ title: '类目', dataIndex: 'category', render: (c) => <Tag color="blue">{catLabel[c] || c}</Tag> },
{ title: '操作', render: (_, r) => <Button size="small" type="primary" onClick={() => issue(r.review_id)}>发码签发</Button> },
]
const contentCols = (action, label, color) => [
{ title: 'MA 码', dataIndex: 'ma_code', render: (v) => <Text code copyable>{v}</Text> },
{ title: '作品', dataIndex: 'title' },
{ title: '状态', dataIndex: 'status', render: (s) => <Tag>{s}</Tag> },
{ title: '操作', render: (_, r) => <Button size="small" type="primary" onClick={() => action(r)}>{label}</Button> },
]
const queue = (title, count, table) => (
<Card size="small" title={<Space>{title}<Badge count={count} showZero color={count ? '#1677ff' : '#ccc'} /></Space>} style={{ marginBottom: 12 }}>
{table}
</Card>
)
return (
<div>
<Button icon={<ReloadOutlined />} size="small" onClick={load} style={{ marginBottom: 12 }}>刷新队列</Button>
{queue('① 待审队列(CSPS 合规审核)', pending.length,
<Table rowKey="review_id" size="small" pagination={false} columns={reviewCols} dataSource={pending}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无待审" /> }} />)}
{queue('② 待发码队列(审核通过 → 发码)', toIssue.length,
<Table rowKey="review_id" size="small" pagination={false} columns={issueCols} dataSource={toIssue}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无待发码" /> }} />)}
{queue('③ 待入库队列(已发码 → 入媒资库)', toIngest.length,
<Table rowKey="ma_code" size="small" pagination={false}
columns={contentCols(ingest, '入媒资库', 'green')} dataSource={toIngest}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无待入库" /> }} />)}
{queue('④ 待发布队列(已入库 → 发布运营商)', toPublish.length,
<Table rowKey="ma_code" size="small" pagination={false}
columns={contentCols(publish, '发布', 'orange')} dataSource={toPublish}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无待发布" /> }} />)}
</div>
)
}
// ============ 运营商工作台 ============
function OperatorDesk({ tick, onChanged }) {
const [toInject, setToInject] = useState([])
async function load() {
const res = await call('operator', 'GET', '/content/list?status=published')
setToInject(res.data?.data?.contents || [])
}
useEffect(() => { load() }, [tick])
async function inject(r, tamper) {
const fh = tamper ? 'TAMPERED-' + r.file_hash : r.file_hash
const res = await call('operator', 'POST', '/content/inject', {
content_twin_id: r.content_twin_id, ma_code: r.ma_code, file_sha256: fh,
operator_id: 'CT-SX-IPTV', cdn_endpoint: 'cdn://ct-sx/vod/' + r.ma_code.slice(-4),
})
if (res.ok) { message.success(`注入成功 ${res.data.data.distribution_id}`); onChanged() }
else message.warning('注入校验:' + (res.data.message || '哈希不匹配被拒'))
}
const cols = [
{ title: 'MA 码', dataIndex: 'ma_code', render: (v) => <Text code>{v}</Text> },
{ title: '作品', dataIndex: 'title' },
{ title: '操作', render: (_, r) => (
<Space>
<Button size="small" type="primary" onClick={() => inject(r, false)}>CDN 注入正确</Button>
<Button size="small" danger onClick={() => inject(r, true)}>模拟篡改注入</Button>
</Space>
) },
]
return (
<Card size="small" title={<Space>运营商工作台 · 待注入队列<Badge count={toInject.length} showZero /></Space>}
extra={<Button icon={<ReloadOutlined />} size="small" onClick={load}>刷新</Button>}>
<Table rowKey="ma_code" size="small" pagination={false} columns={cols} dataSource={toInject}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无待注入(需先在审核台发布)" /> }} />
</Card>
)
}
// ============ 总览看板 ============
function Overview({ tick }) {
const [counts, setCounts] = useState({})
async function load() {
const statuses = ['approved', 'in_library', 'published', 'revoked']
const results = await Promise.all([
call('regulator', 'GET', '/content/reviews?status=pending'),
call('regulator', 'GET', '/content/reviews?status=approved'),
...statuses.map((s) => call('regulator', 'GET', '/content/list?status=' + s)),
])
setCounts({
pending: results[0].data?.data?.reviews?.length || 0,
toIssue: results[1].data?.data?.reviews?.length || 0,
toIngest: results[2].data?.data?.contents?.length || 0,
toPublish: results[3].data?.data?.contents?.length || 0,
published: results[4].data?.data?.contents?.length || 0,
revoked: results[5].data?.data?.contents?.length || 0,
})
}
useEffect(() => { load() }, [tick])
const items = [
{ label: '待审', v: counts.pending, c: '#faad14' },
{ label: '待发码', v: counts.toIssue, c: '#1677ff' },
{ label: '待入库', v: counts.toIngest, c: '#52c41a' },
{ label: '待发布', v: counts.toPublish, c: '#fa8c16' },
{ label: '已发布(流通中)', v: counts.published, c: '#13c2c2' },
{ label: '已下架', v: counts.revoked, c: '#f5222d' },
]
return (
<Card size="small" title="全局看板(各环节在途数量)" extra={<Button icon={<ReloadOutlined />} size="small" onClick={load}>刷新</Button>}>
<Space size="large" wrap>
{items.map((it) => (
<Card key={it.label} size="small" style={{ width: 130, textAlign: 'center', borderTop: `3px solid ${it.c}` }}>
<div style={{ fontSize: 28, fontWeight: 700, color: it.c }}>{it.v ?? 0}</div>
<div style={{ color: '#888' }}>{it.label}</div>
</Card>
))}
</Space>
</Card>
)
}
// ============ 监管片库 ============
function LibraryDesk({ tick, onChanged }) {
const [filter, setFilter] = useState('all')
const [rows, setRows] = useState([])
const [loading, setLoading] = useState(false)
const [detail, setDetail] = useState(null) // {maCode, mappings, episodes}
async function load() {
setLoading(true)
const status = filter === 'all' ? '' : filter
const res = await call('regulator', 'GET', '/content/list?status=' + status)
setRows(res.data?.data?.contents || [])
setLoading(false)
}
useEffect(() => { load() }, [tick, filter])
function confirmTakedown(r) {
Modal.confirm({
title: '违规应急下架',
content: `确认对《${r.title}》(${r.ma_code}) 执行全网下架?将解析三方编码秒级同步。`,
okText: '确认下架', okType: 'danger', cancelText: '取消',
onOk: async () => {
const res = await api.takedown(r.ma_code, '监管片库应急下架')
if (res.ok) {
message.success('已全网下架,受影响 CDN: ' + (res.data.data.cdn_endpoints || []).join(', '))
onChanged()
} else message.error(res.data.message || '下架失败')
},
})
}
async function viewDetail(r) {
const [m, e] = await Promise.all([api.mappings(r.ma_code), api.episodes(r.ma_code)])
setDetail({
content: r,
mappings: m.data?.data?.mappings || [],
cdn: m.data?.data?.cdn_endpoints || [],
episodes: e.data?.data?.episodes || [],
})
}
function takedownEpisode(maCode, episode) {
Modal.confirm({
title: `集级下架 · 第 ${episode}`,
content: `只下架《${maCode}#E${String(episode).padStart(2, '0')}》本集,整剧其他集继续流通。确认?`,
okText: '下架本集', okType: 'danger', cancelText: '取消',
onOk: async () => {
const res = await api.takedownEpisode(maCode, episode, '监管片库集级下架')
if (res.ok) {
message.success(`${episode} 集已下架`)
await viewDetail(detail.content) // 刷新弹窗
onChanged()
} else message.error(res.data.message || '下架失败')
},
})
}
async function doRestore(r) {
const res = await api.restore(r.ma_code)
if (res.ok) { message.success('《' + r.title + '》已恢复上架'); onChanged() }
else message.error(res.data.message || '恢复失败')
}
async function restoreEpisode(maCode, episode) {
const res = await api.restoreEpisode(maCode, episode)
if (res.ok) {
message.success('第 ' + episode + ' 集已恢复上架')
await viewDetail(detail.content)
onChanged()
} else message.error(res.data.message || '恢复失败')
}
const cols = [
{ title: 'MA 码', dataIndex: 'ma_code', render: (v) => <Text code copyable>{v}</Text> },
{ title: '作品', dataIndex: 'title' },
{ title: '集数', dataIndex: 'episode_count', width: 70 },
{ title: '状态', dataIndex: 'status', width: 110, render: (s) => {
const m = statusMeta[s] || { label: s, color: 'default' }
return <Tag color={m.color}>{m.label}</Tag>
} },
{ title: '操作', width: 240, render: (_, r) => (
<Space>
<Button size="small" onClick={() => viewDetail(r)}>详情</Button>
{r.status === 'revoked' ? (
<Button size="small" type="primary" ghost onClick={() => doRestore(r)}>恢复上架</Button>
) : (
<Button size="small" danger icon={<StopOutlined />} onClick={() => confirmTakedown(r)}>
应急下架
</Button>
)}
</Space>
) },
]
return (
<Card
size="small"
title={<Space>监管片库<Badge count={rows.length} showZero color="#1677ff" /></Space>}
extra={
<Space>
<Segmented value={filter} onChange={setFilter} options={[
{ label: '全部', value: 'all' },
{ label: '流通中', value: 'published' },
{ label: '在库', value: 'in_library' },
{ label: '待入库', value: 'approved' },
{ label: '已下架', value: 'revoked' },
]} />
<Button icon={<ReloadOutlined />} size="small" onClick={load}>刷新</Button>
</Space>
}
>
<Table rowKey="ma_code" size="small" loading={loading} columns={cols} dataSource={rows}
pagination={{ pageSize: 8 }}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="片库暂无内容" /> }} />
<Modal open={!!detail} onCancel={() => setDetail(null)} footer={null} width={760}
title={detail ? `片库详情 · ${detail.content.title}` : ''}>
{detail && (
<>
<Descriptions bordered size="small" column={1} style={{ marginBottom: 12 }}>
<Descriptions.Item label="MA 码">{detail.content.ma_code}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={(statusMeta[detail.content.status] || {}).color}>
{(statusMeta[detail.content.status] || {}).label || detail.content.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="整剧哈希">{detail.content.file_hash || '-'}</Descriptions.Item>
</Descriptions>
<Card size="small" title="三方编码映射" style={{ marginBottom: 12 }}>
<Table rowKey={(r, i) => i} size="small" pagination={false}
dataSource={detail.mappings}
columns={[
{ title: '角色', dataIndex: 'party' },
{ title: '编码', dataIndex: 'party_id' },
{ title: '名称', dataIndex: 'party_name', render: (v) => v || '-' },
{ title: 'CDN', dataIndex: 'cdn_endpoint', render: (v) => v || '-' },
]} />
</Card>
<Card size="small" title={`集级哈希(${detail.episodes.length} 集)· 可单集下架`}>
<Table rowKey="episode" size="small" pagination={false}
dataSource={detail.episodes}
columns={[
{ title: '集', dataIndex: 'episode', width: 50 },
{ title: '子标识', render: (_, r) => <Text code>{`${detail.content.ma_code}#E${String(r.episode).padStart(2, '0')}`}</Text> },
{ title: '哈希', dataIndex: 'hash_value', ellipsis: true },
{ title: '状态', width: 80, render: (_, r) => r.revoked ? <Tag color="red">已下架</Tag> : <Tag color="green">流通中</Tag> },
{ title: '操作', width: 110, render: (_, r) => (
r.revoked
? <Button size="small" type="primary" ghost onClick={() => restoreEpisode(detail.content.ma_code, r.episode)}>恢复本集</Button>
: <Button size="small" danger onClick={() => takedownEpisode(detail.content.ma_code, r.episode)}>下架本集</Button>
) },
]} />
</Card>
</>
)}
</Modal>
</Card>
)
}
export default function RoleDesk() {
const [tick, bump] = useTick()
return (
<div>
<div style={{ marginBottom: 16 }}><Overview tick={tick} /></div>
<Tabs
type="card"
items={[
{ key: 'cp', label: '🟢 内容提供商', children: <CPDesk onChanged={bump} /> },
{ key: 'reviewer', label: '🔵 审核监管(CSPS/媒资)', children: <ReviewerDesk tick={tick} onChanged={bump} /> },
{ key: 'operator', label: '🟠 运营商', children: <OperatorDesk tick={tick} onChanged={bump} /> },
{ key: 'library', label: '🔴 监管片库(下架处置)', children: <LibraryDesk tick={tick} onChanged={bump} /> },
]}
/>
</div>
)
}
+68
View File
@@ -0,0 +1,68 @@
// API 客户端:Web Crypto 实现 HMAC-SHA256 签名,与后端 httpx.Sign 一致。
//
// ⚠️ 安全提示:四角色密钥放前端仅用于 MVP/演示。
// 生产必须改为「控制台 BFF + 会话令牌」,密钥不下发浏览器。
const enc = new TextEncoder()
async function hmacSha256Base64(secret, message) {
const key = await crypto.subtle.importKey(
'raw', enc.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
)
const sig = await crypto.subtle.sign('HMAC', key, enc.encode(message))
let bin = ''
for (const b of new Uint8Array(sig)) bin += String.fromCharCode(b)
return btoa(bin)
}
// 四角色演示密钥(与 api-svc 预置一致)
export const ROLE_KEYS = {
regulator: { apiKey: 'ak-regulator', apiSecret: 'sk-regulator', label: '监管主体' },
reviewer: { apiKey: 'ak-reviewer', apiSecret: 'sk-reviewer', label: '审核/媒资' },
cp: { apiKey: 'ak-cp', apiSecret: 'sk-cp', label: '内容提供商' },
operator: { apiKey: 'ak-operator', apiSecret: 'sk-operator', label: '运营商' },
}
async function request(role, method, path, body) {
const cred = ROLE_KEYS[role]
const signPath = '/api/v1' + path.split('?')[0]
const sig = await hmacSha256Base64(cred.apiSecret, method + '\n' + signPath)
const headers = { Authorization: `TCS ${cred.apiKey}:${sig}` }
const opts = { method, headers }
if (body !== undefined) {
headers['Content-Type'] = 'application/json'
opts.body = JSON.stringify(body)
}
const resp = await fetch('/api/v1' + path, opts)
const data = await resp.json().catch(() => ({}))
return { status: resp.status, ok: resp.status >= 200 && resp.status < 300, data }
}
// 通用:按角色发起请求(供多角色工作台使用)
export function call(role, method, path, body) {
return request(role, method, path, body)
}
export const api = {
// 全流程各步骤(标注发起角色)
register: (body) => request('cp', 'POST', '/content/register', body),
issue: (body) => request('regulator', 'POST', '/content/issue', body),
csps: (body) => request('reviewer', 'POST', '/content/csps-result', body),
ingest: (body) => request('reviewer', 'POST', '/content/ingest', body),
publish: (body) => request('reviewer', 'POST', '/content/publish', body),
inject: (body) => request('operator', 'POST', '/content/inject', body),
// 监管功能
verify: (maCode, fileHash) => request('regulator', 'POST', '/content/verify', { ma_code: maCode, file_sha256: fileHash }),
mappings: (maCode) => request('regulator', 'GET', '/content/mappings?ma_code=' + encodeURIComponent(maCode)),
takedown: (maCode, reason) => request('regulator', 'POST', '/content/takedown', { ma_code: maCode, reason }),
takedownEpisode: (maCode, episode, reason) => request('regulator', 'POST', '/content/takedown-episode', { ma_code: maCode, episode, reason }),
restore: (maCode) => request('regulator', 'POST', '/content/restore', { ma_code: maCode }),
restoreEpisode: (maCode, episode) => request('regulator', 'POST', '/content/restore-episode', { ma_code: maCode, episode }),
// 集级粒度(一剧一码 + 集级哈希)
episodes: (maCode) => request('regulator', 'GET', '/content/episodes?ma_code=' + encodeURIComponent(maCode)),
verifyEpisode: (maCode, episode, fileHash) => request('regulator', 'POST', '/content/verify-episode', { ma_code: maCode, episode, file_sha256: fileHash }),
// 工作队列(多角色工作台)
reviews: (role, status) => request(role, 'GET', '/content/reviews?status=' + status),
list: (role, status) => request(role, 'GET', '/content/list?status=' + status),
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import App from './App.jsx'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<ConfigProvider locale={zhCN}>
<App />
</ConfigProvider>
</React.StrictMode>
)
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// 开发态将 /api 代理到 api-svc(:8080),避免跨域
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})