瀏覽代碼

feat: 实现书籍批量视频生成和视频播放功能

- 后端新增批量视频生成接口 POST /api/book-generator/langgraph/books/:id/videos
- 前端添加 /videos 路径的 Vite 代理配置
- 修复 book-generator.store.ts 的 updateChapterById 方法,支持 videoUrl 和 videoDuration 字段
- 优化音频和视频生成逻辑,增加 contentStatus 状态检查
- 添加视频播放调试日志
- 完善容错机制和进度监控功能
- 修复章节内容生成状态验证逻辑
MyFramework User 4 月之前
父節點
當前提交
63d8ff6612

+ 90 - 10
my-uniapp-vue3/src/pages/book-generator/chapter-detail.vue

@@ -28,15 +28,31 @@
         </view>
       </view>
 
-      <!-- 章节正文(Markdown + LaTeX 渲染) -->
-      <view class="chapter-body">
+      <!-- 只有小节(level=3)才显示内容 -->
+      <view v-if="chapterLevel === 3" class="chapter-body">
         <rich-text :nodes="renderedContent" class="chapter-text"></rich-text>
       </view>
+      
+      <!-- 章和节显示提示 -->
+      <view v-if="chapterLevel !== 3" class="chapter-body">
+        <view class="level-hint-large">
+          <text class="hint-icon-large">📖</text>
+          <text class="hint-text-large">这是一个{{ chapterLevel === 1 ? '章' : '节' }},没有具体内容</text>
+          <text class="hint-subtext">请点击下方的具体小节查看内容</text>
+        </view>
+      </view>
 
       <!-- 底部操作区 -->
       <view class="chapter-actions">
+        <!-- 提示:章和节没有内容,只有小节才有 -->
+        <view v-if="chapterLevel !== 3" class="level-hint">
+          <text class="hint-icon">💡</text>
+          <text class="hint-text">这是一个{{ chapterLevel === 1 ? '章' : '节' }},请点击具体小节查看内容和生成音频</text>
+        </view>
+        
+        <!-- 只有小节(level=3)才能生成音频 -->
         <button
-          v-if="chapterStatus === 'completed' && !chapterAudioUrl"
+          v-if="chapterLevel === 3 && chapterStatus === 'completed' && !chapterAudioUrl"
           class="action-btn audio-btn"
           :disabled="generatingAudio"
           @click="handleGenerateAudio"
@@ -239,6 +255,7 @@ const chapterAudioUrl = ref<string>('');
 const chapterVideoUrl = ref<string>('');
 const chapterIsPublic = ref<boolean>(false);
 const chapterId = ref<number>(0);
+const chapterLevel = ref<number>(1); // 章节层级:1=章, 2=节, 3=小节
 
 // 章节列表(用于上下章导航)
 const chapters = ref<Chapter[]>([]);
@@ -282,9 +299,8 @@ function toggleAudio() {
     return;
   }
   
-  // 获取当前章节的 ID
-  const currentChapter = chapters.value.find((c) => c.number === chapterNumber.value);
-  const chapterId = currentChapter?.id || `${bookId.value}-${chapterNumber.value}`;
+  // 直接使用当前章节的 ID(页面加载时已设置)
+  const currentChapterId = chapterId.value;
   
   // 设置播放列表和当前音频,使用 audioStore 播放
   const audioChapters = chapters.value.filter((c) => c.audioUrl);
@@ -295,26 +311,31 @@ function toggleAudio() {
     wordCount: c.wordCount || 0,
     audioUrl: c.audioUrl,
   }));
-  const playIndex = audioChapters.findIndex((c) => c.number === chapterNumber.value);
+  const playIndex = audioChapters.findIndex((c) => c.id === String(currentChapterId));
   
   audioStore.setPlaylist(playlist as any, playIndex >= 0 ? playIndex : 0, true);
   audioStore.play({
-    id: parseInt(chapterId as string),
+    id: currentChapterId,
     title: chapterTitle.value,
     audioDuration: 0,
     wordCount: chapterWordCount.value,
     audioUrl: chapterAudioUrl.value,
   } as any);
   
