Files
MAcode/tcs-iptv/internal/service/distribution.go
T
selfrelease 468c3b5daa 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接口已就绪可平滑替换
2026-06-14 17:24:56 +08:00

414 lines
14 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"
"time"
"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
}
// 授权核验(需求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,
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
}
// ---- 二期 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
}