Files
geo/docs/superpowers/specs/2026-04-28-compliance-detection-design.md
T
root b36e2bf2ad docs(compliance): add content compliance detection design spec
落地敏感词 / 广告法 / 行业违禁词检测的设计文档,覆盖 ops 强制 vs 自愿模式、
PolicyResolver fail-closed 语义、ack 防伪、LLM 事后复核队列等关键决策。
v1 版本保留备查并指向 superpowers spec。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:36:15 +08:00

1119 lines
69 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.
# 内容合规检测(敏感词 / 广告法)设计
- 日期:2026-04-28
- 状态:Final (Revision 3, 2026-04-28) — Implementation Ready
- 涉及端:`apps/admin-web``apps/ops-web``server/internal/tenant``server/internal/ops``server/cmd/ops-api``server/cmd/tenant-api``server/cmd/scheduler``packages/shared-types`
- 类型:跨端新增模块
## 0. Review Log
### Revision 32026-04-28
最后修订收口实现前硬决策,原则是满足当前发布合规场景,不引入新的基础设施:
| # | 问题 | 收口位置 |
| --- | --- | --- |
| R3-P0 | ack 绑定旧 check_record,但发布请求会重新 Gate,导致二次提交 ack 与新 record 不匹配 | §6.3 / §6.9`GateForPublish` 接收可选 `ack_record_id`,先校验 ack 覆盖的原 check_record,消费仍在发布事务内 CAS 完成 |
| R3-P1 | 高并发下发布闸必须有确定延迟上界 | §6.10:发布闸热路径、regex 预算、violation 写入上限、超时语义全部固定 |
| R3-P2 | LLM 事后复核队列需要明确宿主 | §6.7:复用现有 RabbitMQ`compliance_review_jobs` 做状态表与恢复兜底,worker 消费 `compliance.review` 队列 |
| R3-P3 | tenant-api 访问 ops 策略的路径需要固定 | §4.2 / §6.6tenant-api 直接只读查询同库 `ops.*` 表,通过进程内 snapshot/last-known cache 扛读压力 |
| R3-P4 | plaintext snapshot、历史回填、版本保留、CSV、灰度、记录保留等实现决策需要收口 | §4.7 / §6.2 / §6.8 / §6.10 / §13:全部给出一期决策 |
### Revision 22026-04-28
针对 Revision 1 后的二轮 review 收口 8 条新问题:
| # | 问题 | 收口位置 |
| --- | --- | --- |
| R2-P0 | `PolicyResolver.Resolve` 失败时直接 fail-openmandatory 租户被静默放行 | §6.5.1last-known cache → conservative mandatory fail-closed;冷启动 fail-fast |
| R2-P1-1 | `GateForPublish(ctx, articleID, req.ArticleVersionID)` 类型不匹配 + nil 解引用 panic | §6.3:新增 `articles.ResolvePublishableVersion` 解析 `effectiveVersionID`,发布闸入参改为 `GateForPublishRequest` 值对象 |
| R2-P1-2 | ack 数据模型未真正落表(只有 `compliance_violations.acknowledged` bool | §6.2 新增 `compliance_ack_records` 表 + CAS UPDATE;§6.9 ack lifecycle |
| R2-P1-3 | ack endpoint 在 violation 粒度,不能授权整篇发布 | §7.1:`POST /api/tenant/compliance/check-records/:id/ack`;§6.9:粒度 = check_record,必须 ack 全部非 block violation |
| R2-P1-4 | `compliance:check` 在 Gin 里被解析成 path parameter | §7.1:统一改 `/check` 普通 segmenthandler 注册示例同步 |
| R2-P1-5 | shared-types 用 camelCase,与现有 snake_case 风格不一致 | §6.3 / §9.3:所有新增 TS 字段 `article_version_id` / `ack_record_id` 等都用 snake_case |
| R2-P1-6 | `ArticleDetail` 不暴露 `current_version_id`,前端无法拿到稳定 id | §6.3:扩展 ArticleDetail;§9.3:后端 + TS 同步改 |
| R2-P2 | line 217 / 539 路径与 SQL 残留旧形态 | 全部修正为 `/api/tenant/compliance/runtime-status` / `ops.compliance_dictionaries` |
### Revision 12026-04-28
针对前序 review 收口 9 条问题:
| # | 问题 | 收口位置 |
| --- | --- | --- |
| P0-1 | v1 文档已归档(`docs/compliance-detection-tech-design-v1.md` 顶部已加 Superseded 横幅),实现以本 spec 为准 | — |
| P0-2 | `CreatePublishJobRequest``article_version_id``ack_record_id`;前后端 + 共享 TS 三处契约不通 | §3 R15、§6.3、§6.8、§9 |
| P1-1 | API 路径与现有 `/api/tenant/...` 前缀不一致 | §7、§5.1 |
| P1-2 | ops 侧 SQL 必须显式 `ops.` schema 前缀,否则会落到 public | §6.2 |
| P1-3 | `UNIQUE(scope, tenant_id)` 无法约束多条 global policyPG 不视 NULL 相等) | §6.2partial unique + CHECK |
| P1-4 | mandatory 模式下 LLM 异步语义不自洽 | §4.3、§6.5、§6.7 |
| P2-1 | `LoadArticleVersionContent` 会扫目标版本之前所有版本 + diff 重建,发布闸 30ms 预算扛不住 | §4.7、§6.2、§6.7 |
| P2-2 | 隐私约束(matched_text 截断)只在文字里,schema 没 enforce | §6.2CHECK |
| P2-3 | `env:"COMPLIANCE_ENABLED"` 装饰 tag 在现有 `applyEnvOverrides` 体系下不会自动生效 | §6.4 |
## 1. 背景与目标
现状:发布器 `PublishJobService.Create()` 只校验作者权限、订阅余量、素材完整性,**没有内容合规闸**。监管侧(《广告法》极限词、政治敏感词、行业违禁词)要求文章发布前必须过一道检查,且产品侧要求 ops 可在"硬阻断"和"仅提示"之间切换。运维侧另需一个 kill-switch,在引擎抖动 / DB 异常 / 误报洪水时全站快速止血。
本次新增「内容合规检测」模块满足上述需求。
### 1.1 目标
- 双模式可切换:`mandatory`(命中阻断)/ `advisory`(仅提示)/ `disabled`(关闭),ops 全局 + 租户级覆盖
- 三个调用入口:编辑器手动按钮、发布器同步闸、纯文本 API(标题 / 摘要单查)
- 词库 + 正则 + 可选 LLM 三层检测,前两层毫秒级,第三层可关
- config 级 kill-switch`cfg.Compliance.Enabled`),凌驾 ops 策略之上
- 检测结果留审计与申诉证据;运营操作落 audit log
- 与现有 tenant / ops 双 schema、AI 点数计费体系无缝衔接
### 1.2 落地原则
- 发布闸必须小而确定:不调用 LLM、不跨服务 HTTP、不做全文历史重建,不因为统计、通知、复核队列阻塞发文主链路
- 高可用优先使用现有能力:PostgreSQL、RabbitMQ、进程内 `atomic.Pointer` 快照、已有 scheduler 进程;一期不新增 Redis pub/sub、Kafka 或独立规则服务
- 高并发用"限流 + 有界写入 + CAS"解决,不引入分布式锁;只有 ack 消费需要 DB 原子更新
- 合规证据可复现:每次检测记录保存 `article_version_id`、内容 hash、策略指纹、词库版本和命中快照,便于申诉与复盘
- 默认安全但可止血:mandatory 失败走 fail-closed;全站误报或底层故障由 config kill-switch 明确人工关闭
### 1.3 非目标(明确不做)
- **图片 OCR 检测**:违规海报 / 表情包识别留二期,本期仅检纯文本
- **外链落地页爬取**:链接目的地内容不抓取
- **自动改写违规词**:仅给修改建议,不主动替换文本
- **跨租户 ML 模型 / 个性化训练**
- **Redis pub/sub 秒级同步**:一期用 30s 轮询版本号
- **词库 Diff / 版本对比 UI**ops 控制台只做"发布版本 + 一键回滚"
- **流式检测**:不在用户打字过程中实时查,只在显式触发点查
- **租户自助上调严苛度**:租户只能加白 / 黑名单,不能修改 ops 下发的等级
## 2. 当前状态
| 模块 | 现状 |
| --- | --- |
| `server/internal/tenant/app/publish_job_service.go` | `Create()` 只检 quota / membership / article state,无合规闸 |
| `server/internal/shared/config/config.go` | 无 `ComplianceConfig` 段 |
| `server/internal/tenant/compliance/` | 不存在 |
| `server/internal/ops/compliance/` | 不存在 |
| `apps/admin-web` 编辑器 | 顶部菜单 `EditorActionMenu.vue` 只有 AI 助手、发布等按钮 |
| `apps/admin-web` 发布弹窗 | 直接提交,无合规拦截 |
| `apps/ops-web` | 侧边栏无"内容安全"分组 |
| `server/internal/shared/llm/client.go` | 已存在统一 LLM 客户端,可直接复用 |
| `server/internal/tenant/app/ai_point_usage.go` | 已存在 AI 点数计费 ledger,可作为新场景宿主 |
| `server/internal/shared/auditlog/async.go` | 已存在异步审计 writer,可直接接入 |
| `server/migrations_ops/` | 已存在 ops schema 迁移目录及独立 `schema_migrations` 表 |
## 3. 需求
| ID | 需求 |
| --- | --- |
| R1 | 发布器在创建发布任务前必须经过合规闸;强制模式下命中 `block` 等级直接拒绝 |
| R2 | 自愿模式下命中违规仅展示提醒;用户显式 ack 后可放行(带 `ack_record_id` 重发) |
| R3 | 编辑器侧提供独立"合规检测"按钮,触发同步检测并展示按等级分组的违规清单 |
| R4 | 检测引擎必须支持词典精确匹配 / 正则 / LLM 语义三层;前两层毫秒级 |
| R5 | LLM 兜底必须可被 ops 策略和 config 双层关闭;运行时计费走 AI 点数 ledger |
| R6 | 词库改动必须在 30 秒内对所有 tenant-api 实例生效,不需重启 |
| R7 | 检测结果必须留有审计记录:命中片段、等级、词库版本、检测耗时、来源(manual / publish / api |
| R8 | 运营操作(改词库、改策略)必须落 audit log |
| R9 | `cfg.Compliance.Enabled = false` 时整模块短路,不产生 DB / LLM 调用 |
| R10 | 词库支持版本回滚(最近 10 个发布版本可一键回滚) |
| R11 | 一期随迁移自动注入 4 类极限词种子(广告法 / 政治 / 涉黄涉暴 / 通用违禁) |
| R12 | 富文本编辑器命中违规处可按 DOM 路径精确高亮,避免文本搜索错位 |
| R13 | ops 控制台支持租户级策略覆盖(特定租户可放宽 / 收紧) |
| R14 | 引擎熔断(DB 不可达 / panic)时按策略选择 fail-open 或 fail-closed |
| R15 | `CreatePublishJobRequest` 必须扩展 `article_version_id``ack_record_id` 两个可选字段;共享 TS 类型同步;扩展为向后兼容(旧 client 不传仍可走 mandatory 流程,但无法 ack |
| R16 | 文章纯文本不得在发布闸时同步重建版本历史;必须有"plaintext snapshot"读取路径在 30ms 内可达 |
| R17 | 引擎写入 `compliance_violations` 时必须强制截断 `matched_text` 到上限长度,schema 同时加 CHECK 兜底 |
| R18 | `PolicyResolver` 必须维护进程级 last-known cache`Resolve` 失败时优先用 cache;都没有时返回 `GateBlock`conservative mandatory),不得 fail-open |
| R19 | ack 必须落独立表 `compliance_ack_records`(非 violation 列),粒度 = check_record,单次有效 + 24h TTL,消费用 CAS UPDATE |
| R20 | `ArticleDetail` 必须暴露 `current_version_id`;前端发布请求**应**携带,未携带则服务端通过 `ResolvePublishableVersion` 兜底 |
| R21 | 路径不得使用 `:action` 后缀(与 Gin 参数路由冲突);统一用 `/check``/ack` 等普通 segment |
| R22 | 前后端字段命名统一 snake_case(与现有 shared-types 一致),不在 API client 做 camelCase 转换 |
| R23 | 发布闸 P99 目标 < 50ms;超过 timeout 时按 §4.6 fail-open / fail-closed,不允许无界等待 |
| R24 | 单次检测写入 violation 明细必须有上限,避免违规词洪水放大 DB 写入;记录保留总命中数与截断标记 |
| R25 | ack 必须绑定 `article_version_id + content_hash + policy_fingerprint + check_record_id`,策略或内容变化后旧 ack 不可复用 |
| R26 | LLM 事后复核必须持久化可恢复,多实例 worker 并发处理不得重复消费 |
## 4. 选型与权衡
### 4.1 词典层引擎 — AC 自动机
**Chosen** AhoCorasick 自动机(`goahocorasick` 或等价 Go 实现),万级词典 P99 < 5ms。
**Why**
- 单次扫描批量出全部命中,不需要 N 次重复扫
- 满足发布闸 30ms 延迟预算
- Go 生态有成熟实现,无需自造
**Not chosen**
- 朴素 `strings.Contains` 全文扫:O(N·M),万级词典稳定性差
- Trie:单点查询可,但批量命中弱
- 数据库 `ILIKE`:每次发布都打 DB,扛不住
### 4.2 词库热更新 — 版本号 + 30s 轮询
**Chosen** `ops.compliance_dictionaries.version` + 30s `SELECT` 轮询;tenant-api 直接只读查询同库 `ops.*` 表,变更时后台构建快照、`atomic.Pointer[Snapshot]` 原子替换。
**Why**
- 不引入 Redis pub/sub 跨进程依赖(ops-api 与 tenant-api 是独立部署)
- 实现简单,一行 SQL 比对版本号
- 30s 延迟对运营场景可接受
- 现有部署里 ops/main 共用同一 PostgreSQL,只是 schema 与 migration table 隔离;tenant-api 读 `ops.*` 比走 ops-api 内部 HTTP 更少一个可用性依赖
**Not chosen**
- Redis pub/sub:基础设施依赖更重,二期再上
- 文件 watch:多实例不一致风险
- 强依赖 PG `LISTEN/NOTIFY`:跨服务连接管理复杂
- tenant-api 通过 ops-api 内部 HTTP 拉策略:多一跳、多一个服务 SLA 绑定,且策略数据本质仍在 DB;一期不上
### 4.3 LLM 兜底 — 编辑器同步、发布闸"硬闸不含 LLM"
**Chosen**
- 编辑器手动检测:**同步**等 LLM(500ms 超时降级为不含 LLM 结论)
- 发布器闸:**LLM 不参与硬闸判定**。GateForPublish 只看词典 + 正则结果决定 `GatePass / GateBlock / GateNeedsAck`;LLM 任务派发到事后复核队列,命中时写"待复核"violation 并发企业内通知,**不阻断本次发布**
- 强制模式(mandatory)下亦如此:发布闸的硬阻断**只**来自词典 / 正则;LLM 是事后兜底,不是硬闸
**Why**
- 发布闸总预算 30~50ms(含 plaintext 读取),LLM 调用最少几百 ms,强行同步会让发布超时率不可控
- 词典 + 正则是确定性、可解释、可申诉的硬闸;LLM 是概率性判断,**不应作为硬阻断依据**——否则误报洪水时无法人工复核
- 自愿模式下 LLM 命中本来就只是提醒,行为一致;强制模式下 LLM 命中走"事后下架 / 复核"流程更符合现实运营节奏
**Not chosen**
- "强制 + LLM 启用 → 同步等 LLM 决定 GateBlock":发布闸 P99 不可控,且无法人工复核
- "强制 + LLM 启用 → 等 LLM 但有超时降级":超时降级行为本身就破坏了"强制"语义
- 全部异步:编辑器手动检测体验差,用户主动点检测就该等到 LLM 结论
- 加一个"超严格模式"配置项让 LLM 同步阻断:增加配置面,又不解决误报问题,不上
**实现要点:** `enforcement_mode = mandatory` + `llm_judge_enabled = true` 的组合下,`GateForPublish` 仍只返回词典/正则结果;LLM 任务先写入 `compliance_review_jobs` 状态表,再向 RabbitMQ `compliance.review` 队列投递轻量消息,worker 事后处理(不阻塞调用线程)。复核结果通过现有 audit / 通知通道触达运营。
### 4.4 偏移映射 — 纯文本偏移 + DOM 路径双轨
**Chosen** 引擎只看纯文本 + 偏移;后端同时输出 `dom_path` 让前端精准注入高亮 mark。
**Why**
- 引擎纯文本最简单,无需理解 ProseMirror 节点树
- 前端单纯文本 `search()` 高亮在分块编辑场景必错位(同一字符串多处出现)
**Not chosen**
- 让引擎走 AST:耦合 TipTap 内部,升级风险
- 前端文本搜索:多处命中无法消歧
### 4.5 ops vs tenant schema 拆分
**Chosen**
- 词库表、策略表落 `server/migrations_ops/`
- 租户白/黑名单、检测记录、命中明细落 `server/migrations/`
- 沿用现有"独立 `schema_migrations` 表"约定
**Why**
- 词库/策略由 ops 跨租户共享,自然归 ops schema
- 检测记录与租户数据同生命周期,与 articles 同隔离模型
- ops/tenant 双 schema_migrations 已有约定,避免互相看见对方版本号
**Not chosen**
- 全部塞 opstenant API 跨库读写复杂
- 全部塞 tenant:词库要复制 N 份给每个租户,更新困难
### 4.6 引擎不可用时的失败语义
**Chosen** **强制模式 fail-closed(拦截发布),自愿模式 fail-open(放行 + 标记跳过)。**
**Why**
- 强制模式语义是"宁可误拦,不可漏过"——引擎失联仍要保护监管底线
- 自愿模式语义是"提醒为主"——引擎失联不该误伤正常发布
**Not chosen**
- 全 fail-open:监管失守
- 全 fail-closed:自愿模式下引擎抖动会造成大面积发布失败
### 4.7 文章 plaintext 读取策略 — 写时落地,读时直取
**问题:** 当前 `repository.LoadArticleVersionContent`[`server/internal/tenant/repository/article_version_storage.go`](../../../server/internal/tenant/repository/article_version_storage.go))会用 `version_no <= (SELECT...)` 拉目标版本之前所有版本,并通过 `diffmatchpatch` 重建。长编辑历史的文章发布闸无法在 30ms 预算内拿到纯文本。
**Chosen** 写时落地 plaintext 快照 — 文章每次保存版本时,在 `article_versions` 表写入一列冗余 `plaintext_snapshot TEXT`,引擎读取一行 SQL 即得当前版本纯文本,O(1)。
**Why**
- 检测场景按"当前版本"读取,不需要重建历史;冗余列代价为单次保存多写一份纯文本(已经按版本分行存储)
- 不动现有版本重建路径,纯增量字段,迁移可加默认空值后续异步回填
- 编辑器手动检测可直接用前端传入的纯文本(不读 DB);发布闸用快照列;两条路径都不走 diff 重建
**Not chosen**
- 在发布闸里调 `LoadArticleVersionContent` + 临时缓存:长尾文章首次发布仍要扫历史版本
- 完全前端传 plaintext:发布闸接口契约会膨胀,且 server 必须用 server 视角的内容(防客户端伪造)
- 在 Redis 缓存重建结果:缓存 miss 时延迟尖刺无法控制;冗余列更朴素
- 新建 `article_version_plaintexts` 子表:隔离性更好,但每次版本写入多一条跨表写路径;当前 `article_versions` 已按版本分行,列冗余足够
**实现要点:**
- 迁移:`article_versions``plaintext_snapshot TEXT NULL`,索引不需要
- 写入侧:`ArticleService.SaveVersion()` 在写 html/markdown 同时调 `text_extractor.Extract()` 写入纯文本快照(与 §6.7 引擎共用提取器)
- 历史回填:迁移完成后跑一次性维护命令 `server/cmd/maintenance/backfill_article_plaintext`,按租户 + `article_versions.id` 分批回填;上线前优先跑完活跃租户,未回填文章仅首次发布走 fallback
- 读取侧:`compliance_repo.GetArticleVersionPlaintext(ctx, versionID)` 单行 SQL;找不到(旧数据未回填)时降级到 `LoadArticleVersionContent` 并发告警
### 4.8 文章纯文本检测范围
**Chosen** 标题、正文纯文本、摘要、SEO 描述、首图 alt 文本。
**Not chosen** 图片二进制、外链落地页、视频字幕(占位非目标,二期再说)。
## 5. 用户面体验
### 5.1 编辑器
- 进入编辑页 `ArticleEditorView` 时调一次 `GET /api/tenant/compliance/runtime-status`
- 返回 `enabled = false` → 按钮整组**隐藏**(不是置灰,避免空跑误导)
- 返回 `enabled = true``EditorActionMenu` 增加「合规检测」按钮,点击后:
- 抽屉式结果面板,违规项按等级(block / high / medium / info)分组
- 每条违规支持「定位到原文」「加入白名单」「忽略」
- 文档中违规处加 mark 高亮,按 `dom_path` 注入
- 面板顶部显示运行时摘要:当前模式 + LLM 是否参与 + 词库版本
### 5.2 发布弹窗
- 点击发布按钮 → 同步调一次 `POST /api/tenant/articles/:id/compliance/check`
- `GatePass`:直接进入正常发布流程
- `GateNeedsAck`:弹窗替换为合规警示视图,列出**全部**非 block violations;用户必须勾选"已了解风险,仍要发布"才能继续,调用 `POST /api/tenant/compliance/check-records/:id/ack` 拿到一次性 `ack_record_id`,提交发布请求时携带(详见 §6.3 / §7)
- `GateBlock`:禁用发布按钮,给出修改指引;不允许 ack 强发
### 5.3 ops 控制台
新增侧栏分组「内容安全」(与现有「审计日志」并列),路由:
- `/ops/compliance/policy` — 全局策略:模式开关 + 启用词库勾选 + LLM 开关
- `/ops/compliance/dictionaries` — 词库列表(版本 / 启用 / 导入)
- `/ops/compliance/dictionaries/:id/edit` — 词条 CRUD + CSV 导入 + 版本发布 / 回滚
- `/ops/compliance/tenant-overrides` — 租户级覆盖
- `/ops/compliance/records` — 全局检测记录搜索
- `/ops/compliance/stats` — 命中分布、拦截率、误报率(按 ack 数据反推)
## 6. 设计
### 6.1 模块划分
```
server/internal/
├── ops/compliance/ # ops 平台面
│ ├── transport/{dictionary,policy}_handler.go
│ ├── app/{dictionary,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 # RE2 预编译 + 有界 worker pool + 总预算
│ │ │ ├── llm_judge.go # 语义兜底
│ │ │ └── text_extractor.go # 富文本 → 纯文本 + 偏移映射
│ │ ├── policy_resolver.go # 全局 vs 租户覆盖
│ │ └── snapshot_loader.go # 30s 版本轮询
│ ├── repository/
│ └── domain/violation.go
└── shared/compliance/types.go # 跨边界 DTO + GateDecision 枚举
```
### 6.2 数据模型
> 迁移分布:ops 表 → `server/migrations_ops/` 且 **必须显式 `ops.` schema 前缀**(与现有 `ops.operator_accounts` 保持一致,详见 [`server/migrations_ops/20260428100000_create_ops_schema.up.sql`](../../../server/migrations_ops/20260428100000_create_ops_schema.up.sql));tenant 表 → `server/migrations/`。两侧独立 `schema_migrations` 表。
**ops 侧(schema = `ops`**
- `ops.compliance_dictionaries``id, code(uniq), name, category, default_level, enabled, version, description, ts`
- `ops.compliance_dictionary_terms``id, dictionary_id(fk), match_type(exact|regex|semantic_keyword), pattern, level_override, hint, reference_law, enabled, ts``(dictionary_id) WHERE enabled = TRUE` 部分索引
- `ops.compliance_dictionary_versions``id, dictionary_id(fk), version, snapshot(jsonb), published_by, published_at` — 用于回滚
- `ops.compliance_policies``id, scope(global|tenant_override), tenant_id(nullable), enforcement_mode(mandatory|advisory|disabled), enabled_dictionaries(jsonb), llm_judge_enabled, llm_categories(jsonb), revision, updated_by, ts`
**`ops.compliance_policies` 唯一性约束(PG NULL 不视相等,必须 partial unique + CHECK):**
```sql
-- 全局策略至多一条
CREATE UNIQUE INDEX uk_compliance_policy_global
ON ops.compliance_policies (scope)
WHERE scope = 'global';
-- 每租户至多一条覆盖
CREATE UNIQUE INDEX uk_compliance_policy_tenant
ON ops.compliance_policies (tenant_id)
WHERE scope = 'tenant_override';
-- 数据一致性兜底:scope=global 时 tenant_id 必须为空,反之必须非空
ALTER TABLE ops.compliance_policies
ADD CONSTRAINT ck_compliance_policy_scope CHECK (
(scope = 'global' AND tenant_id IS NULL) OR
(scope = 'tenant_override' AND tenant_id IS NOT NULL)
);
```
`revision` 由 ops service 在策略更新事务内 `revision = revision + 1`,不依赖数据库 trigger。`policy_fingerprint = sha256(scope + tenant_id + revision + enabled_dictionaries + llm_judge_enabled + dict_versions)`,写入每条 `compliance_check_records``compliance_ack_records`,用于 ack 防旧策略复用。
**tenant 侧**
- `compliance_tenant_terms``id, tenant_id, kind(whitelist|blacklist), pattern, match_type, level(blacklist 用), hint, ts`
- `compliance_check_records``id, tenant_id, article_id, article_version_id, content_hash, policy_fingerprint, trigger_source(editor_manual|publish_gate|auto_draft|api), enforcement_mode, highest_level, passed, hit_count, stored_hit_count, truncated, dict_versions(jsonb), llm_used, duration_ms, checked_by, checked_at``(tenant_id, article_id, checked_at DESC)` 索引
- `compliance_violations``id, record_id(fk), tenant_id, source(dict|regex|llm|tenant_blacklist), dictionary_code, term_pattern, matched_text, start_offset, end_offset, dom_path, level, hint, reference_law, acknowledged_in_ack_id(fk to compliance_ack_records, NULL = 未归入任何 ack)`
- `compliance_ack_records`**ack 粒度落到 check_record 级别**,单次有效 + TTL;不在 violation 表上做(详见 §6.9
- `compliance_review_jobs`:发布闸 LLM 事后复核任务状态表;RabbitMQ 负责唤醒 worker,DB 负责幂等、重试、恢复兜底
```sql
CREATE TABLE compliance_ack_records (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
article_id BIGINT NOT NULL,
article_version_id BIGINT NOT NULL,
check_record_id BIGINT NOT NULL REFERENCES compliance_check_records(id) ON DELETE CASCADE,
content_hash CHAR(64) NOT NULL,
policy_fingerprint VARCHAR(64) NOT NULL,
acknowledged_by BIGINT NOT NULL,
ack_violation_ids BIGINT[] NOT NULL, -- 用户确认放过的全部非 block violation id(快照)
expires_at TIMESTAMPTZ NOT NULL, -- 默认 createdAt + 24h
consumed_at TIMESTAMPTZ, -- NULL = 尚未消费;CAS 写入后变 NOT NULL
consumed_by_job_id UUID, -- 哪条 publish_job 消费的(desktop_id
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_ack_lookup
ON compliance_ack_records (tenant_id, article_id, article_version_id, check_record_id)
WHERE consumed_at IS NULL;
```
`UPDATE` CAS 消费(`ConsumeAckTx` 实现,详见 §6.3):
```sql
UPDATE compliance_ack_records
SET consumed_at = NOW(), consumed_by_job_id = $job_id
WHERE id = $ack_record_id
AND tenant_id = $tenant_id
AND article_id = $article_id
AND article_version_id = $article_version_id
AND check_record_id = $check_record_id
AND content_hash = $content_hash
AND policy_fingerprint = $policy_fingerprint
AND consumed_at IS NULL
AND expires_at > NOW()
RETURNING id;
```
只有 `RETURNING` 命中行才视为消费成功。前端拿到 `41003 ack_mismatch` 时必须重做检测 + 重新 ack,不能复用旧 `ack_record_id`
**`compliance_review_jobs`(LLM 事后复核,DB 状态表 + RabbitMQ 触发):**
```sql
CREATE TABLE compliance_review_jobs (
id BIGSERIAL PRIMARY KEY,
message_id UUID NOT NULL,
tenant_id BIGINT NOT NULL,
article_id BIGINT NOT NULL,
article_version_id BIGINT NOT NULL,
check_record_id BIGINT NOT NULL REFERENCES compliance_check_records(id) ON DELETE CASCADE,
status VARCHAR(16) NOT NULL DEFAULT 'pending', -- pending | running | done | failed
attempts INT NOT NULL DEFAULT 0,
available_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
locked_at TIMESTAMPTZ,
locked_by TEXT,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(check_record_id),
UNIQUE(message_id)
);
CREATE INDEX idx_compliance_review_jobs_pending
ON compliance_review_jobs (available_at, id)
WHERE status = 'pending';
```
RabbitMQ 消息只带轻量定位信息,不带正文:
```json
{
"message_id": "uuid",
"job_id": 123,
"tenant_id": 1,
"check_record_id": 456
}
```
RabbitMQ 路由:
- exchange:复用现有 direct exchange 配置能力,新增 routing key `compliance.review`
- queue`compliance.review`durablemanual ackprefetch 默认 10
- DLQ:沿用现有 RabbitMQ DLX/DLQ 约定;业务重试仍以 `compliance_review_jobs.available_at` 为准,DLQ 只用于排查不可解析消息
worker 消费消息后先 CAS claim DB 记录,claim 成功才调用 LLM
```sql
UPDATE compliance_review_jobs
SET status = 'running',
locked_at = NOW(),
locked_by = $worker_id,
attempts = attempts + 1,
updated_at = NOW()
WHERE id = $job_id
AND message_id = $message_id
AND status = 'pending'
AND available_at <= NOW()
RETURNING id, tenant_id, article_id, article_version_id, check_record_id, attempts;
```
`RETURNING` 为空说明消息重复、过期或已被其他 worker 处理,直接 ack RabbitMQ 消息即可。RabbitMQ publish 失败时,DB row 仍是 `pending`;恢复任务每分钟小批量扫描并补发消息:
```sql
SELECT id
FROM compliance_review_jobs
WHERE status = 'pending'
AND available_at <= NOW()
ORDER BY available_at, id
LIMIT $batch_size
FOR UPDATE SKIP LOCKED;
```
复核任务最多重试 3 次,指数退避 1m / 5m / 30m;失败后更新 `available_at` 并重新 publish RabbitMQ。最终失败只影响 LLM 兜底,不回滚已创建的发布任务。
**`compliance_violations.matched_text` 隐私强制截断(写入层 + schema 兜底):**
```sql
ALTER TABLE compliance_violations
ADD CONSTRAINT ck_violations_matched_text_len CHECK (char_length(matched_text) <= 80);
```
写入侧(`engine.go`)必须在写库前主动截断到 80 字符,schema CHECK 是最后一道兜底——若有人绕过 service 直插 DB 也会被拦。80 字符已覆盖一般违规上下文,且不存全文。
**`article_versions.plaintext_snapshot` 新增列(详见 §4.7):**
```sql
ALTER TABLE article_versions
ADD COLUMN plaintext_snapshot TEXT NULL;
COMMENT ON COLUMN article_versions.plaintext_snapshot IS
'Plaintext extracted at save time, used by compliance gate to skip diff reconstruction.';
```
历史回填脚本(`server/cmd/maintenance/backfill_article_plaintext`):分租户迭代 `article_versions`,对每个版本调一次 `LoadArticleVersionContent` 重建 + `text_extractor.Extract()` 写入 `plaintext_snapshot`,幂等可重跑。
完整 SQL(含 `up` / `down` / 索引)在 implementation 阶段一次性落迁移;本 spec 已固定表结构边界。
### 6.3 调用契约
```go
// shared/compliance/types.go: GateDecision 与跨端 DTO
type GateDecision int
const (
GatePass GateDecision = iota
GateBlock // 强制 + block 等级
GateNeedsAck // 自愿模式 或 强制模式 high/medium 等级
)
// tenant/compliance/app/service.go: 发布闸请求、ack 消费参数与服务接口留在 app 层,可依赖 pgx.Tx / uuid
type GateForPublishRequest struct {
TenantID int64
ArticleID int64
ArticleVersionID int64
ActorID int64
AckRecordID *int64
}
type ConsumeAckQuery struct {
TenantID int64
ArticleID int64
ArticleVersionID int64
CheckRecordID int64
ContentHash string
PolicyFingerprint string
ConsumedByJobID uuid.UUID
}
type ComplianceService interface {
Check(ctx context.Context, req CheckRequest) (*CheckResult, error)
// GateForPublish: 调用方必须先 ResolvePublishableVersion;传入 ack_record_id 时优先校验原 check_record,不新建第二条 record
GateForPublish(ctx context.Context, req GateForPublishRequest) (GateDecision, *CheckRecord, error)
// CreateAck: 用户确认非 block 风险后调用,返回一次性 ack record;粒度是 check_record(不是 violation
CreateAck(ctx context.Context, tenantID, checkRecordID, actor int64) (*AckRecord, error)
// ConsumeAckTx: 发布事务内 CAS 消费 ack;只有六元一致 + 未消费 + 未过期 才成功
ConsumeAckTx(ctx context.Context, tx pgx.Tx, ackRecordID int64, q ConsumeAckQuery) (bool, error)
RuntimeStatus(ctx context.Context) (*RuntimeStatus, error)
}
```
`GateForPublish` 规则:
- `AckRecordID == nil`:读取当前 plaintext snapshot,运行词典 + 正则,写一条新的 `publish_gate` check_record,并按策略返回 `GatePass / GateBlock / GateNeedsAck`
- `AckRecordID != nil`:先读取 ack 绑定的原 check_record;校验 tenant/article/version、`content_hash``policy_fingerprint`、未过期、未消费、无 block violation、ack 覆盖全部非 block violation;校验通过直接返回 `GatePass` 和原 check_record**不再新建第二条 check_record**
- ack 校验失败统一返回 `41003 compliance_ack_mismatch`;前端必须重新检测 + 重新 ack
- 即使 ack 校验通过,真正的 `consumed_at` 更新也必须等发布 job 创建后,在同一个 DB 事务里调用 `ConsumeAckTx`
**`CreatePublishJobRequest` 字段扩展(向后兼容):**
当前 [`server/internal/tenant/app/publish_job_service.go:54`](../../../server/internal/tenant/app/publish_job_service.go) 只有 `Title / ContentRef / Accounts / ScheduledAt`,新增两个**可选**字段:
```go
type CreatePublishJobRequest struct {
Title string `json:"title" binding:"required"`
ContentRef map[string]any `json:"content_ref" binding:"required"`
Accounts []CreatePublishJobAccountRequest `json:"accounts" binding:"required,min=1"`
ScheduledAt *time.Time `json:"scheduled_at"`
ArticleVersionID *int64 `json:"article_version_id,omitempty"` // 新增
AckRecordID *int64 `json:"ack_record_id,omitempty"` // 新增
}
```
兼容性约束:
- 旧 client 不传 `article_version_id`:服务端走 `articles.ResolvePublishableVersion``articles.current_version_id` 兜底(详见下方 §6.3 入口代码);不破坏既有行为,但**命中 NeedsAck 时**仍会因为缺少 ack 被拦
- 旧 client 不传 `ack_record_id`:在 advisory / mandatory NeedsAck 场景下无法放行;提示前端必须升级以解锁 ack 流程
**Wire format(前后端命名一致性):** shared-types 现有请求字段全部是 snake_case(如 `account_id` / `content_ref` / `scheduled_at`,参见 [`packages/shared-types/src/index.ts:319`](../../../packages/shared-types/src/index.ts)),本模块**沿用 snake_case**TS 字段名直接等于 JSON 字段名,不在 API client 做 camelCase 转换:
```typescript
// packages/shared-types/src/index.ts
export interface CreatePublishJobRequest {
title: string;
content_ref: Record<string, JsonValue>;
accounts: CreatePublishJobAccountRequest[];
scheduled_at?: string | null;
article_version_id?: number; // 新增
ack_record_id?: number; // 新增
}
```
admin-web 调用点([`apps/admin-web/src/components/PublishArticleModal.vue:280`](../../../apps/admin-web/src/components/PublishArticleModal.vue))升级时统一以 snake_case 字段构造请求体。
**`ArticleDetail` 必须暴露 `current_version_id`**
当前 TS `ArticleDetail`[`packages/shared-types/src/index.ts:577`](../../../packages/shared-types/src/index.ts))只有 `version_no: number | null`,没有 `current_version_id`;后端 [`article_service.go:335`](../../../server/internal/tenant/app/article_service.go) 内部用了但没透出。
**Chosen**`ArticleDetail` 增加 `current_version_id: number | null`,与 DB 列 `articles.current_version_id` 一一对应;前端在发布时把这个 id 作为 `article_version_id` 传回。
**Why** 让前端拥有当前版本身份是更稳定的契约——ack 重发也用同一个 id;不需要在前端二次解析 `version_no` → id。
**Not chosen**
- "永远由服务端解析当前版本,前端不传":发布闸 → 检测 → ack → 重发 publish 这条链路里,ack 必须绑定到具体 `article_version_id`,前端从一开始就拿 id 才好对账。退化方案是:前端不传,服务端兜底;即下方 §6.3 入口 `ResolvePublishableVersion` 的实际行为。两条路径并存:前端**应**传,未传则服务端解析。
**`PublishJobService.Create()` 入口:**
```go
// 1. 解析 effectiveVersionID — 永远由服务端裁决最终值
// - 请求传入:校验属于该 (tenantID, articleID);不归属直接 41005 invalid_article_version
// - 未传入:读 articles.current_version_id;为 NULL 拒绝(文章无可发布版本)
effectiveVersionID, err := s.articles.ResolvePublishableVersion(ctx, actor.TenantID, articleID, req.ArticleVersionID)
if err != nil { return err }
// 2. 合规闸:无 ack 时新建 record;有 ack 时校验原 record,不制造二次 record/ack mismatch
decision, record, err := s.complianceSvc.GateForPublish(ctx, GateForPublishRequest{
TenantID: actor.TenantID,
ArticleID: articleID,
ArticleVersionID: effectiveVersionID,
ActorID: actor.UserID,
AckRecordID: req.AckRecordID,
})
if err != nil { /* 按 §4.6 / §6.5.1 fail-open / fail-closed 由 service 内部处理,外层只看 decision */ }
switch decision {
case GateBlock:
return ErrComplianceBlocked.WithDetails(record)
case GateNeedsAck:
return ErrComplianceNeedsAck.WithDetails(record)
}
jobID := uuid.New()
tx, err := s.pool.Begin(ctx)
if err != nil { return err }
defer tx.Rollback(ctx)
// 3. 先创建 publish job / tasks;若请求带 ack,则在同一事务内 CAS 消费 ack
job, err := createPublishJobTx(ctx, tx, jobID, ...)
if err != nil { return err }
if req.AckRecordID != nil {
consumed, err := s.complianceSvc.ConsumeAckTx(ctx, tx, *req.AckRecordID, ConsumeAckQuery{
TenantID: actor.TenantID,
ArticleID: articleID,
ArticleVersionID: effectiveVersionID,
CheckRecordID: record.ID,
ContentHash: record.ContentHash,
PolicyFingerprint: record.PolicyFingerprint,
ConsumedByJobID: jobID,
})
if err != nil { return err }
if !consumed { return ErrComplianceAckMismatch }
}
if err := tx.Commit(ctx); err != nil { return err }
return job, nil
```
`ConsumeAckTx` 校验六元一致 + 一次性 + TTL(详见 §6.2 `compliance_ack_records` 表 + §6.9 ack lifecycle),用单条 `UPDATE ... RETURNING id` 完成 CAS。返回 `consumed=true` 仅当 `RETURNING` 命中行。
### 6.4 配置 kill-switch
> **注意:** 现有配置体系([`server/internal/shared/config/config.go:386`](../../../server/internal/shared/config/config.go) 的 `applyEnvOverrides`)是手写的 env 映射,**不解析 struct tag**。若仅在 struct 上写 `env:"..."` tag,运行时不会生效。本节给出的是对现有体系**两处都改**的落地方案。
新增 struct(与 `cfg.LLM` 同级),yaml 反序列化使用 `mapstructure` tag
```go
// server/internal/shared/config/config.go
type ComplianceConfig struct {
Enabled bool `mapstructure:"enabled"`
LLMJudgeEnabled bool `mapstructure:"llm_judge_enabled"`
SnapshotReloadInterval time.Duration `mapstructure:"snapshot_reload_interval"`
PublishGateTimeout time.Duration `mapstructure:"publish_gate_timeout"`
}
```
YAML 默认值(`config.yaml`):
```yaml
compliance:
enabled: true
llm_judge_enabled: false
snapshot_reload_interval: 30s
publish_gate_timeout: 800ms
```
env override 必须在 `applyEnvOverrides` 显式补四行(与 `JWT_SECRET / LLM_API_KEY` 同款):
```go
// server/internal/shared/config/config.go applyEnvOverrides()
if v, ok := lookupBoolEnv("COMPLIANCE_ENABLED"); ok {
cfg.Compliance.Enabled = v
}
if v, ok := lookupBoolEnv("COMPLIANCE_LLM_ENABLED"); ok {
cfg.Compliance.LLMJudgeEnabled = v
}
if d, ok := lookupDurationEnv("COMPLIANCE_SNAPSHOT_RELOAD_INTERVAL"); ok {
cfg.Compliance.SnapshotReloadInterval = d
}
if d, ok := lookupDurationEnv("COMPLIANCE_PUBLISH_GATE_TIMEOUT"); ok {
cfg.Compliance.PublishGateTimeout = d
}
```
`lookupBoolEnv` 若不存在需要新增(参照 `lookupDurationEnv` / `lookupNonEmptyEnv` 同位置)。`normalize` 阶段对零值兜底:`Enabled` 默认 true、`SnapshotReloadInterval` 默认 30s、`PublishGateTimeout` 默认 800ms。
短路写在 `compliance_service.go` 门面入口,**不在每个 handler 重复**
```go
func (s *ComplianceService) Check(ctx context.Context, req CheckRequest) (*CheckResult, error) {
if !s.cfg.Compliance.Enabled {
s.metrics.SkippedTotal.WithLabelValues("config_disabled").Inc()
return &CheckResult{Decision: GatePass, Skipped: true, Reason: "compliance_disabled_by_config"}, nil
}
// ...
}
func (s *ComplianceService) GateForPublish(ctx context.Context, req GateForPublishRequest) (GateDecision, *CheckRecord, error) {
if !s.cfg.Compliance.Enabled {
s.metrics.SkippedTotal.WithLabelValues("config_disabled").Inc()
return GatePass, nil, nil
}
// ...
}
```
层级覆盖关系:
```
config.compliance.enabled = false
└─ ops.compliance_policies(scope=global).enforcement_mode = disabled
└─ ops.compliance_policies(scope=tenant_override).enforcement_mode = disabled
```
### 6.5 检测引擎流程
两条入口,共享同一个引擎,但**纯文本来源**和**LLM 时机**不同(见 §4.3、§4.7):
```
ComplianceService.Check(编辑器手动 / 纯文本 API)
├─ 0. KillSwitch 短路(cfg.Enabled / policy.enforcement_mode
├─ 1. PolicyResolver: 当前租户生效策略 + 白名单(LRU 60s)
├─ 2. 纯文本来源 = 请求体直传(前端编辑器实时内容)
├─ 3. DictMatcher: AC 自动机一次性扫 exact 词条
├─ 4. RegexMatcher: 预编译 RE2 + 有界 worker pool + 总预算(详见 §6.10
├─ 5. WhitelistFilter: 剔除租户白名单覆盖项
├─ 6. LLMJudge(同步,500ms 超时降级): 仅对开启的 categories 跑
└─ 7. 聚合 → 写 compliance_check_records + violations → 返回完整 DTO
ComplianceService.GateForPublish(发布闸)
├─ 0. KillSwitch 短路
├─ 1. PolicyResolver
├─ 2. 纯文本来源 = article_versions.plaintext_snapshot 单行 SQL(§4.7
│ └─ 命中空(旧数据未回填)→ 降级 LoadArticleVersionContent + metric 告警
├─ 3. DictMatcher
├─ 4. RegexMatcher(发布闸预算更短,详见 §6.10)
├─ 5. WhitelistFilter
├─ 6. 聚合(仅词典 + 正则)→ 立即返回 GateDecision
└─ 7. 后台异步派发 LLMJudge 到复核队列(不阻塞 caller)
└─ 命中 → 写"待复核"violation + 通知运营(不影响本次发布)
```
关键差异:
- 编辑器场景用户在等结果,LLM 同步可接受;发布闸预算 30~50ms,LLM 必须异步
- `compliance_check_records.llm_used` 在发布闸场景写 false(异步结果另起一条记录)
- `content_hash = sha256(normalized_title + "\n" + normalized_plaintext + "\n" + summary/seo/alt)`;归一化只做换行和首尾空白处理,不做同义改写,确保 ack 与版本内容一一对应
- `policy_fingerprint` 由当前 policy revision + enabled dictionaries + dict versions 计算;策略或词库发布后旧 ack 自动失效
### 6.5.1 引擎不可用时的失败语义实现
承接 §4.6 总原则:**mandatory fail-closed / advisory fail-open**。
关键修订(v1 review 暴露的问题):当 `PolicyResolver.Resolve` 失败时,无法判断当前租户是 mandatory 还是 advisory**不能直接 fail-open**——否则强制租户在 ops DB 抖动期被静默放行,破坏监管底线。
修复方式:
1. **PolicyResolver 维护进程级 last-known cache**(最长 24h 有效,附时间戳),所有租户最近一次成功解析的策略缓存于内存
2. **首选用 last-known 策略**继续走引擎,标记 metric `compliance_policy_stale_total`
3. **last-known 不可用(冷启动尚未拉到任何策略)** → 视作 conservative mandatory,返回 `GateBlock`,附 reason`config.Compliance.Enabled = false` 仍可一键止血
```go
func (s *ComplianceService) GateForPublish(ctx context.Context, req GateForPublishRequest) (GateDecision, *CheckRecord, error) {
if !s.cfg.Compliance.Enabled {
s.metrics.SkippedTotal.WithLabelValues("config_disabled").Inc()
return GatePass, nil, nil
}
if req.AckRecordID != nil {
return s.validateAckForPublish(ctx, req)
}
// (a) 解析策略:失败优先用 last-known cache;都没有则 conservative mandatory
policy, ok, err := s.resolver.Resolve(ctx, req.TenantID)
if err != nil || !ok {
if cached, has := s.resolver.LastKnown(req.TenantID); has {
s.metrics.PolicyStaleTotal.WithLabelValues("last_known_used").Inc()
policy = cached
} else {
s.metrics.EngineFailuresTotal.WithLabelValues("policy_load_failed", "no_cache").Inc()
return GateBlock, &CheckRecord{Skipped: true, Reason: "policy_load_failed_no_cache"}, nil
}
}
// (b) 解析后才能基于 policy.Mode 决定后续失败语义
plaintext, err := s.repo.GetArticleVersionPlaintext(ctx, req.ArticleVersionID)
if err != nil {
return s.failByMode(policy.Mode, "plaintext_load_failed"), nil, nil
}
result, err := s.engine.RunSync(ctx, plaintext, policy)
if err != nil {
return s.failByMode(policy.Mode, "engine_run_failed"), nil, nil
}
// 写 check_records + violations,返回 GateDecision
// ...
}
func (s *ComplianceService) failByMode(mode, reason string) GateDecision {
s.metrics.EngineFailuresTotal.WithLabelValues(reason, mode).Inc()
if mode == "mandatory" { return GateBlock }
return GatePass
}
```
`PolicyResolver` 实现要点:
- `Resolve` 成功后立即把 `(tenantID → policy)` 写入 `sync.Map`-style cache,附时间戳
- `LastKnown(tenantID)` 返回 24h 内缓存;超过 24h 视为无缓存,回到 conservative mandatory;可由 ops 策略更新后的轮询刷新覆盖
- 后台 reloader 每 60s 主动刷一次全量策略,让 cache 不至于无限陈旧
- 冷启动期:服务启动时**先**全量加载所有租户策略,加载完成才开服;启动失败 fail-fast(部署侧兜底)
错误码 41004 仅用于 `policy_load_failed_no_cache` 这种"既无在线策略又无缓存"的极端情况;常规熔断按 fail-closed / fail-open 静默处理。
### 6.6 词库快照与热更新
`snapshot_loader.go`
- 启动时全量拉取 → 构建快照 → 注册到 `atomic.Pointer[Snapshot]`
- 后台 goroutine 每 `cfg.SnapshotReloadInterval` 执行 `SELECT id, version FROM ops.compliance_dictionaries`
- 比对内存中各词库版本号,发现变更则**只重建变更项**,原子替换
- 失败重试:指数退避 5s → 10s → 30s → 60s(封顶);连续失败 5 次发 metric 告警
- 进程内并发安全:所有读路径都从 `atomic.Pointer.Load()` 拿快照副本
- tenant-api 只读访问 `ops.*`,不经 ops-api HTTP;部署上可给 tenant-api DB 用户只授予 `SELECT` 权限到 `ops.compliance_*`
- ops 端不提供强制跨进程 reload RPC;一期实现为 bump dictionary/policy `revision` + tenant-api 轮询 30s 内生效;确需秒级再上 Redis pub/sub
### 6.7 LLM 兜底与计费
- 复用 [`server/internal/shared/llm/client.go`](../../../server/internal/shared/llm/client.go)
- 计费走 [`server/internal/tenant/app/ai_point_usage.go`](../../../server/internal/tenant/app/ai_point_usage.go),新增计费场景常量 `compliance_llm_judge`
- **编辑器手动检测**:同步 + 500ms 超时降级;超时时返回不含 LLM 结论的部分结果
- **发布闸****永远异步**,不参与 GateDecision`GateForPublish``compliance_review_jobs` 后投递 RabbitMQ `compliance.review` 消息,不等待 worker;命中时写"待复核"violation,同时给运营发通知;本次发布**不被阻断**
- **mandatory + llm_judge_enabled = true**:行为同上,LLM 仍异步;强制模式的硬阻断只来自词典 / 正则。理由见 §4.3
- 计费失败时:LLM 调用直接降级跳过,不阻塞主流程;记录 metric `compliance_llm_billing_failures_total`
- AI 点数余量为 0 时:LLM 调用跳过,不影响词典 / 正则结果;编辑器面板显示"LLM 检测因点数耗尽未参与"
- `server/cmd/scheduler` 启动 RabbitMQ consumer 处理 `compliance.review`DB claim 保证幂等,恢复 ticker 扫描 `pending/running_timeout` 任务并补发消息
- RabbitMQ 不可用时,`GateForPublish` 只记录 `compliance_review_jobs` pending + metric,不阻断发布;恢复 ticker 在 RabbitMQ 恢复后补发
### 6.8 极限词库种子
通过迁移(`server/migrations_ops/<ts>_seed_compliance_dictionaries.up.sql`)注入:
- 《广告法》极限词约 200 条("国家级"、"最高级"、"最佳"等)— `default_level = block`
- 政治敏感词(公开版) — `default_level = block`
- 涉黄涉暴通用词 — `default_level = block`
- 通用违禁(赌博 / 毒品 / 黑产) — `default_level = high`
后续运营 CSV 扩展,不依赖迁移。
### 6.9 Ack 生命周期(粒度 / 防重放 / 失效)
**粒度:** ack 落在 `compliance_check_record` 级别,**不在 violation 级别**。理由:一次发布闸检测可能产生多条非 block violation;若按 violation 粒度 ack,用户单条 ack 后不该被授权放行整篇文章;按 record 粒度,"用户对该次检测的全部非 block 风险都已知悉"语义更清晰,且实现更简单。
**lifecycle**
1. **生成阶段**:用户在前端发布弹窗看到 `GateNeedsAck` 后调 `POST /api/tenant/compliance/check-records/:id/ack`,请求体携带 `acknowledged_violation_ids`(必须等于该 check_record 下全部非 block violation id 集合,缺一项即 400;若存在 block violation 则 409);服务端把原 record 的 `content_hash``policy_fingerprint` 复制进 `compliance_ack_records``expires_at = NOW() + 24h`,返回 `ack_record_id`
2. **发布前校验阶段**:前端把 `ack_record_id` 与发布请求一起重发,`GateForPublish` 读取原 check_record 校验六元一致;通过后直接返回 `GatePass`,避免重新检测产生新 record 导致 ack mismatch
3. **消费阶段**:发布 job / tasks 写入成功后,`PublishJobService` 在同一 DB 事务内调用 `ConsumeAckTx` 单条 SQL CAS 消费(见 §6.2 / §6.3
4. **失效条件(任一即失败)**:六元(tenant/article/version/check_record/content_hash/policy_fingerprint)任一不匹配;已 consumed;超过 expires_at;原 check_record 被删除;超时后清理任务每天扫一次将 `expires_at < NOW() - 7d` 的硬删除
**防重放:**
- `consumed_at` 字段从 NULL → NOT NULL 由 CAS UPDATE 完成;并发情况下只有第一个事务能 RETURNING 命中
- consumed 后即使 `expires_at > NOW()` 也不能再用
- 前端拿到 41003 必须从头重新检测 + 重新 ack(不能简单重试同一 ack_record_id
**与 violations 的关联:**
- `compliance_violations.acknowledged_in_ack_id` 默认 NULLack 创建时把 `ack_violation_ids[]` 中的每条 violation 该列写为 ack record id(事务内)
- 用于审计与误报率统计:`SELECT COUNT(*) FROM compliance_violations WHERE acknowledged_in_ack_id IS NOT NULL` 即"被 ack 放过"的总量
### 6.10 高可用与高并发约束
**发布闸热路径:**
- 必须只做:解析策略(进程内 last-known cache 优先)、读取 `article_versions.plaintext_snapshot` 一行、内存 AC/regex 扫描、写 `check_records + violations`;不调 LLM、不调 ops-api HTTP、不发通知、不等待复核 worker
- `cfg.Compliance.PublishGateTimeout` 默认 800ms 是硬上限;正常目标是 P95 < 30ms、P99 < 50ms。超过硬上限按 §4.6 fail-open / fail-closed
- `GateForPublish` 捕获 panic 并转成 `engine_run_failed`,避免单条异常规则打崩 tenant-api 进程
**匹配与写入上限:**
- AC exact 词条不设业务上限,但 snapshot 构建失败不得替换旧快照;旧快照继续服务并打 `snapshot_stale` metric
- regex 使用 Go RE2 预编译;发布闸不允许每条 regex 一个 goroutine,必须走有界 worker pool(默认 `min(runtime.NumCPU(), 4)`
- regex 总预算:发布闸 20ms,编辑器手动 150ms;预算耗尽后停止剩余 regex,记录 `regex_budget_exhausted`mandatory 下不因此直接阻断
- 单次检测最多落库 200 条 violation 明细;`hit_count` 保存真实命中总数,`stored_hit_count` 保存实际落库数,`truncated=true` 表示明细被截断
- `matched_text` 写入前截断到 80 字符,全文只存 `content_hash`,不存正文快照
**并发与幂等:**
- 发布闸不加分布式锁;同一文章并发发布会各自检测,各自写独立 check_record。真正需要全局互斥的是 ack 消费,使用 `UPDATE ... WHERE consumed_at IS NULL ... RETURNING` CAS
- ack 消费必须在 publish job 创建事务内执行;事务失败则 ack 不会被消费,避免"发布失败但 ack 丢失"
- LLM 复核任务写入 `compliance_review_jobs` 并投递 RabbitMQ 后即可返回;worker 多实例消费同一队列,最终由 DB CAS claim 保证不重复处理同一任务
- snapshot reload 构建新对象成功后再 `atomic.Pointer.Store()`;构建失败保留旧对象,避免 reload 期间读路径空指针
**索引与保留策略:**
- 必备索引:`compliance_check_records(tenant_id, article_id, checked_at DESC)``compliance_violations(record_id)``compliance_violations(tenant_id, level, id DESC)``compliance_ack_records(tenant_id, article_id, article_version_id, check_record_id) WHERE consumed_at IS NULL``compliance_review_jobs(available_at, id) WHERE status='pending'``compliance_review_jobs(message_id)`
- 一期不做分区,避免 schema 复杂度;检测记录热数据保留 180 天,每日清理任务按 `id` 小批量删除过期 records/violations/ack/review_jobs
- 词库版本快照保留最近 10 个发布版本;超出部分删除 DB snapshot,不上 OSS。若单词库 snapshot 超过 50MB,再单独评估对象存储
**富文本定位:**
- 编辑器手动检测优先提交前端导出的 `dom_path` / block key 映射;后端只透传,不理解编辑器 AST
- `dom_path` 缺失或失效时,前端退回 plain offset + 当前文本节点搜索;高亮可降级,但发布闸判定不依赖高亮定位
## 7. 对外 API
### 7.1 租户面(tenant-api
> **路径前缀必须是 `/api/tenant/...`**:现有租户路由全部挂在 `tenantProtected := protected.Group("/tenant")` 下([`server/internal/tenant/transport/router.go:67`](../../../server/internal/tenant/transport/router.go)),发布接口是 `/api/tenant/publish-jobs`。本模块路径与现有体系对齐。
>
> **不使用 `:action` 后缀**Gin 把 `:` 当 path parameter 前缀,`compliance:check` / `compliance.POST(":check", ...)` 会被解析成参数路由。统一用 `/check` 普通 segment(与现有 `/api/tenant/publish-jobs` 同款风格)。
| Method | Path | 用途 |
| --- | --- | --- |
| GET | `/api/tenant/compliance/runtime-status` | 运行时状态(kill-switch / 模式 / LLM 开关 / 词库版本) |
| POST | `/api/tenant/articles/:id/compliance/check` | 编辑器一键检测(按文章) |
| POST | `/api/tenant/compliance/check` | 无文章上下文的纯文本检测 |
| GET | `/api/tenant/articles/:id/compliance/latest` | 拿最近一次检测结果 + violations |
| POST | `/api/tenant/compliance/check-records/:id/ack` | **粒度:record**。用户标记"该次检测的全部非 block 风险已知悉",返回 `ack_record_id`(一次性、24h TTL,详见 §6.9 |
| GET / POST / DELETE | `/api/tenant/compliance/tenant-terms` | 租户白 / 黑名单管理 |
发布器拦截**不暴露 HTTP**,是 `PublishJobService` 内部调用 `GateForPublish`
handler 注册方式与 KOL profile 同构(参考 [`router.go:100-122`](../../../server/internal/tenant/transport/router.go#L100)):
```go
compliance := tenantProtected.Group("/compliance")
complianceHandler := NewComplianceHandler(app)
compliance.GET("/runtime-status", complianceHandler.RuntimeStatus)
compliance.POST("/check", complianceHandler.CheckPlain)
compliance.POST("/check-records/:id/ack", complianceHandler.CreateAck)
// 文章范围内的检测挂在 articles 组下
articles := tenantProtected.Group("/articles")
articles.POST("/:id/compliance/check", complianceHandler.CheckArticle)
articles.GET("/:id/compliance/latest", complianceHandler.LatestCheck)
```
### 7.2 ops 面(ops-api,独立部署)
| Method | Path | 用途 |
| --- | --- | --- |
| GET / POST / PUT / DELETE | `/api/ops/compliance/dictionaries` | 词库 CRUD |
| GET / 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` | 命中分布 / 拦截率 / 误报率 |
| GET | `/api/ops/compliance/records` | 全局检测记录搜索 |
## 8. 错误码
沿用现有 `errcode` 风格(4xxxx 段):
| Code | 含义 | HTTP | 说明 |
| --- | --- | --- | --- |
| 41001 | `compliance_blocked` | 409 | 强制模式 + block 等级,禁止发布 |
| 41002 | `compliance_needs_ack` | 409 | 自愿 / 强制 high+ 模式,需用户 ack 后重发 |
| 41003 | `compliance_ack_mismatch` | 400 | ack 校验 / CAS 失败:六元任一不匹配、已 consumed、已过期;前端必须重新检测 + 重新 ack |
| 41004 | `compliance_engine_unavailable` | 503 | 引擎拒绝服务且无 last-known 策略可用(极端情况) |
| 41005 | `invalid_article_version` | 400 | 请求传入的 `article_version_id` 不属于该 `(tenant_id, article_id)` |
## 9. 文件改动清单
### 9.1 Backend
- `server/internal/shared/config/config.go` — 新增 `ComplianceConfig` + `applyEnvOverrides` 补 4 个 env 映射 + 可能补 `lookupBoolEnv`(详见 §6.4
- `server/internal/shared/compliance/types.go` — DTO + `GateDecision` 枚举
- `server/internal/tenant/compliance/**` — 新增整套 domaintransport / app / engine / repository
- `server/internal/ops/compliance/**` — 新增整套 domain
- `server/internal/tenant/app/publish_job_service.go``Create()` 入口接 `GateForPublish``CreatePublishJobRequest``ArticleVersionID` / `AckRecordID` 两个可选字段(详见 §6.3
- `server/internal/tenant/app/article_service.go``SaveVersion()` 写入 `plaintext_snapshot`(详见 §4.7
- `server/internal/tenant/repository/article_version_storage.go` — 新增 `GetArticleVersionPlaintext` 单行 SQL 读取路径
- `server/internal/tenant/transport/router.go``tenantProtected.Group("/compliance")` 注册 handler
- `server/cmd/tenant-api/main.go` — 启动 snapshot loader goroutine
- `server/cmd/ops-api/main.go` — 注册 ops 侧 handler
- `server/cmd/scheduler/main.go` — 启动 `compliance.review` RabbitMQ consumer、`compliance_review_jobs` 恢复 ticker 与清理任务
- `server/cmd/maintenance/backfill_article_plaintext/` — 新增一次性回填脚本
### 9.2 Migrations
- `server/migrations_ops/<ts>_create_compliance_dictionaries.up.sql` — 4 张 ops 表(**SQL 必须用 `ops.` schema 前缀**,详见 §6.2
- `server/migrations_ops/<ts>_seed_compliance_dictionaries.up.sql` — 极限词库种子
- `server/migrations/<ts>_create_compliance_tables.up.sql` — tenant 侧 `tenant_terms / check_records / violations / ack_records / review_jobs`(含 `matched_text` CHECK 与必要索引)
- `server/migrations/<ts>_add_article_versions_plaintext_snapshot.up.sql``article_versions` 加冗余列
### 9.3 Shared types(前后端契约)
- `packages/shared-types/src/index.ts`
- `CreatePublishJobRequest``article_version_id?: number; ack_record_id?: number;`**snake_case,与现有字段保持一致**)
- `ArticleDetail``current_version_id: number | null`(详见 §6.3
- 新增 `ComplianceCheckRequest / ComplianceCheckResult / ComplianceViolation / GateDecisionLiteral / RuntimeStatus / CreateAckRequest / AckRecord` 等类型,全部使用 snake_case 字段名
- `server/internal/tenant/app/article_service.go`:在加载 ArticleDetail 的查询里把 `current_version_id` 映射到返回 DTO(同步改后端 + TS 类型)
### 9.4 Frontend (admin-web)
- `apps/admin-web/src/components/editor-common/EditorComplianceCheck.vue` — 新增按钮 + 抽屉
- `apps/admin-web/src/components/editor-common/EditorActionMenu.vue` — 挂载点
- `apps/admin-web/src/components/PublishArticleModal.vue` — 三种 GateDecision 渲染 + 提交请求携带 `article_version_id / ack_record_id`
- `apps/admin-web/src/lib/api.ts` — 新增 `complianceApi``publishJobsApi.create` 入参类型扩展
### 9.5 Frontend (ops-web)
- `apps/ops-web/src/views/compliance/{CompliancePolicyView,ComplianceDictionariesView,ComplianceDictionaryEditView,ComplianceTenantOverrideView,ComplianceRecordsView,ComplianceStatsView}.vue`
- `apps/ops-web/src/router/index.ts` — 新增 6 条路由
- `apps/ops-web/src/layouts/AppShell.vue` — 侧栏分组「内容安全」
## 10. 测试
### 10.1 后端单测
- `engine`:词典命中、正则命中、LLM 兜底、白名单覆盖、偏移映射准确性、`matched_text` 写入侧截断到 80 字符
- `policy_resolver`global vs tenant_override 优先级、灰度规则一致性(`tenant_id % 100`
- `snapshot_loader`:版本变更检测、原子替换、失败重试退避、并发读
- `compliance_service`
- kill-switch 短路(config + policy 两层)
- `GateForPublish` 三种返回路径
- **mandatory + LLM 启用** 时 `GateForPublish` 仍只看词典 / 正则结果;LLM 任务确实异步派发
- **plaintext snapshot 命中空时** 降级到 `LoadArticleVersionContent` + metric 上涨
- 引擎不可用时 fail-open / fail-closed
- `ConsumeAckTx`:六元一致校验通过 / 不通过 / ack 已 consume 后再用返回 false / 跨租户 ack 拒绝 / 发布事务失败不消费 ack
- `dictionary_service`ops):发布版本 bump version + 写快照、回滚
- `applyEnvOverrides` 测试:4 个 env 变量都能正确覆盖(防 P2-3 复发)
- `compliance_policies` 唯一性测试:插入第二条 global 应失败、插入同 tenant 第二条 tenant_override 应失败、CHECK 约束生效
- `GateForPublish` ack 路径:传入 `ack_record_id` 时复用原 check_record;策略指纹或 content_hash 变化时返回 41003
- `compliance_review_worker`RabbitMQ 重复消息幂等、DB CAS claim、多实例不重复消费、失败退避、最多 3 次重试、publish 失败后恢复 ticker 可补发
### 10.2 集成测
- `PublishJobService` 接 gate 后,强制 / 自愿模式下端到端
- 旧 client 不传 `article_version_id / ack_record_id` 时的回退行为
- ops-api 改策略 → tenant-api 30s 内引擎生效
- LLM 计费失败时降级路径
- 文章保存触发 `plaintext_snapshot` 写入;发布闸读取 O(1)
### 10.3 前端
- `EditorComplianceCheck`:抽屉显示、定位原文、加白名单、忽略
- `PublishArticleModal``GatePass / GateNeedsAck / GateBlock` 三种渲染;提交时携带 `article_version_id / ack_record_id`
- `runtime-status.enabled = false` 时按钮整组消失
- shared-types 类型同步:`CreatePublishJobRequest` 新字段 TS 编译通过
### 10.4 手动验收
1. ops 切到 mandatory + 词库含 block 词 → 编辑器命中 → 发布按钮拦截 ✅
2. ops 切到 advisory → 命中后弹 ack 提示 → 提交可发布;同一 ack 第二次提交被 `ack_mismatch` 拒绝 ✅
3. config kill-switch 关闭 → 编辑器按钮消失 + 发布器跳过检测 + metric `skipped_total{reason="config_disabled"}` 上涨 ✅
4. ops 添加新词条 → 30 秒内编辑器再次检测命中新词 ✅
5. ops 回滚到上一版本 → 30 秒内引擎按旧词库判定 ✅
6. 租户白名单覆盖 → 命中词条不出现在违规列表 ✅
7. 强制模式 + DB 失联(人为断开)→ 发布闸 fail-closed 拦截 ✅
8. 自愿模式 + DB 失联 → 发布闸 fail-open 放行 + metric 告警 ✅
9. mandatory + LLM 启用 + 文章正文含 LLM-only 风险段落 → 发布闸**放行** + 复核队列出现"待复核"violation + 运营收到通知 ✅
10. 长编辑历史的文章发布 → 走 `plaintext_snapshot` 路径,发布闸 P99 < 50ms ✅
## 11. Risks And Mitigations
| Risk | Impact | Mitigation |
| --- | --- | --- |
| TipTap 富文本 dom_path 在分块编辑后漂移 | 前端高亮错位 | 编辑器侧提交 `dom_path` / block key 映射,后端只透传;失效时退回 plain offset + 前端 textContent 搜索 |
| LLM 兜底成本 | AI 点数过快消耗 | 默认 LLM 关闭;开启时按 `tenant_id % 100` 灰度;ops 端可见每租户 LLM 调用频次 |
| 公开词库覆盖度不足(医美 / 教育 / 金融) | 误漏 | 一期接受;上线后基于 ack 数据 + 运营反馈持续补 |
| 30s 轮询延迟 | 词库新增最长 30s 才生效 | 一期接受;ops 发布词库会 bump version/revisiontenant-api 30s 内轮询生效;二期再评估 Redis pub/sub |
| config kill-switch 误触 | 全站合规失效 | 启动日志 WARN;ops 控制台首页显著标注"config 已关闭"红条;metric + Slack 告警 |
| 词库版本快照 JSON 体积(10k 词条 ≈ MB 级) | `versions` 表膨胀 | 只保留最近 10 个发布版本;单词库 snapshot 超过 50MB 时再评估对象存储 |
| ops-api 与 tenant-api 跨进程访问 ops schema | 跨库连接管理复杂 | tenant-api 仅读 ops 策略表,使用只读连接;不写 ops schema |
| RabbitMQ 短暂不可用 | LLM 事后复核延迟 | 发布闸仍写 `compliance_review_jobs` pending,不阻断发布;恢复 ticker 在 RabbitMQ 恢复后补发消息 |
| 强制模式 + 引擎熔断 fail-closed | 发布全面阻断 | 熔断时按 fail-closed 但允许运维通过 config 临时关 `Enabled` 止血;事后再修复引擎 |
| 词条正则表达式恶意 / 性能不佳 | 单条 regex 拖慢发布闸 | Go RE2 预编译 + 有界 worker pool + 总预算;预算耗尽只丢弃剩余 regex 并告警,不无界等待 |
| ack 重发请求被伪造 / 重放 | 自愿模式被绕过 | 独立 `compliance_ack_records` 表 + 六元 `(tenant_id, article_id, article_version_id, check_record_id, content_hash, policy_fingerprint)` CAS UPDATE`consumed_at IS NULL AND expires_at > NOW()` 守门;24h TTLack 粒度落到 check_record 级,必须 ack 全部非 block violations 才能生成 ack record(详见 §6.9 |
| `PolicyResolver` 失败时静默放行 mandatory 租户 | 强制模式监管失守 | 进程级 last-known cache + 60s 主动刷新;冷启动等全量加载完成才开服;`Resolve` 失败且无 cache 时返回 GateBlockconservative mandatory),不 fail-open(详见 §6.5.1 |
| `effectiveVersionID` 由前端传入但被伪造(指向其他文章的版本) | 跨文章 ack 绕过 | `ResolvePublishableVersion` 强制校验 `(tenant_id, article_id, version_id)` 归属;不归属返 41005 invalid_article_version |
| `article_versions.plaintext_snapshot` 历史回填进度慢 | 长尾文章发布闸 fallback 到 diff 重建,吃光 30ms 预算 | 新发布默认走 plaintext snapshotfallback 路径加 metric 告警;回填脚本按租户分批,跑完前提示"个别文章首次发布会慢" |
| `CreatePublishJobRequest` 字段扩展属于契约破坏 | 旧 client 在自愿/强制 NeedsAck 场景下无法 ack 放行 | 新字段定义为可选,旧 client 仍可走 mandatory pass 路径;server 对缺 ack 的 NeedsAck 直接返 41002,前端升级后即可解锁;shared-types 同步发版本 |
| `applyEnvOverrides` 漏改导致 env 不生效 | 容器化部署改 env 不生效,配置漂移 | 加 `config_test.go` 校验 4 个 env 变量都能被读到;启动日志打印 `cfg.Compliance` 摘要供运维确认 |
## 12. Rollout Notes
- **迁移顺序:** ops 表先(含极限词库种子)→ tenant 表后
- **默认配置:** `cfg.Compliance.Enabled = true` + `enforcement_mode = advisory`,避免上线即阻断生产发布
- **观察期:** 上线后第一周观察 metrics(拦截率 / 误报率 / P99 / LLM 调用量),稳定后由产品决定是否切 `mandatory`
- **种子词库回滚:** 若发现误报严重,先在 ops 控制台关闭对应词库 → 不依赖代码回滚
- **观测埋点(必须):** `compliance_check_total{source,level,mode}` / `compliance_duration_ms{phase}` / `compliance_skipped_total{reason}` / `compliance_engine_failures_total{kind}`
## 13. 最终决策清单
本 spec 已收口实现前决策,一期按以下内容落地:
| 主题 | 最终决策 |
| --- | --- |
| 发布请求契约 | 直接扩展现有 `/api/tenant/publish-jobs`,新增可选 `article_version_id / ack_record_id`;不新开 v2 路径 |
| plaintext 存储 | `article_versions.plaintext_snapshot TEXT NULL` 冗余列;不建子表 |
| 历史回填 | 一次性维护命令 `server/cmd/maintenance/backfill_article_plaintext`,按租户和版本 ID 分批;活跃租户上线前优先跑完 |
| ack 实现 | DB 表 + `ConsumeAckTx` CAS;24h TTL;六元一致;不使用内存 LRU |
| dom_path | 前端提交 `dom_path` / block key,后端透传;失效时前端 plain offset 兜底 |
| 词库版本保留 | 每个词库保留最近 10 个发布版本;超出删除 DB snapshot;单 snapshot > 50MB 再评估 OSS |
| tenant-api 访问 ops 数据 | 同库只读查询 `ops.compliance_*`;不通过 ops-api 内部 HTTP |
| LLM 待复核队列 | 复用 RabbitMQ `compliance.review` 队列;`compliance_review_jobs` 做状态表、幂等 claim、重试与恢复兜底 |
| CSV 导入列 | 支持中文 / 英文表头;标准列为 `pattern,match_type,level_override,hint,reference_law,enabled`;不预留 `tenant_visibility` |
| 灰度维度 | 一期只支持 `tenant_id % 100` 百分比灰度;不做按文章量限额 |
| 检测记录保留 | 热数据 180 天,小批量清理;一期不分区 |
## 14. 后续
实施计划后续可落到 `docs/superpowers/plans/2026-04-28-compliance-detection.md`,按本 spec 拆分阶段、列出每阶段交付物与验收点;本 spec 不承担工时与排期。