478 lines
16 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.

/**
* 任务-详情上传完成接口(改造版)
* POST - 打包文件、生成预览图、保存资产信息
*/
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { validateTaskNo, TASK_STATUS } from '@/lib/product-package';
import {
createZipFromFiles,
createZipFromZips,
cleanupTempFiles,
cleanupOnError,
saveProductPackageAssets,
ASSET_TYPE,
} from '@/lib/task-material-processor';
import { getUploadedFile } from '@/lib/upload-sessions';
import path from 'path';
import { mkdir, writeFile, readFile } from 'fs/promises';
interface AssetUpload {
uploadId?: string; // 可选:分片上传的 uploadId
fileUrl?: string; // 可选:直接上传到 S3 的 URL
fileName: string;
fileSize: number;
sortOrder?: number; // 可选:排序顺序
}
interface PsdAsset {
fileUrl: string;
fileName: string;
fileSize: number;
}
interface CompleteRequestBody {
materialId: string;
taskNo?: string;
assets: {
main?: AssetUpload[];
sku?: AssetUpload[];
detail?: AssetUpload[];
psd?: PsdAsset[];
};
}
export async function POST(request: NextRequest) {
let materialId: string | null = null;
const tempFiles: string[] = [];
const uploadIds: string[] = [];
const uploadedPreviewUrls: string[] = [];
const createdZipPaths: string[] = [];
try {
// 验证用户登录
const session = await getServerSession(authOptions);
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
);
}
const body: CompleteRequestBody = await request.json();
const { materialId: bodyMaterialId, taskNo, assets } = body;
materialId = bodyMaterialId;
console.log('[Product Package Complete] ========== 开始处理 ==========');
console.log('[Product Package Complete] MaterialId:', materialId);
console.log('[Product Package Complete] TaskNo:', taskNo || '(无)');
console.log('[Product Package Complete] Assets:', {
main: assets.main?.length || 0,
sku: assets.sku?.length || 0,
detail: assets.detail?.length || 0,
psd: assets.psd?.length || 0,
});
// 验证必填字段
if (!materialId || !assets) {
return NextResponse.json(
{ success: false, error: '缺少必填参数' },
{ status: 400 }
);
}
// 验证至少有一张图片
const totalImages = (assets.main?.length || 0) + (assets.sku?.length || 0) + (assets.detail?.length || 0);
if (totalImages === 0) {
return NextResponse.json(
{ success: false, error: '请至少上传一张图片' },
{ status: 400 }
);
}
// 如果提供了任务号,验证格式
if (taskNo && taskNo.trim() && !validateTaskNo(taskNo)) {
return NextResponse.json(
{ success: false, error: '任务号格式错误' },
{ status: 400 }
);
}
// 验证素材是否存在且属于当前用户
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: 收集原图文件
console.log('[Product Package] Step 1: 收集原图文件...');
const mainFiles: Array<{ path: string; name: string; size: number }> = [];
const skuFiles: Array<{ path: string; name: string; size: number }> = [];
const detailFiles: Array<{ path: string; name: string; size: number }> = [];
// 收集主图
if (assets.main && assets.main.length > 0) {
for (const asset of assets.main) {
// 支持两种模式:uploadId(分片上传)或 fileUrl(直接上传到S3)
if (asset.uploadId) {
// 模式1:分片上传,从临时目录读取
const filePath = await getUploadedFile(asset.uploadId);
if (filePath) {
mainFiles.push({ path: filePath, name: asset.fileName, size: asset.fileSize });
tempFiles.push(filePath);
uploadIds.push(asset.uploadId);
}
} else if (asset.fileUrl) {
// 模式2:直接上传到S3,下载到临时目录
const tempPath = path.join(process.cwd(), 'uploads', 'temp', `main-${Date.now()}-${Math.random().toString(36).substring(2, 9)}-${asset.fileName}`);
await mkdir(path.dirname(tempPath), { recursive: true });
// 从S3下载文件
const response = await fetch(asset.fileUrl);
if (!response.ok) {
throw new Error(`下载主图失败: ${asset.fileName}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
await writeFile(tempPath, buffer);
mainFiles.push({ path: tempPath, name: asset.fileName, size: asset.fileSize });
tempFiles.push(tempPath);
}
}
}
// 收集SKU图
if (assets.sku && assets.sku.length > 0) {
for (const asset of assets.sku) {
if (asset.uploadId) {
const filePath = await getUploadedFile(asset.uploadId);
if (filePath) {
skuFiles.push({ path: filePath, name: asset.fileName, size: asset.fileSize });
tempFiles.push(filePath);
uploadIds.push(asset.uploadId);
}
} else if (asset.fileUrl) {
const tempPath = path.join(process.cwd(), 'uploads', 'temp', `sku-${Date.now()}-${Math.random().toString(36).substring(2, 9)}-${asset.fileName}`);
await mkdir(path.dirname(tempPath), { recursive: true });
const response = await fetch(asset.fileUrl);
if (!response.ok) {
throw new Error(`下载SKU图失败: ${asset.fileName}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
await writeFile(tempPath, buffer);
skuFiles.push({ path: tempPath, name: asset.fileName, size: asset.fileSize });
tempFiles.push(tempPath);
}
}
}
// 收集详情图
if (assets.detail && assets.detail.length > 0) {
for (const asset of assets.detail) {
if (asset.uploadId) {
const filePath = await getUploadedFile(asset.uploadId);
if (filePath) {
detailFiles.push({ path: filePath, name: asset.fileName, size: asset.fileSize });
tempFiles.push(filePath);
uploadIds.push(asset.uploadId);
}
} else if (asset.fileUrl) {
const tempPath = path.join(process.cwd(), 'uploads', 'temp', `detail-${Date.now()}-${Math.random().toString(36).substring(2, 9)}-${asset.fileName}`);
await mkdir(path.dirname(tempPath), { recursive: true });
const response = await fetch(asset.fileUrl);
if (!response.ok) {
throw new Error(`下载详情图失败: ${asset.fileName}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
await writeFile(tempPath, buffer);
detailFiles.push({ path: tempPath, name: asset.fileName, size: asset.fileSize });
tempFiles.push(tempPath);
}
}
}
console.log('[Product Package] 收集完成:', {
main: mainFiles.length,
sku: skuFiles.length,
detail: detailFiles.length,
});
// 步骤2: 生成预览图(使用优化的压缩参数)
console.log('[Product Package] Step 2: 生成预览图...');
const previewUrls = {
main: [] as string[],
sku: [] as string[],
detail: [] as string[],
};
// 任务-详情的压缩参数(节省带宽和存储)
const compressOptions = {
maxWidth: 800,
maxHeight: 1200,
maxSizeKB: 200,
};
// 生成预览图的辅助函数(支持从 S3 URL 或本地文件)
const generatePreviewsFromAssets = async (
assets: AssetUpload[],
type: string
): Promise<string[]> => {
if (!assets || assets.length === 0) return [];
const previews: string[] = [];
for (let i = 0; i < assets.length; i++) {
const asset = assets[i];
try {
let buffer: Buffer;
if (asset.fileUrl) {
// 从 S3 下载原图
console.log(`[Preview] Downloading ${type} image ${i + 1}/${assets.length} from S3: ${asset.fileUrl}`);
const response = await fetch(asset.fileUrl);
if (!response.ok) {
throw new Error(`下载失败: ${asset.fileName}`);
}
buffer = Buffer.from(await response.arrayBuffer());
} else if (asset.uploadId) {
// 从本地临时文件读取
const filePath = mainFiles.find(f => f.name === asset.fileName)?.path ||
skuFiles.find(f => f.name === asset.fileName)?.path ||
detailFiles.find(f => f.name === asset.fileName)?.path;
if (!filePath) {
throw new Error(`文件不存在: ${asset.fileName}`);
}
buffer = await readFile(filePath);
} else {
throw new Error(`无效的资产数据: ${asset.fileName}`);
}
// 压缩并上传预览图
const { compressImage } = await import('@/lib/image-processor');
const compressedBuffer = await compressImage(buffer, {
maxWidth: compressOptions.maxWidth,
maxHeight: compressOptions.maxHeight,
maxSizeKB: compressOptions.maxSizeKB,
format: 'webp',
});
const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(2, 9);
const fileName = `${type}-preview-${timestamp}-${i}-${randomSuffix}.webp`;
const s3Path = `product-package/${fileName}`;
const { uploadToS3 } = await import('@/lib/s3');
const previewUrl = await uploadToS3(compressedBuffer, s3Path, 'image/webp');
previews.push(previewUrl);
console.log(`[Preview] Generated preview ${i + 1}/${assets.length}:`, previewUrl);
} catch (err) {
const error = `生成预览图失败 ${asset.fileName}: ${err instanceof Error ? err.message : String(err)}`;
console.error(`[Preview] ${error}`);
throw new Error(error);
}
}
return previews;
};
if (assets.main && assets.main.length > 0) {
previewUrls.main = await generatePreviewsFromAssets(assets.main, 'main');
uploadedPreviewUrls.push(...previewUrls.main);
}
if (assets.sku && assets.sku.length > 0) {
previewUrls.sku = await generatePreviewsFromAssets(assets.sku, 'sku');
uploadedPreviewUrls.push(...previewUrls.sku);
}
if (assets.detail && assets.detail.length > 0) {
previewUrls.detail = await generatePreviewsFromAssets(assets.detail, 'detail');
uploadedPreviewUrls.push(...previewUrls.detail);
}
console.log('[Product Package] 预览图生成完成:', {
main: previewUrls.main.length,
sku: previewUrls.sku.length,
detail: previewUrls.detail.length,
});
// 步骤3: 打包原图成分类zip
console.log('[Product Package] Step 3: 打包原图...');
const zipInfos: {
main?: { filePath: string; fileName: string; fileSize: number };
sku?: { filePath: string; fileName: string; fileSize: number };
detail?: { filePath: string; fileName: string; fileSize: number };
all: { filePath: string; fileName: string; fileSize: number };
} = {} as any;
const zipFilesForAll: Array<{ path: string; name: string }> = [];
// 打包主图
if (mainFiles.length > 0) {
zipInfos.main = await createZipFromFiles(
mainFiles,
materialId,
material.productModel || 'product',
'任务-详情',
'main'
);
createdZipPaths.push(zipInfos.main.filePath);
zipFilesForAll.push({ path: zipInfos.main.filePath, name: zipInfos.main.fileName });
console.log('[Product Package] 主图打包完成:', zipInfos.main.fileName);
}
// 打包SKU图
if (skuFiles.length > 0) {
zipInfos.sku = await createZipFromFiles(
skuFiles,
materialId,
material.productModel || 'product',
'任务-详情',
'sku'
);
createdZipPaths.push(zipInfos.sku.filePath);
zipFilesForAll.push({ path: zipInfos.sku.filePath, name: zipInfos.sku.fileName });
console.log('[Product Package] SKU图打包完成:', zipInfos.sku.fileName);
}
// 打包详情图
if (detailFiles.length > 0) {
zipInfos.detail = await createZipFromFiles(
detailFiles,
materialId,
material.productModel || 'product',
'任务-详情',
'detail'
);
createdZipPaths.push(zipInfos.detail.filePath);
zipFilesForAll.push({ path: zipInfos.detail.filePath, name: zipInfos.detail.fileName });
console.log('[Product Package] 详情图打包完成:', zipInfos.detail.fileName);
}
// 步骤4: 打包总zip(包含所有分类zip + PSD)
console.log('[Product Package] Step 4: 打包总zip...');
const psdFilesForAll: Array<{ path: string; name: string }> = [];
if (assets.psd && assets.psd.length > 0) {
for (const psd of assets.psd) {
const psdPath = psd.fileUrl.startsWith('/') ? psd.fileUrl : `/${psd.fileUrl}`;
psdFilesForAll.push({ path: psdPath, name: psd.fileName });
}
}
zipInfos.all = await createZipFromZips(
zipFilesForAll,
psdFilesForAll,
materialId,
material.productModel || 'product',
'任务-详情'
);
createdZipPaths.push(zipInfos.all.filePath);
console.log('[Product Package] 总zip打包完成:', zipInfos.all.fileName);
// 步骤5: 保存资产记录到数据库
console.log('[Product Package] Step 5: 保存资产记录...');
const psdInfos = assets.psd?.map(psd => ({
filePath: psd.fileUrl,
fileName: psd.fileName,
fileSize: psd.fileSize,
}));
await saveProductPackageAssets(
materialId,
previewUrls,
zipInfos,
psdInfos
);
// 步骤6: 更新素材记录(写入taskNo)
console.log('[Product Package] Step 6: 更新素材记录...');
await prisma.material.update({
where: { id: materialId },
data: {
...(taskNo && taskNo.trim() ? { taskNo } : {}),
taskStatus: TASK_STATUS.COMPLETED,
},
});
// 步骤7: 清理临时文件
console.log('[Product Package] Step 7: 清理临时文件...');
await cleanupTempFiles(tempFiles, uploadIds);
// 记录日志
await prisma.materialLog.create({
data: {
materialId,
userId: (session.user as any).id,
action: 'UPLOAD',
changes: JSON.stringify({
...(taskNo && taskNo.trim() ? { taskNo } : {}),
assetCounts: {
main: mainFiles.length,
sku: skuFiles.length,
detail: detailFiles.length,
psd: assets.psd?.length || 0,
},
zipSizes: {
main: zipInfos.main?.fileSize || 0,
sku: zipInfos.sku?.fileSize || 0,
detail: zipInfos.detail?.fileSize || 0,
all: zipInfos.all.fileSize,
},
}),
},
});
console.log('[Product Package] ========== 处理完成 ==========');
return NextResponse.json({
success: true,
data: {
materialId,
taskNo: taskNo && taskNo.trim() ? taskNo : null,
message: '上传完成',
},
});
} catch (error) {
console.error('[Product Package Complete] Error:', error);
console.error('[Product Package Complete] Error details:', {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
materialId,
});
// 清理失败时的资源
await cleanupOnError(
uploadedPreviewUrls,
createdZipPaths, // 传入所有zip文件路径
tempFiles,
materialId
);
const errorMessage = error instanceof Error ? error.message : String(error);
return NextResponse.json(
{ success: false, error: errorMessage || '服务器错误' },
{ status: 500 }
);
}
}