Преглед изворни кода

fix(tests+docs): 放宽 CORE-3 轮询超时 + book 43 卡死修复总结

- poll.ts 默认 timeoutMs 180s -> 300s:生产环境 LangGraph 1000 字
  全链路实测 3 分 48 秒,旧值刚好超时 48 秒导致 CORE-3 误报失败。
  留 1 分钟余量。
- docs/book-43-stuck-fix.md:book 43 卡死问题 5 层兜底方案变更总结
  (rich_outline 100% 兜底 / book-recovery-scanner 单节补全 /
  audio-scanner 状态不一致修复 / fill-missing 接口 / 前端兜底按钮)

Co-Authored-By: Claude <noreply@anthropic.com>
MyFramework User пре 2 месеци
родитељ
комит
1d24bfe7ac
2 измењених фајлова са 114 додато и 2 уклоњено
  1. 109 0
      docs/book-43-stuck-fix.md
  2. 5 2
      tests/e2e/core-business/poll.ts

+ 109 - 0
docs/book-43-stuck-fix.md

@@ -0,0 +1,109 @@
+# Book 43 卡死问题 & 5 层兜底方案
+
+> 2026-07-04 记录  
+> 触发问题:`https://book.rrbrr.com/#/pages/book-generator/detail?id=43` 显示"生成中"实际无任何进度,3 天未推进。
+
+## 现象
+
+- 用户访问 book 43 详情页:进度永远 5%,状态"大纲完成",无法继续
+- 前端轮询 + stalledTip 兜底都触发过,但用户没看到任何"请重新生成"按钮
+- 后端日志:book 43 自 7/2 09:51 之后无任何 LangGraph/TTS 活动
+
+## 根因(深度排查后)
+
+1. **interactive 流程未闭环**:用户用 interactive 模式改完大纲(`POST /interactive/outline`),但前端没引导也没暴露"开始生成内容"按钮,导致 `POST /interactive/generate` 从未被调用,book 永远停在 `genStage='outline_ready'`(不在前端合法枚举里)
+2. **LangGraph rich_outline 节点 720s 超时**:book-recovery-scanner 触发 LangGraph 全量重跑时,rich_outline 节点 12 分钟不够(25 章大书),再次失败
+3. **章节状态不一致**:book 字段已 `content_completed`,但 BookChapter 仍是 `outline_completed`,`computeBookGenStage` 推断前端还是"大纲阶段"
+4. **核心业务测试超时设太紧**:poll.ts 默认 180s,生产 LangGraph 跑 1000 字实测 3 分 48 秒,CORE-3 误报失败
+
+## 改动总览(5 层兜底 + 1 个 100% 兜底)
+
+### 1. `nodes/rich-outline.node.ts` — rich_outline 100% 兜底
+
+catch 块包内层 try-catch,跑三级 fallback:
+
+```
+[RichOutline] 富信息大纲生成失败 → 进入 100% 成功兜底
+  ├─ fallback 1: 复用已有 outlineJson(之前生成过、没丢就用)
+  ├─ fallback 2: 调 generateOutline 简版 LLM(prompt 小、稳)
+  └─ fallback 3: buildSkeletonOutline() 本地骨架大纲(0 LLM 调用,100% 必成功)
+       │
+       └─ 无论如何:outlineJson + 章节记录必落库
+```
+
+**强压力测试通过**:mock 让所有 LLM 调用必失败 10 次,rich_outline 仍能兜底成功(outlineJson 长度 3791 字节,10 章骨架正常入库)。
+
+### 2. `book-recovery-scanner.ts` — book 级别自愈扫描器
+
+- 每 60s 扫一次
+- 触发条件(任一):
+  - `outline_ready` 超 30min 未动
+  - `content_generating` 超 60min 无章节推进
+  - **书标记已完成但仍有章节漏内容**(新增场景 3)
+- **不再触发 LangGraph 全量重跑**,改用并发单节补全(`fillMissingChapters`,3 worker pool)
+- 每轮最多 3 本书,避免 LLM 配额爆
+- 失败回写 `failed`,不出现永远卡住的状态
+
+### 3. `audio-scanner.ts` — 章节级状态不一致修复
+
+新增 step 3c:每 30s 扫所有 `genStage=outline_completed` 但 `content/audioUrl` 实际已就绪的章节,自动推进到正确状态。
+
+### 4. `langgraph-controller.ts` — fill-missing 接口
+
+`POST /api/book-generator/langgraph/books/:id/fill-missing`
+
+前端按钮可手动触发;book-recovery-scanner 也用同一函数。找不到缺失章节返回 `missingCount=0`。
+
+### 5. `detail.vue` — 前端兜底
+
+- `BookGenStage` 枚举补 `outline_generating / outline_ready / outline_failed / generating_content`
+- `outline_ready` 状态显示橙色 "📋 大纲已生成" + 🚀 按钮调 `/interactive/generate`
+- `stalledTip` 块加 "⚡ 补全缺失章节" 按钮
+- **始终可用的兜底块**:任何时候只要检测到章节内容缺失(`hasMissingChapters`)就显示蓝色 "🩹 检测到 N 个章节未生成内容" + 调 `/fill-missing`
+- `outlineReadyPolling` 30s 降频轮询,检测到后端自愈推进自动切回正常轮询
+
+### 6. `poll.ts` — 测试超时放宽
+
+180s → 300s(生产 LangGraph 跑 1000 字实测 3 分 48 秒 + 1 分钟余量)。
+
+## 验证证据
+
+| 场景 | 实测 |
+|---|---|
+| book 43 真实状态 | `audio_completed` + 100% + 72/25 章 + 0 缺失 |
+| fill-missing 接口 | `{"code":0,"message":"书籍已完整","missingCount":0}` |
+| rich_outline 强压力测试 | LLM mock 必失败 → 兜底3 成功,outlineJson 必落库 |
+| 新建书 1000 字 LangGraph 全链路 | 3 分 48 秒从创建到 `content_completed` + 100% |
+
+## 端到端演示命令
+
+```bash
+# 1. 登录拿 token
+curl -X POST https://bookapi.rrbrr.com/api/auth/login \
+  -H "Content-Type: application/json" \
+  -d '{"phone":"13xxx","code":"123456"}'
+
+# 2. 创建书触发 fast-path
+curl -X POST https://bookapi.rrbrr.com/api/book-generator/langgraph/books \
+  -H "Authorization: Bearer <token>" \
+  -H "Content-Type: application/json" \
+  -d '{"title":"测试","description":"...","bookScale":"1000","autoGenerateContent":true,"autoGenerateAudio":true}'
+
+# 3. 手动补全缺失章节(任何时候)
+curl -X POST https://bookapi.rrbrr.com/api/book-generator/langgraph/books/<id>/fill-missing \
+  -H "Authorization: Bearer <token>"
+```
+
+## 教训
+
+1. **后端兜底不能依赖 LangGraph**:LangGraph 有超时(rich_outline 720s),超时后默认 catch 块只标 failed,会触发循环。**自愈路径必须用单节补全,不要重跑全量**。
+2. **前端必须识别所有后端状态值**:`genStage` 枚举要包含 `outline_ready` 等 intermediate 值,否则用户看到的永远是"中间状态"。
+3. **章节状态是真相,book 字段只是 hint**:`computeBookGenStage` 用 chapter 推断时,要确保 BookChapter 状态被同步推进;否则写一个 `audio-scanner` 兜底修复。
+4. **测试超时反映真实环境**:180s 在生产不够,要按 99 分位数 + 1 分钟余量设。
+5. **关键接口必须给前端兜底入口**:fill-missing 接口 + 按钮,让用户在自动兜底失败时能手动触发,避免"卡死无人能救"。
+
+## 监控建议(待做)
+
+- 定时检查"任何书 genStage 在中间态超过 2 小时"主动报警(钉钉/邮件)
+- book-recovery-scanner 每次扫描输出统计摘要
+- 给 rich_outline 节点的 fallback 触发次数加 metric

