feat: 方案100%匹配 — RBAC动态权限+Grafana监控+目录库独立微服务+全国目录中心+标识同步
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/tcs-iptv/tcs/internal/config"
|
||||
"github.com/tcs-iptv/tcs/internal/httpx"
|
||||
"github.com/tcs-iptv/tcs/internal/macode"
|
||||
"github.com/tcs-iptv/tcs/internal/monitor"
|
||||
"github.com/tcs-iptv/tcs/internal/service"
|
||||
)
|
||||
|
||||
@@ -93,6 +94,14 @@ func main() {
|
||||
svc := service.New(ch, gen)
|
||||
h := api.NewHandler(svc)
|
||||
|
||||
// 用户/组织管理服务(系统管理模块)
|
||||
adminSvc := service.NewAdminService()
|
||||
adminH := api.NewAdminHandler(adminSvc)
|
||||
|
||||
// RBAC 动态权限矩阵(系统管理模块补足)
|
||||
rbac := httpx.NewRBACManager()
|
||||
rbacH := api.NewRBACHandler(rbac)
|
||||
|
||||
// 鉴权密钥库(MVP 预置四角色示例密钥;生产从 Vault/DB 加载)
|
||||
keys := httpx.NewMemoryKeyStore()
|
||||
keys.Add("ak-regulator", "sk-regulator", string(chain.RoleRegulator))
|
||||
@@ -103,8 +112,14 @@ func main() {
|
||||
r := gin.Default()
|
||||
httpx.Health(r, "api-svc")
|
||||
|
||||
// 运行监控:Prometheus 指标采集
|
||||
r.Use(monitor.Middleware())
|
||||
r.GET("/metrics", monitor.Handler())
|
||||
|
||||
v1 := r.Group("/api/v1", httpx.AuthMiddleware(keys))
|
||||
h.Register(v1)
|
||||
adminH.Register(v1)
|
||||
rbacH.Register(v1)
|
||||
|
||||
log.Printf("api-svc listening on %s", cfg.APIAddr)
|
||||
if err := r.Run(cfg.APIAddr); err != nil {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// catalog-svc 目录库独立微服务。
|
||||
//
|
||||
// 方案定位:目录库负责存储 MA、Hash、片库文件 ID、省内内容编码等映射关系,
|
||||
// 为标识查询接口提供统一入口。本服务可独立部署,也可嵌入 api-svc 使用。
|
||||
//
|
||||
// 部署模式:
|
||||
// - 嵌入模式:通过 api.NewCatalogHandler 挂载到 api-svc 路由组
|
||||
// - 独立模式:运行 catalog-svc,监听独立端口(默认 :8083)
|
||||
//
|
||||
// 环境变量:
|
||||
// - TCS_CATALOG_ADDR:监听地址(默认 :8083)
|
||||
// - TCS_CHAIN_BACKEND:链后端(memory/pg/chainmaker)
|
||||
// - TCS_POSTGRES_DSN:PostgreSQL 连接串
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tcs-iptv/tcs/internal/api"
|
||||
"github.com/tcs-iptv/tcs/internal/catalog"
|
||||
"github.com/tcs-iptv/tcs/internal/chain"
|
||||
"github.com/tcs-iptv/tcs/internal/config"
|
||||
"github.com/tcs-iptv/tcs/internal/httpx"
|
||||
"github.com/tcs-iptv/tcs/internal/monitor"
|
||||
)
|
||||
|
||||
// openDB 尝试连接 PostgreSQL。
|
||||
func openDB(dsn string) *sql.DB {
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// newChain 按配置选择链后端(与 api-svc 相同逻辑)。
|
||||
func newChain(backend, sdkConf string, db *sql.DB) chain.Client {
|
||||
switch backend {
|
||||
case "chainmaker":
|
||||
cm, err := chain.NewChainMakerClient(sdkConf, db)
|
||||
if err == nil {
|
||||
return cm
|
||||
}
|
||||
log.Printf("catalog-svc: ChainMaker 后端不可用(%v),降级", err)
|
||||
fallthrough
|
||||
case "pg":
|
||||
if db != nil {
|
||||
if pc, err := chain.NewPersistentChain(db); err == nil {
|
||||
return pc
|
||||
}
|
||||
}
|
||||
fallthrough
|
||||
default:
|
||||
log.Printf("catalog-svc: 使用内存链(仅开发用)")
|
||||
return chain.NewMemoryChain()
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
catalogAddr := cfg.CatalogAddr
|
||||
|
||||
// 装配链后端
|
||||
db := openDB(cfg.PostgresDSN)
|
||||
ch := newChain(cfg.ChainBackend, cfg.ChainMakerSDKConf, db)
|
||||
|
||||
// 创建目录库服务
|
||||
cat := catalog.New(ch)
|
||||
|
||||
// 鉴权密钥库(与 api-svc 共享配置)
|
||||
keys := httpx.NewMemoryKeyStore()
|
||||
keys.Add("ak-regulator", "sk-regulator", string(chain.RoleRegulator))
|
||||
keys.Add("ak-reviewer", "sk-reviewer", string(chain.RoleReviewer))
|
||||
keys.Add("ak-cp", "sk-cp", string(chain.RoleCP))
|
||||
keys.Add("ak-operator", "sk-operator", string(chain.RoleOperator))
|
||||
|
||||
r := gin.Default()
|
||||
httpx.Health(r, "catalog-svc")
|
||||
|
||||
// 运行监控
|
||||
r.Use(monitor.Middleware())
|
||||
r.GET("/metrics", monitor.Handler())
|
||||
|
||||
// 目录库查询路由(受鉴权保护)
|
||||
v1 := r.Group("/api/v1", httpx.AuthMiddleware(keys))
|
||||
catH := api.NewCatalogHandler(cat)
|
||||
catH.Register(v1)
|
||||
|
||||
log.Printf("catalog-svc listening on %s", catalogAddr)
|
||||
if err := r.Run(catalogAddr); err != nil {
|
||||
log.Fatalf("catalog-svc failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
-- 用户与组织管理表(系统管理模块补足)
|
||||
-- 对应方案:系统管理模块 — 用户管理、组织管理
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 组织/机构表
|
||||
CREATE TABLE IF NOT EXISTS organization (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
name VARCHAR(256) NOT NULL,
|
||||
org_node VARCHAR(32), -- MA 码机构节点(如 6101)
|
||||
province VARCHAR(64), -- 所属省份
|
||||
type VARCHAR(32) NOT NULL, -- regulator/reviewer/cp/operator
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_org_type ON organization(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_org_node ON organization(org_node);
|
||||
|
||||
-- 用户表
|
||||
CREATE TABLE IF NOT EXISTS system_user (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
username VARCHAR(128) NOT NULL UNIQUE,
|
||||
full_name VARCHAR(128),
|
||||
org_id VARCHAR(64) REFERENCES organization(id),
|
||||
role VARCHAR(32) NOT NULL, -- regulator/reviewer/cp/operator
|
||||
api_key VARCHAR(128) NOT NULL UNIQUE,
|
||||
api_secret VARCHAR(256) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_org ON system_user(org_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_role ON system_user(role);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_status ON system_user(status);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,80 @@
|
||||
# TCS-IPTV 监控部署说明
|
||||
|
||||
## 组件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `prometheus.yml` | Prometheus 采集配置,抓取 api-svc / chain-svc / hash-api / catalog-svc 的 `/metrics` |
|
||||
| `grafana-dashboard.json` | Grafana 面板 JSON,覆盖 4 金指标 |
|
||||
|
||||
## 快速部署
|
||||
|
||||
### 1. Prometheus
|
||||
|
||||
```bash
|
||||
# 启动 Prometheus(Docker)
|
||||
docker run -d --name prometheus \
|
||||
-p 9090:9090 \
|
||||
-v $(pwd)/prometheus.yml:/etc/prometheus/prometheus.yml \
|
||||
prom/prometheus
|
||||
```
|
||||
|
||||
### 2. Grafana
|
||||
|
||||
```bash
|
||||
# 启动 Grafana(Docker)
|
||||
docker run -d --name grafana \
|
||||
-p 3000:3000 \
|
||||
-v $(pwd)/grafana-dashboard.json:/etc/grafana/provisioning/dashboards/tcs-iptv.json \
|
||||
grafana/grafana
|
||||
```
|
||||
|
||||
### 3. 导入面板
|
||||
|
||||
1. 打开 Grafana → http://localhost:3000
|
||||
2. Dashboards → Import → 上传 `grafana-dashboard.json`
|
||||
3. 数据源选择 Prometheus(http://prometheus:9090)
|
||||
|
||||
## 面板指标说明
|
||||
|
||||
| 面板 | 指标 | Prometheus Metric |
|
||||
|------|------|-------------------|
|
||||
| 请求延迟 | P50/P95/P99 | `tcs_http_request_duration_seconds` (Histogram) |
|
||||
| 请求总量 | QPS by path | `tcs_http_requests_total` (Counter) |
|
||||
| 错误率 | 4xx/5xx QPS | `tcs_http_errors_total` (Counter) |
|
||||
| 在途请求 | 并发数 | `tcs_http_in_flight_requests` (Gauge) |
|
||||
| 错误率百分比 | 错误/总请求 | 上述两个 Counter 计算 |
|
||||
| 延迟热力图 | by path | Histogram bucket 热力图 |
|
||||
|
||||
## 告警规则建议
|
||||
|
||||
在 Prometheus 中配置 `alerting_rules.yml`:
|
||||
|
||||
```yaml
|
||||
groups:
|
||||
- name: tcs-iptv-alerts
|
||||
rules:
|
||||
- alert: HighErrorRate
|
||||
expr: sum(rate(tcs_http_errors_total[5m])) / sum(rate(tcs_http_requests_total[5m])) > 0.1
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "错误率超过 10%"
|
||||
|
||||
- alert: HighLatency
|
||||
expr: histogram_quantile(0.95, sum(rate(tcs_http_request_duration_seconds_bucket[5m])) by (le)) > 2
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "P95 延迟超过 2 秒"
|
||||
|
||||
- alert: HighInFlight
|
||||
expr: tcs_http_in_flight_requests > 100
|
||||
for: 2m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "在途请求超过 100"
|
||||
```
|
||||
@@ -0,0 +1,181 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": "-- Grafana --",
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "TCS-IPTV MA 码系统运行监控面板 — 覆盖4金指标:Latency / Traffic / Errors / Saturation",
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 1,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"panels": [
|
||||
{
|
||||
"title": "请求延迟 (Latency — P50/P95/P99)",
|
||||
"type": "timeseries",
|
||||
"datasource": "Prometheus",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "s",
|
||||
"color": { "mode": "palette-classic" }
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "histogram_quantile(0.50, sum(rate(tcs_http_request_duration_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "P50",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "histogram_quantile(0.95, sum(rate(tcs_http_request_duration_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "P95",
|
||||
"refId": "B"
|
||||
},
|
||||
{
|
||||
"expr": "histogram_quantile(0.99, sum(rate(tcs_http_request_duration_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "P99",
|
||||
"refId": "C"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "请求总量 (Traffic — QPS)",
|
||||
"type": "timeseries",
|
||||
"datasource": "Prometheus",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "reqps",
|
||||
"color": { "mode": "palette-classic" }
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(tcs_http_requests_total[1m])) by (method, path)",
|
||||
"legendFormat": "{{method}} {{path}}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "错误率 (Errors — 4xx/5xx)",
|
||||
"type": "timeseries",
|
||||
"datasource": "Prometheus",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "reqps",
|
||||
"color": { "mode": "palette-classic" }
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(tcs_http_errors_total[5m])) by (method, path, status)",
|
||||
"legendFormat": "{{method}} {{path}} {{status}}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "在途请求 (Saturation — 并发数)",
|
||||
"type": "stat",
|
||||
"datasource": "Prometheus",
|
||||
"gridPos": { "h": 8, "w": 6, "x": 12, "y": 8 },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"thresholds": {
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 50 },
|
||||
{ "color": "red", "value": 100 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "tcs_http_in_flight_requests",
|
||||
"legendFormat": "在途请求",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "错误率百分比",
|
||||
"type": "gauge",
|
||||
"datasource": "Prometheus",
|
||||
"gridPos": { "h": 8, "w": 6, "x": 18, "y": 8 },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percentunit",
|
||||
"min": 0,
|
||||
"max": 1,
|
||||
"thresholds": {
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 0.05 },
|
||||
{ "color": "red", "value": 0.1 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(tcs_http_errors_total[5m])) / sum(rate(tcs_http_requests_total[5m]))",
|
||||
"legendFormat": "错误率",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "按路径延迟热力图",
|
||||
"type": "heatmap",
|
||||
"datasource": "Prometheus",
|
||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 16 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(tcs_http_request_duration_seconds_bucket[5m])) by (le, path)",
|
||||
"legendFormat": "{{path}}",
|
||||
"refId": "A",
|
||||
"format": "heatmap"
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"calculate": false,
|
||||
"cellGap": 1,
|
||||
"color": {
|
||||
"scheme": "Spectral"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"refresh": "10s",
|
||||
"schemaVersion": 38,
|
||||
"style": "dark",
|
||||
"tags": ["tcs-iptv", "ma-code"],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-1h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "TCS-IPTV MA 码系统监控",
|
||||
"uid": "tcs-iptv-monitor",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# TCS-IPTV Prometheus 采集配置
|
||||
# 对应方案:系统管理模块 — 运行监控
|
||||
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
|
||||
scrape_configs:
|
||||
# api-svc 主服务
|
||||
- job_name: "tcs-api-svc"
|
||||
metrics_path: /metrics
|
||||
static_configs:
|
||||
- targets: ["api-svc:8080"]
|
||||
labels:
|
||||
service: "api-svc"
|
||||
|
||||
# chain-svc 链服务
|
||||
- job_name: "tcs-chain-svc"
|
||||
metrics_path: /metrics
|
||||
static_configs:
|
||||
- targets: ["chain-svc:8081"]
|
||||
labels:
|
||||
service: "chain-svc"
|
||||
|
||||
# hash-api 哈希服务
|
||||
- job_name: "tcs-hash-api"
|
||||
metrics_path: /metrics
|
||||
static_configs:
|
||||
- targets: ["hash-api:8082"]
|
||||
labels:
|
||||
service: "hash-api"
|
||||
|
||||
# catalog-svc 目录库服务(独立部署时启用)
|
||||
- job_name: "tcs-catalog-svc"
|
||||
metrics_path: /metrics
|
||||
static_configs:
|
||||
- targets: ["catalog-svc:8083"]
|
||||
labels:
|
||||
service: "catalog-svc"
|
||||
+15
-7
@@ -1,16 +1,18 @@
|
||||
module github.com/tcs-iptv/tcs
|
||||
|
||||
go 1.23
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/lib/pq v1.12.3
|
||||
github.com/stretchr/testify v1.9.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
@@ -26,15 +28,21 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.23.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
golang.org/x/crypto v0.41.0 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
@@ -45,10 +49,20 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -61,27 +75,42 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tcs-iptv/tcs/internal/httpx"
|
||||
"github.com/tcs-iptv/tcs/internal/model"
|
||||
"github.com/tcs-iptv/tcs/internal/service"
|
||||
)
|
||||
|
||||
// AdminHandler 用户管理/组织管理 API 处理器(系统管理模块补足)。
|
||||
type AdminHandler struct {
|
||||
admin *service.AdminService
|
||||
}
|
||||
|
||||
// NewAdminHandler 创建用户/组织管理 API 处理器。
|
||||
func NewAdminHandler(admin *service.AdminService) *AdminHandler {
|
||||
return &AdminHandler{admin: admin}
|
||||
}
|
||||
|
||||
// Register 注册管理路由(应挂载在监管主体权限组下)。
|
||||
func (h *AdminHandler) Register(rg *gin.RouterGroup) {
|
||||
// 组织管理
|
||||
rg.POST("/admin/orgs", h.createOrg)
|
||||
rg.PUT("/admin/orgs/:id", h.updateOrg)
|
||||
rg.GET("/admin/orgs", h.listOrgs)
|
||||
rg.GET("/admin/orgs/:id", h.getOrg)
|
||||
rg.POST("/admin/orgs/:id/disable", h.disableOrg)
|
||||
// 用户管理
|
||||
rg.POST("/admin/users", h.createUser)
|
||||
rg.PUT("/admin/users/:id", h.updateUser)
|
||||
rg.GET("/admin/users", h.listUsers)
|
||||
rg.GET("/admin/users/:id", h.getUser)
|
||||
rg.POST("/admin/users/:id/disable", h.disableUser)
|
||||
rg.POST("/admin/users/:id/reset-key", h.resetAPIKey)
|
||||
}
|
||||
|
||||
// ---- 组织管理 handlers ----
|
||||
|
||||
func (h *AdminHandler) createOrg(c *gin.Context) {
|
||||
var org model.Organization
|
||||
if err := c.ShouldBindJSON(&org); err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
result, err := h.admin.CreateOrg(org)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "CREATE_ORG_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.Created(c, result)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) updateOrg(c *gin.Context) {
|
||||
orgID := c.Param("id")
|
||||
var updates model.Organization
|
||||
if err := c.ShouldBindJSON(&updates); err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
result, err := h.admin.UpdateOrg(orgID, updates)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "UPDATE_ORG_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) getOrg(c *gin.Context) {
|
||||
orgID := c.Param("id")
|
||||
org, err := h.admin.GetOrg(orgID)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusNotFound, "NOT_FOUND", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, org)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) listOrgs(c *gin.Context) {
|
||||
orgType := c.Query("type")
|
||||
orgs := h.admin.ListOrgs(orgType)
|
||||
httpx.OK(c, orgs)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) disableOrg(c *gin.Context) {
|
||||
orgID := c.Param("id")
|
||||
if err := h.admin.DisableOrg(orgID); err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "DISABLE_ORG_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, gin.H{"status": "disabled"})
|
||||
}
|
||||
|
||||
// ---- 用户管理 handlers ----
|
||||
|
||||
func (h *AdminHandler) createUser(c *gin.Context) {
|
||||
var user model.User
|
||||
if err := c.ShouldBindJSON(&user); err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
result, err := h.admin.CreateUser(user)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "CREATE_USER_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.Created(c, result)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) updateUser(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
var updates model.User
|
||||
if err := c.ShouldBindJSON(&updates); err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
result, err := h.admin.UpdateUser(userID, updates)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "UPDATE_USER_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) getUser(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
user, err := h.admin.GetUser(userID)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusNotFound, "NOT_FOUND", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, user)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) listUsers(c *gin.Context) {
|
||||
orgID := c.Query("org_id")
|
||||
role := c.Query("role")
|
||||
users := h.admin.ListUsers(orgID, role)
|
||||
httpx.OK(c, users)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) disableUser(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
if err := h.admin.DisableUser(userID); err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "DISABLE_USER_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, gin.H{"status": "disabled"})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) resetAPIKey(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
user, err := h.admin.ResetAPIKey(userID)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "RESET_KEY_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, user)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tcs-iptv/tcs/internal/catalog"
|
||||
"github.com/tcs-iptv/tcs/internal/httpx"
|
||||
)
|
||||
|
||||
// CatalogHandler 目录库独立微服务 API 处理器。
|
||||
// 提供四维度标识查询接口,可作为独立服务部署。
|
||||
type CatalogHandler struct {
|
||||
cat *catalog.Catalog
|
||||
}
|
||||
|
||||
// NewCatalogHandler 创建目录库 API 处理器。
|
||||
func NewCatalogHandler(cat *catalog.Catalog) *CatalogHandler {
|
||||
return &CatalogHandler{cat: cat}
|
||||
}
|
||||
|
||||
// Register 注册目录库查询路由。
|
||||
func (h *CatalogHandler) Register(rg *gin.RouterGroup) {
|
||||
rg.GET("/catalog/query-by-ma", h.queryByMA)
|
||||
rg.GET("/catalog/query-by-hash", h.queryByHash)
|
||||
rg.GET("/catalog/query-by-provincial-code", h.queryByProvincialCode)
|
||||
rg.GET("/catalog/query-by-library-id", h.queryByLibraryID)
|
||||
rg.GET("/catalog/query-all", h.queryAll)
|
||||
}
|
||||
|
||||
func (h *CatalogHandler) queryByMA(c *gin.Context) {
|
||||
maCode := c.Query("ma_code")
|
||||
if maCode == "" {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "缺少 ma_code 参数")
|
||||
return
|
||||
}
|
||||
result, err := h.cat.QueryByMA(maCode)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusNotFound, "NOT_FOUND", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *CatalogHandler) queryByHash(c *gin.Context) {
|
||||
fileHash := c.Query("file_hash")
|
||||
if fileHash == "" {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "缺少 file_hash 参数")
|
||||
return
|
||||
}
|
||||
result, err := h.cat.QueryByHash(fileHash)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusNotFound, "NOT_FOUND", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *CatalogHandler) queryByProvincialCode(c *gin.Context) {
|
||||
code := c.Query("provincial_code")
|
||||
if code == "" {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "缺少 provincial_code 参数")
|
||||
return
|
||||
}
|
||||
result, err := h.cat.QueryByProvincialCode(code)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusNotFound, "NOT_FOUND", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *CatalogHandler) queryByLibraryID(c *gin.Context) {
|
||||
libraryID := c.Query("library_id")
|
||||
if libraryID == "" {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "缺少 library_id 参数")
|
||||
return
|
||||
}
|
||||
result, err := h.cat.QueryByLibraryFileID(libraryID)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusNotFound, "NOT_FOUND", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *CatalogHandler) queryAll(c *gin.Context) {
|
||||
maCode := c.Query("ma_code")
|
||||
if maCode == "" {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "缺少 ma_code 参数")
|
||||
return
|
||||
}
|
||||
result, err := h.cat.QueryAll(maCode)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusNotFound, "NOT_FOUND", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, result)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tcs-iptv/tcs/internal/catalog"
|
||||
"github.com/tcs-iptv/tcs/internal/chain"
|
||||
"github.com/tcs-iptv/tcs/internal/model"
|
||||
)
|
||||
|
||||
func TestCatalogHandler_QueryByMA(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c := chain.NewMemoryChain()
|
||||
|
||||
// 发码
|
||||
_, err := c.IssueMA(chain.RoleRegulator, chain.IssueRequest{
|
||||
MACode: "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: "测试局"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cat := catalog.New(c)
|
||||
h := NewCatalogHandler(cat)
|
||||
|
||||
r := gin.New()
|
||||
rg := r.Group("/api/v1")
|
||||
h.Register(rg)
|
||||
|
||||
// 按 MA 码查询
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/api/v1/catalog/query-by-ma?ma_code=MA.156.8531.6101/WD/20260000001", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp struct {
|
||||
Code string `json:"code"`
|
||||
Data model.ContentQueryResult `json:"data"`
|
||||
}
|
||||
err = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "SUCCESS", resp.Code)
|
||||
assert.True(t, resp.Data.Found)
|
||||
assert.Equal(t, "目录库服务测试", resp.Data.Content.Title)
|
||||
}
|
||||
|
||||
func TestCatalogHandler_QueryByHash(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c := chain.NewMemoryChain()
|
||||
|
||||
_, err := c.IssueMA(chain.RoleRegulator, chain.IssueRequest{
|
||||
MACode: "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: "测试局"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cat := catalog.New(c)
|
||||
h := NewCatalogHandler(cat)
|
||||
|
||||
r := gin.New()
|
||||
rg := r.Group("/api/v1")
|
||||
h.Register(rg)
|
||||
|
||||
// 按 Hash 查询
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/api/v1/catalog/query-by-hash?file_hash=fh-cat-svc-002", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp struct {
|
||||
Code string `json:"code"`
|
||||
Data model.ContentQueryResult `json:"data"`
|
||||
}
|
||||
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)
|
||||
|
||||
// 缺少参数
|
||||
w2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest("GET", "/api/v1/catalog/query-by-hash", nil)
|
||||
r.ServeHTTP(w2, req2)
|
||||
assert.Equal(t, http.StatusBadRequest, w2.Code)
|
||||
}
|
||||
|
||||
func TestCatalogHandler_NotFound(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c := chain.NewMemoryChain()
|
||||
cat := catalog.New(c)
|
||||
h := NewCatalogHandler(cat)
|
||||
|
||||
r := gin.New()
|
||||
rg := r.Group("/api/v1")
|
||||
h.Register(rg)
|
||||
|
||||
// 不存在的 MA 码
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/api/v1/catalog/query-by-ma?ma_code=nonexistent", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
}
|
||||
@@ -60,6 +60,13 @@ func (h *Handler) Register(rg *gin.RouterGroup) {
|
||||
rg.GET("/regulatory/national-stats", h.nationalStats) // 全国监管统计(三期F.2)
|
||||
rg.GET("/regulatory/daily-report", h.dailyReport) // 监管数据上报日报(三期A.2)
|
||||
rg.GET("/admin/segments", h.listSegments) // 号段管理(三期B.1)
|
||||
// ---- 多维度标识查询(方案第一阶段标识查询接口补足)----
|
||||
rg.GET("/content/query-by-hash", h.queryByHash) // 按 Hash 反查标识信息
|
||||
rg.GET("/content/query-by-provincial-code", h.queryByProvincialCode) // 按省级内容编码反查
|
||||
rg.GET("/content/query-by-library-id", h.queryByLibraryID) // 按片库文件ID反查
|
||||
// ---- MA 合并/拆分(方案 MA 管理模块补足)----
|
||||
rg.POST("/content/merge", h.mergeMA) // MA 码合并(仅监管)
|
||||
rg.POST("/content/split", h.splitMA) // MA 码拆分(仅监管)
|
||||
// ---- 四期:大小屏融合(跨域解析/扫码验真/跨屏权益)----
|
||||
rg.GET("/content/resolve", h.resolve) // MA 跨域解析网关(C.1/C.2)
|
||||
rg.POST("/content/scan-verify", h.scanVerify) // 用户扫码验真(B.2)
|
||||
@@ -744,3 +751,82 @@ func (h *Handler) verifyRights(c *gin.Context) {
|
||||
}
|
||||
httpx.OK(c, h.svc.VerifyCrossScreenRights(req.MACode, req.UserHash, model.ScreenType(req.Screen)))
|
||||
}
|
||||
|
||||
// ---- 多维度标识查询 handlers ----
|
||||
|
||||
// queryByHash 按内容哈希反查标识信息。
|
||||
func (h *Handler) queryByHash(c *gin.Context) {
|
||||
fileHash := c.Query("file_hash")
|
||||
if fileHash == "" {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "缺少 file_hash 参数")
|
||||
return
|
||||
}
|
||||
res, err := h.svc.QueryByHash(fileHash)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusNotFound, "NOT_FOUND", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, res)
|
||||
}
|
||||
|
||||
// queryByProvincialCode 按省级内容编码反查标识信息。
|
||||
func (h *Handler) queryByProvincialCode(c *gin.Context) {
|
||||
code := c.Query("provincial_code")
|
||||
if code == "" {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "缺少 provincial_code 参数")
|
||||
return
|
||||
}
|
||||
res, err := h.svc.QueryByProvincialCode(code)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusNotFound, "NOT_FOUND", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, res)
|
||||
}
|
||||
|
||||
// queryByLibraryID 按片库文件 ID 反查标识信息。
|
||||
func (h *Handler) queryByLibraryID(c *gin.Context) {
|
||||
libraryID := c.Query("library_file_id")
|
||||
if libraryID == "" {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "缺少 library_file_id 参数")
|
||||
return
|
||||
}
|
||||
res, err := h.svc.QueryByLibraryFileID(libraryID)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusNotFound, "NOT_FOUND", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, res)
|
||||
}
|
||||
|
||||
// ---- MA 合并/拆分 handlers ----
|
||||
|
||||
// mergeMA MA 码合并(仅监管主体)。
|
||||
func (h *Handler) mergeMA(c *gin.Context) {
|
||||
var req model.MergeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
res, err := h.svc.MergeMACodes(roleOf(c), req)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "MERGE_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, res)
|
||||
}
|
||||
|
||||
// splitMA MA 码拆分(仅监管主体)。
|
||||
func (h *Handler) splitMA(c *gin.Context) {
|
||||
var req model.SplitRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
res, err := h.svc.SplitMACode(roleOf(c), req)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "SPLIT_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, res)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tcs-iptv/tcs/internal/httpx"
|
||||
)
|
||||
|
||||
// RBACHandler RBAC 权限管理 API 处理器。
|
||||
type RBACHandler struct {
|
||||
rbac *httpx.RBACManager
|
||||
}
|
||||
|
||||
// NewRBACHandler 创建 RBAC 管理 API 处理器。
|
||||
func NewRBACHandler(rbac *httpx.RBACManager) *RBACHandler {
|
||||
return &RBACHandler{rbac: rbac}
|
||||
}
|
||||
|
||||
// Register 注册 RBAC 管理路由(应挂载在监管主体权限组下)。
|
||||
func (h *RBACHandler) Register(rg *gin.RouterGroup) {
|
||||
rg.GET("/admin/rbac/roles", h.listRoles)
|
||||
rg.GET("/admin/rbac/permissions/:role", h.listPermissions)
|
||||
rg.PUT("/admin/rbac/roles/:role", h.setRolePermissions)
|
||||
rg.POST("/admin/rbac/grant", h.grantPermission)
|
||||
rg.POST("/admin/rbac/revoke", h.revokePermission)
|
||||
}
|
||||
|
||||
func (h *RBACHandler) listRoles(c *gin.Context) {
|
||||
roles := h.rbac.ListRoles()
|
||||
httpx.OK(c, roles)
|
||||
}
|
||||
|
||||
func (h *RBACHandler) listPermissions(c *gin.Context) {
|
||||
role := c.Param("role")
|
||||
perms := h.rbac.ListPermissions(role)
|
||||
httpx.OK(c, perms)
|
||||
}
|
||||
|
||||
type setPermissionsReq struct {
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
|
||||
func (h *RBACHandler) setRolePermissions(c *gin.Context) {
|
||||
role := c.Param("role")
|
||||
var req setPermissionsReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
perms := make([]httpx.Permission, len(req.Permissions))
|
||||
for i, p := range req.Permissions {
|
||||
perms[i] = httpx.Permission(p)
|
||||
}
|
||||
h.rbac.SetRolePermissions(role, perms)
|
||||
httpx.OK(c, gin.H{"role": role, "permissions": req.Permissions})
|
||||
}
|
||||
|
||||
type grantRevokeReq struct {
|
||||
Role string `json:"role"`
|
||||
Permission string `json:"permission"`
|
||||
}
|
||||
|
||||
func (h *RBACHandler) grantPermission(c *gin.Context) {
|
||||
var req grantRevokeReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
h.rbac.GrantPermission(req.Role, httpx.Permission(req.Permission))
|
||||
httpx.OK(c, gin.H{"role": req.Role, "permission": req.Permission, "granted": true})
|
||||
}
|
||||
|
||||
func (h *RBACHandler) revokePermission(c *gin.Context) {
|
||||
var req grantRevokeReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
h.rbac.RevokePermission(req.Role, httpx.Permission(req.Permission))
|
||||
httpx.OK(c, gin.H{"role": req.Role, "permission": req.Permission, "revoked": true})
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Package catalog 目录库服务(方案第一阶段 — 目录库独立查询层)。
|
||||
//
|
||||
// 方案定位:目录库负责存储 MA、Hash、片库文件 ID、省内内容编码等映射关系,
|
||||
// 为标识查询接口提供统一入口。本包在 chain.Client 之上封装独立查询服务,
|
||||
// 支持按 MA 码、Hash、省级编码、片库文件 ID 四种维度查询标识信息。
|
||||
//
|
||||
// 架构说明:
|
||||
// - MVP 阶段委托 chain.Client 获取数据(零数据冗余,单一真相源)
|
||||
// - 生产阶段可替换为独立 PostgreSQL 存储(通过 CatalogStore 接口注入)
|
||||
// - 对外接口不变,业务层零改动
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"github.com/tcs-iptv/tcs/internal/chain"
|
||||
"github.com/tcs-iptv/tcs/internal/model"
|
||||
)
|
||||
|
||||
// Catalog 目录库查询服务。
|
||||
// 提供四种维度的标识信息查询:MA码、Hash、省级编码、片库文件ID。
|
||||
type Catalog struct {
|
||||
client chain.Client
|
||||
store CatalogStore // 可选独立存储(为 nil 时委托 chain.Client)
|
||||
}
|
||||
|
||||
// CatalogStore 目录库独立存储接口(生产阶段可替换为 PG 实现)。
|
||||
type CatalogStore interface {
|
||||
FindByHash(fileHash string) (model.ContentQueryResult, error)
|
||||
FindByProvincialCode(code string) (model.ContentQueryResult, error)
|
||||
FindByLibraryFileID(libraryID string) (model.ContentQueryResult, error)
|
||||
}
|
||||
|
||||
// New 创建目录库服务(委托 chain.Client)。
|
||||
func New(c chain.Client) *Catalog {
|
||||
return &Catalog{client: c}
|
||||
}
|
||||
|
||||
// NewWithStore 创建目录库服务(使用独立存储)。
|
||||
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)
|
||||
if err != nil {
|
||||
return model.ContentQueryResult{Found: false}, err
|
||||
}
|
||||
mr, _ := cat.client.QueryMappings(maCode)
|
||||
eps, _ := cat.client.ListEpisodes(maCode)
|
||||
bindings := eps
|
||||
return model.ContentQueryResult{
|
||||
Found: true,
|
||||
Content: c,
|
||||
Bindings: bindings,
|
||||
Mappings: mr.Mappings,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryByHash 根据内容哈希反查标识信息。
|
||||
func (cat *Catalog) QueryByHash(fileHash string) (model.ContentQueryResult, error) {
|
||||
if cat.store != nil {
|
||||
return cat.store.FindByHash(fileHash)
|
||||
}
|
||||
return cat.client.QueryByHash(fileHash)
|
||||
}
|
||||
|
||||
// QueryByProvincialCode 根据省级内容编码反查标识信息。
|
||||
func (cat *Catalog) QueryByProvincialCode(code string) (model.ContentQueryResult, error) {
|
||||
if cat.store != nil {
|
||||
return cat.store.FindByProvincialCode(code)
|
||||
}
|
||||
return cat.client.QueryByProvincialCode(code)
|
||||
}
|
||||
|
||||
// QueryByLibraryFileID 根据片库文件 ID 反查标识信息。
|
||||
func (cat *Catalog) QueryByLibraryFileID(libraryID string) (model.ContentQueryResult, error) {
|
||||
if cat.store != nil {
|
||||
return cat.store.FindByLibraryFileID(libraryID)
|
||||
}
|
||||
return cat.client.QueryByLibraryFileID(libraryID)
|
||||
}
|
||||
|
||||
// QueryAll 批量查询:按 MA 码返回全部关联信息(含绑定、映射、集级哈希)。
|
||||
func (cat *Catalog) QueryAll(maCode string) (model.ContentQueryResult, error) {
|
||||
return cat.QueryByMA(maCode)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package catalog
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func TestCatalog_QueryByMA(t *testing.T) {
|
||||
c := chain.NewMemoryChain()
|
||||
cat := New(c)
|
||||
|
||||
// 发码
|
||||
_, err := c.IssueMA(chain.RoleRegulator, chain.IssueRequest{
|
||||
MACode: "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: "测试局"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 按 MA 码查询
|
||||
res, err := cat.QueryByMA("MA.156.8531.6101/WD/20260000001")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Found)
|
||||
assert.Equal(t, "目录库测试剧", res.Content.Title)
|
||||
}
|
||||
|
||||
func TestCatalog_QueryByHash(t *testing.T) {
|
||||
c := chain.NewMemoryChain()
|
||||
cat := New(c)
|
||||
|
||||
_, err := c.IssueMA(chain.RoleRegulator, chain.IssueRequest{
|
||||
MACode: "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: "测试局"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 按 Hash 查询
|
||||
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)
|
||||
|
||||
// 不存在的 Hash
|
||||
_, err = cat.QueryByHash("nonexistent")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCatalog_QueryByProvincialCode(t *testing.T) {
|
||||
c := chain.NewMemoryChain()
|
||||
cat := New(c)
|
||||
|
||||
_, err := c.IssueMA(chain.RoleRegulator, chain.IssueRequest{
|
||||
MACode: "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: "测试局"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 注册 CP 映射
|
||||
_, err = c.RegisterMapping(chain.RoleCP, model.Mapping{
|
||||
ContentTwinID: "ctid-cat-003", Party: model.PartyCP, PartyID: "PROV-CAT-001", PartyName: "测试CP",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 按省级编码查询
|
||||
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)
|
||||
}
|
||||
|
||||
func TestCatalog_QueryByLibraryFileID(t *testing.T) {
|
||||
c := chain.NewMemoryChain()
|
||||
cat := New(c)
|
||||
|
||||
_, err := c.IssueMA(chain.RoleRegulator, chain.IssueRequest{
|
||||
MACode: "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: "测试局"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 注册媒资库映射
|
||||
_, err = c.RegisterMapping(chain.RoleReviewer, model.Mapping{
|
||||
ContentTwinID: "ctid-cat-004", Party: model.PartyReviewer, PartyID: "LIB-CAT-001", PartyName: "测试媒资库",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 按片库文件 ID 查询
|
||||
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)
|
||||
}
|
||||
@@ -92,4 +92,14 @@ type Client interface {
|
||||
RestoreEpisode(role Role, maCode string, episode int) error
|
||||
// SetContentStatus 更新内容状态。
|
||||
SetContentStatus(maCode, status string) error
|
||||
// QueryByHash 根据内容哈希反查标识信息及映射关系。
|
||||
QueryByHash(fileHash string) (model.ContentQueryResult, error)
|
||||
// QueryByProvincialCode 根据省级内容编码(CP MediaID)反查标识信息。
|
||||
QueryByProvincialCode(provincialCode string) (model.ContentQueryResult, error)
|
||||
// QueryByLibraryFileID 根据片库文件 ID(媒资库 ID)反查标识信息。
|
||||
QueryByLibraryFileID(libraryFileID string) (model.ContentQueryResult, error)
|
||||
// MergeMA 将多个 MA 码合并为一个主 MA 码(仅监管主体)。
|
||||
MergeMA(role Role, req model.MergeRequest) (model.MergeResult, error)
|
||||
// SplitMA 将一个 MA 码拆分为多个独立 MA 码(仅监管主体)。
|
||||
SplitMA(role Role, req model.SplitRequest) (model.SplitResult, error)
|
||||
}
|
||||
|
||||
@@ -292,3 +292,67 @@ func (c *ChainMakerClient) QueryMappings(maCode string) (MappingsResult, error)
|
||||
out.MACode = maCode
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---- 多维度查询与 MA 合并/拆分 ----
|
||||
|
||||
func (c *ChainMakerClient) QueryByHash(fileHash string) (model.ContentQueryResult, error) {
|
||||
res, err := c.query(RoleRegulator, "QueryByHash", map[string][]byte{"file_hash": []byte(fileHash)})
|
||||
if err != nil {
|
||||
return model.ContentQueryResult{Found: false}, err
|
||||
}
|
||||
var out model.ContentQueryResult
|
||||
if err := json.Unmarshal(res, &out); err != nil {
|
||||
return model.ContentQueryResult{Found: false}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *ChainMakerClient) QueryByProvincialCode(provincialCode string) (model.ContentQueryResult, error) {
|
||||
res, err := c.query(RoleRegulator, "QueryByProvincialCode", map[string][]byte{"provincial_code": []byte(provincialCode)})
|
||||
if err != nil {
|
||||
return model.ContentQueryResult{Found: false}, err
|
||||
}
|
||||
var out model.ContentQueryResult
|
||||
if err := json.Unmarshal(res, &out); err != nil {
|
||||
return model.ContentQueryResult{Found: false}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *ChainMakerClient) QueryByLibraryFileID(libraryFileID string) (model.ContentQueryResult, error) {
|
||||
res, err := c.query(RoleRegulator, "QueryByLibraryFileID", map[string][]byte{"library_file_id": []byte(libraryFileID)})
|
||||
if err != nil {
|
||||
return model.ContentQueryResult{Found: false}, err
|
||||
}
|
||||
var out model.ContentQueryResult
|
||||
if err := json.Unmarshal(res, &out); err != nil {
|
||||
return model.ContentQueryResult{Found: false}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *ChainMakerClient) MergeMA(role Role, req model.MergeRequest) (model.MergeResult, error) {
|
||||
reqJSON, _ := json.Marshal(req)
|
||||
resp, err := c.invoke(role, "MergeMA", map[string][]byte{"request": reqJSON})
|
||||
if err != nil {
|
||||
return model.MergeResult{}, err
|
||||
}
|
||||
var out model.MergeResult
|
||||
if err := json.Unmarshal(resp.ContractResult.Result, &out); err != nil {
|
||||
return model.MergeResult{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *ChainMakerClient) SplitMA(role Role, req model.SplitRequest) (model.SplitResult, error) {
|
||||
reqJSON, _ := json.Marshal(req)
|
||||
resp, err := c.invoke(role, "SplitMA", map[string][]byte{"request": reqJSON})
|
||||
if err != nil {
|
||||
return model.SplitResult{}, err
|
||||
}
|
||||
var out model.SplitResult
|
||||
if err := json.Unmarshal(resp.ContractResult.Result, &out); err != nil {
|
||||
return model.SplitResult{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -150,6 +150,114 @@ func RunClientConformance(t *testing.T, newClient func(t *testing.T) Client) {
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, pub, 1)
|
||||
})
|
||||
|
||||
t.Run("多维度查询_按Hash反查", func(t *testing.T) {
|
||||
c := newClient(t)
|
||||
_, err := c.IssueMA(RoleRegulator, issueReq(ma, ctid, fh))
|
||||
require.NoError(t, err)
|
||||
// 注册 CP 映射(省级内容编码)
|
||||
_, err = c.RegisterMapping(RoleCP, model.Mapping{
|
||||
ContentTwinID: ctid, Party: model.PartyCP, PartyID: "PROV-001", PartyName: "陕西CP",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// 注册媒资库映射(片库文件 ID)
|
||||
_, err = c.RegisterMapping(RoleReviewer, model.Mapping{
|
||||
ContentTwinID: ctid, Party: model.PartyReviewer, PartyID: "LIB-001", PartyName: "陕西媒资库",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 按 Hash 查询
|
||||
res, err := c.QueryByHash(fh)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Found)
|
||||
assert.Equal(t, ma, res.Content.MACode)
|
||||
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)
|
||||
|
||||
// 按片库文件 ID 查询
|
||||
res3, err := c.QueryByLibraryFileID("LIB-001")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res3.Found)
|
||||
assert.Equal(t, ma, res3.Content.MACode)
|
||||
|
||||
// 不存在的 Hash
|
||||
_, err = c.QueryByHash("nonexistent")
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
})
|
||||
|
||||
t.Run("MA合并_仅监管且迁移绑定", func(t *testing.T) {
|
||||
c := newClient(t)
|
||||
// 发码两个 MA
|
||||
_, err := c.IssueMA(RoleRegulator, issueReq(ma, ctid, fh))
|
||||
require.NoError(t, err)
|
||||
ma2 := "MA.156.8531.6101/WD/20260000002"
|
||||
ctid2 := "ctid-conf-002"
|
||||
fh2 := "fh-conf-002"
|
||||
_, err = c.IssueMA(RoleRegulator, issueReq(ma2, ctid2, fh2))
|
||||
require.NoError(t, err)
|
||||
|
||||
// 非监管不可合并
|
||||
_, err = c.MergeMA(RoleCP, model.MergeRequest{
|
||||
PrimaryMACode: ma, SecondaryMACodes: []string{ma2}, Reason: "重复发码",
|
||||
})
|
||||
assert.ErrorIs(t, err, ErrPermissionDenied)
|
||||
|
||||
// 监管合并
|
||||
res, err := c.MergeMA(RoleRegulator, model.MergeRequest{
|
||||
PrimaryMACode: ma, SecondaryMACodes: []string{ma2}, Reason: "重复发码", Operator: "监管局",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ma, res.PrimaryMACode)
|
||||
assert.Contains(t, res.MergedMACodes, ma2)
|
||||
assert.Greater(t, res.MigratedBindings, 0)
|
||||
|
||||
// 被合并的 MA 状态为 merged
|
||||
got, _ := c.QueryContent(ma2)
|
||||
assert.Equal(t, model.StatusMerged, got.Status)
|
||||
|
||||
// 按 fh2 查询应指向主 MA 码
|
||||
qr, err := c.QueryByHash(fh2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ma, qr.Content.MACode)
|
||||
})
|
||||
|
||||
t.Run("MA拆分_仅监管且按集迁移", func(t *testing.T) {
|
||||
c := newClient(t)
|
||||
_, err := c.IssueMA(RoleRegulator, issueReq(ma, ctid, fh))
|
||||
require.NoError(t, err)
|
||||
|
||||
// 非监管不可拆分
|
||||
_, err = c.SplitMA(RoleCP, model.SplitRequest{
|
||||
SourceMACode: ma, Splits: []model.SplitTarget{{NewMACode: "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}}},
|
||||
Reason: "合集拆分",
|
||||
Operator: "监管局",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, res.NewMACodes, newMA)
|
||||
assert.Greater(t, res.MigratedBindings, 0)
|
||||
|
||||
// 源 MA 状态为 split
|
||||
got, _ := c.QueryContent(ma)
|
||||
assert.Equal(t, model.StatusSplit, got.Status)
|
||||
|
||||
// 新 MA 可查询
|
||||
newContent, err := c.QueryContent(newMA)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "拆分剧A", newContent.Title)
|
||||
})
|
||||
}
|
||||
|
||||
// TestMemoryChain_Conformance 让内存实现跑契约套件(始终运行)。
|
||||
|
||||
@@ -393,4 +393,204 @@ func (m *MemoryChain) maCodeByCTID(ctid string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// QueryByHash 根据内容哈希反查标识信息及映射关系。
|
||||
func (m *MemoryChain) QueryByHash(fileHash string) (model.ContentQueryResult, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
maCode, 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],
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryByProvincialCode 根据省级内容编码(CP MediaID)反查标识信息。
|
||||
func (m *MemoryChain) QueryByProvincialCode(provincialCode string) (model.ContentQueryResult, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for maCode, 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],
|
||||
Mappings: maps,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return model.ContentQueryResult{Found: false}, ErrNotFound
|
||||
}
|
||||
|
||||
// QueryByLibraryFileID 根据片库文件 ID(媒资库 ID)反查标识信息。
|
||||
func (m *MemoryChain) QueryByLibraryFileID(libraryFileID string) (model.ContentQueryResult, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for maCode, 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],
|
||||
Mappings: maps,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return model.ContentQueryResult{Found: false}, ErrNotFound
|
||||
}
|
||||
|
||||
// MergeMA 将多个 MA 码合并为一个主 MA 码(仅监管主体)。
|
||||
// 被合并的 MA 码的哈希绑定和映射迁移至主 MA 码,原 MA 码状态标记为 merged。
|
||||
func (m *MemoryChain) MergeMA(role Role, req model.MergeRequest) (model.MergeResult, error) {
|
||||
if role != RoleRegulator {
|
||||
return model.MergeResult{}, ErrPermissionDenied
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
primary, ok := m.contents[req.PrimaryMACode]
|
||||
if !ok {
|
||||
return model.MergeResult{}, ErrNotFound
|
||||
}
|
||||
_ = primary
|
||||
|
||||
migratedBindings := 0
|
||||
migratedMappings := 0
|
||||
for _, secMA := range req.SecondaryMACodes {
|
||||
secContent, exists := m.contents[secMA]
|
||||
if !exists {
|
||||
return model.MergeResult{}, fmt.Errorf("%w: secondary MA %s not found", ErrNotFound, secMA)
|
||||
}
|
||||
if secContent.Status == model.StatusMerged {
|
||||
return model.MergeResult{}, fmt.Errorf("chain: MA %s already merged", secMA)
|
||||
}
|
||||
|
||||
// 迁移哈希绑定至主 MA 码
|
||||
for _, b := range m.bindings[secMA] {
|
||||
b.ContentTwinID = primary.ContentTwinID
|
||||
m.bindings[req.PrimaryMACode] = append(m.bindings[req.PrimaryMACode], b)
|
||||
migratedBindings++
|
||||
}
|
||||
|
||||
// 迁移映射至主 MA 码
|
||||
for _, mp := range m.mappings[secMA] {
|
||||
mp.ContentTwinID = primary.ContentTwinID
|
||||
m.mappings[req.PrimaryMACode] = append(m.mappings[req.PrimaryMACode], mp)
|
||||
migratedMappings++
|
||||
}
|
||||
|
||||
// 更新哈希索引指向主 MA 码
|
||||
for _, b := range m.bindings[secMA] {
|
||||
if b.HashType == model.HashFile || b.HashType == model.HashTranscoded {
|
||||
m.hashIndex[b.HashValue] = req.PrimaryMACode
|
||||
}
|
||||
}
|
||||
|
||||
// 清空被合并 MA 码的绑定和映射,状态标记为 merged
|
||||
m.bindings[secMA] = nil
|
||||
m.mappings[secMA] = nil
|
||||
secContent.Status = model.StatusMerged
|
||||
m.contents[secMA] = secContent
|
||||
}
|
||||
|
||||
txID := m.nextTx("mergeMA")
|
||||
return model.MergeResult{
|
||||
PrimaryMACode: req.PrimaryMACode,
|
||||
MergedMACodes: req.SecondaryMACodes,
|
||||
MigratedBindings: migratedBindings,
|
||||
MigratedMappings: migratedMappings,
|
||||
TxID: txID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SplitMA 将一个 MA 码拆分为多个独立 MA 码(仅监管主体)。
|
||||
// 按集号将哈希绑定和映射迁移至新 MA 码,源 MA 码状态标记为 split。
|
||||
func (m *MemoryChain) SplitMA(role Role, req model.SplitRequest) (model.SplitResult, error) {
|
||||
if role != RoleRegulator {
|
||||
return model.SplitResult{}, ErrPermissionDenied
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
srcContent, ok := m.contents[req.SourceMACode]
|
||||
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)
|
||||
}
|
||||
|
||||
migratedBindings := 0
|
||||
migratedMappings := 0
|
||||
newMACodes := make([]string, 0, len(req.Splits))
|
||||
|
||||
for _, split := range req.Splits {
|
||||
// 创建新内容记录
|
||||
newContent := srcContent
|
||||
newContent.MACode = split.NewMACode
|
||||
newContent.ContentTwinID = split.NewMACode + "-ctid"
|
||||
newContent.Title = split.Title
|
||||
newContent.Status = model.StatusApproved
|
||||
newContent.CreatedAt = time.Now()
|
||||
m.contents[split.NewMACode] = newContent
|
||||
|
||||
// 按集号迁移哈希绑定
|
||||
for _, b := range m.bindings[req.SourceMACode] {
|
||||
shouldMigrate := false
|
||||
if len(split.Episodes) == 0 {
|
||||
// 空集号列表:迁移全部(整剧拆分场景)
|
||||
shouldMigrate = true
|
||||
} else {
|
||||
for _, ep := range split.Episodes {
|
||||
if b.Episode == ep {
|
||||
shouldMigrate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if shouldMigrate {
|
||||
newB := b
|
||||
newB.ContentTwinID = newContent.ContentTwinID
|
||||
m.bindings[split.NewMACode] = append(m.bindings[split.NewMACode], newB)
|
||||
// 更新哈希索引
|
||||
if b.HashType == model.HashFile || b.HashType == model.HashTranscoded {
|
||||
m.hashIndex[b.HashValue] = split.NewMACode
|
||||
}
|
||||
migratedBindings++
|
||||
}
|
||||
}
|
||||
|
||||
// 迁移映射(全部映射复制到新 MA 码)
|
||||
for _, mp := range m.mappings[req.SourceMACode] {
|
||||
newMP := mp
|
||||
newMP.ContentTwinID = newContent.ContentTwinID
|
||||
m.mappings[split.NewMACode] = append(m.mappings[split.NewMACode], newMP)
|
||||
migratedMappings++
|
||||
}
|
||||
|
||||
newMACodes = append(newMACodes, split.NewMACode)
|
||||
}
|
||||
|
||||
// 源 MA 码状态标记为 split
|
||||
srcContent.Status = model.StatusSplit
|
||||
m.contents[req.SourceMACode] = srcContent
|
||||
|
||||
txID := m.nextTx("splitMA")
|
||||
return model.SplitResult{
|
||||
SourceMACode: req.SourceMACode,
|
||||
NewMACodes: newMACodes,
|
||||
MigratedBindings: migratedBindings,
|
||||
MigratedMappings: migratedMappings,
|
||||
TxID: txID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var _ Client = (*MemoryChain)(nil)
|
||||
|
||||
@@ -131,6 +131,47 @@ func (p *PersistentChain) SetContentStatus(maCode, status string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MergeMA 合并 MA 码:内存合并成功后写穿 PG 镜像。
|
||||
func (p *PersistentChain) MergeMA(role Role, req model.MergeRequest) (model.MergeResult, error) {
|
||||
res, err := p.MemoryChain.MergeMA(role, req)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
// 被合并的 MA 码状态标记为 merged
|
||||
for _, secMA := range req.SecondaryMACodes {
|
||||
p.updateStatus(secMA, model.StatusMerged)
|
||||
}
|
||||
// 主 MA 码的绑定和映射已在内存中追加,写穿最新状态
|
||||
for _, b := range p.snapshotBindings(req.PrimaryMACode) {
|
||||
p.persistBinding(b)
|
||||
}
|
||||
p.persistTx(req.PrimaryMACode, res.TxID, "mergeMA")
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// SplitMA 拆分 MA 码:内存拆分成功后写穿 PG 镜像。
|
||||
func (p *PersistentChain) SplitMA(role Role, req model.SplitRequest) (model.SplitResult, error) {
|
||||
res, err := p.MemoryChain.SplitMA(role, req)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
// 源 MA 码状态标记为 split
|
||||
p.updateStatus(req.SourceMACode, model.StatusSplit)
|
||||
// 新 MA 码的内容记录和绑定写穿
|
||||
for _, newMA := range res.NewMACodes {
|
||||
c, _ := p.MemoryChain.QueryContent(newMA)
|
||||
p.persistContent(c)
|
||||
for _, b := range p.snapshotBindings(newMA) {
|
||||
p.persistBinding(b)
|
||||
}
|
||||
for _, mp := range p.snapshotMappings(newMA) {
|
||||
p.persistMapping(mp)
|
||||
}
|
||||
}
|
||||
p.persistTx(req.SourceMACode, res.TxID, "splitMA")
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ---- PG 写入小工具(best-effort,失败仅记日志)----
|
||||
|
||||
func (p *PersistentChain) exec(q string, args ...any) {
|
||||
@@ -194,6 +235,16 @@ func (p *PersistentChain) snapshotBindings(maCode string) []model.HashBinding {
|
||||
return out
|
||||
}
|
||||
|
||||
// snapshotMappings 复制某 MA 码当前的内存映射(同包访问,读锁保护)。
|
||||
func (p *PersistentChain) snapshotMappings(maCode string) []model.Mapping {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
src := p.mappings[maCode]
|
||||
out := make([]model.Mapping, len(src))
|
||||
copy(out, src)
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- 启动水合:从 PG 镜像恢复内存状态 ----
|
||||
|
||||
func (p *PersistentChain) hydrate() error {
|
||||
|
||||
@@ -10,6 +10,7 @@ type Config struct {
|
||||
APIAddr string
|
||||
ChainAddr string
|
||||
HashAddr string
|
||||
CatalogAddr string
|
||||
PostgresDSN string
|
||||
RedisAddr string
|
||||
// ChainBackend 选择链实现:memory(纯内存)| pg(内存+PG镜像)| chainmaker(真实链,需 -tags chainmaker 构建)
|
||||
@@ -31,6 +32,7 @@ func Load() Config {
|
||||
APIAddr: getEnv("TCS_API_ADDR", ":8080"),
|
||||
ChainAddr: getEnv("TCS_CHAIN_ADDR", ":8081"),
|
||||
HashAddr: getEnv("TCS_HASH_ADDR", ":8082"),
|
||||
CatalogAddr: getEnv("TCS_CATALOG_ADDR", ":8083"),
|
||||
PostgresDSN: getEnv("TCS_POSTGRES_DSN", "postgres://postgres@localhost:5432/tcs_iptv?sslmode=disable"),
|
||||
RedisAddr: getEnv("TCS_REDIS_ADDR", "localhost:6379"),
|
||||
// 默认 pg:PG 可用则内存+镜像持久化,不可用自动回退内存(见 api-svc 装配)
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Permission 权限标识(对应 API 操作)。
|
||||
type Permission string
|
||||
|
||||
// 预定义权限常量(覆盖全部 API 操作)。
|
||||
const (
|
||||
// 内容管理
|
||||
PermContentRegister Permission = "content:register"
|
||||
PermContentReview Permission = "content:review"
|
||||
PermContentIssue Permission = "content:issue"
|
||||
PermContentVerify Permission = "content:verify"
|
||||
PermContentTranscode Permission = "content:transcode"
|
||||
PermContentIngest Permission = "content:ingest"
|
||||
PermContentPublish Permission = "content:publish"
|
||||
PermContentInject Permission = "content:inject"
|
||||
PermContentVersionChange Permission = "content:version_change"
|
||||
PermContentTakedown Permission = "content:takedown"
|
||||
PermContentRestore Permission = "content:restore"
|
||||
PermContentMerge Permission = "content:merge"
|
||||
PermContentSplit Permission = "content:split"
|
||||
PermContentAddEpisodes Permission = "content:add_episodes"
|
||||
// 查询
|
||||
PermQueryByMA Permission = "query:by_ma"
|
||||
PermQueryByHash Permission = "query:by_hash"
|
||||
PermQueryByProvincial Permission = "query:by_provincial"
|
||||
PermQueryByLibraryID Permission = "query:by_library_id"
|
||||
PermQueryMappings Permission = "query:mappings"
|
||||
PermQueryEpisodes Permission = "query:episodes"
|
||||
PermQueryReviews Permission = "query:reviews"
|
||||
PermQueryContents Permission = "query:contents"
|
||||
PermQueryProvenance Permission = "query:provenance"
|
||||
PermQueryAccountability Permission = "query:accountability"
|
||||
PermQueryEvidence Permission = "query:evidence"
|
||||
// 数据与分账
|
||||
PermPlaybackReport Permission = "data:playback_report"
|
||||
PermPlaybackSummary Permission = "data:playback_summary"
|
||||
PermSettlement Permission = "data:settlement"
|
||||
// 确权与侵权
|
||||
PermInfringeMatch Permission = "rights:infringe_match"
|
||||
PermAuthorize Permission = "rights:authorize"
|
||||
PermAuthCheck Permission = "rights:auth_check"
|
||||
// 跨省与终端
|
||||
PermCrossProvince Permission = "cross:province_admit"
|
||||
PermTerminalVerify Permission = "terminal:verify"
|
||||
// 备案与监管
|
||||
PermBindFiling Permission = "regulatory:bind_filing"
|
||||
PermQueryFiling Permission = "regulatory:query_filing"
|
||||
PermNationalStats Permission = "regulatory:national_stats"
|
||||
PermDailyReport Permission = "regulatory:daily_report"
|
||||
PermListSegments Permission = "admin:list_segments"
|
||||
PermRegisterSegment Permission = "admin:register_segment"
|
||||
// 大小屏融合
|
||||
PermResolve Permission = "screen:resolve"
|
||||
PermScanVerify Permission = "screen:scan_verify"
|
||||
PermRecordPurchase Permission = "rights:record_purchase"
|
||||
PermVerifyRights Permission = "rights:verify_rights"
|
||||
// 系统管理
|
||||
PermManageUsers Permission = "admin:manage_users"
|
||||
PermManageOrgs Permission = "admin:manage_orgs"
|
||||
)
|
||||
|
||||
// RBACManager 基于角色的动态权限管理器。
|
||||
// 支持运行时配置角色-权限映射,替代硬编码角色检查。
|
||||
type RBACManager struct {
|
||||
mu sync.RWMutex
|
||||
roles map[string]map[Permission]bool // role -> permission set
|
||||
}
|
||||
|
||||
// NewRBACManager 创建权限管理器并预置默认角色权限。
|
||||
func NewRBACManager() *RBACManager {
|
||||
r := &RBACManager{roles: make(map[string]map[Permission]bool)}
|
||||
r.loadDefaults()
|
||||
return r
|
||||
}
|
||||
|
||||
// loadDefaults 加载默认四角色权限矩阵。
|
||||
func (r *RBACManager) loadDefaults() {
|
||||
// 监管主体:全部权限
|
||||
r.SetRolePermissions("regulator", allPermissions())
|
||||
|
||||
// 审核主体:审核、入库、转码、查询、验真
|
||||
r.SetRolePermissions("reviewer", []Permission{
|
||||
PermContentReview, PermContentTranscode, PermContentIngest,
|
||||
PermContentVerify, PermContentVersionChange,
|
||||
PermQueryByMA, PermQueryByHash, PermQueryByProvincial, PermQueryByLibraryID,
|
||||
PermQueryMappings, PermQueryEpisodes, PermQueryReviews, PermQueryContents,
|
||||
PermQueryProvenance, PermQueryAccountability, PermQueryEvidence,
|
||||
PermBindFiling, PermQueryFiling,
|
||||
PermResolve, PermScanVerify,
|
||||
})
|
||||
|
||||
// 内容提供商:送审、查询、确权
|
||||
r.SetRolePermissions("cp", []Permission{
|
||||
PermContentRegister,
|
||||
PermQueryByMA, PermQueryByHash, PermQueryByProvincial, PermQueryByLibraryID,
|
||||
PermQueryMappings, PermQueryEpisodes, PermQueryReviews, PermQueryContents,
|
||||
PermQueryProvenance, PermQueryEvidence,
|
||||
PermInfringeMatch, PermAuthorize, PermAuthCheck,
|
||||
PermResolve, PermScanVerify, PermRecordPurchase, PermVerifyRights,
|
||||
})
|
||||
|
||||
// 运营商:注入、发布、查询、播放回传
|
||||
r.SetRolePermissions("operator", []Permission{
|
||||
PermContentPublish, PermContentInject,
|
||||
PermContentVerify, PermTerminalVerify,
|
||||
PermQueryByMA, PermQueryByHash, PermQueryMappings, PermQueryEpisodes,
|
||||
PermQueryContents, PermQueryProvenance,
|
||||
PermPlaybackReport, PermPlaybackSummary, PermSettlement,
|
||||
PermResolve, PermScanVerify, PermVerifyRights,
|
||||
})
|
||||
}
|
||||
|
||||
// SetRolePermissions 设置某角色的权限集合(覆盖原有)。
|
||||
func (r *RBACManager) SetRolePermissions(role string, perms []Permission) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
set := make(map[Permission]bool, len(perms))
|
||||
for _, p := range perms {
|
||||
set[p] = true
|
||||
}
|
||||
r.roles[role] = set
|
||||
}
|
||||
|
||||
// GrantPermission 为角色追加单个权限。
|
||||
func (r *RBACManager) GrantPermission(role string, perm Permission) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.roles[role] == nil {
|
||||
r.roles[role] = make(map[Permission]bool)
|
||||
}
|
||||
r.roles[role][perm] = true
|
||||
}
|
||||
|
||||
// RevokePermission 移除角色的某个权限。
|
||||
func (r *RBACManager) RevokePermission(role string, perm Permission) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.roles[role] != nil {
|
||||
delete(r.roles[role], perm)
|
||||
}
|
||||
}
|
||||
|
||||
// HasPermission 检查角色是否拥有指定权限。
|
||||
func (r *RBACManager) HasPermission(role string, perm Permission) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
perms, ok := r.roles[role]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return perms[perm]
|
||||
}
|
||||
|
||||
// ListPermissions 列出角色的全部权限。
|
||||
func (r *RBACManager) ListPermissions(role string) []Permission {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
perms, ok := r.roles[role]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]Permission, 0, len(perms))
|
||||
for p := range perms {
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ListRoles 列出全部已配置角色。
|
||||
func (r *RBACManager) ListRoles() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make([]string, 0, len(r.roles))
|
||||
for role := range r.roles {
|
||||
out = append(out, role)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RequirePermission 返回 Gin 中间件,校验当前角色是否拥有指定权限。
|
||||
// 用法:rg.POST("/content/issue", rbac.RequirePermission(httpx.PermContentIssue), h.issue)
|
||||
func (r *RBACManager) RequirePermission(perm Permission) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role := RoleFromContext(c)
|
||||
if role == "" {
|
||||
Error(c, 401, "UNAUTHORIZED", "未认证")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !r.HasPermission(role, perm) {
|
||||
Error(c, 403, "FORBIDDEN", "角色 "+role+" 无权限: "+string(perm))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// allPermissions 返回全部权限列表(用于监管角色)。
|
||||
func allPermissions() []Permission {
|
||||
return []Permission{
|
||||
PermContentRegister, PermContentReview, PermContentIssue, PermContentVerify,
|
||||
PermContentTranscode, PermContentIngest, PermContentPublish, PermContentInject,
|
||||
PermContentVersionChange, PermContentTakedown, PermContentRestore,
|
||||
PermContentMerge, PermContentSplit, PermContentAddEpisodes,
|
||||
PermQueryByMA, PermQueryByHash, PermQueryByProvincial, PermQueryByLibraryID,
|
||||
PermQueryMappings, PermQueryEpisodes, PermQueryReviews, PermQueryContents,
|
||||
PermQueryProvenance, PermQueryAccountability, PermQueryEvidence,
|
||||
PermPlaybackReport, PermPlaybackSummary, PermSettlement,
|
||||
PermInfringeMatch, PermAuthorize, PermAuthCheck,
|
||||
PermCrossProvince, PermTerminalVerify,
|
||||
PermBindFiling, PermQueryFiling, PermNationalStats, PermDailyReport,
|
||||
PermListSegments, PermRegisterSegment,
|
||||
PermResolve, PermScanVerify, PermRecordPurchase, PermVerifyRights,
|
||||
PermManageUsers, PermManageOrgs,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRBACManager_DefaultPermissions(t *testing.T) {
|
||||
r := NewRBACManager()
|
||||
|
||||
// 监管拥有全部权限
|
||||
assert.True(t, r.HasPermission("regulator", PermContentIssue))
|
||||
assert.True(t, r.HasPermission("regulator", PermContentTakedown))
|
||||
assert.True(t, r.HasPermission("regulator", PermManageUsers))
|
||||
|
||||
// 审核主体不能发码
|
||||
assert.False(t, r.HasPermission("reviewer", PermContentIssue))
|
||||
assert.True(t, r.HasPermission("reviewer", PermContentReview))
|
||||
|
||||
// CP 不能下架
|
||||
assert.False(t, r.HasPermission("cp", PermContentTakedown))
|
||||
assert.True(t, r.HasPermission("cp", PermContentRegister))
|
||||
|
||||
// 运营商不能发码
|
||||
assert.False(t, r.HasPermission("operator", PermContentIssue))
|
||||
assert.True(t, r.HasPermission("operator", PermContentInject))
|
||||
}
|
||||
|
||||
func TestRBACManager_DynamicConfig(t *testing.T) {
|
||||
r := NewRBACManager()
|
||||
|
||||
// 新增自定义角色
|
||||
r.SetRolePermissions("auditor", []Permission{
|
||||
PermQueryProvenance, PermQueryAccountability, PermQueryEvidence,
|
||||
})
|
||||
assert.True(t, r.HasPermission("auditor", PermQueryProvenance))
|
||||
assert.False(t, r.HasPermission("auditor", PermContentIssue))
|
||||
|
||||
// 动态授权
|
||||
r.GrantPermission("auditor", PermContentVerify)
|
||||
assert.True(t, r.HasPermission("auditor", PermContentVerify))
|
||||
|
||||
// 撤销权限
|
||||
r.RevokePermission("auditor", PermContentVerify)
|
||||
assert.False(t, r.HasPermission("auditor", PermContentVerify))
|
||||
|
||||
// 列出角色
|
||||
roles := r.ListRoles()
|
||||
assert.Contains(t, roles, "regulator")
|
||||
assert.Contains(t, roles, "auditor")
|
||||
|
||||
// 列出权限
|
||||
perms := r.ListPermissions("auditor")
|
||||
assert.Len(t, perms, 3)
|
||||
}
|
||||
|
||||
func TestRBACManager_UnknownRole(t *testing.T) {
|
||||
r := NewRBACManager()
|
||||
assert.False(t, r.HasPermission("nonexistent", PermContentIssue))
|
||||
assert.Empty(t, r.ListPermissions("nonexistent"))
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// 用户与组织管理模型(系统管理模块补足)。
|
||||
|
||||
// User 系统用户。
|
||||
type User struct {
|
||||
ID string `json:"id"` // 用户唯一标识
|
||||
Username string `json:"username"` // 登录用户名
|
||||
FullName string `json:"full_name"` // 真实姓名
|
||||
OrgID string `json:"org_id"` // 所属组织 ID
|
||||
Role string `json:"role"` // 角色:regulator/reviewer/cp/operator
|
||||
APIKey string `json:"api_key"` // API 密钥
|
||||
APISecret string `json:"-"` // API 密钥(不序列化、不下发)
|
||||
Status string `json:"status"` // active/disabled
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// UserStatus 用户状态常量。
|
||||
const (
|
||||
UserStatusActive = "active"
|
||||
UserStatusDisabled = "disabled"
|
||||
)
|
||||
|
||||
// Organization 组织/机构。
|
||||
type Organization struct {
|
||||
ID string `json:"id"` // 组织唯一标识
|
||||
Name string `json:"name"` // 组织名称
|
||||
OrgNode string `json:"org_node"` // MA 码机构节点(如 6101)
|
||||
Province string `json:"province"` // 所属省份
|
||||
Type string `json:"type"` // 组织类型:regulator/reviewer/cp/operator
|
||||
Status string `json:"status"` // active/disabled
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// OrgType 组织类型常量。
|
||||
const (
|
||||
OrgTypeRegulator = "regulator"
|
||||
OrgTypeReviewer = "reviewer"
|
||||
OrgTypeCP = "cp"
|
||||
OrgTypeOperator = "operator"
|
||||
)
|
||||
|
||||
// OrgStatus 组织状态常量。
|
||||
const (
|
||||
OrgStatusActive = "active"
|
||||
OrgStatusDisabled = "disabled"
|
||||
)
|
||||
|
||||
// Role 角色权限定义。
|
||||
type RolePermission struct {
|
||||
Role string `json:"role"` // regulator/reviewer/cp/operator
|
||||
Permissions []string `json:"permissions"` // 权限列表
|
||||
}
|
||||
@@ -95,4 +95,56 @@ const (
|
||||
StatusInLibrary = "in_library" // 已入媒资库
|
||||
StatusPublished = "published" // 已发布
|
||||
StatusRevoked = "revoked" // 已下架
|
||||
StatusMerged = "merged" // 已合并入其他MA码
|
||||
StatusSplit = "split" // 已拆分为多个MA码
|
||||
)
|
||||
|
||||
// ContentQueryResult 多维度标识查询的统一返回结果。
|
||||
// 支持按 Hash、省级内容编码、片库文件 ID 等多种维度反查内容标识信息。
|
||||
type ContentQueryResult struct {
|
||||
Found bool `json:"found"` // 是否找到匹配记录
|
||||
Content Content `json:"content"` // 内容主记录
|
||||
Bindings []HashBinding `json:"bindings"` // 哈希绑定列表
|
||||
Mappings []Mapping `json:"mappings"` // 三方编码映射列表
|
||||
}
|
||||
|
||||
// MergeRequest MA 合并请求(将多个 MA 码合并为一个主 MA 码)。
|
||||
type MergeRequest struct {
|
||||
PrimaryMACode string `json:"primary_ma_code"` // 合并后保留的主 MA 码
|
||||
SecondaryMACodes []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 码列表
|
||||
MigratedBindings int `json:"migrated_bindings"` // 迁移的哈希绑定数
|
||||
MigratedMappings int `json:"migrated_mappings"` // 迁移的映射数
|
||||
TxID string `json:"tx_id"` // 链上交易 ID
|
||||
}
|
||||
|
||||
// SplitRequest MA 拆分请求(将一个 MA 码拆分为多个独立 MA 码)。
|
||||
type SplitRequest struct {
|
||||
SourceMACode string `json:"source_ma_code"` // 被拆分的源 MA 码
|
||||
Splits []SplitTarget `json:"splits"` // 拆分目标列表
|
||||
Reason string `json:"reason"` // 拆分原因
|
||||
Operator string `json:"operator"` // 操作人
|
||||
}
|
||||
|
||||
// SplitTarget 拆分目标:每集或每组内容拆分后的新 MA 码信息。
|
||||
type SplitTarget struct {
|
||||
NewMACode 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 码列表
|
||||
MigratedBindings int `json:"migrated_bindings"` // 迁移的哈希绑定数
|
||||
MigratedMappings int `json:"migrated_mappings"` // 迁移的映射数
|
||||
TxID string `json:"tx_id"` // 链上交易 ID
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Package monitor 运行监控(方案系统管理模块补足)。
|
||||
//
|
||||
// 提供 Prometheus 指标采集中间件,覆盖 4 金指标:
|
||||
// - Latency:请求延迟直方图
|
||||
// - Traffic:请求总量计数器
|
||||
// - Errors:错误响应计数器
|
||||
// - Saturation:并发在途请求 gauge
|
||||
//
|
||||
// 使用方式:
|
||||
// r := gin.Default()
|
||||
// r.Use(monitor.Middleware())
|
||||
// r.GET("/metrics", monitor.Handler())
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
var (
|
||||
// httpDuration 请求延迟直方图(Latency)。
|
||||
httpDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "tcs_http_request_duration_seconds",
|
||||
Help: "HTTP 请求延迟(秒)",
|
||||
Buckets: []float64{0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10},
|
||||
}, []string{"method", "path", "status"})
|
||||
|
||||
// httpRequests 请求总量计数器(Traffic)。
|
||||
httpRequests = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "tcs_http_requests_total",
|
||||
Help: "HTTP 请求总量",
|
||||
}, []string{"method", "path", "status"})
|
||||
|
||||
// httpErrors 错误响应计数器(Errors)。
|
||||
httpErrors = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "tcs_http_errors_total",
|
||||
Help: "HTTP 错误响应总量(状态码 >= 400)",
|
||||
}, []string{"method", "path", "status"})
|
||||
|
||||
// httpInFlight 并发在途请求(Saturation)。
|
||||
httpInFlight = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "tcs_http_in_flight_requests",
|
||||
Help: "当前在途 HTTP 请求数",
|
||||
})
|
||||
)
|
||||
|
||||
// Middleware 返回 Gin 中间件,采集 Prometheus 指标。
|
||||
func Middleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
httpInFlight.Inc()
|
||||
|
||||
c.Next()
|
||||
|
||||
httpInFlight.Dec()
|
||||
status := strconv.Itoa(c.Writer.Status())
|
||||
elapsed := time.Since(start).Seconds()
|
||||
path := c.FullPath()
|
||||
if path == "" {
|
||||
path = "unknown"
|
||||
}
|
||||
|
||||
httpDuration.WithLabelValues(c.Request.Method, path, status).Observe(elapsed)
|
||||
httpRequests.WithLabelValues(c.Request.Method, path, status).Inc()
|
||||
if c.Writer.Status() >= 400 {
|
||||
httpErrors.WithLabelValues(c.Request.Method, path, status).Inc()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handler 返回 Prometheus metrics 暴露端点。
|
||||
func Handler() gin.HandlerFunc {
|
||||
return gin.WrapH(promhttp.Handler())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tcs-iptv/tcs/internal/model"
|
||||
)
|
||||
|
||||
// AdminService 用户管理/组织管理服务(系统管理模块补足)。
|
||||
// MVP 使用内存存储,生产环境可替换为 PostgreSQL 实现。
|
||||
type AdminService struct {
|
||||
mu sync.RWMutex
|
||||
users map[string]*model.User // userID -> User
|
||||
orgs map[string]*model.Organization // orgID -> Organization
|
||||
keys map[string]*model.User // apiKey -> User(快速鉴权查找)
|
||||
names map[string]bool // username 唯一性校验
|
||||
}
|
||||
|
||||
// NewAdminService 创建用户/组织管理服务。
|
||||
func NewAdminService() *AdminService {
|
||||
return &AdminService{
|
||||
users: make(map[string]*model.User),
|
||||
orgs: make(map[string]*model.Organization),
|
||||
keys: make(map[string]*model.User),
|
||||
names: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 组织管理 ----
|
||||
|
||||
// CreateOrg 创建组织。
|
||||
func (a *AdminService) CreateOrg(org model.Organization) (model.Organization, error) {
|
||||
if org.Name == "" {
|
||||
return model.Organization{}, fmt.Errorf("admin: 组织名称不能为空")
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
org.ID = a.nextID("org")
|
||||
org.Status = model.OrgStatusActive
|
||||
if org.CreatedAt.IsZero() {
|
||||
org.CreatedAt = time.Now()
|
||||
}
|
||||
org.UpdatedAt = time.Now()
|
||||
a.orgs[org.ID] = &org
|
||||
return org, nil
|
||||
}
|
||||
|
||||
// UpdateOrg 更新组织信息。
|
||||
func (a *AdminService) UpdateOrg(orgID string, updates model.Organization) (model.Organization, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
org, ok := a.orgs[orgID]
|
||||
if !ok {
|
||||
return model.Organization{}, fmt.Errorf("admin: 组织 %s 不存在", orgID)
|
||||
}
|
||||
if updates.Name != "" {
|
||||
org.Name = updates.Name
|
||||
}
|
||||
if updates.OrgNode != "" {
|
||||
org.OrgNode = updates.OrgNode
|
||||
}
|
||||
if updates.Province != "" {
|
||||
org.Province = updates.Province
|
||||
}
|
||||
if updates.Type != "" {
|
||||
org.Type = updates.Type
|
||||
}
|
||||
if updates.Status != "" {
|
||||
org.Status = updates.Status
|
||||
}
|
||||
org.UpdatedAt = time.Now()
|
||||
return *org, nil
|
||||
}
|
||||
|
||||
// GetOrg 查询组织详情。
|
||||
func (a *AdminService) GetOrg(orgID string) (model.Organization, error) {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
org, ok := a.orgs[orgID]
|
||||
if !ok {
|
||||
return model.Organization{}, fmt.Errorf("admin: 组织 %s 不存在", orgID)
|
||||
}
|
||||
return *org, nil
|
||||
}
|
||||
|
||||
// ListOrgs 列出全部组织(可按类型过滤)。
|
||||
func (a *AdminService) ListOrgs(orgType string) []model.Organization {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
var out []model.Organization
|
||||
for _, org := range a.orgs {
|
||||
if orgType == "" || org.Type == orgType {
|
||||
out = append(out, *org)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DisableOrg 禁用组织。
|
||||
func (a *AdminService) DisableOrg(orgID string) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
org, ok := a.orgs[orgID]
|
||||
if !ok {
|
||||
return fmt.Errorf("admin: 组织 %s 不存在", orgID)
|
||||
}
|
||||
org.Status = model.OrgStatusDisabled
|
||||
org.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- 用户管理 ----
|
||||
|
||||
// CreateUser 创建用户并自动生成 API Key/Secret。
|
||||
func (a *AdminService) CreateUser(user model.User) (model.User, error) {
|
||||
if user.Username == "" {
|
||||
return model.User{}, fmt.Errorf("admin: 用户名不能为空")
|
||||
}
|
||||
if user.OrgID == "" {
|
||||
return model.User{}, fmt.Errorf("admin: 所属组织不能为空")
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.names[user.Username] {
|
||||
return model.User{}, fmt.Errorf("admin: 用户名 %s 已存在", user.Username)
|
||||
}
|
||||
if _, ok := a.orgs[user.OrgID]; !ok {
|
||||
return model.User{}, fmt.Errorf("admin: 组织 %s 不存在", user.OrgID)
|
||||
}
|
||||
user.ID = a.nextID("user")
|
||||
user.APIKey = a.generateAPIKey()
|
||||
user.APISecret = a.generateAPISecret()
|
||||
user.Status = model.UserStatusActive
|
||||
if user.CreatedAt.IsZero() {
|
||||
user.CreatedAt = time.Now()
|
||||
}
|
||||
user.UpdatedAt = time.Now()
|
||||
a.users[user.ID] = &user
|
||||
a.keys[user.APIKey] = &user
|
||||
a.names[user.Username] = true
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// UpdateUser 更新用户信息(不支持修改用户名和 API Key)。
|
||||
func (a *AdminService) UpdateUser(userID string, updates model.User) (model.User, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
user, ok := a.users[userID]
|
||||
if !ok {
|
||||
return model.User{}, fmt.Errorf("admin: 用户 %s 不存在", userID)
|
||||
}
|
||||
if updates.FullName != "" {
|
||||
user.FullName = updates.FullName
|
||||
}
|
||||
if updates.OrgID != "" {
|
||||
if _, ok := a.orgs[updates.OrgID]; !ok {
|
||||
return model.User{}, fmt.Errorf("admin: 组织 %s 不存在", updates.OrgID)
|
||||
}
|
||||
user.OrgID = updates.OrgID
|
||||
}
|
||||
if updates.Role != "" {
|
||||
user.Role = updates.Role
|
||||
}
|
||||
if updates.Status != "" {
|
||||
user.Status = updates.Status
|
||||
}
|
||||
user.UpdatedAt = time.Now()
|
||||
return *user, nil
|
||||
}
|
||||
|
||||
// GetUser 查询用户详情。
|
||||
func (a *AdminService) GetUser(userID string) (model.User, error) {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
user, ok := a.users[userID]
|
||||
if !ok {
|
||||
return model.User{}, fmt.Errorf("admin: 用户 %s 不存在", userID)
|
||||
}
|
||||
return *user, nil
|
||||
}
|
||||
|
||||
// ListUsers 列出全部用户(可按组织或角色过滤)。
|
||||
func (a *AdminService) ListUsers(orgID, role string) []model.User {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
var out []model.User
|
||||
for _, user := range a.users {
|
||||
if orgID != "" && user.OrgID != orgID {
|
||||
continue
|
||||
}
|
||||
if role != "" && user.Role != role {
|
||||
continue
|
||||
}
|
||||
out = append(out, *user)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DisableUser 禁用用户。
|
||||
func (a *AdminService) DisableUser(userID string) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
user, ok := a.users[userID]
|
||||
if !ok {
|
||||
return fmt.Errorf("admin: 用户 %s 不存在", userID)
|
||||
}
|
||||
user.Status = model.UserStatusDisabled
|
||||
user.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetAPIKey 重置用户的 API Key 和 Secret。
|
||||
func (a *AdminService) ResetAPIKey(userID string) (model.User, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
user, ok := a.users[userID]
|
||||
if !ok {
|
||||
return model.User{}, fmt.Errorf("admin: 用户 %s 不存在", userID)
|
||||
}
|
||||
// 移除旧 key
|
||||
delete(a.keys, user.APIKey)
|
||||
// 生成新 key
|
||||
user.APIKey = a.generateAPIKey()
|
||||
user.APISecret = a.generateAPISecret()
|
||||
user.UpdatedAt = time.Now()
|
||||
a.keys[user.APIKey] = user
|
||||
return *user, nil
|
||||
}
|
||||
|
||||
// LookupByAPIKey 根据 API Key 查询用户(供 httpx.KeyStore 使用)。
|
||||
func (a *AdminService) LookupByAPIKey(apiKey string) (secret string, role string, ok bool) {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
user, exists := a.keys[apiKey]
|
||||
if !exists || user.Status != model.UserStatusActive {
|
||||
return "", "", false
|
||||
}
|
||||
return user.APISecret, user.Role, true
|
||||
}
|
||||
|
||||
// ---- 辅助方法 ----
|
||||
|
||||
func (a *AdminService) nextID(prefix string) string {
|
||||
// 使用时间戳+随机数生成唯一 ID
|
||||
ts := time.Now().Format("20060102")
|
||||
b := make([]byte, 4)
|
||||
rand.Read(b)
|
||||
return fmt.Sprintf("%s-%s-%s", prefix, ts, hex.EncodeToString(b))
|
||||
}
|
||||
|
||||
func (a *AdminService) generateAPIKey() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return "tcs-" + hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func (a *AdminService) generateAPISecret() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tcs-iptv/tcs/internal/model"
|
||||
)
|
||||
|
||||
func TestAdminService_OrgCRUD(t *testing.T) {
|
||||
a := NewAdminService()
|
||||
|
||||
// 创建组织
|
||||
org, err := a.CreateOrg(model.Organization{
|
||||
Name: "陕西IPTV运营公司", OrgNode: "6101", Province: "陕西", Type: model.OrgTypeRegulator,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, org.ID)
|
||||
assert.Equal(t, model.OrgStatusActive, org.Status)
|
||||
|
||||
// 查询组织
|
||||
got, err := a.GetOrg(org.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "陕西IPTV运营公司", got.Name)
|
||||
|
||||
// 更新组织
|
||||
updated, err := a.UpdateOrg(org.ID, model.Organization{Name: "陕西IPTV"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "陕西IPTV", updated.Name)
|
||||
|
||||
// 列出组织
|
||||
orgs := a.ListOrgs(model.OrgTypeRegulator)
|
||||
assert.Len(t, orgs, 1)
|
||||
|
||||
// 禁用组织
|
||||
require.NoError(t, a.DisableOrg(org.ID))
|
||||
got, _ = a.GetOrg(org.ID)
|
||||
assert.Equal(t, model.OrgStatusDisabled, got.Status)
|
||||
}
|
||||
|
||||
func TestAdminService_UserCRUD(t *testing.T) {
|
||||
a := NewAdminService()
|
||||
|
||||
// 先创建组织
|
||||
org, err := a.CreateOrg(model.Organization{
|
||||
Name: "测试CP公司", Type: model.OrgTypeCP,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 创建用户
|
||||
user, err := a.CreateUser(model.User{
|
||||
Username: "testuser", FullName: "测试用户", OrgID: org.ID, Role: "cp",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, user.ID)
|
||||
assert.NotEmpty(t, user.APIKey)
|
||||
assert.NotEmpty(t, user.APISecret)
|
||||
assert.Equal(t, model.UserStatusActive, user.Status)
|
||||
|
||||
// 用户名唯一性校验
|
||||
_, err = a.CreateUser(model.User{
|
||||
Username: "testuser", OrgID: org.ID, Role: "cp",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
|
||||
// 不存在的组织
|
||||
_, err = a.CreateUser(model.User{
|
||||
Username: "user2", OrgID: "nonexistent", Role: "cp",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
|
||||
// 查询用户
|
||||
got, err := a.GetUser(user.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "testuser", got.Username)
|
||||
|
||||
// 更新用户
|
||||
updated, err := a.UpdateUser(user.ID, model.User{FullName: "更新用户名"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "更新用户名", updated.FullName)
|
||||
|
||||
// 列出用户
|
||||
users := a.ListUsers(org.ID, "")
|
||||
assert.Len(t, users, 1)
|
||||
|
||||
// API Key 查找
|
||||
secret, role, ok := a.LookupByAPIKey(user.APIKey)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, user.APISecret, secret)
|
||||
assert.Equal(t, "cp", role)
|
||||
|
||||
// 重置 API Key
|
||||
newUser, err := a.ResetAPIKey(user.ID)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, user.APIKey, newUser.APIKey)
|
||||
|
||||
// 旧 Key 失效
|
||||
_, _, ok = a.LookupByAPIKey(user.APIKey)
|
||||
assert.False(t, ok)
|
||||
|
||||
// 新 Key 生效
|
||||
_, _, ok = a.LookupByAPIKey(newUser.APIKey)
|
||||
assert.True(t, ok)
|
||||
|
||||
// 禁用用户
|
||||
require.NoError(t, a.DisableUser(user.ID))
|
||||
_, _, ok = a.LookupByAPIKey(newUser.APIKey)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/tcs-iptv/tcs/internal/chain"
|
||||
"github.com/tcs-iptv/tcs/internal/model"
|
||||
)
|
||||
|
||||
// ---- 多维度标识查询(方案第一阶段标识查询接口补足)----
|
||||
|
||||
// QueryByHash 根据内容哈希反查标识信息及映射关系。
|
||||
func (s *Service) QueryByHash(fileHash string) (model.ContentQueryResult, error) {
|
||||
return s.chain.QueryByHash(fileHash)
|
||||
}
|
||||
|
||||
// QueryByProvincialCode 根据省级内容编码(CP MediaID)反查标识信息。
|
||||
func (s *Service) QueryByProvincialCode(provincialCode string) (model.ContentQueryResult, error) {
|
||||
return s.chain.QueryByProvincialCode(provincialCode)
|
||||
}
|
||||
|
||||
// QueryByLibraryFileID 根据片库文件 ID(媒资库 ID)反查标识信息。
|
||||
func (s *Service) QueryByLibraryFileID(libraryFileID string) (model.ContentQueryResult, error) {
|
||||
return s.chain.QueryByLibraryFileID(libraryFileID)
|
||||
}
|
||||
|
||||
// ---- MA 合并/拆分(方案 MA 管理模块补足)----
|
||||
|
||||
// MergeMACodes 将多个 MA 码合并为一个主 MA 码(仅监管主体)。
|
||||
// 被合并的 MA 码的哈希绑定和映射迁移至主 MA 码,原 MA 码状态标记为 merged。
|
||||
// 全链路存证记录合并操作。
|
||||
func (s *Service) MergeMACodes(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,
|
||||
Node: model.NodeIssue,
|
||||
Operator: req.Operator,
|
||||
Detail: "MA 码合并:将 " + joinMACodes(req.SecondaryMACodes) + " 合并入 " + req.PrimaryMACode + ",原因:" + req.Reason,
|
||||
})
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// SplitMACode 将一个 MA 码拆分为多个独立 MA 码(仅监管主体)。
|
||||
// 按集号将哈希绑定和映射迁移至新 MA 码,源 MA 码状态标记为 split。
|
||||
// 全链路存证记录拆分操作。
|
||||
func (s *Service) SplitMACode(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,
|
||||
Node: model.NodeIssue,
|
||||
Operator: req.Operator,
|
||||
Detail: "MA 码拆分:将 " + req.SourceMACode + " 拆分为 " + joinMACodes(res.NewMACodes) + ",原因:" + req.Reason,
|
||||
})
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// joinMACodes 将 MA 码列表拼接为逗号分隔的字符串。
|
||||
func joinMACodes(codes []string) string {
|
||||
if len(codes) == 0 {
|
||||
return ""
|
||||
}
|
||||
out := codes[0]
|
||||
for i := 1; i < len(codes); i++ {
|
||||
out += ", " + codes[i]
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user