book-generator.store.ts 58 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731
  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 { consumeAudioMinutes, canUseTtsProvider } from '../subscription/subscription.service';
  17. import { runWithContext } from '../../services/llm-context';
  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: Number(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 deleteAllChapters(bookId: string): Promise<number> {
  571. const bookIdNum = parseInt(bookId);
  572. // 先删子节点再删章,避免外键依赖
  573. const { count: deletedPlaylistItems } = await prisma.playlistItem.deleteMany({
  574. where: { chapter: { bookId: bookIdNum } },
  575. });
  576. const { count: deletedTtsTasks } = await prisma.ttsTask.deleteMany({
  577. where: { chapter: { bookId: bookIdNum } },
  578. });
  579. const { count: deletedChapters } = await prisma.bookChapter.deleteMany({
  580. where: { bookId: bookIdNum },
  581. });
  582. console.log(`[Store] 清理书籍${bookId}旧数据: ${deletedChapters}章, ${deletedTtsTasks}个TTS任务, ${deletedPlaylistItems}个播放列表项`);
  583. return deletedChapters;
  584. }
  585. /**
  586. * 创建章节
  587. */
  588. async createChapter(data: {
  589. bookId: string;
  590. number: number;
  591. title: string;
  592. summary?: string;
  593. keyPoints?: string[];
  594. estimatedWords?: number;
  595. }): Promise<void> {
  596. await prisma.bookChapter.create({
  597. data: {
  598. bookId: parseInt(data.bookId),
  599. number: data.number,
  600. title: data.title,
  601. summary: data.summary,
  602. keyPoints: data.keyPoints ? JSON.stringify(data.keyPoints) : null,
  603. estimatedWords: data.estimatedWords || 5000,
  604. genStage: 'outline_completed',
  605. },
  606. });
  607. }
  608. /**
  609. * 批量创建章节(支持重新生成)
  610. * 使用 upsert 避免唯一约束冲突
  611. */
  612. async createChapters(bookId: string, chapters: Array<{
  613. number: number;
  614. title: string;
  615. summary?: string;
  616. keyPoints?: string[];
  617. estimatedWords?: number;
  618. }>): Promise<void> {
  619. const bookIdNum = parseInt(bookId);
  620. // 使用 upsert 避免重复创建(parentId=0表示章级别)
  621. for (const c of chapters) {
  622. const upserted = await prisma.bookChapter.upsert({
  623. where: {
  624. bookId_parentId_level_number: {
  625. bookId: bookIdNum,
  626. parentId: 0,
  627. level: 1,
  628. number: c.number,
  629. }
  630. },
  631. update: {
  632. title: c.title,
  633. summary: c.summary,
  634. keyPoints: c.keyPoints ? JSON.stringify(c.keyPoints) : null,
  635. estimatedWords: c.estimatedWords || 5000,
  636. genStage: 'outline_completed',
  637. } as any,
  638. create: {
  639. bookId: bookIdNum,
  640. parentId: 0,
  641. level: 1,
  642. number: c.number,
  643. title: c.title,
  644. summary: c.summary,
  645. keyPoints: c.keyPoints ? JSON.stringify(c.keyPoints) : null,
  646. estimatedWords: c.estimatedWords || 5000,
  647. genStage: 'outline_completed',
  648. } as any,
  649. });
  650. }
  651. }
  652. /**
  653. * 创建章节条目(支持任意层级:章/节/小节)
  654. * @param bookIdNum 书籍ID
  655. * @param item 节/小节数据
  656. * @param parentId 父节点ID(null表示章这一级)
  657. * @param level 层级:1=章, 2=节, 3=小节
  658. * @returns 创建的记录ID
  659. */
  660. async createChapterItem(bookIdNum: number, item: {
  661. number: number;
  662. title: string;
  663. summary?: string;
  664. keyPoints?: string[];
  665. estimatedWords?: number;
  666. }, parentId: number | null, level: number): Promise<number> {
  667. // 防御:LLM 可能返回 undefined/null 的字段
  668. let chapterNumber = item.number;
  669. if (chapterNumber == null || chapterNumber === undefined || isNaN(chapterNumber as number)) {
  670. const maxExisting = await prisma.bookChapter.findFirst({
  671. where: { bookId: bookIdNum, parentId: parentId ?? 0, level },
  672. orderBy: { number: 'desc' },
  673. select: { number: true },
  674. });
  675. chapterNumber = (maxExisting?.number ?? 0) + 1;
  676. console.warn(`[BookStore] chapter number missing/invalid, auto-assigned: ${chapterNumber} (bookId=${bookIdNum}, level=${level})`);
  677. }
  678. // 防御:title 也不能是 undefined
  679. const safeTitle = item.title || `章节${chapterNumber}`;
  680. if (!item.title) {
  681. console.warn(`[BookStore] chapter title missing, using fallback: ${safeTitle} (bookId=${bookIdNum})`);
  682. }
  683. // 使用 upsert 避免重复创建
  684. const upserted = await prisma.bookChapter.upsert({
  685. where: {
  686. bookId_parentId_level_number: {
  687. bookId: bookIdNum,
  688. parentId: parentId,
  689. level: level,
  690. number: chapterNumber,
  691. }
  692. },
  693. update: {
  694. title: safeTitle,
  695. summary: item.summary || '',
  696. keyPoints: item.keyPoints ? JSON.stringify(item.keyPoints) : null,
  697. estimatedWords: item.estimatedWords || 1000,
  698. genStage: 'outline_completed',
  699. } as any,
  700. create: {
  701. bookId: bookIdNum,
  702. parentId,
  703. level,
  704. number: chapterNumber,
  705. title: safeTitle,
  706. summary: item.summary || '',
  707. keyPoints: item.keyPoints ? JSON.stringify(item.keyPoints) : null,
  708. estimatedWords: item.estimatedWords || 1000,
  709. genStage: 'outline_completed',
  710. } as any,
  711. });
  712. return upserted.id;
  713. }
  714. /**
  715. * 批量创建章节条目
  716. */
  717. async createChapterItems(bookIdNum: number, items: Array<{
  718. number: number;
  719. title: string;
  720. summary?: string;
  721. keyPoints?: string[];
  722. estimatedWords?: number;
  723. }>, parentId: number | null, level: number): Promise<number[]> {
  724. const ids: number[] = [];
  725. for (const item of items) {
  726. const id = await this.createChapterItem(bookIdNum, item, parentId, level);
  727. ids.push(id);
  728. }
  729. return ids;
  730. }
  731. /**
  732. * 更新章节内容
  733. * @param level 可选层级过滤,避免同 number 不同 level 的章节串写
  734. */
  735. async updateChapter(bookId: string, chapterNumber: number, data: Partial<{
  736. content: string;
  737. wordCount: number;
  738. genStage: string;
  739. errorMsg: string;
  740. }>, level?: number): Promise<Chapter | null> {
  741. const where: any = {
  742. bookId: parseInt(bookId),
  743. number: chapterNumber,
  744. };
  745. if (level !== undefined) where.level = level;
  746. const chapter = await prisma.bookChapter.findFirst({ where });
  747. if (!chapter) return null;
  748. const updated = await prisma.bookChapter.update({
  749. where: { id: chapter.id },
  750. data: {
  751. ...data,
  752. generatedAt: data.content ? new Date() : undefined,
  753. },
  754. });
  755. return {
  756. id: String(updated.id),
  757. bookId: String(updated.bookId),
  758. number: updated.number,
  759. title: updated.title,
  760. content: updated.content || '',
  761. wordCount: updated.wordCount,
  762. genStage: updated.genStage as ChapterGenStage | undefined,
  763. summary: updated.summary || undefined,
  764. generatedAt: updated.generatedAt || undefined,
  765. error: (updated as any).errorMsg || undefined,
  766. };
  767. }
  768. /**
  769. * 按 ID 更新章节内容
  770. */
  771. async updateChapterById(id: number, data: Partial<{
  772. content: string;
  773. wordCount: number;
  774. genStage: string;
  775. errorMsg: string;
  776. contentError: string;
  777. audioUrl: string;
  778. audioDuration: number;
  779. lrcLyrics: string | null;
  780. videoUrl: string;
  781. videoDuration: number;
  782. }>): Promise<Chapter | null> {
  783. // 转换 errorMsg -> contentError (Prisma字段名)
  784. const prismaData: any = { ...data };
  785. if ('errorMsg' in prismaData) {
  786. prismaData.contentError = prismaData.errorMsg;
  787. delete prismaData.errorMsg;
  788. }
  789. const updated = await prisma.bookChapter.update({
  790. where: { id },
  791. data: {
  792. ...prismaData,
  793. generatedAt: data.content ? new Date() : undefined,
  794. },
  795. });
  796. return {
  797. id: String(updated.id),
  798. bookId: String(updated.bookId),
  799. number: updated.number,
  800. title: updated.title,
  801. content: updated.content || '',
  802. wordCount: updated.wordCount,
  803. genStage: updated.genStage as ChapterGenStage | undefined,
  804. summary: updated.summary || undefined,
  805. generatedAt: updated.generatedAt || undefined,
  806. error: updated.contentError || undefined,
  807. audioUrl: updated.audioUrl || undefined,
  808. audioDuration: updated.audioDuration || 0,
  809. videoUrl: updated.videoUrl || undefined,
  810. videoDuration: updated.videoDuration || undefined,
  811. };
  812. }
  813. /**
  814. * 获取书籍的章节
  815. */
  816. async getChapters(bookId: string): Promise<Chapter[]> {
  817. const chapters = await prisma.bookChapter.findMany({
  818. where: { bookId: parseInt(bookId) },
  819. orderBy: { number: 'asc' },
  820. });
  821. return chaptersFromDb(chapters, parseInt(bookId));
  822. }
  823. /**
  824. * 获取书籍的完整章节树(章→节→小节)
  825. * 按 level 和 number 排序
  826. */
  827. async getChapterTree(bookId: string): Promise<any[]> {
  828. const chapters = await prisma.bookChapter.findMany({
  829. where: { bookId: parseInt(bookId) },
  830. orderBy: [{ level: 'asc' }, { number: 'asc' }],
  831. });
  832. return chapters;
  833. }
  834. /**
  835. * 统计书籍完成章节数
  836. */
  837. async countCompletedChapters(bookId: string): Promise<number> {
  838. return prisma.bookChapter.count({
  839. where: {
  840. bookId: parseInt(bookId),
  841. genStage: 'video_completed',
  842. },
  843. });
  844. }
  845. /**
  846. * 发布书籍(将 isPublished 设为 true,同时公开所有章节)
  847. */
  848. async publishAlbum(bookId: string): Promise<void> {
  849. await prisma.$transaction([
  850. prisma.book.update({
  851. where: { id: parseInt(bookId) },
  852. data: { isPublished: true },
  853. }),
  854. // 公开所有有音频的章节
  855. prisma.bookChapter.updateMany({
  856. where: {
  857. bookId: parseInt(bookId),
  858. audioUrl: { not: '' },
  859. },
  860. data: { isPublic: true },
  861. }),
  862. ]);
  863. }
  864. /**
  865. * 取消发布书籍(将 isPublished 设为 false,同时取消公开所有章节)
  866. */
  867. async unpublishAlbum(bookId: string): Promise<void> {
  868. await prisma.$transaction([
  869. prisma.book.update({
  870. where: { id: parseInt(bookId) },
  871. data: { isPublished: false },
  872. }),
  873. // 取消公开所有章节
  874. prisma.bookChapter.updateMany({
  875. where: {
  876. bookId: parseInt(bookId),
  877. },
  878. data: { isPublic: false },
  879. }),
  880. ]);
  881. }
  882. /**
  883. * 切换书籍公开状态
  884. */
  885. async togglePublish(bookId: string): Promise<boolean> {
  886. const book = await prisma.book.findUnique({
  887. where: { id: parseInt(bookId) },
  888. select: { isPublished: true },
  889. });
  890. const newStatus = !book?.isPublished;
  891. if (newStatus) {
  892. await this.publishAlbum(bookId);
  893. } else {
  894. await this.unpublishAlbum(bookId);
  895. }
  896. return newStatus;
  897. }
  898. /**
  899. * 为书籍章节生成音频并关联(更新 BookChapter.audioUrl)
  900. */
  901. async generateChapterAudio(bookId: string, chapterNumber: number, userId?: number): Promise<{
  902. audioUrl: string;
  903. } | null> {
  904. const chapter = await prisma.bookChapter.findFirst({
  905. where: { bookId: parseInt(bookId), number: chapterNumber },
  906. include: { book: true },
  907. });
  908. if (!chapter || !chapter.content) {
  909. return null;
  910. }
  911. // 生成音频(异步模式,通过回调更新章节)
  912. const result = await generateAudio(
  913. userId ? String(userId) : String(chapter.book?.userId || '0'),
  914. chapter.content,
  915. 'longyingling_v3',
  916. { speed: 1.0, pitch: 0, volume: 50 },
  917. async (audioUrl: string, duration: number) => {
  918. // 音频生成完成后更新章节
  919. await prisma.bookChapter.update({
  920. where: { id: chapter.id },
  921. data: {
  922. audioUrl,
  923. audioDuration: duration,
  924. },
  925. });
  926. console.log(`✅ 章节${chapterNumber}音频生成完成:`, audioUrl);
  927. }
  928. );
  929. return {
  930. audioUrl: result.audioUrl, // 初始为空字符串,实际URL通过回调更新
  931. };
  932. }
  933. /**
  934. * 按 ID 生成章节音频(数据库队列 + 内容哈希去重)
  935. *
  936. * 流程:
  937. * 1. 计算内容 SHA256 哈希
  938. * 2. 检查是否已有相同哈希的已完成任务 → 复用
  939. * 3. 检查是否有进行中的任务 → 跳过(不中断)
  940. * 4. 创建 TtsTask 记录 → 队列处理器异步执行
  941. *
  942. * 关键保护:
  943. * - 同一内容不会重复生成 TTS
  944. * - 已在 audio_generating 的章节不会被回退
  945. * - 无递归调用,重试由队列处理器平铺循环控制
  946. */
  947. async generateChapterAudioById(chapterId: number, userId?: number, voiceId?: string): Promise<{
  948. audioUrl: string;
  949. } | null> {
  950. // ===== 步骤 1:读取章节状态 =====
  951. const chapterBefore = await prisma.bookChapter.findUnique({
  952. where: { id: chapterId },
  953. include: { book: true },
  954. });
  955. if (!chapterBefore) {
  956. console.warn(`[Audio] 章节不存在: ${chapterId}`);
  957. return null;
  958. }
  959. // 检查内容是否存在
  960. if (!chapterBefore.content || chapterBefore.genStage === 'idle') {
  961. console.warn(`[Audio] 章节内容未生成完成: ${chapterId}, genStage: ${chapterBefore.genStage}`);
  962. return null;
  963. }
  964. // ===== 步骤 2:内容哈希去重 =====
  965. const contentHash = computeContentHash(chapterBefore.content);
  966. // 2a. 检查是否有相同内容的已完成 TTS 任务
  967. const existingCompleted = await prisma.ttsTask.findFirst({
  968. where: { chapterId, taskType: 'tts', contentHash, status: 'completed' },
  969. orderBy: { completedAt: 'desc' },
  970. });
  971. if (existingCompleted?.audioUrl) {
  972. // 章节的 audioUrl 还在 → 内容没变、音频也没被清 → 可安全复用
  973. if (chapterBefore.audioUrl) {
  974. console.log(`[Audio] 章节${chapterId}内容未变化,复用已有音频`);
  975. return { audioUrl: chapterBefore.audioUrl };
  976. }
  977. // audioUrl 已被清空(可能用户主动重整音频)→ 不复用,走新建任务
  978. console.log(`[Audio] 章节${chapterId}内容未变但音频已清空,重新生成`);
  979. }
  980. // 2b. 检查是否有进行中的 TTS 任务(pending 或 processing)
  981. const inProgressTask = await prisma.ttsTask.findFirst({
  982. where: { chapterId, taskType: 'tts', status: { in: ['pending', 'processing'] } },
  983. });
  984. if (inProgressTask) {
  985. console.log(`[Audio] 章节${chapterId}已有进行中的任务#${inProgressTask.id}(状态=${inProgressTask.status}),不重复创建`);
  986. return { audioUrl: '' }; // 返回空,前端通过 genStage 轮询
  987. }
  988. // ===== 步骤 2.5:权限检查 =====
  989. const effectiveUserId = userId || chapterBefore.book?.userId;
  990. if (effectiveUserId) {
  991. const user = await prisma.user.findUnique({ where: { id: effectiveUserId } });
  992. if (user) {
  993. // 免费用户(memberLevel=0)不能使用TTS
  994. if (user.memberLevel === 0) {
  995. console.warn(`[Audio] 免费用户无法生成音频: userId=${effectiveUserId}`);
  996. await prisma.bookChapter.update({
  997. where: { id: chapterId },
  998. data: { genStage: chapterBefore.genStage }
  999. }).catch(() => {});
  1000. throw new Error('免费用户暂不支持音频生成,请升级会员');
  1001. }
  1002. // 普通会员(memberLevel=1)只能使用Edge TTS
  1003. // 这里只做警告,实际provider限制在TTS调用时生效
  1004. if (user.memberLevel === 1) {
  1005. console.log(`[Audio] 普通会员用户,使用Edge TTS享受2折优惠: userId=${effectiveUserId}`);
  1006. }
  1007. }
  1008. }
  1009. // ===== 步骤 3:已有音频且 genStage 正确 → 直接返回 =====
  1010. const alreadyDoneStages = ['audio_completed', 'video_generating', 'video_completed'];
  1011. if (alreadyDoneStages.includes(chapterBefore.genStage) && chapterBefore.audioUrl) {
  1012. console.log(`[Audio] 章节${chapterId}音频已就绪(genStage=${chapterBefore.genStage}),跳过`);
  1013. return { audioUrl: chapterBefore.audioUrl };
  1014. }
  1015. // ===== 步骤 4:推进 genStage 到 audio_generating =====
  1016. // 如果已是 audio_generating,说明之前的任务中断了,先回退再前进
  1017. if (chapterBefore.genStage === 'audio_generating') {
  1018. console.log(`[Audio] 章节${chapterId}上次生成中断,回退后重新排队`);
  1019. await regenerateChapter(chapterId, 'content_completed').catch(() => {});
  1020. }
  1021. // 如果已是 audio_completed/video_* 等更后的阶段,也先回退
  1022. if (alreadyDoneStages.includes(chapterBefore.genStage) || chapterBefore.genStage === 'video_completed') {
  1023. await regenerateChapter(chapterId, 'content_completed').catch(() => {});
  1024. }
  1025. await advanceChapter(chapterId, 'audio_generating');
  1026. // 注:移除乐观锁二次检查。advanceChapter 自身使用 safeTransitionChapter
  1027. // (乐观锁 UPDATE ... WHERE genStage=current),如果冲突会返回 count=0
  1028. // 而不会错误推进。二次检查在高并发下可能误判合法请求。
  1029. // ===== 步骤 5:复用已有 failed 任务或创建新任务 =====
  1030. // 关键:同一 chapterId 只能有一个活跃任务,避免重复创建
  1031. const existingFailed = await prisma.ttsTask.findFirst({
  1032. where: { chapterId, taskType: 'tts', status: 'failed' },
  1033. orderBy: { createdAt: 'desc' },
  1034. });
  1035. let task: any;
  1036. if (existingFailed) {
  1037. // 复用已有 failed 任务,重置为 pending,但累加 retryCount(保留历史重试记录)
  1038. const prevRetryCount = existingFailed.retryCount || 0;
  1039. // 关键:更新 voiceId 为当前有效的音色,避免旧任务用已禁用的 MiniMax 音色导致循环失败
  1040. const taskVoiceId = voiceId || 'longyingling_v3';
  1041. task = await prisma.ttsTask.update({
  1042. where: { id: existingFailed.id },
  1043. data: {
  1044. status: 'pending',
  1045. content: chapterBefore.content,
  1046. contentHash,
  1047. voiceId: taskVoiceId,
  1048. retryCount: prevRetryCount, // 保留历史重试次数,不重置为0
  1049. errorMsg: null,
  1050. startedAt: null,
  1051. completedAt: null,
  1052. },
  1053. });
  1054. console.log(`[Audio] 复用已有任务#${existingFailed.id}: chapterId=${chapterId}, voiceId=${taskVoiceId}, 累计重试=${prevRetryCount}次`);
  1055. } else {
  1056. console.log(`[Audio] 创建TTS任务: chapterId=${chapterId}, 内容长度=${chapterBefore.content.length}, hash=${contentHash.substring(0, 12)}...`);
  1057. task = await prisma.ttsTask.create({
  1058. data: {
  1059. taskType: 'tts',
  1060. chapterId,
  1061. bookId: chapterBefore.bookId,
  1062. userId: userId || chapterBefore.book?.userId || null,
  1063. contentHash,
  1064. content: chapterBefore.content, // 保存提交时的内容副本
  1065. voiceId: voiceId || 'longyingling_v3', // 支持外部传入音色,默认兼容有声书
  1066. status: 'pending',
  1067. },
  1068. });
  1069. console.log(`[Audio] TTS任务 #${task.id} 已创建,等待队列处理器处理`);
  1070. }
  1071. // 返回空 audioUrl,前端通过 genStage 或 WebSocket 获取进度
  1072. return { audioUrl: '' };
  1073. }
  1074. /**
  1075. * [DEPRECATED] 创建章节内容生成任务(数据库队列 + 内容哈希去重)
  1076. *
  1077. * ⚠️ 当前无 content 队列处理器(tts-queue.ts仅创建了tts队列)。
  1078. * 内容生成已改为 LangGraph 节点直接调用 LLM,不再使用数据库队列。
  1079. * 此方法保留仅用于向后兼容,请勿调用。
  1080. *
  1081. * 与 TTS 队列共用 TtsTask 表,通过 taskType='content' 区分。
  1082. * hash 基于章节关键属性(标题、父级、预估字数等),
  1083. * 相同参数不重复提交 LLM 请求。
  1084. *
  1085. * @returns taskId 或 null(如果已有进行中/已完成任务)
  1086. */
  1087. async enqueueContentGeneration(chapterId: number, bookId?: number): Promise<number | null> {
  1088. const chapter = await prisma.bookChapter.findUnique({
  1089. where: { id: chapterId },
  1090. include: { book: true },
  1091. });
  1092. if (!chapter) {
  1093. console.warn(`[Content] 章节不存在: ${chapterId}`);
  1094. return null;
  1095. }
  1096. // ===== 拼接关键属性计算哈希 =====
  1097. const attrString = [
  1098. chapter.bookId,
  1099. chapter.title,
  1100. chapter.parentId,
  1101. chapter.level,
  1102. chapter.estimatedWords,
  1103. ].join('|');
  1104. const contentHash = computeContentHash(attrString);
  1105. // 去重:已完成的内容任务
  1106. const existingCompleted = await prisma.ttsTask.findFirst({
  1107. where: { chapterId, taskType: 'content', contentHash, status: 'completed' },
  1108. orderBy: { completedAt: 'desc' },
  1109. });
  1110. if (existingCompleted) {
  1111. console.log(`[Content] 章节${chapterId}相同参数已有完成记录(hash=${contentHash.substring(0, 8)}...),跳过`);
  1112. return null;
  1113. }
  1114. // 去重:进行中的任务
  1115. const inProgressTask = await prisma.ttsTask.findFirst({
  1116. where: { chapterId, taskType: 'content', status: { in: ['pending', 'processing'] } },
  1117. });
  1118. if (inProgressTask) {
  1119. console.log(`[Content] 章节${chapterId}已有进行中的内容任务#${inProgressTask.id},不重复创建`);
  1120. return inProgressTask.id;
  1121. }
  1122. // 已有内容且 genStage 正常 → 跳过
  1123. if (chapter.content && ['content_completed', 'audio_generating', 'audio_completed'].includes(chapter.genStage)) {
  1124. console.log(`[Content] 章节${chapterId}已有内容(genStage=${chapter.genStage}),跳过`);
  1125. return null;
  1126. }
  1127. // 推进状态
  1128. if (chapter.genStage !== 'content_generating') {
  1129. if (chapter.genStage !== 'idle' && chapter.genStage !== 'outline_completed') {
  1130. await regenerateChapter(chapterId, 'content_generating').catch(() => {});
  1131. } else {
  1132. await advanceChapter(chapterId, 'content_generating').catch(() => {});
  1133. }
  1134. }
  1135. const task = await prisma.ttsTask.create({
  1136. data: {
  1137. taskType: 'content',
  1138. chapterId,
  1139. bookId: bookId || chapter.bookId,
  1140. userId: chapter.book?.userId || null,
  1141. contentHash,
  1142. content: attrString, // 保存提交时的属性快照
  1143. status: 'pending',
  1144. },
  1145. });
  1146. console.log(`[Content] 内容任务 #${task.id} 已创建, chapterId=${chapterId}, hash=${contentHash.substring(0, 12)}...`);
  1147. return task.id;
  1148. }
  1149. /**
  1150. * 处理内容生成队列任务(由 ContentQueue 调用)
  1151. *
  1152. * [DEPRECATED] 实际的 LLM 文本生成逻辑,平铺循环重试(最多 3 次),无递归。
  1153. *
  1154. * ⚠️ 无 content 队列处理器调用此方法。内容生成由 LangGraph 节点直接完成。
  1155. */
  1156. async processContentTask(taskId: number): Promise<void> {
  1157. const task = await prisma.ttsTask.findUnique({ where: { id: taskId } });
  1158. if (!task || task.status !== 'processing' || task.taskType !== 'content') {
  1159. console.warn(`[ContentTask] 任务#${taskId}状态异常,跳过`);
  1160. return;
  1161. }
  1162. const chapterId = task.chapterId;
  1163. console.log(`[ContentTask] 开始处理任务#${taskId}, chapterId=${chapterId}`);
  1164. const MAX_RETRIES = 3;
  1165. for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
  1166. try {
  1167. console.log(`[ContentTask] #${taskId} 第${attempt + 1}次尝试生成内容...`);
  1168. const chapter = await prisma.bookChapter.findUnique({
  1169. where: { id: chapterId },
  1170. include: { book: true },
  1171. });
  1172. if (!chapter) {
  1173. await this._failTask(taskId, `章节${chapterId}不存在`);
  1174. return;
  1175. }
  1176. const bookId = String(chapter.bookId);
  1177. // 委托给现有的生成方法(它已经包含完整的 LLM 调用逻辑)
  1178. await this.generateSingleChapterContent(bookId, chapterId);
  1179. // 验证生成结果
  1180. const updated = await prisma.bookChapter.findUnique({ where: { id: chapterId } });
  1181. if (updated?.content && updated.genStage === 'content_completed') {
  1182. await prisma.ttsTask.update({
  1183. where: { id: taskId },
  1184. data: {
  1185. status: 'completed',
  1186. completedAt: new Date(),
  1187. retryCount: attempt,
  1188. },
  1189. });
  1190. console.log(`[ContentTask] ✅ 任务#${taskId} 完成`);
  1191. await this.tryCleanupBookTasks(chapterId);
  1192. return;
  1193. }
  1194. // 内容为空或状态不对 → 视为失败
  1195. throw new Error(updated?.content ? `状态异常: ${updated.genStage}` : '生成内容为空');
  1196. } catch (err: any) {
  1197. const errorMsg = err?.message || String(err);
  1198. console.error(`[ContentTask] ❌ 任务#${taskId} 第${attempt + 1}次失败:`, errorMsg);
  1199. if (attempt < MAX_RETRIES) {
  1200. const delay = Math.min(3000 * Math.pow(2, attempt) + Math.random() * 2000, 60000);
  1201. console.log(`[ContentTask] #${taskId} ${(delay / 1000).toFixed(1)}s 后重试...`);
  1202. await prisma.ttsTask.update({
  1203. where: { id: taskId },
  1204. data: { retryCount: attempt + 1, errorMsg: errorMsg.substring(0, 500) },
  1205. });
  1206. await new Promise(resolve => setTimeout(resolve, delay));
  1207. continue;
  1208. }
  1209. await this._failTask(taskId, errorMsg);
  1210. return;
  1211. }
  1212. }
  1213. await this._failTask(taskId, '重试耗尽');
  1214. }
  1215. /**
  1216. * 等待内容生成队列任务完成(调用方轮询,最多等待 30 分钟)
  1217. *
  1218. * @returns 生成后的章节内容
  1219. */
  1220. async awaitContentTask(taskId: number, timeoutMs: number = 30 * 60 * 1000): Promise<{
  1221. content: string;
  1222. wordCount: number;
  1223. genStage: string;
  1224. }> {
  1225. const task = await prisma.ttsTask.findUnique({ where: { id: taskId } });
  1226. if (!task) throw new Error(`任务#${taskId}不存在`);
  1227. if (task.taskType !== 'content') throw new Error(`任务#${taskId}不是内容生成类型`);
  1228. const start = Date.now();
  1229. let lastLogTime = 0;
  1230. while (Date.now() - start < timeoutMs) {
  1231. const chapter = await prisma.bookChapter.findUnique({
  1232. where: { id: task.chapterId },
  1233. select: { content: true, wordCount: true, genStage: true, contentError: true },
  1234. });
  1235. if (!chapter) throw new Error(`章节${task.chapterId}不存在`);
  1236. // 完成
  1237. if (chapter.genStage === 'content_completed' && chapter.content) {
  1238. console.log(`[ContentTask] 任务#${taskId} 轮询完成 (${((Date.now() - start) / 1000).toFixed(0)}s)`);
  1239. return { content: chapter.content, wordCount: chapter.wordCount, genStage: 'content_completed' };
  1240. }
  1241. // 失败
  1242. if (chapter.genStage === 'failed') {
  1243. const updated = await prisma.ttsTask.findUnique({ where: { id: taskId }, select: { errorMsg: true } });
  1244. throw new Error(updated?.errorMsg || chapter.contentError || '内容生成失败');
  1245. }
  1246. // 进度日志(每 30 秒)
  1247. const now = Date.now();
  1248. if (now - lastLogTime > 30000) {
  1249. lastLogTime = now;
  1250. const elapsed = ((now - start) / 1000).toFixed(0);
  1251. console.log(`[ContentTask] 任务#${taskId} 等待中... genStage=${chapter.genStage}, 已等${elapsed}s`);
  1252. }
  1253. await new Promise(resolve => setTimeout(resolve, 3000));
  1254. }
  1255. throw new Error(`内容生成超时 (${timeoutMs / 1000}s)`);
  1256. }
  1257. /**
  1258. * 处理 TTS 队列任务(由 tts-queue.ts 调用)
  1259. *
  1260. * 只尝试 1 次。Provider 层会自动遍历所有可用 Provider。
  1261. * 失败后标记 failed,由用户手动重新生成。
  1262. */
  1263. async processTtsTask(taskId: number): Promise<void> {
  1264. const task = await prisma.ttsTask.findUnique({ where: { id: taskId } });
  1265. if (!task || task.status !== 'processing') {
  1266. console.warn(`[TtsTask] 任务#${taskId}状态异常(status=${task?.status}),跳过`);
  1267. return;
  1268. }
  1269. const chapterId = task.chapterId;
  1270. const content = task.content;
  1271. if (!content) {
  1272. await this._failTask(taskId, '任务内容为空');
  1273. return;
  1274. }
  1275. // 最小字数校验:过短文本 TTS API 会报 InvalidParameter,触发熔断
  1276. if (content.trim().length < 10) {
  1277. await this._failTask(taskId, '文本过短(少于10字),无法生成音频');
  1278. return;
  1279. }
  1280. console.log(`[TtsTask] 开始处理任务#${taskId}, chapterId=${chapterId}, 内容长度=${content.length}`);
  1281. try {
  1282. const chapter = await prisma.bookChapter.findUnique({
  1283. where: { id: chapterId },
  1284. include: { book: true },
  1285. });
  1286. if (!chapter) {
  1287. await this._failTask(taskId, `章节${chapterId}不存在`);
  1288. return;
  1289. }
  1290. const userId = chapter.book?.userId || task.userId || 1;
  1291. const bookId = chapter.bookId;
  1292. // 设置上下文,后续 TTS 调用自动携带 userId/bookId/chapterId
  1293. await runWithContext({ userId, bookId, chapterId }, async () => {
  1294. const result = await generateAudio(
  1295. String(userId),
  1296. content,
  1297. task.voiceId || 'longyingling_v3',
  1298. { speed: 1.0, pitch: 0, volume: 50 },
  1299. async (audioUrl: string, duration: number) => {
  1300. console.log(`[TtsTask] #${taskId} 音频就绪: ${audioUrl?.substring(0, 60)}...`);
  1301. try {
  1302. await advanceChapter(chapterId, 'audio_completed');
  1303. try {
  1304. const userId = chapter.book?.userId || task.userId || 1;
  1305. const audioMinutes = Math.ceil(duration / 60);
  1306. await consumeAudioMinutes(userId, audioMinutes, `书籍「${chapter.book?.title || '未知'}」- ${chapter.title} (${duration}s)`);
  1307. } catch (quotaErr: any) {
  1308. console.warn(`[Quota] 消耗音频配额失败:`, quotaErr.message);
  1309. }
  1310. tryAutoMerge(chapterId, chapter.bookId, chapter.level, chapter.parentId);
  1311. } catch (advanceErr: any) {
  1312. console.warn(`[TtsTask] #${taskId} genStage推进失败: ${advanceErr.message},音频已就绪`);
  1313. }
  1314. },
  1315. {
  1316. bookId: chapter.bookId ? String(chapter.bookId) : undefined,
  1317. chapterId: chapter.id,
  1318. chapterTitle: chapter.title,
  1319. }
  1320. );
  1321. await prisma.ttsTask.update({
  1322. where: { id: taskId },
  1323. data: { status: 'completed', audioUrl: result.audioUrl, completedAt: new Date() },
  1324. });
  1325. console.log(`[TtsTask] ✅ 任务#${taskId} 完成 bookId=${bookId} chapterId=${chapterId} chars=${content?.length || 0}`);
  1326. await this.tryCleanupBookTasks(chapterId);
  1327. }); // runWithContext 结束
  1328. } catch (err: any) {
  1329. const errorMsg = err?.message || String(err);
  1330. console.error(`[TtsTask] ❌ 任务#${taskId} 失败 bookId=${task.bookId} chapterId=${chapterId}:`, errorMsg);
  1331. await this._failTask(taskId, errorMsg);
  1332. // 不可恢复错误(参数错误等)→ 标记章节为 failed,阻止 AudioScanner 反复重试
  1333. const lowerMsg = errorMsg.toLowerCase();
  1334. if (lowerMsg.includes('invalidparameter') || lowerMsg.includes('invalid_parameter')) {
  1335. try {
  1336. await prisma.bookChapter.update({
  1337. where: { id: chapterId },
  1338. data: { genStage: 'failed', contentError: errorMsg.substring(0, 500) },
  1339. });
  1340. console.log(`[TtsTask] 🔒 章节${chapterId}标记为failed(不可恢复错误),不再自动重试`);
  1341. } catch (chapterErr) {
  1342. console.warn(`[TtsTask] 标记章节失败状态失败:`, chapterErr);
  1343. }
  1344. }
  1345. }
  1346. }
  1347. /**
  1348. * 标记任务失败(不回退章节状态,由用户手动重新生成)
  1349. */
  1350. private async _failTask(taskId: number, errorMsg: string): Promise<void> {
  1351. console.error(`[TtsTask] ❌ 任务#${taskId} 最终失败:`, errorMsg);
  1352. await prisma.ttsTask.update({
  1353. where: { id: taskId },
  1354. data: {
  1355. status: 'failed',
  1356. errorMsg: errorMsg.substring(0, 1000),
  1357. completedAt: new Date(),
  1358. },
  1359. });
  1360. }
  1361. /**
  1362. * 任务成功后尝试清理整本书的任务记录
  1363. * 当一本书所有章节都已生成完毕(>= audio_completed),清空该书的所有队列任务
  1364. */
  1365. private async tryCleanupBookTasks(chapterId: number): Promise<void> {
  1366. try {
  1367. const chapter = await prisma.bookChapter.findUnique({
  1368. where: { id: chapterId },
  1369. select: { bookId: true },
  1370. });
  1371. if (!chapter?.bookId) return;
  1372. // 检查该书的章节是否全部完成
  1373. const allChapters = await prisma.bookChapter.findMany({
  1374. where: { bookId: chapter.bookId },
  1375. select: { genStage: true },
  1376. });
  1377. if (allChapters.length === 0) return;
  1378. const doneStages = ['audio_completed', 'video_generating', 'video_completed'];
  1379. const allDone = allChapters.every(c => doneStages.includes(c.genStage));
  1380. if (!allDone) return;
  1381. // 全书完成,清理任务记录
  1382. const result = await prisma.ttsTask.deleteMany({
  1383. where: { bookId: chapter.bookId },
  1384. });
  1385. if (result.count > 0) {
  1386. console.log(`[Cleanup] 书籍#${chapter.bookId}已完成,清理了 ${result.count} 条队列任务`);
  1387. }
  1388. } catch (err: any) {
  1389. console.warn(`[Cleanup] 清理任务失败:`, err.message);
  1390. }
  1391. }
  1392. /**
  1393. * 重新生成单个章节内容(仅叶节点)
  1394. * 直接生成,不触发整个书籍生成流程
  1395. */
  1396. async generateSingleChapterContent(bookId: string, chapterId: number): Promise<void> {
  1397. const book = await this.getById(bookId);
  1398. if (!book) {
  1399. console.error(`[generateSingleChapterContent] 书籍不存在: ${bookId}`);
  1400. return;
  1401. }
  1402. const chapter = await prisma.bookChapter.findUnique({
  1403. where: { id: chapterId },
  1404. });
  1405. if (!chapter) {
  1406. console.error(`[generateSingleChapterContent] 章节不存在: ${chapterId}`);
  1407. return;
  1408. }
  1409. // 构建父节点映射
  1410. const chaptersAndSections = await prisma.bookChapter.findMany({
  1411. where: { bookId: parseInt(bookId), level: { in: [1, 2] } }
  1412. });
  1413. const chapterMap = new Map<number, any>();
  1414. const sectionMap = new Map<number, any>();
  1415. chaptersAndSections.forEach(c => {
  1416. if (c.level === 1) chapterMap.set(c.id, c);
  1417. if (c.level === 2) sectionMap.set(c.id, c);
  1418. });
  1419. const parentSection = sectionMap.get(chapter.parentId || 0);
  1420. const parentChapter = parentSection ? chapterMap.get(parentSection.parentId || 0) : null;
  1421. const chapterTitle = parentChapter?.title || '未知章';
  1422. const sectionTitle = parentSection?.title || '未知节';
  1423. const chapterSummary = parentChapter?.summary || '';
  1424. const sectionSummary = parentSection?.summary || '';
  1425. // 判断是否是短文
  1426. const isShortArticle = !parentSection && chapter.level === 1;
  1427. // 从 description 中提取写作风格要求
  1428. const styleMatch = book.description?.match(/写作风格:([^\\n]+)/);
  1429. const writingStyle = styleMatch ? styleMatch[1] : (book.style || '');
  1430. let messages: ChatMessage[];
  1431. if (isShortArticle) {
  1432. messages = [
  1433. { role: 'system', content: SUBSECTION_CONTENT_SYSTEM_PROMPT },
  1434. {
  1435. role: 'user',
  1436. content: `书名:《${book.title}》
  1437. ${book.description || ''}
  1438. ${writingStyle ? `写作风格:${writingStyle}` : ''}
  1439. 章标题:${chapter.title}
  1440. 章概述:${chapter.summary || ''}
  1441. 预估字数:${chapter.estimatedWords || 500}字
  1442. 请撰写该章节的正文内容。`,
  1443. },
  1444. ];
  1445. } else {
  1446. messages = buildSubsectionContentMessages(
  1447. book.title,
  1448. book.description || '',
  1449. chapterTitle,
  1450. chapterSummary,
  1451. sectionTitle,
  1452. sectionSummary,
  1453. chapter
  1454. );
  1455. }
  1456. const bookTools = createBookTools(bookId, this);
  1457. try {
  1458. let content: string;
  1459. try {
  1460. const result = await callLLMWithTools(messages, bookTools);
  1461. content = cleanThinkingText(result.text);
  1462. } catch {
  1463. content = cleanThinkingText(await callLLMWithMessages(messages));
  1464. }
  1465. const wordCount = countWords(content);
  1466. await this.updateChapterById(chapterId, {
  1467. content,
  1468. wordCount,
  1469. });
  1470. // 推进到 content_completed(如已越过则跳过,不抛异常)
  1471. const currentChapter = await prisma.bookChapter.findUnique({ where: { id: chapterId } });
  1472. if (currentChapter) {
  1473. const order = ['idle', 'outline_completed', 'content_generating', 'content_completed', 'audio_generating', 'audio_completed', 'video_generating', 'video_completed'];
  1474. const curIdx = order.indexOf(currentChapter.genStage);
  1475. const tgtIdx = order.indexOf('content_completed');
  1476. // 只有当前在 content_completed 之前才推进(避免竞态:音频已推进到 audio_generating)
  1477. if (curIdx >= 0 && curIdx < tgtIdx) {
  1478. try {
  1479. // 状态机要求:outline_completed → content_generating → content_completed
  1480. if (currentChapter.genStage !== 'content_generating') {
  1481. await advanceChapter(chapterId, 'content_generating').catch(() => {});
  1482. }
  1483. await advanceChapter(chapterId, 'content_completed');
  1484. } catch (advanceErr: any) {
  1485. console.warn(`[generateSingleChapterContent] genStage推进失败 chapterId=${chapterId}: ${advanceErr.message}, 可能已被其他流程推进`);
  1486. }
  1487. }
  1488. }
  1489. console.log(`✅ 章节「${chapter.title}」内容重新生成完成,字数: ${wordCount}`);
  1490. } catch (error) {
  1491. const errorMsg = error instanceof Error ? error.message : '失败';
  1492. await this.updateChapterById(chapterId, {
  1493. contentError: errorMsg,
  1494. });
  1495. // 仅当LLM调用失败时才回退到 content_generating
  1496. console.error(`❌ 章节${chapterId}内容重新生成失败:`, errorMsg);
  1497. }
  1498. }
  1499. /**
  1500. * 转换数据库模型到 Book 类型
  1501. * @param excludeContent 是否排除章节内容(用于列表/详情页,只返回标题不返回正文)
  1502. */
  1503. private toBook(dbBook: {
  1504. id: number;
  1505. userId: number | null;
  1506. title: string;
  1507. subtitle: string | null;
  1508. description: string;
  1509. targetAudience: string;
  1510. style: string;
  1511. bookScale: string;
  1512. totalChapters: number;
  1513. estimatedWords: number;
  1514. progress: number;
  1515. isPublished: boolean;
  1516. genStage?: string;
  1517. failedStage?: string;
  1518. outlineJson: string | null;
  1519. foreword: string | null;
  1520. afterword: string | null;
  1521. errorMsg: string | null;
  1522. bookAnalysis?: string | null;
  1523. createdAt: Date;
  1524. updatedAt: Date;
  1525. chapters: any[];
  1526. }, excludeContent: boolean = false): Book {
  1527. const outline = parseOutlineJson(dbBook.outlineJson);
  1528. return {
  1529. id: String(dbBook.id),
  1530. userId: dbBook.userId || undefined,
  1531. title: dbBook.title,
  1532. subtitle: dbBook.subtitle || undefined,
  1533. description: dbBook.description,
  1534. targetAudience: dbBook.targetAudience,
  1535. style: dbBook.style,
  1536. bookScale: dbBook.bookScale,
  1537. totalChapters: dbBook.totalChapters,
  1538. estimatedWords: dbBook.estimatedWords,
  1539. progress: dbBook.progress,
  1540. isPublished: dbBook.isPublished,
  1541. genStage: computeBookGenStage(dbBook.chapters),
  1542. failedStage: dbBook.failedStage || undefined,
  1543. chapters: chaptersFromDb(dbBook.chapters, dbBook.id, excludeContent),
  1544. outline: outline || undefined,
  1545. metadata: {
  1546. foreword: dbBook.foreword || undefined,
  1547. afterword: dbBook.afterword || undefined,
  1548. },
  1549. bookAnalysis: dbBook.bookAnalysis || undefined,
  1550. error: dbBook.errorMsg || undefined,
  1551. createdAt: dbBook.createdAt,
  1552. updatedAt: dbBook.updatedAt,
  1553. };
  1554. }
  1555. }
  1556. // 导出单例
  1557. export const bookStore = new BookStore();