Преглед изворни кода

chore(deploy-package): 入仓排查用调试脚本 (book-80/82/ch-527/test-books/e2e/en-e2e/trigger-audio/verify-model-switch)

9 个 .js/.cjs 临时调试脚本, 用于排查 7 月份书籍生成问题
非生产代码, 入仓便于跨机器复用

Co-Authored-By: Claude <noreply@anthropic.com>
MyFramework User пре 1 месец
родитељ
комит
3ee77691f1

+ 14 - 0
deploy-package/scripts/check-book-80.js

@@ -0,0 +1,14 @@
+const { PrismaClient } = require('@prisma/client');
+const p = new PrismaClient();
+(async () => {
+  const b = await p.book.findUnique({ where: { id: 80 }, include: { chapters: true } });
+  console.log(`book 80 lang=${b.targetLanguage} stage=${b.genStage} chapters=${b.chapters.length}`);
+  for (const c of b.chapters) {
+    const sample = (c.content || '').substring(0, 300).replace(/\n/g, ' / ');
+    const hasCN = /[一-鿿]/.test(c.content || '');
+    console.log(`  ch#${c.id} [${c.genStage}] audio=${c.audioUrl ? 'yes' : 'NO'}`);
+    console.log(`    ${sample}`);
+    console.log(`    hasChinese: ${hasCN ? '❌ 是(有中文!)' : '✅ 否(纯英文)'}`);
+  }
+  await p.$disconnect();
+})();

+ 20 - 0
deploy-package/scripts/check-book-82.js

@@ -0,0 +1,20 @@
+const { PrismaClient } = require('@prisma/client');
+const p = new PrismaClient();
+(async () => {
+  const b = await p.book.findUnique({ where: { id: 82 }, include: { chapters: true } });
+  console.log(`book 82 lang=${b.targetLanguage} stage=${b.genStage} chapters=${b.chapters.length}`);
+  for (const c of b.chapters) {
+    const sample = (c.content || '').substring(0, 400).replace(/\n/g, ' / ');
+    const hasCN = /[一-鿿]/.test(c.content || '');
+    console.log(`  ch#${c.id} [${c.genStage}] audio=${c.audioUrl ? 'yes' : 'NO'}`);
+    console.log(`    ${sample}`);
+    console.log(`    hasChinese: ${hasCN ? '❌ 是(有中文!)' : '✅ 否(纯英文)'}`);
+  }
+  // 同时查 TtsTask
+  const tasks = await p.ttsTask.findMany({ where: { chapterId: { in: b.chapters.map(c => c.id) } }, select: { id: true, chapterId: true, status: true, voiceId: true, targetLanguage: true, preferredVendor: true, preferredModel: true } });
+  console.log(`\n  TtsTask count: ${tasks.length}`);
+  for (const t of tasks) {
+    console.log(`  ttsTask#${t.id} ch=${t.chapterId} status=${t.status} voice=${t.voiceId} lang=${t.targetLanguage} vendor=${t.preferredVendor} model=${t.preferredModel}`);
+  }
+  await p.$disconnect();
+})();

+ 9 - 0
deploy-package/scripts/check-ch-527.js

@@ -0,0 +1,9 @@
+const { PrismaClient } = require('@prisma/client');
+const p = new PrismaClient();
+(async () => {
+  const t = await p.ttsTask.findFirst({ where: { chapterId: 527 }, select: { id: true, status: true, voiceId: true, targetLanguage: true, preferredVendor: true, preferredModel: true } });
+  console.log('ttsTask:', JSON.stringify(t));
+  const ch = await p.bookChapter.findUnique({ where: { id: 527 }, select: { audioUrl: true, audioDuration: true, title: true } });
+  console.log('chapter 527:', JSON.stringify(ch));
+  await p.$disconnect();
+})();

+ 14 - 0
deploy-package/scripts/check-test-books.js

@@ -0,0 +1,14 @@
+const { PrismaClient } = require('@prisma/client');
+const p = new PrismaClient();
+(async () => {
+  const bs = await p.book.findMany({ where: { OR: [{ id: 80 }, { id: 81 }, { id: 82 }] } });
+  for (const b of bs) {
+    const chs = await p.bookChapter.findMany({ where: { bookId: b.id } });
+    const auds = await p.audioRecord.findMany({ where: { bookId: b.id } });
+    console.log(`book ${b.id} '${b.title}' ch=${chs.length} audio=${auds.length}`);
+    for (const a of auds.slice(0, 3)) {
+      console.log(`  audio#${a.id} status=${a.status} voice=${a.voiceId} url=${(a.audioUrl||'').slice(-50)}`);
+    }
+  }
+  await p.$disconnect();
+})();

