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

122 KiB
Raw Blame History

内容合规检测(敏感词 / 广告法)设计

  • 日期:2026-04-28
  • 状态:Final (Revision 9, 2026-05-05) — Implementation Ready
  • 涉及端:apps/admin-webapps/ops-webserver/internal/tenantserver/internal/opsserver/cmd/ops-apiserver/cmd/tenant-apiserver/cmd/schedulerpackages/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.13cfg.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_bypassoriginal_check_record_id ON DELETE SET NULL 且冗余审计快照;前端发布列表展示 blocked + 重新发布;tenant API 统一 manual-reviewsCheckResult 返回 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_idA 平台 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.9GateForPublish 接收可选 ack_record_id,先校验 ack 覆盖的原 check_record,消费仍在发布事务内 CAS 完成
R3-P1 高并发下发布闸必须有确定延迟上界 §6.10:发布闸热路径、regex 预算、violation 写入上限、超时语义全部固定
R3-P2 LLM 事后复核队列需要明确宿主 §6.7:复用现有 RabbitMQcompliance_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.1POST /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 CreatePublishJobRequestarticle_version_idack_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-switchcfg.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_idack_record_id 两个可选字段;共享 TS 类型同步;扩展为向后兼容(旧 client 不传仍可走 mandatory 流程,但无法 ack
R16 文章纯文本不得在发布闸时同步重建版本历史;必须有"plaintext snapshot"读取路径在 30ms 内可达
R17 引擎写入 compliance_violations 时必须强制截断 matched_text 到上限长度,schema 同时加 CHECK 兜底
R18 PolicyResolver 必须维护进程级 last-known cacheResolve 失败时优先用 cache;都没有时返回 GateBlockconservative 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

  • 编辑器手动检测:同步等 LLM500ms 超时降级为不含 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.LoadArticleVersionContentserver/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_versionsplaintext_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_snapshotmanual_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);tenant 表 → server/migrations/。两侧独立 schema_migrations 表。

ops 侧(schema = ops

  • ops.compliance_dictionariesid, 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_termsid, dictionary_id(fk), match_type(exact|regex), pattern, level_override, hint, reference_law, enabled, ts(dictionary_id) WHERE enabled = TRUE 部分索引
  • ops.compliance_dictionary_versionsid, dictionary_id(fk), version, snapshot(jsonb), published_by, published_at — 用于回滚;snapshot 内冗余 platform_scope / applicable_platforms,回滚后仍保留作用域
  • ops.compliance_policiesid(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「总开关」对应字段,只存在全局策略行
    • 数据一致性兜底 CHECKid = 1

ops.compliance_policies 单例约束:

-- 数据一致性兜底:只允许一条全局策略
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_recordscompliance_ack_records,用于 ack 防旧策略复用。

tenant 侧

  • compliance_check_recordsid, 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_violationsid, 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_recordsack 粒度落到 check_record 级别,单次有效 + TTL;不在 violation 表上做(详见 §6.9
  • compliance_manual_reviews:人工审阅审计表,绑定到 article_version_idtenant schema 内存储;ops-api 的人工审阅工作台跨 tenant 查询时使用专用只读连接访问 tenant schema(显式允许该跨 schema 查询边界),详见 §6.13
  • compliance_review_jobs:发布闸 LLM 事后复核任务状态表;RabbitMQ 负责唤醒 worker,DB 负责幂等、重试、恢复兜底

compliance_check_records.trigger_source 必须显式包含调度器重检与人工审阅绕过来源,避免占位记录 / 重检记录落库被 CHECK 拒绝:

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'
    )
  );
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):

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):

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

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

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 触发):

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 消息只带轻量定位信息,不带正文:

{
  "message_id": "uuid",
  "job_id": 123,
  "tenant_id": 1,
  "check_record_id": 456
}

RabbitMQ 路由:

  • exchange:复用现有 direct exchange 配置能力,新增 routing key compliance.review
  • queuecompliance.reviewdurablemanual ackprefetch 默认 10
  • DLQ:沿用现有 RabbitMQ DLX/DLQ 约定;业务重试仍以 compliance_review_jobs.available_at 为准,DLQ 只用于排查不可解析消息

worker 消费消息后先 CAS claim DB 记录,claim 成功才调用 LLM:

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;恢复任务每分钟小批量扫描并补发消息:

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 兜底):

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):

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):

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_reviewsarticle_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 调用契约

