Files
writer-work-flow/CODE_REVIEW.md

134 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 代码审查报告 — 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`)。