9 Commits

Author SHA1 Message Date
selfrelease 96e2393c50 chore: 移除临时提交信息文件 2026-06-14 20:47:48 +08:00
selfrelease 166f460d57 feat(chain): ChainMaker 真实链接入脚手架(build tag 隔离)+ 契约测试
- internal/chain/chainmaker.go [//go:build chainmaker]: ChainMakerClient 适配器骨架,
  实现 chain.Client 全部方法到合约 Invoke/Query,按角色证书做链上鉴权,错误映射回标准错误
- internal/chain/chainmaker_stub.go [//go:build !chainmaker]: 占位构造函数,
  保证默认构建不依赖 SDK、主工程始终可编译
- contracts/tcs_registry/registry.go: 补齐合约方法
  RegisterHashBinding/VerifyEpisodeHash/ListEpisodes/HashExists/RecordVersionChange/
  RevokeEpisode/Restore/RestoreEpisode/SetContentStatus/QueryMappings/ListContents
  并增加集级哈希/映射/版本计数索引 KV 设计
- config: TCS_CHAIN_BACKEND=memory|pg|chainmaker + TCS_CHAINMAKER_SDK_CONF 开关
- cmd/api-svc: newChain 按 backend 选择,chainmaker 失败逐级降级 pg 到内存
- internal/chain/conformance_test.go: chain.Client 契约测试套件,双实现共用
  MemoryChain 默认跑;PersistentChain 经 TCS_TEST_PG_DSN;ChainMaker 经 -tags 与 env
- 验证: 默认 build/vet/test 全绿;MemoryChain 与 PersistentChain 契约套件均通过,行为一致
2026-06-14 20:47:21 +08:00
selfrelease 8a9ea6b40b fix(web): 工作台说明改为「模拟三方各自系统的协同演示」
- 澄清三方不在同一台协作:实际各自系统操作,经 MA码+哈希在可信数据空间协同
- 本台仅为演示集中呈现三方视角
- 菜单标签改为「角色工作台(多方协同演示)」
2026-06-14 20:03:10 +08:00
selfrelease 719ed5b65c feat(web): 三页统一加顶部说明,去掉「X期」字样
- 大小屏融合: 去掉「四期·」前缀
- 角色工作台: 增加三方协作全流程说明
- 全流程演示: 增加一键闭环演示说明
- 文案均不含阶段标号,面向业务演示
2026-06-14 19:54:17 +08:00
selfrelease 1b516583b9 feat(web): 导航移入 header 菜单,移除演示模式提示条,腾出演示区面积
- 三个视图(角色工作台/全流程演示/大小屏融合)从 Tabs 改为 Header 顶部横向菜单
- 删除页面顶部「演示模式…」Alert 提示条
- Content 区按选中菜单渲染对应视图,演示区获得更大可视面积
2026-06-14 19:45:51 +08:00
selfrelease a287c52000 feat(web): 删除冗余「监管大屏」tab,新增「大小屏融合」tab
- 监管大屏功能(查映射/验真/下架)已被角色工作台·监管片库覆盖,移除该tab及RegulatorConsole
- 新增 ScreenFusion.jsx「大小屏融合」tab:四期能力可视化
  - 跨域解析网关(C.1/C.2):六段式+集级子标识解析、流通状态、三屏可用
  - 扫码验真(B.2):真伪/合规结果卡片,防盗版
  - 跨屏权益通兑(D.1):一屏购买→换屏核验通看不重复付费
- api.js: 新增 resolve/scanVerify/purchase/verifyRights
- seed_demo.sh: 更新查看入口提示
- 前端 build 通过
2026-06-14 19:42:29 +08:00
selfrelease 9db0a8a4d4 demo(seed): 送审携带分集哈希,演示一剧一码+集级哈希落库
- seed_demo.sh: 每剧提交6集独立哈希(file/merkle/perceptual+分辨率/时长)
- 修复演示数据只有整剧哈希、无集级绑定的问题
- 验证: 3剧×6集集级哈希落 hash_binding(episode>0),集级验真 match/mismatch 正常
2026-06-14 19:31:42 +08:00
selfrelease 599ccfe1c8 feat(chain): PostgreSQL 持久化链(最小改动·写穿+启动水合)
- internal/chain/persistent.go: PersistentChain 装饰 MemoryChain
  - 复用 MemoryChain 全部业务规则(权限/1:1绑定/防换壳),读走内存
  - 写路径在内存变更成功后写穿 PG 镜像表(content_registry/hash_binding/identity_mapping/version_history/chain_tx)
  - 启动从 PG 水合恢复内存状态,重启不丢数据;PG 为非权威镜像,写穿失败仅记日志
- deploy/migrations/0004_binding_revoked.sql: hash_binding 增加 revoked/revoked_reason 列(集级下架镜像)
- cmd/api-svc/main.go: 共享一个 *sql.DB,PG 可用时启用 PersistentChain+PostgresStore,否则回退内存
- 验证: seed_demo 后内容/映射/哈希落库;重启水合3条内容,resolve/mappings 正常恢复
- 面向未来: 接真实 ChainMaker 时整体替换 chain.Client,业务层零改动
2026-06-14 19:24:38 +08:00
selfrelease 34b03a7afa Merge 四期后端可代码部分(大小屏融合:跨域解析/扫码验真/跨屏权益) 2026-06-14 19:01:33 +08:00
17 changed files with 1881 additions and 191 deletions
+410
View File
@@ -0,0 +1,410 @@
# TCS-IPTV 内容可信锁定系统 — 完整说明文档
> 版本:V1.0(一期 MVP + 二期贯通 + 三期生态 + 四期大小屏融合·后端可代码部分)
> 编制日期:2026 年 6 月
> 运营主体:陕西 IPTV 运营公司(机构节点 `MA.156.8531.6101`
> 配套文档:`0-req-IPTV.md`(需求)、`1-prd-IPTV.md`PRD)、`2-task-IPTV-MVP.md` / `3-task-IPTV-二期.md` / `4-task-IPTV-三期.md` / `5-task-IPTV-四期.md`(任务)、`tcs-iptv/DELIVERY.md`(交付说明)
本文面向业务专家、技术评审与试点联调三类读者,覆盖:业务背景与价值、核心原理、业务流程、系统架构、功能完成情况、试用说明、接口清单、质量与安全状况、后续路线。
---
## 一、项目背景与价值
### 1.1 要解决的问题
IPTV/网络视听内容在"**送审 → 入库 → 分发 → 终端播放**"链路中存在长期痛点:
- **审与发脱节**:内容审核通过后,缺乏一个贯穿全链路、不可篡改的"身份",无法保证分发出去的就是审过的那一版("换壳重发""偷梁换柱")。
- **多方编码割裂**:CP、审核/媒资、运营商各有一套编码,同一内容跨系统对不上号,监管难以"一码贯穿"。
- **维权举证难**:版权归属、首次锁定时间缺乏可信凭证,侵权追责成本高。
- **数据与分账不可信**:播放数据由各平台自报,结算缺乏可信依据。
- **跨省/跨屏重复审核**:同一内容跨省、跨大小屏(IPTV/OTT/手机)重复审核,效率低。
### 1.2 解决思路与价值
**内容提供商(CP)、审核和监管部门、运营商** 三方现有系统之上,建立一层"**可信身份映射层**"
> 以 **MA 码(监管身份锚点)+ 哈希码(技术指纹锚点)** 双锚定,
> 实现内容"**审过即锁定,锁定即通行,通行可追溯**"。
| 价值维度 | 解法 |
|----------|------|
| **权利**(确权维权) | 全链路存证 + 确权证据链 + 感知哈希侵权比对,"谁先锁定谁有权" |
| **效率**(少跑路) | 一次锁定跨省/跨屏复用,三重校验快速准入,追更增量赋码 |
| **利益**(可信分账) | 以 MA 码为维度聚合可信播放数据,自动分账 |
| **监管**(看得见管得住) | 一码贯穿三方映射,一键应急下架,全国统计大屏 |
---
## 二、角色与术语
### 2.1 角色(三方)
| 角色 | 链上角色码 | 现有系统 | 在 TCS 中的职责 |
|------|-----------|----------|-----------------|
| 内容提供商 CP | `cp` | 制作/媒资系统 | 送审节目信息 + 哈希包(**不传原片**) |
| 审核和监管部门 | `reviewer`(审核/媒资)、`regulator`(监管发码) | **CSPS 审核系统 + 媒体资源库** | 合规审核、媒资入库、发布;监管发码与应急下架 |
| 运营商 | `operator` | BOSS/CDN | 注入前哈希校验、播放数据回传 |
> 说明:审核与监管部门内部细分两类链上权限 —— `reviewer`(CSPS/媒资库的审核与入库发布)与 `regulator`(监管主体,唯一可发码/下架)。
### 2.2 核心术语
- **MA 码**:监管身份锚点。六段式结构 `MA.156.{行业节点}.{机构节点}/{类目}/{年份}{序列}`,例:`MA.156.8531.6101/WD/20260000004`
- `156`=国家码(中国)、`8531`=行业节点(IPTV 视听)、`6101`=机构节点(陕西)、`WD/WJ/DY/DH`=微短剧/网络剧/网络电影/网络动画。
- **集级子标识**:一剧一码下按集寻址,形如 `MA.156.8531.6101/WD/20260000004#E07`(第 7 集)。
- **哈希码**:技术指纹锚点。文件 SHA-256 / 分段 Merkle 根 / 感知哈希(用于侵权比对)。
- **CTIDContent Twin ID**:内容机器主键,链下双锚定主键。
- **可信数据空间**:联盟链(长安链 ChainMaker,国密),存哈希与映射、不存原片。
- **模式 B 自行发码**:与 MA 发码机构对接获取"号段 + 备案规则",由 TCS 在本地按规则原子发码。
---
## 三、核心设计原理
1. **双锚定**:MA 码(监管/法律身份)与哈希(技术指纹)在发码时 **1:1 强绑定且不可解绑**,链上同时记录 CTID。
2. **一剧一码 + 集级哈希**MA 码按"剧/备案"颁发(对齐网标证),各集独立哈希挂在同一码下,支持集级验真、集级下架/恢复。
3. **先审后发**:CP 送审 → CSPS 审核通过 → **才发码签发**(审过才发证发码),杜绝"先发码后审核"的空子。
4. **不传原片**:链上只存哈希,原片仍走审核方既有渠道做内容审核 —— 最小侵入、不替代现有系统。
5. **防换壳重发**:同一文件哈希再次送审被直接拦截并关联原 MA 码。
6. **权限分离**:仅监管主体可发码与下架;发布必须携带"MA 码 + 哈希证书"。
---
## 四、业务流程
### 4.1 内容全生命周期(主流程)
```
CP 送审 监管发码签发 CSPS 审核 / 媒资 发布给运营商 CDN 注入校验
(不传原片) ──▶ (审核通过后) ──▶ 入媒资库 ──▶ (携 MA+哈希证书) ──▶ (注入前哈希比对)
节目信息+哈希包 1:1 强绑定哈希 建媒资编码映射 匹配→放行 / 不匹配→拒绝告警
```
详细步骤:
1. **CP 送审**`cp`):提交标题、集数、类目、文件哈希、Merkle 根、感知哈希、各集哈希;系统校验哈希包完整性 + 防换壳重发,返回送审流水号 `REV-…``CTID`
2. **CSPS 合规审核**`reviewer`):审核通过/驳回(原片走既有审核渠道,TCS 记审核结论)。
3. **发码签发**`regulator`,审过才发):按类目从号段原子分配 MA 码,与哈希包 1:1 强绑定上链,生成"MA 码 + 哈希证书"。
4. **媒资库入库**`reviewer`):建立媒资编码映射,状态 → 已入库。
5. **发布给运营商**`reviewer`):校验证书(须含 MA 码)后,状态 → 已发布。
6. **CDN 注入校验**`operator`):注入前比对哈希;匹配则放行并注册运营商/CDN 映射,不匹配则拒绝并告警;同时做授权核验(地域/平台/期限)。
7. **终端抽检**:终端按集抽检片段哈希,不匹配提示断流切备用源。
### 4.2 治理与权益流程(二期)
- **应急下架**:监管主体一键下架,解析出该 MA 码绑定的三方编码与 CDN 端点;支持**集级下架**(只下某集,整剧其余集继续流通)与**恢复上架**。
- **版本变更重审**:哈希变化判定绑定断裂,触发重审,并可定位被改的具体集。
- **可信分账**:运营商以 MA 码为维度回传播放数据 → 聚合 → 按比例分账(示例 CP 60% / 平台 34% / 服务费 6%)。
- **追责取证 / 确权举证**:全链路存证定位首次哈希变化环节与责任方;导出确权证据链;感知哈希侵权比对。
- **授权链**:登记信息网络传播权(地域/平台/期限),发布与注入前核验。
- **跨省复用**B 省凭"MA 码 + 哈希证书"三重校验(码有效 + 哈希一致 + 非黑名单)快速准入。
### 4.3 大小屏融合流程(四期)
- **跨域解析网关**:同一 MA 码在 IPTV/OTT/APP 统一解析(含集级子标识),返回一致的流通状态与跨屏可用性。
- **扫码验真**:用户扫码返回真伪(链上存在且结构合法)+ 合规(仅 `published` 为合规流通)+ 流通状态,下架码判为"真码但不合规"。
- **跨屏权益通兑**:以 MA 码为维度记录购买,**任一屏购买即全屏(电视/手机/OTT)通看,重复购买幂等不重复扣费**,权益归一到整剧 MA 码。
---
## 五、系统架构与工程结构
### 5.1 分层架构
```
┌───────────────── 监管大屏(React + AntD)─────────────────┐
│ 角色工作台 │ 全流程演示 │ 监管片库(权益与治理) │
└──────────────────────────┬──────────────────────────────┘
│ 会话令牌(密钥不下发浏览器)
┌─────────┴─────────┐
│ console-bff :8090 │(BFF 安全层)
└─────────┬─────────┘
│ HMAC-SHA256 鉴权
┌────────────────────────── api-svc :8080(业务编排)──────────────────────────┐
│ service:送审/审核/发码/入库/发布/注入/下架/分账/追责/确权/授权/跨省/解析/权益 │
│ macode:六段式发码与号段(PG 行锁防重号) │ hashSHA256/Merkle/感知哈希 │
└───────────────┬─────────────────────────────────────┬─────────────────────────┘
│ chain.Client 接口 │
┌──────────┴──────────┐ ┌─────────┴─────────┐
│ MemoryChain(等价实现)│ 平滑替换 ──▶ │ ChainMaker 国密链 │(合约源码已就绪)
└─────────────────────┘ └───────────────────┘
┌──────────┴──────────┐
│ PostgreSQL 16 / Redis │(号段游标、链上数据镜像、缓存)
└─────────────────────┘
```
### 5.2 工程结构
```
tcs-iptv/
├── cmd/
│ ├── api-svc/ # 业务后端(:8080
│ ├── chain-svc/ # 链交互服务(:8081
│ ├── hash-api/ # 哈希SDK HTTP API:8082
│ └── console-bff/ # 监管控制台 BFF:8090
├── internal/
│ ├── hash/ # 哈希核心(SHA256/Merkle/感知哈希)
│ ├── macode/ # MA码生成/解析/号段(含 PG 存储)
│ ├── chain/ # 可信数据空间抽象 + MemoryChain
│ ├── service/ # 业务编排(含 phase4.go 大小屏融合)
│ ├── playback/ # 播放聚合与分账
│ ├── provenance/ # 全链路存证与追责
│ ├── bff/ # 控制台 BFF
│ ├── api/ # HTTP 路由与处理器
│ ├── model/ # 领域模型(含 rights.go 跨屏权益)
│ └── config/ httpx/ # 配置、通用 HTTP / 鉴权
├── contracts/tcs_registry/ # ChainMaker Go 合约(独立模块)
├── deploy/migrations/ # PostgreSQL 迁移(0001-0003
├── web-console/ # React 监管大屏
├── scripts/ # seed_demo.sh / e2e_smoke.sh
└── .gitlab-ci.yml
```
---
## 六、功能完成情况
> 图例:✅ 已完成(代码可交付,含测试);⏸ 需外部环境/流程(非本机代码可完成,已诚实标注)。
### 6.1 一期 MVP(核心闭环)✅
| 模块 | 状态 | 说明 |
|------|------|------|
| 哈希 SDK | ✅ | 文件 SHA-256 / 分段 Merkle / 感知哈希 |
| MA 码生成 | ✅ | 六段式、号段原子分配、PostgreSQL 行锁防重号 |
| 可信数据空间 | ✅ | 1:1 强绑定不可解绑、防换壳重发、权限控制 |
| 送审→审核→发码→入库→发布→注入→下架 | ✅ | 全闭环 |
| 一剧一码 + 集级哈希 | ✅ | 集级验真、集级下架/恢复、整剧下架/恢复 |
| HTTP API + HMAC 三角色权限 | ✅ | 四角色密钥 |
| 监管大屏 | ✅ | 角色工作台 / 全流程演示 / 监管片库 |
### 6.2 二期 贯通(权益场景)✅
| 能力 | 状态 | 说明 |
|------|------|------|
| 数据回传聚合 + 可信分账 | ✅ | 以 MA 码聚合,CP60/平台34/服务费6 |
| 全链路追责取证 | ✅ | 定位首次哈希变化环节与责任方 |
| 确权证据链 + 感知哈希侵权比对 | ✅ | "谁先锁定谁有权" |
| 追更增量赋码 | ✅ | 不触发存量重审、不重新发码 |
| 跨省复用快速准入 | ✅ | 三重校验(码有效+哈希一致+非黑名单) |
| 授权链登记 + 发布/注入前核验 | ✅ | 地域/平台/期限拦截 |
| 终端片段抽检 | ✅ | 不匹配提示断流切源 |
| 前端"权益与治理"可视化 | ✅ | 分账/追责/确权/授权标签 |
| CI/CD | ✅ | GitLab CI 流水线 |
### 6.3 三期 生态(代码可交付部分)✅ / ⏸
| 能力 | 状态 | 说明 |
|------|------|------|
| 备案对接(网标号/备案号关联) | ✅ | `/content/bind-filing``/content/filing` |
| 监管数据日报 | ✅ | `/regulatory/daily-report` |
| 号段管理 | ✅ | `/admin/segments` |
| 全国统计(按省/类目/状态) | ✅ | `/regulatory/national-stats` |
| 监管大屏 BFF 安全化 | ✅ | 密钥仅存后端,浏览器只用会话令牌 |
| 真实链合约源码 | ✅ | `contracts/tcs_registry/registry.go`ChainMaker Go |
| 真实链部署 / 等保测评 / 压测 / 行业标准 | ⏸ | 需外部环境与流程 |
### 6.4 四期 大小屏融合(后端可代码部分)✅ / ⏸
| 任务 | 状态 | 说明 |
|------|------|------|
| C.1 MA 跨域解析网关 | ✅ | `GET /content/resolve` |
| C.2 大小屏身份互通 | ✅ | 同一 MA 码跨 iptv/ott/app 一致解析与哈希身份 |
| B.2 扫码验真 | ✅ | `POST /content/scan-verify` |
| D.1 跨屏权益通兑 | ✅ | `POST /rights/purchase``/rights/verify`,任一屏购买全屏通看 |
| A.1/A.2 OTT 端 SDK / 播放器抽检 | ⏸ | 复用后端 inject/verify/resolve/terminal 能力;端侧 SDK 待真实终端 |
| B.1 移动端壳接入 | ⏸ | 复用统一鉴权与后端校验;RN/小程序待移动端环境 |
| E.1 移动端 C2PA 内容凭证 | ⏸ | 依赖 C2PA 类水印 SDK,衔接 AVCC 体系 |
---
## 七、试用说明
### 7.1 环境准备
> 本地直接使用已安装的 PostgreSQL / Redis**无需 Docker**。
- Go 1.23+
- Node 20+(前端)
- PostgreSQL 16(创建库 `tcs_iptv`psql 已加入 PATH
- Redis 7.x
可选环境变量(缺省即适配本地):
| 变量 | 默认值 |
|------|--------|
| `TCS_POSTGRES_DSN` | `postgres://postgres@localhost:5432/tcs_iptv?sslmode=disable` |
| `TCS_REDIS_ADDR` | `localhost:6379` |
| `TCS_API_ADDR` | `:8080` |
### 7.2 启动步骤
```bash
cd tcs-iptv
# 1. 数据库迁移(库 tcs_iptv 需已创建)
make migrate
make db-check # 列出已建表
make redis-check # 应返回 PONG
# 2. 运行测试(确认环境就绪)
make test
# 3. 启动后端
make run-api # api-svc :8080
go run ./cmd/console-bff # BFF :8090(监管大屏走 BFF,可选)
# 4. 启动前端监管大屏
cd web-console && npm install && npm run dev # :5173/5174
# 5. 造演示数据(陕西 IPTV 场景)
bash scripts/seed_demo.sh
# 6. 全相位端到端冒烟
bash scripts/e2e_smoke.sh
```
监管大屏访问:`http://localhost:5174`(角色工作台 / 全流程演示 / 监管片库)。
`seed_demo.sh` 会打印生成的 MA 码,可复制到大屏查询全链路三方映射。
### 7.3 演示场景(陕西 IPTV
| 参与方 | 示例 |
|--------|------|
| 管理方(审核+监管) | 陕西 IPTV 运营公司(机构节点 6101) |
| 内容提供商 CP | 西安曲江丝路文化传播 / 陕文投艺达影视 / 西部电影集团(西影) |
| 运营商 | 中国电信陕西(天翼高清)/ 中国移动陕西(魔百和)/ 中国联通陕西 |
| 示例内容 | 《长安少年行》(微短剧) /《白鹿原·麦客》(网络剧) /《丝路驼铃》(网络电影) |
### 7.4 API 鉴权与调用
所有 `/api/v1/**` 接口需 HMAC-SHA256 鉴权。
- **签名串**`base64( HMAC-SHA256( secret, "{METHOD}\n/api/v1{path不含query}" ) )`
- **请求头**`Authorization: TCS {apiKey}:{signature}`
预置四角色示例密钥(生产从 Vault/DB 加载):
| 角色 | apiKey | secret |
|------|--------|--------|
| 监管主体 | `ak-regulator` | `sk-regulator` |
| 审核/媒资 | `ak-reviewer` | `sk-reviewer` |
| 内容提供商 | `ak-cp` | `sk-cp` |
| 运营商 | `ak-operator` | `sk-operator` |
通用签名/调用函数(bash):
```bash
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 sig; sig=$(sign "$2" "$3" "/api/v1${4%%\?*}")
if [ "$3" = "GET" ]; then curl -s "$BASE$4" -H "Authorization: TCS $1:$sig";
else curl -s -X "$3" "$BASE$4" -H "Authorization: TCS $1:$sig" -H "Content-Type: application/json" -d "$5"; fi
}
```
### 7.5 四期新接口试用示例
```bash
# 先用 seed_demo.sh 生成一个已发布的 MA 码,记为 $MA
# C.1/C.2 跨域解析(GET,跨屏统一解析;支持集级子标识 #E03)
call ak-regulator sk-regulator GET "/content/resolve?ma_code=$MA"
call ak-regulator sk-regulator GET "/content/resolve?ma_code=$MA#E03"
# B.2 扫码验真(返回 authentic 真伪 / compliant 合规)
call ak-operator sk-operator POST /content/scan-verify "{\"ma_code\":\"$MA\"}"
# D.1 跨屏权益:电视端购买
call ak-operator sk-operator POST /rights/purchase \
"{\"ma_code\":\"$MA\",\"user_hash\":\"user-1\",\"screen\":\"iptv\"}"
# D.1 手机端核验权益 → 通看,不重复付费
call ak-operator sk-operator POST /rights/verify \
"{\"ma_code\":\"$MA\",\"user_hash\":\"user-1\",\"screen\":\"app\"}"
```
---
## 八、接口清单(节选,均在 `/api/v1` 下)
| 分类 | 方法 路径 | 说明 |
|------|-----------|------|
| 送审/发码 | `POST /content/register` | CP 送审(哈希包,不传原片) |
| | `POST /content/csps-result` | CSPS 合规审核结论 |
| | `POST /content/issue` | 审核通过后发码签发(仅监管) |
| 验真 | `POST /content/verify` | 整剧哈希验真 |
| | `POST /content/verify-episode` | 集级验真 |
| 分发 | `POST /content/ingest` / `publish` / `inject` | 入库 / 发布 / CDN 注入校验 |
| 治理 | `POST /content/takedown` / `takedown-episode` | 整剧 / 集级应急下架(仅监管) |
| | `POST /content/restore` / `restore-episode` | 恢复上架 |
| | `GET /content/mappings` | 三方映射与 CDN 端点查询 |
| 权益 | `POST /data/playback` / `GET /data/playback-summary` | 播放回传 / 可信聚合 |
| | `POST /settlement/compute` | 可信分账 |
| 追责确权 | `GET /content/provenance` / `accountability` / `evidence` | 存证 / 追责 / 确权 |
| | `POST /content/infringe-match` | 感知哈希侵权比对 |
| 授权/追更/跨省 | `POST /content/authorize` / `auth-check` / `add-episodes` / `cross-province` | — |
| 终端 | `POST /terminal/verify-segment` | 终端片段抽检 |
| 三期生态 | `POST /content/bind-filing``GET /content/filing``GET /regulatory/national-stats``GET /regulatory/daily-report``GET /admin/segments` | 备案/统计/上报/号段 |
| **四期大小屏** | `GET /content/resolve` | **跨域解析网关** |
| | `POST /content/scan-verify` | **扫码验真** |
| | `POST /rights/purchase` | **记录跨屏购买** |
| | `POST /rights/verify` | **跨屏权益核验** |
---
## 九、质量状况
| 指标 | 状况 |
|------|------|
| 测试用例 | 100+(含四期 18 个新单测),全部通过 |
| 核心覆盖率 | playback 100% / hash 88% / service 85% / macode 75% |
| `go build ./...` / `go vet` | 通过 |
| 前端构建 | 通过 |
| 端到端冒烟 | 一期 → 四期(后端可代码部分)全相位通过 |
---
## 十、待外部环境/流程的事项(诚实标注,非代码可完成)
| 项 | 说明 | 就绪度 |
|----|------|--------|
| 真实 ChainMaker 国密测试网 | 需多节点链环境 | 合约源码 + `chain.Client` 接口就绪,平滑替换 MemoryChain |
| 链上数据 PG 镜像接入 | 需真实链 | 镜像表已建(migrations |
| 性能压测 / 高可用灾备 | 需集群 + 压测工具 | 架构支持,待环境 |
| 等保三级正式测评 | 需第三方机构 + 正式环境 | 安全设计就绪(BFF/HMAC/国密/审计) |
| HSM 密钥托管 | 需硬件 | 接口预留 |
| 行业分账标准发布 | 政策/行业协作 | 分账引擎已实现 |
| OTT / 移动端 SDK 接入 | 需 Android TV/OTT、RN/小程序真实终端 | 后端解析/校验/权益能力就绪可复用 |
| 移动端 C2PA 内容凭证 | 需 C2PA 类水印 SDK,衔接 AVCC | 待端侧环境 |
---
## 十一、安全说明
- ✅ 已实现:HMAC-SHA256 鉴权、三角色权限矩阵、MA 码 1:1 不可解绑、哈希本地计算不上链原片、关键操作存证、**监管大屏 BFF 化(密钥不下发浏览器)**。
- ⚠️ 上生产前需补齐:真实国密链替换、等保三级测评、HSM 密钥托管、生产凭证接入 Vault/SSO。
- 网络暴露提示:当前示例服务以预置密钥启动,仅用于本机演示/联调;公网部署前必须更换密钥来源并启用 TLS、网关与审计。
---
## 十二、版本与后续路线
| 阶段 | 主题 | 状态 |
|------|------|------|
| 一期 MVP | 内容可信锁定核心闭环 | ✅ 完成 |
| 二期 贯通 | 权利/效率/利益/合规场景 | ✅ 完成 |
| 三期 生态 | 备案对接/全国监管/BFF 安全/真实链合约 | ✅ 代码部分完成;部署待环境 |
| 四期 大小屏融合 | 跨域解析/扫码验真/跨屏权益 | ✅ 后端完成;端侧 SDK 待环境 |
> 四期完成后,TCS-IPTV 从"IPTV 内容可信锁定"升级为"全场景视听内容可信身份基础设施",
> 可与 AVCC(AIGC 视听内容编码)体系形成大小屏、传统/AIGC 内容的统一身份底座。
---
> 本系统一期至四期"可本机代码部分"均已实现并通过回归测试,可用于演示、试点联调与功能验收。
> 剩余为真实链部署、等保测评、压测、HSM、行业标准、端侧 SDK 等需外部环境/流程的事项,已在上文逐项标注。
+52 -10
View File
@@ -15,25 +15,67 @@ import (
"github.com/tcs-iptv/tcs/internal/service"
)
// newAllocationStore 优先使用 PostgreSQL(持久、防重号),不可用时回退内存。
func newAllocationStore(dsn string) macode.AllocationStore {
// openDB 尝试连接 PostgreSQL,连通则返回 *sql.DB,否则返回 nil回退内存
func openDB(dsn string) *sql.DB {
db, err := sql.Open("postgres", dsn)
if err == nil {
if pingErr := db.Ping(); pingErr == nil {
log.Printf("macode: 使用 PostgreSQL 号段存储")
return macode.NewPostgresStore(db)
}
if err != nil {
return nil
}
if err := db.Ping(); err != nil {
return nil
}
return db
}
// newAllocationStore 优先使用 PostgreSQL(持久、防重号),不可用时回退内存。
func newAllocationStore(db *sql.DB) macode.AllocationStore {
if db != nil {
log.Printf("macode: 使用 PostgreSQL 号段存储")
return macode.NewPostgresStore(db)
}
log.Printf("macode: PostgreSQL 不可用,回退内存号段存储(仅开发用)")
return macode.NewMemoryStore()
}
// newChain 按配置选择链后端:
// - chainmaker:真实链(需 -tags chainmaker 构建并配置 SDK),失败回退 pg/内存
// - pg:PG 持久化链(写穿+水合,重启不丢数据)
// - memory:纯内存(仅开发)
//
// PG 不可用时自动降级到内存。
func newChain(backend, sdkConf string, db *sql.DB) chain.Client {
switch backend {
case "chainmaker":
cm, err := chain.NewChainMakerClient(sdkConf, db)
if err == nil {
log.Printf("chain: 使用 ChainMaker 真实链后端")
return cm
}
log.Printf("chain: ChainMaker 后端不可用(%v),降级", err)
fallthrough
case "pg":
if db != nil {
if pc, err := chain.NewPersistentChain(db); err == nil {
log.Printf("chain: 使用 PostgreSQL 持久化链(写穿+水合,重启不丢数据)")
return pc
} else {
log.Printf("chain: PG 持久化链初始化失败(%v),回退内存链", err)
}
}
fallthrough
default:
log.Printf("chain: 使用内存链(仅开发用,重启丢数据)")
return chain.NewMemoryChain()
}
}
func main() {
cfg := config.Load()
// 装配依赖:链(MVP 用内存 mock)+ MA 码生成器(登记号段)+ 业务服务
ch := chain.NewMemoryChain()
gen := macode.NewGenerator(newAllocationStore(cfg.PostgresDSN))
// 装配依赖:共享一个 PG 连接给链持久化与号段存储
db := openDB(cfg.PostgresDSN)
ch := newChain(cfg.ChainBackend, cfg.ChainMakerSDKConf, db)
gen := macode.NewGenerator(newAllocationStore(db))
// 示例号段(生产由与发码机构对接后配置)
// 机构节点 6101 = 陕西(管理方:陕西IPTV运营公司);行业节点 8531 = IPTV视听内容
_ = gen.RegisterSegment(macode.Segment{
+287 -38
View File
@@ -1,23 +1,31 @@
// Package main 是 TCS-IPTV 可信数据空间的 ChainMaker 智能合约(Go,三期 A.2)。
// Package main 是 TCS-IPTV 可信数据空间的 ChainMaker 智能合约(Go)。
//
// 本合约为独立合约模块(独立 go.mod),按 ChainMaker docker-go/wasm 合约规范部署。
// 本合约为独立合约模块(独立 go.mod),按 ChainMaker docker-go 合约规范部署。
// 与 internal/chain.Client 接口语义一一对应;MVP/二期用 MemoryChain 等价实现,
// 具备链环境后部署本合约,由 chain-svc 通过 ChainMaker Go SDK 调用替换 MemoryChain
// 具备链环境后部署本合约,由 chain-svc / ChainMakerClient 通过 SDK 调用替换内存实现
//
// 状态键设计(KV):
//
// content:{maCode} -> Content JSON
// binding:{maCode}:{idx} -> HashBinding JSON
// content:{maCode} -> Content JSON(含 status
// binding:{maCode}:0 -> 整剧 file 哈希绑定 JSON
// binding:{maCode}:p -> 感知哈希绑定 JSON
// ep:{maCode}:{n} -> 集级绑定 JSON {episode,hash,revoked,reason}
// epcount:{maCode} -> 集数 N
// hashidx:{fileHash} -> maCode(防换壳重发)
// mapping:{maCode}:{idx} -> Mapping JSON
// mapcount:{maCode} -> 映射数 N
// version:{maCode}:{idx} -> VersionChange JSON
// vercount:{maCode} -> 版本变更数 N
// ctid2ma:{ctid} -> maCode
// allmacodes -> []maCode(供 ListContents 遍历)
//
// 权限:通过 sender 组织/角色证书判断(仅监管组织可 IssueMA/Revoke)。
// 权限:通过 sender 组织证书判断(仅监管组织可 IssueMA/Revoke/RevokeEpisode/Restore)。
package main
import (
"encoding/json"
"errors"
"strconv"
"chainmaker.org/chainmaker/contract-sdk-go/v2/pb/protogo"
"chainmaker.org/chainmaker/contract-sdk-go/v2/sandbox"
@@ -28,7 +36,8 @@ import (
type TCSRegistry struct{}
const (
orgRegulator = "regulator" // 监管组织仅其可签发/下架
orgRegulator = "regulator" // 监管组织仅其可签发/下架
orgReviewer = "reviewer" // 审核组织:哈希绑定/版本变更/状态流转
)
// InitContract 合约初始化。
@@ -41,13 +50,46 @@ func (t *TCSRegistry) UpgradeContract() protogo.Response {
return sdk.Success([]byte("tcs_registry upgraded"))
}
// senderOrg 取调用方组织标识(基于证书 OU/OrgId)。
func senderOrg() string {
org, _ := sdk.Instance.GetSenderOrgId()
return org
}
// IssueMA 签发 MA 码并 1:1 强绑定哈希(仅监管组织)。
func getInt(key, field string) int {
v, _ := sdk.Instance.GetStateByte(key, field)
if len(v) == 0 {
return 0
}
n, _ := strconv.Atoi(string(v))
return n
}
func putInt(key, field string, n int) {
_ = sdk.Instance.PutStateByte(key, field, []byte(strconv.Itoa(n)))
}
// epBinding 集级绑定的链上结构。
type epBinding struct {
Episode int `json:"episode"`
HashValue string `json:"hash_value"`
Revoked bool `json:"revoked"`
Reason string `json:"revoked_reason,omitempty"`
}
// appendMACode 把新发码加入全局列表(供 ListContents 遍历)。
func appendMACode(maCode string) {
var all []string
if v, _ := sdk.Instance.GetStateByte("allmacodes", ""); len(v) > 0 {
_ = json.Unmarshal(v, &all)
}
all = append(all, maCode)
b, _ := json.Marshal(all)
_ = sdk.Instance.PutStateByte("allmacodes", "", b)
}
// ---- 写方法 ----
// IssueMA 签发 MA 码并 1:1 强绑定哈希;同时登记集级哈希(仅监管组织)。
func (t *TCSRegistry) IssueMA() protogo.Response {
if senderOrg() != orgRegulator {
return sdk.Error("permission denied: only regulator can issue MA")
@@ -56,85 +98,292 @@ func (t *TCSRegistry) IssueMA() protogo.Response {
maCode := string(args["ma_code"])
ctid := string(args["ctid"])
fileHash := string(args["file_hash"])
contentJSON := args["content"]
// MA 不可重复签发
if existing, _ := sdk.Instance.GetStateByte("content", maCode); len(existing) > 0 {
return sdk.Error("MA already issued (1:1 binding immutable)")
}
// 防换壳重发:同哈希不可绑定到不同 MA
if bound, _ := sdk.Instance.GetStateByte("hashidx", fileHash); len(bound) > 0 {
return sdk.Error("content hash already exists")
}
_ = sdk.Instance.PutStateByte("content", maCode, contentJSON)
binding := map[string]string{"hash_type": "file_sha256", "hash_value": fileHash, "version": "v1.0"}
bj, _ := json.Marshal(binding)
_ = sdk.Instance.PutStateByte("binding", maCode+":0", bj)
// 内容主记录(强制 status=approved,与 MemoryChain 一致)
var content map[string]interface{}
_ = json.Unmarshal(args["content"], &content)
if content == nil {
content = map[string]interface{}{}
}
content["ma_code"] = maCode
content["content_twin_id"] = ctid
content["status"] = "approved"
cj, _ := json.Marshal(content)
_ = sdk.Instance.PutStateByte("content", maCode, cj)
// 整剧 file 绑定 + 感知哈希绑定
fb, _ := json.Marshal(epBinding{Episode: 0, HashValue: fileHash})
_ = sdk.Instance.PutStateByte("binding", maCode+":0", fb)
if ph := string(args["perceptual_hash"]); ph != "" {
pb, _ := json.Marshal(map[string]string{"hash_type": "perceptual", "hash_value": ph})
_ = sdk.Instance.PutStateByte("binding", maCode+":p", pb)
}
_ = sdk.Instance.PutStateByte("hashidx", fileHash, []byte(maCode))
_ = sdk.Instance.PutStateByte("ctid2ma", ctid, []byte(maCode))
// 集级哈希
var eps []map[string]interface{}
_ = json.Unmarshal(args["episodes"], &eps)
n := 0
for _, e := range eps {
ep := int(toFloat(e["episode"]))
hv, _ := e["file_sha256"].(string)
if ep <= 0 || hv == "" {
continue
}
eb, _ := json.Marshal(epBinding{Episode: ep, HashValue: hv})
_ = sdk.Instance.PutStateByte("ep", maCode+":"+strconv.Itoa(ep), eb)
if exist, _ := sdk.Instance.GetStateByte("hashidx", hv); len(exist) == 0 {
_ = sdk.Instance.PutStateByte("hashidx", hv, []byte(maCode))
}
if ep > n {
n = ep
}
}
putInt("epcount", maCode, n)
appendMACode(maCode)
sdk.Instance.EmitEvent("RegisterSuccess", []string{maCode, fileHash})
return sdk.Success([]byte(maCode))
}
// RegisterMapping 注册三方编码映射(MA 必须已签发)。
func (t *TCSRegistry) RegisterMapping() protogo.Response {
func toFloat(v interface{}) float64 {
if f, ok := v.(float64); ok {
return f
}
return 0
}
// RegisterHashBinding 追加哈希绑定(如转码版)。MA 必须已签发(审核/监管)。
func (t *TCSRegistry) RegisterHashBinding() protogo.Response {
if o := senderOrg(); o != orgReviewer && o != orgRegulator {
return sdk.Error("permission denied")
}
args := sdk.Instance.GetArgs()
maCode := string(args["ma_code"])
if v, _ := sdk.Instance.GetStateByte("content", maCode); len(v) == 0 {
ctid := string(args["ctid"])
ma, _ := sdk.Instance.GetStateByte("ctid2ma", ctid)
if len(ma) == 0 {
return sdk.Error("MA not issued")
}
idx := string(args["idx"])
_ = sdk.Instance.PutStateByte("mapping", maCode+":"+idx, args["mapping"])
idx := getInt("bindextra", string(ma)) + 1
_ = sdk.Instance.PutStateByte("bindextra", string(ma)+":"+strconv.Itoa(idx), args["binding"])
putInt("bindextra", string(ma), idx)
return sdk.Success([]byte("ok"))
}
// VerifyHash 校验提交哈希是否与绑定哈希一致
func (t *TCSRegistry) VerifyHash() protogo.Response {
// RegisterMapping 注册三方编码映射;MA 必须已签发(任意角色注册本方)
func (t *TCSRegistry) RegisterMapping() protogo.Response {
args := sdk.Instance.GetArgs()
maCode := string(args["ma_code"])
fileHash := string(args["file_hash"])
bound, _ := sdk.Instance.GetStateByte("hashidx", fileHash)
if string(bound) == maCode {
return sdk.Success([]byte("true"))
ctid := string(args["ctid"])
ma, _ := sdk.Instance.GetStateByte("ctid2ma", ctid)
if len(ma) == 0 {
return sdk.Error("MA not issued")
}
return sdk.Success([]byte("false"))
idx := getInt("mapcount", string(ma)) + 1
_ = sdk.Instance.PutStateByte("mapping", string(ma)+":"+strconv.Itoa(idx), args["mapping"])
putInt("mapcount", string(ma), idx)
return sdk.Success([]byte("ok"))
}
// Revoke 下架(仅监管组织)。
// RecordVersionChange 记录版本变更(审核/监管)。
func (t *TCSRegistry) RecordVersionChange() protogo.Response {
if o := senderOrg(); o != orgReviewer && o != orgRegulator {
return sdk.Error("permission denied")
}
args := sdk.Instance.GetArgs()
ctid := string(args["ctid"])
ma, _ := sdk.Instance.GetStateByte("ctid2ma", ctid)
if len(ma) == 0 {
return sdk.Error("MA not issued")
}
idx := getInt("vercount", string(ma)) + 1
_ = sdk.Instance.PutStateByte("version", string(ma)+":"+strconv.Itoa(idx), args["vc"])
putInt("vercount", string(ma), idx)
return sdk.Success([]byte("ok"))
}
// Revoke 整剧下架(仅监管组织)。
func (t *TCSRegistry) Revoke() protogo.Response {
if senderOrg() != orgRegulator {
return sdk.Error("permission denied: only regulator can revoke")
}
return setStatus(string(sdk.Instance.GetArgs()["ma_code"]), "revoked", "Revoked")
}
// SetContentStatus 状态流转(审核/监管):入库/发布等。
func (t *TCSRegistry) SetContentStatus() protogo.Response {
if o := senderOrg(); o != orgReviewer && o != orgRegulator {
return sdk.Error("permission denied")
}
args := sdk.Instance.GetArgs()
maCode := string(args["ma_code"])
return setStatus(string(args["ma_code"]), string(args["status"]), "StatusChanged")
}
func setStatus(maCode, status, event string) protogo.Response {
cj, _ := sdk.Instance.GetStateByte("content", maCode)
if len(cj) == 0 {
return sdk.Error("not found")
}
var content map[string]interface{}
_ = json.Unmarshal(cj, &content)
content["status"] = "revoked"
content["status"] = status
nj, _ := json.Marshal(content)
_ = sdk.Instance.PutStateByte("content", maCode, nj)
sdk.Instance.EmitEvent("Revoked", []string{maCode})
sdk.Instance.EmitEvent(event, []string{maCode, status})
return sdk.Success([]byte("ok"))
}
// RevokeEpisode 集级下架(仅监管组织)。
func (t *TCSRegistry) RevokeEpisode() protogo.Response {
if senderOrg() != orgRegulator {
return sdk.Error("permission denied")
}
args := sdk.Instance.GetArgs()
return setEpisodeRevoked(string(args["ma_code"]), string(args["episode"]), true, string(args["reason"]))
}
// RestoreEpisode 集级恢复(仅监管组织)。
func (t *TCSRegistry) RestoreEpisode() protogo.Response {
if senderOrg() != orgRegulator {
return sdk.Error("permission denied")
}
args := sdk.Instance.GetArgs()
return setEpisodeRevoked(string(args["ma_code"]), string(args["episode"]), false, "")
}
// Restore 整剧恢复(仅监管组织)。
func (t *TCSRegistry) Restore() protogo.Response {
if senderOrg() != orgRegulator {
return sdk.Error("permission denied")
}
return setStatus(string(sdk.Instance.GetArgs()["ma_code"]), "published", "Restored")
}
func setEpisodeRevoked(maCode, epStr string, revoked bool, reason string) protogo.Response {
key := maCode + ":" + epStr
v, _ := sdk.Instance.GetStateByte("ep", key)
if len(v) == 0 {
return sdk.Error("not found")
}
var eb epBinding
_ = json.Unmarshal(v, &eb)
eb.Revoked = revoked
eb.Reason = reason
nb, _ := json.Marshal(eb)
_ = sdk.Instance.PutStateByte("ep", key, nb)
return sdk.Success([]byte("ok"))
}
// ---- 读方法 ----
// VerifyHash 校验整剧/转码哈希是否绑定到该 MA。
func (t *TCSRegistry) VerifyHash() protogo.Response {
args := sdk.Instance.GetArgs()
bound, _ := sdk.Instance.GetStateByte("hashidx", string(args["file_hash"]))
if string(bound) == string(args["ma_code"]) {
return sdk.Success([]byte("true"))
}
return sdk.Success([]byte("false"))
}
// VerifyEpisodeHash 校验某集哈希。
func (t *TCSRegistry) VerifyEpisodeHash() protogo.Response {
args := sdk.Instance.GetArgs()
v, _ := sdk.Instance.GetStateByte("ep", string(args["ma_code"])+":"+string(args["episode"]))
if len(v) == 0 {
return sdk.Success([]byte("false"))
}
var eb epBinding
_ = json.Unmarshal(v, &eb)
if eb.HashValue == string(args["file_hash"]) {
return sdk.Success([]byte("true"))
}
return sdk.Success([]byte("false"))
}
// HashExists 返回绑定的 MA(不存在返回空)。
func (t *TCSRegistry) HashExists() protogo.Response {
bound, _ := sdk.Instance.GetStateByte("hashidx", string(sdk.Instance.GetArgs()["file_hash"]))
return sdk.Success(bound)
}
// ListEpisodes 返回某 MA 的集级绑定数组(JSON)。
func (t *TCSRegistry) ListEpisodes() protogo.Response {
maCode := string(sdk.Instance.GetArgs()["ma_code"])
n := getInt("epcount", maCode)
out := make([]epBinding, 0, n)
for i := 1; i <= n; i++ {
if v, _ := sdk.Instance.GetStateByte("ep", maCode+":"+strconv.Itoa(i)); len(v) > 0 {
var eb epBinding
_ = json.Unmarshal(v, &eb)
out = append(out, eb)
}
}
b, _ := json.Marshal(out)
return sdk.Success(b)
}
// QueryContent 查询内容主记录。
func (t *TCSRegistry) QueryContent() protogo.Response {
maCode := string(sdk.Instance.GetArgs()["ma_code"])
v, _ := sdk.Instance.GetStateByte("content", maCode)
v, _ := sdk.Instance.GetStateByte("content", string(sdk.Instance.GetArgs()["ma_code"]))
if len(v) == 0 {
return sdk.Error("not found")
}
return sdk.Success(v)
}
// QueryMappings 返回某 MA 的全部映射(JSON {mappings:[],cdn_endpoints:[]})。
func (t *TCSRegistry) QueryMappings() protogo.Response {
maCode := string(sdk.Instance.GetArgs()["ma_code"])
n := getInt("mapcount", maCode)
maps := make([]map[string]interface{}, 0, n)
cdns := []string{}
for i := 1; i <= n; i++ {
if v, _ := sdk.Instance.GetStateByte("mapping", maCode+":"+strconv.Itoa(i)); len(v) > 0 {
var m map[string]interface{}
_ = json.Unmarshal(v, &m)
maps = append(maps, m)
if ep, ok := m["cdn_endpoint"].(string); ok && ep != "" {
cdns = append(cdns, ep)
}
}
}
b, _ := json.Marshal(map[string]interface{}{"ma_code": maCode, "mappings": maps, "cdn_endpoints": cdns})
return sdk.Success(b)
}
// ListContents 按状态返回内容数组(空状态返回全部)。
func (t *TCSRegistry) ListContents() protogo.Response {
status := string(sdk.Instance.GetArgs()["status"])
var all []string
if v, _ := sdk.Instance.GetStateByte("allmacodes", ""); len(v) > 0 {
_ = json.Unmarshal(v, &all)
}
out := make([]map[string]interface{}, 0, len(all))
for _, ma := range all {
v, _ := sdk.Instance.GetStateByte("content", ma)
if len(v) == 0 {
continue
}
var c map[string]interface{}
_ = json.Unmarshal(v, &c)
if status == "" || c["status"] == status {
out = append(out, c)
}
}
b, _ := json.Marshal(out)
return sdk.Success(b)
}
func main() {
err := sandbox.Start(new(TCSRegistry))
if err != nil {
if err := sandbox.Start(new(TCSRegistry)); err != nil {
_ = errors.New(err.Error())
}
}
@@ -0,0 +1,12 @@
-- 集级下架状态镜像:hash_binding 增加 revoked / revoked_reason 列。
-- 对应需求11(集级应急下架/恢复),使集级下架状态可持久化镜像。
BEGIN;
ALTER TABLE hash_binding
ADD COLUMN IF NOT EXISTS revoked BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS revoked_reason TEXT;
COMMENT ON COLUMN hash_binding.revoked IS '集级下架标记:true=该集已下架';
COMMIT;
+294
View File
@@ -0,0 +1,294 @@
//go:build chainmaker
// Package chain — 真实链后端(长安链 ChainMaker)适配器骨架。
//
// 仅在 `go build -tags chainmaker` 时编译;默认构建由 chainmaker_stub.go 提供占位,
// 因此主工程在没有 ChainMaker Go SDK 依赖时也始终可编译。
//
// 接入步骤(需真实环境):
// 1. 引入 SDK 依赖:
// go get chainmaker.org/chainmaker/sdk-go/v2
// 2. 准备 sdk_config.yml(节点地址、TLS、四角色组织证书),路径由 TCS_CHAINMAKER_SDK_CONF 指定。
// 3. 部署 contracts/tcs_registry 合约,合约名见 contractName 常量。
// 4. 启动:TCS_CHAIN_BACKEND=chainmaker go run -tags chainmaker ./cmd/api-svc
//
// 设计:每个业务角色(监管/审核/CP/运营商)使用各自组织证书的 ChainClient
// 合约内 senderOrg() 据此做链上权限判定(IssueMA/Revoke 仅监管组织)。
// 写操作走 InvokeContract(同步等待上链确认),读操作走 QueryContract(不产生交易)。
package chain
import (
"database/sql"
"encoding/json"
"fmt"
"strings"
"chainmaker.org/chainmaker/pb-go/v2/common"
sdk "chainmaker.org/chainmaker/sdk-go/v2"
"github.com/tcs-iptv/tcs/internal/model"
)
const contractName = "tcs_registry"
// ChainMakerClient 是 chain.Client 的真实链实现。
type ChainMakerClient struct {
// clients 为四角色各自证书初始化的链客户端(组织/用户证书不同)。
clients map[Role]*sdk.ChainClient
// mirror 可选 PG 镜像(链为权威,镜像供高效查询)。为 nil 时仅用链。
mirror *sql.DB
}
var _ Client = (*ChainMakerClient)(nil)
// NewChainMakerClient 按 sdk_config.yml 初始化四角色链客户端。
//
// 真实实现需为每个角色加载其组织/用户证书(可在 sdk_config.yml 用多 user 段,
// 或为每个角色单独一个 config 文件)。此处给出装配骨架,证书细节随部署而定。
func NewChainMakerClient(sdkConfPath string, mirror *sql.DB) (Client, error) {
roles := []Role{RoleRegulator, RoleReviewer, RoleCP, RoleOperator}
clients := make(map[Role]*sdk.ChainClient, len(roles))
for _, r := range roles {
// TODO(deploy): 为每个角色加载其证书。示例:约定每角色一个配置文件
// conf := fmt.Sprintf("%s.%s.yml", strings.TrimSuffix(sdkConfPath, ".yml"), r)
cli, err := sdk.NewChainClient(sdk.WithConfPath(sdkConfPath))
if err != nil {
return nil, fmt.Errorf("chainmaker: 初始化角色 %s 客户端失败: %w", r, err)
}
clients[r] = cli
}
return &ChainMakerClient{clients: clients, mirror: mirror}, nil
}
// kv 构造合约入参键值对。
func kv(m map[string][]byte) []*common.KeyValuePair {
out := make([]*common.KeyValuePair, 0, len(m))
for k, v := range m {
out = append(out, &common.KeyValuePair{Key: k, Value: v})
}
return out
}
// invoke 以指定角色身份提交合约写交易(同步等待上链)。
func (c *ChainMakerClient) invoke(role Role, method string, args map[string][]byte) (*common.TxResponse, error) {
cli, ok := c.clients[role]
if !ok {
return nil, fmt.Errorf("chainmaker: 未配置角色 %s 的链客户端", role)
}
// withSyncResult=true:等待交易上链并返回合约执行结果
resp, err := cli.InvokeContract(contractName, method, "", kv(args), -1, true)
if err != nil {
return nil, err
}
if resp.Code != common.TxStatusCode_SUCCESS {
return nil, fmt.Errorf("chainmaker: tx 失败: %s", resp.Message)
}
if resp.ContractResult != nil && resp.ContractResult.Code != 0 {
return nil, mapContractError(string(resp.ContractResult.Message))
}
return resp, nil
}
// query 以指定角色身份发起合约查询(不产生交易)。
func (c *ChainMakerClient) query(role Role, method string, args map[string][]byte) ([]byte, error) {
cli := c.clients[role]
resp, err := cli.QueryContract(contractName, method, "", kv(args), -1)
if err != nil {
return nil, err
}
if resp.ContractResult != nil && resp.ContractResult.Code != 0 {
return nil, mapContractError(string(resp.ContractResult.Message))
}
if resp.ContractResult == nil {
return nil, ErrNotFound
}
return resp.ContractResult.Result, nil
}
// mapContractError 把合约返回的错误消息映射回 chain 包标准错误,保证与 MemoryChain 行为一致。
func mapContractError(msg string) error {
switch {
case strings.Contains(msg, "permission denied"):
return ErrPermissionDenied
case strings.Contains(msg, "already issued"):
return ErrMAAlreadyIssued
case strings.Contains(msg, "hash already exists"):
return ErrHashExists
case strings.Contains(msg, "not issued"):
return ErrMANotIssued
case strings.Contains(msg, "not found"):
return ErrNotFound
default:
return fmt.Errorf("chainmaker: %s", msg)
}
}
// ---- chain.Client 实现(写操作)----
func (c *ChainMakerClient) IssueMA(role Role, req IssueRequest) (string, error) {
contentJSON, _ := json.Marshal(req.Content)
epJSON, _ := json.Marshal(req.Episodes)
resp, err := c.invoke(role, "IssueMA", map[string][]byte{
"ma_code": []byte(req.MACode),
"ctid": []byte(req.ContentTwinID),
"merkle_root": []byte(req.MerkleRoot),
"file_hash": []byte(req.FileHash),
"perceptual_hash": []byte(req.PerceptualHash),
"episodes": epJSON,
"content": contentJSON,
})
if err != nil {
return "", err
}
// TODO(mirror): 成功后写 PG 镜像(可复用 PersistentChain 的 persist* 逻辑)
return resp.TxId, nil
}
func (c *ChainMakerClient) RegisterHashBinding(role Role, b model.HashBinding) (string, error) {
bj, _ := json.Marshal(b)
resp, err := c.invoke(role, "RegisterHashBinding", map[string][]byte{
"ctid": []byte(b.ContentTwinID), "binding": bj,
})
if err != nil {
return "", err
}
return resp.TxId, nil
}
func (c *ChainMakerClient) RegisterMapping(role Role, m model.Mapping) (string, error) {
mj, _ := json.Marshal(m)
resp, err := c.invoke(role, "RegisterMapping", map[string][]byte{
"ctid": []byte(m.ContentTwinID), "mapping": mj,
})
if err != nil {
return "", err
}
return resp.TxId, nil
}
func (c *ChainMakerClient) RecordVersionChange(vc model.VersionChange) (string, error) {
vj, _ := json.Marshal(vc)
resp, err := c.invoke(RoleReviewer, "RecordVersionChange", map[string][]byte{
"ctid": []byte(vc.ContentTwinID), "vc": vj,
})
if err != nil {
return "", err
}
return resp.TxId, nil
}
func (c *ChainMakerClient) Revoke(role Role, maCode, reason string) (MappingsResult, error) {
if _, err := c.invoke(role, "Revoke", map[string][]byte{
"ma_code": []byte(maCode), "reason": []byte(reason),
}); err != nil {
return MappingsResult{}, err
}
return c.QueryMappings(maCode)
}
func (c *ChainMakerClient) RevokeEpisode(role Role, maCode string, episode int, reason string) error {
_, err := c.invoke(role, "RevokeEpisode", map[string][]byte{
"ma_code": []byte(maCode), "episode": []byte(fmt.Sprint(episode)), "reason": []byte(reason),
})
return err
}
func (c *ChainMakerClient) Restore(role Role, maCode string) error {
_, err := c.invoke(role, "Restore", map[string][]byte{"ma_code": []byte(maCode)})
return err
}
func (c *ChainMakerClient) RestoreEpisode(role Role, maCode string, episode int) error {
_, err := c.invoke(role, "RestoreEpisode", map[string][]byte{
"ma_code": []byte(maCode), "episode": []byte(fmt.Sprint(episode)),
})
return err
}
func (c *ChainMakerClient) SetContentStatus(maCode, status string) error {
_, err := c.invoke(RoleReviewer, "SetContentStatus", map[string][]byte{
"ma_code": []byte(maCode), "status": []byte(status),
})
return err
}
// ---- chain.Client 实现(读操作)----
func (c *ChainMakerClient) VerifyHash(maCode, fileHash string) (VerifyResult, error) {
res, err := c.query(RoleOperator, "VerifyHash", map[string][]byte{
"ma_code": []byte(maCode), "file_hash": []byte(fileHash),
})
if err != nil {
return VerifyResult{MACode: maCode, SubmittedHash: fileHash}, err
}
match := string(res) == "true"
return VerifyResult{Valid: true, MACode: maCode, SubmittedHash: fileHash, Match: match}, nil
}
func (c *ChainMakerClient) VerifyEpisodeHash(maCode string, episode int, fileHash string) (VerifyResult, error) {
res, err := c.query(RoleOperator, "VerifyEpisodeHash", map[string][]byte{
"ma_code": []byte(maCode), "episode": []byte(fmt.Sprint(episode)), "file_hash": []byte(fileHash),
})
if err != nil {
return VerifyResult{MACode: maCode, SubmittedHash: fileHash}, err
}
return VerifyResult{Valid: true, MACode: maCode, SubmittedHash: fileHash, Match: string(res) == "true"}, nil
}
func (c *ChainMakerClient) ListEpisodes(maCode string) ([]model.HashBinding, error) {
res, err := c.query(RoleRegulator, "ListEpisodes", map[string][]byte{"ma_code": []byte(maCode)})
if err != nil {
return nil, err
}
var out []model.HashBinding
if err := json.Unmarshal(res, &out); err != nil {
return nil, err
}
return out, nil
}
func (c *ChainMakerClient) HashExists(fileHash string) (string, bool) {
res, err := c.query(RoleRegulator, "HashExists", map[string][]byte{"file_hash": []byte(fileHash)})
if err != nil || len(res) == 0 {
return "", false
}
return string(res), true
}
func (c *ChainMakerClient) QueryContent(maCode string) (model.Content, error) {
res, err := c.query(RoleRegulator, "QueryContent", map[string][]byte{"ma_code": []byte(maCode)})
if err != nil {
return model.Content{}, err
}
var content model.Content
if err := json.Unmarshal(res, &content); err != nil {
return model.Content{}, err
}
return content, nil
}
func (c *ChainMakerClient) ListContents(status string) ([]model.Content, error) {
// 优先走 PG 镜像(链上范围扫描代价高);无镜像时回源合约范围查询。
res, err := c.query(RoleRegulator, "ListContents", map[string][]byte{"status": []byte(status)})
if err != nil {
return nil, err
}
var out []model.Content
if err := json.Unmarshal(res, &out); err != nil {
return nil, err
}
return out, nil
}
func (c *ChainMakerClient) QueryMappings(maCode string) (MappingsResult, error) {
res, err := c.query(RoleRegulator, "QueryMappings", map[string][]byte{"ma_code": []byte(maCode)})
if err != nil {
return MappingsResult{}, err
}
var out MappingsResult
if err := json.Unmarshal(res, &out); err != nil {
return MappingsResult{}, err
}
out.MACode = maCode
return out, nil
}
@@ -0,0 +1,29 @@
//go:build chainmaker
package chain
import (
"os"
"testing"
"github.com/stretchr/testify/require"
)
// TestChainMakerClient_Conformance 让真实链实现跑同一套契约套件。
//
// 仅在 `go test -tags chainmaker` 且配置了测试链时运行:
// - TCS_TEST_CHAINMAKER_CONF:测试链 sdk_config.yml 路径
//
// 注意:真实链不易"清空状态",建议每次用全新 maCode/合约实例,或对接专用测试链。
// 本用例提供接线骨架,实际跑通需真实 ChainMaker 测试网与已部署的 tcs_registry 合约。
func TestChainMakerClient_Conformance(t *testing.T) {
conf := os.Getenv("TCS_TEST_CHAINMAKER_CONF")
if conf == "" {
t.Skip("未设置 TCS_TEST_CHAINMAKER_CONF,跳过真实链契约测试")
}
RunClientConformance(t, func(t *testing.T) Client {
c, err := NewChainMakerClient(conf, nil)
require.NoError(t, err)
return c
})
}
@@ -0,0 +1,18 @@
//go:build !chainmaker
package chain
import (
"database/sql"
"errors"
)
// ErrChainMakerNotBuilt 表示二进制未启用 chainmaker 构建标签,无法使用真实链后端。
var ErrChainMakerNotBuilt = errors.New("chain: 未启用 chainmaker 构建标签,请使用 `go build -tags chainmaker` 并引入 ChainMaker Go SDK")
// NewChainMakerClient 是真实链后端的占位实现(默认构建)。
// 真正的实现位于 chainmaker.go//go:build chainmaker),需引入 ChainMaker Go SDK。
// 这样默认构建不依赖链 SDK,主工程始终可编译;装配处可统一引用本构造函数。
func NewChainMakerClient(sdkConfPath string, mirror *sql.DB) (Client, error) {
return nil, ErrChainMakerNotBuilt
}
+160
View File
@@ -0,0 +1,160 @@
package chain
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tcs-iptv/tcs/internal/model"
)
// RunClientConformance 是 chain.Client 的契约测试套件,校验任意实现都满足同一组
// 不可变业务规则(权限/1:1强绑定/防换壳/状态机/集级粒度)。
//
// 各实现(MemoryChain / PersistentChain / ChainMakerClient)复用本套件,
// 保证「换实现不换行为」,这是平滑替换真实链的安全保障。
//
// newClient 必须返回一个干净(空状态)的 Client 实例。
func RunClientConformance(t *testing.T, newClient func(t *testing.T) Client) {
// 构造一条标准发码请求(集级 3 集)。
issueReq := func(ma, ctid, fh string) IssueRequest {
return IssueRequest{
MACode: ma, ContentTwinID: ctid, MerkleRoot: "mr-" + fh, FileHash: fh,
PerceptualHash: "ph-" + fh,
Episodes: []model.EpisodeHash{
{Episode: 1, FileSHA256: fh + "-E1"},
{Episode: 2, FileSHA256: fh + "-E2"},
{Episode: 3, FileSHA256: fh + "-E3"},
},
Content: model.Content{Title: "契约测试剧", EpisodeCount: 3, MAType: "WD", Issuer: "测试局"},
}
}
const ma = "MA.156.8531.6101/WD/20260000001"
const ctid = "ctid-conf-001"
const fh = "fh-conf-001"
t.Run("IssueMA_仅监管可发码", func(t *testing.T) {
c := newClient(t)
_, err := c.IssueMA(RoleCP, issueReq(ma, ctid, fh))
assert.ErrorIs(t, err, ErrPermissionDenied)
})
t.Run("IssueMA_成功并可查询", func(t *testing.T) {
c := newClient(t)
tx, err := c.IssueMA(RoleRegulator, issueReq(ma, ctid, fh))
require.NoError(t, err)
assert.NotEmpty(t, tx)
got, err := c.QueryContent(ma)
require.NoError(t, err)
assert.Equal(t, "契约测试剧", got.Title)
assert.Equal(t, model.StatusApproved, got.Status)
})
t.Run("IssueMA_不可重复签发", func(t *testing.T) {
c := newClient(t)
_, err := c.IssueMA(RoleRegulator, issueReq(ma, ctid, fh))
require.NoError(t, err)
_, err = c.IssueMA(RoleRegulator, issueReq(ma, ctid, fh))
assert.ErrorIs(t, err, ErrMAAlreadyIssued)
})
t.Run("防换壳_同哈希不可绑不同MA", func(t *testing.T) {
c := newClient(t)
_, err := c.IssueMA(RoleRegulator, issueReq(ma, ctid, fh))
require.NoError(t, err)
_, exists := c.HashExists(fh)
assert.True(t, exists)
_, err = c.IssueMA(RoleRegulator, issueReq("MA.156.8531.6101/WD/20260000002", "ctid-x", fh))
assert.ErrorIs(t, err, ErrHashExists)
})
t.Run("VerifyHash_匹配与不匹配", func(t *testing.T) {
c := newClient(t)
_, err := c.IssueMA(RoleRegulator, issueReq(ma, ctid, fh))
require.NoError(t, err)
r, err := c.VerifyHash(ma, fh)
require.NoError(t, err)
assert.True(t, r.Match)
r2, _ := c.VerifyHash(ma, "tampered")
assert.False(t, r2.Match)
})
t.Run("集级验真与列出", func(t *testing.T) {
c := newClient(t)
_, err := c.IssueMA(RoleRegulator, issueReq(ma, ctid, fh))
require.NoError(t, err)
r, err := c.VerifyEpisodeHash(ma, 2, fh+"-E2")
require.NoError(t, err)
assert.True(t, r.Match)
eps, err := c.ListEpisodes(ma)
require.NoError(t, err)
assert.Len(t, eps, 3)
})
t.Run("映射注册需先发码且可查", func(t *testing.T) {
c := newClient(t)
// 未发码不可注册映射
_, err := c.RegisterMapping(RoleCP, model.Mapping{ContentTwinID: "ctid-none", Party: model.PartyCP, PartyID: "X"})
assert.ErrorIs(t, err, ErrMANotIssued)
// 发码后可注册并查询
_, err = c.IssueMA(RoleRegulator, issueReq(ma, ctid, fh))
require.NoError(t, err)
_, err = c.RegisterMapping(RoleOperator, model.Mapping{
ContentTwinID: ctid, Party: model.PartyOperator, PartyID: "OP-1", CDNEndpoint: "cdn://x",
})
require.NoError(t, err)
mr, err := c.QueryMappings(ma)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(mr.Mappings), 1)
assert.Contains(t, mr.CDNEndpoints, "cdn://x")
})
t.Run("下架_仅监管且状态变更", func(t *testing.T) {
c := newClient(t)
_, err := c.IssueMA(RoleRegulator, issueReq(ma, ctid, fh))
require.NoError(t, err)
_, err = c.Revoke(RoleCP, ma, "x")
assert.ErrorIs(t, err, ErrPermissionDenied)
_, err = c.Revoke(RoleRegulator, ma, "违规")
require.NoError(t, err)
got, _ := c.QueryContent(ma)
assert.Equal(t, model.StatusRevoked, got.Status)
})
t.Run("集级下架与恢复", func(t *testing.T) {
c := newClient(t)
_, err := c.IssueMA(RoleRegulator, issueReq(ma, ctid, fh))
require.NoError(t, err)
require.NoError(t, c.RevokeEpisode(RoleRegulator, ma, 2, "违规集"))
eps, _ := c.ListEpisodes(ma)
for _, e := range eps {
if e.Episode == 2 {
assert.True(t, e.Revoked)
}
}
require.NoError(t, c.RestoreEpisode(RoleRegulator, ma, 2))
eps, _ = c.ListEpisodes(ma)
for _, e := range eps {
if e.Episode == 2 {
assert.False(t, e.Revoked)
}
}
})
t.Run("状态流转与按状态列举", func(t *testing.T) {
c := newClient(t)
_, err := c.IssueMA(RoleRegulator, issueReq(ma, ctid, fh))
require.NoError(t, err)
require.NoError(t, c.SetContentStatus(ma, model.StatusPublished))
pub, err := c.ListContents(model.StatusPublished)
require.NoError(t, err)
assert.Len(t, pub, 1)
})
}
// TestMemoryChain_Conformance 让内存实现跑契约套件(始终运行)。
func TestMemoryChain_Conformance(t *testing.T) {
RunClientConformance(t, func(t *testing.T) Client {
return NewMemoryChain()
})
}
+297
View File
@@ -0,0 +1,297 @@
package chain
import (
"database/sql"
"log"
"github.com/tcs-iptv/tcs/internal/model"
)
// PersistentChain 在 MemoryChain 之上叠加 PostgreSQL 持久化(最小改动模式)。
//
// 设计要点(面向未来平滑替换真实链):
// - 业务规则(权限、1:1 强绑定、防换壳重发、状态机)全部复用 MemoryChain,单一真相来源;
// - 读路径直接走内存(快);写路径在内存变更成功后「写穿」到 PG 镜像表;
// - 启动时从 PG「水合」恢复内存状态,进程重启不丢数据;
// - 链上为权威数据源的设计不变:PG 仅作镜像,写穿失败仅记日志、不阻断主流程。
//
// 未来接入真实 ChainMaker 时,整体以新的 chain.Client 实现替换即可,业务层零改动。
type PersistentChain struct {
*MemoryChain
db *sql.DB
}
var _ Client = (*PersistentChain)(nil)
// NewPersistentChain 创建持久化链客户端,并从 PG 水合既有数据。
func NewPersistentChain(db *sql.DB) (*PersistentChain, error) {
p := &PersistentChain{MemoryChain: NewMemoryChain(), db: db}
if err := p.hydrate(); err != nil {
return nil, err
}
return p, nil
}
// ---- 写穿(在内存变更成功后镜像到 PG)----
// IssueMA 发码签发:内存绑定成功后镜像内容主记录与全部哈希绑定。
func (p *PersistentChain) IssueMA(role Role, req IssueRequest) (string, error) {
tx, err := p.MemoryChain.IssueMA(role, req)
if err != nil {
return tx, err
}
c, _ := p.MemoryChain.QueryContent(req.MACode)
p.persistContent(c)
for _, b := range p.snapshotBindings(req.MACode) {
p.persistBinding(b)
}
p.persistTx(req.ContentTwinID, tx, "issueMA")
return tx, nil
}
// RegisterHashBinding 追加哈希绑定(如转码版):镜像该条绑定。
func (p *PersistentChain) RegisterHashBinding(role Role, b model.HashBinding) (string, error) {
tx, err := p.MemoryChain.RegisterHashBinding(role, b)
if err != nil {
return tx, err
}
p.persistBinding(b)
p.persistTx(b.ContentTwinID, tx, "registerHashBinding")
return tx, nil
}
// RegisterMapping 注册三方映射:镜像该映射(按唯一键幂等)。
func (p *PersistentChain) RegisterMapping(role Role, mp model.Mapping) (string, error) {
tx, err := p.MemoryChain.RegisterMapping(role, mp)
if err != nil {
return tx, err
}
p.persistMapping(mp)
p.persistTx(mp.ContentTwinID, tx, "registerMapping")
return tx, nil
}
// RecordVersionChange 版本变更:镜像到 version_history。
func (p *PersistentChain) RecordVersionChange(vc model.VersionChange) (string, error) {
tx, err := p.MemoryChain.RecordVersionChange(vc)
if err != nil {
return tx, err
}
p.exec(`INSERT INTO version_history
(content_twin_id, version, change_reason, prev_hash, new_hash, reaudit_required, reaudit_status, affected_episode)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
vc.ContentTwinID, vc.Version, vc.ChangeReason, vc.PrevHash, vc.NewHash,
vc.ReauditRequired, vc.ReauditStatus, vc.AffectedEpisode)
return tx, nil
}
// Revoke 整剧下架:镜像内容状态。
func (p *PersistentChain) Revoke(role Role, maCode, reason string) (MappingsResult, error) {
res, err := p.MemoryChain.Revoke(role, maCode, reason)
if err != nil {
return res, err
}
p.updateStatus(maCode, model.StatusRevoked)
return res, nil
}
// Restore 整剧恢复上架:镜像内容状态。
func (p *PersistentChain) Restore(role Role, maCode string) error {
if err := p.MemoryChain.Restore(role, maCode); err != nil {
return err
}
p.updateStatus(maCode, model.StatusPublished)
return nil
}
// RevokeEpisode 集级下架:镜像该集 revoked 标记。
func (p *PersistentChain) RevokeEpisode(role Role, maCode string, episode int, reason string) error {
if err := p.MemoryChain.RevokeEpisode(role, maCode, episode, reason); err != nil {
return err
}
p.updateEpisodeRevoked(maCode, episode, true, reason)
return nil
}
// RestoreEpisode 集级恢复:镜像该集 revoked 标记。
func (p *PersistentChain) RestoreEpisode(role Role, maCode string, episode int) error {
if err := p.MemoryChain.RestoreEpisode(role, maCode, episode); err != nil {
return err
}
p.updateEpisodeRevoked(maCode, episode, false, "")
return nil
}
// SetContentStatus 状态流转(入库/发布等):镜像内容状态。
func (p *PersistentChain) SetContentStatus(maCode, status string) error {
if err := p.MemoryChain.SetContentStatus(maCode, status); err != nil {
return err
}
p.updateStatus(maCode, status)
return nil
}
// ---- PG 写入小工具(best-effort,失败仅记日志)----
func (p *PersistentChain) exec(q string, args ...any) {
if _, err := p.db.Exec(q, args...); err != nil {
log.Printf("chain/pg: 写穿失败(忽略,镜像为非权威): %v", err)
}
}
func (p *PersistentChain) persistContent(c model.Content) {
var issueDate any
if c.IssueDate != "" {
issueDate = c.IssueDate
}
p.exec(`INSERT INTO content_registry
(content_twin_id, ma_code, ma_type, title, episode_count, status, issuer, issue_date, created_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
ON CONFLICT (content_twin_id) DO UPDATE SET status=EXCLUDED.status, updated_at=NOW()`,
c.ContentTwinID, c.MACode, c.MAType, c.Title, c.EpisodeCount, c.Status, c.Issuer, issueDate, c.CreatedAt)
}
func (p *PersistentChain) persistBinding(b model.HashBinding) {
p.exec(`INSERT INTO hash_binding
(content_twin_id, hash_type, hash_value, merkle_root, file_format, resolution, duration, version, parent_hash, episode, revoked, revoked_reason, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`,
b.ContentTwinID, string(b.HashType), b.HashValue, b.MerkleRoot, b.FileFormat, b.Resolution,
b.Duration, b.Version, b.ParentHash, b.Episode, b.Revoked, b.RevokedReason, b.CreatedBy)
}
func (p *PersistentChain) persistMapping(mp model.Mapping) {
p.exec(`INSERT INTO identity_mapping
(content_twin_id, party, party_id, party_name, cdn_endpoint)
VALUES ($1,$2,$3,$4,$5)
ON CONFLICT (content_twin_id, party, party_id) DO UPDATE SET cdn_endpoint=EXCLUDED.cdn_endpoint`,
mp.ContentTwinID, string(mp.Party), mp.PartyID, mp.PartyName, mp.CDNEndpoint)
}
func (p *PersistentChain) persistTx(ctid, txID, method string) {
p.exec(`INSERT INTO chain_tx (content_twin_id, tx_id, method, status)
VALUES ($1,$2,$3,'confirmed') ON CONFLICT (tx_id) DO NOTHING`,
ctid, txID, method)
}
func (p *PersistentChain) updateStatus(maCode, status string) {
p.exec(`UPDATE content_registry SET status=$1, updated_at=NOW() WHERE ma_code=$2`, status, maCode)
}
func (p *PersistentChain) updateEpisodeRevoked(maCode string, episode int, revoked bool, reason string) {
p.exec(`UPDATE hash_binding hb SET revoked=$1, revoked_reason=$2
FROM content_registry cr
WHERE hb.content_twin_id = cr.content_twin_id AND cr.ma_code=$3 AND hb.episode=$4`,
revoked, reason, maCode, episode)
}
// snapshotBindings 复制某 MA 码当前的内存绑定(同包访问,读锁保护)。
func (p *PersistentChain) snapshotBindings(maCode string) []model.HashBinding {
p.mu.RLock()
defer p.mu.RUnlock()
src := p.bindings[maCode]
out := make([]model.HashBinding, len(src))
copy(out, src)
return out
}
// ---- 启动水合:从 PG 镜像恢复内存状态 ----
func (p *PersistentChain) hydrate() error {
// 1) 内容主表 + 建立 ctid -> maCode 映射
ctidToMA := map[string]string{}
rows, err := p.db.Query(`SELECT content_twin_id, ma_code, COALESCE(ma_type,''), title,
COALESCE(episode_count,1), status, COALESCE(issuer,''),
COALESCE(to_char(issue_date,'YYYY-MM-DD'),''), created_at FROM content_registry`)
if err != nil {
return err
}
n := 0
for rows.Next() {
var c model.Content
if err := rows.Scan(&c.ContentTwinID, &c.MACode, &c.MAType, &c.Title,
&c.EpisodeCount, &c.Status, &c.Issuer, &c.IssueDate, &c.CreatedAt); err != nil {
rows.Close()
return err
}
p.contents[c.MACode] = c
ctidToMA[c.ContentTwinID] = c.MACode
n++
}
rows.Close()
// 2) 哈希绑定(含集级、转码、感知)+ 重建防换壳哈希索引
bRows, err := p.db.Query(`SELECT content_twin_id, hash_type, hash_value, COALESCE(merkle_root,''),
COALESCE(file_format,''), COALESCE(resolution,''), COALESCE(duration,0), version,
COALESCE(parent_hash,''), episode, revoked, COALESCE(revoked_reason,''), COALESCE(created_by,'')
FROM hash_binding ORDER BY id`)
if err != nil {
return err
}
for bRows.Next() {
var b model.HashBinding
var ht string
if err := bRows.Scan(&b.ContentTwinID, &ht, &b.HashValue, &b.MerkleRoot, &b.FileFormat,
&b.Resolution, &b.Duration, &b.Version, &b.ParentHash, &b.Episode, &b.Revoked,
&b.RevokedReason, &b.CreatedBy); err != nil {
bRows.Close()
return err
}
b.HashType = model.HashType(ht)
ma := ctidToMA[b.ContentTwinID]
if ma == "" {
continue
}
p.bindings[ma] = append(p.bindings[ma], b)
if (b.HashType == model.HashFile || b.HashType == model.HashTranscoded) && b.HashValue != "" {
if _, ok := p.hashIndex[b.HashValue]; !ok {
p.hashIndex[b.HashValue] = ma
}
}
}
bRows.Close()
// 3) 三方映射
mRows, err := p.db.Query(`SELECT content_twin_id, party, party_id, COALESCE(party_name,''), COALESCE(cdn_endpoint,'')
FROM identity_mapping ORDER BY id`)
if err != nil {
return err
}
for mRows.Next() {
var mp model.Mapping
var party string
if err := mRows.Scan(&mp.ContentTwinID, &party, &mp.PartyID, &mp.PartyName, &mp.CDNEndpoint); err != nil {
mRows.Close()
return err
}
mp.Party = model.Party(party)
if ma := ctidToMA[mp.ContentTwinID]; ma != "" {
p.mappings[ma] = append(p.mappings[ma], mp)
}
}
mRows.Close()
// 4) 版本变更
vRows, err := p.db.Query(`SELECT content_twin_id, version, COALESCE(change_reason,''), COALESCE(prev_hash,''),
COALESCE(new_hash,''), reaudit_required, COALESCE(reaudit_status,''), COALESCE(affected_episode,0)
FROM version_history ORDER BY id`)
if err != nil {
return err
}
for vRows.Next() {
var vc model.VersionChange
if err := vRows.Scan(&vc.ContentTwinID, &vc.Version, &vc.ChangeReason, &vc.PrevHash,
&vc.NewHash, &vc.ReauditRequired, &vc.ReauditStatus, &vc.AffectedEpisode); err != nil {
vRows.Close()
return err
}
if ma := ctidToMA[vc.ContentTwinID]; ma != "" {
p.versions[ma] = append(p.versions[ma], vc)
}
}
vRows.Close()
if n > 0 {
log.Printf("chain/pg: 已从 PostgreSQL 水合 %d 条内容记录", n)
}
return nil
}
@@ -0,0 +1,36 @@
package chain
import (
"database/sql"
"os"
"testing"
_ "github.com/lib/pq"
"github.com/stretchr/testify/require"
)
// TestPersistentChain_Conformance 让 PG 持久化实现跑同一套契约套件。
//
// 需要一个可写的测试库:设置 TCS_TEST_PG_DSN 后运行,例如
//
// TCS_TEST_PG_DSN='postgres://postgres@localhost:5432/tcs_iptv_test?sslmode=disable' go test ./internal/chain/
//
// 未设置则跳过(不污染开发库)。每个子用例前清空镜像表,保证干净状态。
func TestPersistentChain_Conformance(t *testing.T) {
dsn := os.Getenv("TCS_TEST_PG_DSN")
if dsn == "" {
t.Skip("未设置 TCS_TEST_PG_DSN,跳过 PG 契约测试")
}
db, err := sql.Open("postgres", dsn)
require.NoError(t, err)
require.NoError(t, db.Ping())
t.Cleanup(func() { _ = db.Close() })
RunClientConformance(t, func(t *testing.T) Client {
_, err := db.Exec(`TRUNCATE content_registry, hash_binding, identity_mapping, version_history, chain_tx CASCADE`)
require.NoError(t, err)
pc, err := NewPersistentChain(db)
require.NoError(t, err)
return pc
})
}
+7
View File
@@ -12,6 +12,10 @@ type Config struct {
HashAddr string
PostgresDSN string
RedisAddr string
// ChainBackend 选择链实现:memory(纯内存)| pg(内存+PG镜像)| chainmaker(真实链,需 -tags chainmaker 构建)
ChainBackend string
// ChainMakerSDKConf ChainMaker Go SDK 配置文件路径(节点地址/TLS/组织证书),仅 chainmaker 后端使用
ChainMakerSDKConf string
}
func getEnv(key, def string) string {
@@ -29,5 +33,8 @@ func Load() Config {
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"),
// 默认 pg:PG 可用则内存+镜像持久化,不可用自动回退内存(见 api-svc 装配)
ChainBackend: getEnv("TCS_CHAIN_BACKEND", "pg"),
ChainMakerSDKConf: getEnv("TCS_CHAINMAKER_SDK_CONF", "deploy/chainmaker/sdk_config.yml"),
}
}
+17 -4
View File
@@ -21,12 +21,24 @@ call() { # key secret method path body
}
field() { echo "$1" | sed -n "s/.*\"$2\":\"\([^\"]*\)\".*/\1/p"; }
# 一条内容完整流转:title category fhash cp_id cp_name op_id op_name cdn
# episodes_json base_hash count → 构造分集哈希数组(一剧一码 + 集级独立哈希)
episodes_json() {
local fh="$1" n="${2:-6}" i out=""
for i in $(seq 1 "$n"); do
[ -n "$out" ] && out="$out,"
out="$out{\"episode\":$i,\"file_sha256\":\"$fh-E$i\",\"merkle_root\":\"mr-$fh-E$i\",\"perceptual_hash\":\"ph-$fh-E$i\",\"resolution\":\"1080p\",\"duration\":2400}"
done
echo "[$out]"
}
# 一条内容完整流转:title category fhash cp_id cp_name op_id op_name cdn [episodes]
flow() {
local title="$1" cat="$2" fh="$3" cpid="$4" cpname="$5" opid="$6" opname="$7" cdn="$8"
echo ">>> [$title] CP=$cpname"
local n="${9:-6}"
echo ">>> [$title] CP=$cpname $n 集,每集独立哈希)"
local eps; eps=$(episodes_json "$fh" "$n")
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\"}")
"{\"title\":\"$title\",\"episode_count\":$n,\"category\":\"$cat\",\"file_sha256\":\"$fh\",\"merkle_root\":\"mr-$fh\",\"perceptual_hash\":\"ph-$fh\",\"episodes\":$eps,\"cp_media_id\":\"$cpid\",\"cp_name\":\"$cpname\"}")
local rid ctid; rid=$(field "$reg" review_id); ctid=$(field "$reg" content_twin_id)
# CSPS 合规审核(发码前)
@@ -67,4 +79,5 @@ echo ""
echo "=== 已生成 MA 码(可复制到监管大屏查询)==="
cat /tmp/tcs_demo_macodes.txt
echo ""
echo "提示:在 http://localhost:5174 输入上述任一 MA 码查询全链路三方映射"
echo "提示:在 http://localhost:5174「角色工作台 → 监管片库」点详情查看全链路三方映射与集级哈希;"
echo " 或在「大小屏融合」tab 用上述 MA 码体验跨域解析 / 扫码验真 / 跨屏权益。"
+24 -138
View File
@@ -1,157 +1,43 @@
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 { Layout, Typography, Menu } from 'antd'
import FlowDemo from './FlowDemo.jsx'
import RoleDesk from './RoleDesk.jsx'
import ScreenFusion from './ScreenFusion.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>
</>
)
const VIEWS = {
desk: <RoleDesk />,
flow: <FlowDemo />,
fusion: <ScreenFusion />,
}
const MENU_ITEMS = [
{ key: 'desk', label: '角色工作台(多方协同演示)' },
{ key: 'flow', label: '全流程演示(一键)' },
{ key: 'fusion', label: '大小屏融合(OTT/手机)' },
]
export default function App() {
const [view, setView] = useState('desk')
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 }}>
<Header style={{ background: '#1a237e', display: 'flex', alignItems: 'center', paddingInline: 24 }}>
<Title level={4} style={{ color: '#fff', margin: 0, whiteSpace: 'nowrap' }}>
TCS-IPTV 内容可信锁定系统
</Title>
<Text style={{ color: '#b3c5ff', marginLeft: 16, whiteSpace: 'nowrap' }}>
陕西IPTV运营公司 · MA码+哈希双锚定
</Text>
<Menu
mode="horizontal" theme="dark" selectedKeys={[view]}
onClick={(e) => setView(e.key)} items={MENU_ITEMS}
style={{ background: 'transparent', flex: 1, justifyContent: 'flex-end', borderBottom: 'none', minWidth: 0 }}
/>
</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 /> },
]}
/>
{VIEWS[view]}
</Content>
</Layout>
)
+6 -1
View File
@@ -157,7 +157,11 @@ export default function FlowDemo() {
}
return (
<Row gutter={16}>
<>
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
全流程演示一键跑通一条内容送审审核发码入库发布注入全闭环直观展示"审过即锁定,锁定即通行"并可按集验真与模拟篡改拦截
</Paragraph>
<Row gutter={16}>
<Col span={9}>
<Card title="演示参数" size="small" style={{ marginBottom: 16 }}>
<Form form={form} layout="vertical" size="small" initialValues={{
@@ -272,5 +276,6 @@ export default function FlowDemo() {
</Card>
</Col>
</Row>
</>
)
}
+3
View File
@@ -432,6 +432,9 @@ export default function RoleDesk() {
const [tick, bump] = useTick()
return (
<div>
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
角色工作台演示模拟内容提供商审核监管运营商三方<strong>未来在各自系统中的协同工作模式</strong>实际部署中三方分别在自有系统操作通过 MA + 哈希在可信数据空间协同本台仅为便于演示将三方视角集中呈现依次完成送审审核发码入库发布注入全流程监管片库可查全链路三方映射按集下架/恢复与权益治理
</Typography.Paragraph>
<div style={{ marginBottom: 16 }}><Overview tick={tick} /></div>
<Tabs
type="card"
+224
View File
@@ -0,0 +1,224 @@
import React, { useState } from 'react'
import {
Card, Input, Button, Space, Tag, message, Descriptions, Row, Col,
Segmented, Typography, Result, Divider,
} from 'antd'
import {
ScanOutlined, GlobalOutlined, MobileOutlined, DesktopOutlined,
ShoppingOutlined, SafetyCertificateOutlined,
} from '@ant-design/icons'
import { api } from './api.js'
const { Text, Paragraph } = Typography
const screenMeta = {
iptv: { label: 'IPTV 大屏', color: 'blue', icon: <DesktopOutlined /> },
ott: { label: 'OTT/智能电视', color: 'geekblue', icon: <DesktopOutlined /> },
app: { label: '手机 APP', color: 'green', icon: <MobileOutlined /> },
}
function ScreenTags({ screens }) {
if (!screens || screens.length === 0) return <Tag>暂不可用</Tag>
return (
<Space>
{screens.map((s) => (
<Tag key={s} color={screenMeta[s]?.color} icon={screenMeta[s]?.icon}>
{screenMeta[s]?.label || s}
</Tag>
))}
</Space>
)
}
// ============ 跨域解析网关(C.1/C.2============
function ResolvePanel() {
const [maCode, setMaCode] = useState('')
const [res, setRes] = useState(null)
const [loading, setLoading] = useState(false)
async function doResolve() {
if (!maCode) return message.warning('请输入 MA 码(支持集级子标识 #E03)')
setLoading(true)
const r = await api.resolve(maCode.trim())
setLoading(false)
if (r.ok) setRes(r.data.data)
else { setRes(null); message.error(r.data.message || '解析失败') }
}
const p = res?.parsed
return (
<Card size="small" title={<Space><GlobalOutlined />MA 跨域解析网关 · 同一码三屏统一解析</Space>}>
<Space.Compact style={{ width: '100%', maxWidth: 640 }}>
<Input placeholder="如 MA.156.8531.6101/WD/20260000021 或 ...#E03"
value={maCode} onChange={(e) => setMaCode(e.target.value)} onPressEnter={doResolve} />
<Button type="primary" loading={loading} onClick={doResolve}>解析</Button>
</Space.Compact>
{res && (
<div style={{ marginTop: 16 }}>
<Descriptions bordered size="small" column={2}>
<Descriptions.Item label="解析结果">
{res.resolved ? <Tag color="green">解析成功</Tag> : <Tag color="red">未解析/未登记</Tag>}
</Descriptions.Item>
<Descriptions.Item label="流通状态">
{res.in_circulation ? <Tag color="blue">流通中</Tag> : <Tag color="orange">{res.status || '不可用'}</Tag>}
</Descriptions.Item>
<Descriptions.Item label="作品">{res.title || '-'}</Descriptions.Item>
<Descriptions.Item label="发证主体">{res.issuer || '-'}</Descriptions.Item>
<Descriptions.Item label="跨屏可用" span={2}><ScreenTags screens={res.screens} /></Descriptions.Item>
<Descriptions.Item label="结构解析" span={2}>
{p?.valid ? (
<Space wrap>
<Tag>国家码 {p.country_code}</Tag>
<Tag>行业 {p.industry_node}</Tag>
<Tag>机构 {p.org_node}</Tag>
<Tag color="purple">类目 {p.category}</Tag>
<Tag>{p.year} </Tag>
<Tag>序列 {p.sequence}</Tag>
{p.episode > 0 && <Tag color="magenta"> {p.episode} </Tag>}
</Space>
) : <Tag color="red">结构非法</Tag>}
</Descriptions.Item>
<Descriptions.Item label="说明" span={2}><Text type="secondary">{res.message}</Text></Descriptions.Item>
</Descriptions>
</div>
)}
</Card>
)
}
// ============ 扫码验真(B.2============
function ScanVerifyPanel() {
const [maCode, setMaCode] = useState('')
const [res, setRes] = useState(null)
const [loading, setLoading] = useState(false)
async function doScan() {
if (!maCode) return message.warning('请输入/扫描 MA 码')
setLoading(true)
const r = await api.scanVerify(maCode.trim())
setLoading(false)
if (r.ok) setRes(r.data.data)
else { setRes(null); message.error(r.data.message || '验真失败') }
}
let status = 'info', title = '请扫码验真'
if (res) {
if (res.authentic && res.compliant) { status = 'success'; title = '正版内容 · 合规流通' }
else if (res.authentic && !res.compliant) { status = 'warning'; title = '真码 · 但不合规(已下架/未流通)' }
else { status = 'error'; title = '验真失败 · 疑似盗版/伪造' }
}
return (
<Card size="small" title={<Space><ScanOutlined />用户扫码验真 · 防盗版</Space>}>
<Space.Compact style={{ width: '100%', maxWidth: 640 }}>
<Input placeholder="模拟扫码:粘贴 MA 码" value={maCode}
onChange={(e) => setMaCode(e.target.value)} onPressEnter={doScan} />
<Button type="primary" icon={<ScanOutlined />} loading={loading} onClick={doScan}>扫码验真</Button>
</Space.Compact>
{res && (
<Result style={{ paddingTop: 16, paddingBottom: 8 }}
status={status} title={title}
subTitle={
<Space direction="vertical">
<Space>
<Tag color={res.authentic ? 'green' : 'red'} icon={<SafetyCertificateOutlined />}>
{res.authentic ? '真码' : '假码/未登记'}
</Tag>
<Tag color={res.compliant ? 'blue' : 'orange'}>{res.compliant ? '合规流通' : '不合规'}</Tag>
{res.title && <Text>{res.title}</Text>}
</Space>
<ScreenTags screens={res.screens} />
<Text type="secondary">{res.message}</Text>
</Space>
} />
)}
</Card>
)
}
// ============ 跨屏权益通兑(D.1============
function RightsPanel() {
const [maCode, setMaCode] = useState('')
const [userHash, setUserHash] = useState('user-demo-001')
const [buyScreen, setBuyScreen] = useState('iptv')
const [verifyScreen, setVerifyScreen] = useState('app')
const [buyRes, setBuyRes] = useState(null)
const [verifyRes, setVerifyRes] = useState(null)
async function doBuy() {
if (!maCode) return message.warning('请输入 MA 码')
const r = await api.purchase(maCode.trim(), userHash, buyScreen)
if (r.ok) { setBuyRes(r.data.data); message.success(`已在「${screenMeta[buyScreen].label}」购买`) }
else message.error(r.data.message || '购买失败')
}
async function doVerify() {
if (!maCode) return message.warning('请输入 MA 码')
const r = await api.verifyRights(maCode.trim(), userHash, verifyScreen)
if (r.ok) setVerifyRes(r.data.data)
else message.error(r.data.message || '核验失败')
}
const opts = Object.entries(screenMeta).map(([v, m]) => ({ label: m.label, value: v }))
return (
<Card size="small" title={<Space><ShoppingOutlined />跨屏权益通兑 · 一次购买全屏通看</Space>}>
<Space direction="vertical" style={{ width: '100%' }}>
<Space wrap>
<Input addonBefore="MA 码" style={{ width: 380 }} value={maCode}
onChange={(e) => setMaCode(e.target.value)} placeholder="已发布的 MA 码" />
<Input addonBefore="用户" style={{ width: 220 }} value={userHash}
onChange={(e) => setUserHash(e.target.value)} />
</Space>
<Row gutter={16}>
<Col span={12}>
<Card size="small" title="① 购买(任一屏)" type="inner">
<Space direction="vertical" style={{ width: '100%' }}>
<Segmented value={buyScreen} onChange={setBuyScreen} options={opts} />
<Button type="primary" icon={<ShoppingOutlined />} onClick={doBuy}>购买</Button>
{buyRes && (
<Text type="success">
已购买{screenMeta[buyRes.screen]?.label}{new Date(buyRes.purchased_at).toLocaleString()}
</Text>
)}
</Space>
</Card>
</Col>
<Col span={12}>
<Card size="small" title="② 换一屏核验权益" type="inner">
<Space direction="vertical" style={{ width: '100%' }}>
<Segmented value={verifyScreen} onChange={setVerifyScreen} options={opts} />
<Button icon={<SafetyCertificateOutlined />} onClick={doVerify}>核验权益</Button>
{verifyRes && (
verifyRes.entitled
? <Tag color="green" style={{ whiteSpace: 'normal' }}> 有权益通兑{verifyRes.message}</Tag>
: <Tag color="red" style={{ whiteSpace: 'normal' }}> 无权益{verifyRes.message}</Tag>
)}
</Space>
</Card>
</Col>
</Row>
<Text type="secondary" style={{ fontSize: 12 }}>
演示IPTV 大屏购买后切到手机 APP核验应通兑通看且不重复付费权益归一到整剧 MA
</Text>
</Space>
</Card>
)
}
export default function ScreenFusion() {
return (
<div>
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
大小屏融合同一 MA 码贯通 IPTV / OTT / 手机 APP统一解析扫码验真一次购买全屏通看
</Paragraph>
<Space direction="vertical" style={{ width: '100%' }} size={16}>
<ResolvePanel />
<ScanVerifyPanel />
<RightsPanel />
</Space>
</div>
)
}
+5
View File
@@ -74,4 +74,9 @@ export const api = {
authorize: (maCode, regions, platforms, expiryAt) => request('regulator', 'POST', '/content/authorize', { ma_code: maCode, regions, platforms, expiry_at: expiryAt }),
authCheck: (maCode, region, platform) => request('regulator', 'POST', '/content/auth-check', { ma_code: maCode, region, platform }),
crossProvince: (maCode, fileHash, province) => request('regulator', 'POST', '/content/cross-province', { ma_code: maCode, file_sha256: fileHash, province }),
// 四期:大小屏融合(跨域解析/扫码验真/跨屏权益)
resolve: (maCode) => request('regulator', 'GET', '/content/resolve?ma_code=' + encodeURIComponent(maCode)),
scanVerify: (maCode) => request('operator', 'POST', '/content/scan-verify', { ma_code: maCode }),
purchase: (maCode, userHash, screen) => request('operator', 'POST', '/rights/purchase', { ma_code: maCode, user_hash: userHash, screen }),
verifyRights: (maCode, userHash, screen) => request('operator', 'POST', '/rights/verify', { ma_code: maCode, user_hash: userHash, screen }),
}