// 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;前端不再另猜状态:

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_hashpolicy_fingerprintfingerprint 已包含 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

返回 GateBlockCheckRecord.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/rejectedpending -> 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 只有 Title / ContentRef / Accounts / ScheduledAt,新增两个可选字段:

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.ResolvePublishableVersionarticles.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),本模块沿用 snake_case,TS 字段名直接等于 JSON 字段名,不在 API client 做 camelCase 转换:

// 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)升级时统一以 snake_case 字段构造请求体。

ArticleDetail 必须暴露 current_version_id

当前 TS ArticleDetailpackages/shared-types/src/index.ts:577)只有 version_no: number | null,没有 current_version_id;后端 article_service.go:335 内部用了但没透出。

ChosenArticleDetail 增加 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() 入口:

// 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:386applyEnvOverrides)是手写的 env 映射,不解析 struct tag。若仅在 struct 上写 env:"..." tag,运行时不会生效。本节给出的是对现有体系两处都改的落地方案。

新增 struct(与 cfg.LLM 同级),yaml 反序列化使用 mapstructure tag

// 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):

compliance:
  enabled: true
  llm_judge_enabled: false
  snapshot_reload_interval: 30s
  publish_gate_timeout: 800ms

env override 必须在 applyEnvOverrides 显式补四行(与 JWT_SECRET / LLM_API_KEY 同款):

// 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.Enabledmanual_review stampglobal.master_enabledenforcement_mode → 新鲜检测。任一短路层都返回对应 GateDecisionmetrics.SkippedTotal label 区分原因(config_disabled / manual_review_approved / ops_master_off / mode_disabled);metrics.BlockedTotalmanual_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,附 reasonconfig.Compliance.Enabled = false 仍可一键止血
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_cachemanual_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/tenant/app/ai_point_usage.go,新增计费场景常量 compliance_llm_judge
  • 编辑器手动检测:同步 + 500ms 超时降级;超时时返回不含 LLM 结论的部分结果
  • 发布闸永远异步,不参与 GateDecisionGateForPublishcompliance_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.reviewDB 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_hashpolicy_fingerprint 复制进 compliance_ack_recordsexpires_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_exhaustedmandatory 下不因此直接阻断
  • 单次检测最多落库 200 条 violation 明细;hit_count 保存真实命中总数,stored_hit_count 保存实际落库数,truncated=true 表示明细被截断
  • matched_text 写入前截断到 80 字符,全文只存 content_hash,不存正文快照

写入快路径(缩短 P99 尾巴):

  • 无命中:只 INSERT 一条 compliance_check_recordshit_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 异步 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 NULLcompliance_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_tasksdesktop_content_service 取正文路径不兼容(payload 里就算放脱敏稿,桌面端仍按 article_id 拉原文)
  • 漏洞面:替换链路一旦被任意环节绕过(桌面端从原文重拉、用户撤回后重发),平台仍会收到原词,反而比"直接拒绝"更不安全
  • 技术债:替换需要从 plaintext offset 反查富文本 / Markdown / HTML offset,要么扛 dom_path 的脆弱性、要么落一份重的 plaintext_map(存储爆炸),与"以速度为准、不引入存储爆炸"的原则相悖

替代方案(已收口在 R29 / R31):

  • 命中强制模式 → 直接拒绝发布,发布器只显示阻断结论,引导作者回编辑页用合规检测查看完整命中词并改稿
  • 命中 advisory → 发布器只显示有敏感词风险,确认后可继续;完整词列表仍回编辑页合规检测查看
  • 通用(all 作用域)词库命中 → 发布器不展示平台或词条细节
  • 定时发布到点执行前在调度器侧再过一道闸(详见 §6.12),保证策略 / 词库变更后的 stale job 仍能被拦下

6.12 调度器执行前重检(定时发布场景)

