/** * 一次性数据修复脚本:把所有以 /uploads/... 开头的本地 audioUrl * 重新上传到 OSS 并更新 DB audioUrl。 * * 背景:tts.service.ts 老版本对 book chapter 模式只写本地路径, * player.service.ts mergeChapterAudios 清理逻辑误删了小节本地副本, * 导致线上任何本地路径 audioUrl 全部 404。 * 修复后 tts.service 已改为始终走 OSS,但存量 DB 仍含坏数据。 * * 用法:cd server && npx tsx scripts/fix-local-audio-urls.ts [--dry-run] * 必须先在 .env.production 或运行 env 加载 STORAGE_TYPE=oss 等 OSS 配置, * 否则 storageService 会走 uploadToLocal,结果毫无变化。 */ import { prisma } from '../src/models'; import { storageService } from '../src/services/storage.service'; import { AudioMerger } from '../src/modules/tts/audio-merger'; import path from 'path'; import fs from 'fs'; const LOCAL_PREFIX = '/uploads/'; async function findLocalFile(audioUrl: string, chapterId: number): Promise { // 优先用 audioUrl 拼出的本地路径 const fromUrl = path.join(process.cwd(), audioUrl.replace(/^\//, '')); if (fs.existsSync(fromUrl)) return fromUrl; // 兜底: 标准本地副本路径 const fallback = path.join(process.cwd(), 'uploads', 'chapters', String(chapterId), 'output.mp3'); if (fs.existsSync(fallback)) return fallback; // 合并产物路径 const merged = path.join(process.cwd(), 'uploads', 'merged', 'chapter', String(chapterId), 'merged.mp3'); if (fs.existsSync(merged)) return merged; const mergedOld = path.join(process.cwd(), 'uploads', 'merged', String(chapterId), 'merged.mp3'); if (fs.existsSync(mergedOld)) return mergedOld; return null; } async function main() { const dryRun = process.argv.includes('--dry-run'); console.log(`[FixLocalUrls] mode=${dryRun ? 'DRY-RUN' : 'WRITE'}`); console.log(`[FixLocalUrls] storageType=${storageService.getStorageType()}`); if (storageService.getStorageType() !== 'oss' && !dryRun) { console.error('[FixLocalUrls] STORAGE_TYPE 不是 oss,先确认 OSS 链路可用'); console.error(' 跑: STORAGE_TYPE=oss OSS_ACCESS_KEY_ID=... npx tsx scripts/fix-local-audio-urls.ts'); process.exit(1); } const chapters = await prisma.bookChapter.findMany({ where: { audioUrl: { startsWith: LOCAL_PREFIX } }, select: { id: true, audioUrl: true, audioDuration: true, bookId: true, parentId: true, level: true }, orderBy: { id: 'asc' }, }); console.log(`[FixLocalUrls] 扫描到 ${chapters.length} 个本地 audioUrl 章节`); let ok = 0; let missing = 0; let failed = 0; for (const ch of chapters) { const localFile = await findLocalFile(ch.audioUrl!, ch.id); if (!localFile) { console.warn(`[FixLocalUrls] ⚠️ 章节 ${ch.id} 本地文件不存在: ${ch.audioUrl}`); missing++; continue; } try { const size = fs.statSync(localFile).size; const duration = await AudioMerger.getDuration(localFile).catch(() => ch.audioDuration || 0); if (dryRun) { console.log(`[FixLocalUrls] [DRY] 章节 ${ch.id} 本地=${localFile} (${size}B) -> OSS`); ok++; continue; } // 走 storageService.uploadAudio 用 URL-based key(按 audioId),保持与其他上传路径一致 // 这里直接用 ossService.uploadFile 是因为 chapter 没有 audioId 概念 const { ossService } = await import('../src/services/oss.service'); const objectKey = `audio/repair/chapter/${ch.id}/output.mp3`; const newUrl = await ossService.uploadFile(localFile, objectKey); await prisma.bookChapter.update({ where: { id: ch.id }, data: { audioUrl: newUrl, audioDuration: Math.round(duration) }, }); console.log(`[FixLocalUrls] ✅ 章节 ${ch.id}: ${ch.audioUrl} -> ${newUrl}`); ok++; } catch (e: any) { console.error(`[FixLocalUrls] ❌ 章节 ${ch.id} 失败: ${e.message}`); failed++; } } console.log(`\n[FixLocalUrls] 完成: 成功=${ok} 缺文件=${missing} 失败=${failed} 共=${chapters.length}`); await prisma.$disconnect(); } main().catch(e => { console.error(e); process.exit(1); });