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 }
)
}
}