feat(phase2): 追责取证与确权举证(F19/F20)

- internal/provenance: 全链路存证(送审/审核/发码/转码/入库/注入)+责任界定
- service: Provenance/Accountability(定位首次哈希变化节点)/CopyrightEvidence/MatchInfringement
- api: /content/provenance, /accountability, /evidence, /infringe-match
- 转码版哈希不误判为篡改; 感知哈希侵权比对分级(high/medium)
- 11项新测试通过; 端到端: 审播一致判定+证据链+侵权命中
This commit is contained in:
selfrelease
2026-06-14 17:13:58 +08:00
parent f44c53c5bb
commit dc3095a2d5
7 changed files with 405 additions and 15 deletions
@@ -0,0 +1,97 @@
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"
)
// 全链路一致:发码→入库→正确注入,追责判定审播一致。
func TestAccountability_Consistent(t *testing.T) {
s := newService(t)
maCode, ctid, cert := issueOne(t, s)
require.NoError(t, s.IngestToLibrary(chain.RoleReviewer, maCode, ctid, "M", "陕西IPTV媒资库"))
require.NoError(t, s.PublishToOperator(PublishRequest{MACode: maCode, Certificate: cert}))
_, err := s.InjectToCDN(chain.RoleOperator, ctid, maCode, "filehash-abc", "CT-SX", "cdn://x")
require.NoError(t, err)
rep := s.Accountability(maCode)
assert.True(t, rep.Consistent, "全链路应一致")
assert.Nil(t, rep.FirstChange)
assert.Equal(t, "filehash-abc", rep.BaselineHash)
assert.NotEmpty(t, rep.Trail)
}
// 注入环节偷换:追责定位到 cdn_inject 节点与运营商。
func TestAccountability_TamperLocated(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}))
// 直接往存证里记录一次"被偷换"的注入(绕过校验模拟违规留痕)
// 实际中 InjectToCDN 会拒绝不匹配,但运营商侧若绕过校验偷换,存证仍会暴露
_, _ = s.InjectToCDN(chain.RoleOperator, ctid, maCode, "filehash-abc", "CT-SX", "cdn://x")
s.prov.Record(model.ProvenanceEvent{
MACode: maCode, Node: model.NodeInject, HashValue: "TAMPERED",
Operator: "CT-SX-违规", Detail: "疑似偷换",
})
rep := s.Accountability(maCode)
assert.False(t, rep.Consistent)
require.NotNil(t, rep.FirstChange)
assert.Equal(t, model.NodeInject, rep.FirstChange.Node)
assert.Contains(t, rep.Conclusion, "cdn_inject")
}
// 转码版哈希不同属合法,不应误判为篡改。
func TestAccountability_TranscodeNotFlagged(t *testing.T) {
s := newService(t)
maCode, ctid, _ := issueOne(t, s)
_, err := s.BindTranscoded(chain.RoleReviewer, ctid, "filehash-abc", "h265-4k", "H.265", "4K", "v1-4k")
require.NoError(t, err)
s.prov.Record(model.ProvenanceEvent{MACode: maCode, Node: model.NodeTranscode, HashValue: "h265-4k", Operator: "转码中心"})
rep := s.Accountability(maCode)
assert.True(t, rep.Consistent, "转码版哈希不同属合法,不应判为篡改")
}
func TestCopyrightEvidence(t *testing.T) {
s := newService(t)
maCode, _, _ := issueOne(t, s)
ev, err := s.CopyrightEvidence(maCode)
require.NoError(t, err)
assert.Equal(t, maCode, ev.MACode)
assert.NotEmpty(t, ev.Trail)
assert.False(t, ev.FirstSeenAt.IsZero(), "应有最早登记时间戳")
assert.Contains(t, ev.Statement, "谁先锁定谁有权")
}
func TestMatchInfringement(t *testing.T) {
s := newService(t)
// 注册一部正版,感知哈希为合法 16 位十六进制(模拟 aHash/dHash 输出)
sub := sampleSub()
sub.Perceptual = "a1b2c3d4e5f60718"
r, err := s.SubmitForReview(sub)
require.NoError(t, err)
require.NoError(t, s.ReviewCSPS(r.ReviewID, true, "rv-1"))
issued, err := s.ApproveAndIssue(chain.RoleRegulator, r.ReviewID, "陕西IPTV")
require.NoError(t, err)
// 完全相同的感知哈希 → 高度相似(距离 0)
matches, err := s.MatchInfringement("a1b2c3d4e5f60718", 5, 10)
require.NoError(t, err)
require.NotEmpty(t, matches)
assert.Equal(t, issued.MACode, matches[0].MACode)
assert.Equal(t, "high", matches[0].Similarity)
assert.Equal(t, 0, matches[0].Distance)
// 差异极大的哈希 → 不命中
noMatch, err := s.MatchInfringement("ffffffffffffffff", 5, 10)
require.NoError(t, err)
assert.Empty(t, noMatch)
}
+65
View File
@@ -48,6 +48,7 @@ func (s *Service) IngestToLibrary(role chain.Role, maCode, ctid, mediaAssetID, l
}); err != nil {
return err
}
s.prov.Record(model.ProvenanceEvent{MACode: maCode, Node: model.NodeIngest, Operator: libName, Detail: "审合格入媒资库"})
return s.chain.SetContentStatus(maCode, model.StatusInLibrary)
}
@@ -111,6 +112,7 @@ func (s *Service) InjectToCDN(role chain.Role, ctid, maCode, injectFileHash, ope
}); err != nil {
return InjectResult{}, err
}
s.prov.Record(model.ProvenanceEvent{MACode: maCode, Node: model.NodeInject, HashValue: injectFileHash, Operator: operatorID, Detail: "CDN 注入校验通过"})
return InjectResult{Allowed: true, DistributionID: distID}, nil
}
@@ -205,3 +207,66 @@ func (s *Service) ComputeSettlement(maCode, period string) (model.Settlement, er
}
return s.pb.ComputeSettlement(maCode, period, model.DefaultShareConfig())
}
// ---- 二期 F19/F20:追责取证与确权举证(需求22/23) ----
// Provenance 返回某 MA 码的全链路存证(需求22-AC1)。
func (s *Service) Provenance(maCode string) []model.ProvenanceEvent {
return s.prov.Trail(maCode)
}
// Accountability 责任界定取证:定位首次哈希变化节点与责任方(需求22-AC2)。
func (s *Service) Accountability(maCode string) model.AccountabilityReport {
return s.prov.Accountability(maCode)
}
// CopyrightEvidence 导出版权确权证据链(需求23-AC1/AC2)。
func (s *Service) CopyrightEvidence(maCode string) (model.CopyrightEvidence, error) {
c, err := s.chain.QueryContent(maCode)
if err != nil {
return model.CopyrightEvidence{}, err
}
trail := s.prov.Trail(maCode)
ev := model.CopyrightEvidence{
MACode: maCode, Title: c.Title, Issuer: c.Issuer, IssueDate: c.IssueDate,
ContentHash: c.FileHash, ChainAnchor: "chain://" + maCode, Trail: trail,
Statement: "本证据链由 MA 码、内容哈希与上链时间戳构成,遵循『谁先锁定谁有权』,不可抵赖,可用于侵权投诉与司法举证。",
}
for _, e := range trail {
if e.Node == model.NodeSubmit {
ev.FirstSeenAt = e.Timestamp
if ev.ContentHash == "" {
ev.ContentHash = e.HashValue
}
break
}
}
return ev, nil
}
// MatchInfringement 用感知哈希在已确权内容中检索疑似侵权(需求23-AC3)。
// threshold 为汉明距离阈值(<=high 高度相似,<=medium 中度相似)。
func (s *Service) MatchInfringement(perceptual string, high, medium int) ([]model.InfringeMatch, error) {
s.mu.Lock()
entries := make(map[string]phashEntry, len(s.phash))
for k, v := range s.phash {
entries[k] = v
}
s.mu.Unlock()
var out []model.InfringeMatch
for ma, e := range entries {
d, err := hash.HammingDistance(perceptual, e.Perceptual)
if err != nil {
continue // 长度不一致跳过
}
if d <= medium {
sim := "medium"
if d <= high {
sim = "high"
}
out = append(out, model.InfringeMatch{MACode: ma, Title: e.Title, Distance: d, Similarity: sim})
}
}
return out, nil
}
+26 -1
View File
@@ -13,6 +13,7 @@ import (
"github.com/tcs-iptv/tcs/internal/macode"
"github.com/tcs-iptv/tcs/internal/model"
"github.com/tcs-iptv/tcs/internal/playback"
"github.com/tcs-iptv/tcs/internal/provenance"
)
// 业务错误。
@@ -51,6 +52,8 @@ type Service struct {
chain chain.Client
gen *macode.Generator
pb *playback.Store
prov *provenance.Store
phash map[string]phashEntry // maCode -> 感知哈希条目(确权侵权比对)
mu sync.Mutex
seqMu sync.Mutex
seqs map[string]int // 按前缀独立计数(REV/ctid/DIST 各自从 1 递增)
@@ -65,9 +68,21 @@ type reviewItem struct {
MACode string
}
// phashEntry 感知哈希注册项,用于确权侵权比对。
type phashEntry struct {
Title string
Perceptual string
}
// New 创建业务服务。
func New(c chain.Client, gen *macode.Generator) *Service {
return &Service{chain: c, gen: gen, pb: playback.NewStore(), seqs: make(map[string]int), reviews: make(map[string]*reviewItem)}
return &Service{
chain: c, gen: gen,
pb: playback.NewStore(),
prov: provenance.NewStore(),
phash: make(map[string]phashEntry),
seqs: make(map[string]int), reviews: make(map[string]*reviewItem),
}
}
func (s *Service) nextID(prefix string) string {
@@ -190,6 +205,16 @@ func (s *Service) ApproveAndIssue(role chain.Role, reviewID, issuer string) (Iss
PartyName: item.Sub.CPName,
})
// 记录全链路存证(送审→审核→发码)+ 注册感知哈希供确权比对
s.prov.Record(model.ProvenanceEvent{MACode: maCode, Node: model.NodeSubmit, HashValue: item.Sub.FileHash, Operator: item.Sub.CPName, Detail: "CP 送审"})
s.prov.Record(model.ProvenanceEvent{MACode: maCode, Node: model.NodeCSPSReview, Operator: "CSPS", Detail: "合规审核通过"})
s.prov.Record(model.ProvenanceEvent{MACode: maCode, Node: model.NodeIssue, HashValue: item.Sub.FileHash, Operator: issuer, Detail: "发码签发,绑定基准哈希"})
if item.Sub.Perceptual != "" {
s.mu.Lock()
s.phash[maCode] = phashEntry{Title: item.Sub.Title, Perceptual: item.Sub.Perceptual}
s.mu.Unlock()
}
cert := fmt.Sprintf("CERT|%s|%s|%s", maCode, item.Sub.FileHash, item.Sub.MerkleRoot)
return IssueResult{
MACode: maCode,