+ 158 - 0
deploy-package/scripts/e2e-create-book.cjs

@@ -0,0 +1,158 @@
+// e2e-create-book-and-verify.cjs
+// 实际创建一本书,验证完整链路 + 模型切换在生产 DB 中正确工作
+const fs = require('fs');
+const path = require('path');
+
+// 加载 .env
+try {
+  const envPath = path.resolve(__dirname, '../../server/.env');
+  const envContent = fs.readFileSync(envPath, 'utf-8');
+  for (const line of envContent.split('\n')) {
+    const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
+    if (m && !process.env[m[1]]) {
+      process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
+    }
+  }
+} catch {}
+
+const { PrismaClient } = require('@prisma/client');
+const p = new PrismaClient();
+
+(async () => {
+  try {
+    console.log('=== E2E 实际场景测试 ===\n');
+
+    // 1. 扫描所有卡的/失败的书,看扫描器是否在工作
+    console.log('1. 扫描所有 books,找出需要恢复的:');
+
+    const needRecovery = await p.book.findMany({
+      where: {
+        OR: [
+          // 卡的:content_generating 超过 30 分钟无更新
+          {
+            genStage: 'content_generating',
+            updatedAt: { lt: new Date(Date.now() - 30 * 60 * 1000) },
+          },
+          // 卡的:outline_ready 超过 15 分钟无更新
+          {
+            genStage: 'outline_ready',
+            updatedAt: { lt: new Date(Date.now() - 15 * 60 * 1000) },
+          },
+          // outlining 孤儿:超过 15 分钟无新章节
+          {
+            genStage: 'outlining',
+            updatedAt: { lt: new Date(Date.now() - 15 * 60 * 1000) },
+          },
+        ],
+      },
+      select: { id: true, title: true, genStage: true, errorMsg: true, updatedAt: true },
+    });
+
+    if (needRecovery.length === 0) {
+      console.log('  ✅ 没有需要恢复的书,扫描器空闲');
+    } else {
+      console.log(`  发现 ${needRecovery.length} 本需要恢复的书:`);
+      for (const b of needRecovery) {
+        const ageMin = Math.round((Date.now() - b.updatedAt.getTime()) / 60000);
+        console.log(`    - #${b.id} "${b.title.slice(0, 30)}..." stage=${b.genStage} 卡了 ${ageMin}分钟`);
+      }
+    }
+
+    // 2. 验证 failed 章节的恢复情况
+    console.log('\n2. failed 章节情况:');
+    const failedChapters = await p.bookChapter.findMany({
+      where: { genStage: 'failed' },
+      select: {
+        id: true, bookId: true, title: true, genStage: true, contentError: true,
+      },
+      take: 10,
+    });
+
+    if (failedChapters.length === 0) {
+      console.log('  ✅ 没有 failed 状态的章节');
+    } else {
+      console.log(`  ${failedChapters.length} 个 failed 章节:`);
+      for (const c of failedChapters) {
+        console.log(`    ch#${c.id} (book ${c.bookId}) "${c.title.slice(0, 30)}"`);
+        console.log(`      err: ${(c.contentError || '').slice(0, 150)}`);
+      }
+    }
+
+    // 3. 验证历史 429 错误章节是否已被恢复(content 已生成 + audio 已完成)
+    console.log('\n3. 历史 429 章节的恢复情况:');
+    const quotaErrors = await p.bookChapter.findMany({
+      where: {
+        OR: [
+          { contentError: { contains: '429' } },
+          { contentError: { contains: 'quota' } },
+          { contentError: { contains: 'usage' } },
+          { contentError: { contains: 'rate limit' } },
+        ],
+      },
+      select: {
+        id: true, bookId: true, title: true, genStage: true,
+        content: true, audioUrl: true, contentError: true,
+      },
+    });
+
+    let recovered = 0, stillFailed = 0;
+    for (const c of quotaErrors) {
+      const hasContent = !!(c.content && c.content.length > 50);
+      const hasAudio = !!c.audioUrl;
+      const isFailed = c.genStage === 'failed';
+
+      if (hasContent && (hasAudio || c.genStage === 'audio_generating')) {
+        recovered++;
+      }
+      if (isFailed) {
+        stillFailed++;
+      }
+
+      const status = hasContent ? '✅' : '❌';
+      const audioStatus = hasAudio ? '✅' : '❌';
+      console.log(`    ch#${c.id} (book ${c.bookId}) [${c.genStage}] 内容=${status} 音频=${audioStatus}`);
+    }
+
+    console.log(`\n  共 ${quotaErrors.length} 个历史 429 章节:`);
+    console.log(`    ✅ 成功恢复: ${recovered}`);
+    console.log(`    ❌ 仍失败: ${stillFailed}`);
+
+    if (quotaErrors.length > 0 && recovered === quotaErrors.length) {
+      console.log(`\n  🎉 100% 恢复成功!模型切换修复在生产中确实生效。`);
+    }
+
+    // 4. 系统健康总结
+    console.log('\n4. 系统健康总结:');
+    const totalBooks = await p.book.count();
+    const totalChapters = await p.bookChapter.count();
+    const completedBooks = await p.book.count({ where: { genStage: 'audio_completed' } });
+    console.log(`  总书籍: ${totalBooks}`);
+    console.log(`  总章节: ${totalChapters}`);
+    console.log(`  已完成书籍(audio_completed): ${completedBooks} (${((completedBooks / totalBooks) * 100).toFixed(1)}%)`);
+
+    // 健康检查
+    const healthScore = {
+      noStuck: needRecovery.length === 0,
+      no429History: quotaErrors.length === 0,
+      hasRecoveries: recovered > 0,
+    };
+
+    console.log('\n=== 综合评估 ===');
+    if (healthScore.noStuck && quotaErrors.length === 0) {
+      console.log('  ✅ 完美:没有卡住的书,没有 429 历史错误');
+    } else if (quotaErrors.length > 0 && recovered === quotaErrors.length) {
+      console.log(`  ✅ 良好:历史 429 错误已 100% 恢复,模型切换工作正常`);
+    } else if (needRecovery.length > 0) {
+      console.log(`  ⚠️ 注意:有 ${needRecovery.length} 本卡住的书,可能需要人工干预`);
+    } else {
+      console.log(`  ✅ 系统状态正常`);
+    }
+
+  } catch (err) {
+    console.error('❌ 测试异常:', err.message);
+    console.error(err.stack);
+    process.exit(1);
+  } finally {
+    await p.$disconnect();
+  }
+})();

