760 lines
32 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.

// 素材上传API - 支持多种素材类型
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { uploadToS3 } from '@/lib/s3';
import { promises as fs } from 'fs';
import path from 'path';
import { randomBytes } from 'crypto';
import { parseAudioMetadata, isAudioFile } from '@/lib/audio-processor';
import { enqueueVideoTranscodeTask } from '@/lib/video-transcode-task';
import { AUTO_GENERAL_TYPES, requiresCategoryAndScene, requiresRenderer, getDefaultRenderer } from '@/lib/material-validation';
import { compressImage, sanitizeFileName } from '@/lib/image-processor';
import { processPdfUpload } from '@/lib/pdf-upload-handler';
import { deleteFiles } from '@/lib/file-cleanup';
import { deleteMaterialRelatedRecords } from '@/lib/material-cleanup';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 10800; // 3小时
// 音频素材占位符预览图(SVG格式)
const AUDIO_PLACEHOLDER = (() => {
const svg = `<svg width="600" height="400" xmlns="http://www.w3.org/2000/svg"><rect width="100%" height="100%" fill="#1e293b"/><circle cx="300" cy="180" r="60" fill="#3b82f6" opacity="0.8"/><polygon points="285,155 285,205 325,180" fill="#ffffff"/><text x="50%" y="75%" font-size="20" fill="#94a3b8" text-anchor="middle" font-family="PingFang SC, 'Microsoft YaHei', 'Segoe UI', sans-serif">音频素材</text></svg>`
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
})()
// 生成唯一文件名
function generateUniqueFilename(prefix: string, ext: string): string {
const timestamp = Date.now();
const random = randomBytes(8).toString('hex');
return `${prefix}-${timestamp}-${random}${ext}`;
}
// POST - 上传素材
import { isDetailTemplateType } from '@/lib/material-type-mapper';
export async function POST(request: NextRequest) {
// 用于清理的文件URL列表(上传失败时使用)
const uploadedFileUrls: string[] = [];
let zipFilePath = '';
let materialId: string | null = null;
try {
console.log('[Upload] Starting upload process...');
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json(
{ error: '请先登录' },
{ status: 401 }
);
}
const formData = await request.formData();
const previewImage = formData.get('previewImage') as File | null; // 第一张预览图(主预览图)
const previewImages = formData.getAll('previewImages') as File[]; // 其他预览图(作为展示图)
const displayImage = formData.get('displayImage') as File | null;
const modelImages = formData.getAll('modelImages') as File[];
const displayImagesFromForm = formData.getAll('displayImages') as File[];
const zipFile = formData.get('zipFile') as File | null;
// 支持分片上传后复用服务器已合并的文件
const existingFilePath = (formData.get('existingFilePath') as string) || '';
const originalFileName = (formData.get('originalFileName') as string) || '';
const productModel = formData.get('productModel') as string;
const categoryName = formData.get('categoryName') as string;
const sceneName = formData.get('sceneName') as string;
const platformName = (formData.get('platformName') as string) || '';
const productCategoryName = (formData.get('productCategoryName') as string) || '';
const materialType = (formData.get('materialType') as string) || '3D场景';
const rendererParam = formData.get('renderer') as string | null;
// 统一处理renderer:3D场景必须有renderer,其他类型可选
const renderer = requiresRenderer(materialType)
? (rendererParam || getDefaultRenderer(materialType))
: (rendererParam || '');
const taskNo = (formData.get('taskNo') as string) || '';
console.log('[Upload] Form data received:', {
hasPreviewImage: !!previewImage,
previewImagesCount: previewImages.length,
hasDisplayImage: !!displayImage,
modelImagesCount: modelImages.length,
hasZipFile: !!zipFile,
hasExistingFilePath: !!existingFilePath,
productModel,
categoryName,
sceneName,
materialType,
});
// 使用统一的验证逻辑验证必填字段
if (!productModel) {
return NextResponse.json(
{ error: '产品型号是必填的' },
{ status: 400 }
);
}
// 任务号验证(可选,如果提供则验证格式和唯一性)
if (taskNo && taskNo.trim()) {
const { validateTaskNo, isTaskNoExists } = await import('@/lib/product-package');
if (!validateTaskNo(taskNo)) {
return NextResponse.json(
{ error: '任务号格式错误,应为 TASK + 12-18位数字,可选的多个子任务号(-2位数字,如 TASK202601141713525624-01-01)' },
{ status: 400 }
);
}
// 检查任务号是否已存在
if (await isTaskNoExists(taskNo)) {
return NextResponse.json(
{ error: '任务号已存在,请使用其他任务号' },
{ status: 400 }
);
}
}
if (requiresCategoryAndScene(materialType) && (!categoryName || !sceneName)) {
return NextResponse.json(
{ error: '请填写所有必填字段(店铺、场景)' },
{ status: 400 }
);
}
// 根据素材类型验证主文件(与前端保持一致:除「模特」「标准模型」「买家秀图片」「小红书种草」外都需要主文件)
const requiresMainFile = !['模特', '标准模型', '买家秀图片', '小红书种草'].includes(materialType);
const hasMainFile = !!zipFile || !!existingFilePath;
if (requiresMainFile && !hasMainFile) {
return NextResponse.json(
{ error: '请上传素材文件' },
{ status: 400 }
);
}
// 获取用户信息
const user = await prisma.user.findUnique({
where: { id: (session.user as any).id },
select: { username: true },
});
if (!user) {
return NextResponse.json(
{ error: '用户不存在' },
{ status: 404 }
);
}
let previewUrl = '';
const displayImages: string[] = [];
// 处理预览图(第一张作为主预览图)
// 详情页/详情模板:如果用户上传了预览图,优先使用;否则使用展示图切片的第一张
if (previewImage && !['模特'].includes(materialType)) {
if (previewImage.size > 50 * 1024 * 1024) {
return NextResponse.json(
{ error: `预览图过大,请使用小于50MB的图片` },
{ status: 400 }
);
}
console.log('[Upload] Processing preview image (1/1)...');
const buffer = Buffer.from(await previewImage.arrayBuffer());
// 压缩预览图
console.log('[Upload] Compressing preview image...');
const compressedBuffer = await compressImage(buffer, {
maxSizeKB: 300,
maxWidth: 1200,
maxHeight: 1600,
format: 'webp'
});
const fileName = sanitizeFileName(previewImage.name);
const filename = generateUniqueFilename('preview', '.webp');
console.log('[Upload] Uploading compressed preview image to S3...');
previewUrl = await uploadToS3(compressedBuffer, filename, 'image/webp');
uploadedFileUrls.push(previewUrl); // 记录已上传的文件,用于失败时清理
console.log('[Upload] Preview image uploaded:', previewUrl);
}
// 处理其他预览图(作为展示图)- 并行处理以提高速度
// 详情页/详情模板:其他预览图也作为展示图处理
if (previewImages.length > 0 && !['模特'].includes(materialType)) {
console.log(`[Upload] Processing ${previewImages.length} preview images in parallel...`);
// 验证所有图片大小
for (let i = 0; i < previewImages.length; i++) {
if (previewImages[i].size > 50 * 1024 * 1024) {
return NextResponse.json(
{ error: `预览图 ${i + 2} 过大,请使用小于50MB的图片` },
{ status: 400 }
);
}
}
// 并行处理所有预览图
const uploadPromises = previewImages.map(async (image, index) => {
console.log(`[Upload] Processing preview image ${index + 2}/${previewImages.length + 1}...`);
const buffer = Buffer.from(await image.arrayBuffer());
// 压缩预览图
console.log(`[Upload] Compressing preview image ${index + 2}...`);
const compressedBuffer = await compressImage(buffer, {
maxSizeKB: 300,
maxWidth: 1200,
maxHeight: 1600,
format: 'webp'
});
const filename = generateUniqueFilename(`preview-${index + 2}`, '.webp');
console.log(`[Upload] Uploading compressed preview image ${index + 2} to S3...`);
const url = await uploadToS3(compressedBuffer, filename, 'image/webp');
uploadedFileUrls.push(url); // 记录已上传的文件,用于失败时清理
console.log(`[Upload] Preview image ${index + 2} uploaded:`, url);
return url;
});
// 等待所有预览图上传完成
const urls = await Promise.all(uploadPromises);
displayImages.push(...urls);
console.log(`[Upload] All ${previewImages.length} preview images uploaded successfully`);
}
// 处理展示图片(详情模板)- 后端自动压缩和切片
if (isDetailTemplateType(materialType) && displayImage) {
const buffer = Buffer.from(await displayImage.arrayBuffer());
// 使用后端的 sliceImage 函数自动压缩和切片
const { sliceImage } = await import('@/lib/image-processor');
console.log('[Upload] Processing display image (backend slicing)...');
const slices = await sliceImage(buffer, 1500, 500); // maxHeight=1500px, maxSizeKB=500KB
// 上传所有切片
for (let i = 0; i < slices.length; i++) {
const sliceBuffer = slices[i];
const filename = generateUniqueFilename(`display-${i + 1}`, '.webp');
const url = await uploadToS3(sliceBuffer, filename, 'image/webp');
displayImages.push(url);
uploadedFileUrls.push(url); // 记录已上传的文件,用于失败时清理
// 如果用户没有上传预览图,使用第一张切片作为预览图
if (i === 0 && !previewUrl) {
previewUrl = url;
console.log('[Upload] Using first display image slice as preview (no user preview uploaded)');
}
console.log(`[Upload] Display image slice ${i + 1}/${slices.length} uploaded:`, url);
}
}
// 处理模特图片
if (['模特', '实拍图片', '买家秀图片'].includes(materialType) && modelImages.length > 0) {
for (let i = 0; i < modelImages.length; i++) {
try {
const image = modelImages[i];
if (!image || image.size === 0) {
console.warn(`[Upload] Model image ${i + 1} is empty, skipping`);
continue;
}
const buffer = Buffer.from(await image.arrayBuffer());
const filename = generateUniqueFilename(`model-${i + 1}`, path.extname(image.name));
const url = await uploadToS3(buffer, filename, image.type);
displayImages.push(url);
uploadedFileUrls.push(url); // 记录已上传的文件,用于失败时清理
if (i === 0 && !previewUrl) {
previewUrl = url;
}
console.log(`[Upload] Model image ${i + 1} uploaded:`, url);
} catch (imageError) {
console.error(`[Upload] Failed to process model image ${i + 1}:`, imageError);
throw new Error(`处理图片 ${i + 1} 失败: ${imageError instanceof Error ? imageError.message : String(imageError)}`);
}
}
}
// 处理通用展示图(买家秀/小红书/主图模版/SKU模版/品牌VI 等)
if (displayImagesFromForm.length > 0) {
for (let i = 0; i < displayImagesFromForm.length; i++) {
try {
const image = displayImagesFromForm[i];
if (!image || image.size === 0) {
console.warn(`[Upload] Display image ${i + 1} is empty, skipping`);
continue;
}
const buffer = Buffer.from(await image.arrayBuffer());
const filename = generateUniqueFilename(`display-${i + 1}`, path.extname(image.name));
const url = await uploadToS3(buffer, filename, image.type);
displayImages.push(url);
uploadedFileUrls.push(url); // 记录已上传的文件,用于失败时清理
if (!previewUrl) {
previewUrl = url;
}
console.log(`[Upload] Display image ${i + 1} uploaded:`, url);
} catch (imageError) {
console.error(`[Upload] Failed to process display image ${i + 1}:`, imageError);
throw new Error(`处理展示图 ${i + 1} 失败: ${imageError instanceof Error ? imageError.message : String(imageError)}`);
}
}
}
// 处理素材文件(主文件:3D场景/PSD/视频源等)
let zipFileName = '';
let zipFilePath = '';
let zipFileSize = BigInt(0);
if (zipFile || existingFilePath) {
let ext = '';
let buffer: Buffer | null = null;
let sourcePath = '';
if (zipFile) {
ext = path.extname(zipFile.name);
buffer = Buffer.from(await zipFile.arrayBuffer());
zipFileSize = BigInt(buffer.length);
console.log('[Upload] Using uploaded file buffer');
} else {
// 使用分片合并后的服务器侧文件
sourcePath = existingFilePath;
const stat = await fs.stat(sourcePath);
zipFileSize = BigInt(stat.size);
ext = path.extname(originalFileName || sourcePath) || '.zip';
console.log('[Upload] Using existing merged file:', sourcePath, 'size:', stat.size);
}
const isVideoOrAudio = ['主图视频', '安装视频', '种草视频', '音乐'].includes(materialType);
// 主文件一律本地化存储到 uploads 目录,MinIO 只用于预览文件
const uploadDir = process.env.UPLOAD_DIR || './uploads';
await fs.mkdir(uploadDir, { recursive: true });
// 根据类型决定文件名前缀
const basePrefix = isVideoOrAudio
? materialType === '音乐'
? 'audio-source'
: 'video'
: 'material';
zipFileName = generateUniqueFilename(basePrefix, ext || '.bin');
zipFilePath = path.join(uploadDir, zipFileName);
if (sourcePath && !zipFile) {
// 分片上传场景:直接移动合并后的大文件,避免重复读写 50MB+ 文件
await fs.rename(sourcePath, zipFilePath);
// 已经移动到最终位置,不再额外清理
sourcePath = '';
console.log('[Upload] Moved merged file to final path (no re-copy):', zipFilePath);
} else if (buffer) {
// 直传场景:使用内存中的 buffer 写入
await fs.writeFile(zipFilePath, buffer);
console.log('[Upload] File saved locally:', zipFilePath);
} else {
throw new Error('未找到可写入的素材文件数据');
}
// PDF:从PDF文件提取第一页作为预览图(在文件保存后)
if (materialType === '品牌VI灵感') {
console.log('[Upload] 开始处理品牌VI灵感PDF预览图提取...');
console.log('[Upload] PDF文件路径:', zipFilePath);
// 强制提取PDF预览图,即使已有previewUrl也要重新提取(确保是图片而不是PDF文件)
const pdfResult = await processPdfUpload(zipFilePath, '');
console.log('[Upload] PDF预览图提取结果:', {
success: pdfResult.success,
hasPreviewUrl: !!pdfResult.previewUrl,
previewUrl: pdfResult.previewUrl,
error: pdfResult.error
});
if (pdfResult.success && pdfResult.previewUrl) {
previewUrl = pdfResult.previewUrl;
console.log('[Upload] ✅ PDF预览图提取成功,已设置previewUrl:', previewUrl);
} else {
const errorMsg = pdfResult.error || '未知错误';
console.error('[Upload] ❌ PDF预览图提取失败:', errorMsg);
console.error('[Upload] 这会导致首页卡片显示占位符,请检查:');
console.error('[Upload] 1. PDF文件是否存在:', zipFilePath);
console.error('[Upload] 2. poppler-utils是否已安装');
console.error('[Upload] 3. PDF文件是否有效');
// PDF预览提取失败时,previewUrl保持为空,首页卡片会显示占位符
previewUrl = '';
}
}
// 音频:尝试从本地文件/缓冲中解析封面信息,仅用于生成预览图,源文件仍本地化
if (materialType === '音乐' && zipFile && buffer && isAudioFile(zipFile.type)) {
try {
console.log('[Upload] Audio file detected, parsing metadata and extracting cover image...');
const audioMetadata = await parseAudioMetadata(buffer, zipFile.type);
if (audioMetadata) {
console.log('[Upload] Audio metadata parsed:', {
duration: audioMetadata.duration,
durationFormatted: audioMetadata.durationFormatted,
format: audioMetadata.format,
hasPicture: !!audioMetadata.picture,
});
// 如果音频文件包含封面图,提取并上传到 MinIO 作为预览
if (audioMetadata.picture && !previewUrl) {
try {
const pictureBuffer = audioMetadata.picture.data;
const pictureFormat = audioMetadata.picture.format || 'image/jpeg';
const pictureExt = pictureFormat.includes('png') ? '.png' : '.jpg';
const pictureFilename = generateUniqueFilename('audio-cover', pictureExt);
const pictureUrl = await uploadToS3(pictureBuffer, pictureFilename, pictureFormat);
previewUrl = pictureUrl;
uploadedFileUrls.push(pictureUrl); // 记录已上传的文件,用于失败时清理
console.log('[Upload] Audio cover image extracted and uploaded:', pictureUrl);
} catch (coverError) {
console.error('[Upload] Failed to upload audio cover image:', coverError);
}
}
}
} catch (audioError) {
console.error('[Upload] Failed to parse audio metadata:', audioError);
// 音频解析失败不影响上传,继续处理
}
}
// 如果是复用服务器合并文件,用后清理以节省空间
if (sourcePath) {
try {
// 使用统一的文件删除工具
const { deleteLocalFile } = await import('@/lib/file-cleanup');
const result = await deleteLocalFile(sourcePath);
if (result.success) {
console.log('[Upload] 已清理临时合并文件:', sourcePath);
} else {
console.warn('[Upload] 清理临时合并文件失败:', sourcePath, result.error);
}
// 同时清理整个临时目录(如果为空)
const tempDir = path.dirname(sourcePath);
try {
const files = await fs.readdir(tempDir);
if (files.length === 0) {
const { deleteLocalFile } = await import('@/lib/file-cleanup');
await deleteLocalFile(tempDir);
console.log('[Upload] 已清理空临时目录:', tempDir);
}
} catch (dirError) {
// 忽略目录清理错误
}
} catch (cleanupError) {
console.warn('[Upload] 清理临时合并文件失败(忽略):', cleanupError);
}
}
}
// 音频素材:如果没有预览图,使用占位符
if (materialType === '音乐' && (!previewUrl || previewUrl.trim() === '')) {
previewUrl = AUDIO_PLACEHOLDER
console.log('[Upload] Audio material without cover image, using audio placeholder')
}
// 验证买家秀图片必须有至少一张图片和预览图
if (materialType === '买家秀图片') {
if (displayImages.length === 0) {
return NextResponse.json(
{ error: '买家秀图片必须上传至少一张图片' },
{ status: 400 }
);
}
if (!previewUrl || previewUrl.trim() === '') {
return NextResponse.json(
{ error: '买家秀图片必须有一张预览图' },
{ status: 400 }
);
}
}
// 创建数据库记录(包含所有字段,确保数据不丢失)
// 对于视频类型,videoSourceUrl 保存本地相对路径(如:/uploads/xxx.mp4),仅用于转码和回放
const videoSourceUrl =
['主图视频', '安装视频', '种草视频'].includes(materialType) && zipFileName
? `/uploads/${zipFileName}`
: null;
// 最终保存前的previewUrl验证
if (materialType === '品牌VI灵感') {
console.log('[Upload] ========== 品牌VI灵感预览图最终检查 ==========');
console.log('[Upload] previewUrl:', previewUrl);
console.log('[Upload] previewUrl是否为空:', !previewUrl || previewUrl.trim() === '');
console.log('[Upload] previewUrl是否为PDF文件:', previewUrl && previewUrl.endsWith('.pdf'));
console.log('[Upload] previewUrl是否为图片文件:', previewUrl && (previewUrl.endsWith('.jpg') || previewUrl.endsWith('.jpeg') || previewUrl.endsWith('.png') || previewUrl.endsWith('.webp')));
console.log('[Upload] ============================================');
}
// 创建素材记录(不写入taskNo,上传完成后再写入)
const material = await prisma.material.create({
data: {
previewUrl,
productModel,
categoryName,
sceneName,
platformName,
productCategoryName,
materialType,
renderer,
authorName: user.username,
zipFileName,
zipFilePath,
zipFileSize,
displayImages,
videoSourceUrl, // 视频类型时保存视频URL
videoProcessingStatus: videoSourceUrl ? 'pending' : null, // 视频类型时初始化为pending状态
// taskNo: 不写入,上传完成后再写入
// taskStatus: 不写入,上传完成后再写入
userId: (session.user as any).id,
} as any,
include: {
user: {
select: { username: true },
},
},
});
console.log('[Upload] Database record created:', material.id);
materialId = material.id; // 保存materialId,用于失败时清理
// 为任务类型素材创建 material_assets 记录
const taskMaterialTypes = ['任务-3D原图', '任务-AI原图', '任务-实拍原图', '任务-视频截图'];
if (taskMaterialTypes.includes(materialType)) {
try {
console.log('[Upload] Creating material_assets records for task material...');
const assetRecords: Array<{
materialId: string;
assetType: string;
fileUrl: string;
fileName: string;
fileSize: bigint;
sortOrder: number;
}> = [];
// 添加预览图资产
if (previewUrl) {
assetRecords.push({
materialId: material.id,
assetType: 'preview',
fileUrl: previewUrl,
fileName: 'preview-0.webp',
fileSize: BigInt(0),
sortOrder: 0,
});
}
// 添加ZIP资产
if (zipFilePath) {
assetRecords.push({
materialId: material.id,
assetType: 'zip',
fileUrl: zipFilePath,
fileName: zipFileName,
fileSize: zipFileSize,
sortOrder: 0,
});
}
// 批量创建资产记录
if (assetRecords.length > 0) {
await prisma.materialAsset.createMany({
data: assetRecords,
});
console.log(`[Upload] Created ${assetRecords.length} material_assets records`);
}
} catch (assetError) {
console.error('[Upload] Failed to create material_assets records:', assetError);
// 资产记录创建失败不影响上传流程,只记录错误
}
}
// 如果提供了taskNo,上传完成后写入taskNo(所有素材类型统一处理)
if (taskNo && taskNo.trim()) {
try {
// 双重验证:再次检查taskNo唯一性(防止并发问题)
const { validateTaskNo, isTaskNoExists } = await import('@/lib/product-package');
if (validateTaskNo(taskNo)) {
const exists = await isTaskNoExists(taskNo);
if (!exists) {
// 写入taskNo和taskStatus
await prisma.material.update({
where: { id: material.id },
data: {
taskNo: taskNo,
taskStatus: 'completed',
} as any,
});
console.log('[Upload] TaskNo written to database:', taskNo);
} else {
console.warn('[Upload] TaskNo already exists, skipping write:', taskNo);
}
}
} catch (taskNoError) {
console.error('[Upload] Failed to write taskNo:', taskNoError);
// taskNo写入失败不影响上传流程,只记录错误
}
}
// 验证文件是否存在(特别是音乐和视频文件)
if (zipFilePath && (materialType === '音乐' || ['主图视频', '安装视频', '种草视频'].includes(materialType))) {
try {
const stats = await fs.stat(zipFilePath);
console.log('[Upload] File verified:', { path: zipFilePath, size: stats.size });
} catch (fileError: any) {
console.error('[Upload] ⚠️ File not found after upload:', {
path: zipFilePath,
error: fileError.message,
materialId: material.id,
});
// 文件不存在,但记录已创建,这会导致播放失败
// 这里只记录错误,不阻止流程,因为可能是其他问题
}
}
// 清理上传会话(如果使用了分片上传)
const uploadId = formData.get('uploadId') as string | null;
if (uploadId) {
try {
const { deleteUploadSession } = await import('@/lib/upload-sessions');
await deleteUploadSession(uploadId);
console.log('[Upload] 已清理上传会话:', uploadId);
} catch (cleanupError) {
console.warn('[Upload] 清理上传会话失败(忽略):', cleanupError);
}
}
// 如果是视频类型素材,上传完成后立即入队触发后台转码(异步,不阻塞返回)
if (videoSourceUrl) {
const needsCoverImage = !previewUrl || previewUrl.trim() === '';
// 不 await,让入队操作在后台异步执行,不阻塞上传接口返回 201
enqueueVideoTranscodeTask(material.id, videoSourceUrl, needsCoverImage)
.then(() => {
console.log('[Upload] ✅ Video transcode task enqueued successfully:', material.id);
})
.catch(async (e: any) => {
// 入队失败时,记录详细错误信息到数据库,并尝试清理可能无效的源文件,避免产生垃圾文件
const errorMsg = e?.message || String(e) || '未知错误';
const errorStack = e?.stack ? `\n堆栈: ${e.stack.substring(0, 1000)}` : '';
const fullErrorMsg = `入队失败: ${errorMsg}${errorStack}`;
console.error('[Upload] ❌ Failed to enqueue video transcode task:', {
materialId: material.id,
videoSourceUrl,
error: errorMsg,
stack: e?.stack,
});
try {
// 标记为失败并记录错误信息,方便后续诊断
await prisma.material.update({
where: { id: material.id },
data: {
videoProcessingStatus: 'failed',
transcodeLastError: fullErrorMsg.substring(0, 1000),
} as any,
});
console.log('[Upload] ⚠️ Updated material status with enqueue error (status: failed)');
} catch (dbError) {
console.error('[Upload] ❌ Failed to update material status:', dbError);
}
// 尝试清理源视频文件,避免长时间占用磁盘空间(垃圾文件)
if (zipFilePath) {
try {
const { deleteLocalFile } = await import('@/lib/file-cleanup');
const result = await deleteLocalFile(zipFilePath);
if (result.success) {
console.log('[Upload] 🧹 Removed source video file after enqueue failure:', zipFilePath);
} else {
console.warn('[Upload] ⚠️ Failed to remove source video file:', zipFilePath, result.error);
}
} catch (removeError) {
console.warn('[Upload] ⚠️ Failed to remove source video file after enqueue failure (ignored):', {
path: zipFilePath,
error: (removeError as any)?.message || String(removeError),
});
}
}
});
}
return NextResponse.json(
{
message: '上传成功',
material: {
...material,
zipFileSize: material.zipFileSize.toString(),
},
},
{ status: 201 }
);
} catch (error) {
// 输出详细的错误信息以便调试
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
console.error('[Upload] ❌ Upload error:', {
message: errorMessage,
stack: errorStack,
materialType: (error as any)?.materialType,
});
// 清理已上传的文件和已创建的素材记录(防止垃圾文件)
try {
const filesToDelete: string[] = [...uploadedFileUrls];
if (zipFilePath) {
filesToDelete.push(zipFilePath);
}
if (filesToDelete.length > 0) {
const { deleteFiles } = await import('@/lib/file-cleanup');
const cleanupResult = await deleteFiles(filesToDelete);
console.log(`[Upload] Cleaned up ${cleanupResult.deletedCount} files, ${cleanupResult.failedCount} failed`);
}
// 如果素材记录已创建但没有taskNo,删除记录(避免残留)
if (materialId) {
try {
const material = await prisma.material.findUnique({
where: { id: materialId },
select: { taskNo: true },
});
// 如果没有taskNo,说明上传未完成,删除记录
if (material && !material.taskNo) {
// 先删除关联记录
const materialType = (error as any)?.materialType || '未知';
await deleteMaterialRelatedRecords(materialId, materialType);
// 再删除素材记录
await prisma.material.delete({
where: { id: materialId },
});
console.log(`[Upload] Deleted incomplete material record: ${materialId}`);
}
} catch (deleteError) {
console.error('[Upload] Failed to delete material record:', deleteError);
// 删除失败不影响错误返回
}
}
} catch (cleanupError) {
console.error('[Upload] Cleanup failed:', cleanupError);
// 清理失败不影响错误返回
}
// 返回更详细的错误信息(开发环境)或通用错误(生产环境)
const isDevelopment = process.env.NODE_ENV === 'development';
return NextResponse.json(
{
error: isDevelopment
? `上传失败: ${errorMessage}`
: '上传失败,请稍后再试',
...(isDevelopment && errorStack ? { stack: errorStack.substring(0, 500) } : {})
},
{ status: 500 }
);
}
}