book-generator.store.ts 55 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663
  1. /**
  2. * 书籍生成模块 - Prisma 数据库存储
  3. */
  4. import crypto from 'crypto';
  5. import { prisma } from '../../models';
  6. import { Book, BookOutline, Chapter, ChapterGenStage, BookGenStage } from './book-generator.types';
  7. import { Prisma } from '@prisma/client';
  8. import { generateAudio } from '../tts/tts.service';
  9. import { callLLMWithMessages, callLLMWithTools, ChatMessage } from '../../services/llm';
  10. import { createBookTools } from '../../services/llm/book-tools';
  11. import { SUBSECTION_CONTENT_SYSTEM_PROMPT } from './prompts/templates';
  12. import { countWords } from './utils';
  13. import { cleanThinkingText } from './utils/content-cleaner';
  14. import { advanceChapter, regenerateChapter, safeTransitionChapter } from './stage-manager';
  15. import { mergeChapterAudios } from '../player/player.service';
  16. import { logAiCall } from '../../services/ai-call-logger';
  17. import { consumeAudioMinutes } from '../subscription/subscription.service';
  18. /**
  19. * 取消书籍所有章节的音频生成(将 pending/processing 任务标记为 cancelled,回退章节阶段)
  20. */
  21. export async function cancelAudioGeneration(bookId: string): Promise<{ cancelledCount: number; rolledBackChapters: number[] }> {
  22. const chapters = await prisma.bookChapter.findMany({
  23. where: { bookId: BigInt(bookId) },
  24. select: { id: true, level: true },
  25. });
  26. if (chapters.length === 0) {
  27. return { cancelledCount: 0, rolledBackChapters: [] };
  28. }
  29. const maxLevel = Math.max(...chapters.map(c => c.level || 0));
  30. const leafChapterIds = chapters.filter(c => c.level === maxLevel).map(c => c.id);
  31. // 找出所有 pending/processing 状态的 TTS 任务
  32. const activeTasks = await prisma.ttsTask.findMany({
  33. where: {
  34. chapterId: { in: leafChapterIds },
  35. taskType: 'tts',
  36. status: { in: ['pending', 'processing'] },
  37. },
  38. select: { id: true, chapterId: true },
  39. });
  40. if (activeTasks.length === 0) {
  41. return { cancelledCount: 0, rolledBackChapters: [] };
  42. }
  43. const taskIds = activeTasks.map(t => t.id);
  44. const affectedChapterIds = [...new Set(activeTasks.map(t => t.chapterId))];
  45. // 批量标记任务为 cancelled
  46. await prisma.ttsTask.updateMany({
  47. where: { id: { in: taskIds } },
  48. data: { status: 'cancelled' },
  49. });
  50. // 回退受影响章节的 genStage 到 content_completed
  51. await prisma.bookChapter.updateMany({
  52. where: { id: { in: affectedChapterIds }, genStage: 'audio_generating' },
  53. data: { genStage: 'content_completed' },
  54. });
  55. return {
  56. cancelledCount: taskIds.length,
  57. rolledBackChapters: affectedChapterIds,
  58. };
  59. }
  60. // ============ 删除书籍 ============
  61. /**
  62. * 根据所有章节状态计算书籍阶段
  63. * 书籍阶段 = 所有章节中最低的阶段(最落后的章节决定了书籍的进度)
  64. */
  65. function computeBookGenStage(chapters: { genStage: string }[]): BookGenStage {
  66. if (chapters.length === 0) return 'draft';
  67. // 章节阶段顺序(索引越大越"后")
  68. const stageOrder = ['idle', 'outline_completed', 'content_generating', 'content_completed', 'audio_generating', 'audio_completed', 'video_generating', 'video_completed', 'failed'];
  69. // 找出最低阶段的索引
  70. let minIdx = stageOrder.length; // 默认最大
  71. for (const ch of chapters) {
  72. const idx = stageOrder.indexOf(ch.genStage);
  73. if (idx === -1) {
  74. console.warn(`[BookStore] 未知章节阶段: chapterId=${(ch as any).id}, genStage="${ch.genStage}",跳过该章节`);
  75. continue;
  76. }
  77. if (idx < minIdx) {
  78. minIdx = idx;
  79. }
  80. }
  81. // 全部未知 → 回退到 draft
  82. if (minIdx >= stageOrder.length) {
  83. console.warn('[BookStore] 所有章节阶段未知,回退到 draft');
  84. return 'draft';
  85. }
  86. // 最低阶段索引对应的阶段
  87. const minStage = stageOrder[minIdx];
  88. // 映射到书籍阶段
  89. // idle → outlining(待生成大纲)
  90. // outline_completed → outline_completed(大纲已完成,待生成内容)
  91. // content_generating → content_generating(正在生成内容)
  92. // content_completed → content_completed
  93. // audio_generating → audio_generating
  94. // audio_completed → audio_completed
  95. // video_generating → video_generating
  96. // video_completed → video_completed
  97. // failed → failed
  98. const stageMap: Record<string, BookGenStage> = {
  99. 'idle': 'outlining',
  100. 'outline_completed': 'outline_completed',
  101. 'content_generating': 'content_generating',
  102. 'content_completed': 'content_completed',
  103. 'audio_generating': 'audio_generating',
  104. 'audio_completed': 'audio_completed',
  105. 'video_generating': 'video_generating',
  106. 'video_completed': 'video_completed',
  107. 'failed': 'failed',
  108. };
  109. return stageMap[minStage] || 'draft';
  110. }
  111. /**
  112. * 计算内容 SHA256 哈希(用于 TTS 去重)
  113. * 相同内容 → 相同哈希 → 不重复生成音频
  114. */
  115. function computeContentHash(content: string): string {
  116. return crypto.createHash('sha256').update(content.trim()).digest('hex');
  117. }
  118. /**
  119. * 自动检测:当某个叶节点音频完成后,检查其所属1级章节下所有叶节点
  120. * 是否都已就绪,若是则自动触发音频合并到该章节。
  121. */
  122. export async function tryAutoMerge(
  123. completedLeafId: number,
  124. bookId: number | null,
  125. leafLevel: number | null,
  126. leafParentId: number | null,
  127. ) {
  128. if (!bookId || leafLevel == null || leafLevel <= 1) return;
  129. try {
  130. // 1. 找到该叶节点所属的1级章节
  131. let chapterId: number | null = null;
  132. if (leafLevel === 2) {
  133. chapterId = leafParentId;
  134. } else if (leafLevel === 3 && leafParentId != null) {
  135. const parentSection = await prisma.bookChapter.findUnique({
  136. where: { id: leafParentId },
  137. select: { parentId: true, level: true },
  138. });
  139. if (parentSection && parentSection.level === 2) {
  140. chapterId = parentSection.parentId;
  141. }
  142. }
  143. if (!chapterId) return;
  144. // 2. 计算该书的最大层级
  145. const allChapters = await prisma.bookChapter.findMany({
  146. where: { bookId },
  147. select: { id: true, level: true, parentId: true, audioUrl: true, audioDuration: true },
  148. });
  149. if (allChapters.length === 0) {
  150. console.warn('[tryAutoMerge] 书籍无章节,跳过');
  151. return;
  152. }
  153. const maxLevel = Math.max(...allChapters.map(c => c.level));
  154. if (maxLevel <= 1) return;
  155. // 3. 收集属于该章节的所有叶节点
  156. // 构建 parentId → children 映射用于遍历子树
  157. const childrenMap = new Map<number, number[]>();
  158. const nodeMap = new Map<number, (typeof allChapters)[number]>();
  159. for (const ch of allChapters) {
  160. nodeMap.set(ch.id, ch);
  161. if (ch.parentId) {
  162. if (!childrenMap.has(ch.parentId)) childrenMap.set(ch.parentId, []);
  163. childrenMap.get(ch.parentId)!.push(ch.id);
  164. }
  165. }
  166. // BFS 收集章节下所有后代节点
  167. const descendantIds: number[] = [];
  168. const queue = [chapterId];
  169. while (queue.length > 0) {
  170. const current = queue.shift()!;
  171. const children = childrenMap.get(current) || [];
  172. for (const childId of children) {
  173. descendantIds.push(childId);
  174. queue.push(childId);
  175. }
  176. }
  177. // 父节点ID集合(用于判断是否是叶节点)
  178. const parentIdSet = new Set(allChapters.map(c => c.parentId).filter(Boolean));
  179. // 筛选叶节点:在章节后代中 且 不是任何节点的父节点
  180. const leafNodesUnderChapter = allChapters.filter(
  181. ch => descendantIds.includes(ch.id) && !parentIdSet.has(ch.id),
  182. );
  183. if (leafNodesUnderChapter.length === 0) return;
  184. // 4. 检查是否所有叶节点都有音频
  185. const allHaveAudio = leafNodesUnderChapter.every(
  186. ch => ch.audioUrl && ch.audioUrl !== '',
  187. );
  188. if (!allHaveAudio) {
  189. const missingCount = leafNodesUnderChapter.filter(
  190. ch => !ch.audioUrl || ch.audioUrl === '',
  191. ).length;
  192. console.log(
  193. `[AutoMerge] 章节${chapterId}: ${leafNodesUnderChapter.length - missingCount}/${leafNodesUnderChapter.length} 个叶节点音频就绪,等待剩余 ${missingCount} 个...`,
  194. );
  195. return;
  196. }
  197. // 5. 检查该章节是否已有合并音频(幂等)
  198. // 但如果父章节的合并音频时长与子节时长总和不匹配,需要重新合并
  199. const chapter = nodeMap.get(chapterId);
  200. if (chapter?.audioUrl && chapter.audioUrl.includes('_merged')) {
  201. // 检查合并音频时长是否与子节时长总和匹配
  202. const childDurations = leafNodesUnderChapter
  203. .filter(ch => ch.audioUrl)
  204. .map(ch => ch.audioDuration || 0);
  205. const totalChildDuration = childDurations.reduce((sum, d) => sum + d, 0);
  206. const parentDuration = chapter.audioDuration || 0;
  207. // 如果合并音频时长 < 子节总时长的 80%,说明合并不完整,需要重新合并
  208. if (parentDuration > 0 && totalChildDuration > 0 && parentDuration < totalChildDuration * 0.8) {
  209. console.log(`[AutoMerge] 章节${chapterId}合并音频时长(${parentDuration}s) < 子节总时长(${totalChildDuration}s)的80%,需要重新合并`);
  210. // 清除旧的合并音频
  211. await prisma.bookChapter.update({
  212. where: { id: chapterId },
  213. data: { audioUrl: '', audioDuration: 0 },
  214. });
  215. } else {
  216. console.log(`[AutoMerge] 章节${chapterId}已有合并音频,跳过`);
  217. return;
  218. }
  219. }
  220. // 6. 触发合并
  221. console.log(
  222. `[AutoMerge] 🎵 章节${chapterId}下所有${leafNodesUnderChapter.length}个叶节点音频已就绪,开始自动合并...`,
  223. );
  224. const mergedUrl = await mergeChapterAudios(chapterId);
  225. if (mergedUrl) {
  226. console.log(`[AutoMerge] ✅ 章节${chapterId}音频自动合并完成: ${mergedUrl}`);
  227. } else {
  228. console.warn(`[AutoMerge] ⚠️ 章节${chapterId}合并返回空结果`);
  229. }
  230. } catch (err) {
  231. console.error(`[AutoMerge] 自动合并检测失败:`, err);
  232. }
  233. }
  234. /**
  235. * 构建小节内容生成消息
  236. */
  237. function buildSubsectionContentMessages(
  238. topic: string,
  239. bookDescription: string,
  240. chapterTitle: string,
  241. chapterSummary: string,
  242. sectionTitle: string,
  243. sectionSummary: string,
  244. subsection: any,
  245. writingStyle?: string
  246. ): ChatMessage[] {
  247. const keyPoints = typeof subsection.keyPoints === 'string'
  248. ? JSON.parse(subsection.keyPoints)
  249. : (subsection.keyPoints || []);
  250. // 从 bookDescription 中提取写作风格
  251. const styleMatch = bookDescription.match(/写作风格:([^\\n]+)/);
  252. const style = writingStyle || styleMatch?.[1] || '';
  253. return [
  254. { role: 'system', content: SUBSECTION_CONTENT_SYSTEM_PROMPT },
  255. {
  256. role: 'user',
  257. content: `书名:《${topic}》
  258. ${bookDescription || ''}
  259. ${style ? `写作风格:${style}` : ''}
  260. 章标题:${chapterTitle}
  261. 章概述:${chapterSummary || ''}
  262. 节标题:${sectionTitle}
  263. 节概述:${sectionSummary}
  264. 小节标题:${subsection.title}
  265. 小节概述:${subsection.summary || ''}
  266. 核心知识点:${keyPoints.join('、')}
  267. 预估字数:${subsection.estimatedWords || 500}字
  268. 请撰写该小节的正文内容。`,
  269. },
  270. ];
  271. }
  272. // ============ 类型转换 ============
  273. function parseOutlineJson(jsonStr: string | null): BookOutline | null {
  274. if (!jsonStr) return null;
  275. try {
  276. return JSON.parse(jsonStr);
  277. } catch {
  278. return null;
  279. }
  280. }
  281. function chaptersFromDb(dbChapters: any[], bookId: number, excludeContent: boolean = false): Chapter[] {
  282. return dbChapters.map((c) => ({
  283. id: String(c.id),
  284. bookId: String(c.bookId),
  285. number: c.number,
  286. title: c.title,
  287. content: excludeContent ? '' : (c.content || ''),
  288. wordCount: c.wordCount,
  289. summary: c.summary || undefined,
  290. generatedAt: c.generatedAt || undefined,
  291. error: c.errorMsg || undefined,
  292. audioUrl: c.audioUrl || undefined,
  293. audioDuration: c.audioDuration || 0,
  294. videoUrl: c.videoUrl || undefined,
  295. videoDuration: c.videoDuration || undefined,
  296. isPublic: c.isPublic || false,
  297. level: c.level, // 层级:1=章, 2=节, 3=小节
  298. parentId: c.parentId, // 父节点ID(0表示章)
  299. genStage: c.genStage || undefined, // 线性阶段状态
  300. }));
  301. }
  302. function outlineChapterFromDb(dbChapter: any) {
  303. return {
  304. number: dbChapter.number,
  305. title: dbChapter.title,
  306. summary: dbChapter.summary || '',
  307. keyPoints: dbChapter.keyPoints ? JSON.parse(dbChapter.keyPoints) : [],
  308. estimatedWords: dbChapter.estimatedWords,
  309. };
  310. }
  311. // ============ 存储类 ============
  312. export class BookStore {
  313. /**
  314. * 安全解析JSON
  315. */
  316. private safeParseJson(jsonStr: string | null): any[] {
  317. if (!jsonStr) return [];
  318. try {
  319. return JSON.parse(jsonStr);
  320. } catch {
  321. return [];
  322. }
  323. }
  324. /**
  325. * 从数据库章节记录构建树形结构的outline
  326. */
  327. private buildOutlineFromChapters(chapters: any[]): BookOutline | null {
  328. if (!chapters || chapters.length === 0) return null;
  329. // 获取所有level=1的章
  330. const level1Chapters = chapters.filter(c => c.level === 1);
  331. if (level1Chapters.length === 0) return null;
  332. // 构建映射表
  333. const chapterMap = new Map<number, any>();
  334. const sectionMap = new Map<number, any>();
  335. chapters.forEach(c => {
  336. if (c.level === 1) {
  337. chapterMap.set(c.id, {
  338. number: c.number,
  339. title: c.title,
  340. summary: c.summary || '',
  341. keyPoints: this.safeParseJson(c.keyPoints),
  342. estimatedWords: c.estimatedWords,
  343. sections: []
  344. });
  345. } else if (c.level === 2) {
  346. sectionMap.set(c.id, {
  347. number: c.number,
  348. title: c.title,
  349. summary: c.summary || '',
  350. keyPoints: this.safeParseJson(c.keyPoints),
  351. estimatedWords: c.estimatedWords,
  352. subsections: []
  353. });
  354. }
  355. });
  356. // 构建节和小节的关系
  357. chapters.forEach(c => {
  358. if (c.level === 2 && c.parentId) {
  359. const chapter = chapterMap.get(c.parentId);
  360. const section = sectionMap.get(c.id);
  361. if (chapter && section) {
  362. chapter.sections.push(section);
  363. }
  364. } else if (c.level === 3 && c.parentId) {
  365. const section = sectionMap.get(c.parentId);
  366. if (section) {
  367. section.subsections.push({
  368. number: c.number,
  369. title: c.title,
  370. summary: c.summary || '',
  371. keyPoints: this.safeParseJson(c.keyPoints),
  372. estimatedWords: c.estimatedWords
  373. });
  374. }
  375. }
  376. });
  377. return {
  378. mainTheme: '',
  379. structureLogic: '',
  380. chapters: Array.from(chapterMap.values())
  381. };
  382. }
  383. /**
  384. * 创建书籍
  385. */
  386. async create(data: {
  387. userId?: number;
  388. title: string;
  389. subtitle?: string;
  390. description: string;
  391. targetAudience?: string;
  392. style?: string;
  393. bookScale?: string;
  394. totalChapters?: number;
  395. estimatedWords?: number;
  396. }): Promise<Book> {
  397. const book = await prisma.book.create({
  398. data: {
  399. userId: data.userId,
  400. title: data.title,
  401. subtitle: data.subtitle,
  402. description: data.description,
  403. targetAudience: data.targetAudience || '通用',
  404. style: data.style || '专业严谨',
  405. bookScale: data.bookScale || '1000',
  406. totalChapters: data.totalChapters ?? 10,
  407. estimatedWords: data.estimatedWords ?? 0,
  408. genStage: 'draft',
  409. progress: 0,
  410. isPublished: false, // 预发布:等书籍完成后再发布
  411. },
  412. include: { chapters: true },
  413. });
  414. return this.toBook(book);
  415. }
  416. /**
  417. * 获取书籍
  418. * @param id 书籍ID
  419. * @param filterPublic 是否过滤公开音频(默认false,返回所有)
  420. * @param userId 当前用户ID(用于判断是否所有者)
  421. */
  422. async getById(id: string, filterPublic: boolean = false, userId?: number): Promise<Book | null> {
  423. const book = await prisma.book.findUnique({
  424. where: { id: parseInt(id) },
  425. include: {
  426. chapters: {
  427. orderBy: [
  428. { level: 'asc' },
  429. { number: 'asc' }
  430. ]
  431. }
  432. },
  433. });
  434. if (!book) return null;
  435. // 构建树形结构的outline(从数据库章节记录构建)
  436. let outline: BookOutline | null = null;
  437. try {
  438. outline = this.buildOutlineFromChapters(book.chapters);
  439. } catch (error) {
  440. console.error('[BookStore] buildOutlineFromChapters 失败:', error);
  441. }
  442. let result = this.toBook(book, false); // 需要返回章节content
  443. console.log('[BookStore.getById] bookScale:', result.bookScale);
  444. console.log('[BookStore.getById] result keys:', Object.keys(result));
  445. // 用数据库构建的outline替换outlineJson解析的
  446. if (outline) {
  447. result.outline = outline;
  448. }
  449. // 返回所有章节(level=1,2,3),前端需要完整数据来构建三级树形结构
  450. // outline中已包含完整的树形结构(章→节→小节)
  451. // result.chapters = result.chapters.filter((c) => c.level === 1); // 旧代码:只返回章
  452. // 如果需要过滤公开音频
  453. if (filterPublic && userId) {
  454. const isOwner = book.userId === userId;
  455. result.chapters = result.chapters.filter((c) => {
  456. // 所有者可以看到所有音频
  457. if (isOwner) return true;
  458. // 非所有者只能看到公开的音频
  459. return c.audioUrl && c.isPublic === true;
  460. });
  461. }
  462. return result;
  463. }
  464. /**
  465. * 按 bookId + number + level 查找章节记录
  466. */
  467. async findChapter(bookId: number, number: number, level: number): Promise<{ id: number; parentId: number | null; title: string } | null> {
  468. const chapter = await prisma.bookChapter.findFirst({
  469. where: { bookId, number, level },
  470. });
  471. return chapter;
  472. }
  473. /**
  474. * 获取用户的所有书籍
  475. * @param userId 当前用户ID
  476. * @param includePublic 是否包含公开书籍(用于首页显示)
  477. */
  478. async getAllByUser(userId?: number, includePublic: boolean = true): Promise<Book[]> {
  479. let books;
  480. if (userId) {
  481. // 查询指定用户的书籍(包括 userId 为该用户或为 null 的书籍)
  482. // userId 为 null 表示"游客"创建的书籍,也返回给当前用户查看
  483. books = await prisma.book.findMany({
  484. where: {
  485. OR: [
  486. { userId }, // 自己创建的书籍
  487. { userId: null }, // 游客创建的书籍(兼容旧数据)
  488. ]
  489. },
  490. include: { chapters: true },
  491. orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
  492. });
  493. } else {
  494. // 未登录用户或不需要用户过滤
  495. books = await prisma.book.findMany({
  496. where: includePublic ? {} : undefined,
  497. include: { chapters: true },
  498. orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
  499. });
  500. }
  501. return books.map((b) => this.toBook(b));
  502. }
  503. /**
  504. * 获取公开的书籍列表(用于首页展示)
  505. * 只返回 isPublished=true 的书籍,且只返回有音频的章节
  506. */
  507. async getPublicBooks(): Promise<Book[]> {
  508. const books = await prisma.book.findMany({
  509. where: {
  510. isPublished: true,
  511. chapters: {
  512. some: {
  513. audioUrl: { not: '' },
  514. }
  515. }
  516. },
  517. include: { chapters: {
  518. where: { audioUrl: { not: '' } }, // 只返回有音频的章节
  519. orderBy: { number: 'asc' }
  520. }},
  521. orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
  522. });
  523. return books.map((b) => this.toBook(b));
  524. }
  525. /**
  526. * 更新书籍
  527. */
  528. async update(id: string, data: Partial<{
  529. genStage: string;
  530. failedStage: string;
  531. progress: number;
  532. outlineJson: string;
  533. outline: any;
  534. foreword: string;
  535. afterword: string;
  536. estimatedWords: number;
  537. errorMsg: string;
  538. totalChapters: number;
  539. bookAnalysis: string;
  540. title: string;
  541. bookScale: string;
  542. }>): Promise<Book | null> {
  543. // 如果传入 outline 对象,转换为 outlineJson 字符串
  544. const updateData: any = { ...data, updatedAt: new Date() };
  545. if (data.outline) {
  546. updateData.outlineJson = JSON.stringify(data.outline);
  547. delete updateData.outline;
  548. }
  549. const book = await prisma.book.update({
  550. where: { id: parseInt(id) },
  551. data: updateData,
  552. include: { chapters: { where: { level: 1 }, orderBy: { number: 'asc' } } },
  553. });
  554. return this.toBook(book);
  555. }
  556. /**
  557. * 删除书籍
  558. */
  559. async delete(id: string): Promise<boolean> {
  560. try {
  561. await prisma.book.delete({ where: { id: parseInt(id) } });
  562. return true;
  563. } catch {
  564. return false;
  565. }
  566. }
  567. /**
  568. * 创建章节
  569. */
  570. async createChapter(data: {
  571. bookId: string;
  572. number: number;
  573. title: string;
  574. summary?: string;
  575. keyPoints?: string[];
  576. estimatedWords?: number;
  577. }): Promise<void> {
  578. await prisma.bookChapter.create({
  579. data: {
  580. bookId: parseInt(data.bookId),
  581. number: data.number,
  582. title: data.title,
  583. summary: data.summary,
  584. keyPoints: data.keyPoints ? JSON.stringify(data.keyPoints) : null,
  585. estimatedWords: data.estimatedWords || 5000,
  586. genStage: 'outline_completed',
  587. },
  588. });
  589. }
  590. /**
  591. * 批量创建章节(支持重新生成)
  592. * 使用 upsert 避免唯一约束冲突
  593. */
  594. async createChapters(bookId: string, chapters: Array<{
  595. number: number;
  596. title: string;
  597. summary?: string;
  598. keyPoints?: string[];
  599. estimatedWords?: number;
  600. }>): Promise<void> {
  601. const bookIdNum = parseInt(bookId);
  602. // 使用 upsert 避免重复创建(parentId=0表示章级别)
  603. for (const c of chapters) {
  604. const upserted = await prisma.bookChapter.upsert({
  605. where: {
  606. bookId_parentId_level_number: {
  607. bookId: bookIdNum,
  608. parentId: 0,
  609. level: 1,
  610. number: c.number,
  611. }
  612. },
  613. update: {
  614. title: c.title,
  615. summary: c.summary,
  616. keyPoints: c.keyPoints ? JSON.stringify(c.keyPoints) : null,
  617. estimatedWords: c.estimatedWords || 5000,
  618. genStage: 'outline_completed',
  619. } as any,
  620. create: {
  621. bookId: bookIdNum,
  622. parentId: 0,
  623. level: 1,
  624. number: c.number,
  625. title: c.title,
  626. summary: c.summary,
  627. keyPoints: c.keyPoints ? JSON.stringify(c.keyPoints) : null,
  628. estimatedWords: c.estimatedWords || 5000,
  629. genStage: 'outline_completed',
  630. } as any,
  631. });
  632. }
  633. }
  634. /**
  635. * 创建章节条目(支持任意层级:章/节/小节)
  636. * @param bookIdNum 书籍ID
  637. * @param item 节/小节数据
  638. * @param parentId 父节点ID(null表示章这一级)
  639. * @param level 层级:1=章, 2=节, 3=小节
  640. * @returns 创建的记录ID
  641. */
  642. async createChapterItem(bookIdNum: number, item: {
  643. number: number;
  644. title: string;
  645. summary?: string;
  646. keyPoints?: string[];
  647. estimatedWords?: number;
  648. }, parentId: number | null, level: number): Promise<number> {
  649. // 防御:LLM 可能返回 undefined/null 的字段
  650. let chapterNumber = item.number;
  651. if (chapterNumber == null || chapterNumber === undefined || isNaN(chapterNumber as number)) {
  652. const maxExisting = await prisma.bookChapter.findFirst({
  653. where: { bookId: bookIdNum, parentId: parentId ?? 0, level },
  654. orderBy: { number: 'desc' },
  655. select: { number: true },
  656. });
  657. chapterNumber = (maxExisting?.number ?? 0) + 1;
  658. console.warn(`[BookStore] chapter number missing/invalid, auto-assigned: ${chapterNumber} (bookId=${bookIdNum}, level=${level})`);
  659. }
  660. // 防御:title 也不能是 undefined
  661. const safeTitle = item.title || `章节${chapterNumber}`;
  662. if (!item.title) {
  663. console.warn(`[BookStore] chapter title missing, using fallback: ${safeTitle} (bookId=${bookIdNum})`);
  664. }
  665. // 使用 upsert 避免重复创建
  666. const upserted = await prisma.bookChapter.upsert({
  667. where: {
  668. bookId_parentId_level_number: {
  669. bookId: bookIdNum,
  670. parentId: parentId,
  671. level: level,
  672. number: chapterNumber,
  673. }
  674. },
  675. update: {
  676. title: safeTitle,
  677. summary: item.summary || '',
  678. keyPoints: item.keyPoints ? JSON.stringify(item.keyPoints) : null,
  679. estimatedWords: item.estimatedWords || 1000,
  680. genStage: 'outline_completed',
  681. } as any,
  682. create: {
  683. bookId: bookIdNum,
  684. parentId,
  685. level,
  686. number: chapterNumber,
  687. title: safeTitle,
  688. summary: item.summary || '',
  689. keyPoints: item.keyPoints ? JSON.stringify(item.keyPoints) : null,
  690. estimatedWords: item.estimatedWords || 1000,
  691. genStage: 'outline_completed',
  692. } as any,
  693. });
  694. return upserted.id;
  695. }
  696. /**
  697. * 批量创建章节条目
  698. */
  699. async createChapterItems(bookIdNum: number, items: Array<{
  700. number: number;
  701. title: string;
  702. summary?: string;
  703. keyPoints?: string[];
  704. estimatedWords?: number;
  705. }>, parentId: number | null, level: number): Promise<number[]> {
  706. const ids: number[] = [];
  707. for (const item of items) {
  708. const id = await this.createChapterItem(bookIdNum, item, parentId, level);
  709. ids.push(id);
  710. }
  711. return ids;
  712. }
  713. /**
  714. * 更新章节内容
  715. * @param level 可选层级过滤,避免同 number 不同 level 的章节串写
  716. */
  717. async updateChapter(bookId: string, chapterNumber: number, data: Partial<{
  718. content: string;
  719. wordCount: number;
  720. genStage: string;
  721. errorMsg: string;
  722. }>, level?: number): Promise<Chapter | null> {
  723. const where: any = {
  724. bookId: parseInt(bookId),
  725. number: chapterNumber,
  726. };
  727. if (level !== undefined) where.level = level;
  728. const chapter = await prisma.bookChapter.findFirst({ where });
  729. if (!chapter) return null;
  730. const updated = await prisma.bookChapter.update({
  731. where: { id: chapter.id },
  732. data: {
  733. ...data,
  734. generatedAt: data.content ? new Date() : undefined,
  735. },
  736. });
  737. return {
  738. id: String(updated.id),
  739. bookId: String(updated.bookId),
  740. number: updated.number,
  741. title: updated.title,
  742. content: updated.content || '',
  743. wordCount: updated.wordCount,
  744. genStage: updated.genStage as ChapterGenStage | undefined,
  745. summary: updated.summary || undefined,
  746. generatedAt: updated.generatedAt || undefined,
  747. error: (updated as any).errorMsg || undefined,
  748. };
  749. }
  750. /**
  751. * 按 ID 更新章节内容
  752. */
  753. async updateChapterById(id: number, data: Partial<{
  754. content: string;
  755. wordCount: number;
  756. genStage: string;
  757. errorMsg: string;
  758. contentError: string;
  759. audioUrl: string;
  760. audioDuration: number;
  761. lrcLyrics: string | null;
  762. videoUrl: string;
  763. videoDuration: number;
  764. }>): Promise<Chapter | null> {
  765. // 转换 errorMsg -> contentError (Prisma字段名)
  766. const prismaData: any = { ...data };
  767. if ('errorMsg' in prismaData) {
  768. prismaData.contentError = prismaData.errorMsg;
  769. delete prismaData.errorMsg;
  770. }
  771. const updated = await prisma.bookChapter.update({
  772. where: { id },
  773. data: {
  774. ...prismaData,
  775. generatedAt: data.content ? new Date() : undefined,
  776. },
  777. });
  778. return {
  779. id: String(updated.id),
  780. bookId: String(updated.bookId),
  781. number: updated.number,
  782. title: updated.title,
  783. content: updated.content || '',
  784. wordCount: updated.wordCount,
  785. genStage: updated.genStage as ChapterGenStage | undefined,
  786. summary: updated.summary || undefined,
  787. generatedAt: updated.generatedAt || undefined,
  788. error: updated.contentError || undefined,
  789. audioUrl: updated.audioUrl || undefined,
  790. audioDuration: updated.audioDuration || 0,
  791. videoUrl: updated.videoUrl || undefined,
  792. videoDuration: updated.videoDuration || undefined,
  793. };
  794. }
  795. /**
  796. * 获取书籍的章节
  797. */
  798. async getChapters(bookId: string): Promise<Chapter[]> {
  799. const chapters = await prisma.bookChapter.findMany({
  800. where: { bookId: parseInt(bookId) },
  801. orderBy: { number: 'asc' },
  802. });
  803. return chaptersFromDb(chapters, parseInt(bookId));
  804. }
  805. /**
  806. * 获取书籍的完整章节树(章→节→小节)
  807. * 按 level 和 number 排序
  808. */
  809. async getChapterTree(bookId: string): Promise<any[]> {
  810. const chapters = await prisma.bookChapter.findMany({
  811. where: { bookId: parseInt(bookId) },
  812. orderBy: [{ level: 'asc' }, { number: 'asc' }],
  813. });
  814. return chapters;
  815. }
  816. /**
  817. * 统计书籍完成章节数
  818. */
  819. async countCompletedChapters(bookId: string): Promise<number> {
  820. return prisma.bookChapter.count({
  821. where: {
  822. bookId: parseInt(bookId),
  823. genStage: 'video_completed',
  824. },
  825. });
  826. }
  827. /**
  828. * 发布书籍(将 isPublished 设为 true,同时公开所有章节)
  829. */
  830. async publishAlbum(bookId: string): Promise<void> {
  831. await prisma.$transaction([
  832. prisma.book.update({
  833. where: { id: parseInt(bookId) },
  834. data: { isPublished: true },
  835. }),
  836. // 公开所有有音频的章节
  837. prisma.bookChapter.updateMany({
  838. where: {
  839. bookId: parseInt(bookId),
  840. audioUrl: { not: '' },
  841. },
  842. data: { isPublic: true },
  843. }),
  844. ]);
  845. }
  846. /**
  847. * 取消发布书籍(将 isPublished 设为 false,同时取消公开所有章节)
  848. */
  849. async unpublishAlbum(bookId: string): Promise<void> {
  850. await prisma.$transaction([
  851. prisma.book.update({
  852. where: { id: parseInt(bookId) },
  853. data: { isPublished: false },
  854. }),
  855. // 取消公开所有章节
  856. prisma.bookChapter.updateMany({
  857. where: {
  858. bookId: parseInt(bookId),
  859. },
  860. data: { isPublic: false },
  861. }),
  862. ]);
  863. }
  864. /**
  865. * 切换书籍公开状态
  866. */
  867. async togglePublish(bookId: string): Promise<boolean> {
  868. const book = await prisma.book.findUnique({
  869. where: { id: parseInt(bookId) },
  870. select: { isPublished: true },
  871. });
  872. const newStatus = !book?.isPublished;
  873. if (newStatus) {
  874. await this.publishAlbum(bookId);
  875. } else {
  876. await this.unpublishAlbum(bookId);
  877. }
  878. return newStatus;
  879. }
  880. /**
  881. * 为书籍章节生成音频并关联(更新 BookChapter.audioUrl)
  882. */
  883. async generateChapterAudio(bookId: string, chapterNumber: number, userId?: number): Promise<{
  884. audioUrl: string;
  885. } | null> {
  886. const chapter = await prisma.bookChapter.findFirst({
  887. where: { bookId: parseInt(bookId), number: chapterNumber },
  888. include: { book: true },
  889. });
  890. if (!chapter || !chapter.content) {
  891. return null;
  892. }
  893. // 生成音频(异步模式,通过回调更新章节)
  894. const result = await generateAudio(
  895. userId ? String(userId) : String(chapter.book?.userId || '0'),
  896. chapter.content,
  897. 'longyingling_v3',
  898. { speed: 1.0, pitch: 0, volume: 50 },
  899. async (audioUrl: string, duration: number) => {
  900. // 音频生成完成后更新章节
  901. await prisma.bookChapter.update({
  902. where: { id: chapter.id },
  903. data: {
  904. audioUrl,
  905. audioDuration: duration,
  906. },
  907. });
  908. console.log(`✅ 章节${chapterNumber}音频生成完成:`, audioUrl);
  909. }
  910. );
  911. return {
  912. audioUrl: result.audioUrl, // 初始为空字符串,实际URL通过回调更新
  913. };
  914. }
  915. /**
  916. * 按 ID 生成章节音频(数据库队列 + 内容哈希去重)
  917. *
  918. * 流程:
  919. * 1. 计算内容 SHA256 哈希
  920. * 2. 检查是否已有相同哈希的已完成任务 → 复用
  921. * 3. 检查是否有进行中的任务 → 跳过(不中断)
  922. * 4. 创建 TtsTask 记录 → 队列处理器异步执行
  923. *
  924. * 关键保护:
  925. * - 同一内容不会重复生成 TTS
  926. * - 已在 audio_generating 的章节不会被回退
  927. * - 无递归调用,重试由队列处理器平铺循环控制
  928. */
  929. async generateChapterAudioById(chapterId: number, userId?: number): Promise<{
  930. audioUrl: string;
  931. } | null> {
  932. // ===== 步骤 1:读取章节状态 =====
  933. const chapterBefore = await prisma.bookChapter.findUnique({
  934. where: { id: chapterId },
  935. include: { book: true },
  936. });
  937. if (!chapterBefore) {
  938. console.warn(`[Audio] 章节不存在: ${chapterId}`);
  939. return null;
  940. }
  941. // 检查内容是否存在
  942. if (!chapterBefore.content || chapterBefore.genStage === 'idle') {
  943. console.warn(`[Audio] 章节内容未生成完成: ${chapterId}, genStage: ${chapterBefore.genStage}`);
  944. return null;
  945. }
  946. // ===== 步骤 2:内容哈希去重 =====
  947. const contentHash = computeContentHash(chapterBefore.content);
  948. // 2a. 检查是否有相同内容的已完成 TTS 任务
  949. const existingCompleted = await prisma.ttsTask.findFirst({
  950. where: { chapterId, taskType: 'tts', contentHash, status: 'completed' },
  951. orderBy: { completedAt: 'desc' },
  952. });
  953. if (existingCompleted?.audioUrl) {
  954. // 章节的 audioUrl 还在 → 内容没变、音频也没被清 → 可安全复用
  955. if (chapterBefore.audioUrl) {
  956. console.log(`[Audio] 章节${chapterId}内容未变化,复用已有音频`);
  957. return { audioUrl: chapterBefore.audioUrl };
  958. }
  959. // audioUrl 已被清空(可能用户主动重整音频)→ 不复用,走新建任务
  960. console.log(`[Audio] 章节${chapterId}内容未变但音频已清空,重新生成`);
  961. }
  962. // 2b. 检查是否有进行中的 TTS 任务(pending 或 processing)
  963. const inProgressTask = await prisma.ttsTask.findFirst({
  964. where: { chapterId, taskType: 'tts', status: { in: ['pending', 'processing'] } },
  965. });
  966. if (inProgressTask) {
  967. console.log(`[Audio] 章节${chapterId}已有进行中的任务#${inProgressTask.id}(状态=${inProgressTask.status}),不重复创建`);
  968. return { audioUrl: '' }; // 返回空,前端通过 genStage 轮询
  969. }
  970. // ===== 步骤 3:已有音频且 genStage 正确 → 直接返回 =====
  971. const alreadyDoneStages = ['audio_completed', 'video_generating', 'video_completed'];
  972. if (alreadyDoneStages.includes(chapterBefore.genStage) && chapterBefore.audioUrl) {
  973. console.log(`[Audio] 章节${chapterId}音频已就绪(genStage=${chapterBefore.genStage}),跳过`);
  974. return { audioUrl: chapterBefore.audioUrl };
  975. }
  976. // ===== 步骤 4:推进 genStage 到 audio_generating =====
  977. // 如果已是 audio_generating,说明之前的任务中断了,先回退再前进
  978. if (chapterBefore.genStage === 'audio_generating') {
  979. console.log(`[Audio] 章节${chapterId}上次生成中断,回退后重新排队`);
  980. await regenerateChapter(chapterId, 'content_completed').catch(() => {});
  981. }
  982. // 如果已是 audio_completed/video_* 等更后的阶段,也先回退
  983. if (alreadyDoneStages.includes(chapterBefore.genStage) || chapterBefore.genStage === 'video_completed') {
  984. await regenerateChapter(chapterId, 'content_completed').catch(() => {});
  985. }
  986. await advanceChapter(chapterId, 'audio_generating');
  987. // 注:移除乐观锁二次检查。advanceChapter 自身使用 safeTransitionChapter
  988. // (乐观锁 UPDATE ... WHERE genStage=current),如果冲突会返回 count=0
  989. // 而不会错误推进。二次检查在高并发下可能误判合法请求。
  990. // ===== 步骤 5:复用已有 failed 任务或创建新任务 =====
  991. // 关键:同一 chapterId 只能有一个活跃任务,避免重复创建
  992. const existingFailed = await prisma.ttsTask.findFirst({
  993. where: { chapterId, taskType: 'tts', status: 'failed' },
  994. orderBy: { createdAt: 'desc' },
  995. });
  996. let task: any;
  997. if (existingFailed) {
  998. // 复用已有 failed 任务,重置为 pending,但累加 retryCount(保留历史重试记录)
  999. const prevRetryCount = existingFailed.retryCount || 0;
  1000. task = await prisma.ttsTask.update({
  1001. where: { id: existingFailed.id },
  1002. data: {
  1003. status: 'pending',
  1004. content: chapterBefore.content,
  1005. contentHash,
  1006. retryCount: prevRetryCount, // 保留历史重试次数,不重置为0
  1007. errorMsg: null,
  1008. startedAt: null,
  1009. completedAt: null,
  1010. },
  1011. });
  1012. console.log(`[Audio] 复用已有任务#${existingFailed.id}: chapterId=${chapterId}, 累计重试=${prevRetryCount}次`);
  1013. } else {
  1014. console.log(`[Audio] 创建TTS任务: chapterId=${chapterId}, 内容长度=${chapterBefore.content.length}, hash=${contentHash.substring(0, 12)}...`);
  1015. task = await prisma.ttsTask.create({
  1016. data: {
  1017. taskType: 'tts',
  1018. chapterId,
  1019. bookId: chapterBefore.bookId,
  1020. userId: userId || chapterBefore.book?.userId || null,
  1021. contentHash,
  1022. content: chapterBefore.content, // 保存提交时的内容副本
  1023. voiceId: 'longyingling_v3',
  1024. status: 'pending',
  1025. },
  1026. });
  1027. console.log(`[Audio] TTS任务 #${task.id} 已创建,等待队列处理器处理`);
  1028. }
  1029. // 返回空 audioUrl,前端通过 genStage 或 WebSocket 获取进度
  1030. return { audioUrl: '' };
  1031. }
  1032. /**
  1033. * [DEPRECATED] 创建章节内容生成任务(数据库队列 + 内容哈希去重)
  1034. *
  1035. * ⚠️ 当前无 content 队列处理器(tts-queue.ts仅创建了tts队列)。
  1036. * 内容生成已改为 LangGraph 节点直接调用 LLM,不再使用数据库队列。
  1037. * 此方法保留仅用于向后兼容,请勿调用。
  1038. *
  1039. * 与 TTS 队列共用 TtsTask 表,通过 taskType='content' 区分。
  1040. * hash 基于章节关键属性(标题、父级、预估字数等),
  1041. * 相同参数不重复提交 LLM 请求。
  1042. *
  1043. * @returns taskId 或 null(如果已有进行中/已完成任务)
  1044. */
  1045. async enqueueContentGeneration(chapterId: number, bookId?: number): Promise<number | null> {
  1046. const chapter = await prisma.bookChapter.findUnique({
  1047. where: { id: chapterId },
  1048. include: { book: true },
  1049. });
  1050. if (!chapter) {
  1051. console.warn(`[Content] 章节不存在: ${chapterId}`);
  1052. return null;
  1053. }
  1054. // ===== 拼接关键属性计算哈希 =====
  1055. const attrString = [
  1056. chapter.bookId,
  1057. chapter.title,
  1058. chapter.parentId,
  1059. chapter.level,
  1060. chapter.estimatedWords,
  1061. ].join('|');
  1062. const contentHash = computeContentHash(attrString);
  1063. // 去重:已完成的内容任务
  1064. const existingCompleted = await prisma.ttsTask.findFirst({
  1065. where: { chapterId, taskType: 'content', contentHash, status: 'completed' },
  1066. orderBy: { completedAt: 'desc' },
  1067. });
  1068. if (existingCompleted) {
  1069. console.log(`[Content] 章节${chapterId}相同参数已有完成记录(hash=${contentHash.substring(0, 8)}...),跳过`);
  1070. return null;
  1071. }
  1072. // 去重:进行中的任务
  1073. const inProgressTask = await prisma.ttsTask.findFirst({
  1074. where: { chapterId, taskType: 'content', status: { in: ['pending', 'processing'] } },
  1075. });
  1076. if (inProgressTask) {
  1077. console.log(`[Content] 章节${chapterId}已有进行中的内容任务#${inProgressTask.id},不重复创建`);
  1078. return inProgressTask.id;
  1079. }
  1080. // 已有内容且 genStage 正常 → 跳过
  1081. if (chapter.content && ['content_completed', 'audio_generating', 'audio_completed'].includes(chapter.genStage)) {
  1082. console.log(`[Content] 章节${chapterId}已有内容(genStage=${chapter.genStage}),跳过`);
  1083. return null;
  1084. }
  1085. // 推进状态
  1086. if (chapter.genStage !== 'content_generating') {
  1087. if (chapter.genStage !== 'idle' && chapter.genStage !== 'outline_completed') {
  1088. await regenerateChapter(chapterId, 'content_generating').catch(() => {});
  1089. } else {
  1090. await advanceChapter(chapterId, 'content_generating').catch(() => {});
  1091. }
  1092. }
  1093. const task = await prisma.ttsTask.create({
  1094. data: {
  1095. taskType: 'content',
  1096. chapterId,
  1097. bookId: bookId || chapter.bookId,
  1098. userId: chapter.book?.userId || null,
  1099. contentHash,
  1100. content: attrString, // 保存提交时的属性快照
  1101. status: 'pending',
  1102. },
  1103. });
  1104. console.log(`[Content] 内容任务 #${task.id} 已创建, chapterId=${chapterId}, hash=${contentHash.substring(0, 12)}...`);
  1105. return task.id;
  1106. }
  1107. /**
  1108. * 处理内容生成队列任务(由 ContentQueue 调用)
  1109. *
  1110. * [DEPRECATED] 实际的 LLM 文本生成逻辑,平铺循环重试(最多 3 次),无递归。
  1111. *
  1112. * ⚠️ 无 content 队列处理器调用此方法。内容生成由 LangGraph 节点直接完成。
  1113. */
  1114. async processContentTask(taskId: number): Promise<void> {
  1115. const task = await prisma.ttsTask.findUnique({ where: { id: taskId } });
  1116. if (!task || task.status !== 'processing' || task.taskType !== 'content') {
  1117. console.warn(`[ContentTask] 任务#${taskId}状态异常,跳过`);
  1118. return;
  1119. }
  1120. const chapterId = task.chapterId;
  1121. console.log(`[ContentTask] 开始处理任务#${taskId}, chapterId=${chapterId}`);
  1122. const MAX_RETRIES = 3;
  1123. for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
  1124. try {
  1125. console.log(`[ContentTask] #${taskId} 第${attempt + 1}次尝试生成内容...`);
  1126. const chapter = await prisma.bookChapter.findUnique({
  1127. where: { id: chapterId },
  1128. include: { book: true },
  1129. });
  1130. if (!chapter) {
  1131. await this._failTask(taskId, `章节${chapterId}不存在`);
  1132. return;
  1133. }
  1134. const bookId = String(chapter.bookId);
  1135. // 委托给现有的生成方法(它已经包含完整的 LLM 调用逻辑)
  1136. await this.generateSingleChapterContent(bookId, chapterId);
  1137. // 验证生成结果
  1138. const updated = await prisma.bookChapter.findUnique({ where: { id: chapterId } });
  1139. if (updated?.content && updated.genStage === 'content_completed') {
  1140. await prisma.ttsTask.update({
  1141. where: { id: taskId },
  1142. data: {
  1143. status: 'completed',
  1144. completedAt: new Date(),
  1145. retryCount: attempt,
  1146. },
  1147. });
  1148. console.log(`[ContentTask] ✅ 任务#${taskId} 完成`);
  1149. await this.tryCleanupBookTasks(chapterId);
  1150. return;
  1151. }
  1152. // 内容为空或状态不对 → 视为失败
  1153. throw new Error(updated?.content ? `状态异常: ${updated.genStage}` : '生成内容为空');
  1154. } catch (err: any) {
  1155. const errorMsg = err?.message || String(err);
  1156. console.error(`[ContentTask] ❌ 任务#${taskId} 第${attempt + 1}次失败:`, errorMsg);
  1157. if (attempt < MAX_RETRIES) {
  1158. const delay = Math.min(3000 * Math.pow(2, attempt) + Math.random() * 2000, 60000);
  1159. console.log(`[ContentTask] #${taskId} ${(delay / 1000).toFixed(1)}s 后重试...`);
  1160. await prisma.ttsTask.update({
  1161. where: { id: taskId },
  1162. data: { retryCount: attempt + 1, errorMsg: errorMsg.substring(0, 500) },
  1163. });
  1164. await new Promise(resolve => setTimeout(resolve, delay));
  1165. continue;
  1166. }
  1167. await this._failTask(taskId, errorMsg);
  1168. return;
  1169. }
  1170. }
  1171. await this._failTask(taskId, '重试耗尽');
  1172. }
  1173. /**
  1174. * 等待内容生成队列任务完成(调用方轮询,最多等待 30 分钟)
  1175. *
  1176. * @returns 生成后的章节内容
  1177. */
  1178. async awaitContentTask(taskId: number, timeoutMs: number = 30 * 60 * 1000): Promise<{
  1179. content: string;
  1180. wordCount: number;
  1181. genStage: string;
  1182. }> {
  1183. const task = await prisma.ttsTask.findUnique({ where: { id: taskId } });
  1184. if (!task) throw new Error(`任务#${taskId}不存在`);
  1185. if (task.taskType !== 'content') throw new Error(`任务#${taskId}不是内容生成类型`);
  1186. const start = Date.now();
  1187. let lastLogTime = 0;
  1188. while (Date.now() - start < timeoutMs) {
  1189. const chapter = await prisma.bookChapter.findUnique({
  1190. where: { id: task.chapterId },
  1191. select: { content: true, wordCount: true, genStage: true, contentError: true },
  1192. });
  1193. if (!chapter) throw new Error(`章节${task.chapterId}不存在`);
  1194. // 完成
  1195. if (chapter.genStage === 'content_completed' && chapter.content) {
  1196. console.log(`[ContentTask] 任务#${taskId} 轮询完成 (${((Date.now() - start) / 1000).toFixed(0)}s)`);
  1197. return { content: chapter.content, wordCount: chapter.wordCount, genStage: 'content_completed' };
  1198. }
  1199. // 失败
  1200. if (chapter.genStage === 'failed') {
  1201. const updated = await prisma.ttsTask.findUnique({ where: { id: taskId }, select: { errorMsg: true } });
  1202. throw new Error(updated?.errorMsg || chapter.contentError || '内容生成失败');
  1203. }
  1204. // 进度日志(每 30 秒)
  1205. const now = Date.now();
  1206. if (now - lastLogTime > 30000) {
  1207. lastLogTime = now;
  1208. const elapsed = ((now - start) / 1000).toFixed(0);
  1209. console.log(`[ContentTask] 任务#${taskId} 等待中... genStage=${chapter.genStage}, 已等${elapsed}s`);
  1210. }
  1211. await new Promise(resolve => setTimeout(resolve, 3000));
  1212. }
  1213. throw new Error(`内容生成超时 (${timeoutMs / 1000}s)`);
  1214. }
  1215. /**
  1216. * 处理 TTS 队列任务(由 tts-queue.ts 调用)
  1217. *
  1218. * 只尝试 1 次。Provider 层会自动遍历所有可用 Provider。
  1219. * 失败后标记 failed,由用户手动重新生成。
  1220. */
  1221. async processTtsTask(taskId: number): Promise<void> {
  1222. const task = await prisma.ttsTask.findUnique({ where: { id: taskId } });
  1223. if (!task || task.status !== 'processing') {
  1224. console.warn(`[TtsTask] 任务#${taskId}状态异常(status=${task?.status}),跳过`);
  1225. return;
  1226. }
  1227. const chapterId = task.chapterId;
  1228. const content = task.content;
  1229. if (!content) {
  1230. await this._failTask(taskId, '任务内容为空');
  1231. return;
  1232. }
  1233. console.log(`[TtsTask] 开始处理任务#${taskId}, chapterId=${chapterId}, 内容长度=${content.length}`);
  1234. try {
  1235. const chapter = await prisma.bookChapter.findUnique({
  1236. where: { id: chapterId },
  1237. include: { book: true },
  1238. });
  1239. if (!chapter) {
  1240. await this._failTask(taskId, `章节${chapterId}不存在`);
  1241. return;
  1242. }
  1243. const result = await generateAudio(
  1244. String(task.userId || chapter.book?.userId || '0'),
  1245. content,
  1246. 'longyingling_v3',
  1247. { speed: 1.0, pitch: 0, volume: 50 },
  1248. async (audioUrl: string, duration: number) => {
  1249. console.log(`[TtsTask] #${taskId} 音频就绪: ${audioUrl?.substring(0, 60)}...`);
  1250. try {
  1251. await advanceChapter(chapterId, 'audio_completed');
  1252. try {
  1253. const userId = chapter.book?.userId || task.userId || 1;
  1254. const audioMinutes = Math.ceil(duration / 60);
  1255. await consumeAudioMinutes(userId, audioMinutes, `书籍「${chapter.book?.title || '未知'}」- ${chapter.title} (${duration}s)`);
  1256. } catch (quotaErr: any) {
  1257. console.warn(`[Quota] 消耗音频配额失败:`, quotaErr.message);
  1258. }
  1259. tryAutoMerge(chapterId, chapter.bookId, chapter.level, chapter.parentId);
  1260. } catch (advanceErr: any) {
  1261. console.warn(`[TtsTask] #${taskId} genStage推进失败: ${advanceErr.message},音频已就绪`);
  1262. }
  1263. },
  1264. {
  1265. bookId: chapter.bookId ? String(chapter.bookId) : undefined,
  1266. chapterId: chapter.id,
  1267. chapterTitle: chapter.title,
  1268. }
  1269. );
  1270. await prisma.ttsTask.update({
  1271. where: { id: taskId },
  1272. data: { status: 'completed', audioUrl: result.audioUrl, completedAt: new Date() },
  1273. });
  1274. console.log(`[TtsTask] ✅ 任务#${taskId} 完成`);
  1275. logAiCall({ callType: 'tts_task_completed', provider: 'tts', model: 'tts-task', textLen: content?.length || 0, success: true, chapterId, bookId: task.bookId ?? undefined });
  1276. await this.tryCleanupBookTasks(chapterId);
  1277. } catch (err: any) {
  1278. const errorMsg = err?.message || String(err);
  1279. console.error(`[TtsTask] ❌ 任务#${taskId} 失败:`, errorMsg);
  1280. logAiCall({ callType: 'tts_task_failed', provider: 'tts', model: 'tts-task', textLen: content?.length || 0, success: false, errorMsg, chapterId, bookId: task.bookId ?? undefined });
  1281. await this._failTask(taskId, errorMsg);
  1282. }
  1283. }
  1284. /**
  1285. * 标记任务失败(不回退章节状态,由用户手动重新生成)
  1286. */
  1287. private async _failTask(taskId: number, errorMsg: string): Promise<void> {
  1288. console.error(`[TtsTask] ❌ 任务#${taskId} 最终失败:`, errorMsg);
  1289. await prisma.ttsTask.update({
  1290. where: { id: taskId },
  1291. data: {
  1292. status: 'failed',
  1293. errorMsg: errorMsg.substring(0, 1000),
  1294. completedAt: new Date(),
  1295. },
  1296. });
  1297. }
  1298. /**
  1299. * 任务成功后尝试清理整本书的任务记录
  1300. * 当一本书所有章节都已生成完毕(>= audio_completed),清空该书的所有队列任务
  1301. */
  1302. private async tryCleanupBookTasks(chapterId: number): Promise<void> {
  1303. try {
  1304. const chapter = await prisma.bookChapter.findUnique({
  1305. where: { id: chapterId },
  1306. select: { bookId: true },
  1307. });
  1308. if (!chapter?.bookId) return;
  1309. // 检查该书的章节是否全部完成
  1310. const allChapters = await prisma.bookChapter.findMany({
  1311. where: { bookId: chapter.bookId },
  1312. select: { genStage: true },
  1313. });
  1314. if (allChapters.length === 0) return;
  1315. const doneStages = ['audio_completed', 'video_generating', 'video_completed'];
  1316. const allDone = allChapters.every(c => doneStages.includes(c.genStage));
  1317. if (!allDone) return;
  1318. // 全书完成,清理任务记录
  1319. const result = await prisma.ttsTask.deleteMany({
  1320. where: { bookId: chapter.bookId },
  1321. });
  1322. if (result.count > 0) {
  1323. console.log(`[Cleanup] 书籍#${chapter.bookId}已完成,清理了 ${result.count} 条队列任务`);
  1324. }
  1325. } catch (err: any) {
  1326. console.warn(`[Cleanup] 清理任务失败:`, err.message);
  1327. }
  1328. }
  1329. /**
  1330. * 重新生成单个章节内容(仅叶节点)
  1331. * 直接生成,不触发整个书籍生成流程
  1332. */
  1333. async generateSingleChapterContent(bookId: string, chapterId: number): Promise<void> {
  1334. const book = await this.getById(bookId);
  1335. if (!book) {
  1336. console.error(`[generateSingleChapterContent] 书籍不存在: ${bookId}`);
  1337. return;
  1338. }
  1339. const chapter = await prisma.bookChapter.findUnique({
  1340. where: { id: chapterId },
  1341. });
  1342. if (!chapter) {
  1343. console.error(`[generateSingleChapterContent] 章节不存在: ${chapterId}`);
  1344. return;
  1345. }
  1346. // 构建父节点映射
  1347. const chaptersAndSections = await prisma.bookChapter.findMany({
  1348. where: { bookId: parseInt(bookId), level: { in: [1, 2] } }
  1349. });
  1350. const chapterMap = new Map<number, any>();
  1351. const sectionMap = new Map<number, any>();
  1352. chaptersAndSections.forEach(c => {
  1353. if (c.level === 1) chapterMap.set(c.id, c);
  1354. if (c.level === 2) sectionMap.set(c.id, c);
  1355. });
  1356. const parentSection = sectionMap.get(chapter.parentId || 0);
  1357. const parentChapter = parentSection ? chapterMap.get(parentSection.parentId || 0) : null;
  1358. const chapterTitle = parentChapter?.title || '未知章';
  1359. const sectionTitle = parentSection?.title || '未知节';
  1360. const chapterSummary = parentChapter?.summary || '';
  1361. const sectionSummary = parentSection?.summary || '';
  1362. // 判断是否是短文
  1363. const isShortArticle = !parentSection && chapter.level === 1;
  1364. // 从 description 中提取写作风格要求
  1365. const styleMatch = book.description?.match(/写作风格:([^\\n]+)/);
  1366. const writingStyle = styleMatch ? styleMatch[1] : (book.style || '');
  1367. let messages: ChatMessage[];
  1368. if (isShortArticle) {
  1369. messages = [
  1370. { role: 'system', content: SUBSECTION_CONTENT_SYSTEM_PROMPT },
  1371. {
  1372. role: 'user',
  1373. content: `书名:《${book.title}》
  1374. ${book.description || ''}
  1375. ${writingStyle ? `写作风格:${writingStyle}` : ''}
  1376. 章标题:${chapter.title}
  1377. 章概述:${chapter.summary || ''}
  1378. 预估字数:${chapter.estimatedWords || 500}字
  1379. 请撰写该章节的正文内容。`,
  1380. },
  1381. ];
  1382. } else {
  1383. messages = buildSubsectionContentMessages(
  1384. book.title,
  1385. book.description || '',
  1386. chapterTitle,
  1387. chapterSummary,
  1388. sectionTitle,
  1389. sectionSummary,
  1390. chapter
  1391. );
  1392. }
  1393. const bookTools = createBookTools(bookId, this);
  1394. try {
  1395. let content: string;
  1396. try {
  1397. const result = await callLLMWithTools(messages, bookTools);
  1398. content = cleanThinkingText(result.text);
  1399. } catch {
  1400. content = cleanThinkingText(await callLLMWithMessages(messages));
  1401. }
  1402. const wordCount = countWords(content);
  1403. await this.updateChapterById(chapterId, {
  1404. content,
  1405. wordCount,
  1406. });
  1407. // 推进到 content_completed(如已越过则跳过,不抛异常)
  1408. const currentChapter = await prisma.bookChapter.findUnique({ where: { id: chapterId } });
  1409. if (currentChapter) {
  1410. const order = ['idle', 'outline_completed', 'content_generating', 'content_completed', 'audio_generating', 'audio_completed', 'video_generating', 'video_completed'];
  1411. const curIdx = order.indexOf(currentChapter.genStage);
  1412. const tgtIdx = order.indexOf('content_completed');
  1413. // 只有当前在 content_completed 之前才推进(避免竞态:音频已推进到 audio_generating)
  1414. if (curIdx >= 0 && curIdx < tgtIdx) {
  1415. try {
  1416. // 状态机要求:outline_completed → content_generating → content_completed
  1417. if (currentChapter.genStage !== 'content_generating') {
  1418. await advanceChapter(chapterId, 'content_generating').catch(() => {});
  1419. }
  1420. await advanceChapter(chapterId, 'content_completed');
  1421. } catch (advanceErr: any) {
  1422. console.warn(`[generateSingleChapterContent] genStage推进失败 chapterId=${chapterId}: ${advanceErr.message}, 可能已被其他流程推进`);
  1423. }
  1424. }
  1425. }
  1426. console.log(`✅ 章节「${chapter.title}」内容重新生成完成,字数: ${wordCount}`);
  1427. } catch (error) {
  1428. const errorMsg = error instanceof Error ? error.message : '失败';
  1429. await this.updateChapterById(chapterId, {
  1430. contentError: errorMsg,
  1431. });
  1432. // 仅当LLM调用失败时才回退到 content_generating
  1433. console.error(`❌ 章节${chapterId}内容重新生成失败:`, errorMsg);
  1434. }
  1435. }
  1436. /**
  1437. * 转换数据库模型到 Book 类型
  1438. * @param excludeContent 是否排除章节内容(用于列表/详情页,只返回标题不返回正文)
  1439. */
  1440. private toBook(dbBook: {
  1441. id: number;
  1442. userId: number | null;
  1443. title: string;
  1444. subtitle: string | null;
  1445. description: string;
  1446. targetAudience: string;
  1447. style: string;
  1448. bookScale: string;
  1449. totalChapters: number;
  1450. estimatedWords: number;
  1451. progress: number;
  1452. isPublished: boolean;
  1453. genStage?: string;
  1454. failedStage?: string;
  1455. outlineJson: string | null;
  1456. foreword: string | null;
  1457. afterword: string | null;
  1458. errorMsg: string | null;
  1459. bookAnalysis?: string | null;
  1460. createdAt: Date;
  1461. updatedAt: Date;
  1462. chapters: any[];
  1463. }, excludeContent: boolean = false): Book {
  1464. const outline = parseOutlineJson(dbBook.outlineJson);
  1465. return {
  1466. id: String(dbBook.id),
  1467. userId: dbBook.userId || undefined,
  1468. title: dbBook.title,
  1469. subtitle: dbBook.subtitle || undefined,
  1470. description: dbBook.description,
  1471. targetAudience: dbBook.targetAudience,
  1472. style: dbBook.style,
  1473. bookScale: dbBook.bookScale,
  1474. totalChapters: dbBook.totalChapters,
  1475. estimatedWords: dbBook.estimatedWords,
  1476. progress: dbBook.progress,
  1477. isPublished: dbBook.isPublished,
  1478. genStage: computeBookGenStage(dbBook.chapters),
  1479. failedStage: dbBook.failedStage || undefined,
  1480. chapters: chaptersFromDb(dbBook.chapters, dbBook.id, excludeContent),
  1481. outline: outline || undefined,
  1482. metadata: {
  1483. foreword: dbBook.foreword || undefined,
  1484. afterword: dbBook.afterword || undefined,
  1485. },
  1486. bookAnalysis: dbBook.bookAnalysis || undefined,
  1487. error: dbBook.errorMsg || undefined,
  1488. createdAt: dbBook.createdAt,
  1489. updatedAt: dbBook.updatedAt,
  1490. };
  1491. }
  1492. }
  1493. // 导出单例
  1494. export const bookStore = new BookStore();