Просмотр исходного кода

feat: 书籍生成服务增强 - Redis队列并发控制+断点续传+重启自动恢复

- 按小节粒度生成(支持8.1.1, 8.1.2等细粒度)
- 断点续传:跳过已完成小节,从断点继续
- Redis队列:最多3本书同时生成,防止API限流
- 重启自动恢复:服务器重启后自动检测并恢复中断任务
- 所有生成任务通过队列执行,不再直接调用
MyFramework User 4 месяцев назад
Родитель
Сommit
fd5d484619

+ 75 - 2
server/src/app.ts

@@ -6,9 +6,23 @@ import koaBody from 'koa-body';
 import serve from 'koa-static';
 import mount from 'koa-mount';
 import path from 'path';
+import http from 'http';
 import { config } from './config';
 import { connectDatabase } from './models';
 import { errorHandler } from './middleware/errorHandler';
+import { requestLogger } from './services/requestLogger';
+import { httpLogger } from './services/logger.service';
+import { redisService } from './services/redis.service';
+import { ossService } from './services/oss.service';
+import { storageService } from './services/storage.service';
+import { queueService } from './services/queue.service';
+import { initBookGenerationQueue, resumeInterruptedTasks } from './modules/book-generator/book-queue.processor';
+import { websocketService } from './services/websocket.service';
+import { initSentry, sentryErrorHandler } from './services/sentry.service';
+import { xssProtection, sqlInjectionProtection } from './middleware/security';
+import { performanceMonitor, getMetrics } from './middleware/performance';
+import { apiRateLimiter } from './middleware/rate-limiter';
+import logRoutes from './services/log.controller';
 import authRoutes from './modules/auth/auth.controller';
 import ttsRoutes from './modules/tts/tts.controller';
 import memberRoutes from './modules/member/member.controller';
@@ -40,13 +54,22 @@ import { initializePlans } from './modules/subscription/subscription.service';
 const app = new Koa();
 const router = new Router();
 
+// 创建 HTTP 服务器
+const server = http.createServer(app.callback());
+
 // 中间件
 app.use(errorHandler);
+app.use(sentryErrorHandler()); // Sentry 错误监控
+app.use(performanceMonitor()); // 性能监控
+app.use(httpLogger); // Winston 日志
+app.use(xssProtection()); // XSS 防护
+app.use(sqlInjectionProtection()); // SQL 注入防护
 app.use(cors({
   origin: '*',
   allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
   allowHeaders: ['Content-Type', 'Authorization'],
 }));
