Files
geo/docs/compliance-detection-tech-design-v1.md
T
root 745cdd79cf feat(compliance): add content compliance detection across tenant, ops, and clients
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>
2026-05-05 20:48:14 +08:00

569 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 为准。
---
> 版本:v12026-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(不含 LLMLLM 异步进 review 队列),总闸超时由 `cfg.Compliance.PublishGateTimeout` 控制。 |
| **可观测** | 给 [`server/internal/shared/middleware`](../server/internal/shared/middleware) 加埋点:每次 check 的 `duration_ms / hit_count / level / source / dict_version` 进 metricsops 侧拉曲线。 |
| **审计日志** | 写词库、改策略等管理操作通过现有 `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 告警 + 启动日志 WARNINGops 控制台首页显著标注 "config 已关闭" 状态 |
---
## 17. 文档维护
- 词库结构 / 等级分级 / 模式覆盖关系如有调整,本文同步升版本号(v1 → v1.1 → v2)。
- 实施过程产生的偏差(例如 P1 提前并入 P0、引擎库选型变更)回写到本文 `§14 实施分期` 与对应章节。
-`geo-platform-prd-v1.md``geo-platform-ops-admin-prd-v1.md` 中"内容安全 / 合规"段落保持口径一致;如冲突以本设计文档为后端实现口径,PRD 为产品验收口径。