265 lines
9.0 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.

// 数据恢复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 }
);
}
}