chenpei 4dba93c4e5 feat: 完成任务-详情素材迁移到新逻辑
- 迁移57个老素材到新逻辑(本地zip + S3预览图)
- 优化文件命名:SPU_作者_店铺_类型.zip
- PSD文件单独存储,不打包进all.zip
- 修复下载接口,优先使用本地zip文件
- 修复预览图重复问题
- 添加迁移脚本和验证脚本
- 更新文档和迁移计划

迁移统计:
- 总素材数:77个
- 成功迁移:57个
- 新逻辑上传:18个
- 空记录:2个
2026-01-21 10:16:28 +08:00

1376 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 }
);
}
}