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

784 lines
27 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.

/**
* 外部接口:文件下载
* GET - 通过 Token 下载文件
*/
import { NextRequest, NextResponse } from 'next/server';
import { verifyDownloadToken, isTokenExpired } from '@/lib/download-token';
import { errorResponse, API_ERROR } from '@/lib/external-api';
import { prisma } from '@/lib/prisma';
import { ASSET_TYPE } from '@/lib/product-package';
import archiver from 'archiver';
import { Readable } from 'stream';
import path from 'path';
import fs from 'fs';
import { createReadStream } from 'fs';
import { access, stat } from 'fs/promises';
import { lookup, extension } from 'mime-types';
import JSZip from 'jszip';
import { isProductPackageType } from '@/lib/material-type-mapper';
/**
* GET - 下载文件
*/
export async function GET(request: NextRequest) {
const token = request.nextUrl.searchParams.get('token');
if (!token) {
return errorResponse(API_ERROR.INVALID_TOKEN);
}
// 检查 Token 是否过期
if (isTokenExpired(token)) {
return errorResponse(API_ERROR.TOKEN_EXPIRED);
}
// 验证 Token
const payload = verifyDownloadToken(token);
if (!payload) {
return errorResponse(API_ERROR.INVALID_TOKEN);
}
const { materialId, downloadType, assetId } = payload;
try {
// 查询素材
const material: any = await prisma.material.findUnique({
where: { id: materialId, deletedAt: null },
include: {
assets: true,
},
});
if (!material) {
return errorResponse(API_ERROR.TASK_NOT_FOUND);
}
// TODO: 权限检查(暂时保留接口,后续扩展)
// 后续需要实现:根据 API Key 或用户权限检查下载权限
// 参考:/api/materials/download 中的权限检查逻辑
// 任务类型素材:使用assets逻辑
const isTaskMaterial = ['任务-详情', '任务-3D原图', '任务-AI原图', '任务-实拍原图', '任务-视频截图'].includes(material.materialType);
if (isTaskMaterial) {
console.log(`[external/download] ========== 任务类型素材下载 ==========`);
console.log(`[external/download] materialId: ${materialId}`);
console.log(`[external/download] materialType: ${material.materialType}`);
console.log(`[external/download] taskNo: ${material.taskNo}`);
console.log(`[external/download] downloadType: ${downloadType}`);
console.log(`[external/download] zipFilePath: ${material.zipFilePath || '(无)'}`);
console.log(`[external/download] 数据库中资产总数: ${material.assets?.length || 0}`);
// 按类型统计资产数量
const assetsByType: Record<string, number> = {};
(material.assets || []).forEach((a: any) => {
assetsByType[a.assetType] = (assetsByType[a.assetType] || 0) + 1;
});
console.log(`[external/download] 资产分布:`, assetsByType);
console.log(`[external/download] ====================================`);
if (isProductPackageType(material.materialType)) {
// 任务-详情:使用预先生成的zip文件
// 下载全部:使用 all.zip
if (downloadType === 'all') {
const allZipAsset = material.assets.find((a: any) => a.assetType === 'all');
if (!allZipAsset) {
return NextResponse.json(
{ success: false, error: { code: 'NO_ASSETS', message: '没有可下载的文件' } },
{ status: 404 }
);
}
console.log(`[external/download] 使用 all.zip: ${allZipAsset.fileUrl}`);
return await downloadSingleFile(allZipAsset);
}
// 下载分类:使用 main.zip / sku.zip / detail.zip
if (['main', 'sku', 'detail'].includes(downloadType)) {
const zipAsset = material.assets.find((a: any) => a.assetType === downloadType);
if (!zipAsset) {
return NextResponse.json(
{ success: false, error: { code: 'NO_ASSETS', message: '没有可下载的文件' } },
{ status: 404 }
);
}
console.log(`[external/download] 使用 ${downloadType}.zip: ${zipAsset.fileUrl}`);
return await downloadSingleFile(zipAsset);
}
// 单个资产下载
if (downloadType === 'single' && assetId) {
const asset = material.assets.find((a: any) => a.id === assetId);
if (!asset) {
return NextResponse.json(
{ success: false, error: { code: 'ASSET_NOT_FOUND', message: '资产不存在' } },
{ status: 404 }
);
}
return await downloadSingleFile(asset);
}
// PSD 下载
if (downloadType === 'psd') {
if (assetId) {
const psdAsset = material.assets.find((a: any) => a.id === assetId && a.assetType === ASSET_TYPE.PSD);
if (!psdAsset) {
return NextResponse.json(
{ success: false, error: { code: 'PSD_NOT_FOUND', message: 'PSD文件不存在' } },
{ status: 404 }
);
}
return await downloadSingleFile(psdAsset);
}
const psdAssets = material.assets.filter((a: any) => a.assetType === ASSET_TYPE.PSD);
if (psdAssets.length === 0) {
return NextResponse.json(
{ success: false, error: { code: 'PSD_NOT_FOUND', message: 'PSD文件不存在' } },
{ status: 404 }
);
}
if (psdAssets.length === 1) {
return await downloadSingleFile(psdAssets[0]);
}
return await downloadAsZip(psdAssets, material.taskNo || material.id, 'psd');
}
// 未知的下载类型
return NextResponse.json(
{ success: false, error: { code: 'INVALID_DOWNLOAD_TYPE', message: '无效的下载类型' } },
{ status: 400 }
);
} else {
// 任务-3D原图/AI原图/实拍原图:下载zip文件
const zipAssets = material.assets.filter((a: any) => a.assetType === 'zip');
if (zipAssets.length === 0) {
return NextResponse.json(
{ success: false, error: { code: 'NO_ZIP_FILE', message: '没有可下载的压缩包' } },
{ status: 404 }
);
}
// 返回第一个zip文件
const zipAsset = zipAssets[0];
return await downloadSingleFile(zipAsset);
}
}
// 其他素材类型:复用 /api/materials/download 的逻辑
// 注意:外部接口通过 token 验证,已跳过权限检查
return await downloadNonProductPackageMaterial(material, downloadType, request);
} catch (error) {
console.error('[external/download] error:', error);
return errorResponse(API_ERROR.SERVER_ERROR);
}
}
/**
* 根据下载类型获取资产列表
* 排除预览类型资产
*/
function getAssetsForDownload(assets: any[], downloadType: string) {
switch (downloadType) {
case 'main':
return assets.filter(a => a.assetType === ASSET_TYPE.MAIN);
case 'sku':
return assets.filter(a => a.assetType === ASSET_TYPE.SKU);
case 'detail':
return assets.filter(a => a.assetType === ASSET_TYPE.DETAIL);
case 'all':
// 全部下载不含 PSD 和预览类型
return assets.filter(a =>
a.assetType !== ASSET_TYPE.PSD &&
!a.assetType.includes('-preview')
);
default:
return [];
}
}
/**
* 下载单个文件
*/
async function downloadSingleFile(asset: any) {
const fileUrl = asset.fileUrl;
const fileName = asset.fileName || path.basename(fileUrl);
// 判断是本地文件还是远程文件
if (fileUrl.startsWith('/uploads/') || fileUrl.startsWith('uploads/')) {
// 本地文件
const localPath = path.join(process.cwd(), fileUrl.replace(/^\//, ''));
if (!fs.existsSync(localPath)) {
return NextResponse.json(
{ success: false, error: { code: 'FILE_NOT_FOUND', message: '文件不存在' } },
{ status: 404 }
);
}
const fileBuffer = fs.readFileSync(localPath);
const contentType = getContentType(fileName);
return new NextResponse(fileBuffer, {
headers: {
'Content-Type': contentType,
'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`,
'Content-Length': fileBuffer.length.toString(),
},
});
} else {
// 远程文件(S3)
try {
const response = await fetch(fileUrl);
if (!response.ok) {
return NextResponse.json(
{ success: false, error: { code: 'FILE_NOT_FOUND', message: '文件不存在' } },
{ status: 404 }
);
}
const arrayBuffer = await response.arrayBuffer();
const contentType = response.headers.get('content-type') || getContentType(fileName);
return new NextResponse(arrayBuffer, {
headers: {
'Content-Type': contentType,
'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`,
'Content-Length': arrayBuffer.byteLength.toString(),
},
});
} catch (error) {
console.error('[downloadSingleFile] fetch error:', error);
return NextResponse.json(
{ success: false, error: { code: 'DOWNLOAD_FAILED', message: '下载失败' } },
{ status: 500 }
);
}
}
}
/**
* 打包下载为 ZIP
*/
async function downloadAsZip(assets: any[], taskNo: string, downloadType: string) {
console.log(`[downloadAsZip] ========== 开始打包下载 ==========`);
console.log(`[downloadAsZip] taskNo: ${taskNo}, downloadType: ${downloadType}`);
console.log(`[downloadAsZip] 待打包资产数量: ${assets.length}`);
assets.forEach((asset, index) => {
console.log(`[downloadAsZip] 资产 ${index + 1}: type=${asset.assetType}, url=${asset.fileUrl}, fileName=${asset.fileName}`);
});
return new Promise<NextResponse>((resolve, reject) => {
const archive = archiver('zip', { zlib: { level: 5 } });
const chunks: Buffer[] = [];
let successCount = 0;
let failCount = 0;
archive.on('data', (chunk) => chunks.push(chunk));
archive.on('end', () => {
const buffer = Buffer.concat(chunks);
const fileName = `${taskNo}-${downloadType}.zip`;
console.log(`[downloadAsZip] 打包完成: 成功 ${successCount} 个, 失败 ${failCount} 个`);
console.log(`[downloadAsZip] ZIP 文件大小: ${(buffer.length / 1024 / 1024).toFixed(2)} MB`);
console.log(`[downloadAsZip] ====================================`);
resolve(new NextResponse(buffer, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`,
'Content-Length': buffer.length.toString(),
},
}));
});
archive.on('error', (err) => {
console.error('[downloadAsZip] archive error:', err);
reject(err);
});
// 添加文件到压缩包
(async () => {
for (const asset of assets) {
const fileUrl = asset.fileUrl;
const fileName = asset.fileName || path.basename(fileUrl);
const folder = asset.assetType; // main/sku/detail
try {
if (fileUrl.startsWith('/uploads/') || fileUrl.startsWith('uploads/')) {
// 本地文件
const localPath = path.join(process.cwd(), fileUrl.replace(/^\//, ''));
if (fs.existsSync(localPath)) {
archive.file(localPath, { name: `${folder}/${fileName}` });
successCount++;
console.log(`[downloadAsZip] ✅ 添加本地文件成功: ${folder}/${fileName}`);
} else {
failCount++;
console.error(`[downloadAsZip] ❌ 本地文件不存在: ${localPath}`);
}
} else {
// 远程文件(S3)
console.log(`[downloadAsZip] 正在获取远程文件: ${fileUrl}`);
const response = await fetch(fileUrl);
if (response.ok) {
const arrayBuffer = await response.arrayBuffer();
archive.append(Buffer.from(arrayBuffer), { name: `${folder}/${fileName}` });
successCount++;
console.log(`[downloadAsZip] ✅ 添加远程文件成功: ${folder}/${fileName} (${(arrayBuffer.byteLength / 1024).toFixed(2)} KB)`);
} else {
failCount++;
console.error(`[downloadAsZip] ❌ 远程文件获取失败: ${fileUrl}, status=${response.status}`);
}
}
} catch (error) {
failCount++;
console.error(`[downloadAsZip] ❌ 添加文件失败: ${fileName}`, error);
}
}
archive.finalize();
})();
});
}
/**
* 根据文件名获取 Content-Type
*/
function getContentType(fileName: string): string {
const ext = path.extname(fileName).toLowerCase();
const mimeTypes: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.psd': 'application/octet-stream',
'.zip': 'application/zip',
'.rar': 'application/x-rar-compressed',
'.7z': 'application/x-7z-compressed',
'.mp4': 'video/mp4',
'.mov': 'video/quicktime',
'.avi': 'video/x-msvideo',
'.mkv': 'video/x-matroska',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.flac': 'audio/flac',
'.pdf': 'application/pdf',
};
return mimeTypes[ext] || 'application/octet-stream';
}
/**
* 判断是否为 HTTP URL
*/
function isHttpUrl(url?: string | null): boolean {
if (!url || typeof url !== 'string') return false;
return /^https?:\/\//i.test(url.trim());
}
/**
* 下载非任务-详情类型的素材
* 复用 /api/materials/download 的逻辑,但跳过权限检查
*/
async function downloadNonProductPackageMaterial(material: any, downloadType: string, request: NextRequest) {
// 优先检查本地 ZIP 文件(新上传的图片类素材会有本地 ZIP)
if (material.zipFilePath && material.zipFilePath.trim() !== '') {
const localZipPath = path.isAbsolute(material.zipFilePath)
? material.zipFilePath
: path.join(process.cwd(), material.zipFilePath);
// 检查是否为本地文件路径(不是 HTTP URL)
if (!material.zipFilePath.startsWith('http')) {
try {
await access(localZipPath);
const fileStats = await stat(localZipPath);
console.log(`[external/download] 使用本地 ZIP 文件: ${localZipPath}`);
const fileStream = createReadStream(localZipPath);
const stream = new ReadableStream({
start(controller) {
fileStream.on('data', (chunk) => controller.enqueue(chunk));
fileStream.on('end', () => controller.close());
fileStream.on('error', (err) => controller.error(err));
},
cancel() {
fileStream.destroy();
},
});
const ext = path.extname(material.zipFileName || localZipPath) || '.zip';
const mimeType = lookup(ext) || 'application/octet-stream';
const filenameParts = [
material.productModel || 'unknown',
material.categoryName || '',
material.sceneName || '',
'源文件',
].filter(Boolean);
const filename = `${filenameParts.join('_')}${ext}`;
return new NextResponse(stream, {
headers: {
'Accept-Ranges': 'bytes',
'Content-Length': fileStats.size.toString(),
'Content-Type': mimeType,
'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`,
},
});
} catch (error) {
console.warn(`[external/download] 本地 ZIP 文件不存在,尝试其他方式: ${localZipPath}`);
// 继续尝试其他下载方式
}
}
}
// 检查是否有ZIP文件;若无,则处理S3直链,否则再尝试动态打包
if (!material.zipFilePath || material.zipFilePath.trim() === '') {
const zipUrl = (material.zipFileName || '').trim();
// 场景:音频/视频源文件直接存放在S3,zipFileName 是 S3 URL
if (zipUrl && isHttpUrl(zipUrl)) {
console.log('[external/download] zipFilePath empty, proxy S3 url from zipFileName:', zipUrl);
const urlObj = new URL(zipUrl);
const extFromUrl = path.extname(urlObj.pathname) || '';
const mimeType = lookup(extFromUrl) || 'application/octet-stream';
const filenameParts = [
material.productModel || 'unknown',
material.categoryName || '',
material.sceneName || '',
material.productCategoryName || '',
'源文件',
].filter(Boolean);
const finalExt = extFromUrl || '.bin';
const filename = `${filenameParts.join('_')}${finalExt}`;
try {
const response = await fetch(zipUrl);
if (!response.ok || !response.body) {
console.error('[external/download] Failed to fetch S3 url:', zipUrl, 'status:', response.status);
return NextResponse.json(
{ success: false, error: { code: 'FILE_NOT_FOUND', message: 'S3文件访问失败' } },
{ status: 404 }
);
}
const nodeStream = Readable.fromWeb(response.body as any);
const stream = new ReadableStream({
start(controller) {
nodeStream.on('data', (chunk) => controller.enqueue(chunk));
nodeStream.on('end', () => controller.close());
nodeStream.on('error', (err) => controller.error(err));
},
cancel() {
nodeStream.destroy();
},
});
return new NextResponse(stream as any, {
status: 200,
headers: {
'Content-Type': mimeType,
'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`,
},
});
} catch (error) {
console.error('[external/download] Error proxying S3 url:', zipUrl, error);
return NextResponse.json(
{ success: false, error: { code: 'DOWNLOAD_FAILED', message: 'S3代理下载失败' } },
{ status: 500 }
);
}
}
// 支持动态打包的素材类型
const supportedTypes = [
'模特',
'实拍图片',
'买家秀图片',
'小红书种草',
'主图模版',
'SKU模版',
'品牌VI灵感',
];
if (
supportedTypes.includes(material.materialType) &&
Array.isArray(material.displayImages) &&
material.displayImages.length > 0
) {
return await buildDynamicZipFromDisplayImages(material);
}
return NextResponse.json(
{ success: false, error: { code: 'NO_DOWNLOADABLE_FILES', message: '该素材可能只包含图片' } },
{ status: 404 }
);
}
// 处理本地文件
const isOldMaterial =
material.zipFilePath.includes('103.118.245.6') ||
material.zipFilePath.startsWith('http');
let filePath = material.zipFilePath;
// 如果是旧素材(HTTP URL),直接通过 fetch 下载
if (isOldMaterial) {
try {
const response = await fetch(filePath);
if (!response.ok || !response.body) {
console.error('[external/download] Failed to fetch old material URL:', filePath, 'status:', response.status);
return NextResponse.json(
{ success: false, error: { code: 'FILE_NOT_FOUND', message: '文件访问失败' } },
{ status: 404 }
);
}
const urlObj = new URL(filePath);
const extFromUrl = path.extname(urlObj.pathname) || '';
const mimeType = lookup(extFromUrl) || 'application/octet-stream';
const filenameParts = [
material.productModel || 'unknown',
material.categoryName || '',
material.sceneName || '',
material.productCategoryName || '',
'源文件',
].filter(Boolean);
const finalExt = extFromUrl || '.bin';
const filename = `${filenameParts.join('_')}${finalExt}`;
const nodeStream = Readable.fromWeb(response.body as any);
const stream = new ReadableStream({
start(controller) {
nodeStream.on('data', (chunk) => controller.enqueue(chunk));
nodeStream.on('end', () => controller.close());
nodeStream.on('error', (err) => controller.error(err));
},
cancel() {
nodeStream.destroy();
},
});
return new NextResponse(stream as any, {
status: 200,
headers: {
'Content-Type': mimeType,
'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`,
},
});
} catch (error) {
console.error('[external/download] Error fetching old material URL:', filePath, error);
return NextResponse.json(
{ success: false, error: { code: 'DOWNLOAD_FAILED', message: '文件下载失败' } },
{ status: 500 }
);
}
}
// 处理本地文件路径
if (!path.isAbsolute(filePath)) {
filePath = path.join(process.cwd(), filePath);
}
// 检查文件是否存在
try {
await access(filePath);
} catch (error) {
console.error('[external/download] File not found:', filePath, error);
// 如果本地文件不存在,尝试动态打包
const supportedTypes = [
'模特',
'实拍图片',
'买家秀图片',
'小红书种草',
'主图模版',
'SKU模版',
'品牌VI灵感',
];
if (
supportedTypes.includes(material.materialType) &&
Array.isArray(material.displayImages) &&
material.displayImages.length > 0
) {
console.log('[external/download] Local file missing, fallback to dynamic zip from displayImages');
return await buildDynamicZipFromDisplayImages(material);
}
return NextResponse.json(
{ success: false, error: { code: 'FILE_NOT_FOUND', message: '文件可能已被删除' } },
{ status: 404 }
);
}
// 获取文件大小
const fileStats = await stat(filePath);
const fileSize = fileStats.size;
// 确定文件扩展名和MIME类型
const originalExt = material.zipFileName ? path.extname(material.zipFileName) : '';
const actualExt = path.extname(filePath);
const ext = originalExt || actualExt;
const mimeType = lookup(ext || filePath) || 'application/octet-stream';
// 生成下载文件名
const filenameParts = [
material.productModel || 'unknown',
material.categoryName || '',
material.sceneName || '',
material.productCategoryName || '',
'源文件',
].filter(Boolean);
const finalExt = ext || '.bin';
const filename = `${filenameParts.join('_')}${finalExt}`;
// 处理Range请求(断点续传)
const rangeHeader = request.headers.get('range');
if (rangeHeader) {
const rangePrefix = 'bytes=';
if (!rangeHeader.startsWith(rangePrefix)) {
return new NextResponse('Range header format is invalid', { status: 416 });
}
const rangeParts = rangeHeader.replace(rangePrefix, '').split('-');
const start = parseInt(rangeParts[0], 10);
const end = rangeParts[1] ? parseInt(rangeParts[1], 10) : fileSize - 1;
if (isNaN(start) || isNaN(end) || start >= fileSize || end >= fileSize) {
return new NextResponse('Range header out of bounds', { status: 416 });
}
const chunkSize = end - start + 1;
const fileStream = createReadStream(filePath, { start, end });
const stream = new ReadableStream({
start(controller) {
fileStream.on('data', (chunk) => controller.enqueue(chunk));
fileStream.on('end', () => controller.close());
fileStream.on('error', (err) => controller.error(err));
},
cancel() {
fileStream.destroy();
},
});
return new NextResponse(stream, {
status: 206,
headers: {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunkSize.toString(),
'Content-Type': mimeType,
'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`,
},
});
}
// 正常下载
const fileStream = createReadStream(filePath);
const stream = new ReadableStream({
start(controller) {
fileStream.on('data', (chunk) => controller.enqueue(chunk));
fileStream.on('end', () => controller.close());
fileStream.on('error', (err) => controller.error(err));
},
cancel() {
fileStream.destroy();
},
});
return new NextResponse(stream, {
headers: {
'Accept-Ranges': 'bytes',
'Content-Length': fileSize.toString(),
'Content-Type': mimeType,
'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`,
},
});
}
/**
* 动态打包displayImages为ZIP
*/
async function buildDynamicZipFromDisplayImages(material: any) {
const displayImages = Array.isArray(material.displayImages)
? material.displayImages.filter((img: any) => typeof img === 'string' && img.trim().length > 0)
: [];
if (displayImages.length === 0) {
return NextResponse.json(
{ success: false, error: { code: 'NO_DOWNLOADABLE_FILES', message: '该素材可能只包含图片' } },
{ status: 404 }
);
}
const zip = new JSZip();
let addedCount = 0;
for (let i = 0; i < displayImages.length; i++) {
const imageUrl = displayImages[i];
try {
const response = await fetch(imageUrl);
if (!response.ok) {
console.warn(`[external/download] Failed to fetch display image index=${i} url=${imageUrl}, status=${response.status}`);
continue;
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
if (!buffer || buffer.length === 0) {
console.warn(`[external/download] Empty buffer for display image index=${i} url=${imageUrl}, skipping`);
continue;
}
const urlObj = new URL(imageUrl);
const filename = (path.basename(urlObj.pathname) || `image-${i + 1}.webp`).replace(/[^\w\-.]+/g, '_');
zip.file(filename, buffer);
addedCount++;
} catch (error) {
console.error(`[external/download] Error while adding display image index=${i} url=${imageUrl} to zip:`, error);
}
}
if (addedCount === 0) {
return NextResponse.json(
{ success: false, error: { code: 'NO_DOWNLOADABLE_FILES', message: '该素材可能只包含图片' } },
{ status: 404 }
);
}
const zipBuffer = await zip.generateAsync({
type: 'nodebuffer',
compression: 'DEFLATE',
compressionOptions: { level: 6 },
});
const filenameParts = [
material.productModel || 'unknown',
material.categoryName || '',
material.sceneName || '',
material.productCategoryName || '',
'展示图合集',
].filter(Boolean);
const filename = `${filenameParts.join('_')}.zip`;
return new NextResponse(zipBuffer as any, {
headers: {
'Content-Type': 'application/zip',
'Content-Length': zipBuffer.length.toString(),
'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`,
},
});
}