+// app.use(apiRateLimiter); // 全局限流 - 已关闭
 app.use(koaBody({
   multipart: true,
   formidable: {
@@ -67,6 +90,9 @@ router.get('/health', (ctx) => {
   ctx.body = { code: 0, message: 'ok', data: { status: 'healthy' } };
 });
 
+// 性能指标
+router.get('/api/metrics', getMetrics);
+
 // 注册路由
 router.use('/api/auth', authRoutes.routes());
 router.use('/api/tts', ttsRoutes.routes());
@@ -94,21 +120,68 @@ router.use('/api/sign', signRoutes.routes());
 router.use('/api/subscription', subscriptionRoutes.routes());
 router.use('/api/payment', paymentRoutes.routes());
 router.use('/api/publish', publishRoutes.routes());
+router.use('/api', logRoutes.routes());
 
 app.use(router.routes()).use(router.allowedMethods());
 
 // 启动服务
 async function start() {
   try {
+    // 0. 初始化 Sentry
+    initSentry();
+    
+    // 1. 连接数据库
     await connectDatabase();
     console.log('✅ MySQL 连接成功');
     
-    // 初始化订阅套餐数据
+    // 2. 测试 Redis 连接
+    const redisOk = await redisService.testConnection();
+    console.log(redisOk ? '✅ Redis 连接成功' : '⚠️  Redis 连接失败(缓存功能将不可用)');
+    
+    // 3. 测试存储连接
+    const storageOk = await storageService.testConnection();
+    const storageType = storageService.getStorageType();
+    console.log(storageOk 
+      ? `✅ 存储连接成功 (${storageType === 'oss' ? '阿里云 OSS' : '本地存储'})` 
+      : '⚠️  存储连接失败');
+    
+    // 4. 初始化订阅套餐数据
     await initializePlans();
     
-    app.listen(config.port, () => {
+    // 5. 初始化 WebSocket
+    websocketService.initialize(server);
+    
+    // 6. 启动服务
+    server.listen(config.port, () => {
       console.log(`🚀 服务启动成功: http://localhost:${config.port}`);
       console.log(`📁 上传目录: ${config.upload.dir}`);
+      console.log(`💾 存储模式: ${storageType === 'oss' ? '阿里云 OSS' : '本地存储'}`);
+      console.log(`⚡ 缓存模式: ${redisOk ? 'Redis' : '内存'}`);
+      console.log(`🔌 WebSocket: ws://localhost:${config.port}/ws`);
+      console.log(`\n💡 提示: 修改 .env 中的 STORAGE_TYPE 可切换存储模式`);
+    });
+    
+    // 7. 初始化书籍生成队列处理器
+    initBookGenerationQueue();
+    
+    // 自动恢复中断的任务
+    resumeInterruptedTasks();
+    
+    // 7. 优雅关闭
+    process.on('SIGTERM', async () => {
+      console.log('收到 SIGTERM 信号,正在关闭服务...');
+      websocketService.close();
+      await queueService.closeAll();
+      await redisService.disconnect();
+      process.exit(0);
+    });
+    
+    process.on('SIGINT', async () => {
+      console.log('收到 SIGINT 信号,正在关闭服务...');
+      websocketService.close();
+      await queueService.closeAll();
+      await redisService.disconnect();
+      process.exit(0);
     });
   } catch (error) {
     console.error('❌ 服务启动失败:', error);

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

@@ -0,0 +1,101 @@
+/**
+ * 书籍生成队列处理器
+ * 使用 Bull 队列管理并发生成任务
+ */
+
+import { queueService, QueueType } from '../../services/queue.service';
+import { langGraphGenerator } from './langgraph-generator';
+import { bookStore } from './book-generator.store';
+import { prisma } from '../../models';
+
+/**
+ * 初始化书籍生成队列处理器
+ */
+export function initBookGenerationQueue() {
+  console.log('[BookQueue] 初始化书籍生成队列处理器');
+  
+  // 获取书籍生成队列
+  const queue = (queueService as any).getQueue(QueueType.BOOK_GENERATION);
+  
+  // 设置并发限制:最多同时处理 3 个生成任务
+  queue.process(3, async (job: any) => {
+    const { bookId, topic, bookScale } = job.data;
+    
+    console.log(`[BookQueue] 开始处理任务: bookId=${bookId}, 队列进度 ${job.progress()}%`);
+    
+    // 更新状态为生成中
+    await bookStore.update(bookId, { status: 'generating', progress: 0 });
+    
+    try {
+      // 调用 LangGraph 生成器
+      await langGraphGenerator.generate(bookId, topic, bookScale);
+      
+      console.log(`[BookQueue] 任务完成: bookId=${bookId}`);
+      return { success: true, bookId };
+    } catch (error) {
+      console.error(`[BookQueue] 任务失败: bookId=${bookId}`, error);
+      throw error;
+    }
+  });
+  
+  // 监听任务进度更新
+  queue.on('progress', (job: any, progress: number) => {
+    console.log(`[BookQueue] 任务进度: bookId=${job.data.bookId}, progress=${progress}%`);
+  });
+  
+  // 监听任务完成
+  queue.on('completed', (job: any) => {
+    console.log(`[BookQueue] 任务已完成: bookId=${job.data.bookId}`);
+  });
+  
+  // 监听任务失败
+  queue.on('failed', async (job: any, err: Error) => {
+    const { bookId } = job.data;
+    console.error(`[BookQueue] 任务失败: bookId=${bookId}`, err.message);
+    
+    // 更新书籍状态为失败
+    await bookStore.update(bookId, { 
+      status: 'failed', 
+      errorMsg: err.message || '生成失败' 
+    });
+  });
+  
+  console.log('[BookQueue] 队列处理器已启动,最大并发数: 3');
+}
+
+/**
+ * 服务器启动时恢复中断的生成任务
+ */
+export async function resumeInterruptedTasks() {
+  console.log('[BookQueue] 检查是否有中断的生成任务...');
+  
+  try {
+    // 查询所有状态为 generating 的书籍
+    const interruptedBooks = await prisma.book.findMany({
+      where: { status: 'generating' },
+      select: { id: true, title: true, description: true, bookScale: true },
+    });
+    
+    if (interruptedBooks.length === 0) {
+      console.log('[BookQueue] 没有中断的任务');
+      return;
+    }
+    
+    console.log(`[BookQueue] 发现 ${interruptedBooks.length} 个中断的任务,开始恢复...`);
+    
+    // 将中断的任务重新加入队列
+    for (const book of interruptedBooks) {
+      const jobId = await queueService.addBookGenerationTask({
+        bookId: book.id.toString(),
+        topic: book.description || book.title,
+        bookScale: book.bookScale || '标准教程',
+      });
+      
+      console.log(`[BookQueue] 已恢复: 《${book.title}》 (bookId=${book.id}, jobId=${jobId})`);
+    }
+    
+    console.log(`[BookQueue] 成功恢复 ${interruptedBooks.length} 个任务`);
+  } catch (error) {
+    console.error('[BookQueue] 恢复中断任务失败:', error);
+  }
+}

+ 594 - 44
server/src/modules/book-generator/langgraph-controller.ts

@@ -5,21 +5,18 @@
 
 import Router from '@koa/router';
 import { Context } from 'koa';
-import { langGraphGenerator } from './langgraph-generator';
+import { langGraphGenerator, getScaleConfig } from './langgraph-generator';
+import { queueService, QueueType } from '../../services/queue.service';
 import { bookStore } from './book-generator.store';
 import { estimateBookWords, estimateAudioMinutesFromWords, checkBookGenerationQuota } from '../subscription/subscription.service';
+import { optionalAuth } from '../../middleware/auth';
+import { getAllBookTypes, getDetectableTypes, getBookTypeConfig, BOOK_TYPE_CONFIG, DETECTABLE_TYPES } from './book-type-config';
+import { callLLMWithMessages, ChatMessage } from '../../services/llm';
 
-const router = new Router();
+// 开发环境测试用户ID
+const TEST_USER_ID = '1';
 
-// 书籍规模到章节数映射
-const SCALE_TO_CHAPTERS: Record<string, number> = {
-  '800': 1,
-  '2000': 1,
-  '5000': 1,
-  '小册子': 5,
-  '标准教程': 10,
-  '系统教材': 15,
-};
+const router = new Router();
 
 /**
  * GET /api/book-generator/langgraph/estimate
@@ -33,17 +30,18 @@ router.get('/estimate', async (ctx: Context) => {
     ctx.body = { code: 1, message: '请提供书籍规模' };
     return;
   }
-  
-  const wordEstimate = estimateBookWords(scale);
-  const audioMinutes = estimateAudioMinutesFromWords(wordEstimate.avg);
-  const estimatedChapters = SCALE_TO_CHAPTERS[scale] || 10;
-  
+
+  const scaleConfig = getScaleConfig(scale);
+  const avgWords = Math.round((scaleConfig.wordRange.min + scaleConfig.wordRange.max) / 2);
+  const audioMinutes = estimateAudioMinutesFromWords(avgWords);
+  const estimatedChapters = Math.round((scaleConfig.chapterRange.min + scaleConfig.chapterRange.max) / 2);
+
   const result: any = {
     scale,
-    words: wordEstimate,
+    scaleConfig,
     audioMinutes: {
-      min: estimateAudioMinutesFromWords(wordEstimate.min),
-      max: estimateAudioMinutesFromWords(wordEstimate.max),
+      min: estimateAudioMinutesFromWords(scaleConfig.wordRange.min),
+      max: estimateAudioMinutesFromWords(scaleConfig.wordRange.max),
       avg: audioMinutes
     },
     estimatedChapters
@@ -62,16 +60,232 @@ router.get('/estimate', async (ctx: Context) => {
   };
 });
 
+/**
+ * GET /api/book-generator/langgraph/book-types
+ * 获取所有书籍类型配置(供前端显示)
+ */
+router.get('/book-types', async (ctx: Context) => {
+  const types = getAllBookTypes().map(t => ({
+    key: t.key,
+    label: t.label,
+    description: t.description,
+    chapters: t.chapters,
+    totalWords: t.totalWords,
+    chapterWords: t.chapterWords,
+    sectionWords: t.sectionWords,
+    structureFormat: t.structureFormat,
+    readingDifficulty: t.readingDifficulty,
+    isShortArticle: t.isShortArticle,
+  }));
+
+  ctx.body = {
+    code: 0,
+    message: 'success',
+    data: types,
+  };
+});
+
+/**
+ * POST /api/book-generator/langgraph/detect-book-type
+ * AI 自动检测书籍类型
+ */
+router.post('/detect-book-type', async (ctx: Context) => {
+  const { title, description } = ctx.request.body as {
+    title?: string;
+    description?: string;
+  };
+
+  if (!title) {
+    ctx.status = 400;
+    ctx.body = { code: 1, message: '请提供书籍标题' };
+    return;
+  }
+
+  const detectableTypes = DETECTABLE_TYPES;
+
+  // 构建类型特征描述,帮助 AI 分类
+  const typeDescriptions = detectableTypes.map(key => {
+    const t = BOOK_TYPE_CONFIG[key];
+    return `- ${t.key}: ${t.description},${t.chapters.min}-${t.chapters.max}章,总字数${t.totalWords.min}-${t.totalWords.max}字,每章约${t.chapterWords}字,结构形式:${t.structureFormat},阅读难度:${t.readingDifficulty}`;
+  }).join('\n');
+
+  const messages: ChatMessage[] = [
+    {
+      role: 'system',
+      content: `你是一位专业的图书分类编辑。根据用户提供的标题和描述,判断这本书最可能属于以下哪种类型:
+
+可选类型:
+${typeDescriptions}
+
+## 分类规则
+1. 分析标题中的关键词(如"科普""青少年""专业""小说"等)
+2. 分析描述中的目标读者、写作风格、内容深度
+3. 匹配最符合的类型
+
+## 输出格式
+必须返回 JSON,不要包含 markdown 代码块标记:
+{
+  "detectedType": "类型 key",
+  "confidence": 0.85,
+  "reasoning": "分类理由,1-2句话"
+}
+
+confidence 是 0-1 的数值,表示置信程度。`,
+    },
+    {
+      role: 'user',
+      content: `标题:《${title}》\n描述:${description || '无'}`,
+    },
+  ];
+
+  try {
+    const response = await callLLMWithMessages(messages);
+    const parsed = parseDetectResult(response);
+
+    if (!parsed) {
+      // 降级到关键词匹配
+      const fallback = keywordFallback(title, description || '');
+      ctx.body = {
+        code: 0,
+        message: 'success',
+        data: {
+          detectedType: fallback.detectedType,
+          confidence: 0.5,
+          reasoning: '基于关键词匹配(AI 解析失败,使用降级策略)',
+          config: getBookTypeConfig(fallback.detectedType),
+        },
+      };
+      return;
+    }
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: {
+        detectedType: parsed.detectedType,
+        confidence: parsed.confidence,
+        reasoning: parsed.reasoning,
+        config: getBookTypeConfig(parsed.detectedType),
+      },
+    };
+  } catch (error: any) {
+    // 最终降级:关键词匹配
+    const fallback = keywordFallback(title, description || '');
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: {
+        detectedType: fallback.detectedType,
+        confidence: 0.3,
+        reasoning: 'LLM 调用失败,使用关键词匹配降级',
+        config: getBookTypeConfig(fallback.detectedType),
+      },
+    };
+  }
+});
+
+function parseDetectResult(text: string): { detectedType: string; confidence: number; reasoning: string } | null {
+  try {
+    const match = text.match(/\{[\s\S]*\}/);
+    if (!match) return null;
+    const data = JSON.parse(match[0]);
+    if (!data.detectedType) return null;
+    // 验证类型是否有效
+    if (!DETECTABLE_TYPES.includes(data.detectedType)) {
+      // 尝试模糊匹配
+      const found = DETECTABLE_TYPES.find(t => data.detectedType.includes(t) || t.includes(data.detectedType));
+      if (!found) return null;
+      data.detectedType = found;
+    }
+    return {
+      detectedType: data.detectedType,
+      confidence: Math.min(1, Math.max(0, data.confidence || 0.5)),
+      reasoning: data.reasoning || '',
+    };
+  } catch {
+    return null;
+  }
+}
+
+function keywordFallback(title: string, description: string): { detectedType: string } {
+  const text = `${title} ${description}`.toLowerCase();
+
+  if (text.includes('小说') || text.includes('故事') || text.includes('fiction')) {
+    if (text.includes('网络') || text.includes('连载') || text.includes('修仙') || text.includes('穿越')) {
+      return { detectedType: '网络小说' };
+    }
+    return { detectedType: '现代出版长篇小说' };
+  }
+  if (text.includes('科普') || text.includes('经管') || text.includes('畅销') || text.includes('通俗')) {
+    return { detectedType: '科普经管畅销书' };
+  }
+  if (text.includes('专业') || text.includes('大学') || text.includes('研究生') || text.includes('算法') || text.includes('操作系统') || text.includes('数据库')) {
+    return { detectedType: '大学专业教材' };
+  }
+  if (text.includes('中小学') || text.includes('初中') || text.includes('高中') || text.includes('青少年') || text.includes('儿童')) {
+    return { detectedType: '中小学课本' };
+  }
+  if (text.includes('古典') || text.includes('章回') || text.includes('名著') || text.includes('红楼') || text.includes('西游') || text.includes('三国') || text.includes('水浒')) {
+    return { detectedType: '古典名著' };
+  }
+
+  return { detectedType: '中小学课本' }; // 默认
+}
+
+/**
+ * AI 自动检测书籍类型(后端静默调用,前端无感知)
+ */
+async function autoDetectBookType(title: string, description: string): Promise<string> {
+  const detectableTypes = DETECTABLE_TYPES;
+  const typeDescriptions = detectableTypes.map(key => {
+    const t = BOOK_TYPE_CONFIG[key];
+    return `- ${t.key}: ${t.description},${t.chapters.min}-${t.chapters.max}章,总字数${t.totalWords.min}-${t.totalWords.max}字`;
+  }).join('\n');
+
+  const messages: ChatMessage[] = [
+    {
+      role: 'system',
+      content: `你是一位专业的图书分类编辑。根据标题和描述,判断书籍属于以下哪种类型:
+
+可选类型:
+${typeDescriptions}
+
+分类规则:
+1. 分析标题中的关键词(如"科普""青少年""专业""小说"等)
+2. 分析描述中的目标读者、写作风格、内容深度
+3. 匹配最符合的类型
+
+输出格式:只返回类型 key,不要其他内容。`,
+    },
+    {
+      role: 'user',
+      content: `标题:《${title}》\n描述:${description}`,
+    },
+  ];
+
+  try {
+    const response = await callLLMWithMessages(messages);
+    const trimmed = response.trim();
+    if (detectableTypes.includes(trimmed)) return trimmed;
+    const found = detectableTypes.find(t => trimmed.includes(t) || t.includes(trimmed));
+    if (found) return found;
+    return keywordFallback(title, description).detectedType;
+  } catch {
+    return keywordFallback(title, description).detectedType;
+  }
+}
+
 /**
  * POST /api/book-generator/langgraph/books
- * 使用 LangGraph 创建并生成书籍
+ * 使用 LangGraph 创建并生成书籍(异步,自动生成大纲和内容)
+ * AI 根据标题+描述自动判断书籍类型
  */
 router.post('/books', async (ctx: Context) => {
   try {
     const body = ctx.request.body as {
       title: string;
       description: string;
-      bookScale?: 'short' | 'medium' | 'long';
+      bookScale?: string;
       generateForeword?: boolean;
       generateAfterword?: boolean;
     };
@@ -82,32 +296,40 @@ router.post('/books', async (ctx: Context) => {
       return;
     }
 
-    const bookScale = body.bookScale || '标准教程';
+    // AI 自动检测书籍类型(如果前端传了 bookScale 则用前端的,否则自动检测)
+    let bookScale = body.bookScale;
+    if (!bookScale) {
+      const detectResult = await autoDetectBookType(body.title, body.description);
+      bookScale = detectResult;
+      console.log(`[LangGraph] AI 自动检测类型: ${body.title} -> ${bookScale}`);
+    }
 
-    // 创建书籍(先设置一个预估章节数,实际数量由AI分析后确定)
-    const estimatedChapters = SCALE_TO_CHAPTERS[bookScale] || 10;
+    // 创建书籍(预估章节数,实际数量由AI根据字数范围分析后确定)
+    const scaleConfig = getScaleConfig(bookScale);
+    const estimatedChapters = Math.round((scaleConfig.chapterRange.min + scaleConfig.chapterRange.max) / 2);
     const book = await bookStore.create({
       title: body.title,
       description: body.description,
+      bookScale: bookScale,
       totalChapters: estimatedChapters,
     });
 
-    // 启动 LangGraph 生成(异步,不阻塞
-    langGraphGenerator.generate(
-      book.id,
-      body.description,
-      bookScale
-    ).catch(err => {
-      console.error('[LangGraph] 生成失败:', err);
+    // 将生成任务加入队列(由队列处理器异步执行
+    const jobId = await queueService.addBookGenerationTask({
+      bookId: book.id,
+      topic: body.description,
+      bookScale,
+    });
+    console.log([LangGraph] 生成任务已加入队列: bookId=, jobId=\);
     });
 
     ctx.body = {
       code: 0,
-      message: 'LangGraph 生成任务已启动',
+      message: '书籍创建成功,生成已开始',
       data: {
-        bookId: book.id,
+        book,
         taskId: `lg_${book.id}_${Date.now()}`,
-        status: 'started',
+        status: 'generating',
       },
     };
   } catch (error) {
@@ -123,10 +345,34 @@ router.post('/books', async (ctx: Context) => {
 /**
  * GET /api/book-generator/langgraph/books
  * 获取书籍列表
+ * 返回:公开的书籍(有公开音频)+ 当前用户自己的书籍
+ * 注意:此接口已废弃,请使用 /public-books 或 /my-books
  */
-router.get('/books', async (ctx: Context) => {
+router.get('/books', optionalAuth, async (ctx: Context) => {
   try {
-    const books = await bookStore.getAllByUser();
+    const userId = ctx.state.user?.userId;
+    const userIdNum = userId ? parseInt(userId as string) : undefined;
+    
+    // 获取公开书籍(有公开音频的书籍)
+    const publicBooks = await bookStore.getPublicBooks();
+    
+    // 如果用户已登录,获取用户自己的书籍
+    let userBooks: any[] = [];
+    if (userIdNum) {
+      userBooks = await bookStore.getAllByUser(userIdNum, false);
+    }
+    
+    // 合并并去重(按 id)
+    const bookMap = new Map<string, any>();
+    publicBooks.forEach(b => bookMap.set(b.id, b));
+    userBooks.forEach(b => {
+      if (!bookMap.has(b.id)) {
+        bookMap.set(b.id, b);
+      }
+    });
+    
+    const books = Array.from(bookMap.values());
+    
     ctx.body = { code: 0, message: 'success', data: { books } };
   } catch (error) {
     console.error('查询失败:', error);
@@ -135,14 +381,77 @@ router.get('/books', async (ctx: Context) => {
   }
 });
 
+/**
+ * GET /api/book-generator/langgraph/public-books
+ * 获取公开书籍列表(首页专用)
+ * 只返回有公开音频的书籍
+ */
+router.get('/public-books', async (ctx: Context) => {
+  try {
+    const publicBooks = await bookStore.getPublicBooks();
+    ctx.body = { code: 0, message: 'success', data: { books: publicBooks } };
+  } catch (error) {
+    console.error('查询公开书籍失败:', error);
+    ctx.status = 500;
+    ctx.body = { code: 1, message: error instanceof Error ? error.message : '查询失败' };
+  }
+});
+
+/**
+ * GET /api/book-generator/langgraph/my-books
+ * 获取当前用户自己的书籍列表(管理页专用)
+ * 只返回当前用户创建的书籍
+ */
+router.get('/my-books', optionalAuth, async (ctx: Context) => {
+  try {
+    const userId = ctx.state.user?.userId;
+    if (!userId) {
+      ctx.status = 401;
+      ctx.body = { code: 1, message: '请先登录' };
+      return;
+    }
+    
+    const userBooks = await bookStore.getAllByUser(parseInt(userId as string), false);
+    ctx.body = { code: 0, message: 'success', data: { books: userBooks } };
+  } catch (error) {
+    console.error('查询用户书籍失败:', error);
+    ctx.status = 500;
+    ctx.body = { code: 1, message: error instanceof Error ? error.message : '查询失败' };
+  }
+});
+
+/**
+ * PUT /api/book-generator/langgraph/books/:id/publish
+ * 切换书籍公开状态
+ */
+router.put('/books/:id/publish', optionalAuth, async (ctx: Context) => {
+  try {
+    const bookId = ctx.params.id;
+    const newStatus = await bookStore.togglePublish(bookId);
+    ctx.body = { 
+      code: 0, 
+      message: 'success', 
+      data: { isPublished: newStatus } 
+    };
+  } catch (error) {
+    console.error('切换发布状态失败:', error);
+    ctx.status = 500;
+    ctx.body = { code: 1, message: error instanceof Error ? error.message : '操作失败' };
+  }
+});
+
 /**
  * GET /api/book-generator/langgraph/books/:id
  * 获取书籍详情
+ * 支持公开过滤:?filterPublic=true&userId=1
  */
-router.get('/books/:id', async (ctx: Context) => {
+router.get('/books/:id', optionalAuth, async (ctx: Context) => {
   try {
     const bookId = ctx.params.id as string;
-    const book = await bookStore.getById(bookId);
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    const filterPublic = ctx.query.filterPublic === 'true';
+    
+    const book = await bookStore.getById(bookId, filterPublic, parseInt(userId as string));
 
     if (!book) {
       ctx.status = 404;
@@ -158,6 +467,49 @@ router.get('/books/:id', async (ctx: Context) => {
   }
 });
 
+/**
+ * GET /api/book-generator/langgraph/books/:id/progress
+ * 获取书籍生成进度
+ */
+router.get('/books/:id/progress', 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;
+    }
+
+    // 计算实际进度(基于章节完成情况)
+    const completedChapters = book.chapters.filter((c) => c.status === 'completed').length;
+    const totalChapters = book.outline?.chapters?.length || book.totalChapters || 0;
+
+    // 如果有大纲,使用大纲章节数计算进度
+    let progress = book.progress;
+    if (totalChapters > 0 && book.status !== 'completed') {
+      progress = Math.round((completedChapters / totalChapters) * 100);
+    }
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: {
+        bookId,
+        status: book.status,
+        progress,
+        completedChapters,
+        totalChapters,
+      },
+    };
+  } catch (error) {
+    console.error('查询进度失败:', error);
+    ctx.status = 500;
+    ctx.body = { code: 1, message: error instanceof Error ? error.message : '查询失败' };
+  }
+});
+
 /**
  * DELETE /api/book-generator/langgraph/books/:id
  * 删除书籍
@@ -181,6 +533,7 @@ router.delete('/books/:id', async (ctx: Context) => {
 router.post('/books/:id/generate', async (ctx: Context) => {
   try {
     const bookId = ctx.params.id as string;
+    const body = ctx.request.body as { bookScale?: string };
     const book = await bookStore.getById(bookId);
 
     if (!book) {
@@ -189,14 +542,17 @@ router.post('/books/:id/generate', async (ctx: Context) => {
       return;
     }
 
-    // 启动 LangGraph 生成
-    langGraphGenerator.generate(
+    // 优先使用请求传入的 scale,否则使用书籍保存的 scale,最后默认标准教程
+    const bookScale = body.bookScale || book.bookScale || '标准教程';
+
+    // 将生成任务加入队列
+    const jobId = await queueService.addBookGenerationTask({
       bookId,
-      book.description,
-      '标准教程'  // 固定为标准教程规模
-    ).catch(err => {
-      console.error('[LangGraph] 生成失败:', err);
+      topic: book.description,
+      bookScale,
     });
+    
+    console.log(`[LangGraph] 重新生成任务已加入队列: bookId=${bookId}, jobId=${jobId}`);
 
     ctx.body = {
       code: 0,
@@ -217,4 +573,198 @@ router.post('/books/:id/generate', async (ctx: Context) => {
   }
 });
 
+/**
+ * POST /api/book-generator/langgraph/books/:id/audio
+ * 批量生成书籍所有小节的音频
+ */
+router.post('/books/:id/audio', async (ctx: Context) => {
+  try {
+    const bookId = ctx.params.id as string;
+    const { voiceId = 'cherry' } = ctx.request.body as { voiceId?: 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 => c.level === 3 && c.content);
+
+    if (subsections.length === 0) {
+      ctx.body = { code: 1, message: '没有可生成音频的小节' };
+      return;
+    }
+
+    // 异步生成所有小节音频
+    for (const sub of subsections) {
+      bookStore.generateChapterAudioById(sub.id, book.userId || 1).catch(err => {
+        console.error(`[Audio] 小节${sub.number}音频生成失败:`, err);
+      });
+    }
+
+    ctx.body = {
+      code: 0,
+      message: '音频生成任务已启动',
+      data: {
+        totalSubsections: subsections.length,
+        taskId: `audio_${bookId}_${Date.now()}`,
+      },
+    };
+  } catch (error) {
+    console.error('音频生成失败:', error);
+    ctx.status = 500;
+    ctx.body = { code: 1, message: error instanceof Error ? error.message : '音频生成失败' };
+  }
+});
+
+/**
+ * GET /api/book-generator/langgraph/books/:id/failed-chapters
+ * 获取生成失败的小节列表
+ */
+router.get('/books/:id/failed-chapters', 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;
+    }
+
+    // 获取所有失败的小节
+    const chapters = await bookStore.getChapterTree(bookId);
+    const failedSubsections = chapters.filter(c => c.status === 'failed');
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: {
+        bookId,
+        failedCount: failedSubsections.length,
+        failedChapters: failedSubsections.map(c => ({
+          id: c.id,
+          number: c.number,
+          title: c.title,
+          level: c.level,
+          errorMsg: c.errorMsg,
+          parentId: c.parentId,
+        })),
+      },
+    };
+  } catch (error) {
+    console.error('获取失败小节失败:', error);
+    ctx.status = 500;
+    ctx.body = { code: 1, message: error instanceof Error ? error.message : '获取失败' };
+  }
+});
+
+/**
+ * POST /api/book-generator/langgraph/books/:id/resume
+ * 从断点处继续生成(重试失败的小节)
+ */
+router.post('/books/:id/resume', 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;
+    }
+
+    if (book.status === 'completed') {
+      ctx.body = { code: 1, message: '书籍已生成完成,无需继续' };
+      return;
+    }
+
+    // 获取失败的小节
+    const chapters = await bookStore.getChapterTree(bookId);
+    const failedSubsections = chapters.filter(c => c.status === 'failed');
+
+    if (failedSubsections.length === 0) {
+      ctx.body = { code: 1, message: '没有失败的小节需要重试' };
+      return;
+    }
+
+    // 重置失败小节的状态为 pending
+    for (const sub of failedSubsections) {
+      await bookStore.updateChapterById(sub.id, {
+        status: 'pending',
+        errorMsg: null,
+        content: null,
+      });
+    }
+
+    // 将续生成任务加入队列
+    const jobId = await queueService.addBookGenerationTask({
+      bookId,
+      topic: book.description || book.title,
+      bookScale: book.bookScale || '标准教程',
+    });
+    
+    console.log(`[LangGraph] 续生成任务已加入队列: bookId=${bookId}, jobId=${jobId}`);
+    });
+
+    ctx.body = {
+      code: 0,
+      message: `已重启生成,将重试 ${failedSubsections.length} 个失败的小节`,
+      data: {
+        bookId,
+        retryCount: failedSubsections.length,
+        status: 'resuming',
+      },
+    };
+  } catch (error) {
+    console.error('继续生成失败:', error);
+    ctx.status = 500;
+    ctx.body = { code: 1, message: error instanceof Error ? error.message : '继续生成失败' };
+  }
+});
+
+/**
+ * POST /api/book-generator/langgraph/books/:id/retry-chapter
+ * 单独重试某个失败的小节
+ */
+router.post('/books/:id/retry-chapter', async (ctx: Context) => {
+  try {
+    const bookId = ctx.params.id as string;
+    const { chapterId } = ctx.request.body as { chapterId: number };
+
+    if (!chapterId) {
+      ctx.status = 400;
+      ctx.body = { code: 1, message: '请提供章节 ID' };
+      return;
+    }
+
+    // 获取所有章节查找指定的章节
+    const chapters = await bookStore.getChapterTree(bookId);
+    const chapter = chapters.find(c => c.id === chapterId);
+    if (!chapter) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '章节不存在' };
+      return;
+    }
+
+    // 重置章节状态
+    await bookStore.updateChapterById(chapterId, {
+      status: 'pending',
+      errorMsg: null,
+      content: null,
+    });
+
+    ctx.body = {
+      code: 0,
+      message: '章节已重置为待生成状态',
+      data: { chapterId },
+    };
+  } catch (error) {
+    console.error('重试章节失败:', error);
+    ctx.status = 500;
+    ctx.body = { code: 1, message: error instanceof Error ? error.message : '重试失败' };
+  }
+});
+
 export default router;

Разница между файлами не показана из-за своего большого размера
+ 944 - 35
server/src/modules/book-generator/langgraph-generator.ts


+ 278 - 0
server/src/services/queue.service.ts

@@ -0,0 +1,278 @@
+import Queue from 'bull';
+import { redisService } from './redis.service';
+
+// 任务队列类型
+export enum QueueType {
+  AUDIO_GENERATION = 'audio:generation',
+  VIDEO_GENERATION = 'video:generation',
+  BOOK_GENERATION = 'book:generation',
+  EMAIL_SEND = 'email:send',
+}
+
+// 任务状态
+export enum TaskStatus {
+  WAITING = 'waiting',
+  ACTIVE = 'active',
+  COMPLETED = 'completed',
+  FAILED = 'failed',
+  DELAYED = 'delayed',
+}
+
+// 任务数据接口
+interface TaskData {
+  userId?: number;
+  [key: string]: any;
+}
+
+// 任务进度回调
+export type ProgressCallback = (progress: number, data?: any) => void;
+
+class QueueService {
+  private queues: Map<string, Queue.Queue> = new Map();
+  private progressCallbacks: Map<string, ProgressCallback> = new Map();
+
+  constructor() {
+    // 如果 Redis 不可用,记录警告
+    if (!redisService.isAvailable()) {
+      console.warn('[Queue] Redis 不可用,任务队列将无法正常工作');
+    }
+  }
+
+  /**
+   * 获取或创建队列
+   * @param queueName 队列名称
+   */
+  private getQueue(queueName: string): Queue.Queue {
+    if (!this.queues.has(queueName)) {
+      const queue = new Queue(queueName, {
+        redis: {
+          host: process.env.REDIS_HOST || 'localhost',
+          port: parseInt(process.env.REDIS_PORT || '6379'),
+          password: process.env.REDIS_PASSWORD || undefined,
+          db: parseInt(process.env.REDIS_DB || '0'),
+        },
+        defaultJobOptions: {
+          attempts: 3,
+          backoff: {
+            type: 'exponential',
+            delay: 1000,
+          },
+          removeOnComplete: 100,
+          removeOnFail: 50,
+        },
+      });
+
+      // 监听队列事件
+      queue.on('completed', (job) => {
+        console.log(`[Queue] 任务完成: ${queueName}#${job.id}`);
+        this.clearProgressCallback(job.id.toString());
+      });
+
+      queue.on('failed', (job, err) => {
+        console.error(`[Queue] 任务失败: ${queueName}#${job.id}`, err.message);
+        this.clearProgressCallback(job.id.toString());
+      });
+
+      queue.on('error', (err) => {
+        console.error(`[Queue] 队列错误: ${queueName}`, err.message);
+      });
+
+      this.queues.set(queueName, queue);
+      console.log(`[Queue] 队列初始化: ${queueName}`);
+    }
+
+    return this.queues.get(queueName)!;
+  }
+
+  /**
+   * 添加任务到队列
+   * @param queueType 队列类型
+   * @param data 任务数据
+   * @param options 任务选项
+   * @returns 任务 ID
+   */
+  async addTask(
+    queueType: QueueType,
+    data: TaskData,
+    options: Queue.JobOptions = {}
+  ): Promise<string> {
+    const queue = this.getQueue(queueType);
+    const job = await queue.add(data, options);
+    console.log(`[Queue] 任务添加: ${queueType}#${job.id}`);
+    return job.id.toString();
+  }
+
+  /**
+   * 添加音频生成任务
+   * @param data 任务数据
+   */
+  async addAudioGenerationTask(data: TaskData): Promise<string> {
+    return this.addTask(QueueType.AUDIO_GENERATION, data, {
+      timeout: 300000, // 5 分钟超时
+    });
+  }
+
+  /**
+   * 添加视频生成任务
+   * @param data 任务数据
+   */
+  async addVideoGenerationTask(data: TaskData): Promise<string> {
+    return this.addTask(QueueType.VIDEO_GENERATION, data, {
+      timeout: 600000, // 10 分钟超时
+    });
+  }
+
+  /**
+   * 添加书籍生成任务
+   * @param data 任务数据
+   */
+  async addBookGenerationTask(data: TaskData): Promise<string> {
+    return this.addTask(QueueType.BOOK_GENERATION, data, {
+      timeout: 1800000, // 30 分钟超时
+    });
+  }
+
+  /**
+   * 获取任务状态
+   * @param queueType 队列类型
+   * @param jobId 任务 ID
+   */
+  async getTaskStatus(queueType: QueueType, jobId: string): Promise<{
+    status: TaskStatus;
+    progress: number;
+    data?: any;
+    error?: string;
+  }> {
+    const queue = this.getQueue(queueType);
+    const job = await queue.getJob(jobId);
+
+    if (!job) {
+      return {
+        status: TaskStatus.FAILED,
+        progress: 0,
+        error: '任务不存在',
+      };
+    }
+
+    const state = await job.getState();
+    const statusMap: Record<string, TaskStatus> = {
+      waiting: TaskStatus.WAITING,
+      active: TaskStatus.ACTIVE,
+      completed: TaskStatus.COMPLETED,
+      failed: TaskStatus.FAILED,
+      delayed: TaskStatus.DELAYED,
+    };
+
+    return {
+      status: statusMap[state] || TaskStatus.FAILED,
+      progress: job.progress() || 0,
+      data: job.returnvalue,
+      error: job.failedReason,
+    };
+  }
+
+  /**
+   * 更新任务进度
+   * @param queueType 队列类型
+   * @param jobId 任务 ID
+   * @param progress 进度 (0-100)
+   * @param data 附加数据
+   */
+  async updateProgress(
+    queueType: QueueType,
+    jobId: string,
+    progress: number,
+    data?: any
+  ): Promise<void> {
+    const queue = this.getQueue(queueType);
+    const job = await queue.getJob(jobId);
+
+    if (job) {
+      await job.progress(progress);
+      
+      // 触发进度回调
+      const callback = this.progressCallbacks.get(jobId);
+      if (callback) {
+        callback(progress, data);
+      }
+    }
+  }
+
+  /**
+   * 注册进度回调
+   * @param jobId 任务 ID
+   * @param callback 回调函数
+   */
+  onProgress(jobId: string, callback: ProgressCallback): void {
+    this.progressCallbacks.set(jobId, callback);
+  }
+
+  /**
+   * 清除进度回调
+   * @param jobId 任务 ID
+   */
+  clearProgressCallback(jobId: string): void {
+    this.progressCallbacks.delete(jobId);
+  }
+
+  /**
+   * 获取队列统计信息
+   * @param queueType 队列类型
+   */
+  async getQueueStats(queueType: QueueType): Promise<{
+    waiting: number;
+    active: number;
+    completed: number;
+    failed: number;
+    delayed: number;
+  }> {
+    const queue = this.getQueue(queueType);
+    const counts = await queue.getJobCounts();
+    return counts;
+  }
+
+  /**
+   * 清空队列
+   * @param queueType 队列类型
+   */
+  async clearQueue(queueType: QueueType): Promise<void> {
+    const queue = this.getQueue(queueType);
+    await queue.empty();
+    console.log(`[Queue] 队列清空: ${queueType}`);
+  }
+
+  /**
+   * 暂停队列
+   * @param queueType 队列类型
+   */
+  async pauseQueue(queueType: QueueType): Promise<void> {
+    const queue = this.getQueue(queueType);
+    await queue.pause();
+    console.log(`[Queue] 队列暂停: ${queueType}`);
+  }
+
+  /**
+   * 恢复队列
+   * @param queueType 队列类型
+   */
+  async resumeQueue(queueType: QueueType): Promise<void> {
+    const queue = this.getQueue(queueType);
+    await queue.resume();
+    console.log(`[Queue] 队列恢复: ${queueType}`);
+  }
+
+  /**
+   * 关闭所有队列
+   */
+  async closeAll(): Promise<void> {
+    for (const [name, queue] of this.queues) {
+      await queue.close();
+      console.log(`[Queue] 队列关闭: ${name}`);
+    }
+    this.queues.clear();
+  }
+}
+
+// 导出单例
+export const queueService = new QueueService();
+export default queueService;

Некоторые файлы не были показаны из-за большого количества измененных файлов