4 Commits

Author SHA1 Message Date
selfrelease 8db9d33694 feat(phase3): 备案对接/全国统计/号段管理/BFF安全化/链合约源码
- A.1 备案对接: BindFiling/QueryFiling 关联网标号+备案号
- A.2 监管上报: DailyRegulatoryReport 日报
- B.1 号段管理: ListSegments + /admin/segments
- C.1/C.2 全国统计按省聚合 + 跨省协同(单一可信源天然联动)
- F.2 全国监管大屏: NationalStats(按省/类目/状态)
- B(遗留) 监管大屏BFF: internal/bff + cmd/console-bff, 密钥仅存后端浏览器只用会话令牌
- G 真实链合约源码: contracts/tcs_registry/registry.go (ChainMaker Go)
- 新增9个API+BFF服务; 5项新测试; 端到端BFF验证
- D/E(压测/等保/HSM)/F.1(标准)/真实链部署 标注需外部环境
2026-06-14 17:53:12 +08:00
selfrelease f34c82241e merge: 二期前端可视化(分账/追责/确权/授权) 2026-06-14 17:32:12 +08:00
selfrelease 73e22f79d2 feat(phase2-fe): 二期可视化(分账/追责/确权/授权/回传)
- GovernancePanel: 监管片库详情新增'权益与治理'标签(分账/追责取证/确权举证/授权管理)
- 分账面板: 播放聚合统计+CP60/平台34/服务费6分账展示
- 追责面板: 全链路存证Timeline+审播一致/篡改定位结果
- 确权面板: 证据链+谁先锁定谁有权声明
- 授权面板: 登记授权范围(地域/平台/期限)+核验
- 运营商台: 回传播放(含购买)按钮喂分账数据
- 前端build通过, HMR生效
2026-06-14 17:31:49 +08:00
selfrelease 57ea9f122d merge: 二期(贯通)纯代码功能完成
数据聚合分账(F09/F18) + 追责确权(F19/F20) + 追更跨省授权(F21/F13/F22) + 终端抽检(F08) + CI(K)
真实链(A)/BFF(B)延后至有环境/三期
2026-06-14 17:25:19 +08:00
14 changed files with 916 additions and 75 deletions
+60 -58
View File
@@ -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 测试网部署(需链环境;合约+接口已就绪,平滑替换)
- 多省/平台实际接入(商务运营推进)
---
+42
View File
@@ -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
}
+6
View File
@@ -0,0 +1,6 @@
// 独立合约模块:不纳入主服务构建,按 ChainMaker 合约规范单独编译部署。
module tcs_registry
go 1.23
require chainmaker.org/chainmaker/contract-sdk-go/v2 v2.3.3
+140
View File
@@ -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())
}
}
+63
View File
@@ -55,6 +55,11 @@ 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
}
func roleOf(c *gin.Context) chain.Role {
@@ -617,3 +622,61 @@ 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()})
}
+154
View File
@@ -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)
}
+24
View File
@@ -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
+38
View File
@@ -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"`
}
+115
View File
@@ -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"])
}
+18 -16
View File
@@ -49,16 +49,17 @@ 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)
mu sync.Mutex
seqMu sync.Mutex
seqs map[string]int // 按前缀独立计数(REV/ctid/DIST 各自从 1 递增)
// reviewStore 暂存送审申报(MVP 内存;生产落 PG)
reviews map[string]*reviewItem
}
@@ -80,12 +81,13 @@ 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),
seqs: make(map[string]int), reviews: make(map[string]*reviewItem),
}
}
@@ -0,0 +1,146 @@
import React, { useState, useEffect } from 'react'
import {
Tabs, Card, Button, Space, Tag, Table, Descriptions, Statistic, Row, Col,
Form, Input, DatePicker, message, Timeline, Typography, Alert, Result,
} from 'antd'
import { api } from './api.js'
const { Text, Paragraph } = Typography
const yuan = (cent) => '¥' + (cent / 100).toFixed(2)
const nodeLabel = {
cp_submit: 'CP送审', csps_review: 'CSPS审核', ma_issue: '发码签发',
transcode: '转码', media_ingest: '媒资入库', cdn_inject: 'CDN注入',
}
// 分账面板
function SettlementPanel({ maCode }) {
const [sum, setSum] = useState(null)
const [st, setSt] = useState(null)
async function load() {
const s = await api.playbackSummary(maCode)
setSum(s.data?.data)
const r = await api.settlement(maCode, '2026-06')
if (r.ok) setSt(r.data.data)
}
useEffect(() => { load() }, [maCode])
if (!sum) return null
return (
<div>
<Row gutter={16} style={{ marginBottom: 12 }}>
<Col span={6}><Card size="small"><Statistic title="总播放" value={sum.total_plays} /></Card></Col>
<Col span={6}><Card size="small"><Statistic title="完播" value={sum.total_complete} /></Card></Col>
<Col span={6}><Card size="small"><Statistic title="总收益" value={yuan(sum.total_revenue_cent || 0)} /></Card></Col>
</Row>
{st ? (
<Card size="small" title="分账结算(依据:链上可信播放数据)">
<Descriptions bordered size="small" column={2}>
<Descriptions.Item label="总收益">{yuan(st.total_revenue_cent)}</Descriptions.Item>
<Descriptions.Item label="结算周期">{st.period}</Descriptions.Item>
<Descriptions.Item label="内容方(CP) 60%"><Text strong>{yuan(st.cp_share_cent)}</Text></Descriptions.Item>
<Descriptions.Item label="平台/运营商 34%">{yuan(st.platform_share_cent)}</Descriptions.Item>
<Descriptions.Item label="运营服务费 6%">{yuan(st.hub_fee_cent)}</Descriptions.Item>
<Descriptions.Item label="数据来源"><Tag color="blue">{st.data_source}</Tag></Descriptions.Item>
</Descriptions>
</Card>
) : <Alert type="info" message="暂无收益数据,可在运营商工作台回传播放/购买事件" />}
</div>
)
}
// 追责取证面板
function AccountabilityPanel({ maCode }) {
const [rep, setRep] = useState(null)
useEffect(() => { api.accountability(maCode).then((r) => setRep(r.data?.data)) }, [maCode])
if (!rep) return null
return (
<div>
{rep.consistent
? <Result status="success" title="审播一致" subTitle={rep.conclusion} style={{ padding: 12 }} />
: <Result status="error" title="检出哈希不一致" subTitle={rep.conclusion} style={{ padding: 12 }} />}
<Card size="small" title={'全链路存证(基准哈希 ' + (rep.baseline_hash || '-') + ''}>
<Timeline items={(rep.trail || []).map((e) => ({
color: rep.first_change && e.node === rep.first_change.node && e.hash_value === rep.first_change.hash_value ? 'red' : 'green',
children: (
<Space direction="vertical" size={0}>
<Space><Tag>{nodeLabel[e.node] || e.node}</Tag><Text strong>{e.operator}</Text></Space>
<Text type="secondary" style={{ fontSize: 12 }}>{e.detail} {e.hash_value ? '· hash=' + e.hash_value : ''}</Text>
</Space>
),
}))} />
</Card>
</div>
)
}
// 确权举证面板
function EvidencePanel({ maCode }) {
const [ev, setEv] = useState(null)
useEffect(() => { api.evidence(maCode).then((r) => setEv(r.data?.data)) }, [maCode])
if (!ev) return null
return (
<Card size="small" title="版权确权证据链">
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="作品">{ev.title}</Descriptions.Item>
<Descriptions.Item label="MA 码">{ev.ma_code}</Descriptions.Item>
<Descriptions.Item label="内容哈希">{ev.content_hash || '-'}</Descriptions.Item>
<Descriptions.Item label="签发方">{ev.issuer}</Descriptions.Item>
<Descriptions.Item label="链上锚定">{ev.chain_anchor}</Descriptions.Item>
<Descriptions.Item label="最早登记时间">{ev.first_seen_at}</Descriptions.Item>
</Descriptions>
<Alert style={{ marginTop: 12 }} type="success" showIcon message="确权声明" description={ev.statement} />
</Card>
)
}
// 授权管理面板
function AuthPanel({ maCode }) {
const [form] = Form.useForm()
const [checkResult, setCheckResult] = useState(null)
async function grant() {
const v = await form.validateFields()
const regions = v.regions ? v.regions.split(',').map((s) => s.trim()).filter(Boolean) : []
const platforms = v.platforms ? v.platforms.split(',').map((s) => s.trim()).filter(Boolean) : []
const expiry = v.expiry ? v.expiry.toISOString() : ''
const res = await api.authorize(maCode, regions, platforms, expiry)
if (res.ok) message.success('授权已登记')
else message.error(res.data.message)
}
async function check() {
const v = form.getFieldsValue()
const res = await api.authCheck(maCode, v.checkRegion || '', v.checkPlatform || '')
setCheckResult(res.data?.data)
}
return (
<Form form={form} layout="vertical" size="small">
<Card size="small" title="登记授权范围" style={{ marginBottom: 12 }}>
<Form.Item label="授权地域(省码,逗号分隔,空=全国)" name="regions"><Input placeholder="610000,440000" /></Form.Item>
<Form.Item label="授权平台(逗号分隔,空=不限)" name="platforms"><Input placeholder="CT-SX-IPTV,CM-SX-IPTV" /></Form.Item>
<Form.Item label="到期时间(空=长期)" name="expiry"><DatePicker showTime style={{ width: '100%' }} /></Form.Item>
<Button type="primary" onClick={grant}>登记授权</Button>
</Card>
<Card size="small" title="授权核验">
<Space>
<Form.Item name="checkRegion" noStyle><Input placeholder="地域省码" style={{ width: 130 }} /></Form.Item>
<Form.Item name="checkPlatform" noStyle><Input placeholder="平台编码" style={{ width: 150 }} /></Form.Item>
<Button onClick={check}>核验</Button>
</Space>
{checkResult && (
<Alert style={{ marginTop: 12 }} type={checkResult.allowed ? 'success' : 'error'}
message={checkResult.allowed ? '在授权范围内' : '拦截:' + checkResult.reason} />
)}
</Card>
</Form>
)
}
export default function GovernancePanel({ maCode }) {
return (
<Tabs size="small" items={[
{ key: 'settle', label: '💰 分账', children: <SettlementPanel maCode={maCode} /> },
{ key: 'account', label: '⚖️ 追责取证', children: <AccountabilityPanel maCode={maCode} /> },
{ key: 'evidence', label: '📜 确权举证', children: <EvidencePanel maCode={maCode} /> },
{ key: 'auth', label: '🔑 授权管理', children: <AuthPanel maCode={maCode} /> },
]} />
)
}
+18 -1
View File
@@ -5,6 +5,7 @@ import {
} from 'antd'
import { ReloadOutlined, SendOutlined, StopOutlined } from '@ant-design/icons'
import { call, api } from './api.js'
import GovernancePanel from './GovernancePanel.jsx'
const { Text } = Typography
@@ -189,6 +190,16 @@ function OperatorDesk({ tick, onChanged }) {
else message.warning('注入校验:' + (res.data.message || '哈希不匹配被拒'))
}
async function reportPlay(r) {
// 演示:回传 1 次播放 + 1 次购买(15元)
const res = await api.playback(r.operator_id || 'CT-SX-IPTV', [
{ ma_code: r.ma_code, event_type: 'play' },
{ ma_code: r.ma_code, event_type: 'purchase', revenue_cent: 1500 },
])
if (res.ok) message.success(`已回传播放数据(接收 ${res.data.data.accepted} 条),可在监管片库查看分账`)
else message.error(res.data.message)
}
const cols = [
{ title: 'MA 码', dataIndex: 'ma_code', render: (v) => <Text code>{v}</Text> },
{ title: '作品', dataIndex: 'title' },
@@ -196,6 +207,7 @@ function OperatorDesk({ tick, onChanged }) {
<Space>
<Button size="small" type="primary" onClick={() => inject(r, false)}>CDN 注入正确</Button>
<Button size="small" danger onClick={() => inject(r, true)}>模拟篡改注入</Button>
<Button size="small" onClick={() => reportPlay(r)}>回传播放(含购买)</Button>
</Space>
) },
]
@@ -366,9 +378,11 @@ function LibraryDesk({ tick, onChanged }) {
pagination={{ pageSize: 8 }}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="片库暂无内容" /> }} />
<Modal open={!!detail} onCancel={() => setDetail(null)} footer={null} width={760}
<Modal open={!!detail} onCancel={() => setDetail(null)} footer={null} width={820}
title={detail ? `片库详情 · ${detail.content.title}` : ''}>
{detail && (
<Tabs size="small" items={[
{ key: 'overview', label: '概览', children: (
<>
<Descriptions bordered size="small" column={1} style={{ marginBottom: 12 }}>
<Descriptions.Item label="MA 码">{detail.content.ma_code}</Descriptions.Item>
@@ -405,6 +419,9 @@ function LibraryDesk({ tick, onChanged }) {
]} />
</Card>
</>
) },
{ key: 'gov', label: '权益与治理', children: <GovernancePanel maCode={detail.content.ma_code} /> },
]} />
)}
</Modal>
</Card>
+9
View File
@@ -65,4 +65,13 @@ export const api = {
// 工作队列(多角色工作台)
reviews: (role, status) => request(role, 'GET', '/content/reviews?status=' + status),
list: (role, status) => request(role, 'GET', '/content/list?status=' + status),
// 二期:分账/追责/确权/授权/跨省/追更/回传
playback: (platformId, batch) => request('operator', 'POST', '/data/playback', { platform_id: platformId, batch }),
playbackSummary: (maCode) => request('regulator', 'GET', '/data/playback-summary?ma_code=' + encodeURIComponent(maCode)),
settlement: (maCode, period) => request('regulator', 'POST', '/settlement/compute', { ma_code: maCode, period }),
accountability: (maCode) => request('regulator', 'GET', '/content/accountability?ma_code=' + encodeURIComponent(maCode)),
evidence: (maCode) => request('regulator', 'GET', '/content/evidence?ma_code=' + encodeURIComponent(maCode)),
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 }),
}