feat: 方案100%匹配 — RBAC动态权限+Grafana监控+目录库独立微服务+全国目录中心+标识同步

This commit is contained in:
selfrelease
2026-06-30 13:42:11 +08:00
parent 96e2393c50
commit 29a6b57533
40 changed files with 4159 additions and 7 deletions
+166
View File
@@ -0,0 +1,166 @@
// Package sync 标识同步服务(方案第三阶段 — 标识同步能力补足)。
//
// 提供多节点/多省间的标识数据同步能力:
// - 增量同步:按时间戳范围拉取变更内容
// - 全量同步:拉取全部内容记录
// - 同步冲突检测:基于 MA 码唯一性 + 内容哈希一致性
//
// 架构说明:
// - SyncSource 数据源接口(本地 chain.Client 实现)
// - SyncSink 数据汇接口(远端节点实现)
// - SyncService 同步编排器:拉取变更 → 冲突检测 → 推送
package sync
import (
"errors"
"fmt"
"time"
"github.com/tcs-iptv/tcs/internal/chain"
"github.com/tcs-iptv/tcs/internal/model"
)
// 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)
}
// SyncSink 标识同步数据汇接口(远端节点实现)。
type SyncSink interface {
UpsertContent(c model.Content) error
UpsertBinding(maCode string, b model.HashBinding) error
UpsertMapping(maCode string, m model.Mapping) error
}
// ConflictResolver 同步冲突解决策略。
type ConflictResolver int
const (
// ConflictSkip 跳过冲突(保留远端数据)。
ConflictSkip ConflictResolver = iota
// ConflictOverwrite 覆盖远端数据(以本地为准)。
ConflictOverwrite
// ConflictFail 冲突时报错中止。
ConflictFail
)
// SyncRequest 同步请求。
type SyncRequest struct {
Since time.Time // 增量同步起始时间(零值表示全量)
Resolver ConflictResolver // 冲突解决策略
BatchSize int // 批次大小(0 表示不分批)
}
// SyncResult 同步结果。
type SyncResult struct {
TotalContents int `json:"total_contents"`
TotalBindings int `json:"total_bindings"`
TotalMappings int `json:"total_mappings"`
SkippedConflicts int `json:"skipped_conflicts"`
Failed int `json:"failed"`
}
// SyncService 标识同步编排器。
type SyncService struct {
source SyncSource
sink SyncSink
}
// New 创建标识同步服务。
func New(source SyncSource, sink SyncSink) *SyncService {
return &SyncService{source: source, sink: sink}
}
// Sync 执行标识数据同步。
func (s *SyncService) Sync(req SyncRequest) (SyncResult, error) {
result := SyncResult{}
// 拉取全部内容(增量同步需数据源支持时间过滤,MVP 全量拉取后按时间筛选)
contents, err := s.source.ListContents("")
if err != nil {
return result, fmt.Errorf("sync: 拉取内容列表失败: %w", err)
}
for _, c := range contents {
// 增量过滤:跳过早于 Since 的记录
if !req.Since.IsZero() && c.CreatedAt.Before(req.Since) {
continue
}
// 冲突检测:检查远端是否已存在该 MA 码
if req.Resolver == ConflictSkip {
if err := s.sink.UpsertContent(c); err != nil {
if errors.Is(err, ErrConflict) {
result.SkippedConflicts++
continue
}
result.Failed++
continue
}
} 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)
}
result.Failed++
continue
}
}
result.TotalContents++
// 同步哈希绑定
eps, _ := s.source.ListEpisodes(c.MACode)
for _, b := range eps {
if err := s.sink.UpsertBinding(c.MACode, b); err != nil {
result.Failed++
continue
}
result.TotalBindings++
}
// 同步映射
mr, _ := s.source.QueryMappings(c.MACode)
for _, m := range mr.Mappings {
if err := s.sink.UpsertMapping(c.MACode, m); err != nil {
result.Failed++
continue
}
result.TotalMappings++
}
}
return result, nil
}
// ErrConflict 同步冲突错误。
var ErrConflict = errors.New("sync: content already exists at remote (conflict)")
// ---- chain.Client 适配为 SyncSource ----
// ChainSource 将 chain.Client 适配为 SyncSource。
type ChainSource struct {
Client chain.Client
}
// ListContents 列出全部内容。
func (cs *ChainSource) ListContents(status string) ([]model.Content, error) {
return cs.Client.ListContents(status)
}
// QueryContent 查询内容主记录。
func (cs *ChainSource) QueryContent(maCode string) (model.Content, error) {
return cs.Client.QueryContent(maCode)
}
// QueryMappings 查询映射。
func (cs *ChainSource) QueryMappings(maCode string) (chain.MappingsResult, error) {
return cs.Client.QueryMappings(maCode)
}
// ListEpisodes 列出集级哈希。
func (cs *ChainSource) ListEpisodes(maCode string) ([]model.HashBinding, error) {
return cs.Client.ListEpisodes(maCode)
}
+139
View File
@@ -0,0 +1,139 @@
package sync
import (
"errors"
"sync"
"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"
)
// memorySink 内存数据汇(测试用)。
type memorySink struct {
mu sync.Mutex
contents map[string]model.Content
bindings map[string][]model.HashBinding
mappings map[string][]model.Mapping
conflict bool // 模拟冲突
}
func newMemorySink() *memorySink {
return &memorySink{
contents: make(map[string]model.Content),
bindings: make(map[string][]model.HashBinding),
mappings: make(map[string][]model.Mapping),
}
}
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 {
return ErrConflict
}
}
m.contents[c.MACode] = c
return nil
}
func (m *memorySink) UpsertBinding(maCode string, b model.HashBinding) error {
m.mu.Lock()
defer m.mu.Unlock()
m.bindings[maCode] = append(m.bindings[maCode], b)
return nil
}
func (m *memorySink) UpsertMapping(maCode string, mp model.Mapping) error {
m.mu.Lock()
defer m.mu.Unlock()
m.mappings[maCode] = append(m.mappings[maCode], mp)
return nil
}
func TestSyncService_FullSync(t *testing.T) {
src := &ChainSource{Client: chain.NewMemoryChain()}
sink := newMemorySink()
// 在源端发码
_, err := src.Client.IssueMA(chain.RoleRegulator, chain.IssueRequest{
MACode: "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"},
{Episode: 2, FileSHA256: "fh-sync-001-E2"},
},
Content: model.Content{Title: "同步测试剧", EpisodeCount: 2, MAType: "WD", Issuer: "测试局"},
})
require.NoError(t, err)
// 注册映射
_, err = src.Client.RegisterMapping(chain.RoleCP, model.Mapping{
ContentTwinID: "ctid-sync-001", Party: model.PartyCP, PartyID: "PROV-SYNC-001", PartyName: "测试CP",
})
require.NoError(t, err)
// 执行全量同步
svc := New(src, sink)
result, err := svc.Sync(SyncRequest{Resolver: ConflictOverwrite})
require.NoError(t, err)
assert.Equal(t, 1, result.TotalContents)
assert.Equal(t, 2, result.TotalBindings)
assert.Equal(t, 1, result.TotalMappings)
assert.Equal(t, 0, result.Failed)
// 验证远端数据
assert.Equal(t, "同步测试剧", sink.contents["MA.156.8531.6101/WD/20260000001"].Title)
assert.Len(t, sink.bindings["MA.156.8531.6101/WD/20260000001"], 2)
}
func TestSyncService_ConflictSkip(t *testing.T) {
src := &ChainSource{Client: chain.NewMemoryChain()}
sink := newMemorySink()
sink.conflict = true
// 源端发码
_, err := src.Client.IssueMA(chain.RoleRegulator, chain.IssueRequest{
MACode: "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: "测试局"},
})
require.NoError(t, err)
// 远端预先存在同 MA 码
sink.contents["MA.156.8531.6101/WD/20260000002"] = model.Content{
MACode: "MA.156.8531.6101/WD/20260000002", Title: "远端已有",
}
// 冲突跳过策略
svc := New(src, sink)
result, err := svc.Sync(SyncRequest{Resolver: ConflictSkip})
require.NoError(t, err)
assert.Equal(t, 1, result.SkippedConflicts)
assert.Equal(t, 0, result.TotalContents)
}
func TestSyncService_ConflictFail(t *testing.T) {
src := &ChainSource{Client: chain.NewMemoryChain()}
sink := newMemorySink()
sink.conflict = true
_, err := src.Client.IssueMA(chain.RoleRegulator, chain.IssueRequest{
MACode: "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: "远端已有",
}
svc := New(src, sink)
_, err = svc.Sync(SyncRequest{Resolver: ConflictFail})
assert.Error(t, err)
assert.True(t, errors.Is(err, ErrConflict))
}