package chain import ( "fmt" "sync" "time" "github.com/tcs-iptv/tcs/internal/model" ) // MemoryChain 是 Client 的内存实现(MVP / 测试用)。 // 严格执行合约级业务规则:签发权限、1:1 不可解绑、映射前置签发、防重复哈希。 type MemoryChain struct { mu sync.RWMutex contents map[string]model.Content // ccCode -> Content bindings map[string][]model.HashBinding // ccCode -> bindings mappings map[string][]model.Mapping // ccCode -> mappings versions map[string][]model.VersionChange hashIndex map[string]string // fileHash -> ccCode(防换壳重发) txSeq int } // NewMemoryChain 创建内存链客户端。 func NewMemoryChain() *MemoryChain { return &MemoryChain{ contents: make(map[string]model.Content), bindings: make(map[string][]model.HashBinding), mappings: make(map[string][]model.Mapping), versions: make(map[string][]model.VersionChange), hashIndex: make(map[string]string), } } func (m *MemoryChain) nextTx(method string) string { m.txSeq++ return fmt.Sprintf("tx-%s-%06d", method, m.txSeq) } // IssueMA 仅监管主体可调用;MA 不可重复签发;哈希 1:1 强绑定不可解绑。 func (m *MemoryChain) IssueMA(role Role, req IssueRequest) (string, error) { if role != RoleRegulator { return "", ErrPermissionDenied } m.mu.Lock() defer m.mu.Unlock() if _, ok := m.contents[req.CCCode]; ok { return "", ErrMAAlreadyIssued } if existing, ok := m.hashIndex[req.FileHash]; ok { return "", fmt.Errorf("%w: bound to %s", ErrHashExists, existing) } c := req.Content c.CCCode = req.CCCode c.ContentTwinID = req.ContentTwinID c.Status = model.StatusApproved if c.CreatedAt.IsZero() { c.CreatedAt = time.Now() } m.contents[req.CCCode] = c m.bindings[req.CCCode] = []model.HashBinding{{ ContentTwinID: req.ContentTwinID, HashType: model.HashFile, HashValue: req.FileHash, MerkleRoot: req.MerkleRoot, Version: "v1.0", CreatedBy: string(RoleRegulator), }} if req.PerceptualHash != "" { m.bindings[req.CCCode] = append(m.bindings[req.CCCode], model.HashBinding{ ContentTwinID: req.ContentTwinID, HashType: model.HashPerceptual, HashValue: req.PerceptualHash, Version: "v1.0", CreatedBy: string(RoleRegulator), }) } m.hashIndex[req.FileHash] = req.CCCode // 集级哈希绑定(分集内容):每集独立哈希,挂在同一 MA 码下。 for _, ep := range req.Episodes { m.bindings[req.CCCode] = append(m.bindings[req.CCCode], model.HashBinding{ ContentTwinID: req.ContentTwinID, HashType: model.HashFile, HashValue: ep.FileSHA256, MerkleRoot: ep.MerkleRoot, Episode: ep.Episode, Resolution: ep.Resolution, Duration: ep.Duration, Version: "v1.0", CreatedBy: string(RoleRegulator), }) if ep.FileSHA256 != "" { if _, ok := m.hashIndex[ep.FileSHA256]; !ok { m.hashIndex[ep.FileSHA256] = req.CCCode } } } return m.nextTx("issueMA"), nil } // RegisterHashBinding 追加哈希绑定(如转码版)。MA 必须已签发。 func (m *MemoryChain) RegisterHashBinding(role Role, b model.HashBinding) (string, error) { if role != RoleReviewer && role != RoleRegulator { return "", ErrPermissionDenied } m.mu.Lock() defer m.mu.Unlock() ccCode := m.ccCodeByCTID(b.ContentTwinID) if ccCode == "" { return "", ErrMANotIssued } m.bindings[ccCode] = append(m.bindings[ccCode], b) if b.HashType == model.HashFile || b.HashType == model.HashTranscoded { if _, ok := m.hashIndex[b.HashValue]; !ok { m.hashIndex[b.HashValue] = ccCode } } return m.nextTx("registerHashBinding"), nil } // RegisterMapping 注册三方编码映射;MA 必须已签发(需求16-AC3)。 func (m *MemoryChain) RegisterMapping(role Role, mp model.Mapping) (string, error) { m.mu.Lock() defer m.mu.Unlock() ccCode := m.ccCodeByCTID(mp.ContentTwinID) if ccCode == "" { return "", ErrMANotIssued } m.mappings[ccCode] = append(m.mappings[ccCode], mp) return m.nextTx("registerMapping"), nil } // VerifyHash 按 MA 码校验提交哈希。 func (m *MemoryChain) VerifyHash(ccCode, fileHash string) (VerifyResult, error) { m.mu.RLock() defer m.mu.RUnlock() bs, ok := m.bindings[ccCode] if !ok { return VerifyResult{Valid: false, CCCode: ccCode, SubmittedHash: fileHash}, ErrMANotIssued } for _, b := range bs { if b.HashType == model.HashFile || b.HashType == model.HashTranscoded { if b.HashValue == fileHash { return VerifyResult{ Valid: true, CCCode: ccCode, BoundHash: b.HashValue, SubmittedHash: fileHash, Match: true, Version: b.Version, }, nil } } } // 取首个文件哈希作为 bound 参考 bound := "" for _, b := range bs { if b.HashType == model.HashFile { bound = b.HashValue break } } return VerifyResult{Valid: true, CCCode: ccCode, BoundHash: bound, SubmittedHash: fileHash, Match: false}, nil } // HashExists 判断内容哈希是否已存在。 func (m *MemoryChain) HashExists(fileHash string) (string, bool) { m.mu.RLock() defer m.mu.RUnlock() ma, ok := m.hashIndex[fileHash] return ma, ok } // VerifyEpisodeHash 按 MA 码+集号校验该集哈希。 func (m *MemoryChain) VerifyEpisodeHash(ccCode string, episode int, fileHash string) (VerifyResult, error) { m.mu.RLock() defer m.mu.RUnlock() bs, ok := m.bindings[ccCode] if !ok { return VerifyResult{Valid: false, CCCode: ccCode, SubmittedHash: fileHash}, ErrMANotIssued } var bound string for _, b := range bs { if b.Episode == episode && (b.HashType == model.HashFile || b.HashType == model.HashTranscoded) { if bound == "" { bound = b.HashValue } if b.HashValue == fileHash { return VerifyResult{ Valid: true, CCCode: ccCode, BoundHash: b.HashValue, SubmittedHash: fileHash, Match: true, Version: b.Version, }, nil } } } if bound == "" { return VerifyResult{Valid: false, CCCode: ccCode, SubmittedHash: fileHash}, ErrNotFound } return VerifyResult{Valid: true, CCCode: ccCode, BoundHash: bound, SubmittedHash: fileHash, Match: false}, nil } // ListEpisodes 返回某 MA 码下的全部集级哈希绑定(episode > 0)。 func (m *MemoryChain) ListEpisodes(ccCode string) ([]model.HashBinding, error) { m.mu.RLock() defer m.mu.RUnlock() bs, ok := m.bindings[ccCode] if !ok { return nil, ErrMANotIssued } var out []model.HashBinding for _, b := range bs { if b.Episode > 0 { out = append(out, b) } } return out, nil } // QueryContent 查询内容主记录。 func (m *MemoryChain) QueryContent(ccCode string) (model.Content, error) { m.mu.RLock() defer m.mu.RUnlock() c, ok := m.contents[ccCode] if !ok { return model.Content{}, ErrNotFound } return c, nil } // ListContents 按状态列出内容(空状态返回全部),附带整剧文件哈希便于演示。 func (m *MemoryChain) ListContents(status string) ([]model.Content, error) { m.mu.RLock() defer m.mu.RUnlock() var out []model.Content for ma, c := range m.contents { if status == "" || c.Status == status { // 附带整剧文件哈希(episode==0 的 file 绑定) for _, b := range m.bindings[ma] { if b.HashType == model.HashFile && b.Episode == 0 { c.FileHash = b.HashValue break } } out = append(out, c) } } return out, nil } // QueryMappings 查询 MA 码绑定的全部映射与 CDN 端点。 func (m *MemoryChain) QueryMappings(ccCode string) (MappingsResult, error) { m.mu.RLock() defer m.mu.RUnlock() if _, ok := m.contents[ccCode]; !ok { return MappingsResult{}, ErrNotFound } res := MappingsResult{CCCode: ccCode, Mappings: m.mappings[ccCode]} for _, mp := range m.mappings[ccCode] { if mp.CDNEndpoint != "" { res.CDNEndpoints = append(res.CDNEndpoints, mp.CDNEndpoint) } } return res, nil } // RecordVersionChange 记录版本变更。 func (m *MemoryChain) RecordVersionChange(vc model.VersionChange) (string, error) { m.mu.Lock() defer m.mu.Unlock() ccCode := m.ccCodeByCTID(vc.ContentTwinID) if ccCode == "" { return "", ErrMANotIssued } m.versions[ccCode] = append(m.versions[ccCode], vc) return m.nextTx("recordVersionChange"), nil } // Revoke 下架,仅监管主体。 func (m *MemoryChain) Revoke(role Role, ccCode, reason string) (MappingsResult, error) { if role != RoleRegulator { return MappingsResult{}, ErrPermissionDenied } m.mu.Lock() defer m.mu.Unlock() c, ok := m.contents[ccCode] if !ok { return MappingsResult{}, ErrNotFound } c.Status = model.StatusRevoked m.contents[ccCode] = c res := MappingsResult{CCCode: ccCode, Mappings: m.mappings[ccCode]} for _, mp := range m.mappings[ccCode] { if mp.CDNEndpoint != "" { res.CDNEndpoints = append(res.CDNEndpoints, mp.CDNEndpoint) } } return res, nil } // RevokeEpisode 集级下架:只下架指定集,整剧其他集不受影响(仅监管主体)。 func (m *MemoryChain) RevokeEpisode(role Role, ccCode string, episode int, reason string) error { if role != RoleRegulator { return ErrPermissionDenied } m.mu.Lock() defer m.mu.Unlock() bs, ok := m.bindings[ccCode] if !ok { return ErrMANotIssued } found := false for i := range bs { if bs[i].Episode == episode { bs[i].Revoked = true bs[i].RevokedReason = reason found = true } } if !found { return ErrNotFound } m.bindings[ccCode] = bs return nil } // Restore 恢复上架整剧:下架状态恢复为流通中(仅监管主体)。 func (m *MemoryChain) Restore(role Role, ccCode string) error { if role != RoleRegulator { return ErrPermissionDenied } m.mu.Lock() defer m.mu.Unlock() c, ok := m.contents[ccCode] if !ok { return ErrNotFound } c.Status = model.StatusPublished m.contents[ccCode] = c return nil } // RestoreEpisode 恢复上架指定集(仅监管主体)。 func (m *MemoryChain) RestoreEpisode(role Role, ccCode string, episode int) error { if role != RoleRegulator { return ErrPermissionDenied } m.mu.Lock() defer m.mu.Unlock() bs, ok := m.bindings[ccCode] if !ok { return ErrMANotIssued } found := false for i := range bs { if bs[i].Episode == episode { bs[i].Revoked = false bs[i].RevokedReason = "" found = true } } if !found { return ErrNotFound } m.bindings[ccCode] = bs return nil } // SetContentStatus 更新内容状态。 func (m *MemoryChain) SetContentStatus(ccCode, status string) error { m.mu.Lock() defer m.mu.Unlock() c, ok := m.contents[ccCode] if !ok { return ErrNotFound } c.Status = status m.contents[ccCode] = c return nil } // ccCodeByCTID 内部辅助:通过 CTID 反查 MA 码(调用方已持锁)。 func (m *MemoryChain) ccCodeByCTID(ctid string) string { for ma, c := range m.contents { if c.ContentTwinID == ctid { return ma } } return "" } // QueryByHash 根据内容哈希反查标识信息及映射关系。 func (m *MemoryChain) QueryByHash(fileHash string) (model.ContentQueryResult, error) { m.mu.RLock() defer m.mu.RUnlock() ccCode, ok := m.hashIndex[fileHash] if !ok { return model.ContentQueryResult{Found: false}, ErrNotFound } return model.ContentQueryResult{ Found: true, Content: m.contents[ccCode], Bindings: m.bindings[ccCode], Mappings: m.mappings[ccCode], }, nil } // QueryByProvincialCode 根据省级内容编码(CP MediaID)反查标识信息。 func (m *MemoryChain) QueryByProvincialCode(provincialCode string) (model.ContentQueryResult, error) { m.mu.RLock() defer m.mu.RUnlock() for ccCode, maps := range m.mappings { for _, mp := range maps { if mp.Party == model.PartyCP && mp.PartyID == provincialCode { return model.ContentQueryResult{ Found: true, Content: m.contents[ccCode], Bindings: m.bindings[ccCode], Mappings: maps, }, nil } } } return model.ContentQueryResult{Found: false}, ErrNotFound } // QueryByLibraryFileID 根据片库文件 ID(媒资库 ID)反查标识信息。 func (m *MemoryChain) QueryByLibraryFileID(libraryFileID string) (model.ContentQueryResult, error) { m.mu.RLock() defer m.mu.RUnlock() for ccCode, maps := range m.mappings { for _, mp := range maps { if mp.Party == model.PartyReviewer && mp.PartyID == libraryFileID { return model.ContentQueryResult{ Found: true, Content: m.contents[ccCode], Bindings: m.bindings[ccCode], Mappings: maps, }, nil } } } return model.ContentQueryResult{Found: false}, ErrNotFound } // MergeMA 将多个 MA 码合并为一个主 MA 码(仅监管主体)。 // 被合并的 MA 码的哈希绑定和映射迁移至主 MA 码,原 MA 码状态标记为 merged。 func (m *MemoryChain) MergeMA(role Role, req model.MergeRequest) (model.MergeResult, error) { if role != RoleRegulator { return model.MergeResult{}, ErrPermissionDenied } m.mu.Lock() defer m.mu.Unlock() primary, ok := m.contents[req.PrimaryCCCode] if !ok { return model.MergeResult{}, ErrNotFound } _ = primary migratedBindings := 0 migratedMappings := 0 for _, secMA := range req.SecondaryCCCodes { secContent, exists := m.contents[secMA] if !exists { return model.MergeResult{}, fmt.Errorf("%w: secondary MA %s not found", ErrNotFound, secMA) } if secContent.Status == model.StatusMerged { return model.MergeResult{}, fmt.Errorf("chain: MA %s already merged", secMA) } // 迁移哈希绑定至主 MA 码 for _, b := range m.bindings[secMA] { b.ContentTwinID = primary.ContentTwinID m.bindings[req.PrimaryCCCode] = append(m.bindings[req.PrimaryCCCode], b) migratedBindings++ } // 迁移映射至主 MA 码 for _, mp := range m.mappings[secMA] { mp.ContentTwinID = primary.ContentTwinID m.mappings[req.PrimaryCCCode] = append(m.mappings[req.PrimaryCCCode], mp) migratedMappings++ } // 更新哈希索引指向主 MA 码 for _, b := range m.bindings[secMA] { if b.HashType == model.HashFile || b.HashType == model.HashTranscoded { m.hashIndex[b.HashValue] = req.PrimaryCCCode } } // 清空被合并 MA 码的绑定和映射,状态标记为 merged m.bindings[secMA] = nil m.mappings[secMA] = nil secContent.Status = model.StatusMerged m.contents[secMA] = secContent } txID := m.nextTx("mergeMA") return model.MergeResult{ PrimaryCCCode: req.PrimaryCCCode, MergedCCCodes: req.SecondaryCCCodes, MigratedBindings: migratedBindings, MigratedMappings: migratedMappings, TxID: txID, }, nil } // SplitMA 将一个 MA 码拆分为多个独立 MA 码(仅监管主体)。 // 按集号将哈希绑定和映射迁移至新 MA 码,源 MA 码状态标记为 split。 func (m *MemoryChain) SplitMA(role Role, req model.SplitRequest) (model.SplitResult, error) { if role != RoleRegulator { return model.SplitResult{}, ErrPermissionDenied } m.mu.Lock() defer m.mu.Unlock() srcContent, ok := m.contents[req.SourceCCCode] if !ok { return model.SplitResult{}, ErrNotFound } if srcContent.Status == model.StatusMerged || srcContent.Status == model.StatusSplit { return model.SplitResult{}, fmt.Errorf("chain: MA %s already %s", req.SourceCCCode, srcContent.Status) } migratedBindings := 0 migratedMappings := 0 newCCCodes := make([]string, 0, len(req.Splits)) for _, split := range req.Splits { // 创建新内容记录 newContent := srcContent newContent.CCCode = split.NewCCCode newContent.ContentTwinID = split.NewCCCode + "-ctid" newContent.Title = split.Title newContent.Status = model.StatusApproved newContent.CreatedAt = time.Now() m.contents[split.NewCCCode] = newContent // 按集号迁移哈希绑定 for _, b := range m.bindings[req.SourceCCCode] { shouldMigrate := false if len(split.Episodes) == 0 { // 空集号列表:迁移全部(整剧拆分场景) shouldMigrate = true } else { for _, ep := range split.Episodes { if b.Episode == ep { shouldMigrate = true break } } } if shouldMigrate { newB := b newB.ContentTwinID = newContent.ContentTwinID m.bindings[split.NewCCCode] = append(m.bindings[split.NewCCCode], newB) // 更新哈希索引 if b.HashType == model.HashFile || b.HashType == model.HashTranscoded { m.hashIndex[b.HashValue] = split.NewCCCode } migratedBindings++ } } // 迁移映射(全部映射复制到新 MA 码) for _, mp := range m.mappings[req.SourceCCCode] { newMP := mp newMP.ContentTwinID = newContent.ContentTwinID m.mappings[split.NewCCCode] = append(m.mappings[split.NewCCCode], newMP) migratedMappings++ } newCCCodes = append(newCCCodes, split.NewCCCode) } // 源 MA 码状态标记为 split srcContent.Status = model.StatusSplit m.contents[req.SourceCCCode] = srcContent txID := m.nextTx("splitMA") return model.SplitResult{ SourceCCCode: req.SourceCCCode, NewCCCodes: newCCCodes, MigratedBindings: migratedBindings, MigratedMappings: migratedMappings, TxID: txID, }, nil } var _ Client = (*MemoryChain)(nil)