Explorar o código

fix: 半成品书永久卡'生成中' - 恢复判据+续生成+前端卡死检测

根因(book 39 类问题):
1. resumeInterruptedTasks 只看'有没有章节'就跳过恢复,半成品(部分叶节点空壳)
   被误判为已完成,永远无人接管.
2. 前端 outlining 既非终态也无 failedStage,永久显示'生成中'转圈,
   但后台早已停止.

修复:
1. book-queue.processor.ts: 新增 getLeafContentStatus,按'叶节点内容是否完整'
   判断. 无叶节点→完整恢复; 半成品→告警不自动跑(避免重启误动数据);
   叶节点全有内容→跳过. 实测 book39(3/18)/book17(2/12)正确识别为半成品.
2. /resume 端点: 从只处理 failed 扩展到也补 outline_completed 空壳叶节点,
   复用 generateSingleChapterContent 单章补齐(不重建大纲,保留已有内容),
   音频由 AudioScanner 兜底,完成后推进 book genStage.
3. detail.vue: 轮询加卡死检测(进度+各章 genStage 快照连续~2分钟无变化判定
   后台已停),停止转圈并展示'继续生成'(调 /resume)和'整本重新生成'入口.
MyFramework User hai 2 meses
pai
achega
4160839031

+ 66 - 1
my-uniapp-vue3/src/pages/book-generator/detail.vue

@@ -51,8 +51,17 @@
           <button class="retry-btn" @click="handleRetryGenerate">🔄 重新生成</button>
         </view>
 
+        <!-- 卡死状态:进度长时间无变化,后台疑似已停 -->
+        <view v-if="stalledTip && !isGenerationFailed" class="failed-tip">
+          <text class="failed-icon">⏸️</text>
+          <text class="failed-text">生成似乎已停止</text>
+          <text class="failed-hint">进度长时间未更新,后台任务可能已中断。可继续补齐未完成的章节,或整本重新生成。</text>
+          <button class="retry-btn" @click="handleResume">▶️ 继续生成</button>
+          <button class="retry-btn" style="margin-top: 12rpx; background: #888;" @click="handleLangGraphGenerate">🔄 整本重新生成</button>
+        </view>
+
         <!-- 实时生成状态面板 -->
-        <view v-if="isCurrentlyGenerating && !isGenerationFailed" class="generation-status">
+        <view v-if="isCurrentlyGenerating && !isGenerationFailed && !stalledTip" class="generation-status">
           <view class="status-header">
             <text class="status-icon">⚡</text>
             <text class="status-title">正在生成中...【时间较长,请耐心等待,可关闭页面,后台会继续生成】</text>
@@ -426,6 +435,12 @@ let pollTimer: ReturnType<typeof setInterval> | null = null;
 let audioPollTimer: ReturnType<typeof setInterval> | null = null;
 let videoPollTimer: ReturnType<typeof setInterval> | null = null;
 
