2026-05-25 17:12:11 +08:00

273 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 });
}
}