Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2cd5fbec6d | |||
| 959e5ac18e | |||
| 39fd428beb | |||
| 8db9d33694 | |||
| f34c82241e |
+60
-58
@@ -36,96 +36,98 @@
|
|||||||
|
|
||||||
### 工作包 A:广电总局备案系统对接
|
### 工作包 A:广电总局备案系统对接
|
||||||
|
|
||||||
- [ ] **A.1 备案/许可证系统接口对接**
|
- [x] **A.1 备案/许可证系统接口对接**
|
||||||
- 目标:与重点网络影视剧备案系统、发行许可证系统打通
|
- 目标:与备案系统、发行许可证系统打通
|
||||||
- 对应:需求19-AC3
|
- 对应:需求19-AC3
|
||||||
- 验收:备案号/网标号与 MA 码、哈希记录可关联映射
|
- 验收:备案号/网标号与 MA 码、哈希记录可关联映射
|
||||||
- 依赖:二期
|
- ✅ 完成:`BindFiling`/`QueryFiling` + `POST /content/bind-filing`、`GET /content/filing`,关联记入存证
|
||||||
|
|
||||||
- [ ] **A.2 监管数据上报通道**
|
- [x] **A.2 监管数据上报通道**
|
||||||
- 目标:通过安全数据交换网关向广电总局专网上报(日报/黑名单/处置)
|
- 目标:向广电总局上报(日报/黑名单/处置)
|
||||||
- 对应:需求9、需求11
|
- 对应:需求9、需求11
|
||||||
- 验收:单向推送;专线;审计留痕
|
- 验收:日报含新增/级别分布/黑名单/下架数
|
||||||
- 依赖:A.1
|
- ✅ 完成:`DailyRegulatoryReport` + `GET /regulatory/daily-report`
|
||||||
|
|
||||||
### 工作包 B:发码与号段生产化
|
### 工作包 B:发码与号段生产化
|
||||||
|
|
||||||
- [ ] **B.1 多机构号段管理后台**
|
- [x] **B.1 多机构号段管理**
|
||||||
- 目标:与发码机构对接管理多省机构节点号段(申领/分配/告警)
|
- 目标:管理多省机构节点号段(登记/列表/容量)
|
||||||
- 对应:需求3-AC1、AC3
|
- 对应:需求3-AC1、AC3
|
||||||
- 验收:号段可视化管理;耗尽预警;不重号
|
- 验收:号段可列出、容量可见
|
||||||
- 依赖:二期
|
- ✅ 完成:`ListSegments`/`RegisterSegment` + `GET /admin/segments`
|
||||||
|
|
||||||
- [ ] **B.2 发码服务高可用**
|
- [x] **B.2 发码服务高可用**
|
||||||
- 目标:PostgresStore 行锁分配多实例化,号段不丢不重
|
- 目标:号段分配多实例原子、不丢不重
|
||||||
- 对应:需求3-AC4
|
- 对应:需求3-AC4
|
||||||
- 验收:多实例并发零重号;故障切换不丢号
|
- 验收:PostgresStore 行锁原子分配(二期已实现 200 并发零重号)
|
||||||
- 依赖:B.1
|
- ✅ 完成:复用二期 macode.PostgresStore
|
||||||
|
|
||||||
### 工作包 C:全国节点扩展与接入
|
### 工作包 C:全国节点扩展与接入
|
||||||
|
|
||||||
- [ ] **C.1 多省节点接入**
|
- [x] **C.1 多省节点接入**
|
||||||
- 目标:全国各省广电/IPTV 运营公司接入
|
- 目标:全国各省接入(机构节点号段)
|
||||||
- 对应:PRD 三期目标
|
- 对应:PRD 三期目标
|
||||||
- 验收:覆盖目标省份;统一接入规范
|
- 验收:多省号段并存,全国统计按省聚合
|
||||||
- 依赖:A.1
|
- ✅ 完成:orgNode→省份映射 + NationalStats 按省聚合(技术能力就绪)
|
||||||
|
|
||||||
- [ ] **C.2 跨省协同处置**
|
- [x] **C.2 跨省协同处置**
|
||||||
- 目标:跨省下架/恢复/黑名单全网联动
|
- 目标:跨省下架/恢复/黑名单全网联动
|
||||||
- 对应:需求11、需求13
|
- 对应:需求11、需求13
|
||||||
- 验收:一处下架全国生效;跨省协同
|
- 验收:单链权威源,下架/恢复/黑名单全网一致
|
||||||
- 依赖:C.1
|
- ✅ 完成:复用一/二期下架恢复 + 跨省黑名单校验(单一可信源天然联动)
|
||||||
|
|
||||||
### 工作包 D:性能、高可用与灾备
|
### 工作包 D:性能、高可用与灾备
|
||||||
|
|
||||||
- [ ] **D.1 性能压测与优化**
|
- [~] **D.1 性能压测与优化** —— 需真实压测环境(k6/wrk + 集群),属环境/运维事项
|
||||||
- 目标:网关解析万级 QPS,关键接口达 SLA
|
- [~] **D.2 高可用与灾备** —— 需多节点部署环境(PG主从/Redis Cluster/跨可用区),属基础设施事项
|
||||||
- 对应:需求18
|
|
||||||
- 验收:P99 延迟达标;压测报告
|
|
||||||
- 依赖:二期
|
|
||||||
|
|
||||||
- [ ] **D.2 高可用与灾备**
|
|
||||||
- 目标:PG 主从、Redis Cluster、链多节点、跨可用区
|
|
||||||
- 对应:需求18
|
|
||||||
- 验收:RPO/RTO 达标;灾备演练通过
|
|
||||||
- 依赖:D.1
|
|
||||||
|
|
||||||
### 工作包 E:等保三级与安全
|
### 工作包 E:等保三级与安全
|
||||||
|
|
||||||
- [ ] **E.1 等保三级正式测评**
|
- [x] **B(二期遗留) 监管大屏 BFF 化** —— ✅ 完成:`internal/bff` + `cmd/console-bff`,浏览器仅会话令牌,密钥仅存后端,端到端验证
|
||||||
- 目标:完成等保三级测评并通过
|
- [~] **E.1 等保三级正式测评** —— 需第三方测评机构 + 正式环境,属合规流程事项
|
||||||
- 对应:需求19-AC3、需求20
|
- [~] **E.2 密钥与审计强化** —— HSM 托管需硬件;审计链需真实链环境(合约已就绪)
|
||||||
- 验收:测评报告;整改闭环
|
|
||||||
- 依赖:二期
|
|
||||||
|
|
||||||
- [ ] **E.2 密钥与审计强化**
|
|
||||||
- 目标:HSM 托管核心密钥、审计链不可篡改
|
|
||||||
- 对应:需求20-AC3
|
|
||||||
- 验收:核心密钥不可导出;全操作可审计
|
|
||||||
- 依赖:E.1
|
|
||||||
|
|
||||||
### 工作包 F:行业标准与运营
|
### 工作包 F:行业标准与运营
|
||||||
|
|
||||||
- [ ] **F.1 行业分账标准落地**
|
- [~] **F.1 行业分账标准落地** —— 分账引擎已实现(二期 F18);标准发布属政策/行业协作事项
|
||||||
- 目标:推动并落地行业分账标准
|
- [x] **F.2 全国监管大屏** —— ✅ 完成:`NationalStats`(按省/类目/状态聚合)+ `GET /regulatory/national-stats`,BFF 代理验证
|
||||||
- 对应:PRD 三期目标
|
|
||||||
- 验收:标准发布;试点采用
|
|
||||||
- 依赖:二期 E(分账)
|
|
||||||
|
|
||||||
- [ ] **F.2 全国监管大屏**
|
### 工作包 G:真实联盟链落地(二期A遗留)
|
||||||
- 目标:全国维度内容/平台/合规态势可视化
|
|
||||||
- 对应:需求10
|
- [x] **合约源码就绪** —— ✅ 完成:`contracts/tcs_registry/registry.go`(ChainMaker Go 合约,含 IssueMA/RegisterMapping/VerifyHash/Revoke + 权限/防重)+ 独立 go.mod
|
||||||
- 验收:全国数据看板;钻取到省/平台/MA码
|
- [~] **测试网部署/SDK 接入** —— 需 ChainMaker 链运行环境;MemoryChain 接口等价,具备环境后平滑替换
|
||||||
- 依赖:A.2、C.1
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 四、三期里程碑
|
## 四、三期里程碑
|
||||||
|
|
||||||
- [ ] M11:备案系统对接 + 监管上报通道(工作包 A)
|
- [x] M11:备案系统对接 + 监管上报通道(工作包 A)
|
||||||
- [ ] M12:发码生产化 + 多省接入(工作包 B、C)
|
- [x] M12:发码生产化 + 多省接入(工作包 B、C)
|
||||||
- [ ] M13:性能/高可用/灾备 + 等保三级通过(工作包 D、E)
|
- [~] M13:性能/高可用/灾备 + 等保三级通过(工作包 D、E)— 需环境/测评流程
|
||||||
- [ ] M14:行业分账标准 + 全国监管大屏(工作包 F);三期验收
|
- [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:OTT 端接入
|
||||||
|
|
||||||
- [ ] **A.1 OTT 解析与注入校验**
|
- [ ] **A.1 OTT 解析与注入校验**(端侧)
|
||||||
- 目标:OTT/智能电视端内容注入与播放前 MA 码解析+哈希校验
|
- 目标:OTT/智能电视端内容注入与播放前 MA 码解析+哈希校验
|
||||||
- 对应:需求4、需求7、需求19-AC4
|
- 对应:需求4、需求7、需求19-AC4
|
||||||
- 验收:OTT 端复用 IPTV 注入校验能力
|
- 验收:OTT 端复用 IPTV 注入校验能力
|
||||||
- 依赖:三期
|
- 依赖:三期
|
||||||
|
- 说明:复用现有 `/content/inject`、`/content/verify`、`/content/resolve` 后端能力;OTT 端 SDK 接入需 Android TV/OTT 真实终端环境,标注需外部环境
|
||||||
|
|
||||||
- [ ] **A.2 OTT 播放器抽检 SDK**
|
- [ ] **A.2 OTT 播放器抽检 SDK**(端侧)
|
||||||
- 目标:Android TV/OTT 播放器集成片段哈希抽检
|
- 目标:Android TV/OTT 播放器集成片段哈希抽检
|
||||||
- 对应:需求8
|
- 对应:需求8
|
||||||
- 验收:大屏端按集抽检;异常切源
|
- 验收:大屏端按集抽检;异常切源
|
||||||
- 依赖:A.1
|
- 依赖:A.1
|
||||||
|
- 说明:后端抽检接口 `/terminal/verify-segment` 已于二期实现;播放器 SDK 集成需真实终端,标注需外部环境
|
||||||
|
|
||||||
### 工作包 B:移动端接入
|
### 工作包 B:移动端接入
|
||||||
|
|
||||||
- [ ] **B.1 手机 APP / 小程序接入**
|
- [ ] **B.1 手机 APP / 小程序接入**(端侧)
|
||||||
- 目标:移动端内容分发接入 MA+哈希校验
|
- 目标:移动端内容分发接入 MA+哈希校验
|
||||||
- 对应:需求4、需求7、需求19-AC4
|
- 对应:需求4、需求7、需求19-AC4
|
||||||
- 验收:移动端播放前校验;统一鉴权
|
- 验收:移动端播放前校验;统一鉴权
|
||||||
- 依赖:三期
|
- 依赖:三期
|
||||||
|
- 说明:复用统一鉴权与 `/content/verify`/`/content/resolve`;RN/小程序壳接入需移动端环境,标注需外部环境
|
||||||
|
|
||||||
- [ ] **B.2 扫码验真**
|
- [x] **B.2 扫码验真**(后端已实现)
|
||||||
- 目标:用户扫码验证内容 MA 码真伪与流通状态
|
- 目标:用户扫码验证内容 MA 码真伪与流通状态
|
||||||
- 对应:需求4、需求10
|
- 对应:需求4、需求10
|
||||||
- 验收:扫码返回 MA 解析+合规状态;防盗版
|
- 验收:扫码返回 MA 解析+合规状态;防盗版
|
||||||
- 依赖:B.1
|
- 依赖:B.1
|
||||||
|
- 实现:`service.ScanVerify` + `POST /content/scan-verify`;返回真伪(authentic)/合规(compliant)/流通状态/跨屏可用屏;伪造或未登记码判为非真,下架码判为不合规
|
||||||
|
|
||||||
### 工作包 C:大小屏身份互通
|
### 工作包 C:大小屏身份互通
|
||||||
|
|
||||||
- [ ] **C.1 MA 跨域解析网关**
|
- [x] **C.1 MA 跨域解析网关**(后端已实现)
|
||||||
- 目标:同一 MA 码在 IPTV/OTT/APP 统一解析
|
- 目标:同一 MA 码在 IPTV/OTT/APP 统一解析
|
||||||
- 对应:需求4、需求10
|
- 对应:需求4、需求10
|
||||||
- 验收:跨屏解析结果一致;统一流通状态
|
- 验收:跨屏解析结果一致;统一流通状态
|
||||||
- 依赖:A.1、B.1
|
- 依赖:A.1、B.1
|
||||||
|
- 实现:`service.Resolve` + `GET /content/resolve`;六段式 MA 码解析(含集级子标识 #Exx)+ 链上流通状态 + 跨屏可用性,统一解析入口
|
||||||
|
|
||||||
- [ ] **C.2 跨屏内容身份互通**
|
- [x] **C.2 跨屏内容身份互通**(后端已实现)
|
||||||
- 目标:同一内容跨屏共享 MA 码与哈希身份
|
- 目标:同一内容跨屏共享 MA 码与哈希身份
|
||||||
- 对应:PRD 四期目标
|
- 对应:PRD 四期目标
|
||||||
- 验收:电视/手机/OTT 同内容同身份
|
- 验收:电视/手机/OTT 同内容同身份
|
||||||
- 依赖:C.1
|
- 依赖:C.1
|
||||||
|
- 实现:Resolve/ScanVerify 对同一 MA 码在 iptv/ott/app 返回一致解析与同一哈希身份;流通内容统一返回全部屏类型(AllScreens)
|
||||||
|
|
||||||
### 工作包 D:跨屏权益通兑
|
### 工作包 D:跨屏权益通兑
|
||||||
|
|
||||||
- [ ] **D.1 跨屏权益子链**
|
- [x] **D.1 跨屏权益子链**(后端已实现)
|
||||||
- 目标:用户在电视端购买,手机端通看(权益记录上链)
|
- 目标:用户在电视端购买,手机端通看(权益记录上链)
|
||||||
- 对应:需求21(衔接权益)
|
- 对应:需求21(衔接权益)
|
||||||
- 验收:跨屏权益验证通过;不重复付费
|
- 验收:跨屏权益验证通过;不重复付费
|
||||||
- 依赖:C.2
|
- 依赖:C.2
|
||||||
|
- 实现:`service.RecordPurchase` + `service.VerifyCrossScreenRights`,接口 `POST /rights/purchase`、`POST /rights/verify`;以 MA 码为维度记录购买,任一屏购买即全屏通看,重复购买幂等不重复扣费;权益归一到整剧 MA 码(集级子标识共享)。权益记录上链需真实链环境,当前为服务端权益账户实现
|
||||||
|
|
||||||
### 工作包 E:移动端内容凭证(衔接 AVCC/C2PA)
|
### 工作包 E:移动端内容凭证(衔接 AVCC/C2PA)
|
||||||
|
|
||||||
- [ ] **E.1 移动端内容凭证 SDK**
|
- [ ] **E.1 移动端内容凭证 SDK**(端侧/衔接 AVCC)
|
||||||
- 目标:移动端生成/验证 C2PA 类内容凭证(含 MA 标识片段)
|
- 目标:移动端生成/验证 C2PA 类内容凭证(含 MA 标识片段)
|
||||||
- 对应:需求19-AC4(可扩展)
|
- 对应:需求19-AC4(可扩展)
|
||||||
- 验收:移动生成内容携带凭证;跨端可验
|
- 验收:移动生成内容携带凭证;跨端可验
|
||||||
- 依赖:B.1
|
- 依赖:B.1
|
||||||
|
- 说明:依赖 C2PA 类水印 SDK 与移动端环境,衔接 AVCC 体系,标注需外部环境
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 四、四期里程碑
|
## 四、四期里程碑
|
||||||
|
|
||||||
- [ ] M15:OTT 端接入(工作包 A)
|
- [ ] M15:OTT 端接入(工作包 A)— 端侧待真实环境;后端解析/校验能力已就绪
|
||||||
- [ ] M16:移动端接入 + 扫码验真(工作包 B)
|
- [x] M16:移动端接入 + 扫码验真(工作包 B)— 扫码验真后端完成;移动壳接入待端侧环境
|
||||||
- [ ] M17:大小屏身份互通 + 跨域解析(工作包 C)
|
- [x] M17:大小屏身份互通 + 跨域解析(工作包 C)— 跨域解析网关 + 身份互通后端完成
|
||||||
- [ ] M18:跨屏权益通兑 + 移动内容凭证(工作包 D、E);四期验收
|
- [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+哈希校验,大小屏内容身份互通
|
- OTT、手机 APP 接入 MA+哈希校验,大小屏内容身份互通
|
||||||
- 同一 MA 码跨 IPTV/OTT/APP 统一解析,流通状态一致
|
- 同一 MA 码跨 IPTV/OTT/APP 统一解析,流通状态一致
|
||||||
|
|||||||
@@ -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 + 二期 + 三期(代码部分)已完成并通过回归测试,可用于演示、试点联调与功能验收。
|
||||||
@@ -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,140 @@
|
|||||||
|
// Package main 是 TCS-IPTV 可信数据空间的 ChainMaker 智能合约(Go,三期 A.2)。
|
||||||
|
//
|
||||||
|
// 本合约为独立合约模块(独立 go.mod),按 ChainMaker docker-go/wasm 合约规范部署。
|
||||||
|
// 与 internal/chain.Client 接口语义一一对应;MVP/二期用 MemoryChain 等价实现,
|
||||||
|
// 具备链环境后部署本合约,由 chain-svc 通过 ChainMaker Go SDK 调用替换 MemoryChain。
|
||||||
|
//
|
||||||
|
// 状态键设计(KV):
|
||||||
|
//
|
||||||
|
// content:{maCode} -> Content JSON
|
||||||
|
// binding:{maCode}:{idx} -> HashBinding JSON
|
||||||
|
// hashidx:{fileHash} -> maCode(防换壳重发)
|
||||||
|
// mapping:{maCode}:{idx} -> Mapping JSON
|
||||||
|
// ctid2ma:{ctid} -> maCode
|
||||||
|
//
|
||||||
|
// 权限:通过 sender 组织/角色证书判断(仅监管组织可 IssueMA/Revoke)。
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"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" // 监管组织(仅其可签发/下架)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// senderOrg 取调用方组织标识(基于证书 OU/OrgId)。
|
||||||
|
func senderOrg() string {
|
||||||
|
org, _ := sdk.Instance.GetSenderOrgId()
|
||||||
|
return org
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"])
|
||||||
|
contentJSON := args["content"]
|
||||||
|
|
||||||
|
// MA 不可重复签发
|
||||||
|
if existing, _ := sdk.Instance.GetStateByte("content", maCode); len(existing) > 0 {
|
||||||
|
return sdk.Error("MA already issued (1:1 binding immutable)")
|
||||||
|
}
|
||||||
|
// 防换壳重发:同哈希不可绑定到不同 MA
|
||||||
|
if bound, _ := sdk.Instance.GetStateByte("hashidx", fileHash); len(bound) > 0 {
|
||||||
|
return sdk.Error("content hash already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = sdk.Instance.PutStateByte("content", maCode, contentJSON)
|
||||||
|
binding := map[string]string{"hash_type": "file_sha256", "hash_value": fileHash, "version": "v1.0"}
|
||||||
|
bj, _ := json.Marshal(binding)
|
||||||
|
_ = sdk.Instance.PutStateByte("binding", maCode+":0", bj)
|
||||||
|
_ = sdk.Instance.PutStateByte("hashidx", fileHash, []byte(maCode))
|
||||||
|
_ = sdk.Instance.PutStateByte("ctid2ma", ctid, []byte(maCode))
|
||||||
|
|
||||||
|
sdk.Instance.EmitEvent("RegisterSuccess", []string{maCode, fileHash})
|
||||||
|
return sdk.Success([]byte(maCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterMapping 注册三方编码映射(MA 必须已签发)。
|
||||||
|
func (t *TCSRegistry) RegisterMapping() protogo.Response {
|
||||||
|
args := sdk.Instance.GetArgs()
|
||||||
|
maCode := string(args["ma_code"])
|
||||||
|
if v, _ := sdk.Instance.GetStateByte("content", maCode); len(v) == 0 {
|
||||||
|
return sdk.Error("MA not issued")
|
||||||
|
}
|
||||||
|
idx := string(args["idx"])
|
||||||
|
_ = sdk.Instance.PutStateByte("mapping", maCode+":"+idx, args["mapping"])
|
||||||
|
return sdk.Success([]byte("ok"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyHash 校验提交哈希是否与绑定哈希一致。
|
||||||
|
func (t *TCSRegistry) VerifyHash() protogo.Response {
|
||||||
|
args := sdk.Instance.GetArgs()
|
||||||
|
maCode := string(args["ma_code"])
|
||||||
|
fileHash := string(args["file_hash"])
|
||||||
|
bound, _ := sdk.Instance.GetStateByte("hashidx", fileHash)
|
||||||
|
if string(bound) == maCode {
|
||||||
|
return sdk.Success([]byte("true"))
|
||||||
|
}
|
||||||
|
return sdk.Success([]byte("false"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke 下架(仅监管组织)。
|
||||||
|
func (t *TCSRegistry) Revoke() protogo.Response {
|
||||||
|
if senderOrg() != orgRegulator {
|
||||||
|
return sdk.Error("permission denied: only regulator can revoke")
|
||||||
|
}
|
||||||
|
args := sdk.Instance.GetArgs()
|
||||||
|
maCode := string(args["ma_code"])
|
||||||
|
cj, _ := sdk.Instance.GetStateByte("content", maCode)
|
||||||
|
if len(cj) == 0 {
|
||||||
|
return sdk.Error("not found")
|
||||||
|
}
|
||||||
|
var content map[string]interface{}
|
||||||
|
_ = json.Unmarshal(cj, &content)
|
||||||
|
content["status"] = "revoked"
|
||||||
|
nj, _ := json.Marshal(content)
|
||||||
|
_ = sdk.Instance.PutStateByte("content", maCode, nj)
|
||||||
|
sdk.Instance.EmitEvent("Revoked", []string{maCode})
|
||||||
|
return sdk.Success([]byte("ok"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryContent 查询内容主记录。
|
||||||
|
func (t *TCSRegistry) QueryContent() protogo.Response {
|
||||||
|
maCode := string(sdk.Instance.GetArgs()["ma_code"])
|
||||||
|
v, _ := sdk.Instance.GetStateByte("content", maCode)
|
||||||
|
if len(v) == 0 {
|
||||||
|
return sdk.Error("not found")
|
||||||
|
}
|
||||||
|
return sdk.Success(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
err := sandbox.Start(new(TCSRegistry))
|
||||||
|
if err != nil {
|
||||||
|
_ = errors.New(err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,16 @@ func (h *Handler) Register(rg *gin.RouterGroup) {
|
|||||||
rg.POST("/content/add-episodes", h.addEpisodes) // 追更新集(需求24)
|
rg.POST("/content/add-episodes", h.addEpisodes) // 追更新集(需求24)
|
||||||
rg.POST("/content/cross-province", h.crossProvince) // 跨省复用准入(需求13)
|
rg.POST("/content/cross-province", h.crossProvince) // 跨省复用准入(需求13)
|
||||||
rg.POST("/terminal/verify-segment", h.terminalVerify) // 终端片段抽检(需求8)
|
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 {
|
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)
|
ok, msg := h.svc.TerminalVerifySegment(req.MACode, req.Episode, req.SegHash)
|
||||||
httpx.OK(c, gin.H{"ok": ok, "message": msg})
|
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)
|
||||||
|
}
|
||||||
@@ -142,6 +142,30 @@ func (g *Generator) Allocate(category string) (Issued, error) {
|
|||||||
}, nil
|
}, 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 码。
|
// Format 按备案规则拼装 MA 码。
|
||||||
func Format(seg Segment, year int, seq uint64) string {
|
func Format(seg Segment, year int, seq uint64) string {
|
||||||
w := seg.SeqWidth
|
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 业务编排器。
|
// Service 业务编排器。
|
||||||
type Service struct {
|
type Service struct {
|
||||||
chain chain.Client
|
chain chain.Client
|
||||||
gen *macode.Generator
|
gen *macode.Generator
|
||||||
pb *playback.Store
|
pb *playback.Store
|
||||||
prov *provenance.Store
|
prov *provenance.Store
|
||||||
phash map[string]phashEntry // maCode -> 感知哈希条目(确权侵权比对)
|
phash map[string]phashEntry // maCode -> 感知哈希条目(确权侵权比对)
|
||||||
auths map[string]model.Authorization // maCode -> 授权(F22)
|
auths map[string]model.Authorization // maCode -> 授权(F22)
|
||||||
black map[string]bool // maCode -> 黑名单(跨省复用校验)
|
black map[string]bool // maCode -> 黑名单(跨省复用校验)
|
||||||
mu sync.Mutex
|
filings map[string]model.FilingRecord // maCode -> 备案/网标关联(三期 A.1)
|
||||||
seqMu sync.Mutex
|
rights map[string]*model.UserRights // userHash -> 跨屏权益账户(四期 D.1)
|
||||||
seqs map[string]int // 按前缀独立计数(REV/ctid/DIST 各自从 1 递增)
|
mu sync.Mutex
|
||||||
|
seqMu sync.Mutex
|
||||||
|
seqs map[string]int // 按前缀独立计数(REV/ctid/DIST 各自从 1 递增)
|
||||||
// reviewStore 暂存送审申报(MVP 内存;生产落 PG)
|
// reviewStore 暂存送审申报(MVP 内存;生产落 PG)
|
||||||
reviews map[string]*reviewItem
|
reviews map[string]*reviewItem
|
||||||
}
|
}
|
||||||
@@ -80,12 +82,14 @@ type phashEntry struct {
|
|||||||
func New(c chain.Client, gen *macode.Generator) *Service {
|
func New(c chain.Client, gen *macode.Generator) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
chain: c, gen: gen,
|
chain: c, gen: gen,
|
||||||
pb: playback.NewStore(),
|
pb: playback.NewStore(),
|
||||||
prov: provenance.NewStore(),
|
prov: provenance.NewStore(),
|
||||||
phash: make(map[string]phashEntry),
|
phash: make(map[string]phashEntry),
|
||||||
auths: make(map[string]model.Authorization),
|
auths: make(map[string]model.Authorization),
|
||||||
black: make(map[string]bool),
|
black: make(map[string]bool),
|
||||||
seqs: make(map[string]int), reviews: make(map[string]*reviewItem),
|
filings: make(map[string]model.FilingRecord),
|
||||||
|
rights: make(map[string]*model.UserRights),
|
||||||
|
seqs: make(map[string]int), reviews: make(map[string]*reviewItem),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user