# 状态系统重构方案 — 线性阶段模型 ## 一、核心变更 ### 删除字段 | 实体 | 删除字段 | 原因 | |------|---------|------| | BookChapter | `status`(大纲) | 合并到 `genStage` | | BookChapter | `contentStatus` | 合并到 `genStage` | | BookChapter | `audioStatus` | 合并到 `genStage` | | BookChapter | `videoStatus` | 合并到 `genStage` | ### 新增字段 | 实体 | 新增字段 | 类型 | 默认值 | 说明 | |------|---------|------|--------|------| | BookChapter | `genStage` | String | `"idle"` | 唯一阶段字段 | | Book | `genStage` | String | `"draft"` | 唯一阶段字段(替代原 `status`) | | Book | `failedStage` | String? | `null` | 记录失败时卡在哪个阶段(调试用) | ### 保留字段 - Book.**`progress`**(0-100)— 推进中不断更新 - BookChapter.**`content`** / **`wordCount`** — 实际数据 - BookChapter.**`audioUrl`** / **`audioDuration`** — 实际数据 - BookChapter.**`videoUrl`** / **`videoDuration`** — 实际数据 - BookChapter.**`isPublic`** — 独立于生成进程 --- ## 二、Book.genStage 完整定义 ```typescript export type BookGenStage = | 'draft' // 草稿,初始状态 | 'outlining' // 大纲生成中(LangGraph) | 'outline_completed' // 大纲完成 | 'content_generating' // 逐章生成内容中 | 'content_completed' // 所有章节内容完成 | 'audio_generating' // 逐章生成音频中 | 'audio_completed' // 所有章节音频完成 | 'video_generating' // 逐章生成视频中 | 'video_completed' // 所有章节视频完成(终态) | 'failed'; // 失败(终态) ``` ### Book 阶段索引(用于比较大小) ```typescript const BOOK_STAGE_ORDER: BookGenStage[] = [ 'draft', // 0 'outlining', // 1 'outline_completed', // 2 'content_generating', // 3 'content_completed', // 4 'audio_generating', // 5 'audio_completed', // 6 'video_generating', // 7 'video_completed', // 8 'failed', // 9(终态) ]; function bookStageIndex(s: BookGenStage): number { return BOOK_STAGE_ORDER.indexOf(s); } ``` ### Book 转移规则 | 规则 | 说明 | |------|------| | **前进** | 只能在阶段索引上正向推进,不允许跳跃 | | **回退** | Book 不直接回退。当子章节回退导致"所有子章节 >= 某阶段"条件不再满足时自动回退 | | **失败** | 任何阶段可进入 `failed` | **前进允许的转移:** ``` draft → outlining 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 任意 → failed ``` **回退通过 `checkAndRegressBook()` 触发(见 5.2 节):** 当任意子章节因重新生成而回退到上游阶段后,Book 检查当前 `genStage` 所代表的"所有章节 >= minStage"条件是否仍满足。不满足时自动回退到实际满足的阶段。 --- ## 三、Chapter.genStage 完整定义 ```typescript export type ChapterGenStage = | 'idle' // 初始状态 | 'content_generating' // 内容生成中 | 'content_completed' // 内容完成 | 'audio_generating' // 音频生成中 | 'audio_completed' // 音频完成 | 'video_generating' // 视频生成中 | 'video_completed' // 视频完成(终态) | 'failed'; // 失败(终态) ``` ### Chapter 阶段索引 ```typescript const CHAPTER_STAGE_ORDER: ChapterGenStage[] = [ 'idle', // 0 'content_generating', // 1 'content_completed', // 2 'audio_generating', // 3 'audio_completed', // 4 'video_generating', // 5 'video_completed', // 6 'failed', // 7(终态) ]; function chapterStageIndex(s: ChapterGenStage): number { return CHAPTER_STAGE_ORDER.indexOf(s); } ``` ### Chapter 转移规则 | 规则 | 说明 | |------|------| | **前进** | 只能按阶段索引正向推进,不可跳跃 | | **回退** | **允许**,但必须满足:只回退到当前阶段和之前的阶段,不能跳到更后的阶段;回退会自动清除下游资源 | | **重新生成** | 在 `content_completed` / `audio_completed` / `video_completed` 上点击"重新生成"视为"前进到对应的 generating 阶段 + 清除下游资源" | | **失败** | 任何阶段可进入 `failed` | | **失败重试** | 从 `failed` 回到对应的回调阶段前 | ### 完整的转移矩阵 ```typescript const CHAPTER_VALID_TRANSITIONS: Record = { // idle → 内容生成 'idle': ['content_generating', 'failed'], // 内容生成中 → 内容完成 或 失败 'content_generating': [ 'content_completed', // 前进 'failed', ], // 内容完成 → 音频生成(前进)或 重新生成内容(回退清除 audio/video) 'content_completed': [ 'audio_generating', // 前进到音频 'content_generating', // ← 重新生成内容(清除 audioUrl、videoUrl) 'failed', ], // 音频生成中 → 音频完成 或 失败 'audio_generating': [ 'audio_completed', // 前进 'failed', ], // 音频完成 → 视频生成(前进)或 重新生成音频(清除 video)或 重新生成内容 'audio_completed': [ 'video_generating', // 前进到视频 'audio_generating', // ← 重新生成音频(清除 videoUrl) 'content_generating', // ← 重新生成内容(清除 audioUrl、videoUrl) 'failed', ], // 视频生成中 → 视频完成 或 失败 'video_generating': [ 'video_completed', // 前进 'failed', ], // 视频完成 → 重新生成 或 失败 'video_completed': [ 'video_generating', // ← 重新生成视频 'audio_generating', // ← 从音频重新开始 'content_generating', // ← 从内容重新开始 'failed', ], // 失败 → 重试到对应阶段前 'failed': [ 'content_generating', // 内容生成失败,重试 'audio_generating', // 音频生成失败,重试 'video_generating', // 视频生成失败,重试 'idle', // 完全重置 ], }; ``` ### 回退时的资源清理规则 ```typescript /** * 判断本次转移是否需要清理下游资源。 * 如果目标阶段比某资源的阶段索引更小,则清除该资源。 */ function getCleanupForRegression( targetStage: ChapterGenStage, ): { audioUrl?: null; audioDuration?: 0; videoUrl?: null; videoDuration?: 0 } { const targetIdx = chapterStageIndex(targetStage); const audioStageIdx = chapterStageIndex('audio_completed'); // 4 const videoStageIdx = chapterStageIndex('video_completed'); // 6 const clean: any = {}; if (targetIdx < audioStageIdx) { clean.audioUrl = null; clean.audioDuration = 0; } if (targetIdx < videoStageIdx) { clean.videoUrl = null; clean.videoDuration = 0; } return clean; } ``` --- ## 四、安全转移函数 ### 4.1 通用验证 + 乐观锁 ```typescript async function safeTransitionChapter( chapterId: number, currentStage: ChapterGenStage, targetStage: ChapterGenStage, ): Promise { // ① 验证是否在转移矩阵中 const allowed = CHAPTER_VALID_TRANSITIONS[currentStage]; if (!allowed || !allowed.includes(targetStage)) { throw new Error( `非法状态转移:Chapter ${chapterId} 当前 ${currentStage},` + `不允许转到 ${targetStage}。允许的转移:[${allowed?.join(', ') || '无'}]` ); } // ② 判断是否需要清理下游资源 const cleanData = getCleanupForRegression(targetStage); // ③ 乐观锁写入 const result = await prisma.bookChapter.updateMany({ where: { id: chapterId, genStage: currentStage }, data: { genStage: targetStage, ...cleanData, updatedAt: new Date() }, }); if (result.count === 0) { throw new Error( `并发冲突:Chapter ${chapterId} 预期当前状态 ${currentStage},` + `但数据库中已不是该状态` ); } } async function safeTransitionBook( bookId: number, currentStage: BookGenStage, targetStage: BookGenStage, ): Promise { const allowed = BOOK_VALID_TRANSITIONS[currentStage]; if (!allowed || !allowed.includes(targetStage)) { throw new Error( `非法状态转移:Book ${bookId} 当前 ${currentStage},` + `不允许转到 ${targetStage}。允许的转移:[${allowed?.join(', ') || '无'}]` ); } const result = await prisma.book.updateMany({ where: { id: bookId, genStage: currentStage }, data: { genStage: targetStage, failedStage: targetStage === 'failed' ? currentStage : undefined, updatedAt: new Date(), }, }); if (result.count === 0) { throw new Error( `并发冲突:Book ${bookId} 预期当前状态 ${currentStage},` + `但数据库中已不是该状态` ); } } ``` ### 4.2 上层封装:`advanceChapter()` 和 `regenerateChapter()` 为了让调用方不用关心前进/回退的矩阵细节,提供两个命名语义明确的上层函数: ```typescript /** * 前进:推进到下一个阶段。只能前进不能回退。 * 例:content_completed → audio_generating */ async function advanceChapter( chapterId: number, targetStage: ChapterGenStage, ): Promise { const chapter = await prisma.bookChapter.findUniqueOrThrow({ where: { id: chapterId }, }); const cur = chapter.genStage as ChapterGenStage; const curIdx = chapterStageIndex(cur); const tgtIdx = chapterStageIndex(targetStage); if (tgtIdx <= curIdx) { throw new Error( `advanceChapter 不能用于回退:当前 ${cur},目标 ${targetStage}。` + `请使用 regenerateChapter()` ); } await safeTransitionChapter(chapterId, cur, targetStage); } /** * 重新生成:回退到上游阶段(或同级 generating 状态)以重新生成。 * 自动清除下游资源。 * 例:audio_completed → audio_generating(重新生成音频) * audio_completed → content_generating(从内容重新开始) * failed → audio_generating(失败重试) */ async function regenerateChapter( chapterId: number, targetStage: ChapterGenStage, ): Promise { const chapter = await prisma.bookChapter.findUniqueOrThrow({ where: { id: chapterId }, }); const cur = chapter.genStage as ChapterGenStage; await safeTransitionChapter(chapterId, cur, targetStage); // 回退后检查 Book 是否需要联动回退 await checkAndRegressBook(chapter.bookId); } ``` --- ## 五、Book 推进与回退逻辑 ### 5.1 Book 推进条件 Book 的 `genStage` 不代表"正在做这个操作",而是 **"所有章节至少达到了某个阶段"**。 ```typescript /** * 检查所有叶子节点是否至少达到了某阶段。 * 例如 allChaptersAtLeast('content_completed') = 所有章节都 >= content_completed。 */ function allChaptersAtLeast( chapters: ChapterGenStage[], minStage: ChapterGenStage, ): boolean { const minIdx = chapterStageIndex(minStage); return chapters.every(c => chapterStageIndex(c) >= minIdx); } ``` **推进只发生在 Book 的某个 generating 阶段,且所有子章节已满足条件时:** ``` Book = content_generating + 所有子章节 >= content_completed → Book 推进到 content_completed → 立即继续推进到 audio_generating Book = audio_generating + 所有子章节 >= audio_completed → Book 推进到 audio_completed → 立即继续推进到 video_generating(如适用) ``` ### 5.2 Book 回退机制(自动联动) 当任意子章节因 `regenerateChapter()` 回退到上游时,Book 阶段可能不再适用。**检查并自动回退**: ```typescript async function checkAndRegressBook(bookId: number): Promise { const book = await prisma.book.findUnique({ where: { id: bookId }, include: { chapters: { where: { level: { gte: 1 }, parentId: 0 } } }, }); if (!book) return; const curBookStage = book.genStage as BookGenStage; const chapterStages = book.chapters.map(c => c.genStage as ChapterGenStage); // 从当前 Book 阶段往前检查,找到第一个所有章节都满足的阶段 let targetIdx = bookStageIndex(curBookStage); for (let i = targetIdx; i >= 0; i--) { const stage = BOOK_STAGE_ORDER[i]; // failing 和 draft 是终态/初始态,不去检查子章节 if (stage === 'failed' || stage === 'draft') continue; // 非 generating 阶段(completed 阶段)才检查子章节条件 if (!stage.endsWith('_generating')) { const chStage = stageToChapterStage(stage); if (chStage && allChaptersAtLeast(chapterStages, chStage)) { targetIdx = i; break; } } else { // generating 阶段也检查 targetIdx = i; break; } } const targetBookStage = BOOK_STAGE_ORDER[targetIdx]; if (targetBookStage !== curBookStage) { // 直接写数据库(跳过 safeTransition,因为这是合法回退) await prisma.book.update({ where: { id: bookId }, data: { genStage: targetBookStage, updatedAt: new Date() }, }); } } /** * Book stage → Chapter stage 映射(对应关系) */ function stageToChapterStage(bookStage: BookGenStage): ChapterGenStage | null { const map: Partial> = { 'content_completed': 'content_completed', 'audio_completed': 'audio_completed', 'video_completed': 'video_completed', 'outline_completed': 'content_completed', // 大纲完成 ≈ 内容准备开始 }; return map[bookStage] || null; } ``` ### 5.3 示例:章节不同步时的流程 ``` 时间 Chapter 1 Chapter 2 Book.genStage ─── ────────── ────────── ───────────── T1 content_completed content_generating content_generating ↑ 已跑在前面 ↑ 还在生成中 ↑ 正确:还没全部完成 T2 audio_completed content_generating content_generating ↑ 独立推进到音频 ↑ 仍在内容阶段 ↑ 还是 content_generating ↑ 因为条件 allChapters('content_completed') 不满足 T3 audio_completed content_completed content_completed → audio_generating ↑ 刚完成内容 ↑ 检测到全部 >= content_completed ↑ 立即推进 T4 audio_completed audio_generating audio_generating ↑ 正在生成 ↑ 正确推进到 audio 阶段 ``` **每个章节独立推进自己的 genStage,不受 Book 影响。** Chapter 1 在 T2 时虽然 Book 还显示 content_generating,但用户依然可以操作 Chapter 1 的音频播放/下载。 --- ## 六、前端改动方案 ### 6.1 类型定义 ```typescript // book-generator-api.ts export interface Book { genStage: BookGenStage; progress: number; failedStage?: string; } export interface Chapter { genStage: ChapterGenStage; } ``` ### 6.2 按钮显隐:单函数决策 ```typescript type ButtonState = 'hidden' | 'generate' | 'generating' | 'retry' | 'play' | 'preview'; function getAudioButtonState(genStage: ChapterGenStage): ButtonState { switch (genStage) { case 'content_completed': return 'generate'; // 显示"生成音频" case 'audio_generating': return 'generating'; // 显示"生成中..." case 'audio_completed': case 'video_generating': case 'video_completed': return 'play'; // 显示"播放" case 'failed': return 'retry'; // 显示"重试" default: return 'hidden'; } } function getVideoButtonState(genStage: ChapterGenStage): ButtonState { switch (genStage) { case 'audio_completed': return 'generate'; // 显示"生成视频" case 'video_generating': return 'generating'; // 显示"生成中..." case 'video_completed': return 'preview'; // 显示"预览" case 'failed': return 'retry'; default: return 'hidden'; } } ``` ### 6.3 重新生成按钮 ```vue ``` ### 6.4 进度描述 ```typescript function getProgressDescription( genStage: BookGenStage, chapters: Chapter[], ): string { switch (genStage) { case 'draft': return '草稿'; case 'outlining': return '正在生成大纲...'; case 'outline_completed': return '大纲已完成'; case 'content_generating': return describeStageCount(chapters, 'content_completed', '内容'); case 'content_completed': return '内容已完成'; case 'audio_generating': return describeStageCount(chapters, 'audio_completed', '音频'); case 'audio_completed': return '音频已完成'; case 'video_generating': return describeStageCount(chapters, 'video_completed', '视频'); case 'video_completed': return '全部完成 ✓'; case 'failed': return `生成失败(${book.failedStage}阶段)`; } } function describeStageCount( chapters: Chapter[], stage: ChapterGenStage, label: string, ): string { const targetIdx = chapterStageIndex(stage); const done = chapters.filter(c => chapterStageIndex(c.genStage) >= targetIdx).length; const total = chapters.length; // 附带上超过当前阶段的章节数 const ahead = chapters.filter(c => chapterStageIndex(c.genStage) > targetIdx).length; const aheadText = ahead > 0 ? `,其中${ahead}章已进入下一阶段` : ''; return `${label}生成中 (${done}/${total}${aheadText})`; } ``` --- ## 七、数据迁移方案 ### 7.1 将旧字段合并为 genStage ```sql -- Book 迁移 UPDATE Book SET genStage = CASE status WHEN 'draft' THEN 'draft' WHEN 'planning' THEN 'outline_completed' WHEN 'generating' THEN 'content_generating' WHEN 'completed' THEN CASE WHEN EXISTS (SELECT 1 FROM BookChapter WHERE bookId = Book.id AND videoUrl IS NOT NULL) THEN 'video_completed' WHEN EXISTS (SELECT 1 FROM BookChapter WHERE bookId = Book.id AND audioUrl IS NOT NULL) THEN 'audio_completed' ELSE 'content_completed' END WHEN 'failed' THEN 'failed' WHEN 'interrupted' THEN 'failed' ELSE 'draft' END; -- BookChapter 迁移 UPDATE BookChapter SET genStage = CASE WHEN videoStatus = 'completed' THEN 'video_completed' WHEN videoStatus = 'generating' THEN 'video_generating' WHEN audioStatus = 'completed' THEN 'audio_completed' WHEN audioStatus = 'generating' THEN 'audio_generating' WHEN contentStatus = 'completed' THEN 'content_completed' WHEN contentStatus = 'generating' THEN 'content_generating' WHEN status = 'completed' THEN 'content_completed' ELSE 'idle' END; ``` ### 7.2 Prisma Schema ```prisma model Book { // ... 原有字段 ... genStage String @default("draft") failedStage String? @db.VarChar(30) progress Int @default(0) } model BookChapter { // ... 原有字段(不含 status/contentStatus/audioStatus/videoStatus)... genStage String @default("idle") } ``` --- ## 八、实施步骤 | 步骤 | 内容 | 回滚? | |------|------|--------| | 1 | 新建 `stage-manager.ts`(转移矩阵 + 安全函数) | ✅ 纯新增 | | 2 | Prisma 新增 `genStage` + `failedStage`(旧字段保留) | ✅ 可逆 | | 3 | 数据迁移脚本 | ✅ 可逆 | | 4 | 逐文件替换后端赋值代码 | ⚠️ 需测试 | | 5 | 更新前端 | ✅ 可逆 | | 6 | 稳定后删除旧字段 | 24h 后执行 | --- ## 九、核心安全原则(终版) ``` 1. 所有状态变更经过 safeTransitionChapter / safeTransitionBook → 自动验证转移矩阵 + 乐观锁 + 资源清理 2. 调用方只面向 advanceChapter() / regenerateChapter() → 前者的所有目标阶段索引必须大于当前 → 后者的目标阶段可以等于或小于当前 3. 资源清理自动完成 → 回退到 content_generating 时自动清除 audioUrl + videoUrl → 回退到 audio_generating 时自动清除 videoUrl → 永不出现"audioUrl 还在但内容重写了"的不一致 4. Book 阶段联动 → 子章节推进时检查 Book 是否满足推进条件 → 子章节回退时自动触发 checkAndRegressBook() → Book 阶段总是反映"所有子章节实际达到的最小进展" 5. 前端只读 genStage,永不推断 ``` ## 十、旧状态 → 新 genStage 映射表 ### Book | 旧 status | 实际情况 | 新 genStage | |-----------|---------|-------------| | `draft` | 任意 | `draft` | | `planning` | 大纲完成 | `outline_completed` | | `generating` | 部分章节内容未完成 | `content_generating` | | `generating` | 内容全完成、部分音频未完成 | `audio_generating` | | `generating` | 音频全完成、部分视频未完成 | `video_generating` | | `completed` | 只有内容 | `content_completed` | | `completed` | 有音频无视频 | `audio_completed` | | `completed` | 有视频 | `video_completed` | | `failed` / `interrupted` | 任意 | `failed` | ### Chapter | 旧 contentStatus | 旧 audioStatus | 旧 videoStatus | 新 genStage | |-----------------|---------------|---------------|-------------| | `null` | `null` | `null` | `idle` | | `generating` | `null` | `null` | `content_generating` | | `completed` | `null` | `null` | `content_completed` | | `completed` | `generating` | `null` | `audio_generating` | | `completed` | `completed` | `null` | `audio_completed` | | `completed` | `completed` | `generating` | `video_generating` | | `completed` | `completed` | `completed` | `video_completed` | | 任意失败 | 任意 | 任意 | `failed` |