Skip to content

06 · 改造方案:同步解耦与并发加固(2026-07-23) ​

本文是待审方案,不含已落地代码。来源于三条独立评审的合并: (1) 用户反馈的痛点「润色/手改触发上下文同步、卡住阅读」; (2) 前端代码评审(7/10);(3) 后端代码评审(7/10)。 每条改动都精确到 文件:行号 与函数,附前后对比、风险点、验证方式。

实施状态(2026-07-23) ​

P0 + P1 五项已全部实施并验证(尚未 commit,待审)。四个「二选一」按推荐落地: 手改留「可选非阻塞」同步 / syncJob 改 Map 按章号 / P1-⑤ 用 APP_ENV 门控 / P0-② 先做 extract_and_apply 自洽。

编号状态落地要点验证
P0-①✅Reader.tsx 润色 apply 不再同步;手改改「可选非阻塞角标」;新增阅读器同步角标/询问条tsc / eslint / vitest(18) / build
P0-②✅extract_and_apply 自洽事务(入口丢快照 + LLM 前后各提交);新增并发回归测试pytest(122)、回归测试实测:去掉「LLM 前提交」立刻复现 database is locked
P1-③✅ChaptersPanel 接 useChapters,reload→invalidateProject(顶栏统计一并刷新)tsc / eslint / vitest
P1-④✅pendingSync 绑定 current.chapter_number 根治串章;syncJob→按章号 Map 根治竞态tsc / eslint
P1-⑤✅app_env + _assert_secure_config 启动自检;docker-compose 加 APP_ENV=prodpytest(新增 3 个安全测试)

一处刻意的偏差(P0-② 尾部重试):原计划「生成 runner 加遇锁重试」未做。原因: extract_and_apply 自洽已从根上消除 S1(读快照升级写锁的 SQLITE_BUSY_SNAPSHOT, 回归测试实锤),残余的普通写竞争由 busy_timeout=30000 兜底;而尾部重试要重构 generate_chapter 尾段(与 outline/memory 局部纠缠)、动核心生成路径且收益有限。已留 graceful degradation:尾段万一失败,正文已在 :338 落库,可用带重试的 re-extract 补同步。

P2 技术债:未动(见文末清单),待单独排期。


优先级总表 ​

编号优先级一句话主战场风险
P0-①🔴阅读器润色/手改不再强制阻塞同步Reader.tsx低(纯前端编排)
P0-②🔴database is locked 根因下沉进 extract_and_apply,一次修好 3 个调用方extractor.py中(动核心生成路径,无测试兜底)
P1-③🟠ChaptersPanel 并入 React Query,消除「双真相」陈旧数据ChaptersPanel.tsx中(改数据源,回归面大)
P1-④🟠pendingSync 切章残留 + syncJob 角标竞态ChaptersPanel.tsx低
P1-⑤🟠启动 fail-fast 兜住弱默认密钥(非 compose 启动)main.py/config.py低
P2🟡死代码清理、M4/M5/M6、死持久化、补并发测试、a11y前后端—

关键协同:P0-① 修好后,后端那条最爱锁的重路径(re-extract)被触发频率大幅下降——因为占绝大多数的「润色」不再走它。所以 P0-① 既止用户的痛,又顺带降低 P0-② 的暴露面。建议 P0-① 先做。


P0-① 阅读器润色/手改不再强制阻塞同步 ​

现状与证据 ​

  • Reader.tsx:238-268 applyReplacement 被 AI 润色应用(applyPolish)和手动改段(editOpen)共用。
  • 它存盘后无条件发起 reExtractAsync + pollJob(:255-260),面板要等 job 跑完才关(:261-263),中途只能干等 spinner「同步一致性引擎…」。
  • BookReader.tsx:109-113 也传了 polishCtx,所以「阅读全书时润色一段」同样触发——正是「无法正常阅读」的现场。
  • 后端 re-extract-async(chapters.py:276-345)第 2 步 rebuild_summaries_after(chapter.py:109-174)对改动章之后所有已写章逐章各调一次 LLM:改第 3 章、后写 50 章 = 47 次串行 LLM,数分钟。

