四期(大小屏融合)后端可代码部分:跨域解析网关/扫码验真/跨屏权益通兑
- model/rights.go: ScreenType/ParsedMA/ResolveResult/ScanVerifyResult/UserRights/PurchaseRecord/CrossScreenRightsResult - service/phase4.go: ParseMACode + Resolve(C.1/C.2) + ScanVerify(B.2) + RecordPurchase/VerifyCrossScreenRights(D.1) - api/handlers.go: GET /content/resolve, POST /content/scan-verify, /rights/purchase, /rights/verify - service/phase4_test.go: 18 单测全绿 - 同一MA码跨iptv/ott/app统一解析; 任一屏购买全屏通看不重复扣费 - OTT/移动端SDK/C2PA凭证标注需外部环境 - 更新 5-task-IPTV-四期.md 进度
This commit is contained in:
+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 统一解析,流通状态一致
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ func (h *Handler) Register(rg *gin.RouterGroup) {
|
|||||||
rg.GET("/regulatory/national-stats", h.nationalStats) // 全国监管统计(三期F.2)
|
rg.GET("/regulatory/national-stats", h.nationalStats) // 全国监管统计(三期F.2)
|
||||||
rg.GET("/regulatory/daily-report", h.dailyReport) // 监管数据上报日报(三期A.2)
|
rg.GET("/regulatory/daily-report", h.dailyReport) // 监管数据上报日报(三期A.2)
|
||||||
rg.GET("/admin/segments", h.listSegments) // 号段管理(三期B.1)
|
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 {
|
||||||
@@ -680,3 +685,62 @@ func (h *Handler) dailyReport(c *gin.Context) {
|
|||||||
func (h *Handler) listSegments(c *gin.Context) {
|
func (h *Handler) listSegments(c *gin.Context) {
|
||||||
httpx.OK(c, gin.H{"segments": h.svc.ListSegments()})
|
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,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)
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@ type Service struct {
|
|||||||
auths map[string]model.Authorization // maCode -> 授权(F22)
|
auths map[string]model.Authorization // maCode -> 授权(F22)
|
||||||
black map[string]bool // maCode -> 黑名单(跨省复用校验)
|
black map[string]bool // maCode -> 黑名单(跨省复用校验)
|
||||||
filings map[string]model.FilingRecord // maCode -> 备案/网标关联(三期 A.1)
|
filings map[string]model.FilingRecord // maCode -> 备案/网标关联(三期 A.1)
|
||||||
|
rights map[string]*model.UserRights // userHash -> 跨屏权益账户(四期 D.1)
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
seqMu sync.Mutex
|
seqMu sync.Mutex
|
||||||
seqs map[string]int // 按前缀独立计数(REV/ctid/DIST 各自从 1 递增)
|
seqs map[string]int // 按前缀独立计数(REV/ctid/DIST 各自从 1 递增)
|
||||||
@@ -87,6 +88,7 @@ func New(c chain.Client, gen *macode.Generator) *Service {
|
|||||||
auths: make(map[string]model.Authorization),
|
auths: make(map[string]model.Authorization),
|
||||||
black: make(map[string]bool),
|
black: make(map[string]bool),
|
||||||
filings: make(map[string]model.FilingRecord),
|
filings: make(map[string]model.FilingRecord),
|
||||||
|
rights: make(map[string]*model.UserRights),
|
||||||
seqs: make(map[string]int), reviews: make(map[string]*reviewItem),
|
seqs: make(map[string]int), reviews: make(map[string]*reviewItem),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user