status-system-redesign.md 22 KB

状态系统重构方案 — 线性阶段模型

一、核心变更

删除字段

实体 删除字段 原因
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 完整定义

export type BookGenStage =
  | 'draft'               // 草稿,初始状态
  | 'outlining'           // 大纲生成中(LangGraph)
  | 'outline_completed'   // 大纲完成
  | 'content_generating'  // 逐章生成内容中
  | 'content_completed'   // 所有章节内容完成
  | 'audio_generating'    // 逐章生成音频中
  | 'audio_completed'     // 所有章节音频完成
  | 'video_generating'    // 逐章生成视频中
  | 'video_completed'     // 所有章节视频完成(终态)
  | 'failed';             // 失败(终态)

Book 阶段索引(用于比较大小)

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 完整定义

export type ChapterGenStage =
  | 'idle'                  // 初始状态
  | 'content_generating'    // 内容生成中
  | 'content_completed'     // 内容完成
  | 'audio_generating'      // 音频生成中
  | 'audio_completed'       // 音频完成
  | 'video_generating'      // 视频生成中
  | 'video_completed'       // 视频完成(终态)
  | 'failed';               // 失败(终态)

Chapter 阶段索引

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 回到对应的回调阶段前

完整的转移矩阵

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;
}

四、安全转移函数

4.1 通用验证 + 乐观锁

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},` +
      `但数据库中已不是该状态`
    );
  }
}

4.2 上层封装: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 推进与回退逻辑

5.1 Book 推进条件

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(如适用)

5.2 Book 回退机制(自动联动)

当任意子章节因 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;
}

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 类型定义

// book-generator-api.ts
export interface Book {
  genStage: BookGenStage;
  progress: number;
  failedStage?: string;
}

export interface Chapter {
  genStage: ChapterGenStage;
}

6.2 按钮显隐:单函数决策

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 重新生成按钮

<!-- 在播放/预览按钮附近 -->
<button v-if="genStage === 'audio_completed' || genStage === 'video_completed'"
        @click="handleRegenerate('audio')">
  🔄 重新生成
</button>

6.4 进度描述

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

-- 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

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