1172 lines
39 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 { isProductPackageType } from '@/lib/material-type-mapper';
import { isPrivateTaskDetail } from '@/lib/task-detail-privacy';
import { resolveExternalImageDownloadSource } from '@/lib/external-image-assets';
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 || isPrivateTaskDetail(material)) {
return errorResponse(API_ERROR.TASK_NOT_FOUND);
}
// Additive, new-API-only download type. Existing tokens keep their original
// branches. Never unpack, repackage or substitute previews for an original.
if (downloadType === 'image-source') {
const source = await resolveExternalImageDownloadSource(material, assetId);
if (!source) {
return NextResponse.json(
{ success: false, error: { code: 'ORIGINAL_SOURCE_NOT_FOUND', message: '原始资源不存在或不可访问' } },
{ status: 404 },
);
}
return await downloadSingleFile(source, rangeHeader);
}
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<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, 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<string, any>;
other: any[];
}): Promise<ReadableStream<Uint8Array> | 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: <T extends Readable>(source: T) => T,
fileUrl: string,
entryName: string,
): Promise<void> {
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<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) {
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)}"`,
},
});
}