feat(phase2): 追更/授权链/跨省复用/终端抽检/CI(F21/F22/F13/F08/K)

- F21 追更: AddEpisodes 追加新集不重新发码; Merkle定位变更集
- F22 授权链: RecordAuthorization + CheckAuthorization(地域/平台/期限), 嵌入注入前核验
- F13 跨省复用: CrossProvinceAdmit 三重校验(MA有效+哈希一致+非黑名单)快速准入
- F08 终端抽检: TerminalVerifySegment 片段校验+断流提示
- K.1 CI: .gitlab-ci.yml(后端构建/测试/前端构建)
- 新增6个API; 16项测试通过; 二期纯代码功能全部完成
- A(真实链)/B(BFF)延后至有环境/三期, MemoryChain接口已就绪可平滑替换
This commit is contained in:
selfrelease
2026-06-14 17:24:56 +08:00
parent dc3095a2d5
commit 468c3b5daa
7 changed files with 529 additions and 81 deletions
+141
View File
@@ -2,6 +2,7 @@ package service
import (
"strings"
"time"
"github.com/tcs-iptv/tcs/internal/chain"
"github.com/tcs-iptv/tcs/internal/hash"
@@ -103,6 +104,11 @@ func (s *Service) InjectToCDN(role chain.Role, ctid, maCode, injectFileHash, ope
return InjectResult{Allowed: false, Reason: "哈希不匹配,疑似篡改,拒绝注入并告警"}, ErrHashMismatch
}
// 授权核验(需求25-AC2/AC3):若已登记授权,校验该运营商是否在授权平台内
if authRes := s.CheckAuthorization(maCode, "", operatorID); !authRes.Allowed {
return InjectResult{Allowed: false, Reason: authRes.Reason}, ErrNotApproved
}
distID := s.nextID("DIST")
if _, err := s.chain.RegisterMapping(role, model.Mapping{
ContentTwinID: ctid,
@@ -270,3 +276,138 @@ func (s *Service) MatchInfringement(perceptual string, high, medium int) ([]mode
}
return out, nil
}
// ---- 二期 F22:授权链与发布前核验(需求25) ----
// RecordAuthorization 登记信息网络传播权授权(需求25-AC1)。
func (s *Service) RecordAuthorization(maCode string, regions, platforms []string, expiry time.Time) error {
if _, err := s.chain.QueryContent(maCode); err != nil {
return err
}
s.mu.Lock()
s.auths[maCode] = model.Authorization{
MACode: maCode, Regions: regions, Platforms: platforms,
ExpiryAt: expiry, GrantedAt: time.Now(),
}
s.mu.Unlock()
return nil
}
// CheckAuthorization 核验某地域/平台是否在授权范围内(需求25-AC2/AC3)。
// 未登记授权时默认放行(向后兼容);登记后超地域/过期/非授权平台拦截。
func (s *Service) CheckAuthorization(maCode, region, platform string) model.AuthCheckResult {
s.mu.Lock()
a, ok := s.auths[maCode]
s.mu.Unlock()
if !ok {
return model.AuthCheckResult{Allowed: true, Reason: "未登记授权限制"}
}
if !a.ExpiryAt.IsZero() && time.Now().After(a.ExpiryAt) {
return model.AuthCheckResult{Allowed: false, Reason: "授权已过期"}
}
if region != "" && len(a.Regions) > 0 && !contains(a.Regions, region) {
return model.AuthCheckResult{Allowed: false, Reason: "超出授权地域: " + region}
}
if platform != "" && len(a.Platforms) > 0 && !contains(a.Platforms, platform) {
return model.AuthCheckResult{Allowed: false, Reason: "非授权平台: " + platform}
}
return model.AuthCheckResult{Allowed: true, Reason: "在授权范围内"}
}
// ---- 二期 F21:追更与增量哈希更新(需求24) ----
// AddEpisodes 追更:为已发码剧追加新集哈希,不触发存量重审、不重新发码(需求24-AC4)。
func (s *Service) AddEpisodes(role chain.Role, maCode string, episodes []model.EpisodeHash) error {
c, err := s.chain.QueryContent(maCode)
if err != nil {
return err
}
for _, ep := range episodes {
if _, err := s.chain.RegisterHashBinding(role, model.HashBinding{
ContentTwinID: c.ContentTwinID,
HashType: model.HashFile,
HashValue: ep.FileSHA256,
MerkleRoot: ep.MerkleRoot,
Episode: ep.Episode,
Version: "v1.0",
CreatedBy: string(role),
}); err != nil {
return err
}
}
s.prov.Record(model.ProvenanceEvent{
MACode: maCode, Node: model.NodeSubmit,
Operator: "追更", Detail: "追加新集,增量赋码",
})
return nil
}
// ---- 二期 F13:跨省复用快速准入(需求13) ----
// Blacklist 将 MA 码加入黑名单(用于跨省校验)。
func (s *Service) Blacklist(maCode string) {
s.mu.Lock()
s.black[maCode] = true
s.mu.Unlock()
}
// CrossProvinceAdmit B 省凭 MA 码+哈希证书快速准入(需求13)。
// 三重校验:MA 码有效 + 哈希与原过审版一致 + 非黑名单。
func (s *Service) CrossProvinceAdmit(maCode, fileHash, province string) model.CrossProvinceResult {
res := model.CrossProvinceResult{}
// 1. MA 码有效
if _, err := s.chain.QueryContent(maCode); err != nil {
res.Reason = "MA 码无效或不存在"
return res
}
res.MACodeValid = true
// 2. 哈希与原过审版一致
vr, err := s.chain.VerifyHash(maCode, fileHash)
if err != nil || !vr.Match {
res.Reason = "哈希与原过审版不一致"
return res
}
res.HashConsistent = true
// 3. 非黑名单
s.mu.Lock()
bl := s.black[maCode]
s.mu.Unlock()
if bl {
res.Reason = "内容在黑名单中"
return res
}
res.NotBlacklisted = true
// 准入:生成本省流水号并注册本省映射
res.ProvinceFlowNo = s.nextID("REV-" + province)
res.Admitted = true
res.Reason = "三重校验通过,快速准入(审核简化为合规抽检)"
return res
}
// ---- 二期 F08:终端片段抽检(需求8) ----
// TerminalVerifySegment 终端按集抽检:校验某集哈希,不匹配则提示断流(需求8-AC1/AC2)。
func (s *Service) TerminalVerifySegment(maCode string, episode int, segHash string) (bool, string) {
res, err := s.chain.VerifyEpisodeHash(maCode, episode, segHash)
if err != nil {
return false, "无法校验:" + err.Error()
}
if !res.Match {
return false, "片段哈希不匹配,疑似 CDN 劫持/传输篡改,建议断流切备用源"
}
return true, "校验通过"
}
// contains 判断字符串切片是否包含目标。
func contains(arr []string, v string) bool {
for _, a := range arr {
if a == v {
return true
}
}
return false
}
+133
View File
@@ -0,0 +1,133 @@
package service
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tcs-iptv/tcs/internal/chain"
"github.com/tcs-iptv/tcs/internal/model"
)
// ---- F22 授权链与发布前核验 ----
func TestAuthorization_PlatformGate(t *testing.T) {
s := newService(t)
maCode, ctid, cert := issueOne(t, s)
require.NoError(t, s.IngestToLibrary(chain.RoleReviewer, maCode, ctid, "M", "媒资库"))
require.NoError(t, s.PublishToOperator(PublishRequest{MACode: maCode, Certificate: cert}))
// 仅授权 CT-SX
require.NoError(t, s.RecordAuthorization(maCode, nil, []string{"CT-SX"}, time.Time{}))
// 授权平台注入通过
_, err := s.InjectToCDN(chain.RoleOperator, ctid, maCode, "filehash-abc", "CT-SX", "cdn://ct")
require.NoError(t, err)
// 非授权平台注入被拒
_, err = s.InjectToCDN(chain.RoleOperator, ctid, maCode, "filehash-abc", "CM-SX", "cdn://cm")
assert.ErrorIs(t, err, ErrNotApproved)
}
func TestAuthorization_Expiry(t *testing.T) {
s := newService(t)
maCode, _, _ := issueOne(t, s)
// 已过期授权
require.NoError(t, s.RecordAuthorization(maCode, nil, nil, time.Now().Add(-time.Hour)))
res := s.CheckAuthorization(maCode, "", "")
assert.False(t, res.Allowed)
assert.Contains(t, res.Reason, "过期")
}
func TestAuthorization_RegionGate(t *testing.T) {
s := newService(t)
maCode, _, _ := issueOne(t, s)
require.NoError(t, s.RecordAuthorization(maCode, []string{"610000"}, nil, time.Time{}))
assert.True(t, s.CheckAuthorization(maCode, "610000", "").Allowed) // 陕西
assert.False(t, s.CheckAuthorization(maCode, "440000", "").Allowed) // 广东,超域
}
// ---- F21 追更 ----
func TestAddEpisodes_NoReissue(t *testing.T) {
s := newService(t)
// 初始 2 集
sub := sampleSub()
sub.Episodes = []model.EpisodeHash{{Episode: 1, FileSHA256: "e1"}, {Episode: 2, FileSHA256: "e2"}}
r, _ := s.SubmitForReview(sub)
require.NoError(t, s.ReviewCSPS(r.ReviewID, true, "rv"))
issued, err := s.ApproveAndIssue(chain.RoleRegulator, r.ReviewID, "陕西IPTV")
require.NoError(t, err)
// 追更第 3、4 集(不重新发码)
require.NoError(t, s.AddEpisodes(chain.RoleReviewer, issued.MACode, []model.EpisodeHash{
{Episode: 3, FileSHA256: "e3"}, {Episode: 4, FileSHA256: "e4"},
}))
list, err := s.ListEpisodes(issued.MACode)
require.NoError(t, err)
assert.Len(t, list, 4, "应有 4 集")
// 新集可独立验真
res, err := s.VerifyEpisode(issued.MACode, 3, "e3")
require.NoError(t, err)
assert.True(t, res.Match)
}
// ---- F13 跨省复用 ----
func TestCrossProvince_Admit(t *testing.T) {
s := newService(t)
maCode, _, _ := issueOne(t, s) // sampleSub FileHash=filehash-abc
res := s.CrossProvinceAdmit(maCode, "filehash-abc", "610000")
assert.True(t, res.Admitted)
assert.True(t, res.MACodeValid && res.HashConsistent && res.NotBlacklisted)
assert.NotEmpty(t, res.ProvinceFlowNo)
}
func TestCrossProvince_HashMismatch(t *testing.T) {
s := newService(t)
maCode, _, _ := issueOne(t, s)
res := s.CrossProvinceAdmit(maCode, "tampered", "610000")
assert.False(t, res.Admitted)
assert.True(t, res.MACodeValid)
assert.False(t, res.HashConsistent)
}
func TestCrossProvince_Blacklisted(t *testing.T) {
s := newService(t)
maCode, _, _ := issueOne(t, s)
s.Blacklist(maCode)
res := s.CrossProvinceAdmit(maCode, "filehash-abc", "610000")
assert.False(t, res.Admitted)
assert.False(t, res.NotBlacklisted)
assert.Contains(t, res.Reason, "黑名单")
}
func TestCrossProvince_UnknownMA(t *testing.T) {
s := newService(t)
res := s.CrossProvinceAdmit("MA.156.8531.6101/WD/不存在", "h", "610000")
assert.False(t, res.Admitted)
assert.False(t, res.MACodeValid)
}
// ---- F08 终端抽检 ----
func TestTerminalVerifySegment(t *testing.T) {
s := newService(t)
sub := sampleSub()
sub.Episodes = []model.EpisodeHash{{Episode: 1, FileSHA256: "seg1"}}
r, _ := s.SubmitForReview(sub)
require.NoError(t, s.ReviewCSPS(r.ReviewID, true, "rv"))
issued, err := s.ApproveAndIssue(chain.RoleRegulator, r.ReviewID, "陕西IPTV")
require.NoError(t, err)
ok, _ := s.TerminalVerifySegment(issued.MACode, 1, "seg1")
assert.True(t, ok)
ok, msg := s.TerminalVerifySegment(issued.MACode, 1, "tampered")
assert.False(t, ok)
assert.Contains(t, msg, "断流")
}
+5 -1
View File
@@ -53,7 +53,9 @@ type Service struct {
gen *macode.Generator
pb *playback.Store
prov *provenance.Store
phash map[string]phashEntry // maCode -> 感知哈希条目(确权侵权比对)
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 递增)
@@ -81,6 +83,8 @@ func New(c chain.Client, gen *macode.Generator) *Service {
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),
}
}