| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663 |
- /**
- * 书籍生成模块 - 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<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 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): 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<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;
- }
- 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<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();
|