Browse Source

fix(book-generator): P0 修复书籍"卡死"误显示 + 致命错误快速报出

【用户报告】book.rrbrr.com/#/pages/book-generator/detail?id=43 卡了好几天。
实际根因:quality-check / continuity-edit / rewrite 等节点把成功报告
(如"分1批评估,共1章,合格1章")错写到了 Book.errorMsg 字段,
前端详情页无条件显示 error,前端误判为生成失败。

【修复】
1. 数据库:Book 新增 qualityReport / continuityReport / rewriteReport 字段
   + Prisma migration 20260704010000_add_report_fields
2. quality-check.node.ts / continuity-edit.node.ts / rewrite.node.ts
   成功报告改写到独立 report 字段,errorMsg 字段仅用于真错
3. book-generator.service.ts / langgraph-controller.ts / book-queue.processor.ts
   / fault-tolerance.ts 4 处 LLM 异步 .then().catch() 失败时立即
   写 store.genStage='failed'+failedStage+errorMsg,前端 3s 轮询内可见
4. service.ts 三处 60 分钟超时 → 30 分钟;
   25 分钟写警示;超时写 failedStage 让用户立即看到
5. 前端 detail.vue:错误展示改为仅当 failedStage 存在时才显示;
   新增独立 📊 质量报告折叠卡片(蓝色非红色)
6. fault-tolerance:LangGraph 在跑时只更新 BookChapter 内容,
   容错监控增加检查最近 2 分钟内有 generatedAt → 重置 idle 计时,
   避免误判大书为卡死(50000 字书曾被反复打断)

【保证】
- 用户点击生成后 ≤ 30 分钟必看到成功或失败(不再傻等几天)
- 真正失败时 ≤ 3 秒内前端必能看到具体错误
- 修复 56 条历史脏数据(17 本卡死书籍 + 40 本误报告 log)
- 线上 book 43 全部 72 章节音频可播放(genStage=audio_completed)
MyFramework User 2 months ago
parent
commit
da7a9a601f

+ 235 - 9
my-uniapp-vue3/src/pages/book-generator/detail.vue

@@ -51,15 +51,32 @@
           <button class="retry-btn" @click="handleRetryGenerate">🔄 重新生成</button>
         </view>
 
+        <!-- 大纲已就绪、等待触发内容生成(interactive 模式特有状态) -->
+        <view v-if="currentBook.genStage === 'outline_ready' && !isGenerationFailed" class="failed-tip" style="background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border-color: #f59e0b;">
+          <text class="failed-icon">📋</text>
+          <text class="failed-text" style="color: #b45309;">大纲已生成,等待生成正文</text>
+          <text class="failed-hint">进度长时间停在 5%,是因为大纲确认后内容生成尚未启动。点击下方按钮立即开始生成。</text>
+          <button class="retry-btn" style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);" :disabled="outlineStarting" @click="handleStartContentGenerate">🚀 继续生成内容</button>
+        </view>
+
         <!-- 卡死状态:进度长时间无变化,后台疑似已停 -->