-  // 跳转到播放页面,使用章节的真实 ID
+  // 跳转到播放页面,使用当前章节的真实 ID
   uni.navigateTo({
-    url: `/pages/player/index?id=${chapterId}`
+    url: `/pages/player/index?id=${currentChapterId}`
   });
 }
 
 // 播放视频 - 使用弹窗方式
 function playVideo() {
+  console.log('[Video Play] 点击播放视频');
+  console.log('[Video Play] chapterVideoUrl:', chapterVideoUrl.value);
+  console.log('[Video Play] chapterTitle:', chapterTitle.value);
+  
   if (!chapterVideoUrl.value) {
+    console.log('[Video Play] 视频URL为空');
     uni.showToast({ title: '该章节暂无视频', icon: 'none' });
     return;
   }
@@ -322,6 +343,7 @@ function playVideo() {
   videoUrl.value = chapterVideoUrl.value.startsWith('http') ? chapterVideoUrl.value : chapterVideoUrl.value;
   videoTitle.value = chapterTitle.value;
   showVideoModal.value = true;
+  console.log('[Video Play] 打开视频弹窗, videoUrl:', videoUrl.value);
 }
 
 function closeVideoModal() {
@@ -400,6 +422,17 @@ async function loadChapter() {
       chapterStatus.value = chapter.status;
       chapterAudioUrl.value = chapter.audioUrl || '';
       chapterVideoUrl.value = chapter.videoUrl || '';
+      chapterLevel.value = chapter.level || 1;
+      
+      console.log('[Chapter Detail] 加载章节:', {
+        id: chapter.id,
+        number: chapter.number,
+        title: chapter.title,
+        level: chapter.level,
+        audioUrl: chapter.audioUrl,
+        videoUrl: chapter.videoUrl,
+        status: chapter.status
+      });
       
       // 从 API 获取公开状态
       if (chapter.audioUrl && chapterId.value) {
@@ -691,6 +724,53 @@ onShow(() => {
   margin-bottom: 16rpx;
 }
 
+.level-hint {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  padding: 24rpx;
+  background: rgba(79, 70, 229, 0.05);
+  border: 1px solid rgba(79, 70, 229, 0.2);
+  border-radius: 12rpx;
+  margin-bottom: 20rpx;
+}
+
+.level-hint-large {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 120rpx 40rpx;
+  text-align: center;
+}
+
+.hint-icon-large {
+  font-size: 120rpx;
+  margin-bottom: 32rpx;
+}
+
+.hint-text-large {
+  font-size: 32rpx;
+  color: #333;
+  font-weight: 500;
+  margin-bottom: 16rpx;
+}
+
+.hint-subtext {
+  font-size: 26rpx;
+  color: #999;
+}
+
+.hint-icon {
+  font-size: 32rpx;
+}
+
+.hint-text {
+  font-size: 26rpx;
+  color: #4f46e5;
+  line-height: 1.5;
+}
+
 .action-btn {
   width: 100%;
   height: 88rpx;

+ 557 - 35
my-uniapp-vue3/src/pages/book-generator/index.vue

@@ -359,6 +359,37 @@
             <view class="progress-fill" :style="{ width: currentBook.progress + '%' }"></view>
           </view>
           
+          <!-- 实时生成状态面板 -->
+          <view v-if="currentBook.status === 'generating'" class="generation-status">
+            <view class="status-header">
+              <text class="status-icon">⚡</text>
+              <text class="status-title">正在生成中...</text>
+            </view>
+            
+            <!-- 当前阶段 -->
+            <view class="status-stage">
+              <text class="stage-label">当前阶段:</text>
+              <text class="stage-value">{{ getCurrentStage() }}</text>
+            </view>
+            
+            <!-- 错误信息(如果有) -->
+            <view v-if="currentBook.error" class="status-error">
+              <text class="error-icon">⚠️</text>
+              <text class="error-text">{{ currentBook.error }}</text>
+            </view>
+            
+            <!-- 自动恢复提示 -->
+            <view v-if="currentBook.error && currentBook.error.includes('自动恢复')" class="status-recovery">
+              <text class="recovery-icon">🔄</text>
+              <text class="recovery-text">系统正在尝试自动恢复...</text>
+            </view>
+            
+            <!-- 提示 -->
+            <view class="status-tip">
+              <text class="tip-text">💡 生成过程可能需要几分钟,请耐心等待</text>
+            </view>
+          </view>
+          
           <!-- 实时配额显示 -->
           <view v-if="currentBook.status === 'generating'" class="quota-monitor">
             <text class="quota-monitor-title">🎧 额度监控</text>
@@ -377,6 +408,15 @@
             <text class="interrupted-text">生成已中断:额度不足,已保存当前进度</text>
             <text class="interrupted-hint">可升级套餐后继续生成</text>
           </view>
+          
+          <!-- 失败提示(非额度原因) -->
+          <view v-if="currentBook.status === 'failed' && !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>
 
         <!-- 完整内容区(整合大纲+实际内容) -->
@@ -399,10 +439,10 @@
 
           <!-- 章节列表(树形结构,整合大纲信息) -->
           <view class="content-tree-list">
-            <!-- 章 -->
+            <!-- 章 (只遍历level=1) -->
             <view
-              v-for="chapter in currentBook.chapters"
-              :key="'ch-' + chapter.id"
+              v-for="chapter in chaptersLevel1"
+              :key="chapter.id"
               class="content-tree-item content-chapter"
             >
               <view class="content-tree-row chapter-row">
@@ -429,57 +469,63 @@
                     <text v-if="chapter.videoUrl" class="media-badge">🎬 视频</text>
                   </view>
                 </view>
-                <view :class="['chapter-status', chapter.status === 'completed' ? 'done' : 'pending']">
-                  {{ chapter.status === 'completed' ? '✓' : '○' }}
+                <!-- 章状态 -->
+                <view :class="['chapter-status', getNodeStatusClass(chapter)]">
+                  <text class="status-icon">{{ getNodeStatusIcon(chapter) }}</text>
+                  <text class="status-text">{{ getNodeStatusText(chapter) }}</text>
                 </view>
               </view>
           
-              <!-- 节(从大纲获取) -->
-              <view v-if="getChapterSections(chapter.number) && getChapterSections(chapter.number).length > 0" class="content-sections-list">
+              <!-- 节(从数据库获取) -->
+              <view v-if="getChapterSectionsFromDB(chapter.id) && getChapterSectionsFromDB(chapter.id).length > 0" class="content-sections-list">
                 <view
-                  v-for="(section, sIdx) in getChapterSections(chapter.number)"
-                  :key="'sec-' + chapter.number + '-' + sIdx"
+                  v-for="section in getChapterSectionsFromDB(chapter.id)"
+                  :key="'sec-' + section.id"
                   class="content-tree-item content-section"
                 >
                   <view class="content-tree-row section-row">
-                    <view class="section-num-badge">{{ chapter.number }}.{{ sIdx + 1 }}</view>
+                    <view class="section-num-badge">{{ section.number }}</view>
                     <view class="section-info">
                       <text class="section-title">{{ section.title }}</text>
                       <text v-if="section.summary" class="section-summary">{{ section.summary }}</text>
                     </view>
+                    <!-- 节状态 -->
+                    <view :class="['section-status', getNodeStatusClass(section)]">
+                      <text class="status-icon">{{ getNodeStatusIcon(section) }}</text>
+                      <text class="status-text">{{ getNodeStatusText(section) }}</text>
+                    </view>
                   </view>
           
-                  <!-- 小节(subsections) - 叶子节点,添加点击链接 -->
-                  <view v-if="section.subsections && section.subsections.length > 0" class="content-subsections-list">
+                  <!-- 小节(从数据库获取) -->
+                  <view v-if="getSectionSubsectionsFromDB(section.id) && getSectionSubsectionsFromDB(section.id).length > 0" class="content-subsections-list">
                     <view
-                      v-for="(sub, subIdx) in section.subsections"
-                      :key="'sub-' + chapter.number + '-' + sIdx + '-' + subIdx"
+                      v-for="subsection in getSectionSubsectionsFromDB(section.id)"
+                      :key="'sub-' + subsection.id"
                       class="content-tree-item content-subsection"
-                      @click="goToChapterDetail(chapter)"
+                      @click="goToChapterDetail(subsection)"
                     >
                       <view class="content-tree-row subsection-row">
-                        <view class="subsection-num-badge">{{ chapter.number }}.{{ sIdx + 1 }}.{{ subIdx + 1 }}</view>
+                        <view class="subsection-num-badge">{{ subsection.number }}</view>
                         <view class="subsection-info">
-                          <text class="subsection-title">{{ sub.title }}</text>
+                          <text class="subsection-title">{{ subsection.title }}</text>
+                          <!-- 媒体标识 -->
+                          <view class="subsection-media-badges">
+                            <text v-if="subsection.audioUrl" class="media-badge-small">🎵 音频</text>
+                            <text v-if="subsection.videoUrl" class="media-badge-small">🎬 视频</text>
+                          </view>
+                          <!-- 内容预览 -->
+                          <text v-if="subsection.content" class="subsection-content-preview">
+                            {{ subsection.content.substring(0, 50) }}...
+                          </text>
                         </view>
-                        <view class="content-item-right">
-                          <text class="expand-icon">→</text>
+                        <!-- 小节状态 -->
+                        <view :class="['subsection-status', getNodeStatusClass(subsection)]">
+                          <text class="status-icon">{{ getNodeStatusIcon(subsection) }}</text>
+                          <text class="status-text">{{ getNodeStatusText(subsection) }}</text>
                         </view>
                       </view>
                     </view>
                   </view>
-          
-                  <!-- 如果节没有小节,节本身就是叶子节点,添加点击链接 -->
-                  <view v-else class="content-tree-item content-subsection" @click="goToChapterDetail(chapter)">
-                    <view class="content-tree-row subsection-row">
-                      <view class="subsection-info" style="margin-left: 56rpx;">
-                        <text class="subsection-title">{{ section.title }}</text>
-                      </view>
-                      <view class="content-item-right">
-                        <text class="expand-icon">→</text>
-                      </view>
-                    </view>
-                  </view>
                 </view>
               </view>
           
@@ -551,6 +597,7 @@ import { ref, computed, onMounted, watch, nextTick } from 'vue';
 import { onShow } from '@dcloudio/uni-app';
 import * as api from '../../utils/book-generator-api';
 import type { Book, BookOutline, Chapter } from '../../utils/book-generator-api';
+import { wsService } from '../../utils/websocket';
 
 // 使用相对路径(Nginx 反向代理)
 const BASE_URL = '/api';
@@ -775,6 +822,12 @@ const completedChapters = computed(() => {
   return currentBook.value.chapters.filter((c) => c.status === 'completed').length;
 });
 
+// 过滤出level=1的章(避免在模板中使用filter导致重复渲染)
+const chaptersLevel1 = computed(() => {
+  if (!currentBook.value?.chapters) return [];
+  return currentBook.value.chapters.filter((c) => c.level === 1);
+});
+
 // 计算已使用的音频时长(基于已完成章节的字数)
 const usedAudioMinutes = computed(() => {
   if (!currentBook.value) return 0;
@@ -913,6 +966,119 @@ function getStatusText(status: string): string {
   return map[status] || status;
 }
 
+/**
+ * 获取当前生成阶段
+ */
+function getCurrentStage(): string {
+  if (!currentBook.value) return '未知';
+  
+  const progress = currentBook.value.progress || 0;
+  const error = currentBook.value.error || '';
+  
+  // 如果有错误,优先显示
+  if (error) {
+    if (error.includes('AI调用')) return 'AI调用失败,正在重试...';
+    if (error.includes('超时')) return '节点执行超时,正在恢复...';
+    if (error.includes('自动恢复')) return '系统正在自动恢复...';
+  }
+  
+  // 根据进度判断阶段
+  if (progress === 0) return '准备中...';
+  if (progress <= 10) return '正在生成章大纲...';
+  if (progress <= 20) return '正在生成节大纲...';
+  if (progress <= 30) return '正在生成小节大纲...';
+  if (progress <= 90) return '正在生成章节内容...';
+  if (progress <= 95) return '正在生成前言...';
+  if (progress < 100) return '正在生成后记...';
+  if (progress === 100) return '生成完成!';
+  
+  return '生成中...';
+}
+
+/**
+ * 获取节点状态样式类
+ */
+function getNodeStatusClass(node: any): string {
+  if (node.contentStatus === 'completed') return 'status-completed';
+  if (node.contentStatus === 'generating') return 'status-generating';
+  if (node.contentStatus === 'failed') return 'status-failed';
+  if (node.status === 'completed') return 'status-completed';
+  if (node.status === 'pending') return 'status-pending';
+  return 'status-pending';
+}
+
+/**
+ * 获取节点状态图标
+ */
+function getNodeStatusIcon(node: any): string {
+  if (node.contentStatus === 'completed') return '✓';
+  if (node.contentStatus === 'generating') return '⚡';
+  if (node.contentStatus === 'failed') return '✗';
+  if (node.contentError) return '⚠';
+  if (node.status === 'completed') return '✓';
+  if (node.status === 'pending') return '○';
+  return '○';
+}
+
+/**
+ * 获取节点状态文本
+ */
+function getNodeStatusText(node: any): string {
+  if (node.contentStatus === 'completed') return '已完成';
+  if (node.contentStatus === 'generating') return '生成中';
+  if (node.contentStatus === 'failed') return '失败';
+  if (node.contentError) return '错误';
+  if (node.status === 'completed') return '大纲完成';
+  if (node.status === 'pending') return '待生成';
+  return '待生成';
+}
+
+/**
+ * 从数据库获取章的节列表(level=2)
+ */
+function getChapterSectionsFromDB(chapterId: string | number) {
+  if (!currentBook.value?.chapters) return [];
+  const chapterIdNum = typeof chapterId === 'string' ? parseInt(chapterId) : chapterId;
+  const result = currentBook.value.chapters.filter((c: any) => {
+    const cParentId = typeof c.parentId === 'string' ? parseInt(c.parentId) : c.parentId;
+    return cParentId === chapterIdNum && c.level === 2;
+  });
+  console.log(`[getChapterSectionsFromDB] chapterId=${chapterId}, 找到${result.length}个节`);
+  return result;
+}
+
+/**
+ * 从数据库获取节的小节列表(level=3)
+ */
+function getSectionSubsectionsFromDB(sectionId: string | number) {
+  if (!currentBook.value?.chapters) return [];
+  const sectionIdNum = typeof sectionId === 'string' ? parseInt(sectionId) : sectionId;
+  const result = currentBook.value.chapters.filter((c: any) => {
+    const cParentId = typeof c.parentId === 'string' ? parseInt(c.parentId) : c.parentId;
+    return cParentId === sectionIdNum && c.level === 3;
+  });
+  console.log(`[getSectionSubsectionsFromDB] sectionId=${sectionId}, 找到${result.length}个小节`);
+  return result;
+}
+
+/**
+ * 重新生成
+ */
+async function handleRetryGenerate() {
+  if (!currentBook.value) return;
+  
+  uni.showModal({
+    title: '确认重新生成',
+    content: '将重新开始生成,是否继续?',
+    success: async (res) => {
+      if (res.confirm) {
+        // 调用LangGraph生成
+        await handleLangGraphGenerate();
+      }
+    }
+  });
+}
+
 function getChapterStatus(chapterNum: number): string {
   const chapter = currentBook.value?.chapters.find((c) => c.number === chapterNum);
   if (!chapter) return 'pending';
@@ -1511,6 +1677,36 @@ function goToPublishBook(book: Book) {
 // 页面加载
 onMounted(() => {
   loadBooks();
+  
+  // 连接WebSocket接收实时通知
+  try {
+    const token = uni.getStorageSync('token');
+    if (token) {
+      wsService.connect(token);
+      
+      // 监听书籍生成通知
+      wsService.on('book_generation_notification', (data: any) => {
+        console.log('[书籍生成通知]', data);
+        
+        // 如果是当前正在查看的书籍,更新显示
+        if (currentBook.value && data.bookId === currentBook.value.id.toString()) {
+          // 显示通知消息
+          if (data.message) {
+            uni.showToast({
+              title: data.message,
+              icon: 'none',
+              duration: 3000
+            });
+          }
+          
+          // 刷新书籍信息
+          openBook(currentBook.value);
+        }
+      });
+    }
+  } catch (error) {
+    console.error('[WebSocket] 初始化失败:', error);
+  }
 });
 </script>
 
@@ -2217,6 +2413,295 @@ onMounted(() => {
   border-radius: 6rpx;
 }
 
+/* 实时生成状态面板 */
+.generation-status {
+  margin-top: 24rpx;
+  padding: 24rpx;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  border-radius: 16rpx;
+  color: white;
+}
+
+.status-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+
+.status-icon {
+  font-size: 36rpx;
+  margin-right: 12rpx;
+  animation: pulse 2s ease-in-out infinite;
+}
+
+@keyframes pulse {
+  0%, 100% { opacity: 1; }
+  50% { opacity: 0.5; }
+}
+
+.status-title {
+  font-size: 32rpx;
+  font-weight: 600;
+}
+
+.status-stage {
+  display: flex;
+  align-items: center;
+  padding: 16rpx;
+  background: rgba(255, 255, 255, 0.2);
+  border-radius: 12rpx;
+  margin-bottom: 16rpx;
+}
+
+.stage-label {
+  font-size: 26rpx;
+  opacity: 0.9;
+  margin-right: 8rpx;
+}
+
+.stage-value {
+  font-size: 28rpx;
+  font-weight: 600;
+}
+
+.status-error {
+  display: flex;
+  align-items: flex-start;
+  padding: 16rpx;
+  background: rgba(239, 68, 68, 0.3);
+  border-radius: 12rpx;
+  margin-bottom: 16rpx;
+}
+
+.error-icon {
+  font-size: 28rpx;
+  margin-right: 12rpx;
+  flex-shrink: 0;
+}
+
+.error-text {
+  font-size: 24rpx;
+  line-height: 1.6;
+  flex: 1;
+}
+
+.status-recovery {
+  display: flex;
+  align-items: center;
+  padding: 16rpx;
+  background: rgba(251, 191, 36, 0.3);
+  border-radius: 12rpx;
+  margin-bottom: 16rpx;
+}
+
+.recovery-icon {
+  font-size: 32rpx;
+  margin-right: 12rpx;
+  animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+  from { transform: rotate(0deg); }
+  to { transform: rotate(360deg); }
+}
+
+.recovery-text {
+  font-size: 26rpx;
+  font-weight: 500;
+}
+
+.status-tip {
+  padding: 12rpx;
+  background: rgba(255, 255, 255, 0.1);
+  border-radius: 8rpx;
+}
+
+.tip-text {
+  font-size: 24rpx;
+  opacity: 0.9;
+}
+
+/* 失败提示 */
+.failed-tip {
+  margin-top: 24rpx;
+  padding: 24rpx;
+  background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
+  border-radius: 16rpx;
+  border: 2rpx solid #fca5a5;
+}
+
+.failed-icon {
+  font-size: 40rpx;
+  display: block;
+  text-align: center;
+  margin-bottom: 12rpx;
+}
+
+.failed-text {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #dc2626;
+  display: block;
+  text-align: center;
+  margin-bottom: 12rpx;
+}
+
+.failed-error {
+  font-size: 26rpx;
+  color: #991b1b;
+  display: block;
+  padding: 16rpx;
+  background: rgba(255, 255, 255, 0.5);
+  border-radius: 8rpx;
+  margin-bottom: 12rpx;
+  line-height: 1.6;
+}
+
+.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;
+}
+
+.chapter-status {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  min-width: 80rpx;
+  padding: 8rpx 16rpx;
+  border-radius: 8rpx;
+  flex-shrink: 0;
+}
+
+.chapter-status.status-completed {
+  background: #d1fae5;
+  color: #059669;
+}
+
+.chapter-status.status-pending {
+  background: #e5e7eb;
+  color: #6b7280;
+}
+
+.chapter-status.status-generating {
+  background: #fef3c7;
+  color: #d97706;
+  animation: pulse-bg 2s ease-in-out infinite;
+}
+
+.chapter-status.status-failed {
+  background: #fee2e2;
+  color: #dc2626;
+}
+
+@keyframes pulse-bg {
+  0%, 100% { opacity: 1; }
+  50% { opacity: 0.7; }
+}
+
+.status-icon {
+  font-size: 28rpx;
+  font-weight: bold;
+  line-height: 1;
+}
+
+.status-text {
+  font-size: 20rpx;
+  margin-top: 4rpx;
+  white-space: nowrap;
+}
+
+/* 节状态 */
+.section-status {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  min-width: 70rpx;
+  padding: 6rpx 12rpx;
+  border-radius: 6rpx;
+  flex-shrink: 0;
+}
+
+.section-status.status-completed {
+  background: #d1fae5;
+  color: #059669;
+}
+
+.section-status.status-pending {
+  background: #f3f4f6;
+  color: #9ca3af;
+}
+
+.section-status.status-generating {
+  background: #fef3c7;
+  color: #d97706;
+  animation: pulse-bg 2s ease-in-out infinite;
+}
+
+.section-status.status-failed {
+  background: #fee2e2;
+  color: #dc2626;
+}
+
+/* 小节状态 */
+.subsection-status {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  min-width: 70rpx;
+  padding: 6rpx 12rpx;
+  border-radius: 6rpx;
+  flex-shrink: 0;
+}
+
+.subsection-status.status-completed {
+  background: #d1fae5;
+  color: #059669;
+}
+
+.subsection-status.status-pending {
+  background: #f3f4f6;
+  color: #9ca3af;
+}
+
+.subsection-status.status-generating {
+  background: #fef3c7;
+  color: #d97706;
+  animation: pulse-bg 2s ease-in-out infinite;
+}
+
+.subsection-status.status-failed {
+  background: #fee2e2;
+  color: #dc2626;
+}
+
+/* 内容预览 */
+.subsection-content-preview {
+  font-size: 22rpx;
+  color: #6b7280;
+  margin-top: 4rpx;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+}
+
 /* 大纲卡片 */
 .outline-theme {
   font-size: 24rpx;
@@ -2773,6 +3258,10 @@ onMounted(() => {
 .chapter-row {
   width: 100%;
   cursor: pointer;
+  background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
+  padding: 16rpx;
+  border-radius: 12rpx;
+  border-left: 4rpx solid #3b82f6;
 }
 
 .chapter-info {
@@ -2839,11 +3328,27 @@ onMounted(() => {
 }
 
 /* 节列表 */
+
+/* 小节媒体标识 */
+.subsection-media-badges {
+  display: flex;
+  gap: 8rpx;
+  margin-top: 6rpx;
+}
+
+.media-badge-small {
+  font-size: 18rpx;
+  padding: 2rpx 8rpx;
+  border-radius: 4rpx;
+  background: rgba(79, 70, 229, 0.1);
+  color: #4f46e5;
+}
 .content-sections-list {
-  margin-left: 64rpx;
+  margin-left: 40rpx;
   margin-top: 12rpx;
-  padding-left: 20rpx;
-  border-left: 2rpx solid #e5e7eb;
+  padding-left: 24rpx;
+  border-left: 3rpx solid #e5e7eb;
+  background: linear-gradient(to right, rgba(243, 244, 246, 0.3), transparent);
 }
 
 .content-section {
@@ -2853,6 +3358,10 @@ onMounted(() => {
 
 .section-row {
   width: 100%;
+  background: #fafafa;
+  padding: 12rpx;
+  border-radius: 8rpx;
+  border-left: 3rpx solid #10b981;
 }
 
 .section-num-badge {
@@ -2889,8 +3398,11 @@ onMounted(() => {
 
 /* 小节列表 */
 .content-subsections-list {
-  margin-left: 56rpx;
+  margin-left: 32rpx;
   margin-top: 8rpx;
+  padding-left: 20rpx;
+  border-left: 2rpx dashed #d1d5db;
+  background: linear-gradient(to right, rgba(209, 213, 219, 0.2), transparent);
 }
 
 .content-subsection {
@@ -2900,6 +3412,15 @@ onMounted(() => {
 
 .subsection-row {
   width: 100%;
+  padding: 10rpx;
+  border-radius: 6rpx;
+  border-left: 2rpx solid #8b5cf6;
+  transition: all 0.2s;
+}
+
+.subsection-row:active {
+  background: #f3f4f6;
+  transform: translateX(4rpx);
 }
 
 .subsection-num-badge {
@@ -3206,3 +3727,4 @@ onMounted(() => {
 }
 </style>
 
+

+ 62 - 2
my-uniapp-vue3/src/pages/player/index.vue

@@ -9,6 +9,17 @@
       <view class="nav-btn" />
     </view>
 
+    <!-- 无音频提示 -->
+    <view v-if="!audio?.audioUrl" class="empty-state">
+      <text class="empty-icon">🎵</text>
+      <text class="empty-title">暂无音频</text>
+      <text class="empty-desc">该章节还没有生成音频</text>
+      <button class="empty-btn" @click="goBack">返回</button>
+    </view>
+
+    <!-- 正常播放界面 -->
+    <template v-else>
+
     <!-- 封面和标题 -->
     <view class="cover-section">
       <view class="cover">
@@ -95,6 +106,7 @@
         </view>
       </view>
     </view>
+    </template>
   </view>
 </template>
 
@@ -140,9 +152,19 @@ onUnmounted(() => {
 // 获取音频详情
 async function fetchAudio() {
   try {
-    const result = await get<AudioItem>(`/audio/${audioId.value}`);
+    const result = await get<AudioItem>(`/player/audio/${audioId.value}`);
     audio.value = result;
 
+    // 检查是否有音频URL
+    if (!result.audioUrl) {
+      uni.showToast({ 
+        title: '该章节没有音频,请先生成音频', 
+        icon: 'none',
+        duration: 2000
+      });
+      return;
+    }
+
     // 开始播放(store 中已初始化 audioContext)
     audioStore.play(result);
   } catch (error: any) {
@@ -154,7 +176,7 @@ async function fetchAudio() {
 // 获取播放列表
 async function fetchPlaylist() {
   try {
-    const result = await get<{ list: AudioItem[] }>('/audio/list', {
+    const result = await get<{ list: AudioItem[] }>('/player/audio/list', {
       page: 1,
       pageSize: 100,
     });
@@ -303,6 +325,44 @@ function copyShareLink() {
   padding-bottom: env(safe-area-inset-bottom);
 }
 
+/* 空状态 */
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 200rpx 40rpx;
+  text-align: center;
+}
+
+.empty-icon {
+  font-size: 120rpx;
+  margin-bottom: 32rpx;
+  opacity: 0.5;
+}
+
+.empty-title {
+  font-size: 36rpx;
+  font-weight: 600;
+  color: #ffffff;
+  margin-bottom: 16rpx;
+}
+
+.empty-desc {
+  font-size: 28rpx;
+  color: #9ca3af;
+  margin-bottom: 48rpx;
+}
+
+.empty-btn {
+  padding: 24rpx 64rpx;
+  background: linear-gradient(135deg, #4F46E5 0%, #7C3AED 100%);
+  color: #ffffff;
+  border-radius: 48rpx;
+  font-size: 28rpx;
+  border: none;
+}
+
 .nav-bar {
   display: flex;
   align-items: center;

+ 5 - 4
my-uniapp-vue3/src/utils/book-generator-api.ts

@@ -284,15 +284,16 @@ export async function generateWithLangGraph(bookId: string): Promise<{ bookId: s
 // ============ 音频生成 API ============
 
 /**
- * 生成单个章节音频
+ * 生成单个章节音频(实际调用批量生成,后端会生成所有章节)
  */
 export async function generateChapterAudio(
   bookId: string,
   chapterNumber: number,
   voiceId: string = 'cherry'
-): Promise<{ taskId: string; chapterId: string }> {
-  const result = await request<{ taskId: string; chapterId: string }>(
-    `${BASE_URL}/books/${bookId}/chapters/${chapterNumber}/audio`,
+): Promise<{ taskId: string; totalSubsections: number }> {
+  // 后端只有批量生成API,所以调用批量生成
+  const result = await request<{ taskId: string; totalSubsections: number }>(
+    `${BASE_URL}/books/${bookId}/audio`,
     {
       method: 'POST',
       data: { voiceId },

+ 104 - 0
my-uniapp-vue3/src/utils/websocket.ts

@@ -0,0 +1,104 @@
+/**
+ * WebSocket服务 - 接收实时通知
+ */
+
+class WebSocketService {
+  private ws: WebSocket | null = null;
+  private reconnectTimer: number | null = null;
+  private messageHandlers: Map<string, Function[]> = new Map();
+
+  /**
+   * 连接WebSocket
+   */
+  connect(token: string) {
+    if (this.ws) {
+      this.ws.close();
+    }
+
+    const wsUrl = `ws://localhost:3000/ws?token=${token}`;
+    
+    try {
+      this.ws = new WebSocket(wsUrl);
+
+      this.ws.onopen = () => {
+        console.log('[WebSocket] 连接成功');
+      };
+
+      this.ws.onmessage = (event) => {
+        try {
+          const message = JSON.parse(event.data);
+          this.handleMessage(message);
+        } catch (error) {
+          console.error('[WebSocket] 消息解析失败:', error);
+        }
+      };
+
+      this.ws.onerror = (error) => {
+        console.error('[WebSocket] 错误:', error);
+      };
+
+      this.ws.onclose = () => {
+        console.log('[WebSocket] 连接关闭,3秒后重连...');
+        this.scheduleReconnect(token);
+      };
+    } catch (error) {
+      console.error('[WebSocket] 连接失败:', error);
+      this.scheduleReconnect(token);
+    }
+  }
+
+  /**
+   * 处理消息
+   */
+  private handleMessage(message: any) {
+    console.log('[WebSocket] 收到消息:', message.type);
+
+    // 书籍生成通知
+    if (message.type === 'book_generation_notification') {
+      const handlers = this.messageHandlers.get('book_generation_notification') || [];
+      handlers.forEach(handler => handler(message.data));
+    }
+
+    // 其他类型的消息...
+  }
+
+  /**
+   * 重连
+   */
+  private scheduleReconnect(token: string) {
+    if (this.reconnectTimer) {
+      clearTimeout(this.reconnectTimer);
+    }
+
+    this.reconnectTimer = setTimeout(() => {
+      this.connect(token);
+    }, 3000) as unknown as number;
+  }
+
+  /**
+   * 监听消息
+   */
+  on(eventType: string, handler: Function) {
+    if (!this.messageHandlers.has(eventType)) {
+      this.messageHandlers.set(eventType, []);
+    }
+    this.messageHandlers.get(eventType)!.push(handler);
+  }
+
+  /**
+   * 断开连接
+   */
+  disconnect() {
+    if (this.reconnectTimer) {
+      clearTimeout(this.reconnectTimer);
+      this.reconnectTimer = null;
+    }
+
+    if (this.ws) {
+      this.ws.close();
+      this.ws = null;
+    }
+  }
+}
+
+export const wsService = new WebSocketService();

+ 4 - 0
my-uniapp-vue3/vite.config.ts

@@ -14,6 +14,10 @@ export default defineConfig({
         target: 'http://localhost:3000',
         changeOrigin: true,
       },
+      '/videos': {
+        target: 'http://localhost:3000',
+        changeOrigin: true,
+      },
     },
   },
 });

+ 2 - 2
server/prisma/schema.prisma

@@ -186,11 +186,11 @@ model Book {
 // 书籍章节(内容单元)
 // 一个章节 = 一份内容,有3种形式:content(文本)、audioUrl(音频)、videoUrl(视频)
 // level=1 表示章(顶级),level=2 表示节,level=3 表示小节
-// parentId = null 表示章(顶级),parentId = 章ID 表示节,parentId = 节ID 表示小节
+// parentId = 0 表示章(顶级),parentId = 章ID 表示节,parentId = 节ID 表示小节
 model BookChapter {
   id              Int       @id @default(autoincrement())
   bookId          Int
-  parentId        Int?      // 父节点ID(null = 章这一级)
+  parentId        Int       @default(0) // 父节点ID(0 = 章这一级)
   level           Int       @default(1) // 层级:1=章, 2=节, 3=小节
   number          Int       // 同级排序序号
   title           String    // 章节标题

+ 333 - 0
server/src/modules/book-generator/FAULT_TOLERANCE.md

@@ -0,0 +1,333 @@
+# 书籍生成容错机制说明
+
+## 核心问题
+
+用户之前遇到的痛点:
+1. **AI调用失败后任务卡住**,用户不知道发生了什么
+2. **长时间无响应**,用户傻等一天也不知道进度
+3. **没有自动重试**,一次失败就彻底失败
+4. **没有用户通知**,前端无法实时显示错误信息
+
+## 解决方案
+
+### 1. AI调用重试机制(3次重试 + 指数退避)
+
+**文件**: `fault-tolerance.ts` → `callLLMWithRetry()`
+
+**工作流程**:
+```
+AI调用失败 
+  ↓
+等待2秒后重试 (第1次)
+  ↓  
+等待4秒后重试 (第2次)
+  ↓
+等待8秒后重试 (第3次)
+  ↓
+全部失败 → 标记失败 + 通知用户
+```
+
+**特点**:
+- 指数退避:2秒 → 4秒 → 8秒 → 16秒 → 30秒(最大)
+- 每次重试都会记录到数据库的errorMsg字段
+- 每次重试都会通过WebSocket通知用户
+
+**代码示例**:
+```typescript
+const response = await callLLMWithRetry(
+  messages,
+  undefined,
+  {
+    bookId: state.bookId,
+    nodeId: 'generate_outline',
+    attempt: 0,
+    maxAttempts: 3,
+  }
+);
+```
+
+### 2. 节点级超时控制
+
+**文件**: `fault-tolerance.ts` → `executeNodeWithTimeout()`
+
+**超时配置**:
+```typescript
+generate_outline: 5分钟      // 大纲生成
+generate_sections: 10分钟    // 节生成
+generate_subsections: 15分钟 // 小节生成
+write_chapters: 30分钟       // 内容生成(最长)
+write_foreword: 5分钟        // 前言
+write_afterword: 5分钟       // 后记
+```
+
+**工作流程**:
+```
+节点开始执行
+  ↓
+启动超时计时器
+  ↓
+节点执行完成 → 清除计时器 → 通知用户
+  ↓
+超时 → 抛出错误 → 触发自动恢复
+```
+
+**代码示例**:
+```typescript
+const response = await executeNodeWithTimeout(
+  state.bookId,
+  'generate_outline',
+  async () => {
+    return callLLMWithRetry(...);
+  },
+  5 * 60 * 1000  // 5分钟超时
+);
+```
+
+### 3. 进度监控和长时间无响应告警
+
+**文件**: `fault-tolerance.ts` → `startProgressMonitor()`
+
+**监控策略**:
+```
+每1分钟检查一次进度
+  ↓
+10分钟无进度更新 → 第1次警告(用户收到通知)
+  ↓
+20分钟无进度更新 → 第2次警告(建议手动干预)
+  ↓
+30分钟无进度更新 → 尝试自动恢复
+```
+
+**通知内容**:
+- 第1次:"生成进度长时间未更新,系统正在监控中..."
+- 第2次:"生成任务可能已卡住,建议刷新页面或重新生成"
+- 第3次:"系统检测到任务异常,正在尝试自动恢复..."
+
+**代码示例**:
+```typescript
+// 在主生成器中启动
+const stopMonitor = startProgressMonitor(bookId);
+
+try {
+  // 执行生成任务
+} finally {
+  // 无论成功或失败都停止监控
+  stopMonitor();
+}
+```
+
+### 4. 失败自动恢复机制
+
+**文件**: `fault-tolerance.ts` → `attemptAutoRecovery()`
+
+**恢复策略**:
+```
+检测到任务失败/超时
+  ↓
+等待5分钟(给AI服务恢复的时间)
+  ↓
+重新加入队列
+  ↓
+最多尝试2次
+  ↓
+2次都失败 → 标记为最终失败 → 通知用户手动操作
+```
+
+**限制**:
+- 最多自动恢复2次(防止无限循环)
+- 每次恢复间隔5分钟
+- 达到最大次数后标记为最终失败
+
+**代码示例**:
+```typescript
+// 进度监控器检测到异常时自动调用
+await attemptAutoRecovery(bookId);
+```
+
+### 5. WebSocket实时通知
+
+**文件**: `fault-tolerance.ts` → `notifyUser()`
+
+**通知类型**:
+```typescript
+type: 'ai_retry'          // AI调用重试
+type: 'ai_failed'         // AI调用最终失败
+type: 'node_start'        // 节点开始执行
+type: 'node_complete'     // 节点执行完成
+type: 'node_timeout'      // 节点执行超时
+type: 'progress_warning'  // 进度警告(10分钟)
+type: 'progress_critical' // 进度严重警告(20分钟)
+type: 'auto_recovery'     // 正在自动恢复
+type: 'recovery_success'  // 自动恢复成功
+type: 'recovery_failed'   // 自动恢复失败
+type: 'recovery_error'    // 自动恢复出错
+```
+
+**通知内容**:
+```json
+{
+  "type": "book_generation_notification",
+  "data": {
+    "bookId": "1",
+    "timestamp": 1234567890,
+    "type": "ai_retry",
+    "nodeId": "generate_outline",
+    "attempt": 1,
+    "maxAttempts": 3,
+    "message": "AI调用失败,正在重试 (1/3)..."
+  }
+}
+```
+
+## 集成方式
+
+### 方式1:在节点中使用(推荐)
+
+```typescript
+import { callLLMWithRetry, executeNodeWithTimeout, FAULT_TOLERANCE_CONFIG } from '../fault-tolerance';
+
+export async function generateOutlineNode(state: typeof GraphState.State) {
+  try {
+    const response = await executeNodeWithTimeout(
+      state.bookId,
+      'generate_outline',
+      async () => {
+        return callLLMWithRetry(
+          buildOutlineMessages(...),
+          undefined,
+          {
+            bookId: state.bookId,
+            nodeId: 'generate_outline',
+            attempt: 0,
+            maxAttempts: FAULT_TOLERANCE_CONFIG.aiRetry.maxRetries,
+          }
+        );
+      },
+      FAULT_TOLERANCE_CONFIG.nodeTimeout.generate_outline
+    );
+    
+    // 处理响应...
+  } catch (error) {
+    // 错误已记录,抛出给上层处理
+    throw error;
+  }
+}
+```
+
+### 方式2:在主生成器中启动进度监控
+
+```typescript
+import { startProgressMonitor } from './fault-tolerance';
+
+async generate(bookId: string, topic: string, bookScale: string) {
+  // 启动进度监控
+  const stopMonitor = startProgressMonitor(bookId);
+  
+  try {
+    // 执行生成任务
+    const stream = await this.graph.stream(initialState);
+    for await (const step of stream) {
+      // ...
+    }
+  } finally {
+    // 无论成功或失败都停止监控
+    stopMonitor();
+  }
+}
+```
+
+## 当前状态
+
+### ✅ 已完成
+1. 容错层核心逻辑(fault-tolerance.ts)
+2. AI调用重试机制(3次重试 + 指数退避)
+3. 节点级超时控制(6个节点各有不同超时时间)
+4. 进度监控器(10/20/30分钟三级告警)
+5. 自动恢复机制(最多2次,间隔5分钟)
+6. WebSocket实时通知(12种通知类型)
+7. 集成到大纲生成节点(outline.node.ts)
+8. 集成到主生成器(index.ts)
+
+### ⏳ 待完成
+1. 集成到其他节点(sections.node.ts、content.node.ts、foreword.node.ts)
+2. 前端WebSocket接收和显示通知
+3. 前端UI展示错误信息和重试进度
+
+## 测试建议
+
+### 场景1:AI调用超时
+```
+1. 触发书籍生成
+2. 等待5分钟(大纲节点超时时间)
+3. 观察:
+   - 终端日志:"[容错] ⚠️ 节点 generate_outline 执行超时"
+   - WebSocket通知:type='node_timeout'
+   - 数据库:status='failed', errorMsg包含超时信息
+   - 自动恢复:5分钟后重新加入队列
+```
+
+### 场景2:AI调用失败(网络错误)
+```
+1. 触发书籍生成
+2. 断开网络或模拟API错误
+3. 观察:
+   - 终端日志:"[容错] AI调用失败 (attempt 1/3)"
+   - 等待2秒后自动重试
+   - WebSocket通知:type='ai_retry', attempt=1
+   - 3次都失败后:type='ai_failed'
+   - 自动恢复机制启动
+```
+
+### 场景3:长时间无响应
+```
+1. 触发书籍生成
+2. 在AI调用处设置断点或延迟
+3. 观察:
+   - 10分钟后:type='progress_warning'
+   - 20分钟后:type='progress_critical'
+   - 30分钟后:type='auto_recovery',尝试自动恢复
+```
+
+## 优势总结
+
+### 对比之前
+| 问题 | 之前 | 现在 |
+|------|------|------|
+| AI调用失败 | 直接报错,任务卡住 | 自动重试3次,失败后自动恢复 |
+| 超时 | 无超时控制,无限等待 | 节点级超时,超时自动处理 |
+| 用户反馈 | 无反馈,用户傻等 | WebSocket实时推送进度和错误 |
+| 长时间无响应 | 无检测机制 | 10/20/30分钟三级告警+自动恢复 |
+| 自动恢复 | 仅重启时恢复 | 运行时自动检测+恢复(最多2次) |
+
+### 用户体验提升
+1. **不再傻等**:10分钟无响应就会收到警告
+2. **不再困惑**:实时知道当前进度和遇到的问题
+3. **不再手动重试**:系统自动重试和恢复
+4. **明确的操作指引**:自动恢复失败后会提示手动操作
+
+## 配置调整
+
+所有配置集中在 `FAULT_TOLERANCE_CONFIG`:
+
+```typescript
+export const FAULT_TOLERANCE_CONFIG = {
+  aiRetry: {
+    maxRetries: 3,              // 调整重试次数
+    initialDelayMs: 2000,       // 调整初始延迟
+    maxDelayMs: 30000,          // 调整最大延迟
+    backoffMultiplier: 2,       // 调整退避倍数
+  },
+  nodeTimeout: {
+    generate_outline: 5 * 60 * 1000,    // 调整各节点超时时间
+    // ...
+  },
+  progressMonitor: {
+    maxIdleTimeMs: 10 * 60 * 1000,  // 调整告警阈值
+    checkIntervalMs: 60 * 1000,     // 调整检查频率
+  },
+  autoRecovery: {
+    maxRecoveryAttempts: 2,         // 调整最大恢复次数
+    recoveryDelayMs: 5 * 60 * 1000, // 调整恢复延迟
+  },
+};
+```

+ 52 - 38
server/src/modules/book-generator/book-generator.store.ts

@@ -36,7 +36,7 @@ function chaptersFromDb(dbChapters: any[], bookId: number): Chapter[] {
     videoDuration: c.videoDuration || undefined,
     isPublic: c.isPublic || false,
     level: c.level,        // 层级:1=章, 2=节, 3=小节
-    parentId: c.parentId,  // 父节点ID(null表示章)
+    parentId: c.parentId,  // 父节点ID(0表示章)
   }));
 }
 
@@ -202,6 +202,10 @@ export class BookStore {
       result.outline = outline;
     }
     
+    // 返回所有章节(level=1,2,3),前端需要完整数据来构建三级树形结构
+    // outline中已包含完整的树形结构(章→节→小节)
+    // result.chapters = result.chapters.filter((c) => c.level === 1);  // 旧代码:只返回章
+    
     // 如果需要过滤公开音频
     if (filterPublic && userId) {
       const isOwner = book.userId === userId;
@@ -354,46 +358,36 @@ export class BookStore {
     estimatedWords?: number;
   }>): Promise<void> {
     const bookIdNum = parseInt(bookId);
-    // 由于parentId为null不能作为复合唯一约束,改用find+upsert方式
+    // 使用 upsert 避免重复创建(parentId=0表示章级别)
     for (const c of chapters) {
-      // 先查找是否存在
-      const existing = await prisma.bookChapter.findFirst({
+      await prisma.bookChapter.upsert({
         where: {
+          bookId_parentId_level_number: {
+            bookId: bookIdNum,
+            parentId: 0,
+            level: 1,
+            number: c.number,
+          }
+        },
+        update: {
+          title: c.title,
+          summary: c.summary,
+          keyPoints: c.keyPoints ? JSON.stringify(c.keyPoints) : null,
+          estimatedWords: c.estimatedWords || 5000,
+          status: 'completed',
+        } as any,
+        create: {
           bookId: bookIdNum,
-          parentId: null,
+          parentId: 0,
           level: 1,
           number: c.number,
-        }
+          title: c.title,
+          summary: c.summary,
+          keyPoints: c.keyPoints ? JSON.stringify(c.keyPoints) : null,
+          estimatedWords: c.estimatedWords || 5000,
+          status: 'completed',
+        } as any,
       });
-
-      if (existing) {
-        // 存在则更新
-        await prisma.bookChapter.update({
-          where: { id: existing.id },
-          data: {
-            title: c.title,
-            summary: c.summary,
-            keyPoints: c.keyPoints ? JSON.stringify(c.keyPoints) : null,
-            estimatedWords: c.estimatedWords || 5000,
-            status: 'completed',
-          } as any,
-        });
-      } else {
-        // 不存在则创建
-        await prisma.bookChapter.create({
-          data: {
-            bookId: bookIdNum,
-            parentId: null,
-            level: 1,
-            number: c.number,
-            title: c.title,
-            summary: c.summary,
-            keyPoints: c.keyPoints ? JSON.stringify(c.keyPoints) : null,
-            estimatedWords: c.estimatedWords || 5000,
-            status: 'completed',
-          } as any,
-        });
-      }
     }
   }
 
@@ -497,7 +491,7 @@ export class BookStore {
       status: updated.status as ChapterStatus,
       summary: updated.summary || undefined,
       generatedAt: updated.generatedAt || undefined,
-      error: updated.contentError || undefined,
+      error: updated.errorMsg || undefined,
     };
   }
 
@@ -511,6 +505,10 @@ export class BookStore {
     errorMsg: string;
     contentStatus: 'pending' | 'generating' | 'completed' | 'failed';
     contentError: string;
+    audioUrl: string;
+    audioDuration: number;
+    videoUrl: string;
+    videoDuration: number;
   }>): Promise<Chapter | null> {
     const updated = await prisma.bookChapter.update({
       where: { id },
@@ -530,7 +528,11 @@ export class BookStore {
       status: updated.status as ChapterStatus,
       summary: updated.summary || undefined,
       generatedAt: updated.generatedAt || undefined,
-      error: updated.contentError || undefined,
+      error: updated.errorMsg || undefined,
+      audioUrl: updated.audioUrl || undefined,
+      audioDuration: updated.audioDuration || 0,
+      videoUrl: updated.videoUrl || undefined,
+      videoDuration: updated.videoDuration || undefined,
     };
   }
 
@@ -678,7 +680,15 @@ export class BookStore {
       include: { book: true },
     });
 
-    if (!chapter || !chapter.content) {
+    if (!chapter) {
+      console.warn(`[Audio] 章节不存在: ${chapterId}`);
+      return null;
+    }
+
+    // 检查内容是否生成完成
+    const chapterAny = chapter as any;
+    if (!chapter.content || chapterAny.contentStatus !== 'completed') {
+      console.warn(`[Audio] 章节内容未生成完成: ${chapterId}, contentStatus: ${chapterAny.contentStatus}`);
       return null;
     }
 
@@ -694,6 +704,10 @@ export class BookStore {
           data: { audioUrl, audioDuration: duration },
         });
         console.log(`✅ 章节${chapterId}音频生成完成:`, audioUrl);
+      },
+      {
+        bookId: chapter.bookId ? String(chapter.bookId) : undefined,
+        chapterTitle: chapter.title,
       }
     );
 

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

@@ -87,7 +87,7 @@ export interface Chapter {
   videoDuration?: number;           // 视频时长
   isPublic?: boolean;               // 是否公开(2026-04-14 新增)
   level?: number;                   // 层级:1=章, 2=节, 3=小节
-  parentId?: number | null;         // 父节点ID(null表示章)
+  parentId?: number;                // 父节点ID(0表示章)
 }
 
 /** 书籍元数据 */

+ 400 - 0
server/src/modules/book-generator/fault-tolerance.ts

@@ -0,0 +1,400 @@
+/**
+ * 智能容错层 - 为书籍生成提供稳定性保障
+ * 
+ * 核心功能:
+ * 1. AI调用重试机制(3次重试 + 指数退避)
+ * 2. 节点级超时控制(超时自动跳过或降级)
+ * 3. 进度监控和长时间无响应告警
+ * 4. 失败自动恢复(智能重试策略)
+ * 5. WebSocket实时推送错误信息给用户
+ */
+
+import { bookStore } from './book-generator.store';
+import { callLLMWithMessages, ChatMessage } from '../../services/llm';
+import { websocketService } from '../../services/websocket.service';
+
+// ============ 配置 ============
+
+export const FAULT_TOLERANCE_CONFIG = {
+  // AI调用重试
+  aiRetry: {
+    maxRetries: 3,              // 最大重试次数
+    initialDelayMs: 2000,       // 初始延迟 2秒
+    maxDelayMs: 30000,          // 最大延迟 30秒
+    backoffMultiplier: 2,       // 指数退避倍数
+  },
+  
+  // 节点超时
+  nodeTimeout: {
+    generate_outline: 5 * 60 * 1000,    // 大纲生成:5分钟
+    generate_sections: 10 * 60 * 1000,  // 节生成:10分钟
+    generate_subsections: 15 * 60 * 1000, // 小节生成:15分钟
+    write_chapters: 30 * 60 * 1000,     // 内容生成:30分钟
+    write_foreword: 5 * 60 * 1000,      // 前言:5分钟
+    write_afterword: 5 * 60 * 1000,     // 后记:5分钟
+  },
+  
+  // 进度监控
+  progressMonitor: {
+    maxIdleTimeMs: 10 * 60 * 1000,  // 最大空闲时间 10分钟
+    checkIntervalMs: 60 * 1000,     // 检查间隔 1分钟
+  },
+  
+  // 自动恢复
+  autoRecovery: {
+    maxRecoveryAttempts: 2,         // 最大恢复尝试次数
+    recoveryDelayMs: 5 * 60 * 1000, // 恢复延迟 5分钟
+  },
+};
+
+// ============ 类型定义 ============
+
+export interface FaultToleranceContext {
+  bookId: string;
+  nodeId: string;
+  attempt: number;
+  maxAttempts: number;
+}
+
+// ============ AI调用重试包装器 ============
+
+/**
+ * 带重试机制的AI调用
+ * 失败后自动重试,使用指数退避策略
+ */
+export async function callLLMWithRetry(
+  messages: ChatMessage[],
+  modelId: string | undefined,
+  context: FaultToleranceContext
+): Promise<string> {
+  const { bookId, nodeId, attempt, maxAttempts } = context;
+  const config = FAULT_TOLERANCE_CONFIG.aiRetry;
+  
+  let lastError: Error | null = null;
+  
+  for (let i = 0; i <= maxAttempts; i++) {
+    try {
+      // 第一次不显示重试日志
+      if (i > 0) {
+        const delay = Math.min(
+          config.initialDelayMs * Math.pow(config.backoffMultiplier, i - 1),
+          config.maxDelayMs
+        );
+        
+        console.log(`[容错] AI调用重试 ${i}/${maxAttempts},等待 ${delay/1000}秒后重试...`);
+        await sleep(delay);
+        
+        // 通知用户正在重试
+        await notifyUser(bookId, {
+          type: 'ai_retry',
+          nodeId,
+          attempt: i,
+          maxAttempts,
+          message: `AI调用失败,正在重试 (${i}/${maxAttempts})...`,
+        });
+      }
+      
+      return await callLLMWithMessages(messages, modelId);
+    } catch (error: any) {
+      lastError = error;
+      console.error(`[容错] AI调用失败 (attempt ${i + 1}/${maxAttempts + 1}):`, error.message);
+      
+      // 记录失败到数据库
+      await logAIFailure(bookId, nodeId, error.message, i + 1);
+    }
+  }
+  
+  // 所有重试都失败了
+  const finalError = new Error(`AI调用失败,已重试${maxAttempts}次: ${lastError?.message}`);
+  console.error(`[容错] ❌ AI调用最终失败:`, finalError.message);
+  
+  // 通知用户AI调用失败
+  await notifyUser(bookId, {
+    type: 'ai_failed',
+    nodeId,
+    message: `AI调用失败,已重试${maxAttempts}次。系统将尝试自动恢复。`,
+    error: lastError?.message,
+  });
+  
+  throw finalError;
+}
+
+// ============ 节点超时控制 ============
+
+/**
+ * 带超时控制的节点执行
+ * 超时后根据策略处理(跳过/降级/标记失败)
+ */
+export async function executeNodeWithTimeout<T>(
+  bookId: string,
+  nodeId: string,
+  nodeFn: () => Promise<T>,
+  timeoutMs?: number
+): Promise<T> {
+  const timeout = timeoutMs || FAULT_TOLERANCE_CONFIG.nodeTimeout[nodeId] || 10 * 60 * 1000;
+  
+  console.log(`[容错] 执行节点 ${nodeId},超时时间: ${timeout/1000}秒`);
+  
+  // 通知用户节点开始执行
+  await notifyUser(bookId, {
+    type: 'node_start',
+    nodeId,
+    message: `正在执行: ${getNodeDisplayName(nodeId)}`,
+  });
+  
+  return new Promise<T>((resolve, reject) => {
+    const timeoutId = setTimeout(async () => {
+      console.error(`[容错] ⚠️ 节点 ${nodeId} 执行超时 (${timeout/1000}秒)`);
+      
+      // 通知用户超时
+      await notifyUser(bookId, {
+        type: 'node_timeout',
+        nodeId,
+        message: `节点执行超时 (${timeout/1000}秒),系统将尝试恢复...`,
+      });
+      
+      reject(new Error(`节点 ${nodeId} 执行超时`));
+    }, timeout);
+    
+    nodeFn()
+      .then((result) => {
+        clearTimeout(timeoutId);
+        
+        // 通知用户节点完成
+        notifyUser(bookId, {
+          type: 'node_complete',
+          nodeId,
+          message: `节点完成: ${getNodeDisplayName(nodeId)}`,
+        }).catch(err => console.error('[容错] 通知用户失败:', err));
+        
+        resolve(result);
+      })
+      .catch((error) => {
+        clearTimeout(timeoutId);
+        reject(error);
+      });
+  });
+}
+
+// ============ 进度监控 ============
+
+/**
+ * 启动进度监控器
+ * 检测长时间无响应的任务
+ */
+export function startProgressMonitor(bookId: string) {
+  const config = FAULT_TOLERANCE_CONFIG.progressMonitor;
+  let lastProgressTime = Date.now();
+  let lastProgress = 0;
+  let idleWarnings = 0;
+  
+  const monitorInterval = setInterval(async () => {
+    try {
+      // 从数据库获取最新进度
+      const book = await bookStore.getById(bookId);
+      if (!book) {
+        clearInterval(monitorInterval);
+        return;
+      }
+      
+      const currentProgress = book.progress || 0;
+      
+      // 如果进度有更新,重置计数器
+      if (currentProgress > lastProgress) {
+        lastProgress = currentProgress;
+        lastProgressTime = Date.now();
+        idleWarnings = 0;
+        return;
+      }
+      
+      // 检查是否超时
+      const idleTime = Date.now() - lastProgressTime;
+      if (idleTime > config.maxIdleTimeMs) {
+        idleWarnings++;
+        
+        console.warn(`[容错] ⚠️ 任务长时间无响应: bookId=${bookId}, 空闲时间=${idleTime/1000}秒`);
+        
+        // 第一次警告
+        if (idleWarnings === 1) {
+          await notifyUser(bookId, {
+            type: 'progress_warning',
+            message: '生成进度长时间未更新,系统正在监控中...',
+            idleTime: Math.round(idleTime / 1000 / 60), // 分钟
+          });
+        }
+        
+        // 第二次警告,建议用户手动干预
+        if (idleWarnings === 2) {
+          await notifyUser(bookId, {
+            type: 'progress_critical',
+            message: '生成任务可能已卡住,建议刷新页面或重新生成',
+            idleTime: Math.round(idleTime / 1000 / 60),
+          });
+        }
+        
+        // 第三次,尝试自动恢复
+        if (idleWarnings >= 3) {
+          console.error(`[容错] ❌ 任务长时间无响应,尝试自动恢复...`);
+          clearInterval(monitorInterval);
+          
+          await notifyUser(bookId, {
+            type: 'auto_recovery',
+            message: '系统检测到任务异常,正在尝试自动恢复...',
+          });
+          
+          await attemptAutoRecovery(bookId);
+        }
+      }
+    } catch (error) {
+      console.error('[容错] 进度监控失败:', error);
+    }
+  }, config.checkIntervalMs);
+  
+  // 返回停止函数
+  return () => {
+    clearInterval(monitorInterval);
+    console.log(`[容错] 进度监控已停止: bookId=${bookId}`);
+  };
+}
+
+// ============ 自动恢复机制 ============
+
+/**
+ * 尝试自动恢复失败的任务
+ */
+export async function attemptAutoRecovery(bookId: string) {
+  const config = FAULT_TOLERANCE_CONFIG.autoRecovery;
+  
+  try {
+    // 获取书籍当前状态
+    const book: any = await bookStore.getById(bookId);
+    if (!book) return;
+    
+    // 检查是否已经超过最大恢复次数(从errorMsg中判断)
+    const errorCount = (book.errorMsg || '').match(/自动恢复/g)?.length || 0;
+    if (errorCount >= config.maxRecoveryAttempts) {
+      console.error(`[容错] ❌ 已达到最大恢复次数 (${config.maxRecoveryAttempts}),放弃恢复`);
+      
+      await notifyUser(bookId, {
+        type: 'recovery_failed',
+        message: `自动恢复失败(已尝试${config.maxRecoveryAttempts}次),请手动重新生成`,
+      });
+      
+      // 标记为最终失败
+      await bookStore.update(bookId, {
+        status: 'failed',
+        errorMsg: `生成失败,已尝试自动恢复${config.maxRecoveryAttempts}次`,
+      });
+      
+      return;
+    }
+    
+    console.log(`[容错] 🔄 尝试自动恢复 (第${errorCount + 1}次)...`);
+    
+    // 等待一段时间后重试
+    await sleep(config.recoveryDelayMs);
+    
+    // 重新加入队列
+    const { queueService } = await import('../../services/queue.service');
+    const jobId = await queueService.addBookGenerationTask({
+      bookId,
+      topic: book.description || book.title,
+      bookScale: book.bookScale || '标准教程',
+    });
+    
+    console.log(`[容错] ✅ 已重新加入队列: jobId=${jobId}`);
+    
+    await notifyUser(bookId, {
+      type: 'recovery_success',
+      message: '系统已自动恢复生成任务,请耐心等待...',
+      jobId,
+    });
+  } catch (error) {
+    console.error('[容错] 自动恢复失败:', error);
+    
+    await notifyUser(bookId, {
+      type: 'recovery_error',
+      message: '自动恢复失败,请手动重新生成',
+      error: error instanceof Error ? error.message : '未知错误',
+    });
+  }
+}
+
+// ============ 用户通知 ============
+
+export interface UserNotification {
+  type: 'ai_retry' | 'ai_failed' | 'node_start' | 'node_complete' | 'node_timeout' |
+        'progress_warning' | 'progress_critical' | 'auto_recovery' | 
+        'recovery_success' | 'recovery_failed' | 'recovery_error';
+  nodeId?: string;
+  attempt?: number;
+  maxAttempts?: number;
+  message: string;
+  error?: string;
+  idleTime?: number;
+  jobId?: string;
+}
+
+/**
+ * 通过WebSocket通知用户
+ */
+async function notifyUser(bookId: string, notification: UserNotification) {
+  try {
+    // 获取书籍的userId
+    const book = await bookStore.getById(bookId);
+    if (!book?.userId) return;
+    
+    const userId = book.userId;
+    
+    // 通过WebSocket发送通知
+    websocketService.sendMessage(userId, {
+      type: 'book_generation_notification',
+      data: {
+        bookId,
+        timestamp: Date.now(),
+        ...notification,
+      },
+    });
+    
+    console.log(`[容错] 📢 已通知用户: ${notification.type} - ${notification.message}`);
+  } catch (error) {
+    console.error('[容错] 通知用户失败:', error);
+  }
+}
+
+// ============ 失败日志 ============
+
+/**
+ * 记录AI调用失败到数据库
+ */
+async function logAIFailure(bookId: string, nodeId: string, errorMessage: string, attempt: number) {
+  try {
+    // 目前先记录到errorMsg字段
+    const book: any = await bookStore.getById(bookId);
+    const existingErrors = book.errorMsg ? `${book.errorMsg}\n` : '';
+    
+    await bookStore.update(bookId, {
+      errorMsg: `${existingErrors}[${new Date().toISOString()}] ${nodeId} 失败 (尝试${attempt}次): ${errorMessage}`,
+    } as any);
+  } catch (error) {
+    console.error('[容错] 记录失败日志失败:', error);
+  }
+}
+
+// ============ 辅助函数 ============
+
+function sleep(ms: number): Promise<void> {
+  return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+function getNodeDisplayName(nodeId: string): string {
+  const names: Record<string, string> = {
+    generate_outline: '生成章大纲',
+    generate_sections: '生成节大纲',
+    generate_subsections: '生成小节大纲',
+    write_chapters: '生成章节内容',
+    write_foreword: '生成前言',
+    write_afterword: '生成后记',
+  };
+  return names[nodeId] || nodeId;
+}

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

@@ -3,6 +3,7 @@
  * 使用 @langchain/langgraph v1.2.8 API
  * 状态通过数据库传递,LangGraph 只负责流程控制
  * 支持生成过程中额度监控和中断保存
+ * 集成智能容错机制(AI重试、超时控制、进度监控、自动恢复)
  */
 
 import { GraphState } from './graph';
@@ -12,6 +13,7 @@ import { generateSectionsNode, generateSubsectionsNode } from './nodes/sections.
 import { writeChaptersNode } from './nodes/content.node';
 import { writeForewordNode, writeAfterwordNode } from './nodes/foreword.node';
 import { bookStore } from './book-generator.store';
+import { startProgressMonitor } from './fault-tolerance';
 
 // ============ 创建工作流 ============
 
@@ -52,6 +54,9 @@ export class LangGraphBookGenerator {
     };
     await bookStore.update(bookId, { status: 'generating', progress: 0 });
 
+    // 启动进度监控器
+    const stopMonitor = startProgressMonitor(bookId);
+
     try {
       const stream = await this.graph.stream(initialState);
       for await (const step of stream) {
@@ -66,6 +71,9 @@ export class LangGraphBookGenerator {
         status: 'failed', 
         errorMsg: error instanceof Error ? error.message : '生成失败' 
       });
+    } finally {
+      // 停止进度监控
+      stopMonitor();
     }
   }
 }

+ 242 - 38
server/src/modules/book-generator/langgraph-controller.ts

@@ -12,6 +12,7 @@ import { estimateBookWords, estimateAudioMinutesFromWords, checkBookGenerationQu
 import { optionalAuth } from '../../middleware/auth';
 import { getAllBookTypes, getDetectableTypes, getBookTypeConfig, BOOK_TYPE_CONFIG, DETECTABLE_TYPES } from './book-type-config';
 import { callLLMWithMessages, ChatMessage } from '../../services/llm';
+import { createVideoProjectFromBook, generateVideoForProject } from '../video-generator/video-generator.service';
 
 // 开发环境测试用户ID
 const TEST_USER_ID = '1';
@@ -314,23 +315,64 @@ router.post('/books', async (ctx: Context) => {
       totalChapters: estimatedChapters,
     });
 
-    // 将生成任务加入队列(由队列处理器异步执行)
-    const jobId = await queueService.addBookGenerationTask({
-      bookId: book.id,
-      topic: body.description,
-      bookScale,
-    });
-    console.log(`[LangGraph] 生成任务已加入队列: bookId=${book.id}, jobId=${jobId}`);
-
-    ctx.body = {
-      code: 0,
-      message: '书籍创建成功,生成已开始',
-      data: {
-        book,
-        taskId: `lg_${book.id}_${Date.now()}`,
-        status: 'generating',
-      },
-    };
+    // 尝试将生成任务加入队列(队列只是为了改善用户体验)
+    try {
+      const jobId = await queueService.addBookGenerationTask({
+        bookId: book.id,
+        topic: body.description,
+        bookScale,
+      });
+      console.log(`[LangGraph] 生成任务已加入队列: bookId=${book.id}, jobId=${jobId}`);
+      
+      ctx.body = {
+        code: 0,
+        message: '书籍创建成功,生成已开始(队列模式)',
+        data: {
+          book,
+          jobId,
+          status: 'generating',
+          mode: 'queue',
+        },
+      };
+    } catch (queueError) {
+      // 队列失败时,降级为同步执行(确保核心业务不受影响)
+      console.warn(`[LangGraph] 队列不可用,降级为同步执行: bookId=${book.id}`, queueError);
+      
+      try {
+        // 同步调用生成器,等待完成
+        await langGraphGenerator.generate(book.id.toString(), body.description, bookScale);
+        
+        console.log(`[LangGraph] 同步执行完成: bookId=${book.id}`);
+        
+        ctx.body = {
+          code: 0,
+          message: '书籍创建成功,生成已完成(同步模式)',
+          data: {
+            book,
+            status: 'completed',
+            mode: 'sync',
+          },
+        };
+      } catch (generateError) {
+        // 生成失败,更新书籍状态
+        console.error(`[LangGraph] 同步执行失败: bookId=${book.id}`, generateError);
+        await bookStore.update(book.id.toString(), {
+          status: 'failed',
+          errorMsg: generateError instanceof Error ? generateError.message : '生成失败',
+        });
+        
+        ctx.body = {
+          code: 0,
+          message: '书籍创建成功,但生成失败',
+          data: {
+            book,
+            status: 'failed',
+            mode: 'sync',
+            error: generateError instanceof Error ? generateError.message : '生成失败',
+          },
+        };
+      }
+    }
   } catch (error) {
     console.error('启动失败:', error);
     ctx.status = 500;
@@ -563,24 +605,65 @@ router.post('/books/:id/generate', async (ctx: Context) => {
     // 优先使用请求传入的 scale,否则使用书籍保存的 scale,最后默认标准教程
     const bookScale = body.bookScale || book.bookScale || '标准教程';
 
-    // 将生成任务加入队列
-    const jobId = await queueService.addBookGenerationTask({
-      bookId,
-      topic: book.description,
-      bookScale,
-    });
-    
-    console.log(`[LangGraph] 重新生成任务已加入队列: bookId=${bookId}, jobId=${jobId}`);
-
-    ctx.body = {
-      code: 0,
-      message: 'LangGraph 生成任务已启动',
-      data: {
+    // 尝试将生成任务加入队列(队列只是为了改善用户体验)
+    try {
+      const jobId = await queueService.addBookGenerationTask({
         bookId,
-        taskId: `lg_${bookId}_${Date.now()}`,
-        status: 'started',
-      },
-    };
+        topic: book.description,
+        bookScale,
+      });
+      
+      console.log(`[LangGraph] 生成任务已加入队列: bookId=${bookId}, jobId=${jobId}`);
+      
+      ctx.body = {
+        code: 0,
+        message: 'LangGraph 生成任务已启动(队列模式)',
+        data: {
+          bookId,
+          jobId,
+          status: 'queued',
+          mode: 'queue',
+        },
+      };
+    } catch (queueError) {
+      // 队列失败时,降级为同步执行(确保核心业务不受影响)
+      console.warn(`[LangGraph] 队列不可用,降级为同步执行: bookId=${bookId}`, queueError);
+      
+      try {
+        // 同步调用生成器,等待完成
+        await langGraphGenerator.generate(bookId, book.description, bookScale);
+        
+        console.log(`[LangGraph] 同步执行完成: bookId=${bookId}`);
+        
+        ctx.body = {
+          code: 0,
+          message: 'LangGraph 生成任务已完成(同步模式)',
+          data: {
+            bookId,
+            status: 'completed',
+            mode: 'sync',
+          },
+        };
+      } catch (generateError) {
+        // 生成失败,更新书籍状态
+        console.error(`[LangGraph] 同步执行失败: bookId=${bookId}`, generateError);
+        await bookStore.update(bookId, {
+          status: 'failed',
+          errorMsg: generateError instanceof Error ? generateError.message : '生成失败',
+        });
+        
+        ctx.body = {
+          code: 0,
+          message: 'LangGraph 生成任务失败',
+          data: {
+            bookId,
+            status: 'failed',
+            mode: 'sync',
+            error: generateError instanceof Error ? generateError.message : '生成失败',
+          },
+        };
+      }
+    }
   } catch (error) {
     console.error('启动失败:', error);
     ctx.status = 500;
@@ -609,15 +692,34 @@ router.post('/books/:id/audio', async (ctx: Context) => {
 
     // 获取所有小节 (level=3)
     const chapters = await bookStore.getChapterTree(bookId);
-    const subsections = chapters.filter(c => c.level === 3 && c.content);
+    const subsections = chapters.filter((c: any) => c.level === 3);
+
+    // 检查内容状态
+    const subsectionsWithContent = subsections.filter((c: any) => c.content && c.contentStatus === 'completed');
 
     if (subsections.length === 0) {
-      ctx.body = { code: 1, message: '没有可生成音频的小节' };
+      ctx.body = { code: 1, message: '没有小节' };
       return;
     }
 
+    if (subsectionsWithContent.length === 0) {
+      ctx.body = { 
+        code: 1, 
+        message: '没有内容生成完成的小节,请先生成内容',
+        data: {
+          totalSubsections: subsections.length,
+          completedSubsections: 0,
+        }
+      };
+      return;
+    }
+
+    if (subsections.length !== subsectionsWithContent.length) {
+      console.log(`[Audio] 书籍 ${bookId} 共有 ${subsections.length} 个小节,其中 ${subsectionsWithContent.length} 个内容已生成完成`);
+    }
+
     // 异步生成所有小节音频
-    for (const sub of subsections) {
+    for (const sub of subsectionsWithContent) {
       bookStore.generateChapterAudioById(sub.id, book.userId || 1).catch(err => {
         console.error(`[Audio] 小节${sub.number}音频生成失败:`, err);
       });
@@ -627,7 +729,7 @@ router.post('/books/:id/audio', async (ctx: Context) => {
       code: 0,
       message: '音频生成任务已启动',
       data: {
-        totalSubsections: subsections.length,
+        totalSubsections: subsectionsWithContent.length,
         taskId: `audio_${bookId}_${Date.now()}`,
       },
     };
@@ -784,4 +886,106 @@ router.post('/books/:id/retry-chapter', async (ctx: Context) => {
   }
 });
 
+/**
+ * POST /api/book-generator/langgraph/books/:id/videos
+ * 批量生成书籍所有章节视频
+ */
+router.post('/books/:id/videos', async (ctx: Context) => {
+  try {
+    const bookId = ctx.params.id as string;
+    const book = await bookStore.getById(bookId);
+    
+    if (!book) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
+    // 获取所有小节 (level=3)
+    const chapters = await bookStore.getChapterTree(bookId);
+    const subsections = chapters.filter((c: any) => c.level === 3);
+
+    // 过滤出有音频的小节(audioUrl存在且不为空)
+    const subsectionsWithAudio = subsections.filter((c: any) => c.audioUrl && c.audioUrl !== '');
+
+    if (subsections.length === 0) {
+      ctx.body = { code: 1, message: '没有小节' };
+      return;
+    }
+
+    if (subsectionsWithAudio.length === 0) {
+      ctx.body = { 
+        code: 1, 
+        message: '没有音频生成完成的小节,请先生成音频',
+        data: {
+          totalSubsections: subsections.length,
+          audioCompletedSubsections: 0,
+        }
+      };
+      return;
+    }
+
+    if (subsections.length !== subsectionsWithAudio.length) {
+      console.log(`[Video] 书籍 ${bookId} 共有 ${subsections.length} 个小节,其中 ${subsectionsWithAudio.length} 个音频已生成完成`);
+    }
+
+    // 异步生成所有章节视频
+    const taskId = `video_${bookId}_${Date.now()}`;
+    console.log(`[Video] 开始批量生成视频: bookId=${bookId}, taskId=${taskId}, 总数=${subsectionsWithAudio.length}`);
+
+    for (const sub of subsectionsWithAudio) {
+      // 异步处理每个章节的视频生成
+      (async () => {
+        try {
+          console.log(`[Video] 开始生成章节 ${sub.number} 的视频: ${sub.title}`);
+          
+          // 从书籍章节创建视频项目
+          const project = await createVideoProjectFromBook(
+            parseInt(bookId),
+            sub.id,
+            book.userId || 1
+          );
+
+          if (!project) {
+            console.error(`[Video] 章节 ${sub.number} 视频项目创建失败`);
+            return;
+          }
+
+          console.log(`[Video] 章节 ${sub.number} 视频项目创建成功: projectId=${project.id}`);
+
+          // 生成视频
+          const result = await generateVideoForProject(project.id);
+
+          if (result.success && result.outputUrl) {
+            // 更新章节的视频URL
+            await bookStore.updateChapterById(sub.id, {
+              videoUrl: result.outputUrl,
+              videoDuration: result.duration,
+            });
+            console.log(`[Video] 章节 ${sub.number} 视频生成成功: ${result.outputUrl}`);
+          } else {
+            console.error(`[Video] 章节 ${sub.number} 视频生成失败:`, result.error);
+          }
+        } catch (error) {
+          console.error(`[Video] 章节 ${sub.number} 视频生成异常:`, error);
+        }
+      })();
+    }
+
+    ctx.body = {
+      code: 0,
+      message: '视频生成任务已启动',
+      data: {
+        taskId,
+        totalChapters: subsectionsWithAudio.length,
+        totalSubsections: subsections.length,
+      },
+    };
+  } catch (error) {
+    console.error('批量生成视频失败:', error);
+    ctx.status = 500;
+    ctx.body = { code: 1, message: error instanceof Error ? error.message : '批量生成视频失败' };
+  }
+});
+
 export default router;

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

@@ -1,5 +1,5 @@
 /**
- * 大纲生成节点
+ * 大纲生成节点(集成容错机制)
  */
 
 import { GraphState } from '../graph';
@@ -8,14 +8,31 @@ import { callLLMWithMessages } from '../../../services/llm';
 import { parseOutline } from '../parsers/outline.parser';
 import { buildOutlineMessages } from '../prompts/builder';
 import { PROGRESS } from '../utils';
+import { callLLMWithRetry, executeNodeWithTimeout, FAULT_TOLERANCE_CONFIG } from '../fault-tolerance';
 
 export async function generateOutlineNode(state: typeof GraphState.State): Promise<Partial<typeof GraphState.State>> {
   console.log('[LangGraph] 生成大纲, bookId:', state.bookId, 'scale:', state.bookScale);
 
-  const messages = buildOutlineMessages(state.topic, state.bookScale, state.description);
-
   try {
-    const response = await callLLMWithMessages(messages);
+    // 使用容错包装器执行AI调用
+    const response = await executeNodeWithTimeout(
+      state.bookId,
+      'generate_outline',
+      async () => {
+        return callLLMWithRetry(
+          buildOutlineMessages(state.topic, state.bookScale, state.description),
+          undefined,
+          {
+            bookId: state.bookId,
+            nodeId: 'generate_outline',
+            attempt: 0,
+            maxAttempts: FAULT_TOLERANCE_CONFIG.aiRetry.maxRetries,
+          }
+        );
+      },
+      FAULT_TOLERANCE_CONFIG.nodeTimeout.generate_outline
+    );
+
     const outline = parseOutline(response);
     if (!outline) throw new Error('大纲解析失败');
 

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

@@ -56,7 +56,7 @@ export async function generateSectionsNode(state: typeof GraphState.State): Prom
         const chapterRecord = await prisma.bookChapter.findFirst({
           where: { 
             bookId: bookIdNum,
-            parentId: null,
+            parentId: 0,
             level: 1, 
             number: chapter.number 
           }
@@ -151,7 +151,7 @@ export async function generateSubsectionsNode(state: typeof GraphState.State): P
             const chapterRecord = await prisma.bookChapter.findFirst({
               where: { 
                 bookId: bookIdNum,
-                parentId: null,
+                parentId: 0,
                 level: 1, 
                 number: chapter.number 
               }

+ 6 - 2
server/src/modules/tts/audio-merger.ts

@@ -14,8 +14,12 @@ export class AudioMerger {
     }
 
     if (inputFiles.length === 1) {
-      // 单个文件直接返回
-      return inputFiles[0];
+      // 单个文件也要复制到outputPath,保持文件名一致
+      const fs = require('fs');
+      if (inputFiles[0] !== outputPath) {
+        fs.copyFileSync(inputFiles[0], outputPath);
+      }
+      return outputPath;
     }
 
     // 检测是否有远程 URL

+ 27 - 8
server/src/services/queue.service.ts

@@ -1,3 +1,20 @@
+/**
+ * 队列服务
+ * 
+ * 【架构职责】
+ * 队列只负责:排队 + 并发控制
+ * 
+ * 【职责边界】
+ * - ✅ 做:任务排队、并发限制、任务分发
+ * - ❌ 不做:失败重试、超时管理、业务逻辑
+ * 
+ * 【设计原则】
+ * - 单一职责:队列只管排队,不管其他
+ * - 失败重试 → 容错层(fault-tolerance.ts)
+ * - 超时管理 → AI服务层(llm.service.ts)
+ * - 业务逻辑 → 领域层(langgraph-generator.ts)
+ */
+
 import Queue from 'bull';
 import { redisService } from './redis.service';
 
@@ -52,14 +69,16 @@ class QueueService {
           db: parseInt(process.env.REDIS_DB || '0'),
         },
         defaultJobOptions: {
-          attempts: 3,
-          backoff: {
-            type: 'exponential',
-            delay: 1000,
-          },
-          removeOnComplete: 100,
-          removeOnFail: 50,
+          // 队列只负责排队,不负责重试、超时等
+          // 失败重试、超时控制应该由核心业务逻辑处理
+          removeOnComplete: 100,  // 完成后保留100条记录(用于统计)
+          removeOnFail: 50,       // 失败后保留50条记录(用于排查)
         },
+        settings: {
+          // 宽松配置:不要误判正在执行的任务
+          stalledInterval: 10 * 60 * 1000,  // 10分钟检查一次
+          maxStalledCount: 10,               // 允许10次(几乎不限制)
+        }
       });
 
       // 监听队列事件
@@ -128,7 +147,7 @@ class QueueService {
    */
   async addBookGenerationTask(data: TaskData): Promise<string> {
     return this.addTask(QueueType.BOOK_GENERATION, data, {
-      timeout: 1800000, // 30 分钟超时
+      timeout: 7200000, // 2小时超时(一本书完整生成约40-60分钟)
     });
   }