+ 58 - 0
deploy-package/scripts/test-en-e2e.js

@@ -0,0 +1,58 @@
+// e2e-en.js — 创建英文书,等生成,触发音频,验证英文
+const { PrismaClient } = require('@prisma/client');
+const { bookStore } = require('./dist/modules/book-generator/book-generator.store.js');
+const p = new PrismaClient();
+
+(async () => {
+  const LANG = 'en-US';
+  console.log(`=== 1. 创建英文书 (${LANG}) ===`);
+  const book = await bookStore.create({
+    userId: 1,
+    title: 'EN E2E Audit Test',
+    description: 'Test English content generation and English audio',
+    bookScale: '500',
+    targetLanguage: LANG,
+    autoGenerateContent: true,
+    autoGenerateAudio: false,
+  });
+  console.log(`  bookId=${book.id} targetLanguage=${book.targetLanguage}`);
+  const bookId = parseInt(book.id);
+
+  console.log('=== 2. 等 60s AI 生成内容 ===');
+  await new Promise(r => setTimeout(r, 60000));
+
+  const chs = await p.bookChapter.findMany({ where: { bookId }, select: { id: true, title: true, content: true, genStage: true } });
+  console.log(`  共 ${chs.length} 章`);
+  for (const ch of chs) {
+    const sample = (ch.content || '').substring(0, 200).replace(/\n/g, ' / ');
+    console.log(`  ch#${ch.id} [${ch.genStage}] ${ch.title}`);
+    console.log(`    ${sample}`);
+    // 检测是否有中文(检测 unicode 范围)
+    const hasChinese = /[一-鿿]/.test(ch.content || '');
+    console.log(`    has Chinese: ${hasChinese ? '❌ 有中文!' : '✅ 纯英文'}`);
+  }
+
+  console.log('=== 3. 触发音频生成 ===');
+  for (const ch of chs) {
+    try {
+      await bookStore.generateChapterAudioById(ch.id, 1, undefined, {});
+      console.log(`  queued ch#${ch.id}`);
+    } catch (e) {
+      console.log(`  err: ${e.message.slice(0, 100)}`);
+    }
+  }
+
+  console.log('=== 4. 等 90s 让 TTS 跑完 ===');
+  await new Promise(r => setTimeout(r, 90000));
+
+  console.log('=== 5. 查 BookChapter.audioUrl + TtsTask 状态 ===');
+  for (const ch of chs) {
+    const full = await p.bookChapter.findUnique({ where: { id: ch.id }, select: { audioUrl: true, audioDuration: true } });
+    const tasks = await p.ttsTask.findMany({ where: { chapterId: ch.id }, select: { voiceId: true, targetLanguage: true, preferredVendor: true, status: true } });
+    console.log(`  ch#${ch.id} audioUrl=${full.audioUrl ? '✅ ' + full.audioUrl.slice(-50) : '❌ 无'} dur=${full.audioDuration}s`);
+    for (const t of tasks) {
+      console.log(`    ttsTask status=${t.status} voice=${t.voiceId} lang=${t.targetLanguage} vendor=${t.preferredVendor}`);
+    }
+  }
+  await p.$disconnect();
+})();

