audio-scanner.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. /**
  2. * 音频生成扫描器
  3. *
  4. * 作为 fire-and-forget 触发方式的兜底保障:
  5. * - 定时扫描 content_completed 但无音频的章节,自动入队
  6. * - 发现 audio_generating 卡死(有 audioUrl 但 genStage 没推进)→ 自动修复
  7. * - 统计遗漏修复数量,方便监控告警
  8. *
  9. * 设计原则:
  10. * 1. 纯兜底,不取代 fire-and-forget(保持实时性)
  11. * 2. 幂等安全:已处理的章节不会重复入队
  12. * 3. 可观测:每次扫描输出统计摘要
  13. */
  14. import { prisma } from '../../models';
  15. import { bookStore } from './book-generator.store';
  16. import { tryAutoMerge } from './book-generator.store';
  17. // ============ 配置 ============
  18. const SCAN_INTERVAL_MS = 30_000; // 扫描间隔 30 秒
  19. const LOG_STATS_INTERVAL = 10; // 每 10 轮输出一次统计摘要
  20. // ============ 统计 ============
  21. interface ScanStats {
  22. totalScans: number;
  23. foundMissing: number; // 发现 content_completed 但无音频的章节
  24. enqueuedSuccess: number; // 成功入队
  25. enqueuedFailed: number; // 入队失败
  26. fixedStuck: number; // 修复了卡死状态(有 audioUrl 但 genStage 不对)
  27. lastScanTime: number;
  28. }
  29. const stats: ScanStats = {
  30. totalScans: 0,
  31. foundMissing: 0,
  32. enqueuedSuccess: 0,
  33. enqueuedFailed: 0,
  34. fixedStuck: 0,
  35. lastScanTime: 0,
  36. };
  37. // ============ 核心扫描 ============
  38. /**
  39. * 额外扫描:检查叶节点音频完成但1级章节缺少合并音频的情况
  40. * 修复 advanceChapter 失败导致 tryAutoMerge 被跳过的问题
  41. */
  42. async function scanAndTryAutoMerge(): Promise<void> {
  43. try {
  44. // 找到所有 audio_completed 的叶节点(level >= 2),其所属1级章节没有合并音频
  45. const leafNodesWithAudio = await prisma.bookChapter.findMany({
  46. where: {
  47. genStage: 'audio_completed',
  48. audioUrl: { not: '' },
  49. level: { gte: 2 },
  50. },
  51. select: {
  52. id: true,
  53. bookId: true,
  54. level: true,
  55. parentId: true,
  56. },
  57. take: 50,
  58. });
  59. // 按 parentId (level=2) 或 parentId→parentId (level=3) 分组找到1级章节
  60. // 为避免复杂查询,直接对每个叶节点调用 tryAutoMerge(内部会判断是否所有兄弟节点都已就绪)
  61. for (const leaf of leafNodesWithAudio) {
  62. tryAutoMerge(leaf.id, leaf.bookId, leaf.level, leaf.parentId).catch(() => {});
  63. }
  64. } catch (err: any) {
  65. // 不影响主扫描流程
  66. }
  67. }
  68. /**
  69. * 扫描并修复:找到 content_completed 但没有音频的叶节点,自动创建 TTS 任务
  70. */
  71. async function scanAndEnqueue(): Promise<void> {
  72. const startTime = Date.now();
  73. stats.totalScans++;
  74. stats.lastScanTime = startTime;
  75. try {
  76. // ---- 步骤 1:查找遗漏的章节(content_completed 但无音频) ----
  77. const missingChapters = await prisma.bookChapter.findMany({
  78. where: {
  79. genStage: 'content_completed',
  80. content: { not: null },
  81. OR: [
  82. { audioUrl: '' },
  83. { audioUrl: null },
  84. ],
  85. // 确保没有进行中的 TTS 任务(避免重复入队)
  86. ttsTasks: {
  87. none: { status: { in: ['pending', 'processing'] } },
  88. },
  89. },
  90. include: { book: true },
  91. take: 50, // 每次最多处理 50 条,避免一次性拉取过多
  92. orderBy: { id: 'asc' },
  93. });
  94. // ---- 步骤 2a:查找卡死章节(audio_generating 且有 audioUrl) ----
  95. const stuckChapters = await prisma.bookChapter.findMany({
  96. where: {
  97. genStage: 'audio_generating',
  98. audioUrl: { not: '' },
  99. // 确认没有进行中的任务
  100. ttsTasks: {
  101. none: { status: { in: ['pending', 'processing'] } },
  102. },
  103. },
  104. take: 50,
  105. orderBy: { id: 'asc' },
  106. });
  107. // ---- 步骤 2b:查找 TTS 失败但 genStage 未回退的章节(audio_generating + 无 audioUrl + 无进行中任务) ----
  108. const orphanAudioChapters = await prisma.bookChapter.findMany({
  109. where: {
  110. genStage: 'audio_generating',
  111. content: { not: null },
  112. OR: [
  113. { audioUrl: '' },
  114. { audioUrl: null },
  115. ],
  116. // 确认没有进行中的任务
  117. ttsTasks: {
  118. none: { status: { in: ['pending', 'processing'] } },
  119. },
  120. },
  121. take: 50,
  122. orderBy: { id: 'asc' },
  123. });
  124. // ---- 步骤 3a:修复卡死章节(有音频文件,直接推进 + 触发自动合并检查) ----
  125. for (const chapter of stuckChapters) {
  126. try {
  127. await bookStore.updateChapterById(chapter.id, {
  128. genStage: 'audio_completed',
  129. });
  130. stats.fixedStuck++;
  131. console.log(`[AudioScanner] 🔧 修复卡死: chapterId=${chapter.id} (audio_generating → audio_completed)`);
  132. // 修复后检查是否需要触发自动合并(advanceChapter 失败时 tryAutoMerge 会被跳过)
  133. tryAutoMerge(chapter.id, chapter.bookId, chapter.level, chapter.parentId).catch((mergeErr: any) => {
  134. console.warn(`[AudioScanner] 修复卡死后自动合并失败 chapterId=${chapter.id}:`, mergeErr.message);
  135. });
  136. } catch (err: any) {
  137. console.error(`[AudioScanner] 修复卡死失败 chapterId=${chapter.id}:`, err.message);
  138. }
  139. }
  140. // ---- 步骤 3b:回退孤立的 audio_generating 章节(无音频,TTS 任务已失败但 genStage 未回退) ----
  141. let orphanFixed = 0;
  142. for (const chapter of orphanAudioChapters) {
  143. try {
  144. // 回退到 content_completed,后续扫描会重新入队
  145. await bookStore.updateChapterById(chapter.id, {
  146. genStage: 'content_completed',
  147. });
  148. orphanFixed++;
  149. console.log(`[AudioScanner] 🔄 回退孤立场: chapterId=${chapter.id} (audio_generating → content_completed,将重新入队)`);
  150. } catch (err: any) {
  151. console.error(`[AudioScanner] 回退孤立场失败 chapterId=${chapter.id}:`, err.message);
  152. }
  153. }
  154. // ---- 步骤 4:为遗漏章节创建 TTS 任务(带防死循环保护) ----
  155. // 过滤掉 24h 内已失败超过 MAX_RETRY_PER_DAY 次的章节,避免 Provider 全部不可用时无限重试
  156. const MAX_RETRY_PER_DAY = 5;
  157. const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
  158. let safeChapters = missingChapters;
  159. if (missingChapters.length > 0) {
  160. const failedCounts = await prisma.ttsTask.groupBy({
  161. by: ['chapterId'],
  162. where: {
  163. chapterId: { in: missingChapters.map(c => c.id) },
  164. status: 'failed',
  165. createdAt: { gte: twentyFourHoursAgo },
  166. },
  167. _count: { id: true },
  168. });
  169. const failedCountMap = new Map(failedCounts.map(f => [f.chapterId, f._count.id]));
  170. safeChapters = missingChapters.filter(ch => {
  171. const count = failedCountMap.get(ch.id) || 0;
  172. if (count >= MAX_RETRY_PER_DAY) {
  173. // 超过重试上限 → 标记 failed,用户可以手工重新生成
  174. bookStore.updateChapterById(ch.id, {
  175. genStage: 'failed',
  176. contentError: `TTS重试${count}次全部失败,请手工生成音频`,
  177. }).catch(() => {});
  178. console.log(`[AudioScanner] 🔒 章节${ch.id} 24h内已失败${count}次,标记为failed(等候用户手工处理)`);
  179. return false;
  180. }
  181. return true;
  182. });
  183. if (safeChapters.length > 0) {
  184. console.log(`[AudioScanner] 发现 ${safeChapters.length} 个缺少音频的章节(跳过${missingChapters.length - safeChapters.length}个已达重试上限),开始入队...`);
  185. }
  186. stats.foundMissing += safeChapters.length;
  187. } else {
  188. stats.foundMissing += missingChapters.length;
  189. }
  190. for (const chapter of safeChapters) {
  191. try {
  192. const result = await bookStore.generateChapterAudioById(chapter.id);
  193. if (result === null) {
  194. // 入队失败(可能内容为空等),记录但不计入统计
  195. console.warn(`[AudioScanner] ⚠️ 入队失败(return null): chapterId=${chapter.id}`);
  196. } else if (result.audioUrl) {
  197. // 已有音频(复用/已完成),也算成功
  198. stats.enqueuedSuccess++;
  199. } else {
  200. // 成功创建 pending 任务
  201. stats.enqueuedSuccess++;
  202. }
  203. } catch (err: any) {
  204. stats.enqueuedFailed++;
  205. console.error(`[AudioScanner] ❌ 入队异常 chapterId=${chapter.id}:`, err.message);
  206. }
  207. }
  208. // ---- 步骤 5:额外扫描 - 检查叶节点音频完成但1级章节缺少合并音频 ----
  209. await scanAndTryAutoMerge();
  210. // ---- 步骤 6:输出本轮摘要 ----
  211. const elapsed = Date.now() - startTime;
  212. const roundTotal = missingChapters.length + stuckChapters.length + orphanAudioChapters.length;
  213. if (roundTotal > 0) {
  214. console.log(
  215. `[AudioScanner] 本轮扫描完成 (${elapsed}ms): ` +
  216. `遗漏=${missingChapters.length}, 卡死修复=${stuckChapters.length}, 孤章回退=${orphanFixed}, ` +
  217. `成功=${stats.enqueuedSuccess}, 失败=${stats.enqueuedFailed}`
  218. );
  219. }
  220. // 定期输出累计统计
  221. if (stats.totalScans % LOG_STATS_INTERVAL === 0) {
  222. console.log(
  223. `[AudioScanner] 📊 累计统计: 扫描${stats.totalScans}轮, ` +
  224. `累计发现遗漏=${stats.foundMissing}, 累计修复卡死=${stats.fixedStuck}, ` +
  225. `入队成功=${stats.enqueuedSuccess}, 入队失败=${stats.enqueuedFailed}`
  226. );
  227. }
  228. } catch (err: any) {
  229. console.error(`[AudioScanner] 扫描异常:`, err.message);
  230. }
  231. }
  232. // ============ 生命周期 ============
  233. let timer: ReturnType<typeof setInterval> | null = null;
  234. let isRunning = false;
  235. export function startAudioScanner(): void {
  236. if (isRunning) {
  237. console.warn('[AudioScanner] 已在运行,跳过重复启动');
  238. return;
  239. }
  240. isRunning = true;
  241. console.log(`[AudioScanner] 音频扫描器已启动 (间隔=${SCAN_INTERVAL_MS / 1000}s, 兜底模式)`);
  242. // 启动后立即执行一次扫描(处理上一次运行期间遗留的)
  243. scanAndEnqueue().catch(err => {
  244. console.error('[AudioScanner] 首次扫描失败:', err);
  245. });
  246. // 定时轮询
  247. timer = setInterval(() => {
  248. scanAndEnqueue().catch(err => {
  249. console.error('[AudioScanner] 定时扫描失败:', err);
  250. });
  251. }, SCAN_INTERVAL_MS);
  252. }
  253. export function stopAudioScanner(): void {
  254. if (!isRunning) return;
  255. isRunning = false;
  256. if (timer) {
  257. clearInterval(timer);
  258. timer = null;
  259. }
  260. console.log(`[AudioScanner] 扫描器已停止 (累计: ${stats.totalScans}轮, 修复${stats.fixedStuck}个卡死, 入队${stats.enqueuedSuccess}个遗漏)`);
  261. }
  262. export function getAudioScannerStats(): ScanStats {
  263. return { ...stats };
  264. }