# 代码审查报告 — writer-work-flow

> 审查日期：2026-06-21 · 分支：`develop`
> 方法：7 个并行专项 Agent（架构 / FastAPI / Python 质量 / 安全 / 数据库 / React / TypeScript）逐文件审读真实代码 + 主控量化扫描。
> 门禁现状（审查中复跑验证）：后端 `ruff` clean · `mypy` clean（98–107 files）· `pytest` **281 passed** · 前端 `pnpm typecheck`/`next lint` clean。

---

## 1. 总体结论

**架构是合理的，代码整体质量高。** CLAUDE.md 中 9 条架构不变量经逐一对照真实代码 **全部成立**（非仅文档声明）：DB 为唯一真源、agent 仅经 DB 通信、agent 只声明 `tier` 不绑模型/SDK、四审只读 + 验收事务为唯一写入门、digest 从 final_text 抽取、checkpoint 仅存控制流、确定性记忆选择（无向量）、snake_case 契约、Prompt 缓存稳定前缀。Repository/Protocol 解耦贯彻彻底（15 个 Protocol 仓储），SDK 局限在网关内，前端 100% 消费 OpenAPI 生成的类型。

**没有发现 CRITICAL 安全漏洞**：Fernet 静态加密、响应/日志一致脱敏、SQL 全参数化、无硬编码密钥、无 XSS（零 `dangerouslySetInnerHTML`）、无 SSRF（base_url 服务端固定）。前端 TS 异常干净：`strict: true`，零 `as any`/`@ts-ignore`/非空断言。

**主要问题集中在「运维硬化 + 边界健壮性 + 一致性」**，而非架构缺陷。最关键的几项被多个 Agent 独立交叉确认（见下方 ⊕ 标记），可信度高。

| 维度 | 评级 |
|---|---|
| 架构合理性 | ✅ 优秀（9/9 不变量成立） |
| 后端代码质量 | ✅ 良好（ruff/mypy/pytest 全绿） |
| 安全 | 🟡 良好，有 4 项 HIGH 运维硬化缺口 |
| 数据库 | 🟡 设计扎实，时区/索引/事务竞态需修 |
| 前端质量 | ✅ 良好，有 stale-closure + a11y 缺口 |
| 类型安全 | 🟡 优秀，边界处有 `as` 强转削弱保证 |

---

## 2. 按严重度汇总（⊕ = 多 Agent 交叉确认）

### 🔴 P0 — 上生产前必修（正确性 / 安全 / 数据完整性）

| # | 问题 | 证据 | 修复 |
|---|---|---|---|
| P0-1 ⊕ | **仓储内部自提交，破坏事务原子性。** `SqlCredentialStore` 的 upsert/delete/routing 各自 `commit()`，`PUT /settings/providers` 多凭据写入非原子——第 3 条成功、路由失败则 DB 处于半更新且无回滚。`SqlChapterRepo.save_draft` 同样自 `commit()`，违反全仓「仓储只 flush、调用方提交」契约。 | `services/credentials.py:159,194,210,236`；`domain/chapter_repo.py:130,135` | 全部改为 `flush()`，由 `upsert_providers`/端点统一 `commit()` 一次。 |
| P0-2 ⊕ | **加密密钥空默认 + 异常未兜底。** `credential_enc_key` 默认 `""`，进程照常启动接流量；首次凭据读写才抛 `CredentialKeyError`（`RuntimeError`），而 `main.py` 仅注册了 `AppError` 处理器 → 裸 500、无 envelope、无 request_id。`.env.example` 也留空，强化误配路径。 | `config/settings.py:18`；`security/credentials.py`；`routers/settings_providers.py:82`；`main.py` | 在 `_lifespan` 启动校验 `_fernet(enc_key)` **快速失败**；并加 catch-all `Exception` handler 统一 envelope。 |
| P0-3 | **`job.error` 直接回传 `str(exc)` 到前端。** 后台任务把原始异常字符串写库并经 `GET /jobs/{id}` 原样返回、前端直接渲染 → 信息泄露（解密失败提示、内部路径等）。 | `services/job_runner.py:79`；`routers/jobs.py:32`；`lib/jobs/job.ts:102` | `run_job` 按类型分流：`AppError` 存 code+message；其他存通用「任务执行失败」，绝不存 `str(exc)`。 |
| P0-4 | **SSE 流内异常无 error 事件 / 尾部 commit 无兜底。** 评审流 `graph.ainvoke` 直接 await，抛错则流被截断、客户端收不到 `error` 事件；两处生成器尾部 `session.commit()` 失败会静默丢失用量账本记录。 | `routers/projects.py:407`、`:279`、`:413` | `graph.ainvoke` 包 try/except 先 yield `error_event` 再 return；尾部 commit 包 try/except 至少 `log.error`。 |

