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