2026-05-07 16:37:22 +08:00

1083 lines
36 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';
import {
getWhiteBackgroundZipFileName,
groupWhiteBackgroundAssetsBySku,
hasAnyWhiteBackgroundAssetGroup,
parseStandardModelWhiteBgAssetType,
STANDARD_MODEL_WHITE_BG_VIEWS,
} from '@/lib/standard-model-white-background';
/**
* 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, skuId } = 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);
}
if (
material.materialType === '标准模型' &&
(downloadType === 'white-background-single' || downloadType === 'white-background-zip')
) {
return await downloadStandardModelWhiteBackground(material, downloadType, assetId, skuId);
}
// 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) {
console.log(`[external/download] 使用 all.zip: ${allZipAsset.fileUrl}`);
return await downloadSingleFile(allZipAsset);
}
const fallbackAssets = getAssetsForDownload(material.assets || [], 'all');
if (fallbackAssets.length === 0) {
return NextResponse.json(
{ success: false, error: { code: 'NO_ASSETS', message: '没有可下载的文件' } },
{ status: 404 }
);
}
console.warn('[external/download] all.zip 缺失,使用 main/sku/detail 动态打包兜底');
return await downloadAsZip(fallbackAssets, material.taskNo || material.id, 'all');
}
// 下载分类:使用 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');
let lastNotFoundResponse: NextResponse | null = null;
for (const zipAsset of zipAssets) {
const response = await downloadSingleFile(zipAsset);
if (response.status < 400) {
return response;
}
if (response.status !== 404) {
return response;
}
lastNotFoundResponse = response;
console.warn(`[external/download] 任务 zip 资产不可用,尝试下一个: ${zipAsset.fileUrl}`);
}
console.warn('[external/download] zip 资产均失败,回退到通用下载链路');
const fallbackResponse = await downloadNonProductPackageMaterial(material, downloadType, request);
if (fallbackResponse.status < 400) {
return fallbackResponse;
}
if (lastNotFoundResponse) {
return lastNotFoundResponse;
}
return NextResponse.json(
{ success: false, error: { code: 'NO_ZIP_FILE', message: '没有可下载的压缩包' } },
{ status: 404 }
);
}
}
// 其他素材类型:复用 /api/materials/download 的逻辑
// 注意:外部接口通过 token 验证,已跳过权限检查
return await downloadNonProductPackageMaterial(material, downloadType, request);
} catch (error) {
console.error('[external/download] error:', error);
return errorResponse(API_ERROR.SERVER_ERROR);
}
}
async function downloadStandardModelWhiteBackground(
material: any,
downloadType: 'white-background-single' | 'white-background-zip',
assetId?: string,
skuId?: string,
) {
if (downloadType === 'white-background-single') {
if (!assetId) {
return NextResponse.json(
{ success: false, error: { code: 'INVALID_TOKEN', message: '白底图下载令牌无效' } },
{ status: 400 }
);
}
const asset = (material.assets || []).find((item: any) => item.id === assetId);
const parsedType = parseStandardModelWhiteBgAssetType(asset?.assetType || '');
if (!asset || !parsedType || parsedType.kind === 'zip') {
return NextResponse.json(
{ success: false, error: { code: 'FILE_NOT_FOUND', message: '白底图不存在' } },
{ status: 404 }
);
}
return downloadSingleFile(asset);
}
if (!skuId) {
return NextResponse.json(
{ success: false, error: { code: 'INVALID_TOKEN', message: '白底图下载令牌无效' } },
{ status: 400 }
);
}
const sku = await prisma.materialSku.findFirst({
where: {
id: skuId,
materialId: material.id,
deletedAt: null,
},
select: {
id: true,
skuName: true,
},
});
if (!sku) {
return NextResponse.json(
{ success: false, error: { code: 'FILE_NOT_FOUND', message: 'SKU 不存在' } },
{ status: 404 }
);
}
const whiteBackgroundGroup = groupWhiteBackgroundAssetsBySku(material.assets || []).get(sku.id);
if (whiteBackgroundGroup && hasAnyWhiteBackgroundAssetGroup(whiteBackgroundGroup)) {
const zipBuffer = await buildExternalWhiteBackgroundZipBuffer(whiteBackgroundGroup);
if (zipBuffer.length === 0) {
return NextResponse.json(
{ success: false, error: { code: 'FILE_NOT_FOUND', message: '白底图不存在' } },
{ status: 404 }
);
}
const fileName = getWhiteBackgroundZipFileName(sku.skuName);
return new NextResponse(zipBuffer as any, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`,
'Content-Length': zipBuffer.length.toString(),
},
});
}
const fallbackZipAsset = whiteBackgroundGroup?.zipAssets?.[0];
if (fallbackZipAsset) {
return downloadSingleFile({
...fallbackZipAsset,
fileName: getWhiteBackgroundZipFileName(sku.skuName),
});
}
return NextResponse.json(
{ success: false, error: { code: 'FILE_NOT_FOUND', message: '白底图不存在' } },
{ status: 404 }
);
}
/**
* 根据下载类型获取资产列表
* 排除预览类型资产
*/
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 [];
}
}
/**
* 下载单个文件
* ⚠️ 安全修复:使用流式传输替代同步读取,防止大文件导致内存溢出(R24)
*/
async function downloadSingleFile(asset: any) {
const normalizedFileUrl = normalizeAssetFileUrl(asset?.fileUrl);
const fileName = resolveAssetFileName(asset, normalizedFileUrl);
const localPath = resolveAssetLocalPath(asset?.fileUrl);
// 本地文件
if (localPath) {
if (!fs.existsSync(localPath)) {
return NextResponse.json(
{ success: false, error: { code: 'FILE_NOT_FOUND', message: '文件不存在' } },
{ status: 404 }
);
}
// 使用流式传输,避免将整个文件加载到内存中(修复 R24)
const fileStream = createReadStream(localPath);
// N6修复:确保文件流在任何情况下都能正确清理
const stream = new ReadableStream({
start(controller) {
fileStream.on('data', (chunk) => controller.enqueue(chunk));
fileStream.on('end', () => controller.close());
fileStream.on('error', (err) => {
// 错误时也要销毁文件流,防止文件描述符泄漏
fileStream.destroy();
controller.error(err);
});
},
cancel() {
fileStream.destroy();
},
});
const contentType = getContentType(fileName);
const fileStats = await stat(localPath);
return new NextResponse(stream, {
headers: {
'Content-Type': contentType,
'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`,
'Content-Length': fileStats.size.toString(),
},
});
}
// 远程文件(S3)
if (isHttpUrl(normalizedFileUrl)) {
try {
const response = await fetch(normalizedFileUrl);
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 }
);
}
}
return NextResponse.json(
{ success: false, error: { code: 'INVALID_FILE_URL', message: '文件地址无效' } },
{ status: 400 }
);
}
async function buildExternalWhiteBackgroundZipBuffer(whiteBackgroundGroup: {
fixed: Record<string, any>;
other: any[];
}) {
const zip = new JSZip();
let addedCount = 0
for (const view of STANDARD_MODEL_WHITE_BG_VIEWS) {
const asset = whiteBackgroundGroup.fixed[view.key];
if (!asset?.fileUrl) {
continue;
}
const fileBuffer = await readExternalAssetBuffer(asset.fileUrl);
const ext = path.extname(asset.fileName || asset.fileUrl) || '.jpg';
const fileName = asset.fileName || `${view.label}${ext}`;
zip.file(fileName, fileBuffer);
addedCount += 1
}
for (const asset of whiteBackgroundGroup.other || []) {
if (!asset?.fileUrl) {
continue;
}
const fileBuffer = await readExternalAssetBuffer(asset.fileUrl);
zip.file(`other/${asset.fileName || path.basename(asset.fileUrl)}`, fileBuffer);
addedCount += 1
}
if (addedCount === 0) {
return Buffer.alloc(0);
}
return zip.generateAsync({
type: 'nodebuffer',
compression: 'DEFLATE',
compressionOptions: { level: 6 },
});
}
async function readExternalAssetBuffer(fileUrl: string): Promise<Buffer> {
if (isHttpUrl(fileUrl)) {
const response = await fetch(fileUrl);
if (!response.ok) {
throw new Error(`读取资源失败: ${response.status}`);
}
return Buffer.from(await response.arrayBuffer());
}
const localPath = resolveAssetLocalPath(fileUrl);
if (!localPath) {
throw new Error('资源路径无效');
}
return fs.promises.readFile(localPath);
}
/**
* 打包下载为 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',
'.psb': 'application/vnd.adobe.photoshop',
'.tif': 'image/tiff',
'.tiff': 'image/tiff',
'.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());
}
/**
* 规范化资产 fileUrl(统一斜杠并去除首尾空格)
*/
function normalizeAssetFileUrl(fileUrl?: string | null): string {
if (!fileUrl || typeof fileUrl !== 'string') return '';
return fileUrl.trim().replace(/\\/g, '/');
}
/**
* 将资产 fileUrl 解析为本地绝对路径(仅针对非 http(s) 地址)
*/
function resolveAssetLocalPath(fileUrl?: string | null): string | null {
if (!fileUrl || typeof fileUrl !== 'string') return null;
const normalizedUrl = normalizeAssetFileUrl(fileUrl);
if (!normalizedUrl || isHttpUrl(normalizedUrl)) {
return null;
}
if (normalizedUrl.startsWith('/uploads/')) {
return path.join(process.cwd(), normalizedUrl.slice(1));
}
if (path.isAbsolute(fileUrl)) {
return fileUrl;
}
const relativePath = normalizedUrl.replace(/^\.?\//, '');
return path.join(process.cwd(), relativePath);
}
/**
* 推断下载文件名
*/
function resolveAssetFileName(asset: any, normalizedFileUrl: string): string {
if (asset?.fileName && typeof asset.fileName === 'string' && asset.fileName.trim()) {
return asset.fileName;
}
if (isHttpUrl(normalizedFileUrl)) {
try {
return path.basename(new URL(normalizedFileUrl).pathname) || 'download-file';
} catch {
return path.basename(normalizedFileUrl) || 'download-file';
}
}
return path.basename(normalizedFileUrl) || 'download-file';
}
/**
* 下载非任务-详情类型的素材
* 复用 /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);
// N6修复:确保文件流在任何情况下都能正确清理
const stream = new ReadableStream({
start(controller) {
fileStream.on('data', (chunk) => controller.enqueue(chunk));
fileStream.on('end', () => controller.close());
fileStream.on('error', (err) => {
// 错误时也要销毁文件流,防止文件描述符泄漏
fileStream.destroy();
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);
}
// R29修复:路径遍历保护
// 确保文件路径在允许的目录内,防止 ../../ 攻击
const { isPathSafe, isPathInAllowedDirs, getAllowedFileDirectories } = await import('@/lib/path-security');
if (!isPathSafe(material.zipFilePath)) {
console.error('[external/download] 检测到危险路径:', material.zipFilePath);
return NextResponse.json(
{ success: false, error: { code: 'INVALID_PATH', message: '文件路径无效' } },
{ status: 400 }
);
}
const allowedDirs = getAllowedFileDirectories();
if (!isPathInAllowedDirs(filePath, allowedDirs)) {
console.error('[external/download] 路径越界:', filePath, '允许的目录:', allowedDirs);
return NextResponse.json(
{ success: false, error: { code: 'ACCESS_DENIED', message: '无权访问该文件' } },
{ status: 403 }
);
}
// 检查文件是否存在
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 });
// N6修复:确保文件流在任何情况下都能正确清理
const stream = new ReadableStream({
start(controller) {
fileStream.on('data', (chunk) => controller.enqueue(chunk));
fileStream.on('end', () => controller.close());
fileStream.on('error', (err) => {
// 错误时也要销毁文件流,防止文件描述符泄漏
fileStream.destroy();
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);
// N6修复:确保文件流在任何情况下都能正确清理
const stream = new ReadableStream({
start(controller) {
fileStream.on('data', (chunk) => controller.enqueue(chunk));
fileStream.on('end', () => controller.close());
fileStream.on('error', (err) => {
// 错误时也要销毁文件流,防止文件描述符泄漏
fileStream.destroy();
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)}"`,
},
});
}