主题
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=prod | pytest(新增 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-268applyReplacement被 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);
// 不再调 reExtractAsyncB. 手动改段(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-56purge_*是 DELETE/UPDATE(拿 WAL 写锁):59-79读known_entities/active_facts/open_fs(开读快照):82await ...FACT_EXTRACT).ask()(拿着写锁 + 读快照跨整段 LLM 调用):91-94apply_*(再写)
由此产生两个并发病:
- M2:purge 拿到写锁后跨 LLM 才提交,并发下把所有写者(含用量记账)堵到 LLM 时长。
- 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.pyre-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-58reload()直接api.listChapters写本地态;:231/:280等处只await reload()。useInvalidateProject(:8/:24)只在:64toggleGuard 调过一次——生成/保存/回退/同步全没失效父级缓存。- 父级
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 提交零测试改动)。
建议实施顺序
- P0-①(纯前端、低风险、直接止痛,还顺带降 P0-② 暴露面)→ 立即可发。
- P1-④(小改、和 ① 同属「同步 UX」,一起收)。
- P0-②(动核心生成,先补并发测试再改)。
- P1-③(回归面大,配 Vitest 逐个过)。
- P1-⑤(几行断言 + 人工确认线上 .env)。
- P2 按需清。