### 🟠 P1 — 应修（健壮性 / 性能 / 真实 bug）

| # | 问题 | 证据 | 修复 |
|---|---|---|---|
| P1-1 | **时间戳全部 `TIMESTAMP WITHOUT TIME ZONE`。** 16 张表 `created_at/updated_at` 用 `func.now()` 无 `timezone=True`，存服务器本地时；`ChapterReview.created_at` 是「取最新评审」的排序键，跨时区/改时区即静默错乱。 | `db/base.py:23,27-28`；初始迁移全部时间列 | `DateTime(timezone=True)` → TIMESTAMPTZ；加 `ALTER ... AT TIME ZONE 'UTC'` 迁移。 |
| P1-2 | **熔断器对非可重试错误不计数。** `Gateway.run` 命中不可重试错误（401/400/内容策略）时直接 `raise`，从不 `record_failure` → 错误 API key 永不触发熔断，每次仍打同一坏 provider。 | `llm_gateway/gateway.py:211-216` | 重试前对持续性 4xx（至少 401/403）也 `record_failure`。 |
| P1-3 | **验收热路径缺复合索引。** `list_for_chapter` 按 `(project_id, chapter_no) ORDER BY created_at DESC` 查询，仅有单列 `project_id` 索引；`chapter_digests.recent()` 同样。`projects.owner_id`（主访问谓词）、`usage_ledger`、`jobs.status`、`rules.project_id` 均无索引。 | 初始迁移；`review_repo.py:116`、`project_repo.py:91` | 新迁移加复合/部分索引（见 §4 SQL）。 |
| P1-4 | **`max_version` TOCTOU 竞态。** `promote_to_accepted` 读 `max(version)` 再插入未加锁，并发验收同一章会撞唯一约束 → 未捕获 `IntegrityError` 抛 500。`save_draft` 的 read-then-insert 同类竞态。 | `domain/chapter_repo.py:160-172`、`104-137` | `max_version` 查询加 `.with_for_update()`，或 `INSERT ... ON CONFLICT`。 |
| P1-5 | **`scan_overdue` 全表扫 + 逐行更新。** 拉全项目伏笔行到内存再 Python 过滤/逐条 UPDATE。 | `domain/foreshadow_repo.py:201-218` | 谓词下推 SQL `WHERE`，批量 `UPDATE ... RETURNING`。 |
| P1-6 ⊕ | **两处 `useEffect` 抑制 exhaustive-deps，隐藏 stale-closure。** `useStyleLearn` 的 `projectId`、`useKimiOauth` 的 `refreshStatus` 被排除依赖；切项目/回调变化时会用到旧值。 | `lib/style/useStyleLearn.ts:64`；`lib/settings/useKimiOauth.ts:78` | 把稳定回调/值纳入依赖（均已 `useCallback`，零成本）或用 ref 显式追踪。 |
| P1-7 | **两处模态框无 focus trap。** `CommandPalette`、`Drawer` 声明 `aria-modal` 但 Tab 可逃逸到背景，违反 WCAG 2.1.2。 | `components/command/CommandPalette.tsx:99`；`components/Drawer.tsx:26` | 实现焦点陷阱（`focus-trap-react` 或自写 Tab 循环）。 |
| P1-8 | **边界处 `as` 强转削弱类型保证。** `parseSseBlock`/`parseReviewBlock` 末尾 `as SseEvent`、`server.ts` 的 `(await res.json()) as T` 在「流→状态」「服务端→组件」两个最关键信任边界绕过结构校验。 | `lib/stream/sse.ts:42`；`lib/review/sse.ts:142`；`lib/api/server.ts:25,35` | 改逐事件类型守卫或 Zod 解析后返回，去掉强转。 |
| P1-9 | **`assert` 作类型收窄出现在生产适配器路径。** `-O` 运行时被剥离，`output_schema` 可能漏成 `None`。 | `adapters/anthropic.py:147`；`adapters/openai_compat.py:129` | 换成显式 `if ... is None: raise ValueError(...)`。 |
| P1-10 | **OAuth `user_code` 写入结构化日志。** 授权窗口内日志可见者可冒用用户授权意图。 | `routers/kimi_oauth.py:150-155` | 移除 `user_code`，仅留 `request_id`/`job_id` 关联。 |

