feat: 方案100%匹配 — RBAC动态权限+Grafana监控+目录库独立微服务+全国目录中心+标识同步
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
// Package nationalcatalog 全国目录中心服务(方案第三阶段 — 全国统一标识与审核协同)。
|
||||
//
|
||||
// 功能:
|
||||
// - 汇聚多省标识数据,建立全国统一目录
|
||||
// - 提供跨省标识查询(按 MA 码、Hash、省级编码查询)
|
||||
// - 省级目录库 ↔ 全国目录中心数据同步
|
||||
// - 各省内容编码与全国统一 MA 之间的映射同步
|
||||
// - 跨省审核记录共享
|
||||
//
|
||||
// 架构说明:
|
||||
// - NationalCatalog 全国目录中心,聚合多省数据
|
||||
// - ProvinceNode 省级节点抽象,通过 SyncService 向全国中心同步
|
||||
// - 基于 internal/sync 包的同步能力
|
||||
package nationalcatalog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
stdsync "sync"
|
||||
|
||||
"github.com/tcs-iptv/tcs/internal/model"
|
||||
syncpkg "github.com/tcs-iptv/tcs/internal/sync"
|
||||
)
|
||||
|
||||
// NationalCatalog 全国目录中心。
|
||||
// 聚合多省标识数据,提供统一查询入口。
|
||||
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
|
||||
provinces map[string]*ProvinceInfo // provinceCode -> 省级信息
|
||||
auditShared map[string][]model.ProvenanceEvent // maCode -> 跨省共享审核记录
|
||||
}
|
||||
|
||||
// ProvinceInfo 省级节点信息。
|
||||
type ProvinceInfo struct {
|
||||
ProvinceCode string `json:"province_code"` // 省级编码
|
||||
ProvinceName string `json:"province_name"` // 省份名称
|
||||
OrgNode string `json:"org_node"` // MA 码机构节点
|
||||
LastSyncAt string `json:"last_sync_at"` // 最后同步时间
|
||||
Status string `json:"status"` // active/inactive
|
||||
}
|
||||
|
||||
// New 创建全国目录中心。
|
||||
func New() *NationalCatalog {
|
||||
return &NationalCatalog{
|
||||
contents: make(map[string]model.Content),
|
||||
bindings: make(map[string][]model.HashBinding),
|
||||
mappings: make(map[string][]model.Mapping),
|
||||
provinces: make(map[string]*ProvinceInfo),
|
||||
auditShared: make(map[string][]model.ProvenanceEvent),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterProvince 注册省级节点。
|
||||
func (nc *NationalCatalog) RegisterProvince(info ProvinceInfo) error {
|
||||
if info.ProvinceCode == "" {
|
||||
return fmt.Errorf("national: 省级编码不能为空")
|
||||
}
|
||||
nc.mu.Lock()
|
||||
defer nc.mu.Unlock()
|
||||
info.Status = "active"
|
||||
nc.provinces[info.ProvinceCode] = &info
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListProvinces 列出已注册的省级节点。
|
||||
func (nc *NationalCatalog) ListProvinces() []ProvinceInfo {
|
||||
nc.mu.RLock()
|
||||
defer nc.mu.RUnlock()
|
||||
out := make([]ProvinceInfo, 0, len(nc.provinces))
|
||||
for _, p := range nc.provinces {
|
||||
out = append(out, *p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- 实现 sync.SyncSink 接口 ----
|
||||
|
||||
// UpsertContent 写入/更新内容记录。
|
||||
func (nc *NationalCatalog) UpsertContent(c model.Content) error {
|
||||
nc.mu.Lock()
|
||||
defer nc.mu.Unlock()
|
||||
if _, exists := nc.contents[c.MACode]; exists {
|
||||
return syncpkg.ErrConflict
|
||||
}
|
||||
nc.contents[c.MACode] = c
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpsertBinding 写入/更新哈希绑定。
|
||||
func (nc *NationalCatalog) UpsertBinding(maCode string, b model.HashBinding) error {
|
||||
nc.mu.Lock()
|
||||
defer nc.mu.Unlock()
|
||||
nc.bindings[maCode] = append(nc.bindings[maCode], b)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpsertMapping 写入/更新映射。
|
||||
func (nc *NationalCatalog) UpsertMapping(maCode string, m model.Mapping) error {
|
||||
nc.mu.Lock()
|
||||
defer nc.mu.Unlock()
|
||||
nc.mappings[maCode] = append(nc.mappings[maCode], m)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- 全国统一查询 ----
|
||||
|
||||
// QueryByMA 按 MA 码查询(全国维度)。
|
||||
func (nc *NationalCatalog) QueryByMA(maCode string) (model.ContentQueryResult, error) {
|
||||
nc.mu.RLock()
|
||||
defer nc.mu.RUnlock()
|
||||
c, ok := nc.contents[maCode]
|
||||
if !ok {
|
||||
return model.ContentQueryResult{Found: false}, fmt.Errorf("national: MA 码 %s 未找到", maCode)
|
||||
}
|
||||
return model.ContentQueryResult{
|
||||
Found: true,
|
||||
Content: c,
|
||||
Bindings: nc.bindings[maCode],
|
||||
Mappings: nc.mappings[maCode],
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryByHash 按 Hash 查询(全国维度)。
|
||||
func (nc *NationalCatalog) QueryByHash(fileHash string) (model.ContentQueryResult, error) {
|
||||
nc.mu.RLock()
|
||||
defer nc.mu.RUnlock()
|
||||
// 先搜索 Content.FileHash(整剧主哈希)
|
||||
for maCode, c := range nc.contents {
|
||||
if c.FileHash == fileHash {
|
||||
return model.ContentQueryResult{
|
||||
Found: true,
|
||||
Content: c,
|
||||
Bindings: nc.bindings[maCode],
|
||||
Mappings: nc.mappings[maCode],
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
// 再搜索 bindings 中的 HashValue(集级/转码版哈希)
|
||||
for maCode, bindings := range nc.bindings {
|
||||
for _, b := range bindings {
|
||||
if b.HashValue == fileHash {
|
||||
return model.ContentQueryResult{
|
||||
Found: true,
|
||||
Content: nc.contents[maCode],
|
||||
Bindings: bindings,
|
||||
Mappings: nc.mappings[maCode],
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return model.ContentQueryResult{Found: false}, fmt.Errorf("national: Hash %s 未找到", fileHash)
|
||||
}
|
||||
|
||||
// QueryByProvincialCode 按省级内容编码查询(全国维度,跨省查询)。
|
||||
func (nc *NationalCatalog) QueryByProvincialCode(provincialCode string) (model.ContentQueryResult, error) {
|
||||
nc.mu.RLock()
|
||||
defer nc.mu.RUnlock()
|
||||
for maCode, 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],
|
||||
Mappings: mappings,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return model.ContentQueryResult{Found: false}, fmt.Errorf("national: 省级编码 %s 未找到", provincialCode)
|
||||
}
|
||||
|
||||
// ---- 跨省审核记录共享 ----
|
||||
|
||||
// ShareAuditRecord 省级节点上报审核记录至全国中心。
|
||||
func (nc *NationalCatalog) ShareAuditRecord(maCode string, event model.ProvenanceEvent) {
|
||||
nc.mu.Lock()
|
||||
defer nc.mu.Unlock()
|
||||
nc.auditShared[maCode] = append(nc.auditShared[maCode], event)
|
||||
}
|
||||
|
||||
// QuerySharedAudit 查询跨省共享的审核记录。
|
||||
func (nc *NationalCatalog) QuerySharedAudit(maCode string) []model.ProvenanceEvent {
|
||||
nc.mu.RLock()
|
||||
defer nc.mu.RUnlock()
|
||||
return nc.auditShared[maCode]
|
||||
}
|
||||
|
||||
// ---- 全国统计 ----
|
||||
|
||||
// NationalStats 全国统计信息。
|
||||
type NationalStats struct {
|
||||
TotalContents int `json:"total_contents"`
|
||||
ByProvince map[string]int `json:"by_province"`
|
||||
ByStatus map[string]int `json:"by_status"`
|
||||
ByCategory map[string]int `json:"by_category"`
|
||||
TotalProvinces int `json:"total_provinces"`
|
||||
}
|
||||
|
||||
// Stats 返回全国统计。
|
||||
func (nc *NationalCatalog) Stats() NationalStats {
|
||||
nc.mu.RLock()
|
||||
defer nc.mu.RUnlock()
|
||||
st := NationalStats{
|
||||
ByProvince: make(map[string]int),
|
||||
ByStatus: make(map[string]int),
|
||||
ByCategory: make(map[string]int),
|
||||
}
|
||||
st.TotalContents = len(nc.contents)
|
||||
st.TotalProvinces = len(nc.provinces)
|
||||
for _, c := range nc.contents {
|
||||
st.ByStatus[c.Status]++
|
||||
st.ByCategory[c.MAType]++
|
||||
// 按机构节点统计省份
|
||||
for _, mp := range nc.mappings[c.MACode] {
|
||||
if mp.Party == model.PartyCP {
|
||||
st.ByProvince[mp.PartyName]++
|
||||
}
|
||||
}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// SyncFromProvince 从省级节点同步数据至全国中心。
|
||||
func (nc *NationalCatalog) SyncFromProvince(source syncpkg.SyncSource, resolver syncpkg.ConflictResolver) (syncpkg.SyncResult, error) {
|
||||
svc := syncpkg.New(source, nc)
|
||||
return svc.Sync(syncpkg.SyncRequest{Resolver: resolver})
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package nationalcatalog
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tcs-iptv/tcs/internal/chain"
|
||||
"github.com/tcs-iptv/tcs/internal/model"
|
||||
syncpkg "github.com/tcs-iptv/tcs/internal/sync"
|
||||
)
|
||||
|
||||
func TestNationalCatalog_RegisterAndListProvinces(t *testing.T) {
|
||||
nc := New()
|
||||
err := nc.RegisterProvince(ProvinceInfo{
|
||||
ProvinceCode: "6101", ProvinceName: "陕西", OrgNode: "6101",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
provinces := nc.ListProvinces()
|
||||
assert.Len(t, provinces, 1)
|
||||
assert.Equal(t, "陕西", provinces[0].ProvinceName)
|
||||
}
|
||||
|
||||
func TestNationalCatalog_SyncFromProvince(t *testing.T) {
|
||||
nc := New()
|
||||
nc.RegisterProvince(ProvinceInfo{ProvinceCode: "6101", ProvinceName: "陕西"})
|
||||
|
||||
// 源端发码
|
||||
srcClient := chain.NewMemoryChain()
|
||||
_, err := srcClient.IssueMA(chain.RoleRegulator, chain.IssueRequest{
|
||||
MACode: "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"},
|
||||
},
|
||||
Content: model.Content{Title: "全国同步测试剧", MAType: "WD", Issuer: "陕西局"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = srcClient.RegisterMapping(chain.RoleCP, model.Mapping{
|
||||
ContentTwinID: "ctid-nat-001", Party: model.PartyCP, PartyID: "PROV-NAT-001", PartyName: "陕西CP",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 同步至全国中心
|
||||
src := &syncpkg.ChainSource{Client: srcClient}
|
||||
result, err := nc.SyncFromProvince(src, syncpkg.ConflictOverwrite)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, result.TotalContents)
|
||||
assert.Equal(t, 1, result.TotalBindings)
|
||||
assert.Equal(t, 1, result.TotalMappings)
|
||||
|
||||
// 全国中心查询验证
|
||||
res, err := nc.QueryByMA("MA.156.8531.6101/WD/20260000001")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Found)
|
||||
assert.Equal(t, "全国同步测试剧", res.Content.Title)
|
||||
|
||||
// 按 Hash 查询
|
||||
res2, err := nc.QueryByHash("fh-nat-001")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res2.Found)
|
||||
|
||||
// 按省级编码查询
|
||||
res3, err := nc.QueryByProvincialCode("PROV-NAT-001")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res3.Found)
|
||||
}
|
||||
|
||||
func TestNationalCatalog_Stats(t *testing.T) {
|
||||
nc := New()
|
||||
nc.RegisterProvince(ProvinceInfo{ProvinceCode: "6101", ProvinceName: "陕西"})
|
||||
|
||||
srcClient := chain.NewMemoryChain()
|
||||
_, err := srcClient.IssueMA(chain.RoleRegulator, chain.IssueRequest{
|
||||
MACode: "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: "陕西局"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = srcClient.RegisterMapping(chain.RoleCP, model.Mapping{
|
||||
ContentTwinID: "ctid-nat-002", Party: model.PartyCP, PartyID: "PROV-NAT-002", PartyName: "陕西CP",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
src := &syncpkg.ChainSource{Client: srcClient}
|
||||
_, err = nc.SyncFromProvince(src, syncpkg.ConflictOverwrite)
|
||||
require.NoError(t, err)
|
||||
|
||||
stats := nc.Stats()
|
||||
assert.Equal(t, 1, stats.TotalContents)
|
||||
assert.Equal(t, 1, stats.TotalProvinces)
|
||||
assert.Equal(t, 1, stats.ByCategory["WD"])
|
||||
}
|
||||
|
||||
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",
|
||||
Node: model.NodeIssue,
|
||||
Operator: "陕西局",
|
||||
Detail: "跨省审核记录",
|
||||
})
|
||||
|
||||
records := nc.QuerySharedAudit("MA.156.8531.6101/WD/20260000003")
|
||||
assert.Len(t, records, 1)
|
||||
assert.Equal(t, "跨省审核记录", records[0].Detail)
|
||||
}
|
||||
Reference in New Issue
Block a user