264 lines
8.1 KiB
TypeScript
264 lines
8.1 KiB
TypeScript
/**
|
||
* 恢复缺失的音频文件脚本
|
||
*
|
||
* 目标:将 S3 上的音频文件下载到本地 uploads 目录,并更新数据库
|
||
*
|
||
* 说明:
|
||
* - 处理所有音乐类型的素材
|
||
* - 检查本地文件是否存在,如果不存在则尝试从 S3 恢复
|
||
* - 由于原始的 S3 URL 可能已丢失,会尝试匹配 S3 上的所有音频文件
|
||
*
|
||
* ⚠️ 重要说明:
|
||
* - 本脚本仅从 S3 下载文件到本地,不会删除 S3 上的任何文件
|
||
* - S3 存储桶内的源文件将完整保留,确保迁移失败时源文件不丢失
|
||
* - 如果需要清理 S3 文件,请在确认迁移完全成功后再手动执行
|
||
*/
|
||
|
||
import { PrismaClient } from '@prisma/client'
|
||
import { S3Client, GetObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3'
|
||
import { Readable } from 'stream'
|
||
import { mkdir, writeFile, stat as fsStat } from 'fs/promises'
|
||
import { existsSync } from 'fs'
|
||
import path from 'path'
|
||
|
||
const prisma = new PrismaClient()
|
||
|
||
// 注意:音频源文件在旧存储桶(jsxiangche)中,需要迁移到本地
|
||
// 新存储桶(gongxiangpan)只用于预览文件,不需要处理
|
||
|
||
// 旧存储桶配置(音频源文件所在位置)
|
||
// 注意:需要在运行脚本时设置环境变量 S3_ACCESS_KEY_ID_OLD 和 S3_SECRET_ACCESS_KEY_OLD
|
||
// 或者设置 S3_ACCESS_KEY_OLD 和 S3_SECRET_KEY_OLD
|
||
const OLD_S3_ENDPOINT = process.env.S3_ENDPOINT_OLD || 'https://s3.1080p.hk'
|
||
const OLD_S3_BUCKET = process.env.S3_BUCKET_OLD || 'jsxiangche'
|
||
const OLD_S3_ACCESS_KEY_ID = process.env.S3_ACCESS_KEY_ID_OLD || process.env.S3_ACCESS_KEY_OLD || ''
|
||
const OLD_S3_SECRET_ACCESS_KEY = process.env.S3_SECRET_ACCESS_KEY_OLD || process.env.S3_SECRET_KEY_OLD || ''
|
||
|
||
// S3 客户端配置(使用旧存储桶配置)
|
||
const s3Client = new S3Client({
|
||
endpoint: OLD_S3_ENDPOINT,
|
||
region: process.env.S3_REGION || 'us-east-1',
|
||
credentials: {
|
||
accessKeyId: OLD_S3_ACCESS_KEY_ID,
|
||
secretAccessKey: OLD_S3_SECRET_ACCESS_KEY,
|
||
},
|
||
forcePathStyle: true,
|
||
requestHandler: {
|
||
requestTimeout: 300000, // 5分钟超时
|
||
connectionTimeout: 30000, // 30秒连接超时
|
||
},
|
||
maxAttempts: 5,
|
||
retryMode: 'adaptive',
|
||
})
|
||
|
||
const S3_BUCKET = OLD_S3_BUCKET
|
||
const S3_PUBLIC_DOMAIN = `https://s3.1080p.hk/${OLD_S3_BUCKET}`
|
||
const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads'
|
||
|
||
/**
|
||
* 从 S3 下载整个对象为 Buffer
|
||
*/
|
||
async function downloadS3Object(key: string): Promise<Buffer> {
|
||
const cmd = new GetObjectCommand({
|
||
Bucket: S3_BUCKET,
|
||
Key: key,
|
||
})
|
||
|
||
const res = await s3Client.send(cmd)
|
||
const body = res.Body as Readable | undefined
|
||
if (!body) {
|
||
throw new Error('S3 对象 Body 为空')
|
||
}
|
||
|
||
const chunks: Buffer[] = []
|
||
for await (const chunk of body) {
|
||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
||
}
|
||
return Buffer.concat(chunks)
|
||
}
|
||
|
||
/**
|
||
* 列出 S3 上的所有音频文件
|
||
*/
|
||
async function listS3AudioFiles(): Promise<string[]> {
|
||
const audioKeys: string[] = []
|
||
let continuationToken: string | undefined = undefined
|
||
|
||
do {
|
||
const listCommand = new ListObjectsV2Command({
|
||
Bucket: S3_BUCKET,
|
||
Prefix: '由壹点AI/',
|
||
ContinuationToken: continuationToken,
|
||
MaxKeys: 1000,
|
||
})
|
||
|
||
const response = await s3Client.send(listCommand) as any
|
||
const audioFiles = (response.Contents || []).filter((obj: any) =>
|
||
obj.Key && (obj.Key.endsWith('.mp3') || obj.Key.endsWith('.wav') || obj.Key.endsWith('.m4a'))
|
||
)
|
||
|
||
audioKeys.push(...audioFiles.map((obj: any) => obj.Key!))
|
||
continuationToken = response.NextContinuationToken
|
||
} while (continuationToken)
|
||
|
||
return audioKeys
|
||
}
|
||
|
||
/**
|
||
* 恢复一个音频文件
|
||
*/
|
||
async function restoreOneAudio(materialId: string, currentFileName: string, s3Key: string): Promise<boolean> {
|
||
const localFileName = currentFileName // 使用数据库中的文件名
|
||
const localFullPath = path.join(UPLOAD_DIR, localFileName)
|
||
|
||
// 如果文件已存在,跳过
|
||
if (existsSync(localFullPath)) {
|
||
console.log(` ✅ 文件已存在: ${localFileName}`)
|
||
return true
|
||
}
|
||
|
||
try {
|
||
console.log(` 📥 从 S3 下载: ${s3Key}`)
|
||
const buffer = await downloadS3Object(s3Key)
|
||
console.log(` 📦 下载完成,大小:${buffer.length} 字节`)
|
||
|
||
// 确保 uploads 目录存在
|
||
if (!existsSync(UPLOAD_DIR)) {
|
||
await mkdir(UPLOAD_DIR, { recursive: true })
|
||
}
|
||
|
||
// 写入本地(使用数据库中的文件名)
|
||
console.log(` 💾 写入本地文件: ${localFullPath}`)
|
||
await writeFile(localFullPath, buffer)
|
||
|
||
// 校验
|
||
const st = await fsStat(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,
|
||
} as any,
|
||
})
|
||
|
||
console.log(` ✅ 恢复完成: ${localFileName}`)
|
||
return true
|
||
} catch (error: any) {
|
||
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: '音乐' as any,
|
||
},
|
||
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.join(UPLOAD_DIR, m.zipFileName)
|
||
return !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: any) {
|
||
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()
|
||
})
|
||
|