241 lines
7.7 KiB
TypeScript
241 lines
7.7 KiB
TypeScript
// 数据恢复API - 从S3和本地文件恢复素材数据
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import { prisma } from '@/lib/prisma';
|
||
import https from 'https';
|
||
import fs from 'fs';
|
||
import path from 'path';
|
||
import crypto from 'crypto';
|
||
|
||
const UPLOADS_DIR = '/app/uploads';
|
||
const ADMIN_EMAIL = 'admin';
|
||
const ADMIN_USERNAME = 'admin';
|
||
const ADMIN_PASSWORD = 'admin123456';
|
||
const DEFAULT_CATEGORY = '默认店铺';
|
||
const DEFAULT_SCENE = '默认场景';
|
||
|
||
// 从S3获取预览图列表
|
||
async function fetchS3PreviewList(): Promise<string[]> {
|
||
return new Promise((resolve, reject) => {
|
||
https.get('https://s3.1080p.hk/jsxiangche/?max-keys=2000', (res) => {
|
||
let data = '';
|
||
res.on('data', (chunk) => { data += chunk; });
|
||
res.on('end', () => {
|
||
const keys = parseS3ListResponse(data);
|
||
resolve(keys.filter(key => key.includes('preview-')));
|
||
});
|
||
}).on('error', reject);
|
||
});
|
||
}
|
||
|
||
// 解析S3列表响应
|
||
function parseS3ListResponse(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;
|
||
}
|
||
|
||
// 从文件名提取时间戳
|
||
function extractTimestamp(filename: string): number | null {
|
||
const match = filename.match(/-(\d{13})-/);
|
||
return match ? parseInt(match[1]) : null;
|
||
}
|
||
|
||
// 扫描本地素材文件
|
||
function scanLocalMaterials(dir: string): string[] {
|
||
try {
|
||
return fs.readdirSync(dir).filter(file =>
|
||
file.startsWith('zip-') &&
|
||
(file.endsWith('.zip') || file.endsWith('.rar') || file.endsWith('.7z'))
|
||
);
|
||
} catch (error: any) {
|
||
console.error('读取本地文件失败:', error.message);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// 匹配预览图和素材文件
|
||
function matchPreviewsWithMaterials(
|
||
previews: string[],
|
||
materials: string[]
|
||
): Array<{ previewFile: string | null; materialFile: string; timestamp: number | null }> {
|
||
const matches: Array<{ previewFile: string | null; materialFile: string; timestamp: number | null }> = [];
|
||
|
||
for (const material of materials) {
|
||
const materialTimestamp = extractTimestamp(material);
|
||
if (!materialTimestamp) continue;
|
||
|
||
// 查找时间戳相近的预览图(允许1秒误差)
|
||
const matchedPreview = previews.find(preview => {
|
||
const previewTimestamp = extractTimestamp(preview);
|
||
if (!previewTimestamp) return false;
|
||
return Math.abs(previewTimestamp - materialTimestamp) < 1000;
|
||
});
|
||
|
||
matches.push({
|
||
previewFile: matchedPreview || null,
|
||
materialFile: material,
|
||
timestamp: materialTimestamp,
|
||
});
|
||
}
|
||
|
||
return matches;
|
||
}
|
||
|
||
// 从文件名提取产品名称
|
||
function extractProductName(filename: string): string {
|
||
const parts = filename.split('-');
|
||
if (parts.length >= 3) {
|
||
return parts.slice(2).join('-').replace(/\.(zip|rar|7z)$/i, '');
|
||
}
|
||
return filename;
|
||
}
|
||
|
||
// POST - 执行数据恢复
|
||
export async function POST(request: NextRequest) {
|
||
try {
|
||
let adminUser;
|
||
const logs: string[] = [];
|
||
|
||
logs.push('🚀 开始数据恢复...\n');
|
||
|
||
// 步骤1: 创建管理员用户
|
||
logs.push('📝 步骤1: 创建管理员用户');
|
||
try {
|
||
const hashedPassword = crypto.createHash('sha256').update(ADMIN_PASSWORD).digest('hex');
|
||
adminUser = await prisma.user.create({
|
||
data: {
|
||
email: ADMIN_EMAIL,
|
||
username: ADMIN_USERNAME,
|
||
password: hashedPassword,
|
||
role: 'ADMIN',
|
||
isActive: true,
|
||
canDownload: true,
|
||
},
|
||
});
|
||
logs.push(`✅ 创建管理员用户: ${ADMIN_USERNAME}`);
|
||
} catch (error: any) {
|
||
if (error.code === 'P2002') {
|
||
adminUser = await prisma.user.findUnique({ where: { email: ADMIN_EMAIL } });
|
||
logs.push(`ℹ️ 管理员用户已存在`);
|
||
} else {
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
if (!adminUser) {
|
||
throw new Error('无法创建或找到管理员用户');
|
||
}
|
||
|
||
// 步骤2: 创建默认分类和场景
|
||
logs.push('\n📝 步骤2: 创建默认分类和场景');
|
||
try {
|
||
await prisma.category.create({ data: { name: DEFAULT_CATEGORY } });
|
||
logs.push(`✅ 创建默认分类: ${DEFAULT_CATEGORY}`);
|
||
} catch (error: any) {
|
||
if (error.code === 'P2002') {
|
||
logs.push(`ℹ️ 默认分类已存在`);
|
||
}
|
||
}
|
||
|
||
try {
|
||
await prisma.scene.create({ data: { name: DEFAULT_SCENE } });
|
||
logs.push(`✅ 创建默认场景: ${DEFAULT_SCENE}`);
|
||
} catch (error: any) {
|
||
if (error.code === 'P2002') {
|
||
logs.push(`ℹ️ 默认场景已存在`);
|
||
}
|
||
}
|
||
|
||
// 步骤3: 获取S3预览图列表
|
||
logs.push('\n📝 步骤3: 获取S3预览图列表');
|
||
const s3Previews = await fetchS3PreviewList();
|
||
logs.push(`✅ 从S3找到 ${s3Previews.length} 个预览图`);
|
||
|
||
// 步骤4: 扫描本地素材文件
|
||
logs.push('\n📝 步骤4: 扫描本地素材文件');
|
||
const localMaterials = scanLocalMaterials(UPLOADS_DIR);
|
||
logs.push(`✅ 从本地找到 ${localMaterials.length} 个素材文件`);
|
||
|
||
// 步骤5: 匹配预览图和素材文件
|
||
logs.push('\n📝 步骤5: 匹配预览图和素材文件');
|
||
const matches = matchPreviewsWithMaterials(s3Previews, localMaterials);
|
||
const withPreview = matches.filter(m => m.previewFile).length;
|
||
const withoutPreview = matches.length - withPreview;
|
||
logs.push(`✅ 总计 ${matches.length} 个素材 (${withPreview} 个有预览图, ${withoutPreview} 个无预览图)`);
|
||
|
||
// 步骤6: 恢复素材数据到数据库
|
||
logs.push('\n📝 步骤6: 恢复素材数据到数据库');
|
||
let successCount = 0;
|
||
let failCount = 0;
|
||
|
||
for (const match of matches) {
|
||
try {
|
||
const productName = extractProductName(match.materialFile);
|
||
const localPath = path.join(UPLOADS_DIR, match.materialFile);
|
||
const dockerPath = path.join('/mnt/PoolOne/apps/youyidian-3d/uploads', match.materialFile);
|
||
const stats = fs.statSync(localPath);
|
||
|
||
await prisma.material.create({
|
||
data: {
|
||
previewUrl: match.previewFile ? `https://s3.1080p.hk/jsxiangche/${match.previewFile}` : '',
|
||
productModel: productName.substring(0, 50),
|
||
categoryName: DEFAULT_CATEGORY,
|
||
sceneName: DEFAULT_SCENE,
|
||
materialType: '3D场景',
|
||
renderer: 'CR',
|
||
authorName: ADMIN_USERNAME,
|
||
zipFileName: match.materialFile,
|
||
zipFilePath: dockerPath,
|
||
zipFileSize: BigInt(stats.size),
|
||
downloadCount: 0,
|
||
likeCount: 0,
|
||
userId: adminUser.id,
|
||
},
|
||
});
|
||
|
||
successCount++;
|
||
if (successCount % 10 === 0) {
|
||
logs.push(`✅ 已恢复: ${successCount}/${matches.length}`);
|
||
}
|
||
} catch (error: any) {
|
||
failCount++;
|
||
logs.push(`❌ 恢复失败: ${match.materialFile} - ${error.message}`);
|
||
}
|
||
}
|
||
|
||
logs.push(`\n📊 恢复完成统计:`);
|
||
logs.push(` ✅ 成功: ${successCount} 个`);
|
||
logs.push(` ❌ 失败: ${failCount} 个`);
|
||
logs.push(` 📁 总计: ${matches.length} 个文件`);
|
||
logs.push(`\n🎉 数据恢复完成!`);
|
||
logs.push(`\n📌 默认信息:`);
|
||
logs.push(` 管理员账号: ${ADMIN_USERNAME}`);
|
||
logs.push(` 管理员密码: ${ADMIN_PASSWORD}`);
|
||
logs.push(` 默认分类: ${DEFAULT_CATEGORY}`);
|
||
logs.push(` 默认场景: ${DEFAULT_SCENE}`);
|
||
logs.push(`\n⚠️ 提示: 请登录后台修改素材的SPU、分类、场景等信息`);
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
message: logs.join('\n'),
|
||
stats: {
|
||
successCount,
|
||
failCount,
|
||
total: matches.length,
|
||
},
|
||
});
|
||
} catch (error: any) {
|
||
return NextResponse.json(
|
||
{
|
||
success: false,
|
||
message: error.message,
|
||
},
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|