265 lines
9.0 KiB
TypeScript
265 lines
9.0 KiB
TypeScript
// 数据恢复API - 从S3和本地文件恢复素材数据
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import { getServerSession } from 'next-auth';
|
||
import { prisma } from '@/lib/prisma';
|
||
import https from 'https';
|
||
import fs from 'fs';
|
||
import path from 'path';
|
||
import { authOptions } from '@/lib/auth';
|
||
import { hasSystemAdminPermission } from '@/lib/department-permission';
|
||
|
||
const UPLOADS_DIR = '/app/uploads';
|
||
const DEFAULT_CATEGORY = '默认店铺';
|
||
const DEFAULT_SCENE = '默认场景';
|
||
const MAX_RESTORE_ITEMS = 500;
|
||
const RATE_LIMIT_WINDOW_MS = 30 * 60 * 1000;
|
||
const RATE_LIMIT_MAX_REQUESTS = 2;
|
||
const requestCounts = new Map<string, { count: number; resetAt: number }>();
|
||
|
||
function consumeRateLimit(userId: string): number | null {
|
||
const now = Date.now();
|
||
const current = requestCounts.get(userId);
|
||
if (!current || current.resetAt <= now) {
|
||
requestCounts.set(userId, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
|
||
return null;
|
||
}
|
||
if (current.count >= RATE_LIMIT_MAX_REQUESTS) {
|
||
return Math.max(1, Math.ceil((current.resetAt - now) / 1000));
|
||
}
|
||
current.count += 1;
|
||
return null;
|
||
}
|
||
|
||
// 从S3获取预览图列表
|
||
async function fetchS3PreviewList(maxKeys: number): Promise<string[]> {
|
||
return new Promise((resolve, reject) => {
|
||
https.get(`https://s3.1080p.hk/jsxiangche/?max-keys=${maxKeys}`, (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, limit: number): string[] {
|
||
try {
|
||
return fs.readdirSync(dir).filter(file =>
|
||
file.startsWith('zip-') &&
|
||
(file.endsWith('.zip') || file.endsWith('.rar') || file.endsWith('.7z'))
|
||
).slice(0, limit);
|
||
} 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 {
|
||
const session = await getServerSession(authOptions);
|
||
if (!session?.user) {
|
||
return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||
}
|
||
|
||
const sessionUser = session.user as any;
|
||
if (!hasSystemAdminPermission(sessionUser.role, Boolean(sessionUser.isSuperAdmin))) {
|
||
return NextResponse.json({ error: '仅系统管理员可执行数据恢复' }, { status: 403 });
|
||
}
|
||
|
||
const retryAfter = consumeRateLimit(String(sessionUser.id || 'unknown'));
|
||
if (retryAfter) {
|
||
return NextResponse.json(
|
||
{ error: '数据恢复请求过于频繁,请稍后再试' },
|
||
{ status: 429, headers: { 'Retry-After': String(retryAfter) } },
|
||
);
|
||
}
|
||
|
||
const limit = Number(request.nextUrl.searchParams.get('limit') || '100');
|
||
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_RESTORE_ITEMS) {
|
||
return NextResponse.json(
|
||
{ error: `limit 必须是 1 到 ${MAX_RESTORE_ITEMS} 之间的整数` },
|
||
{ status: 400 },
|
||
);
|
||
}
|
||
|
||
const adminUser = await prisma.user.findUnique({
|
||
where: { id: String(sessionUser.id) },
|
||
select: { id: true, username: true },
|
||
});
|
||
if (!adminUser) {
|
||
return NextResponse.json({ error: '当前管理员不存在' }, { status: 403 });
|
||
}
|
||
|
||
const logs: string[] = [];
|
||
|
||
logs.push('🚀 开始数据恢复...\n');
|
||
|
||
logs.push(`📝 步骤1: 使用当前管理员 ${adminUser.username} 作为恢复数据归属人`);
|
||
|
||
// 步骤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 candidateLimit = Math.min(limit * 4, 2000);
|
||
const s3Previews = await fetchS3PreviewList(candidateLimit);
|
||
logs.push(`✅ 从S3找到 ${s3Previews.length} 个预览图`);
|
||
|
||
// 步骤4: 扫描本地素材文件
|
||
logs.push('\n📝 步骤4: 扫描本地素材文件');
|
||
const localMaterials = scanLocalMaterials(UPLOADS_DIR, candidateLimit);
|
||
logs.push(`✅ 从本地找到 ${localMaterials.length} 个素材文件`);
|
||
|
||
// 步骤5: 匹配预览图和素材文件
|
||
logs.push('\n📝 步骤5: 匹配预览图和素材文件');
|
||
const matches = matchPreviewsWithMaterials(s3Previews, localMaterials).slice(0, limit);
|
||
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: adminUser.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(` 数据归属管理员: ${adminUser.username}`);
|
||
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 }
|
||
);
|
||
}
|
||
}
|