### 🟡 P2 — 打磨（一致性 / DRY / 类型收紧 / 次要硬化）

- **DRY：`GatewayRun` Protocol 4 处重复**（`orchestrator/{review,generation,outline,style_extract}_node.py`）→ 抽到 `_protocols.py` 单点定义。⊕（Python agent；gotcha 注明的「故意分散」理由不成立，因仅依赖 `ww_llm_gateway.types` 无环）
- **DRY：`_is_transient`/`_TRANSIENT_NAMES` 3 处适配器重复** → 抽到 `adapters/base.py`。
- **codegen 质量：弱类型响应字段。** `GET /jobs/{id}` 返回裸 `dict`、`StyleFingerprintResponse.dimensions/evidence: dict[str, Any]`、`ReviewHistoryItem` 的 JSONB 裸 dict、`selling_points: list[Any]` → 定义具体 Pydantic 子模型，提升 TS 客户端类型。
- **路由无 OpenAPI 错误响应声明**（`responses={404: ErrorEnvelope, ...}`）→ TS 端无类型化错误。
- **缺速率限制**（所有触发 LLM 的端点 + `/oauth/start` + `/settings/providers/test`）→ 至少对 LLM 端点加轻量限流；若接受延后，记入 `memory/decisions.md`。
- **读端点缺 project 存在性校验**：`list_characters`/`list_world_entities`/`list_foreshadow` 不存在的 project 返回空 200 而非 404。
- **`httpx.AsyncClient()` 无 timeout**（Kimi token refresh，`project_deps.py:308`）→ 加 `timeout=30.0`。
- **架构层轻微泄漏（M1）：** `provider_deps.py:13` 直接 `from openai import AsyncOpenAI` 并在 `:31-41` 重复 `_PROVIDER_BASE_URLS`（网关 `factory.py` 已拥有）→ 改调网关 `build_adapter(...)`，base_url 单点归网关。⊕（架构 agent + 主控 grep）
- **`accept_service.py:91` `session: object` + `type: ignore`** → 换 1 方法 `Protocol`（`Committable`）。
- **冗余/可删强转**：`useRefine.ts:68`、`useCharacterGen.ts:111`、`useStyleLearn.ts:114`、`useForeshadow.ts:63`、`useAutosave.ts:41`（`data` 已由生成客户端定型，直接用即可）。
- **`tsconfig` 缺 `noUncheckedIndexedAccess`** → 开启以捕获数组/Record 索引的隐性 `undefined`。
- **CORS：`allow_credentials=True` + `allow_methods/headers=["*"]`** → 收窄白名单，并加启动断言 origins 不含 `"*"`。
- **`credential_enc_key` 用裸 `str`** → 改 `SecretStr` 防误序列化。
- **`request_id` 头未校验**（`middleware.py:22`）→ 正则白名单，否则生成新 UUID。
- **测试用 `"x"*44` 充当 Fernet key**（非法 key，`setdefault` 有误用真 key 风险）→ conftest 用 `Fernet.generate_key()`。
- **前端小项**：`Toast` 定时器未在卸载清理 + key 用 `Date.now()+Math.random()`；`key={index}` 用在可重排冲突列表（`AnnotatedText.tsx:29`、`ConflictAdjudication.tsx:35`）；`ReviewErrorNote` 裸 `<a target=_blank>` 缺 `rel="noopener"`（`ReviewReport.tsx:410`，兄弟组件已用 `<Link>`）；`ReviewReport` 在 render 内 `segmentText` 大文本 split 未 `useMemo`；`ConflictCard` `fieldset/legend` 误用于 toggle 按钮；`useInjection.save` 并发无锁。
- **大文件提示**：`specs.py` 403、`generation_node.py` 307（均在 800 硬线内，可后续按域拆分）。
- **日志未接管 stdlib root**（SQLAlchemy/httpx/uvicorn 日志绕过 structlog JSON）→ `logging.basicConfig` + `ProcessorFormatter`。

---

## 3. 修复计划（分阶段）

