1376 lines
53 KiB
TypeScript
1376 lines
53 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
||
import { getServerSession } from 'next-auth';
|
||
import { authOptions } from '@/lib/auth';
|
||
import { prisma } from '@/lib/prisma';
|
||
import { access, stat } from 'fs/promises';
|
||
import { createReadStream } from 'fs';
|
||
import { Readable } from 'stream';
|
||
import path from 'path';
|
||
import { lookup, extension } from 'mime-types';
|
||
import JSZip from 'jszip';
|
||
import {
|
||
canUserDownloadMaterial,
|
||
isShareLinkAccess,
|
||
canUserDownloadSource,
|
||
} from '@/lib/department-permission';
|
||
import {
|
||
DownloadErrorCode,
|
||
createDownloadError,
|
||
DownloadErrorResponse,
|
||
} from '@/lib/download-errors';
|
||
|
||
function isHttpUrl(url?: string | null): boolean {
|
||
if (!url || typeof url !== 'string') return false;
|
||
return /^https?:\/\//i.test(url.trim());
|
||
}
|
||
|
||
import { isProductPackageType } from '@/lib/material-type-mapper';
|
||
|
||
// 处理产品全案分类下载
|
||
async function handleProductPackageDownload(material: any, downloadType: string) {
|
||
// 确保所有资产数据中的 BigInt 都被转换为字符串,避免序列化错误
|
||
const assets = (material.assets || []).map((asset: any) => ({
|
||
...asset,
|
||
fileSize: asset.fileSize ? (typeof asset.fileSize === 'bigint' ? asset.fileSize.toString() : asset.fileSize) : '0',
|
||
}));
|
||
|
||
// 添加详细日志
|
||
console.log(`[Download] handleProductPackageDownload: materialId=${material.id}, downloadType=${downloadType}, total assets=${assets.length}`);
|
||
console.log(`[Download] Assets breakdown:`, {
|
||
main: assets.filter((a: any) => a.assetType === 'main').length,
|
||
sku: assets.filter((a: any) => a.assetType === 'sku').length,
|
||
detail: assets.filter((a: any) => a.assetType === 'detail').length,
|
||
psd: assets.filter((a: any) => a.assetType === 'psd').length,
|
||
});
|
||
|
||
// 根据类型筛选资产
|
||
let targetAssets: any[] = [];
|
||
let zipName = '';
|
||
|
||
console.log(`[Download] ========== 开始筛选资产 ==========`);
|
||
console.log(`[Download] Requested downloadType: ${downloadType}`);
|
||
console.log(`[Download] Total assets available: ${assets.length}`);
|
||
|
||
switch (downloadType) {
|
||
case 'all':
|
||
// 全部下载(不含 PSD)
|
||
targetAssets = assets.filter((a: any) => {
|
||
const isNotPsd = a.assetType !== 'psd';
|
||
if (!isNotPsd) {
|
||
console.log(`[Download] Filtering out PSD asset: id=${a.id}, type=${a.assetType}`);
|
||
}
|
||
return isNotPsd;
|
||
});
|
||
zipName = '全部';
|
||
break;
|
||
case 'main':
|
||
targetAssets = assets.filter((a: any) => {
|
||
const isMain = a.assetType === 'main';
|
||
if (!isMain) {
|
||
console.log(`[Download] Filtering out non-main asset: id=${a.id}, type=${a.assetType}, expected=main`);
|
||
}
|
||
return isMain;
|
||
});
|
||
zipName = '主图';
|
||
break;
|
||
case 'sku':
|
||
targetAssets = assets.filter((a: any) => {
|
||
const isSku = a.assetType === 'sku';
|
||
if (!isSku) {
|
||
console.log(`[Download] Filtering out non-sku asset: id=${a.id}, type=${a.assetType}, expected=sku`);
|
||
}
|
||
return isSku;
|
||
});
|
||
zipName = 'SKU图';
|
||
break;
|
||
case 'detail':
|
||
targetAssets = assets.filter((a: any) => {
|
||
const isDetail = a.assetType === 'detail';
|
||
if (!isDetail) {
|
||
console.log(`[Download] Filtering out non-detail asset: id=${a.id}, type=${a.assetType}, expected=detail`);
|
||
}
|
||
return isDetail;
|
||
});
|
||
zipName = '详情图';
|
||
break;
|
||
case 'psd':
|
||
targetAssets = assets.filter((a: any) => {
|
||
const isPsd = a.assetType === 'psd';
|
||
if (!isPsd) {
|
||
console.log(`[Download] Filtering out non-psd asset: id=${a.id}, type=${a.assetType}, expected=psd`);
|
||
}
|
||
return isPsd;
|
||
});
|
||
zipName = 'PSD源文件';
|
||
break;
|
||
default:
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.INVALID_DOWNLOAD_TYPE),
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
console.log(`[Download] Filtered assets for ${downloadType}: count=${targetAssets.length}`);
|
||
console.log(`[Download] Selected assets (完整信息):`);
|
||
targetAssets.forEach((asset: any, index: number) => {
|
||
console.log(` [${index}] {
|
||
id: "${asset.id}",
|
||
assetType: "${asset.assetType}",
|
||
fileUrl: "${asset.fileUrl}",
|
||
fileName: "${asset.fileName}",
|
||
fileSize: "${asset.fileSize}"
|
||
}`);
|
||
});
|
||
|
||
// 验证:检查所有资产的fileUrl是否都相同(这可能是问题的根源)
|
||
if (targetAssets.length > 1) {
|
||
const firstUrl = targetAssets[0]?.fileUrl;
|
||
const allSameUrl = targetAssets.every((a: any) => a.fileUrl === firstUrl);
|
||
if (allSameUrl) {
|
||
console.error(`[Download] ⚠️ CRITICAL ERROR: All ${targetAssets.length} assets have the same fileUrl: ${firstUrl}`);
|
||
console.error(`[Download] This means all files are pointing to the same URL, which is likely the root cause of the download issue!`);
|
||
console.error(`[Download] Asset details:`);
|
||
targetAssets.forEach((asset: any, index: number) => {
|
||
console.error(` [${index}] id=${asset.id}, type=${asset.assetType}, url=${asset.fileUrl}, fileName=${asset.fileName}`);
|
||
});
|
||
} else {
|
||
console.log(`[Download] ✅ Assets have different fileUrls (this is correct)`);
|
||
// 打印每个资产的URL,用于验证
|
||
targetAssets.forEach((asset: any, index: number) => {
|
||
console.log(` Asset ${index} URL: ${asset.fileUrl}`);
|
||
});
|
||
}
|
||
}
|
||
|
||
// 验证:检查资产类型是否与请求的类型匹配
|
||
const mismatchedAssets = targetAssets.filter((a: any) => a.assetType !== downloadType);
|
||
if (mismatchedAssets.length > 0) {
|
||
console.error(`[Download] ⚠️ CRITICAL ERROR: Found ${mismatchedAssets.length} assets with mismatched types!`);
|
||
console.error(`[Download] Requested type: ${downloadType}`);
|
||
mismatchedAssets.forEach((asset: any) => {
|
||
console.error(` Asset id=${asset.id}, expected type=${downloadType}, actual type=${asset.assetType}, url=${asset.fileUrl}`);
|
||
});
|
||
} else {
|
||
console.log(`[Download] ✅ All ${targetAssets.length} assets have correct type: ${downloadType}`);
|
||
}
|
||
|
||
console.log(`[Download] ====================================`);
|
||
|
||
if (targetAssets.length === 0) {
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.NO_DOWNLOADABLE_FILES),
|
||
{ status: 404 }
|
||
);
|
||
}
|
||
|
||
// 单个文件直接下载
|
||
if (targetAssets.length === 1 && downloadType === 'psd') {
|
||
const asset = targetAssets[0];
|
||
const filePath = asset.fileUrl;
|
||
|
||
// PSD 存储在本地
|
||
if (!path.isAbsolute(filePath)) {
|
||
const absolutePath = path.join(process.cwd(), filePath);
|
||
|
||
try {
|
||
await access(absolutePath);
|
||
const fileStats = await stat(absolutePath);
|
||
const fileStream = createReadStream(absolutePath);
|
||
|
||
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(asset.fileName || filePath);
|
||
const mimeType = lookup(ext) || 'application/octet-stream';
|
||
|
||
return new NextResponse(stream, {
|
||
headers: {
|
||
'Content-Type': mimeType,
|
||
'Content-Length': fileStats.size.toString(),
|
||
'Content-Disposition': `attachment; filename="${encodeURIComponent(asset.fileName || 'source.psd')}"`,
|
||
// 静态PSD文件:长期缓存(1天),允许重新验证
|
||
'Cache-Control': 'public, max-age=86400, must-revalidate',
|
||
'Accept-Ranges': 'bytes',
|
||
// 性能优化:启用压缩(如果客户端支持)
|
||
'Vary': 'Accept-Encoding',
|
||
// 添加ETag支持
|
||
'ETag': `"${Buffer.from(`${absolutePath}-${fileStats.size}`).toString('base64').slice(0, 27)}"`,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.error('[Download] PSD file not found:', absolutePath, error);
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.PSD_NOT_FOUND),
|
||
{ status: 404 }
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 多个文件打包下载
|
||
const zip = new JSZip();
|
||
let addedCount = 0;
|
||
|
||
console.log(`[Download] Starting download for type: ${downloadType}, total assets: ${targetAssets.length}`);
|
||
|
||
// 按类型分组资产,如果是全部下载,需要分组到不同文件夹
|
||
const shouldGroupByType = downloadType === 'all';
|
||
const assetsByType: { [key: string]: any[] } = {};
|
||
|
||
if (shouldGroupByType) {
|
||
targetAssets.forEach((asset: any) => {
|
||
// 确保使用资产的实际类型,而不是从变量名推断
|
||
const type = asset.assetType || 'other';
|
||
if (!assetsByType[type]) {
|
||
assetsByType[type] = [];
|
||
}
|
||
// 验证资产类型是否匹配
|
||
if (asset.assetType && asset.assetType !== type) {
|
||
console.error(`[Download] CRITICAL: Asset type mismatch! asset.assetType=${asset.assetType}, inferred type=${type}, assetId=${asset.id}`);
|
||
}
|
||
assetsByType[type].push(asset);
|
||
});
|
||
|
||
console.log(`[Download] Grouped assets by type:`, Object.keys(assetsByType).map(type => `${type}: ${assetsByType[type].length}`).join(', '));
|
||
// 打印每个分组的详细信息
|
||
for (const [type, assets] of Object.entries(assetsByType)) {
|
||
console.log(`[Download] Type ${type} assets:`, assets.map((a: any) => ({ id: a.id, url: a.fileUrl, fileName: a.fileName })));
|
||
}
|
||
} else {
|
||
assetsByType['all'] = targetAssets;
|
||
}
|
||
|
||
// 类型文件夹名称映射
|
||
const typeFolderMap: { [key: string]: string } = {
|
||
main: '主图',
|
||
sku: 'SKU图',
|
||
detail: '详情图',
|
||
psd: 'PSD源文件',
|
||
};
|
||
|
||
// 用于跟踪已使用的文件名(完整路径),避免重名覆盖
|
||
const usedFileNames = new Set<string>();
|
||
|
||
// 遍历所有资产
|
||
const processAssets = async (assets: any[], folderPath: string = '') => {
|
||
for (let index = 0; index < assets.length; index++) {
|
||
const asset = assets[index];
|
||
try {
|
||
// 确保使用资产的原始数据,不要被任何变量覆盖
|
||
const fileUrl = asset.fileUrl;
|
||
const assetType = asset.assetType;
|
||
const assetId = asset.id;
|
||
const fileName = asset.fileName;
|
||
|
||
// 验证关键字段
|
||
if (!fileUrl) {
|
||
console.error(`[Download] CRITICAL: Asset has no fileUrl! assetId=${assetId}, type=${assetType}`);
|
||
continue;
|
||
}
|
||
if (!assetType) {
|
||
console.error(`[Download] CRITICAL: Asset has no assetType! assetId=${assetId}, url=${fileUrl}`);
|
||
continue;
|
||
}
|
||
|
||
// 生成唯一文件名(避免重名覆盖)
|
||
// 需要检查完整路径(包含文件夹)是否已使用
|
||
const baseFileName = fileName || `${assetType}-${index}.jpg`;
|
||
const ext = path.extname(baseFileName) || '.jpg';
|
||
const nameWithoutExt = path.basename(baseFileName, ext);
|
||
|
||
// 生成唯一文件名(考虑文件夹路径)
|
||
let uniqueFileName = baseFileName;
|
||
let suffix = 1;
|
||
let fullZipPath = folderPath ? `${folderPath}/${uniqueFileName}` : uniqueFileName;
|
||
|
||
while (usedFileNames.has(fullZipPath)) {
|
||
uniqueFileName = `${nameWithoutExt}_${suffix}${ext}`;
|
||
fullZipPath = folderPath ? `${folderPath}/${uniqueFileName}` : uniqueFileName;
|
||
suffix++;
|
||
}
|
||
|
||
// 标记为已使用
|
||
usedFileNames.add(fullZipPath);
|
||
|
||
// 添加详细日志
|
||
console.log(`[Download] ========== Processing asset ==========`);
|
||
console.log(`[Download] Asset ID: ${assetId}`);
|
||
console.log(`[Download] Asset Type: ${assetType}`);
|
||
console.log(`[Download] File URL: ${fileUrl}`);
|
||
console.log(`[Download] Original File Name: ${fileName}`);
|
||
console.log(`[Download] Unique File Name: ${uniqueFileName}`);
|
||
console.log(`[Download] Folder Path: ${folderPath || '(root)'}`);
|
||
|
||
// 判断是 S3 URL 还是本地路径
|
||
if (isHttpUrl(fileUrl)) {
|
||
// S3 文件
|
||
console.log(`[Download] Detected S3 URL, fetching file...`);
|
||
console.log(`[Download] Fetch URL: ${fileUrl}`);
|
||
const response = await fetch(fileUrl);
|
||
if (!response.ok) {
|
||
console.error(`[Download] ❌ Failed to fetch asset: ${fileUrl}, status: ${response.status}, assetType=${assetType}, assetId=${assetId}`);
|
||
continue;
|
||
}
|
||
const arrayBuffer = await response.arrayBuffer();
|
||
const buffer = Buffer.from(arrayBuffer);
|
||
|
||
// 验证文件大小
|
||
if (buffer.length === 0) {
|
||
console.error(`[Download] ❌ Empty buffer for asset: ${fileUrl}, assetType=${assetType}, assetId=${assetId}`);
|
||
continue;
|
||
}
|
||
|
||
// 计算文件哈希(前16字节的十六进制),用于验证文件是否相同
|
||
const fileHash = buffer.slice(0, Math.min(16, buffer.length)).toString('hex');
|
||
const fileHashEnd = buffer.slice(Math.max(0, buffer.length - 16)).toString('hex');
|
||
console.log(`[Download] File hash (first 16 bytes): ${fileHash}`);
|
||
console.log(`[Download] File hash (last 16 bytes): ${fileHashEnd}`);
|
||
|
||
// 使用唯一文件名(fullZipPath 已经在上面计算好了)
|
||
const zipPath = fullZipPath;
|
||
|
||
console.log(`[Download] Adding to ZIP:`);
|
||
console.log(` - ZIP Path: ${zipPath}`);
|
||
console.log(` - File Size: ${buffer.length} bytes`);
|
||
console.log(` - Asset Type: ${assetType}`);
|
||
console.log(` - Asset ID: ${assetId}`);
|
||
console.log(` - Source URL: ${fileUrl}`);
|
||
console.log(` - File Hash (start): ${fileHash}`);
|
||
console.log(` - File Hash (end): ${fileHashEnd}`);
|
||
|
||
zip.file(zipPath, buffer);
|
||
addedCount++;
|
||
console.log(`[Download] ✅ Successfully added S3 file to zip`);
|
||
console.log(`[Download] ====================================`);
|
||
} else {
|
||
// 本地文件
|
||
const absolutePath = path.isAbsolute(fileUrl)
|
||
? fileUrl
|
||
: path.join(process.cwd(), fileUrl);
|
||
|
||
console.log(`[Download] Reading local file: ${absolutePath} for asset type=${assetType}, id=${assetId}`);
|
||
try {
|
||
await access(absolutePath);
|
||
const fileStream = createReadStream(absolutePath);
|
||
const chunks: Buffer[] = [];
|
||
|
||
await new Promise<void>((resolve, reject) => {
|
||
fileStream.on('data', (chunk: any) => chunks.push(Buffer.from(chunk)));
|
||
fileStream.on('end', () => resolve());
|
||
fileStream.on('error', reject);
|
||
});
|
||
|
||
const buffer = Buffer.concat(chunks);
|
||
|
||
// 验证文件大小
|
||
if (buffer.length === 0) {
|
||
console.warn(`[Download] Empty buffer for local file: ${absolutePath}, assetType=${assetType}, assetId=${assetId}`);
|
||
continue;
|
||
}
|
||
|
||
// 使用唯一文件名(fullZipPath 已经在上面计算好了)
|
||
const zipPath = fullZipPath;
|
||
zip.file(zipPath, buffer);
|
||
addedCount++;
|
||
console.log(`[Download] ✅ Added local file to zip: ${zipPath} (assetType=${assetType}, assetId=${assetId}, path=${absolutePath}, size=${buffer.length} bytes)`);
|
||
} catch (error) {
|
||
console.warn(`[Download] Failed to read local file: ${absolutePath}, assetType=${assetType}, assetId=${assetId}`, error);
|
||
continue;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error(`[Download] Error adding asset to zip:`, error, asset);
|
||
}
|
||
}
|
||
};
|
||
|
||
// 如果按类型分组,为每种类型创建文件夹
|
||
if (shouldGroupByType) {
|
||
console.log(`[Download] ========== Processing grouped assets ==========`);
|
||
for (const [type, assets] of Object.entries(assetsByType)) {
|
||
const folderName = typeFolderMap[type] || type;
|
||
console.log(`[Download] ========== Processing type=${type}, folder=${folderName} ==========`);
|
||
console.log(`[Download] Assets count: ${assets.length}`);
|
||
|
||
// 验证资产类型是否正确,并打印每个资产的详细信息
|
||
for (const asset of assets) {
|
||
console.log(`[Download] Verifying asset: id=${asset.id}, type=${asset.assetType}, url=${asset.fileUrl}, fileName=${asset.fileName}`);
|
||
if (asset.assetType !== type) {
|
||
console.error(`[Download] ❌ CRITICAL ERROR: Asset type mismatch! Expected ${type}, got ${asset.assetType}, assetId=${asset.id}, url=${asset.fileUrl}, fileName=${asset.fileName}`);
|
||
} else {
|
||
console.log(`[Download] ✅ Asset type verified: ${asset.assetType}`);
|
||
}
|
||
}
|
||
await processAssets(assets, folderName);
|
||
console.log(`[Download] ========== Finished processing type=${type} ==========`);
|
||
}
|
||
} else {
|
||
console.log(`[Download] ========== Processing single type=${downloadType} ==========`);
|
||
console.log(`[Download] Assets count: ${targetAssets.length}`);
|
||
|
||
// 验证资产类型是否正确,并打印每个资产的详细信息
|
||
for (const asset of targetAssets) {
|
||
console.log(`[Download] Verifying asset: id=${asset.id}, type=${asset.assetType}, url=${asset.fileUrl}, fileName=${asset.fileName}`);
|
||
if (asset.assetType !== downloadType) {
|
||
console.error(`[Download] ❌ CRITICAL ERROR: Asset type mismatch! Expected ${downloadType}, got ${asset.assetType}, assetId=${asset.id}, url=${asset.fileUrl}, fileName=${asset.fileName}`);
|
||
} else {
|
||
console.log(`[Download] ✅ Asset type verified: ${asset.assetType}`);
|
||
}
|
||
}
|
||
await processAssets(targetAssets);
|
||
console.log(`[Download] ========== Finished processing single type ==========`);
|
||
}
|
||
|
||
console.log(`[Download] Total files added to zip: ${addedCount}`);
|
||
|
||
if (addedCount === 0) {
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.NO_DOWNLOADABLE_FILES),
|
||
{ status: 404 }
|
||
);
|
||
}
|
||
|
||
const zipBuffer = await zip.generateAsync({
|
||
type: 'nodebuffer',
|
||
compression: 'DEFLATE',
|
||
compressionOptions: { level: 6 },
|
||
});
|
||
|
||
const filenameParts = [
|
||
material.productModel || 'unknown',
|
||
material.categoryName || '',
|
||
material.sceneName || '',
|
||
zipName,
|
||
].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)}"`,
|
||
// 动态生成的ZIP文件:短期缓存(1小时),允许重新验证
|
||
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
||
'Accept-Ranges': 'bytes',
|
||
// 性能优化:启用压缩(如果客户端支持)
|
||
'Vary': 'Accept-Encoding',
|
||
},
|
||
});
|
||
}
|
||
|
||
// 动态打包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) {
|
||
console.warn('[Download API] No displayImages available for dynamic zip, materialId:', material.id);
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.NO_DOWNLOADABLE_FILES, '该素材可能只包含图片'),
|
||
{ status: 404 }
|
||
);
|
||
}
|
||
|
||
console.log(`[Download API] Building ZIP from displayImages for material ${material.id}, total: ${displayImages.length}`);
|
||
|
||
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(`[Download API] 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(`[Download API] 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(`[Download API] Error while adding display image index=${i} url=${imageUrl} to zip:`, error);
|
||
}
|
||
}
|
||
|
||
if (addedCount === 0) {
|
||
console.warn('[Download API] No valid displayImages added to zip, materialId:', material.id);
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.NO_DOWNLOADABLE_FILES, '该素材可能只包含图片'),
|
||
{ 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`;
|
||
|
||
console.log(
|
||
'[Download API] Dynamic ZIP built from displayImages, materialId:',
|
||
material.id,
|
||
'size:',
|
||
(zipBuffer.length / 1024 / 1024).toFixed(2),
|
||
'MB'
|
||
);
|
||
|
||
return new NextResponse(zipBuffer as any, {
|
||
headers: {
|
||
'Content-Type': 'application/zip',
|
||
'Content-Length': zipBuffer.length.toString(),
|
||
'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`,
|
||
// 动态生成的ZIP文件:短期缓存(1小时),允许重新验证
|
||
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
||
'Accept-Ranges': 'bytes',
|
||
// 性能优化:启用压缩(如果客户端支持)
|
||
'Vary': 'Accept-Encoding',
|
||
},
|
||
});
|
||
}
|
||
|
||
// GET - 下载素材
|
||
export async function GET(request: NextRequest) {
|
||
try {
|
||
const isShare = isShareLinkAccess(request);
|
||
|
||
// 如果不是分享链接,需要登录
|
||
if (!isShare) {
|
||
const session = await getServerSession(authOptions);
|
||
if (!session) {
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.UNAUTHORIZED),
|
||
{ status: 401 }
|
||
);
|
||
}
|
||
|
||
const userId = (session.user as any).id;
|
||
const userRole = (session.user as any).role;
|
||
|
||
// 检查用户级别的下载权限
|
||
if (userRole !== 'ADMIN') {
|
||
const user = await prisma.user.findUnique({
|
||
where: { id: userId },
|
||
select: { canDownload: true },
|
||
});
|
||
|
||
if (!user || !user.canDownload) {
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.FORBIDDEN),
|
||
{ status: 403 }
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
const { searchParams } = new URL(request.url);
|
||
const materialId = searchParams.get('id');
|
||
const downloadType = searchParams.get('type'); // all/main/sku/detail/psd
|
||
const assetId = searchParams.get('assetId'); // 单个资产ID(用于单个PSD文件下载)
|
||
|
||
if (!materialId) {
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.INVALID_DOWNLOAD_TYPE, '缺少素材ID'),
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
// 查询素材(产品全案需要包含资产信息)
|
||
let material: any;
|
||
try {
|
||
material = await prisma.material.findUnique({
|
||
where: { id: materialId },
|
||
select: {
|
||
id: true,
|
||
zipFilePath: true,
|
||
zipFileName: true,
|
||
productModel: true,
|
||
categoryName: true,
|
||
sceneName: true,
|
||
productCategoryName: true,
|
||
authorName: true,
|
||
materialType: true,
|
||
downloadCount: true,
|
||
displayImages: true,
|
||
videoSourceUrl: true,
|
||
videoPreview540Url: true,
|
||
videoPreviewClipUrl: true,
|
||
videoCoverUrl: true,
|
||
previewUrl: true,
|
||
createdAt: true,
|
||
// assets: {
|
||
// select: {
|
||
// id: true,
|
||
// assetType: true,
|
||
// fileUrl: true,
|
||
// fileName: true,
|
||
// fileSize: true,
|
||
// sortOrder: true,
|
||
// },
|
||
// orderBy: {
|
||
// sortOrder: 'asc',
|
||
// },
|
||
// },
|
||
},
|
||
});
|
||
|
||
// 如果是任务-详情(兼容老的"产品全案"),单独查询 assets
|
||
if (material && isProductPackageType(material.materialType)) {
|
||
const assets = await prisma.materialAsset.findMany({
|
||
where: {
|
||
materialId: material.id,
|
||
},
|
||
select: {
|
||
id: true,
|
||
assetType: true,
|
||
fileUrl: true,
|
||
fileName: true,
|
||
fileSize: true,
|
||
sortOrder: true,
|
||
},
|
||
orderBy: {
|
||
sortOrder: 'asc',
|
||
},
|
||
});
|
||
|
||
// 添加详细日志,验证资产数据
|
||
console.log(`[Download API] ========== 资产查询结果 ==========`);
|
||
console.log(`[Download API] MaterialId: ${material.id}`);
|
||
console.log(`[Download API] Total assets: ${assets.length}`);
|
||
// 将 BigInt 转换为字符串后再序列化
|
||
const assetsForLog = assets.map((a: any) => ({
|
||
...a,
|
||
fileSize: a.fileSize ? a.fileSize.toString() : '0',
|
||
}));
|
||
console.log(`[Download API] Raw assets from database:`, JSON.stringify(assetsForLog, null, 2));
|
||
|
||
// 按类型分组并打印详细信息
|
||
const assetsByType = {
|
||
main: assets.filter((a: any) => a.assetType === 'main'),
|
||
sku: assets.filter((a: any) => a.assetType === 'sku'),
|
||
detail: assets.filter((a: any) => a.assetType === 'detail'),
|
||
psd: assets.filter((a: any) => a.assetType === 'psd'),
|
||
};
|
||
|
||
console.log(`[Download API] Assets breakdown:`);
|
||
console.log(` - main: ${assetsByType.main.length} assets`);
|
||
assetsByType.main.forEach((a: any, i: number) => {
|
||
console.log(` [${i}] id=${a.id}, type=${a.assetType}, url=${a.fileUrl}, fileName=${a.fileName}`);
|
||
});
|
||
|
||
console.log(` - sku: ${assetsByType.sku.length} assets`);
|
||
assetsByType.sku.forEach((a: any, i: number) => {
|
||
console.log(` [${i}] id=${a.id}, type=${a.assetType}, url=${a.fileUrl}, fileName=${a.fileName}`);
|
||
});
|
||
|
||
console.log(` - detail: ${assetsByType.detail.length} assets`);
|
||
assetsByType.detail.forEach((a: any, i: number) => {
|
||
console.log(` [${i}] id=${a.id}, type=${a.assetType}, url=${a.fileUrl}, fileName=${a.fileName}`);
|
||
});
|
||
|
||
console.log(` - psd: ${assetsByType.psd.length} assets`);
|
||
assetsByType.psd.forEach((a: any, i: number) => {
|
||
console.log(` [${i}] id=${a.id}, type=${a.assetType}, url=${a.fileUrl}, fileName=${a.fileName}`);
|
||
});
|
||
|
||
// 检查是否有重复的fileUrl
|
||
const urlCounts: { [key: string]: { count: number; types: string[]; ids: string[] } } = {};
|
||
assets.forEach((asset: any) => {
|
||
if (asset.fileUrl) {
|
||
if (!urlCounts[asset.fileUrl]) {
|
||
urlCounts[asset.fileUrl] = { count: 0, types: [], ids: [] };
|
||
}
|
||
urlCounts[asset.fileUrl].count++;
|
||
urlCounts[asset.fileUrl].types.push(asset.assetType);
|
||
urlCounts[asset.fileUrl].ids.push(asset.id);
|
||
}
|
||
});
|
||
|
||
const duplicateUrls = Object.entries(urlCounts).filter(([url, info]) => info.count > 1);
|
||
if (duplicateUrls.length > 0) {
|
||
console.error(`[Download API] ⚠️ CRITICAL: Found duplicate fileUrls!`);
|
||
duplicateUrls.forEach(([url, info]) => {
|
||
console.error(` URL: ${url}`);
|
||
console.error(` Used by ${info.count} assets:`);
|
||
console.error(` Types: ${info.types.join(', ')}`);
|
||
console.error(` IDs: ${info.ids.join(', ')}`);
|
||
});
|
||
} else {
|
||
console.log(`[Download API] ✅ No duplicate fileUrls found`);
|
||
}
|
||
|
||
// 检查是否有资产类型为空或无效
|
||
const invalidAssets = assets.filter((a: any) => !a.assetType || !['main', 'sku', 'detail', 'psd'].includes(a.assetType));
|
||
if (invalidAssets.length > 0) {
|
||
console.error(`[Download API] ⚠️ CRITICAL: Found assets with invalid assetType!`);
|
||
invalidAssets.forEach((a: any) => {
|
||
console.error(` Asset ID: ${a.id}, assetType: ${a.assetType}, url: ${a.fileUrl}`);
|
||
});
|
||
}
|
||
|
||
console.log(`[Download API] ====================================`);
|
||
|
||
// 将 BigInt 转换为字符串,避免序列化错误
|
||
material.assets = assets.map((asset: any) => ({
|
||
...asset,
|
||
fileSize: asset.fileSize ? asset.fileSize.toString() : '0',
|
||
}));
|
||
}
|
||
} catch (error: any) {
|
||
if (error?.meta?.column?.includes('deletedAt')) {
|
||
console.warn('[Download API] deletedAt field not found, retrying without deletedAt');
|
||
material = await prisma.material.findUnique({
|
||
where: { id: materialId },
|
||
select: {
|
||
id: true,
|
||
zipFilePath: true,
|
||
zipFileName: true,
|
||
productModel: true,
|
||
categoryName: true,
|
||
sceneName: true,
|
||
productCategoryName: true,
|
||
authorName: true,
|
||
materialType: true,
|
||
downloadCount: true,
|
||
displayImages: true,
|
||
videoSourceUrl: true,
|
||
videoPreview540Url: true,
|
||
videoPreviewClipUrl: true,
|
||
videoCoverUrl: true,
|
||
previewUrl: true,
|
||
createdAt: true,
|
||
},
|
||
});
|
||
|
||
// 如果是任务-详情(兼容老的"产品全案"),单独查询 assets
|
||
if (material && isProductPackageType(material.materialType)) {
|
||
const assets = await prisma.materialAsset.findMany({
|
||
where: { materialId: material.id },
|
||
select: {
|
||
id: true,
|
||
assetType: true,
|
||
fileUrl: true,
|
||
fileName: true,
|
||
fileSize: true,
|
||
sortOrder: true,
|
||
},
|
||
orderBy: {
|
||
sortOrder: 'asc',
|
||
},
|
||
});
|
||
material.assets = assets;
|
||
}
|
||
} else {
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
if (!material) {
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.MATERIAL_NOT_FOUND),
|
||
{ status: 404 }
|
||
);
|
||
}
|
||
|
||
// 权限检查:如果不是分享链接,检查部门级别的下载权限
|
||
if (!isShare) {
|
||
const session = await getServerSession(authOptions);
|
||
if (session?.user) {
|
||
const userRole = (session.user as any).role;
|
||
const userDepartment = (session.user as any).department;
|
||
const managedDepartment = (session.user as any).managedDepartment;
|
||
const userId = (session.user as any).id;
|
||
|
||
// 超级管理员不受限制
|
||
const { isSuperAdmin } = await import('@/lib/department-permission');
|
||
if (!(await isSuperAdmin(userId))) {
|
||
const canDownload = await canUserDownloadMaterial(
|
||
userDepartment,
|
||
userRole,
|
||
managedDepartment,
|
||
material.materialType,
|
||
userId
|
||
);
|
||
|
||
if (!canDownload) {
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.NO_PERMISSION),
|
||
{ status: 403 }
|
||
);
|
||
}
|
||
|
||
// 任务-详情:PSD下载权限参考"详情模板"类型的 canDownloadSource 权限
|
||
// 如果下载的是 PSD,需要额外检查"详情模板"类型的源文件下载权限
|
||
if (isProductPackageType(material.materialType) && downloadType === 'psd') {
|
||
const canDownloadPsd = await canUserDownloadSource(
|
||
userDepartment,
|
||
userRole,
|
||
managedDepartment,
|
||
'详情模板', // 产品全案的 PSD 下载权限参考"详情模板"类型
|
||
userId
|
||
);
|
||
|
||
if (!canDownloadPsd) {
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.NO_PERMISSION, '无权限下载 PSD 源文件'),
|
||
{ status: 403 }
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 任务-详情:处理分类下载
|
||
if (isProductPackageType(material.materialType) && downloadType && material.assets) {
|
||
// 如果指定了assetId,直接下载单个PSD文件(不打包)
|
||
if (assetId && downloadType === 'psd') {
|
||
const asset = (material.assets as any[]).find((a: any) => a.id === assetId && a.assetType === 'psd');
|
||
if (!asset) {
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.PSD_NOT_FOUND),
|
||
{ status: 404 }
|
||
);
|
||
}
|
||
|
||
const filePath = asset.fileUrl;
|
||
const absolutePath = path.isAbsolute(filePath)
|
||
? filePath
|
||
: path.join(process.cwd(), filePath);
|
||
|
||
try {
|
||
await access(absolutePath);
|
||
const fileStats = await stat(absolutePath);
|
||
const fileStream = createReadStream(absolutePath);
|
||
|
||
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(asset.fileName || filePath);
|
||
const mimeType = lookup(ext) || 'application/octet-stream';
|
||
|
||
return new NextResponse(stream, {
|
||
headers: {
|
||
'Content-Type': mimeType,
|
||
'Content-Length': fileStats.size.toString(),
|
||
'Content-Disposition': `attachment; filename="${encodeURIComponent(asset.fileName || 'source.psd')}"`,
|
||
// 静态PSD文件:长期缓存(1天),允许重新验证
|
||
'Cache-Control': 'public, max-age=86400, must-revalidate',
|
||
'Accept-Ranges': 'bytes',
|
||
// 性能优化:启用压缩(如果客户端支持)
|
||
'Vary': 'Accept-Encoding',
|
||
// 添加ETag支持
|
||
'ETag': `"${Buffer.from(`${absolutePath}-${fileStats.size}`).toString('base64').slice(0, 27)}"`,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.error('[Download] PSD file not found:', absolutePath, error);
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.PSD_NOT_FOUND),
|
||
{ status: 404 }
|
||
);
|
||
}
|
||
}
|
||
|
||
return handleProductPackageDownload(material, downloadType);
|
||
}
|
||
|
||
// 增加下载次数(仅非分享链接时)
|
||
if (!isShare) {
|
||
try {
|
||
await prisma.material.update({
|
||
where: { id: materialId },
|
||
data: { downloadCount: { increment: 1 } },
|
||
});
|
||
} catch (error: any) {
|
||
if (error?.meta?.column?.includes('deletedAt')) {
|
||
console.warn('[Download API] deletedAt field not found in update, retrying without deletedAt');
|
||
await prisma.material.update({
|
||
where: { id: materialId },
|
||
data: { downloadCount: { increment: 1 } },
|
||
});
|
||
} else {
|
||
throw error;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 检查是否有ZIP文件;若无,则处理S3直链(音频等存放在zipFileName的场景),否则再尝试动态打包
|
||
if (!material.zipFilePath || material.zipFilePath.trim() === '') {
|
||
const zipUrl = (material.zipFileName || '').trim();
|
||
|
||
// 场景:音频/视频源文件直接存放在S3,zipFileName 是 S3 URL
|
||
if (zipUrl && isHttpUrl(zipUrl)) {
|
||
console.log('[Download API] 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('[Download API] Failed to fetch S3 url from zipFileName:', zipUrl, 'status:', response.status);
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.FILE_NOT_FOUND, 'S3文件访问失败'),
|
||
{ status: 404 }
|
||
);
|
||
}
|
||
|
||
// 将 web ReadableStream 转为 Node.js Readable 再封装为 Web ReadableStream 返回
|
||
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)}"`,
|
||
// S3代理下载:中等缓存(6小时),允许重新验证
|
||
'Cache-Control': 'public, max-age=21600, must-revalidate',
|
||
'Accept-Ranges': 'bytes',
|
||
// 性能优化:启用压缩(如果客户端支持)
|
||
'Vary': 'Accept-Encoding',
|
||
// 不设置 Content-Length,因 S3 直链已被代理流式返回
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.error('[Download API] Error proxying S3 url from zipFileName:', zipUrl, error);
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.S3_ACCESS_FAILED, 'S3代理下载失败'),
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|
||
|
||
console.log('[Download API] Material has no zipFilePath, try dynamic zip if supported. type=', material.materialType);
|
||
|
||
// 支持动态打包的素材类型
|
||
const supportedTypes = [
|
||
'模特',
|
||
'实拍图片',
|
||
'买家秀图片',
|
||
'小红书种草',
|
||
'主图模版',
|
||
'SKU模版',
|
||
'品牌VI灵感',
|
||
];
|
||
|
||
if (
|
||
supportedTypes.includes(material.materialType) &&
|
||
Array.isArray(material.displayImages) &&
|
||
material.displayImages.length > 0
|
||
) {
|
||
return buildDynamicZipFromDisplayImages(material);
|
||
}
|
||
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.NO_DOWNLOADABLE_FILES, '该素材可能只包含图片'),
|
||
{ status: 404 }
|
||
);
|
||
}
|
||
|
||
// 处理文件路径
|
||
// 判断是否老素材:根据文档,老素材的特征是:
|
||
// 1. 创建时间 ≤ 2025-10-27
|
||
// 2. previewUrl 不包含 "由壹点AI/"
|
||
// 3. zipFilePath 可能是相对路径(如 uploads/material-*.zip),实际文件存储在外网服务器 103.118.245.6
|
||
const oldMaterialDate = new Date('2025-10-27T23:59:59.999Z');
|
||
const materialCreatedAt = material.createdAt ? new Date(material.createdAt) : null;
|
||
const isOldByDate = materialCreatedAt && materialCreatedAt <= oldMaterialDate;
|
||
const isOldByUrl = material.zipFilePath?.includes('103.118.245.6') ||
|
||
material.zipFilePath?.startsWith('http') ||
|
||
(material.previewUrl && !material.previewUrl.includes('由壹点AI/'));
|
||
|
||
const isOldMaterial = isOldByDate || isOldByUrl;
|
||
|
||
let filePath = material.zipFilePath;
|
||
|
||
// 老素材:如果 zipFilePath 是相对路径,尝试从外网服务器下载
|
||
if (isOldMaterial && filePath && !filePath.startsWith('http') && !path.isAbsolute(filePath)) {
|
||
// 老素材的相对路径,实际文件在外网服务器上
|
||
const externalUrl = `http://103.118.245.6/${filePath.replace(/^\/+/, '')}`;
|
||
console.log('[Download API] Old material detected, using external server URL:', externalUrl);
|
||
|
||
try {
|
||
// 检查外网服务器文件是否存在
|
||
const headResponse = await fetch(externalUrl, { method: 'HEAD' });
|
||
if (headResponse.ok) {
|
||
console.log('[Download API] File found on external server, proxying download');
|
||
|
||
// 从外网服务器代理下载
|
||
const downloadResponse = await fetch(externalUrl);
|
||
if (!downloadResponse.ok || !downloadResponse.body) {
|
||
throw new Error(`External server returned ${downloadResponse.status}`);
|
||
}
|
||
|
||
// 生成下载文件名
|
||
const filenameParts = [
|
||
material.productModel || 'unknown',
|
||
material.categoryName || '',
|
||
material.sceneName || '',
|
||
material.productCategoryName || '',
|
||
'源文件',
|
||
].filter(Boolean);
|
||
const originalExt = material.zipFileName ? path.extname(material.zipFileName) : '';
|
||
const finalExt = originalExt || '.bin';
|
||
const filename = `${filenameParts.join('_')}${finalExt}`;
|
||
|
||
// 将 web ReadableStream 转为 Node.js Readable 再封装为 Web ReadableStream 返回
|
||
const nodeStream = Readable.fromWeb(downloadResponse.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();
|
||
},
|
||
});
|
||
|
||
const ext = path.extname(material.zipFileName || '');
|
||
const mimeType = lookup(ext) || 'application/octet-stream';
|
||
|
||
return new NextResponse(stream as any, {
|
||
status: 200,
|
||
headers: {
|
||
'Content-Type': mimeType,
|
||
'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`,
|
||
// 外网服务器代理下载:中等缓存(6小时),允许重新验证
|
||
'Cache-Control': 'public, max-age=21600, must-revalidate',
|
||
'Accept-Ranges': 'bytes',
|
||
'Vary': 'Accept-Encoding',
|
||
},
|
||
});
|
||
} else {
|
||
console.warn('[Download API] External server file not found, status:', headResponse.status);
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.FILE_NOT_FOUND, '老素材文件在外网服务器上不存在'),
|
||
{ status: 404 }
|
||
);
|
||
}
|
||
} catch (externalError) {
|
||
console.error('[Download API] External server download failed:', externalError);
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.NETWORK_ERROR, '无法连接到外网服务器,请检查网络连接'),
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|
||
|
||
// 新素材:相对路径转绝对路径
|
||
if (!isOldMaterial) {
|
||
if (!path.isAbsolute(filePath)) {
|
||
filePath = path.join(process.cwd(), filePath);
|
||
}
|
||
}
|
||
|
||
console.log('Attempting to download file:', filePath, '(Old material:', isOldMaterial, ')');
|
||
|
||
// 检查文件是否存在
|
||
try {
|
||
await access(filePath);
|
||
} catch (error) {
|
||
console.error('File not found:', filePath, 'error:', error);
|
||
|
||
// 如果是视频类素材且源文件不存在,尝试下载720P(预览视频,回退方案)
|
||
const videoTypes = ['主图视频', '安装视频', '种草视频'];
|
||
const isVideoMaterial = videoTypes.includes(material.materialType) ||
|
||
material.videoSourceUrl ||
|
||
material.videoPreview540Url;
|
||
|
||
if (isVideoMaterial && material.videoPreview540Url) {
|
||
console.log('[Download API] Video source file missing, fallback to 720P preview video, materialId:', material.id);
|
||
console.log('[Download API] Preview video URL:', material.videoPreview540Url);
|
||
|
||
try {
|
||
// 检查预览视频URL是否可访问
|
||
const previewResponse = await fetch(material.videoPreview540Url, { method: 'HEAD' });
|
||
if (previewResponse.ok) {
|
||
console.log('[Download API] Preview video accessible, proxying download');
|
||
|
||
// 从S3代理下载预览视频
|
||
const downloadResponse = await fetch(material.videoPreview540Url);
|
||
if (!downloadResponse.ok || !downloadResponse.body) {
|
||
throw new Error(`Preview video fetch returned ${downloadResponse.status}`);
|
||
}
|
||
|
||
// 生成下载文件名(标注为720P预览)
|
||
const filenameParts = [
|
||
material.productModel || 'unknown',
|
||
material.categoryName || '',
|
||
material.sceneName || '',
|
||
material.productCategoryName || '',
|
||
'720P',
|
||
].filter(Boolean);
|
||
const filename = `${filenameParts.join('_')}.mp4`;
|
||
|
||
// 将 web ReadableStream 转为 Node.js Readable 再封装为 Web ReadableStream 返回
|
||
const nodeStream = Readable.fromWeb(downloadResponse.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': 'video/mp4',
|
||
'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`,
|
||
// 预览视频代理下载:中等缓存(6小时),允许重新验证
|
||
'Cache-Control': 'public, max-age=21600, must-revalidate',
|
||
'Accept-Ranges': 'bytes',
|
||
'Vary': 'Accept-Encoding',
|
||
},
|
||
});
|
||
} else {
|
||
console.warn('[Download API] Preview video not accessible, status:', previewResponse.status);
|
||
// 预览视频不可访问,继续尝试其他fallback
|
||
}
|
||
} catch (videoError) {
|
||
console.error('[Download API] Preview video download failed:', videoError);
|
||
// 预览视频下载失败,继续尝试其他fallback
|
||
}
|
||
}
|
||
|
||
// 如果本地文件不存在,尝试动态打包
|
||
const supportedTypes = [
|
||
'模特',
|
||
'实拍图片',
|
||
'买家秀图片',
|
||
'小红书种草',
|
||
'主图模版',
|
||
'SKU模版',
|
||
'品牌VI灵感',
|
||
];
|
||
|
||
if (
|
||
supportedTypes.includes(material.materialType) &&
|
||
Array.isArray(material.displayImages) &&
|
||
material.displayImages.length > 0
|
||
) {
|
||
console.log('[Download API] Local file missing, fallback to dynamic zip from displayImages, materialId:', material.id);
|
||
return buildDynamicZipFromDisplayImages(material);
|
||
}
|
||
|
||
// 所有fallback都失败,返回明确的错误信息
|
||
if (isOldMaterial) {
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.FILE_NOT_FOUND, '老素材文件在外网服务器上不存在或无法访问'),
|
||
{ status: 404 }
|
||
);
|
||
} else if (isVideoMaterial) {
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.FILE_NOT_FOUND, '视频源文件不存在,且预览视频也无法访问'),
|
||
{ status: 404 }
|
||
);
|
||
} else {
|
||
return NextResponse.json(
|
||
createDownloadError(DownloadErrorCode.FILE_NOT_FOUND, '文件不存在或已被删除'),
|
||
{ 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 mimeExt = extension(mimeType);
|
||
const finalExt = ext || (mimeExt ? `.${mimeExt}` : '');
|
||
|
||
// 生成下载文件名
|
||
const filenameParts = [
|
||
material.productModel || 'unknown',
|
||
material.categoryName || '',
|
||
material.sceneName || '',
|
||
material.productCategoryName || '',
|
||
'源文件',
|
||
].filter(Boolean);
|
||
|
||
const filename = `${filenameParts.join('_')}${finalExt || '.bin'}`;
|
||
|
||
console.log('Download filename:', filename, 'size:', fileSize, 'mime:', mimeType);
|
||
|
||
// 处理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)}"`,
|
||
// 静态文件Range请求:长期缓存(1天),允许重新验证
|
||
'Cache-Control': 'public, max-age=86400, must-revalidate',
|
||
// 性能优化:启用压缩(如果客户端支持)
|
||
'Vary': 'Accept-Encoding, Range',
|
||
},
|
||
});
|
||
}
|
||
|
||
// 正常下载
|
||
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)}"`,
|
||
// 静态文件下载:长期缓存(1天),允许重新验证
|
||
// 注意:对于已存在的文件,内容不会改变,可以缓存
|
||
'Cache-Control': 'public, max-age=86400, must-revalidate',
|
||
// 性能优化:启用压缩(如果客户端支持)
|
||
'Vary': 'Accept-Encoding',
|
||
// 添加ETag支持(基于文件路径和大小)
|
||
'ETag': `"${Buffer.from(`${filePath}-${fileSize}`).toString('base64').slice(0, 27)}"`,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.error('Download error:', error);
|
||
|
||
// 根据错误类型返回对应的错误代码
|
||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||
|
||
let errorCode = DownloadErrorCode.UNKNOWN_ERROR;
|
||
let details: string | undefined;
|
||
|
||
if (errorMessage.includes('BigInt')) {
|
||
errorCode = DownloadErrorCode.BIGINT_SERIALIZATION_ERROR;
|
||
} else if (errorMessage.includes('ENOENT') || errorMessage.includes('not found')) {
|
||
errorCode = DownloadErrorCode.FILE_NOT_FOUND;
|
||
details = errorMessage;
|
||
} else if (errorMessage.includes('S3') || errorMessage.includes('存储')) {
|
||
errorCode = DownloadErrorCode.S3_ACCESS_FAILED;
|
||
details = errorMessage;
|
||
} else if (errorMessage.includes('network') || errorMessage.includes('网络')) {
|
||
errorCode = DownloadErrorCode.NETWORK_ERROR;
|
||
details = errorMessage;
|
||
} else if (errorMessage.includes('stream') || errorMessage.includes('流')) {
|
||
errorCode = DownloadErrorCode.STREAM_ERROR;
|
||
details = errorMessage;
|
||
} else if (errorMessage.includes('zip') || errorMessage.includes('ZIP')) {
|
||
errorCode = DownloadErrorCode.ZIP_CREATION_FAILED;
|
||
details = errorMessage;
|
||
} else {
|
||
details = errorMessage;
|
||
}
|
||
|
||
return NextResponse.json(
|
||
createDownloadError(errorCode, details),
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|