chenpei cd6e40d9d2 优化:网络传输性能和稳定性 - TCP缓冲区+并发控制优化
主要改动:
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>
2026-01-27 09:33:09 +08:00

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