init: AIGC-Hub/AVCC 方案文档 + TCS-IPTV 内容可信锁定系统 MVP
- 方案文档: AVCC 体系建设、IPTV TCS 需求(0-req)/PRD(1-prd)/任务(2-task)/二三四期任务 - tcs-iptv: Go 后端(哈希SDK/MA码生成/可信数据空间mock/业务编排/HTTP API+HMAC鉴权) - web-console: React+AntD 监管大屏(角色工作台/全流程演示/监管片库) - 一剧一码+集级哈希, 集级下架/恢复, 全栈测试通过
This commit is contained in:
@@ -0,0 +1,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
|
||||
@@ -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 已加入 PATH(Postgres.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
|
||||
@@ -0,0 +1,79 @@
|
||||
# TCS-IPTV 内容可信锁定系统
|
||||
|
||||
> MA码(监管身份)+ 哈希码(技术指纹)双锚定,在 CP / 审核和监管部门 / 运营商 三方系统之上建立"可信身份映射层"。
|
||||
>
|
||||
> 上游文档:`../0-req-IPTV.md`(需求)、`../1-prd-IPTV.md`(PRD)、`../2-task-IPTV.md`(任务)
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 后端 / 链交互 / 哈希SDK:Go 1.23 + Gin
|
||||
- 智能合约:Go(ChainMaker 链原生)
|
||||
- 联盟链:长安链 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 |
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 测试网后落地替换。
|
||||
@@ -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;
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)})
|
||||
}
|
||||
@@ -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, "下架端到端应在秒级内(目标分钟级)")
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Package chain 定义可信数据空间(联盟链)的客户端抽象。
|
||||
// MVP 提供内存 mock 实现,使业务逻辑可在无真实 ChainMaker 网络时开发与测试;
|
||||
// 后续以 ChainMaker Go SDK 实现替换,接口不变。
|
||||
// 对应需求:需求16(智能合约方法)、需求3/4(签发与验真)、需求14(权限)。
|
||||
package chain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/tcs-iptv/tcs/internal/model"
|
||||
)
|
||||
|
||||
// Role 调用方角色,用于合约级权限控制(需求14)。
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleRegulator Role = "regulator" // 监管主体:唯一可 issueMA / 下架
|
||||
RoleReviewer Role = "reviewer" // 审核主体(CSPS/媒资库)
|
||||
RoleCP Role = "cp" // 内容提供商
|
||||
RoleOperator Role = "operator" // 运营商
|
||||
)
|
||||
|
||||
// 错误定义。
|
||||
var (
|
||||
ErrPermissionDenied = errors.New("chain: permission denied")
|
||||
ErrMANotIssued = errors.New("chain: MA not issued")
|
||||
ErrMAAlreadyIssued = errors.New("chain: MA already issued (1:1 binding immutable)")
|
||||
ErrHashExists = errors.New("chain: content hash already exists")
|
||||
ErrNotFound = errors.New("chain: not found")
|
||||
)
|
||||
|
||||
// IssueRequest 签发 MA 码并强绑定哈希包。
|
||||
type IssueRequest struct {
|
||||
MACode string
|
||||
ContentTwinID string
|
||||
MerkleRoot string
|
||||
FileHash string
|
||||
PerceptualHash string
|
||||
Episodes []model.EpisodeHash // 集级哈希(分集内容)
|
||||
Content model.Content
|
||||
}
|
||||
|
||||
// VerifyResult 哈希验真结果(需求4-AC4)。
|
||||
type VerifyResult struct {
|
||||
Valid bool `json:"valid"`
|
||||
MACode string `json:"ma_code"`
|
||||
BoundHash string `json:"bound_hash"`
|
||||
SubmittedHash string `json:"submitted_hash"`
|
||||
Match bool `json:"match"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// MappingsResult 映射查询结果(需求11/17)。
|
||||
type MappingsResult struct {
|
||||
MACode string `json:"ma_code"`
|
||||
Mappings []model.Mapping `json:"mappings"`
|
||||
CDNEndpoints []string `json:"cdn_endpoints"`
|
||||
}
|
||||
|
||||
// Client 是可信数据空间的统一访问接口。
|
||||
// 业务服务只依赖此接口,不感知底层是 mock 还是 ChainMaker。
|
||||
type Client interface {
|
||||
// IssueMA 签发 MA 码并与哈希包 1:1 强绑定(仅监管主体)。
|
||||
IssueMA(role Role, req IssueRequest) (txID string, err error)
|
||||
// RegisterHashBinding 追加哈希绑定(如转码版,建立父子关系)。
|
||||
RegisterHashBinding(role Role, b model.HashBinding) (txID string, err error)
|
||||
// RegisterMapping 注册三方编码映射(MA 必须已签发)。
|
||||
RegisterMapping(role Role, m model.Mapping) (txID string, err error)
|
||||
// VerifyHash 按 MA 码校验提交哈希是否与绑定哈希一致。
|
||||
VerifyHash(maCode, fileHash string) (VerifyResult, error)
|
||||
// VerifyEpisodeHash 按 MA 码+集号校验该集哈希。
|
||||
VerifyEpisodeHash(maCode string, episode int, fileHash string) (VerifyResult, error)
|
||||
// ListEpisodes 返回某 MA 码下的全部集级哈希绑定。
|
||||
ListEpisodes(maCode string) ([]model.HashBinding, error)
|
||||
// HashExists 判断内容哈希是否已存在(防换壳重发)。
|
||||
HashExists(fileHash string) (maCode string, exists bool)
|
||||
// QueryContent 查询内容主记录。
|
||||
QueryContent(maCode string) (model.Content, error)
|
||||
// ListContents 按状态列出内容(空状态返回全部)。
|
||||
ListContents(status string) ([]model.Content, error)
|
||||
// QueryMappings 查询 MA 码绑定的全部三方映射与 CDN 端点。
|
||||
QueryMappings(maCode string) (MappingsResult, error)
|
||||
// RecordVersionChange 记录版本变更(绑定断裂触发重审)。
|
||||
RecordVersionChange(vc model.VersionChange) (txID string, err error)
|
||||
// Revoke 下架(仅监管主体),返回受影响的映射。
|
||||
Revoke(role Role, maCode, reason string) (MappingsResult, error)
|
||||
// RevokeEpisode 集级下架(仅监管主体):只下架指定集,整剧其他集不受影响。
|
||||
RevokeEpisode(role Role, maCode string, episode int, reason string) error
|
||||
// Restore 恢复上架整剧(仅监管主体):下架状态恢复为流通中。
|
||||
Restore(role Role, maCode string) error
|
||||
// RestoreEpisode 恢复上架指定集(仅监管主体)。
|
||||
RestoreEpisode(role Role, maCode string, episode int) error
|
||||
// SetContentStatus 更新内容状态。
|
||||
SetContentStatus(maCode, status string) error
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tcs-iptv/tcs/internal/model"
|
||||
)
|
||||
|
||||
// MemoryChain 是 Client 的内存实现(MVP / 测试用)。
|
||||
// 严格执行合约级业务规则:签发权限、1:1 不可解绑、映射前置签发、防重复哈希。
|
||||
type MemoryChain struct {
|
||||
mu sync.RWMutex
|
||||
contents map[string]model.Content // maCode -> Content
|
||||
bindings map[string][]model.HashBinding // maCode -> bindings
|
||||
mappings map[string][]model.Mapping // maCode -> mappings
|
||||
versions map[string][]model.VersionChange
|
||||
hashIndex map[string]string // fileHash -> maCode(防换壳重发)
|
||||
txSeq int
|
||||
}
|
||||
|
||||
// NewMemoryChain 创建内存链客户端。
|
||||
func NewMemoryChain() *MemoryChain {
|
||||
return &MemoryChain{
|
||||
contents: make(map[string]model.Content),
|
||||
bindings: make(map[string][]model.HashBinding),
|
||||
mappings: make(map[string][]model.Mapping),
|
||||
versions: make(map[string][]model.VersionChange),
|
||||
hashIndex: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MemoryChain) nextTx(method string) string {
|
||||
m.txSeq++
|
||||
return fmt.Sprintf("tx-%s-%06d", method, m.txSeq)
|
||||
}
|
||||
|
||||
// IssueMA 仅监管主体可调用;MA 不可重复签发;哈希 1:1 强绑定不可解绑。
|
||||
func (m *MemoryChain) IssueMA(role Role, req IssueRequest) (string, error) {
|
||||
if role != RoleRegulator {
|
||||
return "", ErrPermissionDenied
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if _, ok := m.contents[req.MACode]; ok {
|
||||
return "", ErrMAAlreadyIssued
|
||||
}
|
||||
if existing, ok := m.hashIndex[req.FileHash]; ok {
|
||||
return "", fmt.Errorf("%w: bound to %s", ErrHashExists, existing)
|
||||
}
|
||||
|
||||
c := req.Content
|
||||
c.MACode = req.MACode
|
||||
c.ContentTwinID = req.ContentTwinID
|
||||
c.Status = model.StatusApproved
|
||||
if c.CreatedAt.IsZero() {
|
||||
c.CreatedAt = time.Now()
|
||||
}
|
||||
m.contents[req.MACode] = c
|
||||
|
||||
m.bindings[req.MACode] = []model.HashBinding{{
|
||||
ContentTwinID: req.ContentTwinID,
|
||||
HashType: model.HashFile,
|
||||
HashValue: req.FileHash,
|
||||
MerkleRoot: req.MerkleRoot,
|
||||
Version: "v1.0",
|
||||
CreatedBy: string(RoleRegulator),
|
||||
}}
|
||||
if req.PerceptualHash != "" {
|
||||
m.bindings[req.MACode] = append(m.bindings[req.MACode], model.HashBinding{
|
||||
ContentTwinID: req.ContentTwinID,
|
||||
HashType: model.HashPerceptual,
|
||||
HashValue: req.PerceptualHash,
|
||||
Version: "v1.0",
|
||||
CreatedBy: string(RoleRegulator),
|
||||
})
|
||||
}
|
||||
m.hashIndex[req.FileHash] = req.MACode
|
||||
|
||||
// 集级哈希绑定(分集内容):每集独立哈希,挂在同一 MA 码下。
|
||||
for _, ep := range req.Episodes {
|
||||
m.bindings[req.MACode] = append(m.bindings[req.MACode], model.HashBinding{
|
||||
ContentTwinID: req.ContentTwinID,
|
||||
HashType: model.HashFile,
|
||||
HashValue: ep.FileSHA256,
|
||||
MerkleRoot: ep.MerkleRoot,
|
||||
Episode: ep.Episode,
|
||||
Resolution: ep.Resolution,
|
||||
Duration: ep.Duration,
|
||||
Version: "v1.0",
|
||||
CreatedBy: string(RoleRegulator),
|
||||
})
|
||||
if ep.FileSHA256 != "" {
|
||||
if _, ok := m.hashIndex[ep.FileSHA256]; !ok {
|
||||
m.hashIndex[ep.FileSHA256] = req.MACode
|
||||
}
|
||||
}
|
||||
}
|
||||
return m.nextTx("issueMA"), nil
|
||||
}
|
||||
|
||||
// RegisterHashBinding 追加哈希绑定(如转码版)。MA 必须已签发。
|
||||
func (m *MemoryChain) RegisterHashBinding(role Role, b model.HashBinding) (string, error) {
|
||||
if role != RoleReviewer && role != RoleRegulator {
|
||||
return "", ErrPermissionDenied
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
maCode := m.maCodeByCTID(b.ContentTwinID)
|
||||
if maCode == "" {
|
||||
return "", ErrMANotIssued
|
||||
}
|
||||
m.bindings[maCode] = append(m.bindings[maCode], b)
|
||||
if b.HashType == model.HashFile || b.HashType == model.HashTranscoded {
|
||||
if _, ok := m.hashIndex[b.HashValue]; !ok {
|
||||
m.hashIndex[b.HashValue] = maCode
|
||||
}
|
||||
}
|
||||
return m.nextTx("registerHashBinding"), nil
|
||||
}
|
||||
|
||||
// RegisterMapping 注册三方编码映射;MA 必须已签发(需求16-AC3)。
|
||||
func (m *MemoryChain) RegisterMapping(role Role, mp model.Mapping) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
maCode := m.maCodeByCTID(mp.ContentTwinID)
|
||||
if maCode == "" {
|
||||
return "", ErrMANotIssued
|
||||
}
|
||||
m.mappings[maCode] = append(m.mappings[maCode], mp)
|
||||
return m.nextTx("registerMapping"), nil
|
||||
}
|
||||
|
||||
// VerifyHash 按 MA 码校验提交哈希。
|
||||
func (m *MemoryChain) VerifyHash(maCode, fileHash string) (VerifyResult, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
bs, ok := m.bindings[maCode]
|
||||
if !ok {
|
||||
return VerifyResult{Valid: false, MACode: maCode, SubmittedHash: fileHash}, ErrMANotIssued
|
||||
}
|
||||
for _, b := range bs {
|
||||
if b.HashType == model.HashFile || b.HashType == model.HashTranscoded {
|
||||
if b.HashValue == fileHash {
|
||||
return VerifyResult{
|
||||
Valid: true, MACode: maCode,
|
||||
BoundHash: b.HashValue, SubmittedHash: fileHash,
|
||||
Match: true, Version: b.Version,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
// 取首个文件哈希作为 bound 参考
|
||||
bound := ""
|
||||
for _, b := range bs {
|
||||
if b.HashType == model.HashFile {
|
||||
bound = b.HashValue
|
||||
break
|
||||
}
|
||||
}
|
||||
return VerifyResult{Valid: true, MACode: maCode, BoundHash: bound, SubmittedHash: fileHash, Match: false}, nil
|
||||
}
|
||||
|
||||
// HashExists 判断内容哈希是否已存在。
|
||||
func (m *MemoryChain) HashExists(fileHash string) (string, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
ma, ok := m.hashIndex[fileHash]
|
||||
return ma, ok
|
||||
}
|
||||
|
||||
// VerifyEpisodeHash 按 MA 码+集号校验该集哈希。
|
||||
func (m *MemoryChain) VerifyEpisodeHash(maCode string, episode int, fileHash string) (VerifyResult, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
bs, ok := m.bindings[maCode]
|
||||
if !ok {
|
||||
return VerifyResult{Valid: false, MACode: maCode, SubmittedHash: fileHash}, ErrMANotIssued
|
||||
}
|
||||
var bound string
|
||||
for _, b := range bs {
|
||||
if b.Episode == episode && (b.HashType == model.HashFile || b.HashType == model.HashTranscoded) {
|
||||
if bound == "" {
|
||||
bound = b.HashValue
|
||||
}
|
||||
if b.HashValue == fileHash {
|
||||
return VerifyResult{
|
||||
Valid: true, MACode: maCode,
|
||||
BoundHash: b.HashValue, SubmittedHash: fileHash,
|
||||
Match: true, Version: b.Version,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if bound == "" {
|
||||
return VerifyResult{Valid: false, MACode: maCode, SubmittedHash: fileHash}, ErrNotFound
|
||||
}
|
||||
return VerifyResult{Valid: true, MACode: maCode, BoundHash: bound, SubmittedHash: fileHash, Match: false}, nil
|
||||
}
|
||||
|
||||
// ListEpisodes 返回某 MA 码下的全部集级哈希绑定(episode > 0)。
|
||||
func (m *MemoryChain) ListEpisodes(maCode string) ([]model.HashBinding, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
bs, ok := m.bindings[maCode]
|
||||
if !ok {
|
||||
return nil, ErrMANotIssued
|
||||
}
|
||||
var out []model.HashBinding
|
||||
for _, b := range bs {
|
||||
if b.Episode > 0 {
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// QueryContent 查询内容主记录。
|
||||
func (m *MemoryChain) QueryContent(maCode string) (model.Content, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
c, ok := m.contents[maCode]
|
||||
if !ok {
|
||||
return model.Content{}, ErrNotFound
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// ListContents 按状态列出内容(空状态返回全部),附带整剧文件哈希便于演示。
|
||||
func (m *MemoryChain) ListContents(status string) ([]model.Content, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
var out []model.Content
|
||||
for ma, c := range m.contents {
|
||||
if status == "" || c.Status == status {
|
||||
// 附带整剧文件哈希(episode==0 的 file 绑定)
|
||||
for _, b := range m.bindings[ma] {
|
||||
if b.HashType == model.HashFile && b.Episode == 0 {
|
||||
c.FileHash = b.HashValue
|
||||
break
|
||||
}
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// QueryMappings 查询 MA 码绑定的全部映射与 CDN 端点。
|
||||
func (m *MemoryChain) QueryMappings(maCode string) (MappingsResult, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if _, ok := m.contents[maCode]; !ok {
|
||||
return MappingsResult{}, ErrNotFound
|
||||
}
|
||||
res := MappingsResult{MACode: maCode, Mappings: m.mappings[maCode]}
|
||||
for _, mp := range m.mappings[maCode] {
|
||||
if mp.CDNEndpoint != "" {
|
||||
res.CDNEndpoints = append(res.CDNEndpoints, mp.CDNEndpoint)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// RecordVersionChange 记录版本变更。
|
||||
func (m *MemoryChain) RecordVersionChange(vc model.VersionChange) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
maCode := m.maCodeByCTID(vc.ContentTwinID)
|
||||
if maCode == "" {
|
||||
return "", ErrMANotIssued
|
||||
}
|
||||
m.versions[maCode] = append(m.versions[maCode], vc)
|
||||
return m.nextTx("recordVersionChange"), nil
|
||||
}
|
||||
|
||||
// Revoke 下架,仅监管主体。
|
||||
func (m *MemoryChain) Revoke(role Role, maCode, reason string) (MappingsResult, error) {
|
||||
if role != RoleRegulator {
|
||||
return MappingsResult{}, ErrPermissionDenied
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
c, ok := m.contents[maCode]
|
||||
if !ok {
|
||||
return MappingsResult{}, ErrNotFound
|
||||
}
|
||||
c.Status = model.StatusRevoked
|
||||
m.contents[maCode] = c
|
||||
|
||||
res := MappingsResult{MACode: maCode, Mappings: m.mappings[maCode]}
|
||||
for _, mp := range m.mappings[maCode] {
|
||||
if mp.CDNEndpoint != "" {
|
||||
res.CDNEndpoints = append(res.CDNEndpoints, mp.CDNEndpoint)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// RevokeEpisode 集级下架:只下架指定集,整剧其他集不受影响(仅监管主体)。
|
||||
func (m *MemoryChain) RevokeEpisode(role Role, maCode string, episode int, reason string) error {
|
||||
if role != RoleRegulator {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
bs, ok := m.bindings[maCode]
|
||||
if !ok {
|
||||
return ErrMANotIssued
|
||||
}
|
||||
found := false
|
||||
for i := range bs {
|
||||
if bs[i].Episode == episode {
|
||||
bs[i].Revoked = true
|
||||
bs[i].RevokedReason = reason
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return ErrNotFound
|
||||
}
|
||||
m.bindings[maCode] = bs
|
||||
return nil
|
||||
}
|
||||
|
||||
// Restore 恢复上架整剧:下架状态恢复为流通中(仅监管主体)。
|
||||
func (m *MemoryChain) Restore(role Role, maCode string) error {
|
||||
if role != RoleRegulator {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
c, ok := m.contents[maCode]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
c.Status = model.StatusPublished
|
||||
m.contents[maCode] = c
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestoreEpisode 恢复上架指定集(仅监管主体)。
|
||||
func (m *MemoryChain) RestoreEpisode(role Role, maCode string, episode int) error {
|
||||
if role != RoleRegulator {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
bs, ok := m.bindings[maCode]
|
||||
if !ok {
|
||||
return ErrMANotIssued
|
||||
}
|
||||
found := false
|
||||
for i := range bs {
|
||||
if bs[i].Episode == episode {
|
||||
bs[i].Revoked = false
|
||||
bs[i].RevokedReason = ""
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return ErrNotFound
|
||||
}
|
||||
m.bindings[maCode] = bs
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetContentStatus 更新内容状态。
|
||||
func (m *MemoryChain) SetContentStatus(maCode, status string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
c, ok := m.contents[maCode]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
c.Status = status
|
||||
m.contents[maCode] = c
|
||||
return nil
|
||||
}
|
||||
|
||||
// maCodeByCTID 内部辅助:通过 CTID 反查 MA 码(调用方已持锁)。
|
||||
func (m *MemoryChain) maCodeByCTID(ctid string) string {
|
||||
for ma, c := range m.contents {
|
||||
if c.ContentTwinID == ctid {
|
||||
return ma
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var _ Client = (*MemoryChain)(nil)
|
||||
@@ -0,0 +1,149 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tcs-iptv/tcs/internal/model"
|
||||
)
|
||||
|
||||
func newIssued(t *testing.T) *MemoryChain {
|
||||
t.Helper()
|
||||
c := NewMemoryChain()
|
||||
_, err := c.IssueMA(RoleRegulator, IssueRequest{
|
||||
MACode: "(京)网微剧审字(2025)第123号",
|
||||
ContentTwinID: "ctid-001",
|
||||
MerkleRoot: "merkle-root-1",
|
||||
FileHash: "filehash-1",
|
||||
Content: model.Content{Title: "示例剧集", EpisodeCount: 24},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return c
|
||||
}
|
||||
|
||||
func TestIssueMA_OnlyRegulator(t *testing.T) {
|
||||
c := NewMemoryChain()
|
||||
_, err := c.IssueMA(RoleCP, IssueRequest{MACode: "MA-1", ContentTwinID: "ct-1", FileHash: "h1"})
|
||||
assert.ErrorIs(t, err, ErrPermissionDenied)
|
||||
|
||||
_, err = c.IssueMA(RoleReviewer, IssueRequest{MACode: "MA-1", ContentTwinID: "ct-1", FileHash: "h1"})
|
||||
assert.ErrorIs(t, err, ErrPermissionDenied)
|
||||
|
||||
_, err = c.IssueMA(RoleRegulator, IssueRequest{MACode: "MA-1", ContentTwinID: "ct-1", FileHash: "h1"})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestIssueMA_NoReissue(t *testing.T) {
|
||||
c := newIssued(t)
|
||||
// 同 MA 码重复签发被拒(1:1 不可解绑/不可覆盖)
|
||||
_, err := c.IssueMA(RoleRegulator, IssueRequest{
|
||||
MACode: "(京)网微剧审字(2025)第123号", ContentTwinID: "ctid-001", FileHash: "other",
|
||||
})
|
||||
assert.ErrorIs(t, err, ErrMAAlreadyIssued)
|
||||
}
|
||||
|
||||
func TestIssueMA_DuplicateHashRejected(t *testing.T) {
|
||||
c := newIssued(t)
|
||||
// 换壳重发:不同 MA 码但相同内容哈希 → 拒绝
|
||||
_, err := c.IssueMA(RoleRegulator, IssueRequest{
|
||||
MACode: "(沪)网微剧审字(2025)第999号", ContentTwinID: "ctid-002", FileHash: "filehash-1",
|
||||
})
|
||||
assert.ErrorIs(t, err, ErrHashExists)
|
||||
}
|
||||
|
||||
func TestVerifyHash_MatchAndMismatch(t *testing.T) {
|
||||
c := newIssued(t)
|
||||
|
||||
res, err := c.VerifyHash("(京)网微剧审字(2025)第123号", "filehash-1")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Match)
|
||||
assert.True(t, res.Valid)
|
||||
|
||||
res, err = c.VerifyHash("(京)网微剧审字(2025)第123号", "tampered-hash")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, res.Match)
|
||||
assert.Equal(t, "filehash-1", res.BoundHash)
|
||||
}
|
||||
|
||||
func TestVerifyHash_UnknownMA(t *testing.T) {
|
||||
c := NewMemoryChain()
|
||||
_, err := c.VerifyHash("no-such-ma", "h")
|
||||
assert.ErrorIs(t, err, ErrMANotIssued)
|
||||
}
|
||||
|
||||
func TestRegisterMapping_RequiresIssuedMA(t *testing.T) {
|
||||
c := NewMemoryChain()
|
||||
// CTID 未签发 → 拒绝
|
||||
_, err := c.RegisterMapping(RoleOperator, model.Mapping{
|
||||
ContentTwinID: "ctid-x", Party: model.PartyOperator, PartyID: "OP-1",
|
||||
})
|
||||
assert.ErrorIs(t, err, ErrMANotIssued)
|
||||
|
||||
c = newIssued(t)
|
||||
_, err = c.RegisterMapping(RoleOperator, model.Mapping{
|
||||
ContentTwinID: "ctid-001", Party: model.PartyOperator,
|
||||
PartyID: "CT-IPTV-008923", CDNEndpoint: "cdn://ct-gd/iptv/vod/008923",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
res, err := c.QueryMappings("(京)网微剧审字(2025)第123号")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, res.Mappings, 1)
|
||||
assert.Equal(t, []string{"cdn://ct-gd/iptv/vod/008923"}, res.CDNEndpoints)
|
||||
}
|
||||
|
||||
func TestTranscodedBinding_ParentChild(t *testing.T) {
|
||||
c := newIssued(t)
|
||||
_, err := c.RegisterHashBinding(RoleReviewer, model.HashBinding{
|
||||
ContentTwinID: "ctid-001",
|
||||
HashType: model.HashTranscoded,
|
||||
HashValue: "transcoded-h265-4k",
|
||||
ParentHash: "filehash-1",
|
||||
Version: "v1.0-h265",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 转码版哈希也能验真通过
|
||||
res, err := c.VerifyHash("(京)网微剧审字(2025)第123号", "transcoded-h265-4k")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Match)
|
||||
}
|
||||
|
||||
func TestRevoke_OnlyRegulator(t *testing.T) {
|
||||
c := newIssued(t)
|
||||
_, _ = c.RegisterMapping(RoleOperator, model.Mapping{
|
||||
ContentTwinID: "ctid-001", Party: model.PartyOperator,
|
||||
PartyID: "OP-1", CDNEndpoint: "cdn://x/y/z",
|
||||
})
|
||||
|
||||
_, err := c.Revoke(RoleOperator, "(京)网微剧审字(2025)第123号", "试图越权")
|
||||
assert.ErrorIs(t, err, ErrPermissionDenied)
|
||||
|
||||
res, err := c.Revoke(RoleRegulator, "(京)网微剧审字(2025)第123号", "违规")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, res.CDNEndpoints, "cdn://x/y/z")
|
||||
|
||||
ct, _ := c.QueryContent("(京)网微剧审字(2025)第123号")
|
||||
assert.Equal(t, model.StatusRevoked, ct.Status)
|
||||
}
|
||||
|
||||
func TestRecordVersionChange(t *testing.T) {
|
||||
c := newIssued(t)
|
||||
_, err := c.RecordVersionChange(model.VersionChange{
|
||||
ContentTwinID: "ctid-001", Version: "v2.0",
|
||||
ChangeReason: "片尾字幕修正", PrevHash: "filehash-1", NewHash: "filehash-2",
|
||||
ReauditRequired: true, AffectedEpisode: 24,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestHashExists(t *testing.T) {
|
||||
c := newIssued(t)
|
||||
ma, ok := c.HashExists("filehash-1")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "(京)网微剧审字(2025)第123号", ma)
|
||||
|
||||
_, ok = c.HashExists("unknown")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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" // 已下架
|
||||
)
|
||||
@@ -0,0 +1,176 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tcs-iptv/tcs/internal/chain"
|
||||
"github.com/tcs-iptv/tcs/internal/hash"
|
||||
"github.com/tcs-iptv/tcs/internal/model"
|
||||
)
|
||||
|
||||
// ---- 工作包7:转码版哈希绑定(需求5) ----
|
||||
// 注:CSPS 合规审核已前移至发码前(service.ReviewCSPS),此处仅处理转码。
|
||||
|
||||
// BindTranscoded 绑定转码版哈希,与母版建立父子关系(需求5-AC3/AC4/AC5)。
|
||||
func (s *Service) BindTranscoded(role chain.Role, ctid, parentFileHash, transcodedHash, format, resolution, version string) (string, error) {
|
||||
if transcodedHash == "" {
|
||||
return "", ErrIncompleteHashPkg
|
||||
}
|
||||
return s.chain.RegisterHashBinding(role, model.HashBinding{
|
||||
ContentTwinID: ctid,
|
||||
HashType: model.HashTranscoded,
|
||||
HashValue: transcodedHash,
|
||||
ParentHash: parentFileHash,
|
||||
FileFormat: format,
|
||||
Resolution: resolution,
|
||||
Version: version,
|
||||
CreatedBy: string(role),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 工作包8:媒体资源库入库、发布与映射(需求6) ----
|
||||
|
||||
// IngestToLibrary 审核合格内容入媒资库,建立媒资编码映射(需求6-AC1/AC2/AC3)。
|
||||
func (s *Service) IngestToLibrary(role chain.Role, maCode, ctid, mediaAssetID, libName string) error {
|
||||
c, err := s.chain.QueryContent(maCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 门禁:未审核通过/未绑定 MA 码不得入库可发布状态
|
||||
if c.Status == model.StatusRejected || c.Status == model.StatusRevoked {
|
||||
return ErrNotApproved
|
||||
}
|
||||
if _, err := s.chain.RegisterMapping(role, model.Mapping{
|
||||
ContentTwinID: ctid,
|
||||
Party: model.PartyReviewer,
|
||||
PartyID: mediaAssetID,
|
||||
PartyName: libName,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.chain.SetContentStatus(maCode, model.StatusInLibrary)
|
||||
}
|
||||
|
||||
// PublishRequest 从媒资库向运营商发布的请求(需求6-AC4)。
|
||||
type PublishRequest struct {
|
||||
MACode string
|
||||
Certificate string // 必须携带 MA码+哈希证书
|
||||
}
|
||||
|
||||
// PublishToOperator 校验证书后将内容置为已发布(需求6-AC4/AC5、需求3-AC8)。
|
||||
func (s *Service) PublishToOperator(req PublishRequest) error {
|
||||
c, err := s.chain.QueryContent(req.MACode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Status != model.StatusInLibrary && c.Status != model.StatusPublished {
|
||||
return ErrNotApproved
|
||||
}
|
||||
// 发布必须携带证书(含 MA 码)
|
||||
if req.Certificate == "" || !certContainsMA(req.Certificate, req.MACode) {
|
||||
return ErrNoCertificate
|
||||
}
|
||||
return s.chain.SetContentStatus(req.MACode, model.StatusPublished)
|
||||
}
|
||||
|
||||
// ---- 工作包9:CDN 注入校验(需求7) ----
|
||||
|
||||
// InjectResult CDN 注入校验结果。
|
||||
type InjectResult struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
DistributionID string `json:"distribution_id,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// InjectToCDN 运营商注入 CDN 前校验哈希;匹配则放行并注册运营商映射(需求7-AC1~AC4)。
|
||||
func (s *Service) InjectToCDN(role chain.Role, ctid, maCode, injectFileHash, operatorID, cdnEndpoint string) (InjectResult, error) {
|
||||
// 内容须处于已发布状态
|
||||
c, err := s.chain.QueryContent(maCode)
|
||||
if err != nil {
|
||||
return InjectResult{}, err
|
||||
}
|
||||
if c.Status == model.StatusRevoked {
|
||||
return InjectResult{Allowed: false, Reason: "内容已下架"}, ErrNotApproved
|
||||
}
|
||||
|
||||
res, err := s.chain.VerifyHash(maCode, injectFileHash)
|
||||
if err != nil {
|
||||
return InjectResult{Allowed: false, Reason: err.Error()}, err
|
||||
}
|
||||
if !res.Match {
|
||||
// 不匹配:拒绝注入(需求7-AC3、需求15-AC2)
|
||||
return InjectResult{Allowed: false, Reason: "哈希不匹配,疑似篡改,拒绝注入并告警"}, ErrHashMismatch
|
||||
}
|
||||
|
||||
distID := s.nextID("DIST")
|
||||
if _, err := s.chain.RegisterMapping(role, model.Mapping{
|
||||
ContentTwinID: ctid,
|
||||
Party: model.PartyOperator,
|
||||
PartyID: operatorID,
|
||||
CDNEndpoint: cdnEndpoint,
|
||||
}); err != nil {
|
||||
return InjectResult{}, err
|
||||
}
|
||||
return InjectResult{Allowed: true, DistributionID: distID}, nil
|
||||
}
|
||||
|
||||
// ---- 工作包10:版本变更与重审(需求12) ----
|
||||
|
||||
// ReportVersionChange 上报内容变更:哈希变化判定绑定断裂,触发重审(需求12-AC1/AC2)。
|
||||
// 当提供 oldSegments/newSegments 时,定位被篡改的具体集(需求12-AC3)。
|
||||
func (s *Service) ReportVersionChange(ctid, reason, prevHash, newHash string, oldSegments, newSegments []string) ([]int, error) {
|
||||
var changedEpisodes []int
|
||||
affected := 0
|
||||
if len(oldSegments) > 0 || len(newSegments) > 0 {
|
||||
changedEpisodes = hash.LocateChangedLeaves(oldSegments, newSegments)
|
||||
if len(changedEpisodes) > 0 {
|
||||
affected = changedEpisodes[0] + 1 // 1-based 集号
|
||||
}
|
||||
}
|
||||
_, err := s.chain.RecordVersionChange(model.VersionChange{
|
||||
ContentTwinID: ctid,
|
||||
Version: "v-next",
|
||||
ChangeReason: reason,
|
||||
PrevHash: prevHash,
|
||||
NewHash: newHash,
|
||||
ReauditRequired: true,
|
||||
ReauditStatus: "pending",
|
||||
AffectedEpisode: affected,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 转为 1-based 集号返回
|
||||
episodes := make([]int, len(changedEpisodes))
|
||||
for i, idx := range changedEpisodes {
|
||||
episodes[i] = idx + 1
|
||||
}
|
||||
return episodes, nil
|
||||
}
|
||||
|
||||
// ---- 工作包14:违规应急下架(需求11) ----
|
||||
|
||||
// Takedown 监管主体一键下架:解析 MA 码绑定的三方编码与 CDN 端点(需求11-AC1/AC2/AC4)。
|
||||
func (s *Service) Takedown(role chain.Role, maCode, reason string) (chain.MappingsResult, error) {
|
||||
return s.chain.Revoke(role, maCode, reason)
|
||||
}
|
||||
|
||||
// TakedownEpisode 集级下架:只下架指定集,整剧其他集继续流通(仅监管主体)。
|
||||
func (s *Service) TakedownEpisode(role chain.Role, maCode string, episode int, reason string) error {
|
||||
return s.chain.RevokeEpisode(role, maCode, episode, reason)
|
||||
}
|
||||
|
||||
// Restore 恢复上架整剧(仅监管主体)。
|
||||
func (s *Service) Restore(role chain.Role, maCode string) error {
|
||||
return s.chain.Restore(role, maCode)
|
||||
}
|
||||
|
||||
// RestoreEpisode 恢复上架指定集(仅监管主体)。
|
||||
func (s *Service) RestoreEpisode(role chain.Role, maCode string, episode int) error {
|
||||
return s.chain.RestoreEpisode(role, maCode, episode)
|
||||
}
|
||||
|
||||
// certContainsMA 校验证书是否包含指定 MA 码。
|
||||
func certContainsMA(cert, maCode string) bool {
|
||||
return cert != "" && maCode != "" && strings.Contains(cert, maCode)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tcs-iptv/tcs/internal/chain"
|
||||
"github.com/tcs-iptv/tcs/internal/hash"
|
||||
)
|
||||
|
||||
// issueOne 完成一次"送审→CSPS审核→发码签发",返回 maCode、ctid、证书。
|
||||
func issueOne(t *testing.T, s *Service) (string, string, string) {
|
||||
t.Helper()
|
||||
sub, err := s.SubmitForReview(sampleSub())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.ReviewCSPS(sub.ReviewID, true, "reviewer-1")) // 审核在前
|
||||
issued, err := s.ApproveAndIssue(chain.RoleRegulator, sub.ReviewID, "北京市广播电视局")
|
||||
require.NoError(t, err)
|
||||
return issued.MACode, issued.ContentTwinID, issued.Certificate
|
||||
}
|
||||
|
||||
func TestCSPSAndTranscode(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, ctid, _ := issueOne(t, s)
|
||||
|
||||
_, err := s.BindTranscoded(chain.RoleReviewer, ctid, "filehash-abc",
|
||||
"transcoded-h265-4k", "H.265", "3840x2160", "v1.0-4k")
|
||||
require.NoError(t, err)
|
||||
|
||||
// 转码版也能验真通过
|
||||
res, err := s.Verify(maCode, "transcoded-h265-4k")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Match)
|
||||
}
|
||||
|
||||
func TestCSPSRejected(t *testing.T) {
|
||||
s := newService(t)
|
||||
sub, err := s.SubmitForReview(sampleSub())
|
||||
require.NoError(t, err)
|
||||
// CSPS 审核驳回 → 不得发码
|
||||
require.NoError(t, s.ReviewCSPS(sub.ReviewID, false, "reviewer-1"))
|
||||
_, err = s.ApproveAndIssue(chain.RoleRegulator, sub.ReviewID, "issuer")
|
||||
assert.ErrorIs(t, err, ErrNotApproved)
|
||||
}
|
||||
|
||||
func TestIssueRequiresCSPSApproval(t *testing.T) {
|
||||
s := newService(t)
|
||||
sub, err := s.SubmitForReview(sampleSub())
|
||||
require.NoError(t, err)
|
||||
// 未经 CSPS 审核直接发码 → 拒绝
|
||||
_, err = s.ApproveAndIssue(chain.RoleRegulator, sub.ReviewID, "issuer")
|
||||
assert.ErrorIs(t, err, ErrNotApproved)
|
||||
}
|
||||
|
||||
func TestIngestAndPublish(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, ctid, cert := issueOne(t, s)
|
||||
|
||||
require.NoError(t, s.IngestToLibrary(chain.RoleReviewer, maCode, ctid, "MEDIA-001", "广东IPTV媒资库"))
|
||||
|
||||
// 无证书发布被拒
|
||||
err := s.PublishToOperator(PublishRequest{MACode: maCode, Certificate: ""})
|
||||
assert.ErrorIs(t, err, ErrNoCertificate)
|
||||
|
||||
// 携带证书发布成功
|
||||
require.NoError(t, s.PublishToOperator(PublishRequest{MACode: maCode, Certificate: cert}))
|
||||
}
|
||||
|
||||
func TestInjectToCDN_MatchAndMismatch(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, ctid, cert := issueOne(t, s)
|
||||
require.NoError(t, s.IngestToLibrary(chain.RoleReviewer, maCode, ctid, "MEDIA-001", "媒资库"))
|
||||
require.NoError(t, s.PublishToOperator(PublishRequest{MACode: maCode, Certificate: cert}))
|
||||
|
||||
// 哈希匹配 → 允许注入
|
||||
res, err := s.InjectToCDN(chain.RoleOperator, ctid, maCode, "filehash-abc",
|
||||
"CT-IPTV-GD", "cdn://ct-gd/iptv/vod/008923")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Allowed)
|
||||
assert.NotEmpty(t, res.DistributionID)
|
||||
|
||||
// 哈希不匹配 → 拒绝注入
|
||||
res, err = s.InjectToCDN(chain.RoleOperator, ctid, maCode, "tampered-hash",
|
||||
"CT-IPTV-GD", "cdn://x")
|
||||
assert.ErrorIs(t, err, ErrHashMismatch)
|
||||
assert.False(t, res.Allowed)
|
||||
}
|
||||
|
||||
func TestInjectToCDN_RevokedBlocked(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, ctid, cert := issueOne(t, s)
|
||||
require.NoError(t, s.IngestToLibrary(chain.RoleReviewer, maCode, ctid, "MEDIA-001", "媒资库"))
|
||||
require.NoError(t, s.PublishToOperator(PublishRequest{MACode: maCode, Certificate: cert}))
|
||||
|
||||
// 下架后不得注入
|
||||
_, err := s.Takedown(chain.RoleRegulator, maCode, "违规")
|
||||
require.NoError(t, err)
|
||||
_, err = s.InjectToCDN(chain.RoleOperator, ctid, maCode, "filehash-abc", "OP", "cdn://x")
|
||||
assert.ErrorIs(t, err, ErrNotApproved)
|
||||
}
|
||||
|
||||
func TestTakedown_ResolvesMappings(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, ctid, cert := issueOne(t, s)
|
||||
require.NoError(t, s.IngestToLibrary(chain.RoleReviewer, maCode, ctid, "MEDIA-001", "媒资库"))
|
||||
require.NoError(t, s.PublishToOperator(PublishRequest{MACode: maCode, Certificate: cert}))
|
||||
_, _ = s.InjectToCDN(chain.RoleOperator, ctid, maCode, "filehash-abc", "CT-IPTV-GD", "cdn://ct-gd/vod/1")
|
||||
|
||||
// 非监管主体不得下架
|
||||
_, err := s.Takedown(chain.RoleOperator, maCode, "越权")
|
||||
assert.ErrorIs(t, err, chain.ErrPermissionDenied)
|
||||
|
||||
// 监管下架,解析出 CDN 端点
|
||||
res, err := s.Takedown(chain.RoleRegulator, maCode, "违规")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, res.CDNEndpoints, "cdn://ct-gd/vod/1")
|
||||
}
|
||||
|
||||
func TestReportVersionChange_LocatesEpisode(t *testing.T) {
|
||||
s := newService(t)
|
||||
_, ctid, _ := issueOne(t, s)
|
||||
|
||||
old := []string{
|
||||
hash.SHA256Hex([]byte("ep1")),
|
||||
hash.SHA256Hex([]byte("ep2")),
|
||||
hash.SHA256Hex([]byte("ep3")),
|
||||
}
|
||||
neu := []string{
|
||||
hash.SHA256Hex([]byte("ep1")),
|
||||
hash.SHA256Hex([]byte("ep2-tampered")),
|
||||
hash.SHA256Hex([]byte("ep3")),
|
||||
}
|
||||
episodes, err := s.ReportVersionChange(ctid, "第2集被替换", "root-old", "root-new", old, neu)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []int{2}, episodes, "应定位到第2集(1-based)")
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tcs-iptv/tcs/internal/chain"
|
||||
"github.com/tcs-iptv/tcs/internal/macode"
|
||||
"github.com/tcs-iptv/tcs/internal/model"
|
||||
)
|
||||
|
||||
// 24 集微短剧:一剧一 MA 码,每集独立哈希,可按集验真。
|
||||
func TestEpisodeLevel_OneSeriesOneCodeMultiEpisodeHash(t *testing.T) {
|
||||
s := newService(t)
|
||||
|
||||
eps := make([]model.EpisodeHash, 0, 24)
|
||||
for i := 1; i <= 24; i++ {
|
||||
eps = append(eps, model.EpisodeHash{
|
||||
Episode: i,
|
||||
FileSHA256: "ep-hash-" + string(rune('a'+i)),
|
||||
MerkleRoot: "ep-mr-" + string(rune('a'+i)),
|
||||
Duration: 180,
|
||||
})
|
||||
}
|
||||
sub := Submission{
|
||||
Title: "长安少年行", EpisodeCount: 24, Category: macode.CategoryMicroDrama,
|
||||
FileHash: "series-root-hash", MerkleRoot: "series-merkle-root",
|
||||
Episodes: eps,
|
||||
CPMediaID: "XAQJSL-2026-001", CPName: "西安曲江丝路文化传播有限公司",
|
||||
}
|
||||
r, err := s.SubmitForReview(sub)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.ReviewCSPS(r.ReviewID, true, "rv-1"))
|
||||
issued, err := s.ApproveAndIssue(chain.RoleRegulator, r.ReviewID, "陕西IPTV运营公司")
|
||||
require.NoError(t, err)
|
||||
|
||||
// 一剧一码
|
||||
assert.True(t, macode.IsValid(issued.MACode))
|
||||
|
||||
// 24 集哈希全部绑定在同一 MA 码下
|
||||
list, err := s.ListEpisodes(issued.MACode)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, list, 24)
|
||||
|
||||
// 按集验真:第 7 集正确哈希匹配
|
||||
res, err := s.VerifyEpisode(issued.MACode, 7, "ep-hash-"+string(rune('a'+7)))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Match)
|
||||
|
||||
// 第 7 集错误哈希 → 不匹配(疑似该集被替换)
|
||||
_, err = s.VerifyEpisode(issued.MACode, 7, "tampered-ep7")
|
||||
assert.ErrorIs(t, err, ErrHashMismatch)
|
||||
}
|
||||
|
||||
// 集级下架:只下架第3集,整剧其他集不受影响。
|
||||
func TestEpisodeTakedown(t *testing.T) {
|
||||
s := newService(t)
|
||||
|
||||
eps := []model.EpisodeHash{
|
||||
{Episode: 1, FileSHA256: "h1"}, {Episode: 2, FileSHA256: "h2"},
|
||||
{Episode: 3, FileSHA256: "h3"}, {Episode: 4, FileSHA256: "h4"},
|
||||
}
|
||||
sub := Submission{
|
||||
Title: "多集剧", EpisodeCount: 4, Category: macode.CategoryMicroDrama,
|
||||
FileHash: "series-h", MerkleRoot: "series-mr", Episodes: eps,
|
||||
}
|
||||
r, err := s.SubmitForReview(sub)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.ReviewCSPS(r.ReviewID, true, "rv-1"))
|
||||
issued, err := s.ApproveAndIssue(chain.RoleRegulator, r.ReviewID, "陕西IPTV运营公司")
|
||||
require.NoError(t, err)
|
||||
|
||||
// 运营商无权集级下架
|
||||
err = s.TakedownEpisode(chain.RoleOperator, issued.MACode, 3, "第3集违规")
|
||||
assert.ErrorIs(t, err, chain.ErrPermissionDenied)
|
||||
|
||||
// 监管下架第3集
|
||||
require.NoError(t, s.TakedownEpisode(chain.RoleRegulator, issued.MACode, 3, "第3集违规"))
|
||||
|
||||
list, err := s.ListEpisodes(issued.MACode)
|
||||
require.NoError(t, err)
|
||||
for _, b := range list {
|
||||
if b.Episode == 3 {
|
||||
assert.True(t, b.Revoked, "第3集应已下架")
|
||||
} else {
|
||||
assert.False(t, b.Revoked, "第%d集不应受影响", b.Episode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 集级子标识:MA码#E07 解析与生成。
|
||||
func TestEpisodeSubID(t *testing.T) {
|
||||
ma := "MA.156.8531.6101/WD/20260000001"
|
||||
sub := macode.EpisodeSubID(ma, 7)
|
||||
assert.Equal(t, "MA.156.8531.6101/WD/20260000001#E07", sub)
|
||||
|
||||
parsedMA, ep := macode.ParseEpisodeSubID(sub)
|
||||
assert.Equal(t, ma, parsedMA)
|
||||
assert.Equal(t, 7, ep)
|
||||
|
||||
// 无后缀 → 整剧(episode 0)
|
||||
parsedMA2, ep2 := macode.ParseEpisodeSubID(ma)
|
||||
assert.Equal(t, ma, parsedMA2)
|
||||
assert.Equal(t, 0, ep2)
|
||||
}
|
||||
|
||||
// 单体内容(电影,无分集):episodes 为空也能正常签发与整剧验真。
|
||||
func TestSingleContent_NoEpisodes(t *testing.T) {
|
||||
s := newService(t)
|
||||
sub := sampleSub()
|
||||
sub.Episodes = nil
|
||||
r, err := s.SubmitForReview(sub)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.ReviewCSPS(r.ReviewID, true, "rv-1"))
|
||||
issued, err := s.ApproveAndIssue(chain.RoleRegulator, r.ReviewID, "陕西IPTV运营公司")
|
||||
require.NoError(t, err)
|
||||
|
||||
list, err := s.ListEpisodes(issued.MACode)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, list, "单体内容无集级绑定")
|
||||
|
||||
// 整剧验真仍可用
|
||||
res, err := s.Verify(issued.MACode, sub.FileHash)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Match)
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// Package service 实现 TCS-IPTV 的业务编排,
|
||||
// 依赖 chain.Client(链)与哈希校验,串联送审→签发→验真→入库→发布→下架全流程。
|
||||
// 对应需求:需求2/3/4/5/6/7/11/12/15。
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tcs-iptv/tcs/internal/chain"
|
||||
"github.com/tcs-iptv/tcs/internal/macode"
|
||||
"github.com/tcs-iptv/tcs/internal/model"
|
||||
)
|
||||
|
||||
// 业务错误。
|
||||
var (
|
||||
ErrIncompleteHashPkg = errors.New("service: incomplete hash package")
|
||||
ErrDuplicateContent = errors.New("service: duplicate content (hash exists)")
|
||||
ErrHashMismatch = errors.New("service: hash mismatch (suspected version replacement)")
|
||||
ErrNotApproved = errors.New("service: content not approved")
|
||||
ErrNoCertificate = errors.New("service: missing MA code or hash certificate")
|
||||
ErrReauditPending = errors.New("service: reaudit pending, distribution blocked")
|
||||
)
|
||||
|
||||
// Submission 送审申报(需求2)。
|
||||
type Submission struct {
|
||||
Title string
|
||||
EpisodeCount int
|
||||
Category string // 内容类目(macode.CategoryXxx),决定发码号段
|
||||
FileHash string
|
||||
MerkleRoot string
|
||||
Perceptual string
|
||||
Episodes []model.EpisodeHash // 分集哈希(按集提交)
|
||||
CPMediaID string
|
||||
CPName string
|
||||
}
|
||||
|
||||
// SubmissionResult 送审受理结果。
|
||||
type SubmissionResult struct {
|
||||
ReviewID string `json:"review_id"`
|
||||
ContentTwinID string `json:"content_twin_id"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Service 业务编排器。
|
||||
type Service struct {
|
||||
chain chain.Client
|
||||
gen *macode.Generator
|
||||
mu sync.Mutex
|
||||
seqMu sync.Mutex
|
||||
seqs map[string]int // 按前缀独立计数(REV/ctid/DIST 各自从 1 递增)
|
||||
// reviewStore 暂存送审申报(MVP 内存;生产落 PG)
|
||||
reviews map[string]*reviewItem
|
||||
}
|
||||
|
||||
type reviewItem struct {
|
||||
ContentTwinID string
|
||||
Sub Submission
|
||||
Status string
|
||||
MACode string
|
||||
}
|
||||
|
||||
// New 创建业务服务。
|
||||
func New(c chain.Client, gen *macode.Generator) *Service {
|
||||
return &Service{chain: c, gen: gen, seqs: make(map[string]int), reviews: make(map[string]*reviewItem)}
|
||||
}
|
||||
|
||||
func (s *Service) nextID(prefix string) string {
|
||||
s.seqMu.Lock()
|
||||
defer s.seqMu.Unlock()
|
||||
s.seqs[prefix]++
|
||||
return fmt.Sprintf("%s-%s-%04d", prefix, time.Now().Format("20060102"), s.seqs[prefix])
|
||||
}
|
||||
|
||||
// SubmitForReview 处理 CP 送审申报(需求2)。
|
||||
// 校验哈希包完整性、拦截换壳重发,受理后返回送审流水号。
|
||||
func (s *Service) SubmitForReview(sub Submission) (SubmissionResult, error) {
|
||||
if sub.FileHash == "" || sub.MerkleRoot == "" {
|
||||
return SubmissionResult{}, ErrIncompleteHashPkg
|
||||
}
|
||||
// 防换壳重发(需求2-AC3、需求15-AC5)
|
||||
if maCode, exists := s.chain.HashExists(sub.FileHash); exists {
|
||||
return SubmissionResult{
|
||||
Status: "rejected",
|
||||
Message: fmt.Sprintf("内容哈希已存在,关联原 MA 码: %s", maCode),
|
||||
}, ErrDuplicateContent
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
reviewID := s.nextID("REV")
|
||||
ctid := s.nextID("ctid")
|
||||
s.reviews[reviewID] = &reviewItem{
|
||||
ContentTwinID: ctid,
|
||||
Sub: sub,
|
||||
Status: model.StatusPending,
|
||||
}
|
||||
return SubmissionResult{
|
||||
ReviewID: reviewID,
|
||||
ContentTwinID: ctid,
|
||||
Status: model.StatusPending,
|
||||
Message: "哈希已受理,待审核签发 MA 码",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IssueResult 签发结果。
|
||||
type IssueResult struct {
|
||||
MACode string `json:"ma_code"`
|
||||
ContentTwinID string `json:"content_twin_id"`
|
||||
TxID string `json:"tx_id"`
|
||||
Certificate string `json:"certificate"` // MA码+哈希证书(MVP 简化为字符串)
|
||||
}
|
||||
|
||||
// ReviewCSPS CSPS 合规审核(发码前)。审核通过后方可发码,体现"审过才发证发码"。
|
||||
// 对应需求5(CSPS审核)+ 需求3-AC2(审核通过后生成MA码)。
|
||||
func (s *Service) ReviewCSPS(reviewID string, approved bool, reviewerID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
item, ok := s.reviews[reviewID]
|
||||
if !ok {
|
||||
return fmt.Errorf("service: review %s not found", reviewID)
|
||||
}
|
||||
if approved {
|
||||
item.Status = model.StatusApproved
|
||||
} else {
|
||||
item.Status = model.StatusRejected
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApproveAndIssue 在 CSPS 审核通过后**生成 MA 码**并强绑定哈希(需求3,模式B 自行发码)。
|
||||
// 前置:该送审必须已通过 CSPS 审核(审过才发码)。
|
||||
// MA 码由 macode.Generator 按内容类目从号段中原子分配。仅监管主体可调用。
|
||||
func (s *Service) ApproveAndIssue(role chain.Role, reviewID, issuer string) (IssueResult, error) {
|
||||
s.mu.Lock()
|
||||
item, ok := s.reviews[reviewID]
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
return IssueResult{}, fmt.Errorf("service: review %s not found", reviewID)
|
||||
}
|
||||
// 审核门禁:未通过 CSPS 审核不得发码
|
||||
if item.Status == model.StatusRejected {
|
||||
return IssueResult{}, ErrNotApproved
|
||||
}
|
||||
if item.Status != model.StatusApproved {
|
||||
return IssueResult{}, fmt.Errorf("%w: 需先通过 CSPS 审核", ErrNotApproved)
|
||||
}
|
||||
|
||||
// 模式B:按类目自行发码
|
||||
issued, err := s.gen.Allocate(item.Sub.Category)
|
||||
if err != nil {
|
||||
return IssueResult{}, fmt.Errorf("service: allocate MA code: %w", err)
|
||||
}
|
||||
maCode := issued.MACode
|
||||
|
||||
txID, err := s.chain.IssueMA(role, chain.IssueRequest{
|
||||
MACode: maCode,
|
||||
ContentTwinID: item.ContentTwinID,
|
||||
MerkleRoot: item.Sub.MerkleRoot,
|
||||
FileHash: item.Sub.FileHash,
|
||||
PerceptualHash: item.Sub.Perceptual,
|
||||
Episodes: item.Sub.Episodes,
|
||||
Content: model.Content{
|
||||
Title: item.Sub.Title,
|
||||
EpisodeCount: item.Sub.EpisodeCount,
|
||||
MAType: item.Sub.Category,
|
||||
Issuer: issuer,
|
||||
IssueDate: time.Now().Format("2006-01-02"),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return IssueResult{}, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
item.Status = model.StatusIssued // 已发码,移出"待发码"队列
|
||||
item.MACode = maCode
|
||||
s.mu.Unlock()
|
||||
|
||||
// CP 注册本方映射
|
||||
_, _ = s.chain.RegisterMapping(role, model.Mapping{
|
||||
ContentTwinID: item.ContentTwinID,
|
||||
Party: model.PartyCP,
|
||||
PartyID: item.Sub.CPMediaID,
|
||||
PartyName: item.Sub.CPName,
|
||||
})
|
||||
|
||||
cert := fmt.Sprintf("CERT|%s|%s|%s", maCode, item.Sub.FileHash, item.Sub.MerkleRoot)
|
||||
return IssueResult{
|
||||
MACode: maCode,
|
||||
ContentTwinID: item.ContentTwinID,
|
||||
TxID: txID,
|
||||
Certificate: cert,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Verify 送审文件验真 / CDN 注入校验通用入口(需求4、需求7)。
|
||||
func (s *Service) Verify(maCode, fileHash string) (chain.VerifyResult, error) {
|
||||
res, err := s.chain.VerifyHash(maCode, fileHash)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
if !res.Match {
|
||||
return res, ErrHashMismatch
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// QueryMappings 查询 MA 码绑定的三方映射与 CDN 端点(需求11/17)。
|
||||
func (s *Service) QueryMappings(maCode string) (chain.MappingsResult, error) {
|
||||
return s.chain.QueryMappings(maCode)
|
||||
}
|
||||
|
||||
// VerifyEpisode 按集级子标识(MA码#E07)或 MA码+集号 验真单集。
|
||||
func (s *Service) VerifyEpisode(maCode string, episode int, fileHash string) (chain.VerifyResult, error) {
|
||||
res, err := s.chain.VerifyEpisodeHash(maCode, episode, fileHash)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
if !res.Match {
|
||||
return res, ErrHashMismatch
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ListEpisodes 列出某剧的全部集级哈希绑定。
|
||||
func (s *Service) ListEpisodes(maCode string) ([]model.HashBinding, error) {
|
||||
return s.chain.ListEpisodes(maCode)
|
||||
}
|
||||
|
||||
// ReviewSummary 送审待办摘要(发码前阶段)。
|
||||
type ReviewSummary struct {
|
||||
ReviewID string `json:"review_id"`
|
||||
ContentTwinID string `json:"content_twin_id"`
|
||||
Title string `json:"title"`
|
||||
Category string `json:"category"`
|
||||
EpisodeCount int `json:"episode_count"`
|
||||
Status string `json:"status"`
|
||||
CPName string `json:"cp_name"`
|
||||
MACode string `json:"ma_code"`
|
||||
}
|
||||
|
||||
// ListReviews 列出指定状态的送审待办(用于审核台/发码台队列)。
|
||||
// status 空则返回全部;常用 pending(待审)、approved(待发码)。
|
||||
func (s *Service) ListReviews(status string) []ReviewSummary {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var out []ReviewSummary
|
||||
for id, item := range s.reviews {
|
||||
if status != "" && item.Status != status {
|
||||
continue
|
||||
}
|
||||
out = append(out, ReviewSummary{
|
||||
ReviewID: id, ContentTwinID: item.ContentTwinID,
|
||||
Title: item.Sub.Title, Category: item.Sub.Category,
|
||||
EpisodeCount: item.Sub.EpisodeCount, Status: item.Status,
|
||||
CPName: item.Sub.CPName, MACode: item.MACode,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ListContentsByStatus 列出指定状态的内容(用于入库台/发布台/注入台队列)。
|
||||
// 常用 approved(待入库)、in_library(待发布)、published(待注入)。
|
||||
func (s *Service) ListContentsByStatus(status string) ([]model.Content, error) {
|
||||
return s.chain.ListContents(status)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tcs-iptv/tcs/internal/chain"
|
||||
"github.com/tcs-iptv/tcs/internal/macode"
|
||||
)
|
||||
|
||||
func newService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
gen := macode.NewGenerator(macode.NewMemoryStore())
|
||||
require.NoError(t, gen.RegisterSegment(macode.Segment{
|
||||
IndustryNode: "8531", OrgNode: "4401",
|
||||
Category: macode.CategoryMicroDrama, Start: 1, End: 100, SeqWidth: 7,
|
||||
}))
|
||||
return New(chain.NewMemoryChain(), gen)
|
||||
}
|
||||
|
||||
func sampleSub() Submission {
|
||||
return Submission{
|
||||
Title: "示例微短剧", EpisodeCount: 24, Category: macode.CategoryMicroDrama,
|
||||
FileHash: "filehash-abc", MerkleRoot: "merkle-abc", Perceptual: "phash-abc",
|
||||
CPMediaID: "FS-MEDIA-77821", CPName: "飞翮信息",
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmit_IncompleteHashRejected(t *testing.T) {
|
||||
s := newService(t)
|
||||
_, err := s.SubmitForReview(Submission{Title: "无哈希", Category: macode.CategoryMicroDrama})
|
||||
assert.ErrorIs(t, err, ErrIncompleteHashPkg)
|
||||
}
|
||||
|
||||
func TestSubmit_Success(t *testing.T) {
|
||||
s := newService(t)
|
||||
res, err := s.SubmitForReview(sampleSub())
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, res.ReviewID)
|
||||
assert.NotEmpty(t, res.ContentTwinID)
|
||||
assert.Equal(t, "pending", res.Status)
|
||||
}
|
||||
|
||||
func TestApproveAndIssue_GeneratesMACode(t *testing.T) {
|
||||
s := newService(t)
|
||||
sub, err := s.SubmitForReview(sampleSub())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.ReviewCSPS(sub.ReviewID, true, "rv-1"))
|
||||
|
||||
issued, err := s.ApproveAndIssue(chain.RoleRegulator, sub.ReviewID, "北京市广播电视局")
|
||||
require.NoError(t, err)
|
||||
// 模式B:MA 码由系统按号段生成
|
||||
assert.True(t, macode.IsValid(issued.MACode), "应生成合法 MA 码: %s", issued.MACode)
|
||||
assert.True(t, strings.HasPrefix(issued.MACode, "MA.156.8531.4401/WD/"), "前缀应匹配号段: %s", issued.MACode)
|
||||
assert.NotEmpty(t, issued.TxID)
|
||||
assert.Contains(t, issued.Certificate, issued.MACode)
|
||||
}
|
||||
|
||||
func TestApproveAndIssue_OnlyRegulator(t *testing.T) {
|
||||
s := newService(t)
|
||||
sub, _ := s.SubmitForReview(sampleSub())
|
||||
require.NoError(t, s.ReviewCSPS(sub.ReviewID, true, "rv-1")) // 先过审,才轮到校验角色
|
||||
_, err := s.ApproveAndIssue(chain.RoleCP, sub.ReviewID, "x")
|
||||
assert.ErrorIs(t, err, chain.ErrPermissionDenied)
|
||||
}
|
||||
|
||||
func TestSubmit_DuplicateRejectedAfterIssue(t *testing.T) {
|
||||
s := newService(t)
|
||||
sub, _ := s.SubmitForReview(sampleSub())
|
||||
require.NoError(t, s.ReviewCSPS(sub.ReviewID, true, "rv-1"))
|
||||
_, err := s.ApproveAndIssue(chain.RoleRegulator, sub.ReviewID, "issuer")
|
||||
require.NoError(t, err)
|
||||
|
||||
// 同哈希再次送审 → 换壳重发拦截
|
||||
_, err = s.SubmitForReview(sampleSub())
|
||||
assert.ErrorIs(t, err, ErrDuplicateContent)
|
||||
}
|
||||
|
||||
func TestVerify_MatchAndMismatch(t *testing.T) {
|
||||
s := newService(t)
|
||||
sub, _ := s.SubmitForReview(sampleSub())
|
||||
require.NoError(t, s.ReviewCSPS(sub.ReviewID, true, "rv-1"))
|
||||
issued, _ := s.ApproveAndIssue(chain.RoleRegulator, sub.ReviewID, "issuer")
|
||||
|
||||
res, err := s.Verify(issued.MACode, "filehash-abc")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Match)
|
||||
|
||||
_, err = s.Verify(issued.MACode, "tampered")
|
||||
assert.ErrorIs(t, err, ErrHashMismatch)
|
||||
}
|
||||
|
||||
func TestApproveAndIssue_TwoContentsUniqueCodes(t *testing.T) {
|
||||
s := newService(t)
|
||||
sub1, _ := s.SubmitForReview(sampleSub())
|
||||
require.NoError(t, s.ReviewCSPS(sub1.ReviewID, true, "rv-1"))
|
||||
i1, err := s.ApproveAndIssue(chain.RoleRegulator, sub1.ReviewID, "issuer")
|
||||
require.NoError(t, err)
|
||||
|
||||
sub2v := sampleSub()
|
||||
sub2v.FileHash = "filehash-def"
|
||||
sub2v.MerkleRoot = "merkle-def"
|
||||
sub2, _ := s.SubmitForReview(sub2v)
|
||||
require.NoError(t, s.ReviewCSPS(sub2.ReviewID, true, "rv-1"))
|
||||
i2, err := s.ApproveAndIssue(chain.RoleRegulator, sub2.ReviewID, "issuer")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, i1.MACode, i2.MACode, "两条内容应分配不同 MA 码")
|
||||
}
|
||||
Executable
+73
@@ -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 "== 完成 =="
|
||||
@@ -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 码查询全链路三方映射。"
|
||||
@@ -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>
|
||||
Generated
+2746
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user