+// 卡死检测:进度/阶段长时间无变化则判定后台已停(避免永久"生成中"转圈)
+const stalledTip = ref(false);            // 是否疑似卡死
+let lastProgressSnapshot = '';            // 上次的 progress+genStage 快照
+let stalledTicks = 0;                     // 连续无变化的轮询次数
+const STALLED_THRESHOLD = 40;             // 40 次 × 3s ≈ 2 分钟无变化判定卡死
+
 // 计算属性
 const completedChapters = computed(() => {
   if (!currentBook.value) return 0;
@@ -699,6 +714,10 @@ async function loadBook(id: string) {
 
 function startPollingProgress(bookId: string) {
   stopPollingProgress();
+  // 重置卡死检测
+  stalledTip.value = false;
+  lastProgressSnapshot = '';
+  stalledTicks = 0;
   pollTimer = setInterval(async () => {
     try {
       const book = await api.getBook(bookId);
@@ -726,6 +745,7 @@ function startPollingProgress(bookId: string) {
       if (doneStages.includes(book.genStage)) {
         stopPollingProgress();
         generating.value = false;
+        stalledTip.value = false;
         currentBook.value = book;
         if (book.genStage === 'content_completed' || book.genStage === 'audio_completed' || book.genStage === 'video_completed') {
           uni.showToast({ title: '生成完成!', icon: 'success' });
@@ -734,6 +754,25 @@ function startPollingProgress(bookId: string) {
           const stageLabel = book.genStage === 'content_completed' ? '内容' : book.genStage === 'audio_completed' ? '音频' : '视频';
           notifStore.add({ type: 'content_complete', title: `${stageLabel}生成完成`, message: `《${currentBook.value?.title || '书籍'}》${stageLabel}已生成完毕`, bookId });
         }
+        return;
+      }
+
+      // 卡死检测:进度+阶段连续 STALLED_THRESHOLD 次无变化 → 后台疑似已停
+      // 用每章 genStage 拼快照,能感知"内容在逐章推进"的细微变化,避免误判
+      const chapterSig = (book.chapters || []).map((c: any) => c.genStage).join(',');
+      const snapshot = `${book.progress}|${book.genStage}|${chapterSig}`;
+      if (snapshot === lastProgressSnapshot) {
+        stalledTicks++;
+        if (stalledTicks >= STALLED_THRESHOLD) {
+          // 判定卡死:停止轮询,退出"生成中"假象,提示用户可重新生成/续生成
+          stopPollingProgress();
+          generating.value = false;
+          stalledTip.value = true;
+          console.warn('[Detail] 生成进度长时间无变化,判定后台已停止,停止轮询');
+        }
+      } else {
+        lastProgressSnapshot = snapshot;
+        stalledTicks = 0;
       }
     } catch (e) {
       console.error('轮询进度失败:', e);
@@ -815,6 +854,32 @@ async function handleRetryGenerate() {
   });
 }
 
+// 续生成:只补齐未完成(失败/空壳)的章节,保留已生成的内容,不重建大纲
+async function handleResume() {
+  if (!currentBook.value) return;
+  const bookId = currentBook.value.id;
+  uni.showModal({
+    title: '继续生成',
+    content: '将补齐未完成的章节内容(保留已生成的部分),是否继续?',
+    success: async (res) => {
+      if (!res.confirm) return;
+      try {
+        uni.showLoading({ title: '提交中...', mask: true });
+        await post(`/book-generator/langgraph/books/${bookId}/resume`, {});
+        uni.hideLoading();
+        uni.showToast({ title: '已开始续生成', icon: 'success' });
+        // 切回生成中并重新轮询
+        stalledTip.value = false;
+        generating.value = true;
+        startPollingProgress(bookId);
+      } catch (e: any) {
+        uni.hideLoading();
+        uni.showToast({ title: e?.message || '续生成启动失败', icon: 'none' });
+      }
+    },
+  });
+}
+
 async function handleGenerateAudio() {
   if (!currentBook.value || generatingAudio.value) return;
   uni.showModal({

+ 58 - 13
server/src/modules/book-generator/book-queue.processor.ts

@@ -16,17 +16,19 @@ import { memoryQueue } from '../../services/memory-queue';
  * 服务启动时恢复中断的生成任务
  *
  * 原则:
- *  1. 已有内容产出的书籍不重新生成(保护已有成果)
- *  2. 只恢复真正中断在早期阶段的书籍(无大纲/无内容)
- *  3. 音频遗漏由 AudioScanner 兜底,不需要重跑整个生成流程
+ *  1. 完全没有章节(连大纲都没生成)→ 走完整 generate 恢复
+ *  2. 有大纲但叶节点内容不完整(半成品)→ 仅记录告警,不自动续生成
+ *     (避免服务重启时意外覆盖/重跑用户数据;用户可手动调 /resume 续生成)
+ *  3. 所有叶节点内容齐全 → 真完成,跳过(音频遗漏由 AudioScanner 兜底)
+ *
+ * 判据从"有没有章节"升级为"叶节点内容是否完整",
+ * 修复半成品书(如部分叶节点空壳)被误判为已完成而永久无人接管的问题。
  */
 export async function resumeInterruptedTasks() {
   console.log('[DBQueue] 扫描中断的生成任务...');
 
   try {
-    // 只恢复早期阶段:outlining(无大纲)、outline_completed(有大纲但无内容开始)
-    // 跳过 content_completed / audio_generating / audio_completed / video_generating
-    // 这些阶段的书籍已有产出,音频遗漏由 AudioScanner 兜底
+    // 只关注早期阶段:outlining(无大纲/生成中)、outline_completed(大纲完成待生成内容)
     const earlyStages = ['outlining', 'outline_completed'];
 
     const interruptedBooks = await prisma.book.findMany({
@@ -39,24 +41,34 @@ export async function resumeInterruptedTasks() {
       return;
     }
 
-    // 进一步过滤:如果书籍已有章节(说明之前生成过部分内容),也跳过
     const booksToResume: typeof interruptedBooks = [];
+
     for (const book of interruptedBooks) {
-      const chapterCount = await prisma.bookChapter.count({ where: { bookId: book.id } });
-      if (chapterCount === 0) {
+      const status = await getLeafContentStatus(book.id);
+
+      if (status.leafCount === 0) {
+        // 连大纲/叶节点都没有 → 真正中断在早期,走完整恢复
         booksToResume.push(book);
-        console.log(`[DBQueue] 需恢复: 《${book.title}》 (bookId=${book.id}, stage=${book.genStage}, 章节数=0)`);
+        console.log(`[DBQueue] 需恢复(无叶节点): 《${book.title}》 (bookId=${book.id}, stage=${book.genStage})`);
+      } else if (status.emptyLeafCount > 0) {
+        // 半成品:有叶节点但部分空壳 → 仅告警,不自动续生成(避免重启误动用户数据)
+        console.warn(
+          `[DBQueue] ⚠️ 半成品书未完成: 《${book.title}》 (bookId=${book.id}, stage=${book.genStage}, ` +
+          `叶节点 ${status.filledLeafCount}/${status.leafCount} 有内容,${status.emptyLeafCount} 个空壳)。` +
+          `不自动续生成,用户可调用 POST /api/book-generator/langgraph/books/${book.id}/resume 续生成。`
+        );
       } else {
-        console.log(`[DBQueue] ⏭️ 跳过: 《${book.title}》 (bookId=${book.id}, stage=${book.genStage}, 已有${chapterCount}个章节,不破坏已有成果)`);
+        // 所有叶节点都有内容 → 真完成,跳过
+        console.log(`[DBQueue] ⏭️ 跳过(叶节点内容齐全): 《${book.title}》 (bookId=${book.id}, 叶节点 ${status.leafCount} 全有内容)`);
       }
     }
 
     if (booksToResume.length === 0) {
-      console.log('[DBQueue] 所有早期中断书籍都已有章节产出,跳过恢复(音频由 AudioScanner 兜底)');
+      console.log('[DBQueue] 没有需要自动恢复的任务(半成品书已记录告警,等待手动续生成)');
       return;
     }
 
-    console.log(`[DBQueue] 恢复 ${booksToResume.length} 个真正中断的任务...`);
+    console.log(`[DBQueue] 恢复 ${booksToResume.length} 个真正中断(无内容)的任务...`);
 
     const { langGraphGenerator } = await import('./index.js');
     for (const book of booksToResume) {
@@ -76,6 +88,39 @@ export async function resumeInterruptedTasks() {
   }
 }
 
+/**
+ * 统计一本书叶节点(最大 level 的节点)的内容完整度
+ * 叶节点 = 没有任何子节点的节点(用 parentId 集合判断)
+ */
+export async function getLeafContentStatus(bookId: number): Promise<{
+  leafCount: number;
+  filledLeafCount: number;
+  emptyLeafCount: number;
+  emptyLeafIds: number[];
+}> {
+  const chapters = await prisma.bookChapter.findMany({
+    where: { bookId },
+    select: { id: true, parentId: true, content: true },
+  });
+
+  if (chapters.length === 0) {
+    return { leafCount: 0, filledLeafCount: 0, emptyLeafCount: 0, emptyLeafIds: [] };
+  }
+
+  // 叶节点 = 不是任何节点的 parent
+  const parentIds = new Set(chapters.map(c => c.parentId).filter(Boolean));
+  const leaves = chapters.filter(c => !parentIds.has(c.id));
+
+  const emptyLeaves = leaves.filter(c => !c.content || c.content.trim().length === 0);
+
+  return {
+    leafCount: leaves.length,
+    filledLeafCount: leaves.length - emptyLeaves.length,
+    emptyLeafCount: emptyLeaves.length,
+    emptyLeafIds: emptyLeaves.map(c => c.id),
+  };
+}
+
 /**
  * 初始化(保持兼容,不再需要队列处理器注册)
  */

+ 56 - 22
server/src/modules/book-generator/langgraph-controller.ts

@@ -1308,7 +1308,13 @@ router.get('/books/:id/failed-chapters', async (ctx: Context) => {
 
 /**
  * POST /api/book-generator/langgraph/books/:id/resume
- * 从断点处继续生成(重试失败的小节)
+ * 从断点处继续生成:补齐失败的小节 + 内容空壳的叶节点(不重建大纲,复用已有内容)
+ *
+ * 处理两类未完成叶节点:
+ *  1. genStage='failed' —— 之前生成失败的
+ *  2. content 为空的叶节点(如 outline_completed 空壳)—— 生成中途断流遗留的
+ * 逐个调用 generateSingleChapterContent(只补单章,不碰大纲),
+ * 音频随后由 AudioScanner 兜底入队。
  */
 router.post('/books/:id/resume', async (ctx: Context) => {
   try {
@@ -1325,43 +1331,71 @@ router.post('/books/:id/resume', async (ctx: Context) => {
       return;
     }
 
-    // 获取失败的小节
+    // 取章节树,找出"叶节点"(没有子节点的节点)
     const chapters = await bookStore.getChapterTree(bookId);
-    const failedSubsections = chapters.filter(c => c.genStage === 'failed');
+    const parentIds = new Set(chapters.map((c: any) => c.parentId).filter(Boolean));
+    const leaves = chapters.filter((c: any) => !parentIds.has(c.id));
 
-    if (failedSubsections.length === 0) {
-      ctx.body = { code: 1, message: '没有失败的小节需要重试' };
+    // 需补齐的叶节点:失败的 OR 内容空壳的
+    const incompleteLeaves = leaves.filter((c: any) =>
+      c.genStage === 'failed' || !c.content || String(c.content).trim().length === 0
+    );
+
+    if (incompleteLeaves.length === 0) {
+      ctx.body = { code: 1, message: '没有需要补齐的章节(所有叶节点内容已完整)' };
       return;
     }
 
-    // 重置失败小节的状态为 pending
-    for (const sub of failedSubsections) {
-      await bookStore.updateChapterById(sub.id, {
+    // 重置这些叶节点为 idle(清掉旧的失败标记/空内容),让 generateSingleChapterContent 重新生成
+    for (const leaf of incompleteLeaves) {
+      await bookStore.updateChapterById(leaf.id, {
         genStage: 'idle',
         errorMsg: null,
         content: null,
       });
     }
 
-    // 直接重新生成(跳过队列)
-    const { langGraphGenerator } = await import('./index.js');
-    langGraphGenerator.generate(
-      bookId,
-      book.description || book.title,
-      book.bookScale || '1000'
-    ).then(() => {
-      console.log(`[LangGraph] 续生成完成: bookId=${bookId}`);
-    }).catch(err => {
-      console.error(`[LangGraph] 续生成失败: bookId=${bookId}`, err);
-    });
+    // 标记书籍为生成中
+    await bookStore.update(bookId, { genStage: 'content_generating' });
+
+    // 后台逐个补齐内容(不重建大纲,复用 generateSingleChapterContent)
+    (async () => {
+      let done = 0;
+      for (const leaf of incompleteLeaves) {
+        try {
+          await bookStore.generateSingleChapterContent(bookId, leaf.id);
+          done++;
+          console.log(`[Resume] bookId=${bookId} 补齐进度 ${done}/${incompleteLeaves.length} (chapterId=${leaf.id})`);
+        } catch (err: any) {
+          console.error(`[Resume] bookId=${bookId} chapterId=${leaf.id} 补齐失败:`, err?.message || err);
+        }
+      }
+      // 内容补齐后,根据叶节点实际状态推进书籍 genStage(音频由 AudioScanner 兜底)
+      try {
+        const after = await bookStore.getChapterTree(bookId);
+        const pIds = new Set(after.map((c: any) => c.parentId).filter(Boolean));
+        const leafNodes = after.filter((c: any) => !pIds.has(c.id));
+        const allContentDone = leafNodes.length > 0 && leafNodes.every((c: any) =>
+          c.genStage === 'content_completed' || c.genStage === 'audio_completed' || c.genStage === 'video_completed'
+        );
+        const allAudioDone = leafNodes.length > 0 && leafNodes.every((c: any) =>
+          c.genStage === 'audio_completed' || c.genStage === 'video_completed'
+        );
+        const finalStage = allAudioDone ? 'audio_completed' : allContentDone ? 'content_completed' : 'content_generating';
+        await bookStore.update(bookId, { genStage: finalStage, progress: 100 });
+        console.log(`[Resume] bookId=${bookId} 补齐完成,genStage=${finalStage}`);
+      } catch (err: any) {
+        console.error(`[Resume] bookId=${bookId} 更新最终状态失败:`, err?.message || err);
+      }
+    })();
 
     ctx.body = {
       code: 0,
-      message: `已重启生成,将重试 ${failedSubsections.length} 个失败的小节`,
+      message: `已开始续生成,将补齐 ${incompleteLeaves.length} 个未完成的章节`,
       data: {
         bookId,
-        retryCount: failedSubsections.length,
-        genStage: 'resuming',
+        retryCount: incompleteLeaves.length,
+        genStage: 'content_generating',
       },
     };
   } catch (error) {