import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { createMaterialLog } from '@/lib/material-log' import { uploadToS3 } from '@/lib/s3' import { processImage, getProcessedFileName, sliceImage } from '@/lib/image-processor' import { parseAudioMetadata, isAudioFile } from '@/lib/audio-processor' import { validateTemplateId } from '@/lib/template-validator' import path from 'path' import { randomBytes } from 'crypto' const MATERIAL_ID_PREFIX = 'FLNXI' type MaterialKind = 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER' // 音频素材占位符预览图 const AUDIO_PLACEHOLDER = (() => { const svg = `音频素材` return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}` })() async function generateMaterialId() { const now = new Date() const dateStr = [ now.getFullYear(), String(now.getMonth() + 1).padStart(2, '0'), String(now.getDate()).padStart(2, '0'), ].join('') for (let attempt = 0; attempt < 5; attempt += 1) { const randomSuffix = Math.floor(Math.random() * 100000) .toString() .padStart(5, '0') const candidateId = `${MATERIAL_ID_PREFIX}${dateStr}${randomSuffix}` const exists = await prisma.material.findUnique({ where: { id: candidateId }, select: { id: true }, }) if (!exists) { return candidateId } } const fallbackSuffix = Date.now().toString().slice(-5) return `${MATERIAL_ID_PREFIX}${dateStr}${fallbackSuffix}` } // 生成唯一文件名(避免并发冲突) function generateUniqueFileName(prefix: string, ext: string): string { const timestamp = Date.now() const randomId = randomBytes(8).toString('hex') // 16个字符的随机字符串 return `${prefix}-${timestamp}-${randomId}${ext}` } // Configure route to handle large files export const runtime = 'nodejs' export const dynamic = 'force-dynamic' // Increase timeout for large file uploads export const maxDuration = 10800 // 3 hours timeout (10800 seconds) const VIDEO_PENDING_PLACEHOLDER = (() => { const svg = `等待转码预览生成中…` return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}` })() export async function POST(req: NextRequest) { try { console.log('[Upload] Starting upload process...') const session = await getServerSession(authOptions) console.log('[Upload] Session checked:', !!session) if (!session) { return NextResponse.json( { error: '请先登录' }, { status: 401 } ) } console.log('[Upload] Parsing form data...') const formData = await req.formData() const normalizeInput = (value: FormDataEntryValue | null) => { if (!value) return null const trimmed = value.toString().trim() return trimmed ? trimmed : null } const previewImage = formData.get('previewImage') as File // 向后兼容:第一张预览图 const previewImages = formData.getAll('previewImages') as File[] // 多预览图(最多5张) const displayImage = formData.get('displayImage') as File // PSD素材的展示图片 const zipFile = formData.get('zipFile') as File 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 materialType = (formData.get('materialType') as string) || '3DMAX' const rawMaterialKind = (formData.get('materialKind') as string) || '' const allowedMaterialKinds: MaterialKind[] = ['IMAGE', 'VIDEO', 'AUDIO', 'OTHER'] const normalizedKind = rawMaterialKind ? rawMaterialKind.toUpperCase() : materialType === '视频' ? 'VIDEO' : materialType === '音频' ? 'AUDIO' : 'IMAGE' const materialKind: MaterialKind = allowedMaterialKinds.includes(normalizedKind as MaterialKind) ? (normalizedKind as MaterialKind) : materialType === '视频' ? 'VIDEO' : materialType === '音频' ? 'AUDIO' : 'IMAGE' const isVideoMaterial = materialKind === 'VIDEO' const isAudioMaterial = materialKind === 'AUDIO' const renderer = (formData.get('renderer') as string) || 'CR' const brandName = normalizeInput(formData.get('brandName')) const seriesName = normalizeInput(formData.get('seriesName')) const skuCode = normalizeInput(formData.get('skuCode')) const materialTexture = normalizeInput(formData.get('materialTexture')) const renderTime = normalizeInput(formData.get('renderTime')) const imageResolution = normalizeInput(formData.get('imageResolution')) const colorMode = normalizeInput(formData.get('colorMode')) let audioDuration = normalizeInput(formData.get('audioDuration')) const audioCategory = normalizeInput(formData.get('audioCategory')) const audioScene = normalizeInput(formData.get('audioScene')) // 作者名称:优先使用表单提交的值(如音频元数据中的艺术家),否则使用上传者用户名 const submittedAuthorName = normalizeInput(formData.get('authorName')) let audioUrl: string | null = null // 音频文件URL,用于在线播放 // 模板相关字段 const templateId = formData.get('templateId') as string | null const templateFieldsStr = formData.get('templateFields') as string | null let templateFields: Record = {} if (templateFieldsStr) { try { templateFields = JSON.parse(templateFieldsStr) } catch (error) { console.error('[Upload] Failed to parse templateFields:', error) } } console.log('[Upload] Form data received:', { hasPreviewImage: !!previewImage, previewImageSize: previewImage?.size, hasDisplayImage: !!displayImage, displayImageSize: displayImage?.size, hasZipFile: !!zipFile, zipFileSize: zipFile?.size, productModel, categoryName, sceneName, materialType }) console.log('[Upload] Material type/kind:', materialType, materialKind) // Validation - 基础字段验证(材质类型不需要分类和场景) if (materialType !== '材质' && (!productModel || !categoryName || !sceneName)) { console.log('[Upload] Validation failed - missing required fields') return NextResponse.json( { error: '产品型号、分类和场景都是必填的' }, { status: 400 } ) } // 材质类型只需要产品型号 if (materialType === '材质' && !productModel) { console.log('[Upload] Validation failed - material type requires product model') return NextResponse.json( { error: '请填写材质名称' }, { status: 400 } ) } // 素材文件验证(模特类型和材质类型素材文件可选) if (materialType !== '模特' && materialType !== '材质' && !zipFile) { console.log('[Upload] Validation failed - missing zip file') return NextResponse.json( { error: '请上传素材文件' }, { status: 400 } ) } // 对于视频类型,必须有视频文件 if (isVideoMaterial && !zipFile) { console.log('[Upload] Validation failed - video requires video file') return NextResponse.json( { error: '请上传视频文件' }, { status: 400 } ) } // 对于PSD类型,必须有展示图片 if (materialType === 'PSD' && !displayImage) { console.log('[Upload] Validation failed - PSD requires display image') return NextResponse.json( { error: 'PSD素材必须上传展示图片' }, { status: 400 } ) } // 处理多预览图(优先使用previewImages,如果没有则使用previewImage向后兼容) const imagesToProcess = previewImages.length > 0 ? previewImages : (previewImage ? [previewImage] : []) // 对于视频类型,封面图是可选的(如果没有上传,转码任务会自动从视频中截取) // 对于音频类型,预览图是可选的(使用占位符) // 对于非PSD、非模特、非视频、非材质、非音频类型,必须有预览图(包括3DMAX等类型) if (materialType !== 'PSD' && materialType !== '视频' && materialType !== '材质' && materialType !== '音频' && imagesToProcess.length === 0) { console.log('[Upload] Validation failed - non-PSD/Video/Material/Audio types require preview image') return NextResponse.json( { error: '请上传预览图' }, { status: 400 } ) } // 获取用户信息作为作者花名 const userId = (session.user as any).id console.log('[Upload] Fetching user info... Session user ID:', userId, 'Type:', typeof userId) const user = await prisma.user.findUnique({ where: { id: userId }, select: { username: true }, }) if (!user) { console.log('[Upload] User not found for ID:', userId) return NextResponse.json( { error: '用户不存在' }, { status: 404 } ) } // 作者名称:优先使用表单提交的值(如音频元数据中的艺术家),否则使用上传者用户名 const authorName = submittedAuthorName || user.username console.log('[Upload] User found:', user.username, 'Author name:', authorName) let previewUrl: string = '' let previewUrls: string[] = [] // 多预览图URLs let hasUserUploadedCover = false // 标记用户是否实际上传了封面图(用于视频素材) // 处理多预览图(非PSD、非模特、非材质类型,以及视频类型需要封面) const shouldProcessPreviewImages = imagesToProcess.length > 0 && (isVideoMaterial || (materialType !== 'PSD' && materialType !== '材质')) if (shouldProcessPreviewImages) { const allowedImageTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'] const maxPreviewSize = 50 * 1024 * 1024 // 50MB // 验证所有预览图 for (const img of imagesToProcess) { if (img.size > maxPreviewSize) { console.log(`[Upload] Preview image too large: ${(img.size / 1024 / 1024).toFixed(2)}MB`) return NextResponse.json( { error: `预览图过大,请使用小于50MB的图片(当前:${(img.size / 1024 / 1024).toFixed(2)}MB)` }, { status: 400 } ) } if (!allowedImageTypes.includes(img.type)) { console.log(`[Upload] Invalid image type: ${img.type}`) return NextResponse.json( { error: `不支持的图片格式:${img.type},请使用 JPG、PNG、WebP 或 GIF 格式` }, { status: 400 } ) } } // 处理所有预览图 console.log(`[Upload] Processing ${imagesToProcess.length} preview images...`) try { for (let i = 0; i < imagesToProcess.length; i++) { const img = imagesToProcess[i] console.log(`[Upload] Processing preview image ${i + 1}/${imagesToProcess.length}... (${img.type}, ${(img.size / 1024).toFixed(2)}KB)`) try { const originalBuffer = Buffer.from(await img.arrayBuffer()) console.log(`[Upload] Preview image ${i + 1} buffer size: ${originalBuffer.length} bytes`) const processedBuffer = await processImage(originalBuffer, { maxSizeKB: 300, maxWidth: 600, maxHeight: 800, format: 'webp', }) console.log(`[Upload] Preview image ${i + 1} processed, size: ${processedBuffer.length} bytes`) const processedFileName = getProcessedFileName(img.name) const fileExt = path.extname(processedFileName) const previewFileName = generateUniqueFileName(`preview-${i}`, fileExt) console.log(`[Upload] Uploading preview image ${i + 1} to S3: ${previewFileName}`) const uploadedUrl = await uploadToS3(processedBuffer, previewFileName, 'image/webp') previewUrls.push(uploadedUrl) console.log(`[Upload] Preview image ${i + 1} uploaded to S3:`, uploadedUrl) } catch (singleImageError) { console.error(`[Upload] Error processing preview image ${i + 1}:`, singleImageError) if (singleImageError instanceof Error) { console.error(`[Upload] Error details: ${singleImageError.message}`) console.error(`[Upload] Error stack: ${singleImageError.stack}`) } throw new Error(`预览图 ${i + 1} 处理失败:${singleImageError instanceof Error ? singleImageError.message : '未知错误'}`) } } // 第一张作为主预览图(向后兼容) previewUrl = previewUrls[0] || '' hasUserUploadedCover = previewUrl.length > 0 // 用户实际上传了封面图 console.log('[Upload] All preview images processed successfully, previewUrl:', previewUrl ? `${previewUrl.substring(0, 50)}...` : 'EMPTY') } catch (imageError) { console.error('[Upload] Preview image processing error:', imageError) if (imageError instanceof Error) { console.error('[Upload] Preview image error name:', imageError.name) console.error('[Upload] Preview image error message:', imageError.message) console.error('[Upload] Preview image error stack:', imageError.stack) } return NextResponse.json( { error: `预览图处理失败:${imageError instanceof Error ? imageError.message : '未知错误'}`, details: imageError instanceof Error ? imageError.stack : undefined, errorType: imageError instanceof Error ? imageError.name : 'UnknownError' }, { status: 500 } ) } } // Handle PSD display images (切割和上传展示图片) let displayImages: string[] = [] if (materialType === 'PSD' && displayImage) { console.log('[Upload] Processing PSD display image...') try { // 读取展示图片 const displayImageBuffer = Buffer.from(await displayImage.arrayBuffer()) // 切割图片(按1500px高度) console.log('[Upload] Slicing display image (max height: 1500px)...') const slicedBuffers = await sliceImage(displayImageBuffer, 1500) console.log(`[Upload] Image sliced into ${slicedBuffers.length} parts`) // 上传每个切片到S3 for (let i = 0; i < slicedBuffers.length; i++) { const sliceFileName = generateUniqueFileName(`display-${i + 1}`, '.webp') const sliceUrl = await uploadToS3(slicedBuffers[i], sliceFileName, 'image/webp') displayImages.push(sliceUrl) console.log(`[Upload] Display image slice ${i + 1}/${slicedBuffers.length} uploaded to S3:`, sliceUrl) } // 如果没有预览图,使用第一张展示图片作为预览图 if (!previewImage && displayImages.length > 0) { previewUrl = displayImages[0] console.log('[Upload] Using first display image as preview:', previewUrl) } } catch (displayImageError) { console.error('[Upload] Display image processing error:', displayImageError) return NextResponse.json( { error: `PSD展示图片处理失败:${displayImageError instanceof Error ? displayImageError.message : '未知错误'}` }, { status: 500 } ) } } // Save zip/PSD/视频 file to S3 (if exists - 模特类型和材质类型可以没有素材文件) let zipFileName = '' let zipFilePath = '' let zipFileSize = BigInt(0) if (!zipFile) { console.log('[Upload] No file provided (Model/Material type with images only), skipping file save...') } else { console.log('[Upload] Uploading original file to S3...') try { const zipFileExt = path.extname(zipFile.name) zipFileName = generateUniqueFileName('material', zipFileExt) // 读取并上传素材文件(包括视频文件和音频文件) const zipBuffer = Buffer.from(await zipFile.arrayBuffer()) zipFileSize = BigInt(zipBuffer.length) // 记录文件大小(字节) const contentType = zipFile.type || 'application/octet-stream' zipFilePath = await uploadToS3(zipBuffer, zipFileName, contentType) console.log('[Upload] File uploaded to bucket:', zipFilePath, `(${(Number(zipFileSize) / 1024 / 1024).toFixed(2)} MB)`) // 如果是音频文件,自动解析音频时长并记录音频URL if (isAudioFile(contentType)) { console.log('[Upload] Audio file detected, parsing metadata...') // 记录音频文件URL用于在线播放 audioUrl = zipFilePath try { const audioMetadata = await parseAudioMetadata(zipBuffer, contentType) if (audioMetadata) { // 使用解析的时长覆盖表单提交的值 audioDuration = audioMetadata.durationFormatted console.log('[Upload] Audio metadata parsed:', { duration: audioMetadata.duration, durationFormatted: audioMetadata.durationFormatted, format: audioMetadata.format, bitrate: audioMetadata.bitrate, sampleRate: audioMetadata.sampleRate, }) } } catch (audioError) { console.error('[Upload] Failed to parse audio metadata:', audioError) // 音频解析失败不影响上传,继续处理 } } } catch (fileError) { console.error('[Upload] File save error:', fileError) if (fileError instanceof Error) { console.error('[Upload] File save error message:', fileError.message) console.error('[Upload] File save error stack:', fileError.stack) } return NextResponse.json( { error: `文件保存失败:${fileError instanceof Error ? fileError.message : '未知错误'}` }, { status: 500 } ) } } // Create material record console.log('[Upload] Creating database record...') console.log('[Upload] Preview URL status:', { hasPreviewUrl: !!previewUrl, previewUrlLength: previewUrl?.length || 0, previewUrlsCount: previewUrls.length, materialType, imagesToProcessCount: imagesToProcess.length, isVideoMaterial, }) // 验证必填字段 - previewUrl是数据库必填字段 // 对于视频素材,如果用户没有上传封面图,使用占位符(转码任务会自动生成封面图) // 对于音频素材,预览图可选,使用音频占位符 if (!previewUrl || previewUrl.trim() === '') { if (isVideoMaterial) { // 视频素材没有上传封面图时,使用占位符URL(转码任务会自动生成) previewUrl = VIDEO_PENDING_PLACEHOLDER console.log('[Upload] Video material without cover image, using placeholder') } else if (isAudioMaterial) { // 音频素材使用音频占位符 previewUrl = AUDIO_PLACEHOLDER console.log('[Upload] Audio material without cover image, using audio placeholder') } else { console.error('[Upload] Validation failed - previewUrl is required but empty', { materialType, hasPreviewImages: imagesToProcess.length > 0, previewUrlsCount: previewUrls.length, hasDisplayImages: displayImages.length > 0, }) return NextResponse.json( { error: '预览图处理失败,请重新上传预览图。如果已上传预览图,可能是图片处理出错,请检查图片格式是否正确' }, { status: 400 } ) } } if (!productModel || productModel.trim() === '') { console.error('[Upload] Validation failed - productModel is required') return NextResponse.json( { error: '产品型号不能为空' }, { status: 400 } ) } if (materialType !== '材质' && (!categoryName || categoryName.trim() === '')) { console.error('[Upload] Validation failed - categoryName is required for non-material type') return NextResponse.json( { error: '分类不能为空' }, { status: 400 } ) } if (materialType !== '材质' && (!sceneName || sceneName.trim() === '')) { console.error('[Upload] Validation failed - sceneName is required for non-material type') return NextResponse.json( { error: '场景不能为空' }, { status: 400 } ) } // 构建 metadata,包含模板字段值和多预览图URLs let metadata: string | null = null const metadataObj: any = {} if (templateId && Object.keys(templateFields).length > 0) { metadataObj.templateId = templateId metadataObj.templateFields = templateFields } if (previewUrls.length > 0) { metadataObj.previewUrls = previewUrls } if (Object.keys(metadataObj).length > 0) { metadata = JSON.stringify(metadataObj) } console.log('[Upload] Material data to create:', { previewUrl: previewUrl ? `${previewUrl.substring(0, 50)}...` : 'EMPTY', previewUrlLength: previewUrl?.length || 0, productModel, categoryName: materialType === '材质' ? '通用' : categoryName, sceneName: materialType === '材质' ? '通用' : sceneName, materialType, materialKind, renderer, authorName, hasMetadata: !!metadata, hasTemplateId: !!templateId, templateId, userId: (session.user as any).id, zipFileName, zipFileSize: zipFileSize.toString(), previewUrlsCount: previewUrls.length, }) // 验证并清理数据,确保符合数据库约束 const finalCategoryName = materialType === '材质' ? '通用' : (categoryName || '') const finalSceneName = materialType === '材质' ? '通用' : (sceneName || '') const finalPlatformName = platformName || '' const finalZipFileName = zipFileName || '' const finalZipFilePath = zipFilePath || '' // 验证字符串长度(PostgreSQL TEXT 类型理论上无限制,但为了安全起见检查) const maxStringLength = 10000 // 设置一个合理的最大长度 if (previewUrl.length > maxStringLength) { console.error('[Upload] previewUrl too long:', previewUrl.length) return NextResponse.json( { error: '预览图URL过长,请检查' }, { status: 400 } ) } if (productModel.length > maxStringLength) { console.error('[Upload] productModel too long:', productModel.length) return NextResponse.json( { error: '产品型号过长,请缩短后重试' }, { status: 400 } ) } // 验证 userId 是否存在(userId 已在前面定义) if (!userId || typeof userId !== 'string') { console.error('[Upload] Invalid userId:', userId) return NextResponse.json( { error: '用户ID无效,请重新登录' }, { status: 401 } ) } // 验证模板ID(如果提供) if (templateId) { console.log('[Upload] Validating template:', templateId) const validation = await validateTemplateId(templateId, { checkStatus: true, // 检查模板状态 logPerformance: true, // 记录性能日志 }) if (!validation.valid) { console.error('[Upload] Template validation failed:', { templateId, error: validation.error, errorCode: validation.errorCode, validationTime: validation.validationTime, }) return NextResponse.json( { error: validation.error || '模板验证失败', errorCode: validation.errorCode, }, { status: 400 } ) } console.log('[Upload] Template validation passed:', { templateId, isBuiltin: validation.isBuiltin, templateKey: validation.templateKey, templateName: validation.templateName, validationTime: validation.validationTime, }) } try { const materialId = await generateMaterialId() const material = await prisma.material.create({ data: { id: materialId, previewUrl: previewUrl.substring(0, maxStringLength), // 确保不超过最大长度 // previewUrls: previewUrls.length > 0 ? JSON.stringify(previewUrls) : null, // 暂时存储在metadata中,等数据库迁移后启用 productModel: productModel.substring(0, maxStringLength), brandName: brandName ? brandName.substring(0, maxStringLength) : null, seriesName: seriesName ? seriesName.substring(0, maxStringLength) : null, skuCode: skuCode ? skuCode.substring(0, maxStringLength) : null, materialTexture: materialTexture ? materialTexture.substring(0, maxStringLength) : null, categoryName: finalCategoryName.substring(0, maxStringLength), sceneName: finalSceneName.substring(0, maxStringLength), platformName: finalPlatformName.substring(0, maxStringLength), materialType, // @ts-ignore - materialKind 字段已在 schema 中定义,Prisma Client 类型可能需要 IDE 重启后更新 materialKind, renderer, renderTime: renderTime ? renderTime.substring(0, maxStringLength) : null, imageResolution: imageResolution ? imageResolution.substring(0, maxStringLength) : null, colorMode: colorMode ? colorMode.substring(0, maxStringLength) : null, // @ts-ignore - 音频字段已在数据库中添加,Prisma Client 类型可能需要重新生成 audioDuration: audioDuration ? audioDuration.substring(0, maxStringLength) : null, // @ts-ignore audioCategory: audioCategory ? audioCategory.substring(0, maxStringLength) : null, // @ts-ignore audioScene: audioScene ? audioScene.substring(0, maxStringLength) : null, // @ts-ignore audioUrl: audioUrl ? audioUrl.substring(0, maxStringLength) : null, authorName: authorName.substring(0, maxStringLength), // 素材归属:默认归属团队空间,createdByUserId 记录上传者 ownerType: 'TEAM', ownerId: 'default-team', createdByUserId: userId, zipFileName: finalZipFileName.substring(0, maxStringLength), zipFilePath: finalZipFilePath.substring(0, maxStringLength), zipFileSize, displayImages: JSON.stringify(displayImages).substring(0, maxStringLength * 10), // displayImages 可能较长 metadata: metadata ? metadata.substring(0, maxStringLength * 10) : null, // metadata 可能较长 templateId: templateId || null, videoSourceUrl: isVideoMaterial && finalZipFilePath ? finalZipFilePath.substring(0, maxStringLength) : null, videoPreview540Url: isVideoMaterial ? null : null, videoPreviewClipUrl: isVideoMaterial ? null : null, // 如果用户上传了封面图就使用,否则为null(转码任务会自动从视频中截取) // 注意:只有当用户实际上传了封面图时才设置,占位符不算 videoCoverUrl: isVideoMaterial && hasUserUploadedCover ? previewUrl.substring(0, maxStringLength) : null, videoProcessingStatus: isVideoMaterial ? 'pending' : null, videoDuration: isVideoMaterial ? null : null, userId, }, include: { user: { select: { username: true, }, }, }, }) console.log('[Upload] Database record created:', material.id) // 记录上传日志 await createMaterialLog({ materialId: material.id, userId, action: 'UPLOAD', }) // 如果是视频素材,异步触发转码任务 if (isVideoMaterial && finalZipFilePath) { console.log('[Upload] Video material detected, triggering transcode task...') // 直接调用转码队列,而不是通过HTTP API(避免网络连接问题) // 使用 setImmediate 确保在响应返回后再执行,避免阻塞 setImmediate(async () => { try { // 动态导入转码相关模块(避免循环依赖和初始化问题) const { transcodeQueue } = await import('@/lib/video-transcode-queue') // 定义转码任务函数(与 transcode/route.ts 中的逻辑一致) const processVideoTranscodeTask = async ( materialId: string, videoSourceUrl: string, needsCoverImage: boolean, retryCount: number = 0 ): Promise => { const { processVideoTranscode } = await import('@/lib/video-processor') const { tmpdir } = await import('os') const MAX_RETRIES = parseInt(process.env.VIDEO_TRANSCODE_MAX_RETRIES || '2', 10) try { console.log(`[Transcode] Starting transcode task for material ${materialId}${retryCount > 0 ? ` (retry ${retryCount}/${MAX_RETRIES})` : ''}...`) const outputDir = tmpdir() const result = await processVideoTranscode({ inputPath: videoSourceUrl, outputDir, materialId, needsCoverImage, }) // 更新数据库 const updateData: any = { videoProcessingStatus: 'done', videoPreview540Url: result.preview540Url, videoPreviewClipUrl: result.clipUrl, videoDuration: result.duration, } // 如果用户没有上传封面图,使用自动截取的封面图 if (needsCoverImage && result.coverUrl) { updateData.videoCoverUrl = result.coverUrl // 同时更新 previewUrl(如果当前是占位符) const material = await prisma.material.findUnique({ where: { id: materialId }, select: { previewUrl: true }, }) if (material?.previewUrl?.startsWith('data:image')) { updateData.previewUrl = result.coverUrl } } await prisma.material.update({ where: { id: materialId }, data: updateData, }) console.log(`[Transcode] Transcode task completed for material ${materialId}`) } catch (error) { console.error(`[Transcode] Transcode task failed for material ${materialId}:`, error) // 重试机制 if (retryCount < MAX_RETRIES) { const delay = Math.min(1000 * Math.pow(2, retryCount), 30000) console.log(`[Transcode] Retrying transcode task for material ${materialId} after ${delay}ms...`) await new Promise((resolve) => setTimeout(resolve, delay)) return processVideoTranscodeTask(materialId, videoSourceUrl, needsCoverImage, retryCount + 1) } else { console.error(`[Transcode] Max retries (${MAX_RETRIES}) reached for material ${materialId}, marking as failed`) throw error } } } // 检查是否需要生成封面图 const needsCoverImage = !material.videoCoverUrl // 添加到转码队列 transcodeQueue.add( material.id, material.videoSourceUrl!, needsCoverImage, processVideoTranscodeTask ).catch((error) => { console.error(`[Upload] Transcode task failed for material ${material.id}:`, error) // 更新状态为 failed prisma.material.update({ where: { id: material.id }, data: { videoProcessingStatus: 'failed' }, }).catch((dbError) => { console.error(`[Upload] Failed to update status to failed:`, dbError) }) }) console.log('[Upload] Transcode task added to queue for material:', material.id) } catch (error) { console.error('[Upload] Failed to trigger transcode task:', error) // 转码任务触发失败不影响上传成功,但更新状态为failed try { await prisma.material.update({ where: { id: material.id }, data: { videoProcessingStatus: 'failed' }, }) } catch (dbError) { console.error('[Upload] Failed to update material status to failed:', dbError) } } }) } // 转换BigInt为字符串以支持JSON序列化 const materialData = { ...material, zipFileSize: material.zipFileSize.toString() } console.log('[Upload] Upload completed successfully!') return NextResponse.json( { message: '上传成功', material: materialData }, { status: 201 } ) } catch (dbError) { console.error('[Upload] Database error:', dbError) if (dbError instanceof Error) { console.error('[Upload] Database error name:', dbError.name) console.error('[Upload] Database error message:', dbError.message) console.error('[Upload] Database error stack:', dbError.stack) // 记录创建数据时的所有字段值(用于调试) console.error('[Upload] Failed data:', { previewUrl: previewUrl ? `${previewUrl.substring(0, 50)}...` : 'EMPTY', previewUrlLength: previewUrl?.length || 0, productModel, categoryName: materialType === '材质' ? '通用' : categoryName, sceneName: materialType === '材质' ? '通用' : sceneName, materialType, renderer, authorName, userId: (session.user as any).id, templateId, zipFileName, zipFileSize: zipFileSize.toString(), hasMetadata: !!metadata, }) // 提供更具体的数据库错误信息 const errorMsg = dbError.message.toLowerCase() if (errorMsg.includes('unique constraint') || errorMsg.includes('duplicate')) { return NextResponse.json( { error: '数据已存在,请检查是否重复上传', details: dbError.message, errorType: dbError.name }, { status: 400 } ) } if (errorMsg.includes('foreign key constraint') || errorMsg.includes('foreign key')) { // 检查是哪个外键失败 let detailMsg = '关联数据不存在' if (errorMsg.includes('userid') || errorMsg.includes('user')) { detailMsg = `用户ID不存在:${(session.user as any).id}` } else if (errorMsg.includes('templateid') || errorMsg.includes('template')) { detailMsg = `模板ID不存在:${templateId}` } return NextResponse.json( { error: detailMsg, details: dbError.message, errorType: dbError.name }, { status: 400 } ) } if (errorMsg.includes('required') || errorMsg.includes('not null')) { return NextResponse.json( { error: `必填字段缺失:${dbError.message}`, details: dbError.message, errorType: dbError.name }, { status: 400 } ) } if (errorMsg.includes('value too long') || errorMsg.includes('string data right truncated')) { return NextResponse.json( { error: '字段值过长,请检查产品型号、分类、场景等字段长度', details: dbError.message, errorType: dbError.name }, { status: 400 } ) } if (errorMsg.includes('invalid input') || errorMsg.includes('invalid value')) { return NextResponse.json( { error: '数据格式不正确,请检查所有字段值', details: dbError.message, errorType: dbError.name }, { status: 400 } ) } // 对于其他数据库错误,返回详细信息 return NextResponse.json( { error: '数据库操作失败', details: dbError.message, errorType: dbError.name, errorStack: process.env.NODE_ENV === 'development' ? dbError.stack : undefined }, { status: 500 } ) } throw dbError // 重新抛出,让外层catch处理 } } catch (error) { console.error('[Upload] Upload error:', error) // Log detailed error information if (error instanceof Error) { console.error('[Upload] Error name:', error.name) console.error('[Upload] Error message:', error.message) console.error('[Upload] Error stack:', error.stack) // 根据错误类型提供更具体的错误信息 if (error.message.includes('S3') || error.message.includes('upload')) { return NextResponse.json( { error: '文件上传到存储失败,请稍后重试', details: process.env.NODE_ENV === 'development' ? error.message : undefined }, { status: 500 } ) } if (error.message.includes('processImage') || error.message.includes('image')) { return NextResponse.json( { error: '图片处理失败,请检查图片格式是否正确', details: process.env.NODE_ENV === 'development' ? error.message : undefined }, { status: 500 } ) } if (error.message.includes('database') || error.message.includes('prisma')) { return NextResponse.json( { error: '数据库操作失败,请稍后重试', details: process.env.NODE_ENV === 'development' ? error.message : undefined }, { status: 500 } ) } } // Return more specific error message for debugging const errorMessage = error instanceof Error ? error.message : '上传失败,请稍后再试' // 在生产环境也返回错误详情,帮助排查问题 return NextResponse.json( { error: '上传失败,请稍后再试', details: errorMessage, // 始终返回错误详情,帮助排查问题 errorType: error instanceof Error ? error.name : 'UnknownError' }, { status: 500 } ) } }