四期(大小屏融合)后端可代码部分:跨域解析网关/扫码验真/跨屏权益通兑

- 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:
selfrelease
2026-06-14 19:01:26 +08:00
parent 959e5ac18e
commit 2cd5fbec6d
6 changed files with 590 additions and 13 deletions
+195
View File
@@ -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
}
+183
View File
@@ -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)
}
+2
View File
@@ -57,6 +57,7 @@ type Service struct {
auths map[string]model.Authorization // maCode -> 授权(F22
black map[string]bool // maCode -> 黑名单(跨省复用校验)
filings map[string]model.FilingRecord // maCode -> 备案/网标关联(三期 A.1)
rights map[string]*model.UserRights // userHash -> 跨屏权益账户(四期 D.1)
mu sync.Mutex
seqMu sync.Mutex
seqs map[string]int // 按前缀独立计数(REV/ctid/DIST 各自从 1 递增)
@@ -87,6 +88,7 @@ func New(c chain.Client, gen *macode.Generator) *Service {
auths: make(map[string]model.Authorization),
black: make(map[string]bool),
filings: make(map[string]model.FilingRecord),
rights: make(map[string]*model.UserRights),
seqs: make(map[string]int), reviews: make(map[string]*reviewItem),
}
}