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