273 lines
8.7 KiB
TypeScript
273 lines
8.7 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
||
import { prisma } from '@/lib/prisma';
|
||
import https from 'https';
|
||
import fs from 'fs';
|
||
import path from 'path';
|
||
import bcrypt from 'bcryptjs';
|
||
|
||
// 配置
|
||
const UPLOADS_DIR = '/app/uploads';
|
||
const S3_BUCKET_URL = process.env.RESTORE_S3_BUCKET_URL as string;
|
||
const S3_PUBLIC_URL = process.env.RESTORE_S3_PUBLIC_URL as string;
|
||
const RESTORE_DATA_TOKEN = process.env.RESTORE_DATA_TOKEN?.trim() || '';
|
||
|
||
// 默认配置
|
||
const ADMIN_EMAIL = process.env.RESTORE_ADMIN_EMAIL?.trim() || 'admin@local.flnxi';
|
||
const ADMIN_USERNAME = process.env.RESTORE_ADMIN_USERNAME?.trim() || 'admin';
|
||
const ADMIN_PASSWORD = process.env.RESTORE_ADMIN_PASSWORD?.trim() || '';
|
||
const DEFAULT_CATEGORY = '默认店铺';
|
||
const DEFAULT_SCENE = '默认场景';
|
||
const DEFAULT_RENDERER = 'CR';
|
||
const DEFAULT_MATERIAL_TYPE = '3DMAX';
|
||
|
||
// 解析S3 XML
|
||
function parseS3XML(xml: string): string[] {
|
||
const keys: string[] = [];
|
||
const keyRegex = /<Key>([^<]+)<\/Key>/g;
|
||
let match;
|
||
while ((match = keyRegex.exec(xml)) !== null) {
|
||
keys.push(match[1]);
|
||
}
|
||
return keys;
|
||
}
|
||
|
||
// 获取S3预览图列表
|
||
async function getS3PreviewFiles(): Promise<string[]> {
|
||
return new Promise((resolve, reject) => {
|
||
https.get(S3_BUCKET_URL, (res) => {
|
||
let data = '';
|
||
res.on('data', (chunk) => { data += chunk; });
|
||
res.on('end', () => {
|
||
const allKeys = parseS3XML(data);
|
||
const previewFiles = allKeys.filter(key => key.includes('preview-'));
|
||
resolve(previewFiles);
|
||
});
|
||
}).on('error', reject);
|
||
});
|
||
}
|
||
|
||
// 获取本地素材文件
|
||
function getLocalMaterialFiles(uploadsDir: string): string[] {
|
||
try {
|
||
const files = fs.readdirSync(uploadsDir);
|
||
return files.filter(f =>
|
||
f.startsWith('zip-') && (f.endsWith('.zip') || f.endsWith('.rar') || f.endsWith('.7z'))
|
||
);
|
||
} catch (error: any) {
|
||
console.error('读取本地文件失败:', error.message);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// 提取时间戳
|
||
function extractTimestamp(filename: string): number | null {
|
||
const match = filename.match(/-(\d{13})-/);
|
||
return match ? parseInt(match[1]) : null;
|
||
}
|
||
|
||
// 提取原始名称
|
||
function extractOriginalName(filename: string): string {
|
||
const parts = filename.split('-');
|
||
if (parts.length >= 3) {
|
||
const namePart = parts.slice(2).join('-');
|
||
return namePart.replace(/\.(zip|rar|7z)$/i, '');
|
||
}
|
||
return filename;
|
||
}
|
||
|
||
// 匹配文件
|
||
interface Match {
|
||
previewFile: string | null;
|
||
materialFile: string;
|
||
timestamp: number;
|
||
}
|
||
|
||
function matchFiles(previewFiles: string[], materialFiles: string[]): Match[] {
|
||
const matches: Match[] = [];
|
||
|
||
for (const materialFile of materialFiles) {
|
||
const materialTimestamp = extractTimestamp(materialFile);
|
||
if (!materialTimestamp) continue;
|
||
|
||
const matchedPreview = previewFiles.find(previewFile => {
|
||
const previewTimestamp = extractTimestamp(previewFile);
|
||
if (!previewTimestamp) return false;
|
||
return Math.abs(previewTimestamp - materialTimestamp) < 1000;
|
||
});
|
||
|
||
matches.push({
|
||
previewFile: matchedPreview || null,
|
||
materialFile: materialFile,
|
||
timestamp: materialTimestamp
|
||
});
|
||
}
|
||
|
||
return matches;
|
||
}
|
||
|
||
export async function POST(req: NextRequest) {
|
||
try {
|
||
if (!RESTORE_DATA_TOKEN) {
|
||
return NextResponse.json({
|
||
success: false,
|
||
message: '未配置 RESTORE_DATA_TOKEN,恢复接口已禁用',
|
||
}, { status: 503 });
|
||
}
|
||
|
||
const requestToken = req.headers.get('x-restore-data-token')?.trim() || '';
|
||
if (requestToken !== RESTORE_DATA_TOKEN) {
|
||
return NextResponse.json({
|
||
success: false,
|
||
message: '恢复令牌无效',
|
||
}, { status: 401 });
|
||
}
|
||
|
||
if (!ADMIN_PASSWORD) {
|
||
return NextResponse.json({
|
||
success: false,
|
||
message: '未配置 RESTORE_ADMIN_PASSWORD,无法创建恢复管理员',
|
||
}, { status: 503 });
|
||
}
|
||
|
||
const output: string[] = [];
|
||
|
||
output.push('🚀 开始数据恢复...\n');
|
||
|
||
// 创建管理员用户
|
||
output.push('📝 步骤1: 创建管理员用户');
|
||
let admin;
|
||
try {
|
||
const hashedPassword = await bcrypt.hash(ADMIN_PASSWORD, 10);
|
||
admin = await prisma.user.create({
|
||
data: {
|
||
email: ADMIN_EMAIL,
|
||
username: ADMIN_USERNAME,
|
||
password: hashedPassword,
|
||
role: 'ADMIN',
|
||
isActive: true,
|
||
canDownload: true
|
||
}
|
||
});
|
||
output.push(`✅ 创建管理员用户: ${ADMIN_USERNAME}`);
|
||
} catch (error: any) {
|
||
if (error.code === 'P2002') {
|
||
admin = await prisma.user.findUnique({
|
||
where: { email: ADMIN_EMAIL }
|
||
});
|
||
output.push(`ℹ️ 管理员用户已存在`);
|
||
} else {
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
if (!admin) {
|
||
throw new Error('无法创建或找到管理员用户');
|
||
}
|
||
|
||
// 创建默认分类
|
||
output.push('\n📝 步骤2: 创建默认分类和场景');
|
||
try {
|
||
await prisma.category.create({ data: { name: DEFAULT_CATEGORY } });
|
||
output.push(`✅ 创建默认分类: ${DEFAULT_CATEGORY}`);
|
||
} catch (error: any) {
|
||
if (error.code === 'P2002') {
|
||
output.push(`ℹ️ 默认分类已存在`);
|
||
}
|
||
}
|
||
|
||
// 创建默认场景
|
||
try {
|
||
await prisma.scene.create({ data: { name: DEFAULT_SCENE } });
|
||
output.push(`✅ 创建默认场景: ${DEFAULT_SCENE}`);
|
||
} catch (error: any) {
|
||
if (error.code === 'P2002') {
|
||
output.push(`ℹ️ 默认场景已存在`);
|
||
}
|
||
}
|
||
|
||
// 获取S3预览图
|
||
output.push('\n📝 步骤3: 获取S3预览图列表');
|
||
const previewFiles = await getS3PreviewFiles();
|
||
output.push(`✅ 从S3找到 ${previewFiles.length} 个预览图`);
|
||
|
||
// 获取本地素材文件
|
||
output.push('\n📝 步骤4: 扫描本地素材文件');
|
||
const materialFiles = getLocalMaterialFiles(UPLOADS_DIR);
|
||
output.push(`✅ 从本地找到 ${materialFiles.length} 个素材文件`);
|
||
|
||
// 匹配文件
|
||
output.push('\n📝 步骤5: 匹配预览图和素材文件');
|
||
const matches = matchFiles(previewFiles, materialFiles);
|
||
const withPreview = matches.filter(m => m.previewFile).length;
|
||
const withoutPreview = matches.length - withPreview;
|
||
output.push(`✅ 总计 ${matches.length} 个素材 (${withPreview} 个有预览图, ${withoutPreview} 个无预览图)`);
|
||
|
||
// 恢复素材数据
|
||
output.push('\n📝 步骤6: 恢复素材数据到数据库');
|
||
const HOST_UPLOADS_DIR = '/mnt/PoolOne/apps/youyidian-3d/uploads';
|
||
let successCount = 0;
|
||
let failCount = 0;
|
||
|
||
for (const match of matches) {
|
||
try {
|
||
const originalName = extractOriginalName(match.materialFile);
|
||
const containerPath = path.join(UPLOADS_DIR, match.materialFile);
|
||
const hostPath = path.join(HOST_UPLOADS_DIR, match.materialFile);
|
||
const stats = fs.statSync(containerPath);
|
||
|
||
await prisma.material.create({
|
||
data: {
|
||
previewUrl: match.previewFile ? `${S3_PUBLIC_URL}${match.previewFile}` : '',
|
||
productModel: originalName.substring(0, 50),
|
||
categoryName: DEFAULT_CATEGORY,
|
||
sceneName: DEFAULT_SCENE,
|
||
materialType: DEFAULT_MATERIAL_TYPE,
|
||
renderer: DEFAULT_RENDERER,
|
||
authorName: ADMIN_USERNAME,
|
||
zipFileName: match.materialFile,
|
||
zipFilePath: hostPath,
|
||
zipFileSize: BigInt(stats.size),
|
||
downloadCount: 0,
|
||
likeCount: 0,
|
||
userId: admin.id
|
||
}
|
||
});
|
||
|
||
successCount++;
|
||
if (successCount % 10 === 0) {
|
||
output.push(`✅ 已恢复: ${successCount}/${matches.length}`);
|
||
}
|
||
} catch (error: any) {
|
||
failCount++;
|
||
output.push(`❌ 恢复失败: ${match.materialFile} - ${error.message}`);
|
||
}
|
||
}
|
||
|
||
output.push(`\n📊 恢复完成统计:`);
|
||
output.push(` ✅ 成功: ${successCount} 个`);
|
||
output.push(` ❌ 失败: ${failCount} 个`);
|
||
output.push(` 📁 总计: ${matches.length} 个文件`);
|
||
|
||
output.push(`\n🎉 数据恢复完成!`);
|
||
output.push(`\n📌 默认信息:`);
|
||
output.push(` 管理员账号: ${ADMIN_USERNAME}`);
|
||
output.push(` 管理员密码: 已使用 RESTORE_ADMIN_PASSWORD 配置`);
|
||
output.push(` 默认分类: ${DEFAULT_CATEGORY}`);
|
||
output.push(` 默认场景: ${DEFAULT_SCENE}`);
|
||
output.push(`\n⚠️ 提示: 请登录后台修改素材的SPU、分类、场景等信息`);
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
message: output.join('\n'),
|
||
stats: { successCount, failCount, total: matches.length }
|
||
});
|
||
|
||
} catch (error: any) {
|
||
return NextResponse.json({
|
||
success: false,
|
||
message: error.message
|
||
}, { status: 500 });
|
||
}
|
||
}
|
||
|