/** * 书籍生成模块 - 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 { logAiCall } from '../../services/ai-call-logger'; import { consumeAudioMinutes } from '../subscription/subscription.service'; /** * 取消书籍所有章节的音频生成(将 pending/processing 任务标记为 cancelled,回退章节阶段) */ export async function cancelAudioGeneration(bookId: string): Promise<{ cancelledCount: number; rolledBackChapters: number[] }> { const chapters = await prisma.bookChapter.findMany({ where: { bookId: BigInt(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 = { '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(); const nodeMap = new Map(); 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(); const sectionMap = new Map(); 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 { 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 { 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 { 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 { 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 { // 如果传入 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 { try { await prisma.book.delete({ where: { id: parseInt(id) } }); return true; } catch { return false; } } /** * 创建章节 */ async createChapter(data: { bookId: string; number: number; title: string; summary?: string; keyPoints?: string[]; estimatedWords?: number; }): Promise { 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 { 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 { // 防御: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 { 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 { 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 { // 转换 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 { 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 { const chapters = await prisma.bookChapter.findMany({ where: { bookId: parseInt(bookId) }, orderBy: [{ level: 'asc' }, { number: 'asc' }], }); return chapters; } /** * 统计书籍完成章节数 */ async countCompletedChapters(bookId: string): Promise { return prisma.bookChapter.count({ where: { bookId: parseInt(bookId), genStage: 'video_completed', }, }); } /** * 发布书籍(将 isPublished 设为 true,同时公开所有章节) */ async publishAlbum(bookId: string): Promise { 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 { 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 { 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): 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 轮询 } // ===== 步骤 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; task = await prisma.ttsTask.update({ where: { id: existingFailed.id }, data: { status: 'pending', content: chapterBefore.content, contentHash, retryCount: prevRetryCount, // 保留历史重试次数,不重置为0 errorMsg: null, startedAt: null, completedAt: null, }, }); console.log(`[Audio] 复用已有任务#${existingFailed.id}: chapterId=${chapterId}, 累计重试=${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: '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 { 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 { 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 { 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; } 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 result = await generateAudio( String(task.userId || chapter.book?.userId || '0'), content, '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} 完成`); logAiCall({ callType: 'tts_task_completed', provider: 'tts', model: 'tts-task', textLen: content?.length || 0, success: true, chapterId, bookId: task.bookId ?? undefined }); await this.tryCleanupBookTasks(chapterId); } catch (err: any) { const errorMsg = err?.message || String(err); console.error(`[TtsTask] ❌ 任务#${taskId} 失败:`, errorMsg); logAiCall({ callType: 'tts_task_failed', provider: 'tts', model: 'tts-task', textLen: content?.length || 0, success: false, errorMsg, chapterId, bookId: task.bookId ?? undefined }); await this._failTask(taskId, errorMsg); } } /** * 标记任务失败(不回退章节状态,由用户手动重新生成) */ private async _failTask(taskId: number, errorMsg: string): Promise { 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 { 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 { 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(); const sectionMap = new Map(); 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();