220 lines
8.0 KiB
JavaScript
220 lines
8.0 KiB
JavaScript
"use strict";
|
||
/**
|
||
* 恢复缺失的音频文件脚本
|
||
*
|
||
* 目标:将 S3 上的音频文件下载到本地 uploads 目录,并更新数据库
|
||
*
|
||
* 说明:
|
||
* - 处理所有音乐类型的素材
|
||
* - 检查本地文件是否存在,如果不存在则尝试从 S3 恢复
|
||
* - 由于原始的 S3 URL 可能已丢失,会尝试匹配 S3 上的所有音频文件
|
||
*
|
||
* ⚠️ 重要说明:
|
||
* - 本脚本仅从 S3 下载文件到本地,不会删除 S3 上的任何文件
|
||
* - S3 存储桶内的源文件将完整保留,确保迁移失败时源文件不丢失
|
||
* - 如果需要清理 S3 文件,请在确认迁移完全成功后再手动执行
|
||
*/
|
||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||
};
|
||
Object.defineProperty(exports, "__esModule", { value: true });
|
||
const client_1 = require("@prisma/client");
|
||
const client_s3_1 = require("@aws-sdk/client-s3");
|
||
const promises_1 = require("fs/promises");
|
||
const fs_1 = require("fs");
|
||
const path_1 = __importDefault(require("path"));
|
||
const prisma = new client_1.PrismaClient();
|
||
// S3 客户端配置
|
||
const s3Client = new client_s3_1.S3Client({
|
||
endpoint: process.env.S3_ENDPOINT || '',
|
||
region: process.env.S3_REGION || 'us-east-1',
|
||
credentials: {
|
||
accessKeyId: process.env.S3_ACCESS_KEY_ID,
|
||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
|
||
},
|
||
forcePathStyle: true,
|
||
requestHandler: {
|
||
requestTimeout: 300000, // 5分钟超时
|
||
connectionTimeout: 30000, // 30秒连接超时
|
||
},
|
||
maxAttempts: 5,
|
||
retryMode: 'adaptive',
|
||
});
|
||
const S3_BUCKET = process.env.S3_BUCKET || '';
|
||
const S3_PUBLIC_DOMAIN = process.env.S3_PUBLIC_DOMAIN || '';
|
||
const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads';
|
||
/**
|
||
* 从 S3 下载整个对象为 Buffer
|
||
*/
|
||
async function downloadS3Object(key) {
|
||
const cmd = new client_s3_1.GetObjectCommand({
|
||
Bucket: S3_BUCKET,
|
||
Key: key,
|
||
});
|
||
const res = await s3Client.send(cmd);
|
||
const body = res.Body;
|
||
if (!body) {
|
||
throw new Error('S3 对象 Body 为空');
|
||
}
|
||
const chunks = [];
|
||
for await (const chunk of body) {
|
||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||
}
|
||
return Buffer.concat(chunks);
|
||
}
|
||
/**
|
||
* 列出 S3 上的所有音频文件
|
||
*/
|
||
async function listS3AudioFiles() {
|
||
const audioKeys = [];
|
||
let continuationToken = undefined;
|
||
do {
|
||
const listCommand = new client_s3_1.ListObjectsV2Command({
|
||
Bucket: S3_BUCKET,
|
||
Prefix: '由壹点AI/',
|
||
ContinuationToken: continuationToken,
|
||
MaxKeys: 1000,
|
||
});
|
||
const response = await s3Client.send(listCommand);
|
||
const audioFiles = (response.Contents || []).filter((obj) => obj.Key && (obj.Key.endsWith('.mp3') || obj.Key.endsWith('.wav') || obj.Key.endsWith('.m4a')));
|
||
audioKeys.push(...audioFiles.map((obj) => obj.Key));
|
||
continuationToken = response.NextContinuationToken;
|
||
} while (continuationToken);
|
||
return audioKeys;
|
||
}
|
||
/**
|
||
* 恢复一个音频文件
|
||
*/
|
||
async function restoreOneAudio(materialId, currentFileName, s3Key) {
|
||
const localFileName = currentFileName; // 使用数据库中的文件名
|
||
const localFullPath = path_1.default.join(UPLOAD_DIR, localFileName);
|
||
// 如果文件已存在,跳过
|
||
if ((0, fs_1.existsSync)(localFullPath)) {
|
||
console.log(` ✅ 文件已存在: ${localFileName}`);
|
||
return true;
|
||
}
|
||
try {
|
||
console.log(` 📥 从 S3 下载: ${s3Key}`);
|
||
const buffer = await downloadS3Object(s3Key);
|
||
console.log(` 📦 下载完成,大小:${buffer.length} 字节`);
|
||
// 确保 uploads 目录存在
|
||
if (!(0, fs_1.existsSync)(UPLOAD_DIR)) {
|
||
await (0, promises_1.mkdir)(UPLOAD_DIR, { recursive: true });
|
||
}
|
||
// 写入本地(使用数据库中的文件名)
|
||
console.log(` 💾 写入本地文件: ${localFullPath}`);
|
||
await (0, promises_1.writeFile)(localFullPath, buffer);
|
||
// 校验
|
||
const st = await (0, promises_1.stat)(localFullPath);
|
||
if (st.size !== buffer.length) {
|
||
throw new Error(`本地文件大小不匹配,期望 ${buffer.length} 实际 ${st.size}`);
|
||
}
|
||
// 更新数据库的 zipFilePath(使用 Linux 路径分隔符)
|
||
const zipFilePath = localFullPath.replace(/\\/g, '/');
|
||
await prisma.material.update({
|
||
where: { id: materialId },
|
||
data: {
|
||
zipFilePath: zipFilePath,
|
||
},
|
||
});
|
||
console.log(` ✅ 恢复完成: ${localFileName}`);
|
||
return true;
|
||
}
|
||
catch (error) {
|
||
console.error(` ❌ 恢复失败: ${error.message}`);
|
||
return false;
|
||
}
|
||
}
|
||
async function main() {
|
||
console.log('🚀 开始恢复缺失的音频文件...\n');
|
||
if (!S3_BUCKET || !S3_PUBLIC_DOMAIN) {
|
||
console.error('❌ 缺少必要环境变量:S3_BUCKET 或 S3_PUBLIC_DOMAIN');
|
||
process.exit(1);
|
||
}
|
||
console.log('📋 配置信息:');
|
||
console.log(` S3_BUCKET: ${S3_BUCKET}`);
|
||
console.log(` S3_PUBLIC_DOMAIN: ${S3_PUBLIC_DOMAIN}`);
|
||
console.log(` UPLOAD_DIR: ${UPLOAD_DIR}`);
|
||
console.log('');
|
||
// 查询所有音乐素材
|
||
const materials = await prisma.material.findMany({
|
||
where: {
|
||
deletedAt: null,
|
||
materialType: '音乐',
|
||
},
|
||
select: {
|
||
id: true,
|
||
zipFileName: true,
|
||
zipFilePath: true,
|
||
},
|
||
orderBy: { createdAt: 'asc' },
|
||
});
|
||
console.log(`🔍 找到 ${materials.length} 个音乐素材\n`);
|
||
// 列出 S3 上的所有音频文件
|
||
console.log('📋 列出 S3 上的音频文件...');
|
||
const s3AudioKeys = await listS3AudioFiles();
|
||
console.log(`📦 S3 上共有 ${s3AudioKeys.length} 个音频文件\n`);
|
||
if (s3AudioKeys.length === 0) {
|
||
console.log('❌ S3 上没有音频文件');
|
||
return;
|
||
}
|
||
// 统计需要恢复的文件
|
||
let restored = 0;
|
||
let skipped = 0;
|
||
let failed = 0;
|
||
// 简单匹配策略:按照创建时间顺序匹配
|
||
// 由于无法精确匹配,我们将按照顺序分配 S3 文件给缺失文件的素材
|
||
const missingMaterials = materials.filter(m => {
|
||
if (!m.zipFileName)
|
||
return false;
|
||
const localPath = path_1.default.join(UPLOAD_DIR, m.zipFileName);
|
||
return !(0, fs_1.existsSync)(localPath);
|
||
});
|
||
console.log(`📊 需要恢复的文件数: ${missingMaterials.length}\n`);
|
||
if (missingMaterials.length === 0) {
|
||
console.log('✅ 所有文件都已存在,无需恢复');
|
||
return;
|
||
}
|
||
// 如果缺失的文件数超过 S3 上的文件数,只处理能匹配的部分
|
||
const maxToRestore = Math.min(missingMaterials.length, s3AudioKeys.length);
|
||
console.log(`⚠️ 注意:将按照顺序匹配前 ${maxToRestore} 个文件\n`);
|
||
for (let i = 0; i < maxToRestore; i++) {
|
||
const material = missingMaterials[i];
|
||
const s3Key = s3AudioKeys[i];
|
||
console.log(`\n==== [${i + 1}/${maxToRestore}] 恢复素材 ${material.id} ====`);
|
||
console.log(` 当前文件名: ${material.zipFileName}`);
|
||
console.log(` S3 Key: ${s3Key}`);
|
||
try {
|
||
const success = await restoreOneAudio(material.id, material.zipFileName, s3Key);
|
||
if (success) {
|
||
restored++;
|
||
}
|
||
else {
|
||
failed++;
|
||
}
|
||
}
|
||
catch (error) {
|
||
failed++;
|
||
console.error(` ❌ 恢复失败: ${error.message}`);
|
||
}
|
||
}
|
||
console.log('\n📊 恢复结果统计:');
|
||
console.log(` 成功恢复: ${restored}`);
|
||
console.log(` 跳过: ${skipped}`);
|
||
console.log(` 失败: ${failed}`);
|
||
if (restored > 0) {
|
||
console.log('\n✅ 音频文件恢复完成');
|
||
}
|
||
else {
|
||
console.log('\n⚠️ 没有成功恢复任何文件');
|
||
}
|
||
}
|
||
main()
|
||
.catch((err) => {
|
||
console.error('❌ 脚本执行出错:', err);
|
||
process.exit(1);
|
||
})
|
||
.finally(async () => {
|
||
await prisma.$disconnect();
|
||
});
|