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>
This commit is contained in:
@@ -1,12 +1,71 @@
|
||||
# 内容合规检测(敏感词 / 广告法)设计
|
||||
|
||||
- 日期:2026-04-28
|
||||
- 状态:Final (Revision 3, 2026-04-28) — Implementation Ready
|
||||
- 状态: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 9(2026-05-05)
|
||||
|
||||
按产品新口径收口发布器展示边界:
|
||||
|
||||
| # | 问题 | 收口位置 |
|
||||
| --- | --- | --- |
|
||||
| R9-P0 | 发布器空间有限,展示具体命中词会淹没发布流程,且与文章编辑页合规检测职责重叠 | §5.2 / §6.5 / §9.4:发布器只展示门禁结论;强制模式任意命中直接 GateBlock;仅提示确认模式任意命中 GateNeedsAck;具体命中词统一回文章编辑页合规检测查看;关闭检测时发布器不显示任何敏感词模块 |
|
||||
| R9-P1 | 开发阶段不需要人工特批逃生口,发布器不应提供「提交运营审阅」入口 | §5.2 / §9.4 / §10.3:发布器移除人工特批入口与审阅状态展示,GateBlock 只引导回编辑页改稿 |
|
||||
|
||||
### Revision 8(2026-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 API;pending 可由作者撤回,撤回后同版本可重新提交;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 7(2026-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 可撤回为 cancelled,active/terminal partial unique 约束 approved / rejected 不可重提 |
|
||||
|
||||
### Revision 6(2026-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 5(2026-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 4(2026-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 3(2026-04-28)
|
||||
|
||||
最后修订收口实现前硬决策,原则是满足当前发布合规场景,不引入新的基础设施:
|
||||
@@ -44,7 +103,7 @@
|
||||
| P0-2 | `CreatePublishJobRequest` 缺 `article_version_id` 与 `ack_record_id`;前后端 + 共享 TS 三处契约不通 | §3 R15、§6.3、§6.8、§9 |
|
||||
| P1-1 | API 路径与现有 `/api/tenant/...` 前缀不一致 | §7、§5.1 |
|
||||
| P1-2 | ops 侧 SQL 必须显式 `ops.` schema 前缀,否则会落到 public | §6.2 |
|
||||
| P1-3 | `UNIQUE(scope, tenant_id)` 无法约束多条 global policy(PG 不视 NULL 相等) | §6.2(partial unique + CHECK) |
|
||||
| P1-3 | global policy 必须用唯一约束保证全站只有一条策略 | §6.2(partial 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.2(CHECK) |
|
||||
@@ -58,10 +117,13 @@
|
||||
|
||||
### 1.1 目标
|
||||
|
||||
- 双模式可切换:`mandatory`(命中阻断)/ `advisory`(仅提示)/ `disabled`(关闭),ops 全局 + 租户级覆盖
|
||||
- 双模式可切换:`mandatory`(命中阻断)/ `advisory`(仅提示)/ `disabled`(关闭),ops 只维护全局策略
|
||||
- 三个调用入口:编辑器手动按钮、发布器同步闸、纯文本 API(标题 / 摘要单查)
|
||||
- 词库 + 正则 + 可选 LLM 三层检测,前两层毫秒级,第三层可关
|
||||
- config 级 kill-switch(`cfg.Compliance.Enabled`),凌驾 ops 策略之上
|
||||
- 词库管理首页是运营自建词库的统一列表(不做固定 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 点数计费体系无缝衔接
|
||||
|
||||
@@ -82,7 +144,7 @@
|
||||
- **Redis pub/sub 秒级同步**:一期用 30s 轮询版本号
|
||||
- **词库 Diff / 版本对比 UI**:ops 控制台只做"发布版本 + 一键回滚"
|
||||
- **流式检测**:不在用户打字过程中实时查,只在显式触发点查
|
||||
- **租户自助上调严苛度**:租户只能加白 / 黑名单,不能修改 ops 下发的等级
|
||||
- **租户自助白 / 黑名单**:本期不提供租户侧自定义词条,全部词库和策略由 ops 统一维护
|
||||
|
||||
## 2. 当前状态
|
||||
|
||||
@@ -104,7 +166,7 @@
|
||||
|
||||
| ID | 需求 |
|
||||
| --- | --- |
|
||||
| R1 | 发布器在创建发布任务前必须经过合规闸;强制模式下命中 `block` 等级直接拒绝 |
|
||||
| R1 | 发布器在创建发布任务前必须经过合规闸;强制模式下任意敏感词命中直接拒绝 |
|
||||
| R2 | 自愿模式下命中违规仅展示提醒;用户显式 ack 后可放行(带 `ack_record_id` 重发) |
|
||||
| R3 | 编辑器侧提供独立"合规检测"按钮,触发同步检测并展示按等级分组的违规清单 |
|
||||
| R4 | 检测引擎必须支持词典精确匹配 / 正则 / LLM 语义三层;前两层毫秒级 |
|
||||
@@ -112,7 +174,7 @@
|
||||
| R6 | 词库改动必须在 30 秒内对所有 tenant-api 实例生效,不需重启 |
|
||||
| R7 | 检测结果必须留有审计记录:命中片段、等级、词库版本、检测耗时、来源(manual / publish / api) |
|
||||
| R8 | 运营操作(改词库、改策略)必须落 audit log |
|
||||
| R9 | `cfg.Compliance.Enabled = false` 时整模块短路,不产生 DB / LLM 调用 |
|
||||
| R9 | `cfg.Compliance.Enabled = false` 是部署级硬关闭:发布闸不读取 `compliance_manual_reviews`、不产生词库 / 正则 / LLM 调用,直接 `GatePass`;该开关只允许由运维在事故止血时使用,并必须打 audit / metric / 告警 |
|
||||
| R10 | 词库支持版本回滚(最近 10 个发布版本可一键回滚) |
|
||||
| R11 | 一期随迁移自动注入 4 类极限词种子(广告法 / 政治 / 涉黄涉暴 / 通用违禁) |
|
||||
| R12 | 富文本编辑器命中违规处可按 DOM 路径精确高亮,避免文本搜索错位 |
|
||||
@@ -130,6 +192,13 @@
|
||||
| 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)` 拆 violation;mandatory 任意命中即整体 GateBlock;advisory 任意命中即整体 GateNeedsAck;fingerprint 把 `target_platforms` 集合 hash 进去,平台集合变更即旧 ack 失效 |
|
||||
| R33 | 开发阶段不提供作者侧人工特批 / 运营审阅入口;当 `GateForPublish` 返回 `GateBlock` 时,发布器只引导作者回文章编辑页用合规检测查看完整命中词并改稿 |
|
||||
|
||||
## 4. 选型与权衡
|
||||
|
||||
@@ -201,7 +270,7 @@
|
||||
|
||||
**Chosen:**
|
||||
- 词库表、策略表落 `server/migrations_ops/`
|
||||
- 租户白/黑名单、检测记录、命中明细落 `server/migrations/`
|
||||
- 发布门禁检测记录、命中明细落 `server/migrations/`
|
||||
- 沿用现有"独立 `schema_migrations` 表"约定
|
||||
|
||||
**Why:**
|
||||
@@ -249,7 +318,7 @@
|
||||
- 迁移:`article_versions` 加 `plaintext_snapshot TEXT NULL`,索引不需要
|
||||
- 写入侧:`ArticleService.SaveVersion()` 在写 html/markdown 同时调 `text_extractor.Extract()` 写入纯文本快照(与 §6.7 引擎共用提取器)
|
||||
- 历史回填:迁移完成后跑一次性维护命令 `server/cmd/maintenance/backfill_article_plaintext`,按租户 + `article_versions.id` 分批回填;上线前优先跑完活跃租户,未回填文章仅首次发布走 fallback
|
||||
- 读取侧:`compliance_repo.GetArticleVersionPlaintext(ctx, versionID)` 单行 SQL;找不到(旧数据未回填)时降级到 `LoadArticleVersionContent` 并发告警
|
||||
- 读取侧:`compliance_repo.GetArticleVersionComplianceSnapshot(ctx, versionID)` 单行 SQL 同时取 `plaintext_snapshot` 与 `manual_review_status`;plaintext 为空(旧数据未回填)时降级到 `LoadArticleVersionContent` 并发告警
|
||||
|
||||
### 4.8 文章纯文本检测范围
|
||||
|
||||
@@ -261,32 +330,53 @@
|
||||
|
||||
### 5.1 编辑器
|
||||
|
||||
- 进入编辑页 `ArticleEditorView` 时调一次 `GET /api/tenant/compliance/runtime-status`
|
||||
- 返回 `enabled = false` → 按钮整组**隐藏**(不是置灰,避免空跑误导)
|
||||
- 返回 `enabled = true` → `EditorActionMenu` 增加「合规检测」按钮,点击后:
|
||||
- `EditorActionMenu` 始终显示「合规检测」按钮,点击后执行一次参考检测:
|
||||
- 抽屉式结果面板,违规项按等级(block / high / medium / info)分组
|
||||
- 每条违规支持「定位到原文」「加入白名单」「忽略」
|
||||
- 每条违规仅支持「定位到原文」
|
||||
- 文档中违规处加 mark 高亮,按 `dom_path` 注入
|
||||
- 面板顶部显示运行时摘要:当前模式 + LLM 是否参与 + 词库版本
|
||||
- 面板顶部只显示参考结果摘要和耗时,不显示发布判定模式,不保存 check_record / violations
|
||||
|
||||
### 5.2 发布弹窗
|
||||
|
||||
- 点击发布按钮 → 同步调一次 `POST /api/tenant/articles/:id/compliance/check`
|
||||
- 点击发布按钮 → 同步调一次 `POST /api/tenant/articles/:id/compliance/check`,请求体带本次发布勾选的 `target_platforms`(账号去重后的平台集合);服务端按这组平台一次性产出**单条** check_record,包含跨平台聚合 violations
|
||||
- `GatePass`:直接进入正常发布流程
|
||||
- `GateNeedsAck`:弹窗替换为合规警示视图,列出**全部**非 block violations;用户必须勾选"已了解风险,仍要发布"才能继续,调用 `POST /api/tenant/compliance/check-records/:id/ack` 拿到一次性 `ack_record_id`,提交发布请求时携带(详见 §6.3 / §7)
|
||||
- `GateBlock`:禁用发布按钮,给出修改指引;不允许 ack 强发
|
||||
- `GateNeedsAck`:发布弹窗只显示"检测到敏感词风险"的门禁提醒,不展示具体词、词库、平台分组或处理建议;引导用户回到文章编辑页使用「合规检测」查看全部命中词。用户确认风险后调用 `POST /api/tenant/compliance/check-records/:id/ack` 拿到一次性 `ack_record_id`,提交发布请求时携带(详见 §6.3 / §7)
|
||||
- `GateBlock`:禁用发布按钮;发布弹窗只显示"检测到敏感词,已阻断发布"的门禁结论,不展示具体词、词库、平台分组或处理建议;引导用户回到文章编辑页使用「合规检测」查看全部命中词并逐项修改。
|
||||
- `disabled`:发布弹窗不显示任何敏感词 / 合规检测相关模块。
|
||||
|
||||
### 5.3 ops 控制台
|
||||
|
||||
新增侧栏分组「内容安全」(与现有「审计日志」并列),路由:
|
||||
|
||||
- `/ops/compliance/policy` — 全局策略:模式开关 + 启用词库勾选 + LLM 开关
|
||||
- `/ops/compliance/dictionaries` — 词库列表(版本 / 启用 / 导入)
|
||||
- `/ops/compliance/dictionaries/:id/edit` — 词条 CRUD + CSV 导入 + 版本发布 / 回滚
|
||||
- `/ops/compliance/tenant-overrides` — 租户级覆盖
|
||||
- `/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 模块划分
|
||||
@@ -308,7 +398,8 @@ server/internal/
|
||||
│ │ │ ├── regex_matcher.go # RE2 预编译 + 有界 worker pool + 总预算
|
||||
│ │ │ ├── llm_judge.go # 语义兜底
|
||||
│ │ │ └── text_extractor.go # 富文本 → 纯文本 + 偏移映射
|
||||
│ │ ├── policy_resolver.go # 全局 vs 租户覆盖
|
||||
│ │ ├── policy_resolver.go # 全局策略解析
|
||||
│ │ ├── manual_review_resolver.go # version 审阅戳读取 + LRU/terminal cache + fail-closed
|
||||
│ │ └── snapshot_loader.go # 30s 版本轮询
|
||||
│ ├── repository/
|
||||
│ └── domain/violation.go
|
||||
@@ -322,42 +413,53 @@ server/internal/
|
||||
|
||||
**ops 侧(schema = `ops`)**
|
||||
|
||||
- `ops.compliance_dictionaries`:`id, code(uniq), name, category, default_level, enabled, version, description, ts`
|
||||
- `ops.compliance_dictionary_terms`:`id, dictionary_id(fk), match_type(exact|regex|semantic_keyword), pattern, level_override, hint, reference_law, enabled, ts`,`(dictionary_id) WHERE enabled = TRUE` 部分索引
|
||||
- `ops.compliance_dictionary_versions`:`id, dictionary_id(fk), version, snapshot(jsonb), published_by, published_at` — 用于回滚
|
||||
- `ops.compliance_policies`:`id, scope(global|tenant_override), tenant_id(nullable), enforcement_mode(mandatory|advisory|disabled), enabled_dictionaries(jsonb), llm_judge_enabled, llm_categories(jsonb), revision, updated_by, ts`
|
||||
- `ops.compliance_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` 唯一性约束(PG NULL 不视相等,必须 partial unique + CHECK):**
|
||||
**`ops.compliance_policies` 单例约束:**
|
||||
|
||||
```sql
|
||||
-- 全局策略至多一条
|
||||
CREATE UNIQUE INDEX uk_compliance_policy_global
|
||||
ON ops.compliance_policies (scope)
|
||||
WHERE scope = 'global';
|
||||
|
||||
-- 每租户至多一条覆盖
|
||||
CREATE UNIQUE INDEX uk_compliance_policy_tenant
|
||||
ON ops.compliance_policies (tenant_id)
|
||||
WHERE scope = 'tenant_override';
|
||||
|
||||
-- 数据一致性兜底:scope=global 时 tenant_id 必须为空,反之必须非空
|
||||
-- 数据一致性兜底:只允许一条全局策略
|
||||
ALTER TABLE ops.compliance_policies
|
||||
ADD CONSTRAINT ck_compliance_policy_scope CHECK (
|
||||
(scope = 'global' AND tenant_id IS NULL) OR
|
||||
(scope = 'tenant_override' AND tenant_id IS NOT NULL)
|
||||
ADD CONSTRAINT ck_compliance_policy_singleton CHECK (
|
||||
id = 1
|
||||
);
|
||||
```
|
||||
|
||||
`revision` 由 ops service 在策略更新事务内 `revision = revision + 1`,不依赖数据库 trigger。`policy_fingerprint = sha256(scope + tenant_id + revision + enabled_dictionaries + llm_judge_enabled + dict_versions)`,写入每条 `compliance_check_records` 和 `compliance_ack_records`,用于 ack 防旧策略复用。
|
||||
`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_tenant_terms`:`id, tenant_id, kind(whitelist|blacklist), pattern, match_type, level(blacklist 用), hint, ts`
|
||||
- `compliance_check_records`:`id, tenant_id, article_id, article_version_id, content_hash, policy_fingerprint, trigger_source(editor_manual|publish_gate|auto_draft|api), enforcement_mode, highest_level, passed, hit_count, stored_hit_count, truncated, dict_versions(jsonb), llm_used, duration_ms, checked_by, checked_at`,`(tenant_id, article_id, checked_at DESC)` 索引
|
||||
- `compliance_violations`:`id, record_id(fk), tenant_id, source(dict|regex|llm|tenant_blacklist), dictionary_code, term_pattern, matched_text, start_offset, end_offset, dom_path, level, hint, reference_law, acknowledged_in_ack_id(fk to compliance_ack_records, NULL = 未归入任何 ack)`
|
||||
- `compliance_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,
|
||||
@@ -398,6 +500,91 @@ 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 review;cancelled 只保留历史,可重新提交。
|
||||
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
|
||||
@@ -489,6 +676,21 @@ 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 已固定表结构边界。
|
||||
@@ -502,8 +704,8 @@ type GateDecision int
|
||||
|
||||
const (
|
||||
GatePass GateDecision = iota
|
||||
GateBlock // 强制 + block 等级
|
||||
GateNeedsAck // 自愿模式 或 强制模式 high/medium 等级
|
||||
GateBlock // 强制模式任意命中
|
||||
GateNeedsAck // 仅提示确认模式任意命中
|
||||
)
|
||||
|
||||
// tenant/compliance/app/service.go: 发布闸请求、ack 消费参数与服务接口留在 app 层,可依赖 pgx.Tx / uuid
|
||||
@@ -512,7 +714,9 @@ type GateForPublishRequest struct {
|
||||
ArticleID int64
|
||||
ArticleVersionID int64
|
||||
ActorID int64
|
||||
TargetPlatforms []string // 本次发布请求 accounts[].platform 去重后的集合;空 = 全部平台
|
||||
AckRecordID *int64
|
||||
TriggerSource string // publish_gate | scheduler_recheck(详见 §6.12)
|
||||
}
|
||||
|
||||
type ConsumeAckQuery struct {
|
||||
@@ -537,12 +741,46 @@ type ComplianceService interface {
|
||||
}
|
||||
```
|
||||
|
||||
`GateForPublish` 规则:
|
||||
`CheckResult` 必须携带人工审阅状态,供编辑器检测抽屉与发布弹窗复用同一 DTO;前端不再另猜状态:
|
||||
|
||||
- `AckRecordID == nil`:读取当前 plaintext snapshot,运行词典 + 正则,写一条新的 `publish_gate` check_record,并按策略返回 `GatePass / GateBlock / GateNeedsAck`
|
||||
- `AckRecordID != nil`:先读取 ack 绑定的原 check_record;校验 tenant/article/version、`content_hash`、`policy_fingerprint`、未过期、未消费、无 block violation、ack 覆盖全部非 block violation;校验通过直接返回 `GatePass` 和原 check_record,**不再新建第二条 check_record**
|
||||
- ack 校验失败统一返回 `41003 compliance_ack_mismatch`;前端必须重新检测 + 重新 ack
|
||||
- 即使 ack 校验通过,真正的 `consumed_at` 更新也必须等发布 job 创建后,在同一个 DB 事务里调用 `ConsumeAckTx`
|
||||
```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 60s;terminal stamp(approved / rejected)额外写入单调 cache,TTL 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 / timestamps;status = `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` 字段扩展(向后兼容):**
|
||||
|
||||
@@ -602,14 +840,17 @@ effectiveVersionID, err := s.articles.ResolvePublishableVersion(ctx, actor.Tenan
|
||||
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 { /* 按 §4.6 / §6.5.1 fail-open / fail-closed 由 service 内部处理,外层只看 decision */ }
|
||||
if err != nil { return err } // 例如 41004 manual_review_load_failed_no_cache / policy_load_failed_no_cache
|
||||
|
||||
switch decision {
|
||||
case GateBlock:
|
||||
@@ -693,34 +934,22 @@ if d, ok := lookupDurationEnv("COMPLIANCE_PUBLISH_GATE_TIMEOUT"); ok {
|
||||
|
||||
`lookupBoolEnv` 若不存在需要新增(参照 `lookupDurationEnv` / `lookupNonEmptyEnv` 同位置)。`normalize` 阶段对零值兜底:`Enabled` 默认 true、`SnapshotReloadInterval` 默认 30s、`PublishGateTimeout` 默认 800ms。
|
||||
|
||||
短路写在 `compliance_service.go` 门面入口,**不在每个 handler 重复**:
|
||||
config kill-switch 短路写在 `compliance_service.go` 门面入口,**必须在 `GateForPublish` 查人工审阅状态戳之前执行**。`cfg.Compliance.Enabled=false` 是部署级硬关闭,不是普通检测器开关;只用于引擎 / 审阅服务 / DB 事故止血,必须伴随启动 WARN、ops 红条、metric 与 audit log。`Check`(编辑器手动 / 纯文本 API)同样可直接按 config 短路。
|
||||
|
||||
```go
|
||||
func (s *ComplianceService) Check(ctx context.Context, req CheckRequest) (*CheckResult, error) {
|
||||
if !s.cfg.Compliance.Enabled {
|
||||
s.metrics.SkippedTotal.WithLabelValues("config_disabled").Inc()
|
||||
return &CheckResult{Decision: GatePass, Skipped: true, Reason: "compliance_disabled_by_config"}, nil
|
||||
}
|
||||
// ...
|
||||
}
|
||||
|
||||
func (s *ComplianceService) GateForPublish(ctx context.Context, req GateForPublishRequest) (GateDecision, *CheckRecord, error) {
|
||||
if !s.cfg.Compliance.Enabled {
|
||||
s.metrics.SkippedTotal.WithLabelValues("config_disabled").Inc()
|
||||
return GatePass, nil, nil
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
层级覆盖关系:
|
||||
层级覆盖关系(自上而下,任一层短路即决策):
|
||||
|
||||
```
|
||||
config.compliance.enabled = false
|
||||
└─ ops.compliance_policies(scope=global).enforcement_mode = disabled
|
||||
└─ ops.compliance_policies(scope=tenant_override).enforcement_mode = disabled
|
||||
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):
|
||||
@@ -728,24 +957,27 @@ config.compliance.enabled = false
|
||||
```
|
||||
ComplianceService.Check(编辑器手动 / 纯文本 API)
|
||||
│
|
||||
├─ 0. KillSwitch 短路(cfg.Enabled / policy.enforcement_mode)
|
||||
├─ 1. PolicyResolver: 当前租户生效策略 + 白名单(LRU 60s)
|
||||
├─ 2. 纯文本来源 = 请求体直传(前端编辑器实时内容)
|
||||
├─ 3. DictMatcher: AC 自动机一次性扫 exact 词条
|
||||
├─ 4. RegexMatcher: 预编译 RE2 + 有界 worker pool + 总预算(详见 §6.10)
|
||||
├─ 5. WhitelistFilter: 剔除租户白名单覆盖项
|
||||
├─ 6. LLMJudge(同步,500ms 超时降级): 仅对开启的 categories 跑
|
||||
└─ 7. 聚合 → 写 compliance_check_records + violations → 返回完整 DTO
|
||||
├─ 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. KillSwitch 短路
|
||||
├─ 1. PolicyResolver
|
||||
├─ 2. 纯文本来源 = article_versions.plaintext_snapshot 单行 SQL(§4.7)
|
||||
├─ 0. ConfigKillSwitch: cfg.Enabled=false → GatePass(部署级硬关闭)
|
||||
├─ 1. ManualReviewResolver + ArticleVersionComplianceSnapshot 单行读取
|
||||
│ ├─ pending → 41007;approved → GatePass;rejected → 41006
|
||||
│ └─ none → 继续,复用 snapshot.plaintext_snapshot;查询失败无可信 cache → 41004/503
|
||||
├─ 2. PolicyResolver
|
||||
├─ 3. 纯文本来源 = 上一步 snapshot.plaintext_snapshot(§4.7)
|
||||
│ └─ 命中空(旧数据未回填)→ 降级 LoadArticleVersionContent + metric 告警
|
||||
├─ 3. DictMatcher
|
||||
├─ 4. RegexMatcher(发布闸预算更短,详见 §6.10)
|
||||
├─ 5. WhitelistFilter
|
||||
├─ 4. DictMatcher
|
||||
├─ 5. RegexMatcher(发布闸预算更短,详见 §6.10)
|
||||
├─ 6. 聚合(仅词典 + 正则)→ 立即返回 GateDecision
|
||||
└─ 7. 后台异步派发 LLMJudge 到复核队列(不阻塞 caller)
|
||||
└─ 命中 → 写"待复核"violation + 通知运营(不影响本次发布)
|
||||
@@ -758,6 +990,17 @@ ComplianceService.GateForPublish(发布闸)
|
||||
- `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**。
|
||||
@@ -772,16 +1015,23 @@ ComplianceService.GateForPublish(发布闸)
|
||||
|
||||
```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
|
||||
}
|
||||
|
||||
if req.AckRecordID != nil {
|
||||
return s.validateAckForPublish(ctx, req)
|
||||
// (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
|
||||
}
|
||||
|
||||
// (a) 解析策略:失败优先用 last-known cache;都没有则 conservative mandatory
|
||||
// (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 {
|
||||
@@ -793,10 +1043,27 @@ func (s *ComplianceService) GateForPublish(ctx context.Context, req GateForPubli
|
||||
}
|
||||
}
|
||||
|
||||
// (b) 解析后才能基于 policy.Mode 决定后续失败语义
|
||||
plaintext, err := s.repo.GetArticleVersionPlaintext(ctx, req.ArticleVersionID)
|
||||
if err != nil {
|
||||
return s.failByMode(policy.Mode, "plaintext_load_failed"), nil, 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)
|
||||
@@ -821,7 +1088,7 @@ func (s *ComplianceService) failByMode(mode, reason string) GateDecision {
|
||||
- 后台 reloader 每 60s 主动刷一次全量策略,让 cache 不至于无限陈旧
|
||||
- 冷启动期:服务启动时**先**全量加载所有租户策略,加载完成才开服;启动失败 fail-fast(部署侧兜底)
|
||||
|
||||
错误码 41004 仅用于 `policy_load_failed_no_cache` 这种"既无在线策略又无缓存"的极端情况;常规熔断按 fail-closed / fail-open 静默处理。
|
||||
错误码 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 词库快照与热更新
|
||||
|
||||
@@ -884,7 +1151,7 @@ func (s *ComplianceService) failByMode(mode, reason string) GateDecision {
|
||||
|
||||
**发布闸热路径:**
|
||||
|
||||
- 必须只做:解析策略(进程内 last-known cache 优先)、读取 `article_versions.plaintext_snapshot` 一行、内存 AC/regex 扫描、写 `check_records + violations`;不调 LLM、不调 ops-api HTTP、不发通知、不等待复核 worker
|
||||
- 必须只做:解析策略(进程内 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 进程
|
||||
|
||||
@@ -896,6 +1163,14 @@ func (s *ComplianceService) failByMode(mode, reason string) GateDecision {
|
||||
- 单次检测最多落库 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
|
||||
@@ -906,7 +1181,7 @@ func (s *ComplianceService) failByMode(mode, reason string) GateDecision {
|
||||
**索引与保留策略:**
|
||||
|
||||
- 必备索引:`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
|
||||
- 一期不做分区,避免 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,再单独评估对象存储
|
||||
|
||||
**富文本定位:**
|
||||
@@ -914,6 +1189,58 @@ func (s *ComplianceService) failByMode(mode, reason string) GateDecision {
|
||||
- 编辑器手动检测优先提交前端导出的 `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)
|
||||
@@ -929,8 +1256,9 @@ func (s *ComplianceService) failByMode(mode, reason string) GateDecision {
|
||||
| POST | `/api/tenant/compliance/check` | 无文章上下文的纯文本检测 |
|
||||
| GET | `/api/tenant/articles/:id/compliance/latest` | 拿最近一次检测结果 + violations |
|
||||
| POST | `/api/tenant/compliance/check-records/:id/ack` | **粒度:record**。用户标记"该次检测的全部非 block 风险已知悉",返回 `ack_record_id`(一次性、24h TTL,详见 §6.9) |
|
||||
| GET / POST / DELETE | `/api/tenant/compliance/tenant-terms` | 租户白 / 黑名单管理 |
|
||||
|
||||
| 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)):
|
||||
@@ -956,9 +1284,13 @@ articles.GET("/:id/compliance/latest", complianceHandler.LatestCheck)
|
||||
| POST | `/api/ops/compliance/dictionaries/:id/terms:batch-import` | CSV 批量导入 |
|
||||
| POST | `/api/ops/compliance/dictionaries/:id/publish` | 发布版本(bump version + 写 versions 快照) |
|
||||
| POST | `/api/ops/compliance/dictionaries/:id/rollback` | 回滚到指定版本 |
|
||||
| GET / PUT | `/api/ops/compliance/policy/global` | 全局策略 |
|
||||
| GET / PUT | `/api/ops/compliance/policy/tenants/:tenantId` | 租户级覆盖 |
|
||||
| GET | `/api/ops/compliance/stats` | 命中分布 / 拦截率 / 误报率 |
|
||||
| GET / 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. 错误码
|
||||
@@ -967,11 +1299,16 @@ articles.GET("/:id/compliance/latest", complianceHandler.LatestCheck)
|
||||
|
||||
| Code | 含义 | HTTP | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 41001 | `compliance_blocked` | 409 | 强制模式 + block 等级,禁止发布 |
|
||||
| 41002 | `compliance_needs_ack` | 409 | 自愿 / 强制 high+ 模式,需用户 ack 后重发 |
|
||||
| 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. 文件改动清单
|
||||
|
||||
@@ -979,11 +1316,12 @@ articles.GET("/:id/compliance/latest", complianceHandler.LatestCheck)
|
||||
|
||||
- `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/**` — 新增整套 domain(transport / app / engine / repository)
|
||||
- `server/internal/ops/compliance/**` — 新增整套 domain
|
||||
- `server/internal/tenant/app/publish_job_service.go` — `Create()` 入口接 `GateForPublish`;`CreatePublishJobRequest` 加 `ArticleVersionID` / `AckRecordID` 两个可选字段(详见 §6.3)
|
||||
- `server/internal/tenant/compliance/**` — 新增整套 domain(transport / app / engine / repository);`app/manual_review_service.go` 提交 / 撤回审阅 + 状态查询;`app/manual_review_resolver.go` 承载 LRU/terminal cache;`GateForPublish` 在 config kill-switch 之后查 manual review stamp(pending / 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` — 新增 `GetArticleVersionPlaintext` 单行 SQL 读取路径
|
||||
- `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
|
||||
@@ -992,38 +1330,47 @@ articles.GET("/:id/compliance/latest", complianceHandler.LatestCheck)
|
||||
|
||||
### 9.2 Migrations
|
||||
|
||||
- `server/migrations_ops/<ts>_create_compliance_dictionaries.up.sql` — 4 张 ops 表(**SQL 必须用 `ops.` schema 前缀**,详见 §6.2)
|
||||
- `server/migrations_ops/<ts>_seed_compliance_dictionaries.up.sql` — 极限词库种子
|
||||
- `server/migrations/<ts>_create_compliance_tables.up.sql` — tenant 侧 `tenant_terms / check_records / violations / ack_records / review_jobs`(含 `matched_text` CHECK 与必要索引)
|
||||
- `server/migrations/<ts>_add_article_versions_plaintext_snapshot.up.sql` — `article_versions` 加冗余列
|
||||
- `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` 等类型,全部使用 snake_case 字段名
|
||||
- 新增 `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/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,ComplianceDictionariesView,ComplianceDictionaryEditView,ComplianceTenantOverrideView,ComplianceRecordsView,ComplianceStatsView}.vue`
|
||||
- `apps/ops-web/src/router/index.ts` — 新增 6 条路由
|
||||
- `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 vs tenant_override 优先级、灰度规则一致性(`tenant_id % 100`)
|
||||
- `engine`:词典命中、正则命中、LLM 兜底、偏移映射准确性、`matched_text` 写入侧截断到 80 字符
|
||||
- `policy_resolver`:global 策略解析、快照刷新与失败回退
|
||||
- `snapshot_loader`:版本变更检测、原子替换、失败重试退避、并发读
|
||||
- `compliance_service`:
|
||||
- kill-switch 短路(config + policy 两层)
|
||||
@@ -1034,9 +1381,10 @@ articles.GET("/:id/compliance/latest", complianceHandler.LatestCheck)
|
||||
- `ConsumeAckTx`:六元一致校验通过 / 不通过 / ack 已 consume 后再用返回 false / 跨租户 ack 拒绝 / 发布事务失败不消费 ack
|
||||
- `dictionary_service`(ops):发布版本 bump version + 写快照、回滚
|
||||
- `applyEnvOverrides` 测试:4 个 env 变量都能正确覆盖(防 P2-3 复发)
|
||||
- `compliance_policies` 唯一性测试:插入第二条 global 应失败、插入同 tenant 第二条 tenant_override 应失败、CHECK 约束生效
|
||||
- `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 集成测
|
||||
|
||||
@@ -1044,27 +1392,33 @@ articles.GET("/:id/compliance/latest", complianceHandler.LatestCheck)
|
||||
- 旧 client 不传 `article_version_id / ack_record_id` 时的回退行为
|
||||
- ops-api 改策略 → tenant-api 30s 内引擎生效
|
||||
- LLM 计费失败时降级路径
|
||||
- 文章保存触发 `plaintext_snapshot` 写入;发布闸读取 O(1)
|
||||
- 文章保存触发 `plaintext_snapshot` 写入;发布闸通过 `GetArticleVersionComplianceSnapshot` 单行读取 O(1)
|
||||
|
||||
### 10.3 前端
|
||||
|
||||
- `EditorComplianceCheck`:抽屉显示、定位原文、加白名单、忽略
|
||||
- `PublishArticleModal`:`GatePass / GateNeedsAck / GateBlock` 三种渲染;提交时携带 `article_version_id / ack_record_id`
|
||||
- `runtime-status.enabled = false` 时按钮整组消失
|
||||
- `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"}` 上涨 ✅
|
||||
3. config kill-switch 关闭 → 编辑器参考检测仍可用 + 发布器跳过检测 + metric `skipped_total{reason="config_disabled"}` 上涨 ✅
|
||||
4. ops 添加新词条 → 30 秒内编辑器再次检测命中新词 ✅
|
||||
5. ops 回滚到上一版本 → 30 秒内引擎按旧词库判定 ✅
|
||||
6. 租户白名单覆盖 → 命中词条不出现在违规列表 ✅
|
||||
7. 强制模式 + DB 失联(人为断开)→ 发布闸 fail-closed 拦截 ✅
|
||||
8. 自愿模式 + DB 失联 → 发布闸 fail-open 放行 + metric 告警 ✅
|
||||
9. mandatory + LLM 启用 + 文章正文含 LLM-only 风险段落 → 发布闸**放行** + 复核队列出现"待复核"violation + 运营收到通知 ✅
|
||||
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_record,violations 正常落库;发布器不展示平台分组或词条明细;强制模式整体 `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
|
||||
|
||||
@@ -1074,7 +1428,7 @@ articles.GET("/:id/compliance/latest", complianceHandler.LatestCheck)
|
||||
| LLM 兜底成本 | AI 点数过快消耗 | 默认 LLM 关闭;开启时按 `tenant_id % 100` 灰度;ops 端可见每租户 LLM 调用频次 |
|
||||
| 公开词库覆盖度不足(医美 / 教育 / 金融) | 误漏 | 一期接受;上线后基于 ack 数据 + 运营反馈持续补 |
|
||||
| 30s 轮询延迟 | 词库新增最长 30s 才生效 | 一期接受;ops 发布词库会 bump version/revision,tenant-api 30s 内轮询生效;二期再评估 Redis pub/sub |
|
||||
| config kill-switch 误触 | 全站合规失效 | 启动日志 WARN;ops 控制台首页显著标注"config 已关闭"红条;metric + Slack 告警 |
|
||||
| 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 恢复后补发消息 |
|
||||
@@ -1082,18 +1436,43 @@ articles.GET("/:id/compliance/latest", complianceHandler.LatestCheck)
|
||||
| 词条正则表达式恶意 / 性能不佳 | 单条 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 TTL;ack 粒度落到 check_record 级,必须 ack 全部非 block violations 才能生成 ack record(详见 §6.9) |
|
||||
| `PolicyResolver` 失败时静默放行 mandatory 租户 | 强制模式监管失守 | 进程级 last-known cache + 60s 主动刷新;冷启动等全量加载完成才开服;`Resolve` 失败且无 cache 时返回 GateBlock(conservative 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 snapshot;fallback 路径加 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,前端二次确认 dialog;audit 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 只关闭自动检测器,不撤销 approved;config 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`
|
||||
- **观察期:** 阶段 ② 上线后第一周观察 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_check_total{source,level,mode}` / `compliance_duration_ms{phase}` / `compliance_skipped_total{reason}` / `compliance_engine_failures_total{kind}` / `compliance_recheck_blocked_total{platform}`
|
||||
|
||||
## 13. 最终决策清单
|
||||
|
||||
@@ -1102,11 +1481,19 @@ articles.GET("/:id/compliance/latest", complianceHandler.LatestCheck)
|
||||
| 主题 | 最终决策 |
|
||||
| --- | --- |
|
||||
| 发布请求契约 | 直接扩展现有 `/api/tenant/publish-jobs`,新增可选 `article_version_id / ack_record_id`;不新开 v2 路径 |
|
||||
| plaintext 存储 | `article_versions.plaintext_snapshot TEXT NULL` 冗余列;不建子表 |
|
||||
| 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_platforms;fingerprint 把平台集合 hash 进去;mandatory 任意命中 → 整体 GateBlock;advisory 任意命中 → 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` |
|
||||
|
||||
Reference in New Issue
Block a user