+ 18 - 0
deploy-package/scripts/trigger-book-82-audio.js

@@ -0,0 +1,18 @@
+// 触发 book 82 所有章节的音频生成
+const { bookStore } = require('./dist/modules/book-generator/book-generator.store.js');
+const { PrismaClient } = require('@prisma/client');
+const p = new PrismaClient();
+
+(async () => {
+  const chs = await p.bookChapter.findMany({ where: { bookId: 82 }, select: { id: true, title: true } });
+  console.log(`book 82: ${chs.length} chapters`);
+  for (const ch of chs) {
+    try {
+      await bookStore.generateChapterAudioById(ch.id, 1, undefined, {});
+      console.log(`  queued ch#${ch.id} ${ch.title.slice(0, 30)}`);
+    } catch (e) {
+      console.log(`  err ch#${ch.id}: ${e.message.slice(0, 100)}`);
+    }
+  }
+  await p.$disconnect();
+})();

+ 123 - 0
deploy-package/scripts/verify-model-switch.cjs

@@ -0,0 +1,123 @@
+// e2e-verify-model-switch.js
+// 实际数据库连接,验证扫描器/状态机/供应商切换
+const fs = require('fs');
+const path = require('path');
+
+try {
+  const envPath = path.resolve(__dirname, '../../server/.env');
+  const envContent = fs.readFileSync(envPath, 'utf-8');
+  for (const line of envContent.split('\n')) {
+    const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
+    if (m && !process.env[m[1]]) {
+      process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
+    }
+  }
+} catch (e) {
+  console.warn('未找到 .env, 依靠环境变量');
+}
+
+const { PrismaClient } = require('@prisma/client');
+const p = new PrismaClient();
+
+(async () => {
+  try {
+    // 1. 最近的书
+    console.log('=== 1. 最近 10 本书状态 ===');
+    const allBooks = await p.book.findMany({
+      select: { id: true, title: true, genStage: true, errorMsg: true, createdAt: true },
+      orderBy: { createdAt: 'desc' },
+      take: 10,
+    });
+    for (const b of allBooks) {
+      console.log(`  #${b.id} "${b.title.slice(0, 30)}..." stage=${b.genStage} ${b.errorMsg ? `err=${b.errorMsg.slice(0, 60)}` : ''}`);
+    }
+
+    // 2. 按状态统计
+    console.log('\n=== 2. 书的 genStage 分布 ===');
+    const bookStats = await p.book.groupBy({
+      by: ['genStage'],
+      _count: { genStage: true },
+    });
+    for (const s of bookStats.sort((a, b) => b._count.genStage - a._count.genStage)) {
+      console.log(`  ${s.genStage}: ${s._count.genStage}`);
+    }
+
+    // 3. 章节状态分布
+    console.log('\n=== 3. 章节的 genStage 分布 ===');
+    const chapterStats = await p.bookChapter.groupBy({
+      by: ['genStage'],
+      _count: { genStage: true },
+    });
+    for (const s of chapterStats.sort((a, b) => b._count.genStage - a._count.genStage)) {
+      console.log(`  ${s.genStage}: ${s._count.genStage}`);
+    }
+
+    // 4. 失败章节的内容错误
+    console.log('\n=== 4. 看最近 5 个有 contentError 的章节 ===');
+    const failedSamples = await p.bookChapter.findMany({
+      where: { contentError: { not: null } },
+      select: { id: true, bookId: true, title: true, contentError: true, genStage: true },
+      take: 5,
+    });
+
+    let hasQuotError = false;
+    let hasNetworkErr = false;
+    for (const c of failedSamples) {
+      console.log(`  ch#${c.id} (book ${c.bookId}) [${c.genStage}] "${c.title.slice(0, 30)}"`);
+      console.log(`    err: ${(c.contentError || '').slice(0, 200)}`);
+      if (/429|quota|usage|rate limit|insufficient|usage limit/i.test(c.contentError || '')) {
+        hasQuotError = true;
+      }
+      if (/timeout|ETIMEDOUT|ECONNRESET|fetch failed/i.test(c.contentError || '')) {
+        hasNetworkErr = true;
+      }
+    }
+
+    // 5. 卡住的检查
+    console.log('\n=== 5. 卡住的检查 ===');
+    const stuckGen = await p.book.count({
+      where: {
+        genStage: 'content_generating',
+        updatedAt: { lt: new Date(Date.now() - 30 * 60 * 1000) },
+      },
+    });
+    console.log(`  卡在 content_generating 超过 30 分钟: ${stuckGen}`);
+
+    const stuckOutline = await p.book.count({
+      where: {
+        genStage: 'outline_ready',
+        updatedAt: { lt: new Date(Date.now() - 15 * 60 * 1000) },
+      },
+    });
+    console.log(`  卡在 outline_ready 超过 15 分钟: ${stuckOutline}`);
+
+    const failedChaptersByTime = await p.bookChapter.count({
+      where: {
+        genStage: 'failed',
+        updatedAt: { lt: new Date(Date.now() - 10 * 60 * 1000) }, // 10 分钟还没恢复
+      },
+    });
+    console.log(`  failed 超过 10 分钟未恢复: ${failedChaptersByTime}`);
+
+    // 6. 健康总览
+    console.log('\n=== 6. 健康总览 ===');
+    console.log(`  ✅ 数据库连接正常`);
+    console.log(`  总书籍数: ${allBooks.length}(显示最近 10 本)`);
+    console.log(`  是否有 429/quota 错误证据: ${hasQuotError ? '⚠️ 是(需要检查供应商切换)' : '✅ 否'}`);
+    console.log(`  是否有网络错误证据: ${hasNetworkErr ? '⚠️ 是' : '✅ 否'}`);
+    console.log(`  卡住的书(需要扫描器恢复): ${stuckGen + stuckOutline}`);
+    console.log(`  5分钟内无法恢复的失败章节: ${failedChaptersByTime}`);
+
+    console.log('\n=== 结论 ===');
+    if (stuckGen === 0 && stuckOutline === 0 && failedChaptersByTime === 0) {
+      console.log(`  ✅ 系统中没有明显的卡死任务,扫描器和切换逻辑工作正常`);
+    } else {
+      console.log(`  ⚠️ 发现可能的卡死任务(但也可能正常在处理,需要看后续)`);
+    }
+  } catch (err) {
+    console.error('❌ 测试异常:', err.message);
+    process.exit(1);
+  } finally {
+    await p.$disconnect();
+  }
+})();

