414 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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