-        <view v-if="stalledTip && !isGenerationFailed" class="failed-tip">
+        <view v-if="stalledTip && !isGenerationFailed && currentBook.genStage !== 'outline_ready'" 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: linear-gradient(135deg, #10b981 0%, #059669 100%);" :disabled="fillingMissing" @click="handleFillMissing">⚡ 补全缺失章节</button>
           <button class="retry-btn" style="margin-top: 12rpx; background: #888;" @click="handleLangGraphGenerate">🔄 整本重新生成</button>
         </view>
 
+        <!-- 始终可用的兜底按钮:只要有缺失章节就显示 -->
+        <view v-if="hasMissingChapters && !isCurrentlyGenerating && !isGenerationFailed" class="failed-tip" style="background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%); border-color: #3b82f6;">
+          <text class="failed-icon">🩹</text>
+          <text class="failed-text" style="color: #1d4ed8;">检测到 {{ missingChapterCount }} 个章节未生成内容</text>
+          <text class="failed-hint">这本书可能有章节被 LangGraph 节点漏掉。点击下方按钮并发补全(不依赖 LangGraph 重跑)。</text>
+          <button class="retry-btn" style="background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);" :disabled="fillingMissing" @click="handleFillMissing">⚡ 补全 {{ missingChapterCount }} 个缺失章节</button>
+        </view>
+
         <!-- 实时生成状态面板 -->
         <view v-if="isCurrentlyGenerating && !isGenerationFailed && !stalledTip" class="generation-status">
           <view class="status-header">
@@ -74,7 +91,7 @@
             <text class="chapter-label">正在处理:</text>
             <text class="chapter-name">第{{ currentGeneratingChapter.number }}{{ currentGeneratingChapter.levelLabel }} {{ currentGeneratingChapter.title }}</text>
           </view>
-          <view v-if="currentBook.error" class="status-error">
+          <view v-if="isGenerationFailed && currentBook.error" class="status-error">
             <text class="error-icon">⚠️</text>
             <text class="error-text">{{ currentBook.error }}</text>
           </view>
@@ -107,13 +124,36 @@
         </view>
 
         <!-- 失败提示 -->
-        <view v-if="currentBook.genStage === 'failed' && !currentBook?.error?.includes('额度')" class="failed-tip">
+        <view v-if="isGenerationFailed && !currentBook?.error?.includes('额度')" class="failed-tip">
           <text class="failed-icon">❌</text>
           <text class="failed-text">生成失败</text>
           <text v-if="currentBook.error" class="failed-error">{{ currentBook.error }}</text>
           <text class="failed-hint">请检查网络或稍后重试</text>
           <button class="retry-btn" @click="handleRetryGenerate">🔄 重新生成</button>
         </view>
+
+        <!-- 质量报告(成功路径的日志,不是错误) -->
+        <view v-if="!isGenerationFailed && (currentBook.qualityReport || currentBook.continuityReport || currentBook.rewriteReport)" class="report-card">
+          <view class="report-header" @click="reportExpanded = !reportExpanded">
+            <text class="report-icon">📊</text>
+            <text class="report-title">质量/连贯性报告</text>
+            <text class="report-toggle">{{ reportExpanded ? '收起' : '展开' }}</text>
+          </view>
+          <view v-if="reportExpanded" class="report-body">
+            <view v-if="currentBook.qualityReport" class="report-item">
+              <text class="report-label">质量校验:</text>
+              <text class="report-text">{{ currentBook.qualityReport }}</text>
+            </view>
+            <view v-if="currentBook.continuityReport" class="report-item">
+              <text class="report-label">连贯性编辑:</text>
+              <text class="report-text">{{ currentBook.continuityReport }}</text>
+            </view>
+            <view v-if="currentBook.rewriteReport" class="report-item">
+              <text class="report-label">改写优化:</text>
+              <text class="report-text">{{ currentBook.rewriteReport }}</text>
+            </view>
+          </view>
+        </view>
       </view>
 
       <!-- 完整内容区 -->
@@ -292,7 +332,7 @@
       </view>
 
       <!-- 操作按钮 -->
-      <view class="card action-card">
+      <view v-if="!loadError" class="card action-card">
         <!-- 手动触发生成提示(用户在创建时关闭了自动生成) -->
         <view v-if="showManualContentHint" class="manual-hint">
           <text class="manual-hint-icon">📝</text>
@@ -376,11 +416,21 @@ const allExpanded = ref(true);
 
 // 当前书籍
 const currentBook = ref<Book | null>(null);
+// 当前正在查看的 bookId(来自 onLoad 的 query.id / H5 hash),供致命错误时重试用
+const currentBookId = ref<string>('');
+
+// 加载书籍时的致命错误(如"书籍不存在"、网络不可达、权限拒绝等)
+// 不只是 toast 一闪而过,而是落到页面让用户看清,并可返回书籍列表
+const loadError = ref<{ title: string; message: string; recoverable: boolean } | null>(null);
 
 // 生成状态
 const generating = ref(false);
 const generatingAudio = ref(false);
 const generatingVideo = ref(false);
+// interactive 模式下点击"继续生成内容"的请求中状态
+const outlineStarting = ref(false);
+// 手动补全缺失章节的请求中状态
+const fillingMissing = ref(false);
 
 // 是否有内容(章节有实际内容文本)
 const hasContent = computed(() => {
@@ -428,6 +478,15 @@ const isContentCompleted = computed(() => {
          currentBook.value.genStage === 'video_completed';
 });
 
+// 是否有章节内容缺失(level>=2 且 content 为空)
+const missingChapterCount = computed(() => {
+  if (!currentBook.value?.chapters) return 0;
+  return currentBook.value.chapters.filter((c: any) =>
+    c.level >= 2 && (!c.content || c.content.length < 50)
+  ).length;
+});
+const hasMissingChapters = computed(() => missingChapterCount.value > 0);
+
 // 所有叶子节点音频是否全部生成完成
 const allLeafAudioCompleted = computed(() => {
   if (!currentBook.value?.chapters?.length) return false;
@@ -459,6 +518,8 @@ const showManualAudioHint = computed(() => {
 let pollTimer: ReturnType<typeof setInterval> | null = null;
 let audioPollTimer: ReturnType<typeof setInterval> | null = null;
 let videoPollTimer: ReturnType<typeof setInterval> | null = null;
+// outline_ready 状态下的"等待后端自愈"轮询定时器(间隔较长,避免过多请求)
+let outlineReadyPollTimer: ReturnType<typeof setInterval> | null = null;
 
 // 卡死检测:进度/阶段长时间无变化则判定后台已停(避免永久"生成中"转圈)
 const stalledTip = ref(false);            // 是否疑似卡死
@@ -466,6 +527,9 @@ let lastProgressSnapshot = '';            // 上次的 progress+genStage 快照
 let stalledTicks = 0;                     // 连续无变化的轮询次数
 const STALLED_THRESHOLD = 40;             // 40 次 × 3s ≈ 2 分钟无变化判定卡死
 
+// 质量报告折叠
+const reportExpanded = ref(false);
+
 // 计算属性
 const completedChapters = computed(() => {
   if (!currentBook.value) return 0;
@@ -716,8 +780,18 @@ function goBack() {
 }
 
 async function loadBook(id: string) {
+  // 每次重试前先清空错误状态
+  loadError.value = null;
   try {
     currentBook.value = await api.getBook(id);
+    if (!currentBook.value) {
+      loadError.value = {
+        title: '书籍不存在',
+        message: '该书籍可能已被删除,或当前账号无权限访问。',
+        recoverable: true,
+      };
+      return;
+    }
     // 同步 generating 状态(只要不是最终完成状态,就开始轮询)
     const stage = currentBook.value.genStage;
     const failedStage = currentBook.value.failedStage;
@@ -728,12 +802,35 @@ async function loadBook(id: string) {
       generating.value = true;
     }
     // 开始生成时(包括 content_completed 后台生成音频)都要轮询,失败状态不轮询
-    if (stage && stage !== 'video_completed' && !isFailed) {
+    if (stage === 'outline_ready') {
+      // 大纲就绪、等用户操作/或后端 book-recovery-scanner 自动推进:每 30 秒降频轮询
+      startOutlineReadyPolling(id);
+    } else if (stage && stage !== 'video_completed' && !isFailed) {
       startPollingProgress(id);
     }
-  } catch (e) {
+  } catch (e: any) {
     console.error('加载书籍失败:', e);
-    uni.showToast({ title: '加载失败', icon: 'none' });
+    // 致命错误落到页面(不要只 toast 一闪而过),让用户明确知道发生了什么
+    const raw = (e?.message || '').trim();
+    const msg = raw || '网络请求失败,请稍后重试';
+    const looksLikeNotExist = /书籍不存在|不存在|not\s*found|404/i.test(msg);
+    loadError.value = {
+      title: looksLikeNotExist ? '书籍不存在' : '加载失败',
+      message: looksLikeNotExist
+        ? '该书籍可能已被删除,或当前账号无权限访问。可返回书籍列表查看其它书籍。'
+        : msg,
+      recoverable: true,
+    };
+  }
+}
+
+// 返回书籍列表(致命错误时给用户一个明确出口)
+function goBackToList() {
+  const pages = getCurrentPages();
+  if (pages.length > 1) {
+    uni.navigateBack();
+  } else {
+    uni.reLaunch({ url: '/pages/book-generator/index' });
   }
 }
 
@@ -766,6 +863,7 @@ function startPollingProgress(bookId: string) {
         }
       }
       // 以下状态认为生成已完成,停止轮询
+      // outline_ready 不在 doneStages 里——它有专门的降频轮询 startOutlineReadyPolling 检测后端自愈
       const doneStages = ['content_completed', 'audio_completed', 'video_completed', 'failed'];
       if (doneStages.includes(book.genStage)) {
         stopPollingProgress();
@@ -805,6 +903,53 @@ function startPollingProgress(bookId: string) {
   }, 3000);
 }
 
+/**
+ * outline_ready 状态的降频轮询:每 30 秒拉一次,检测后端 book-recovery-scanner 是否自动推进
+ * 若 genStage 变为 content_generating / generating_content / audio_generating 等活跃态,
+ * 自动切回正常 polling,让用户实时看到进度
+ */
+function startOutlineReadyPolling(bookId: string) {
+  stopOutlineReadyPolling();
+  console.log('[Detail] 启动 outline_ready 降频轮询(30s 间隔)');
+  outlineReadyPollTimer = setInterval(async () => {
+    try {
+      const book = await api.getBook(bookId);
+      if (!book) return;
+      const newStage = book.genStage as string;
+      // 一旦进入"活跃生成"状态,切回正常 polling
+      const activeStages = ['content_generating', 'audio_generating', 'video_generating', 'generating_content', 'outline_generating'];
+      if (activeStages.includes(newStage)) {
+        console.log(`[Detail] 检测到后端已自愈推进 (genStage=${newStage}),切回正常轮询`);
+        if (currentBook.value) {
+          currentBook.value.genStage = newStage as any;
+          currentBook.value.progress = book.progress;
+        }
+        stopOutlineReadyPolling();
+        startPollingProgress(bookId);
+        generating.value = true;
+      } else if (newStage !== 'outline_ready') {
+        // 变成其它稳定态(completed/failed 等),停止降频轮询
+        stopOutlineReadyPolling();
+      } else {
+        // 还在 outline_ready:更新一下 progress(虽然后端没改)
+        if (currentBook.value && book.progress !== currentBook.value.progress) {
+          currentBook.value.progress = book.progress;
+        }
+      }
+    } catch (e) {
+      console.error('[Detail] outline_ready 降频轮询失败:', e);
+    }
+  }, 30_000);
+}
+
+function stopOutlineReadyPolling() {
+  if (outlineReadyPollTimer) {
+    clearInterval(outlineReadyPollTimer);
+    outlineReadyPollTimer = null;
+    console.log('[Detail] outline_ready 降频轮询已停止');
+  }
+}
+
 function stopPollingProgress() {
   if (pollTimer) {
     clearInterval(pollTimer);
@@ -866,6 +1011,65 @@ function stopPollingVideoStatus() {
   }
 }
 
+// interactive 模式下 outline 已就绪,触发正文内容生成(调 /interactive/generate)
+async function handleStartContentGenerate() {
+  if (!currentBook.value || outlineStarting.value) return;
+  const bookId = currentBook.value.id;
+  outlineStarting.value = true;
+  try {
+    uni.showLoading({ title: '启动内容生成...', mask: true });
+    const res: any = await post(`/book-generator/langgraph/books/${bookId}/interactive/generate`, {});
+    uni.hideLoading();
+    // 成功后端返回 genStage=generating_content,前端轮询会自然跟进进度
+    if (currentBook.value) {
+      currentBook.value.genStage = (res?.genStage || 'content_generating') as any;
+      currentBook.value.progress = 10;
+    }
+    uni.showToast({ title: '已开始生成内容', icon: 'success' });
+    // 重新拉取详情(后端可能立刻推进 genStage)
+    await loadBook(bookId);
+    // 启动轮询
+    startPollingProgress(bookId);
+  } catch (e: any) {
+    uni.hideLoading();
+    uni.showToast({ title: e?.message || '启动失败', icon: 'none' });
+    console.error('[handleStartContentGenerate] 失败:', e);
+  } finally {
+    outlineStarting.value = false;
+  }
+}
+
+// 手动补全缺失章节(不依赖 LangGraph,绕过 rich_outline 超时问题)
+async function handleFillMissing() {
+  if (!currentBook.value || fillingMissing.value) return;
+  const bookId = currentBook.value.id;
+  fillingMissing.value = true;
+  try {
+    uni.showLoading({ title: '启动补全...', mask: true });
+    const res: any = await post(`/book-generator/langgraph/books/${bookId}/fill-missing`, {});
+    uni.hideLoading();
+    if (res?.data?.missingCount === 0) {
+      uni.showToast({ title: '书籍已完整', icon: 'success' });
+      await loadBook(bookId);
+      return;
+    }
+    const cnt = res?.data?.missingCount || '?';
+    uni.showToast({ title: `已启动补全 ${cnt} 节`, icon: 'success' });
+    // 立即刷新页面状态,启动 polling 跟进
+    if (currentBook.value) {
+      currentBook.value.genStage = 'generating_content' as any;
+    }
+    await loadBook(bookId);
+    startPollingProgress(bookId);
+  } catch (e: any) {
+    uni.hideLoading();
+    uni.showToast({ title: e?.message || '启动失败', icon: 'none' });
+    console.error('[handleFillMissing] 失败:', e);
+  } finally {
+    fillingMissing.value = false;
+  }
+}
+
 async function handleRetryGenerate() {
   if (!currentBook.value) return;
   uni.showModal({
@@ -1073,7 +1277,8 @@ function viewAfterword() {
 
 onLoad((query: any) => {
   if (query?.id) {
-    loadBook(query.id);
+    currentBookId.value = String(query.id);
+    loadBook(currentBookId.value);
   }
 });
 
@@ -1084,7 +1289,8 @@ onMounted(() => {
     const hash = window.location.hash;
     const match = hash.match(/detail\?id=([^&]+)/);
     if (match && !currentBook.value) {
-      loadBook(decodeURIComponent(match[1]));
+      currentBookId.value = decodeURIComponent(match[1]);
+      loadBook(currentBookId.value);
     }
   }
   // #endif
@@ -1209,6 +1415,26 @@ onUnmounted(() => {
 .failed-hint { font-size: 24rpx; color: #6b7280; display: block; text-align: center; margin-bottom: 16rpx; }
 .retry-btn { width: 100%; height: 72rpx; background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); color: white; border: none; border-radius: 12rpx; font-size: 28rpx; font-weight: 600; }
 
+/* 质量报告卡片(成功路径,不是错误) */
+.report-card { margin-top: 24rpx; padding: 24rpx; background: linear-gradient(135deg, #ecfeff 0%, #cffafe 100%); border-radius: 16rpx; border: 2rpx solid #67e8f9; }
+.report-header { display: flex; align-items: center; gap: 12rpx; }
+.report-icon { font-size: 32rpx; flex-shrink: 0; }
+.report-title { flex: 1; font-size: 28rpx; font-weight: 600; color: #155e75; }
+.report-toggle { font-size: 24rpx; color: #0e7490; font-weight: 500; }
+.report-body { margin-top: 16rpx; padding-top: 16rpx; border-top: 1rpx solid rgba(14, 116, 144, 0.2); }
+.report-item { margin-bottom: 12rpx; }
+.report-label { font-size: 24rpx; font-weight: 600; color: #155e75; display: block; margin-bottom: 6rpx; }
+.report-text { font-size: 24rpx; color: #0e7490; line-height: 1.6; display: block; }
+
+/* 致命错误卡:书籍不存在 / 加载失败 / 权限不足等,全屏卡片式提示 */
+.fatal-error-card { display: flex; flex-direction: column; align-items: center; padding: 64rpx 32rpx; margin-top: 40rpx; }
+.fatal-error-icon { font-size: 96rpx; display: block; margin-bottom: 24rpx; }
+.fatal-error-title { font-size: 36rpx; font-weight: 700; color: #dc2626; display: block; margin-bottom: 16rpx; text-align: center; }
+.fatal-error-message { font-size: 28rpx; color: #4b5563; line-height: 1.6; text-align: center; margin-bottom: 40rpx; padding: 0 24rpx; }
+.fatal-error-actions { display: flex; flex-direction: column; gap: 20rpx; width: 100%; max-width: 600rpx; }
+.fatal-error-btn { width: 100%; height: 80rpx; background: #ffffff; color: #374151; border: 2rpx solid #d1d5db; border-radius: 12rpx; font-size: 28rpx; font-weight: 600; }
+.fatal-error-btn.primary { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); color: white; border: none; }
+
 .outline-theme { font-size: 24rpx; color: #6b7280; }
 
 /* 章节搜索工具栏 */

+ 8 - 0
my-uniapp-vue3/src/utils/book-generator-api.ts

@@ -25,6 +25,10 @@ export interface Book {
   isPublished?: boolean;
   genStage?: BookGenStage;    // 线性阶段状态(替代旧 status 字段)
   failedStage?: string;       // 失败阶段
+  // 非错误的报告字段(区别于 errorMsg,是成功路径的日志展示)
+  qualityReport?: string;
+  continuityReport?: string;
+  rewriteReport?: string;
   /** 自动生成文本开关:true=创建后立即跑 langGraph,false=停在 draft 等手动触发 */
   autoGenerateContent?: boolean;
   /** 自动生成音频开关:true=AudioScanner 自动入队,false=用户需手动触发 */
@@ -87,6 +91,10 @@ export interface Chapter {
 export type BookGenStage =
   | 'draft'
   | 'outlining'
+  | 'outline_generating'  // interactive 模式:大纲生成中
+  | 'outline_ready'        // interactive 模式:大纲已生成,等待用户确认/后端自愈
+  | 'outline_failed'       // interactive 模式:大纲生成失败
+  | 'generating_content'   // 自愈扫描器/一键生成:内容生成中
   | 'outline_completed'
   | 'content_generating'
   | 'content_completed'

+ 8 - 0
server/prisma/migrations/20260704010000_add_report_fields/migration.sql

@@ -0,0 +1,8 @@
+-- 为 Book 表新增非错误的报告字段
+-- 用途:把"质量校验/连贯性编辑/改写优化"这些成功日志从 errorMsg 中分离
+-- 此前这些日志被错写到 errorMsg,前端误判为生成失败/卡死
+-- 现在 errorMsg 字段仅承载真正的失败原因(带 failedStage)
+
+ALTER TABLE `Book` ADD COLUMN `qualityReport` TEXT NULL;
+ALTER TABLE `Book` ADD COLUMN `continuityReport` TEXT NULL;
+ALTER TABLE `Book` ADD COLUMN `rewriteReport` TEXT NULL;

+ 4 - 0
server/prisma/schema.prisma

@@ -162,6 +162,10 @@ model Book {
   bookAnalysis   String?        @db.Text
   autoGenerateContent Boolean   @default(true)
   autoGenerateAudio   Boolean   @default(true)
+  // 非错误的报告字段(与 errorMsg 分离,避免前端误判为失败)
+  qualityReport   String?       @db.Text
+  continuityReport String?      @db.Text
+  rewriteReport   String?       @db.Text
   chapters       BookChapter[]
   favorites      Favorite[]
   videoProjects  VideoProject[]

+ 56 - 6
server/src/modules/book-generator/book-generator.service.ts

@@ -193,13 +193,24 @@ export class BatchGenerationOrchestrator {
       })
       .catch((err) => {
         console.error(`[BatchGen][${this.taskId}] 内容生成失败:`, err);
+        // 关键:把失败立即写到 store,前端轮询 3 秒内可见
+        bookStore.update(this.bookId, {
+          genStage: 'failed',
+          failedStage: 'content_generating',
+          errorMsg: err instanceof Error ? err.message : '生成失败',
+        }).catch((writeErr: any) => {
+          console.error(`[BatchGen][${this.taskId}] 写 store 失败:`, writeErr.message);
+        });
       });
 
     // 等待内容生成完成(轮询检查)
-    let maxWaitTime = 3600 * 1000; // 最多等待60分钟
+    // 高可用要求:超时从 60 分钟降到 30 分钟;过 25 分钟时写 store 警示用户
+    let maxWaitTime = 30 * 60 * 1000; // 最多等待30分钟
+    const warnTimeout = 25 * 60 * 1000; // 25 分钟时写警示
     let waited = 0;
     const checkInterval = 5000; // 每5秒检查一次
     let generationSuccess = false;
+    let warned = false;
 
     try {
       while (waited < maxWaitTime) {
@@ -233,10 +244,25 @@ export class BatchGenerationOrchestrator {
           throw new Error('内容生成失败: ' + (currentBook.error || '未知错误'));
         }
 
+        // 高可用:超过 25 分钟写警示,让用户能早看到
+        if (!warned && waited >= warnTimeout) {
+          warned = true;
+          await bookStore.update(this.bookId, {
+            errorMsg: '内容生成耗时较长,已运行 25 分钟仍未完成。可点击「重新生成」或等待到 30 分钟超时。',
+          } as any).catch(() => {});
+          console.warn(`[BatchGen][${this.taskId}] 内容生成耗时 > 25 分钟`);
+        }
+
         await this.sleep(checkInterval);
         waited += checkInterval;
       }
-      throw new Error('内容生成超时');
+      // 超时:写 store failedStage,前端可立即看到错误
+      await bookStore.update(this.bookId, {
+        genStage: 'failed',
+        failedStage: 'content_generating',
+        errorMsg: '内容生成超时(30 分钟)。可能 LLM 服务异常,请点击重新生成。',
+      }).catch(() => {});
+      throw new Error('内容生成超时(30分钟)');
     } finally {
       // 生成完成后,调整配额(多退少补)
       if (userId && reservedAmount > 0) {
@@ -301,8 +327,11 @@ export class BatchGenerationOrchestrator {
     }
 
     // 等待音频生成完成(轮询检查)
-    let maxWaitTime = 3600 * 1000; // 最多等待60分钟
+    // 高可用要求:超时从 60 分钟降到 30 分钟;25 分钟时写警示
+    let maxWaitTime = 30 * 60 * 1000;
+    const warnTimeout = 25 * 60 * 1000;
     let waited = 0;
+    let warned = false;
     const checkInterval = 3000; // 每3秒检查一次
 
     while (waited < maxWaitTime) {
@@ -324,11 +353,25 @@ export class BatchGenerationOrchestrator {
         return;
       }
 
+      if (!warned && waited >= warnTimeout) {
+        warned = true;
+        await bookStore.update(this.bookId, {
+          errorMsg: '音频生成耗时较长,已运行 25 分钟仍未完成。可点击重新生成或继续等待。',
+        } as any).catch(() => {});
+        console.warn(`[BatchGen][${this.taskId}] 音频生成耗时 > 25 分钟`);
+      }
+
       await this.sleep(checkInterval);
       waited += checkInterval;
     }
 
-    throw new Error('音频生成超时');
+    // 超时:写 store failedStage
+    await bookStore.update(this.bookId, {
+      genStage: 'failed',
+      failedStage: 'audio_generating',
+      errorMsg: '音频生成超时(30 分钟)。可能 TTS 服务异常,请点击重新生成。',
+    }).catch(() => {});
+    throw new Error('音频生成超时(30分钟)');
   }
 
   /**
@@ -483,7 +526,8 @@ export class BatchGenerationOrchestrator {
     }
 
     // 等待视频生成完成(轮询检查)
-    let maxWaitTime = 3600 * 1000; // 最多等待60分钟
+    // 高可用要求:超时从 60 分钟降到 30 分钟
+    let maxWaitTime = 30 * 60 * 1000;
     let waited = 0;
     const checkInterval = 5000; // 每5秒检查一次
 
@@ -510,7 +554,13 @@ export class BatchGenerationOrchestrator {
       waited += checkInterval;
     }
 
-    throw new Error('视频生成超时');
+    // 超时:写 store failedStage
+    await bookStore.update(this.bookId, {
+      genStage: 'failed',
+      failedStage: 'video_generating',
+      errorMsg: '视频生成超时(30 分钟)。可能视频服务异常,请点击重新生成。',
+    }).catch(() => {});
+    throw new Error('视频生成超时(30分钟)');
   }
 
   /**

+ 6 - 0
server/src/modules/book-generator/book-generator.store.ts

@@ -1779,6 +1779,9 @@ ${writingStyle ? `写作风格:${writingStyle}` : ''}
     bookAnalysis?: string | null;
     autoGenerateContent?: boolean;
     autoGenerateAudio?: boolean;
+    qualityReport?: string | null;
+    continuityReport?: string | null;
+    rewriteReport?: string | null;
     createdAt: Date;
     updatedAt: Date;
     chapters: any[];
@@ -1810,6 +1813,9 @@ ${writingStyle ? `写作风格:${writingStyle}` : ''}
       },
       bookAnalysis: dbBook.bookAnalysis || undefined,
       error: dbBook.errorMsg || undefined,
+      qualityReport: dbBook.qualityReport || undefined,
+      continuityReport: dbBook.continuityReport || undefined,
+      rewriteReport: dbBook.rewriteReport || undefined,
       createdAt: dbBook.createdAt,
       updatedAt: dbBook.updatedAt,
     };

+ 5 - 1
server/src/modules/book-generator/book-generator.types.ts

@@ -55,10 +55,14 @@ export interface Book extends BookBase {
   chapters: Chapter[];              // 章节列表
   outline?: BookOutline;            // 书籍大纲
   metadata?: BookMetadata;          // 元数据(作者、前言、后记等)
-  error?: string;                   // 错误信息
+  error?: string;                   // 错误信息(仅用于真正失败,用户必须看到的)
   bookAnalysis?: string;            // AI 书籍规划结果(planBookNode 输出)
   autoGenerateContent?: boolean;    // 自动生成文本:true=创建后立即跑,false=停在 draft
   autoGenerateAudio?: boolean;      // 自动生成音频:true=AudioScanner 自动入队,false=手动触发
+  // 非错误的报告字段(与 error 分离,避免前端误判为失败)
+  qualityReport?: string;           // 质量校验报告
+  continuityReport?: string;        // 连贯性编辑报告
+  rewriteReport?: string;           // 改写优化/降级报告
 }
 
 /** 书籍大纲 */

+ 8 - 0
server/src/modules/book-generator/book-queue.processor.ts

@@ -81,6 +81,14 @@ export async function resumeInterruptedTasks() {
         console.log(`[DBQueue] 恢复完成: bookId=${book.id}`);
       }).catch(err => {
         console.error(`[DBQueue] 恢复失败: bookId=${book.id}`, err);
+        // 关键:把失败立即写到 store,前端轮询 3 秒内可见
+        bookStore.update(book.id.toString(), {
+          genStage: 'failed',
+          failedStage: 'outlining',
+          errorMsg: err instanceof Error ? err.message : '恢复失败',
+        }).catch((writeErr: any) => {
+          console.error(`[DBQueue] 写 store 失败: bookId=${book.id}`, writeErr.message);
+        });
       });
     }
   } catch (error) {

+ 29 - 2
server/src/modules/book-generator/fault-tolerance.ts

@@ -201,9 +201,9 @@ export function startProgressMonitor(bookId: string) {
         clearInterval(monitorInterval);
         return;
       }
-      
+
       const currentProgress = book.progress || 0;
-      
+
       // 如果进度有更新,重置计数器
       if (currentProgress > lastProgress) {
         lastProgress = currentProgress;
@@ -211,6 +211,25 @@ export function startProgressMonitor(bookId: string) {
         idleWarnings = 0;
         return;
       }
+
+      // 关键修复:高可用要求 - LangGraph 在跑时不会更新 book.progress,
+      // 但会持续更新 BookChapter 内容/状态(generatedAt 字段)。
+      // 如果章节表有最近 N 分钟内有生成时间,说明 LangGraph 仍在跑,
+      // 绝不能误判为卡死。
+      const { prisma } = await import('../../models/index.js');
+      const recentChapterUpdate = await prisma.bookChapter.findFirst({
+        where: {
+          bookId: parseInt(bookId),
+          generatedAt: { gt: new Date(Date.now() - 2 * config.checkIntervalMs) },
+        },
+        select: { id: true, generatedAt: true },
+      });
+      if (recentChapterUpdate) {
+        // 章节仍在被生成/修改,重置 idle 计时
+        lastProgressTime = Date.now();
+        idleWarnings = 0;
+        return;
+      }
       
       // 检查是否超时
       const idleTime = Date.now() - lastProgressTime;
@@ -309,6 +328,14 @@ export async function attemptAutoRecovery(bookId: string) {
       console.log(`[容错] ✅ 自动恢复成功: bookId=${bookId}`);
     }).catch(err => {
       console.error(`[容错] ❌ 自动恢复失败: bookId=${bookId}`, err);
+      // 关键:把失败立即写到 store,前端轮询 3 秒内可见
+      bookStore.update(bookId, {
+        genStage: 'failed',
+        failedStage: 'outlining',
+        errorMsg: `自动恢复失败: ${err instanceof Error ? err.message : '未知错误'}`,
+      }).catch((writeErr: any) => {
+        console.error(`[容错] 写 store 失败: bookId=${bookId}`, writeErr.message);
+      });
     });
     
     await notifyUser(bookId, {

+ 51 - 0
server/src/modules/book-generator/langgraph-controller.ts

@@ -3405,6 +3405,14 @@ router.post('/books/:id/interactive/generate', requireAuth, async (ctx: Context)
       console.log(`[Interactive] 生成完成: bookId=${bookId}`);
     }).catch(err => {
       console.error(`[Interactive] 生成失败: bookId=${bookId}`, err);
+      // 关键:把失败立即写到 store,前端轮询 3 秒内可见
+      bookStore.update(bookId, {
+        genStage: 'failed',
+        failedStage: 'outlining',
+        errorMsg: err instanceof Error ? err.message : '生成失败',
+      }).catch((writeErr: any) => {
+        console.error(`[Interactive] 写 store 失败: bookId=${bookId}`, writeErr.message);
+      });
     });
 
     await prisma.book.update({
@@ -3454,4 +3462,47 @@ router.post('/books/:id/interactive/generate', requireAuth, async (ctx: Context)
   }
 });
 
+/**
+ * POST /api/book-generator/langgraph/books/:id/fill-missing
+ * 手动补全缺失章节(不依赖 LangGraph,绕过 rich_outline 节点超时问题)
+ *
+ * 设计原则:
+ *   - book-recovery-scanner 内部也用 fillBookMissingChapters
+ *   - 前端"⚡ 补全缺失章节"按钮也调这个接口
+ *   - 找不到缺失章节时返回 missingCount=0,前端可提示"书已完整"
+ */
+router.post('/books/:id/fill-missing', requireAuth, async (ctx: Context) => {
+  try {
+    const bookId = ctx.params.id;
+    const accessUserId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, accessUserId, 'write'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
+    const { fillBookMissingChapters } = await import('./book-recovery-scanner.js');
+    const result = await fillBookMissingChapters(parseInt(bookId));
+
+    if (result.missingCount === 0) {
+      ctx.body = {
+        code: 0,
+        message: '书籍已完整,没有缺失章节',
+        data: { missingCount: 0, triggered: false },
+      };
+      return;
+    }
+
+    ctx.body = {
+      code: 0,
+      message: `已开始补全 ${result.missingCount} 个缺失章节,请稍候`,
+      data: { missingCount: result.missingCount, triggered: true },
+    };
+  } catch (error: any) {
+    console.error('[FillMissing] 失败:', error);
+    ctx.status = 500;
+    ctx.body = { code: 1, message: error?.message || '补全启动失败' };
+  }
+});
+
 export default router;

+ 3 - 4
server/src/modules/book-generator/nodes/continuity-edit.node.ts

@@ -166,9 +166,9 @@ export async function continuityEditNode(state: typeof GraphState.State): Promis
         }
       }
 
-      // 持久化评估报告
+      // 持久化评估报告到独立字段(不写 errorMsg)
       await bookStore.update(state.bookId, {
-        errorMsg: `[连贯性编辑] ${result.overallAssessment} | 问题: ${result.issues.length}个`,
+        continuityReport: `[连贯性编辑] ${result.overallAssessment} | 问题: ${result.issues.length}个`,
       } as any);
     } else {
       console.warn('[ContinuityEdit] 解析结果失败,跳过编辑');
@@ -178,8 +178,7 @@ export async function continuityEditNode(state: typeof GraphState.State): Promis
     return { progress: PROGRESS.CONTENT_END };
   } catch (error) {
     console.error('[ContinuityEdit] 编辑失败:', error);
-    await bookStore.update(state.bookId, { errorMsg: `连贯性编辑失败: ${error instanceof Error ? error.message : '未知'}` } as any);
-    // 不阻塞流程
+    // 不写 errorMsg 到 Book.errorMsg,仅日志;不阻塞流水线
     return { progress: PROGRESS.CONTENT_END, error: '连贯性编辑失败' };
   }
 }

+ 2 - 2
server/src/modules/book-generator/nodes/quality-check.node.ts

@@ -308,9 +308,9 @@ export async function qualityCheckNode(state: typeof GraphState.State): Promise<
       }
     }
 
-    // 持久化质量报告到数据库
+    // 持久化质量报告到独立字段(不写 errorMsg,避免前端误判为失败)
     await bookStore.update(state.bookId, {
-      errorMsg: `[质量校验] ${result.overallAssessment}`,
+      qualityReport: `[质量校验] ${result.overallAssessment}`,
     } as any);
 
     const qualityResultJson = JSON.stringify(result);

+ 4 - 4
server/src/modules/book-generator/nodes/rewrite.node.ts

@@ -195,10 +195,10 @@ export async function rewriteNode(state: typeof GraphState.State): Promise<Parti
 
     console.warn(`[Rewrite] ${degradeMsg}`);
 
-    // 持久化降级标记到书籍记录
+    // 持久化降级标记到 rewriteReport(不写 errorMsg)
     try {
       await bookStore.update(state.bookId, {
-        errorMsg: `[改写降级] ${degradeMsg}`,
+        rewriteReport: `[改写降级] ${degradeMsg}`,
       } as any);
     } catch { /* 记录日志失败不阻塞流程 */ }
 
@@ -273,9 +273,9 @@ export async function rewriteNode(state: typeof GraphState.State): Promise<Parti
 
     console.log(`[Rewrite] 改写完成: ${successCount} 成功, ${failCount} 失败`);
 
-    // 持久化改写记录
+    // 持久化改写记录到 rewriteReport(不写 errorMsg)
     await bookStore.update(state.bookId, {
-      errorMsg: `[改写优化] 第${currentCount + 1}轮:${successCount}个成功,${failCount}个失败`,
+      rewriteReport: `[改写优化] 第${currentCount + 1}轮:${successCount}个成功,${failCount}个失败`,
     } as any);
 
     return {