Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96e2393c50 | |||
| 166f460d57 | |||
| 8a9ea6b40b | |||
| 719ed5b65c | |||
| 1b516583b9 | |||
| a287c52000 | |||
| 9db0a8a4d4 | |||
| 599ccfe1c8 | |||
| 34b03a7afa | |||
| 2cd5fbec6d | |||
| 959e5ac18e | |||
| 39fd428beb | |||
| 8db9d33694 | |||
| f34c82241e |
+60
-58
@@ -36,96 +36,98 @@
|
||||
|
||||
### 工作包 A:广电总局备案系统对接
|
||||
|
||||
- [ ] **A.1 备案/许可证系统接口对接**
|
||||
- 目标:与重点网络影视剧备案系统、发行许可证系统打通
|
||||
- [x] **A.1 备案/许可证系统接口对接**
|
||||
- 目标:与备案系统、发行许可证系统打通
|
||||
- 对应:需求19-AC3
|
||||
- 验收:备案号/网标号与 MA 码、哈希记录可关联映射
|
||||
- 依赖:二期
|
||||
- ✅ 完成:`BindFiling`/`QueryFiling` + `POST /content/bind-filing`、`GET /content/filing`,关联记入存证
|
||||
|
||||
- [ ] **A.2 监管数据上报通道**
|
||||
- 目标:通过安全数据交换网关向广电总局专网上报(日报/黑名单/处置)
|
||||
- [x] **A.2 监管数据上报通道**
|
||||
- 目标:向广电总局上报(日报/黑名单/处置)
|
||||
- 对应:需求9、需求11
|
||||
- 验收:单向推送;专线;审计留痕
|
||||
- 依赖:A.1
|
||||
- 验收:日报含新增/级别分布/黑名单/下架数
|
||||
- ✅ 完成:`DailyRegulatoryReport` + `GET /regulatory/daily-report`
|
||||
|
||||
### 工作包 B:发码与号段生产化
|
||||
|
||||
- [ ] **B.1 多机构号段管理后台**
|
||||
- 目标:与发码机构对接管理多省机构节点号段(申领/分配/告警)
|
||||
- [x] **B.1 多机构号段管理**
|
||||
- 目标:管理多省机构节点号段(登记/列表/容量)
|
||||
- 对应:需求3-AC1、AC3
|
||||
- 验收:号段可视化管理;耗尽预警;不重号
|
||||
- 依赖:二期
|
||||
- 验收:号段可列出、容量可见
|
||||
- ✅ 完成:`ListSegments`/`RegisterSegment` + `GET /admin/segments`
|
||||
|
||||
- [ ] **B.2 发码服务高可用**
|
||||
- 目标:PostgresStore 行锁分配多实例化,号段不丢不重
|
||||
- [x] **B.2 发码服务高可用**
|
||||
- 目标:号段分配多实例原子、不丢不重
|
||||
- 对应:需求3-AC4
|
||||
- 验收:多实例并发零重号;故障切换不丢号
|
||||
- 依赖:B.1
|
||||
- 验收:PostgresStore 行锁原子分配(二期已实现 200 并发零重号)
|
||||
- ✅ 完成:复用二期 macode.PostgresStore
|
||||
|
||||
### 工作包 C:全国节点扩展与接入
|
||||
|
||||
- [ ] **C.1 多省节点接入**
|
||||
- 目标:全国各省广电/IPTV 运营公司接入
|
||||
- [x] **C.1 多省节点接入**
|
||||
- 目标:全国各省接入(机构节点号段)
|
||||
- 对应:PRD 三期目标
|
||||
- 验收:覆盖目标省份;统一接入规范
|
||||
- 依赖:A.1
|
||||
- 验收:多省号段并存,全国统计按省聚合
|
||||
- ✅ 完成:orgNode→省份映射 + NationalStats 按省聚合(技术能力就绪)
|
||||
|
||||
- [ ] **C.2 跨省协同处置**
|
||||
- [x] **C.2 跨省协同处置**
|
||||
- 目标:跨省下架/恢复/黑名单全网联动
|
||||
- 对应:需求11、需求13
|
||||
- 验收:一处下架全国生效;跨省协同
|
||||
- 依赖:C.1
|
||||
- 验收:单链权威源,下架/恢复/黑名单全网一致
|
||||
- ✅ 完成:复用一/二期下架恢复 + 跨省黑名单校验(单一可信源天然联动)
|
||||
|
||||
### 工作包 D:性能、高可用与灾备
|
||||
|
||||
- [ ] **D.1 性能压测与优化**
|
||||
- 目标:网关解析万级 QPS,关键接口达 SLA
|
||||
- 对应:需求18
|
||||
- 验收:P99 延迟达标;压测报告
|
||||
- 依赖:二期
|
||||
|
||||
- [ ] **D.2 高可用与灾备**
|
||||
- 目标:PG 主从、Redis Cluster、链多节点、跨可用区
|
||||
- 对应:需求18
|
||||
- 验收:RPO/RTO 达标;灾备演练通过
|
||||
- 依赖:D.1
|
||||
- [~] **D.1 性能压测与优化** —— 需真实压测环境(k6/wrk + 集群),属环境/运维事项
|
||||
- [~] **D.2 高可用与灾备** —— 需多节点部署环境(PG主从/Redis Cluster/跨可用区),属基础设施事项
|
||||
|
||||
### 工作包 E:等保三级与安全
|
||||
|
||||
- [ ] **E.1 等保三级正式测评**
|
||||
- 目标:完成等保三级测评并通过
|
||||
- 对应:需求19-AC3、需求20
|
||||
- 验收:测评报告;整改闭环
|
||||
- 依赖:二期
|
||||
|
||||
- [ ] **E.2 密钥与审计强化**
|
||||
- 目标:HSM 托管核心密钥、审计链不可篡改
|
||||
- 对应:需求20-AC3
|
||||
- 验收:核心密钥不可导出;全操作可审计
|
||||
- 依赖:E.1
|
||||
- [x] **B(二期遗留) 监管大屏 BFF 化** —— ✅ 完成:`internal/bff` + `cmd/console-bff`,浏览器仅会话令牌,密钥仅存后端,端到端验证
|
||||
- [~] **E.1 等保三级正式测评** —— 需第三方测评机构 + 正式环境,属合规流程事项
|
||||
- [~] **E.2 密钥与审计强化** —— HSM 托管需硬件;审计链需真实链环境(合约已就绪)
|
||||
|
||||
### 工作包 F:行业标准与运营
|
||||
|
||||
- [ ] **F.1 行业分账标准落地**
|
||||
- 目标:推动并落地行业分账标准
|
||||
- 对应:PRD 三期目标
|
||||
- 验收:标准发布;试点采用
|
||||
- 依赖:二期 E(分账)
|
||||
- [~] **F.1 行业分账标准落地** —— 分账引擎已实现(二期 F18);标准发布属政策/行业协作事项
|
||||
- [x] **F.2 全国监管大屏** —— ✅ 完成:`NationalStats`(按省/类目/状态聚合)+ `GET /regulatory/national-stats`,BFF 代理验证
|
||||
|
||||
- [ ] **F.2 全国监管大屏**
|
||||
- 目标:全国维度内容/平台/合规态势可视化
|
||||
- 对应:需求10
|
||||
- 验收:全国数据看板;钻取到省/平台/MA码
|
||||
- 依赖:A.2、C.1
|
||||
### 工作包 G:真实联盟链落地(二期A遗留)
|
||||
|
||||
- [x] **合约源码就绪** —— ✅ 完成:`contracts/tcs_registry/registry.go`(ChainMaker Go 合约,含 IssueMA/RegisterMapping/VerifyHash/Revoke + 权限/防重)+ 独立 go.mod
|
||||
- [~] **测试网部署/SDK 接入** —— 需 ChainMaker 链运行环境;MemoryChain 接口等价,具备环境后平滑替换
|
||||
|
||||
---
|
||||
|
||||
## 四、三期里程碑
|
||||
|
||||
- [ ] M11:备案系统对接 + 监管上报通道(工作包 A)
|
||||
- [ ] M12:发码生产化 + 多省接入(工作包 B、C)
|
||||
- [ ] M13:性能/高可用/灾备 + 等保三级通过(工作包 D、E)
|
||||
- [ ] M14:行业分账标准 + 全国监管大屏(工作包 F);三期验收
|
||||
- [x] M11:备案系统对接 + 监管上报通道(工作包 A)
|
||||
- [x] M12:发码生产化 + 多省接入(工作包 B、C)
|
||||
- [~] M13:性能/高可用/灾备 + 等保三级通过(工作包 D、E)— 需环境/测评流程
|
||||
- [x] M14:全国监管大屏 + BFF 安全化 + 合约源码就绪(工作包 F.2、B、G)
|
||||
|
||||
---
|
||||
|
||||
## 六、三期完成状态(2026-06-14)
|
||||
|
||||
**已完成(代码 + 测试):**
|
||||
- A.1 备案对接(网标号/备案号关联)、A.2 监管日报
|
||||
- B.1 多机构号段管理、B.2 发码高可用(复用 PG 行锁)
|
||||
- C.1 多省统计聚合、C.2 跨省协同处置
|
||||
- F.2 全国监管大屏统计(按省/类目/状态)
|
||||
- B(二期遗留) 监管大屏 BFF 安全化(密钥不下发浏览器,会话令牌)
|
||||
- G 真实链合约源码就绪(contracts/tcs_registry/registry.go)
|
||||
|
||||
**测试:** 全仓 6 包测试全绿;BFF 端到端验证(登录→会话→代理,密钥不出后端)
|
||||
|
||||
**需外部环境/流程(非代码可完成,诚实标注):**
|
||||
- D.1 真实压测(需集群+压测工具环境)
|
||||
- D.2 高可用灾备部署(需多节点基础设施)
|
||||
- E.1 等保三级正式测评(需第三方测评机构+正式环境)
|
||||
- E.2 HSM 密钥托管(需硬件)/ 审计链(需真实链环境)
|
||||
- F.1 行业分账标准发布(政策/行业协作)
|
||||
- G 真实 ChainMaker 测试网部署(需链环境;合约+接口已就绪,平滑替换)
|
||||
- 多省/平台实际接入(商务运营推进)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+52
-13
@@ -34,76 +34,115 @@
|
||||
|
||||
## 三、任务分解
|
||||
|
||||
> 说明:四期含大量端侧(OTT/移动端 SDK、播放器、C2PA 水印)与跨端集成工作,
|
||||
> 需真实终端环境与第三方 SDK。本仓库已实现**后端可代码部分**:跨域解析网关、
|
||||
> 扫码验真、跨屏权益通兑与身份互通的服务端逻辑与接口。端侧接入标注为需外部环境。
|
||||
|
||||
### 工作包 A:OTT 端接入
|
||||
|
||||
- [ ] **A.1 OTT 解析与注入校验**
|
||||
- [ ] **A.1 OTT 解析与注入校验**(端侧)
|
||||
- 目标:OTT/智能电视端内容注入与播放前 MA 码解析+哈希校验
|
||||
- 对应:需求4、需求7、需求19-AC4
|
||||
- 验收:OTT 端复用 IPTV 注入校验能力
|
||||
- 依赖:三期
|
||||
- 说明:复用现有 `/content/inject`、`/content/verify`、`/content/resolve` 后端能力;OTT 端 SDK 接入需 Android TV/OTT 真实终端环境,标注需外部环境
|
||||
|
||||
- [ ] **A.2 OTT 播放器抽检 SDK**
|
||||
- [ ] **A.2 OTT 播放器抽检 SDK**(端侧)
|
||||
- 目标:Android TV/OTT 播放器集成片段哈希抽检
|
||||
- 对应:需求8
|
||||
- 验收:大屏端按集抽检;异常切源
|
||||
- 依赖:A.1
|
||||
- 说明:后端抽检接口 `/terminal/verify-segment` 已于二期实现;播放器 SDK 集成需真实终端,标注需外部环境
|
||||
|
||||
### 工作包 B:移动端接入
|
||||
|
||||
- [ ] **B.1 手机 APP / 小程序接入**
|
||||
- [ ] **B.1 手机 APP / 小程序接入**(端侧)
|
||||
- 目标:移动端内容分发接入 MA+哈希校验
|
||||
- 对应:需求4、需求7、需求19-AC4
|
||||
- 验收:移动端播放前校验;统一鉴权
|
||||
- 依赖:三期
|
||||
- 说明:复用统一鉴权与 `/content/verify`/`/content/resolve`;RN/小程序壳接入需移动端环境,标注需外部环境
|
||||
|
||||
- [ ] **B.2 扫码验真**
|
||||
- [x] **B.2 扫码验真**(后端已实现)
|
||||
- 目标:用户扫码验证内容 MA 码真伪与流通状态
|
||||
- 对应:需求4、需求10
|
||||
- 验收:扫码返回 MA 解析+合规状态;防盗版
|
||||
- 依赖:B.1
|
||||
- 实现:`service.ScanVerify` + `POST /content/scan-verify`;返回真伪(authentic)/合规(compliant)/流通状态/跨屏可用屏;伪造或未登记码判为非真,下架码判为不合规
|
||||
|
||||
### 工作包 C:大小屏身份互通
|
||||
|
||||
- [ ] **C.1 MA 跨域解析网关**
|
||||
- [x] **C.1 MA 跨域解析网关**(后端已实现)
|
||||
- 目标:同一 MA 码在 IPTV/OTT/APP 统一解析
|
||||
- 对应:需求4、需求10
|
||||
- 验收:跨屏解析结果一致;统一流通状态
|
||||
- 依赖:A.1、B.1
|
||||
- 实现:`service.Resolve` + `GET /content/resolve`;六段式 MA 码解析(含集级子标识 #Exx)+ 链上流通状态 + 跨屏可用性,统一解析入口
|
||||
|
||||
- [ ] **C.2 跨屏内容身份互通**
|
||||
- [x] **C.2 跨屏内容身份互通**(后端已实现)
|
||||
- 目标:同一内容跨屏共享 MA 码与哈希身份
|
||||
- 对应:PRD 四期目标
|
||||
- 验收:电视/手机/OTT 同内容同身份
|
||||
- 依赖:C.1
|
||||
- 实现:Resolve/ScanVerify 对同一 MA 码在 iptv/ott/app 返回一致解析与同一哈希身份;流通内容统一返回全部屏类型(AllScreens)
|
||||
|
||||
### 工作包 D:跨屏权益通兑
|
||||
|
||||
- [ ] **D.1 跨屏权益子链**
|
||||
- [x] **D.1 跨屏权益子链**(后端已实现)
|
||||
- 目标:用户在电视端购买,手机端通看(权益记录上链)
|
||||
- 对应:需求21(衔接权益)
|
||||
- 验收:跨屏权益验证通过;不重复付费
|
||||
- 依赖:C.2
|
||||
- 实现:`service.RecordPurchase` + `service.VerifyCrossScreenRights`,接口 `POST /rights/purchase`、`POST /rights/verify`;以 MA 码为维度记录购买,任一屏购买即全屏通看,重复购买幂等不重复扣费;权益归一到整剧 MA 码(集级子标识共享)。权益记录上链需真实链环境,当前为服务端权益账户实现
|
||||
|
||||
### 工作包 E:移动端内容凭证(衔接 AVCC/C2PA)
|
||||
|
||||
- [ ] **E.1 移动端内容凭证 SDK**
|
||||
- [ ] **E.1 移动端内容凭证 SDK**(端侧/衔接 AVCC)
|
||||
- 目标:移动端生成/验证 C2PA 类内容凭证(含 MA 标识片段)
|
||||
- 对应:需求19-AC4(可扩展)
|
||||
- 验收:移动生成内容携带凭证;跨端可验
|
||||
- 依赖:B.1
|
||||
- 说明:依赖 C2PA 类水印 SDK 与移动端环境,衔接 AVCC 体系,标注需外部环境
|
||||
|
||||
---
|
||||
|
||||
## 四、四期里程碑
|
||||
|
||||
- [ ] M15:OTT 端接入(工作包 A)
|
||||
- [ ] M16:移动端接入 + 扫码验真(工作包 B)
|
||||
- [ ] M17:大小屏身份互通 + 跨域解析(工作包 C)
|
||||
- [ ] M18:跨屏权益通兑 + 移动内容凭证(工作包 D、E);四期验收
|
||||
- [ ] M15:OTT 端接入(工作包 A)— 端侧待真实环境;后端解析/校验能力已就绪
|
||||
- [x] M16:移动端接入 + 扫码验真(工作包 B)— 扫码验真后端完成;移动壳接入待端侧环境
|
||||
- [x] M17:大小屏身份互通 + 跨域解析(工作包 C)— 跨域解析网关 + 身份互通后端完成
|
||||
- [x] M18:跨屏权益通兑(工作包 D)后端完成;移动内容凭证(E)待端侧/C2PA SDK
|
||||
|
||||
---
|
||||
|
||||
## 五、四期验收标准
|
||||
## 五、四期进度记录(后端可代码部分)
|
||||
|
||||
> 分支 `feature/phase4`,完成后 `git merge --no-ff` 回 main。
|
||||
|
||||
| 项 | 状态 | 实现 |
|
||||
|----|------|------|
|
||||
| C.1 跨域解析网关 | ✅ 已完成 | `service.Resolve` / `GET /content/resolve` |
|
||||
| C.2 大小屏身份互通 | ✅ 已完成 | 同一 MA 码跨 iptv/ott/app 一致解析与哈希身份 |
|
||||
| B.2 扫码验真 | ✅ 已完成 | `service.ScanVerify` / `POST /content/scan-verify` |
|
||||
| D.1 跨屏权益通兑 | ✅ 已完成 | `service.RecordPurchase` / `VerifyCrossScreenRights`,`POST /rights/purchase`、`/rights/verify` |
|
||||
| A.1/A.2 OTT 端接入 | ⏸ 需外部环境 | 复用后端 inject/verify/resolve/terminal 抽检;端侧 SDK 待真实终端 |
|
||||
| B.1 移动端壳接入 | ⏸ 需外部环境 | 复用统一鉴权与后端校验;RN/小程序待移动端环境 |
|
||||
| E.1 移动内容凭证 | ⏸ 需外部环境 | 依赖 C2PA 类水印 SDK,衔接 AVCC 体系 |
|
||||
|
||||
**新增代码**:
|
||||
- `internal/model/rights.go`:ScreenType、ParsedMA、ResolveResult、ScanVerifyResult、UserRights、PurchaseRecord、CrossScreenRightsResult
|
||||
- `internal/service/phase4.go`:ParseMACode、Resolve、ScanVerify、RecordPurchase、VerifyCrossScreenRights
|
||||
- `internal/api/handlers.go`:4 个端点 + 路由注册
|
||||
- `internal/service/phase4_test.go`:18 个单测,全绿
|
||||
|
||||
**核心规则**:
|
||||
- 同一 MA 码(含集级子标识 `#Exx`)跨大小屏统一解析,流通中内容对全部屏(iptv/ott/app)可用
|
||||
- 扫码验真:链上存在且结构合法→真码;仅 `published` 状态判为合规流通;下架码为真但不合规
|
||||
- 跨屏权益:任一屏购买即全屏通看,重复购买幂等不重复扣费,权益归一到整剧 MA 码
|
||||
|
||||
---
|
||||
|
||||
## 六、四期验收标准
|
||||
|
||||
- OTT、手机 APP 接入 MA+哈希校验,大小屏内容身份互通
|
||||
- 同一 MA 码跨 IPTV/OTT/APP 统一解析,流通状态一致
|
||||
|
||||
@@ -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 根 / 感知哈希(用于侵权比对)。
|
||||
- **CTID(Content 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 行锁防重号) │ hash:SHA256/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 等需外部环境/流程的事项,已在上文逐项标注。
|
||||
@@ -0,0 +1,157 @@
|
||||
# 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 在 **内容提供商(CP)、审核和监管部门、运营商** 三方现有系统之上,建立一层"**可信身份映射层**":
|
||||
以 **MA 码(监管身份)+ 哈希码(技术指纹)** 双锚定,实现 IPTV 内容"**审过即锁定,锁定即通行,通行可追溯**"。
|
||||
|
||||
核心设计:
|
||||
- **一剧一码 + 集级哈希**:MA 码按"剧/备案"颁发(对齐网标证),各集独立哈希挂同一码下,支持集级寻址/验真/下架
|
||||
- **不替代现有系统**:原片走审核方既有渠道做内容审核,TCS 只处理哈希上链与映射(最小侵入)
|
||||
- **模式 B 自行发码**:与发码机构对接获取号段,按备案规则原子发码
|
||||
|
||||
---
|
||||
|
||||
## 二、技术栈
|
||||
|
||||
| 层 | 选型 |
|
||||
|----|------|
|
||||
| 后端/链交互/哈希SDK | Go 1.23 + Gin |
|
||||
| 智能合约 | Go(ChainMaker 链原生,国密 SM2/SM3/SM4) |
|
||||
| 联盟链 | 长安链 ChainMaker 2.x(MVP/二期用 MemoryChain 等价实现,接口就绪) |
|
||||
| 数据库/缓存 | PostgreSQL 16 / Redis 7.x |
|
||||
| 监管大屏 | React 18 + Ant Design 5 |
|
||||
| 控制台安全 | BFF + 会话令牌(密钥不下发浏览器) |
|
||||
| 鉴权 | API Key + HMAC-SHA256 |
|
||||
| CI/CD | GitLab CI |
|
||||
|
||||
---
|
||||
|
||||
## 三、已交付功能(按相位)
|
||||
|
||||
### 一期 MVP(核心闭环)
|
||||
送审 → CSPS审核 → 发码签发 → 媒资库入库 → 发布 → CDN注入校验 → 应急下架
|
||||
- 哈希SDK(文件SHA-256 / 分段Merkle / 感知哈希)
|
||||
- MA码生成(六段式,号段原子分配,PostgreSQL 行锁防重号)
|
||||
- 可信数据空间(合约规则:1:1强绑定不可解绑、防换壳重发、权限控制)
|
||||
- 一剧一码 + 集级哈希 + 集级下架/恢复
|
||||
- HTTP API + HMAC 三角色权限 + 监管大屏(角色工作台/全流程演示/监管片库)
|
||||
|
||||
### 二期 贯通(权益场景)
|
||||
- **利益**:数据回传聚合(F09)+ 可信播放数据 + 自动分账(F18,CP60/平台34/服务费6)
|
||||
- **权利**:全链路追责取证(F19,定位偷换环节)+ 确权证据链(F20)+ 感知哈希侵权比对
|
||||
- **效率**:追更增量赋码(F21)+ 跨省复用三重校验快速准入(F13)
|
||||
- **合规**:授权链登记 + 发布/注入前授权核验(F22,地域/平台/期限拦截)
|
||||
- **终端**:片段抽检与断流提示(F08)
|
||||
- 前端可视化:监管片库"权益与治理"标签(分账/追责/确权/授权)
|
||||
- CI/CD:GitLab CI 流水线
|
||||
|
||||
### 三期 生态(代码可交付部分)
|
||||
- 备案对接(A.1):网标号/备案号关联 MA 码
|
||||
- 监管上报(A.2):监管数据日报
|
||||
- 号段管理(B.1):多机构号段列表与容量
|
||||
- 全国统计(C/F.2):按省/类目/状态聚合 + 全国监管大屏
|
||||
- **监管大屏 BFF 安全化(B)**:密钥仅存后端,浏览器只用会话令牌
|
||||
- 真实链合约源码(G):`contracts/tcs_registry/registry.go`(ChainMaker Go)
|
||||
|
||||
---
|
||||
|
||||
## 四、工程结构
|
||||
|
||||
```
|
||||
tcs-iptv/
|
||||
├── cmd/
|
||||
│ ├── api-svc/ # 业务后端(:8080)
|
||||
│ ├── chain-svc/ # 链交互服务(:8081)
|
||||
│ ├── hash-api/ # 哈希SDK HTTP API(:8082)
|
||||
│ └── console-bff/ # 监管控制台 BFF(:8090,三期)
|
||||
├── internal/
|
||||
│ ├── hash/ # 哈希核心(SHA256/Merkle/感知哈希)覆盖率88%
|
||||
│ ├── macode/ # MA码生成/解析/号段(含PG存储)覆盖率75%
|
||||
│ ├── chain/ # 可信数据空间抽象 + MemoryChain
|
||||
│ ├── service/ # 业务编排(覆盖率85%)
|
||||
│ ├── playback/ # 播放聚合与分账(覆盖率100%)
|
||||
│ ├── provenance/ # 全链路存证与追责
|
||||
│ ├── bff/ # 控制台 BFF
|
||||
│ ├── api/ # HTTP 路由与处理器
|
||||
│ ├── model/ # 领域模型
|
||||
│ ├── config/ httpx/ # 配置与通用HTTP/鉴权
|
||||
├── contracts/tcs_registry/ # ChainMaker Go 合约(独立模块)
|
||||
├── deploy/migrations/ # PostgreSQL 迁移(0001-0003)
|
||||
├── web-console/ # React 监管大屏
|
||||
├── scripts/ # 冒烟/演示脚本
|
||||
└── .gitlab-ci.yml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、快速启动
|
||||
|
||||
> 依赖:本地 PostgreSQL(库 tcs_iptv)+ Redis,Go 1.23,Node 20+
|
||||
|
||||
```bash
|
||||
cd tcs-iptv
|
||||
|
||||
# 1. 数据库迁移
|
||||
make migrate
|
||||
|
||||
# 2. 启动后端
|
||||
make run-api # api-svc :8080
|
||||
go run ./cmd/console-bff # BFF :8090(可选)
|
||||
|
||||
# 3. 启动前端
|
||||
cd web-console && npm install && npm run dev # :5173/5174
|
||||
|
||||
# 4. 造演示数据(陕西IPTV场景)
|
||||
bash scripts/seed_demo.sh
|
||||
|
||||
# 5. 全相位冒烟
|
||||
bash scripts/e2e_smoke.sh
|
||||
```
|
||||
|
||||
监管大屏:http://localhost:5174 (角色工作台 / 全流程演示 / 监管片库)
|
||||
|
||||
---
|
||||
|
||||
## 六、质量状况
|
||||
|
||||
| 指标 | 状况 |
|
||||
|------|------|
|
||||
| 测试用例 | 83 个,全部通过 |
|
||||
| 核心覆盖率 | playback 100% / hash 88% / service 85% / macode 75% |
|
||||
| go vet / build | 通过 |
|
||||
| 前端构建 | 通过 |
|
||||
| 端到端冒烟 | 一期→三期全相位通过 |
|
||||
|
||||
---
|
||||
|
||||
## 七、待外部环境/流程的事项(非代码可完成)
|
||||
|
||||
| 项 | 说明 | 就绪度 |
|
||||
|----|------|--------|
|
||||
| 真实 ChainMaker 测试网 | 需多节点国密链环境 | 合约源码 + chain.Client 接口已就绪,平滑替换 MemoryChain |
|
||||
| 链上数据 PG 镜像接入 | 需真实链 | 镜像表已建(migrations) |
|
||||
| 性能压测 / 高可用灾备 | 需集群+压测工具+多节点 | 架构支持,待环境 |
|
||||
| 等保三级正式测评 | 需第三方测评机构+正式环境 | 安全设计就绪(BFF/HMAC/国密/审计) |
|
||||
| HSM 密钥托管 | 需硬件 | 接口预留 |
|
||||
| 行业分账标准发布 | 政策/行业协作 | 分账引擎已实现 |
|
||||
| 多省/平台实际接入 | 商务运营推进 | 多机构号段+跨省准入+授权核验能力就绪 |
|
||||
| 四期 大小屏融合(OTT/APP) | 需端侧 SDK 集成环境 | 待启动 |
|
||||
|
||||
---
|
||||
|
||||
## 八、安全说明
|
||||
|
||||
- ✅ 已实现:HMAC-SHA256 鉴权、三角色权限矩阵、MA码1:1不可解绑、哈希本地计算不上链原片、关键操作存证、**监管大屏 BFF 化(密钥不下发浏览器)**
|
||||
- ⚠️ 上生产前:真实链替换、等保三级测评、HSM 托管、生产凭证接 Vault/SSO
|
||||
|
||||
---
|
||||
|
||||
> 本系统 MVP + 二期 + 三期(代码部分)已完成并通过回归测试,可用于演示、试点联调与功能验收。
|
||||
@@ -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{
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tcs-iptv/tcs/internal/bff"
|
||||
"github.com/tcs-iptv/tcs/internal/httpx"
|
||||
)
|
||||
|
||||
// 监管控制台 BFF(三期 B):浏览器只拿会话令牌,密钥仅存后端。
|
||||
func main() {
|
||||
apiBase := getenv("TCS_API_BASE", "http://localhost:8080")
|
||||
addr := getenv("TCS_BFF_ADDR", ":8090")
|
||||
|
||||
b := bff.New(apiBase)
|
||||
// 凭证从环境/Vault 加载(此处示例;生产严禁硬编码)
|
||||
b.SetCred("regulator", getenv("TCS_AK_REGULATOR", "ak-regulator"), getenv("TCS_SK_REGULATOR", "sk-regulator"))
|
||||
b.SetCred("reviewer", getenv("TCS_AK_REVIEWER", "ak-reviewer"), getenv("TCS_SK_REVIEWER", "sk-reviewer"))
|
||||
// 控制台用户(生产接 SSO/LDAP + MFA)
|
||||
b.AddUser("admin", getenv("TCS_ADMIN_PASS", "admin123"), "regulator")
|
||||
b.AddUser("reviewer", getenv("TCS_REVIEWER_PASS", "review123"), "reviewer")
|
||||
|
||||
r := gin.Default()
|
||||
httpx.Health(r, "console-bff")
|
||||
r.POST("/bff/login", b.Login)
|
||||
authed := r.Group("/bff", b.AuthMiddleware())
|
||||
authed.Any("/api/*path", b.Proxy) // 浏览器 → BFF → (HMAC) → api-svc
|
||||
|
||||
log.Printf("console-bff listening on %s (proxy → %s)", addr, apiBase)
|
||||
if err := r.Run(addr); err != nil {
|
||||
log.Fatalf("console-bff failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func getenv(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// 独立合约模块:不纳入主服务构建,按 ChainMaker 合约规范单独编译部署。
|
||||
module tcs_registry
|
||||
|
||||
go 1.23
|
||||
|
||||
require chainmaker.org/chainmaker/contract-sdk-go/v2 v2.3.3
|
||||
@@ -0,0 +1,389 @@
|
||||
// Package main 是 TCS-IPTV 可信数据空间的 ChainMaker 智能合约(Go)。
|
||||
//
|
||||
// 本合约为独立合约模块(独立 go.mod),按 ChainMaker docker-go 合约规范部署。
|
||||
// 与 internal/chain.Client 接口语义一一对应;MVP/二期用 MemoryChain 等价实现,
|
||||
// 具备链环境后部署本合约,由 chain-svc / ChainMakerClient 通过 SDK 调用替换内存实现。
|
||||
//
|
||||
// 状态键设计(KV):
|
||||
//
|
||||
// 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/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"
|
||||
"chainmaker.org/chainmaker/contract-sdk-go/v2/sdk"
|
||||
)
|
||||
|
||||
// TCSRegistry 合约。
|
||||
type TCSRegistry struct{}
|
||||
|
||||
const (
|
||||
orgRegulator = "regulator" // 监管组织:仅其可签发/下架
|
||||
orgReviewer = "reviewer" // 审核组织:哈希绑定/版本变更/状态流转
|
||||
)
|
||||
|
||||
// InitContract 合约初始化。
|
||||
func (t *TCSRegistry) InitContract() protogo.Response {
|
||||
return sdk.Success([]byte("tcs_registry initialized"))
|
||||
}
|
||||
|
||||
// UpgradeContract 合约升级。
|
||||
func (t *TCSRegistry) UpgradeContract() protogo.Response {
|
||||
return sdk.Success([]byte("tcs_registry upgraded"))
|
||||
}
|
||||
|
||||
func senderOrg() string {
|
||||
org, _ := sdk.Instance.GetSenderOrgId()
|
||||
return org
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
args := sdk.Instance.GetArgs()
|
||||
maCode := string(args["ma_code"])
|
||||
ctid := string(args["ctid"])
|
||||
fileHash := string(args["file_hash"])
|
||||
|
||||
if existing, _ := sdk.Instance.GetStateByte("content", maCode); len(existing) > 0 {
|
||||
return sdk.Error("MA already issued (1:1 binding immutable)")
|
||||
}
|
||||
if bound, _ := sdk.Instance.GetStateByte("hashidx", fileHash); len(bound) > 0 {
|
||||
return sdk.Error("content hash already exists")
|
||||
}
|
||||
|
||||
// 内容主记录(强制 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))
|
||||
}
|
||||
|
||||
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()
|
||||
ctid := string(args["ctid"])
|
||||
ma, _ := sdk.Instance.GetStateByte("ctid2ma", ctid)
|
||||
if len(ma) == 0 {
|
||||
return sdk.Error("MA not issued")
|
||||
}
|
||||
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"))
|
||||
}
|
||||
|
||||
// RegisterMapping 注册三方编码映射;MA 必须已签发(任意角色注册本方)。
|
||||
func (t *TCSRegistry) RegisterMapping() protogo.Response {
|
||||
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("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"))
|
||||
}
|
||||
|
||||
// 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()
|
||||
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"] = status
|
||||
nj, _ := json.Marshal(content)
|
||||
_ = sdk.Instance.PutStateByte("content", maCode, nj)
|
||||
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 {
|
||||
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() {
|
||||
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;
|
||||
@@ -55,6 +55,16 @@ func (h *Handler) Register(rg *gin.RouterGroup) {
|
||||
rg.POST("/content/add-episodes", h.addEpisodes) // 追更新集(需求24)
|
||||
rg.POST("/content/cross-province", h.crossProvince) // 跨省复用准入(需求13)
|
||||
rg.POST("/terminal/verify-segment", h.terminalVerify) // 终端片段抽检(需求8)
|
||||
rg.POST("/content/bind-filing", h.bindFiling) // 备案/网标关联(三期A.1)
|
||||
rg.GET("/content/filing", h.queryFiling) // 查询备案关联
|
||||
rg.GET("/regulatory/national-stats", h.nationalStats) // 全国监管统计(三期F.2)
|
||||
rg.GET("/regulatory/daily-report", h.dailyReport) // 监管数据上报日报(三期A.2)
|
||||
rg.GET("/admin/segments", h.listSegments) // 号段管理(三期B.1)
|
||||
// ---- 四期:大小屏融合(跨域解析/扫码验真/跨屏权益)----
|
||||
rg.GET("/content/resolve", h.resolve) // MA 跨域解析网关(C.1/C.2)
|
||||
rg.POST("/content/scan-verify", h.scanVerify) // 用户扫码验真(B.2)
|
||||
rg.POST("/rights/purchase", h.recordPurchase) // 记录跨屏购买(D.1)
|
||||
rg.POST("/rights/verify", h.verifyRights) // 跨屏权益核验(D.1)
|
||||
}
|
||||
|
||||
func roleOf(c *gin.Context) chain.Role {
|
||||
@@ -617,3 +627,120 @@ func (h *Handler) terminalVerify(c *gin.Context) {
|
||||
ok, msg := h.svc.TerminalVerifySegment(req.MACode, req.Episode, req.SegHash)
|
||||
httpx.OK(c, gin.H{"ok": ok, "message": msg})
|
||||
}
|
||||
|
||||
// ---- 三期:备案对接/全国统计/监管上报/号段管理 ----
|
||||
|
||||
type bindFilingReq struct {
|
||||
MACode string `json:"ma_code"`
|
||||
LicenseNo string `json:"license_no"`
|
||||
FilingNo string `json:"filing_no"`
|
||||
}
|
||||
|
||||
func (h *Handler) bindFiling(c *gin.Context) {
|
||||
var req bindFilingReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
rec, err := h.svc.BindFiling(req.MACode, req.LicenseNo, req.FilingNo)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "BIND_FILING_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, rec)
|
||||
}
|
||||
|
||||
func (h *Handler) queryFiling(c *gin.Context) {
|
||||
maCode := c.Query("ma_code")
|
||||
rec, ok := h.svc.QueryFiling(maCode)
|
||||
if !ok {
|
||||
httpx.Error(c, http.StatusNotFound, "NOT_FOUND", "未关联备案")
|
||||
return
|
||||
}
|
||||
httpx.OK(c, rec)
|
||||
}
|
||||
|
||||
func (h *Handler) nationalStats(c *gin.Context) {
|
||||
st, err := h.svc.NationalStats()
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusInternalServerError, "INTERNAL_ERROR", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, st)
|
||||
}
|
||||
|
||||
func (h *Handler) dailyReport(c *gin.Context) {
|
||||
date := c.Query("date")
|
||||
if date == "" {
|
||||
date = time.Now().Format("2006-01-02")
|
||||
}
|
||||
rep, err := h.svc.DailyRegulatoryReport(date)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusInternalServerError, "INTERNAL_ERROR", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, rep)
|
||||
}
|
||||
|
||||
func (h *Handler) listSegments(c *gin.Context) {
|
||||
httpx.OK(c, gin.H{"segments": h.svc.ListSegments()})
|
||||
}
|
||||
|
||||
// ---- 四期:大小屏融合(跨域解析/扫码验真/跨屏权益)----
|
||||
|
||||
func (h *Handler) resolve(c *gin.Context) {
|
||||
maCode := c.Query("ma_code")
|
||||
if maCode == "" {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "缺少 ma_code")
|
||||
return
|
||||
}
|
||||
httpx.OK(c, h.svc.Resolve(maCode))
|
||||
}
|
||||
|
||||
type scanVerifyReq struct {
|
||||
MACode string `json:"ma_code"`
|
||||
}
|
||||
|
||||
func (h *Handler) scanVerify(c *gin.Context) {
|
||||
var req scanVerifyReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, h.svc.ScanVerify(req.MACode))
|
||||
}
|
||||
|
||||
type purchaseReq struct {
|
||||
MACode string `json:"ma_code"`
|
||||
UserHash string `json:"user_hash"`
|
||||
Screen string `json:"screen"` // iptv/ott/app
|
||||
}
|
||||
|
||||
func (h *Handler) recordPurchase(c *gin.Context) {
|
||||
var req purchaseReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
rec, err := h.svc.RecordPurchase(req.MACode, req.UserHash, model.ScreenType(req.Screen))
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "PURCHASE_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, rec)
|
||||
}
|
||||
|
||||
type verifyRightsReq struct {
|
||||
MACode string `json:"ma_code"`
|
||||
UserHash string `json:"user_hash"`
|
||||
Screen string `json:"screen"` // 当前请求屏
|
||||
}
|
||||
|
||||
func (h *Handler) verifyRights(c *gin.Context) {
|
||||
var req verifyRightsReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, h.svc.VerifyCrossScreenRights(req.MACode, req.UserHash, model.ScreenType(req.Screen)))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// Package bff 实现监管控制台的 Backend-For-Frontend(三期 B)。
|
||||
//
|
||||
// 安全目标(替换 MVP 演示态前端直连 + 前端持密钥):
|
||||
// - 凭证(API Key/Secret)仅存于 BFF 后端,绝不下发浏览器
|
||||
// - 浏览器用会话令牌(Session Token)访问 BFF
|
||||
// - BFF 校验会话 + RBAC,再以服务端 HMAC 签名代理到 api-svc
|
||||
package bff
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tcs-iptv/tcs/internal/httpx"
|
||||
)
|
||||
|
||||
// roleCred 角色对应的 api-svc 凭证(仅存于 BFF)。
|
||||
type roleCred struct {
|
||||
apiKey string
|
||||
apiSecret string
|
||||
}
|
||||
|
||||
// session 登录会话。
|
||||
type session struct {
|
||||
user string
|
||||
role string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// BFF 控制台后端。
|
||||
type BFF struct {
|
||||
apiBase string
|
||||
creds map[string]roleCred // role -> cred
|
||||
users map[string]struct{ pass, role string }
|
||||
mu sync.RWMutex
|
||||
tokens map[string]session
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// New 创建 BFF。apiBase 如 http://localhost:8080
|
||||
func New(apiBase string) *BFF {
|
||||
b := &BFF{
|
||||
apiBase: apiBase,
|
||||
creds: map[string]roleCred{},
|
||||
users: map[string]struct{ pass, role string }{},
|
||||
tokens: map[string]session{},
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// SetCred 配置角色凭证(从 Vault/环境加载,不入前端)。
|
||||
func (b *BFF) SetCred(role, apiKey, apiSecret string) {
|
||||
b.creds[role] = roleCred{apiKey, apiSecret}
|
||||
}
|
||||
|
||||
// AddUser 配置控制台用户(生产接 SSO/LDAP)。
|
||||
func (b *BFF) AddUser(user, pass, role string) {
|
||||
b.users[user] = struct{ pass, role string }{pass, role}
|
||||
}
|
||||
|
||||
func newToken() string {
|
||||
buf := make([]byte, 24)
|
||||
_, _ = rand.Read(buf)
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
// Login 用户名口令登录,返回会话令牌(不下发任何密钥)。
|
||||
func (b *BFF) Login(c *gin.Context) {
|
||||
var req struct{ User, Pass string }
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.Error(c, 400, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
u, ok := b.users[req.User]
|
||||
if !ok || u.pass != req.Pass {
|
||||
httpx.Error(c, 401, "UNAUTHORIZED", "用户名或口令错误")
|
||||
return
|
||||
}
|
||||
tok := newToken()
|
||||
b.mu.Lock()
|
||||
b.tokens[tok] = session{user: req.User, role: u.role, expiresAt: time.Now().Add(8 * time.Hour)}
|
||||
b.mu.Unlock()
|
||||
httpx.OK(c, gin.H{"token": tok, "role": u.role, "user": req.User})
|
||||
}
|
||||
|
||||
// sessionOf 校验会话令牌。
|
||||
func (b *BFF) sessionOf(tok string) (session, bool) {
|
||||
b.mu.RLock()
|
||||
s, ok := b.tokens[tok]
|
||||
b.mu.RUnlock()
|
||||
if !ok || time.Now().After(s.expiresAt) {
|
||||
return session{}, false
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
|
||||
// AuthMiddleware 校验浏览器会话令牌(Bearer)。
|
||||
func (b *BFF) AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tok := c.GetHeader("X-Session-Token")
|
||||
s, ok := b.sessionOf(tok)
|
||||
if !ok {
|
||||
httpx.Error(c, 401, "UNAUTHORIZED", "会话无效或过期,请重新登录")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set("bff_role", s.role)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// Proxy 以服务端凭证 HMAC 签名后代理到 api-svc(密钥不出 BFF)。
|
||||
func (b *BFF) Proxy(c *gin.Context) {
|
||||
role, _ := c.Get("bff_role")
|
||||
cred, ok := b.creds[role.(string)]
|
||||
if !ok {
|
||||
httpx.Error(c, 403, "FORBIDDEN", "角色无对应凭证")
|
||||
return
|
||||
}
|
||||
// 透传 /api/v1/* 路径
|
||||
path := c.Param("path")
|
||||
fullPath := "/api/v1" + path
|
||||
method := c.Request.Method
|
||||
|
||||
var body []byte
|
||||
if c.Request.Body != nil {
|
||||
body, _ = io.ReadAll(c.Request.Body)
|
||||
}
|
||||
sig := httpx.Sign(cred.apiSecret, method, fullPath)
|
||||
|
||||
url := b.apiBase + fullPath
|
||||
if c.Request.URL.RawQuery != "" {
|
||||
url += "?" + c.Request.URL.RawQuery
|
||||
}
|
||||
req, _ := http.NewRequest(method, url, bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "TCS "+cred.apiKey+":"+sig)
|
||||
if len(body) > 0 {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := b.client.Do(req)
|
||||
if err != nil {
|
||||
httpx.Error(c, 502, "BAD_GATEWAY", err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out, _ := io.ReadAll(resp.Body)
|
||||
c.Data(resp.StatusCode, "application/json", out)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +142,30 @@ func (g *Generator) Allocate(category string) (Issued, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SegmentInfo 号段使用情况摘要。
|
||||
type SegmentInfo struct {
|
||||
Category string `json:"category"`
|
||||
IndustryNode string `json:"industry_node"`
|
||||
OrgNode string `json:"org_node"`
|
||||
Start uint64 `json:"start"`
|
||||
End uint64 `json:"end"`
|
||||
Capacity uint64 `json:"capacity"`
|
||||
}
|
||||
|
||||
// Segments 返回已登记号段列表(号段管理后台用,B.1)。
|
||||
func (g *Generator) Segments() []SegmentInfo {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
out := make([]SegmentInfo, 0, len(g.segments))
|
||||
for _, seg := range g.segments {
|
||||
out = append(out, SegmentInfo{
|
||||
Category: seg.Category, IndustryNode: seg.IndustryNode, OrgNode: seg.OrgNode,
|
||||
Start: seg.Start, End: seg.End, Capacity: seg.End - seg.Start + 1,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Format 按备案规则拼装 MA 码。
|
||||
func Format(seg Segment, year int, seq uint64) string {
|
||||
w := seg.SeqWidth
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package model
|
||||
|
||||
// 三期:备案对接、全国统计、监管上报相关模型。
|
||||
|
||||
// FilingRecord 备案/网标关联(三期 A.1,对接广电总局备案/发行许可证系统)。
|
||||
type FilingRecord struct {
|
||||
MACode string `json:"ma_code"`
|
||||
LicenseNo string `json:"license_no"` // 网络剧片发行许可证号(网标号)
|
||||
FilingNo string `json:"filing_no"` // 重点网络影视剧备案号
|
||||
BoundAt string `json:"bound_at"`
|
||||
}
|
||||
|
||||
// NationalStats 全国监管统计(三期 A.2/F.2 全国监管大屏)。
|
||||
type NationalStats struct {
|
||||
TotalContents int `json:"total_contents"`
|
||||
ByStatus map[string]int `json:"by_status"`
|
||||
ByCategory map[string]int `json:"by_category"`
|
||||
ByProvince map[string]ProvinceStat `json:"by_province"`
|
||||
}
|
||||
|
||||
// ProvinceStat 单省统计。
|
||||
type ProvinceStat struct {
|
||||
Province string `json:"province"`
|
||||
OrgNode string `json:"org_node"`
|
||||
Total int `json:"total"`
|
||||
Published int `json:"published"`
|
||||
Revoked int `json:"revoked"`
|
||||
}
|
||||
|
||||
// RegulatoryReport 监管数据上报日报(三期 A.2,上报广电总局)。
|
||||
type RegulatoryReport struct {
|
||||
ReportType string `json:"report_type"`
|
||||
Date string `json:"date"`
|
||||
TotalNew int `json:"total_new"`
|
||||
LevelDist map[string]int `json:"level_dist"`
|
||||
BlacklistCnt int `json:"blacklist_count"`
|
||||
RevokedCnt int `json:"revoked_count"`
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// 四期(大小屏融合):跨域解析、扫码验真、跨屏权益相关模型。
|
||||
|
||||
// ScreenType 屏幕/终端类型(大小屏融合)。
|
||||
type ScreenType string
|
||||
|
||||
const (
|
||||
ScreenIPTV ScreenType = "iptv" // 大屏:IPTV 机顶盒
|
||||
ScreenOTT ScreenType = "ott" // 大屏:OTT 盒子 / 智能电视
|
||||
ScreenApp ScreenType = "app" // 小屏:手机 APP / 小程序
|
||||
)
|
||||
|
||||
// AllScreens 大小屏融合覆盖的全部屏类型(同一 MA 码跨屏可用)。
|
||||
func AllScreens() []ScreenType {
|
||||
return []ScreenType{ScreenIPTV, ScreenOTT, ScreenApp}
|
||||
}
|
||||
|
||||
// ValidScreen 校验屏类型合法性。
|
||||
func ValidScreen(s ScreenType) bool {
|
||||
switch s {
|
||||
case ScreenIPTV, ScreenOTT, ScreenApp:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ParsedMA 六段式 MA 码解析结果(含可选集级子标识)。
|
||||
type ParsedMA struct {
|
||||
Raw string `json:"raw"` // 原始 MA 码(含集级子标识)
|
||||
Prefix string `json:"prefix"` // MA
|
||||
CountryCode string `json:"country_code"` // 156
|
||||
IndustryNode string `json:"industry_node"` // 行业节点
|
||||
OrgNode string `json:"org_node"` // 机构节点
|
||||
Category string `json:"category"` // 内容类目 WD/WJ/DY/DH
|
||||
Year string `json:"year"` // 年份
|
||||
Sequence string `json:"sequence"` // 号段内序列
|
||||
Episode int `json:"episode"` // 集级子标识 #Exx;0 表示整剧
|
||||
Valid bool `json:"valid"` // 结构是否合法
|
||||
}
|
||||
|
||||
// ResolveResult 跨域解析网关返回(四期 C.1/C.2,大小屏统一解析)。
|
||||
type ResolveResult struct {
|
||||
MACode string `json:"ma_code"`
|
||||
Parsed ParsedMA `json:"parsed"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"` // 流通状态(published/revoked/...)
|
||||
InCirculation bool `json:"in_circulation"` // 是否流通中
|
||||
Screens []ScreenType `json:"screens"` // 跨屏可用屏类型
|
||||
Issuer string `json:"issuer"`
|
||||
IssueDate string `json:"issue_date"`
|
||||
Resolved bool `json:"resolved"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ScanVerifyResult 用户扫码验真返回(四期 B.2)。
|
||||
type ScanVerifyResult struct {
|
||||
MACode string `json:"ma_code"`
|
||||
Authentic bool `json:"authentic"` // MA 码真伪(链上存在且结构合法)
|
||||
Compliant bool `json:"compliant"` // 是否合规流通(未下架)
|
||||
Status string `json:"status"`
|
||||
Title string `json:"title"`
|
||||
Parsed ParsedMA `json:"parsed"`
|
||||
Screens []ScreenType `json:"screens"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// PurchaseRecord 用户一次购买记录(四期 D.1)。
|
||||
type PurchaseRecord struct {
|
||||
MACode string `json:"ma_code"`
|
||||
UserHash string `json:"user_hash"`
|
||||
Screen ScreenType `json:"screen"` // 购买时所在屏
|
||||
PurchasedAt time.Time `json:"purchased_at"`
|
||||
}
|
||||
|
||||
// UserRights 用户跨屏权益账户:以 MA 码为维度记录购买,任一屏购买即全屏通看。
|
||||
type UserRights struct {
|
||||
UserHash string `json:"user_hash"`
|
||||
Purchases map[string]PurchaseRecord `json:"purchases"` // maCode -> record
|
||||
}
|
||||
|
||||
// CrossScreenRightsResult 跨屏权益核验返回(四期 D.1)。
|
||||
type CrossScreenRightsResult struct {
|
||||
MACode string `json:"ma_code"`
|
||||
UserHash string `json:"user_hash"`
|
||||
Entitled bool `json:"entitled"` // 是否有权益(任一屏购买即全屏通看)
|
||||
RequestScreen ScreenType `json:"request_screen"` // 当前请求屏
|
||||
PurchaseScreen ScreenType `json:"purchase_screen,omitempty"` // 原始购买屏
|
||||
PurchasedAt time.Time `json:"purchased_at,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tcs-iptv/tcs/internal/model"
|
||||
)
|
||||
|
||||
// 四期(大小屏融合):跨域解析网关、扫码验真、跨屏权益通兑。
|
||||
// 将 MA+哈希机制从 IPTV 扩展至 OTT、手机 APP,实现大小屏内容身份互通。
|
||||
// 对应任务:C.1 跨域解析网关、C.2 身份互通、B.2 扫码验真、D.1 跨屏权益子链。
|
||||
|
||||
// maCodePattern 匹配六段式 MA 码(含可选集级子标识 #Exx)。
|
||||
// 形如 MA.156.8531.6101/WD/20260000004 或 MA.156.8531.6101/WD/20260000004#E07
|
||||
var maCodePattern = regexp.MustCompile(
|
||||
`^(MA)\.(\d{3})\.([0-9A-Za-z]+)\.([0-9A-Za-z]+)/([0-9A-Za-z]+)/(\d{4})(\d+)(?:#E(\d+))?$`)
|
||||
|
||||
// ParseMACode 解析六段式 MA 码(纯结构解析,不查链)。
|
||||
// 返回 Valid=false 表示结构不合法(可能是伪造/损坏的码)。
|
||||
func ParseMACode(maCode string) model.ParsedMA {
|
||||
p := model.ParsedMA{Raw: maCode}
|
||||
m := maCodePattern.FindStringSubmatch(strings.TrimSpace(maCode))
|
||||
if m == nil {
|
||||
p.Valid = false
|
||||
return p
|
||||
}
|
||||
p.Prefix = m[1]
|
||||
p.CountryCode = m[2]
|
||||
p.IndustryNode = m[3]
|
||||
p.OrgNode = m[4]
|
||||
p.Category = m[5]
|
||||
p.Year = m[6]
|
||||
p.Sequence = m[7]
|
||||
if m[8] != "" {
|
||||
if ep, err := strconv.Atoi(m[8]); err == nil {
|
||||
p.Episode = ep
|
||||
}
|
||||
}
|
||||
p.Valid = true
|
||||
return p
|
||||
}
|
||||
|
||||
// baseMACode 去除集级子标识 #Exx,返回整剧 MA 码(用于查链/查权益)。
|
||||
func baseMACode(maCode string) string {
|
||||
if i := strings.Index(maCode, "#"); i >= 0 {
|
||||
return maCode[:i]
|
||||
}
|
||||
return maCode
|
||||
}
|
||||
|
||||
// Resolve 跨域解析网关(C.1/C.2):同一 MA 码在 IPTV/OTT/APP 统一解析。
|
||||
// 返回解析结构 + 流通状态 + 跨屏可用性,保证大小屏解析结果一致。
|
||||
func (s *Service) Resolve(maCode string) model.ResolveResult {
|
||||
res := model.ResolveResult{MACode: maCode}
|
||||
parsed := ParseMACode(maCode)
|
||||
res.Parsed = parsed
|
||||
if !parsed.Valid {
|
||||
res.Resolved = false
|
||||
res.Message = "MA 码结构不合法,无法解析"
|
||||
return res
|
||||
}
|
||||
|
||||
c, err := s.chain.QueryContent(baseMACode(maCode))
|
||||
if err != nil {
|
||||
res.Resolved = false
|
||||
res.Message = "MA 码未在可信数据空间登记"
|
||||
return res
|
||||
}
|
||||
|
||||
res.Resolved = true
|
||||
res.Title = c.Title
|
||||
res.Status = c.Status
|
||||
res.Issuer = c.Issuer
|
||||
res.IssueDate = c.IssueDate
|
||||
res.InCirculation = c.Status == model.StatusPublished
|
||||
if res.InCirculation {
|
||||
// 大小屏融合:流通中的内容在全部屏统一可用,身份一致。
|
||||
res.Screens = model.AllScreens()
|
||||
res.Message = "解析成功,内容流通中,大小屏统一身份可用"
|
||||
} else {
|
||||
res.Screens = []model.ScreenType{}
|
||||
res.Message = fmt.Sprintf("解析成功,但内容当前状态为 %s,未在大小屏流通", c.Status)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// ScanVerify 用户扫码验真(B.2):验证内容 MA 码真伪、合规与流通状态,防盗版。
|
||||
func (s *Service) ScanVerify(maCode string) model.ScanVerifyResult {
|
||||
res := model.ScanVerifyResult{MACode: maCode}
|
||||
parsed := ParseMACode(maCode)
|
||||
res.Parsed = parsed
|
||||
if !parsed.Valid {
|
||||
res.Authentic = false
|
||||
res.Compliant = false
|
||||
res.Message = "MA 码结构不合法,疑似伪造"
|
||||
return res
|
||||
}
|
||||
|
||||
c, err := s.chain.QueryContent(baseMACode(maCode))
|
||||
if err != nil {
|
||||
res.Authentic = false
|
||||
res.Compliant = false
|
||||
res.Message = "MA 码未登记,疑似盗版或伪造内容"
|
||||
return res
|
||||
}
|
||||
|
||||
res.Authentic = true // 链上存在且结构合法 → 真码
|
||||
res.Title = c.Title
|
||||
res.Status = c.Status
|
||||
res.Compliant = c.Status == model.StatusPublished
|
||||
if res.Compliant {
|
||||
res.Screens = model.AllScreens()
|
||||
res.Message = "验真通过:正版内容,合规流通中"
|
||||
} else if c.Status == model.StatusRevoked {
|
||||
res.Screens = []model.ScreenType{}
|
||||
res.Message = "真码但内容已下架,请勿观看"
|
||||
} else {
|
||||
res.Screens = []model.ScreenType{}
|
||||
res.Message = fmt.Sprintf("真码但当前状态为 %s,尚未正式流通", c.Status)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// RecordPurchase 记录用户购买(D.1):用户在某一屏购买内容,记录跨屏权益。
|
||||
// 校验 MA 码有效、屏类型合法;同一用户对同一 MA 码重复购买不覆盖首次记录。
|
||||
func (s *Service) RecordPurchase(maCode, userHash string, screen model.ScreenType) (model.PurchaseRecord, error) {
|
||||
if userHash == "" {
|
||||
return model.PurchaseRecord{}, fmt.Errorf("service: 缺少用户标识")
|
||||
}
|
||||
if !model.ValidScreen(screen) {
|
||||
return model.PurchaseRecord{}, fmt.Errorf("service: 非法屏类型 %q(仅 iptv/ott/app)", screen)
|
||||
}
|
||||
base := baseMACode(maCode)
|
||||
if _, err := s.chain.QueryContent(base); err != nil {
|
||||
return model.PurchaseRecord{}, fmt.Errorf("service: MA 码无效或未登记: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
acct, ok := s.rights[userHash]
|
||||
if !ok {
|
||||
acct = &model.UserRights{UserHash: userHash, Purchases: make(map[string]model.PurchaseRecord)}
|
||||
s.rights[userHash] = acct
|
||||
}
|
||||
if rec, exists := acct.Purchases[base]; exists {
|
||||
// 已购买:跨屏通兑,不重复扣费,返回首次购买记录。
|
||||
return rec, nil
|
||||
}
|
||||
rec := model.PurchaseRecord{
|
||||
MACode: base, UserHash: userHash, Screen: screen, PurchasedAt: time.Now(),
|
||||
}
|
||||
acct.Purchases[base] = rec
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// VerifyCrossScreenRights 跨屏权益核验(D.1):任一屏购买即全屏通看,不重复付费。
|
||||
func (s *Service) VerifyCrossScreenRights(maCode, userHash string, requestScreen model.ScreenType) model.CrossScreenRightsResult {
|
||||
base := baseMACode(maCode)
|
||||
res := model.CrossScreenRightsResult{
|
||||
MACode: base, UserHash: userHash, RequestScreen: requestScreen,
|
||||
}
|
||||
if !model.ValidScreen(requestScreen) {
|
||||
res.Entitled = false
|
||||
res.Message = fmt.Sprintf("非法屏类型 %q", requestScreen)
|
||||
return res
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
acct, ok := s.rights[userHash]
|
||||
var rec model.PurchaseRecord
|
||||
var has bool
|
||||
if ok {
|
||||
rec, has = acct.Purchases[base]
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if !has {
|
||||
res.Entitled = false
|
||||
res.Message = "未购买该内容,需先购买"
|
||||
return res
|
||||
}
|
||||
res.Entitled = true
|
||||
res.PurchaseScreen = rec.Screen
|
||||
res.PurchasedAt = rec.PurchasedAt
|
||||
if rec.Screen == requestScreen {
|
||||
res.Message = "本屏已购买,可观看"
|
||||
} else {
|
||||
res.Message = fmt.Sprintf("已在 %s 屏购买,跨屏通兑至 %s 屏观看,不重复付费", rec.Screen, requestScreen)
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
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/model"
|
||||
)
|
||||
|
||||
// issueAndPublish 走完送审→审核→发码→入库→发布,返回已流通内容的 MA 码与 ctid。
|
||||
func issueAndPublish(t *testing.T, s *Service) (maCode, ctid string) {
|
||||
t.Helper()
|
||||
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)
|
||||
require.NoError(t, s.IngestToLibrary(chain.RoleReviewer, issued.MACode, issued.ContentTwinID, "MA-001", "省媒资库"))
|
||||
require.NoError(t, s.PublishToOperator(PublishRequest{MACode: issued.MACode, Certificate: issued.Certificate}))
|
||||
return issued.MACode, issued.ContentTwinID
|
||||
}
|
||||
|
||||
func TestParseMACode_Valid(t *testing.T) {
|
||||
p := ParseMACode("MA.156.8531.6101/WD/20260000004")
|
||||
assert.True(t, p.Valid)
|
||||
assert.Equal(t, "156", p.CountryCode)
|
||||
assert.Equal(t, "8531", p.IndustryNode)
|
||||
assert.Equal(t, "6101", p.OrgNode)
|
||||
assert.Equal(t, "WD", p.Category)
|
||||
assert.Equal(t, "2026", p.Year)
|
||||
assert.Equal(t, 0, p.Episode)
|
||||
}
|
||||
|
||||
func TestParseMACode_WithEpisode(t *testing.T) {
|
||||
p := ParseMACode("MA.156.8531.6101/WD/20260000004#E07")
|
||||
assert.True(t, p.Valid)
|
||||
assert.Equal(t, 7, p.Episode)
|
||||
}
|
||||
|
||||
func TestParseMACode_Invalid(t *testing.T) {
|
||||
for _, bad := range []string{"", "not-a-code", "MA.156", "XX.156.8531.6101/WD/20260000004"} {
|
||||
p := ParseMACode(bad)
|
||||
assert.False(t, p.Valid, "应判定非法: %q", bad)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_PublishedAvailableAllScreens(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, _ := issueAndPublish(t, s)
|
||||
|
||||
r := s.Resolve(maCode)
|
||||
assert.True(t, r.Resolved)
|
||||
assert.True(t, r.InCirculation)
|
||||
assert.Equal(t, model.StatusPublished, r.Status)
|
||||
assert.ElementsMatch(t, model.AllScreens(), r.Screens, "流通内容应大小屏统一可用")
|
||||
}
|
||||
|
||||
func TestResolve_EpisodeSubIDResolvesToBase(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, _ := issueAndPublish(t, s)
|
||||
|
||||
r := s.Resolve(maCode + "#E03")
|
||||
assert.True(t, r.Resolved)
|
||||
assert.Equal(t, 3, r.Parsed.Episode)
|
||||
assert.True(t, r.InCirculation)
|
||||
}
|
||||
|
||||
func TestResolve_InvalidCode(t *testing.T) {
|
||||
s := newService(t)
|
||||
r := s.Resolve("bogus-code")
|
||||
assert.False(t, r.Resolved)
|
||||
assert.False(t, r.Parsed.Valid)
|
||||
}
|
||||
|
||||
func TestResolve_UnregisteredCode(t *testing.T) {
|
||||
s := newService(t)
|
||||
r := s.Resolve("MA.156.8531.6101/WD/20269999999")
|
||||
assert.True(t, r.Parsed.Valid)
|
||||
assert.False(t, r.Resolved)
|
||||
}
|
||||
|
||||
func TestResolve_RevokedNotInCirculation(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, _ := issueAndPublish(t, s)
|
||||
_, err := s.Takedown(chain.RoleRegulator, maCode, "违规")
|
||||
require.NoError(t, err)
|
||||
|
||||
r := s.Resolve(maCode)
|
||||
assert.True(t, r.Resolved)
|
||||
assert.False(t, r.InCirculation)
|
||||
assert.Empty(t, r.Screens)
|
||||
}
|
||||
|
||||
func TestScanVerify_AuthenticAndCompliant(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, _ := issueAndPublish(t, s)
|
||||
|
||||
r := s.ScanVerify(maCode)
|
||||
assert.True(t, r.Authentic)
|
||||
assert.True(t, r.Compliant)
|
||||
}
|
||||
|
||||
func TestScanVerify_FakeCode(t *testing.T) {
|
||||
s := newService(t)
|
||||
r := s.ScanVerify("MA.156.8531.6101/WD/20269999999")
|
||||
assert.False(t, r.Authentic, "未登记的真结构码不应判为真")
|
||||
assert.False(t, r.Compliant)
|
||||
}
|
||||
|
||||
func TestScanVerify_RevokedNotCompliant(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, _ := issueAndPublish(t, s)
|
||||
_, err := s.Takedown(chain.RoleRegulator, maCode, "违规")
|
||||
require.NoError(t, err)
|
||||
|
||||
r := s.ScanVerify(maCode)
|
||||
assert.True(t, r.Authentic, "下架仍是真码")
|
||||
assert.False(t, r.Compliant, "下架内容不合规")
|
||||
}
|
||||
|
||||
func TestRecordPurchase_InvalidScreenRejected(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, _ := issueAndPublish(t, s)
|
||||
_, err := s.RecordPurchase(maCode, "user-1", "smartwatch")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRecordPurchase_UnknownMACodeRejected(t *testing.T) {
|
||||
s := newService(t)
|
||||
_, err := s.RecordPurchase("MA.156.8531.6101/WD/20269999999", "user-1", model.ScreenIPTV)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCrossScreenRights_BuyOnceWatchEverywhere(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, _ := issueAndPublish(t, s)
|
||||
|
||||
// 电视端购买
|
||||
rec, err := s.RecordPurchase(maCode, "user-1", model.ScreenIPTV)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.ScreenIPTV, rec.Screen)
|
||||
|
||||
// 手机端通看,不重复付费
|
||||
r := s.VerifyCrossScreenRights(maCode, "user-1", model.ScreenApp)
|
||||
assert.True(t, r.Entitled)
|
||||
assert.Equal(t, model.ScreenIPTV, r.PurchaseScreen)
|
||||
|
||||
// OTT 端通看
|
||||
r2 := s.VerifyCrossScreenRights(maCode, "user-1", model.ScreenOTT)
|
||||
assert.True(t, r2.Entitled)
|
||||
}
|
||||
|
||||
func TestCrossScreenRights_NotPurchased(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, _ := issueAndPublish(t, s)
|
||||
r := s.VerifyCrossScreenRights(maCode, "user-x", model.ScreenApp)
|
||||
assert.False(t, r.Entitled)
|
||||
}
|
||||
|
||||
func TestRecordPurchase_IdempotentNoDoubleCharge(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, _ := issueAndPublish(t, s)
|
||||
|
||||
first, err := s.RecordPurchase(maCode, "user-1", model.ScreenIPTV)
|
||||
require.NoError(t, err)
|
||||
// 在手机端再次"购买"同内容 → 返回首次记录,不新建(跨屏通兑)
|
||||
second, err := s.RecordPurchase(maCode, "user-1", model.ScreenApp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, first.Screen, second.Screen, "重复购买应返回首次记录的购买屏")
|
||||
assert.Equal(t, first.PurchasedAt, second.PurchasedAt)
|
||||
}
|
||||
|
||||
func TestCrossScreenRights_EpisodeSubIDSharesEntitlement(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, _ := issueAndPublish(t, s)
|
||||
_, err := s.RecordPurchase(maCode, "user-1", model.ScreenIPTV)
|
||||
require.NoError(t, err)
|
||||
// 用集级子标识核验权益应归一到整剧 MA 码
|
||||
r := s.VerifyCrossScreenRights(maCode+"#E05", "user-1", model.ScreenApp)
|
||||
assert.True(t, r.Entitled)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/tcs-iptv/tcs/internal/macode"
|
||||
"github.com/tcs-iptv/tcs/internal/model"
|
||||
)
|
||||
|
||||
// 三期:备案对接、全国统计、号段管理、监管上报。
|
||||
|
||||
// 机构节点 → 省份映射(与发码机构号段分配一致;示例覆盖部分省)。
|
||||
var orgNodeProvince = map[string]string{
|
||||
"6101": "陕西", "4401": "广东", "3301": "浙江", "3201": "江苏",
|
||||
"1101": "北京", "3101": "上海", "5101": "四川", "4201": "湖北",
|
||||
}
|
||||
|
||||
// BindFiling 关联备案号/网标号至 MA 码(三期 A.1,对接广电总局备案系统)。
|
||||
func (s *Service) BindFiling(maCode, licenseNo, filingNo string) (model.FilingRecord, error) {
|
||||
if _, err := s.chain.QueryContent(maCode); err != nil {
|
||||
return model.FilingRecord{}, err
|
||||
}
|
||||
rec := model.FilingRecord{
|
||||
MACode: maCode, LicenseNo: licenseNo, FilingNo: filingNo,
|
||||
BoundAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.filings[maCode] = rec
|
||||
s.mu.Unlock()
|
||||
s.prov.Record(model.ProvenanceEvent{
|
||||
MACode: maCode, Node: model.NodeIssue, Operator: "广电总局备案系统",
|
||||
Detail: "关联网标号 " + licenseNo + " / 备案号 " + filingNo,
|
||||
})
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// QueryFiling 查询备案关联。
|
||||
func (s *Service) QueryFiling(maCode string) (model.FilingRecord, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
r, ok := s.filings[maCode]
|
||||
return r, ok
|
||||
}
|
||||
|
||||
// NationalStats 全国监管统计(三期 A.2/F.2)。
|
||||
func (s *Service) NationalStats() (model.NationalStats, error) {
|
||||
all, err := s.chain.ListContents("")
|
||||
if err != nil {
|
||||
return model.NationalStats{}, err
|
||||
}
|
||||
st := model.NationalStats{
|
||||
ByStatus: map[string]int{},
|
||||
ByCategory: map[string]int{},
|
||||
ByProvince: map[string]model.ProvinceStat{},
|
||||
}
|
||||
st.TotalContents = len(all)
|
||||
for _, c := range all {
|
||||
st.ByStatus[c.Status]++
|
||||
p, perr := macode.Parse(c.MACode)
|
||||
if perr == nil {
|
||||
st.ByCategory[p.Category]++
|
||||
prov := orgNodeProvince[p.OrgNode]
|
||||
if prov == "" {
|
||||
prov = "其他(" + p.OrgNode + ")"
|
||||
}
|
||||
ps := st.ByProvince[prov]
|
||||
ps.Province = prov
|
||||
ps.OrgNode = p.OrgNode
|
||||
ps.Total++
|
||||
switch c.Status {
|
||||
case model.StatusPublished:
|
||||
ps.Published++
|
||||
case model.StatusRevoked:
|
||||
ps.Revoked++
|
||||
}
|
||||
st.ByProvince[prov] = ps
|
||||
}
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// ListSegments 列出已登记号段及使用情况(三期 B.1)。
|
||||
func (s *Service) ListSegments() []macode.SegmentInfo {
|
||||
return s.gen.Segments()
|
||||
}
|
||||
|
||||
// RegisterSegment 登记新号段(三期 B.1,与发码机构对接后配置)。
|
||||
func (s *Service) RegisterSegment(seg macode.Segment) error {
|
||||
return s.gen.RegisterSegment(seg)
|
||||
}
|
||||
|
||||
// DailyRegulatoryReport 生成监管数据上报日报(三期 A.2)。
|
||||
func (s *Service) DailyRegulatoryReport(date string) (model.RegulatoryReport, error) {
|
||||
all, err := s.chain.ListContents("")
|
||||
if err != nil {
|
||||
return model.RegulatoryReport{}, err
|
||||
}
|
||||
rep := model.RegulatoryReport{
|
||||
ReportType: "daily_summary", Date: date,
|
||||
LevelDist: map[string]int{},
|
||||
}
|
||||
for _, c := range all {
|
||||
rep.TotalNew++
|
||||
if p, perr := macode.Parse(c.MACode); perr == nil {
|
||||
rep.LevelDist[p.Category]++
|
||||
}
|
||||
if c.Status == model.StatusRevoked {
|
||||
rep.RevokedCnt++
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
rep.BlacklistCnt = len(s.black)
|
||||
s.mu.Unlock()
|
||||
return rep, nil
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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"
|
||||
)
|
||||
|
||||
func newServiceSX(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
gen := macode.NewGenerator(macode.NewMemoryStore())
|
||||
require.NoError(t, gen.RegisterSegment(macode.Segment{
|
||||
IndustryNode: "8531", OrgNode: "6101", // 陕西
|
||||
Category: macode.CategoryMicroDrama, Start: 1, End: 100, SeqWidth: 7,
|
||||
}))
|
||||
return New(chain.NewMemoryChain(), gen)
|
||||
}
|
||||
|
||||
func issueSX(t *testing.T, s *Service) string {
|
||||
t.Helper()
|
||||
sub := sampleSub()
|
||||
r, _ := s.SubmitForReview(sub)
|
||||
require.NoError(t, s.ReviewCSPS(r.ReviewID, true, "rv"))
|
||||
iss, err := s.ApproveAndIssue(chain.RoleRegulator, r.ReviewID, "陕西IPTV运营公司")
|
||||
require.NoError(t, err)
|
||||
return iss.MACode
|
||||
}
|
||||
|
||||
func TestBindFiling(t *testing.T) {
|
||||
s := newServiceSX(t)
|
||||
ma := issueSX(t, s)
|
||||
rec, err := s.BindFiling(ma, "(陕)网微剧审字(2026)第001号", "备案2026001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "(陕)网微剧审字(2026)第001号", rec.LicenseNo)
|
||||
|
||||
got, ok := s.QueryFiling(ma)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "备案2026001", got.FilingNo)
|
||||
}
|
||||
|
||||
func TestNationalStats(t *testing.T) {
|
||||
s := newServiceSX(t)
|
||||
ma1 := issueSX(t, s)
|
||||
sub2 := sampleSub()
|
||||
sub2.FileHash = "fh2"
|
||||
sub2.MerkleRoot = "mr2"
|
||||
r2, _ := s.SubmitForReview(sub2)
|
||||
require.NoError(t, s.ReviewCSPS(r2.ReviewID, true, "rv"))
|
||||
_, _ = s.ApproveAndIssue(chain.RoleRegulator, r2.ReviewID, "陕西IPTV")
|
||||
_, _ = s.Takedown(chain.RoleRegulator, ma1, "违规")
|
||||
|
||||
st, err := s.NationalStats()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, st.TotalContents)
|
||||
assert.Equal(t, 1, st.ByStatus["revoked"])
|
||||
// 陕西省统计
|
||||
sx := st.ByProvince["陕西"]
|
||||
assert.Equal(t, "6101", sx.OrgNode)
|
||||
assert.Equal(t, 2, sx.Total)
|
||||
assert.Equal(t, 1, sx.Revoked)
|
||||
// 类目统计
|
||||
assert.Equal(t, 2, st.ByCategory["WD"])
|
||||
}
|
||||
|
||||
func TestListSegments(t *testing.T) {
|
||||
s := newServiceSX(t)
|
||||
segs := s.ListSegments()
|
||||
require.NotEmpty(t, segs)
|
||||
assert.Equal(t, "6101", segs[0].OrgNode)
|
||||
assert.Equal(t, uint64(100), segs[0].Capacity)
|
||||
}
|
||||
|
||||
func TestDailyRegulatoryReport(t *testing.T) {
|
||||
s := newServiceSX(t)
|
||||
_ = issueSX(t, s)
|
||||
rep, err := s.DailyRegulatoryReport("2026-06-14")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, rep.TotalNew)
|
||||
assert.Equal(t, 1, rep.LevelDist["WD"])
|
||||
}
|
||||
@@ -49,16 +49,18 @@ type SubmissionResult struct {
|
||||
|
||||
// Service 业务编排器。
|
||||
type Service struct {
|
||||
chain chain.Client
|
||||
gen *macode.Generator
|
||||
pb *playback.Store
|
||||
prov *provenance.Store
|
||||
phash map[string]phashEntry // maCode -> 感知哈希条目(确权侵权比对)
|
||||
auths map[string]model.Authorization // maCode -> 授权(F22)
|
||||
black map[string]bool // maCode -> 黑名单(跨省复用校验)
|
||||
mu sync.Mutex
|
||||
seqMu sync.Mutex
|
||||
seqs map[string]int // 按前缀独立计数(REV/ctid/DIST 各自从 1 递增)
|
||||
chain chain.Client
|
||||
gen *macode.Generator
|
||||
pb *playback.Store
|
||||
prov *provenance.Store
|
||||
phash map[string]phashEntry // maCode -> 感知哈希条目(确权侵权比对)
|
||||
auths map[string]model.Authorization // maCode -> 授权(F22)
|
||||
black map[string]bool // maCode -> 黑名单(跨省复用校验)
|
||||
filings map[string]model.FilingRecord // maCode -> 备案/网标关联(三期 A.1)
|
||||
rights map[string]*model.UserRights // userHash -> 跨屏权益账户(四期 D.1)
|
||||
mu sync.Mutex
|
||||
seqMu sync.Mutex
|
||||
seqs map[string]int // 按前缀独立计数(REV/ctid/DIST 各自从 1 递增)
|
||||
// reviewStore 暂存送审申报(MVP 内存;生产落 PG)
|
||||
reviews map[string]*reviewItem
|
||||
}
|
||||
@@ -80,12 +82,14 @@ type phashEntry struct {
|
||||
func New(c chain.Client, gen *macode.Generator) *Service {
|
||||
return &Service{
|
||||
chain: c, gen: gen,
|
||||
pb: playback.NewStore(),
|
||||
prov: provenance.NewStore(),
|
||||
phash: make(map[string]phashEntry),
|
||||
auths: make(map[string]model.Authorization),
|
||||
black: make(map[string]bool),
|
||||
seqs: make(map[string]int), reviews: make(map[string]*reviewItem),
|
||||
pb: playback.NewStore(),
|
||||
prov: provenance.NewStore(),
|
||||
phash: make(map[string]phashEntry),
|
||||
auths: make(map[string]model.Authorization),
|
||||
black: make(map[string]bool),
|
||||
filings: make(map[string]model.FilingRecord),
|
||||
rights: make(map[string]*model.UserRights),
|
||||
seqs: make(map[string]int), reviews: make(map[string]*reviewItem),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 码体验跨域解析 / 扫码验真 / 跨屏权益。"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user