# 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[文章编辑器
合规检测按钮]
E2[发布弹窗
PublishArticleModal]
end
subgraph ops-web[运营控制台 ops-web]
O1[策略管理]
O2[词库管理]
O3[检测记录与统计]
end
subgraph tenant-api[tenant API server]
T1[ComplianceHandler]
T2[ComplianceService
门面]
T3[Engine
词典+正则+LLM]
T4[PolicyResolver]
T5[SnapshotLoader
词库快照]
T6[PublishJobService
发布闸]
end
subgraph ops-api[ops API server]
P1[DictionaryHandler]
P2[PolicyHandler]
P3[StatsHandler]
end
DB[(PostgreSQL
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 | semantic_keyword
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 BIGSERIAL PRIMARY KEY,
scope VARCHAR(16) NOT NULL, -- 'global' or 'tenant_override'
tenant_id BIGINT, -- scope=tenant_override 时填
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,
UNIQUE(scope, tenant_id)
);
```
### 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(scope=global).enforcement_mode = disabled
│ ↓ 默认关闭,但词库管理 UI 仍可访问
│
└─ ops.compliance_policies(scope=tenant_override).enforcement_mode = disabled
↓ 仅该租户关闭
```
前端也要感知运行时状态:编辑器进入时调一次 `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 / PUT | `/api/ops/compliance/policy/tenants/:tenantId` | 租户级覆盖 |
| 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 导入
├── ComplianceTenantOverrideView.vue # 租户级覆盖
├── 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 为产品验收口径。