langgraph-controller.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991
  1. /**
  2. * LangGraph 书籍生成 - API 路由
  3. * 支持书籍创建时的预估显示
  4. */
  5. import Router from '@koa/router';
  6. import { Context } from 'koa';
  7. import { langGraphGenerator, getScaleConfig } from './index';
  8. import { queueService, QueueType } from '../../services/queue.service';
  9. import { bookStore } from './book-generator.store';
  10. import { estimateBookWords, estimateAudioMinutesFromWords, checkBookGenerationQuota } from '../subscription/subscription.service';
  11. import { optionalAuth } from '../../middleware/auth';
  12. import { getAllBookTypes, getDetectableTypes, getBookTypeConfig, BOOK_TYPE_CONFIG, DETECTABLE_TYPES } from './book-type-config';
  13. import { callLLMWithMessages, ChatMessage } from '../../services/llm';
  14. import { createVideoProjectFromBook, generateVideoForProject } from '../video-generator/video-generator.service';
  15. // 开发环境测试用户ID
  16. const TEST_USER_ID = '1';
  17. const router = new Router();
  18. /**
  19. * GET /api/book-generator/langgraph/estimate
  20. * 获取书籍规模预估信息
  21. */
  22. router.get('/estimate', async (ctx: Context) => {
  23. const { scale, userId } = ctx.query as { scale?: string; userId?: string };
  24. if (!scale) {
  25. ctx.status = 400;
  26. ctx.body = { code: 1, message: '请提供书籍规模' };
  27. return;
  28. }
  29. const scaleConfig = getScaleConfig(scale);
  30. const avgWords = Math.round((scaleConfig.wordRange.min + scaleConfig.wordRange.max) / 2);
  31. const audioMinutes = estimateAudioMinutesFromWords(avgWords);
  32. const estimatedChapters = Math.round((scaleConfig.chapterRange.min + scaleConfig.chapterRange.max) / 2);
  33. const result: any = {
  34. scale,
  35. scaleConfig,
  36. audioMinutes: {
  37. min: estimateAudioMinutesFromWords(scaleConfig.wordRange.min),
  38. max: estimateAudioMinutesFromWords(scaleConfig.wordRange.max),
  39. avg: audioMinutes
  40. },
  41. estimatedChapters
  42. };
  43. // 如果提供了 userId,同时检查用户配额
  44. if (userId) {
  45. const quotaCheck = await checkBookGenerationQuota(parseInt(userId), scale);
  46. result.quotaCheck = quotaCheck;
  47. }
  48. ctx.body = {
  49. code: 0,
  50. message: 'success',
  51. data: result
  52. };
  53. });
  54. /**
  55. * GET /api/book-generator/langgraph/book-types
  56. * 获取所有书籍类型配置(供前端显示)
  57. */
  58. router.get('/book-types', async (ctx: Context) => {
  59. const types = getAllBookTypes().map(t => ({
  60. key: t.key,
  61. label: t.label,
  62. description: t.description,
  63. chapters: t.chapters,
  64. totalWords: t.totalWords,
  65. chapterWords: t.chapterWords,
  66. sectionWords: t.sectionWords,
  67. structureFormat: t.structureFormat,
  68. readingDifficulty: t.readingDifficulty,
  69. isShortArticle: t.isShortArticle,
  70. }));
  71. ctx.body = {
  72. code: 0,
  73. message: 'success',
  74. data: types,
  75. };
  76. });
  77. /**
  78. * POST /api/book-generator/langgraph/detect-book-type
  79. * AI 自动检测书籍类型
  80. */
  81. router.post('/detect-book-type', async (ctx: Context) => {
  82. const { title, description } = ctx.request.body as {
  83. title?: string;
  84. description?: string;
  85. };
  86. if (!title) {
  87. ctx.status = 400;
  88. ctx.body = { code: 1, message: '请提供书籍标题' };
  89. return;
  90. }
  91. const detectableTypes = DETECTABLE_TYPES;
  92. // 构建类型特征描述,帮助 AI 分类
  93. const typeDescriptions = detectableTypes.map(key => {
  94. const t = BOOK_TYPE_CONFIG[key];
  95. return `- ${t.key}: ${t.description},${t.chapters.min}-${t.chapters.max}章,总字数${t.totalWords.min}-${t.totalWords.max}字,每章约${t.chapterWords}字,结构形式:${t.structureFormat},阅读难度:${t.readingDifficulty}`;
  96. }).join('\n');
  97. const messages: ChatMessage[] = [
  98. {
  99. role: 'system',
  100. content: `你是一位专业的图书分类编辑。根据用户提供的标题和描述,判断这本书最可能属于以下哪种类型:
  101. 可选类型:
  102. ${typeDescriptions}
  103. ## 分类规则
  104. 1. 分析标题中的关键词(如"科普""青少年""专业""小说"等)
  105. 2. 分析描述中的目标读者、写作风格、内容深度
  106. 3. 匹配最符合的类型
  107. ## 输出格式
  108. 必须返回 JSON,不要包含 markdown 代码块标记:
  109. {
  110. "detectedType": "类型 key",
  111. "confidence": 0.85,
  112. "reasoning": "分类理由,1-2句话"
  113. }
  114. confidence 是 0-1 的数值,表示置信程度。`,
  115. },
  116. {
  117. role: 'user',
  118. content: `标题:《${title}》\n描述:${description || '无'}`,
  119. },
  120. ];
  121. try {
  122. const response = await callLLMWithMessages(messages);
  123. const parsed = parseDetectResult(response);
  124. if (!parsed) {
  125. // 降级到关键词匹配
  126. const fallback = keywordFallback(title, description || '');
  127. ctx.body = {
  128. code: 0,
  129. message: 'success',
  130. data: {
  131. detectedType: fallback.detectedType,
  132. confidence: 0.5,
  133. reasoning: '基于关键词匹配(AI 解析失败,使用降级策略)',
  134. config: getBookTypeConfig(fallback.detectedType),
  135. },
  136. };
  137. return;
  138. }
  139. ctx.body = {
  140. code: 0,
  141. message: 'success',
  142. data: {
  143. detectedType: parsed.detectedType,
  144. confidence: parsed.confidence,
  145. reasoning: parsed.reasoning,
  146. config: getBookTypeConfig(parsed.detectedType),
  147. },
  148. };
  149. } catch (error: any) {
  150. // 最终降级:关键词匹配
  151. const fallback = keywordFallback(title, description || '');
  152. ctx.body = {
  153. code: 0,
  154. message: 'success',
  155. data: {
  156. detectedType: fallback.detectedType,
  157. confidence: 0.3,
  158. reasoning: 'LLM 调用失败,使用关键词匹配降级',
  159. config: getBookTypeConfig(fallback.detectedType),
  160. },
  161. };
  162. }
  163. });
  164. function parseDetectResult(text: string): { detectedType: string; confidence: number; reasoning: string } | null {
  165. try {
  166. const match = text.match(/\{[\s\S]*\}/);
  167. if (!match) return null;
  168. const data = JSON.parse(match[0]);
  169. if (!data.detectedType) return null;
  170. // 验证类型是否有效
  171. if (!DETECTABLE_TYPES.includes(data.detectedType)) {
  172. // 尝试模糊匹配
  173. const found = DETECTABLE_TYPES.find(t => data.detectedType.includes(t) || t.includes(data.detectedType));
  174. if (!found) return null;
  175. data.detectedType = found;
  176. }
  177. return {
  178. detectedType: data.detectedType,
  179. confidence: Math.min(1, Math.max(0, data.confidence || 0.5)),
  180. reasoning: data.reasoning || '',
  181. };
  182. } catch {
  183. return null;
  184. }
  185. }
  186. function keywordFallback(title: string, description: string): { detectedType: string } {
  187. const text = `${title} ${description}`.toLowerCase();
  188. if (text.includes('小说') || text.includes('故事') || text.includes('fiction')) {
  189. if (text.includes('网络') || text.includes('连载') || text.includes('修仙') || text.includes('穿越')) {
  190. return { detectedType: '网络小说' };
  191. }
  192. return { detectedType: '现代出版长篇小说' };
  193. }
  194. if (text.includes('科普') || text.includes('经管') || text.includes('畅销') || text.includes('通俗')) {
  195. return { detectedType: '科普经管畅销书' };
  196. }
  197. if (text.includes('专业') || text.includes('大学') || text.includes('研究生') || text.includes('算法') || text.includes('操作系统') || text.includes('数据库')) {
  198. return { detectedType: '大学专业教材' };
  199. }
  200. if (text.includes('中小学') || text.includes('初中') || text.includes('高中') || text.includes('青少年') || text.includes('儿童')) {
  201. return { detectedType: '中小学课本' };
  202. }
  203. if (text.includes('古典') || text.includes('章回') || text.includes('名著') || text.includes('红楼') || text.includes('西游') || text.includes('三国') || text.includes('水浒')) {
  204. return { detectedType: '古典名著' };
  205. }
  206. return { detectedType: '中小学课本' }; // 默认
  207. }
  208. /**
  209. * AI 自动检测书籍类型(后端静默调用,前端无感知)
  210. */
  211. async function autoDetectBookType(title: string, description: string): Promise<string> {
  212. const detectableTypes = DETECTABLE_TYPES;
  213. const typeDescriptions = detectableTypes.map(key => {
  214. const t = BOOK_TYPE_CONFIG[key];
  215. return `- ${t.key}: ${t.description},${t.chapters.min}-${t.chapters.max}章,总字数${t.totalWords.min}-${t.totalWords.max}字`;
  216. }).join('\n');
  217. const messages: ChatMessage[] = [
  218. {
  219. role: 'system',
  220. content: `你是一位专业的图书分类编辑。根据标题和描述,判断书籍属于以下哪种类型:
  221. 可选类型:
  222. ${typeDescriptions}
  223. 分类规则:
  224. 1. 分析标题中的关键词(如"科普""青少年""专业""小说"等)
  225. 2. 分析描述中的目标读者、写作风格、内容深度
  226. 3. 匹配最符合的类型
  227. 输出格式:只返回类型 key,不要其他内容。`,
  228. },
  229. {
  230. role: 'user',
  231. content: `标题:《${title}》\n描述:${description}`,
  232. },
  233. ];
  234. try {
  235. const response = await callLLMWithMessages(messages);
  236. const trimmed = response.trim();
  237. if (detectableTypes.includes(trimmed)) return trimmed;
  238. const found = detectableTypes.find(t => trimmed.includes(t) || t.includes(trimmed));
  239. if (found) return found;
  240. return keywordFallback(title, description).detectedType;
  241. } catch {
  242. return keywordFallback(title, description).detectedType;
  243. }
  244. }
  245. /**
  246. * POST /api/book-generator/langgraph/books
  247. * 使用 LangGraph 创建并生成书籍(异步,自动生成大纲和内容)
  248. * AI 根据标题+描述自动判断书籍类型
  249. */
  250. router.post('/books', async (ctx: Context) => {
  251. try {
  252. const body = ctx.request.body as {
  253. title: string;
  254. description: string;
  255. bookScale?: string;
  256. generateForeword?: boolean;
  257. generateAfterword?: boolean;
  258. };
  259. if (!body.title || !body.description) {
  260. ctx.status = 400;
  261. ctx.body = { code: 1, message: '书名和描述不能为空' };
  262. return;
  263. }
  264. // AI 自动检测书籍类型(如果前端传了 bookScale 则用前端的,否则自动检测)
  265. let bookScale = body.bookScale;
  266. if (!bookScale) {
  267. const detectResult = await autoDetectBookType(body.title, body.description);
  268. bookScale = detectResult;
  269. console.log(`[LangGraph] AI 自动检测类型: ${body.title} -> ${bookScale}`);
  270. }
  271. // 创建书籍(预估章节数,实际数量由AI根据字数范围分析后确定)
  272. const scaleConfig = getScaleConfig(bookScale);
  273. const estimatedChapters = Math.round((scaleConfig.chapterRange.min + scaleConfig.chapterRange.max) / 2);
  274. const book = await bookStore.create({
  275. title: body.title,
  276. description: body.description,
  277. bookScale: bookScale,
  278. totalChapters: estimatedChapters,
  279. });
  280. // 尝试将生成任务加入队列(队列只是为了改善用户体验)
  281. try {
  282. const jobId = await queueService.addBookGenerationTask({
  283. bookId: book.id,
  284. topic: body.description,
  285. bookScale,
  286. });
  287. console.log(`[LangGraph] 生成任务已加入队列: bookId=${book.id}, jobId=${jobId}`);
  288. ctx.body = {
  289. code: 0,
  290. message: '书籍创建成功,生成已开始(队列模式)',
  291. data: {
  292. book,
  293. jobId,
  294. status: 'generating',
  295. mode: 'queue',
  296. },
  297. };
  298. } catch (queueError) {
  299. // 队列失败时,降级为同步执行(确保核心业务不受影响)
  300. console.warn(`[LangGraph] 队列不可用,降级为同步执行: bookId=${book.id}`, queueError);
  301. try {
  302. // 同步调用生成器,等待完成
  303. await langGraphGenerator.generate(book.id.toString(), body.description, bookScale);
  304. console.log(`[LangGraph] 同步执行完成: bookId=${book.id}`);
  305. ctx.body = {
  306. code: 0,
  307. message: '书籍创建成功,生成已完成(同步模式)',
  308. data: {
  309. book,
  310. status: 'completed',
  311. mode: 'sync',
  312. },
  313. };
  314. } catch (generateError) {
  315. // 生成失败,更新书籍状态
  316. console.error(`[LangGraph] 同步执行失败: bookId=${book.id}`, generateError);
  317. await bookStore.update(book.id.toString(), {
  318. status: 'failed',
  319. errorMsg: generateError instanceof Error ? generateError.message : '生成失败',
  320. });
  321. ctx.body = {
  322. code: 0,
  323. message: '书籍创建成功,但生成失败',
  324. data: {
  325. book,
  326. status: 'failed',
  327. mode: 'sync',
  328. error: generateError instanceof Error ? generateError.message : '生成失败',
  329. },
  330. };
  331. }
  332. }
  333. } catch (error) {
  334. console.error('启动失败:', error);
  335. ctx.status = 500;
  336. ctx.body = {
  337. code: 1,
  338. message: error instanceof Error ? error.message : '启动失败',
  339. };
  340. }
  341. });
  342. /**
  343. * GET /api/book-generator/langgraph/books
  344. * 获取书籍列表
  345. * 返回:公开的书籍(有公开音频)+ 当前用户自己的书籍
  346. * 注意:此接口已废弃,请使用 /public-books 或 /my-books
  347. */
  348. router.get('/books', optionalAuth, async (ctx: Context) => {
  349. try {
  350. const userId = ctx.state.user?.userId;
  351. const userIdNum = userId ? parseInt(userId as string) : undefined;
  352. // 获取公开书籍(有公开音频的书籍)
  353. const publicBooks = await bookStore.getPublicBooks();
  354. // 如果用户已登录,获取用户自己的书籍
  355. let userBooks: any[] = [];
  356. if (userIdNum) {
  357. userBooks = await bookStore.getAllByUser(userIdNum, false);
  358. }
  359. // 合并并去重(按 id)
  360. const bookMap = new Map<string, any>();
  361. publicBooks.forEach(b => bookMap.set(b.id, b));
  362. userBooks.forEach(b => {
  363. if (!bookMap.has(b.id)) {
  364. bookMap.set(b.id, b);
  365. }
  366. });
  367. const books = Array.from(bookMap.values());
  368. ctx.body = { code: 0, message: 'success', data: { books } };
  369. } catch (error) {
  370. console.error('查询失败:', error);
  371. ctx.status = 500;
  372. ctx.body = { code: 1, message: error instanceof Error ? error.message : '查询失败' };
  373. }
  374. });
  375. /**
  376. * GET /api/book-generator/langgraph/public-books
  377. * 获取公开书籍列表(首页专用)
  378. * 只返回有公开音频的书籍
  379. */
  380. router.get('/public-books', async (ctx: Context) => {
  381. try {
  382. const publicBooks = await bookStore.getPublicBooks();
  383. ctx.body = { code: 0, message: 'success', data: { books: publicBooks } };
  384. } catch (error) {
  385. console.error('查询公开书籍失败:', error);
  386. ctx.status = 500;
  387. ctx.body = { code: 1, message: error instanceof Error ? error.message : '查询失败' };
  388. }
  389. });
  390. /**
  391. * GET /api/book-generator/langgraph/my-books
  392. * 获取当前用户自己的书籍列表(管理页专用)
  393. * 只返回当前用户创建的书籍
  394. */
  395. router.get('/my-books', optionalAuth, async (ctx: Context) => {
  396. try {
  397. const userId = ctx.state.user?.userId;
  398. if (!userId) {
  399. ctx.status = 401;
  400. ctx.body = { code: 1, message: '请先登录' };
  401. return;
  402. }
  403. const userBooks = await bookStore.getAllByUser(parseInt(userId as string), false);
  404. ctx.body = { code: 0, message: 'success', data: { books: userBooks } };
  405. } catch (error) {
  406. console.error('查询用户书籍失败:', error);
  407. ctx.status = 500;
  408. ctx.body = { code: 1, message: error instanceof Error ? error.message : '查询失败' };
  409. }
  410. });
  411. /**
  412. * PUT /api/book-generator/langgraph/books/:id/publish
  413. * 切换书籍公开状态
  414. */
  415. router.put('/books/:id/publish', optionalAuth, async (ctx: Context) => {
  416. try {
  417. const bookId = ctx.params.id;
  418. const newStatus = await bookStore.togglePublish(bookId);
  419. ctx.body = {
  420. code: 0,
  421. message: 'success',
  422. data: { isPublished: newStatus }
  423. };
  424. } catch (error) {
  425. console.error('切换发布状态失败:', error);
  426. ctx.status = 500;
  427. ctx.body = { code: 1, message: error instanceof Error ? error.message : '操作失败' };
  428. }
  429. });
  430. /**
  431. * GET /api/book-generator/langgraph/books/:id
  432. * 获取书籍详情
  433. * 支持公开过滤:?filterPublic=true&userId=1
  434. */
  435. router.get('/books/:id', optionalAuth, async (ctx: Context) => {
  436. try {
  437. const bookId = ctx.params.id as string;
  438. const userId = ctx.state.user?.userId || TEST_USER_ID;
  439. const filterPublic = ctx.query.filterPublic === 'true';
  440. const book = await bookStore.getById(bookId, filterPublic, parseInt(userId as string));
  441. if (!book) {
  442. ctx.status = 404;
  443. ctx.body = { code: 1, message: '书籍不存在' };
  444. return;
  445. }
  446. ctx.body = { code: 0, message: 'success', data: { book } };
  447. } catch (error) {
  448. console.error('查询失败:', error);
  449. ctx.status = 500;
  450. ctx.body = { code: 1, message: error instanceof Error ? error.message : '查询失败' };
  451. }
  452. });
  453. /**
  454. * GET /api/book-generator/langgraph/books/:id/progress
  455. * 获取书籍生成进度
  456. */
  457. router.get('/books/:id/progress', async (ctx: Context) => {
  458. try {
  459. const bookId = ctx.params.id as string;
  460. const book = await bookStore.getById(bookId);
  461. if (!book) {
  462. ctx.status = 404;
  463. ctx.body = { code: 1, message: '书籍不存在' };
  464. return;
  465. }
  466. // 计算实际进度(基于章节完成情况)
  467. const completedChapters = book.chapters.filter((c) => c.status === 'completed').length;
  468. const totalChapters = book.outline?.chapters?.length || book.totalChapters || 0;
  469. // 如果有大纲,使用大纲章节数计算进度
  470. let progress = book.progress;
  471. if (totalChapters > 0 && book.status !== 'completed') {
  472. progress = Math.round((completedChapters / totalChapters) * 100);
  473. }
  474. ctx.body = {
  475. code: 0,
  476. message: 'success',
  477. data: {
  478. bookId,
  479. status: book.status,
  480. progress,
  481. completedChapters,
  482. totalChapters,
  483. },
  484. };
  485. } catch (error) {
  486. console.error('查询进度失败:', error);
  487. ctx.status = 500;
  488. ctx.body = { code: 1, message: error instanceof Error ? error.message : '查询失败' };
  489. }
  490. });
  491. /**
  492. * DELETE /api/book-generator/langgraph/books/:id
  493. * 删除书籍
  494. */
  495. router.delete('/books/:id', async (ctx: Context) => {
  496. try {
  497. const bookId = ctx.params.id as string;
  498. await bookStore.delete(bookId);
  499. ctx.body = { code: 0, message: '删除成功' };
  500. } catch (error) {
  501. console.error('删除失败:', error);
  502. ctx.status = 500;
  503. ctx.body = { code: 1, message: error instanceof Error ? error.message : '删除失败' };
  504. }
  505. });
  506. /**
  507. * POST /api/book-generator/langgraph/books/:id/generate
  508. * 对已有书籍使用 LangGraph 生成
  509. */
  510. router.post('/books/:id/generate', async (ctx: Context) => {
  511. try {
  512. const bookId = ctx.params.id as string;
  513. console.log(`[LangGraph Generate] 收到请求 bookId=${bookId}`);
  514. const body = (ctx.request.body || {}) as { bookScale?: string };
  515. const book = await bookStore.getById(bookId);
  516. console.log(`[LangGraph Generate] book对象:`, book ? '存在' : 'null');
  517. if (!book) {
  518. ctx.status = 404;
  519. ctx.body = { code: 1, message: '书籍不存在' };
  520. return;
  521. }
  522. // 防止重复生成:如果书籍正在生成中,拒绝请求
  523. if (book.status === 'generating') {
  524. console.log(`[LangGraph Generate] 书籍正在生成中,拒绝重复请求 bookId=${bookId}`);
  525. ctx.status = 400;
  526. ctx.body = {
  527. code: 1,
  528. message: '书籍正在生成中,请勿重复提交',
  529. data: {
  530. bookId,
  531. status: book.status,
  532. progress: book.progress,
  533. }
  534. };
  535. return;
  536. }
  537. // 优先使用请求传入的 scale,否则使用书籍保存的 scale,最后默认标准教程
  538. const bookScale = body.bookScale || book.bookScale || '标准教程';
  539. // 尝试将生成任务加入队列(队列只是为了改善用户体验)
  540. try {
  541. const jobId = await queueService.addBookGenerationTask({
  542. bookId,
  543. topic: book.description,
  544. bookScale,
  545. });
  546. console.log(`[LangGraph] 生成任务已加入队列: bookId=${bookId}, jobId=${jobId}`);
  547. ctx.body = {
  548. code: 0,
  549. message: 'LangGraph 生成任务已启动(队列模式)',
  550. data: {
  551. bookId,
  552. jobId,
  553. status: 'queued',
  554. mode: 'queue',
  555. },
  556. };
  557. } catch (queueError) {
  558. // 队列失败时,降级为同步执行(确保核心业务不受影响)
  559. console.warn(`[LangGraph] 队列不可用,降级为同步执行: bookId=${bookId}`, queueError);
  560. try {
  561. // 同步调用生成器,等待完成
  562. await langGraphGenerator.generate(bookId, book.description, bookScale);
  563. console.log(`[LangGraph] 同步执行完成: bookId=${bookId}`);
  564. ctx.body = {
  565. code: 0,
  566. message: 'LangGraph 生成任务已完成(同步模式)',
  567. data: {
  568. bookId,
  569. status: 'completed',
  570. mode: 'sync',
  571. },
  572. };
  573. } catch (generateError) {
  574. // 生成失败,更新书籍状态
  575. console.error(`[LangGraph] 同步执行失败: bookId=${bookId}`, generateError);
  576. await bookStore.update(bookId, {
  577. status: 'failed',
  578. errorMsg: generateError instanceof Error ? generateError.message : '生成失败',
  579. });
  580. ctx.body = {
  581. code: 0,
  582. message: 'LangGraph 生成任务失败',
  583. data: {
  584. bookId,
  585. status: 'failed',
  586. mode: 'sync',
  587. error: generateError instanceof Error ? generateError.message : '生成失败',
  588. },
  589. };
  590. }
  591. }
  592. } catch (error) {
  593. console.error('启动失败:', error);
  594. ctx.status = 500;
  595. ctx.body = {
  596. code: 1,
  597. message: error instanceof Error ? error.message : '启动失败',
  598. };
  599. }
  600. });
  601. /**
  602. * POST /api/book-generator/langgraph/books/:id/audio
  603. * 批量生成书籍所有小节的音频
  604. */
  605. router.post('/books/:id/audio', async (ctx: Context) => {
  606. try {
  607. const bookId = ctx.params.id as string;
  608. const { voiceId = 'cherry' } = ctx.request.body as { voiceId?: string };
  609. const book = await bookStore.getById(bookId);
  610. if (!book) {
  611. ctx.status = 404;
  612. ctx.body = { code: 1, message: '书籍不存在' };
  613. return;
  614. }
  615. // 获取所有小节 (level=3)
  616. const chapters = await bookStore.getChapterTree(bookId);
  617. const subsections = chapters.filter((c: any) => c.level === 3);
  618. // 检查内容状态
  619. const subsectionsWithContent = subsections.filter((c: any) => c.content && c.contentStatus === 'completed');
  620. if (subsections.length === 0) {
  621. ctx.body = { code: 1, message: '没有小节' };
  622. return;
  623. }
  624. if (subsectionsWithContent.length === 0) {
  625. ctx.body = {
  626. code: 1,
  627. message: '没有内容生成完成的小节,请先生成内容',
  628. data: {
  629. totalSubsections: subsections.length,
  630. completedSubsections: 0,
  631. }
  632. };
  633. return;
  634. }
  635. if (subsections.length !== subsectionsWithContent.length) {
  636. console.log(`[Audio] 书籍 ${bookId} 共有 ${subsections.length} 个小节,其中 ${subsectionsWithContent.length} 个内容已生成完成`);
  637. }
  638. // 异步生成所有小节音频
  639. for (const sub of subsectionsWithContent) {
  640. bookStore.generateChapterAudioById(sub.id, book.userId || 1).catch(err => {
  641. console.error(`[Audio] 小节${sub.number}音频生成失败:`, err);
  642. });
  643. }
  644. ctx.body = {
  645. code: 0,
  646. message: '音频生成任务已启动',
  647. data: {
  648. totalSubsections: subsectionsWithContent.length,
  649. taskId: `audio_${bookId}_${Date.now()}`,
  650. },
  651. };
  652. } catch (error) {
  653. console.error('音频生成失败:', error);
  654. ctx.status = 500;
  655. ctx.body = { code: 1, message: error instanceof Error ? error.message : '音频生成失败' };
  656. }
  657. });
  658. /**
  659. * GET /api/book-generator/langgraph/books/:id/failed-chapters
  660. * 获取生成失败的小节列表
  661. */
  662. router.get('/books/:id/failed-chapters', async (ctx: Context) => {
  663. try {
  664. const bookId = ctx.params.id as string;
  665. const book = await bookStore.getById(bookId);
  666. if (!book) {
  667. ctx.status = 404;
  668. ctx.body = { code: 1, message: '书籍不存在' };
  669. return;
  670. }
  671. // 获取所有失败的小节
  672. const chapters = await bookStore.getChapterTree(bookId);
  673. const failedSubsections = chapters.filter(c => c.status === 'failed');
  674. ctx.body = {
  675. code: 0,
  676. message: 'success',
  677. data: {
  678. bookId,
  679. failedCount: failedSubsections.length,
  680. failedChapters: failedSubsections.map(c => ({
  681. id: c.id,
  682. number: c.number,
  683. title: c.title,
  684. level: c.level,
  685. errorMsg: c.errorMsg,
  686. parentId: c.parentId,
  687. })),
  688. },
  689. };
  690. } catch (error) {
  691. console.error('获取失败小节失败:', error);
  692. ctx.status = 500;
  693. ctx.body = { code: 1, message: error instanceof Error ? error.message : '获取失败' };
  694. }
  695. });
  696. /**
  697. * POST /api/book-generator/langgraph/books/:id/resume
  698. * 从断点处继续生成(重试失败的小节)
  699. */
  700. router.post('/books/:id/resume', async (ctx: Context) => {
  701. try {
  702. const bookId = ctx.params.id as string;
  703. const book = await bookStore.getById(bookId);
  704. if (!book) {
  705. ctx.status = 404;
  706. ctx.body = { code: 1, message: '书籍不存在' };
  707. return;
  708. }
  709. if (book.status === 'completed') {
  710. ctx.body = { code: 1, message: '书籍已生成完成,无需继续' };
  711. return;
  712. }
  713. // 获取失败的小节
  714. const chapters = await bookStore.getChapterTree(bookId);
  715. const failedSubsections = chapters.filter(c => c.status === 'failed');
  716. if (failedSubsections.length === 0) {
  717. ctx.body = { code: 1, message: '没有失败的小节需要重试' };
  718. return;
  719. }
  720. // 重置失败小节的状态为 pending
  721. for (const sub of failedSubsections) {
  722. await bookStore.updateChapterById(sub.id, {
  723. status: 'pending',
  724. errorMsg: null,
  725. content: null,
  726. });
  727. }
  728. // 将续生成任务加入队列
  729. const jobId = await queueService.addBookGenerationTask({
  730. bookId,
  731. topic: book.description || book.title,
  732. bookScale: book.bookScale || '标准教程',
  733. });
  734. console.log(`[LangGraph] 续生成任务已加入队列: bookId=${bookId}, jobId=${jobId}`);
  735. ctx.body = {
  736. code: 0,
  737. message: `已重启生成,将重试 ${failedSubsections.length} 个失败的小节`,
  738. data: {
  739. bookId,
  740. retryCount: failedSubsections.length,
  741. status: 'resuming',
  742. },
  743. };
  744. } catch (error) {
  745. console.error('继续生成失败:', error);
  746. ctx.status = 500;
  747. ctx.body = { code: 1, message: error instanceof Error ? error.message : '继续生成失败' };
  748. }
  749. });
  750. /**
  751. * POST /api/book-generator/langgraph/books/:id/retry-chapter
  752. * 单独重试某个失败的小节
  753. */
  754. router.post('/books/:id/retry-chapter', async (ctx: Context) => {
  755. try {
  756. const bookId = ctx.params.id as string;
  757. const { chapterId } = ctx.request.body as { chapterId: number };
  758. if (!chapterId) {
  759. ctx.status = 400;
  760. ctx.body = { code: 1, message: '请提供章节 ID' };
  761. return;
  762. }
  763. // 获取所有章节查找指定的章节
  764. const chapters = await bookStore.getChapterTree(bookId);
  765. const chapter = chapters.find(c => c.id === chapterId);
  766. if (!chapter) {
  767. ctx.status = 404;
  768. ctx.body = { code: 1, message: '章节不存在' };
  769. return;
  770. }
  771. // 重置章节状态
  772. await bookStore.updateChapterById(chapterId, {
  773. status: 'pending',
  774. errorMsg: null,
  775. content: null,
  776. });
  777. ctx.body = {
  778. code: 0,
  779. message: '章节已重置为待生成状态',
  780. data: { chapterId },
  781. };
  782. } catch (error) {
  783. console.error('重试章节失败:', error);
  784. ctx.status = 500;
  785. ctx.body = { code: 1, message: error instanceof Error ? error.message : '重试失败' };
  786. }
  787. });
  788. /**
  789. * POST /api/book-generator/langgraph/books/:id/videos
  790. * 批量生成书籍所有章节视频
  791. */
  792. router.post('/books/:id/videos', async (ctx: Context) => {
  793. try {
  794. const bookId = ctx.params.id as string;
  795. const book = await bookStore.getById(bookId);
  796. if (!book) {
  797. ctx.status = 404;
  798. ctx.body = { code: 1, message: '书籍不存在' };
  799. return;
  800. }
  801. // 获取所有小节 (level=3)
  802. const chapters = await bookStore.getChapterTree(bookId);
  803. const subsections = chapters.filter((c: any) => c.level === 3);
  804. // 过滤出有音频的小节(audioUrl存在且不为空)
  805. const subsectionsWithAudio = subsections.filter((c: any) => c.audioUrl && c.audioUrl !== '');
  806. if (subsections.length === 0) {
  807. ctx.body = { code: 1, message: '没有小节' };
  808. return;
  809. }
  810. if (subsectionsWithAudio.length === 0) {
  811. ctx.body = {
  812. code: 1,
  813. message: '没有音频生成完成的小节,请先生成音频',
  814. data: {
  815. totalSubsections: subsections.length,
  816. audioCompletedSubsections: 0,
  817. }
  818. };
  819. return;
  820. }
  821. if (subsections.length !== subsectionsWithAudio.length) {
  822. console.log(`[Video] 书籍 ${bookId} 共有 ${subsections.length} 个小节,其中 ${subsectionsWithAudio.length} 个音频已生成完成`);
  823. }
  824. // 异步生成所有章节视频
  825. const taskId = `video_${bookId}_${Date.now()}`;
  826. console.log(`[Video] 开始批量生成视频: bookId=${bookId}, taskId=${taskId}, 总数=${subsectionsWithAudio.length}`);
  827. for (const sub of subsectionsWithAudio) {
  828. // 异步处理每个章节的视频生成
  829. (async () => {
  830. try {
  831. console.log(`[Video] 开始生成章节 ${sub.number} 的视频: ${sub.title}`);
  832. // 从书籍章节创建视频项目
  833. const project = await createVideoProjectFromBook(
  834. parseInt(bookId),
  835. sub.id,
  836. book.userId || 1
  837. );
  838. if (!project) {
  839. console.error(`[Video] 章节 ${sub.number} 视频项目创建失败`);
  840. return;
  841. }
  842. console.log(`[Video] 章节 ${sub.number} 视频项目创建成功: projectId=${project.id}`);
  843. // 生成视频
  844. const result = await generateVideoForProject(project.id);
  845. if (result.success && result.outputUrl) {
  846. // 更新章节的视频URL
  847. await bookStore.updateChapterById(sub.id, {
  848. videoUrl: result.outputUrl,
  849. videoDuration: result.duration,
  850. });
  851. console.log(`[Video] 章节 ${sub.number} 视频生成成功: ${result.outputUrl}`);
  852. } else {
  853. console.error(`[Video] 章节 ${sub.number} 视频生成失败:`, result.error);
  854. }
  855. } catch (error) {
  856. console.error(`[Video] 章节 ${sub.number} 视频生成异常:`, error);
  857. }
  858. })();
  859. }
  860. ctx.body = {
  861. code: 0,
  862. message: '视频生成任务已启动',
  863. data: {
  864. taskId,
  865. totalChapters: subsectionsWithAudio.length,
  866. totalSubsections: subsections.length,
  867. },
  868. };
  869. } catch (error) {
  870. console.error('批量生成视频失败:', error);
  871. ctx.status = 500;
  872. ctx.body = { code: 1, message: error instanceof Error ? error.message : '批量生成视频失败' };
  873. }
  874. });
  875. export default router;