+ 127 - 0
deploy-package/server/verify-model-switch.cjs

@@ -0,0 +1,127 @@
+// e2e-verify-model-switch.js
+// 实际数据库连接,验证扫描器/状态机/供应商切换
+const { PrismaClient } = require('@prisma/client');
+
+// 从 server/.env 加载 DATABASE_URL
+const fs = require('fs');
+const path = require('path');
+try {
+  const envPath = path.resolve(__dirname, '../../server/.env');
+  const envContent = fs.readFileSync(envPath, 'utf-8');
+  for (const line of envContent.split('\n')) {
+    const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
+    if (m && !process.env[m[1]]) {
+      process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
+    }
+  }
+} catch (e) {
+  console.warn('未找到 .env, 依靠环境变量');
+}
+
+const p = new PrismaClient();
+
+(async () => {
+  try {
+    // 1. 找到当前数据库所有供应商状态
+    console.log('=== 1. 当前供应商状态 ===');
+    const recentBooks = await p.book.findMany({
+      where: { createdAt: { gte: new Date(Date.now() - 60 * 60 * 1000) } },
+      select: { id: true, title: true, genStage: true, errorMsg: true, updatedAt: true, createdAt: true },
+      orderBy: { createdAt: 'desc' },
+      take: 10,
+    });
+
+    if (recentBooks.length === 0) {
+      console.log('  没有最近 1 小时的书,等待真实数据...');
+      // 看最近 24 小时的
+      const allBooks = await p.book.findMany({
+        select: { id: true, title: true, genStage: true, errorMsg: true, updatedAt: true, createdAt: true },
+        orderBy: { createdAt: 'desc' },
+        take: 10,
+      });
+      console.log(`  最近 10 本书 (24h+):`);
+      for (const b of allBooks) {
+        console.log(`  - #${b.id} "${b.title.slice(0, 30)}..." stage=${b.genStage} err=${b.errorMsg ? b.errorMsg.slice(0, 80) : 'null'}`);
+      }
+    } else {
+      console.log(`  最近 1 小时 ${recentBooks.length} 本书:`);
+      for (const b of recentBooks) {
+        console.log(`  - #${b.id} "${b.title.slice(0, 30)}..." stage=${b.genStage} err=${b.errorMsg ? b.errorMsg.slice(0, 80) : 'null'}`);
+      }
+    }
+
+    // 2. 检查 failed 章节情况
+    console.log('\n=== 2. 失败章节统计 ===');
+    const failedChapters = await p.bookChapter.groupBy({
+      by: ['genStage'],
+      _count: { genStage: true },
+      where: { contentError: { not: null } },
+    });
+
+    for (const f of failedChapters) {
+      console.log(`  ${f.genStage}: ${f._count.genStage}`);
+    }
+
+    // 3. 检查 scan log 来推断恢复情况
+    console.log('\n=== 3. 看最近 5 个 failed 章节的内容错误 ===');
+    const failedSamples = await p.bookChapter.findMany({
+      where: { contentError: { not: null } },
+      select: { id: true, bookId: true, title: true, contentError: true, updatedAt: true },
+      orderBy: { updatedAt: 'desc' },
+      take: 5,
+    });
+
+    for (const c of failedSamples) {
+      console.log(`  ch#${c.id} (book ${c.bookId}) "${c.title.slice(0, 30)}":`);
+      console.log(`    err: ${(c.contentError || '').slice(0, 200)}`);
+      console.log(`    updatedAt: ${c.updatedAt.toISOString()}`);
+    }
+
+    // 4. 验证 recovery scanner 是否在工作
+    console.log('\n=== 4. 检查扫描器状态 ===');
+    const stuckBooks = await p.book.count({
+      where: {
+        genStage: 'content_generating',
+        updatedAt: { lt: new Date(Date.now() - 30 * 60 * 1000) },  // 30分钟没动
+      },
+    });
+    console.log(`  卡在 content_generating 超过 30 分钟的书: ${stuckBooks}`);
+
+    const outlineReadyBooks = await p.book.count({
+      where: {
+        genStage: 'outline_ready',
+        updatedAt: { lt: new Date(Date.now() - 15 * 60 * 1000) },  // 15分钟没动
+      },
+    });
+    console.log(`  卡在 outline_ready 超过 15 分钟的书: ${outlineReadyBooks}`);
+
+    // 5. 健康总结
+    console.log('\n=== 5. 健康总结 ===');
+    const healthCheck = {
+      noCrash: true,  // 如果到这里没崩就算
+      recoverableBooks: 0,
+      totalFailedChapters: failedSamples.length,
+      stuckBooks,
+      has429Evidence: false,
+    };
+
+    // 检测是否有过 429
+    for (const c of failedSamples) {
+      if (c.contentError && /429|quota|usage|rate limit|insufficient/i.test(c.contentError)) {
+        healthCheck.has429Evidence = true;
+        break;
+      }
+    }
+
+    console.log(`  进程存活: ✅`);
+    console.log(`  失败章节总数: ${healthCheck.totalFailedChapters}`);
+    console.log(`  卡住的书籍: ${healthCheck.stuckBooks}`);
+    console.log(`  看到过 429 证据: ${healthCheck.has429Evidence ? '⚠️ 是' : '✅ 否'}`);
+
+  } catch (err) {
+    console.error('❌ 测试异常:', err.message);
+    process.exit(1);
+  } finally {
+    await p.$disconnect();
+  }
+})();