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) } // ---- 工作包9:CDN 注入校验(需求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 }