Files
MAcode/tcs-iptv/internal/service/distribution.go
T
selfrelease dc3095a2d5 feat(phase2): 追责取证与确权举证(F19/F20)
- internal/provenance: 全链路存证(送审/审核/发码/转码/入库/注入)+责任界定
- service: Provenance/Accountability(定位首次哈希变化节点)/CopyrightEvidence/MatchInfringement
- api: /content/provenance, /accountability, /evidence, /infringe-match
- 转码版哈希不误判为篡改; 感知哈希侵权比对分级(high/medium)
- 11项新测试通过; 端到端: 审播一致判定+证据链+侵权命中
2026-06-14 17:13:58 +08:00

273 lines
9.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"strings"
"github.com/tcs-iptv/tcs/internal/chain"
"github.com/tcs-iptv/tcs/internal/hash"
"github.com/tcs-iptv/tcs/internal/model"
)
// ---- 工作包7:转码版哈希绑定(需求5) ----
// 注:CSPS 合规审核已前移至发码前(service.ReviewCSPS),此处仅处理转码。
// BindTranscoded 绑定转码版哈希,与母版建立父子关系(需求5-AC3/AC4/AC5)。
func (s *Service) BindTranscoded(role chain.Role, ctid, parentFileHash, transcodedHash, format, resolution, version string) (string, error) {
if transcodedHash == "" {
return "", ErrIncompleteHashPkg
}
return s.chain.RegisterHashBinding(role, model.HashBinding{
ContentTwinID: ctid,
HashType: model.HashTranscoded,
HashValue: transcodedHash,
ParentHash: parentFileHash,
FileFormat: format,
Resolution: resolution,
Version: version,
CreatedBy: string(role),
})
}
// ---- 工作包8:媒体资源库入库、发布与映射(需求6) ----
// IngestToLibrary 审核合格内容入媒资库,建立媒资编码映射(需求6-AC1/AC2/AC3)。
func (s *Service) IngestToLibrary(role chain.Role, maCode, ctid, mediaAssetID, libName string) error {
c, err := s.chain.QueryContent(maCode)
if err != nil {
return err
}
// 门禁:未审核通过/未绑定 MA 码不得入库可发布状态
if c.Status == model.StatusRejected || c.Status == model.StatusRevoked {
return ErrNotApproved
}
if _, err := s.chain.RegisterMapping(role, model.Mapping{
ContentTwinID: ctid,
Party: model.PartyReviewer,
PartyID: mediaAssetID,
PartyName: libName,
}); err != nil {
return err
}
s.prov.Record(model.ProvenanceEvent{MACode: maCode, Node: model.NodeIngest, Operator: libName, Detail: "审合格入媒资库"})
return s.chain.SetContentStatus(maCode, model.StatusInLibrary)
}
// PublishRequest 从媒资库向运营商发布的请求(需求6-AC4)。
type PublishRequest struct {
MACode string
Certificate string // 必须携带 MA码+哈希证书
}
// PublishToOperator 校验证书后将内容置为已发布(需求6-AC4/AC5、需求3-AC8)。
func (s *Service) PublishToOperator(req PublishRequest) error {
c, err := s.chain.QueryContent(req.MACode)
if err != nil {
return err
}
if c.Status != model.StatusInLibrary && c.Status != model.StatusPublished {
return ErrNotApproved
}
// 发布必须携带证书(含 MA 码)
if req.Certificate == "" || !certContainsMA(req.Certificate, req.MACode) {
return ErrNoCertificate
}
return s.chain.SetContentStatus(req.MACode, model.StatusPublished)
}
// ---- 工作包9CDN 注入校验(需求7) ----
// InjectResult CDN 注入校验结果。
type InjectResult struct {
Allowed bool `json:"allowed"`
DistributionID string `json:"distribution_id,omitempty"`
Reason string `json:"reason,omitempty"`
}
// InjectToCDN 运营商注入 CDN 前校验哈希;匹配则放行并注册运营商映射(需求7-AC1~AC4)。
func (s *Service) InjectToCDN(role chain.Role, ctid, maCode, injectFileHash, operatorID, cdnEndpoint string) (InjectResult, error) {
// 内容须处于已发布状态
c, err := s.chain.QueryContent(maCode)
if err != nil {
return InjectResult{}, err
}
if c.Status == model.StatusRevoked {
return InjectResult{Allowed: false, Reason: "内容已下架"}, ErrNotApproved
}
res, err := s.chain.VerifyHash(maCode, injectFileHash)
if err != nil {
return InjectResult{Allowed: false, Reason: err.Error()}, err
}
if !res.Match {
// 不匹配:拒绝注入(需求7-AC3、需求15-AC2
return InjectResult{Allowed: false, Reason: "哈希不匹配,疑似篡改,拒绝注入并告警"}, ErrHashMismatch
}
distID := s.nextID("DIST")
if _, err := s.chain.RegisterMapping(role, model.Mapping{
ContentTwinID: ctid,
Party: model.PartyOperator,
PartyID: operatorID,
CDNEndpoint: cdnEndpoint,
}); 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
}
// ---- 工作包10:版本变更与重审(需求12) ----
// ReportVersionChange 上报内容变更:哈希变化判定绑定断裂,触发重审(需求12-AC1/AC2)。
// 当提供 oldSegments/newSegments 时,定位被篡改的具体集(需求12-AC3)。
func (s *Service) ReportVersionChange(ctid, reason, prevHash, newHash string, oldSegments, newSegments []string) ([]int, error) {
var changedEpisodes []int
affected := 0
if len(oldSegments) > 0 || len(newSegments) > 0 {
changedEpisodes = hash.LocateChangedLeaves(oldSegments, newSegments)
if len(changedEpisodes) > 0 {
affected = changedEpisodes[0] + 1 // 1-based 集号
}
}
_, err := s.chain.RecordVersionChange(model.VersionChange{
ContentTwinID: ctid,
Version: "v-next",
ChangeReason: reason,
PrevHash: prevHash,
NewHash: newHash,
ReauditRequired: true,
ReauditStatus: "pending",
AffectedEpisode: affected,
})
if err != nil {
return nil, err
}
// 转为 1-based 集号返回
episodes := make([]int, len(changedEpisodes))
for i, idx := range changedEpisodes {
episodes[i] = idx + 1
}
return episodes, nil
}
// ---- 工作包14:违规应急下架(需求11) ----
// Takedown 监管主体一键下架:解析 MA 码绑定的三方编码与 CDN 端点(需求11-AC1/AC2/AC4)。
func (s *Service) Takedown(role chain.Role, maCode, reason string) (chain.MappingsResult, error) {
return s.chain.Revoke(role, maCode, reason)
}
// TakedownEpisode 集级下架:只下架指定集,整剧其他集继续流通(仅监管主体)。
func (s *Service) TakedownEpisode(role chain.Role, maCode string, episode int, reason string) error {
return s.chain.RevokeEpisode(role, maCode, episode, reason)
}
// Restore 恢复上架整剧(仅监管主体)。
func (s *Service) Restore(role chain.Role, maCode string) error {
return s.chain.Restore(role, maCode)
}
// RestoreEpisode 恢复上架指定集(仅监管主体)。
func (s *Service) RestoreEpisode(role chain.Role, maCode string, episode int) error {
return s.chain.RestoreEpisode(role, maCode, episode)
}
// certContainsMA 校验证书是否包含指定 MA 码。
func certContainsMA(cert, maCode string) bool {
return cert != "" && maCode != "" && strings.Contains(cert, maCode)
}
// ---- 二期 F09/F18:数据回传聚合与分账(需求9/需求21) ----
// ReportPlayback 运营商以 MA 码为维度批量回传播放/消费事件(需求9-AC1)。
// 仅当 MA 码存在且处于流通状态时接收,保证数据归属可信。
func (s *Service) ReportPlayback(events []model.PlaybackEvent) (accepted int, rejected int) {
valid := make([]model.PlaybackEvent, 0, len(events))
for _, e := range events {
c, err := s.chain.QueryContent(e.MACode)
if err != nil || c.Status == model.StatusRevoked {
rejected++
continue
}
valid = append(valid, e)
}
accepted = s.pb.Ingest(valid)
return accepted, rejected
}
// PlaybackSummary 查询按 MA 码聚合的可信播放数据(需求9-AC2/AC3)。
func (s *Service) PlaybackSummary(maCode string) model.PlaybackSummary {
return s.pb.Summary(maCode)
}
// ComputeSettlement 基于可信播放数据计算分账(需求21-AC3)。
func (s *Service) ComputeSettlement(maCode, period string) (model.Settlement, error) {
if _, err := s.chain.QueryContent(maCode); err != nil {
return model.Settlement{}, err
}
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
}