745cdd79cf
Implements the Revision 8/9 compliance design: tenant + ops backends, ops-web content-safety pages, admin-web editor/publish gate integration, desktop publish block surfacing, and supporting migrations / shared types / config plumbing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
569 lines
28 KiB
Markdown
569 lines
28 KiB
Markdown
# V1 技术方案:内容合规检测模块(敏感词 / 广告法)
|
||
|
||
> **⚠️ 本文档已归档(Superseded)**
|
||
>
|
||
> 当前权威设计已迁移到 superpowers 规范:
|
||
> [`docs/superpowers/specs/2026-04-28-compliance-detection-design.md`](superpowers/specs/2026-04-28-compliance-detection-design.md)
|
||
>
|
||
> 新文档相对本文的差异:
|
||
>
|
||
> - 显式列出 Non-Goals 与 Open Questions
|
||
> - 每个关键技术决策附 **Chosen / Why / Not chosen**
|
||
> - 工时分期已移出 spec(plan 文档承担)
|
||
> - 新增引擎熔断 fail-open / fail-closed 语义、ack 防伪、正则单条超时等遗漏点
|
||
>
|
||
> 本文保留供回溯,新工作请以 superpowers spec 为准。
|
||
|
||
---
|
||
|
||
> 版本:v1(2026-04-28)
|
||
>
|
||
> 适用范围:省心推(仓库代号 geo-rankly)整套发布器内文章发布前的合规检测。
|
||
> 与现有架构关键耦合点:`server/internal/tenant/app/publish_job_service.go`、`server/internal/shared/llm/`、`server/internal/shared/config/`、`apps/admin-web` 编辑器与发布弹窗、`apps/ops-web` ops 控制台。
|
||
|
||
---
|
||
|
||
## 1. 文档目标
|
||
|
||
落地一套覆盖"敏感词 + 广告法 + 行业违禁词"的内容合规检测系统,满足以下产品诉求:
|
||
|
||
1. 系统管理员(ops)可在控制台切换"强制检测"与"用户自愿检测"两种模式;
|
||
2. 强制模式下,发布器内命中违规的文章直接禁止发布;
|
||
3. 自愿模式下,发布器仅展示风险提示,不阻断发布;
|
||
4. 文章编辑器内提供"一键合规检测"按钮,任意时刻可手动触发;
|
||
5. 模块整体可由配置层硬关闭(kill-switch),凌驾于 ops 策略之上,便于运维快速止血;
|
||
6. 与现有租户隔离、AI 点数计费、ops/tenant 双 schema 体系无缝衔接。
|
||
|
||
---
|
||
|
||
## 2. 产品边界
|
||
|
||
### 2.1 V1 包含
|
||
|
||
- ops 平台维护的全局词库和全局策略
|
||
- 租户自维护的白名单(豁免)、黑名单(自家敏感词)
|
||
- 词典精确匹配 + 正则匹配 + 可选 LLM 语义兜底(开关控制)
|
||
- 编辑器手动一键检测、发布器入口拦截、纯文本 API 三种调用入口
|
||
- 检测记录留存(可审计、可申诉)
|
||
- ops 控制台词库管理、策略管理、命中统计
|
||
|
||
### 2.2 V1 不包含
|
||
|
||
- 图片 OCR 检测、外链落地页检测(占位接口预留,二期再上)
|
||
- 实时流式检测(生成边写边查)
|
||
- 自动改写违规词(仅给出修改建议)
|
||
- 跨租户 ML 模型训练与个性化词库
|
||
- 词库 Redis pub/sub 秒级同步(一期用 30s 轮询版本号)
|
||
|
||
---
|
||
|
||
## 3. 设计目标与边界
|
||
|
||
| 目标 | 说明 |
|
||
|---|---|
|
||
| **双模式可切换** | ops 全局策略:`mandatory`(违规阻断发布) / `advisory`(仅提示) / `disabled`(关闭)。租户级可继承或覆盖(默认继承) |
|
||
| **三处接入点** | ① 编辑器一键检测按钮(手动) ② 保存草稿后异步预扫(可选,P3) ③ 发布器入口拦截(强制模式) |
|
||
| **检测分层** | 词库精确匹配 → 正则 / AC 自动机 → LLM 语义兜底(可关)。前两层毫秒级,第三层走 [`server/internal/shared/llm/client.go`](../server/internal/shared/llm/client.go) |
|
||
| **分级处置** | `block`(禁发) / `high`(强警告) / `medium`(建议改) / `info`(提示),由词库条目自带等级,不在引擎硬编码 |
|
||
| **租户隔离** | 词库分平台级(ops 维护)和租户级白 / 黑名单;检测记录打 `tenant_id`,与 articles 同一隔离模型(参考 [`server/migrations/20260331100011_create_articles.up.sql`](../server/migrations/20260331100011_create_articles.up.sql)) |
|
||
| **词库热更新** | 启动全量加载 → 内存 AC 自动机 → 版本号 + 30s 轮询 reload;支持灰度(按租户 / 百分比) |
|
||
| **配置硬开关** | `cfg.Compliance.Enabled = false` 时全站短路,门面层早返 `GatePass`,检测彻底不跑 |
|
||
|
||
---
|
||
|
||
## 4. 总体架构
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
subgraph admin-web[管理后台 admin-web]
|
||
E1[文章编辑器<br/>合规检测按钮]
|
||
E2[发布弹窗<br/>PublishArticleModal]
|
||
end
|
||
|
||
subgraph ops-web[运营控制台 ops-web]
|
||
O1[策略管理]
|
||
O2[词库管理]
|
||
O3[检测记录与统计]
|
||
end
|
||
|
||
subgraph tenant-api[tenant API server]
|
||
T1[ComplianceHandler]
|
||
T2[ComplianceService<br/>门面]
|
||
T3[Engine<br/>词典+正则+LLM]
|
||
T4[PolicyResolver]
|
||
T5[SnapshotLoader<br/>词库快照]
|
||
T6[PublishJobService<br/>发布闸]
|
||
end
|
||
|
||
subgraph ops-api[ops API server]
|
||
P1[DictionaryHandler]
|
||
P2[PolicyHandler]
|
||
P3[StatsHandler]
|
||
end
|
||
|
||
DB[(PostgreSQL<br/>tenant + ops schema)]
|
||
LLM[Shared LLM Client]
|
||
|
||
E1 -->|POST /api/articles/:id/compliance:check| T1
|
||
E2 -->|发布前同步检测| T1
|
||
E2 -.提交发布.-> T6
|
||
T6 -->|GateForPublish| T2
|
||
T1 --> T2
|
||
T2 --> T4
|
||
T2 --> T3
|
||
T3 --> T5
|
||
T3 -. 可选 .-> LLM
|
||
T5 -- 30s 轮询版本号 --> DB
|
||
T2 -- 写检测记录 --> DB
|
||
T4 --> DB
|
||
|
||
O1 --> P2
|
||
O2 --> P1
|
||
O3 --> P3
|
||
P1 --> DB
|
||
P2 --> DB
|
||
P3 --> DB
|
||
```
|
||
|
||
---
|
||
|
||
## 5. 模块分层
|
||
|
||
新增后端 domain:`server/internal/tenant/compliance/`(租户面)+ `server/internal/ops/compliance/`(平台面),沿用现有 `transport / app / repository / domain` 四层(参考 [`server/internal/tenant/app/article_service.go`](../server/internal/tenant/app/article_service.go))。
|
||
|
||
```
|
||
server/internal/
|
||
├── ops/compliance/ # ops 平台面:词库、策略管理
|
||
│ ├── transport/dictionary_handler.go
|
||
│ ├── transport/policy_handler.go
|
||
│ ├── app/dictionary_service.go
|
||
│ ├── app/policy_service.go
|
||
│ └── repository/...
|
||
│
|
||
├── tenant/compliance/ # 租户面:检测引擎、租户词库、记录
|
||
│ ├── transport/compliance_handler.go # 编辑器/发布器调用
|
||
│ ├── app/
|
||
│ │ ├── compliance_service.go # 对外门面,承担 kill-switch 短路
|
||
│ │ ├── engine/ # 检测引擎
|
||
│ │ │ ├── engine.go # 编排:词典 → 正则 → LLM
|
||
│ │ │ ├── dict_matcher.go # AC 自动机
|
||
│ │ │ ├── regex_matcher.go
|
||
│ │ │ ├── llm_judge.go # 语义兜底
|
||
│ │ │ └── text_extractor.go # 富文本 → 纯文本 + 偏移映射
|
||
│ │ ├── policy_resolver.go # 解析 "租户该用哪条策略"
|
||
│ │ └── snapshot_loader.go # 词库快照 + 热更新
|
||
│ ├── repository/
|
||
│ └── domain/violation.go # 风险等级、命中结构
|
||
│
|
||
└── shared/compliance/ # 跨边界类型(DTO、常量)
|
||
└── types.go
|
||
```
|
||
|
||
发布器拦截:在 [`server/internal/tenant/app/publish_job_service.go`](../server/internal/tenant/app/publish_job_service.go) 的 `Create()` 入口最前面、`tx.Begin` 之前,调用:
|
||
|
||
```go
|
||
decision, record, err := s.complianceSvc.GateForPublish(ctx, articleID, articleVersionID)
|
||
```
|
||
|
||
强制模式下命中 `block` 等级直接返回 `ErrComplianceBlocked`;自愿模式或带 `ack_record_id` 时放行。
|
||
|
||
---
|
||
|
||
## 6. 数据模型
|
||
|
||
> 迁移目录约定按现有规则:ops 用 `server/migrations_ops/`,tenant 用 `server/migrations/`,分别走独立 `schema_migrations` 表(沿用 `migrate_per_schema_table` 约定,避免 ops/main 互相看见对方版本号)。
|
||
|
||
### 6.1 ops 侧(`server/migrations_ops/`)
|
||
|
||
```sql
|
||
-- 平台级词库表(一条词典 = 一组规则)
|
||
CREATE TABLE compliance_dictionaries (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
code VARCHAR(64) UNIQUE NOT NULL, -- e.g. 'ad_law_extreme', 'porn', 'politics'
|
||
name VARCHAR(128) NOT NULL,
|
||
category VARCHAR(32) NOT NULL, -- ad_law | sensitive | industry_med | custom
|
||
default_level VARCHAR(16) NOT NULL, -- block | high | medium | info
|
||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||
version BIGINT NOT NULL DEFAULT 1, -- 词库版本号,引擎据此热加载
|
||
description TEXT,
|
||
created_at TIMESTAMPTZ NOT NULL,
|
||
updated_at TIMESTAMPTZ NOT NULL
|
||
);
|
||
|
||
CREATE TABLE compliance_dictionary_terms (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
dictionary_id BIGINT NOT NULL REFERENCES compliance_dictionaries(id) ON DELETE CASCADE,
|
||
match_type VARCHAR(16) NOT NULL, -- exact | regex
|
||
pattern TEXT NOT NULL,
|
||
level_override VARCHAR(16), -- 该词单独覆盖等级
|
||
hint TEXT, -- 命中后给用户的修改建议
|
||
reference_law VARCHAR(255), -- 法条引用,例如《广告法》第九条
|
||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||
created_at TIMESTAMPTZ NOT NULL
|
||
);
|
||
CREATE INDEX idx_terms_dict ON compliance_dictionary_terms(dictionary_id) WHERE enabled = TRUE;
|
||
|
||
-- 词库版本快照(用于回滚)
|
||
CREATE TABLE compliance_dictionary_versions (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
dictionary_id BIGINT NOT NULL REFERENCES compliance_dictionaries(id) ON DELETE CASCADE,
|
||
version BIGINT NOT NULL,
|
||
snapshot JSONB NOT NULL, -- 词条全量快照
|
||
published_by BIGINT,
|
||
published_at TIMESTAMPTZ NOT NULL,
|
||
UNIQUE(dictionary_id, version)
|
||
);
|
||
|
||
-- 平台默认策略(全局单例)
|
||
CREATE TABLE compliance_policies (
|
||
id BIGINT PRIMARY KEY DEFAULT 1,
|
||
enforcement_mode VARCHAR(16) NOT NULL, -- mandatory | advisory | disabled
|
||
enabled_dictionaries JSONB NOT NULL, -- [{"code":"ad_law_extreme","level_floor":"high","rollout_percent":100}]
|
||
llm_judge_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||
llm_categories JSONB, -- ["politics","medical_claim"]
|
||
updated_by BIGINT,
|
||
updated_at TIMESTAMPTZ NOT NULL,
|
||
CONSTRAINT ck_compliance_policy_singleton CHECK (id = 1)
|
||
);
|
||
```
|
||
|
||
### 6.2 tenant 侧(`server/migrations/`)
|
||
|
||
```sql
|
||
-- 租户自定义词条(白名单豁免 / 自家品牌黑名单)
|
||
CREATE TABLE compliance_tenant_terms (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
|
||
kind VARCHAR(16) NOT NULL, -- whitelist | blacklist
|
||
pattern TEXT NOT NULL,
|
||
match_type VARCHAR(16) NOT NULL, -- exact | regex
|
||
level VARCHAR(16), -- 仅 blacklist 用
|
||
hint TEXT,
|
||
created_by BIGINT,
|
||
created_at TIMESTAMPTZ NOT NULL
|
||
);
|
||
CREATE INDEX idx_tenant_terms ON compliance_tenant_terms(tenant_id, kind);
|
||
|
||
-- 检测记录(一次检测一行,作为审计与申诉依据)
|
||
CREATE TABLE compliance_check_records (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL,
|
||
article_id BIGINT, -- 可空:纯文本检测
|
||
article_version_id BIGINT,
|
||
trigger_source VARCHAR(32) NOT NULL, -- editor_manual | publish_gate | auto_draft | api
|
||
enforcement_mode VARCHAR(16) NOT NULL, -- 当时生效的模式
|
||
highest_level VARCHAR(16) NOT NULL, -- block | high | medium | info | pass
|
||
passed BOOLEAN NOT NULL, -- 是否允许放行
|
||
hit_count INT NOT NULL,
|
||
dict_versions JSONB NOT NULL, -- 命中时各词库版本(可复现)
|
||
llm_used BOOLEAN NOT NULL DEFAULT FALSE,
|
||
duration_ms INT NOT NULL,
|
||
checked_by BIGINT,
|
||
checked_at TIMESTAMPTZ NOT NULL
|
||
);
|
||
CREATE INDEX idx_check_tenant_article ON compliance_check_records(tenant_id, article_id, checked_at DESC);
|
||
|
||
-- 命中明细(前端高亮用)
|
||
CREATE TABLE compliance_violations (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
record_id BIGINT NOT NULL REFERENCES compliance_check_records(id) ON DELETE CASCADE,
|
||
tenant_id BIGINT NOT NULL,
|
||
source VARCHAR(16) NOT NULL, -- dict | regex | llm | tenant_blacklist
|
||
dictionary_code VARCHAR(64),
|
||
term_pattern TEXT NOT NULL,
|
||
matched_text TEXT NOT NULL,
|
||
start_offset INT NOT NULL, -- 纯文本偏移
|
||
end_offset INT NOT NULL,
|
||
dom_path TEXT, -- 富文本节点路径,前端高亮用
|
||
level VARCHAR(16) NOT NULL,
|
||
hint TEXT,
|
||
reference_law VARCHAR(255),
|
||
acknowledged BOOLEAN NOT NULL DEFAULT FALSE -- 用户已知悉/忽略
|
||
);
|
||
CREATE INDEX idx_violations_record ON compliance_violations(record_id);
|
||
```
|
||
|
||
---
|
||
|
||
## 7. 检测引擎
|
||
|
||
### 7.1 调用流程
|
||
|
||
```
|
||
ComplianceService.Check(ctx, ArticleContent)
|
||
│
|
||
├─ 0. KillSwitch: cfg.Compliance.Enabled == false → 直接 GatePass / Skipped
|
||
├─ 1. PolicyResolver: 拿到当前租户生效的策略 + 词库白名单(带 LRU 缓存,TTL 60s)
|
||
├─ 2. TextExtractor: 富文本 HTML/JSON → 纯文本 + (offset → dom_path) 映射
|
||
├─ 3. DictMatcher: AC 自动机一次扫描所有 exact 词条
|
||
├─ 4. RegexMatcher: 并发跑各正则(每条带 100ms 单条上限,超时丢弃并打告警)
|
||
├─ 5. WhitelistFilter: 命中后剔除租户白名单覆盖项
|
||
├─ 6. LLMJudge(可选 / 异步): 把 "未命中关键词但风险类目开启" 的段落丢给 LLM
|
||
└─ 7. 聚合 → 落库 compliance_check_records + compliance_violations → 返回 DTO
|
||
```
|
||
|
||
### 7.2 性能与热更新
|
||
|
||
- **AC 自动机** 单次构建后驻留内存(`goahocorasick` 或等价库),万级词典 P99 < 5ms。
|
||
- **快照加载器** `snapshot_loader.go`:启动时全量拉取 → 构建快照 → 注册到 `atomic.Pointer[Snapshot]`。**每 30s 轮询** `compliance_dictionaries.version`,发现变更则后台构建新快照、原子替换。Ops 端改词库后立即 bump version,避免 Pub/Sub 强依赖;后续可加 Redis pub/sub 做秒级刷新(P3)。
|
||
- **LLM 兜底**:仅对开启了 `llm_categories` 的类目跑;编辑器手动检测**同步**等待结果(500ms 超时降级为不含 LLM 结论),发布器强制拦截**异步**走(写一条 `待复核` violation 后依策略放行或卡住)。
|
||
- **AI 点数计费**:LLM 检测复用现有 [`server/internal/tenant/app/ai_point_usage.go`](../server/internal/tenant/app/ai_point_usage.go) 计费体系,新增计费场景 `compliance_llm_judge`,避免被刷。
|
||
|
||
### 7.3 偏移映射(前端高亮关键)
|
||
|
||
富文本编辑器(看 [`apps/admin-web/src/views/ArticleEditorView.vue`](../apps/admin-web/src/views/ArticleEditorView.vue) 用的是 ArticleEditorCanvas)一般是 ProseMirror / TipTap 之类节点树。`TextExtractor` 输出:
|
||
|
||
```go
|
||
type ExtractedText struct {
|
||
PlainText string
|
||
OffsetMap []OffsetSegment // {start, end, domPath, blockID}
|
||
}
|
||
```
|
||
|
||
引擎拿到 `(start_offset, end_offset)` 后反查 `domPath`,前端按 path 注入高亮 mark,避免前后端各自用文本搜一遍导致错位。
|
||
|
||
### 7.4 引擎接口(门面)
|
||
|
||
```go
|
||
// server/internal/tenant/compliance/app/compliance_service.go
|
||
|
||
type GateDecision int
|
||
|
||
const (
|
||
GatePass GateDecision = iota // 放行
|
||
GateBlock // 必须拦截(强制 + block 等级)
|
||
GateNeedsAck // 需要前端二次确认(advisory 或 强制 + high/medium)
|
||
)
|
||
|
||
type ComplianceService interface {
|
||
// 编辑器手动检测、纯文本检测公用入口
|
||
Check(ctx context.Context, req CheckRequest) (*CheckResult, error)
|
||
|
||
// 发布闸:PublishJobService.Create() 内部调用
|
||
GateForPublish(ctx context.Context, articleID, versionID int64) (GateDecision, *CheckRecord, error)
|
||
|
||
// 用户对某条违规标记 "已知悉,仍要发布"
|
||
Acknowledge(ctx context.Context, violationID int64, actor int64) error
|
||
|
||
// 给前端拉取运行时状态(kill-switch 状态、当前策略、LLM 是否启用)
|
||
RuntimeStatus(ctx context.Context) (*RuntimeStatus, error)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 8. Config Kill-Switch(凌驾于 ops 策略之上)
|
||
|
||
接入现有 [`server/internal/shared/config/config.go`](../server/internal/shared/config/config.go) 配置体系,与 `cfg.LLM` 同级新增:
|
||
|
||
```go
|
||
// shared/config/config.go
|
||
type ComplianceConfig struct {
|
||
Enabled bool `mapstructure:"enabled" env:"COMPLIANCE_ENABLED" default:"true"`
|
||
LLMJudgeEnabled bool `mapstructure:"llm_judge_enabled" env:"COMPLIANCE_LLM_ENABLED" default:"false"`
|
||
SnapshotReloadInterval time.Duration `mapstructure:"snapshot_reload_interval" env:"COMPLIANCE_SNAPSHOT_RELOAD_INTERVAL" default:"30s"`
|
||
PublishGateTimeout time.Duration `mapstructure:"publish_gate_timeout" env:"COMPLIANCE_PUBLISH_GATE_TIMEOUT" default:"800ms"`
|
||
}
|
||
```
|
||
|
||
调用面短路逻辑(写在 `compliance_service.go` 门面入口,不是各 handler):
|
||
|
||
```go
|
||
func (s *ComplianceService) Check(ctx context.Context, req CheckRequest) (*CheckResult, error) {
|
||
if !s.cfg.Compliance.Enabled {
|
||
return &CheckResult{
|
||
Decision: GatePass,
|
||
Skipped: true,
|
||
Reason: "compliance_disabled_by_config",
|
||
}, nil
|
||
}
|
||
// ... 正常流程
|
||
}
|
||
|
||
func (s *ComplianceService) GateForPublish(ctx context.Context, articleID, versionID int64) (GateDecision, *CheckRecord, error) {
|
||
if !s.cfg.Compliance.Enabled {
|
||
return GatePass, nil, nil
|
||
}
|
||
// ... 正常流程
|
||
}
|
||
```
|
||
|
||
层级覆盖关系:
|
||
|
||
```
|
||
config.compliance.enabled = false
|
||
│ ↓ 全站短路(kill-switch,运维专用)
|
||
│
|
||
└─ ops.compliance_policies(id=1).enforcement_mode = disabled
|
||
↓ 默认关闭,但词库管理 UI 仍可访问
|
||
```
|
||
|
||
前端也要感知运行时状态:编辑器进入时调一次 `GET /api/compliance/runtime-status`,返回 `{enabled, mode, llm_enabled, dict_versions}`,**引擎关闭时按钮直接隐藏(不是置灰)**,避免空跑误导用户。
|
||
|
||
---
|
||
|
||
## 9. 对外 API
|
||
|
||
### 9.1 租户面(`/api/...`)
|
||
|
||
| Method | Path | 用途 | 调用方 |
|
||
|---|---|---|---|
|
||
| GET | `/api/compliance/runtime-status` | 读取运行时状态(kill-switch、模式、LLM 开关) | 编辑器初始化 |
|
||
| POST | `/api/articles/:id/compliance:check` | 编辑器一键检测,传 `{content, source: "manual"}` | 编辑器按钮 |
|
||
| POST | `/api/compliance:check` | 无文章上下文的纯文本检测 | 标题 / 摘要单独检测 |
|
||
| GET | `/api/articles/:id/compliance/latest` | 拿最近一次检测结果 + violations | 编辑器进入时回显 |
|
||
| POST | `/api/compliance/violations/:id/ack` | 用户标记 "已知悉,仍要发布" | 自愿模式 |
|
||
| GET / POST / DELETE | `/api/compliance/tenant-terms` | 租户白 / 黑名单管理 | 租户管理员 |
|
||
|
||
发布器拦截**不暴露 HTTP**,是 PublishJobService 内部调用 `GateForPublish`。
|
||
|
||
### 9.2 ops 面(`/api/ops/...`)
|
||
|
||
走独立部署的 ops-api 服务(`server/cmd/ops-api`),handler 落在 `server/internal/ops/compliance/transport/`。
|
||
|
||
| Method | Path | 用途 |
|
||
|---|---|---|
|
||
| GET / POST / PUT / DELETE | `/api/ops/compliance/dictionaries` | 词库 CRUD |
|
||
| GET | `/api/ops/compliance/dictionaries/:id/terms` | 词条列表(分页 / 搜索) |
|
||
| POST | `/api/ops/compliance/dictionaries/:id/terms` | 单条新增 |
|
||
| POST | `/api/ops/compliance/dictionaries/:id/terms:batch-import` | 词条批量导入(CSV,每次几千条) |
|
||
| POST | `/api/ops/compliance/dictionaries/:id/publish` | 词库版本发布(bump version + 写 versions 快照) |
|
||
| POST | `/api/ops/compliance/dictionaries/:id/rollback` | 回滚到指定版本 |
|
||
| GET / PUT | `/api/ops/compliance/policy/global` | 全局策略:强制 / 自愿 / 关闭、启用哪些词库 |
|
||
| GET | `/api/ops/compliance/stats` | 命中分布、拦截率、误报率(来自 ack 数据) |
|
||
| GET | `/api/ops/compliance/records` | 全局检测记录搜索 |
|
||
|
||
---
|
||
|
||
## 10. 两种模式的处置矩阵
|
||
|
||
| 触发点 | 模式 = 强制(`mandatory`) | 模式 = 自愿(`advisory`) | 模式 = 关闭(`disabled` 或 config kill) |
|
||
|---|---|---|---|
|
||
| 编辑器手动检测按钮 | 跑全量检测,UI 标红 + 给修改建议 | 同左(**手动按钮两种模式行为相同**,仅展示) | 按钮隐藏(不是置灰) |
|
||
| 保存草稿(P3 可选) | 异步预扫,不阻断保存,仅在编辑器侧角标提醒 | 同左 | 不跑 |
|
||
| **发布按钮** | 同步检测:命中 `block` → 直接 `409 ErrComplianceBlocked` 拒发,前端弹层列出违规项;命中 `high/medium/info` → 弹"二次确认"对话框 | 同步检测:仅展示提醒面板,用户点 "仍然发布" 即放行(写一条 ack) | 不跑,直接进入 PublishJobService |
|
||
|
||
实现要点:拦截逻辑放在 [`server/internal/tenant/app/publish_job_service.go`](../server/internal/tenant/app/publish_job_service.go) 的 `Create()` 入口最前面,**在 `tx.Begin` 之前**调用 `complianceSvc.GateForPublish(ctx, articleID, articleVersionID)`:
|
||
|
||
```go
|
||
type GateDecision int
|
||
const (
|
||
GatePass GateDecision = iota
|
||
GateBlock // 必须拦截(强制 + block 等级)
|
||
GateNeedsAck // 需要前端二次确认(advisory 模式 或 强制模式 high/medium 等级)
|
||
)
|
||
```
|
||
|
||
前端在 `apps/admin-web/src/components/PublishArticleModal.vue`(待新增 / 拓展)内部对 `GateNeedsAck` 渲染违规清单 + "知悉并继续发布" 按钮,点击后带 `ack_record_id` 重发请求,后端校验 `ack_record_id` 与 `tenant_id + article_version_id` 匹配后放行。
|
||
|
||
---
|
||
|
||
## 11. ops 控制台 UI
|
||
|
||
新增 ops-web 视图(参照已有 [`apps/ops-web/src/views/AuditLogsView.vue`](../apps/ops-web/src/views/AuditLogsView.vue) 风格):
|
||
|
||
```
|
||
apps/ops-web/src/views/compliance/
|
||
├── CompliancePolicyView.vue # 全局策略:模式开关 + 启用词库勾选
|
||
├── ComplianceDictionariesView.vue # 词库列表(含版本 / 启用 / 导入)
|
||
├── ComplianceDictionaryEditView.vue # 词条 CRUD + CSV 导入
|
||
├── ComplianceRecordsView.vue # 全局检测记录、误报率
|
||
└── ComplianceStatsView.vue # 命中 Top 词、拦截率、按租户分布
|
||
```
|
||
|
||
侧边栏分组建议归到"内容安全",与现有审计日志并列。
|
||
|
||
---
|
||
|
||
## 12. 租户面编辑器集成
|
||
|
||
### 12.1 编辑器按钮
|
||
|
||
在 [`apps/admin-web/src/components/editor-common/EditorActionMenu.vue`](../apps/admin-web/src/components/editor-common/EditorActionMenu.vue) 增加一个独立按钮组件 `EditorComplianceCheck.vue`:
|
||
|
||
```
|
||
[AI助手] [合规检测] [发布]
|
||
│
|
||
├─ 点击 → POST /api/articles/:id/compliance:check
|
||
├─ 抽屉式结果面板:违规项列表(按 level 分组)
|
||
├─ 每条支持「定位到原文」「加入白名单」「忽略」
|
||
└─ 文档中违规处加 mark 高亮(按 dom_path)
|
||
```
|
||
|
||
按钮可见性绑定 `runtime-status` 接口返回的 `enabled`:关闭时整组隐藏。
|
||
|
||
### 12.2 发布弹窗改造
|
||
|
||
发布弹窗 `PublishArticleModal` 改造:发布前自动调一次 `compliance:check`(同步),根据返回的 `GateDecision` 渲染:
|
||
|
||
- `pass`:直接进入正常发布流程
|
||
- `needs_ack`:替换为合规警示视图,列出违规项;用户必须勾选 "已了解风险,仍要发布" 才能继续,提交时携带 `ack_record_id`
|
||
- `block`:禁用发布按钮,给出修改指引;不允许带 ack 强发
|
||
|
||
---
|
||
|
||
## 13. 关键工程细节(大厂常踩点)
|
||
|
||
| 点 | 做法 |
|
||
|---|---|
|
||
| **词库版本可回滚** | `compliance_dictionaries.version` + `compliance_dictionary_versions` 快照表。Ops 端"发布"是 bump version + 写快照;保留最近 N 个版本可一键回滚。 |
|
||
| **灰度发布词库** | `compliance_policies.enabled_dictionaries[*]` 增加 `rollout_percent` 与 `rollout_tenant_ids`,PolicyResolver 按 `tenant_id % 100` 做一致性灰度。 |
|
||
| **误报申诉闭环** | 用户 ack 后,记录写入 `compliance_violations.acknowledged=true`;ops 侧统计某词条的 ack 率,> X% 自动标记 "疑似误报" 提示运营复核。 |
|
||
| **富文本不踩坑** | 检测**只看纯文本**,但保留 dom_path 用于回写高亮;图片 OCR、链接落地页 V1 不做(前置说明)。 |
|
||
| **性能预算** | 编辑器手动按钮:纯词典 + 正则 P99 ≤ 50ms;含 LLM ≤ 1.5s。发布拦截:纯词典 + 正则 P99 ≤ 30ms(不含 LLM,LLM 异步进 review 队列),总闸超时由 `cfg.Compliance.PublishGateTimeout` 控制。 |
|
||
| **可观测** | 给 [`server/internal/shared/middleware`](../server/internal/shared/middleware) 加埋点:每次 check 的 `duration_ms / hit_count / level / source / dict_version` 进 metrics;ops 侧拉曲线。 |
|
||
| **审计日志** | 写词库、改策略等管理操作通过现有 `auditlog.AsyncWriter`([`server/internal/shared/auditlog/async.go`](../server/internal/shared/auditlog/async.go))记录。 |
|
||
| **隐私** | check_records 默认只存命中片段(`matched_text`,截断 ≤ 50 字符)和偏移;全文不入审计。LLM 调用走现有 LLM client 的脱敏链路。 |
|
||
| **极限词库种子** | V1 ops 预装:①《广告法》极限词约 200 条 ② 政治敏感词(公开版) ③ 涉黄涉暴 ④ 通用违禁。提供 CSV 模板,运营可自行扩。种子数据走数据库迁移落地,避免运维额外操作。 |
|
||
| **kill-switch 可观测** | 配置短路时仍 emit 一条 metric `compliance_skipped_total{reason="config_disabled"}`,便于运维通过监控确认 kill-switch 已经生效。 |
|
||
| **跨服务调用一致性** | tenant-api 和 ops-api 都引用同一个 `internal/shared/compliance/types.go`,避免 DTO 漂移。 |
|
||
|
||
---
|
||
|
||
## 14. 实施分期
|
||
|
||
| 阶段 | 范围 | 大致工时 |
|
||
|---|---|---|
|
||
| **P0**(最小闭环) | config kill-switch + ops 词库 / 策略表 + 引擎(词典 + 正则)+ 编辑器手动按钮 + 发布器拦截 + 强制 / 自愿模式 + 极限词库种子 | 5–8 人日 |
|
||
| **P1**(运营可用) | ops-web 词库管理 UI + 租户白 / 黑名单 + 检测记录查询 + CSV 导入 + 误报率统计 | 3–4 人日 |
|
||
| **P2**(智能化) | LLM 语义兜底 + 灰度发布 + 命中统计 + 误报申诉闭环 + AI 点数计费打通 | 4–6 人日 |
|
||
| **P3**(增强) | 富文本节点路径高亮联动 + 异步保存预扫 + 词库版本回滚 UI + Redis pub/sub 秒级热更 + 词库 Diff 预览 | 4–5 人日 |
|
||
|
||
---
|
||
|
||
## 15. 与现有架构的衔接清单
|
||
|
||
| 现有模块 | 衔接方式 |
|
||
|---|---|
|
||
| `server/internal/shared/config/config.go` | 新增 `ComplianceConfig` 段,承担 kill-switch |
|
||
| `server/internal/shared/llm/client.go` | LLMJudge 复用,新增 `compliance_llm_judge` 计费场景 |
|
||
| `server/internal/tenant/app/publish_job_service.go` | `Create()` 入口前置 `GateForPublish` 闸 |
|
||
| `server/internal/tenant/app/ai_point_usage.go` | LLM 检测计费走此服务 |
|
||
| `server/internal/shared/auditlog/async.go` | 词库 / 策略管理操作落审计 |
|
||
| `server/internal/shared/middleware/tenant_scope.go` | 租户作用域中间件保证 `tenant_id` 注入 |
|
||
| `server/migrations_ops/` | 新增 4 张 ops 表迁移 |
|
||
| `server/migrations/` | 新增 3 张 tenant 表迁移 |
|
||
| `apps/admin-web/src/views/ArticleEditorView.vue` | 集成 `EditorComplianceCheck.vue` |
|
||
| `apps/admin-web/src/components/editor-common/EditorActionMenu.vue` | 新增按钮挂载点 |
|
||
| `apps/ops-web/src/views/` | 新增 `compliance/` 视图组 |
|
||
|
||
---
|
||
|
||
## 16. 风险与未决项
|
||
|
||
| 项 | 风险 / 待定 | 备选 |
|
||
|---|---|---|
|
||
| 富文本偏移映射准确度 | TipTap 在分块编辑后 dom_path 可能漂移 | 退化方案:仅返回 plain offset,前端基于 textContent 自行 search & highlight |
|
||
| LLM 兜底成本 | 每篇文章 +1 次 LLM 调用,AI 点数消耗显著 | P2 上线前先做小流量灰度,验证 ack 率 / 误报率回报值 |
|
||
| 词库初版覆盖度 | 公开版敏感词库可能不够覆盖行业(医美、教育、金融) | V1 后基于 ack 数据 + 运营反馈持续补 |
|
||
| 30s 轮询延迟 | 词库新增后租户最长 30s 才生效 | P3 上 Redis pub/sub 做秒级;运营误操作时也提供"立即重载"按钮(仅 ops 可见) |
|
||
| 配置 kill-switch 误触 | 运维误关导致全站合规失效 | metrics + Slack 告警 + 启动日志 WARNING;ops 控制台首页显著标注 "config 已关闭" 状态 |
|
||
|
||
---
|
||
|
||
## 17. 文档维护
|
||
|
||
- 词库结构 / 等级分级 / 模式覆盖关系如有调整,本文同步升版本号(v1 → v1.1 → v2)。
|
||
- 实施过程产生的偏差(例如 P1 提前并入 P0、引擎库选型变更)回写到本文 `§14 实施分期` 与对应章节。
|
||
- 与 `geo-platform-prd-v1.md`、`geo-platform-ops-admin-prd-v1.md` 中"内容安全 / 合规"段落保持口径一致;如冲突以本设计文档为后端实现口径,PRD 为产品验收口径。
|