Files
geo/docs/superpowers/specs/2026-04-28-compliance-detection-design.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

1506 lines
122 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 9, 2026-05-05) — 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 92026-05-05
按产品新口径收口发布器展示边界:
| # | 问题 | 收口位置 |
| --- | --- | --- |
| R9-P0 | 发布器空间有限,展示具体命中词会淹没发布流程,且与文章编辑页合规检测职责重叠 | §5.2 / §6.5 / §9.4:发布器只展示门禁结论;强制模式任意命中直接 GateBlock;仅提示确认模式任意命中 GateNeedsAck;具体命中词统一回文章编辑页合规检测查看;关闭检测时发布器不显示任何敏感词模块 |
| R9-P1 | 开发阶段不需要人工特批逃生口,发布器不应提供「提交运营审阅」入口 | §5.2 / §9.4 / §10.3:发布器移除人工特批入口与审阅状态展示,GateBlock 只引导回编辑页改稿 |
### Revision 82026-05-05
收口人工审阅的高可用、止血优先级、撤回与 API/DTO 一致性。本 revision 覆盖 Revision 7 中"人工审阅戳高于 config kill-switch"与"每 version 全生命周期唯一 review record"两处旧决策。
| # | 问题 | 收口位置 |
| --- | --- | --- |
| R8-P0 | 人工审阅状态是发布热路径早期步骤,但查询失败语义未定义,DB 抖动时可能 panic 或 fail-open | §6.3 / §6.5.1:新增 `ManualReviewResolver`,使用 `article_versions.manual_review_status` 热路径冗余 + 进程内 LRU/terminal cache;查询失败仅可使用 pending / terminal cache,无可信状态时返回 503 / 41004 fail-closed |
| R8-P1 | R7 把人工审阅戳置于 config kill-switch 之上,导致审阅服务误写 rejected 时无部署级止血 | §6.3 / §6.4 / §6.13`cfg.Compliance.Enabled=false` 作为部署级硬关闭,优先于人工审阅;ops master switch / enforcement_mode 仍只关闭自动检测器,不覆盖 pending / rejected |
| R8-P2 | pending 无撤回路径,审阅工作台积压会把作者卡死 | §6.2 / §6.13 / §7.1:人工审阅增加 `cancelled` 状态与 tenant 侧 cancel APIpending 可由作者撤回,撤回后同版本可重新提交;approved / rejected 仍终态不可重提 |
| R8-P3 | `trigger_source` enum、manual review 外键保留策略、scheduler blocked 解锁、CheckResult DTO 与 API 命名存在契约缺口 | §6.2 / §6.12 / §7 / §9.3:补 `scheduler_recheck` / `manual_review_bypass``original_check_record_id ON DELETE SET NULL` 且冗余审计快照;前端发布列表展示 blocked + 重新发布;tenant API 统一 `manual-reviews``CheckResult` 返回 manual review 状态 |
### Revision 72026-05-05
收口人工审阅作为"版本级发布裁决"的优先级与 DB 约束:
| # | 问题 | 收口位置 |
| --- | --- | --- |
| R7-P0 | `config kill-switch` / ops 总开关若先短路 `GatePass`,会把已 rejected 或 pending 的版本误放行;这与"不合格文章不允许发布,也不允许再次提交运营审查"冲突(config kill-switch 不在 config yml 配置,在 ops 运营平台开关) | 旧口径:人工审阅高于所有开关。已被 Revision 8 修正为:`cfg.Compliance.Enabled=false` 是部署级硬关闭;ops master / enforcement 不覆盖人工审阅 |
| R7-P1 | `pending unique + terminal unique` 两条 partial index 不能保证"同一 version 全生命周期至多一条 review",终态后仍可插入 pending | 旧口径:全生命周期唯一。已被 Revision 8 修正为:pending 可撤回为 cancelledactive/terminal partial unique 约束 approved / rejected 不可重提 |
### Revision 62026-05-04
补 mandatory 阻断的人工逃生口:
| # | 问题 | 收口位置 |
| --- | --- | --- |
| R6-P0 | mandatory + block 命中时作者只有"改稿"一条路,遇误报 / 法规边缘 / 客户特批场景无人工 override | 新增 R33 / R34 / R35 / R36:发布弹窗 GateBlock 时增加「提交运营审阅」按钮;ops 新增审阅工作台;审阅戳绑定到 `article_version_id`,**通过 → 该版本永久绕过所有自动检测**(发布闸 + 调度器重检),**驳回 → 该版本永久不可发布且不可再次提交审阅**;新版本(作者改稿后)重置;新增 `compliance_manual_reviews` 表 + tenant/ops 双向 API + UI;优先级以 Revision 8 为准 |
### Revision 52026-05-04
收口三处架构边界,规避 codex 复审定位的 P0/P1 风险:
| # | 问题 | 收口位置 |
| --- | --- | --- |
| R5-P0-1 | 旧 §6.11 把"按位替换为 `*`"挂在 publish_tasks payload 上,与现有 `desktop_tasks` 流程不匹配,也越权污染了发布器协议;合规模块的语义只是「门」,不是「内容转换器」 | **删除替换方案**:§3 R29 重写为「命中强制即阻断 + 给出原因」;§6.11 删除整节;§9.1/§9.2 移除 `publish_tasks.compliance_redactions` 字段、`PublishJobService.Create()` 不再做替换;§13 决策清单同步 |
| R5-P0-2 | 多平台同发的 ack 语义未闭合:旧设计每平台独立 fingerprint,但发布请求只有一个 `ack_record_id`A 平台 advisory + B 平台 block 时 UI / 后端无标准回包 | **单次检测覆盖全部目标平台 → 单条 check_record / 单条 ack**:§6.5 引擎一次 AC 扫描后按 platform_scope 拆 violation;任一平台命中 block → 整体 `GateBlock`,回包列出"哪些平台 / 哪些词库 / 哪些词条"advisory 时单条 ack 覆盖全部非 block violation;通用(all)词库命中不显示具体平台、只给原因;fingerprint 把 `target_platforms` 集合一并 hash,平台集合变更即 ack 失效 |
| R5-P1-1 | 定时发布的 publish job 在 `Create()` 时已过闸,但调度器到点取出执行前没有重检;强制模式下若期间运营更新词库 / 文章漂移,旧通过的 job 会被发出 | **新增 R31 调度器重检关卡**:§6.12 新增「定时任务执行前重检」流程;强制模式下命中 block 即把 job 状态置为 `blocked_by_compliance`、写命中原因、通知作者;非强制按 advisory 走 |
| R5-P1-2 | 发布闸 P99 < 50ms 与同步写最多 200 条 violation 明细有张力 | §6.10 补「写入快路径」:无命中只写一条 summary check_record;有命中按 batch insert(单条 INSERT … VALUES (…) 多行)落 violations;命中统计 metric 异步刷;保持事务边界但缩短 DB roundtrip |
| R5-P1-3 | plaintext_snapshot 解决检测速度但不能解决富文本替换的 offset 映射 | 因 P0-1 删除替换需求,本条自动消解:检测仍用 plaintext_snapshot(速度优先 + 不引入额外大列,避免存储爆炸) |
| R5-P2 | 实施直接全量开干风险大 | §12 Rollout Notes 补阶段切片:① DB schema + plaintext_snapshot ② deterministic gate + 强制阻断 ③ advisory ack 闭环 ④ ops UI(策略 + 词库)⑤ 调度器重检 ⑥ LLM 异步复核 |
### Revision 42026-05-04
补两条产品侧硬约束:
| # | 问题 | 收口位置 |
| --- | --- | --- |
| R4-P0 | 词库管理是「运营自建多个词库」的统一列表(不做固定卡片,也不引入 category 枚举字段);新建 / 编辑用独立 page form;词库的"用途分类"完全靠运营在词库名称中自行表达;适用平台支持"全部"或"具体平台多选",二者互斥 | §3 R27 / R28;§5.3;§6.2 `ops.compliance_dictionaries` 新增 `platform_scope` + `applicable_platforms` 列;§6.5 引擎按文章平台过滤 |
| R4-P1 | 命中敏感词的文章在发布到对应平台前必须按位替换为同长度 `*`,原词不可展示;总开关收口到 ops-web 运营平台(不依赖 config 重启) | §3 R29 / R30;§5.3 全局策略页加"总开关";§6.4 层级覆盖关系增加 ops-web 总开关层;新增 §6.11 平台发布前替换流程 |
### 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 | global policy 必须用唯一约束保证全站只有一条策略 | §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 三层检测,前两层毫秒级,第三层可关
- 词库管理首页是运营自建词库的统一列表(不做固定 bucket 卡片,也不引入 category 枚举);运营可自由创建任意多个词库,"违法敏感词 / 广告极限词 / 行业违禁词" 等用途差异完全靠运营自定义词库名称表达;每个词库可指定"全部平台"或"具体平台多选"作用域,二者互斥
- 检测引擎只做"门":命中即返回门禁结论,不改写文章正文,也不污染发布器 / 桌面端 payload;强制模式任意敏感词命中直接阻断发布,advisory 模式仅提示确认
- 定时发布的 publish job 在调度器到点执行前再过一道闸:强制模式任意命中直接将 job 置为 `blocked_by_compliance` 并通知作者
- ops-web 运营平台暴露"内容合规检测总开关",无需改 config / 重启;config 级 kill-switch`cfg.Compliance.Enabled`)保留为**部署级硬关闭**,用于生产事故止血,优先于人工审阅与所有自动检测器
- 检测结果留审计与申诉证据;运营操作落 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 | 发布器在创建发布任务前必须经过合规闸;强制模式下任意敏感词命中直接拒绝 |
| 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` 是部署级硬关闭:发布闸不读取 `compliance_manual_reviews`、不产生词库 / 正则 / LLM 调用,直接 `GatePass`;该开关只允许由运维在事故止血时使用,并必须打 audit / metric / 告警 |
| 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 并发处理不得重复消费 |
| R27 | 词库管理首页是**运营自建词库的统一列表**(不做固定卡片):运营可自由创建任意多个词库,词库的"用途 / 类别"完全靠运营自定义词库名称表达,**不引入 category 枚举字段**(需要时通过名称模糊搜索筛选) |
| R28 | 词库新建 / 编辑使用独立 page form(非弹窗),字段:词库名称、适用平台、敏感词列表(一行一个、不可含空格 / 符号)、状态;适用平台支持「全部平台」或「具体平台多选」二选一,互斥;前端控件 + 后端 schema CHECK 双重保证 |
| R29 | 合规模块**只做检测与阻断,不改写文章正文**:命中强制模式直接拒绝发布;发布器只显示门禁结论,不展示具体词 / 词库 / 平台分组;完整命中词统一在文章编辑页合规检测查看;不在 publish task payload、不在 article version、不在桌面端 payload 做任何替换 |
| R30 | 内容合规检测的「总开关」必须可在 ops-web 全局策略页一键开关,无需改 config / 重启;关闭后仅让**自动检测器**短路,pending / rejected 人工审阅裁决仍生效;它不同于 `cfg.Compliance.Enabled=false` 部署级硬关闭 |
| R31 | 定时发布的 publish job 在调度器取出执行前必须再过一道合规闸:强制模式任意命中 → job 状态置为 `blocked_by_compliance`、记录命中 check_record_id、通过站内通知告知作者;advisory 模式 / 已 ack 走原流程 |
| R32 | 多平台同发使用**单次检测 + 单条 check_record + 单条 ack**:引擎一次扫纯文本,按 `(词库.platform_scope, 词库.applicable_platforms)` 拆 violationmandatory 任意命中即整体 GateBlockadvisory 任意命中即整体 GateNeedsAckfingerprint 把 `target_platforms` 集合 hash 进去,平台集合变更即旧 ack 失效 |
| R33 | 开发阶段不提供作者侧人工特批 / 运营审阅入口;当 `GateForPublish` 返回 `GateBlock` 时,发布器只引导作者回文章编辑页用合规检测查看完整命中词并改稿 |
## 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.GetArticleVersionComplianceSnapshot(ctx, versionID)` 单行 SQL 同时取 `plaintext_snapshot``manual_review_status`;plaintext 为空(旧数据未回填)时降级到 `LoadArticleVersionContent` 并发告警
### 4.8 文章纯文本检测范围
**Chosen** 标题、正文纯文本、摘要、SEO 描述、首图 alt 文本。
**Not chosen** 图片二进制、外链落地页、视频字幕(占位非目标,二期再说)。
## 5. 用户面体验
### 5.1 编辑器
- `EditorActionMenu` 始终显示「合规检测」按钮,点击后执行一次参考检测:
- 抽屉式结果面板,违规项按等级(block / high / medium / info)分组
- 每条违规仅支持「定位到原文」
- 文档中违规处加 mark 高亮,按 `dom_path` 注入
- 面板顶部只显示参考结果摘要和耗时,不显示发布判定模式,不保存 check_record / violations
### 5.2 发布弹窗
- 点击发布按钮 → 同步调一次 `POST /api/tenant/articles/:id/compliance/check`,请求体带本次发布勾选的 `target_platforms`(账号去重后的平台集合);服务端按这组平台一次性产出**单条** check_record,包含跨平台聚合 violations
- `GatePass`:直接进入正常发布流程
- `GateNeedsAck`:发布弹窗只显示"检测到敏感词风险"的门禁提醒,不展示具体词、词库、平台分组或处理建议;引导用户回到文章编辑页使用「合规检测」查看全部命中词。用户确认风险后调用 `POST /api/tenant/compliance/check-records/:id/ack` 拿到一次性 `ack_record_id`,提交发布请求时携带(详见 §6.3 / §7)
- `GateBlock`:禁用发布按钮;发布弹窗只显示"检测到敏感词,已阻断发布"的门禁结论,不展示具体词、词库、平台分组或处理建议;引导用户回到文章编辑页使用「合规检测」查看全部命中词并逐项修改。
- `disabled`:发布弹窗不显示任何敏感词 / 合规检测相关模块。
### 5.3 ops 控制台
新增侧栏分组「内容安全」(与现有「审计日志」并列),路由:
- `/ops/compliance/policy` — 全局策略页:
- **总开关(master switch)**:一键开 / 关自动检测器,关闭后词库 / 正则 / LLM 检测短路;写入全局单例策略 `ops.compliance_policies(id=1).master_enabled` 字段;运维路径无需改 config / 重启;但不覆盖人工审阅 pending / rejected 戳(rejected 版本仍不可发布)。它不是部署级硬关闭,不能替代 `cfg.Compliance.Enabled=false`
- 模式:mandatory / advisory(总开关 = 开 时生效)
- LLM 开关
- 启用词库勾选(按词库名称排序,单一列表展示,可按名称搜索)
- `/ops/compliance/dictionaries` — 词库管理首页:**运营自建词库的统一列表**(表格视图,不做固定卡片,也不显示 category 列)
- 列:名称、适用平台摘要、词条数、版本、状态、最后更新人 / 时间、操作(编辑 / 发布 / 回滚)
- 顶部工具栏:「新建词库」按钮 + 名称模糊搜索 + 状态筛选
- 列表来源:`ops.compliance_dictionaries` 全表,运营可创建任意多个;空态文案引导"点击右上角『新建词库』开始"
- `/ops/compliance/dictionaries/new` — 新建词库 page form
- `/ops/compliance/dictionaries/:id/edit` — 编辑词库 page form(与新建页结构一致):
- **词库名称**(必填,1~64 字);用途分类(如"违法敏感词"/"广告极限词"/"金融违禁词")由运营在名称中自行表达,前端不限制命名形式
- **适用平台**:下拉多选;提供两种互斥模式
- 选中「全部平台」时,禁止再选具体平台(已选项灰显并清空)
- 选中任一具体平台时,「全部平台」选项被禁用
- 平台选项来源:`packages/shared-types` 现有的媒体平台枚举(与发布器一致),不在合规模块再造一份
- **敏感词列表**:textarea,每行一个词;前端拒绝空格 / 控制字符 / 纯标点;保存时去重 + trim;显示当前词条数
- **状态**:开 / 关
- 「保存」「取消」按钮;保存即 bump version 并写一条 `compliance_dictionary_versions` 快照
- 词条 CRUD / CSV 导入 / 版本发布 / 回滚同页操作(CSV 列:`pattern,match_type,level_override,hint,reference_law,enabled`
- `/ops/compliance/manual-reviews` — 人工审阅工作台
- 列表页:三标签 `pending / approved / rejected`,列含文章标题摘要、租户、申请人、申请时间、命中违规摘要(前 3 条)、原 check_record 链接;pending 默认按 `requested_at` 升序,确保 FIFO
- 详情页:左侧文章 plaintext 全文 + violations 完整列表(按平台分组),右侧申请说明 + 「通过」/「驳回」两按钮 + 决策理由必填 textarea;提交即终态、不可改判(按钮提交后整页禁用)
- 权限:复用 ops `operator_accounts` 现有权限位,新增 `compliance:review`;未授权账号看不到侧栏入口、API 直接 403
- `/ops/compliance/records` — 全局检测记录搜索
- `/ops/compliance/stats` — 命中分布、拦截率、误报率(按 ack 数据反推)
> 总开关行为口径:ops 总开关 = 关 时,词库 / 正则 / LLM 等自动检测器短路返回 `GatePass`;运营可自助切换且 30s 内随策略快照传播到所有 tenant-api 实例。人工审阅状态不属于检测器:pending 仍阻止发布等待决策,approved 直接放行,rejected 永久封禁。只有 `cfg.Compliance.Enabled=false` 是部署级硬关闭,可在事故止血时覆盖所有合规裁决并直接放行。
## 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 # 全局策略解析
│ │ ├── manual_review_resolver.go # version 审阅戳读取 + LRU/terminal cache + fail-closed
│ │ └── 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, platform_scope, applicable_platforms, default_level, enabled, version, description, ts`
- `platform_scope VARCHAR(16) NOT NULL DEFAULT 'all'` + `CHECK (platform_scope IN ('all','specific'))`
- `applicable_platforms TEXT[] NULL` — 平台 code 数组(与发布器使用的 platform code 同源)
- 互斥 CHECK`(platform_scope = 'all' AND (applicable_platforms IS NULL OR cardinality(applicable_platforms) = 0)) OR (platform_scope = 'specific' AND cardinality(applicable_platforms) >= 1)`
- 不引入 `category` 列:词库的用途分类(违法敏感词 / 广告极限词 / 行业违禁词……)完全由运营在 `name` / `description` 中自行表达;后端无 enum 约束、引擎不按 category 分流
- `ops.compliance_dictionary_terms``id, dictionary_id(fk), match_type(exact|regex), 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` — 用于回滚;snapshot 内冗余 `platform_scope / applicable_platforms`,回滚后仍保留作用域
- `ops.compliance_policies``id(singleton=1), master_enabled, enforcement_mode(mandatory|advisory|disabled), enabled_dictionaries(jsonb), llm_judge_enabled, llm_categories(jsonb), revision, updated_by, ts`
- `master_enabled BOOLEAN NOT NULL DEFAULT TRUE` — ops-web「总开关」对应字段,只存在全局策略行
- 数据一致性兜底 CHECK`id = 1`
**`ops.compliance_policies` 单例约束:**
```sql
-- 数据一致性兜底:只允许一条全局策略
ALTER TABLE ops.compliance_policies
ADD CONSTRAINT ck_compliance_policy_singleton CHECK (
id = 1
);
```
`revision` 由 ops service 在策略更新事务内 `revision = revision + 1`,不依赖数据库 trigger。`policy_fingerprint = sha256(revision + enabled_dictionaries + llm_judge_enabled + dict_versions)`,写入每条 `compliance_check_records``compliance_ack_records`,用于 ack 防旧策略复用。
**tenant 侧**
- `compliance_check_records``id, tenant_id, article_id, article_version_id, target_platforms(text[]), content_hash, policy_fingerprint, trigger_source(publish_gate|api|scheduler_recheck|manual_review_bypass), 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)` 索引;`target_platforms` 为本次检测使用的平台集合;编辑器参考检测不落库
- `compliance_violations``id, record_id(fk), tenant_id, platform_code(nullable), source(dict|regex|llm), dictionary_code, dictionary_name, 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)``platform_code` 语义:`NULL` = 通用(all 作用域)词库命中、跨全部 target_platforms 共用;非 NULL = 该具体平台的 specific 词库命中(一份命中按平台拆为 N 行)
- `compliance_ack_records`**ack 粒度落到 check_record 级别**,单次有效 + TTL;不在 violation 表上做(详见 §6.9
- `compliance_manual_reviews`:人工审阅审计表,绑定到 `article_version_id`tenant schema 内存储;ops-api 的人工审阅工作台跨 tenant 查询时使用专用只读连接访问 tenant schema(显式允许该跨 schema 查询边界),详见 §6.13
- `compliance_review_jobs`:发布闸 LLM 事后复核任务状态表;RabbitMQ 负责唤醒 worker,DB 负责幂等、重试、恢复兜底
`compliance_check_records.trigger_source` 必须显式包含调度器重检与人工审阅绕过来源,避免占位记录 / 重检记录落库被 CHECK 拒绝:
```sql
ALTER TABLE compliance_check_records
ADD CONSTRAINT ck_compliance_check_trigger_source CHECK (
trigger_source IN (
'editor_manual',
'publish_gate',
'auto_draft',
'api',
'scheduler_recheck',
'manual_review_bypass'
)
);
```
```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_manual_reviews`(人工审阅戳,version 级,详见 §6.13):**
```sql
CREATE TABLE compliance_manual_reviews (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
article_id BIGINT NOT NULL,
article_version_id BIGINT NOT NULL,
original_check_record_id BIGINT NULL REFERENCES compliance_check_records(id) ON DELETE SET NULL,
original_content_hash CHAR(64) NOT NULL,
original_policy_fingerprint VARCHAR(64) NOT NULL,
original_target_platforms TEXT[] NOT NULL DEFAULT '{}',
original_violation_summary JSONB NOT NULL DEFAULT '[]'::jsonb,
requested_by BIGINT NOT NULL,
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
request_note TEXT, -- 申请说明,<= 500 字
status VARCHAR(16) NOT NULL DEFAULT 'pending', -- pending | approved | rejected | cancelled
cancelled_by BIGINT,
cancelled_at TIMESTAMPTZ,
cancel_reason TEXT,
reviewed_by BIGINT, -- ops.operator_accounts.id
reviewed_at TIMESTAMPTZ,
decision_reason TEXT, -- 通过 / 驳回 必填,<= 1000 字
CONSTRAINT ck_manual_review_request_note_len CHECK (request_note IS NULL OR char_length(request_note) <= 500),
CONSTRAINT ck_manual_review_cancel_reason_len CHECK (cancel_reason IS NULL OR char_length(cancel_reason) <= 500),
CONSTRAINT ck_manual_review_decision_reason_len CHECK (decision_reason IS NULL OR char_length(decision_reason) <= 1000),
CONSTRAINT ck_manual_review_decision CHECK (
(status = 'pending' AND reviewed_by IS NULL AND reviewed_at IS NULL AND decision_reason IS NULL
AND cancelled_by IS NULL AND cancelled_at IS NULL AND cancel_reason IS NULL) OR
(status IN ('approved','rejected') AND reviewed_by IS NOT NULL AND reviewed_at IS NOT NULL AND decision_reason IS NOT NULL
AND cancelled_by IS NULL AND cancelled_at IS NULL AND cancel_reason IS NULL) OR
(status = 'cancelled' AND reviewed_by IS NULL AND reviewed_at IS NULL AND decision_reason IS NULL
AND cancelled_by IS NOT NULL AND cancelled_at IS NOT NULL)
)
);
-- 同一 version 同时只能有一条 active / terminal reviewcancelled 只保留历史,可重新提交。
CREATE UNIQUE INDEX uk_manual_review_active_per_version
ON compliance_manual_reviews (article_version_id)
WHERE status IN ('pending','approved','rejected');
CREATE INDEX idx_manual_review_pending_queue
ON compliance_manual_reviews (requested_at)
WHERE status = 'pending';
CREATE INDEX idx_manual_review_lookup_by_version
ON compliance_manual_reviews (article_version_id, status);
```
`uk_manual_review_active_per_version` 保证每个 `article_version_id` 同时至多一条 active/terminal 记录:pending 可由作者撤回为 cancelled,撤回后可再次提交;approved / rejected 仍永久占住该 version,后续 INSERT 命中唯一冲突后回读并映射 41008。`original_check_record_id ON DELETE SET NULL` 允许 §6.10 热数据清理删除 180 天前的 `compliance_check_records`;审阅记录创建时已冗余 `original_content_hash / original_policy_fingerprint / original_target_platforms / original_violation_summary`,不依赖原 check_record 保留审计语义。
ops 决策走 CAS UPDATE
```sql
UPDATE compliance_manual_reviews
SET status = $decision, -- 'approved' | 'rejected'
reviewed_by = $operator_id,
reviewed_at = NOW(),
decision_reason = $reason
WHERE id = $review_id
AND status = 'pending'
RETURNING id;
```
`RETURNING` 命中行才视为决策成功;并发两个 reviewer 同时点击只有一个生效。决策事务还必须同步更新 `article_versions.manual_review_status/manual_review_id/manual_review_updated_at`,并调用 `ManualReviewResolver.Invalidate(versionID)`
作者撤回 pending 也走 CAS
```sql
UPDATE compliance_manual_reviews
SET status = 'cancelled',
cancelled_by = $actor_id,
cancelled_at = NOW(),
cancel_reason = $reason
WHERE id = $review_id
AND tenant_id = $tenant_id
AND article_id = $article_id
AND article_version_id = $article_version_id
AND requested_by = $actor_id
AND status = 'pending'
RETURNING id;
```
撤回事务同步把 `article_versions.manual_review_status='none'、manual_review_id=NULL、manual_review_updated_at=NOW()`;若 `RETURNING` 为空,返回 41010(已决策 / 非本人 / 状态已变化)。
**`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.';
```
**`article_versions.manual_review_status` 热路径冗余列(详见 §6.3 / §6.13):**
```sql
ALTER TABLE article_versions
ADD COLUMN manual_review_status VARCHAR(16) NOT NULL DEFAULT 'none',
ADD COLUMN manual_review_id BIGINT NULL,
ADD COLUMN manual_review_updated_at TIMESTAMPTZ NULL,
ADD CONSTRAINT ck_article_versions_manual_review_status
CHECK (manual_review_status IN ('none','pending','approved','rejected'));
COMMENT ON COLUMN article_versions.manual_review_status IS
'Hot-path manual review stamp: none|pending|approved|rejected. Source of truth for publish gate cache warm path.';
```
`manual_review_status` 是热路径冗余,不替代 `compliance_manual_reviews` 审计表。提交 / 撤回 / 决策必须在同一事务内同时更新 `compliance_manual_reviews``article_versions`;若事务失败,两边都不生效。这样 99.9% 从未走人工审阅的发布请求可以从 `article_versions` 单行读取拿到 `none` 并同时拿到 `plaintext_snapshot`,不额外访问人工审阅表;`manual_review_id` 只在 status 非 `none` 时填充。`approved / rejected` 后的终态详情以 `compliance_manual_reviews` 为准,`article_versions` 只保存热路径 stamp。
历史回填脚本(`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 // 强制模式任意命中
GateNeedsAck // 仅提示确认模式任意命中
)
// tenant/compliance/app/service.go: 发布闸请求、ack 消费参数与服务接口留在 app 层,可依赖 pgx.Tx / uuid
type GateForPublishRequest struct {
TenantID int64
ArticleID int64
ArticleVersionID int64
ActorID int64
TargetPlatforms []string // 本次发布请求 accounts[].platform 去重后的集合;空 = 全部平台
AckRecordID *int64
TriggerSource string // publish_gate | scheduler_recheck(详见 §6.12
}
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)
}
```
`CheckResult` 必须携带人工审阅状态,供编辑器检测抽屉与发布弹窗复用同一 DTO;前端不再另猜状态:
```go
type ManualReviewStatus string // none | pending | approved | rejected
type CheckResult struct {
RecordID int64
Passed bool
Violations []Violation
ManualReviewStatus ManualReviewStatus `json:"manual_review_status"`
ManualReviewID *int64 `json:"manual_review_id,omitempty"`
ManualReviewReason *string `json:"manual_review_reason,omitempty"`
ManualReviewDecidedAt *time.Time `json:"manual_review_decided_at,omitempty"`
}
```
`GateForPublish` 规则(按入口顺序):
1. **config kill-switch(部署级硬关闭)**:若 `cfg.Compliance.Enabled=false`,直接 `GatePass`,不读 manual review、不读策略、不跑自动检测器;仅记录 `metrics.SkippedTotal{reason="config_disabled"}` + audit / 告警。这是生产事故的终极止血口,允许在人工审阅服务误写 rejected / pending 时临时放行。
2. **人工审阅状态戳**(版本级发布裁决,仅次于部署级硬关闭):
- 通过 `ManualReviewResolver.Decide(ctx, tenantID, articleVersionID)` 读取 `article_versions.manual_review_status/manual_review_id`,必要时回查 `compliance_manual_reviews` 填充详情;无审阅时同时返回 `ArticleVersionComplianceSnapshot`,后续新鲜检测复用其中的 `plaintext_snapshot`
- `pending` → 直接返回 `GateBlock` + `ErrComplianceManualReviewPending(41007)`,不跑词典 / 正则、不允许发布;前端按 §5.2 显示"审阅中"
- `approved` → 直接返回 `GatePass` + `CheckRecord{ManualReviewBypass=true, ManualReviewID=..., ReviewedBy=..., ReviewedAt=...}`,不跑词典 / 正则、不读 plaintext snapshot;写一条占位 `compliance_check_records(trigger_source='manual_review_bypass')` 留审计
- `rejected` → 直接返回 `GateBlock` + `ErrComplianceManualReviewRejected(41006)` + `CheckRecord{Reasons=["该版本已被运营驳回 (理由: <decision_reason>)"]}`;前端按 §5.2 渲染禁用横幅
- `none` / `cancelled` → 不短路,继续标准自动检测流程
- resolver 查询失败且无可信 cache → 返回 `GateBlock` + `ErrComplianceEngineUnavailable(41004)`HTTP 503;不得 fail-open,避免误放行 rejected 版本
3. **ops master switch / enforcement_mode = disabled** → 仅关闭自动检测器,短路 `GatePass`;不覆盖上一步 pending / rejected
4. **ack 路径**`AckRecordID != nil`):读取 ack 绑定的原 check_record;校验 tenant/article/version、`content_hash``policy_fingerprint`fingerprint 已包含 target_platforms 集合)、未过期、未消费、无 block violation、ack 覆盖全部非 block violation;校验通过直接返回 `GatePass` 和原 check_record**不再新建第二条 check_record**;失败 → 41003
5. **新鲜检测**`AckRecordID == nil`):读取当前 plaintext snapshot,按 `TargetPlatforms` 筛选词库,运行词典 + 正则**单次扫描**(详见 §6.5),写**一条** `publish_gate` check_record(覆盖全部 target_platforms),并按聚合策略返回 `GatePass / GateBlock / GateNeedsAck`
返回 `GateBlock``CheckRecord.Reasons` 字段按平台分组(`platform_code = NULL` 单独成组、标记"全部平台共用规则"),每条含 `dictionary_name / term_pattern / hint / reference_law`,供前端直接渲染。即使 ack 校验通过,真正的 `consumed_at` 更新也必须等发布 job 创建后,在同一个 DB 事务里调用 `ConsumeAckTx`
**关于 pending 状态:**`ManualReviewResolver` 当前读取到 `pending`(还没 ops 决策),`GateForPublish` 必须直接拒绝发布并返回 `41007 manual_review_pending`。pending 是业务裁决,不是检测器结果;ops 总开关 / enforcement_mode 不能绕过 pending 等待态。`cfg.Compliance.Enabled=false` 是唯一例外,用于部署级硬关闭止血。
**ManualReviewResolver cache 语义:**
- 进程内维护 `versionID -> ManualReviewStamp` LRU,默认 TTL 60sterminal stampapproved / rejected)额外写入单调 cacheTTL 24h,且只允许 `none -> pending -> approved/rejected``pending -> cancelled -> none` 的事务内 invalidation,不允许用旧 `none` 覆盖 terminal
- 正常路径用单条 `GetArticleVersionComplianceSnapshot(versionID)` 读取 `manual_review_status/manual_review_id/manual_review_updated_at/plaintext_snapshot`。status 非 `none` 时再按 `manual_review_id` 查询 `compliance_manual_reviews` 拿 reason / reviewer / timestampsstatus = `none` 时把 snapshot 交回发布闸后续检测复用,避免 99.9% 普通文章为了 manual review 多一次 DB roundtrip
- 查询失败时只接受 pending / approved / rejected 这类"会阻断或明确放行"的可信 cache;若 cache miss 或只有陈旧 `none`,返回 41004 / 503 fail-closed。这样 DB 抖动不会误放行 rejected,也不会把未知状态当作 none
- 提交、撤回、approve、reject 成功后必须主动 `Invalidate(versionID)`;多实例之间接受最多 60s 的陈旧窗口,但 terminal cache 只增不减,跨实例不会把 rejected 误降级成 none
**`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
// target_platforms = req.Accounts[].Platform 去重排序后的集合(决定词库筛选 + fingerprint
decision, record, err := s.complianceSvc.GateForPublish(ctx, GateForPublishRequest{
TenantID: actor.TenantID,
ArticleID: articleID,
ArticleVersionID: effectiveVersionID,
ActorID: actor.UserID,
TargetPlatforms: collectTargetPlatforms(req.Accounts),
AckRecordID: req.AckRecordID,
TriggerSource: "publish_gate",
})
if err != nil { return err } // 例如 41004 manual_review_load_failed_no_cache / policy_load_failed_no_cache
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。
config kill-switch 短路写在 `compliance_service.go` 门面入口,**必须在 `GateForPublish` 查人工审阅状态戳之前执行**。`cfg.Compliance.Enabled=false` 是部署级硬关闭,不是普通检测器开关;只用于引擎 / 审阅服务 / DB 事故止血,必须伴随启动 WARN、ops 红条、metric 与 audit log。`Check`(编辑器手动 / 纯文本 API)同样可直接按 config 短路。
层级覆盖关系(自上而下,任一层短路即决策):
```
1. config.compliance.enabled = false # 部署级硬关闭,覆盖所有合规裁决 → GatePass
2. article_versions.manual_review_status / ManualReviewResolver # 版本级发布裁决
├─ pending → GateBlock / 41007(等待运营审阅,不允许发布)
├─ approved → GatePass(永久绕过该版本的所有自动检测)
└─ rejected → GateBlock / 41006(永久封禁该版本,驳回理由直出)
3. ops.compliance_policies(id=1).master_enabled = false # 仅关闭自动检测器,30s 内生效 → GatePass
4. ops.compliance_policies(id=1).enforcement_mode = disabled # → GatePass
```
`ComplianceService` 门面在 `GateForPublish` 入口短路顺序:`cfg.Compliance.Enabled`**manual_review stamp**`global.master_enabled``enforcement_mode` → 新鲜检测。任一短路层都返回对应 `GateDecision``metrics.SkippedTotal` label 区分原因(`config_disabled` / `manual_review_approved` / `ops_master_off` / `mode_disabled`);`metrics.BlockedTotal``manual_review_pending` / `manual_review_rejected` label。`Check`(编辑器手动 / 纯文本 API)可以展示 manual review 状态,但不把 rejected 当作检测结果写入新的 violations。
### 6.5 检测引擎流程
两条入口,共享同一个引擎,但**纯文本来源**和**LLM 时机**不同(见 §4.3、§4.7):
```
ComplianceService.Check(编辑器手动 / 纯文本 API)
├─ 0. 发布闸遵守 ConfigKillSwitch;编辑器参考检测不受发布检测模式影响
├─ 1. 发布闸读取 manual review 状态;编辑器参考检测始终按当前内容检测
├─ 2. 发布闸遵守 Ops master / enforcement_mode;编辑器参考检测按 advisory 语义返回风险列表
├─ 3. PolicyResolver: 当前租户生效策略(LRU 60s)
├─ 4. 纯文本来源 = 请求体直传(前端编辑器实时内容)
├─ 5. DictMatcher: AC 自动机一次性扫 exact 词条
├─ 6. RegexMatcher: 预编译 RE2 + 有界 worker pool + 总预算(详见 §6.10
├─ 7. LLMJudge(同步,500ms 超时降级): 仅对开启的 categories 跑
└─ 8. 聚合 → 发布闸写 compliance_check_records + violations;编辑器参考检测只返回 DTO
ComplianceService.GateForPublish(发布闸)
├─ 0. ConfigKillSwitch: cfg.Enabled=false → GatePass(部署级硬关闭)
├─ 1. ManualReviewResolver + ArticleVersionComplianceSnapshot 单行读取
│ ├─ pending → 41007approved → GatePassrejected → 41006
│ └─ none → 继续,复用 snapshot.plaintext_snapshot;查询失败无可信 cache → 41004/503
├─ 2. PolicyResolver
├─ 3. 纯文本来源 = 上一步 snapshot.plaintext_snapshot(§4.7
│ └─ 命中空(旧数据未回填)→ 降级 LoadArticleVersionContent + metric 告警
├─ 4. DictMatcher
├─ 5. RegexMatcher(发布闸预算更短,详见 §6.10)
├─ 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 自动失效
**多平台单次检测语义(`PolicyResolver` / 引擎入口共同保证):**
- 调用方(编辑器手动检测、发布闸)必须传入「目标平台集合」`target_platforms []string`:发布闸 = 本次发布请求中所有 `accounts[].platform` 去重后的集合;编辑器手动 = 文章已勾选的待发平台并集;空集等价于"全部平台"
- 引擎对纯文本只跑**一次** AC 自动机扫描(这是热路径中最贵的一步),词库筛选规则:
- 词库 `platform_scope = 'all'` → 始终参与扫描,命中 violation 的 `platform_code = NULL`("全部平台共用规则"
- 词库 `platform_scope = 'specific'` → 仅当 `applicable_platforms ∩ target_platforms ≠ ∅` 时参与扫描,命中 violation 按其 `applicable_platforms ∩ target_platforms` 拆成多条记录,每条 `platform_code` = 具体平台
- **单条 `compliance_check_records`** 覆盖整个 `target_platforms` 集合;`target_platforms` 字段冗余完整集合
- **聚合判定**:当前 mode = mandatory 且存在任意 violation → 整体返回 `GateBlock`;当前 mode = advisory 且存在任意 violation → `GateNeedsAck`;否则 `GatePass`。发布器不渲染 violations 明细,完整命中词只在文章编辑页合规检测中展示。
- `policy_fingerprint = sha256(revision + enabled_dictionaries + llm_judge_enabled + dict_versions + sorted(target_platforms))`;目标平台集合一旦改变 → fingerprint 变 → 旧 ack 自动失效
- **单条 ack** 必须覆盖该 check_record 下全部非 block violation(不再按平台拆 ack);前端展示按平台分组,但 ack 是 record 级别
### 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) {
// (0) 部署级硬关闭优先于所有合规裁决,用于事故止血。
if !s.cfg.Compliance.Enabled {
s.metrics.SkippedTotal.WithLabelValues("config_disabled").Inc()
return GatePass, nil, nil
}
// (1) 版本级人工审阅裁决优先于 ops master / enforcement / 自动检测器。
decision, record, snapshot, handled, err := s.manualReviews.Decide(ctx, req.TenantID, req.ArticleVersionID)
if err != nil {
s.metrics.EngineFailuresTotal.WithLabelValues("manual_review_load_failed", "no_cache").Inc()
return GateBlock, &CheckRecord{Skipped: true, Reason: "manual_review_load_failed_no_cache"}, ErrComplianceEngineUnavailable
}
if handled {
return decision, record, nil
}
// (2) 解析策略:失败优先用 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
}
}
if !policy.MasterEnabled {
s.metrics.SkippedTotal.WithLabelValues("ops_master_off").Inc()
return GatePass, nil, nil
}
if policy.Mode == "disabled" {
s.metrics.SkippedTotal.WithLabelValues("mode_disabled").Inc()
return GatePass, nil, nil
}
if req.AckRecordID != nil {
return s.validateAckForPublish(ctx, req, policy)
}
// (3) 解析后才能基于 policy.Mode 决定后续失败语义;优先复用 manual review 阶段已读的 article_versions snapshot。
plaintext := snapshot.Plaintext
if plaintext == "" {
if fallback, loadErr := s.repo.LoadArticleVersionContent(ctx, req.ArticleVersionID); loadErr == nil {
plaintext = fallback.Plaintext
} else {
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``manual_review_load_failed_no_cache` 这类"既无在线数据又无可信缓存"的极端情况;常规熔断按 fail-closed / fail-open 静默处理。manual review 查询失败的常规兜底只允许使用 pending / approved / rejected 可信 cache,不能用陈旧 none 放行。
### 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` compliance snapshot 一行(`plaintext_snapshot + manual_review_status`)、内存 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`,不存正文快照
**写入快路径(缩短 P99 尾巴):**
- **无命中**:只 INSERT 一条 `compliance_check_records``hit_count=0 / stored_hit_count=0`),不开启 violations 子事务,单次 DB roundtrip
- **有命中**:先 INSERT check_record 拿到 id,再用单条 `INSERT INTO compliance_violations (...) VALUES (...), (...), ...` 批量插入(PG 批量 insert 比 N 次单条快 5~20 倍);最多 200 条明细一次写完
- **统计 / metric**:所有 `compliance_check_total / compliance_duration_ms / compliance_skipped_total` Prometheus counter 用进程内 buffer 异步刷,发布闸热路径不等 metric I/O
- **Audit log**:复用 [`server/internal/shared/auditlog/async.go`](../../../server/internal/shared/auditlog/async.go) 异步 writer,发布闸不同步等 audit 落库
- **基线压测目标(实施阶段验收)**:无命中场景 P99 < 30ms;命中 200 条场景 P99 < 50ms;超出基线则降低 violations 上限或拆为后台异步落明细(仅留 summary 进同步路径)
**并发与幂等:**
- 发布闸不加分布式锁;同一文章并发发布会各自检测,各自写独立 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。`compliance_manual_reviews` 作为版本级裁决与审计表不参与 180 天热数据清理;它的 `original_check_record_id ON DELETE SET NULL` 保证原 check_record 被清理后审阅记录仍保留 `original_*` 快照
- 词库版本快照保留最近 10 个发布版本;超出部分删除 DB snapshot,不上 OSS。若单词库 snapshot 超过 50MB,再单独评估对象存储
**富文本定位:**
- 编辑器手动检测优先提交前端导出的 `dom_path` / block key 映射;后端只透传,不理解编辑器 AST
- `dom_path` 缺失或失效时,前端退回 plain offset + 当前文本节点搜索;高亮可降级,但发布闸判定不依赖高亮定位
### 6.11 为什么不做"发布前替换为 `*`"
历史 Revision 4 曾要求"命中敏感词在发布到平台前按位替换为同长度 `*`"。本次复审决定**删除该方案**,原因:
- **越权**:合规模块的契约是「门」(block / pass / needs_ack),不是「内容转换器」;改写 publish task payload 会污染发布器协议、和现有 `desktop_tasks``desktop_content_service` 取正文路径不兼容(payload 里就算放脱敏稿,桌面端仍按 article_id 拉原文)
- **漏洞面**:替换链路一旦被任意环节绕过(桌面端从原文重拉、用户撤回后重发),平台仍会收到原词,反而比"直接拒绝"更不安全
- **技术债**:替换需要从 plaintext offset 反查富文本 / Markdown / HTML offset,要么扛 dom_path 的脆弱性、要么落一份重的 plaintext_map(存储爆炸),与"以速度为准、不引入存储爆炸"的原则相悖
**替代方案(已收口在 R29 / R31):**
- 命中强制模式 → **直接拒绝发布**,发布器只显示阻断结论,引导作者回编辑页用合规检测查看完整命中词并改稿
- 命中 advisory → 发布器只显示有敏感词风险,确认后可继续;完整词列表仍回编辑页合规检测查看
- 通用(all 作用域)词库命中 → 发布器不展示平台或词条细节
- 定时发布到点执行前在调度器侧再过一道闸(详见 §6.12),保证策略 / 词库变更后的 stale job 仍能被拦下
### 6.12 调度器执行前重检(定时发布场景)
承接 R31`PublishJobService.Create()` 时已过闸的 publish job,若是定时任务(`scheduled_at > now()`),调度器到点取出执行前必须再过一道合规闸。
**为什么必须重检:**
- 创建到执行可能间隔小时 / 天级;期间运营可能新增 / 修改词库、调整 enforcement_mode、扩大 mandatory 范围
- 文章正文也可能在期间被作者编辑(创建发布 job 后允许继续修改原文,发布版本与最新版本可能漂移)
- 强制模式语义是"直到推送出去那一刻都得合规",不是"创建发布请求那一刻合规即可"
**实现位置:** `server/cmd/scheduler` 中现有调度循环取出待执行 job 后、向 desktop / 投递通道下发 task 前,新增一个 `compliance.RecheckBeforeDispatch(jobID)` 步骤。
**重检流程:**
1. 用 job 的 `(article_id, article_version_id, target_platforms)` 重新调用 `GateForPublish(TriggerSource = "scheduler_recheck", AckRecordID = nil)`(不复用旧 ack — ack 是"用户对当时风险已知悉"的承诺,不能跨调度周期)
2. 命中聚合判定:
- `GatePass` → 继续原派发流程,写一条新的 `compliance_check_records` 留审计(`trigger_source = scheduler_recheck`
- `GateBlock`(强制 + block)→ 把 `publish_jobs.status` 置为 `blocked_by_compliance`、写 `compliance_blocked_record_id` 反向引用、停止派发;通过现有站内通知 / 审计通道告诉作者"定时发布被合规拦截 + 原因";不重新调度
- `GateNeedsAck`advisory 或 mandatory 非 block)→ 一期**不**重弹 ack(用户已不在线),直接放行并标记 `compliance_recheck_advisory_only = true`,留审计;如果产品后续要求"advisory 重检也要拦",再加 `blocked_pending_user_action` 状态
**幂等与并发:**
- 同一 job 多实例 scheduler 抢占走现有 advisory lock / `FOR UPDATE SKIP LOCKED` 路径(沿用现状)
- 重检失败按 §4.6 fail-open / fail-closed
- **mandatory 失败 → 直接 fail**:把 `publish_jobs.status` 置为 `failed`、写失败原因(`compliance_recheck_failed`)、停止派发、不进任何 retry 队列;通过站内通知告知作者"定时发布失败:合规重检异常,请联系运营";运营在 metric / alert 看到后人工排查
- **advisory 失败 → 放行 + metric 告警**advisory 语义是"提示为主",不能因引擎抖动误伤)
**与 R23 P99 预算的关系:** 调度器重检不在用户请求路径,预算放宽到 500ms(仍以 plaintext_snapshot 为底,避免历史重建)。
**调度器重检遇到人工审阅戳:** 同 §6.13 — `RecheckBeforeDispatch` 走的是同一个 `GateForPublish`approved 的版本直接 `GatePass`rejected 的版本直接 `GateBlock`job 转 `blocked_by_compliance`),pending 的版本直接 `GateBlock / 41007`job 转 `blocked_by_compliance`,原因写"等待运营审阅",不派发)。
pending 导致的 `blocked_by_compliance` 不做自动解锁:ops approve / reject 只改变 version 的人工审阅戳,不重新唤醒历史 job,避免在用户不在线时把旧定时任务悄悄发出去。admin-web 的"我的发布"列表必须展示 `blocked_by_compliance` 状态、合规原因和「重新发布」按钮;作者看到 approved 后点击重新发布,走新的 `PublishJobService.Create()` 并重新创建 job。若 rejected,则列表显示"已被运营驳回,请修改文章后重新创建版本"。
### 6.13 人工特批
开发阶段不提供作者侧人工特批 / 运营审阅入口。发布器遇到 `GateBlock` 时只提示用户回到文章编辑页,使用「合规检测」查看完整命中词并修改内容;修改后重新打开发布器再次检测。
## 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 |
| POST | `/api/tenant/articles/:id/compliance/manual-reviews` | 提交运营人工审阅。body `{check_record_id, note}`;要求当前 version 无 pending / 无终态记录、`check_record_id` 含 block violation;返回 review id 与 `pending` 状态 |
| GET | `/api/tenant/articles/:id/compliance/manual-reviews/latest` | 查当前 `article_version_id` 最新审阅状态(none / pending / approved / rejected),含决策理由(若已决策);前端发布弹窗轮询 |
| POST | `/api/tenant/articles/:id/compliance/manual-reviews/:review_id/cancel` | 作者撤回 pending 审阅;仅提交人可撤回;成功后 version 回到 none,可重新检测 / 重新提交 |
发布器拦截**不暴露 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` | 全局策略(含「总开关」`master_enabled` |
| POST | `/api/ops/compliance/policy/global/master-switch` | 一键切换总开关(独立 endpoint,单字段写入 + 落 audit log |
| GET | `/api/ops/compliance/manual-reviews` | 人工审阅列表,支持 `?status=pending` / `approved` / `rejected``&tenant_id=...&page=...`;权限 `compliance:review` |
| GET | `/api/ops/compliance/manual-reviews/:id` | 详情:完整 violations + 文章 plaintext + 申请说明 |
| POST | `/api/ops/compliance/manual-reviews/:id/approve` | 通过决策;body `{reason}`CAS UPDATE WHERE status='pending' |
| POST | `/api/ops/compliance/manual-reviews/:id/reject` | 驳回决策;body `{reason}`;同上 CAS |
| GET | `/api/ops/compliance/stats` | 命中分布 / 拦截率 / 误报率 / 审阅通过率 |
| GET | `/api/ops/compliance/records` | 全局检测记录搜索 |
## 8. 错误码
沿用现有 `errcode` 风格(4xxxx 段):
| Code | 含义 | HTTP | 说明 |
| --- | --- | --- | --- |
| 41001 | `compliance_blocked` | 409 | 强制模式任意敏感词命中,禁止发布 |
| 41002 | `compliance_needs_ack` | 409 | 仅提示确认模式命中敏感词,需用户 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)` |
| 41006 | `compliance_manual_review_rejected` | 409 | 当前 `article_version_id` 已被运营驳回,不可发布;前端必须引导作者改稿换版本 |
| 41007 | `compliance_manual_review_pending` | 409 | 当前 `article_version_id` 存在 pending 审阅;提交审阅时表示重复申请,发布时表示等待 ops 决策 |
| 41008 | `compliance_manual_review_terminal` | 409 | 当前 `article_version_id` 已有终态审阅记录(approved 或 rejected),不可再次提交审阅 |
| 41009 | `compliance_manual_review_invalid_check_record` | 400 | 提交审阅时 `check_record_id` 不属于该 (tenant, article, version) 或不含 block 等级 violation |
| 41010 | `compliance_manual_review_not_cancellable` | 409 | 撤回审阅失败:非提交人、记录已被 ops 决策、已撤回或不属于该文章版本 |
## 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);`app/manual_review_service.go` 提交 / 撤回审阅 + 状态查询;`app/manual_review_resolver.go` 承载 LRU/terminal cache`GateForPublish` 在 config kill-switch 之后查 manual review stamppending / approved / rejected 短路)
- `server/internal/ops/compliance/**` — 新增整套 domain`app/manual_review_service.go` 列表 / 详情 / approve / reject CAS UPDATE;权限位 `compliance:review` 注入到 `ops.operator_accounts` 权限体系;ops-api 查询 tenant schema 的 `compliance_manual_reviews` 走专用只读连接,显式允许跨 tenant 汇总
- `server/internal/tenant/app/publish_job_service.go``Create()` 入口接 `GateForPublish`,传入 `target_platforms`accounts.platform 去重);**不再对 publish task payload 做任何替换**(详见 §6.11 删除说明);`CreatePublishJobRequest``ArticleVersionID` / `AckRecordID` 两个可选字段(详见 §6.3
- `server/cmd/scheduler` 调度循环 — 取出 `scheduled_at` 到点的 publish job 后、派发前调 `compliance.RecheckBeforeDispatch`;命中 block → job 状态置 `blocked_by_compliance` + 写命中原因 + 站内通知作者(详见 §6.12);pending 审阅导致 blocked 后由 admin-web 发布列表引导用户重新发布
- `server/internal/tenant/app/article_service.go``SaveVersion()` 写入 `plaintext_snapshot`(详见 §4.7
- `server/internal/tenant/repository/article_version_storage.go` — 新增 `GetArticleVersionComplianceSnapshot` 单行 SQL 读取路径,返回 `plaintext_snapshot + manual_review_status/manual_review_id/manual_review_updated_at`
- `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 表,`compliance_dictionaries` 包含 `platform_scope / applicable_platforms` 两列与互斥 CHECK(**不再引入 `category` 列**);`compliance_policies``id=1` 全局单例,包含 `master_enabled` 列与 singleton CHECK**SQL 必须用 `ops.` schema 前缀**,详见 §6.2
- `server/migrations_ops/<ts>_seed_compliance_dictionaries.up.sql` — 极限词库种子,按词库名称区分(如「广告法极限词」「政治敏感词」「涉黄涉暴通用词」「通用违禁词」四个独立词库),不写 category 字段;种子默认 `platform_scope = 'all'`
- `server/migrations/<ts>_create_compliance_tables.up.sql` — tenant 侧 `tenant_terms / check_records / violations / ack_records / review_jobs``check_records``target_platforms text[]``violations``platform_code` 字段(含 `matched_text` CHECK 与必要索引)
- `server/migrations/<ts>_add_article_versions_compliance_snapshot.up.sql``article_versions``plaintext_snapshot` + `manual_review_status/manual_review_id/manual_review_updated_at` 冗余列
- `server/migrations/<ts>_add_publish_jobs_compliance_block.up.sql``publish_jobs``status` 枚举扩展(含 `blocked_by_compliance`+ `compliance_blocked_record_id BIGINT NULL` + `compliance_blocked_at TIMESTAMPTZ NULL`,用于调度器重检命中 block 后停派(详见 §6.12);**不**对 `desktop_tasks` / `publish_tasks` 加任何合规字段(合规模块不污染发布器 payload)
- `server/migrations/<ts>_create_compliance_manual_reviews.up.sql``compliance_manual_reviews` 表 + `uk_manual_review_active_per_version` partial unique + `cancelled` 状态 + `original_*` 审计快照 + `ON DELETE SET NULL` + 查询索引(详见 §6.2 / §6.13)
### 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 / ManualReviewStatus` 等类型,全部使用 snake_case 字段名;`ComplianceCheckResult` 必须包含 `manual_review_status / manual_review_id / manual_review_reason / manual_review_decided_at`
- `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/views/PublishJobsView.vue`(或现有"我的发布"列表)— 展示 `blocked_by_compliance` 状态、合规原因和「重新发布」按钮;pending 审阅解除后不自动恢复旧 job
- `apps/admin-web/src/lib/api.ts` — 新增 `complianceApi``publishJobsApi.create` 入参类型扩展
### 9.5 Frontend (ops-web)
- `apps/ops-web/src/views/compliance/CompliancePolicyView.vue` — 全局策略页:顶部「总开关」开关组件 + 模式 + LLM + 启用词库勾选
- `apps/ops-web/src/views/compliance/ComplianceDictionariesView.vue` — 词库管理首页:运营自建词库的**统一列表**(表格),列含名称 / 适用平台 / 词条数 / 版本 / 状态 / 最后更新;顶部「新建词库」按钮 + 名称模糊搜索 + 状态筛选;不显示 category 列、不做 category 筛选;空态引导文案
- `apps/ops-web/src/views/compliance/ComplianceDictionaryEditView.vue` — 新建(路由 `/dictionaries/new`/ 编辑(路由 `/dictionaries/:id/edit`)词库 page form:词库名称(运营自定义、用名称表达用途)、适用平台("全部" vs "具体平台多选"互斥控件)、敏感词 textarea、状态、保存 / 取消;同页内嵌词条 CRUD / CSV 导入 / 版本发布 / 回滚区块
- `apps/ops-web/src/views/compliance/{ComplianceRecordsView,ComplianceStatsView}.vue`
- `apps/ops-web/src/views/compliance/ComplianceManualReviewListView.vue` — 人工审阅工作台列表(pending / approved / rejected 三标签 + 筛选)
- `apps/ops-web/src/views/compliance/ComplianceManualReviewDetailView.vue` — 详情页:左侧 plaintext + violations,右侧申请说明 + 决策表单(通过 / 驳回 + 必填理由);提交即整页禁用
- `apps/ops-web/src/components/compliance/PlatformScopePicker.vue` — 复用于词库编辑的"全部 vs 具体平台多选"互斥选择器;平台选项来源 `packages/shared-types` 现有平台枚举
- `apps/ops-web/src/router/index.ts` — 新增 8 条路由(policy / dictionaries / dictionaries/new / dictionaries/:id/edit / manual-reviews / manual-reviews/:id / records / stats
- `apps/ops-web/src/layouts/AppShell.vue` — 侧栏分组「内容安全」
## 10. 测试
### 10.1 后端单测
- `engine`:词典命中、正则命中、LLM 兜底、偏移映射准确性、`matched_text` 写入侧截断到 80 字符
- `policy_resolver`:global 策略解析、快照刷新与失败回退
- `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` 单例测试:插入第二条策略应失败、`id != 1` 应失败、CHECK 约束生效
- `GateForPublish` ack 路径:传入 `ack_record_id` 时复用原 check_record;策略指纹或 content_hash 变化时返回 41003
- `compliance_review_worker`RabbitMQ 重复消息幂等、DB CAS claim、多实例不重复消费、失败退避、最多 3 次重试、publish 失败后恢复 ticker 可补发
- `compliance_check_records` schema`trigger_source` CHECK 接受 `scheduler_recheck``manual_review_bypass`
### 10.2 集成测
- `PublishJobService` 接 gate 后,强制 / 自愿模式下端到端
- 旧 client 不传 `article_version_id / ack_record_id` 时的回退行为
- ops-api 改策略 → tenant-api 30s 内引擎生效
- LLM 计费失败时降级路径
- 文章保存触发 `plaintext_snapshot` 写入;发布闸通过 `GetArticleVersionComplianceSnapshot` 单行读取 O(1)
### 10.3 前端
- `EditorComplianceCheck`:抽屉显示、定位原文;只做参考检测,不保存记录,不显示发布判定模式
- `PublishArticleModal``GatePass / GateNeedsAck / GateBlock` 三种渲染;提交时携带 `article_version_id / ack_record_id`;只显示门禁结论,不渲染具体词、平台分组、处理建议或人工特批入口
- "我的发布"列表:`blocked_by_compliance` 显示原因 + 重新发布按钮;approved 后点击重新发布会创建新 job,不自动解锁旧 job
- `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. 强制模式 + DB 失联(人为断开)→ 发布闸 fail-closed 拦截 ✅
7. 自愿模式 + DB 失联 → 发布闸 fail-open 放行 + metric 告警 ✅
8. mandatory + LLM 启用 + 文章正文含 LLM-only 风险段落 → 发布闸**放行** + 复核队列出现"待复核"violation + 运营收到通知 ✅
10. 长编辑历史的文章发布 → 走 `plaintext_snapshot` 路径,发布闸 P99 < 50ms ✅
11. ops-web 词库管理页 → 看到运营已创建的全部词库统一列表(不含 category 列) → 点击「新建词库」→ 进入 page form → 自定义命名(如"广告极限词")、适用平台选「全部」时具体平台被禁用且反之 → 保存后回到列表,新词库出现在表格中且可通过名称搜索筛出 ✅
12. ops-web 全局策略页关闭「总开关」→ 30 秒内 tenant-api 所有实例编辑器按钮消失 + 发布器跳过检测 + metric `skipped_total{reason="ops_master_off"}` 上涨;重新打开后恢复 ✅
13. 词库 A `platform_scope = specific, applicable_platforms = [xiaohongshu]` 命中文章 + 强制模式 → 同时发布到小红书 + 微信视频号 → 整体 `GateBlock`;发布器只显示阻断结论并提示回编辑页查看完整命中词;发布按钮禁用、文章不进入派发;`desktop_tasks` / `publish_tasks` payload 字段保持原样不被改写 ✅
14. 多平台同发:同篇文章发布到 A 和 B 两个平台,A 平台 specific 词库命中 + 通用 all 词库也命中 → 单条 check_recordviolations 正常落库;发布器不展示平台分组或词条明细;强制模式整体 `GateBlock`,仅提示确认模式整体 `GateNeedsAck`
15. **调度器重检**:advisory 模式下作者定时发布一篇文章 → 创建时通过 → 30 分钟后 ops 把 enforcement_mode 改为 mandatory 并扩充词库命中该文章 → 调度器到点取出 job → `RecheckBeforeDispatch` 命中 block → `publish_jobs.status = blocked_by_compliance``compliance_blocked_record_id` 反向引用 + 站内通知作者"定时发布被合规拦截";不重新调度;作者改稿后重新提交才能放行 ✅
16. 发布器关闭检测模式 → 不显示任何敏感词 / 合规检测相关模块,目标账号和发布按钮按原发布流程可用 ✅
## 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 误触 | 全部合规裁决被部署级硬关闭覆盖,包含人工 rejected / pending | 启动日志 WARNops 控制台首页显著标注"config 已关闭"红条;metric + Slack 告警;发布闸 audit 记录 `config_disabled_override=true`;只允许运维在事故止血窗口使用,恢复后复盘 |
| 词库版本快照 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 |
| `ManualReviewResolver` 查询失败时误放行 rejected 版本 | 人工驳回失效,违规文章发布 | `article_versions.manual_review_status` 热路径冗余 + 进程内 LRU/terminal cache;查询失败只能使用 pending / approved / rejected 可信 cache;无可信 cache 返回 41004/503 fail-closed`cfg.Compliance.Enabled=false` 是唯一覆盖口 |
| `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` 摘要供运维确认 |
| ops-web「总开关」误触全站停用合规 | 自动检测器全站失效 | 总开关切换走 `/master-switch` 独立 endpoint,前端二次确认 dialogaudit log 记录操作人;ops 控制台首页显著标注当前总开关状态;metric + Slack 告警;人工审阅 pending / rejected 裁决仍生效 |
| 词库 `applicable_platforms` 与发布器平台 code 不一致 | 词库永远不命中或全平台命中 | 平台 code 来源同源于 `packages/shared-types` 平台枚举,新增平台时同步更新 ops-web 选项;后端保存时按 enum 校验,未知 code 返回 400 |
| 调度器重检漏检 / 误检 | 定时发布期间策略变更未被拦下,或正常 job 被误拦 | `RecheckBeforeDispatch` 失败 mandatory **直接 fail**(不进 retry 队列)+ 站内通知作者;advisory 失败放行 + metric 告警;`compliance_blocked_record_id` 永远关联具体 check_record,运营可溯源;advisory 重检不重弹 ack(用户已不在线),仅留审计 |
| 单条 check_record 覆盖多平台导致 ack 一改即失效 | 用户每改一次发布平台都得重新 ack | 接受:`policy_fingerprint``target_platforms` 集合 hash 进去是必要的安全约束;前端在 ack 弹窗后若用户调整账号选择,立即清空已 ack 状态并重新检测 |
| pending 审阅长期无人处理 | 作者文章被永久卡住 | 一期不做自动超时;提供作者撤回 pending;ops 工作台 FIFO + 待办通知 + 超 SLA 告警;撤回后同 version 可重新提交或改稿 |
| 人工审阅成"合规免死金牌"被滥用 | 运营手工放行规模化绕开监管底线 | 单 version 同时仅一条 active/terminal 审阅;二次确认 + 申请说明必填 + ops 决策必填理由 + 全链路 audit log;ops 端可看每租户审阅频次 / 通过率,异常租户走商务沟通 |
| approved 后法规重大变化 | 已通过的版本仍能发布有合规风险 | approved 戳绑死 `article_version_id`,新版本不延续;如需推翻只能让作者改稿换版本(一期不做"运营 revoke 已通过审阅",避免决策反复);这是产品明确的人工裁决语义,ops master switch 只关闭自动检测器,不撤销 approvedconfig kill-switch 仅事故止血 |
| 驳回后用户疯狂改版本试图绕过 | rejected 戳被规避 | 这是设计意图:每次改版本产生新 `article_version_id`、走完整检测;rejected 戳只针对单个 version;只要词库命中规则正确、新版本仍会被拦下 |
| 同一 version 多人并发提交 / 撤回 / 决策 | 数据竞争 | INSERT 走 `uk_manual_review_active_per_version`,唯一冲突后回读现有状态映射 41007 / 41008;撤回 / 决策 UPDATE 走 CAS WHERE status='pending';前端拿到 41007 / 41010 / "状态已变更" 提示后刷新即可 |
## 12. Rollout Notes
**分阶段切片(避免全量开干风险):**
| 阶段 | 范围 | 验收 |
| --- | --- | --- |
| ① Schema + article version compliance snapshot | 落 ops/tenant 全部 DB 表 + `article_versions.plaintext_snapshot/manual_review_status` 列 + 回填脚本;无业务接入 | 迁移幂等、回填脚本跑完活跃租户、`schema_migrations` 正常 |
| ② Deterministic gate + 强制阻断 | 接入 `PublishJobService.Create()` 调用 `GateForPublish`;只跑词典 + 正则;mandatory 任意命中直接拒绝发布;发布器只显示阻断结论,不展示命中明细;不接 ack | mandatory 模式下命中可拦下;P99 < 50ms;旧 client 不传 ack 仍可走 mandatory pass 路径 |
| ③ Advisory ack 闭环 | 落 `compliance_ack_records` + `ConsumeAckTx`;前端发布弹窗只显示门禁提醒 + ack 按钮;advisory 模式下命中可 ack 放行 | ack 单次有效;同一 ack 第二次提交被 41003 拒绝;fingerprint 变化后旧 ack 失效 |
| ④ Ops UI(策略 + 词库 + 总开关) | ops-web `/ops/compliance/policy` + `/ops/compliance/dictionaries` 列表 + page form;总开关、模式切换、词库 CRUD / CSV 导入 / 版本发布 / 回滚;30s 轮询生效 | 运营自助创建 / 启用 / 回滚词库;总开关切换 30s 内全实例生效;audit log 全记录 |
| ⑤ 调度器执行前重检 | `server/cmd/scheduler``compliance.RecheckBeforeDispatch``publish_jobs.status` 扩展 `blocked_by_compliance`;命中后站内通知作者;admin-web 发布列表支持 blocked 原因 + 重新发布 | 创建 → 改策略 → 到点重检命中 block → job 被拦 + 作者收到通知;审阅 approved 的 version 重检直接放行;pending blocked 不自动解锁 |
| ⑥ LLM 异步复核 | `compliance_review_jobs` + RabbitMQ `compliance.review` 队列 + scheduler consumer + 恢复 ticker;编辑器手动检测同步等 LLM | LLM 命中走"待复核"violation 通知;多实例 worker 不重复消费;RabbitMQ 抖动期 pending 任务能补发 |
每个阶段都是独立可发布单元;阶段 ② 之后即可在 advisory 模式下全租户灰度运行;阶段 ⑤ 之前定时发布的 stale-policy 风险由短期人工复核兜底。
**通用约束:**
- **迁移顺序:** 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}` / `compliance_recheck_blocked_total{platform}`
## 13. 最终决策清单
本 spec 已收口实现前决策,一期按以下内容落地:
| 主题 | 最终决策 |
| --- | --- |
| 发布请求契约 | 直接扩展现有 `/api/tenant/publish-jobs`,新增可选 `article_version_id / ack_record_id`;不新开 v2 路径 |
| article version 冗余 | `article_versions.plaintext_snapshot TEXT NULL` + `manual_review_status/manual_review_id/manual_review_updated_at` 冗余列;不建子表 |
| 历史回填 | 一次性维护命令 `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 |
| 词库 category | **不引入 category 字段**;用途分类由运营在词库名称中自定义表达;引擎不按 category 分流,前端不做 category 筛选 |
| 词库管理首页 | 运营自建词库的**统一列表**(表格 + 名称搜索 + 状态筛选);不做固定 bucket 卡片;运营可自由创建任意多个词库 |
| 词库适用平台 | `platform_scope ∈ {all, specific}` 互斥;`applicable_platforms` 与 shared-types 平台枚举同源;引擎按目标平台过滤词库 |
| 词库编辑形态 | 独立 page form(新建 `/dictionaries/new`,编辑 `/dictionaries/:id/edit`);不用弹窗;同页内嵌词条 CRUD / CSV 导入 / 版本发布 / 回滚 |
| 总开关位置 | ops-web `/ops/compliance/policy` 顶部「总开关」组件;写 `ops.compliance_policies(id=1).master_enabled`;只关闭自动检测器,不覆盖人工审阅 pending / rejected 裁决。`cfg.Compliance.Enabled=false` 是部署级硬关闭,事故止血时优先于所有合规裁决 |
| 命中后处理方式 | **不替换**。强制模式任意命中即拒绝发布;仅提示确认模式命中走 ack;发布器只显示门禁结论,不展示具体词 / 词库 / 平台分组;完整明细统一在文章编辑页合规检测查看;不污染 publish_tasks / desktop_tasks payload |
| 多平台同发检测形态 | **单次扫描 + 单条 check_record + 单条 ack** 覆盖全部 target_platformsfingerprint 把平台集合 hash 进去;mandatory 任意命中 → 整体 GateBlockadvisory 任意命中 → GateNeedsAck |
| 定时发布合规闭环 | 调度器到点取出 job 后、派发前调 `compliance.RecheckBeforeDispatch`;强制模式任意命中 → `publish_jobs.status = blocked_by_compliance` + 通知作者;advisory 不重弹 ack |
| 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 不承担工时与排期。