849 lines
35 KiB
TypeScript
849 lines
35 KiB
TypeScript
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 path from 'path'
|
||
import { randomBytes } from 'crypto'
|
||
|
||
const MATERIAL_ID_PREFIX = 'FLNXI'
|
||
type MaterialKind = 'IMAGE' | 'VIDEO' | 'OTHER'
|
||
|
||
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 = `<svg width="600" height="400" xmlns="http://www.w3.org/2000/svg"><rect width="100%" height="100%" fill="#0f172a"/><text x="50%" y="46%" font-size="42" fill="#ffffff" text-anchor="middle" font-family="PingFang SC, 'Microsoft YaHei', 'Segoe UI', sans-serif">等待转码</text><text x="50%" y="62%" 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')}`
|
||
})()
|
||
|
||
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', 'OTHER']
|
||
const normalizedKind = rawMaterialKind
|
||
? rawMaterialKind.toUpperCase()
|
||
: materialType === '视频'
|
||
? 'VIDEO'
|
||
: 'IMAGE'
|
||
const materialKind: MaterialKind = allowedMaterialKinds.includes(normalizedKind as MaterialKind)
|
||
? (normalizedKind as MaterialKind)
|
||
: materialType === '视频'
|
||
? 'VIDEO'
|
||
: 'IMAGE'
|
||
const isVideoMaterial = materialKind === 'VIDEO'
|
||
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'))
|
||
|
||
// 模板相关字段
|
||
const templateId = formData.get('templateId') as string | null
|
||
const templateFieldsStr = formData.get('templateFields') as string | null
|
||
let templateFields: Record<string, any> = {}
|
||
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 !== '材质' && imagesToProcess.length === 0) {
|
||
console.log('[Upload] Validation failed - non-PSD/Video/Material 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 = user.username
|
||
console.log('[Upload] User found:', 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)`)
|
||
} 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 {
|
||
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 }
|
||
)
|
||
}
|
||
|
||
// 如果提供了 templateId,验证它是否存在
|
||
if (templateId) {
|
||
try {
|
||
const templateExists = await prisma.uploadTemplate.findUnique({
|
||
where: { id: templateId },
|
||
select: { id: true },
|
||
})
|
||
if (!templateExists) {
|
||
console.error('[Upload] Template not found:', templateId)
|
||
return NextResponse.json(
|
||
{ error: `模板不存在:${templateId}` },
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
} catch (templateCheckError) {
|
||
console.error('[Upload] Error checking template:', templateCheckError)
|
||
// 如果检查模板时出错,继续执行(可能是数据库连接问题)
|
||
}
|
||
}
|
||
|
||
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,
|
||
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<void> => {
|
||
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 }
|
||
)
|
||
}
|
||
}
|
||
|