为什么是设计错误 ​

  • 润色接口自己写着**「只改文笔不改情节」(polish.py:177)。情节/人物/伏笔都没变,重抽圣经、重建后续所有章摘要在语义上是做无用功**;更糟,重抽是让 LLM 从「只改几个字」的正文里重推状态,有随机性,可能把对的圣经改漂移。
  • 阅读只读 final_content(已即时更新),下游摘要/圣经只影响「生成未来章节」——所以跳过同步对当前阅读零影响,只是把一致性上下文的刷新推迟到「用户下次真的续写时」(那时本就会重算)。

改动方案 ​

把「改了什么」和「要不要重建下游」解耦,按修改性质分级:

A. 润色应用(applyPolish → applyReplacement)——根本不同步

// 现状:存盘 → 必然 reExtractAsync + pollJob(阻塞)
// 改为:存盘 → onApplied 回填 → 直接关闭,零等待
const updated = await api.editChapterContent(pid, chapterNumber, newContent);
polishCtx.onApplied(updated);
closePolish(); setEditOpen(false); setSelPara(null);
// 不再调 reExtractAsync

B. 手动改段(editOpen → applyReplacement)——存盘即时,同步「可选+非阻塞」

  • 与写作页(ChaptersPanel 的 pendingSync)对齐:存完弹一句「要同步一致性引擎吗?(改了情节才需要)」,默认可跳过。
  • 阅读器目前没有任何同步角标,需补一个(和写作页 sync-badge 同款),同步任务丢后台角标里跑,不挡翻章/继续读。
  • 需同步更新 Reader.tsx:492 的文案:「保存后自动同步一致性引擎」→「保存后可选同步(改了情节才需要)」。

拆分建议 ​

  • applyReplacement(replacement, { sync }) 加一个 sync 布尔参:applyPolish 传 sync:false,手改按用户选择。或干脆拆成 applyPolishReplacement(不同步)与 applyManualReplacement(问一句)两条,读起来更清楚。

风险点 ​

  • applyReplacement 里 source.indexOf(selText)(:242)精确匹配替换第一次出现——这条逻辑不动,保持不变。

验证 ​

  • 阅读全书 → 润色一段 → 点「替换原文」:文字立即更新、面板立即关、可继续读/翻章,零 spinner。
  • 手改一段 → 保存:立即生效;若选「同步」,角标出现在后台,不挡操作。

P0-② database is locked 根因:把 commit 纪律下沉进 extract_and_apply ​

现状与证据(已对代码确认) ​

extract_and_apply(extractor.py:42-95)的结构是 purge(写) → 读提示输入 → LLM → apply(写),全程没有一次内部 commit:

  • :53-56 purge_* 是 DELETE/UPDATE(拿 WAL 写锁)
  • :59-79 读 known_entities/active_facts/open_fs(开读快照)
  • :82 await ...FACT_EXTRACT).ask()(拿着写锁 + 读快照跨整段 LLM 调用)
  • :91-94 apply_*(再写)

由此产生两个并发病:

  1. M2:purge 拿到写锁后跨 LLM 才提交,并发下把所有写者(含用量记账)堵到 LLM 时长。
  2. S1(严重):generate_chapter 在调它之前先跑了 check_chapter(chapter.py:343,圣经非空时发 LLM)、中间没 commit(:343→:350 之间无 commit),留下过期读快照;extract_and_apply 的 purge 写去升级这个过期快照 → SQLITE_BUSY_SNAPSHOT(WAL 下不走 busy_timeout,直接 database is locked)。而生成 async runner(chapters.py:164-166)遇异常只 fail_job、无重试。

昨天的 455e6f0 只在 re-extract 路径手工穿了 commit,主生成路径(S1)、拆章路径(M2)没动。 触发条件精确:早期章圣经为空、check_chapter 提前 return 不发 LLM(checker.py:35)→ 快照不过期 → 安全;书越长、圣经非空,越容易在「4/5 抽取」处随机崩、整章失败。这解释了为什么短测/早期手测不复现。