承接 R31PublishJobService.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 反向引用、停止派发;通过现有站内通知 / 审计通道告诉作者"定时发布被合规拦截 + 原因";不重新调度
    • GateNeedsAckadvisory 或 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 走的是同一个 GateForPublishapproved 的版本直接 GatePassrejected 的版本直接 GateBlockjob 转 blocked_by_compliance),pending 的版本直接 GateBlock / 41007job 转 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),发布接口是 /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):

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 cacheGateForPublish 在 config kill-switch 之后查 manual review stamppending / approved / rejected 短路)
  • server/internal/ops/compliance/** — 新增整套 domainapp/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.goCreate() 入口接 GateForPublish,传入 target_platformsaccounts.platform 去重);不再对 publish task payload 做任何替换(详见 §6.11 删除说明);CreatePublishJobRequestArticleVersionID / 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.goSaveVersion() 写入 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.gotenantProtected.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_policiesid=1 全局单例,包含 master_enabled 列与 singleton CHECKSQL 必须用 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_jobscheck_recordstarget_platforms text[]violationsplatform_code 字段(含 matched_text CHECK 与必要索引)
  • server/migrations/<ts>_add_article_versions_compliance_snapshot.up.sqlarticle_versionsplaintext_snapshot + manual_review_status/manual_review_id/manual_review_updated_at 冗余列
  • server/migrations/<ts>_add_publish_jobs_compliance_block.up.sqlpublish_jobsstatus 枚举扩展(含 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.sqlcompliance_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
    • CreatePublishJobRequestarticle_version_id?: number; ack_record_id?: number;snake_case,与现有字段保持一致
    • ArticleDetailcurrent_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 — 新增 complianceApipublishJobsApi.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_serviceops):发布版本 bump version + 写快照、回滚
  • applyEnvOverrides 测试:4 个 env 变量都能正确覆盖(防 P2-3 复发)
  • compliance_policies 单例测试:插入第二条策略应失败、id != 1 应失败、CHECK 约束生效
  • GateForPublish ack 路径:传入 ack_record_id 时复用原 check_record;策略指纹或 content_hash 变化时返回 41003
  • compliance_review_workerRabbitMQ 重复消息幂等、DB CAS claim、多实例不重复消费、失败退避、最多 3 次重试、publish 失败后恢复 ticker 可补发
  • compliance_check_records schematrigger_source CHECK 接受 scheduler_recheckmanual_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:抽屉显示、定位原文;只做参考检测,不保存记录,不显示发布判定模式
  • PublishArticleModalGatePass / 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 + 运营收到通知
  9. 长编辑历史的文章发布 → 走 plaintext_snapshot 路径,发布闸 P99 < 50ms
  10. ops-web 词库管理页 → 看到运营已创建的全部词库统一列表(不含 category 列) → 点击「新建词库」→ 进入 page form → 自定义命名(如"广告极限词")、适用平台选「全部」时具体平台被禁用且反之 → 保存后回到列表,新词库出现在表格中且可通过名称搜索筛出
  11. ops-web 全局策略页关闭「总开关」→ 30 秒内 tenant-api 所有实例编辑器按钮消失 + 发布器跳过检测 + metric skipped_total{reason="ops_master_off"} 上涨;重新打开后恢复
  12. 词库 A platform_scope = specific, applicable_platforms = [xiaohongshu] 命中文章 + 强制模式 → 同时发布到小红书 + 微信视频号 → 整体 GateBlock;发布器只显示阻断结论并提示回编辑页查看完整命中词;发布按钮禁用、文章不进入派发;desktop_tasks / publish_tasks payload 字段保持原样不被改写
  13. 多平台同发:同篇文章发布到 A 和 B 两个平台,A 平台 specific 词库命中 + 通用 all 词库也命中 → 单条 check_recordviolations 正常落库;发布器不展示平台分组或词条明细;强制模式整体 GateBlock,仅提示确认模式整体 GateNeedsAck
  14. 调度器重检:advisory 模式下作者定时发布一篇文章 → 创建时通过 → 30 分钟后 ops 把 enforcement_mode 改为 mandatory 并扩充词库命中该文章 → 调度器到点取出 job → RecheckBeforeDispatch 命中 block → publish_jobs.status = blocked_by_compliancecompliance_blocked_record_id 反向引用 + 站内通知作者"定时发布被合规拦截";不重新调度;作者改稿后重新提交才能放行
  15. 发布器关闭检测模式 → 不显示任何敏感词 / 合规检测相关模块,目标账号和发布按钮按原发布流程可用

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 启动日志 WARN;ops 控制台首页显著标注"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 UPDATEconsumed_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-closedcfg.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_fingerprinttarget_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/schedulercompliance.RecheckBeforeDispatchpublish_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 不承担工时与排期。