414 lines
14 KiB
TypeScript
414 lines
14 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
||
import { getServerSession } from 'next-auth';
|
||
import { authOptions } from '@/lib/auth';
|
||
import { prisma } from '@/lib/prisma';
|
||
import { uploadToS3 } from '@/lib/s3';
|
||
import { unlink, mkdir, writeFile } from 'fs/promises';
|
||
import path from 'path';
|
||
import archiver from 'archiver';
|
||
import { createWriteStream } from 'fs';
|
||
import { compressImage } from '@/lib/image-processor';
|
||
|
||
// 资产类型常量
|
||
const ASSET_TYPE = {
|
||
PREVIEW: 'preview',
|
||
ZIP: 'zip',
|
||
} as const;
|
||
|
||
// 生成zip压缩包(从chunk临时目录的原图文件打包)
|
||
async function createZipFromOriginalFiles(
|
||
originalFiles: Array<{ path: string; name: string; size: number }>,
|
||
materialId: string,
|
||
productModel: string
|
||
): Promise<{ filePath: string; fileName: string; fileSize: number }> {
|
||
// 创建目标目录
|
||
const uploadsDir = path.join(process.cwd(), 'uploads', 'materials');
|
||
await mkdir(uploadsDir, { recursive: true });
|
||
|
||
// 生成zip文件名和路径
|
||
const timestamp = Date.now();
|
||
const zipFileName = `${productModel}_${materialId}_${timestamp}.zip`;
|
||
const zipFilePath = path.join(uploadsDir, zipFileName);
|
||
const relativeZipPath = path.join('uploads', 'materials', zipFileName);
|
||
|
||
console.log('[Zip] Creating zip file:', zipFilePath);
|
||
console.log('[Zip] Original files count:', originalFiles.length);
|
||
|
||
return new Promise((resolve, reject) => {
|
||
try {
|
||
// 创建输出流
|
||
const output = createWriteStream(zipFilePath);
|
||
const archive = archiver('zip', {
|
||
zlib: { level: 9 } // 最高压缩级别
|
||
});
|
||
|
||
// 监听完成事件
|
||
output.on('close', () => {
|
||
console.log('[Zip] Zip file created successfully');
|
||
console.log('[Zip] Total bytes:', archive.pointer());
|
||
|
||
resolve({
|
||
filePath: relativeZipPath,
|
||
fileName: zipFileName,
|
||
fileSize: archive.pointer()
|
||
});
|
||
});
|
||
|
||
// 监听错误
|
||
archive.on('error', (err) => {
|
||
console.error('[Zip] Archive error:', err);
|
||
reject(err);
|
||
});
|
||
|
||
output.on('error', (err) => {
|
||
console.error('[Zip] Output stream error:', err);
|
||
reject(err);
|
||
});
|
||
|
||
// 连接archive到输出流
|
||
archive.pipe(output);
|
||
|
||
// 添加每个原图到zip(直接从chunk临时文件添加)
|
||
for (const file of originalFiles) {
|
||
console.log(`[Zip] Adding file: ${file.name} (${file.size} bytes)`);
|
||
archive.file(file.path, { name: file.name });
|
||
}
|
||
|
||
// 完成压缩
|
||
archive.finalize();
|
||
} catch (error) {
|
||
console.error('[Zip] Error:', error);
|
||
reject(error);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 压缩原图生成预览图并上传到S3
|
||
async function compressAndUploadPreview(
|
||
originalFile: File,
|
||
materialId: string
|
||
): Promise<string> {
|
||
const buffer = Buffer.from(await originalFile.arrayBuffer());
|
||
|
||
// 压缩图片
|
||
const compressedBuffer = await compressImage(buffer, {
|
||
maxWidth: 1440,
|
||
maxHeight: 2400,
|
||
maxSizeKB: 500,
|
||
format: 'webp'
|
||
});
|
||
|
||
// 生成文件名
|
||
const timestamp = Date.now();
|
||
const randomSuffix = Math.random().toString(36).substring(2, 9);
|
||
const fileName = `preview-${timestamp}-${randomSuffix}.webp`;
|
||
const s3Path = `product-package/${fileName}`;
|
||
|
||
// 上传到S3
|
||
const url = await uploadToS3(compressedBuffer, s3Path, 'image/webp');
|
||
return url;
|
||
}
|
||
|
||
// POST - 完成3D原图上传
|
||
export async function POST(request: NextRequest) {
|
||
let materialId: string | null = null;
|
||
const tempFiles: string[] = []; // 需要清理的临时文件路径
|
||
let zipFilePath: string | null = null;
|
||
const uploadedPreviewUrls: string[] = []; // 新生成的S3预览图URLs
|
||
|
||
try {
|
||
const session = await getServerSession(authOptions);
|
||
if (!session) {
|
||
return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||
}
|
||
|
||
const body = await request.json();
|
||
const { materialId: matId, assets } = body;
|
||
|
||
materialId = matId;
|
||
|
||
if (!materialId || !assets) {
|
||
return NextResponse.json(
|
||
{ error: '缺少必填字段' },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
console.log('[3D Original Complete] Starting, materialId:', materialId);
|
||
console.log('[3D Original Complete] Preview assets (S3):', assets.preview?.length || 0);
|
||
console.log('[3D Original Complete] Main assets (uploadIds):', assets.main?.length || 0);
|
||
|
||
// 验证素材是否存在且属于当前用户
|
||
const material: any = await prisma.material.findUnique({
|
||
where: { id: materialId },
|
||
});
|
||
|
||
if (!material) {
|
||
return NextResponse.json(
|
||
{ success: false, error: '素材不存在' },
|
||
{ status: 404 }
|
||
);
|
||
}
|
||
|
||
if (material.userId !== (session.user as any).id) {
|
||
return NextResponse.json(
|
||
{ success: false, error: '无权限操作此素材' },
|
||
{ status: 403 }
|
||
);
|
||
}
|
||
|
||
// 步骤1: 从chunk临时目录读取原图文件并打包成zip
|
||
let zipInfo = null;
|
||
if (assets.main && assets.main.length > 0) {
|
||
console.log(`[3D Original] Processing ${assets.main.length} original images from chunk uploads...`);
|
||
|
||
// 从chunk临时目录读取完整的原图文件
|
||
const { getUploadedFile } = await import('@/lib/upload-sessions');
|
||
const originalFiles: Array<{ path: string; name: string; size: number }> = [];
|
||
|
||
for (const asset of assets.main) {
|
||
try {
|
||
const filePath = await getUploadedFile(asset.uploadId);
|
||
if (filePath) {
|
||
originalFiles.push({
|
||
path: filePath,
|
||
name: asset.fileName,
|
||
size: asset.fileSize,
|
||
});
|
||
tempFiles.push(filePath); // 记录需要清理的临时文件
|
||
} else {
|
||
throw new Error(`找不到uploadId对应的文件: ${asset.uploadId}`);
|
||
}
|
||
} catch (error) {
|
||
console.error(`[3D Original] Failed to get file for uploadId ${asset.uploadId}:`, error);
|
||
throw new Error(`获取原图文件失败: ${asset.fileName}`);
|
||
}
|
||
}
|
||
|
||
// 打包原图成zip(保存到本地文件系统)
|
||
console.log(`[3D Original] Creating zip from ${originalFiles.length} original files...`);
|
||
zipInfo = await createZipFromOriginalFiles(
|
||
originalFiles,
|
||
materialId!,
|
||
material.productModel || 'material'
|
||
);
|
||
console.log(`[3D Original] Zip created: ${zipInfo.fileName} (${zipInfo.fileSize} bytes)`);
|
||
zipFilePath = zipInfo.filePath;
|
||
}
|
||
|
||
// 步骤2: 为所有原图生成预览图并上传到S3(如果前端没有提供预览图)
|
||
// 注意:详情页需要显示所有原图的预览版本,所以每张原图都要生成预览图
|
||
if (!assets.preview || assets.preview.length === 0) {
|
||
console.log(`[3D Original] No preview provided, generating previews for all ${assets.main?.length || 0} original images...`);
|
||
|
||
if (assets.main && assets.main.length > 0) {
|
||
const { getUploadedFile } = await import('@/lib/upload-sessions');
|
||
const { readFile } = await import('fs/promises');
|
||
|
||
// 为每张原图生成预览图
|
||
for (let i = 0; i < assets.main.length; i++) {
|
||
const original = assets.main[i];
|
||
try {
|
||
const filePath = await getUploadedFile(original.uploadId);
|
||
|
||
if (filePath) {
|
||
const buffer = await readFile(filePath);
|
||
|
||
// 压缩并上传预览图
|
||
const compressedBuffer = await compressImage(buffer, {
|
||
maxWidth: 1440,
|
||
maxHeight: 2400,
|
||
maxSizeKB: 500,
|
||
format: 'webp',
|
||
});
|
||
|
||
const timestamp = Date.now();
|
||
const randomSuffix = Math.random().toString(36).substring(2, 9);
|
||
const fileName = `preview-${timestamp}-${randomSuffix}.webp`;
|
||
const s3Path = `product-package/${fileName}`;
|
||
|
||
const previewUrl = await uploadToS3(compressedBuffer, s3Path, 'image/webp');
|
||
uploadedPreviewUrls.push(previewUrl);
|
||
|
||
console.log(`[3D Original] Generated preview image ${i + 1}/${assets.main.length}:`, previewUrl);
|
||
}
|
||
} catch (err) {
|
||
console.error(`[3D Original] Failed to generate preview for image ${i + 1}:`, err);
|
||
// 继续处理其他图片,不中断整个流程
|
||
}
|
||
}
|
||
|
||
console.log(`[3D Original] Generated ${uploadedPreviewUrls.length} preview images total`);
|
||
}
|
||
}
|
||
|
||
// 准备资产数据
|
||
const assetRecords: Array<{
|
||
materialId: string;
|
||
assetType: string;
|
||
fileUrl: string;
|
||
fileName?: string;
|
||
fileSize?: bigint;
|
||
sortOrder?: number;
|
||
}> = [];
|
||
|
||
// S3预览图(前端上传的,或后端生成的)
|
||
const previewAssets = assets.preview || [];
|
||
if (uploadedPreviewUrls.length > 0) {
|
||
// 使用后端生成的预览图
|
||
uploadedPreviewUrls.forEach((url, index) => {
|
||
assetRecords.push({
|
||
materialId: materialId!,
|
||
assetType: ASSET_TYPE.PREVIEW,
|
||
fileUrl: url,
|
||
fileName: `preview-${index}.webp`,
|
||
sortOrder: index,
|
||
});
|
||
});
|
||
} else if (previewAssets.length > 0) {
|
||
// 使用前端上传的预览图
|
||
previewAssets.forEach((asset: any, index: number) => {
|
||
assetRecords.push({
|
||
materialId: materialId!,
|
||
assetType: ASSET_TYPE.PREVIEW,
|
||
fileUrl: asset.fileUrl,
|
||
fileName: asset.fileName,
|
||
fileSize: BigInt(asset.fileSize),
|
||
sortOrder: index,
|
||
});
|
||
});
|
||
}
|
||
|
||
// 添加zip资产记录
|
||
if (zipInfo) {
|
||
assetRecords.push({
|
||
materialId: materialId!,
|
||
assetType: ASSET_TYPE.ZIP,
|
||
fileUrl: zipInfo.filePath, // 本地路径
|
||
fileName: zipInfo.fileName,
|
||
fileSize: BigInt(zipInfo.fileSize),
|
||
sortOrder: 0,
|
||
});
|
||
}
|
||
|
||
// 使用事务保存所有资产记录并更新素材信息
|
||
await prisma.$transaction(async (tx) => {
|
||
// 批量创建资产记录
|
||
if (assetRecords.length > 0) {
|
||
await tx.materialAsset.createMany({
|
||
data: assetRecords,
|
||
});
|
||
}
|
||
|
||
// 更新素材记录:设置预览图和压缩包信息
|
||
let firstPreview = '';
|
||
if (uploadedPreviewUrls.length > 0) {
|
||
// 使用后端生成的预览图
|
||
firstPreview = uploadedPreviewUrls[0];
|
||
} else if (assets.preview && assets.preview.length > 0) {
|
||
// 使用前端上传的预览图
|
||
firstPreview = assets.preview[0].fileUrl;
|
||
}
|
||
|
||
await tx.material.update({
|
||
where: { id: materialId! },
|
||
data: {
|
||
previewUrl: firstPreview,
|
||
zipFilePath: zipInfo?.filePath || '',
|
||
zipFileName: zipInfo?.fileName || '',
|
||
zipFileSize: zipInfo?.fileSize ? BigInt(zipInfo.fileSize) : BigInt(0),
|
||
},
|
||
});
|
||
});
|
||
|
||
// 清理临时文件(chunk临时目录和组装的完整文件)
|
||
console.log(`[3D Original] Cleaning up ${tempFiles.length} temporary files...`);
|
||
const { unlink } = await import('fs/promises');
|
||
for (const filePath of tempFiles) {
|
||
try {
|
||
await unlink(filePath);
|
||
console.log(`[3D Original] Deleted temp file: ${filePath}`);
|
||
} catch (err) {
|
||
console.warn(`[3D Original] Failed to delete temp file: ${filePath}`, err);
|
||
}
|
||
}
|
||
|
||
// 清理chunk临时目录
|
||
if (assets.main && assets.main.length > 0) {
|
||
const path = await import('path');
|
||
const { rm } = await import('fs/promises');
|
||
for (const asset of assets.main) {
|
||
const tempDir = path.join(process.env.UPLOAD_DIR || './uploads', 'temp', asset.uploadId);
|
||
try {
|
||
await rm(tempDir, { recursive: true, force: true });
|
||
console.log(`[3D Original] Deleted chunk temp dir: ${tempDir}`);
|
||
} catch (err) {
|
||
console.warn(`[3D Original] Failed to delete chunk temp dir: ${tempDir}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log('[3D Original] Complete success!');
|
||
return NextResponse.json({
|
||
success: true,
|
||
materialId,
|
||
});
|
||
} catch (error: any) {
|
||
console.error('[3D Original Complete Error]', error);
|
||
|
||
// 清理已上传的S3预览图
|
||
if (uploadedPreviewUrls.length > 0) {
|
||
console.log('[Cleanup] Cleaning up uploaded S3 preview images...');
|
||
const { deleteFromS3 } = await import('@/lib/s3');
|
||
for (const fileUrl of uploadedPreviewUrls) {
|
||
try {
|
||
await deleteFromS3(fileUrl);
|
||
} catch (cleanupError) {
|
||
console.error(`[Cleanup] Failed to delete S3 file ${fileUrl}:`, cleanupError);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 清理本地zip文件
|
||
if (zipFilePath) {
|
||
try {
|
||
const { unlink } = await import('fs/promises');
|
||
const fullPath = path.join(process.cwd(), zipFilePath);
|
||
await unlink(fullPath);
|
||
console.log('[Cleanup] Deleted local zip file:', fullPath);
|
||
} catch (cleanupError) {
|
||
console.error('[Cleanup] Failed to delete local zip file:', cleanupError);
|
||
}
|
||
}
|
||
|
||
// 清理临时文件
|
||
if (tempFiles.length > 0) {
|
||
const { unlink } = await import('fs/promises');
|
||
for (const filePath of tempFiles) {
|
||
try {
|
||
await unlink(filePath);
|
||
} catch (cleanupError) {
|
||
console.warn(`[Cleanup] Failed to delete temp file: ${filePath}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 删除素材记录
|
||
if (materialId) {
|
||
try {
|
||
await prisma.material.delete({
|
||
where: { id: materialId },
|
||
});
|
||
console.log('[Cleanup] Deleted material record:', materialId);
|
||
} catch (cleanupError) {
|
||
console.error('[Cleanup] Failed to delete material record:', cleanupError);
|
||
}
|
||
}
|
||
|
||
return NextResponse.json(
|
||
{ error: error.message || '完成上传失败' },
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|