468c3b5daa
- F21 追更: AddEpisodes 追加新集不重新发码; Merkle定位变更集 - F22 授权链: RecordAuthorization + CheckAuthorization(地域/平台/期限), 嵌入注入前核验 - F13 跨省复用: CrossProvinceAdmit 三重校验(MA有效+哈希一致+非黑名单)快速准入 - F08 终端抽检: TerminalVerifySegment 片段校验+断流提示 - K.1 CI: .gitlab-ci.yml(后端构建/测试/前端构建) - 新增6个API; 16项测试通过; 二期纯代码功能全部完成 - A(真实链)/B(BFF)延后至有环境/三期, MemoryChain接口已就绪可平滑替换
414 lines
14 KiB
Go
414 lines
14 KiB
Go
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)
|
||
}
|
||
|
||
// ---- 工作包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
|
||
}
|
||
|
||
// 授权核验(需求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
|
||
}
|