478 lines
16 KiB
TypeScript
478 lines
16 KiB
TypeScript
/**
|
||
* 任务-详情上传完成接口(改造版)
|
||
* 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 }
|
||
);
|
||
}
|
||
}
|