Files
writer-work-flow/CODE_REVIEW.md

14 KiB
Raw Permalink Blame History

代码审查报告 — 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 全参数化、无硬编码密钥、无 XSSdangerouslySetInnerHTML)、无 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,236domain/chapter_repo.py:130,135 全部改为 flush(),由 upsert_providers/端点统一 commit() 一次。
P0-2 ⊕ 加密密钥空默认 + 异常未兜底。 credential_enc_key 默认 "",进程照常启动接流量;首次凭据读写才抛 CredentialKeyErrorRuntimeError),而 main.py 仅注册了 AppError 处理器 → 裸 500、无 envelope、无 request_id。.env.example 也留空,强化误配路径。 config/settings.py:18security/credentials.pyrouters/settings_providers.py:82main.py _lifespan 启动校验 _fernet(enc_key) 快速失败;并加 catch-all Exception handler 统一 envelope。
P0-3 job.error 直接回传 str(exc) 到前端。 后台任务把原始异常字符串写库并经 GET /jobs/{id} 原样返回、前端直接渲染 → 信息泄露(解密失败提示、内部路径等)。 services/job_runner.py:79routers/jobs.py:32lib/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_atfunc.now()timezone=True,存服务器本地时;ChapterReview.created_at 是「取最新评审」的排序键,跨时区/改时区即静默错乱。 db/base.py:23,27-28;初始迁移全部时间列 DateTime(timezone=True) → TIMESTAMPTZALTER ... AT TIME ZONE 'UTC' 迁移。
P1-2 熔断器对非可重试错误不计数。 Gateway.run 命中不可重试错误401/400/内容策略)时直接 raise,从不 record_failure → 错误 API key 永不触发熔断,每次仍打同一坏 provider。 llm_gateway/gateway.py:211-216 重试前对持续性 4xx至少 401/403record_failure
P1-3 验收热路径缺复合索引。 list_for_chapter(project_id, chapter_no) ORDER BY created_at DESC 查询,仅有单列 project_id 索引;chapter_digests.recent() 同样。projects.owner_id(主访问谓词)、usage_ledgerjobs.statusrules.project_id 均无索引。 初始迁移;review_repo.py:116project_repo.py:91 新迁移加复合/部分索引(见 §4 SQL
P1-4 max_version TOCTOU 竞态。 promote_to_acceptedmax(version) 再插入未加锁,并发验收同一章会撞唯一约束 → 未捕获 IntegrityError 抛 500。save_draft 的 read-then-insert 同类竞态。 domain/chapter_repo.py:160-172104-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。 useStyleLearnprojectIduseKimiOauthrefreshStatus 被排除依赖;切项目/回调变化时会用到旧值。 lib/style/useStyleLearn.ts:64lib/settings/useKimiOauth.ts:78 把稳定回调/值纳入依赖(均已 useCallback,零成本)或用 ref 显式追踪。
P1-7 两处模态框无 focus trap。 CommandPaletteDrawer 声明 aria-modal 但 Tab 可逃逸到背景,违反 WCAG 2.1.2。 components/command/CommandPalette.tsx:99components/Drawer.tsx:26 实现焦点陷阱(focus-trap-react 或自写 Tab 循环)。
P1-8 边界处 as 强转削弱类型保证。 parseSseBlock/parseReviewBlock 末尾 as SseEventserver.ts(await res.json()) as T 在「流→状态」「服务端→组件」两个最关键信任边界绕过结构校验。 lib/stream/sse.ts:42lib/review/sse.ts:142lib/api/server.ts:25,35 改逐事件类型守卫或 Zod 解析后返回,去掉强转。
P1-9 assert 作类型收窄出现在生产适配器路径。 -O 运行时被剥离,output_schema 可能漏成 None adapters/anthropic.py:147adapters/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 / 类型收紧 / 次要硬化)

  • DRYGatewayRun 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} 返回裸 dictStyleFingerprintResponse.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() 无 timeoutKimi token refreshproject_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 方法 ProtocolCommittable)。
  • 冗余/可删强转useRefine.ts:68useCharacterGen.ts:111useStyleLearn.ts:114useForeshadow.ts:63useAutosave.ts:41data 已由生成客户端定型,直接用即可)。
  • tsconfignoUncheckedIndexedAccess → 开启以捕获数组/Record 索引的隐性 undefined
  • CORSallow_credentials=True + allow_methods/headers=["*"] → 收窄白名单,并加启动断言 origins 不含 "*"
  • credential_enc_key 用裸 str → 改 SecretStr 防误序列化。
  • request_id 头未校验middleware.py:22)→ 正则白名单,否则生成新 UUID。
  • 测试用 "x"*44 充当 Fernet key(非法 keysetdefault 有误用真 key 风险)→ conftest 用 Fernet.generate_key()
  • 前端小项Toast 定时器未在卸载清理 + key 用 Date.now()+Math.random()key={index} 用在可重排冲突列表(AnnotatedText.tsx:29ConflictAdjudication.tsx:35ReviewErrorNote<a target=_blank>rel="noopener"ReviewReport.tsx:410,兄弟组件已用 <Link>ReviewReport 在 render 内 segmentText 大文本 split 未 useMemoConflictCard fieldset/legend 误用于 toggle 按钮;useInjection.save 并发无锁。
  • 大文件提示specs.py 403、generation_node.py 307均在 800 硬线内,可后续按域拆分)。
  • 日志未接管 stdlib rootSQLAlchemy/httpx/uvicorn 日志绕过 structlog JSONlogging.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 次迁移搞定大部分)

  1. P1-1 时间列改 TIMESTAMPTZbase.py + 数据迁移 AT TIME ZONE 'UTC')。@db
  2. P1-3 新迁移加索引(见 §4。@db
  3. P1-4 max_versionwith_for_update()(或 save_draft/promote 改 upsert。@db
  4. P1-5 scan_overdue 谓词下推 + 批量 UPDATE。@db

阶段 C — 网关 & 适配器健壮性P1/P2

  1. P1-2 熔断器对非可重试 4xx 计数。@llm
  2. P1-9 assert → 显式 raise ValueError。@llm
  3. P1-10 移除 OAuth user_code 日志。@backend
  4. P2GatewayRun 单点 Protocol_is_transient 共享工具;httpx timeoutGemini Protocol 改 async def。@llm

阶段 D — 前端健壮性 & a11yP1/P2

  1. P1-6 修两处 stale-closure 依赖。@frontend
  2. P1-7 CommandPalette/Drawer 加 focus trap。@frontend
  3. P1-8 SSE 解析与 server.tsas 强转,改类型守卫/校验。@frontend
  4. P2 删冗余强转、开 noUncheckedIndexedAccess、Toast 清理、key 稳定化、rel="noopener"useMemo 大文本、useInjection 加锁。@frontend

阶段 E — codegen & 运维硬化P2

  1. 弱类型响应字段建模(JobResponse/DimensionEntry/ReviewConflictView/selling_points: list[str]+ 路由 responses= 错误声明 → 重生成 TS 客户端。@backend → @frontend
  2. 速率限制(或记 decision 延后、CORS 收窄、SecretStrrequest_id 校验、stdlib 日志接管、测试 Fernet key、provider_deps 调网关 factory、读端点 404 校验。@backend/@devops

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

-- 验收/评审热路径
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)。