改动方案(核心) ​

把 commit 纪律下沉进 extract_and_apply 自身,让它自洽——一次修好全部三个调用方(生成 / 拆章 / re-extract),不再靠每个调用点手工穿线。

python
async def extract_and_apply(db, project_id, chapter_number, chapter_text) -> dict:
    # 0. 丢掉调用方可能遗留的读快照(check_chapter 等读过又没提交),否则第一条 purge 写会撞 BUSY
    db.commit()
    bible = BibleService(db, project_id)
    scheduler = ForeshadowScheduler(db, project_id)

    # 1. 清旧账(写)+ 读取抽取提示输入(读)→ 提交:既持久化 purge 又释放读快照
    purge_stats = {"bible": bible.purge_chapter_extraction(chapter_number),
                   "foreshadow": scheduler.purge_chapter_ops(chapter_number)}
    known_entities = ...        # 读
    active_facts = bible.hard_constraints_block(chapter_number)   # 读
    open_fs = ...               # 读
    prompt = EXTRACTION_PROMPT.format(...)
    db.commit()                 # ← 关键:LLM 前提交,别拿着写锁+读快照跨 LLM

    # 2. LLM(此刻无锁无快照)
    try:
        raw = await get_adapter_for(Task.FACT_EXTRACT).ask(prompt)
    except Exception:
        return {}

    # 3. 应用(写)→ 提交
    extraction = parse_llm_json(raw)
    if not extraction:
        return {}
    bible_stats = bible.apply_extraction(chapter_number, extraction)
    fs_stats = scheduler.apply_ops(chapter_number, extraction.get("foreshadow_ops") or [])
    db.commit()
    return {"bible": bible_stats, "foreshadow": fs_stats, "purged": purge_stats}

语义不变:purge 仍在 read/LLM 之前(active_facts 依然看到「本章之前」的状态,re-extract 幂等性保留);crash 落在「purge 已提交、apply 未跑」时,本章抽取会暂时为空,重跑 re-extract 即恢复(purge 幂等,重跑无副作用)。

调用方随之简化:

  • generate_chapter:354 那句 db.commit() 变冗余(可留,无害)。S1 被 extract_and_apply 的入口 commit 化解。
  • word_guard._split_chapter 的 extract 调用同样受益(M2 缓解)。
  • chapters.py re-extract 里手工的 pre-commit 变冗余(可保留作双保险)。

改动方案(兜底) ​

生成/队列 runner 补「遇锁整体重试」。注意幂等性:

  • 章体已在 chapter.py:338 提交,其后的尾部(check→extract→summary→memory→rebuild)全部幂等(extract 幂等 / summary 覆盖写 / memory 删后插 / rebuild 逐章覆盖)。
  • 所以安全做法是只对「:338 之后的尾部」加锁重试,别 blanket 重试整个 generate_chapter(:327 的 snapshot_chapter 版本快照、用量记账不幂等,重试会造重复版本/重复计费)。
  • 实现上可把尾部抽成一个 _finalize_consistency(...) 内部函数,套用 re-extract 已有的 for attempt in range(...) + _db_locked 退避逻辑。

风险点 ​

  • 无测试兜底:现有 test_async_jobs.py 是 mock LLM 秒回、单连接,结构上复现不了这个竞态。改完必须补一个「LLM 里 sleep + 另一连接并发 commit」的回归测试,否则等于没验证(见 P2)。
  • db.commit() 会结束当前事务,调用方若假设「extract 和后续写在同一事务里原子提交」会被打破——已核对三个调用方均把 extract 当作提交边界,不受影响。

验证 ​

  • 造一本 ≥10 章、圣经非空的书,连续生成后续章,观察不再随机 database is locked。
  • 新增并发测试:mock 的 FACT_EXTRACT 里 await sleep 期间,另起连接 commit 一条 usage,断言 extract_and_apply 不抛 BUSY。

