Переглянути джерело

chore(book-generator): 一键修复脚本 fix-stuck-books

复用场景:当用户新书触发了历史遗留 bug(例如 errorMsg 被错写),
可重复运行此脚本将 audio_generating 卡死书籍推进到 audio_completed
并清空所有 [质量校验]/[连贯性编辑]/[改写*] 类型的误报告 log。

幂等可重跑。--dry-run 仅查看不写库。
MyFramework User 2 місяців тому
батько
коміт
34a9840639
2 змінених файлів з 147 додано та 0 видалено
  1. 121 0
      server/scripts/fix-stuck-books.ts
  2. 26 0
      server/scripts/verify-prod.ts

+ 121 - 0
server/scripts/fix-stuck-books.ts

@@ -0,0 +1,121 @@
+/**
+ * 一次性数据库修复脚本:清理被误标为卡死/失败的书籍
+ *
+ * 背景:
+ *  - 之前 quality-check / continuity-edit / rewrite 等节点把"成功报告"写到了 Book.errorMsg
+ *    字段,前端误以为生成失败
+ *  - 同时有 17+ 本书 genStage=audio_generating 但章节 audioUrl 已就绪,是 audioScanner
+ *    未及时自愈的卡死状态
+ *
+ * 本脚本做三件事(全部幂等,可重跑):
+ *  1. 把 audio_generating + 章节 audioUrl 已设 的书的 Book.genStage 推进到 audio_completed
+ *  2. 把 Book.errorMsg 中符合 [质量校验] / [连贯性编辑] / [改写优化] / [改写降级] 正则的清空
+ *  3. 输出修复前后对比,方便回滚
+ *
+ * 用法:
+ *   cd server && npx tsx scripts/fix-stuck-books.ts
+ *   npx tsx scripts/fix-stuck-books.ts --dry-run   # 只统计不写
+ */
+
+import { PrismaClient } from '@prisma/client';
+
+const prisma = new PrismaClient();
+
+const REPORT_LIKE_PREFIXES = [
+  '[质量校验]',
+  '[连贯性编辑]',
+  '[改写优化]',
+  '[改写降级]',
+];
+
+function isReportLike(msg: string | null | undefined): boolean {
+  if (!msg) return false;
+  return REPORT_LIKE_PREFIXES.some((p) => msg.startsWith(p));
+}
+
+async function main() {
+  const dryRun = process.argv.includes('--dry-run');
+  console.log(`[fix-stuck-books] 启动${dryRun ? ' (DRY-RUN, 不写库)' : ''}`);
+
+  // === 1. 找出 audio_generating + 章节 audioUrl 已设的书 ===
+  const stuckBooks = await prisma.book.findMany({
+    where: {
+      genStage: 'audio_generating',
+      chapters: {
+        some: {
+          audioUrl: { not: '' },
+        },
+      },
+    },
+    select: {
+      id: true,
+      title: true,
+      genStage: true,
+      progress: true,
+      errorMsg: true,
+      chapters: {
+        where: { audioUrl: { not: '' } },
+        select: { id: true },
+      },
+    },
+    take: 100,
+  });
+
+  console.log(`\n[1] 卡死书籍(audio_generating 且有音频 URL): ${stuckBooks.length} 本`);
+  for (const b of stuckBooks) {
+    console.log(`    id=${b.id} 《${b.title}》 chapters=${b.chapters.length} err=${(b.errorMsg || '').substring(0, 50)}`);
+  }
+
+  if (!dryRun && stuckBooks.length > 0) {
+    for (const b of stuckBooks) {
+      await prisma.book.update({
+        where: { id: b.id },
+        data: { genStage: 'audio_completed', progress: 100 },
+      });
+      console.log(`    ✅ id=${b.id} → audio_completed`);
+    }
+  }
+
+  // === 2. 清空 errorMsg 中的"报告式"内容 ===
+  const pollutedBooks = await prisma.book.findMany({
+    where: {
+      errorMsg: { not: null },
+    },
+    select: { id: true, title: true, errorMsg: true, genStage: true, failedStage: true },
+    take: 500,
+  });
+
+  const toClean = pollutedBooks.filter((b) => isReportLike(b.errorMsg));
+  console.log(`\n[2] errorMsg 误写为报告的书: ${toClean.length} 本`);
+  for (const b of toClean) {
+    console.log(`    id=${b.id} 《${b.title}》 stage=${b.genStage} failed=${b.failedStage ?? '-'} err=${(b.errorMsg || '').substring(0, 60)}`);
+  }
+
+  if (!dryRun && toClean.length > 0) {
+    for (const b of toClean) {
+      // 只清 errorMsg,保留其他字段不动
+      await prisma.book.update({
+        where: { id: b.id },
+        data: { errorMsg: null },
+      });
+      console.log(`    ✅ id=${b.id} errorMsg 已清空`);
+    }
+  }
+
+  // === 3. 输出对比 ===
+  console.log(`\n[3] 修复总结:`);
+  console.log(`    卡死书籍推进: ${stuckBooks.length} 本 → audio_completed`);
+  console.log(`    误报清空: ${toClean.length} 本`);
+  console.log(`    数据库总改动: ${stuckBooks.length + toClean.length} 条`);
+
+  console.log(`\n[fix-stuck-books] 完成`);
+}
+
+main()
+  .catch((e) => {
+    console.error('[fix-stuck-books] 异常:', e);
+    process.exitCode = 1;
+  })
+  .finally(async () => {
+    await prisma.$disconnect();
+  });

+ 26 - 0
server/scripts/verify-prod.ts

@@ -0,0 +1,26 @@
+import { PrismaClient } from '@prisma/client';
+const p = new PrismaClient();
+
+(async () => {
+  const b43 = await p.book.findUnique({ where: { id: 43 }, include: { chapters: true } });
+  console.log('=== 线上 Book 43 ===');
+  console.log('genStage   :', b43?.genStage);
+  console.log('progress   :', b43?.progress);
+  console.log('errorMsg   :', b43?.errorMsg);
+  console.log('failedStage:', b43?.failedStage);
+  console.log('chapters   :', b43?.chapters.length);
+  for (const c of b43?.chapters || []) {
+    console.log('  #' + c.id, c.title, '|', c.genStage, '| audio=' + (c.audioUrl ? 'YES' : 'NO'));
+  }
+  const reportLike = await p.book.count({ where: { OR: [
+    { errorMsg: { startsWith: '[质量校验]' } },
+    { errorMsg: { startsWith: '[连贯性编辑]' } },
+    { errorMsg: { startsWith: '[改写优化]' } },
+    { errorMsg: { startsWith: '[改写降级]' } }
+  ]}});
+  const audioStuck = await p.book.count({ where: { genStage: 'audio_generating', chapters: { some: { audioUrl: { not: '' } } } } });
+  console.log('--- 整体 ---');
+  console.log('errorMsg 残留报告:', reportLike);
+  console.log('卡死书籍:        ', audioStuck);
+  await p.$disconnect();
+})();