- 创建统一的下载错误处理模块 (lib/download-errors.ts) - 实现13种错误类型分类和中文错误消息 - 后端统一使用 createDownloadError 创建标准化错误响应 - 前端统一使用 parseDownloadError 解析错误并显示 - 修复正式系统数据库Schema,添加产品全案相关字段和表 - 执行数据库迁移:添加 task_no, task_status, task_fetched_at 字段 - 创建 material_assets, api_keys, api_call_logs 表
268 lines
8.1 KiB
TypeScript
268 lines
8.1 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';
|
||
|
||
/**
|
||
* 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);
|
||
}
|
||
|
||
// 单个资产下载
|
||
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') {
|
||
// 如果指定了assetId,下载单个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);
|
||
}
|
||
|
||
// 未指定assetId,查找所有PSD文件
|
||
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 }
|
||
);
|
||
}
|
||
|
||
// 如果只有一个PSD文件,直接下载;否则打包
|
||
if (psdAssets.length === 1) {
|
||
return await downloadSingleFile(psdAssets[0]);
|
||
}
|
||
|
||
// 多个PSD文件,打包下载
|
||
return await downloadAsZip(psdAssets, material.taskNo || material.id, 'psd');
|
||
}
|
||
|
||
// 打包下载
|
||
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);
|
||
} 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) {
|
||
return new Promise<NextResponse>((resolve, reject) => {
|
||
const archive = archiver('zip', { zlib: { level: 5 } });
|
||
const chunks: Buffer[] = [];
|
||
|
||
archive.on('data', (chunk) => chunks.push(chunk));
|
||
archive.on('end', () => {
|
||
const buffer = Buffer.concat(chunks);
|
||
const fileName = `${taskNo}-${downloadType}.zip`;
|
||
|
||
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}` });
|
||
}
|
||
} else {
|
||
// 远程文件
|
||
const response = await fetch(fileUrl);
|
||
if (response.ok) {
|
||
const arrayBuffer = await response.arrayBuffer();
|
||
archive.append(Buffer.from(arrayBuffer), { name: `${folder}/${fileName}` });
|
||
}
|
||
}
|
||
} catch (error) {
|
||
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',
|
||
};
|
||
return mimeTypes[ext] || 'application/octet-stream';
|
||
}
|