// 素材上传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'; import { createMaterialLog } from '@/lib/material-log'; import { buildMaterialPublicDisplayPath, uploadPublicDisplayToS3, } from '@/lib/public-display'; import { LEGACY_COMPATIBLE_VIDEO_MATERIAL_TYPES, normalizeVideoMaterialType, } from '@/lib/video-material-types'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; export const maxDuration = 10800; // 3小时 // 音频素材占位符预览图(SVG格式) const AUDIO_PLACEHOLDER = (() => { const svg = `音频素材` return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}` })() // PDF素材占位符预览图(SVG格式) const PDF_PLACEHOLDER = (() => { const svg = `PDF品牌VI灵感` return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}` })() // SKU模板占位符预览图(SVG格式) const SKU_TEMPLATE_PLACEHOLDER = (() => { const svg = `SKU模版` return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}` })() const VIDEO_TYPES = new Set(LEGACY_COMPATIBLE_VIDEO_MATERIAL_TYPES) const AUDIO_TYPES = new Set(['音乐']) const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.m4v', '.mkv', '.avi', '.3gp']) const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.flac', '.m4a', '.aac']) function normalizeExtension(ext: string): string { if (!ext) return '' return ext.startsWith('.') ? ext.toLowerCase() : `.${ext.toLowerCase()}` } function detectExtFromMime(mime?: string | null): string | null { if (!mime) return null const map: Record = { 'video/mp4': '.mp4', 'video/quicktime': '.mov', 'video/x-matroska': '.mkv', 'video/x-msvideo': '.avi', 'video/3gpp': '.3gp', 'audio/mpeg': '.mp3', 'audio/wav': '.wav', 'audio/flac': '.flac', 'audio/mp4': '.m4a', 'audio/aac': '.aac', } return map[mime] || null } async function detectExtFromFile(filePath?: string): Promise { if (!filePath) return null try { const absolute = path.isAbsolute(filePath) ? filePath : path.join(process.cwd(), filePath) const handle = await fs.open(absolute, 'r') try { const buffer = Buffer.alloc(16) await handle.read(buffer, 0, 16, 0) const signature = buffer.toString('hex') if (buffer.slice(4, 8).toString('ascii').startsWith('ftyp')) { return '.mp4' } if (buffer.slice(0, 4).toString('ascii') === 'RIFF') { return '.avi' } if (signature.startsWith('1a45dfa3')) { return '.mkv' } if (buffer.slice(0, 3).toString('ascii') === 'ID3') { return '.mp3' } if (buffer.slice(0, 4).toString('ascii') === 'fLaC') { return '.flac' } return null } finally { await handle.close() } } catch (error) { console.warn('[Upload] Failed to detect extension from file:', filePath, error) return null } } async function ensureMediaExtension(params: { extCandidate: string mimeType?: string | null filePath?: string fileBuffer?: Buffer | null materialType: string }): Promise { const { extCandidate, mimeType, filePath, fileBuffer, materialType } = params const targetSet = VIDEO_TYPES.has(materialType) ? VIDEO_EXTENSIONS : AUDIO_TYPES.has(materialType) ? AUDIO_EXTENSIONS : null if (!targetSet) { return extCandidate || '.zip' } if (fileBuffer && fileBuffer.length >= 16) { const signature = fileBuffer.toString('hex', 0, 16) const ftyp = fileBuffer.slice(4, 8).toString('ascii') const riff = fileBuffer.slice(0, 4).toString('ascii') const id3 = fileBuffer.slice(0, 3).toString('ascii') const flac = fileBuffer.slice(0, 4).toString('ascii') if (ftyp.startsWith('ftyp') && targetSet.has('.mp4')) return '.mp4' if (riff === 'RIFF' && targetSet.has('.avi')) return '.avi' if (signature.startsWith('1a45dfa3') && targetSet.has('.mkv')) return '.mkv' if (id3 === 'ID3' && targetSet.has('.mp3')) return '.mp3' if (flac === 'fLaC' && targetSet.has('.flac')) return '.flac' } const normalized = normalizeExtension(extCandidate) if (normalized && targetSet.has(normalized)) { return normalized } const mimeExt = detectExtFromMime(mimeType) if (mimeExt && targetSet.has(mimeExt)) { return mimeExt } const detected = await detectExtFromFile(filePath) if (detected && targetSet.has(detected)) { return detected } return VIDEO_TYPES.has(materialType) ? '.mp4' : '.mp3' } function isVideoMaterial(materialType: string) { return VIDEO_TYPES.has(materialType) } function isAudioMaterial(materialType: string) { return AUDIO_TYPES.has(materialType) } function normalizeMaterialType(materialTypeRaw?: string | null): string { const trimmed = (materialTypeRaw || '').trim() if (!trimmed) { return '3D场景' } if (trimmed.toLowerCase() === 'video') { return 'VIDEO' } return normalizeVideoMaterialType(trimmed) } // 生成唯一文件名 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 productModelInput = (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 materialTypeRaw = formData.get('materialType') as string | null; const materialType = normalizeMaterialType(materialTypeRaw); const isTaskOtherMaterial = materialType === '任务-其他'; const rendererParam = formData.get('renderer') as string | null; // 统一处理renderer:3D场景必须有renderer,其他类型可选 const renderer = requiresRenderer(materialType) ? (rendererParam || getDefaultRenderer(materialType)) : (rendererParam || ''); const rawTaskNo = (formData.get('taskNo') as string) || ''; const taskNo = isTaskOtherMaterial ? rawTaskNo.trim().toUpperCase() : rawTaskNo; const productModel = productModelInput || (isTaskOtherMaterial ? taskNo : ''); const shouldAutoFillGeneralCategoryScene = AUTO_GENERAL_TYPES.includes(materialType) || materialType === '标准模型'; const resolvedCategoryName = shouldAutoFillGeneralCategoryScene ? '通用' : categoryName; const resolvedSceneName = shouldAutoFillGeneralCategoryScene ? '通用' : sceneName; console.log('[Upload] Form data received:', { hasPreviewImage: !!previewImage, previewImagesCount: previewImages.length, hasDisplayImage: !!displayImage, modelImagesCount: modelImages.length, hasZipFile: !!zipFile, hasExistingFilePath: !!existingFilePath, productModel, categoryName: resolvedCategoryName, sceneName: resolvedSceneName, materialType, materialTypeRaw, }); // 使用统一的验证逻辑验证必填字段 if (!productModel) { return NextResponse.json( { error: isTaskOtherMaterial ? '任务-其他必须填写任务号' : '产品型号是必填的' }, { status: 400 } ); } if (isTaskOtherMaterial && !taskNo) { return NextResponse.json( { error: '任务-其他必须填写任务号' }, { status: 400 } ); } // 任务号验证(可选,如果提供则验证格式和唯一性) if (taskNo && taskNo.trim()) { const { validateTaskNo, isTaskNoExists, purgeDeletedMaterialsByTaskNo } = 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 } ); } try { const cleanupResult = await purgeDeletedMaterialsByTaskNo(taskNo); if (cleanupResult.deletedMaterials > 0) { console.log( `[Upload] 已清理任务号 ${taskNo} 的旧软删素材 ${cleanupResult.deletedMaterials} 条,删除文件 ${cleanupResult.deletedFiles} 个,失败 ${cleanupResult.failedFiles} 个`, ); } } catch (cleanupError) { console.error('[Upload] 清理旧软删素材失败:', cleanupError); } } if (requiresCategoryAndScene(materialType) && (!categoryName || !sceneName)) { return NextResponse.json( { error: '请填写所有必填字段(店铺、场景)' }, { status: 400 } ); } if (isTaskOtherMaterial && !previewImage && previewImages.length === 0) { 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 uploadPublicDisplayToS3( compressedBuffer, buildMaterialPublicDisplayPath(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 uploadPublicDisplayToS3( compressedBuffer, buildMaterialPublicDisplayPath(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 uploadPublicDisplayToS3( sliceBuffer, buildMaterialPublicDisplayPath(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 uploadPublicDisplayToS3( buffer, buildMaterialPublicDisplayPath(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 uploadPublicDisplayToS3( buffer, buildMaterialPublicDisplayPath(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); console.log('[Upload] Using existing merged file:', sourcePath, 'size:', stat.size); } const isVideoOrAudio = isVideoMaterial(materialType) || isAudioMaterial(materialType); if (isVideoMaterial(materialType)) { ext = await ensureMediaExtension({ extCandidate: ext, mimeType: zipFile?.type, filePath: sourcePath || existingFilePath, fileBuffer: buffer, materialType, }); } else if (isAudioMaterial(materialType)) { ext = await ensureMediaExtension({ extCandidate: ext, mimeType: zipFile?.type, filePath: sourcePath || existingFilePath, fileBuffer: buffer, materialType, }); } else { ext = normalizeExtension(ext) || '.zip'; } // 主文件一律本地化存储到 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('未找到可写入的素材文件数据'); } if (isTaskOtherMaterial) { const sourceName = sanitizeFileName(originalFileName || zipFile?.name || zipFileName); if (sourceName) { zipFileName = sourceName; } } // 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] 使用占位符作为降级方案'); // 降级方案:使用PDF占位符,确保首页卡片有内容显示 previewUrl = PDF_PLACEHOLDER; console.warn('[Upload] ⚠️ 已使用占位符,建议后续手动上传预览图或重新上传PDF'); } } // 音频:尝试从本地文件/缓冲中解析封面信息,仅用于生成预览图,源文件仍本地化 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 tempDir = path.dirname(sourcePath); // 直接删除整个临时目录(包括合并文件、meta.json等所有内容) const { cleanupUploadSession } = await import('@/lib/temp-cleanup'); const uploadId = path.basename(tempDir); const success = await cleanupUploadSession(uploadId); if (success) { console.log('[Upload] 已清理临时目录:', tempDir); } else { console.warn('[Upload] 清理临时目录失败:', tempDir); } } 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 = isVideoMaterial(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] ============================================'); } // SKU模板:如果没有预览图,使用占位符 if (materialType === 'SKU模版' && (!previewUrl || previewUrl.trim() === '')) { console.warn('[Upload] ⚠️ SKU模板没有预览图,使用占位符'); previewUrl = SKU_TEMPLATE_PLACEHOLDER; } // 创建素材记录(不写入taskNo,上传完成后再写入) const material = await prisma.material.create({ data: { previewUrl, productModel, categoryName: resolvedCategoryName, sceneName: resolvedSceneName, 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写入失败不影响上传流程,只记录错误 } } // 记录上传日志 await createMaterialLog({ materialId: material.id, userId: (session.user as any).id, action: 'UPLOAD', changes: { productModel: { oldValue: null, newValue: productModel, }, materialType: { oldValue: null, newValue: materialType, }, categoryName: { oldValue: null, newValue: resolvedCategoryName, }, sceneName: { oldValue: null, newValue: resolvedSceneName, }, taskNo: { oldValue: null, newValue: taskNo || null, }, }, }); // 验证文件是否存在(特别是音乐和视频文件) if (zipFilePath && (materialType === '音乐' || isVideoMaterial(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 } ); } }