| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105 |
- /**
- * 一次性数据修复脚本:把所有以 /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<string | null> {
- // 优先用 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); });
|