1172 lines
39 KiB
TypeScript
1172 lines
39 KiB
TypeScript
/**
|
||
* 外部接口:文件下载
|
||
* 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)}"`,
|
||
},
|
||
});
|
||
}
|