| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731 |
- /**
- * 书籍生成模块 - Prisma 数据库存储
- */
- import crypto from 'crypto';
- import { prisma } from '../../models';
- import { Book, BookOutline, Chapter, ChapterGenStage, BookGenStage } from './book-generator.types';
- import { Prisma } from '@prisma/client';
- import { generateAudio } from '../tts/tts.service';
- import { callLLMWithMessages, callLLMWithTools, ChatMessage } from '../../services/llm';
- import { createBookTools } from '../../services/llm/book-tools';
- import { SUBSECTION_CONTENT_SYSTEM_PROMPT } from './prompts/templates';
- import { countWords } from './utils';
- import { cleanThinkingText } from './utils/content-cleaner';
- import { advanceChapter, regenerateChapter, safeTransitionChapter } from './stage-manager';
- import { mergeChapterAudios } from '../player/player.service';
- import { consumeAudioMinutes, canUseTtsProvider } from '../subscription/subscription.service';
- import { runWithContext } from '../../services/llm-context';
- /**
- * 取消书籍所有章节的音频生成(将 pending/processing 任务标记为 cancelled,回退章节阶段)
- */
- export async function cancelAudioGeneration(bookId: string): Promise<{ cancelledCount: number; rolledBackChapters: number[] }> {
- const chapters = await prisma.bookChapter.findMany({
- where: { bookId: Number(bookId) },
- select: { id: true, level: true },
- });
- if (chapters.length === 0) {
- return { cancelledCount: 0, rolledBackChapters: [] };
- }
- const maxLevel = Math.max(...chapters.map(c => c.level || 0));
- const leafChapterIds = chapters.filter(c => c.level === maxLevel).map(c => c.id);
- // 找出所有 pending/processing 状态的 TTS 任务
- const activeTasks = await prisma.ttsTask.findMany({
- where: {
- chapterId: { in: leafChapterIds },
- taskType: 'tts',
- status: { in: ['pending', 'processing'] },
- },
- select: { id: true, chapterId: true },
- });
- if (activeTasks.length === 0) {
- return { cancelledCount: 0, rolledBackChapters: [] };
- }
- const taskIds = activeTasks.map(t => t.id);
- const affectedChapterIds = [...new Set(activeTasks.map(t => t.chapterId))];
- // 批量标记任务为 cancelled
- await prisma.ttsTask.updateMany({
- where: { id: { in: taskIds } },
- data: { status: 'cancelled' },
- });
- // 回退受影响章节的 genStage 到 content_completed
- await prisma.bookChapter.updateMany({
- where: { id: { in: affectedChapterIds }, genStage: 'audio_generating' },
- data: { genStage: 'content_completed' },
- });
- return {
- cancelledCount: taskIds.length,
- rolledBackChapters: affectedChapterIds,
- };
- }
- // ============ 删除书籍 ============
- /**
- * 根据所有章节状态计算书籍阶段
- * 书籍阶段 = 所有章节中最低的阶段(最落后的章节决定了书籍的进度)
- */
- function computeBookGenStage(chapters: { genStage: string }[]): BookGenStage {
- if (chapters.length === 0) return 'draft';
- // 章节阶段顺序(索引越大越"后")
- const stageOrder = ['idle', 'outline_completed', 'content_generating', 'content_completed', 'audio_generating', 'audio_completed', 'video_generating', 'video_completed', 'failed'];
- // 找出最低阶段的索引
- let minIdx = stageOrder.length; // 默认最大
- for (const ch of chapters) {
- const idx = stageOrder.indexOf(ch.genStage);
- if (idx === -1) {
- console.warn(`[BookStore] 未知章节阶段: chapterId=${(ch as any).id}, genStage="${ch.genStage}",跳过该章节`);
- continue;
- }
- if (idx < minIdx) {
- minIdx = idx;
- }
- }
- // 全部未知 → 回退到 draft
- if (minIdx >= stageOrder.length) {
- console.warn('[BookStore] 所有章节阶段未知,回退到 draft');
- return 'draft';
- }
- // 最低阶段索引对应的阶段
- const minStage = stageOrder[minIdx];
- // 映射到书籍阶段
- // idle → outlining(待生成大纲)
- // outline_completed → outline_completed(大纲已完成,待生成内容)
- // content_generating → content_generating(正在生成内容)
- // content_completed → content_completed
- // audio_generating → audio_generating
- // audio_completed → audio_completed
- // video_generating → video_generating
- // video_completed → video_completed
- // failed → failed
- const stageMap: Record<string, BookGenStage> = {
- 'idle': 'outlining',
- 'outline_completed': 'outline_completed',
- 'content_generating': 'content_generating',
- 'content_completed': 'content_completed',
- 'audio_generating': 'audio_generating',
- 'audio_completed': 'audio_completed',
- 'video_generating': 'video_generating',
- 'video_completed': 'video_completed',
- 'failed': 'failed',
- };
- return stageMap[minStage] || 'draft';
- }
- /**
- * 计算内容 SHA256 哈希(用于 TTS 去重)
- * 相同内容 → 相同哈希 → 不重复生成音频
- */
- function computeContentHash(content: string): string {
- return crypto.createHash('sha256').update(content.trim()).digest('hex');
- }
- /**
- * 自动检测:当某个叶节点音频完成后,检查其所属1级章节下所有叶节点
- * 是否都已就绪,若是则自动触发音频合并到该章节。
- */
- export async function tryAutoMerge(
- completedLeafId: number,
- bookId: number | null,
- leafLevel: number | null,
- leafParentId: number | null,
- ) {
- if (!bookId || leafLevel == null || leafLevel <= 1) return;
- try {
- // 1. 找到该叶节点所属的1级章节
- let chapterId: number | null = null;
- if (leafLevel === 2) {
- chapterId = leafParentId;
- } else if (leafLevel === 3 && leafParentId != null) {
- const parentSection = await prisma.bookChapter.findUnique({
- where: { id: leafParentId },
- select: { parentId: true, level: true },
- });
- if (parentSection && parentSection.level === 2) {
- chapterId = parentSection.parentId;
- }
- }
- if (!chapterId) return;
- // 2. 计算该书的最大层级
- const allChapters = await prisma.bookChapter.findMany({
- where: { bookId },
- select: { id: true, level: true, parentId: true, audioUrl: true, audioDuration: true },
- });
- if (allChapters.length === 0) {
- console.warn('[tryAutoMerge] 书籍无章节,跳过');
- return;
- }
- const maxLevel = Math.max(...allChapters.map(c => c.level));
- if (maxLevel <= 1) return;
- // 3. 收集属于该章节的所有叶节点
- // 构建 parentId → children 映射用于遍历子树
- const childrenMap = new Map<number, number[]>();
- const nodeMap = new Map<number, (typeof allChapters)[number]>();
- for (const ch of allChapters) {
- nodeMap.set(ch.id, ch);
- if (ch.parentId) {
- if (!childrenMap.has(ch.parentId)) childrenMap.set(ch.parentId, []);
- childrenMap.get(ch.parentId)!.push(ch.id);
- }
- }
- // BFS 收集章节下所有后代节点
- const descendantIds: number[] = [];
- const queue = [chapterId];
- while (queue.length > 0) {
- const current = queue.shift()!;
- const children = childrenMap.get(current) || [];
- for (const childId of children) {
- descendantIds.push(childId);
- queue.push(childId);
- }
- }
- // 父节点ID集合(用于判断是否是叶节点)
- const parentIdSet = new Set(allChapters.map(c => c.parentId).filter(Boolean));
- // 筛选叶节点:在章节后代中 且 不是任何节点的父节点
- const leafNodesUnderChapter = allChapters.filter(
- ch => descendantIds.includes(ch.id) && !parentIdSet.has(ch.id),
- );
- if (leafNodesUnderChapter.length === 0) return;
- // 4. 检查是否所有叶节点都有音频
- const allHaveAudio = leafNodesUnderChapter.every(
- ch => ch.audioUrl && ch.audioUrl !== '',
- );
- if (!allHaveAudio) {
- const missingCount = leafNodesUnderChapter.filter(
- ch => !ch.audioUrl || ch.audioUrl === '',
- ).length;
- console.log(
- `[AutoMerge] 章节${chapterId}: ${leafNodesUnderChapter.length - missingCount}/${leafNodesUnderChapter.length} 个叶节点音频就绪,等待剩余 ${missingCount} 个...`,
- );
- return;
- }
- // 5. 检查该章节是否已有合并音频(幂等)
- // 但如果父章节的合并音频时长与子节时长总和不匹配,需要重新合并
- const chapter = nodeMap.get(chapterId);
- if (chapter?.audioUrl && chapter.audioUrl.includes('_merged')) {
- // 检查合并音频时长是否与子节时长总和匹配
- const childDurations = leafNodesUnderChapter
- .filter(ch => ch.audioUrl)
- .map(ch => ch.audioDuration || 0);
- const totalChildDuration = childDurations.reduce((sum, d) => sum + d, 0);
- const parentDuration = chapter.audioDuration || 0;
- // 如果合并音频时长 < 子节总时长的 80%,说明合并不完整,需要重新合并
- if (parentDuration > 0 && totalChildDuration > 0 && parentDuration < totalChildDuration * 0.8) {
- console.log(`[AutoMerge] 章节${chapterId}合并音频时长(${parentDuration}s) < 子节总时长(${totalChildDuration}s)的80%,需要重新合并`);
- // 清除旧的合并音频
- await prisma.bookChapter.update({
- where: { id: chapterId },
- data: { audioUrl: '', audioDuration: 0 },
- });
- } else {
- console.log(`[AutoMerge] 章节${chapterId}已有合并音频,跳过`);
- return;
- }
- }
- // 6. 触发合并
- console.log(
- `[AutoMerge] 🎵 章节${chapterId}下所有${leafNodesUnderChapter.length}个叶节点音频已就绪,开始自动合并...`,
- );
- const mergedUrl = await mergeChapterAudios(chapterId);
- if (mergedUrl) {
- console.log(`[AutoMerge] ✅ 章节${chapterId}音频自动合并完成: ${mergedUrl}`);
- } else {
- console.warn(`[AutoMerge] ⚠️ 章节${chapterId}合并返回空结果`);
- }
- } catch (err) {
- console.error(`[AutoMerge] 自动合并检测失败:`, err);
- }
- }
- /**
- * 构建小节内容生成消息
- */
- function buildSubsectionContentMessages(
- topic: string,
- bookDescription: string,
- chapterTitle: string,
- chapterSummary: string,
- sectionTitle: string,
- sectionSummary: string,
- subsection: any,
- writingStyle?: string
- ): ChatMessage[] {
- const keyPoints = typeof subsection.keyPoints === 'string'
- ? JSON.parse(subsection.keyPoints)
- : (subsection.keyPoints || []);
- // 从 bookDescription 中提取写作风格
- const styleMatch = bookDescription.match(/写作风格:([^\\n]+)/);
- const style = writingStyle || styleMatch?.[1] || '';
- return [
- { role: 'system', content: SUBSECTION_CONTENT_SYSTEM_PROMPT },
- {
- role: 'user',
- content: `书名:《${topic}》
- ${bookDescription || ''}
- ${style ? `写作风格:${style}` : ''}
- 章标题:${chapterTitle}
- 章概述:${chapterSummary || ''}
- 节标题:${sectionTitle}
- 节概述:${sectionSummary}
- 小节标题:${subsection.title}
- 小节概述:${subsection.summary || ''}
- 核心知识点:${keyPoints.join('、')}
- 预估字数:${subsection.estimatedWords || 500}字
- 请撰写该小节的正文内容。`,
- },
- ];
- }
- // ============ 类型转换 ============
- function parseOutlineJson(jsonStr: string | null): BookOutline | null {
- if (!jsonStr) return null;
- try {
- return JSON.parse(jsonStr);
- } catch {
- return null;
- }
- }
- function chaptersFromDb(dbChapters: any[], bookId: number, excludeContent: boolean = false): Chapter[] {
- return dbChapters.map((c) => ({
- id: String(c.id),
- bookId: String(c.bookId),
- number: c.number,
- title: c.title,
- content: excludeContent ? '' : (c.content || ''),
- wordCount: c.wordCount,
- summary: c.summary || undefined,
- generatedAt: c.generatedAt || undefined,
- error: c.errorMsg || undefined,
- audioUrl: c.audioUrl || undefined,
- audioDuration: c.audioDuration || 0,
- videoUrl: c.videoUrl || undefined,
- videoDuration: c.videoDuration || undefined,
- isPublic: c.isPublic || false,
- level: c.level, // 层级:1=章, 2=节, 3=小节
- parentId: c.parentId, // 父节点ID(0表示章)
- genStage: c.genStage || undefined, // 线性阶段状态
- }));
- }
- function outlineChapterFromDb(dbChapter: any) {
- return {
- number: dbChapter.number,
- title: dbChapter.title,
- summary: dbChapter.summary || '',
- keyPoints: dbChapter.keyPoints ? JSON.parse(dbChapter.keyPoints) : [],
- estimatedWords: dbChapter.estimatedWords,
- };
- }
- // ============ 存储类 ============
- export class BookStore {
- /**
- * 安全解析JSON
- */
- private safeParseJson(jsonStr: string | null): any[] {
- if (!jsonStr) return [];
- try {
- return JSON.parse(jsonStr);
- } catch {
- return [];
- }
- }
- /**
- * 从数据库章节记录构建树形结构的outline
- */
- private buildOutlineFromChapters(chapters: any[]): BookOutline | null {
- if (!chapters || chapters.length === 0) return null;
- // 获取所有level=1的章
- const level1Chapters = chapters.filter(c => c.level === 1);
- if (level1Chapters.length === 0) return null;
- // 构建映射表
- const chapterMap = new Map<number, any>();
- const sectionMap = new Map<number, any>();
- chapters.forEach(c => {
- if (c.level === 1) {
- chapterMap.set(c.id, {
- number: c.number,
- title: c.title,
- summary: c.summary || '',
- keyPoints: this.safeParseJson(c.keyPoints),
- estimatedWords: c.estimatedWords,
- sections: []
- });
- } else if (c.level === 2) {
- sectionMap.set(c.id, {
- number: c.number,
- title: c.title,
- summary: c.summary || '',
- keyPoints: this.safeParseJson(c.keyPoints),
- estimatedWords: c.estimatedWords,
- subsections: []
- });
- }
- });
- // 构建节和小节的关系
- chapters.forEach(c => {
- if (c.level === 2 && c.parentId) {
- const chapter = chapterMap.get(c.parentId);
- const section = sectionMap.get(c.id);
- if (chapter && section) {
- chapter.sections.push(section);
- }
- } else if (c.level === 3 && c.parentId) {
- const section = sectionMap.get(c.parentId);
- if (section) {
- section.subsections.push({
- number: c.number,
- title: c.title,
- summary: c.summary || '',
- keyPoints: this.safeParseJson(c.keyPoints),
- estimatedWords: c.estimatedWords
- });
- }
- }
- });
- return {
- mainTheme: '',
- structureLogic: '',
- chapters: Array.from(chapterMap.values())
- };
- }
- /**
- * 创建书籍
- */
- async create(data: {
- userId?: number;
- title: string;
- subtitle?: string;
- description: string;
- targetAudience?: string;
- style?: string;
- bookScale?: string;
- totalChapters?: number;
- estimatedWords?: number;
- }): Promise<Book> {
- const book = await prisma.book.create({
- data: {
- userId: data.userId,
- title: data.title,
- subtitle: data.subtitle,
- description: data.description,
- targetAudience: data.targetAudience || '通用',
- style: data.style || '专业严谨',
- bookScale: data.bookScale || '1000',
- totalChapters: data.totalChapters ?? 10,
- estimatedWords: data.estimatedWords ?? 0,
- genStage: 'draft',
- progress: 0,
- isPublished: false, // 预发布:等书籍完成后再发布
- },
- include: { chapters: true },
- });
- return this.toBook(book);
- }
- /**
- * 获取书籍
- * @param id 书籍ID
- * @param filterPublic 是否过滤公开音频(默认false,返回所有)
- * @param userId 当前用户ID(用于判断是否所有者)
- */
- async getById(id: string, filterPublic: boolean = false, userId?: number): Promise<Book | null> {
- const book = await prisma.book.findUnique({
- where: { id: parseInt(id) },
- include: {
- chapters: {
- orderBy: [
- { level: 'asc' },
- { number: 'asc' }
- ]
- }
- },
- });
-
- if (!book) return null;
-
- // 构建树形结构的outline(从数据库章节记录构建)
- let outline: BookOutline | null = null;
- try {
- outline = this.buildOutlineFromChapters(book.chapters);
- } catch (error) {
- console.error('[BookStore] buildOutlineFromChapters 失败:', error);
- }
-
- let result = this.toBook(book, false); // 需要返回章节content
-
- console.log('[BookStore.getById] bookScale:', result.bookScale);
- console.log('[BookStore.getById] result keys:', Object.keys(result));
-
- // 用数据库构建的outline替换outlineJson解析的
- if (outline) {
- result.outline = outline;
- }
-
- // 返回所有章节(level=1,2,3),前端需要完整数据来构建三级树形结构
- // outline中已包含完整的树形结构(章→节→小节)
- // result.chapters = result.chapters.filter((c) => c.level === 1); // 旧代码:只返回章
-
- // 如果需要过滤公开音频
- if (filterPublic && userId) {
- const isOwner = book.userId === userId;
- result.chapters = result.chapters.filter((c) => {
- // 所有者可以看到所有音频
- if (isOwner) return true;
- // 非所有者只能看到公开的音频
- return c.audioUrl && c.isPublic === true;
- });
- }
-
- return result;
- }
- /**
- * 按 bookId + number + level 查找章节记录
- */
- async findChapter(bookId: number, number: number, level: number): Promise<{ id: number; parentId: number | null; title: string } | null> {
- const chapter = await prisma.bookChapter.findFirst({
- where: { bookId, number, level },
- });
- return chapter;
- }
- /**
- * 获取用户的所有书籍
- * @param userId 当前用户ID
- * @param includePublic 是否包含公开书籍(用于首页显示)
- */
- async getAllByUser(userId?: number, includePublic: boolean = true): Promise<Book[]> {
- let books;
-
- if (userId) {
- // 查询指定用户的书籍(包括 userId 为该用户或为 null 的书籍)
- // userId 为 null 表示"游客"创建的书籍,也返回给当前用户查看
- books = await prisma.book.findMany({
- where: {
- OR: [
- { userId }, // 自己创建的书籍
- { userId: null }, // 游客创建的书籍(兼容旧数据)
- ]
- },
- include: { chapters: true },
- orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
- });
- } else {
- // 未登录用户或不需要用户过滤
- books = await prisma.book.findMany({
- where: includePublic ? {} : undefined,
- include: { chapters: true },
- orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
- });
- }
-
- return books.map((b) => this.toBook(b));
- }
-
- /**
- * 获取公开的书籍列表(用于首页展示)
- * 只返回 isPublished=true 的书籍,且只返回有音频的章节
- */
- async getPublicBooks(): Promise<Book[]> {
- const books = await prisma.book.findMany({
- where: {
- isPublished: true,
- chapters: {
- some: {
- audioUrl: { not: '' },
- }
- }
- },
- include: { chapters: {
- where: { audioUrl: { not: '' } }, // 只返回有音频的章节
- orderBy: { number: 'asc' }
- }},
- orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
- });
- return books.map((b) => this.toBook(b));
- }
- /**
- * 更新书籍
- */
- async update(id: string, data: Partial<{
- genStage: string;
- failedStage: string;
- progress: number;
- outlineJson: string;
- outline: any;
- foreword: string;
- afterword: string;
- estimatedWords: number;
- errorMsg: string;
- totalChapters: number;
- bookAnalysis: string;
- title: string;
- bookScale: string;
- }>): Promise<Book | null> {
- // 如果传入 outline 对象,转换为 outlineJson 字符串
- const updateData: any = { ...data, updatedAt: new Date() };
- if (data.outline) {
- updateData.outlineJson = JSON.stringify(data.outline);
- delete updateData.outline;
- }
- const book = await prisma.book.update({
- where: { id: parseInt(id) },
- data: updateData,
- include: { chapters: { where: { level: 1 }, orderBy: { number: 'asc' } } },
- });
- return this.toBook(book);
- }
- /**
- * 删除书籍
- */
- async delete(id: string): Promise<boolean> {
- try {
- await prisma.book.delete({ where: { id: parseInt(id) } });
- return true;
- } catch {
- return false;
- }
- }
- /**
- * 删除书籍的所有章节(全量重新生成前清理旧数据)
- */
- async deleteAllChapters(bookId: string): Promise<number> {
- const bookIdNum = parseInt(bookId);
- // 先删子节点再删章,避免外键依赖
- const { count: deletedPlaylistItems } = await prisma.playlistItem.deleteMany({
- where: { chapter: { bookId: bookIdNum } },
- });
- const { count: deletedTtsTasks } = await prisma.ttsTask.deleteMany({
- where: { chapter: { bookId: bookIdNum } },
- });
- const { count: deletedChapters } = await prisma.bookChapter.deleteMany({
- where: { bookId: bookIdNum },
- });
- console.log(`[Store] 清理书籍${bookId}旧数据: ${deletedChapters}章, ${deletedTtsTasks}个TTS任务, ${deletedPlaylistItems}个播放列表项`);
- return deletedChapters;
- }
- /**
- * 创建章节
- */
- async createChapter(data: {
- bookId: string;
- number: number;
- title: string;
- summary?: string;
- keyPoints?: string[];
- estimatedWords?: number;
- }): Promise<void> {
- await prisma.bookChapter.create({
- data: {
- bookId: parseInt(data.bookId),
- number: data.number,
- title: data.title,
- summary: data.summary,
- keyPoints: data.keyPoints ? JSON.stringify(data.keyPoints) : null,
- estimatedWords: data.estimatedWords || 5000,
- genStage: 'outline_completed',
- },
- });
- }
- /**
- * 批量创建章节(支持重新生成)
- * 使用 upsert 避免唯一约束冲突
- */
- async createChapters(bookId: string, chapters: Array<{
- number: number;
- title: string;
- summary?: string;
- keyPoints?: string[];
- estimatedWords?: number;
- }>): Promise<void> {
- const bookIdNum = parseInt(bookId);
- // 使用 upsert 避免重复创建(parentId=0表示章级别)
- for (const c of chapters) {
- const upserted = await prisma.bookChapter.upsert({
- where: {
- bookId_parentId_level_number: {
- bookId: bookIdNum,
- parentId: 0,
- level: 1,
- number: c.number,
- }
- },
- update: {
- title: c.title,
- summary: c.summary,
- keyPoints: c.keyPoints ? JSON.stringify(c.keyPoints) : null,
- estimatedWords: c.estimatedWords || 5000,
- genStage: 'outline_completed',
- } as any,
- create: {
- bookId: bookIdNum,
- parentId: 0,
- level: 1,
- number: c.number,
- title: c.title,
- summary: c.summary,
- keyPoints: c.keyPoints ? JSON.stringify(c.keyPoints) : null,
- estimatedWords: c.estimatedWords || 5000,
- genStage: 'outline_completed',
- } as any,
- });
- }
- }
- /**
- * 创建章节条目(支持任意层级:章/节/小节)
- * @param bookIdNum 书籍ID
- * @param item 节/小节数据
- * @param parentId 父节点ID(null表示章这一级)
- * @param level 层级:1=章, 2=节, 3=小节
- * @returns 创建的记录ID
- */
- async createChapterItem(bookIdNum: number, item: {
- number: number;
- title: string;
- summary?: string;
- keyPoints?: string[];
- estimatedWords?: number;
- }, parentId: number | null, level: number): Promise<number> {
- // 防御:LLM 可能返回 undefined/null 的字段
- let chapterNumber = item.number;
- if (chapterNumber == null || chapterNumber === undefined || isNaN(chapterNumber as number)) {
- const maxExisting = await prisma.bookChapter.findFirst({
- where: { bookId: bookIdNum, parentId: parentId ?? 0, level },
- orderBy: { number: 'desc' },
- select: { number: true },
- });
- chapterNumber = (maxExisting?.number ?? 0) + 1;
- console.warn(`[BookStore] chapter number missing/invalid, auto-assigned: ${chapterNumber} (bookId=${bookIdNum}, level=${level})`);
- }
- // 防御:title 也不能是 undefined
- const safeTitle = item.title || `章节${chapterNumber}`;
- if (!item.title) {
- console.warn(`[BookStore] chapter title missing, using fallback: ${safeTitle} (bookId=${bookIdNum})`);
- }
- // 使用 upsert 避免重复创建
- const upserted = await prisma.bookChapter.upsert({
- where: {
- bookId_parentId_level_number: {
- bookId: bookIdNum,
- parentId: parentId,
- level: level,
- number: chapterNumber,
- }
- },
- update: {
- title: safeTitle,
- summary: item.summary || '',
- keyPoints: item.keyPoints ? JSON.stringify(item.keyPoints) : null,
- estimatedWords: item.estimatedWords || 1000,
- genStage: 'outline_completed',
- } as any,
- create: {
- bookId: bookIdNum,
- parentId,
- level,
- number: chapterNumber,
- title: safeTitle,
- summary: item.summary || '',
- keyPoints: item.keyPoints ? JSON.stringify(item.keyPoints) : null,
- estimatedWords: item.estimatedWords || 1000,
- genStage: 'outline_completed',
- } as any,
- });
- return upserted.id;
- }
- /**
- * 批量创建章节条目
- */
- async createChapterItems(bookIdNum: number, items: Array<{
- number: number;
- title: string;
- summary?: string;
- keyPoints?: string[];
- estimatedWords?: number;
- }>, parentId: number | null, level: number): Promise<number[]> {
- const ids: number[] = [];
- for (const item of items) {
- const id = await this.createChapterItem(bookIdNum, item, parentId, level);
- ids.push(id);
- }
- return ids;
- }
- /**
- * 更新章节内容
- * @param level 可选层级过滤,避免同 number 不同 level 的章节串写
- */
- async updateChapter(bookId: string, chapterNumber: number, data: Partial<{
- content: string;
- wordCount: number;
- genStage: string;
- errorMsg: string;
- }>, level?: number): Promise<Chapter | null> {
- const where: any = {
- bookId: parseInt(bookId),
- number: chapterNumber,
- };
- if (level !== undefined) where.level = level;
- const chapter = await prisma.bookChapter.findFirst({ where });
- if (!chapter) return null;
- const updated = await prisma.bookChapter.update({
- where: { id: chapter.id },
- data: {
- ...data,
- generatedAt: data.content ? new Date() : undefined,
- },
- });
- return {
- id: String(updated.id),
- bookId: String(updated.bookId),
- number: updated.number,
- title: updated.title,
- content: updated.content || '',
- wordCount: updated.wordCount,
- genStage: updated.genStage as ChapterGenStage | undefined,
- summary: updated.summary || undefined,
- generatedAt: updated.generatedAt || undefined,
- error: (updated as any).errorMsg || undefined,
- };
- }
- /**
- * 按 ID 更新章节内容
- */
- async updateChapterById(id: number, data: Partial<{
- content: string;
- wordCount: number;
- genStage: string;
- errorMsg: string;
- contentError: string;
- audioUrl: string;
- audioDuration: number;
- lrcLyrics: string | null;
- videoUrl: string;
- videoDuration: number;
- }>): Promise<Chapter | null> {
- // 转换 errorMsg -> contentError (Prisma字段名)
- const prismaData: any = { ...data };
- if ('errorMsg' in prismaData) {
- prismaData.contentError = prismaData.errorMsg;
- delete prismaData.errorMsg;
- }
- const updated = await prisma.bookChapter.update({
- where: { id },
- data: {
- ...prismaData,
- generatedAt: data.content ? new Date() : undefined,
- },
- });
- return {
- id: String(updated.id),
- bookId: String(updated.bookId),
- number: updated.number,
- title: updated.title,
- content: updated.content || '',
- wordCount: updated.wordCount,
- genStage: updated.genStage as ChapterGenStage | undefined,
- summary: updated.summary || undefined,
- generatedAt: updated.generatedAt || undefined,
- error: updated.contentError || undefined,
- audioUrl: updated.audioUrl || undefined,
- audioDuration: updated.audioDuration || 0,
- videoUrl: updated.videoUrl || undefined,
- videoDuration: updated.videoDuration || undefined,
- };
- }
- /**
- * 获取书籍的章节
- */
- async getChapters(bookId: string): Promise<Chapter[]> {
- const chapters = await prisma.bookChapter.findMany({
- where: { bookId: parseInt(bookId) },
- orderBy: { number: 'asc' },
- });
- return chaptersFromDb(chapters, parseInt(bookId));
- }
- /**
- * 获取书籍的完整章节树(章→节→小节)
- * 按 level 和 number 排序
- */
- async getChapterTree(bookId: string): Promise<any[]> {
- const chapters = await prisma.bookChapter.findMany({
- where: { bookId: parseInt(bookId) },
- orderBy: [{ level: 'asc' }, { number: 'asc' }],
- });
- return chapters;
- }
- /**
- * 统计书籍完成章节数
- */
- async countCompletedChapters(bookId: string): Promise<number> {
- return prisma.bookChapter.count({
- where: {
- bookId: parseInt(bookId),
- genStage: 'video_completed',
- },
- });
- }
- /**
- * 发布书籍(将 isPublished 设为 true,同时公开所有章节)
- */
- async publishAlbum(bookId: string): Promise<void> {
- await prisma.$transaction([
- prisma.book.update({
- where: { id: parseInt(bookId) },
- data: { isPublished: true },
- }),
- // 公开所有有音频的章节
- prisma.bookChapter.updateMany({
- where: {
- bookId: parseInt(bookId),
- audioUrl: { not: '' },
- },
- data: { isPublic: true },
- }),
- ]);
- }
- /**
- * 取消发布书籍(将 isPublished 设为 false,同时取消公开所有章节)
- */
- async unpublishAlbum(bookId: string): Promise<void> {
- await prisma.$transaction([
- prisma.book.update({
- where: { id: parseInt(bookId) },
- data: { isPublished: false },
- }),
- // 取消公开所有章节
- prisma.bookChapter.updateMany({
- where: {
- bookId: parseInt(bookId),
- },
- data: { isPublic: false },
- }),
- ]);
- }
- /**
- * 切换书籍公开状态
- */
- async togglePublish(bookId: string): Promise<boolean> {
- const book = await prisma.book.findUnique({
- where: { id: parseInt(bookId) },
- select: { isPublished: true },
- });
-
- const newStatus = !book?.isPublished;
-
- if (newStatus) {
- await this.publishAlbum(bookId);
- } else {
- await this.unpublishAlbum(bookId);
- }
-
- return newStatus;
- }
- /**
- * 为书籍章节生成音频并关联(更新 BookChapter.audioUrl)
- */
- async generateChapterAudio(bookId: string, chapterNumber: number, userId?: number): Promise<{
- audioUrl: string;
- } | null> {
- const chapter = await prisma.bookChapter.findFirst({
- where: { bookId: parseInt(bookId), number: chapterNumber },
- include: { book: true },
- });
- if (!chapter || !chapter.content) {
- return null;
- }
- // 生成音频(异步模式,通过回调更新章节)
- const result = await generateAudio(
- userId ? String(userId) : String(chapter.book?.userId || '0'),
- chapter.content,
- 'longyingling_v3',
- { speed: 1.0, pitch: 0, volume: 50 },
- async (audioUrl: string, duration: number) => {
- // 音频生成完成后更新章节
- await prisma.bookChapter.update({
- where: { id: chapter.id },
- data: {
- audioUrl,
- audioDuration: duration,
- },
- });
- console.log(`✅ 章节${chapterNumber}音频生成完成:`, audioUrl);
- }
- );
- return {
- audioUrl: result.audioUrl, // 初始为空字符串,实际URL通过回调更新
- };
- }
- /**
- * 按 ID 生成章节音频(数据库队列 + 内容哈希去重)
- *
- * 流程:
- * 1. 计算内容 SHA256 哈希
- * 2. 检查是否已有相同哈希的已完成任务 → 复用
- * 3. 检查是否有进行中的任务 → 跳过(不中断)
- * 4. 创建 TtsTask 记录 → 队列处理器异步执行
- *
- * 关键保护:
- * - 同一内容不会重复生成 TTS
- * - 已在 audio_generating 的章节不会被回退
- * - 无递归调用,重试由队列处理器平铺循环控制
- */
- async generateChapterAudioById(chapterId: number, userId?: number, voiceId?: string): Promise<{
- audioUrl: string;
- } | null> {
- // ===== 步骤 1:读取章节状态 =====
- const chapterBefore = await prisma.bookChapter.findUnique({
- where: { id: chapterId },
- include: { book: true },
- });
- if (!chapterBefore) {
- console.warn(`[Audio] 章节不存在: ${chapterId}`);
- return null;
- }
- // 检查内容是否存在
- if (!chapterBefore.content || chapterBefore.genStage === 'idle') {
- console.warn(`[Audio] 章节内容未生成完成: ${chapterId}, genStage: ${chapterBefore.genStage}`);
- return null;
- }
- // ===== 步骤 2:内容哈希去重 =====
- const contentHash = computeContentHash(chapterBefore.content);
- // 2a. 检查是否有相同内容的已完成 TTS 任务
- const existingCompleted = await prisma.ttsTask.findFirst({
- where: { chapterId, taskType: 'tts', contentHash, status: 'completed' },
- orderBy: { completedAt: 'desc' },
- });
- if (existingCompleted?.audioUrl) {
- // 章节的 audioUrl 还在 → 内容没变、音频也没被清 → 可安全复用
- if (chapterBefore.audioUrl) {
- console.log(`[Audio] 章节${chapterId}内容未变化,复用已有音频`);
- return { audioUrl: chapterBefore.audioUrl };
- }
- // audioUrl 已被清空(可能用户主动重整音频)→ 不复用,走新建任务
- console.log(`[Audio] 章节${chapterId}内容未变但音频已清空,重新生成`);
- }
- // 2b. 检查是否有进行中的 TTS 任务(pending 或 processing)
- const inProgressTask = await prisma.ttsTask.findFirst({
- where: { chapterId, taskType: 'tts', status: { in: ['pending', 'processing'] } },
- });
- if (inProgressTask) {
- console.log(`[Audio] 章节${chapterId}已有进行中的任务#${inProgressTask.id}(状态=${inProgressTask.status}),不重复创建`);
- return { audioUrl: '' }; // 返回空,前端通过 genStage 轮询
- }
- // ===== 步骤 2.5:权限检查 =====
- const effectiveUserId = userId || chapterBefore.book?.userId;
- if (effectiveUserId) {
- const user = await prisma.user.findUnique({ where: { id: effectiveUserId } });
- if (user) {
- // 免费用户(memberLevel=0)不能使用TTS
- if (user.memberLevel === 0) {
- console.warn(`[Audio] 免费用户无法生成音频: userId=${effectiveUserId}`);
- await prisma.bookChapter.update({
- where: { id: chapterId },
- data: { genStage: chapterBefore.genStage }
- }).catch(() => {});
- throw new Error('免费用户暂不支持音频生成,请升级会员');
- }
- // 普通会员(memberLevel=1)只能使用Edge TTS
- // 这里只做警告,实际provider限制在TTS调用时生效
- if (user.memberLevel === 1) {
- console.log(`[Audio] 普通会员用户,使用Edge TTS享受2折优惠: userId=${effectiveUserId}`);
- }
- }
- }
- // ===== 步骤 3:已有音频且 genStage 正确 → 直接返回 =====
- const alreadyDoneStages = ['audio_completed', 'video_generating', 'video_completed'];
- if (alreadyDoneStages.includes(chapterBefore.genStage) && chapterBefore.audioUrl) {
- console.log(`[Audio] 章节${chapterId}音频已就绪(genStage=${chapterBefore.genStage}),跳过`);
- return { audioUrl: chapterBefore.audioUrl };
- }
- // ===== 步骤 4:推进 genStage 到 audio_generating =====
- // 如果已是 audio_generating,说明之前的任务中断了,先回退再前进
- if (chapterBefore.genStage === 'audio_generating') {
- console.log(`[Audio] 章节${chapterId}上次生成中断,回退后重新排队`);
- await regenerateChapter(chapterId, 'content_completed').catch(() => {});
- }
- // 如果已是 audio_completed/video_* 等更后的阶段,也先回退
- if (alreadyDoneStages.includes(chapterBefore.genStage) || chapterBefore.genStage === 'video_completed') {
- await regenerateChapter(chapterId, 'content_completed').catch(() => {});
- }
- await advanceChapter(chapterId, 'audio_generating');
- // 注:移除乐观锁二次检查。advanceChapter 自身使用 safeTransitionChapter
- // (乐观锁 UPDATE ... WHERE genStage=current),如果冲突会返回 count=0
- // 而不会错误推进。二次检查在高并发下可能误判合法请求。
- // ===== 步骤 5:复用已有 failed 任务或创建新任务 =====
- // 关键:同一 chapterId 只能有一个活跃任务,避免重复创建
- const existingFailed = await prisma.ttsTask.findFirst({
- where: { chapterId, taskType: 'tts', status: 'failed' },
- orderBy: { createdAt: 'desc' },
- });
-
- let task: any;
- if (existingFailed) {
- // 复用已有 failed 任务,重置为 pending,但累加 retryCount(保留历史重试记录)
- const prevRetryCount = existingFailed.retryCount || 0;
- // 关键:更新 voiceId 为当前有效的音色,避免旧任务用已禁用的 MiniMax 音色导致循环失败
- const taskVoiceId = voiceId || 'longyingling_v3';
- task = await prisma.ttsTask.update({
- where: { id: existingFailed.id },
- data: {
- status: 'pending',
- content: chapterBefore.content,
- contentHash,
- voiceId: taskVoiceId,
- retryCount: prevRetryCount, // 保留历史重试次数,不重置为0
- errorMsg: null,
- startedAt: null,
- completedAt: null,
- },
- });
- console.log(`[Audio] 复用已有任务#${existingFailed.id}: chapterId=${chapterId}, voiceId=${taskVoiceId}, 累计重试=${prevRetryCount}次`);
- } else {
- console.log(`[Audio] 创建TTS任务: chapterId=${chapterId}, 内容长度=${chapterBefore.content.length}, hash=${contentHash.substring(0, 12)}...`);
- task = await prisma.ttsTask.create({
- data: {
- taskType: 'tts',
- chapterId,
- bookId: chapterBefore.bookId,
- userId: userId || chapterBefore.book?.userId || null,
- contentHash,
- content: chapterBefore.content, // 保存提交时的内容副本
- voiceId: voiceId || 'longyingling_v3', // 支持外部传入音色,默认兼容有声书
- status: 'pending',
- },
- });
- console.log(`[Audio] TTS任务 #${task.id} 已创建,等待队列处理器处理`);
- }
- // 返回空 audioUrl,前端通过 genStage 或 WebSocket 获取进度
- return { audioUrl: '' };
- }
- /**
- * [DEPRECATED] 创建章节内容生成任务(数据库队列 + 内容哈希去重)
- *
- * ⚠️ 当前无 content 队列处理器(tts-queue.ts仅创建了tts队列)。
- * 内容生成已改为 LangGraph 节点直接调用 LLM,不再使用数据库队列。
- * 此方法保留仅用于向后兼容,请勿调用。
- *
- * 与 TTS 队列共用 TtsTask 表,通过 taskType='content' 区分。
- * hash 基于章节关键属性(标题、父级、预估字数等),
- * 相同参数不重复提交 LLM 请求。
- *
- * @returns taskId 或 null(如果已有进行中/已完成任务)
- */
- async enqueueContentGeneration(chapterId: number, bookId?: number): Promise<number | null> {
- const chapter = await prisma.bookChapter.findUnique({
- where: { id: chapterId },
- include: { book: true },
- });
- if (!chapter) {
- console.warn(`[Content] 章节不存在: ${chapterId}`);
- return null;
- }
- // ===== 拼接关键属性计算哈希 =====
- const attrString = [
- chapter.bookId,
- chapter.title,
- chapter.parentId,
- chapter.level,
- chapter.estimatedWords,
- ].join('|');
- const contentHash = computeContentHash(attrString);
- // 去重:已完成的内容任务
- const existingCompleted = await prisma.ttsTask.findFirst({
- where: { chapterId, taskType: 'content', contentHash, status: 'completed' },
- orderBy: { completedAt: 'desc' },
- });
- if (existingCompleted) {
- console.log(`[Content] 章节${chapterId}相同参数已有完成记录(hash=${contentHash.substring(0, 8)}...),跳过`);
- return null;
- }
- // 去重:进行中的任务
- const inProgressTask = await prisma.ttsTask.findFirst({
- where: { chapterId, taskType: 'content', status: { in: ['pending', 'processing'] } },
- });
- if (inProgressTask) {
- console.log(`[Content] 章节${chapterId}已有进行中的内容任务#${inProgressTask.id},不重复创建`);
- return inProgressTask.id;
- }
- // 已有内容且 genStage 正常 → 跳过
- if (chapter.content && ['content_completed', 'audio_generating', 'audio_completed'].includes(chapter.genStage)) {
- console.log(`[Content] 章节${chapterId}已有内容(genStage=${chapter.genStage}),跳过`);
- return null;
- }
- // 推进状态
- if (chapter.genStage !== 'content_generating') {
- if (chapter.genStage !== 'idle' && chapter.genStage !== 'outline_completed') {
- await regenerateChapter(chapterId, 'content_generating').catch(() => {});
- } else {
- await advanceChapter(chapterId, 'content_generating').catch(() => {});
- }
- }
- const task = await prisma.ttsTask.create({
- data: {
- taskType: 'content',
- chapterId,
- bookId: bookId || chapter.bookId,
- userId: chapter.book?.userId || null,
- contentHash,
- content: attrString, // 保存提交时的属性快照
- status: 'pending',
- },
- });
- console.log(`[Content] 内容任务 #${task.id} 已创建, chapterId=${chapterId}, hash=${contentHash.substring(0, 12)}...`);
- return task.id;
- }
- /**
- * 处理内容生成队列任务(由 ContentQueue 调用)
- *
- * [DEPRECATED] 实际的 LLM 文本生成逻辑,平铺循环重试(最多 3 次),无递归。
- *
- * ⚠️ 无 content 队列处理器调用此方法。内容生成由 LangGraph 节点直接完成。
- */
- async processContentTask(taskId: number): Promise<void> {
- const task = await prisma.ttsTask.findUnique({ where: { id: taskId } });
- if (!task || task.status !== 'processing' || task.taskType !== 'content') {
- console.warn(`[ContentTask] 任务#${taskId}状态异常,跳过`);
- return;
- }
- const chapterId = task.chapterId;
- console.log(`[ContentTask] 开始处理任务#${taskId}, chapterId=${chapterId}`);
- const MAX_RETRIES = 3;
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
- try {
- console.log(`[ContentTask] #${taskId} 第${attempt + 1}次尝试生成内容...`);
- const chapter = await prisma.bookChapter.findUnique({
- where: { id: chapterId },
- include: { book: true },
- });
- if (!chapter) {
- await this._failTask(taskId, `章节${chapterId}不存在`);
- return;
- }
- const bookId = String(chapter.bookId);
- // 委托给现有的生成方法(它已经包含完整的 LLM 调用逻辑)
- await this.generateSingleChapterContent(bookId, chapterId);
- // 验证生成结果
- const updated = await prisma.bookChapter.findUnique({ where: { id: chapterId } });
- if (updated?.content && updated.genStage === 'content_completed') {
- await prisma.ttsTask.update({
- where: { id: taskId },
- data: {
- status: 'completed',
- completedAt: new Date(),
- retryCount: attempt,
- },
- });
- console.log(`[ContentTask] ✅ 任务#${taskId} 完成`);
- await this.tryCleanupBookTasks(chapterId);
- return;
- }
- // 内容为空或状态不对 → 视为失败
- throw new Error(updated?.content ? `状态异常: ${updated.genStage}` : '生成内容为空');
- } catch (err: any) {
- const errorMsg = err?.message || String(err);
- console.error(`[ContentTask] ❌ 任务#${taskId} 第${attempt + 1}次失败:`, errorMsg);
- if (attempt < MAX_RETRIES) {
- const delay = Math.min(3000 * Math.pow(2, attempt) + Math.random() * 2000, 60000);
- console.log(`[ContentTask] #${taskId} ${(delay / 1000).toFixed(1)}s 后重试...`);
- await prisma.ttsTask.update({
- where: { id: taskId },
- data: { retryCount: attempt + 1, errorMsg: errorMsg.substring(0, 500) },
- });
- await new Promise(resolve => setTimeout(resolve, delay));
- continue;
- }
- await this._failTask(taskId, errorMsg);
- return;
- }
- }
- await this._failTask(taskId, '重试耗尽');
- }
- /**
- * 等待内容生成队列任务完成(调用方轮询,最多等待 30 分钟)
- *
- * @returns 生成后的章节内容
- */
- async awaitContentTask(taskId: number, timeoutMs: number = 30 * 60 * 1000): Promise<{
- content: string;
- wordCount: number;
- genStage: string;
- }> {
- const task = await prisma.ttsTask.findUnique({ where: { id: taskId } });
- if (!task) throw new Error(`任务#${taskId}不存在`);
- if (task.taskType !== 'content') throw new Error(`任务#${taskId}不是内容生成类型`);
- const start = Date.now();
- let lastLogTime = 0;
- while (Date.now() - start < timeoutMs) {
- const chapter = await prisma.bookChapter.findUnique({
- where: { id: task.chapterId },
- select: { content: true, wordCount: true, genStage: true, contentError: true },
- });
- if (!chapter) throw new Error(`章节${task.chapterId}不存在`);
- // 完成
- if (chapter.genStage === 'content_completed' && chapter.content) {
- console.log(`[ContentTask] 任务#${taskId} 轮询完成 (${((Date.now() - start) / 1000).toFixed(0)}s)`);
- return { content: chapter.content, wordCount: chapter.wordCount, genStage: 'content_completed' };
- }
- // 失败
- if (chapter.genStage === 'failed') {
- const updated = await prisma.ttsTask.findUnique({ where: { id: taskId }, select: { errorMsg: true } });
- throw new Error(updated?.errorMsg || chapter.contentError || '内容生成失败');
- }
- // 进度日志(每 30 秒)
- const now = Date.now();
- if (now - lastLogTime > 30000) {
- lastLogTime = now;
- const elapsed = ((now - start) / 1000).toFixed(0);
- console.log(`[ContentTask] 任务#${taskId} 等待中... genStage=${chapter.genStage}, 已等${elapsed}s`);
- }
- await new Promise(resolve => setTimeout(resolve, 3000));
- }
- throw new Error(`内容生成超时 (${timeoutMs / 1000}s)`);
- }
- /**
- * 处理 TTS 队列任务(由 tts-queue.ts 调用)
- *
- * 只尝试 1 次。Provider 层会自动遍历所有可用 Provider。
- * 失败后标记 failed,由用户手动重新生成。
- */
- async processTtsTask(taskId: number): Promise<void> {
- const task = await prisma.ttsTask.findUnique({ where: { id: taskId } });
- if (!task || task.status !== 'processing') {
- console.warn(`[TtsTask] 任务#${taskId}状态异常(status=${task?.status}),跳过`);
- return;
- }
- const chapterId = task.chapterId;
- const content = task.content;
- if (!content) {
- await this._failTask(taskId, '任务内容为空');
- return;
- }
- // 最小字数校验:过短文本 TTS API 会报 InvalidParameter,触发熔断
- if (content.trim().length < 10) {
- await this._failTask(taskId, '文本过短(少于10字),无法生成音频');
- return;
- }
- console.log(`[TtsTask] 开始处理任务#${taskId}, chapterId=${chapterId}, 内容长度=${content.length}`);
- try {
- const chapter = await prisma.bookChapter.findUnique({
- where: { id: chapterId },
- include: { book: true },
- });
- if (!chapter) {
- await this._failTask(taskId, `章节${chapterId}不存在`);
- return;
- }
- const userId = chapter.book?.userId || task.userId || 1;
- const bookId = chapter.bookId;
- // 设置上下文,后续 TTS 调用自动携带 userId/bookId/chapterId
- await runWithContext({ userId, bookId, chapterId }, async () => {
- const result = await generateAudio(
- String(userId),
- content,
- task.voiceId || 'longyingling_v3',
- { speed: 1.0, pitch: 0, volume: 50 },
- async (audioUrl: string, duration: number) => {
- console.log(`[TtsTask] #${taskId} 音频就绪: ${audioUrl?.substring(0, 60)}...`);
- try {
- await advanceChapter(chapterId, 'audio_completed');
- try {
- const userId = chapter.book?.userId || task.userId || 1;
- const audioMinutes = Math.ceil(duration / 60);
- await consumeAudioMinutes(userId, audioMinutes, `书籍「${chapter.book?.title || '未知'}」- ${chapter.title} (${duration}s)`);
- } catch (quotaErr: any) {
- console.warn(`[Quota] 消耗音频配额失败:`, quotaErr.message);
- }
- tryAutoMerge(chapterId, chapter.bookId, chapter.level, chapter.parentId);
- } catch (advanceErr: any) {
- console.warn(`[TtsTask] #${taskId} genStage推进失败: ${advanceErr.message},音频已就绪`);
- }
- },
- {
- bookId: chapter.bookId ? String(chapter.bookId) : undefined,
- chapterId: chapter.id,
- chapterTitle: chapter.title,
- }
- );
- await prisma.ttsTask.update({
- where: { id: taskId },
- data: { status: 'completed', audioUrl: result.audioUrl, completedAt: new Date() },
- });
- console.log(`[TtsTask] ✅ 任务#${taskId} 完成 bookId=${bookId} chapterId=${chapterId} chars=${content?.length || 0}`);
- await this.tryCleanupBookTasks(chapterId);
- }); // runWithContext 结束
- } catch (err: any) {
- const errorMsg = err?.message || String(err);
- console.error(`[TtsTask] ❌ 任务#${taskId} 失败 bookId=${task.bookId} chapterId=${chapterId}:`, errorMsg);
- await this._failTask(taskId, errorMsg);
- // 不可恢复错误(参数错误等)→ 标记章节为 failed,阻止 AudioScanner 反复重试
- const lowerMsg = errorMsg.toLowerCase();
- if (lowerMsg.includes('invalidparameter') || lowerMsg.includes('invalid_parameter')) {
- try {
- await prisma.bookChapter.update({
- where: { id: chapterId },
- data: { genStage: 'failed', contentError: errorMsg.substring(0, 500) },
- });
- console.log(`[TtsTask] 🔒 章节${chapterId}标记为failed(不可恢复错误),不再自动重试`);
- } catch (chapterErr) {
- console.warn(`[TtsTask] 标记章节失败状态失败:`, chapterErr);
- }
- }
- }
- }
- /**
- * 标记任务失败(不回退章节状态,由用户手动重新生成)
- */
- private async _failTask(taskId: number, errorMsg: string): Promise<void> {
- console.error(`[TtsTask] ❌ 任务#${taskId} 最终失败:`, errorMsg);
- await prisma.ttsTask.update({
- where: { id: taskId },
- data: {
- status: 'failed',
- errorMsg: errorMsg.substring(0, 1000),
- completedAt: new Date(),
- },
- });
- }
- /**
- * 任务成功后尝试清理整本书的任务记录
- * 当一本书所有章节都已生成完毕(>= audio_completed),清空该书的所有队列任务
- */
- private async tryCleanupBookTasks(chapterId: number): Promise<void> {
- try {
- const chapter = await prisma.bookChapter.findUnique({
- where: { id: chapterId },
- select: { bookId: true },
- });
- if (!chapter?.bookId) return;
- // 检查该书的章节是否全部完成
- const allChapters = await prisma.bookChapter.findMany({
- where: { bookId: chapter.bookId },
- select: { genStage: true },
- });
- if (allChapters.length === 0) return;
- const doneStages = ['audio_completed', 'video_generating', 'video_completed'];
- const allDone = allChapters.every(c => doneStages.includes(c.genStage));
- if (!allDone) return;
- // 全书完成,清理任务记录
- const result = await prisma.ttsTask.deleteMany({
- where: { bookId: chapter.bookId },
- });
- if (result.count > 0) {
- console.log(`[Cleanup] 书籍#${chapter.bookId}已完成,清理了 ${result.count} 条队列任务`);
- }
- } catch (err: any) {
- console.warn(`[Cleanup] 清理任务失败:`, err.message);
- }
- }
- /**
- * 重新生成单个章节内容(仅叶节点)
- * 直接生成,不触发整个书籍生成流程
- */
- async generateSingleChapterContent(bookId: string, chapterId: number): Promise<void> {
- const book = await this.getById(bookId);
- if (!book) {
- console.error(`[generateSingleChapterContent] 书籍不存在: ${bookId}`);
- return;
- }
- const chapter = await prisma.bookChapter.findUnique({
- where: { id: chapterId },
- });
- if (!chapter) {
- console.error(`[generateSingleChapterContent] 章节不存在: ${chapterId}`);
- return;
- }
- // 构建父节点映射
- const chaptersAndSections = await prisma.bookChapter.findMany({
- where: { bookId: parseInt(bookId), level: { in: [1, 2] } }
- });
- const chapterMap = new Map<number, any>();
- const sectionMap = new Map<number, any>();
- chaptersAndSections.forEach(c => {
- if (c.level === 1) chapterMap.set(c.id, c);
- if (c.level === 2) sectionMap.set(c.id, c);
- });
- const parentSection = sectionMap.get(chapter.parentId || 0);
- const parentChapter = parentSection ? chapterMap.get(parentSection.parentId || 0) : null;
- const chapterTitle = parentChapter?.title || '未知章';
- const sectionTitle = parentSection?.title || '未知节';
- const chapterSummary = parentChapter?.summary || '';
- const sectionSummary = parentSection?.summary || '';
- // 判断是否是短文
- const isShortArticle = !parentSection && chapter.level === 1;
- // 从 description 中提取写作风格要求
- const styleMatch = book.description?.match(/写作风格:([^\\n]+)/);
- const writingStyle = styleMatch ? styleMatch[1] : (book.style || '');
- let messages: ChatMessage[];
- if (isShortArticle) {
- messages = [
- { role: 'system', content: SUBSECTION_CONTENT_SYSTEM_PROMPT },
- {
- role: 'user',
- content: `书名:《${book.title}》
- ${book.description || ''}
- ${writingStyle ? `写作风格:${writingStyle}` : ''}
- 章标题:${chapter.title}
- 章概述:${chapter.summary || ''}
- 预估字数:${chapter.estimatedWords || 500}字
- 请撰写该章节的正文内容。`,
- },
- ];
- } else {
- messages = buildSubsectionContentMessages(
- book.title,
- book.description || '',
- chapterTitle,
- chapterSummary,
- sectionTitle,
- sectionSummary,
- chapter
- );
- }
- const bookTools = createBookTools(bookId, this);
- try {
- let content: string;
- try {
- const result = await callLLMWithTools(messages, bookTools);
- content = cleanThinkingText(result.text);
- } catch {
- content = cleanThinkingText(await callLLMWithMessages(messages));
- }
- const wordCount = countWords(content);
- await this.updateChapterById(chapterId, {
- content,
- wordCount,
- });
- // 推进到 content_completed(如已越过则跳过,不抛异常)
- const currentChapter = await prisma.bookChapter.findUnique({ where: { id: chapterId } });
- if (currentChapter) {
- const order = ['idle', 'outline_completed', 'content_generating', 'content_completed', 'audio_generating', 'audio_completed', 'video_generating', 'video_completed'];
- const curIdx = order.indexOf(currentChapter.genStage);
- const tgtIdx = order.indexOf('content_completed');
- // 只有当前在 content_completed 之前才推进(避免竞态:音频已推进到 audio_generating)
- if (curIdx >= 0 && curIdx < tgtIdx) {
- try {
- // 状态机要求:outline_completed → content_generating → content_completed
- if (currentChapter.genStage !== 'content_generating') {
- await advanceChapter(chapterId, 'content_generating').catch(() => {});
- }
- await advanceChapter(chapterId, 'content_completed');
- } catch (advanceErr: any) {
- console.warn(`[generateSingleChapterContent] genStage推进失败 chapterId=${chapterId}: ${advanceErr.message}, 可能已被其他流程推进`);
- }
- }
- }
- console.log(`✅ 章节「${chapter.title}」内容重新生成完成,字数: ${wordCount}`);
- } catch (error) {
- const errorMsg = error instanceof Error ? error.message : '失败';
- await this.updateChapterById(chapterId, {
- contentError: errorMsg,
- });
- // 仅当LLM调用失败时才回退到 content_generating
- console.error(`❌ 章节${chapterId}内容重新生成失败:`, errorMsg);
- }
- }
- /**
- * 转换数据库模型到 Book 类型
- * @param excludeContent 是否排除章节内容(用于列表/详情页,只返回标题不返回正文)
- */
- private toBook(dbBook: {
- id: number;
- userId: number | null;
- title: string;
- subtitle: string | null;
- description: string;
- targetAudience: string;
- style: string;
- bookScale: string;
- totalChapters: number;
- estimatedWords: number;
- progress: number;
- isPublished: boolean;
- genStage?: string;
- failedStage?: string;
- outlineJson: string | null;
- foreword: string | null;
- afterword: string | null;
- errorMsg: string | null;
- bookAnalysis?: string | null;
- createdAt: Date;
- updatedAt: Date;
- chapters: any[];
- }, excludeContent: boolean = false): Book {
- const outline = parseOutlineJson(dbBook.outlineJson);
- return {
- id: String(dbBook.id),
- userId: dbBook.userId || undefined,
- title: dbBook.title,
- subtitle: dbBook.subtitle || undefined,
- description: dbBook.description,
- targetAudience: dbBook.targetAudience,
- style: dbBook.style,
- bookScale: dbBook.bookScale,
- totalChapters: dbBook.totalChapters,
- estimatedWords: dbBook.estimatedWords,
- progress: dbBook.progress,
- isPublished: dbBook.isPublished,
- genStage: computeBookGenStage(dbBook.chapters),
- failedStage: dbBook.failedStage || undefined,
- chapters: chaptersFromDb(dbBook.chapters, dbBook.id, excludeContent),
- outline: outline || undefined,
- metadata: {
- foreword: dbBook.foreword || undefined,
- afterword: dbBook.afterword || undefined,
- },
- bookAnalysis: dbBook.bookAnalysis || undefined,
- error: dbBook.errorMsg || undefined,
- createdAt: dbBook.createdAt,
- updatedAt: dbBook.updatedAt,
- };
- }
- }
- // 导出单例
- export const bookStore = new BookStore();
|