14 KiB
代码审查报告 — writer-work-flow
审查日期:2026-06-21 · 分支:
develop方法:7 个并行专项 Agent(架构 / FastAPI / Python 质量 / 安全 / 数据库 / React / TypeScript)逐文件审读真实代码 + 主控量化扫描。 门禁现状(审查中复跑验证):后端ruffclean ·mypyclean(98–107 files)·pytest281 passed · 前端pnpm typecheck/next lintclean。
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:
GatewayRunProtocol 4 处重复(orchestrator/{review,generation,outline,style_extract}_node.py)→ 抽到_protocols.py单点定义。⊕(Python agent;gotcha 注明的「故意分散」理由不成立,因仅依赖ww_llm_gateway.types无环) - DRY:
_is_transient/_TRANSIENT_NAMES3 处适配器重复 → 抽到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:91session: 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;ConflictCardfieldset/legend误用于 toggle 按钮;useInjection.save并发无锁。 - 大文件提示:
specs.py403、generation_node.py307(均在 800 硬线内,可后续按域拆分)。 - 日志未接管 stdlib root(SQLAlchemy/httpx/uvicorn 日志绕过 structlog JSON)→
logging.basicConfig+ProcessorFormatter。
3. 修复计划(分阶段)
阶段 A — 安全 & 事务正确性(P0,建议先做,半天内可完成)
- P0-1 移除
SqlCredentialStore4 处 +save_draft2 处commit()→flush();调用方统一 commit。补一条「多凭据一请求」集成测试覆盖部分提交失败回滚。@backend + @db - P0-2
_lifespan启动校验_fernet(enc_key);注册 catch-allExceptionhandler →ErrorCode.INTERNALenvelope。.env.example注明必填。@backend - P0-3
run_job异常分类落库(AppError→ code+message;其他 → 通用文案)。@backend - P0-4 两处 SSE 生成器:
graph.ainvoke包 try/except yield error_event;尾部 commit 包 try/except 记日志。@backend
阶段 B — 数据层正确性 & 性能(P1,1 次迁移搞定大部分)
- P1-1 时间列改 TIMESTAMPTZ(
base.py+ 数据迁移AT TIME ZONE 'UTC')。@db - P1-3 新迁移加索引(见 §4)。@db
- P1-4
max_version加with_for_update()(或save_draft/promote改 upsert)。@db - P1-5
scan_overdue谓词下推 + 批量 UPDATE。@db
阶段 C — 网关 & 适配器健壮性(P1/P2)
- P1-2 熔断器对非可重试 4xx 计数。@llm
- P1-9
assert→ 显式raise ValueError。@llm - P1-10 移除 OAuth
user_code日志。@backend - P2 抽
GatewayRun单点 Protocol;抽_is_transient共享工具;httpxtimeout;Gemini Protocol 改async def。@llm
阶段 D — 前端健壮性 & a11y(P1/P2)
- P1-6 修两处 stale-closure 依赖。@frontend
- P1-7
CommandPalette/Drawer加 focus trap。@frontend - P1-8 SSE 解析与
server.ts去as强转,改类型守卫/校验。@frontend - P2 删冗余强转、开
noUncheckedIndexedAccess、Toast 清理、key稳定化、rel="noopener"、useMemo大文本、useInjection加锁。@frontend
阶段 E — codegen & 运维硬化(P2)
- 弱类型响应字段建模(
JobResponse/DimensionEntry/ReviewConflictView/selling_points: list[str])+ 路由responses=错误声明 → 重生成 TS 客户端。@backend → @frontend - 速率限制(或记 decision 延后)、CORS 收窄、
SecretStr、request_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 约束(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)。