### 阶段 A — 安全 & 事务正确性（P0，建议先做，半天内可完成）
1. **P0-1** 移除 `SqlCredentialStore` 4 处 + `save_draft` 2 处 `commit()` → `flush()`；调用方统一 commit。补一条「多凭据一请求」集成测试覆盖部分提交失败回滚。@backend + @db
2. **P0-2** `_lifespan` 启动校验 `_fernet(enc_key)`；注册 catch-all `Exception` handler → `ErrorCode.INTERNAL` envelope。`.env.example` 注明必填。@backend
3. **P0-3** `run_job` 异常分类落库（`AppError` → code+message；其他 → 通用文案）。@backend
4. **P0-4** 两处 SSE 生成器：`graph.ainvoke` 包 try/except yield error_event；尾部 commit 包 try/except 记日志。@backend

### 阶段 B — 数据层正确性 & 性能（P1，1 次迁移搞定大部分）
5. **P1-1** 时间列改 TIMESTAMPTZ（`base.py` + 数据迁移 `AT TIME ZONE 'UTC'`）。@db
6. **P1-3** 新迁移加索引（见 §4）。@db
7. **P1-4** `max_version` 加 `with_for_update()`（或 `save_draft`/`promote` 改 upsert）。@db
8. **P1-5** `scan_overdue` 谓词下推 + 批量 UPDATE。@db

### 阶段 C — 网关 & 适配器健壮性（P1/P2）
9. **P1-2** 熔断器对非可重试 4xx 计数。@llm
10. **P1-9** `assert` → 显式 `raise ValueError`。@llm
11. **P1-10** 移除 OAuth `user_code` 日志。@backend
12. **P2** 抽 `GatewayRun` 单点 Protocol；抽 `_is_transient` 共享工具；`httpx` timeout；Gemini Protocol 改 `async def`。@llm

### 阶段 D — 前端健壮性 & a11y（P1/P2）
13. **P1-6** 修两处 stale-closure 依赖。@frontend
14. **P1-7** `CommandPalette`/`Drawer` 加 focus trap。@frontend
15. **P1-8** SSE 解析与 `server.ts` 去 `as` 强转，改类型守卫/校验。@frontend
16. **P2** 删冗余强转、开 `noUncheckedIndexedAccess`、Toast 清理、`key` 稳定化、`rel="noopener"`、`useMemo` 大文本、`useInjection` 加锁。@frontend

### 阶段 E — codegen & 运维硬化（P2）
17. 弱类型响应字段建模（`JobResponse`/`DimensionEntry`/`ReviewConflictView`/`selling_points: list[str]`）+ 路由 `responses=` 错误声明 → 重生成 TS 客户端。@backend → @frontend
18. 速率限制（或记 decision 延后）、CORS 收窄、`SecretStr`、`request_id` 校验、stdlib 日志接管、测试 Fernet key、`provider_deps` 调网关 factory、读端点 404 校验。@backend/@devops

---

## 4. 建议的索引迁移（P1-3）

```sql
-- 验收/评审热路径
CREATE INDEX ix_chapter_reviews_project_chapter
  ON chapter_reviews (project_id, chapter_no, created_at DESC);
CREATE INDEX ix_chapter_digests_project_chapter
  ON chapter_digests (project_id, chapter_no DESC);
-- 主访问谓词
CREATE INDEX ix_projects_owner_id      ON projects (owner_id);
CREATE INDEX ix_usage_ledger_owner_id  ON usage_ledger (owner_id);
CREATE INDEX ix_usage_ledger_project   ON usage_ledger (project_id);
CREATE INDEX ix_rules_project_id       ON rules (project_id);
CREATE INDEX ix_jobs_status_running    ON jobs (status) WHERE status = 'running';
-- 可选：全局 rules 部分索引（配合 all_for_project 的 OR project_id IS NULL）
CREATE INDEX ix_rules_global           ON rules (id) WHERE project_id IS NULL;
```

并对 `Chapter.status` / `Foreshadow.status` 加 CHECK 约束（P2，DB 边界兜底状态机）。

---

## 5. 已确认安全的事项（非问题）

无硬编码密钥 · SQL 全参数化（无 `text()` 拼接）· LLM 调用日志只记 `input_chars` 长度不记正文/key · OAuth token Fernet 加密落库且不出现在日志/响应/job 结果 · 无 `dangerouslySetInnerHTML` · base_url 服务端固定无 SSRF · 仅 `NEXT_PUBLIC_API_BASE` 暴露前端（非密钥）· 验收事务严格按 §5.5 顺序：冲突门(`projects.py:478`)→事务外从 final_text 抽 digest(`:491`)→原子事务(`:499`)。
