chenpei ea9076df45 feat: 实现统一的下载错误处理系统并修复产品全案数据库Schema
- 创建统一的下载错误处理模块 (lib/download-errors.ts)
- 实现13种错误类型分类和中文错误消息
- 后端统一使用 createDownloadError 创建标准化错误响应
- 前端统一使用 parseDownloadError 解析错误并显示
- 修复正式系统数据库Schema,添加产品全案相关字段和表
- 执行数据库迁移:添加 task_no, task_status, task_fetched_at 字段
- 创建 material_assets, api_keys, api_call_logs 表
2026-01-05 17:52:42 +08:00

268 lines
8.1 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.

/**
* 外部接口:文件下载
* 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';
}