P1-③ ChaptersPanel 并入 React Query(消除双真相) ​

现状与证据 ​

  • ChaptersPanel.tsx:25 自建 const [chapters,setChapters]=useState;:55-58 reload() 直接 api.listChapters 写本地态;:231/:280 等处只 await reload()。
  • useInvalidateProject(:8/:24)只在 :64 toggleGuard 调过一次——生成/保存/回退/同步全没失效父级缓存。
  • 父级 ProjectPage.tsx 的顶栏统计(doneCount/wordsTotal/staleCount)、BookReader 的目录全依赖 RQ 缓存 → 生成新章后不刷新,全书目录里新章置灰不可点,直到整页刷新。
  • 好消息:useChapters(pid)(queries.ts:40)与 useInvalidateProject(:50)都已存在,只是没被用。改造是「接线」不是「造轮子」。

改动方案 ​

// 删本地态与 reload:
- const [chapters, setChapters] = useState<ChapterBrief[]>([]);
- const reload = useCallback(async () => setChapters(await api.listChapters(pid)), [pid]);
- useEffect(() => { reload()... }, [reload]);
+ const { data: chapters = [] } = useChapters(pid);

// 每处 `await reload()` → `await invalidateProject()`
// (startQueue / trackGenerate / saveEdit / restoreVersion / 挂载重连)
  • current(单章全文 detail)保持本地 state 不变——它不在列表 query 里。
  • 挂载重连查 runningJobs 的 effect(:83-122)保留,但完成回调里的 reload() 改 invalidateProject()。

风险点 ​

  • 同步性差异:reload() 是「await 后本地态即刷新」;invalidateProject() 触发重新拉取,chapters 在下一次渲染才更新。已核对各调用点(生成/保存/回退)在同一 tick 内不依赖刚刷新的 chapters,安全。若发现某处依赖,可改用 queryClient.setQueryData 乐观写入。
  • 回归面较大(七件事都读 chapters),建议配合 Vitest 快照测试逐个过。

验证 ​

  • 生成第 1 章后:顶栏「正文 N 章·M 字」、流程导航、智能建议立即刷新;「阅读全书」目录里新章可点。

P1-④ pendingSync 残留 + syncJob 竞态 ​

④a pendingSync 切章残留 ​

  • 证据::518-531 同步询问条文案硬编码「第 {pendingSync} 章」,但 open()(:199)与看板跳章 effect(:125)切 current 时都没清 pendingSync。编辑第 5 章保存→不点→点开第 3 章 → 第 3 章头上挂着「第 5 章已保存,要同步吗?」。
  • 改法(二选一,推荐后者):
    • open()/跳章 effect 里补 setPendingSync(null);
    • 或渲染条件从 pendingSync !== null 改为 pendingSync === current?.chapter_number,天然不串章。

④b syncJob 单例竞态(角标提前消失) ​

  • 证据:syncJob/syncAbortRef 是单例(:30/:52);restoreVersion 里 void triggerSync(n)(:345)无 !!syncJob 守卫。两个 sync 并发时共享 syncJob,先完成的把 syncJob 置 null,把还在跑的角标清掉(引擎实际还在跑)。
  • 改法(二选一):
    • 最小:所有 triggerSync 入口加 if (syncJob) return/禁用按钮(单用户够用);
    • 彻底:syncJob 改成 Map<number, stage>、syncAbortRef 也按章号存,渲染每章一个角标,允许多章并发各自独立收尾。

风险 / 验证 ​

  • 风险低。验证:保存 A 章起同步 → 立刻回退 B 章版本 → 两个角标都在、各自完成各自消失,不互相清空。

P1-⑤ 启动 fail-fast 兜住弱默认密钥 ​

现状(已核实,非火警) ​

  • config.py:74/78 仍留弱默认 jwt_secret="change-me..."、admin_password="admin12345"。
  • 但 docker-compose.yml:17/21 用 ${JWT_SECRET:?...}/${ADMIN_PASSWORD:?...}——没设直接拒绝启动。线上是 compose 部署,站在跑 = 这俩必然已设,不存在任意接管。
  • 残留风险仅在「不走 compose、裸 uvicorn/docker run 起服务」时:弱默认会静默生效。

