feat: 更新CC码监管方案业务版文档,新增摘要、UGC专项、直播专项、存量处置、误判申诉、水印嵌入、CC码生命周期、组织保障等章节;同步macode到cccode重命名及其他代码变更
This commit is contained in:
@@ -29,12 +29,12 @@ func (h *CatalogHandler) Register(rg *gin.RouterGroup) {
|
||||
}
|
||||
|
||||
func (h *CatalogHandler) queryByMA(c *gin.Context) {
|
||||
maCode := c.Query("ma_code")
|
||||
if maCode == "" {
|
||||
ccCode := c.Query("ma_code")
|
||||
if ccCode == "" {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "缺少 ma_code 参数")
|
||||
return
|
||||
}
|
||||
result, err := h.cat.QueryByMA(maCode)
|
||||
result, err := h.cat.QueryByMA(ccCode)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusNotFound, "NOT_FOUND", err.Error())
|
||||
return
|
||||
@@ -85,12 +85,12 @@ func (h *CatalogHandler) queryByLibraryID(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *CatalogHandler) queryAll(c *gin.Context) {
|
||||
maCode := c.Query("ma_code")
|
||||
if maCode == "" {
|
||||
ccCode := c.Query("ma_code")
|
||||
if ccCode == "" {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "缺少 ma_code 参数")
|
||||
return
|
||||
}
|
||||
result, err := h.cat.QueryAll(maCode)
|
||||
result, err := h.cat.QueryAll(ccCode)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusNotFound, "NOT_FOUND", err.Error())
|
||||
return
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestCatalogHandler_QueryByMA(t *testing.T) {
|
||||
|
||||
// 发码
|
||||
_, err := c.IssueMA(chain.RoleRegulator, chain.IssueRequest{
|
||||
MACode: "MA.156.8531.6101/WD/20260000001", ContentTwinID: "ctid-cat-svc-001",
|
||||
CCCode: "MA.156.8531.6101/WD/20260000001", ContentTwinID: "ctid-cat-svc-001",
|
||||
FileHash: "fh-cat-svc-001", MerkleRoot: "mr-cat-svc-001",
|
||||
Content: model.Content{Title: "目录库服务测试", MAType: "WD", Issuer: "测试局"},
|
||||
})
|
||||
@@ -55,7 +55,7 @@ func TestCatalogHandler_QueryByHash(t *testing.T) {
|
||||
c := chain.NewMemoryChain()
|
||||
|
||||
_, err := c.IssueMA(chain.RoleRegulator, chain.IssueRequest{
|
||||
MACode: "MA.156.8531.6101/WD/20260000002", ContentTwinID: "ctid-cat-svc-002",
|
||||
CCCode: "MA.156.8531.6101/WD/20260000002", ContentTwinID: "ctid-cat-svc-002",
|
||||
FileHash: "fh-cat-svc-002", MerkleRoot: "mr-cat-svc-002",
|
||||
Content: model.Content{Title: "Hash查询服务测试", MAType: "WD", Issuer: "测试局"},
|
||||
})
|
||||
@@ -81,7 +81,7 @@ func TestCatalogHandler_QueryByHash(t *testing.T) {
|
||||
err = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.Data.Found)
|
||||
assert.Equal(t, "MA.156.8531.6101/WD/20260000002", resp.Data.Content.MACode)
|
||||
assert.Equal(t, "MA.156.8531.6101/WD/20260000002", resp.Data.Content.CCCode)
|
||||
|
||||
// 缺少参数
|
||||
w2 := httptest.NewRecorder()
|
||||
|
||||
@@ -44,7 +44,7 @@ func (h *Handler) Register(rg *gin.RouterGroup) {
|
||||
rg.GET("/content/reviews", h.listReviews) // 送审待办队列(待审/待发码)
|
||||
rg.GET("/content/list", h.listContents) // 内容队列(待入库/待发布/待注入)
|
||||
rg.POST("/data/playback", h.reportPlayback) // 播放数据回传(需求9)
|
||||
rg.GET("/data/playback-summary", h.playbackSummary) // 按MA码聚合可信播放数据(需求9/21)
|
||||
rg.GET("/data/playback-summary", h.playbackSummary) // 按CC码聚合可信播放数据(需求9/21)
|
||||
rg.POST("/settlement/compute", h.computeSettlement) // 基于可信播放数据分账(需求21)
|
||||
rg.GET("/content/provenance", h.provenance) // 全链路存证(需求22)
|
||||
rg.GET("/content/accountability", h.accountability) // 责任界定取证(需求22)
|
||||
@@ -147,7 +147,7 @@ func (h *Handler) issue(c *gin.Context) {
|
||||
}
|
||||
|
||||
type verifyReq struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
FileHash string `json:"file_sha256"`
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ func (h *Handler) verify(c *gin.Context) {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
res, err := h.svc.Verify(req.MACode, req.FileHash)
|
||||
res, err := h.svc.Verify(req.CCCode, req.FileHash)
|
||||
if err != nil {
|
||||
// 验真不匹配也返回结果体,便于调用方据 match 处理
|
||||
httpx.Error(c, http.StatusBadRequest, "VERIFY_MISMATCH", err.Error())
|
||||
@@ -210,7 +210,7 @@ func (h *Handler) bindTranscoded(c *gin.Context) {
|
||||
}
|
||||
|
||||
type ingestReq struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
CTID string `json:"content_twin_id"`
|
||||
MediaAssetID string `json:"media_asset_id"`
|
||||
LibName string `json:"lib_name"`
|
||||
@@ -222,15 +222,15 @@ func (h *Handler) ingest(c *gin.Context) {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.svc.IngestToLibrary(roleOf(c), req.MACode, req.CTID, req.MediaAssetID, req.LibName); err != nil {
|
||||
if err := h.svc.IngestToLibrary(roleOf(c), req.CCCode, req.CTID, req.MediaAssetID, req.LibName); err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "INGEST_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, gin.H{"ma_code": req.MACode, "status": "in_library"})
|
||||
httpx.OK(c, gin.H{"ma_code": req.CCCode, "status": "in_library"})
|
||||
}
|
||||
|
||||
type publishReq struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
Certificate string `json:"certificate"`
|
||||
}
|
||||
|
||||
@@ -240,16 +240,16 @@ func (h *Handler) publish(c *gin.Context) {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.svc.PublishToOperator(service.PublishRequest{MACode: req.MACode, Certificate: req.Certificate}); err != nil {
|
||||
if err := h.svc.PublishToOperator(service.PublishRequest{CCCode: req.CCCode, Certificate: req.Certificate}); err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "PUBLISH_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, gin.H{"ma_code": req.MACode, "status": "published"})
|
||||
httpx.OK(c, gin.H{"ma_code": req.CCCode, "status": "published"})
|
||||
}
|
||||
|
||||
type injectReq struct {
|
||||
CTID string `json:"content_twin_id"`
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
FileHash string `json:"file_sha256"`
|
||||
OperatorID string `json:"operator_id"`
|
||||
CDNEndpoint string `json:"cdn_endpoint"`
|
||||
@@ -261,7 +261,7 @@ func (h *Handler) inject(c *gin.Context) {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
res, err := h.svc.InjectToCDN(roleOf(c), req.CTID, req.MACode, req.FileHash, req.OperatorID, req.CDNEndpoint)
|
||||
res, err := h.svc.InjectToCDN(roleOf(c), req.CTID, req.CCCode, req.FileHash, req.OperatorID, req.CDNEndpoint)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "INJECT_REJECTED", err.Error())
|
||||
return
|
||||
@@ -293,7 +293,7 @@ func (h *Handler) versionChange(c *gin.Context) {
|
||||
}
|
||||
|
||||
type takedownReq struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
@@ -303,7 +303,7 @@ func (h *Handler) takedown(c *gin.Context) {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
res, err := h.svc.Takedown(roleOf(c), req.MACode, req.Reason)
|
||||
res, err := h.svc.Takedown(roleOf(c), req.CCCode, req.Reason)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusForbidden, "TAKEDOWN_FAILED", err.Error())
|
||||
return
|
||||
@@ -312,7 +312,7 @@ func (h *Handler) takedown(c *gin.Context) {
|
||||
}
|
||||
|
||||
type takedownEpisodeReq struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
Episode int `json:"episode"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
@@ -323,11 +323,11 @@ func (h *Handler) takedownEpisode(c *gin.Context) {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.svc.TakedownEpisode(roleOf(c), req.MACode, req.Episode, req.Reason); err != nil {
|
||||
if err := h.svc.TakedownEpisode(roleOf(c), req.CCCode, req.Episode, req.Reason); err != nil {
|
||||
httpx.Error(c, http.StatusForbidden, "TAKEDOWN_EPISODE_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, gin.H{"ma_code": req.MACode, "episode": req.Episode, "revoked": true})
|
||||
httpx.OK(c, gin.H{"ma_code": req.CCCode, "episode": req.Episode, "revoked": true})
|
||||
}
|
||||
|
||||
func (h *Handler) restore(c *gin.Context) {
|
||||
@@ -336,11 +336,11 @@ func (h *Handler) restore(c *gin.Context) {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.svc.Restore(roleOf(c), req.MACode); err != nil {
|
||||
if err := h.svc.Restore(roleOf(c), req.CCCode); err != nil {
|
||||
httpx.Error(c, http.StatusForbidden, "RESTORE_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, gin.H{"ma_code": req.MACode, "status": "published"})
|
||||
httpx.OK(c, gin.H{"ma_code": req.CCCode, "status": "published"})
|
||||
}
|
||||
|
||||
func (h *Handler) restoreEpisode(c *gin.Context) {
|
||||
@@ -349,20 +349,20 @@ func (h *Handler) restoreEpisode(c *gin.Context) {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.svc.RestoreEpisode(roleOf(c), req.MACode, req.Episode); err != nil {
|
||||
if err := h.svc.RestoreEpisode(roleOf(c), req.CCCode, req.Episode); err != nil {
|
||||
httpx.Error(c, http.StatusForbidden, "RESTORE_EPISODE_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, gin.H{"ma_code": req.MACode, "episode": req.Episode, "revoked": false})
|
||||
httpx.OK(c, gin.H{"ma_code": req.CCCode, "episode": req.Episode, "revoked": false})
|
||||
}
|
||||
|
||||
func (h *Handler) mappings(c *gin.Context) {
|
||||
maCode := c.Query("ma_code")
|
||||
if maCode == "" {
|
||||
ccCode := c.Query("ma_code")
|
||||
if ccCode == "" {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "缺少 ma_code")
|
||||
return
|
||||
}
|
||||
res, err := h.svc.QueryMappings(maCode)
|
||||
res, err := h.svc.QueryMappings(ccCode)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusNotFound, "NOT_FOUND", err.Error())
|
||||
return
|
||||
@@ -371,7 +371,7 @@ func (h *Handler) mappings(c *gin.Context) {
|
||||
}
|
||||
|
||||
type verifyEpisodeReq struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
Episode int `json:"episode"`
|
||||
FileHash string `json:"file_sha256"`
|
||||
}
|
||||
@@ -382,7 +382,7 @@ func (h *Handler) verifyEpisode(c *gin.Context) {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
res, err := h.svc.VerifyEpisode(req.MACode, req.Episode, req.FileHash)
|
||||
res, err := h.svc.VerifyEpisode(req.CCCode, req.Episode, req.FileHash)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "VERIFY_MISMATCH", err.Error())
|
||||
return
|
||||
@@ -391,17 +391,17 @@ func (h *Handler) verifyEpisode(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *Handler) listEpisodes(c *gin.Context) {
|
||||
maCode := c.Query("ma_code")
|
||||
if maCode == "" {
|
||||
ccCode := c.Query("ma_code")
|
||||
if ccCode == "" {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "缺少 ma_code")
|
||||
return
|
||||
}
|
||||
eps, err := h.svc.ListEpisodes(maCode)
|
||||
eps, err := h.svc.ListEpisodes(ccCode)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusNotFound, "NOT_FOUND", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, gin.H{"ma_code": maCode, "episodes": eps, "count": len(eps)})
|
||||
httpx.OK(c, gin.H{"ma_code": ccCode, "episodes": eps, "count": len(eps)})
|
||||
}
|
||||
|
||||
func (h *Handler) listReviews(c *gin.Context) {
|
||||
@@ -422,7 +422,7 @@ func (h *Handler) listContents(c *gin.Context) {
|
||||
type playbackReq struct {
|
||||
PlatformID string `json:"platform_id"`
|
||||
Batch []struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
Episode int `json:"episode"`
|
||||
UserHash string `json:"user_hash"`
|
||||
EventType string `json:"event_type"`
|
||||
@@ -440,7 +440,7 @@ func (h *Handler) reportPlayback(c *gin.Context) {
|
||||
events := make([]model.PlaybackEvent, 0, len(req.Batch))
|
||||
for _, b := range req.Batch {
|
||||
events = append(events, model.PlaybackEvent{
|
||||
MACode: b.MACode, Episode: b.Episode, PlatformID: req.PlatformID,
|
||||
CCCode: b.CCCode, Episode: b.Episode, PlatformID: req.PlatformID,
|
||||
UserHash: b.UserHash, EventType: model.PlaybackEventType(b.EventType),
|
||||
DurationSec: b.DurationSec, RevenueCent: b.RevenueCent, EventTime: time.Now(),
|
||||
})
|
||||
@@ -450,16 +450,16 @@ func (h *Handler) reportPlayback(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *Handler) playbackSummary(c *gin.Context) {
|
||||
maCode := c.Query("ma_code")
|
||||
if maCode == "" {
|
||||
ccCode := c.Query("ma_code")
|
||||
if ccCode == "" {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "缺少 ma_code")
|
||||
return
|
||||
}
|
||||
httpx.OK(c, h.svc.PlaybackSummary(maCode))
|
||||
httpx.OK(c, h.svc.PlaybackSummary(ccCode))
|
||||
}
|
||||
|
||||
type settlementReq struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
Period string `json:"period"`
|
||||
}
|
||||
|
||||
@@ -469,7 +469,7 @@ func (h *Handler) computeSettlement(c *gin.Context) {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
st, err := h.svc.ComputeSettlement(req.MACode, req.Period)
|
||||
st, err := h.svc.ComputeSettlement(req.CCCode, req.Period)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "SETTLEMENT_FAILED", err.Error())
|
||||
return
|
||||
@@ -480,30 +480,30 @@ func (h *Handler) computeSettlement(c *gin.Context) {
|
||||
// ---- 二期:追责取证与确权举证(需求22/23) ----
|
||||
|
||||
func (h *Handler) provenance(c *gin.Context) {
|
||||
maCode := c.Query("ma_code")
|
||||
if maCode == "" {
|
||||
ccCode := c.Query("ma_code")
|
||||
if ccCode == "" {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "缺少 ma_code")
|
||||
return
|
||||
}
|
||||
httpx.OK(c, gin.H{"ma_code": maCode, "trail": h.svc.Provenance(maCode)})
|
||||
httpx.OK(c, gin.H{"ma_code": ccCode, "trail": h.svc.Provenance(ccCode)})
|
||||
}
|
||||
|
||||
func (h *Handler) accountability(c *gin.Context) {
|
||||
maCode := c.Query("ma_code")
|
||||
if maCode == "" {
|
||||
ccCode := c.Query("ma_code")
|
||||
if ccCode == "" {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "缺少 ma_code")
|
||||
return
|
||||
}
|
||||
httpx.OK(c, h.svc.Accountability(maCode))
|
||||
httpx.OK(c, h.svc.Accountability(ccCode))
|
||||
}
|
||||
|
||||
func (h *Handler) evidence(c *gin.Context) {
|
||||
maCode := c.Query("ma_code")
|
||||
if maCode == "" {
|
||||
ccCode := c.Query("ma_code")
|
||||
if ccCode == "" {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "缺少 ma_code")
|
||||
return
|
||||
}
|
||||
ev, err := h.svc.CopyrightEvidence(maCode)
|
||||
ev, err := h.svc.CopyrightEvidence(ccCode)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusNotFound, "NOT_FOUND", err.Error())
|
||||
return
|
||||
@@ -540,7 +540,7 @@ func (h *Handler) infringeMatch(c *gin.Context) {
|
||||
// ---- 二期:授权链/追更/跨省/终端抽检(需求25/24/13/8) ----
|
||||
|
||||
type authorizeReq struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
Regions []string `json:"regions"`
|
||||
Platforms []string `json:"platforms"`
|
||||
ExpiryAt string `json:"expiry_at"` // RFC3339,空=长期
|
||||
@@ -556,15 +556,15 @@ func (h *Handler) authorize(c *gin.Context) {
|
||||
if req.ExpiryAt != "" {
|
||||
expiry, _ = time.Parse(time.RFC3339, req.ExpiryAt)
|
||||
}
|
||||
if err := h.svc.RecordAuthorization(req.MACode, req.Regions, req.Platforms, expiry); err != nil {
|
||||
if err := h.svc.RecordAuthorization(req.CCCode, req.Regions, req.Platforms, expiry); err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "AUTHORIZE_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, gin.H{"ma_code": req.MACode, "authorized": true})
|
||||
httpx.OK(c, gin.H{"ma_code": req.CCCode, "authorized": true})
|
||||
}
|
||||
|
||||
type authCheckReq struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
Region string `json:"region"`
|
||||
Platform string `json:"platform"`
|
||||
}
|
||||
@@ -575,11 +575,11 @@ func (h *Handler) authCheck(c *gin.Context) {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, h.svc.CheckAuthorization(req.MACode, req.Region, req.Platform))
|
||||
httpx.OK(c, h.svc.CheckAuthorization(req.CCCode, req.Region, req.Platform))
|
||||
}
|
||||
|
||||
type addEpisodesReq struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
Episodes []struct {
|
||||
Episode int `json:"episode"`
|
||||
FileSHA256 string `json:"file_sha256"`
|
||||
@@ -597,15 +597,15 @@ func (h *Handler) addEpisodes(c *gin.Context) {
|
||||
for _, e := range req.Episodes {
|
||||
eps = append(eps, model.EpisodeHash{Episode: e.Episode, FileSHA256: e.FileSHA256, MerkleRoot: e.MerkleRoot})
|
||||
}
|
||||
if err := h.svc.AddEpisodes(roleOf(c), req.MACode, eps); err != nil {
|
||||
if err := h.svc.AddEpisodes(roleOf(c), req.CCCode, eps); err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "ADD_EPISODES_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, gin.H{"ma_code": req.MACode, "added": len(eps)})
|
||||
httpx.OK(c, gin.H{"ma_code": req.CCCode, "added": len(eps)})
|
||||
}
|
||||
|
||||
type crossProvinceReq struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
FileHash string `json:"file_sha256"`
|
||||
Province string `json:"province"`
|
||||
}
|
||||
@@ -616,11 +616,11 @@ func (h *Handler) crossProvince(c *gin.Context) {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, h.svc.CrossProvinceAdmit(req.MACode, req.FileHash, req.Province))
|
||||
httpx.OK(c, h.svc.CrossProvinceAdmit(req.CCCode, req.FileHash, req.Province))
|
||||
}
|
||||
|
||||
type terminalVerifyReq struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
Episode int `json:"episode"`
|
||||
SegHash string `json:"segment_hash"`
|
||||
}
|
||||
@@ -631,14 +631,14 @@ func (h *Handler) terminalVerify(c *gin.Context) {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
ok, msg := h.svc.TerminalVerifySegment(req.MACode, req.Episode, req.SegHash)
|
||||
ok, msg := h.svc.TerminalVerifySegment(req.CCCode, req.Episode, req.SegHash)
|
||||
httpx.OK(c, gin.H{"ok": ok, "message": msg})
|
||||
}
|
||||
|
||||
// ---- 三期:备案对接/全国统计/监管上报/号段管理 ----
|
||||
|
||||
type bindFilingReq struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
LicenseNo string `json:"license_no"`
|
||||
FilingNo string `json:"filing_no"`
|
||||
}
|
||||
@@ -649,7 +649,7 @@ func (h *Handler) bindFiling(c *gin.Context) {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
rec, err := h.svc.BindFiling(req.MACode, req.LicenseNo, req.FilingNo)
|
||||
rec, err := h.svc.BindFiling(req.CCCode, req.LicenseNo, req.FilingNo)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "BIND_FILING_FAILED", err.Error())
|
||||
return
|
||||
@@ -658,8 +658,8 @@ func (h *Handler) bindFiling(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *Handler) queryFiling(c *gin.Context) {
|
||||
maCode := c.Query("ma_code")
|
||||
rec, ok := h.svc.QueryFiling(maCode)
|
||||
ccCode := c.Query("ma_code")
|
||||
rec, ok := h.svc.QueryFiling(ccCode)
|
||||
if !ok {
|
||||
httpx.Error(c, http.StatusNotFound, "NOT_FOUND", "未关联备案")
|
||||
return
|
||||
@@ -696,16 +696,16 @@ func (h *Handler) listSegments(c *gin.Context) {
|
||||
// ---- 四期:大小屏融合(跨域解析/扫码验真/跨屏权益)----
|
||||
|
||||
func (h *Handler) resolve(c *gin.Context) {
|
||||
maCode := c.Query("ma_code")
|
||||
if maCode == "" {
|
||||
ccCode := c.Query("ma_code")
|
||||
if ccCode == "" {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "缺少 ma_code")
|
||||
return
|
||||
}
|
||||
httpx.OK(c, h.svc.Resolve(maCode))
|
||||
httpx.OK(c, h.svc.Resolve(ccCode))
|
||||
}
|
||||
|
||||
type scanVerifyReq struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
}
|
||||
|
||||
func (h *Handler) scanVerify(c *gin.Context) {
|
||||
@@ -714,11 +714,11 @@ func (h *Handler) scanVerify(c *gin.Context) {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, h.svc.ScanVerify(req.MACode))
|
||||
httpx.OK(c, h.svc.ScanVerify(req.CCCode))
|
||||
}
|
||||
|
||||
type purchaseReq struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
UserHash string `json:"user_hash"`
|
||||
Screen string `json:"screen"` // iptv/ott/app
|
||||
}
|
||||
@@ -729,7 +729,7 @@ func (h *Handler) recordPurchase(c *gin.Context) {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
rec, err := h.svc.RecordPurchase(req.MACode, req.UserHash, model.ScreenType(req.Screen))
|
||||
rec, err := h.svc.RecordPurchase(req.CCCode, req.UserHash, model.ScreenType(req.Screen))
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "PURCHASE_FAILED", err.Error())
|
||||
return
|
||||
@@ -738,7 +738,7 @@ func (h *Handler) recordPurchase(c *gin.Context) {
|
||||
}
|
||||
|
||||
type verifyRightsReq struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
UserHash string `json:"user_hash"`
|
||||
Screen string `json:"screen"` // 当前请求屏
|
||||
}
|
||||
@@ -749,7 +749,7 @@ func (h *Handler) verifyRights(c *gin.Context) {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, h.svc.VerifyCrossScreenRights(req.MACode, req.UserHash, model.ScreenType(req.Screen)))
|
||||
httpx.OK(c, h.svc.VerifyCrossScreenRights(req.CCCode, req.UserHash, model.ScreenType(req.Screen)))
|
||||
}
|
||||
|
||||
// ---- 多维度标识查询 handlers ----
|
||||
@@ -808,7 +808,7 @@ func (h *Handler) mergeMA(c *gin.Context) {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
res, err := h.svc.MergeMACodes(roleOf(c), req)
|
||||
res, err := h.svc.MergeCCCodes(roleOf(c), req)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "MERGE_FAILED", err.Error())
|
||||
return
|
||||
@@ -823,7 +823,7 @@ func (h *Handler) splitMA(c *gin.Context) {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
res, err := h.svc.SplitMACode(roleOf(c), req)
|
||||
res, err := h.svc.SplitCCCode(roleOf(c), req)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "SPLIT_FAILED", err.Error())
|
||||
return
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/tcs-iptv/tcs/internal/chain"
|
||||
"github.com/tcs-iptv/tcs/internal/hash"
|
||||
"github.com/tcs-iptv/tcs/internal/httpx"
|
||||
"github.com/tcs-iptv/tcs/internal/macode"
|
||||
"github.com/tcs-iptv/tcs/internal/cccode"
|
||||
"github.com/tcs-iptv/tcs/internal/service"
|
||||
)
|
||||
|
||||
@@ -24,10 +24,10 @@ func testServer(t *testing.T) (*httptest.Server, *httpx.MemoryKeyStore) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
ch := chain.NewMemoryChain()
|
||||
gen := macode.NewGenerator(macode.NewMemoryStore())
|
||||
require.NoError(t, gen.RegisterSegment(macode.Segment{
|
||||
gen := cccode.NewGenerator(cccode.NewMemoryStore())
|
||||
require.NoError(t, gen.RegisterSegment(cccode.Segment{
|
||||
IndustryNode: "8531", OrgNode: "4401",
|
||||
Category: macode.CategoryMicroDrama, Start: 1, End: 9999999, SeqWidth: 7,
|
||||
Category: cccode.CategoryMicroDrama, Start: 1, End: 9999999, SeqWidth: 7,
|
||||
}))
|
||||
svc := service.New(ch, gen)
|
||||
h := NewHandler(svc)
|
||||
@@ -115,39 +115,39 @@ func TestE2E_FullLifecycle(t *testing.T) {
|
||||
"review_id": reviewID, "issuer": "北京市广播电视局",
|
||||
})
|
||||
require.Equal(t, http.StatusOK, st)
|
||||
maCode := dataOf(resp)["ma_code"].(string)
|
||||
ccCode := dataOf(resp)["ma_code"].(string)
|
||||
cert := dataOf(resp)["certificate"].(string)
|
||||
assert.True(t, macode.IsValid(maCode))
|
||||
assert.True(t, cccode.IsValid(ccCode))
|
||||
|
||||
// 4) 入媒资库
|
||||
st, _ = signedCall(t, b, "ak-reviewer", "sk-reviewer", "POST", "/content/ingest", map[string]any{
|
||||
"ma_code": maCode, "content_twin_id": ctid, "media_asset_id": "MEDIA-001", "lib_name": "广东IPTV媒资库",
|
||||
"ma_code": ccCode, "content_twin_id": ctid, "media_asset_id": "MEDIA-001", "lib_name": "广东IPTV媒资库",
|
||||
})
|
||||
require.Equal(t, http.StatusOK, st)
|
||||
|
||||
// 5) 发布
|
||||
st, _ = signedCall(t, b, "ak-reviewer", "sk-reviewer", "POST", "/content/publish", map[string]any{
|
||||
"ma_code": maCode, "certificate": cert,
|
||||
"ma_code": ccCode, "certificate": cert,
|
||||
})
|
||||
require.Equal(t, http.StatusOK, st)
|
||||
|
||||
// 6) CDN 注入(匹配)
|
||||
st, resp = signedCall(t, b, "ak-operator", "sk-operator", "POST", "/content/inject", map[string]any{
|
||||
"content_twin_id": ctid, "ma_code": maCode, "file_sha256": "fh-e2e",
|
||||
"content_twin_id": ctid, "ma_code": ccCode, "file_sha256": "fh-e2e",
|
||||
"operator_id": "CT-IPTV-GD", "cdn_endpoint": "cdn://ct-gd/vod/1",
|
||||
})
|
||||
require.Equal(t, http.StatusOK, st)
|
||||
assert.Equal(t, true, dataOf(resp)["allowed"])
|
||||
|
||||
// 7) 映射查询
|
||||
st, resp = signedCall(t, b, "ak-regulator", "sk-regulator", "GET", "/content/mappings?ma_code="+maCode, nil)
|
||||
st, resp = signedCall(t, b, "ak-regulator", "sk-regulator", "GET", "/content/mappings?ma_code="+ccCode, nil)
|
||||
require.Equal(t, http.StatusOK, st)
|
||||
mappings := dataOf(resp)["mappings"].([]any)
|
||||
assert.Len(t, mappings, 3) // cp + reviewer + operator
|
||||
|
||||
// 8) 监管下架
|
||||
st, resp = signedCall(t, b, "ak-regulator", "sk-regulator", "POST", "/content/takedown", map[string]any{
|
||||
"ma_code": maCode, "reason": "违规",
|
||||
"ma_code": ccCode, "reason": "违规",
|
||||
})
|
||||
require.Equal(t, http.StatusOK, st)
|
||||
assert.NotEmpty(t, dataOf(resp)["cdn_endpoints"])
|
||||
@@ -171,14 +171,14 @@ func TestE2E_TamperRejected(t *testing.T) {
|
||||
_, resp = signedCall(t, b, "ak-regulator", "sk-regulator", "POST", "/content/issue", map[string]any{
|
||||
"review_id": reviewID, "issuer": "x",
|
||||
})
|
||||
maCode := dataOf(resp)["ma_code"].(string)
|
||||
ccCode := dataOf(resp)["ma_code"].(string)
|
||||
cert := dataOf(resp)["certificate"].(string)
|
||||
signedCall(t, b, "ak-reviewer", "sk-reviewer", "POST", "/content/ingest", map[string]any{"ma_code": maCode, "content_twin_id": ctid, "media_asset_id": "M", "lib_name": "L"})
|
||||
signedCall(t, b, "ak-reviewer", "sk-reviewer", "POST", "/content/publish", map[string]any{"ma_code": maCode, "certificate": cert})
|
||||
signedCall(t, b, "ak-reviewer", "sk-reviewer", "POST", "/content/ingest", map[string]any{"ma_code": ccCode, "content_twin_id": ctid, "media_asset_id": "M", "lib_name": "L"})
|
||||
signedCall(t, b, "ak-reviewer", "sk-reviewer", "POST", "/content/publish", map[string]any{"ma_code": ccCode, "certificate": cert})
|
||||
|
||||
// 篡改文件注入 → 拒绝
|
||||
st, _ = signedCall(t, b, "ak-operator", "sk-operator", "POST", "/content/inject", map[string]any{
|
||||
"content_twin_id": ctid, "ma_code": maCode, "file_sha256": "fh-TAMPERED",
|
||||
"content_twin_id": ctid, "ma_code": ccCode, "file_sha256": "fh-TAMPERED",
|
||||
"operator_id": "OP", "cdn_endpoint": "cdn://x",
|
||||
})
|
||||
assert.Equal(t, http.StatusBadRequest, st, "篡改注入必须被拒")
|
||||
@@ -235,14 +235,14 @@ func TestE2E_PermissionMatrix(t *testing.T) {
|
||||
|
||||
// 正常签发
|
||||
_, resp = signedCall(t, b, "ak-regulator", "sk-regulator", "POST", "/content/issue", map[string]any{"review_id": reviewID, "issuer": "x"})
|
||||
maCode := dataOf(resp)["ma_code"].(string)
|
||||
ccCode := dataOf(resp)["ma_code"].(string)
|
||||
|
||||
// 运营商越权下架 → 403
|
||||
st, _ = signedCall(t, b, "ak-operator", "sk-operator", "POST", "/content/takedown", map[string]any{"ma_code": maCode, "reason": "越权"})
|
||||
st, _ = signedCall(t, b, "ak-operator", "sk-operator", "POST", "/content/takedown", map[string]any{"ma_code": ccCode, "reason": "越权"})
|
||||
assert.Equal(t, http.StatusForbidden, st, "运营商不得发起下架")
|
||||
|
||||
// 无效签名 → 401
|
||||
req, _ := http.NewRequest("GET", b+"/api/v1/content/mappings?ma_code="+maCode, nil)
|
||||
req, _ := http.NewRequest("GET", b+"/api/v1/content/mappings?ma_code="+ccCode, nil)
|
||||
req.Header.Set("Authorization", "TCS ak-regulator:badsig")
|
||||
r, _ := http.DefaultClient.Do(req)
|
||||
assert.Equal(t, http.StatusUnauthorized, r.StatusCode, "错误签名应 401")
|
||||
@@ -261,10 +261,10 @@ func TestE2E_TakedownLatency(t *testing.T) {
|
||||
reviewID := dataOf(resp)["review_id"].(string)
|
||||
signedCall(t, b, "ak-reviewer", "sk-reviewer", "POST", "/content/csps-result", map[string]any{"review_id": reviewID, "approved": true})
|
||||
_, resp = signedCall(t, b, "ak-regulator", "sk-regulator", "POST", "/content/issue", map[string]any{"review_id": reviewID, "issuer": "x"})
|
||||
maCode := dataOf(resp)["ma_code"].(string)
|
||||
ccCode := dataOf(resp)["ma_code"].(string)
|
||||
|
||||
start := time.Now()
|
||||
st, _ := signedCall(t, b, "ak-regulator", "sk-regulator", "POST", "/content/takedown", map[string]any{"ma_code": maCode, "reason": "违规"})
|
||||
st, _ := signedCall(t, b, "ak-regulator", "sk-regulator", "POST", "/content/takedown", map[string]any{"ma_code": ccCode, "reason": "违规"})
|
||||
elapsed := time.Since(start)
|
||||
require.Equal(t, http.StatusOK, st)
|
||||
assert.Less(t, elapsed, time.Second, "下架端到端应在秒级内(目标分钟级)")
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
)
|
||||
|
||||
// Catalog 目录库查询服务。
|
||||
// 提供四种维度的标识信息查询:MA码、Hash、省级编码、片库文件ID。
|
||||
// 提供四种维度的标识信息查询:CC码、Hash、省级编码、片库文件ID。
|
||||
type Catalog struct {
|
||||
client chain.Client
|
||||
store CatalogStore // 可选独立存储(为 nil 时委托 chain.Client)
|
||||
@@ -39,14 +39,14 @@ func NewWithStore(c chain.Client, s CatalogStore) *Catalog {
|
||||
return &Catalog{client: c, store: s}
|
||||
}
|
||||
|
||||
// QueryByMA 根据MA码查询标识信息及映射关系。
|
||||
func (cat *Catalog) QueryByMA(maCode string) (model.ContentQueryResult, error) {
|
||||
c, err := cat.client.QueryContent(maCode)
|
||||
// QueryByMA 根据CC码查询标识信息及映射关系。
|
||||
func (cat *Catalog) QueryByMA(ccCode string) (model.ContentQueryResult, error) {
|
||||
c, err := cat.client.QueryContent(ccCode)
|
||||
if err != nil {
|
||||
return model.ContentQueryResult{Found: false}, err
|
||||
}
|
||||
mr, _ := cat.client.QueryMappings(maCode)
|
||||
eps, _ := cat.client.ListEpisodes(maCode)
|
||||
mr, _ := cat.client.QueryMappings(ccCode)
|
||||
eps, _ := cat.client.ListEpisodes(ccCode)
|
||||
bindings := eps
|
||||
return model.ContentQueryResult{
|
||||
Found: true,
|
||||
@@ -81,6 +81,6 @@ func (cat *Catalog) QueryByLibraryFileID(libraryID string) (model.ContentQueryRe
|
||||
}
|
||||
|
||||
// QueryAll 批量查询:按 MA 码返回全部关联信息(含绑定、映射、集级哈希)。
|
||||
func (cat *Catalog) QueryAll(maCode string) (model.ContentQueryResult, error) {
|
||||
return cat.QueryByMA(maCode)
|
||||
func (cat *Catalog) QueryAll(ccCode string) (model.ContentQueryResult, error) {
|
||||
return cat.QueryByMA(ccCode)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ func TestCatalog_QueryByMA(t *testing.T) {
|
||||
|
||||
// 发码
|
||||
_, err := c.IssueMA(chain.RoleRegulator, chain.IssueRequest{
|
||||
MACode: "MA.156.8531.6101/WD/20260000001", ContentTwinID: "ctid-cat-001",
|
||||
CCCode: "MA.156.8531.6101/WD/20260000001", ContentTwinID: "ctid-cat-001",
|
||||
FileHash: "fh-cat-001", MerkleRoot: "mr-cat-001",
|
||||
Content: model.Content{Title: "目录库测试剧", EpisodeCount: 2, MAType: "WD", Issuer: "测试局"},
|
||||
})
|
||||
@@ -33,7 +33,7 @@ func TestCatalog_QueryByHash(t *testing.T) {
|
||||
cat := New(c)
|
||||
|
||||
_, err := c.IssueMA(chain.RoleRegulator, chain.IssueRequest{
|
||||
MACode: "MA.156.8531.6101/WD/20260000002", ContentTwinID: "ctid-cat-002",
|
||||
CCCode: "MA.156.8531.6101/WD/20260000002", ContentTwinID: "ctid-cat-002",
|
||||
FileHash: "fh-cat-002", MerkleRoot: "mr-cat-002",
|
||||
Content: model.Content{Title: "Hash查询测试", MAType: "WD", Issuer: "测试局"},
|
||||
})
|
||||
@@ -43,7 +43,7 @@ func TestCatalog_QueryByHash(t *testing.T) {
|
||||
res, err := cat.QueryByHash("fh-cat-002")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Found)
|
||||
assert.Equal(t, "MA.156.8531.6101/WD/20260000002", res.Content.MACode)
|
||||
assert.Equal(t, "MA.156.8531.6101/WD/20260000002", res.Content.CCCode)
|
||||
|
||||
// 不存在的 Hash
|
||||
_, err = cat.QueryByHash("nonexistent")
|
||||
@@ -55,7 +55,7 @@ func TestCatalog_QueryByProvincialCode(t *testing.T) {
|
||||
cat := New(c)
|
||||
|
||||
_, err := c.IssueMA(chain.RoleRegulator, chain.IssueRequest{
|
||||
MACode: "MA.156.8531.6101/WD/20260000003", ContentTwinID: "ctid-cat-003",
|
||||
CCCode: "MA.156.8531.6101/WD/20260000003", ContentTwinID: "ctid-cat-003",
|
||||
FileHash: "fh-cat-003", MerkleRoot: "mr-cat-003",
|
||||
Content: model.Content{Title: "省级编码查询测试", MAType: "WD", Issuer: "测试局"},
|
||||
})
|
||||
@@ -71,7 +71,7 @@ func TestCatalog_QueryByProvincialCode(t *testing.T) {
|
||||
res, err := cat.QueryByProvincialCode("PROV-CAT-001")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Found)
|
||||
assert.Equal(t, "MA.156.8531.6101/WD/20260000003", res.Content.MACode)
|
||||
assert.Equal(t, "MA.156.8531.6101/WD/20260000003", res.Content.CCCode)
|
||||
}
|
||||
|
||||
func TestCatalog_QueryByLibraryFileID(t *testing.T) {
|
||||
@@ -79,7 +79,7 @@ func TestCatalog_QueryByLibraryFileID(t *testing.T) {
|
||||
cat := New(c)
|
||||
|
||||
_, err := c.IssueMA(chain.RoleRegulator, chain.IssueRequest{
|
||||
MACode: "MA.156.8531.6101/WD/20260000004", ContentTwinID: "ctid-cat-004",
|
||||
CCCode: "MA.156.8531.6101/WD/20260000004", ContentTwinID: "ctid-cat-004",
|
||||
FileHash: "fh-cat-004", MerkleRoot: "mr-cat-004",
|
||||
Content: model.Content{Title: "片库ID查询测试", MAType: "WD", Issuer: "测试局"},
|
||||
})
|
||||
@@ -95,5 +95,5 @@ func TestCatalog_QueryByLibraryFileID(t *testing.T) {
|
||||
res, err := cat.QueryByLibraryFileID("LIB-CAT-001")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Found)
|
||||
assert.Equal(t, "MA.156.8531.6101/WD/20260000004", res.Content.MACode)
|
||||
assert.Equal(t, "MA.156.8531.6101/WD/20260000004", res.Content.CCCode)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Package macode 实现 MA 码生成服务(模式B:自行发码)。
|
||||
// Package cccode 实现 MA 码生成服务(模式B:自行发码)。
|
||||
// TCS 与 MA 发码机构合作获取「码段(号段)」与「备案规则」,在本地按规则原子发码。
|
||||
// 对应需求:需求3(MA码签发)、需求16;与 ISO/IEC 15459 MA 标识体系对齐。
|
||||
// 对应需求:需求3(CC码签发)、需求16;与 ISO/IEC 15459 MA 标识体系对齐。
|
||||
//
|
||||
// MA 码结构(六段式,可由备案规则配置):
|
||||
//
|
||||
@@ -14,7 +14,7 @@
|
||||
// - WD 内容类目(WD=微短剧 / WJ=网络剧 / DY=网络电影 / DH=网络动画)
|
||||
// - yyyy 年份
|
||||
// - sequence 号段内递增序列(按位补零)
|
||||
package macode
|
||||
package cccode
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -25,9 +25,9 @@ import (
|
||||
|
||||
// 错误定义。
|
||||
var (
|
||||
ErrSegmentExhausted = errors.New("macode: code segment exhausted")
|
||||
ErrUnknownCategory = errors.New("macode: unknown content category")
|
||||
ErrInvalidSegment = errors.New("macode: invalid segment range")
|
||||
ErrSegmentExhausted = errors.New("cccode: code segment exhausted")
|
||||
ErrUnknownCategory = errors.New("cccode: unknown content category")
|
||||
ErrInvalidSegment = errors.New("cccode: invalid segment range")
|
||||
)
|
||||
|
||||
// 内容类目码。
|
||||
@@ -108,7 +108,7 @@ func segmentKey(s Segment) string {
|
||||
|
||||
// Issued 一次发码结果。
|
||||
type Issued struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
IndustryNode string `json:"industry_node"`
|
||||
OrgNode string `json:"org_node"`
|
||||
Category string `json:"category"`
|
||||
@@ -133,7 +133,7 @@ func (g *Generator) Allocate(category string) (Issued, error) {
|
||||
year := g.clock().Year()
|
||||
code := Format(seg, year, seq)
|
||||
return Issued{
|
||||
MACode: code,
|
||||
CCCode: code,
|
||||
IndustryNode: seg.IndustryNode,
|
||||
OrgNode: seg.OrgNode,
|
||||
Category: category,
|
||||
@@ -1,4 +1,4 @@
|
||||
package macode
|
||||
package cccode
|
||||
|
||||
import (
|
||||
"sync"
|
||||
@@ -30,9 +30,9 @@ func TestAllocate_SequentialUnique(t *testing.T) {
|
||||
for i := 0; i < 50; i++ {
|
||||
issued, err := g.Allocate(CategoryMicroDrama)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, seen[issued.MACode], "MA 码必须唯一: %s", issued.MACode)
|
||||
seen[issued.MACode] = true
|
||||
assert.True(t, IsValid(issued.MACode), "应符合格式: %s", issued.MACode)
|
||||
assert.False(t, seen[issued.CCCode], "MA 码必须唯一: %s", issued.CCCode)
|
||||
seen[issued.CCCode] = true
|
||||
assert.True(t, IsValid(issued.CCCode), "应符合格式: %s", issued.CCCode)
|
||||
}
|
||||
assert.Len(t, seen, 50)
|
||||
}
|
||||
@@ -41,7 +41,7 @@ func TestAllocate_FormatCorrect(t *testing.T) {
|
||||
g := newGen(t)
|
||||
issued, err := g.Allocate(CategoryMicroDrama)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "MA.156.8531.4401/WD/20250000001", issued.MACode)
|
||||
assert.Equal(t, "MA.156.8531.4401/WD/20250000001", issued.CCCode)
|
||||
assert.Equal(t, uint64(1), issued.Sequence)
|
||||
assert.Equal(t, 2025, issued.Year)
|
||||
}
|
||||
@@ -88,10 +88,10 @@ func TestAllocate_ConcurrentNoDuplicate(t *testing.T) {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
if seen[issued.MACode] {
|
||||
if seen[issued.CCCode] {
|
||||
dup++
|
||||
}
|
||||
seen[issued.MACode] = true
|
||||
seen[issued.CCCode] = true
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package macode
|
||||
package cccode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -17,21 +17,21 @@ type Parsed struct {
|
||||
Sequence uint64
|
||||
}
|
||||
|
||||
// maCodePattern 匹配六段式 MA 码:
|
||||
// ccCodePattern 匹配六段式 MA 码:
|
||||
// MA.156.{industry}.{org}/{category}/{yyyy}{sequence}
|
||||
var maCodePattern = regexp.MustCompile(
|
||||
var ccCodePattern = regexp.MustCompile(
|
||||
`^(MA)\.(\d{3})\.([0-9A-Za-z]+)\.([0-9A-Za-z]+)/([A-Z]{2})/(\d{4})(\d+)$`)
|
||||
|
||||
// Parse 将 MA 码字符串解析为结构化字段;格式非法返回错误(需求4 校验基础)。
|
||||
func Parse(code string) (Parsed, error) {
|
||||
m := maCodePattern.FindStringSubmatch(code)
|
||||
m := ccCodePattern.FindStringSubmatch(code)
|
||||
if m == nil {
|
||||
return Parsed{}, fmt.Errorf("macode: invalid format: %s", code)
|
||||
return Parsed{}, fmt.Errorf("cccode: invalid format: %s", code)
|
||||
}
|
||||
year, _ := strconv.Atoi(m[6])
|
||||
seq, err := strconv.ParseUint(m[7], 10, 64)
|
||||
if err != nil {
|
||||
return Parsed{}, fmt.Errorf("macode: invalid sequence: %w", err)
|
||||
return Parsed{}, fmt.Errorf("cccode: invalid sequence: %w", err)
|
||||
}
|
||||
return Parsed{
|
||||
Root: m[1],
|
||||
@@ -46,13 +46,13 @@ func Parse(code string) (Parsed, error) {
|
||||
|
||||
// IsValid 仅校验格式合法性。
|
||||
func IsValid(code string) bool {
|
||||
return maCodePattern.MatchString(code)
|
||||
return ccCodePattern.MatchString(code)
|
||||
}
|
||||
|
||||
// EpisodeSubID 生成集级子标识:{maCode}#E{NN}。
|
||||
// EpisodeSubID 生成集级子标识:{ccCode}#E{NN}。
|
||||
// 整剧用 MA 码,单集用子标识,便于按集验真/追更/下架。
|
||||
func EpisodeSubID(maCode string, episode int) string {
|
||||
return fmt.Sprintf("%s#E%02d", maCode, episode)
|
||||
func EpisodeSubID(ccCode string, episode int) string {
|
||||
return fmt.Sprintf("%s#E%02d", ccCode, episode)
|
||||
}
|
||||
|
||||
// episodeSubPattern 匹配集级子标识后缀。
|
||||
@@ -60,7 +60,7 @@ var episodeSubPattern = regexp.MustCompile(`^(.+)#E(\d+)$`)
|
||||
|
||||
// ParseEpisodeSubID 拆解集级子标识,返回主 MA 码与集号。
|
||||
// 若无 #E 后缀,episode 返回 0(表示整剧)。
|
||||
func ParseEpisodeSubID(subID string) (maCode string, episode int) {
|
||||
func ParseEpisodeSubID(subID string) (ccCode string, episode int) {
|
||||
m := episodeSubPattern.FindStringSubmatch(subID)
|
||||
if m == nil {
|
||||
return subID, 0
|
||||
@@ -1,4 +1,4 @@
|
||||
package macode
|
||||
package cccode
|
||||
|
||||
import "sync"
|
||||
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
package macode
|
||||
package cccode
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -25,15 +25,15 @@ func NewPostgresStore(db *sql.DB) *PostgresStore {
|
||||
// 单条 SQL 在行锁内完成读改写,并发安全;超过 end 返回耗尽错误。
|
||||
func (s *PostgresStore) Next(segmentKey string, start, end uint64) (uint64, error) {
|
||||
const q = `
|
||||
INSERT INTO macode_cursor (segment_key, cursor)
|
||||
INSERT INTO cccode_cursor (segment_key, cursor)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (segment_key)
|
||||
DO UPDATE SET cursor = macode_cursor.cursor + 1, updated_at = NOW()
|
||||
DO UPDATE SET cursor = cccode_cursor.cursor + 1, updated_at = NOW()
|
||||
RETURNING cursor;`
|
||||
|
||||
var next uint64
|
||||
if err := s.db.QueryRow(q, segmentKey, start).Scan(&next); err != nil {
|
||||
return 0, fmt.Errorf("macode: pg next: %w", err)
|
||||
return 0, fmt.Errorf("cccode: pg next: %w", err)
|
||||
}
|
||||
if next > end {
|
||||
return 0, ErrSegmentExhausted
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
package macode
|
||||
package cccode
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -31,7 +31,7 @@ func openTestDB(t *testing.T) *sql.DB {
|
||||
|
||||
func cleanupKey(t *testing.T, db *sql.DB, key string) {
|
||||
t.Helper()
|
||||
_, _ = db.Exec("DELETE FROM macode_cursor WHERE segment_key = $1", key)
|
||||
_, _ = db.Exec("DELETE FROM cccode_cursor WHERE segment_key = $1", key)
|
||||
}
|
||||
|
||||
func TestPostgresStore_Sequential(t *testing.T) {
|
||||
@@ -122,8 +122,8 @@ func TestPostgresStore_WithGenerator(t *testing.T) {
|
||||
for i := 0; i < 10; i++ {
|
||||
issued, err := g.Allocate(CategoryAnimation)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, seen[issued.MACode])
|
||||
assert.True(t, IsValid(issued.MACode))
|
||||
seen[issued.MACode] = true
|
||||
assert.False(t, seen[issued.CCCode])
|
||||
assert.True(t, IsValid(issued.CCCode))
|
||||
seen[issued.CCCode] = true
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ var (
|
||||
|
||||
// IssueRequest 签发 MA 码并强绑定哈希包。
|
||||
type IssueRequest struct {
|
||||
MACode string
|
||||
CCCode string
|
||||
ContentTwinID string
|
||||
MerkleRoot string
|
||||
FileHash string
|
||||
@@ -43,7 +43,7 @@ type IssueRequest struct {
|
||||
// VerifyResult 哈希验真结果(需求4-AC4)。
|
||||
type VerifyResult struct {
|
||||
Valid bool `json:"valid"`
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
BoundHash string `json:"bound_hash"`
|
||||
SubmittedHash string `json:"submitted_hash"`
|
||||
Match bool `json:"match"`
|
||||
@@ -52,7 +52,7 @@ type VerifyResult struct {
|
||||
|
||||
// MappingsResult 映射查询结果(需求11/17)。
|
||||
type MappingsResult struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
Mappings []model.Mapping `json:"mappings"`
|
||||
CDNEndpoints []string `json:"cdn_endpoints"`
|
||||
}
|
||||
@@ -67,31 +67,31 @@ type Client interface {
|
||||
// RegisterMapping 注册三方编码映射(MA 必须已签发)。
|
||||
RegisterMapping(role Role, m model.Mapping) (txID string, err error)
|
||||
// VerifyHash 按 MA 码校验提交哈希是否与绑定哈希一致。
|
||||
VerifyHash(maCode, fileHash string) (VerifyResult, error)
|
||||
VerifyHash(ccCode, fileHash string) (VerifyResult, error)
|
||||
// VerifyEpisodeHash 按 MA 码+集号校验该集哈希。
|
||||
VerifyEpisodeHash(maCode string, episode int, fileHash string) (VerifyResult, error)
|
||||
VerifyEpisodeHash(ccCode string, episode int, fileHash string) (VerifyResult, error)
|
||||
// ListEpisodes 返回某 MA 码下的全部集级哈希绑定。
|
||||
ListEpisodes(maCode string) ([]model.HashBinding, error)
|
||||
ListEpisodes(ccCode string) ([]model.HashBinding, error)
|
||||
// HashExists 判断内容哈希是否已存在(防换壳重发)。
|
||||
HashExists(fileHash string) (maCode string, exists bool)
|
||||
HashExists(fileHash string) (ccCode string, exists bool)
|
||||
// QueryContent 查询内容主记录。
|
||||
QueryContent(maCode string) (model.Content, error)
|
||||
QueryContent(ccCode string) (model.Content, error)
|
||||
// ListContents 按状态列出内容(空状态返回全部)。
|
||||
ListContents(status string) ([]model.Content, error)
|
||||
// QueryMappings 查询 MA 码绑定的全部三方映射与 CDN 端点。
|
||||
QueryMappings(maCode string) (MappingsResult, error)
|
||||
QueryMappings(ccCode string) (MappingsResult, error)
|
||||
// RecordVersionChange 记录版本变更(绑定断裂触发重审)。
|
||||
RecordVersionChange(vc model.VersionChange) (txID string, err error)
|
||||
// Revoke 下架(仅监管主体),返回受影响的映射。
|
||||
Revoke(role Role, maCode, reason string) (MappingsResult, error)
|
||||
Revoke(role Role, ccCode, reason string) (MappingsResult, error)
|
||||
// RevokeEpisode 集级下架(仅监管主体):只下架指定集,整剧其他集不受影响。
|
||||
RevokeEpisode(role Role, maCode string, episode int, reason string) error
|
||||
RevokeEpisode(role Role, ccCode string, episode int, reason string) error
|
||||
// Restore 恢复上架整剧(仅监管主体):下架状态恢复为流通中。
|
||||
Restore(role Role, maCode string) error
|
||||
Restore(role Role, ccCode string) error
|
||||
// RestoreEpisode 恢复上架指定集(仅监管主体)。
|
||||
RestoreEpisode(role Role, maCode string, episode int) error
|
||||
RestoreEpisode(role Role, ccCode string, episode int) error
|
||||
// SetContentStatus 更新内容状态。
|
||||
SetContentStatus(maCode, status string) error
|
||||
SetContentStatus(ccCode, status string) error
|
||||
// QueryByHash 根据内容哈希反查标识信息及映射关系。
|
||||
QueryByHash(fileHash string) (model.ContentQueryResult, error)
|
||||
// QueryByProvincialCode 根据省级内容编码(CP MediaID)反查标识信息。
|
||||
|
||||
@@ -129,7 +129,7 @@ func (c *ChainMakerClient) IssueMA(role Role, req IssueRequest) (string, error)
|
||||
contentJSON, _ := json.Marshal(req.Content)
|
||||
epJSON, _ := json.Marshal(req.Episodes)
|
||||
resp, err := c.invoke(role, "IssueMA", map[string][]byte{
|
||||
"ma_code": []byte(req.MACode),
|
||||
"ma_code": []byte(req.CCCode),
|
||||
"ctid": []byte(req.ContentTwinID),
|
||||
"merkle_root": []byte(req.MerkleRoot),
|
||||
"file_hash": []byte(req.FileHash),
|
||||
@@ -177,66 +177,66 @@ func (c *ChainMakerClient) RecordVersionChange(vc model.VersionChange) (string,
|
||||
return resp.TxId, nil
|
||||
}
|
||||
|
||||
func (c *ChainMakerClient) Revoke(role Role, maCode, reason string) (MappingsResult, error) {
|
||||
func (c *ChainMakerClient) Revoke(role Role, ccCode, reason string) (MappingsResult, error) {
|
||||
if _, err := c.invoke(role, "Revoke", map[string][]byte{
|
||||
"ma_code": []byte(maCode), "reason": []byte(reason),
|
||||
"ma_code": []byte(ccCode), "reason": []byte(reason),
|
||||
}); err != nil {
|
||||
return MappingsResult{}, err
|
||||
}
|
||||
return c.QueryMappings(maCode)
|
||||
return c.QueryMappings(ccCode)
|
||||
}
|
||||
|
||||
func (c *ChainMakerClient) RevokeEpisode(role Role, maCode string, episode int, reason string) error {
|
||||
func (c *ChainMakerClient) RevokeEpisode(role Role, ccCode string, episode int, reason string) error {
|
||||
_, err := c.invoke(role, "RevokeEpisode", map[string][]byte{
|
||||
"ma_code": []byte(maCode), "episode": []byte(fmt.Sprint(episode)), "reason": []byte(reason),
|
||||
"ma_code": []byte(ccCode), "episode": []byte(fmt.Sprint(episode)), "reason": []byte(reason),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *ChainMakerClient) Restore(role Role, maCode string) error {
|
||||
_, err := c.invoke(role, "Restore", map[string][]byte{"ma_code": []byte(maCode)})
|
||||
func (c *ChainMakerClient) Restore(role Role, ccCode string) error {
|
||||
_, err := c.invoke(role, "Restore", map[string][]byte{"ma_code": []byte(ccCode)})
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *ChainMakerClient) RestoreEpisode(role Role, maCode string, episode int) error {
|
||||
func (c *ChainMakerClient) RestoreEpisode(role Role, ccCode string, episode int) error {
|
||||
_, err := c.invoke(role, "RestoreEpisode", map[string][]byte{
|
||||
"ma_code": []byte(maCode), "episode": []byte(fmt.Sprint(episode)),
|
||||
"ma_code": []byte(ccCode), "episode": []byte(fmt.Sprint(episode)),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *ChainMakerClient) SetContentStatus(maCode, status string) error {
|
||||
func (c *ChainMakerClient) SetContentStatus(ccCode, status string) error {
|
||||
_, err := c.invoke(RoleReviewer, "SetContentStatus", map[string][]byte{
|
||||
"ma_code": []byte(maCode), "status": []byte(status),
|
||||
"ma_code": []byte(ccCode), "status": []byte(status),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- chain.Client 实现(读操作)----
|
||||
|
||||
func (c *ChainMakerClient) VerifyHash(maCode, fileHash string) (VerifyResult, error) {
|
||||
func (c *ChainMakerClient) VerifyHash(ccCode, fileHash string) (VerifyResult, error) {
|
||||
res, err := c.query(RoleOperator, "VerifyHash", map[string][]byte{
|
||||
"ma_code": []byte(maCode), "file_hash": []byte(fileHash),
|
||||
"ma_code": []byte(ccCode), "file_hash": []byte(fileHash),
|
||||
})
|
||||
if err != nil {
|
||||
return VerifyResult{MACode: maCode, SubmittedHash: fileHash}, err
|
||||
return VerifyResult{CCCode: ccCode, SubmittedHash: fileHash}, err
|
||||
}
|
||||
match := string(res) == "true"
|
||||
return VerifyResult{Valid: true, MACode: maCode, SubmittedHash: fileHash, Match: match}, nil
|
||||
return VerifyResult{Valid: true, CCCode: ccCode, SubmittedHash: fileHash, Match: match}, nil
|
||||
}
|
||||
|
||||
func (c *ChainMakerClient) VerifyEpisodeHash(maCode string, episode int, fileHash string) (VerifyResult, error) {
|
||||
func (c *ChainMakerClient) VerifyEpisodeHash(ccCode string, episode int, fileHash string) (VerifyResult, error) {
|
||||
res, err := c.query(RoleOperator, "VerifyEpisodeHash", map[string][]byte{
|
||||
"ma_code": []byte(maCode), "episode": []byte(fmt.Sprint(episode)), "file_hash": []byte(fileHash),
|
||||
"ma_code": []byte(ccCode), "episode": []byte(fmt.Sprint(episode)), "file_hash": []byte(fileHash),
|
||||
})
|
||||
if err != nil {
|
||||
return VerifyResult{MACode: maCode, SubmittedHash: fileHash}, err
|
||||
return VerifyResult{CCCode: ccCode, SubmittedHash: fileHash}, err
|
||||
}
|
||||
return VerifyResult{Valid: true, MACode: maCode, SubmittedHash: fileHash, Match: string(res) == "true"}, nil
|
||||
return VerifyResult{Valid: true, CCCode: ccCode, SubmittedHash: fileHash, Match: string(res) == "true"}, nil
|
||||
}
|
||||
|
||||
func (c *ChainMakerClient) ListEpisodes(maCode string) ([]model.HashBinding, error) {
|
||||
res, err := c.query(RoleRegulator, "ListEpisodes", map[string][]byte{"ma_code": []byte(maCode)})
|
||||
func (c *ChainMakerClient) ListEpisodes(ccCode string) ([]model.HashBinding, error) {
|
||||
res, err := c.query(RoleRegulator, "ListEpisodes", map[string][]byte{"ma_code": []byte(ccCode)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -255,8 +255,8 @@ func (c *ChainMakerClient) HashExists(fileHash string) (string, bool) {
|
||||
return string(res), true
|
||||
}
|
||||
|
||||
func (c *ChainMakerClient) QueryContent(maCode string) (model.Content, error) {
|
||||
res, err := c.query(RoleRegulator, "QueryContent", map[string][]byte{"ma_code": []byte(maCode)})
|
||||
func (c *ChainMakerClient) QueryContent(ccCode string) (model.Content, error) {
|
||||
res, err := c.query(RoleRegulator, "QueryContent", map[string][]byte{"ma_code": []byte(ccCode)})
|
||||
if err != nil {
|
||||
return model.Content{}, err
|
||||
}
|
||||
@@ -280,8 +280,8 @@ func (c *ChainMakerClient) ListContents(status string) ([]model.Content, error)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *ChainMakerClient) QueryMappings(maCode string) (MappingsResult, error) {
|
||||
res, err := c.query(RoleRegulator, "QueryMappings", map[string][]byte{"ma_code": []byte(maCode)})
|
||||
func (c *ChainMakerClient) QueryMappings(ccCode string) (MappingsResult, error) {
|
||||
res, err := c.query(RoleRegulator, "QueryMappings", map[string][]byte{"ma_code": []byte(ccCode)})
|
||||
if err != nil {
|
||||
return MappingsResult{}, err
|
||||
}
|
||||
@@ -289,7 +289,7 @@ func (c *ChainMakerClient) QueryMappings(maCode string) (MappingsResult, error)
|
||||
if err := json.Unmarshal(res, &out); err != nil {
|
||||
return MappingsResult{}, err
|
||||
}
|
||||
out.MACode = maCode
|
||||
out.CCCode = ccCode
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
// 仅在 `go test -tags chainmaker` 且配置了测试链时运行:
|
||||
// - TCS_TEST_CHAINMAKER_CONF:测试链 sdk_config.yml 路径
|
||||
//
|
||||
// 注意:真实链不易"清空状态",建议每次用全新 maCode/合约实例,或对接专用测试链。
|
||||
// 注意:真实链不易"清空状态",建议每次用全新 ccCode/合约实例,或对接专用测试链。
|
||||
// 本用例提供接线骨架,实际跑通需真实 ChainMaker 测试网与已部署的 tcs_registry 合约。
|
||||
func TestChainMakerClient_Conformance(t *testing.T) {
|
||||
conf := os.Getenv("TCS_TEST_CHAINMAKER_CONF")
|
||||
|
||||
@@ -19,7 +19,7 @@ func RunClientConformance(t *testing.T, newClient func(t *testing.T) Client) {
|
||||
// 构造一条标准发码请求(集级 3 集)。
|
||||
issueReq := func(ma, ctid, fh string) IssueRequest {
|
||||
return IssueRequest{
|
||||
MACode: ma, ContentTwinID: ctid, MerkleRoot: "mr-" + fh, FileHash: fh,
|
||||
CCCode: ma, ContentTwinID: ctid, MerkleRoot: "mr-" + fh, FileHash: fh,
|
||||
PerceptualHash: "ph-" + fh,
|
||||
Episodes: []model.EpisodeHash{
|
||||
{Episode: 1, FileSHA256: fh + "-E1"},
|
||||
@@ -170,20 +170,20 @@ func RunClientConformance(t *testing.T, newClient func(t *testing.T) Client) {
|
||||
res, err := c.QueryByHash(fh)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Found)
|
||||
assert.Equal(t, ma, res.Content.MACode)
|
||||
assert.Equal(t, ma, res.Content.CCCode)
|
||||
assert.Equal(t, "契约测试剧", res.Content.Title)
|
||||
|
||||
// 按省级内容编码查询
|
||||
res2, err := c.QueryByProvincialCode("PROV-001")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res2.Found)
|
||||
assert.Equal(t, ma, res2.Content.MACode)
|
||||
assert.Equal(t, ma, res2.Content.CCCode)
|
||||
|
||||
// 按片库文件 ID 查询
|
||||
res3, err := c.QueryByLibraryFileID("LIB-001")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res3.Found)
|
||||
assert.Equal(t, ma, res3.Content.MACode)
|
||||
assert.Equal(t, ma, res3.Content.CCCode)
|
||||
|
||||
// 不存在的 Hash
|
||||
_, err = c.QueryByHash("nonexistent")
|
||||
@@ -203,17 +203,17 @@ func RunClientConformance(t *testing.T, newClient func(t *testing.T) Client) {
|
||||
|
||||
// 非监管不可合并
|
||||
_, err = c.MergeMA(RoleCP, model.MergeRequest{
|
||||
PrimaryMACode: ma, SecondaryMACodes: []string{ma2}, Reason: "重复发码",
|
||||
PrimaryCCCode: ma, SecondaryCCCodes: []string{ma2}, Reason: "重复发码",
|
||||
})
|
||||
assert.ErrorIs(t, err, ErrPermissionDenied)
|
||||
|
||||
// 监管合并
|
||||
res, err := c.MergeMA(RoleRegulator, model.MergeRequest{
|
||||
PrimaryMACode: ma, SecondaryMACodes: []string{ma2}, Reason: "重复发码", Operator: "监管局",
|
||||
PrimaryCCCode: ma, SecondaryCCCodes: []string{ma2}, Reason: "重复发码", Operator: "监管局",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ma, res.PrimaryMACode)
|
||||
assert.Contains(t, res.MergedMACodes, ma2)
|
||||
assert.Equal(t, ma, res.PrimaryCCCode)
|
||||
assert.Contains(t, res.MergedCCCodes, ma2)
|
||||
assert.Greater(t, res.MigratedBindings, 0)
|
||||
|
||||
// 被合并的 MA 状态为 merged
|
||||
@@ -223,7 +223,7 @@ func RunClientConformance(t *testing.T, newClient func(t *testing.T) Client) {
|
||||
// 按 fh2 查询应指向主 MA 码
|
||||
qr, err := c.QueryByHash(fh2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ma, qr.Content.MACode)
|
||||
assert.Equal(t, ma, qr.Content.CCCode)
|
||||
})
|
||||
|
||||
t.Run("MA拆分_仅监管且按集迁移", func(t *testing.T) {
|
||||
@@ -233,20 +233,20 @@ func RunClientConformance(t *testing.T, newClient func(t *testing.T) Client) {
|
||||
|
||||
// 非监管不可拆分
|
||||
_, err = c.SplitMA(RoleCP, model.SplitRequest{
|
||||
SourceMACode: ma, Splits: []model.SplitTarget{{NewMACode: "MA.156.8531.6101/WD/20260000010", Title: "拆分剧A", Episodes: []int{1, 2}}},
|
||||
SourceCCCode: ma, Splits: []model.SplitTarget{{NewCCCode: "MA.156.8531.6101/WD/20260000010", Title: "拆分剧A", Episodes: []int{1, 2}}},
|
||||
})
|
||||
assert.ErrorIs(t, err, ErrPermissionDenied)
|
||||
|
||||
// 监管拆分
|
||||
newMA := "MA.156.8531.6101/WD/20260000011"
|
||||
res, err := c.SplitMA(RoleRegulator, model.SplitRequest{
|
||||
SourceMACode: ma,
|
||||
Splits: []model.SplitTarget{{NewMACode: newMA, Title: "拆分剧A", Episodes: []int{1, 2}}},
|
||||
SourceCCCode: ma,
|
||||
Splits: []model.SplitTarget{{NewCCCode: newMA, Title: "拆分剧A", Episodes: []int{1, 2}}},
|
||||
Reason: "合集拆分",
|
||||
Operator: "监管局",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, res.NewMACodes, newMA)
|
||||
assert.Contains(t, res.NewCCCodes, newMA)
|
||||
assert.Greater(t, res.MigratedBindings, 0)
|
||||
|
||||
// 源 MA 状态为 split
|
||||
|
||||
@@ -12,11 +12,11 @@ import (
|
||||
// 严格执行合约级业务规则:签发权限、1:1 不可解绑、映射前置签发、防重复哈希。
|
||||
type MemoryChain struct {
|
||||
mu sync.RWMutex
|
||||
contents map[string]model.Content // maCode -> Content
|
||||
bindings map[string][]model.HashBinding // maCode -> bindings
|
||||
mappings map[string][]model.Mapping // maCode -> mappings
|
||||
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 -> maCode(防换壳重发)
|
||||
hashIndex map[string]string // fileHash -> ccCode(防换壳重发)
|
||||
txSeq int
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func (m *MemoryChain) IssueMA(role Role, req IssueRequest) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if _, ok := m.contents[req.MACode]; ok {
|
||||
if _, ok := m.contents[req.CCCode]; ok {
|
||||
return "", ErrMAAlreadyIssued
|
||||
}
|
||||
if existing, ok := m.hashIndex[req.FileHash]; ok {
|
||||
@@ -52,15 +52,15 @@ func (m *MemoryChain) IssueMA(role Role, req IssueRequest) (string, error) {
|
||||
}
|
||||
|
||||
c := req.Content
|
||||
c.MACode = req.MACode
|
||||
c.CCCode = req.CCCode
|
||||
c.ContentTwinID = req.ContentTwinID
|
||||
c.Status = model.StatusApproved
|
||||
if c.CreatedAt.IsZero() {
|
||||
c.CreatedAt = time.Now()
|
||||
}
|
||||
m.contents[req.MACode] = c
|
||||
m.contents[req.CCCode] = c
|
||||
|
||||
m.bindings[req.MACode] = []model.HashBinding{{
|
||||
m.bindings[req.CCCode] = []model.HashBinding{{
|
||||
ContentTwinID: req.ContentTwinID,
|
||||
HashType: model.HashFile,
|
||||
HashValue: req.FileHash,
|
||||
@@ -69,7 +69,7 @@ func (m *MemoryChain) IssueMA(role Role, req IssueRequest) (string, error) {
|
||||
CreatedBy: string(RoleRegulator),
|
||||
}}
|
||||
if req.PerceptualHash != "" {
|
||||
m.bindings[req.MACode] = append(m.bindings[req.MACode], model.HashBinding{
|
||||
m.bindings[req.CCCode] = append(m.bindings[req.CCCode], model.HashBinding{
|
||||
ContentTwinID: req.ContentTwinID,
|
||||
HashType: model.HashPerceptual,
|
||||
HashValue: req.PerceptualHash,
|
||||
@@ -77,11 +77,11 @@ func (m *MemoryChain) IssueMA(role Role, req IssueRequest) (string, error) {
|
||||
CreatedBy: string(RoleRegulator),
|
||||
})
|
||||
}
|
||||
m.hashIndex[req.FileHash] = req.MACode
|
||||
m.hashIndex[req.FileHash] = req.CCCode
|
||||
|
||||
// 集级哈希绑定(分集内容):每集独立哈希,挂在同一 MA 码下。
|
||||
for _, ep := range req.Episodes {
|
||||
m.bindings[req.MACode] = append(m.bindings[req.MACode], model.HashBinding{
|
||||
m.bindings[req.CCCode] = append(m.bindings[req.CCCode], model.HashBinding{
|
||||
ContentTwinID: req.ContentTwinID,
|
||||
HashType: model.HashFile,
|
||||
HashValue: ep.FileSHA256,
|
||||
@@ -94,7 +94,7 @@ func (m *MemoryChain) IssueMA(role Role, req IssueRequest) (string, error) {
|
||||
})
|
||||
if ep.FileSHA256 != "" {
|
||||
if _, ok := m.hashIndex[ep.FileSHA256]; !ok {
|
||||
m.hashIndex[ep.FileSHA256] = req.MACode
|
||||
m.hashIndex[ep.FileSHA256] = req.CCCode
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,14 +109,14 @@ func (m *MemoryChain) RegisterHashBinding(role Role, b model.HashBinding) (strin
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
maCode := m.maCodeByCTID(b.ContentTwinID)
|
||||
if maCode == "" {
|
||||
ccCode := m.ccCodeByCTID(b.ContentTwinID)
|
||||
if ccCode == "" {
|
||||
return "", ErrMANotIssued
|
||||
}
|
||||
m.bindings[maCode] = append(m.bindings[maCode], b)
|
||||
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] = maCode
|
||||
m.hashIndex[b.HashValue] = ccCode
|
||||
}
|
||||
}
|
||||
return m.nextTx("registerHashBinding"), nil
|
||||
@@ -127,28 +127,28 @@ func (m *MemoryChain) RegisterMapping(role Role, mp model.Mapping) (string, erro
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
maCode := m.maCodeByCTID(mp.ContentTwinID)
|
||||
if maCode == "" {
|
||||
ccCode := m.ccCodeByCTID(mp.ContentTwinID)
|
||||
if ccCode == "" {
|
||||
return "", ErrMANotIssued
|
||||
}
|
||||
m.mappings[maCode] = append(m.mappings[maCode], mp)
|
||||
m.mappings[ccCode] = append(m.mappings[ccCode], mp)
|
||||
return m.nextTx("registerMapping"), nil
|
||||
}
|
||||
|
||||
// VerifyHash 按 MA 码校验提交哈希。
|
||||
func (m *MemoryChain) VerifyHash(maCode, fileHash string) (VerifyResult, error) {
|
||||
func (m *MemoryChain) VerifyHash(ccCode, fileHash string) (VerifyResult, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
bs, ok := m.bindings[maCode]
|
||||
bs, ok := m.bindings[ccCode]
|
||||
if !ok {
|
||||
return VerifyResult{Valid: false, MACode: maCode, SubmittedHash: fileHash}, ErrMANotIssued
|
||||
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, MACode: maCode,
|
||||
Valid: true, CCCode: ccCode,
|
||||
BoundHash: b.HashValue, SubmittedHash: fileHash,
|
||||
Match: true, Version: b.Version,
|
||||
}, nil
|
||||
@@ -163,7 +163,7 @@ func (m *MemoryChain) VerifyHash(maCode, fileHash string) (VerifyResult, error)
|
||||
break
|
||||
}
|
||||
}
|
||||
return VerifyResult{Valid: true, MACode: maCode, BoundHash: bound, SubmittedHash: fileHash, Match: false}, nil
|
||||
return VerifyResult{Valid: true, CCCode: ccCode, BoundHash: bound, SubmittedHash: fileHash, Match: false}, nil
|
||||
}
|
||||
|
||||
// HashExists 判断内容哈希是否已存在。
|
||||
@@ -175,12 +175,12 @@ func (m *MemoryChain) HashExists(fileHash string) (string, bool) {
|
||||
}
|
||||
|
||||
// VerifyEpisodeHash 按 MA 码+集号校验该集哈希。
|
||||
func (m *MemoryChain) VerifyEpisodeHash(maCode string, episode int, fileHash string) (VerifyResult, error) {
|
||||
func (m *MemoryChain) VerifyEpisodeHash(ccCode string, episode int, fileHash string) (VerifyResult, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
bs, ok := m.bindings[maCode]
|
||||
bs, ok := m.bindings[ccCode]
|
||||
if !ok {
|
||||
return VerifyResult{Valid: false, MACode: maCode, SubmittedHash: fileHash}, ErrMANotIssued
|
||||
return VerifyResult{Valid: false, CCCode: ccCode, SubmittedHash: fileHash}, ErrMANotIssued
|
||||
}
|
||||
var bound string
|
||||
for _, b := range bs {
|
||||
@@ -190,7 +190,7 @@ func (m *MemoryChain) VerifyEpisodeHash(maCode string, episode int, fileHash str
|
||||
}
|
||||
if b.HashValue == fileHash {
|
||||
return VerifyResult{
|
||||
Valid: true, MACode: maCode,
|
||||
Valid: true, CCCode: ccCode,
|
||||
BoundHash: b.HashValue, SubmittedHash: fileHash,
|
||||
Match: true, Version: b.Version,
|
||||
}, nil
|
||||
@@ -198,16 +198,16 @@ func (m *MemoryChain) VerifyEpisodeHash(maCode string, episode int, fileHash str
|
||||
}
|
||||
}
|
||||
if bound == "" {
|
||||
return VerifyResult{Valid: false, MACode: maCode, SubmittedHash: fileHash}, ErrNotFound
|
||||
return VerifyResult{Valid: false, CCCode: ccCode, SubmittedHash: fileHash}, ErrNotFound
|
||||
}
|
||||
return VerifyResult{Valid: true, MACode: maCode, BoundHash: bound, SubmittedHash: fileHash, Match: false}, nil
|
||||
return VerifyResult{Valid: true, CCCode: ccCode, BoundHash: bound, SubmittedHash: fileHash, Match: false}, nil
|
||||
}
|
||||
|
||||
// ListEpisodes 返回某 MA 码下的全部集级哈希绑定(episode > 0)。
|
||||
func (m *MemoryChain) ListEpisodes(maCode string) ([]model.HashBinding, error) {
|
||||
func (m *MemoryChain) ListEpisodes(ccCode string) ([]model.HashBinding, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
bs, ok := m.bindings[maCode]
|
||||
bs, ok := m.bindings[ccCode]
|
||||
if !ok {
|
||||
return nil, ErrMANotIssued
|
||||
}
|
||||
@@ -221,10 +221,10 @@ func (m *MemoryChain) ListEpisodes(maCode string) ([]model.HashBinding, error) {
|
||||
}
|
||||
|
||||
// QueryContent 查询内容主记录。
|
||||
func (m *MemoryChain) QueryContent(maCode string) (model.Content, error) {
|
||||
func (m *MemoryChain) QueryContent(ccCode string) (model.Content, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
c, ok := m.contents[maCode]
|
||||
c, ok := m.contents[ccCode]
|
||||
if !ok {
|
||||
return model.Content{}, ErrNotFound
|
||||
}
|
||||
@@ -252,14 +252,14 @@ func (m *MemoryChain) ListContents(status string) ([]model.Content, error) {
|
||||
}
|
||||
|
||||
// QueryMappings 查询 MA 码绑定的全部映射与 CDN 端点。
|
||||
func (m *MemoryChain) QueryMappings(maCode string) (MappingsResult, error) {
|
||||
func (m *MemoryChain) QueryMappings(ccCode string) (MappingsResult, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if _, ok := m.contents[maCode]; !ok {
|
||||
if _, ok := m.contents[ccCode]; !ok {
|
||||
return MappingsResult{}, ErrNotFound
|
||||
}
|
||||
res := MappingsResult{MACode: maCode, Mappings: m.mappings[maCode]}
|
||||
for _, mp := range m.mappings[maCode] {
|
||||
res := MappingsResult{CCCode: ccCode, Mappings: m.mappings[ccCode]}
|
||||
for _, mp := range m.mappings[ccCode] {
|
||||
if mp.CDNEndpoint != "" {
|
||||
res.CDNEndpoints = append(res.CDNEndpoints, mp.CDNEndpoint)
|
||||
}
|
||||
@@ -271,30 +271,30 @@ func (m *MemoryChain) QueryMappings(maCode string) (MappingsResult, error) {
|
||||
func (m *MemoryChain) RecordVersionChange(vc model.VersionChange) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
maCode := m.maCodeByCTID(vc.ContentTwinID)
|
||||
if maCode == "" {
|
||||
ccCode := m.ccCodeByCTID(vc.ContentTwinID)
|
||||
if ccCode == "" {
|
||||
return "", ErrMANotIssued
|
||||
}
|
||||
m.versions[maCode] = append(m.versions[maCode], vc)
|
||||
m.versions[ccCode] = append(m.versions[ccCode], vc)
|
||||
return m.nextTx("recordVersionChange"), nil
|
||||
}
|
||||
|
||||
// Revoke 下架,仅监管主体。
|
||||
func (m *MemoryChain) Revoke(role Role, maCode, reason string) (MappingsResult, error) {
|
||||
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[maCode]
|
||||
c, ok := m.contents[ccCode]
|
||||
if !ok {
|
||||
return MappingsResult{}, ErrNotFound
|
||||
}
|
||||
c.Status = model.StatusRevoked
|
||||
m.contents[maCode] = c
|
||||
m.contents[ccCode] = c
|
||||
|
||||
res := MappingsResult{MACode: maCode, Mappings: m.mappings[maCode]}
|
||||
for _, mp := range m.mappings[maCode] {
|
||||
res := MappingsResult{CCCode: ccCode, Mappings: m.mappings[ccCode]}
|
||||
for _, mp := range m.mappings[ccCode] {
|
||||
if mp.CDNEndpoint != "" {
|
||||
res.CDNEndpoints = append(res.CDNEndpoints, mp.CDNEndpoint)
|
||||
}
|
||||
@@ -303,13 +303,13 @@ func (m *MemoryChain) Revoke(role Role, maCode, reason string) (MappingsResult,
|
||||
}
|
||||
|
||||
// RevokeEpisode 集级下架:只下架指定集,整剧其他集不受影响(仅监管主体)。
|
||||
func (m *MemoryChain) RevokeEpisode(role Role, maCode string, episode int, reason string) error {
|
||||
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[maCode]
|
||||
bs, ok := m.bindings[ccCode]
|
||||
if !ok {
|
||||
return ErrMANotIssued
|
||||
}
|
||||
@@ -324,34 +324,34 @@ func (m *MemoryChain) RevokeEpisode(role Role, maCode string, episode int, reaso
|
||||
if !found {
|
||||
return ErrNotFound
|
||||
}
|
||||
m.bindings[maCode] = bs
|
||||
m.bindings[ccCode] = bs
|
||||
return nil
|
||||
}
|
||||
|
||||
// Restore 恢复上架整剧:下架状态恢复为流通中(仅监管主体)。
|
||||
func (m *MemoryChain) Restore(role Role, maCode string) error {
|
||||
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[maCode]
|
||||
c, ok := m.contents[ccCode]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
c.Status = model.StatusPublished
|
||||
m.contents[maCode] = c
|
||||
m.contents[ccCode] = c
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestoreEpisode 恢复上架指定集(仅监管主体)。
|
||||
func (m *MemoryChain) RestoreEpisode(role Role, maCode string, episode int) error {
|
||||
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[maCode]
|
||||
bs, ok := m.bindings[ccCode]
|
||||
if !ok {
|
||||
return ErrMANotIssued
|
||||
}
|
||||
@@ -366,25 +366,25 @@ func (m *MemoryChain) RestoreEpisode(role Role, maCode string, episode int) erro
|
||||
if !found {
|
||||
return ErrNotFound
|
||||
}
|
||||
m.bindings[maCode] = bs
|
||||
m.bindings[ccCode] = bs
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetContentStatus 更新内容状态。
|
||||
func (m *MemoryChain) SetContentStatus(maCode, status string) error {
|
||||
func (m *MemoryChain) SetContentStatus(ccCode, status string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
c, ok := m.contents[maCode]
|
||||
c, ok := m.contents[ccCode]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
c.Status = status
|
||||
m.contents[maCode] = c
|
||||
m.contents[ccCode] = c
|
||||
return nil
|
||||
}
|
||||
|
||||
// maCodeByCTID 内部辅助:通过 CTID 反查 MA 码(调用方已持锁)。
|
||||
func (m *MemoryChain) maCodeByCTID(ctid string) string {
|
||||
// ccCodeByCTID 内部辅助:通过 CTID 反查 MA 码(调用方已持锁)。
|
||||
func (m *MemoryChain) ccCodeByCTID(ctid string) string {
|
||||
for ma, c := range m.contents {
|
||||
if c.ContentTwinID == ctid {
|
||||
return ma
|
||||
@@ -397,15 +397,15 @@ func (m *MemoryChain) maCodeByCTID(ctid string) string {
|
||||
func (m *MemoryChain) QueryByHash(fileHash string) (model.ContentQueryResult, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
maCode, ok := m.hashIndex[fileHash]
|
||||
ccCode, ok := m.hashIndex[fileHash]
|
||||
if !ok {
|
||||
return model.ContentQueryResult{Found: false}, ErrNotFound
|
||||
}
|
||||
return model.ContentQueryResult{
|
||||
Found: true,
|
||||
Content: m.contents[maCode],
|
||||
Bindings: m.bindings[maCode],
|
||||
Mappings: m.mappings[maCode],
|
||||
Content: m.contents[ccCode],
|
||||
Bindings: m.bindings[ccCode],
|
||||
Mappings: m.mappings[ccCode],
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -413,13 +413,13 @@ func (m *MemoryChain) QueryByHash(fileHash string) (model.ContentQueryResult, er
|
||||
func (m *MemoryChain) QueryByProvincialCode(provincialCode string) (model.ContentQueryResult, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for maCode, maps := range m.mappings {
|
||||
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[maCode],
|
||||
Bindings: m.bindings[maCode],
|
||||
Content: m.contents[ccCode],
|
||||
Bindings: m.bindings[ccCode],
|
||||
Mappings: maps,
|
||||
}, nil
|
||||
}
|
||||
@@ -432,13 +432,13 @@ func (m *MemoryChain) QueryByProvincialCode(provincialCode string) (model.Conten
|
||||
func (m *MemoryChain) QueryByLibraryFileID(libraryFileID string) (model.ContentQueryResult, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for maCode, maps := range m.mappings {
|
||||
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[maCode],
|
||||
Bindings: m.bindings[maCode],
|
||||
Content: m.contents[ccCode],
|
||||
Bindings: m.bindings[ccCode],
|
||||
Mappings: maps,
|
||||
}, nil
|
||||
}
|
||||
@@ -456,7 +456,7 @@ func (m *MemoryChain) MergeMA(role Role, req model.MergeRequest) (model.MergeRes
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
primary, ok := m.contents[req.PrimaryMACode]
|
||||
primary, ok := m.contents[req.PrimaryCCCode]
|
||||
if !ok {
|
||||
return model.MergeResult{}, ErrNotFound
|
||||
}
|
||||
@@ -464,7 +464,7 @@ func (m *MemoryChain) MergeMA(role Role, req model.MergeRequest) (model.MergeRes
|
||||
|
||||
migratedBindings := 0
|
||||
migratedMappings := 0
|
||||
for _, secMA := range req.SecondaryMACodes {
|
||||
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)
|
||||
@@ -476,21 +476,21 @@ func (m *MemoryChain) MergeMA(role Role, req model.MergeRequest) (model.MergeRes
|
||||
// 迁移哈希绑定至主 MA 码
|
||||
for _, b := range m.bindings[secMA] {
|
||||
b.ContentTwinID = primary.ContentTwinID
|
||||
m.bindings[req.PrimaryMACode] = append(m.bindings[req.PrimaryMACode], b)
|
||||
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.PrimaryMACode] = append(m.mappings[req.PrimaryMACode], mp)
|
||||
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.PrimaryMACode
|
||||
m.hashIndex[b.HashValue] = req.PrimaryCCCode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,8 +503,8 @@ func (m *MemoryChain) MergeMA(role Role, req model.MergeRequest) (model.MergeRes
|
||||
|
||||
txID := m.nextTx("mergeMA")
|
||||
return model.MergeResult{
|
||||
PrimaryMACode: req.PrimaryMACode,
|
||||
MergedMACodes: req.SecondaryMACodes,
|
||||
PrimaryCCCode: req.PrimaryCCCode,
|
||||
MergedCCCodes: req.SecondaryCCCodes,
|
||||
MigratedBindings: migratedBindings,
|
||||
MigratedMappings: migratedMappings,
|
||||
TxID: txID,
|
||||
@@ -520,30 +520,30 @@ func (m *MemoryChain) SplitMA(role Role, req model.SplitRequest) (model.SplitRes
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
srcContent, ok := m.contents[req.SourceMACode]
|
||||
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.SourceMACode, srcContent.Status)
|
||||
return model.SplitResult{}, fmt.Errorf("chain: MA %s already %s", req.SourceCCCode, srcContent.Status)
|
||||
}
|
||||
|
||||
migratedBindings := 0
|
||||
migratedMappings := 0
|
||||
newMACodes := make([]string, 0, len(req.Splits))
|
||||
newCCCodes := make([]string, 0, len(req.Splits))
|
||||
|
||||
for _, split := range req.Splits {
|
||||
// 创建新内容记录
|
||||
newContent := srcContent
|
||||
newContent.MACode = split.NewMACode
|
||||
newContent.ContentTwinID = split.NewMACode + "-ctid"
|
||||
newContent.CCCode = split.NewCCCode
|
||||
newContent.ContentTwinID = split.NewCCCode + "-ctid"
|
||||
newContent.Title = split.Title
|
||||
newContent.Status = model.StatusApproved
|
||||
newContent.CreatedAt = time.Now()
|
||||
m.contents[split.NewMACode] = newContent
|
||||
m.contents[split.NewCCCode] = newContent
|
||||
|
||||
// 按集号迁移哈希绑定
|
||||
for _, b := range m.bindings[req.SourceMACode] {
|
||||
for _, b := range m.bindings[req.SourceCCCode] {
|
||||
shouldMigrate := false
|
||||
if len(split.Episodes) == 0 {
|
||||
// 空集号列表:迁移全部(整剧拆分场景)
|
||||
@@ -559,34 +559,34 @@ func (m *MemoryChain) SplitMA(role Role, req model.SplitRequest) (model.SplitRes
|
||||
if shouldMigrate {
|
||||
newB := b
|
||||
newB.ContentTwinID = newContent.ContentTwinID
|
||||
m.bindings[split.NewMACode] = append(m.bindings[split.NewMACode], newB)
|
||||
m.bindings[split.NewCCCode] = append(m.bindings[split.NewCCCode], newB)
|
||||
// 更新哈希索引
|
||||
if b.HashType == model.HashFile || b.HashType == model.HashTranscoded {
|
||||
m.hashIndex[b.HashValue] = split.NewMACode
|
||||
m.hashIndex[b.HashValue] = split.NewCCCode
|
||||
}
|
||||
migratedBindings++
|
||||
}
|
||||
}
|
||||
|
||||
// 迁移映射(全部映射复制到新 MA 码)
|
||||
for _, mp := range m.mappings[req.SourceMACode] {
|
||||
for _, mp := range m.mappings[req.SourceCCCode] {
|
||||
newMP := mp
|
||||
newMP.ContentTwinID = newContent.ContentTwinID
|
||||
m.mappings[split.NewMACode] = append(m.mappings[split.NewMACode], newMP)
|
||||
m.mappings[split.NewCCCode] = append(m.mappings[split.NewCCCode], newMP)
|
||||
migratedMappings++
|
||||
}
|
||||
|
||||
newMACodes = append(newMACodes, split.NewMACode)
|
||||
newCCCodes = append(newCCCodes, split.NewCCCode)
|
||||
}
|
||||
|
||||
// 源 MA 码状态标记为 split
|
||||
srcContent.Status = model.StatusSplit
|
||||
m.contents[req.SourceMACode] = srcContent
|
||||
m.contents[req.SourceCCCode] = srcContent
|
||||
|
||||
txID := m.nextTx("splitMA")
|
||||
return model.SplitResult{
|
||||
SourceMACode: req.SourceMACode,
|
||||
NewMACodes: newMACodes,
|
||||
SourceCCCode: req.SourceCCCode,
|
||||
NewCCCodes: newCCCodes,
|
||||
MigratedBindings: migratedBindings,
|
||||
MigratedMappings: migratedMappings,
|
||||
TxID: txID,
|
||||
|
||||
@@ -12,7 +12,7 @@ func newIssued(t *testing.T) *MemoryChain {
|
||||
t.Helper()
|
||||
c := NewMemoryChain()
|
||||
_, err := c.IssueMA(RoleRegulator, IssueRequest{
|
||||
MACode: "(京)网微剧审字(2025)第123号",
|
||||
CCCode: "(京)网微剧审字(2025)第123号",
|
||||
ContentTwinID: "ctid-001",
|
||||
MerkleRoot: "merkle-root-1",
|
||||
FileHash: "filehash-1",
|
||||
@@ -24,13 +24,13 @@ func newIssued(t *testing.T) *MemoryChain {
|
||||
|
||||
func TestIssueMA_OnlyRegulator(t *testing.T) {
|
||||
c := NewMemoryChain()
|
||||
_, err := c.IssueMA(RoleCP, IssueRequest{MACode: "MA-1", ContentTwinID: "ct-1", FileHash: "h1"})
|
||||
_, err := c.IssueMA(RoleCP, IssueRequest{CCCode: "MA-1", ContentTwinID: "ct-1", FileHash: "h1"})
|
||||
assert.ErrorIs(t, err, ErrPermissionDenied)
|
||||
|
||||
_, err = c.IssueMA(RoleReviewer, IssueRequest{MACode: "MA-1", ContentTwinID: "ct-1", FileHash: "h1"})
|
||||
_, err = c.IssueMA(RoleReviewer, IssueRequest{CCCode: "MA-1", ContentTwinID: "ct-1", FileHash: "h1"})
|
||||
assert.ErrorIs(t, err, ErrPermissionDenied)
|
||||
|
||||
_, err = c.IssueMA(RoleRegulator, IssueRequest{MACode: "MA-1", ContentTwinID: "ct-1", FileHash: "h1"})
|
||||
_, err = c.IssueMA(RoleRegulator, IssueRequest{CCCode: "MA-1", ContentTwinID: "ct-1", FileHash: "h1"})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestIssueMA_NoReissue(t *testing.T) {
|
||||
c := newIssued(t)
|
||||
// 同 MA 码重复签发被拒(1:1 不可解绑/不可覆盖)
|
||||
_, err := c.IssueMA(RoleRegulator, IssueRequest{
|
||||
MACode: "(京)网微剧审字(2025)第123号", ContentTwinID: "ctid-001", FileHash: "other",
|
||||
CCCode: "(京)网微剧审字(2025)第123号", ContentTwinID: "ctid-001", FileHash: "other",
|
||||
})
|
||||
assert.ErrorIs(t, err, ErrMAAlreadyIssued)
|
||||
}
|
||||
@@ -47,7 +47,7 @@ func TestIssueMA_DuplicateHashRejected(t *testing.T) {
|
||||
c := newIssued(t)
|
||||
// 换壳重发:不同 MA 码但相同内容哈希 → 拒绝
|
||||
_, err := c.IssueMA(RoleRegulator, IssueRequest{
|
||||
MACode: "(沪)网微剧审字(2025)第999号", ContentTwinID: "ctid-002", FileHash: "filehash-1",
|
||||
CCCode: "(沪)网微剧审字(2025)第999号", ContentTwinID: "ctid-002", FileHash: "filehash-1",
|
||||
})
|
||||
assert.ErrorIs(t, err, ErrHashExists)
|
||||
}
|
||||
|
||||
@@ -40,9 +40,9 @@ func (p *PersistentChain) IssueMA(role Role, req IssueRequest) (string, error) {
|
||||
if err != nil {
|
||||
return tx, err
|
||||
}
|
||||
c, _ := p.MemoryChain.QueryContent(req.MACode)
|
||||
c, _ := p.MemoryChain.QueryContent(req.CCCode)
|
||||
p.persistContent(c)
|
||||
for _, b := range p.snapshotBindings(req.MACode) {
|
||||
for _, b := range p.snapshotBindings(req.CCCode) {
|
||||
p.persistBinding(b)
|
||||
}
|
||||
p.persistTx(req.ContentTwinID, tx, "issueMA")
|
||||
@@ -86,48 +86,48 @@ func (p *PersistentChain) RecordVersionChange(vc model.VersionChange) (string, e
|
||||
}
|
||||
|
||||
// Revoke 整剧下架:镜像内容状态。
|
||||
func (p *PersistentChain) Revoke(role Role, maCode, reason string) (MappingsResult, error) {
|
||||
res, err := p.MemoryChain.Revoke(role, maCode, reason)
|
||||
func (p *PersistentChain) Revoke(role Role, ccCode, reason string) (MappingsResult, error) {
|
||||
res, err := p.MemoryChain.Revoke(role, ccCode, reason)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
p.updateStatus(maCode, model.StatusRevoked)
|
||||
p.updateStatus(ccCode, model.StatusRevoked)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Restore 整剧恢复上架:镜像内容状态。
|
||||
func (p *PersistentChain) Restore(role Role, maCode string) error {
|
||||
if err := p.MemoryChain.Restore(role, maCode); err != nil {
|
||||
func (p *PersistentChain) Restore(role Role, ccCode string) error {
|
||||
if err := p.MemoryChain.Restore(role, ccCode); err != nil {
|
||||
return err
|
||||
}
|
||||
p.updateStatus(maCode, model.StatusPublished)
|
||||
p.updateStatus(ccCode, model.StatusPublished)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeEpisode 集级下架:镜像该集 revoked 标记。
|
||||
func (p *PersistentChain) RevokeEpisode(role Role, maCode string, episode int, reason string) error {
|
||||
if err := p.MemoryChain.RevokeEpisode(role, maCode, episode, reason); err != nil {
|
||||
func (p *PersistentChain) RevokeEpisode(role Role, ccCode string, episode int, reason string) error {
|
||||
if err := p.MemoryChain.RevokeEpisode(role, ccCode, episode, reason); err != nil {
|
||||
return err
|
||||
}
|
||||
p.updateEpisodeRevoked(maCode, episode, true, reason)
|
||||
p.updateEpisodeRevoked(ccCode, episode, true, reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestoreEpisode 集级恢复:镜像该集 revoked 标记。
|
||||
func (p *PersistentChain) RestoreEpisode(role Role, maCode string, episode int) error {
|
||||
if err := p.MemoryChain.RestoreEpisode(role, maCode, episode); err != nil {
|
||||
func (p *PersistentChain) RestoreEpisode(role Role, ccCode string, episode int) error {
|
||||
if err := p.MemoryChain.RestoreEpisode(role, ccCode, episode); err != nil {
|
||||
return err
|
||||
}
|
||||
p.updateEpisodeRevoked(maCode, episode, false, "")
|
||||
p.updateEpisodeRevoked(ccCode, episode, false, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetContentStatus 状态流转(入库/发布等):镜像内容状态。
|
||||
func (p *PersistentChain) SetContentStatus(maCode, status string) error {
|
||||
if err := p.MemoryChain.SetContentStatus(maCode, status); err != nil {
|
||||
func (p *PersistentChain) SetContentStatus(ccCode, status string) error {
|
||||
if err := p.MemoryChain.SetContentStatus(ccCode, status); err != nil {
|
||||
return err
|
||||
}
|
||||
p.updateStatus(maCode, status)
|
||||
p.updateStatus(ccCode, status)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -138,14 +138,14 @@ func (p *PersistentChain) MergeMA(role Role, req model.MergeRequest) (model.Merg
|
||||
return res, err
|
||||
}
|
||||
// 被合并的 MA 码状态标记为 merged
|
||||
for _, secMA := range req.SecondaryMACodes {
|
||||
for _, secMA := range req.SecondaryCCCodes {
|
||||
p.updateStatus(secMA, model.StatusMerged)
|
||||
}
|
||||
// 主 MA 码的绑定和映射已在内存中追加,写穿最新状态
|
||||
for _, b := range p.snapshotBindings(req.PrimaryMACode) {
|
||||
for _, b := range p.snapshotBindings(req.PrimaryCCCode) {
|
||||
p.persistBinding(b)
|
||||
}
|
||||
p.persistTx(req.PrimaryMACode, res.TxID, "mergeMA")
|
||||
p.persistTx(req.PrimaryCCCode, res.TxID, "mergeMA")
|
||||
return res, nil
|
||||
}
|
||||
|
||||
@@ -156,9 +156,9 @@ func (p *PersistentChain) SplitMA(role Role, req model.SplitRequest) (model.Spli
|
||||
return res, err
|
||||
}
|
||||
// 源 MA 码状态标记为 split
|
||||
p.updateStatus(req.SourceMACode, model.StatusSplit)
|
||||
p.updateStatus(req.SourceCCCode, model.StatusSplit)
|
||||
// 新 MA 码的内容记录和绑定写穿
|
||||
for _, newMA := range res.NewMACodes {
|
||||
for _, newMA := range res.NewCCCodes {
|
||||
c, _ := p.MemoryChain.QueryContent(newMA)
|
||||
p.persistContent(c)
|
||||
for _, b := range p.snapshotBindings(newMA) {
|
||||
@@ -168,7 +168,7 @@ func (p *PersistentChain) SplitMA(role Role, req model.SplitRequest) (model.Spli
|
||||
p.persistMapping(mp)
|
||||
}
|
||||
}
|
||||
p.persistTx(req.SourceMACode, res.TxID, "splitMA")
|
||||
p.persistTx(req.SourceCCCode, res.TxID, "splitMA")
|
||||
return res, nil
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ func (p *PersistentChain) persistContent(c model.Content) {
|
||||
(content_twin_id, ma_code, ma_type, title, episode_count, status, issuer, issue_date, created_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
ON CONFLICT (content_twin_id) DO UPDATE SET status=EXCLUDED.status, updated_at=NOW()`,
|
||||
c.ContentTwinID, c.MACode, c.MAType, c.Title, c.EpisodeCount, c.Status, c.Issuer, issueDate, c.CreatedAt)
|
||||
c.ContentTwinID, c.CCCode, c.MAType, c.Title, c.EpisodeCount, c.Status, c.Issuer, issueDate, c.CreatedAt)
|
||||
}
|
||||
|
||||
func (p *PersistentChain) persistBinding(b model.HashBinding) {
|
||||
@@ -214,32 +214,32 @@ func (p *PersistentChain) persistTx(ctid, txID, method string) {
|
||||
ctid, txID, method)
|
||||
}
|
||||
|
||||
func (p *PersistentChain) updateStatus(maCode, status string) {
|
||||
p.exec(`UPDATE content_registry SET status=$1, updated_at=NOW() WHERE ma_code=$2`, status, maCode)
|
||||
func (p *PersistentChain) updateStatus(ccCode, status string) {
|
||||
p.exec(`UPDATE content_registry SET status=$1, updated_at=NOW() WHERE ma_code=$2`, status, ccCode)
|
||||
}
|
||||
|
||||
func (p *PersistentChain) updateEpisodeRevoked(maCode string, episode int, revoked bool, reason string) {
|
||||
func (p *PersistentChain) updateEpisodeRevoked(ccCode string, episode int, revoked bool, reason string) {
|
||||
p.exec(`UPDATE hash_binding hb SET revoked=$1, revoked_reason=$2
|
||||
FROM content_registry cr
|
||||
WHERE hb.content_twin_id = cr.content_twin_id AND cr.ma_code=$3 AND hb.episode=$4`,
|
||||
revoked, reason, maCode, episode)
|
||||
revoked, reason, ccCode, episode)
|
||||
}
|
||||
|
||||
// snapshotBindings 复制某 MA 码当前的内存绑定(同包访问,读锁保护)。
|
||||
func (p *PersistentChain) snapshotBindings(maCode string) []model.HashBinding {
|
||||
func (p *PersistentChain) snapshotBindings(ccCode string) []model.HashBinding {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
src := p.bindings[maCode]
|
||||
src := p.bindings[ccCode]
|
||||
out := make([]model.HashBinding, len(src))
|
||||
copy(out, src)
|
||||
return out
|
||||
}
|
||||
|
||||
// snapshotMappings 复制某 MA 码当前的内存映射(同包访问,读锁保护)。
|
||||
func (p *PersistentChain) snapshotMappings(maCode string) []model.Mapping {
|
||||
func (p *PersistentChain) snapshotMappings(ccCode string) []model.Mapping {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
src := p.mappings[maCode]
|
||||
src := p.mappings[ccCode]
|
||||
out := make([]model.Mapping, len(src))
|
||||
copy(out, src)
|
||||
return out
|
||||
@@ -248,7 +248,7 @@ func (p *PersistentChain) snapshotMappings(maCode string) []model.Mapping {
|
||||
// ---- 启动水合:从 PG 镜像恢复内存状态 ----
|
||||
|
||||
func (p *PersistentChain) hydrate() error {
|
||||
// 1) 内容主表 + 建立 ctid -> maCode 映射
|
||||
// 1) 内容主表 + 建立 ctid -> ccCode 映射
|
||||
ctidToMA := map[string]string{}
|
||||
rows, err := p.db.Query(`SELECT content_twin_id, ma_code, COALESCE(ma_type,''), title,
|
||||
COALESCE(episode_count,1), status, COALESCE(issuer,''),
|
||||
@@ -259,13 +259,13 @@ func (p *PersistentChain) hydrate() error {
|
||||
n := 0
|
||||
for rows.Next() {
|
||||
var c model.Content
|
||||
if err := rows.Scan(&c.ContentTwinID, &c.MACode, &c.MAType, &c.Title,
|
||||
if err := rows.Scan(&c.ContentTwinID, &c.CCCode, &c.MAType, &c.Title,
|
||||
&c.EpisodeCount, &c.Status, &c.Issuer, &c.IssueDate, &c.CreatedAt); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
p.contents[c.MACode] = c
|
||||
ctidToMA[c.ContentTwinID] = c.MACode
|
||||
p.contents[c.CCCode] = c
|
||||
ctidToMA[c.ContentTwinID] = c.CCCode
|
||||
n++
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
@@ -6,7 +6,7 @@ import "time"
|
||||
|
||||
// Authorization 信息网络传播权授权(需求25-AC1)。
|
||||
type Authorization struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
Regions []string `json:"regions"` // 授权地域(省码),空=全国
|
||||
Platforms []string `json:"platforms"` // 授权平台/运营商,空=不限
|
||||
ExpiryAt time.Time `json:"expiry_at"` // 授权到期;零值=长期
|
||||
@@ -22,7 +22,7 @@ type AuthCheckResult struct {
|
||||
// CrossProvinceResult 跨省复用准入结果(需求13)。
|
||||
type CrossProvinceResult struct {
|
||||
Admitted bool `json:"admitted"`
|
||||
MACodeValid bool `json:"ma_code_valid"`
|
||||
CCCodeValid bool `json:"ma_code_valid"`
|
||||
HashConsistent bool `json:"hash_consistent"`
|
||||
NotBlacklisted bool `json:"not_blacklisted"`
|
||||
ProvinceFlowNo string `json:"province_flow_no"` // 本省审核流水号
|
||||
|
||||
@@ -16,7 +16,7 @@ const (
|
||||
// Content 内容主表(Content Registry)。
|
||||
type Content struct {
|
||||
ContentTwinID string `json:"content_twin_id"`
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
MAType string `json:"ma_type"`
|
||||
Title string `json:"title"`
|
||||
EpisodeCount int `json:"episode_count"`
|
||||
@@ -95,8 +95,8 @@ const (
|
||||
StatusInLibrary = "in_library" // 已入媒资库
|
||||
StatusPublished = "published" // 已发布
|
||||
StatusRevoked = "revoked" // 已下架
|
||||
StatusMerged = "merged" // 已合并入其他MA码
|
||||
StatusSplit = "split" // 已拆分为多个MA码
|
||||
StatusMerged = "merged" // 已合并入其他CC码
|
||||
StatusSplit = "split" // 已拆分为多个CC码
|
||||
)
|
||||
|
||||
// ContentQueryResult 多维度标识查询的统一返回结果。
|
||||
@@ -110,16 +110,16 @@ type ContentQueryResult struct {
|
||||
|
||||
// MergeRequest MA 合并请求(将多个 MA 码合并为一个主 MA 码)。
|
||||
type MergeRequest struct {
|
||||
PrimaryMACode string `json:"primary_ma_code"` // 合并后保留的主 MA 码
|
||||
SecondaryMACodes []string `json:"secondary_ma_codes"` // 被合并的 MA 码列表(合并后标记为 merged)
|
||||
PrimaryCCCode string `json:"primary_ma_code"` // 合并后保留的主 MA 码
|
||||
SecondaryCCCodes []string `json:"secondary_ma_codes"` // 被合并的 MA 码列表(合并后标记为 merged)
|
||||
Reason string `json:"reason"` // 合并原因
|
||||
Operator string `json:"operator"` // 操作人
|
||||
}
|
||||
|
||||
// MergeResult MA 合并结果。
|
||||
type MergeResult struct {
|
||||
PrimaryMACode string `json:"primary_ma_code"` // 主 MA 码
|
||||
MergedMACodes []string `json:"merged_ma_codes"` // 已合并的 MA 码列表
|
||||
PrimaryCCCode string `json:"primary_ma_code"` // 主 MA 码
|
||||
MergedCCCodes []string `json:"merged_ma_codes"` // 已合并的 MA 码列表
|
||||
MigratedBindings int `json:"migrated_bindings"` // 迁移的哈希绑定数
|
||||
MigratedMappings int `json:"migrated_mappings"` // 迁移的映射数
|
||||
TxID string `json:"tx_id"` // 链上交易 ID
|
||||
@@ -127,7 +127,7 @@ type MergeResult struct {
|
||||
|
||||
// SplitRequest MA 拆分请求(将一个 MA 码拆分为多个独立 MA 码)。
|
||||
type SplitRequest struct {
|
||||
SourceMACode string `json:"source_ma_code"` // 被拆分的源 MA 码
|
||||
SourceCCCode string `json:"source_ma_code"` // 被拆分的源 MA 码
|
||||
Splits []SplitTarget `json:"splits"` // 拆分目标列表
|
||||
Reason string `json:"reason"` // 拆分原因
|
||||
Operator string `json:"operator"` // 操作人
|
||||
@@ -135,15 +135,15 @@ type SplitRequest struct {
|
||||
|
||||
// SplitTarget 拆分目标:每集或每组内容拆分后的新 MA 码信息。
|
||||
type SplitTarget struct {
|
||||
NewMACode string `json:"new_ma_code"` // 拆分后新分配的 MA 码
|
||||
NewCCCode string `json:"new_ma_code"` // 拆分后新分配的 MA 码
|
||||
Title string `json:"title"` // 拆分后内容标题
|
||||
Episodes []int `json:"episodes"` // 关联的集号列表(空表示整剧拆分)
|
||||
}
|
||||
|
||||
// SplitResult MA 拆分结果。
|
||||
type SplitResult struct {
|
||||
SourceMACode string `json:"source_ma_code"` // 源 MA 码
|
||||
NewMACodes []string `json:"new_ma_codes"` // 拆分后生成的新 MA 码列表
|
||||
SourceCCCode string `json:"source_ma_code"` // 源 MA 码
|
||||
NewCCCodes []string `json:"new_ma_codes"` // 拆分后生成的新 MA 码列表
|
||||
MigratedBindings int `json:"migrated_bindings"` // 迁移的哈希绑定数
|
||||
MigratedMappings int `json:"migrated_mappings"` // 迁移的映射数
|
||||
TxID string `json:"tx_id"` // 链上交易 ID
|
||||
|
||||
@@ -15,7 +15,7 @@ const (
|
||||
|
||||
// PlaybackEvent 运营商以 MA 码为维度回传的播放/消费事件(需求9-AC1)。
|
||||
type PlaybackEvent struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
Episode int `json:"episode"` // 0=整剧/单体
|
||||
PlatformID string `json:"platform_id"` // 运营商节点
|
||||
UserHash string `json:"user_hash"` // 用户标识哈希(隐私保护)
|
||||
@@ -27,7 +27,7 @@ type PlaybackEvent struct {
|
||||
|
||||
// PlaybackSummary 按 MA 码聚合的可信播放数据(需求9-AC2、需求21-AC1)。
|
||||
type PlaybackSummary struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
TotalPlays int64 `json:"total_plays"`
|
||||
TotalComplete int64 `json:"total_complete"`
|
||||
TotalRevenue int64 `json:"total_revenue_cent"`
|
||||
@@ -55,7 +55,7 @@ func DefaultShareConfig() RevenueShareConfig {
|
||||
|
||||
// Settlement 基于可信播放数据的分账结算结果(需求21-AC3)。
|
||||
type Settlement struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
Period string `json:"period"`
|
||||
TotalRevenue int64 `json:"total_revenue_cent"`
|
||||
CPShare int64 `json:"cp_share_cent"`
|
||||
|
||||
@@ -18,7 +18,7 @@ const (
|
||||
|
||||
// ProvenanceEvent 全链路存证事件(需求22-AC1/AC3):带时间戳、操作方、不可篡改。
|
||||
type ProvenanceEvent struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
Node ProvenanceNode `json:"node"`
|
||||
HashValue string `json:"hash_value"` // 该节点经手的内容哈希(可空,如纯审核结论)
|
||||
Operator string `json:"operator"` // 操作方标识
|
||||
@@ -28,7 +28,7 @@ type ProvenanceEvent struct {
|
||||
|
||||
// AccountabilityReport 责任界定取证报告(需求22-AC2/AC4)。
|
||||
type AccountabilityReport struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
Trail []ProvenanceEvent `json:"trail"`
|
||||
BaselineHash string `json:"baseline_hash"` // 发码时绑定的基准哈希
|
||||
FirstChange *ProvenanceEvent `json:"first_change"` // 首次发生哈希变化的节点(nil=全程一致)
|
||||
@@ -38,7 +38,7 @@ type AccountabilityReport struct {
|
||||
|
||||
// CopyrightEvidence 版权确权证据链(需求23-AC1/AC2)。
|
||||
type CopyrightEvidence struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
Title string `json:"title"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
Issuer string `json:"issuer"`
|
||||
@@ -51,7 +51,7 @@ type CopyrightEvidence struct {
|
||||
|
||||
// InfringeMatch 疑似侵权命中(需求23-AC3)。
|
||||
type InfringeMatch struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
Title string `json:"title"`
|
||||
Distance int `json:"hamming_distance"` // 感知哈希汉明距离,越小越相似
|
||||
Similarity string `json:"similarity"` // high/medium
|
||||
|
||||
@@ -4,7 +4,7 @@ package model
|
||||
|
||||
// FilingRecord 备案/网标关联(三期 A.1,对接广电总局备案/发行许可证系统)。
|
||||
type FilingRecord struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
LicenseNo string `json:"license_no"` // 网络剧片发行许可证号(网标号)
|
||||
FilingNo string `json:"filing_no"` // 重点网络影视剧备案号
|
||||
BoundAt string `json:"bound_at"`
|
||||
|
||||
@@ -44,7 +44,7 @@ type ParsedMA struct {
|
||||
|
||||
// ResolveResult 跨域解析网关返回(四期 C.1/C.2,大小屏统一解析)。
|
||||
type ResolveResult struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
Parsed ParsedMA `json:"parsed"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"` // 流通状态(published/revoked/...)
|
||||
@@ -58,7 +58,7 @@ type ResolveResult struct {
|
||||
|
||||
// ScanVerifyResult 用户扫码验真返回(四期 B.2)。
|
||||
type ScanVerifyResult struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
Authentic bool `json:"authentic"` // MA 码真伪(链上存在且结构合法)
|
||||
Compliant bool `json:"compliant"` // 是否合规流通(未下架)
|
||||
Status string `json:"status"`
|
||||
@@ -70,7 +70,7 @@ type ScanVerifyResult struct {
|
||||
|
||||
// PurchaseRecord 用户一次购买记录(四期 D.1)。
|
||||
type PurchaseRecord struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
UserHash string `json:"user_hash"`
|
||||
Screen ScreenType `json:"screen"` // 购买时所在屏
|
||||
PurchasedAt time.Time `json:"purchased_at"`
|
||||
@@ -79,12 +79,12 @@ type PurchaseRecord struct {
|
||||
// UserRights 用户跨屏权益账户:以 MA 码为维度记录购买,任一屏购买即全屏通看。
|
||||
type UserRights struct {
|
||||
UserHash string `json:"user_hash"`
|
||||
Purchases map[string]PurchaseRecord `json:"purchases"` // maCode -> record
|
||||
Purchases map[string]PurchaseRecord `json:"purchases"` // ccCode -> record
|
||||
}
|
||||
|
||||
// CrossScreenRightsResult 跨屏权益核验返回(四期 D.1)。
|
||||
type CrossScreenRightsResult struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
UserHash string `json:"user_hash"`
|
||||
Entitled bool `json:"entitled"` // 是否有权益(任一屏购买即全屏通看)
|
||||
RequestScreen ScreenType `json:"request_screen"` // 当前请求屏
|
||||
|
||||
@@ -25,11 +25,11 @@ import (
|
||||
// 聚合多省标识数据,提供统一查询入口。
|
||||
type NationalCatalog struct {
|
||||
mu stdsync.RWMutex
|
||||
contents map[string]model.Content // maCode -> Content
|
||||
bindings map[string][]model.HashBinding // maCode -> bindings
|
||||
mappings map[string][]model.Mapping // maCode -> mappings
|
||||
contents map[string]model.Content // ccCode -> Content
|
||||
bindings map[string][]model.HashBinding // ccCode -> bindings
|
||||
mappings map[string][]model.Mapping // ccCode -> mappings
|
||||
provinces map[string]*ProvinceInfo // provinceCode -> 省级信息
|
||||
auditShared map[string][]model.ProvenanceEvent // maCode -> 跨省共享审核记录
|
||||
auditShared map[string][]model.ProvenanceEvent // ccCode -> 跨省共享审核记录
|
||||
}
|
||||
|
||||
// ProvinceInfo 省级节点信息。
|
||||
@@ -81,44 +81,44 @@ func (nc *NationalCatalog) ListProvinces() []ProvinceInfo {
|
||||
func (nc *NationalCatalog) UpsertContent(c model.Content) error {
|
||||
nc.mu.Lock()
|
||||
defer nc.mu.Unlock()
|
||||
if _, exists := nc.contents[c.MACode]; exists {
|
||||
if _, exists := nc.contents[c.CCCode]; exists {
|
||||
return syncpkg.ErrConflict
|
||||
}
|
||||
nc.contents[c.MACode] = c
|
||||
nc.contents[c.CCCode] = c
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpsertBinding 写入/更新哈希绑定。
|
||||
func (nc *NationalCatalog) UpsertBinding(maCode string, b model.HashBinding) error {
|
||||
func (nc *NationalCatalog) UpsertBinding(ccCode string, b model.HashBinding) error {
|
||||
nc.mu.Lock()
|
||||
defer nc.mu.Unlock()
|
||||
nc.bindings[maCode] = append(nc.bindings[maCode], b)
|
||||
nc.bindings[ccCode] = append(nc.bindings[ccCode], b)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpsertMapping 写入/更新映射。
|
||||
func (nc *NationalCatalog) UpsertMapping(maCode string, m model.Mapping) error {
|
||||
func (nc *NationalCatalog) UpsertMapping(ccCode string, m model.Mapping) error {
|
||||
nc.mu.Lock()
|
||||
defer nc.mu.Unlock()
|
||||
nc.mappings[maCode] = append(nc.mappings[maCode], m)
|
||||
nc.mappings[ccCode] = append(nc.mappings[ccCode], m)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- 全国统一查询 ----
|
||||
|
||||
// QueryByMA 按 MA 码查询(全国维度)。
|
||||
func (nc *NationalCatalog) QueryByMA(maCode string) (model.ContentQueryResult, error) {
|
||||
func (nc *NationalCatalog) QueryByMA(ccCode string) (model.ContentQueryResult, error) {
|
||||
nc.mu.RLock()
|
||||
defer nc.mu.RUnlock()
|
||||
c, ok := nc.contents[maCode]
|
||||
c, ok := nc.contents[ccCode]
|
||||
if !ok {
|
||||
return model.ContentQueryResult{Found: false}, fmt.Errorf("national: MA 码 %s 未找到", maCode)
|
||||
return model.ContentQueryResult{Found: false}, fmt.Errorf("national: MA 码 %s 未找到", ccCode)
|
||||
}
|
||||
return model.ContentQueryResult{
|
||||
Found: true,
|
||||
Content: c,
|
||||
Bindings: nc.bindings[maCode],
|
||||
Mappings: nc.mappings[maCode],
|
||||
Bindings: nc.bindings[ccCode],
|
||||
Mappings: nc.mappings[ccCode],
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -127,25 +127,25 @@ func (nc *NationalCatalog) QueryByHash(fileHash string) (model.ContentQueryResul
|
||||
nc.mu.RLock()
|
||||
defer nc.mu.RUnlock()
|
||||
// 先搜索 Content.FileHash(整剧主哈希)
|
||||
for maCode, c := range nc.contents {
|
||||
for ccCode, c := range nc.contents {
|
||||
if c.FileHash == fileHash {
|
||||
return model.ContentQueryResult{
|
||||
Found: true,
|
||||
Content: c,
|
||||
Bindings: nc.bindings[maCode],
|
||||
Mappings: nc.mappings[maCode],
|
||||
Bindings: nc.bindings[ccCode],
|
||||
Mappings: nc.mappings[ccCode],
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
// 再搜索 bindings 中的 HashValue(集级/转码版哈希)
|
||||
for maCode, bindings := range nc.bindings {
|
||||
for ccCode, bindings := range nc.bindings {
|
||||
for _, b := range bindings {
|
||||
if b.HashValue == fileHash {
|
||||
return model.ContentQueryResult{
|
||||
Found: true,
|
||||
Content: nc.contents[maCode],
|
||||
Content: nc.contents[ccCode],
|
||||
Bindings: bindings,
|
||||
Mappings: nc.mappings[maCode],
|
||||
Mappings: nc.mappings[ccCode],
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@@ -157,13 +157,13 @@ func (nc *NationalCatalog) QueryByHash(fileHash string) (model.ContentQueryResul
|
||||
func (nc *NationalCatalog) QueryByProvincialCode(provincialCode string) (model.ContentQueryResult, error) {
|
||||
nc.mu.RLock()
|
||||
defer nc.mu.RUnlock()
|
||||
for maCode, mappings := range nc.mappings {
|
||||
for ccCode, mappings := range nc.mappings {
|
||||
for _, mp := range mappings {
|
||||
if mp.Party == model.PartyCP && mp.PartyID == provincialCode {
|
||||
return model.ContentQueryResult{
|
||||
Found: true,
|
||||
Content: nc.contents[maCode],
|
||||
Bindings: nc.bindings[maCode],
|
||||
Content: nc.contents[ccCode],
|
||||
Bindings: nc.bindings[ccCode],
|
||||
Mappings: mappings,
|
||||
}, nil
|
||||
}
|
||||
@@ -175,17 +175,17 @@ func (nc *NationalCatalog) QueryByProvincialCode(provincialCode string) (model.C
|
||||
// ---- 跨省审核记录共享 ----
|
||||
|
||||
// ShareAuditRecord 省级节点上报审核记录至全国中心。
|
||||
func (nc *NationalCatalog) ShareAuditRecord(maCode string, event model.ProvenanceEvent) {
|
||||
func (nc *NationalCatalog) ShareAuditRecord(ccCode string, event model.ProvenanceEvent) {
|
||||
nc.mu.Lock()
|
||||
defer nc.mu.Unlock()
|
||||
nc.auditShared[maCode] = append(nc.auditShared[maCode], event)
|
||||
nc.auditShared[ccCode] = append(nc.auditShared[ccCode], event)
|
||||
}
|
||||
|
||||
// QuerySharedAudit 查询跨省共享的审核记录。
|
||||
func (nc *NationalCatalog) QuerySharedAudit(maCode string) []model.ProvenanceEvent {
|
||||
func (nc *NationalCatalog) QuerySharedAudit(ccCode string) []model.ProvenanceEvent {
|
||||
nc.mu.RLock()
|
||||
defer nc.mu.RUnlock()
|
||||
return nc.auditShared[maCode]
|
||||
return nc.auditShared[ccCode]
|
||||
}
|
||||
|
||||
// ---- 全国统计 ----
|
||||
@@ -214,7 +214,7 @@ func (nc *NationalCatalog) Stats() NationalStats {
|
||||
st.ByStatus[c.Status]++
|
||||
st.ByCategory[c.MAType]++
|
||||
// 按机构节点统计省份
|
||||
for _, mp := range nc.mappings[c.MACode] {
|
||||
for _, mp := range nc.mappings[c.CCCode] {
|
||||
if mp.Party == model.PartyCP {
|
||||
st.ByProvince[mp.PartyName]++
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestNationalCatalog_SyncFromProvince(t *testing.T) {
|
||||
// 源端发码
|
||||
srcClient := chain.NewMemoryChain()
|
||||
_, err := srcClient.IssueMA(chain.RoleRegulator, chain.IssueRequest{
|
||||
MACode: "MA.156.8531.6101/WD/20260000001", ContentTwinID: "ctid-nat-001",
|
||||
CCCode: "MA.156.8531.6101/WD/20260000001", ContentTwinID: "ctid-nat-001",
|
||||
FileHash: "fh-nat-001", MerkleRoot: "mr-nat-001",
|
||||
Episodes: []model.EpisodeHash{
|
||||
{Episode: 1, FileSHA256: "fh-nat-001-E1"},
|
||||
@@ -74,7 +74,7 @@ func TestNationalCatalog_Stats(t *testing.T) {
|
||||
|
||||
srcClient := chain.NewMemoryChain()
|
||||
_, err := srcClient.IssueMA(chain.RoleRegulator, chain.IssueRequest{
|
||||
MACode: "MA.156.8531.6101/WD/20260000002", ContentTwinID: "ctid-nat-002",
|
||||
CCCode: "MA.156.8531.6101/WD/20260000002", ContentTwinID: "ctid-nat-002",
|
||||
FileHash: "fh-nat-002", MerkleRoot: "mr-nat-002",
|
||||
Content: model.Content{Title: "统计测试剧", MAType: "WD", Issuer: "陕西局"},
|
||||
})
|
||||
@@ -98,7 +98,7 @@ func TestNationalCatalog_Stats(t *testing.T) {
|
||||
func TestNationalCatalog_ShareAuditRecord(t *testing.T) {
|
||||
nc := New()
|
||||
nc.ShareAuditRecord("MA.156.8531.6101/WD/20260000003", model.ProvenanceEvent{
|
||||
MACode: "MA.156.8531.6101/WD/20260000003",
|
||||
CCCode: "MA.156.8531.6101/WD/20260000003",
|
||||
Node: model.NodeIssue,
|
||||
Operator: "陕西局",
|
||||
Detail: "跨省审核记录",
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
// Store 播放事件存储与聚合。
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
events map[string][]model.PlaybackEvent // maCode -> events
|
||||
events map[string][]model.PlaybackEvent // ccCode -> events
|
||||
}
|
||||
|
||||
// NewStore 创建播放数据存储。
|
||||
@@ -29,21 +29,21 @@ func (s *Store) Ingest(events []model.PlaybackEvent) int {
|
||||
defer s.mu.Unlock()
|
||||
n := 0
|
||||
for _, e := range events {
|
||||
if e.MACode == "" {
|
||||
if e.CCCode == "" {
|
||||
continue
|
||||
}
|
||||
s.events[e.MACode] = append(s.events[e.MACode], e)
|
||||
s.events[e.CCCode] = append(s.events[e.CCCode], e)
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Summary 按 MA 码聚合可信播放数据(需求9-AC2、需求21-AC1)。
|
||||
func (s *Store) Summary(maCode string) model.PlaybackSummary {
|
||||
func (s *Store) Summary(ccCode string) model.PlaybackSummary {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
sum := model.PlaybackSummary{MACode: maCode, ByPlatform: map[string]model.PlatformMetric{}}
|
||||
for _, e := range s.events[maCode] {
|
||||
sum := model.PlaybackSummary{CCCode: ccCode, ByPlatform: map[string]model.PlatformMetric{}}
|
||||
for _, e := range s.events[ccCode] {
|
||||
pm := sum.ByPlatform[e.PlatformID]
|
||||
switch e.EventType {
|
||||
case model.EventPlay:
|
||||
@@ -62,12 +62,12 @@ func (s *Store) Summary(maCode string) model.PlaybackSummary {
|
||||
|
||||
// ComputeSettlement 基于聚合的可信播放收益执行分账(需求21-AC3)。
|
||||
// 分账依据明确标注为"链上可信播放数据",保证 CP 与运营商口径一致。
|
||||
func (s *Store) ComputeSettlement(maCode, period string, cfg model.RevenueShareConfig) (model.Settlement, error) {
|
||||
func (s *Store) ComputeSettlement(ccCode, period string, cfg model.RevenueShareConfig) (model.Settlement, error) {
|
||||
if cfg.CPShareBp+cfg.PlatformShareBp+cfg.HubFeeBp != 10000 {
|
||||
return model.Settlement{}, fmt.Errorf("playback: share config must sum to 10000bp, got %d",
|
||||
cfg.CPShareBp+cfg.PlatformShareBp+cfg.HubFeeBp)
|
||||
}
|
||||
sum := s.Summary(maCode)
|
||||
sum := s.Summary(ccCode)
|
||||
total := sum.TotalRevenue
|
||||
|
||||
cp := total * int64(cfg.CPShareBp) / 10000
|
||||
@@ -76,7 +76,7 @@ func (s *Store) ComputeSettlement(maCode, period string, cfg model.RevenueShareC
|
||||
hub := total - cp - platform
|
||||
|
||||
return model.Settlement{
|
||||
MACode: maCode, Period: period, TotalRevenue: total,
|
||||
CCCode: ccCode, Period: period, TotalRevenue: total,
|
||||
CPShare: cp, PlatformShare: platform, HubFee: hub,
|
||||
DataSource: "链上可信播放数据",
|
||||
}, nil
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
func ev(ma, plat string, t model.PlaybackEventType, rev int64) model.PlaybackEvent {
|
||||
return model.PlaybackEvent{MACode: ma, PlatformID: plat, EventType: t, RevenueCent: rev}
|
||||
return model.PlaybackEvent{CCCode: ma, PlatformID: plat, EventType: t, RevenueCent: rev}
|
||||
}
|
||||
|
||||
func TestIngestAndSummary(t *testing.T) {
|
||||
@@ -33,7 +33,7 @@ func TestIngestAndSummary(t *testing.T) {
|
||||
|
||||
func TestIngestSkipsEmptyMA(t *testing.T) {
|
||||
s := NewStore()
|
||||
n := s.Ingest([]model.PlaybackEvent{{MACode: ""}, ev("MA-1", "P", model.EventPlay, 0)})
|
||||
n := s.Ingest([]model.PlaybackEvent{{CCCode: ""}, ev("MA-1", "P", model.EventPlay, 0)})
|
||||
assert.Equal(t, 1, n)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
// Store 全链路存证存储。
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
trails map[string][]model.ProvenanceEvent // maCode -> 时间序事件
|
||||
trails map[string][]model.ProvenanceEvent // ccCode -> 时间序事件
|
||||
}
|
||||
|
||||
// NewStore 创建存证存储。
|
||||
@@ -28,22 +28,22 @@ func (s *Store) Record(e model.ProvenanceEvent) {
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.trails[e.MACode] = append(s.trails[e.MACode], e)
|
||||
s.trails[e.CCCode] = append(s.trails[e.CCCode], e)
|
||||
}
|
||||
|
||||
// Trail 返回某 MA 码的全链路存证(需求22-AC1)。
|
||||
func (s *Store) Trail(maCode string) []model.ProvenanceEvent {
|
||||
func (s *Store) Trail(ccCode string) []model.ProvenanceEvent {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]model.ProvenanceEvent, len(s.trails[maCode]))
|
||||
copy(out, s.trails[maCode])
|
||||
out := make([]model.ProvenanceEvent, len(s.trails[ccCode]))
|
||||
copy(out, s.trails[ccCode])
|
||||
return out
|
||||
}
|
||||
|
||||
// Accountability 责任界定:以发码基准哈希为准,定位首次发生哈希变化的节点(需求22-AC2)。
|
||||
func (s *Store) Accountability(maCode string) model.AccountabilityReport {
|
||||
trail := s.Trail(maCode)
|
||||
report := model.AccountabilityReport{MACode: maCode, Trail: trail, Consistent: true}
|
||||
func (s *Store) Accountability(ccCode string) model.AccountabilityReport {
|
||||
trail := s.Trail(ccCode)
|
||||
report := model.AccountabilityReport{CCCode: ccCode, Trail: trail, Consistent: true}
|
||||
|
||||
// 基准哈希 = 发码节点(NodeIssue)的哈希
|
||||
for _, e := range trail {
|
||||
|
||||
@@ -12,13 +12,13 @@ import (
|
||||
// 全链路一致:发码→入库→正确注入,追责判定审播一致。
|
||||
func TestAccountability_Consistent(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, ctid, cert := issueOne(t, s)
|
||||
require.NoError(t, s.IngestToLibrary(chain.RoleReviewer, maCode, ctid, "M", "陕西IPTV媒资库"))
|
||||
require.NoError(t, s.PublishToOperator(PublishRequest{MACode: maCode, Certificate: cert}))
|
||||
_, err := s.InjectToCDN(chain.RoleOperator, ctid, maCode, "filehash-abc", "CT-SX", "cdn://x")
|
||||
ccCode, ctid, cert := issueOne(t, s)
|
||||
require.NoError(t, s.IngestToLibrary(chain.RoleReviewer, ccCode, ctid, "M", "陕西IPTV媒资库"))
|
||||
require.NoError(t, s.PublishToOperator(PublishRequest{CCCode: ccCode, Certificate: cert}))
|
||||
_, err := s.InjectToCDN(chain.RoleOperator, ctid, ccCode, "filehash-abc", "CT-SX", "cdn://x")
|
||||
require.NoError(t, err)
|
||||
|
||||
rep := s.Accountability(maCode)
|
||||
rep := s.Accountability(ccCode)
|
||||
assert.True(t, rep.Consistent, "全链路应一致")
|
||||
assert.Nil(t, rep.FirstChange)
|
||||
assert.Equal(t, "filehash-abc", rep.BaselineHash)
|
||||
@@ -28,19 +28,19 @@ func TestAccountability_Consistent(t *testing.T) {
|
||||
// 注入环节偷换:追责定位到 cdn_inject 节点与运营商。
|
||||
func TestAccountability_TamperLocated(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, ctid, cert := issueOne(t, s)
|
||||
require.NoError(t, s.IngestToLibrary(chain.RoleReviewer, maCode, ctid, "M", "媒资库"))
|
||||
require.NoError(t, s.PublishToOperator(PublishRequest{MACode: maCode, Certificate: cert}))
|
||||
ccCode, ctid, cert := issueOne(t, s)
|
||||
require.NoError(t, s.IngestToLibrary(chain.RoleReviewer, ccCode, ctid, "M", "媒资库"))
|
||||
require.NoError(t, s.PublishToOperator(PublishRequest{CCCode: ccCode, Certificate: cert}))
|
||||
|
||||
// 直接往存证里记录一次"被偷换"的注入(绕过校验模拟违规留痕)
|
||||
// 实际中 InjectToCDN 会拒绝不匹配,但运营商侧若绕过校验偷换,存证仍会暴露
|
||||
_, _ = s.InjectToCDN(chain.RoleOperator, ctid, maCode, "filehash-abc", "CT-SX", "cdn://x")
|
||||
_, _ = s.InjectToCDN(chain.RoleOperator, ctid, ccCode, "filehash-abc", "CT-SX", "cdn://x")
|
||||
s.prov.Record(model.ProvenanceEvent{
|
||||
MACode: maCode, Node: model.NodeInject, HashValue: "TAMPERED",
|
||||
CCCode: ccCode, Node: model.NodeInject, HashValue: "TAMPERED",
|
||||
Operator: "CT-SX-违规", Detail: "疑似偷换",
|
||||
})
|
||||
|
||||
rep := s.Accountability(maCode)
|
||||
rep := s.Accountability(ccCode)
|
||||
assert.False(t, rep.Consistent)
|
||||
require.NotNil(t, rep.FirstChange)
|
||||
assert.Equal(t, model.NodeInject, rep.FirstChange.Node)
|
||||
@@ -50,22 +50,22 @@ func TestAccountability_TamperLocated(t *testing.T) {
|
||||
// 转码版哈希不同属合法,不应误判为篡改。
|
||||
func TestAccountability_TranscodeNotFlagged(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, ctid, _ := issueOne(t, s)
|
||||
ccCode, ctid, _ := issueOne(t, s)
|
||||
_, err := s.BindTranscoded(chain.RoleReviewer, ctid, "filehash-abc", "h265-4k", "H.265", "4K", "v1-4k")
|
||||
require.NoError(t, err)
|
||||
s.prov.Record(model.ProvenanceEvent{MACode: maCode, Node: model.NodeTranscode, HashValue: "h265-4k", Operator: "转码中心"})
|
||||
s.prov.Record(model.ProvenanceEvent{CCCode: ccCode, Node: model.NodeTranscode, HashValue: "h265-4k", Operator: "转码中心"})
|
||||
|
||||
rep := s.Accountability(maCode)
|
||||
rep := s.Accountability(ccCode)
|
||||
assert.True(t, rep.Consistent, "转码版哈希不同属合法,不应判为篡改")
|
||||
}
|
||||
|
||||
func TestCopyrightEvidence(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, _, _ := issueOne(t, s)
|
||||
ccCode, _, _ := issueOne(t, s)
|
||||
|
||||
ev, err := s.CopyrightEvidence(maCode)
|
||||
ev, err := s.CopyrightEvidence(ccCode)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, maCode, ev.MACode)
|
||||
assert.Equal(t, ccCode, ev.CCCode)
|
||||
assert.NotEmpty(t, ev.Trail)
|
||||
assert.False(t, ev.FirstSeenAt.IsZero(), "应有最早登记时间戳")
|
||||
assert.Contains(t, ev.Statement, "谁先锁定谁有权")
|
||||
@@ -86,7 +86,7 @@ func TestMatchInfringement(t *testing.T) {
|
||||
matches, err := s.MatchInfringement("a1b2c3d4e5f60718", 5, 10)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, matches)
|
||||
assert.Equal(t, issued.MACode, matches[0].MACode)
|
||||
assert.Equal(t, issued.CCCode, matches[0].CCCode)
|
||||
assert.Equal(t, "high", matches[0].Similarity)
|
||||
assert.Equal(t, 0, matches[0].Distance)
|
||||
|
||||
|
||||
@@ -32,8 +32,8 @@ func (s *Service) BindTranscoded(role chain.Role, ctid, parentFileHash, transcod
|
||||
// ---- 工作包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)
|
||||
func (s *Service) IngestToLibrary(role chain.Role, ccCode, ctid, mediaAssetID, libName string) error {
|
||||
c, err := s.chain.QueryContent(ccCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -49,19 +49,19 @@ func (s *Service) IngestToLibrary(role chain.Role, maCode, ctid, mediaAssetID, l
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
s.prov.Record(model.ProvenanceEvent{MACode: maCode, Node: model.NodeIngest, Operator: libName, Detail: "审合格入媒资库"})
|
||||
return s.chain.SetContentStatus(maCode, model.StatusInLibrary)
|
||||
s.prov.Record(model.ProvenanceEvent{CCCode: ccCode, Node: model.NodeIngest, Operator: libName, Detail: "审合格入媒资库"})
|
||||
return s.chain.SetContentStatus(ccCode, model.StatusInLibrary)
|
||||
}
|
||||
|
||||
// PublishRequest 从媒资库向运营商发布的请求(需求6-AC4)。
|
||||
type PublishRequest struct {
|
||||
MACode string
|
||||
Certificate string // 必须携带 MA码+哈希证书
|
||||
CCCode string
|
||||
Certificate string // 必须携带 CC码+哈希证书
|
||||
}
|
||||
|
||||
// PublishToOperator 校验证书后将内容置为已发布(需求6-AC4/AC5、需求3-AC8)。
|
||||
func (s *Service) PublishToOperator(req PublishRequest) error {
|
||||
c, err := s.chain.QueryContent(req.MACode)
|
||||
c, err := s.chain.QueryContent(req.CCCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -69,10 +69,10 @@ func (s *Service) PublishToOperator(req PublishRequest) error {
|
||||
return ErrNotApproved
|
||||
}
|
||||
// 发布必须携带证书(含 MA 码)
|
||||
if req.Certificate == "" || !certContainsMA(req.Certificate, req.MACode) {
|
||||
if req.Certificate == "" || !certContainsMA(req.Certificate, req.CCCode) {
|
||||
return ErrNoCertificate
|
||||
}
|
||||
return s.chain.SetContentStatus(req.MACode, model.StatusPublished)
|
||||
return s.chain.SetContentStatus(req.CCCode, model.StatusPublished)
|
||||
}
|
||||
|
||||
// ---- 工作包9:CDN 注入校验(需求7) ----
|
||||
@@ -85,9 +85,9 @@ type InjectResult struct {
|
||||
}
|
||||
|
||||
// InjectToCDN 运营商注入 CDN 前校验哈希;匹配则放行并注册运营商映射(需求7-AC1~AC4)。
|
||||
func (s *Service) InjectToCDN(role chain.Role, ctid, maCode, injectFileHash, operatorID, cdnEndpoint string) (InjectResult, error) {
|
||||
func (s *Service) InjectToCDN(role chain.Role, ctid, ccCode, injectFileHash, operatorID, cdnEndpoint string) (InjectResult, error) {
|
||||
// 内容须处于已发布状态
|
||||
c, err := s.chain.QueryContent(maCode)
|
||||
c, err := s.chain.QueryContent(ccCode)
|
||||
if err != nil {
|
||||
return InjectResult{}, err
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func (s *Service) InjectToCDN(role chain.Role, ctid, maCode, injectFileHash, ope
|
||||
return InjectResult{Allowed: false, Reason: "内容已下架"}, ErrNotApproved
|
||||
}
|
||||
|
||||
res, err := s.chain.VerifyHash(maCode, injectFileHash)
|
||||
res, err := s.chain.VerifyHash(ccCode, injectFileHash)
|
||||
if err != nil {
|
||||
return InjectResult{Allowed: false, Reason: err.Error()}, err
|
||||
}
|
||||
@@ -105,7 +105,7 @@ func (s *Service) InjectToCDN(role chain.Role, ctid, maCode, injectFileHash, ope
|
||||
}
|
||||
|
||||
// 授权核验(需求25-AC2/AC3):若已登记授权,校验该运营商是否在授权平台内
|
||||
if authRes := s.CheckAuthorization(maCode, "", operatorID); !authRes.Allowed {
|
||||
if authRes := s.CheckAuthorization(ccCode, "", operatorID); !authRes.Allowed {
|
||||
return InjectResult{Allowed: false, Reason: authRes.Reason}, ErrNotApproved
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ func (s *Service) InjectToCDN(role chain.Role, ctid, maCode, injectFileHash, ope
|
||||
}); err != nil {
|
||||
return InjectResult{}, err
|
||||
}
|
||||
s.prov.Record(model.ProvenanceEvent{MACode: maCode, Node: model.NodeInject, HashValue: injectFileHash, Operator: operatorID, Detail: "CDN 注入校验通过"})
|
||||
s.prov.Record(model.ProvenanceEvent{CCCode: ccCode, Node: model.NodeInject, HashValue: injectFileHash, Operator: operatorID, Detail: "CDN 注入校验通过"})
|
||||
return InjectResult{Allowed: true, DistributionID: distID}, nil
|
||||
}
|
||||
|
||||
@@ -159,28 +159,28 @@ func (s *Service) ReportVersionChange(ctid, reason, prevHash, newHash string, ol
|
||||
// ---- 工作包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)
|
||||
func (s *Service) Takedown(role chain.Role, ccCode, reason string) (chain.MappingsResult, error) {
|
||||
return s.chain.Revoke(role, ccCode, reason)
|
||||
}
|
||||
|
||||
// TakedownEpisode 集级下架:只下架指定集,整剧其他集继续流通(仅监管主体)。
|
||||
func (s *Service) TakedownEpisode(role chain.Role, maCode string, episode int, reason string) error {
|
||||
return s.chain.RevokeEpisode(role, maCode, episode, reason)
|
||||
func (s *Service) TakedownEpisode(role chain.Role, ccCode string, episode int, reason string) error {
|
||||
return s.chain.RevokeEpisode(role, ccCode, episode, reason)
|
||||
}
|
||||
|
||||
// Restore 恢复上架整剧(仅监管主体)。
|
||||
func (s *Service) Restore(role chain.Role, maCode string) error {
|
||||
return s.chain.Restore(role, maCode)
|
||||
func (s *Service) Restore(role chain.Role, ccCode string) error {
|
||||
return s.chain.Restore(role, ccCode)
|
||||
}
|
||||
|
||||
// RestoreEpisode 恢复上架指定集(仅监管主体)。
|
||||
func (s *Service) RestoreEpisode(role chain.Role, maCode string, episode int) error {
|
||||
return s.chain.RestoreEpisode(role, maCode, episode)
|
||||
func (s *Service) RestoreEpisode(role chain.Role, ccCode string, episode int) error {
|
||||
return s.chain.RestoreEpisode(role, ccCode, episode)
|
||||
}
|
||||
|
||||
// certContainsMA 校验证书是否包含指定 MA 码。
|
||||
func certContainsMA(cert, maCode string) bool {
|
||||
return cert != "" && maCode != "" && strings.Contains(cert, maCode)
|
||||
func certContainsMA(cert, ccCode string) bool {
|
||||
return cert != "" && ccCode != "" && strings.Contains(cert, ccCode)
|
||||
}
|
||||
|
||||
// ---- 二期 F09/F18:数据回传聚合与分账(需求9/需求21) ----
|
||||
@@ -190,7 +190,7 @@ func certContainsMA(cert, maCode string) bool {
|
||||
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)
|
||||
c, err := s.chain.QueryContent(e.CCCode)
|
||||
if err != nil || c.Status == model.StatusRevoked {
|
||||
rejected++
|
||||
continue
|
||||
@@ -202,40 +202,40 @@ func (s *Service) ReportPlayback(events []model.PlaybackEvent) (accepted int, re
|
||||
}
|
||||
|
||||
// PlaybackSummary 查询按 MA 码聚合的可信播放数据(需求9-AC2/AC3)。
|
||||
func (s *Service) PlaybackSummary(maCode string) model.PlaybackSummary {
|
||||
return s.pb.Summary(maCode)
|
||||
func (s *Service) PlaybackSummary(ccCode string) model.PlaybackSummary {
|
||||
return s.pb.Summary(ccCode)
|
||||
}
|
||||
|
||||
// ComputeSettlement 基于可信播放数据计算分账(需求21-AC3)。
|
||||
func (s *Service) ComputeSettlement(maCode, period string) (model.Settlement, error) {
|
||||
if _, err := s.chain.QueryContent(maCode); err != nil {
|
||||
func (s *Service) ComputeSettlement(ccCode, period string) (model.Settlement, error) {
|
||||
if _, err := s.chain.QueryContent(ccCode); err != nil {
|
||||
return model.Settlement{}, err
|
||||
}
|
||||
return s.pb.ComputeSettlement(maCode, period, model.DefaultShareConfig())
|
||||
return s.pb.ComputeSettlement(ccCode, period, model.DefaultShareConfig())
|
||||
}
|
||||
|
||||
// ---- 二期 F19/F20:追责取证与确权举证(需求22/23) ----
|
||||
|
||||
// Provenance 返回某 MA 码的全链路存证(需求22-AC1)。
|
||||
func (s *Service) Provenance(maCode string) []model.ProvenanceEvent {
|
||||
return s.prov.Trail(maCode)
|
||||
func (s *Service) Provenance(ccCode string) []model.ProvenanceEvent {
|
||||
return s.prov.Trail(ccCode)
|
||||
}
|
||||
|
||||
// Accountability 责任界定取证:定位首次哈希变化节点与责任方(需求22-AC2)。
|
||||
func (s *Service) Accountability(maCode string) model.AccountabilityReport {
|
||||
return s.prov.Accountability(maCode)
|
||||
func (s *Service) Accountability(ccCode string) model.AccountabilityReport {
|
||||
return s.prov.Accountability(ccCode)
|
||||
}
|
||||
|
||||
// CopyrightEvidence 导出版权确权证据链(需求23-AC1/AC2)。
|
||||
func (s *Service) CopyrightEvidence(maCode string) (model.CopyrightEvidence, error) {
|
||||
c, err := s.chain.QueryContent(maCode)
|
||||
func (s *Service) CopyrightEvidence(ccCode string) (model.CopyrightEvidence, error) {
|
||||
c, err := s.chain.QueryContent(ccCode)
|
||||
if err != nil {
|
||||
return model.CopyrightEvidence{}, err
|
||||
}
|
||||
trail := s.prov.Trail(maCode)
|
||||
trail := s.prov.Trail(ccCode)
|
||||
ev := model.CopyrightEvidence{
|
||||
MACode: maCode, Title: c.Title, Issuer: c.Issuer, IssueDate: c.IssueDate,
|
||||
ContentHash: c.FileHash, ChainAnchor: "chain://" + maCode, Trail: trail,
|
||||
CCCode: ccCode, Title: c.Title, Issuer: c.Issuer, IssueDate: c.IssueDate,
|
||||
ContentHash: c.FileHash, ChainAnchor: "chain://" + ccCode, Trail: trail,
|
||||
Statement: "本证据链由 MA 码、内容哈希与上链时间戳构成,遵循『谁先锁定谁有权』,不可抵赖,可用于侵权投诉与司法举证。",
|
||||
}
|
||||
for _, e := range trail {
|
||||
@@ -271,7 +271,7 @@ func (s *Service) MatchInfringement(perceptual string, high, medium int) ([]mode
|
||||
if d <= high {
|
||||
sim = "high"
|
||||
}
|
||||
out = append(out, model.InfringeMatch{MACode: ma, Title: e.Title, Distance: d, Similarity: sim})
|
||||
out = append(out, model.InfringeMatch{CCCode: ma, Title: e.Title, Distance: d, Similarity: sim})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
@@ -280,13 +280,13 @@ func (s *Service) MatchInfringement(perceptual string, high, medium int) ([]mode
|
||||
// ---- 二期 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 {
|
||||
func (s *Service) RecordAuthorization(ccCode string, regions, platforms []string, expiry time.Time) error {
|
||||
if _, err := s.chain.QueryContent(ccCode); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.auths[maCode] = model.Authorization{
|
||||
MACode: maCode, Regions: regions, Platforms: platforms,
|
||||
s.auths[ccCode] = model.Authorization{
|
||||
CCCode: ccCode, Regions: regions, Platforms: platforms,
|
||||
ExpiryAt: expiry, GrantedAt: time.Now(),
|
||||
}
|
||||
s.mu.Unlock()
|
||||
@@ -295,9 +295,9 @@ func (s *Service) RecordAuthorization(maCode string, regions, platforms []string
|
||||
|
||||
// CheckAuthorization 核验某地域/平台是否在授权范围内(需求25-AC2/AC3)。
|
||||
// 未登记授权时默认放行(向后兼容);登记后超地域/过期/非授权平台拦截。
|
||||
func (s *Service) CheckAuthorization(maCode, region, platform string) model.AuthCheckResult {
|
||||
func (s *Service) CheckAuthorization(ccCode, region, platform string) model.AuthCheckResult {
|
||||
s.mu.Lock()
|
||||
a, ok := s.auths[maCode]
|
||||
a, ok := s.auths[ccCode]
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
return model.AuthCheckResult{Allowed: true, Reason: "未登记授权限制"}
|
||||
@@ -317,8 +317,8 @@ func (s *Service) CheckAuthorization(maCode, region, platform string) model.Auth
|
||||
// ---- 二期 F21:追更与增量哈希更新(需求24) ----
|
||||
|
||||
// AddEpisodes 追更:为已发码剧追加新集哈希,不触发存量重审、不重新发码(需求24-AC4)。
|
||||
func (s *Service) AddEpisodes(role chain.Role, maCode string, episodes []model.EpisodeHash) error {
|
||||
c, err := s.chain.QueryContent(maCode)
|
||||
func (s *Service) AddEpisodes(role chain.Role, ccCode string, episodes []model.EpisodeHash) error {
|
||||
c, err := s.chain.QueryContent(ccCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -336,7 +336,7 @@ func (s *Service) AddEpisodes(role chain.Role, maCode string, episodes []model.E
|
||||
}
|
||||
}
|
||||
s.prov.Record(model.ProvenanceEvent{
|
||||
MACode: maCode, Node: model.NodeSubmit,
|
||||
CCCode: ccCode, Node: model.NodeSubmit,
|
||||
Operator: "追更", Detail: "追加新集,增量赋码",
|
||||
})
|
||||
return nil
|
||||
@@ -345,26 +345,26 @@ func (s *Service) AddEpisodes(role chain.Role, maCode string, episodes []model.E
|
||||
// ---- 二期 F13:跨省复用快速准入(需求13) ----
|
||||
|
||||
// Blacklist 将 MA 码加入黑名单(用于跨省校验)。
|
||||
func (s *Service) Blacklist(maCode string) {
|
||||
func (s *Service) Blacklist(ccCode string) {
|
||||
s.mu.Lock()
|
||||
s.black[maCode] = true
|
||||
s.black[ccCode] = true
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// CrossProvinceAdmit B 省凭 MA 码+哈希证书快速准入(需求13)。
|
||||
// 三重校验:MA 码有效 + 哈希与原过审版一致 + 非黑名单。
|
||||
func (s *Service) CrossProvinceAdmit(maCode, fileHash, province string) model.CrossProvinceResult {
|
||||
func (s *Service) CrossProvinceAdmit(ccCode, fileHash, province string) model.CrossProvinceResult {
|
||||
res := model.CrossProvinceResult{}
|
||||
|
||||
// 1. MA 码有效
|
||||
if _, err := s.chain.QueryContent(maCode); err != nil {
|
||||
if _, err := s.chain.QueryContent(ccCode); err != nil {
|
||||
res.Reason = "MA 码无效或不存在"
|
||||
return res
|
||||
}
|
||||
res.MACodeValid = true
|
||||
res.CCCodeValid = true
|
||||
|
||||
// 2. 哈希与原过审版一致
|
||||
vr, err := s.chain.VerifyHash(maCode, fileHash)
|
||||
vr, err := s.chain.VerifyHash(ccCode, fileHash)
|
||||
if err != nil || !vr.Match {
|
||||
res.Reason = "哈希与原过审版不一致"
|
||||
return res
|
||||
@@ -373,7 +373,7 @@ func (s *Service) CrossProvinceAdmit(maCode, fileHash, province string) model.Cr
|
||||
|
||||
// 3. 非黑名单
|
||||
s.mu.Lock()
|
||||
bl := s.black[maCode]
|
||||
bl := s.black[ccCode]
|
||||
s.mu.Unlock()
|
||||
if bl {
|
||||
res.Reason = "内容在黑名单中"
|
||||
@@ -391,8 +391,8 @@ func (s *Service) CrossProvinceAdmit(maCode, fileHash, province string) model.Cr
|
||||
// ---- 二期 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)
|
||||
func (s *Service) TerminalVerifySegment(ccCode string, episode int, segHash string) (bool, string) {
|
||||
res, err := s.chain.VerifyEpisodeHash(ccCode, episode, segHash)
|
||||
if err != nil {
|
||||
return false, "无法校验:" + err.Error()
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/tcs-iptv/tcs/internal/hash"
|
||||
)
|
||||
|
||||
// issueOne 完成一次"送审→CSPS审核→发码签发",返回 maCode、ctid、证书。
|
||||
// issueOne 完成一次"送审→CSPS审核→发码签发",返回 ccCode、ctid、证书。
|
||||
func issueOne(t *testing.T, s *Service) (string, string, string) {
|
||||
t.Helper()
|
||||
sub, err := s.SubmitForReview(sampleSub())
|
||||
@@ -17,19 +17,19 @@ func issueOne(t *testing.T, s *Service) (string, string, string) {
|
||||
require.NoError(t, s.ReviewCSPS(sub.ReviewID, true, "reviewer-1")) // 审核在前
|
||||
issued, err := s.ApproveAndIssue(chain.RoleRegulator, sub.ReviewID, "北京市广播电视局")
|
||||
require.NoError(t, err)
|
||||
return issued.MACode, issued.ContentTwinID, issued.Certificate
|
||||
return issued.CCCode, issued.ContentTwinID, issued.Certificate
|
||||
}
|
||||
|
||||
func TestCSPSAndTranscode(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, ctid, _ := issueOne(t, s)
|
||||
ccCode, ctid, _ := issueOne(t, s)
|
||||
|
||||
_, err := s.BindTranscoded(chain.RoleReviewer, ctid, "filehash-abc",
|
||||
"transcoded-h265-4k", "H.265", "3840x2160", "v1.0-4k")
|
||||
require.NoError(t, err)
|
||||
|
||||
// 转码版也能验真通过
|
||||
res, err := s.Verify(maCode, "transcoded-h265-4k")
|
||||
res, err := s.Verify(ccCode, "transcoded-h265-4k")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Match)
|
||||
}
|
||||
@@ -55,33 +55,33 @@ func TestIssueRequiresCSPSApproval(t *testing.T) {
|
||||
|
||||
func TestIngestAndPublish(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, ctid, cert := issueOne(t, s)
|
||||
ccCode, ctid, cert := issueOne(t, s)
|
||||
|
||||
require.NoError(t, s.IngestToLibrary(chain.RoleReviewer, maCode, ctid, "MEDIA-001", "广东IPTV媒资库"))
|
||||
require.NoError(t, s.IngestToLibrary(chain.RoleReviewer, ccCode, ctid, "MEDIA-001", "广东IPTV媒资库"))
|
||||
|
||||
// 无证书发布被拒
|
||||
err := s.PublishToOperator(PublishRequest{MACode: maCode, Certificate: ""})
|
||||
err := s.PublishToOperator(PublishRequest{CCCode: ccCode, Certificate: ""})
|
||||
assert.ErrorIs(t, err, ErrNoCertificate)
|
||||
|
||||
// 携带证书发布成功
|
||||
require.NoError(t, s.PublishToOperator(PublishRequest{MACode: maCode, Certificate: cert}))
|
||||
require.NoError(t, s.PublishToOperator(PublishRequest{CCCode: ccCode, Certificate: cert}))
|
||||
}
|
||||
|
||||
func TestInjectToCDN_MatchAndMismatch(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, ctid, cert := issueOne(t, s)
|
||||
require.NoError(t, s.IngestToLibrary(chain.RoleReviewer, maCode, ctid, "MEDIA-001", "媒资库"))
|
||||
require.NoError(t, s.PublishToOperator(PublishRequest{MACode: maCode, Certificate: cert}))
|
||||
ccCode, ctid, cert := issueOne(t, s)
|
||||
require.NoError(t, s.IngestToLibrary(chain.RoleReviewer, ccCode, ctid, "MEDIA-001", "媒资库"))
|
||||
require.NoError(t, s.PublishToOperator(PublishRequest{CCCode: ccCode, Certificate: cert}))
|
||||
|
||||
// 哈希匹配 → 允许注入
|
||||
res, err := s.InjectToCDN(chain.RoleOperator, ctid, maCode, "filehash-abc",
|
||||
res, err := s.InjectToCDN(chain.RoleOperator, ctid, ccCode, "filehash-abc",
|
||||
"CT-IPTV-GD", "cdn://ct-gd/iptv/vod/008923")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Allowed)
|
||||
assert.NotEmpty(t, res.DistributionID)
|
||||
|
||||
// 哈希不匹配 → 拒绝注入
|
||||
res, err = s.InjectToCDN(chain.RoleOperator, ctid, maCode, "tampered-hash",
|
||||
res, err = s.InjectToCDN(chain.RoleOperator, ctid, ccCode, "tampered-hash",
|
||||
"CT-IPTV-GD", "cdn://x")
|
||||
assert.ErrorIs(t, err, ErrHashMismatch)
|
||||
assert.False(t, res.Allowed)
|
||||
@@ -89,30 +89,30 @@ func TestInjectToCDN_MatchAndMismatch(t *testing.T) {
|
||||
|
||||
func TestInjectToCDN_RevokedBlocked(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, ctid, cert := issueOne(t, s)
|
||||
require.NoError(t, s.IngestToLibrary(chain.RoleReviewer, maCode, ctid, "MEDIA-001", "媒资库"))
|
||||
require.NoError(t, s.PublishToOperator(PublishRequest{MACode: maCode, Certificate: cert}))
|
||||
ccCode, ctid, cert := issueOne(t, s)
|
||||
require.NoError(t, s.IngestToLibrary(chain.RoleReviewer, ccCode, ctid, "MEDIA-001", "媒资库"))
|
||||
require.NoError(t, s.PublishToOperator(PublishRequest{CCCode: ccCode, Certificate: cert}))
|
||||
|
||||
// 下架后不得注入
|
||||
_, err := s.Takedown(chain.RoleRegulator, maCode, "违规")
|
||||
_, err := s.Takedown(chain.RoleRegulator, ccCode, "违规")
|
||||
require.NoError(t, err)
|
||||
_, err = s.InjectToCDN(chain.RoleOperator, ctid, maCode, "filehash-abc", "OP", "cdn://x")
|
||||
_, err = s.InjectToCDN(chain.RoleOperator, ctid, ccCode, "filehash-abc", "OP", "cdn://x")
|
||||
assert.ErrorIs(t, err, ErrNotApproved)
|
||||
}
|
||||
|
||||
func TestTakedown_ResolvesMappings(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, ctid, cert := issueOne(t, s)
|
||||
require.NoError(t, s.IngestToLibrary(chain.RoleReviewer, maCode, ctid, "MEDIA-001", "媒资库"))
|
||||
require.NoError(t, s.PublishToOperator(PublishRequest{MACode: maCode, Certificate: cert}))
|
||||
_, _ = s.InjectToCDN(chain.RoleOperator, ctid, maCode, "filehash-abc", "CT-IPTV-GD", "cdn://ct-gd/vod/1")
|
||||
ccCode, ctid, cert := issueOne(t, s)
|
||||
require.NoError(t, s.IngestToLibrary(chain.RoleReviewer, ccCode, ctid, "MEDIA-001", "媒资库"))
|
||||
require.NoError(t, s.PublishToOperator(PublishRequest{CCCode: ccCode, Certificate: cert}))
|
||||
_, _ = s.InjectToCDN(chain.RoleOperator, ctid, ccCode, "filehash-abc", "CT-IPTV-GD", "cdn://ct-gd/vod/1")
|
||||
|
||||
// 非监管主体不得下架
|
||||
_, err := s.Takedown(chain.RoleOperator, maCode, "越权")
|
||||
_, err := s.Takedown(chain.RoleOperator, ccCode, "越权")
|
||||
assert.ErrorIs(t, err, chain.ErrPermissionDenied)
|
||||
|
||||
// 监管下架,解析出 CDN 端点
|
||||
res, err := s.Takedown(chain.RoleRegulator, maCode, "违规")
|
||||
res, err := s.Takedown(chain.RoleRegulator, ccCode, "违规")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, res.CDNEndpoints, "cdn://ct-gd/vod/1")
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tcs-iptv/tcs/internal/chain"
|
||||
"github.com/tcs-iptv/tcs/internal/macode"
|
||||
"github.com/tcs-iptv/tcs/internal/cccode"
|
||||
"github.com/tcs-iptv/tcs/internal/model"
|
||||
)
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestEpisodeLevel_OneSeriesOneCodeMultiEpisodeHash(t *testing.T) {
|
||||
})
|
||||
}
|
||||
sub := Submission{
|
||||
Title: "长安少年行", EpisodeCount: 24, Category: macode.CategoryMicroDrama,
|
||||
Title: "长安少年行", EpisodeCount: 24, Category: cccode.CategoryMicroDrama,
|
||||
FileHash: "series-root-hash", MerkleRoot: "series-merkle-root",
|
||||
Episodes: eps,
|
||||
CPMediaID: "XAQJSL-2026-001", CPName: "西安曲江丝路文化传播有限公司",
|
||||
@@ -36,20 +36,20 @@ func TestEpisodeLevel_OneSeriesOneCodeMultiEpisodeHash(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// 一剧一码
|
||||
assert.True(t, macode.IsValid(issued.MACode))
|
||||
assert.True(t, cccode.IsValid(issued.CCCode))
|
||||
|
||||
// 24 集哈希全部绑定在同一 MA 码下
|
||||
list, err := s.ListEpisodes(issued.MACode)
|
||||
list, err := s.ListEpisodes(issued.CCCode)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, list, 24)
|
||||
|
||||
// 按集验真:第 7 集正确哈希匹配
|
||||
res, err := s.VerifyEpisode(issued.MACode, 7, "ep-hash-"+string(rune('a'+7)))
|
||||
res, err := s.VerifyEpisode(issued.CCCode, 7, "ep-hash-"+string(rune('a'+7)))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Match)
|
||||
|
||||
// 第 7 集错误哈希 → 不匹配(疑似该集被替换)
|
||||
_, err = s.VerifyEpisode(issued.MACode, 7, "tampered-ep7")
|
||||
_, err = s.VerifyEpisode(issued.CCCode, 7, "tampered-ep7")
|
||||
assert.ErrorIs(t, err, ErrHashMismatch)
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestEpisodeTakedown(t *testing.T) {
|
||||
{Episode: 3, FileSHA256: "h3"}, {Episode: 4, FileSHA256: "h4"},
|
||||
}
|
||||
sub := Submission{
|
||||
Title: "多集剧", EpisodeCount: 4, Category: macode.CategoryMicroDrama,
|
||||
Title: "多集剧", EpisodeCount: 4, Category: cccode.CategoryMicroDrama,
|
||||
FileHash: "series-h", MerkleRoot: "series-mr", Episodes: eps,
|
||||
}
|
||||
r, err := s.SubmitForReview(sub)
|
||||
@@ -72,13 +72,13 @@ func TestEpisodeTakedown(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// 运营商无权集级下架
|
||||
err = s.TakedownEpisode(chain.RoleOperator, issued.MACode, 3, "第3集违规")
|
||||
err = s.TakedownEpisode(chain.RoleOperator, issued.CCCode, 3, "第3集违规")
|
||||
assert.ErrorIs(t, err, chain.ErrPermissionDenied)
|
||||
|
||||
// 监管下架第3集
|
||||
require.NoError(t, s.TakedownEpisode(chain.RoleRegulator, issued.MACode, 3, "第3集违规"))
|
||||
require.NoError(t, s.TakedownEpisode(chain.RoleRegulator, issued.CCCode, 3, "第3集违规"))
|
||||
|
||||
list, err := s.ListEpisodes(issued.MACode)
|
||||
list, err := s.ListEpisodes(issued.CCCode)
|
||||
require.NoError(t, err)
|
||||
for _, b := range list {
|
||||
if b.Episode == 3 {
|
||||
@@ -89,18 +89,18 @@ func TestEpisodeTakedown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 集级子标识:MA码#E07 解析与生成。
|
||||
// 集级子标识:CC码#E07 解析与生成。
|
||||
func TestEpisodeSubID(t *testing.T) {
|
||||
ma := "MA.156.8531.6101/WD/20260000001"
|
||||
sub := macode.EpisodeSubID(ma, 7)
|
||||
sub := cccode.EpisodeSubID(ma, 7)
|
||||
assert.Equal(t, "MA.156.8531.6101/WD/20260000001#E07", sub)
|
||||
|
||||
parsedMA, ep := macode.ParseEpisodeSubID(sub)
|
||||
parsedMA, ep := cccode.ParseEpisodeSubID(sub)
|
||||
assert.Equal(t, ma, parsedMA)
|
||||
assert.Equal(t, 7, ep)
|
||||
|
||||
// 无后缀 → 整剧(episode 0)
|
||||
parsedMA2, ep2 := macode.ParseEpisodeSubID(ma)
|
||||
parsedMA2, ep2 := cccode.ParseEpisodeSubID(ma)
|
||||
assert.Equal(t, ma, parsedMA2)
|
||||
assert.Equal(t, 0, ep2)
|
||||
}
|
||||
@@ -116,12 +116,12 @@ func TestSingleContent_NoEpisodes(t *testing.T) {
|
||||
issued, err := s.ApproveAndIssue(chain.RoleRegulator, r.ReviewID, "陕西IPTV运营公司")
|
||||
require.NoError(t, err)
|
||||
|
||||
list, err := s.ListEpisodes(issued.MACode)
|
||||
list, err := s.ListEpisodes(issued.CCCode)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, list, "单体内容无集级绑定")
|
||||
|
||||
// 整剧验真仍可用
|
||||
res, err := s.Verify(issued.MACode, sub.FileHash)
|
||||
res, err := s.Verify(issued.CCCode, sub.FileHash)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Match)
|
||||
}
|
||||
|
||||
@@ -14,38 +14,38 @@ import (
|
||||
|
||||
func TestAuthorization_PlatformGate(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, ctid, cert := issueOne(t, s)
|
||||
require.NoError(t, s.IngestToLibrary(chain.RoleReviewer, maCode, ctid, "M", "媒资库"))
|
||||
require.NoError(t, s.PublishToOperator(PublishRequest{MACode: maCode, Certificate: cert}))
|
||||
ccCode, ctid, cert := issueOne(t, s)
|
||||
require.NoError(t, s.IngestToLibrary(chain.RoleReviewer, ccCode, ctid, "M", "媒资库"))
|
||||
require.NoError(t, s.PublishToOperator(PublishRequest{CCCode: ccCode, Certificate: cert}))
|
||||
|
||||
// 仅授权 CT-SX
|
||||
require.NoError(t, s.RecordAuthorization(maCode, nil, []string{"CT-SX"}, time.Time{}))
|
||||
require.NoError(t, s.RecordAuthorization(ccCode, nil, []string{"CT-SX"}, time.Time{}))
|
||||
|
||||
// 授权平台注入通过
|
||||
_, err := s.InjectToCDN(chain.RoleOperator, ctid, maCode, "filehash-abc", "CT-SX", "cdn://ct")
|
||||
_, err := s.InjectToCDN(chain.RoleOperator, ctid, ccCode, "filehash-abc", "CT-SX", "cdn://ct")
|
||||
require.NoError(t, err)
|
||||
|
||||
// 非授权平台注入被拒
|
||||
_, err = s.InjectToCDN(chain.RoleOperator, ctid, maCode, "filehash-abc", "CM-SX", "cdn://cm")
|
||||
_, err = s.InjectToCDN(chain.RoleOperator, ctid, ccCode, "filehash-abc", "CM-SX", "cdn://cm")
|
||||
assert.ErrorIs(t, err, ErrNotApproved)
|
||||
}
|
||||
|
||||
func TestAuthorization_Expiry(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, _, _ := issueOne(t, s)
|
||||
ccCode, _, _ := issueOne(t, s)
|
||||
// 已过期授权
|
||||
require.NoError(t, s.RecordAuthorization(maCode, nil, nil, time.Now().Add(-time.Hour)))
|
||||
res := s.CheckAuthorization(maCode, "", "")
|
||||
require.NoError(t, s.RecordAuthorization(ccCode, nil, nil, time.Now().Add(-time.Hour)))
|
||||
res := s.CheckAuthorization(ccCode, "", "")
|
||||
assert.False(t, res.Allowed)
|
||||
assert.Contains(t, res.Reason, "过期")
|
||||
}
|
||||
|
||||
func TestAuthorization_RegionGate(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, _, _ := issueOne(t, s)
|
||||
require.NoError(t, s.RecordAuthorization(maCode, []string{"610000"}, nil, time.Time{}))
|
||||
assert.True(t, s.CheckAuthorization(maCode, "610000", "").Allowed) // 陕西
|
||||
assert.False(t, s.CheckAuthorization(maCode, "440000", "").Allowed) // 广东,超域
|
||||
ccCode, _, _ := issueOne(t, s)
|
||||
require.NoError(t, s.RecordAuthorization(ccCode, []string{"610000"}, nil, time.Time{}))
|
||||
assert.True(t, s.CheckAuthorization(ccCode, "610000", "").Allowed) // 陕西
|
||||
assert.False(t, s.CheckAuthorization(ccCode, "440000", "").Allowed) // 广东,超域
|
||||
}
|
||||
|
||||
// ---- F21 追更 ----
|
||||
@@ -61,16 +61,16 @@ func TestAddEpisodes_NoReissue(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// 追更第 3、4 集(不重新发码)
|
||||
require.NoError(t, s.AddEpisodes(chain.RoleReviewer, issued.MACode, []model.EpisodeHash{
|
||||
require.NoError(t, s.AddEpisodes(chain.RoleReviewer, issued.CCCode, []model.EpisodeHash{
|
||||
{Episode: 3, FileSHA256: "e3"}, {Episode: 4, FileSHA256: "e4"},
|
||||
}))
|
||||
|
||||
list, err := s.ListEpisodes(issued.MACode)
|
||||
list, err := s.ListEpisodes(issued.CCCode)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, list, 4, "应有 4 集")
|
||||
|
||||
// 新集可独立验真
|
||||
res, err := s.VerifyEpisode(issued.MACode, 3, "e3")
|
||||
res, err := s.VerifyEpisode(issued.CCCode, 3, "e3")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Match)
|
||||
}
|
||||
@@ -79,28 +79,28 @@ func TestAddEpisodes_NoReissue(t *testing.T) {
|
||||
|
||||
func TestCrossProvince_Admit(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, _, _ := issueOne(t, s) // sampleSub FileHash=filehash-abc
|
||||
ccCode, _, _ := issueOne(t, s) // sampleSub FileHash=filehash-abc
|
||||
|
||||
res := s.CrossProvinceAdmit(maCode, "filehash-abc", "610000")
|
||||
res := s.CrossProvinceAdmit(ccCode, "filehash-abc", "610000")
|
||||
assert.True(t, res.Admitted)
|
||||
assert.True(t, res.MACodeValid && res.HashConsistent && res.NotBlacklisted)
|
||||
assert.True(t, res.CCCodeValid && res.HashConsistent && res.NotBlacklisted)
|
||||
assert.NotEmpty(t, res.ProvinceFlowNo)
|
||||
}
|
||||
|
||||
func TestCrossProvince_HashMismatch(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, _, _ := issueOne(t, s)
|
||||
res := s.CrossProvinceAdmit(maCode, "tampered", "610000")
|
||||
ccCode, _, _ := issueOne(t, s)
|
||||
res := s.CrossProvinceAdmit(ccCode, "tampered", "610000")
|
||||
assert.False(t, res.Admitted)
|
||||
assert.True(t, res.MACodeValid)
|
||||
assert.True(t, res.CCCodeValid)
|
||||
assert.False(t, res.HashConsistent)
|
||||
}
|
||||
|
||||
func TestCrossProvince_Blacklisted(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, _, _ := issueOne(t, s)
|
||||
s.Blacklist(maCode)
|
||||
res := s.CrossProvinceAdmit(maCode, "filehash-abc", "610000")
|
||||
ccCode, _, _ := issueOne(t, s)
|
||||
s.Blacklist(ccCode)
|
||||
res := s.CrossProvinceAdmit(ccCode, "filehash-abc", "610000")
|
||||
assert.False(t, res.Admitted)
|
||||
assert.False(t, res.NotBlacklisted)
|
||||
assert.Contains(t, res.Reason, "黑名单")
|
||||
@@ -110,7 +110,7 @@ func TestCrossProvince_UnknownMA(t *testing.T) {
|
||||
s := newService(t)
|
||||
res := s.CrossProvinceAdmit("MA.156.8531.6101/WD/不存在", "h", "610000")
|
||||
assert.False(t, res.Admitted)
|
||||
assert.False(t, res.MACodeValid)
|
||||
assert.False(t, res.CCCodeValid)
|
||||
}
|
||||
|
||||
// ---- F08 终端抽检 ----
|
||||
@@ -124,10 +124,10 @@ func TestTerminalVerifySegment(t *testing.T) {
|
||||
issued, err := s.ApproveAndIssue(chain.RoleRegulator, r.ReviewID, "陕西IPTV")
|
||||
require.NoError(t, err)
|
||||
|
||||
ok, _ := s.TerminalVerifySegment(issued.MACode, 1, "seg1")
|
||||
ok, _ := s.TerminalVerifySegment(issued.CCCode, 1, "seg1")
|
||||
assert.True(t, ok)
|
||||
|
||||
ok, msg := s.TerminalVerifySegment(issued.MACode, 1, "tampered")
|
||||
ok, msg := s.TerminalVerifySegment(issued.CCCode, 1, "tampered")
|
||||
assert.False(t, ok)
|
||||
assert.Contains(t, msg, "断流")
|
||||
}
|
||||
|
||||
@@ -14,16 +14,16 @@ import (
|
||||
// 将 MA+哈希机制从 IPTV 扩展至 OTT、手机 APP,实现大小屏内容身份互通。
|
||||
// 对应任务:C.1 跨域解析网关、C.2 身份互通、B.2 扫码验真、D.1 跨屏权益子链。
|
||||
|
||||
// maCodePattern 匹配六段式 MA 码(含可选集级子标识 #Exx)。
|
||||
// ccCodePattern 匹配六段式 MA 码(含可选集级子标识 #Exx)。
|
||||
// 形如 MA.156.8531.6101/WD/20260000004 或 MA.156.8531.6101/WD/20260000004#E07
|
||||
var maCodePattern = regexp.MustCompile(
|
||||
var ccCodePattern = regexp.MustCompile(
|
||||
`^(MA)\.(\d{3})\.([0-9A-Za-z]+)\.([0-9A-Za-z]+)/([0-9A-Za-z]+)/(\d{4})(\d+)(?:#E(\d+))?$`)
|
||||
|
||||
// ParseMACode 解析六段式 MA 码(纯结构解析,不查链)。
|
||||
// ParseCCCode 解析六段式 MA 码(纯结构解析,不查链)。
|
||||
// 返回 Valid=false 表示结构不合法(可能是伪造/损坏的码)。
|
||||
func ParseMACode(maCode string) model.ParsedMA {
|
||||
p := model.ParsedMA{Raw: maCode}
|
||||
m := maCodePattern.FindStringSubmatch(strings.TrimSpace(maCode))
|
||||
func ParseCCCode(ccCode string) model.ParsedMA {
|
||||
p := model.ParsedMA{Raw: ccCode}
|
||||
m := ccCodePattern.FindStringSubmatch(strings.TrimSpace(ccCode))
|
||||
if m == nil {
|
||||
p.Valid = false
|
||||
return p
|
||||
@@ -44,19 +44,19 @@ func ParseMACode(maCode string) model.ParsedMA {
|
||||
return p
|
||||
}
|
||||
|
||||
// baseMACode 去除集级子标识 #Exx,返回整剧 MA 码(用于查链/查权益)。
|
||||
func baseMACode(maCode string) string {
|
||||
if i := strings.Index(maCode, "#"); i >= 0 {
|
||||
return maCode[:i]
|
||||
// baseCCCode 去除集级子标识 #Exx,返回整剧 MA 码(用于查链/查权益)。
|
||||
func baseCCCode(ccCode string) string {
|
||||
if i := strings.Index(ccCode, "#"); i >= 0 {
|
||||
return ccCode[:i]
|
||||
}
|
||||
return maCode
|
||||
return ccCode
|
||||
}
|
||||
|
||||
// Resolve 跨域解析网关(C.1/C.2):同一 MA 码在 IPTV/OTT/APP 统一解析。
|
||||
// 返回解析结构 + 流通状态 + 跨屏可用性,保证大小屏解析结果一致。
|
||||
func (s *Service) Resolve(maCode string) model.ResolveResult {
|
||||
res := model.ResolveResult{MACode: maCode}
|
||||
parsed := ParseMACode(maCode)
|
||||
func (s *Service) Resolve(ccCode string) model.ResolveResult {
|
||||
res := model.ResolveResult{CCCode: ccCode}
|
||||
parsed := ParseCCCode(ccCode)
|
||||
res.Parsed = parsed
|
||||
if !parsed.Valid {
|
||||
res.Resolved = false
|
||||
@@ -64,7 +64,7 @@ func (s *Service) Resolve(maCode string) model.ResolveResult {
|
||||
return res
|
||||
}
|
||||
|
||||
c, err := s.chain.QueryContent(baseMACode(maCode))
|
||||
c, err := s.chain.QueryContent(baseCCCode(ccCode))
|
||||
if err != nil {
|
||||
res.Resolved = false
|
||||
res.Message = "MA 码未在可信数据空间登记"
|
||||
@@ -89,9 +89,9 @@ func (s *Service) Resolve(maCode string) model.ResolveResult {
|
||||
}
|
||||
|
||||
// ScanVerify 用户扫码验真(B.2):验证内容 MA 码真伪、合规与流通状态,防盗版。
|
||||
func (s *Service) ScanVerify(maCode string) model.ScanVerifyResult {
|
||||
res := model.ScanVerifyResult{MACode: maCode}
|
||||
parsed := ParseMACode(maCode)
|
||||
func (s *Service) ScanVerify(ccCode string) model.ScanVerifyResult {
|
||||
res := model.ScanVerifyResult{CCCode: ccCode}
|
||||
parsed := ParseCCCode(ccCode)
|
||||
res.Parsed = parsed
|
||||
if !parsed.Valid {
|
||||
res.Authentic = false
|
||||
@@ -100,7 +100,7 @@ func (s *Service) ScanVerify(maCode string) model.ScanVerifyResult {
|
||||
return res
|
||||
}
|
||||
|
||||
c, err := s.chain.QueryContent(baseMACode(maCode))
|
||||
c, err := s.chain.QueryContent(baseCCCode(ccCode))
|
||||
if err != nil {
|
||||
res.Authentic = false
|
||||
res.Compliant = false
|
||||
@@ -127,14 +127,14 @@ func (s *Service) ScanVerify(maCode string) model.ScanVerifyResult {
|
||||
|
||||
// RecordPurchase 记录用户购买(D.1):用户在某一屏购买内容,记录跨屏权益。
|
||||
// 校验 MA 码有效、屏类型合法;同一用户对同一 MA 码重复购买不覆盖首次记录。
|
||||
func (s *Service) RecordPurchase(maCode, userHash string, screen model.ScreenType) (model.PurchaseRecord, error) {
|
||||
func (s *Service) RecordPurchase(ccCode, userHash string, screen model.ScreenType) (model.PurchaseRecord, error) {
|
||||
if userHash == "" {
|
||||
return model.PurchaseRecord{}, fmt.Errorf("service: 缺少用户标识")
|
||||
}
|
||||
if !model.ValidScreen(screen) {
|
||||
return model.PurchaseRecord{}, fmt.Errorf("service: 非法屏类型 %q(仅 iptv/ott/app)", screen)
|
||||
}
|
||||
base := baseMACode(maCode)
|
||||
base := baseCCCode(ccCode)
|
||||
if _, err := s.chain.QueryContent(base); err != nil {
|
||||
return model.PurchaseRecord{}, fmt.Errorf("service: MA 码无效或未登记: %w", err)
|
||||
}
|
||||
@@ -151,17 +151,17 @@ func (s *Service) RecordPurchase(maCode, userHash string, screen model.ScreenTyp
|
||||
return rec, nil
|
||||
}
|
||||
rec := model.PurchaseRecord{
|
||||
MACode: base, UserHash: userHash, Screen: screen, PurchasedAt: time.Now(),
|
||||
CCCode: base, UserHash: userHash, Screen: screen, PurchasedAt: time.Now(),
|
||||
}
|
||||
acct.Purchases[base] = rec
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// VerifyCrossScreenRights 跨屏权益核验(D.1):任一屏购买即全屏通看,不重复付费。
|
||||
func (s *Service) VerifyCrossScreenRights(maCode, userHash string, requestScreen model.ScreenType) model.CrossScreenRightsResult {
|
||||
base := baseMACode(maCode)
|
||||
func (s *Service) VerifyCrossScreenRights(ccCode, userHash string, requestScreen model.ScreenType) model.CrossScreenRightsResult {
|
||||
base := baseCCCode(ccCode)
|
||||
res := model.CrossScreenRightsResult{
|
||||
MACode: base, UserHash: userHash, RequestScreen: requestScreen,
|
||||
CCCode: base, UserHash: userHash, RequestScreen: requestScreen,
|
||||
}
|
||||
if !model.ValidScreen(requestScreen) {
|
||||
res.Entitled = false
|
||||
|
||||
@@ -10,20 +10,20 @@ import (
|
||||
)
|
||||
|
||||
// issueAndPublish 走完送审→审核→发码→入库→发布,返回已流通内容的 MA 码与 ctid。
|
||||
func issueAndPublish(t *testing.T, s *Service) (maCode, ctid string) {
|
||||
func issueAndPublish(t *testing.T, s *Service) (ccCode, ctid string) {
|
||||
t.Helper()
|
||||
sub, err := s.SubmitForReview(sampleSub())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.ReviewCSPS(sub.ReviewID, true, "rv-1"))
|
||||
issued, err := s.ApproveAndIssue(chain.RoleRegulator, sub.ReviewID, "陕西省广播电视局")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.IngestToLibrary(chain.RoleReviewer, issued.MACode, issued.ContentTwinID, "MA-001", "省媒资库"))
|
||||
require.NoError(t, s.PublishToOperator(PublishRequest{MACode: issued.MACode, Certificate: issued.Certificate}))
|
||||
return issued.MACode, issued.ContentTwinID
|
||||
require.NoError(t, s.IngestToLibrary(chain.RoleReviewer, issued.CCCode, issued.ContentTwinID, "MA-001", "省媒资库"))
|
||||
require.NoError(t, s.PublishToOperator(PublishRequest{CCCode: issued.CCCode, Certificate: issued.Certificate}))
|
||||
return issued.CCCode, issued.ContentTwinID
|
||||
}
|
||||
|
||||
func TestParseMACode_Valid(t *testing.T) {
|
||||
p := ParseMACode("MA.156.8531.6101/WD/20260000004")
|
||||
func TestParseCCCode_Valid(t *testing.T) {
|
||||
p := ParseCCCode("MA.156.8531.6101/WD/20260000004")
|
||||
assert.True(t, p.Valid)
|
||||
assert.Equal(t, "156", p.CountryCode)
|
||||
assert.Equal(t, "8531", p.IndustryNode)
|
||||
@@ -33,24 +33,24 @@ func TestParseMACode_Valid(t *testing.T) {
|
||||
assert.Equal(t, 0, p.Episode)
|
||||
}
|
||||
|
||||
func TestParseMACode_WithEpisode(t *testing.T) {
|
||||
p := ParseMACode("MA.156.8531.6101/WD/20260000004#E07")
|
||||
func TestParseCCCode_WithEpisode(t *testing.T) {
|
||||
p := ParseCCCode("MA.156.8531.6101/WD/20260000004#E07")
|
||||
assert.True(t, p.Valid)
|
||||
assert.Equal(t, 7, p.Episode)
|
||||
}
|
||||
|
||||
func TestParseMACode_Invalid(t *testing.T) {
|
||||
func TestParseCCCode_Invalid(t *testing.T) {
|
||||
for _, bad := range []string{"", "not-a-code", "MA.156", "XX.156.8531.6101/WD/20260000004"} {
|
||||
p := ParseMACode(bad)
|
||||
p := ParseCCCode(bad)
|
||||
assert.False(t, p.Valid, "应判定非法: %q", bad)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_PublishedAvailableAllScreens(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, _ := issueAndPublish(t, s)
|
||||
ccCode, _ := issueAndPublish(t, s)
|
||||
|
||||
r := s.Resolve(maCode)
|
||||
r := s.Resolve(ccCode)
|
||||
assert.True(t, r.Resolved)
|
||||
assert.True(t, r.InCirculation)
|
||||
assert.Equal(t, model.StatusPublished, r.Status)
|
||||
@@ -59,9 +59,9 @@ func TestResolve_PublishedAvailableAllScreens(t *testing.T) {
|
||||
|
||||
func TestResolve_EpisodeSubIDResolvesToBase(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, _ := issueAndPublish(t, s)
|
||||
ccCode, _ := issueAndPublish(t, s)
|
||||
|
||||
r := s.Resolve(maCode + "#E03")
|
||||
r := s.Resolve(ccCode + "#E03")
|
||||
assert.True(t, r.Resolved)
|
||||
assert.Equal(t, 3, r.Parsed.Episode)
|
||||
assert.True(t, r.InCirculation)
|
||||
@@ -83,11 +83,11 @@ func TestResolve_UnregisteredCode(t *testing.T) {
|
||||
|
||||
func TestResolve_RevokedNotInCirculation(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, _ := issueAndPublish(t, s)
|
||||
_, err := s.Takedown(chain.RoleRegulator, maCode, "违规")
|
||||
ccCode, _ := issueAndPublish(t, s)
|
||||
_, err := s.Takedown(chain.RoleRegulator, ccCode, "违规")
|
||||
require.NoError(t, err)
|
||||
|
||||
r := s.Resolve(maCode)
|
||||
r := s.Resolve(ccCode)
|
||||
assert.True(t, r.Resolved)
|
||||
assert.False(t, r.InCirculation)
|
||||
assert.Empty(t, r.Screens)
|
||||
@@ -95,9 +95,9 @@ func TestResolve_RevokedNotInCirculation(t *testing.T) {
|
||||
|
||||
func TestScanVerify_AuthenticAndCompliant(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, _ := issueAndPublish(t, s)
|
||||
ccCode, _ := issueAndPublish(t, s)
|
||||
|
||||
r := s.ScanVerify(maCode)
|
||||
r := s.ScanVerify(ccCode)
|
||||
assert.True(t, r.Authentic)
|
||||
assert.True(t, r.Compliant)
|
||||
}
|
||||
@@ -111,23 +111,23 @@ func TestScanVerify_FakeCode(t *testing.T) {
|
||||
|
||||
func TestScanVerify_RevokedNotCompliant(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, _ := issueAndPublish(t, s)
|
||||
_, err := s.Takedown(chain.RoleRegulator, maCode, "违规")
|
||||
ccCode, _ := issueAndPublish(t, s)
|
||||
_, err := s.Takedown(chain.RoleRegulator, ccCode, "违规")
|
||||
require.NoError(t, err)
|
||||
|
||||
r := s.ScanVerify(maCode)
|
||||
r := s.ScanVerify(ccCode)
|
||||
assert.True(t, r.Authentic, "下架仍是真码")
|
||||
assert.False(t, r.Compliant, "下架内容不合规")
|
||||
}
|
||||
|
||||
func TestRecordPurchase_InvalidScreenRejected(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, _ := issueAndPublish(t, s)
|
||||
_, err := s.RecordPurchase(maCode, "user-1", "smartwatch")
|
||||
ccCode, _ := issueAndPublish(t, s)
|
||||
_, err := s.RecordPurchase(ccCode, "user-1", "smartwatch")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRecordPurchase_UnknownMACodeRejected(t *testing.T) {
|
||||
func TestRecordPurchase_UnknownCCCodeRejected(t *testing.T) {
|
||||
s := newService(t)
|
||||
_, err := s.RecordPurchase("MA.156.8531.6101/WD/20269999999", "user-1", model.ScreenIPTV)
|
||||
assert.Error(t, err)
|
||||
@@ -135,38 +135,38 @@ func TestRecordPurchase_UnknownMACodeRejected(t *testing.T) {
|
||||
|
||||
func TestCrossScreenRights_BuyOnceWatchEverywhere(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, _ := issueAndPublish(t, s)
|
||||
ccCode, _ := issueAndPublish(t, s)
|
||||
|
||||
// 电视端购买
|
||||
rec, err := s.RecordPurchase(maCode, "user-1", model.ScreenIPTV)
|
||||
rec, err := s.RecordPurchase(ccCode, "user-1", model.ScreenIPTV)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.ScreenIPTV, rec.Screen)
|
||||
|
||||
// 手机端通看,不重复付费
|
||||
r := s.VerifyCrossScreenRights(maCode, "user-1", model.ScreenApp)
|
||||
r := s.VerifyCrossScreenRights(ccCode, "user-1", model.ScreenApp)
|
||||
assert.True(t, r.Entitled)
|
||||
assert.Equal(t, model.ScreenIPTV, r.PurchaseScreen)
|
||||
|
||||
// OTT 端通看
|
||||
r2 := s.VerifyCrossScreenRights(maCode, "user-1", model.ScreenOTT)
|
||||
r2 := s.VerifyCrossScreenRights(ccCode, "user-1", model.ScreenOTT)
|
||||
assert.True(t, r2.Entitled)
|
||||
}
|
||||
|
||||
func TestCrossScreenRights_NotPurchased(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, _ := issueAndPublish(t, s)
|
||||
r := s.VerifyCrossScreenRights(maCode, "user-x", model.ScreenApp)
|
||||
ccCode, _ := issueAndPublish(t, s)
|
||||
r := s.VerifyCrossScreenRights(ccCode, "user-x", model.ScreenApp)
|
||||
assert.False(t, r.Entitled)
|
||||
}
|
||||
|
||||
func TestRecordPurchase_IdempotentNoDoubleCharge(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, _ := issueAndPublish(t, s)
|
||||
ccCode, _ := issueAndPublish(t, s)
|
||||
|
||||
first, err := s.RecordPurchase(maCode, "user-1", model.ScreenIPTV)
|
||||
first, err := s.RecordPurchase(ccCode, "user-1", model.ScreenIPTV)
|
||||
require.NoError(t, err)
|
||||
// 在手机端再次"购买"同内容 → 返回首次记录,不新建(跨屏通兑)
|
||||
second, err := s.RecordPurchase(maCode, "user-1", model.ScreenApp)
|
||||
second, err := s.RecordPurchase(ccCode, "user-1", model.ScreenApp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, first.Screen, second.Screen, "重复购买应返回首次记录的购买屏")
|
||||
assert.Equal(t, first.PurchasedAt, second.PurchasedAt)
|
||||
@@ -174,10 +174,10 @@ func TestRecordPurchase_IdempotentNoDoubleCharge(t *testing.T) {
|
||||
|
||||
func TestCrossScreenRights_EpisodeSubIDSharesEntitlement(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, _ := issueAndPublish(t, s)
|
||||
_, err := s.RecordPurchase(maCode, "user-1", model.ScreenIPTV)
|
||||
ccCode, _ := issueAndPublish(t, s)
|
||||
_, err := s.RecordPurchase(ccCode, "user-1", model.ScreenIPTV)
|
||||
require.NoError(t, err)
|
||||
// 用集级子标识核验权益应归一到整剧 MA 码
|
||||
r := s.VerifyCrossScreenRights(maCode+"#E05", "user-1", model.ScreenApp)
|
||||
r := s.VerifyCrossScreenRights(ccCode+"#E05", "user-1", model.ScreenApp)
|
||||
assert.True(t, r.Entitled)
|
||||
}
|
||||
|
||||
@@ -24,44 +24,44 @@ func (s *Service) QueryByLibraryFileID(libraryFileID string) (model.ContentQuery
|
||||
|
||||
// ---- MA 合并/拆分(方案 MA 管理模块补足)----
|
||||
|
||||
// MergeMACodes 将多个 MA 码合并为一个主 MA 码(仅监管主体)。
|
||||
// MergeCCCodes 将多个 MA 码合并为一个主 MA 码(仅监管主体)。
|
||||
// 被合并的 MA 码的哈希绑定和映射迁移至主 MA 码,原 MA 码状态标记为 merged。
|
||||
// 全链路存证记录合并操作。
|
||||
func (s *Service) MergeMACodes(role chain.Role, req model.MergeRequest) (model.MergeResult, error) {
|
||||
func (s *Service) MergeCCCodes(role chain.Role, req model.MergeRequest) (model.MergeResult, error) {
|
||||
res, err := s.chain.MergeMA(role, req)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
// 记录存证
|
||||
s.prov.Record(model.ProvenanceEvent{
|
||||
MACode: req.PrimaryMACode,
|
||||
CCCode: req.PrimaryCCCode,
|
||||
Node: model.NodeIssue,
|
||||
Operator: req.Operator,
|
||||
Detail: "MA 码合并:将 " + joinMACodes(req.SecondaryMACodes) + " 合并入 " + req.PrimaryMACode + ",原因:" + req.Reason,
|
||||
Detail: "MA 码合并:将 " + joinCCCodes(req.SecondaryCCCodes) + " 合并入 " + req.PrimaryCCCode + ",原因:" + req.Reason,
|
||||
})
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// SplitMACode 将一个 MA 码拆分为多个独立 MA 码(仅监管主体)。
|
||||
// SplitCCCode 将一个 MA 码拆分为多个独立 MA 码(仅监管主体)。
|
||||
// 按集号将哈希绑定和映射迁移至新 MA 码,源 MA 码状态标记为 split。
|
||||
// 全链路存证记录拆分操作。
|
||||
func (s *Service) SplitMACode(role chain.Role, req model.SplitRequest) (model.SplitResult, error) {
|
||||
func (s *Service) SplitCCCode(role chain.Role, req model.SplitRequest) (model.SplitResult, error) {
|
||||
res, err := s.chain.SplitMA(role, req)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
// 记录存证
|
||||
s.prov.Record(model.ProvenanceEvent{
|
||||
MACode: req.SourceMACode,
|
||||
CCCode: req.SourceCCCode,
|
||||
Node: model.NodeIssue,
|
||||
Operator: req.Operator,
|
||||
Detail: "MA 码拆分:将 " + req.SourceMACode + " 拆分为 " + joinMACodes(res.NewMACodes) + ",原因:" + req.Reason,
|
||||
Detail: "MA 码拆分:将 " + req.SourceCCCode + " 拆分为 " + joinCCCodes(res.NewCCCodes) + ",原因:" + req.Reason,
|
||||
})
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// joinMACodes 将 MA 码列表拼接为逗号分隔的字符串。
|
||||
func joinMACodes(codes []string) string {
|
||||
// joinCCCodes 将 MA 码列表拼接为逗号分隔的字符串。
|
||||
func joinCCCodes(codes []string) string {
|
||||
if len(codes) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package service
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/tcs-iptv/tcs/internal/macode"
|
||||
"github.com/tcs-iptv/tcs/internal/cccode"
|
||||
"github.com/tcs-iptv/tcs/internal/model"
|
||||
)
|
||||
|
||||
@@ -16,29 +16,29 @@ var orgNodeProvince = map[string]string{
|
||||
}
|
||||
|
||||
// BindFiling 关联备案号/网标号至 MA 码(三期 A.1,对接广电总局备案系统)。
|
||||
func (s *Service) BindFiling(maCode, licenseNo, filingNo string) (model.FilingRecord, error) {
|
||||
if _, err := s.chain.QueryContent(maCode); err != nil {
|
||||
func (s *Service) BindFiling(ccCode, licenseNo, filingNo string) (model.FilingRecord, error) {
|
||||
if _, err := s.chain.QueryContent(ccCode); err != nil {
|
||||
return model.FilingRecord{}, err
|
||||
}
|
||||
rec := model.FilingRecord{
|
||||
MACode: maCode, LicenseNo: licenseNo, FilingNo: filingNo,
|
||||
CCCode: ccCode, LicenseNo: licenseNo, FilingNo: filingNo,
|
||||
BoundAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.filings[maCode] = rec
|
||||
s.filings[ccCode] = rec
|
||||
s.mu.Unlock()
|
||||
s.prov.Record(model.ProvenanceEvent{
|
||||
MACode: maCode, Node: model.NodeIssue, Operator: "广电总局备案系统",
|
||||
CCCode: ccCode, Node: model.NodeIssue, Operator: "广电总局备案系统",
|
||||
Detail: "关联网标号 " + licenseNo + " / 备案号 " + filingNo,
|
||||
})
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// QueryFiling 查询备案关联。
|
||||
func (s *Service) QueryFiling(maCode string) (model.FilingRecord, bool) {
|
||||
func (s *Service) QueryFiling(ccCode string) (model.FilingRecord, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
r, ok := s.filings[maCode]
|
||||
r, ok := s.filings[ccCode]
|
||||
return r, ok
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ func (s *Service) NationalStats() (model.NationalStats, error) {
|
||||
st.TotalContents = len(all)
|
||||
for _, c := range all {
|
||||
st.ByStatus[c.Status]++
|
||||
p, perr := macode.Parse(c.MACode)
|
||||
p, perr := cccode.Parse(c.CCCode)
|
||||
if perr == nil {
|
||||
st.ByCategory[p.Category]++
|
||||
prov := orgNodeProvince[p.OrgNode]
|
||||
@@ -80,12 +80,12 @@ func (s *Service) NationalStats() (model.NationalStats, error) {
|
||||
}
|
||||
|
||||
// ListSegments 列出已登记号段及使用情况(三期 B.1)。
|
||||
func (s *Service) ListSegments() []macode.SegmentInfo {
|
||||
func (s *Service) ListSegments() []cccode.SegmentInfo {
|
||||
return s.gen.Segments()
|
||||
}
|
||||
|
||||
// RegisterSegment 登记新号段(三期 B.1,与发码机构对接后配置)。
|
||||
func (s *Service) RegisterSegment(seg macode.Segment) error {
|
||||
func (s *Service) RegisterSegment(seg cccode.Segment) error {
|
||||
return s.gen.RegisterSegment(seg)
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ func (s *Service) DailyRegulatoryReport(date string) (model.RegulatoryReport, er
|
||||
}
|
||||
for _, c := range all {
|
||||
rep.TotalNew++
|
||||
if p, perr := macode.Parse(c.MACode); perr == nil {
|
||||
if p, perr := cccode.Parse(c.CCCode); perr == nil {
|
||||
rep.LevelDist[p.Category]++
|
||||
}
|
||||
if c.Status == model.StatusRevoked {
|
||||
|
||||
@@ -6,15 +6,15 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tcs-iptv/tcs/internal/chain"
|
||||
"github.com/tcs-iptv/tcs/internal/macode"
|
||||
"github.com/tcs-iptv/tcs/internal/cccode"
|
||||
)
|
||||
|
||||
func newServiceSX(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
gen := macode.NewGenerator(macode.NewMemoryStore())
|
||||
require.NoError(t, gen.RegisterSegment(macode.Segment{
|
||||
gen := cccode.NewGenerator(cccode.NewMemoryStore())
|
||||
require.NoError(t, gen.RegisterSegment(cccode.Segment{
|
||||
IndustryNode: "8531", OrgNode: "6101", // 陕西
|
||||
Category: macode.CategoryMicroDrama, Start: 1, End: 100, SeqWidth: 7,
|
||||
Category: cccode.CategoryMicroDrama, Start: 1, End: 100, SeqWidth: 7,
|
||||
}))
|
||||
return New(chain.NewMemoryChain(), gen)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ func issueSX(t *testing.T, s *Service) string {
|
||||
require.NoError(t, s.ReviewCSPS(r.ReviewID, true, "rv"))
|
||||
iss, err := s.ApproveAndIssue(chain.RoleRegulator, r.ReviewID, "陕西IPTV运营公司")
|
||||
require.NoError(t, err)
|
||||
return iss.MACode
|
||||
return iss.CCCode
|
||||
}
|
||||
|
||||
func TestBindFiling(t *testing.T) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/tcs-iptv/tcs/internal/chain"
|
||||
"github.com/tcs-iptv/tcs/internal/macode"
|
||||
"github.com/tcs-iptv/tcs/internal/cccode"
|
||||
"github.com/tcs-iptv/tcs/internal/model"
|
||||
"github.com/tcs-iptv/tcs/internal/playback"
|
||||
"github.com/tcs-iptv/tcs/internal/provenance"
|
||||
@@ -30,7 +30,7 @@ var (
|
||||
type Submission struct {
|
||||
Title string
|
||||
EpisodeCount int
|
||||
Category string // 内容类目(macode.CategoryXxx),决定发码号段
|
||||
Category string // 内容类目(cccode.CategoryXxx),决定发码号段
|
||||
FileHash string
|
||||
MerkleRoot string
|
||||
Perceptual string
|
||||
@@ -50,13 +50,13 @@ type SubmissionResult struct {
|
||||
// Service 业务编排器。
|
||||
type Service struct {
|
||||
chain chain.Client
|
||||
gen *macode.Generator
|
||||
gen *cccode.Generator
|
||||
pb *playback.Store
|
||||
prov *provenance.Store
|
||||
phash map[string]phashEntry // maCode -> 感知哈希条目(确权侵权比对)
|
||||
auths map[string]model.Authorization // maCode -> 授权(F22)
|
||||
black map[string]bool // maCode -> 黑名单(跨省复用校验)
|
||||
filings map[string]model.FilingRecord // maCode -> 备案/网标关联(三期 A.1)
|
||||
phash map[string]phashEntry // ccCode -> 感知哈希条目(确权侵权比对)
|
||||
auths map[string]model.Authorization // ccCode -> 授权(F22)
|
||||
black map[string]bool // ccCode -> 黑名单(跨省复用校验)
|
||||
filings map[string]model.FilingRecord // ccCode -> 备案/网标关联(三期 A.1)
|
||||
rights map[string]*model.UserRights // userHash -> 跨屏权益账户(四期 D.1)
|
||||
mu sync.Mutex
|
||||
seqMu sync.Mutex
|
||||
@@ -69,7 +69,7 @@ type reviewItem struct {
|
||||
ContentTwinID string
|
||||
Sub Submission
|
||||
Status string
|
||||
MACode string
|
||||
CCCode string
|
||||
}
|
||||
|
||||
// phashEntry 感知哈希注册项,用于确权侵权比对。
|
||||
@@ -79,7 +79,7 @@ type phashEntry struct {
|
||||
}
|
||||
|
||||
// New 创建业务服务。
|
||||
func New(c chain.Client, gen *macode.Generator) *Service {
|
||||
func New(c chain.Client, gen *cccode.Generator) *Service {
|
||||
return &Service{
|
||||
chain: c, gen: gen,
|
||||
pb: playback.NewStore(),
|
||||
@@ -107,10 +107,10 @@ func (s *Service) SubmitForReview(sub Submission) (SubmissionResult, error) {
|
||||
return SubmissionResult{}, ErrIncompleteHashPkg
|
||||
}
|
||||
// 防换壳重发(需求2-AC3、需求15-AC5)
|
||||
if maCode, exists := s.chain.HashExists(sub.FileHash); exists {
|
||||
if ccCode, exists := s.chain.HashExists(sub.FileHash); exists {
|
||||
return SubmissionResult{
|
||||
Status: "rejected",
|
||||
Message: fmt.Sprintf("内容哈希已存在,关联原 MA 码: %s", maCode),
|
||||
Message: fmt.Sprintf("内容哈希已存在,关联原 MA 码: %s", ccCode),
|
||||
}, ErrDuplicateContent
|
||||
}
|
||||
|
||||
@@ -133,14 +133,14 @@ func (s *Service) SubmitForReview(sub Submission) (SubmissionResult, error) {
|
||||
|
||||
// IssueResult 签发结果。
|
||||
type IssueResult struct {
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
ContentTwinID string `json:"content_twin_id"`
|
||||
TxID string `json:"tx_id"`
|
||||
Certificate string `json:"certificate"` // MA码+哈希证书(MVP 简化为字符串)
|
||||
Certificate string `json:"certificate"` // CC码+哈希证书(MVP 简化为字符串)
|
||||
}
|
||||
|
||||
// ReviewCSPS CSPS 合规审核(发码前)。审核通过后方可发码,体现"审过才发证发码"。
|
||||
// 对应需求5(CSPS审核)+ 需求3-AC2(审核通过后生成MA码)。
|
||||
// 对应需求5(CSPS审核)+ 需求3-AC2(审核通过后生成CC码)。
|
||||
func (s *Service) ReviewCSPS(reviewID string, approved bool, reviewerID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -158,7 +158,7 @@ func (s *Service) ReviewCSPS(reviewID string, approved bool, reviewerID string)
|
||||
|
||||
// ApproveAndIssue 在 CSPS 审核通过后**生成 MA 码**并强绑定哈希(需求3,模式B 自行发码)。
|
||||
// 前置:该送审必须已通过 CSPS 审核(审过才发码)。
|
||||
// MA 码由 macode.Generator 按内容类目从号段中原子分配。仅监管主体可调用。
|
||||
// MA 码由 cccode.Generator 按内容类目从号段中原子分配。仅监管主体可调用。
|
||||
func (s *Service) ApproveAndIssue(role chain.Role, reviewID, issuer string) (IssueResult, error) {
|
||||
s.mu.Lock()
|
||||
item, ok := s.reviews[reviewID]
|
||||
@@ -179,10 +179,10 @@ func (s *Service) ApproveAndIssue(role chain.Role, reviewID, issuer string) (Iss
|
||||
if err != nil {
|
||||
return IssueResult{}, fmt.Errorf("service: allocate MA code: %w", err)
|
||||
}
|
||||
maCode := issued.MACode
|
||||
ccCode := issued.CCCode
|
||||
|
||||
txID, err := s.chain.IssueMA(role, chain.IssueRequest{
|
||||
MACode: maCode,
|
||||
CCCode: ccCode,
|
||||
ContentTwinID: item.ContentTwinID,
|
||||
MerkleRoot: item.Sub.MerkleRoot,
|
||||
FileHash: item.Sub.FileHash,
|
||||
@@ -202,7 +202,7 @@ func (s *Service) ApproveAndIssue(role chain.Role, reviewID, issuer string) (Iss
|
||||
|
||||
s.mu.Lock()
|
||||
item.Status = model.StatusIssued // 已发码,移出"待发码"队列
|
||||
item.MACode = maCode
|
||||
item.CCCode = ccCode
|
||||
s.mu.Unlock()
|
||||
|
||||
// CP 注册本方映射
|
||||
@@ -214,18 +214,18 @@ func (s *Service) ApproveAndIssue(role chain.Role, reviewID, issuer string) (Iss
|
||||
})
|
||||
|
||||
// 记录全链路存证(送审→审核→发码)+ 注册感知哈希供确权比对
|
||||
s.prov.Record(model.ProvenanceEvent{MACode: maCode, Node: model.NodeSubmit, HashValue: item.Sub.FileHash, Operator: item.Sub.CPName, Detail: "CP 送审"})
|
||||
s.prov.Record(model.ProvenanceEvent{MACode: maCode, Node: model.NodeCSPSReview, Operator: "CSPS", Detail: "合规审核通过"})
|
||||
s.prov.Record(model.ProvenanceEvent{MACode: maCode, Node: model.NodeIssue, HashValue: item.Sub.FileHash, Operator: issuer, Detail: "发码签发,绑定基准哈希"})
|
||||
s.prov.Record(model.ProvenanceEvent{CCCode: ccCode, Node: model.NodeSubmit, HashValue: item.Sub.FileHash, Operator: item.Sub.CPName, Detail: "CP 送审"})
|
||||
s.prov.Record(model.ProvenanceEvent{CCCode: ccCode, Node: model.NodeCSPSReview, Operator: "CSPS", Detail: "合规审核通过"})
|
||||
s.prov.Record(model.ProvenanceEvent{CCCode: ccCode, Node: model.NodeIssue, HashValue: item.Sub.FileHash, Operator: issuer, Detail: "发码签发,绑定基准哈希"})
|
||||
if item.Sub.Perceptual != "" {
|
||||
s.mu.Lock()
|
||||
s.phash[maCode] = phashEntry{Title: item.Sub.Title, Perceptual: item.Sub.Perceptual}
|
||||
s.phash[ccCode] = phashEntry{Title: item.Sub.Title, Perceptual: item.Sub.Perceptual}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
cert := fmt.Sprintf("CERT|%s|%s|%s", maCode, item.Sub.FileHash, item.Sub.MerkleRoot)
|
||||
cert := fmt.Sprintf("CERT|%s|%s|%s", ccCode, item.Sub.FileHash, item.Sub.MerkleRoot)
|
||||
return IssueResult{
|
||||
MACode: maCode,
|
||||
CCCode: ccCode,
|
||||
ContentTwinID: item.ContentTwinID,
|
||||
TxID: txID,
|
||||
Certificate: cert,
|
||||
@@ -233,8 +233,8 @@ func (s *Service) ApproveAndIssue(role chain.Role, reviewID, issuer string) (Iss
|
||||
}
|
||||
|
||||
// Verify 送审文件验真 / CDN 注入校验通用入口(需求4、需求7)。
|
||||
func (s *Service) Verify(maCode, fileHash string) (chain.VerifyResult, error) {
|
||||
res, err := s.chain.VerifyHash(maCode, fileHash)
|
||||
func (s *Service) Verify(ccCode, fileHash string) (chain.VerifyResult, error) {
|
||||
res, err := s.chain.VerifyHash(ccCode, fileHash)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
@@ -245,13 +245,13 @@ func (s *Service) Verify(maCode, fileHash string) (chain.VerifyResult, error) {
|
||||
}
|
||||
|
||||
// QueryMappings 查询 MA 码绑定的三方映射与 CDN 端点(需求11/17)。
|
||||
func (s *Service) QueryMappings(maCode string) (chain.MappingsResult, error) {
|
||||
return s.chain.QueryMappings(maCode)
|
||||
func (s *Service) QueryMappings(ccCode string) (chain.MappingsResult, error) {
|
||||
return s.chain.QueryMappings(ccCode)
|
||||
}
|
||||
|
||||
// VerifyEpisode 按集级子标识(MA码#E07)或 MA码+集号 验真单集。
|
||||
func (s *Service) VerifyEpisode(maCode string, episode int, fileHash string) (chain.VerifyResult, error) {
|
||||
res, err := s.chain.VerifyEpisodeHash(maCode, episode, fileHash)
|
||||
// VerifyEpisode 按集级子标识(CC码#E07)或 CC码+集号 验真单集。
|
||||
func (s *Service) VerifyEpisode(ccCode string, episode int, fileHash string) (chain.VerifyResult, error) {
|
||||
res, err := s.chain.VerifyEpisodeHash(ccCode, episode, fileHash)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
@@ -262,8 +262,8 @@ func (s *Service) VerifyEpisode(maCode string, episode int, fileHash string) (ch
|
||||
}
|
||||
|
||||
// ListEpisodes 列出某剧的全部集级哈希绑定。
|
||||
func (s *Service) ListEpisodes(maCode string) ([]model.HashBinding, error) {
|
||||
return s.chain.ListEpisodes(maCode)
|
||||
func (s *Service) ListEpisodes(ccCode string) ([]model.HashBinding, error) {
|
||||
return s.chain.ListEpisodes(ccCode)
|
||||
}
|
||||
|
||||
// ReviewSummary 送审待办摘要(发码前阶段)。
|
||||
@@ -275,7 +275,7 @@ type ReviewSummary struct {
|
||||
EpisodeCount int `json:"episode_count"`
|
||||
Status string `json:"status"`
|
||||
CPName string `json:"cp_name"`
|
||||
MACode string `json:"ma_code"`
|
||||
CCCode string `json:"ma_code"`
|
||||
}
|
||||
|
||||
// ListReviews 列出指定状态的送审待办(用于审核台/发码台队列)。
|
||||
@@ -292,7 +292,7 @@ func (s *Service) ListReviews(status string) []ReviewSummary {
|
||||
ReviewID: id, ContentTwinID: item.ContentTwinID,
|
||||
Title: item.Sub.Title, Category: item.Sub.Category,
|
||||
EpisodeCount: item.Sub.EpisodeCount, Status: item.Status,
|
||||
CPName: item.Sub.CPName, MACode: item.MACode,
|
||||
CPName: item.Sub.CPName, CCCode: item.CCCode,
|
||||
})
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -7,22 +7,22 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tcs-iptv/tcs/internal/chain"
|
||||
"github.com/tcs-iptv/tcs/internal/macode"
|
||||
"github.com/tcs-iptv/tcs/internal/cccode"
|
||||
)
|
||||
|
||||
func newService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
gen := macode.NewGenerator(macode.NewMemoryStore())
|
||||
require.NoError(t, gen.RegisterSegment(macode.Segment{
|
||||
gen := cccode.NewGenerator(cccode.NewMemoryStore())
|
||||
require.NoError(t, gen.RegisterSegment(cccode.Segment{
|
||||
IndustryNode: "8531", OrgNode: "4401",
|
||||
Category: macode.CategoryMicroDrama, Start: 1, End: 100, SeqWidth: 7,
|
||||
Category: cccode.CategoryMicroDrama, Start: 1, End: 100, SeqWidth: 7,
|
||||
}))
|
||||
return New(chain.NewMemoryChain(), gen)
|
||||
}
|
||||
|
||||
func sampleSub() Submission {
|
||||
return Submission{
|
||||
Title: "示例微短剧", EpisodeCount: 24, Category: macode.CategoryMicroDrama,
|
||||
Title: "示例微短剧", EpisodeCount: 24, Category: cccode.CategoryMicroDrama,
|
||||
FileHash: "filehash-abc", MerkleRoot: "merkle-abc", Perceptual: "phash-abc",
|
||||
CPMediaID: "FS-MEDIA-77821", CPName: "飞翮信息",
|
||||
}
|
||||
@@ -30,7 +30,7 @@ func sampleSub() Submission {
|
||||
|
||||
func TestSubmit_IncompleteHashRejected(t *testing.T) {
|
||||
s := newService(t)
|
||||
_, err := s.SubmitForReview(Submission{Title: "无哈希", Category: macode.CategoryMicroDrama})
|
||||
_, err := s.SubmitForReview(Submission{Title: "无哈希", Category: cccode.CategoryMicroDrama})
|
||||
assert.ErrorIs(t, err, ErrIncompleteHashPkg)
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestSubmit_Success(t *testing.T) {
|
||||
assert.Equal(t, "pending", res.Status)
|
||||
}
|
||||
|
||||
func TestApproveAndIssue_GeneratesMACode(t *testing.T) {
|
||||
func TestApproveAndIssue_GeneratesCCCode(t *testing.T) {
|
||||
s := newService(t)
|
||||
sub, err := s.SubmitForReview(sampleSub())
|
||||
require.NoError(t, err)
|
||||
@@ -52,10 +52,10 @@ func TestApproveAndIssue_GeneratesMACode(t *testing.T) {
|
||||
issued, err := s.ApproveAndIssue(chain.RoleRegulator, sub.ReviewID, "北京市广播电视局")
|
||||
require.NoError(t, err)
|
||||
// 模式B:MA 码由系统按号段生成
|
||||
assert.True(t, macode.IsValid(issued.MACode), "应生成合法 MA 码: %s", issued.MACode)
|
||||
assert.True(t, strings.HasPrefix(issued.MACode, "MA.156.8531.4401/WD/"), "前缀应匹配号段: %s", issued.MACode)
|
||||
assert.True(t, cccode.IsValid(issued.CCCode), "应生成合法 MA 码: %s", issued.CCCode)
|
||||
assert.True(t, strings.HasPrefix(issued.CCCode, "MA.156.8531.4401/WD/"), "前缀应匹配号段: %s", issued.CCCode)
|
||||
assert.NotEmpty(t, issued.TxID)
|
||||
assert.Contains(t, issued.Certificate, issued.MACode)
|
||||
assert.Contains(t, issued.Certificate, issued.CCCode)
|
||||
}
|
||||
|
||||
func TestApproveAndIssue_OnlyRegulator(t *testing.T) {
|
||||
@@ -84,11 +84,11 @@ func TestVerify_MatchAndMismatch(t *testing.T) {
|
||||
require.NoError(t, s.ReviewCSPS(sub.ReviewID, true, "rv-1"))
|
||||
issued, _ := s.ApproveAndIssue(chain.RoleRegulator, sub.ReviewID, "issuer")
|
||||
|
||||
res, err := s.Verify(issued.MACode, "filehash-abc")
|
||||
res, err := s.Verify(issued.CCCode, "filehash-abc")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Match)
|
||||
|
||||
_, err = s.Verify(issued.MACode, "tampered")
|
||||
_, err = s.Verify(issued.CCCode, "tampered")
|
||||
assert.ErrorIs(t, err, ErrHashMismatch)
|
||||
}
|
||||
|
||||
@@ -107,5 +107,5 @@ func TestApproveAndIssue_TwoContentsUniqueCodes(t *testing.T) {
|
||||
i2, err := s.ApproveAndIssue(chain.RoleRegulator, sub2.ReviewID, "issuer")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, i1.MACode, i2.MACode, "两条内容应分配不同 MA 码")
|
||||
assert.NotEqual(t, i1.CCCode, i2.CCCode, "两条内容应分配不同 MA 码")
|
||||
}
|
||||
|
||||
@@ -11,24 +11,24 @@ import (
|
||||
|
||||
func TestReportPlaybackAndSettle(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, _, _ := issueOne(t, s)
|
||||
ccCode, _, _ := issueOne(t, s)
|
||||
|
||||
// 运营商回传播放/购买事件
|
||||
acc, rej := s.ReportPlayback([]model.PlaybackEvent{
|
||||
{MACode: maCode, PlatformID: "CT-SX", EventType: model.EventPlay},
|
||||
{MACode: maCode, PlatformID: "CT-SX", EventType: model.EventPurchase, RevenueCent: 1500},
|
||||
{MACode: maCode, PlatformID: "CM-SX", EventType: model.EventPurchase, RevenueCent: 2500},
|
||||
{CCCode: ccCode, PlatformID: "CT-SX", EventType: model.EventPlay},
|
||||
{CCCode: ccCode, PlatformID: "CT-SX", EventType: model.EventPurchase, RevenueCent: 1500},
|
||||
{CCCode: ccCode, PlatformID: "CM-SX", EventType: model.EventPurchase, RevenueCent: 2500},
|
||||
})
|
||||
assert.Equal(t, 3, acc)
|
||||
assert.Equal(t, 0, rej)
|
||||
|
||||
// 聚合可信播放数据
|
||||
sum := s.PlaybackSummary(maCode)
|
||||
sum := s.PlaybackSummary(ccCode)
|
||||
assert.Equal(t, int64(4000), sum.TotalRevenue)
|
||||
assert.Equal(t, int64(1), sum.TotalPlays)
|
||||
|
||||
// 分账:CP60/平台34/服务费6
|
||||
st, err := s.ComputeSettlement(maCode, "2026-06")
|
||||
st, err := s.ComputeSettlement(ccCode, "2026-06")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(4000), st.TotalRevenue)
|
||||
assert.Equal(t, int64(2400), st.CPShare)
|
||||
@@ -39,20 +39,20 @@ func TestReportPlaybackAndSettle(t *testing.T) {
|
||||
|
||||
func TestReportPlayback_RejectsUnknownOrRevoked(t *testing.T) {
|
||||
s := newService(t)
|
||||
maCode, _, _ := issueOne(t, s)
|
||||
ccCode, _, _ := issueOne(t, s)
|
||||
|
||||
// 未知 MA 码被拒
|
||||
acc, rej := s.ReportPlayback([]model.PlaybackEvent{
|
||||
{MACode: "MA.156.8531.6101/WD/不存在", PlatformID: "P", EventType: model.EventPlay},
|
||||
{CCCode: "MA.156.8531.6101/WD/不存在", PlatformID: "P", EventType: model.EventPlay},
|
||||
})
|
||||
assert.Equal(t, 0, acc)
|
||||
assert.Equal(t, 1, rej)
|
||||
|
||||
// 下架后回传被拒(数据归属不可信)
|
||||
_, err := s.Takedown(chain.RoleRegulator, maCode, "违规")
|
||||
_, err := s.Takedown(chain.RoleRegulator, ccCode, "违规")
|
||||
require.NoError(t, err)
|
||||
acc, rej = s.ReportPlayback([]model.PlaybackEvent{
|
||||
{MACode: maCode, PlatformID: "P", EventType: model.EventPlay},
|
||||
{CCCode: ccCode, PlatformID: "P", EventType: model.EventPlay},
|
||||
})
|
||||
assert.Equal(t, 0, acc)
|
||||
assert.Equal(t, 1, rej)
|
||||
|
||||
@@ -23,16 +23,16 @@ import (
|
||||
// SyncSource 标识同步数据源接口。
|
||||
type SyncSource interface {
|
||||
ListContents(status string) ([]model.Content, error)
|
||||
QueryContent(maCode string) (model.Content, error)
|
||||
QueryMappings(maCode string) (chain.MappingsResult, error)
|
||||
ListEpisodes(maCode string) ([]model.HashBinding, error)
|
||||
QueryContent(ccCode string) (model.Content, error)
|
||||
QueryMappings(ccCode string) (chain.MappingsResult, error)
|
||||
ListEpisodes(ccCode string) ([]model.HashBinding, error)
|
||||
}
|
||||
|
||||
// SyncSink 标识同步数据汇接口(远端节点实现)。
|
||||
type SyncSink interface {
|
||||
UpsertContent(c model.Content) error
|
||||
UpsertBinding(maCode string, b model.HashBinding) error
|
||||
UpsertMapping(maCode string, m model.Mapping) error
|
||||
UpsertBinding(ccCode string, b model.HashBinding) error
|
||||
UpsertMapping(ccCode string, m model.Mapping) error
|
||||
}
|
||||
|
||||
// ConflictResolver 同步冲突解决策略。
|
||||
@@ -103,7 +103,7 @@ func (s *SyncService) Sync(req SyncRequest) (SyncResult, error) {
|
||||
} else {
|
||||
if err := s.sink.UpsertContent(c); err != nil {
|
||||
if errors.Is(err, ErrConflict) && req.Resolver == ConflictFail {
|
||||
return result, fmt.Errorf("sync: 冲突 MA 码 %s: %w", c.MACode, err)
|
||||
return result, fmt.Errorf("sync: 冲突 MA 码 %s: %w", c.CCCode, err)
|
||||
}
|
||||
result.Failed++
|
||||
continue
|
||||
@@ -112,9 +112,9 @@ func (s *SyncService) Sync(req SyncRequest) (SyncResult, error) {
|
||||
result.TotalContents++
|
||||
|
||||
// 同步哈希绑定
|
||||
eps, _ := s.source.ListEpisodes(c.MACode)
|
||||
eps, _ := s.source.ListEpisodes(c.CCCode)
|
||||
for _, b := range eps {
|
||||
if err := s.sink.UpsertBinding(c.MACode, b); err != nil {
|
||||
if err := s.sink.UpsertBinding(c.CCCode, b); err != nil {
|
||||
result.Failed++
|
||||
continue
|
||||
}
|
||||
@@ -122,9 +122,9 @@ func (s *SyncService) Sync(req SyncRequest) (SyncResult, error) {
|
||||
}
|
||||
|
||||
// 同步映射
|
||||
mr, _ := s.source.QueryMappings(c.MACode)
|
||||
mr, _ := s.source.QueryMappings(c.CCCode)
|
||||
for _, m := range mr.Mappings {
|
||||
if err := s.sink.UpsertMapping(c.MACode, m); err != nil {
|
||||
if err := s.sink.UpsertMapping(c.CCCode, m); err != nil {
|
||||
result.Failed++
|
||||
continue
|
||||
}
|
||||
@@ -151,16 +151,16 @@ func (cs *ChainSource) ListContents(status string) ([]model.Content, error) {
|
||||
}
|
||||
|
||||
// QueryContent 查询内容主记录。
|
||||
func (cs *ChainSource) QueryContent(maCode string) (model.Content, error) {
|
||||
return cs.Client.QueryContent(maCode)
|
||||
func (cs *ChainSource) QueryContent(ccCode string) (model.Content, error) {
|
||||
return cs.Client.QueryContent(ccCode)
|
||||
}
|
||||
|
||||
// QueryMappings 查询映射。
|
||||
func (cs *ChainSource) QueryMappings(maCode string) (chain.MappingsResult, error) {
|
||||
return cs.Client.QueryMappings(maCode)
|
||||
func (cs *ChainSource) QueryMappings(ccCode string) (chain.MappingsResult, error) {
|
||||
return cs.Client.QueryMappings(ccCode)
|
||||
}
|
||||
|
||||
// ListEpisodes 列出集级哈希。
|
||||
func (cs *ChainSource) ListEpisodes(maCode string) ([]model.HashBinding, error) {
|
||||
return cs.Client.ListEpisodes(maCode)
|
||||
func (cs *ChainSource) ListEpisodes(ccCode string) ([]model.HashBinding, error) {
|
||||
return cs.Client.ListEpisodes(ccCode)
|
||||
}
|
||||
|
||||
@@ -32,25 +32,25 @@ func (m *memorySink) UpsertContent(c model.Content) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.conflict {
|
||||
if _, exists := m.contents[c.MACode]; exists {
|
||||
if _, exists := m.contents[c.CCCode]; exists {
|
||||
return ErrConflict
|
||||
}
|
||||
}
|
||||
m.contents[c.MACode] = c
|
||||
m.contents[c.CCCode] = c
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memorySink) UpsertBinding(maCode string, b model.HashBinding) error {
|
||||
func (m *memorySink) UpsertBinding(ccCode string, b model.HashBinding) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.bindings[maCode] = append(m.bindings[maCode], b)
|
||||
m.bindings[ccCode] = append(m.bindings[ccCode], b)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memorySink) UpsertMapping(maCode string, mp model.Mapping) error {
|
||||
func (m *memorySink) UpsertMapping(ccCode string, mp model.Mapping) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.mappings[maCode] = append(m.mappings[maCode], mp)
|
||||
m.mappings[ccCode] = append(m.mappings[ccCode], mp)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestSyncService_FullSync(t *testing.T) {
|
||||
|
||||
// 在源端发码
|
||||
_, err := src.Client.IssueMA(chain.RoleRegulator, chain.IssueRequest{
|
||||
MACode: "MA.156.8531.6101/WD/20260000001", ContentTwinID: "ctid-sync-001",
|
||||
CCCode: "MA.156.8531.6101/WD/20260000001", ContentTwinID: "ctid-sync-001",
|
||||
FileHash: "fh-sync-001", MerkleRoot: "mr-sync-001",
|
||||
Episodes: []model.EpisodeHash{
|
||||
{Episode: 1, FileSHA256: "fh-sync-001-E1"},
|
||||
@@ -97,7 +97,7 @@ func TestSyncService_ConflictSkip(t *testing.T) {
|
||||
|
||||
// 源端发码
|
||||
_, err := src.Client.IssueMA(chain.RoleRegulator, chain.IssueRequest{
|
||||
MACode: "MA.156.8531.6101/WD/20260000002", ContentTwinID: "ctid-sync-002",
|
||||
CCCode: "MA.156.8531.6101/WD/20260000002", ContentTwinID: "ctid-sync-002",
|
||||
FileHash: "fh-sync-002", MerkleRoot: "mr-sync-002",
|
||||
Content: model.Content{Title: "冲突测试剧", MAType: "WD", Issuer: "测试局"},
|
||||
})
|
||||
@@ -105,7 +105,7 @@ func TestSyncService_ConflictSkip(t *testing.T) {
|
||||
|
||||
// 远端预先存在同 MA 码
|
||||
sink.contents["MA.156.8531.6101/WD/20260000002"] = model.Content{
|
||||
MACode: "MA.156.8531.6101/WD/20260000002", Title: "远端已有",
|
||||
CCCode: "MA.156.8531.6101/WD/20260000002", Title: "远端已有",
|
||||
}
|
||||
|
||||
// 冲突跳过策略
|
||||
@@ -122,14 +122,14 @@ func TestSyncService_ConflictFail(t *testing.T) {
|
||||
sink.conflict = true
|
||||
|
||||
_, err := src.Client.IssueMA(chain.RoleRegulator, chain.IssueRequest{
|
||||
MACode: "MA.156.8531.6101/WD/20260000003", ContentTwinID: "ctid-sync-003",
|
||||
CCCode: "MA.156.8531.6101/WD/20260000003", ContentTwinID: "ctid-sync-003",
|
||||
FileHash: "fh-sync-003", MerkleRoot: "mr-sync-003",
|
||||
Content: model.Content{Title: "冲突Fail测试", MAType: "WD", Issuer: "测试局"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
sink.contents["MA.156.8531.6101/WD/20260000003"] = model.Content{
|
||||
MACode: "MA.156.8531.6101/WD/20260000003", Title: "远端已有",
|
||||
CCCode: "MA.156.8531.6101/WD/20260000003", Title: "远端已有",
|
||||
}
|
||||
|
||||
svc := New(src, sink)
|
||||
|
||||
Reference in New Issue
Block a user