主要改动: 1. TCP缓冲区优化(16MB)- 解决公网上传速度慢问题 2. 并发数降低到1-3 - 避免4并发导致ERR_CONNECTION_RESET 3. 动态并发控制 - 根据网络环境自动调整 4. Nginx超时增加到120s - 支持大分片上传 5. 上传进度修复 - 修复剩余时间计算 6. 新增任务-页面上传器和详情组件 7. 统一部署文档为deploy-remote-build.ps1 问题修复: - 公网用户上传从200-900KB/s提升到预期3-5MB/s - 内网用户上传更稳定,无连接重置错误 - UploadProgress组件剩余时间计算修正 Co-Authored-By: Claude <noreply@anthropic.com>
551 lines
20 KiB
TypeScript
551 lines
20 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 {
|
||
uploadId?: string; // 分片上传的uploadId
|
||
fileUrl?: string; // 直接上传到S3的URL
|
||
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 },
|
||
include: {
|
||
user: {
|
||
select: {
|
||
realName: true,
|
||
username: true,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
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 }
|
||
);
|
||
}
|
||
|
||
// 提取作者名和店铺名(用于zip文件命名)
|
||
const authorName = material.user?.realName || material.user?.username || '未知作者';
|
||
const categoryName = material.categoryName || '未知店铺';
|
||
console.log('[Product Package] 作者名:', authorName);
|
||
console.log('[Product Package] 店铺名:', categoryName);
|
||
|
||
// 步骤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,
|
||
};
|
||
|
||
// 生成预览图的辅助函数(使用 uploadId 直接获取文件)
|
||
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) {
|
||
// 从本地临时文件读取(使用uploadId直接获取)
|
||
const filePath = await getUploadedFile(asset.uploadId);
|
||
if (!filePath) {
|
||
throw new Error(`文件不存在: ${asset.fileName} (uploadId: ${asset.uploadId})`);
|
||
}
|
||
buffer = await readFile(filePath);
|
||
console.log(`[Preview] Read ${type} image ${i + 1}/${assets.length} from local: ${filePath}`);
|
||
} else {
|
||
throw new Error(`无效的资产数据: ${asset.fileName}`);
|
||
}
|
||
|
||
// 检查是否为详情图,如果是超长图片则使用切片处理
|
||
const { compressImage, sliceImage } = await import('@/lib/image-processor');
|
||
const sharp = await import('sharp');
|
||
const metadata = await sharp.default(buffer).metadata();
|
||
|
||
let buffersToUpload: Buffer[];
|
||
|
||
// 详情图高度超过1500px时,使用切片处理
|
||
if (type === 'detail' && metadata.height && metadata.height > 1500) {
|
||
console.log(`[Preview] 检测到超长详情图: ${metadata.width}x${metadata.height}, 使用切片处理`);
|
||
buffersToUpload = await sliceImage(buffer, 1500, 200);
|
||
console.log(`[Preview] 切片完成: ${buffersToUpload.length} 个片段`);
|
||
} else {
|
||
// 普通压缩处理
|
||
const compressedBuffer = await compressImage(buffer, {
|
||
maxWidth: compressOptions.maxWidth,
|
||
maxHeight: compressOptions.maxHeight,
|
||
maxSizeKB: compressOptions.maxSizeKB,
|
||
format: 'webp',
|
||
});
|
||
buffersToUpload = [compressedBuffer];
|
||
}
|
||
|
||
// 上传每个切片/压缩后的图片
|
||
const { uploadToS3 } = await import('@/lib/s3');
|
||
for (let j = 0; j < buffersToUpload.length; j++) {
|
||
const timestamp = Date.now();
|
||
const randomSuffix = Math.random().toString(36).substring(2, 9);
|
||
const sliceSuffix = buffersToUpload.length > 1 ? `-slice${j}` : '';
|
||
const fileName = `${type}-preview-${timestamp}-${i}${sliceSuffix}-${randomSuffix}.webp`;
|
||
const s3Path = `product-package/${fileName}`;
|
||
|
||
const previewUrl = await uploadToS3(buffersToUpload[j], s3Path, 'image/webp');
|
||
previews.push(previewUrl);
|
||
}
|
||
|
||
console.log(`[Preview] Generated ${buffersToUpload.length} preview(s) for ${i + 1}/${assets.length}`);
|
||
} 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',
|
||
'任务-详情',
|
||
'主图',
|
||
authorName,
|
||
categoryName
|
||
);
|
||
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图',
|
||
authorName,
|
||
categoryName
|
||
);
|
||
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',
|
||
'任务-详情',
|
||
'详情图',
|
||
authorName,
|
||
categoryName
|
||
);
|
||
createdZipPaths.push(zipInfos.detail.filePath);
|
||
zipFilesForAll.push({ path: zipInfos.detail.filePath, name: zipInfos.detail.fileName });
|
||
console.log('[Product Package] 详情图打包完成:', zipInfos.detail.fileName);
|
||
}
|
||
|
||
// 步骤3.5: 移动PSD文件到永久目录
|
||
console.log('[Product Package] Step 3.5: 移动PSD文件到永久目录...');
|
||
const psdPermanentPaths: Array<{ filePath: string; fileName: string; fileSize: number }> = [];
|
||
if (assets.psd && assets.psd.length > 0) {
|
||
const { rename, copyFile } = await import('fs/promises');
|
||
const psdDir = path.join(process.cwd(), 'uploads', 'materials');
|
||
await mkdir(psdDir, { recursive: true });
|
||
|
||
for (const psd of assets.psd) {
|
||
if (psd.uploadId) {
|
||
// 从临时目录获取PSD文件
|
||
const tempFilePath = await getUploadedFile(psd.uploadId);
|
||
if (tempFilePath) {
|
||
// 生成永久文件名和路径
|
||
const timestamp = Date.now();
|
||
const sanitizedFileName = psd.fileName.replace(/[\/\\:*?"<>|]/g, '_');
|
||
const permanentFileName = `psd-${materialId}-${timestamp}-${sanitizedFileName}`;
|
||
const permanentFilePath = path.join(psdDir, permanentFileName);
|
||
const relativePath = path.join('uploads', 'materials', permanentFileName);
|
||
|
||
// 移动文件到永久目录
|
||
try {
|
||
await rename(tempFilePath, permanentFilePath);
|
||
console.log(`[Product Package] PSD文件已移动: ${tempFilePath} -> ${permanentFilePath}`);
|
||
} catch (err) {
|
||
// 如果跨设备移动失败,使用复制+删除
|
||
await copyFile(tempFilePath, permanentFilePath);
|
||
tempFiles.push(tempFilePath); // 标记为需要清理
|
||
console.log(`[Product Package] PSD文件已复制: ${tempFilePath} -> ${permanentFilePath}`);
|
||
}
|
||
|
||
psdPermanentPaths.push({
|
||
filePath: relativePath,
|
||
fileName: psd.fileName, // 保留原始文件名
|
||
fileSize: psd.fileSize,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
console.log(`[Product Package] ${psdPermanentPaths.length} 个PSD文件已移动到永久目录`);
|
||
}
|
||
|
||
// 步骤4: 打包总zip(只包含分类zip,不包含PSD)
|
||
console.log('[Product Package] Step 4: 打包总zip...');
|
||
zipInfos.all = await createZipFromZips(
|
||
zipFilesForAll,
|
||
materialId,
|
||
material.productModel || 'product',
|
||
'任务-详情'
|
||
);
|
||
createdZipPaths.push(zipInfos.all.filePath);
|
||
console.log('[Product Package] 总zip打包完成:', zipInfos.all.fileName);
|
||
|
||
// 步骤5: 保存资产记录到数据库
|
||
console.log('[Product Package] Step 5: 保存资产记录...');
|
||
await saveProductPackageAssets(
|
||
materialId,
|
||
previewUrls,
|
||
zipInfos,
|
||
psdPermanentPaths // 使用永久路径
|
||
);
|
||
|
||
// 步骤6: 更新素材记录(写入taskNo、状态和previewUrl)
|
||
console.log('[Product Package] Step 6: 更新素材记录...');
|
||
// 确定首页卡片的预览图:优先主图,其次SKU图,最后详情图
|
||
const cardPreviewUrl =
|
||
previewUrls.main[0] ||
|
||
previewUrls.sku[0] ||
|
||
previewUrls.detail[0] ||
|
||
material.previewUrl; // 保留原有值作为fallback
|
||
|
||
await prisma.material.update({
|
||
where: { id: materialId },
|
||
data: {
|
||
...(taskNo && taskNo.trim() ? { taskNo } : {}),
|
||
taskStatus: TASK_STATUS.COMPLETED,
|
||
previewUrl: cardPreviewUrl, // 更新首页卡片预览图
|
||
},
|
||
});
|
||
|
||
// 步骤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 }
|
||
);
|
||
}
|
||
}
|