/** * 外部接口:文件下载 * 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 { isProductPackageType } from '@/lib/material-type-mapper'; import { nodeReadableToWebStream } from '@/lib/node-readable-stream'; import { createArchiveDownloadStream } from '@/lib/archive-download-stream'; import { getVideoDownloadAvailability, getVideoDownloadUnavailableMessage, } from '@/lib/video-download-policy'; 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'); const rangeHeader = request.headers.get('range'); 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); } const videoDownloadAvailability = getVideoDownloadAvailability( material.materialType, material.createdAt, ); if (!videoDownloadAvailability.allowed) { return NextResponse.json( { success: false, error: { code: 'VIDEO_WAIT_PERIOD', message: getVideoDownloadUnavailableMessage(videoDownloadAvailability), }, }, { status: 403 }, ); } if ( material.materialType === '标准模型' && (downloadType === 'white-background-single' || downloadType === 'white-background-zip') ) { return await downloadStandardModelWhiteBackground( material, downloadType, assetId, skuId, rangeHeader, ); } // 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 = {}; (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, rangeHeader); } 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, rangeHeader); } // 单个资产下载 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, rangeHeader); } // 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, rangeHeader); } 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], rangeHeader); } 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, rangeHeader); 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 !== 404) { 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, rangeHeader: string | null = null, ) { 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, rangeHeader); } 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 zipStream = await buildExternalWhiteBackgroundZipStream(whiteBackgroundGroup); if (!zipStream) { return NextResponse.json( { success: false, error: { code: 'FILE_NOT_FOUND', message: '白底图不存在' } }, { status: 404 } ); } const fileName = getWhiteBackgroundZipFileName(sku.skuName); return new NextResponse(zipStream, { headers: { 'Content-Type': 'application/zip', 'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`, }, }); } const fallbackZipAsset = whiteBackgroundGroup?.zipAssets?.[0]; if (fallbackZipAsset) { return downloadSingleFile( { ...fallbackZipAsset, fileName: getWhiteBackgroundZipFileName(sku.skuName), }, rangeHeader, ); } 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, rangeHeader: string | null = null) { 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 } ); } const contentType = getContentType(fileName); const fileStats = await stat(localPath); const parsedRange = parseByteRange(rangeHeader, fileStats.size); if (parsedRange === false) { return new NextResponse('Range header out of bounds', { status: 416, headers: { 'Accept-Ranges': 'bytes', 'Content-Range': `bytes */${fileStats.size}`, }, }); } const stream = nodeReadableToWebStream( parsedRange ? createReadStream(localPath, { start: parsedRange.start, end: parsedRange.end }) : createReadStream(localPath), ); const contentLength = parsedRange ? parsedRange.end - parsedRange.start + 1 : fileStats.size; return new NextResponse(stream, { status: parsedRange ? 206 : 200, headers: { 'Accept-Ranges': 'bytes', 'Content-Type': contentType, 'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`, 'Content-Length': contentLength.toString(), ...(parsedRange ? { 'Content-Range': `bytes ${parsedRange.start}-${parsedRange.end}/${fileStats.size}` } : {}), }, }); } // 远程文件(S3) if (isHttpUrl(normalizedFileUrl)) { try { const response = await fetch(normalizedFileUrl, { headers: rangeHeader ? { Range: rangeHeader } : undefined, }); if (!response.ok || !response.body) { if (rangeHeader && response.status === 416) { return new NextResponse('Range header out of bounds', { status: 416, headers: { 'Accept-Ranges': response.headers.get('accept-ranges') || 'bytes', ...(response.headers.get('content-range') ? { 'Content-Range': response.headers.get('content-range')! } : {}), }, }); } return NextResponse.json( { success: false, error: { code: 'FILE_NOT_FOUND', message: '文件不存在' } }, { status: 404 } ); } const contentType = response.headers.get('content-type') || getContentType(fileName); const contentLength = response.headers.get('content-length'); const contentRange = response.headers.get('content-range'); const acceptRanges = response.headers.get('accept-ranges') || 'bytes'; const nodeStream = Readable.fromWeb(response.body as any); const stream = nodeReadableToWebStream(nodeStream); return new NextResponse(stream as any, { status: response.status, headers: { 'Accept-Ranges': acceptRanges, 'Content-Type': contentType, 'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`, ...(contentLength ? { 'Content-Length': contentLength } : {}), ...(contentRange ? { 'Content-Range': contentRange } : {}), }, }); } 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 buildExternalWhiteBackgroundZipStream(whiteBackgroundGroup: { fixed: Record; other: any[]; }): Promise | null> { const archive = archiver('zip', { zlib: { level: 6 } }); const archiveDownload = createArchiveDownloadStream(archive); let addedCount = 0 try { for (const view of STANDARD_MODEL_WHITE_BG_VIEWS) { const asset = whiteBackgroundGroup.fixed[view.key]; if (!asset?.fileUrl) { continue; } const ext = path.extname(asset.fileName || asset.fileUrl) || '.jpg'; const fileName = asset.fileName || `${view.label}${ext}`; await appendExternalAssetToArchive( archive, archiveDownload.trackSource, asset.fileUrl, fileName, ); addedCount += 1 } for (const asset of whiteBackgroundGroup.other || []) { if (!asset?.fileUrl) { continue; } await appendExternalAssetToArchive( archive, archiveDownload.trackSource, asset.fileUrl, `other/${asset.fileName || path.basename(asset.fileUrl)}`, ); addedCount += 1 } if (addedCount === 0) { archiveDownload.abort(); return null; } return archiveDownload.finalize(); } catch (error) { archiveDownload.abort(); throw error; } } function parseByteRange( rangeHeader: string | null, fileSize: number, ): { start: number; end: number } | null | false { if (!rangeHeader) { return null; } if (!Number.isSafeInteger(fileSize) || fileSize <= 0) { return false; } const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim()); if (!match || (!match[1] && !match[2])) { return false; } if (!match[1]) { const suffixLength = Number(match[2]); if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) { return false; } return { start: Math.max(fileSize - suffixLength, 0), end: fileSize - 1, }; } const start = Number(match[1]); if (!Number.isSafeInteger(start) || start < 0 || start >= fileSize) { return false; } if (!match[2]) { return { start, end: fileSize - 1 }; } const requestedEnd = Number(match[2]); if (!Number.isSafeInteger(requestedEnd) || requestedEnd < start) { return false; } const end = Math.min(requestedEnd, fileSize - 1); return { start, end }; } async function appendExternalAssetToArchive( archive: archiver.Archiver, trackSource: (source: T) => T, fileUrl: string, entryName: string, ): Promise { if (isHttpUrl(fileUrl)) { const response = await fetch(fileUrl); if (!response.ok || !response.body) { throw new Error(`读取资源失败: ${response.status}`); } archive.append(trackSource(Readable.fromWeb(response.body as any)), { name: entryName }); return; } const localPath = resolveAssetLocalPath(fileUrl); if (!localPath) { throw new Error('资源路径无效'); } await access(localPath); archive.file(localPath, { name: entryName }); } /** * 打包下载为 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}`); }); const archive = archiver('zip', { zlib: { level: 5 } }); const archiveDownload = createArchiveDownloadStream(archive); let successCount = 0; let failCount = 0; for (const asset of assets) { const fileUrl = normalizeAssetFileUrl(asset.fileUrl); const fileName = asset.fileName || path.basename(fileUrl); const entryName = `${asset.assetType}/${fileName}`; try { const localPath = resolveAssetLocalPath(fileUrl); if (localPath && fs.existsSync(localPath)) { archive.file(localPath, { name: entryName }); successCount++; continue; } if (localPath) { console.error(`[downloadAsZip] ❌ 本地文件不存在: ${localPath}`); failCount++; continue; } if (isHttpUrl(fileUrl)) { const response = await fetch(fileUrl); if (response.ok && response.body) { archive.append( archiveDownload.trackSource(Readable.fromWeb(response.body as any)), { name: entryName }, ); successCount++; continue; } console.error(`[downloadAsZip] ❌ 远程文件获取失败: ${fileUrl}, status=${response.status}`); } else { console.error(`[downloadAsZip] ❌ 文件地址无效: ${fileUrl}`); } failCount++; } catch (error) { failCount++; console.error(`[downloadAsZip] ❌ 添加文件失败: ${fileName}`, error); } } if (successCount === 0) { archiveDownload.abort(); return NextResponse.json( { success: false, error: { code: 'NO_FILES', message: '没有可下载的文件' } }, { status: 404 } ); } const stream = archiveDownload.finalize(); return new NextResponse(stream as any, { headers: { 'Content-Type': 'application/zip', 'Content-Disposition': `attachment; filename="${encodeURIComponent(`${taskNo}-${downloadType}.zip`)}"`, }, }); } /** * 根据文件名获取 Content-Type */ function getContentType(fileName: string): string { const ext = path.extname(fileName).toLowerCase(); const mimeTypes: Record = { '.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) { const rangeHeader = request.headers.get('range'); // 优先检查本地 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); console.log(`[external/download] 使用本地 ZIP 文件: ${localZipPath}`); const ext = path.extname(material.zipFileName || localZipPath) || '.zip'; const filenameParts = [ material.productModel || 'unknown', material.categoryName || '', material.sceneName || '', '源文件', ].filter(Boolean); const filename = `${filenameParts.join('_')}${ext}`; return await downloadSingleFile( { fileUrl: localZipPath, fileName: filename }, rangeHeader, ); } catch (error) { console.warn(`[external/download] 本地 ZIP 文件不存在,尝试其他方式: ${localZipPath}`); // 继续尝试其他下载方式 } } } // 检查是否有ZIP文件;若无,则处理S3直链,否则再尝试动态打包 if (!material.zipFilePath || material.zipFilePath.trim() === '') { const zipUrl = (material.zipFileName || '').trim(); // 仅兼容历史素材:部分音频/视频源文件曾把 S3 URL 写入 zipFileName。 // 新视频源以 videoSourceUrl 对应的生产 uploads/NAS 文件为准,预览产物才写入 S3。 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, { headers: rangeHeader ? { Range: rangeHeader } : undefined, }); if (!response.ok || !response.body) { if (rangeHeader && response.status === 416) { return new NextResponse('Range header out of bounds', { status: 416, headers: { 'Accept-Ranges': response.headers.get('accept-ranges') || 'bytes', ...(response.headers.get('content-range') ? { 'Content-Range': response.headers.get('content-range')! } : {}), }, }); } 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 = nodeReadableToWebStream(nodeStream); const contentType = response.headers.get('content-type') || mimeType; const contentLength = response.headers.get('content-length'); const contentRange = response.headers.get('content-range'); const acceptRanges = response.headers.get('accept-ranges') || 'bytes'; return new NextResponse(stream as any, { status: response.status, headers: { 'Accept-Ranges': acceptRanges, 'Content-Type': contentType, 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, ...(contentLength ? { 'Content-Length': contentLength } : {}), ...(contentRange ? { 'Content-Range': contentRange } : {}), }, }); } 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, { headers: rangeHeader ? { Range: rangeHeader } : undefined, }); if (!response.ok || !response.body) { if (rangeHeader && response.status === 416) { return new NextResponse('Range header out of bounds', { status: 416, headers: { 'Accept-Ranges': response.headers.get('accept-ranges') || 'bytes', ...(response.headers.get('content-range') ? { 'Content-Range': response.headers.get('content-range')! } : {}), }, }); } 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 = nodeReadableToWebStream(nodeStream); const contentType = response.headers.get('content-type') || mimeType; const contentLength = response.headers.get('content-length'); const contentRange = response.headers.get('content-range'); const acceptRanges = response.headers.get('accept-ranges') || 'bytes'; return new NextResponse(stream as any, { status: response.status, headers: { 'Accept-Ranges': acceptRanges, 'Content-Type': contentType, 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, ...(contentLength ? { 'Content-Length': contentLength } : {}), ...(contentRange ? { 'Content-Range': contentRange } : {}), }, }); } 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 parsedRange = parseByteRange(rangeHeader, fileSize); if (parsedRange === false) { return new NextResponse('Range header out of bounds', { status: 416, headers: { 'Accept-Ranges': 'bytes', 'Content-Range': `bytes */${fileSize}`, }, }); } if (parsedRange) { const chunkSize = parsedRange.end - parsedRange.start + 1; const stream = nodeReadableToWebStream( createReadStream(filePath, { start: parsedRange.start, end: parsedRange.end }), ); return new NextResponse(stream, { status: 206, headers: { 'Content-Range': `bytes ${parsedRange.start}-${parsedRange.end}/${fileSize}`, 'Accept-Ranges': 'bytes', 'Content-Length': chunkSize.toString(), 'Content-Type': mimeType, 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, }, }); } // 正常下载 const stream = nodeReadableToWebStream(createReadStream(filePath)); 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 archive = archiver('zip', { zlib: { level: 6 } }); const archiveDownload = createArchiveDownloadStream(archive); let addedCount = 0; for (let i = 0; i < displayImages.length; i++) { const imageUrl = displayImages[i]; try { const response = await fetch(imageUrl); if (!response.ok || !response.body) { console.warn(`[external/download] Failed to fetch display image index=${i} url=${imageUrl}, status=${response.status}`); continue; } const urlObj = new URL(imageUrl); const filename = (path.basename(urlObj.pathname) || `image-${i + 1}.webp`).replace(/[^\w\-.]+/g, '_'); archive.append( archiveDownload.trackSource(Readable.fromWeb(response.body as any)), { name: filename }, ); addedCount++; } catch (error) { console.error(`[external/download] Error while adding display image index=${i} url=${imageUrl} to zip:`, error); } } if (addedCount === 0) { archiveDownload.abort(); return NextResponse.json( { success: false, error: { code: 'NO_DOWNLOADABLE_FILES', message: '该素材可能只包含图片' } }, { status: 404 } ); } const filenameParts = [ material.productModel || 'unknown', material.categoryName || '', material.sceneName || '', material.productCategoryName || '', '展示图合集', ].filter(Boolean); const filename = `${filenameParts.join('_')}.zip`; const stream = archiveDownload.finalize(); return new NextResponse(stream as any, { headers: { 'Content-Type': 'application/zip', 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, }, }); }