docs: 新增代码审查报告 CODE_REVIEW.md(P0/P1/P2 findings + 修复计划)

This commit is contained in:
Yaojia Wang
2026-06-21 19:32:49 +02:00
parent c6651b74b9
commit d65e8b26a3

133
CODE_REVIEW.md Normal file
View File

@@ -0,0 +1,133 @@
# 代码审查报告 — writer-work-flow
> 审查日期2026-06-21 · 分支:`develop`
> 方法7 个并行专项 Agent架构 / FastAPI / Python 质量 / 安全 / 数据库 / React / TypeScript逐文件审读真实代码 + 主控量化扫描。
> 门禁现状(审查中复跑验证):后端 `ruff` clean · `mypy` clean98107 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`)、无 SSRFbase_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 agentgotcha 注明的「故意分散」理由不成立,因仅依赖 `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 — 数据层正确性 & 性能P11 次迁移搞定大部分)
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` timeoutGemini Protocol 改 `async def`@llm
### 阶段 D — 前端健壮性 & a11yP1/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 约束P2DB 边界兜底状态机)。
---
## 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`)。