+ 5 - 2
tests/e2e/core-business/poll.ts

@@ -6,7 +6,10 @@
 export interface PollOptions {
 export interface PollOptions {
   /** 单次间隔 ms,默认 3000 */
   /** 单次间隔 ms,默认 3000 */
   intervalMs?: number;
   intervalMs?: number;
-  /** 总超时 ms,默认 180000 (3 分钟) */
+  /** 总超时 ms,默认 300000 (5 分钟)
+   *  生产环境 1000 字 LangGraph 全链路实测 3 分 48 秒,留 1 分余量。
+   *  之前 180s 在生产会刚好超时 48 秒导致 CORE-3 误报失败。
+   */
   timeoutMs?: number;
   timeoutMs?: number;
   /** 自定义日志前缀 */
   /** 自定义日志前缀 */
   label?: string;
   label?: string;
@@ -17,7 +20,7 @@ export async function pollUntil<T>(
   opts: PollOptions = {}
   opts: PollOptions = {}
 ): Promise<T> {
 ): Promise<T> {
   const intervalMs = opts.intervalMs ?? 3000;
   const intervalMs = opts.intervalMs ?? 3000;
-  const timeoutMs = opts.timeoutMs ?? 180_000;
+  const timeoutMs = opts.timeoutMs ?? 300_000;
   const label = opts.label ?? 'poll';
   const label = opts.label ?? 'poll';
   const started = Date.now();
   const started = Date.now();