| 实体 | 删除字段 | 原因 |
|---|---|---|
| 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 |
记录失败时卡在哪个阶段(调试用) |
progress(0-100)— 推进中不断更新content / wordCount — 实际数据audioUrl / audioDuration — 实际数据videoUrl / videoDuration — 实际数据isPublic — 独立于生成进程export type BookGenStage =
| 'draft' // 草稿,初始状态
| 'outlining' // 大纲生成中(LangGraph)
| 'outline_completed' // 大纲完成
| 'content_generating' // 逐章生成内容中
| 'content_completed' // 所有章节内容完成
| 'audio_generating' // 逐章生成音频中
| 'audio_completed' // 所有章节音频完成
| 'video_generating' // 逐章生成视频中
| 'video_completed' // 所有章节视频完成(终态)
| 'failed'; // 失败(终态)
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 不直接回退。当子章节回退导致"所有子章节 >= 某阶段"条件不再满足时自动回退 |
| 失败 | 任何阶段可进入 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"条件是否仍满足。不满足时自动回退到实际满足的阶段。
export type ChapterGenStage =
| 'idle' // 初始状态
| 'content_generating' // 内容生成中
| 'content_completed' // 内容完成
| 'audio_generating' // 音频生成中
| 'audio_completed' // 音频完成
| 'video_generating' // 视频生成中
| 'video_completed' // 视频完成(终态)
| 'failed'; // 失败(终态)
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);
}
| 规则 | 说明 |
|---|---|
| 前进 | 只能按阶段索引正向推进,不可跳跃 |
| 回退 | 允许,但必须满足:只回退到当前阶段和之前的阶段,不能跳到更后的阶段;回退会自动清除下游资源 |
| 重新生成 | 在 content_completed / audio_completed / video_completed 上点击"重新生成"视为"前进到对应的 generating 阶段 + 清除下游资源" |
| 失败 | 任何阶段可进入 failed |
| 失败重试 | 从 failed 回到对应的回调阶段前 |
const CHAPTER_VALID_TRANSITIONS: Record<ChapterGenStage, ChapterGenStage[]> = {
// 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', // 完全重置
],
};
/**
* 判断本次转移是否需要清理下游资源。
* 如果目标阶段比某资源的阶段索引更小,则清除该资源。
*/
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;
}
async function safeTransitionChapter(
chapterId: number,
currentStage: ChapterGenStage,
targetStage: ChapterGenStage,
): Promise<void> {
// ① 验证是否在转移矩阵中
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<void> {
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},` +
`但数据库中已不是该状态`
);
}
}
advanceChapter() 和 regenerateChapter()为了让调用方不用关心前进/回退的矩阵细节,提供两个命名语义明确的上层函数:
/**
* 前进:推进到下一个阶段。只能前进不能回退。
* 例:content_completed → audio_generating
*/
async function advanceChapter(
chapterId: number,
targetStage: ChapterGenStage,
): Promise<void> {
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<void> {
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 的 genStage 不代表"正在做这个操作",而是 "所有章节至少达到了某个阶段"。
/**
* 检查所有叶子节点是否至少达到了某阶段。
* 例如 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(如适用)
当任意子章节因 regenerateChapter() 回退到上游时,Book 阶段可能不再适用。检查并自动回退:
async function checkAndRegressBook(bookId: number): Promise<void> {
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<Record<BookGenStage, ChapterGenStage>> = {
'content_completed': 'content_completed',
'audio_completed': 'audio_completed',
'video_completed': 'video_completed',
'outline_completed': 'content_completed', // 大纲完成 ≈ 内容准备开始
};
return map[bookStage] || null;
}
时间 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 的音频播放/下载。
// book-generator-api.ts
export interface Book {
genStage: BookGenStage;
progress: number;
failedStage?: string;
}
export interface Chapter {
genStage: ChapterGenStage;
}
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';
}
}
<!-- 在播放/预览按钮附近 -->
<button v-if="genStage === 'audio_completed' || genStage === 'video_completed'"
@click="handleRegenerate('audio')">
🔄 重新生成
</button>
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})`;
}
-- 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;
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,永不推断
| 旧 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 |
| 旧 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 |