改动方案(廉价兜底) ​

在 main.py 启动处(或 get_settings 后)加断言:

python
# 非本地环境下仍是默认密钥 → 直接 fail-fast,别让弱密钥静默上公网
if settings.jwt_secret == "change-me-in-production-please-use-a-random-secret":
    if not settings.database_url.endswith("./jarvis_write.db"):  # 或用显式 ENV=prod 判据
        raise RuntimeError("JWT_SECRET 未设置为随机值,拒绝启动(见 docs/06)")
  • 判据用一个显式的 APP_ENV=dev|prod 更清楚(默认 prod)。
  • 另需人工确认一次:线上 .env 的 JWT_SECRET 是足够长的随机串(既然站在跑就已设,只需确认强度)。

P2 · 技术债清单(有空再清,不阻塞) ​

前端

  • 删死代码:hooks/useAsyncAction.ts(全项目零引用)、api.ts 里 generateChapter/polishChapter/polishSegment/inspire/cascade/impact/parseEditDirective 等未用同步端点。
  • 三套异步封装(useJob / useAsyncAction / 手搓 AbortController+pollJob)统一到 useJob;让 ChaptersPanel 生成任务也进任务中心(现要等 15s 慢轮询才冒出来)。
  • pollJob 抛自定义错误类(JobTimeoutError/JobPollError)替代 msg.startsWith("任务超时") 字符串判型(:255/287)。
  • Tendency 给结构化 interface(替 Record<string,unknown>);getJob 用泛型替假类型。
  • a11y:可点 <span/div onClick> 换 <button> 或补 role/tabIndex/onKeyDown;Reader 自绘弹层套 Radix Dialog 或加 aria-modal+焦点陷阱。
  • 拆 api.ts(498 行 god-file)为 types.ts + 按域分文件;抽 errMsg(e)/safeLocalStorage。

后端

  • M1 死持久化:jobs.py 的 SQLite 只写不读(重启后 GET /api/jobs/{id} 对在跑任务一律 404)。二选一:get_job 内存 miss 时回落查 DB(让持久化真生效),或删掉这层(别给热路径白加同步写)。
  • M4 拆章重复 LLM:_split_chapter 内部已 extract+summary,返回后 generate_chapter 又重跑一遍——每次拆章白烧数次 quality 档。把拆章干净并进流水线尾部。
  • M5 伏笔匹配:foreshadow.py:65-80 双向子串 a in b or b in a 可能绑错伏笔、静默改错状态。改精确 id/规范化匹配。
  • M3 TOCTOU:job 去重是 check-then-create;jobs.py 提供一个加锁的「空闲则占位」原语。
  • M6 god-module:projects.py(743 行)把滚动蓝图规划器挪进 engines/pipeline,异步入口统一走 spawn_job;路由里的 prompt 常量移到 app/prompts/。
  • 轻微:parse_llm_json 复用(word_guard 内联了第二份);openai_compatible.complete() 对 200+错误体做 KeyError 防御;抽取失败作标志位透传到结果卡(别静默退化)。

测试(重要)

  • 现有 3578 行测试全是 mock LLM 秒回、单连接,复现不了并发锁。补一个「LLM 中 sleep + 并发 commit」的回归,专门守 P0-② 这类 bug——否则每次锁修复都无 CI 验证(最近 8 提交零测试改动)。

建议实施顺序 ​

  1. P0-①(纯前端、低风险、直接止痛,还顺带降 P0-② 暴露面)→ 立即可发。
  2. P1-④(小改、和 ① 同属「同步 UX」,一起收)。
  3. P0-②(动核心生成,先补并发测试再改)。
  4. P1-③(回归面大,配 Vitest 逐个过)。
  5. P1-⑤(几行断言 + 人工确认线上 .env)。
  6. P2 按需清。

基于 Apache License 2.0 开源