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, uploadStreamToS3, deleteFromS3, isS3Url } from '@/lib/s3'
import { processImage, getProcessedFileName, sliceImage } from '@/lib/image-processor'
import { parseAudioMetadata, parseAudioMetadataFromFile, isAudioFile } from '@/lib/audio-processor'
import { isBuiltinTemplate, validateTemplateId } from '@/lib/template-validator'
import { validateSubmittedTemplateFields } from '@/lib/template-field-validation'
import { prepareMaterialData, compressMetadata, checkMetadataSize } from '@/lib/field-data-storage'
import { canUploadMaterial, prepareMaterialMetadataWithPaidCore } from '@/lib/license'
import { enqueueVideoTranscodeJob } from '@/lib/video-transcode-service'
import formidable from 'formidable'
import { Readable } from 'stream'
import { createReadStream } from 'fs'
import { mkdtemp, readFile, rm, unlink } from 'fs/promises'
import os from 'os'
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')}`
})()
const MATERIAL_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')}`
})()
type UploadFileHandle = {
name: string
type: string
size: number
filepath?: string
createReadStream: () => Readable
readBuffer: () => Promise
}
type ParsedUploadForm = {
getText: (key: string) => string | null
getFile: (key: string) => UploadFileHandle | null
getFiles: (key: string) => UploadFileHandle[]
cleanup: () => Promise
}
const DEFAULT_MAX_UPLOAD_FILE_SIZE = 10 * 1024 * 1024 * 1024 // 10GB
const DEFAULT_MAX_UPLOAD_FIELD_SIZE = 2 * 1024 * 1024 // 2MB
function getNumberEnv(name: string, fallback: number) {
const value = Number(process.env[name])
return Number.isFinite(value) && value > 0 ? value : fallback
}
function firstTextValue(value: unknown): string | null {
if (Array.isArray(value)) {
return firstTextValue(value[0])
}
if (typeof value !== 'string') {
return null
}
return value
}
function createHandleFromWebFile(file: File): UploadFileHandle {
return {
name: file.name,
type: file.type || 'application/octet-stream',
size: file.size || 0,
createReadStream: () => Readable.fromWeb(file.stream() as any),
readBuffer: async () => Buffer.from(await file.arrayBuffer()),
}
}
function createHandleFromFormidableFile(file: formidable.File): UploadFileHandle {
return {
name: file.originalFilename || file.newFilename || 'upload.bin',
type: file.mimetype || 'application/octet-stream',
size: file.size || 0,
filepath: file.filepath,
createReadStream: () => createReadStream(file.filepath),
readBuffer: () => readFile(file.filepath),
}
}
async function parseLegacyFormData(req: NextRequest): Promise {
const formData = await req.formData() as FormData & {
get?: (key: string) => FormDataEntryValue | null
getAll?: (key: string) => FormDataEntryValue[]
}
if (typeof formData.forEach !== 'function') {
return {
getText: (key) => {
const value = formData.get?.(key)
return typeof value === 'string' ? value : null
},
getFile: (key) => {
const value = formData.get?.(key)
return value && typeof value !== 'string' ? createHandleFromWebFile(value) : null
},
getFiles: (key) => {
const values = formData.getAll?.(key) || []
return values
.filter((value): value is File => value instanceof File)
.map(createHandleFromWebFile)
},
cleanup: async () => {},
}
}
const textFields = new Map()
const fileFields = new Map()
formData.forEach((value, key) => {
if (typeof value === 'string') {
textFields.set(key, [...(textFields.get(key) || []), value])
return
}
fileFields.set(key, [...(fileFields.get(key) || []), createHandleFromWebFile(value)])
})
return {
getText: (key) => textFields.get(key)?.[0] ?? null,
getFile: (key) => fileFields.get(key)?.[0] ?? null,
getFiles: (key) => fileFields.get(key) || [],
cleanup: async () => {},
}
}
async function parseStreamingMultipartForm(req: NextRequest): Promise {
if (!req.body) {
return parseLegacyFormData(req)
}
const uploadDir = await mkdtemp(path.join(os.tmpdir(), 'flnxi-upload-'))
const form = formidable({
uploadDir,
keepExtensions: true,
multiples: true,
maxFileSize: getNumberEnv('MAX_UPLOAD_FILE_SIZE_BYTES', DEFAULT_MAX_UPLOAD_FILE_SIZE),
maxTotalFileSize: getNumberEnv('MAX_UPLOAD_TOTAL_FILE_SIZE_BYTES', DEFAULT_MAX_UPLOAD_FILE_SIZE),
maxFieldsSize: getNumberEnv('MAX_UPLOAD_FIELD_SIZE_BYTES', DEFAULT_MAX_UPLOAD_FIELD_SIZE),
allowEmptyFiles: true,
minFileSize: 0,
})
const nodeStream = Readable.fromWeb(req.body as any) as Readable & {
headers?: Record
method?: string
url?: string
}
nodeStream.headers = Object.fromEntries(req.headers.entries())
nodeStream.method = req.method
nodeStream.url = req.nextUrl?.pathname || '/api/materials/upload'
try {
const [fields, files] = await form.parse(nodeStream as any)
const textFields = new Map()
const fileFields = new Map()
Object.entries(fields).forEach(([key, value]) => {
const text = firstTextValue(value)
if (text !== null) {
textFields.set(key, text)
}
})
Object.entries(files).forEach(([key, value]) => {
const handles = (Array.isArray(value) ? value : value ? [value] : [])
.filter((file): file is formidable.File => Boolean(file))
.map(createHandleFromFormidableFile)
if (handles.length) {
fileFields.set(key, handles)
}
})
return {
getText: (key) => textFields.get(key) ?? null,
getFile: (key) => fileFields.get(key)?.[0] ?? null,
getFiles: (key) => fileFields.get(key) || [],
cleanup: async () => {
await rm(uploadDir, { recursive: true, force: true })
},
}
} catch (error) {
await rm(uploadDir, { recursive: true, force: true })
throw error
}
}
async function parseUploadForm(req: NextRequest): Promise {
const contentType = req.headers?.get?.('content-type') || ''
if (contentType.includes('multipart/form-data')) {
return parseStreamingMultipartForm(req)
}
return parseLegacyFormData(req)
}
export async function POST(req: NextRequest) {
let parsedUploadForm: ParsedUploadForm | null = null
const uploadedArtifactUrls: string[] = []
const isDevelopment = process.env.NODE_ENV === 'development'
const trackUploadedArtifact = (url: string) => {
if (url) {
uploadedArtifactUrls.push(url)
}
}
const cleanupUploadedArtifacts = async () => {
const uniqueUrls = Array.from(new Set(uploadedArtifactUrls.filter((url) => url && url.trim())))
if (!uniqueUrls.length) return
console.warn('[Upload] Cleaning up uploaded artifacts after failure:', uniqueUrls.length)
const results = await Promise.allSettled(
uniqueUrls.map(async (fileUrl) => {
if (isS3Url(fileUrl)) {
await deleteFromS3(fileUrl)
return
}
await unlink(fileUrl)
})
)
results.forEach((result, index) => {
if (result.status === 'rejected') {
console.error('[Upload] Failed to cleanup uploaded artifact:', uniqueUrls[index], result.reason)
}
})
}
const failWithCleanup = async (response: NextResponse) => {
await cleanupUploadedArtifacts()
return response
}
const createSafeErrorPayload = (message: string, error: unknown, includeStack = false) => {
const payload: Record = { error: message }
if (isDevelopment && error instanceof Error) {
payload.details = error.message
payload.errorType = error.name
if (includeStack) {
payload.errorStack = error.stack
}
}
return payload
}
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 }
)
}
// 检查许可证素材数量限制
const materialLimit = await canUploadMaterial();
if (!materialLimit.allowed) {
console.log('[Upload] Material limit reached:', materialLimit)
return NextResponse.json(
{ error: materialLimit.message || '已达到素材数量上限' },
{ status: 403 }
)
}
console.log('[Upload] Parsing multipart form...')
parsedUploadForm = await parseUploadForm(req)
const normalizeInput = (value: string | null) => {
if (!value) return null
const trimmed = value.toString().trim()
return trimmed ? trimmed : null
}
const previewImage = parsedUploadForm.getFile('previewImage') // 向后兼容:第一张预览图
const previewImages = parsedUploadForm.getFiles('previewImages') // 多预览图(最多5张)
const displayImage = parsedUploadForm.getFile('displayImage') // PSD素材的展示图片
const zipFile = parsedUploadForm.getFile('zipFile')
const productModel = parsedUploadForm.getText('productModel') || ''
const categoryName = parsedUploadForm.getText('categoryName') || ''
const sceneName = parsedUploadForm.getText('sceneName') || ''
const platformName = parsedUploadForm.getText('platformName') || '' // 平台分类(可选)
const materialType = parsedUploadForm.getText('materialType') || '3DMAX'
const rawMaterialKind = parsedUploadForm.getText('materialKind') || ''
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 = parsedUploadForm.getText('renderer') || 'CR'
const brandName = normalizeInput(parsedUploadForm.getText('brandName'))
const seriesName = normalizeInput(parsedUploadForm.getText('seriesName'))
const skuCode = normalizeInput(parsedUploadForm.getText('skuCode'))
const materialTexture = normalizeInput(parsedUploadForm.getText('materialTexture'))
const renderTime = normalizeInput(parsedUploadForm.getText('renderTime'))
const imageResolution = normalizeInput(parsedUploadForm.getText('imageResolution'))
const colorMode = normalizeInput(parsedUploadForm.getText('colorMode'))
let audioDuration = normalizeInput(parsedUploadForm.getText('audioDuration'))
const audioCategory = normalizeInput(parsedUploadForm.getText('audioCategory'))
const audioScene = normalizeInput(parsedUploadForm.getText('audioScene'))
// 作者名称:优先使用表单提交的值(如音频元数据中的艺术家),否则使用上传者用户名
const submittedAuthorName = normalizeInput(parsedUploadForm.getText('authorName'))
let audioUrl: string | null = null // 音频文件URL,用于在线播放
// 模板相关字段
const templateId = parsedUploadForm.getText('templateId')
const templateFieldsStr = parsedUploadForm.getText('templateFields')
let templateFields: Record = {}
if (templateFieldsStr) {
try {
templateFields = JSON.parse(templateFieldsStr)
} catch (error) {
console.error('[Upload] Failed to parse templateFields:', error)
return NextResponse.json(
{ error: '模板字段格式无效', errorCode: 'INVALID_TEMPLATE_FIELDS' },
{ status: 400 }
)
}
}
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 await failWithCleanup(NextResponse.json(
{ error: '请上传视频文件' },
{ status: 400 }
))
}
// 对于PSD类型,必须有展示图片
if (materialType === 'PSD' && !displayImage) {
console.log('[Upload] Validation failed - PSD requires display image')
return await failWithCleanup(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 await failWithCleanup(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)
if (templateId) {
console.log('[Upload] Validating template before file upload:', templateId)
const validation = await validateTemplateId(templateId, {
checkStatus: true,
logPerformance: true,
})
if (!validation.valid) {
console.error('[Upload] Template validation failed before file upload:', {
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 before file upload:', {
templateId,
isBuiltin: validation.isBuiltin,
templateKey: validation.templateKey,
templateName: validation.templateName,
validationTime: validation.validationTime,
})
const templateFieldsValidation = await validateSubmittedTemplateFields({
templateId,
templateFields,
allowUnknownKeys: false,
})
if (!templateFieldsValidation.valid) {
return NextResponse.json(
{
error: templateFieldsValidation.error,
errorCode: templateFieldsValidation.errorCode,
details: templateFieldsValidation.details,
},
{ status: 400 }
)
}
templateFields = templateFieldsValidation.templateFields
}
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 = await img.readBuffer()
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)
trackUploadedArtifact(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 await failWithCleanup(NextResponse.json(
createSafeErrorPayload('预览图处理失败,请检查图片格式后重试', imageError, true),
{ status: 500 }
))
}
}
// Handle PSD display images (切割和上传展示图片)
let displayImages: string[] = []
if (materialType === 'PSD' && displayImage) {
console.log('[Upload] Processing PSD display image...')
try {
// 读取展示图片
const displayImageBuffer = await displayImage.readBuffer()
// 切割图片(按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)
trackUploadedArtifact(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 await failWithCleanup(NextResponse.json(
createSafeErrorPayload('PSD展示图片处理失败,请检查图片格式后重试', displayImageError),
{ 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 contentType = zipFile.type || 'application/octet-stream'
zipFileSize = BigInt(zipFile.size || 0) // 记录文件大小(字节)
// 如果是音频文件,自动解析音频时长并记录音频URL
if (isAudioFile(contentType)) {
zipFilePath = await uploadStreamToS3(
zipFile.createReadStream(),
zipFileName,
contentType,
zipFile.size
)
trackUploadedArtifact(zipFilePath)
console.log('[Upload] Audio file uploaded to bucket:', zipFilePath, `(${(Number(zipFileSize) / 1024 / 1024).toFixed(2)} MB)`)
console.log('[Upload] Audio file detected, parsing metadata...')
// 记录音频文件URL用于在线播放
audioUrl = zipFilePath
try {
const audioMetadata = zipFile.filepath
? await parseAudioMetadataFromFile(zipFile.filepath)
: await parseAudioMetadata(await zipFile.readBuffer(), 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)
// 音频解析失败不影响上传,继续处理
}
} else {
zipFilePath = await uploadStreamToS3(zipFile.createReadStream(), zipFileName, contentType, zipFile.size)
trackUploadedArtifact(zipFilePath)
console.log('[Upload] File uploaded to bucket via stream:', 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 await failWithCleanup(NextResponse.json(
createSafeErrorPayload('文件保存失败,请检查存储配置后重试', fileError),
{ 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 if (materialType === '材质') {
// 材质素材允许无预览图,使用中性占位图满足展示和数据库约束
previewUrl = MATERIAL_PLACEHOLDER
console.log('[Upload] Material without preview image, using material 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 await failWithCleanup(NextResponse.json(
{ error: '预览图处理失败,请重新上传预览图。如果已上传预览图,可能是图片处理出错,请检查图片格式是否正确' },
{ status: 400 }
))
}
}
if (!productModel || productModel.trim() === '') {
console.error('[Upload] Validation failed - productModel is required')
return await failWithCleanup(NextResponse.json(
{ error: '产品型号不能为空' },
{ status: 400 }
))
}
if (materialType !== '材质' && (!categoryName || categoryName.trim() === '')) {
console.error('[Upload] Validation failed - categoryName is required for non-material type')
return await failWithCleanup(NextResponse.json(
{ error: '分类不能为空' },
{ status: 400 }
))
}
if (materialType !== '材质' && (!sceneName || sceneName.trim() === '')) {
console.error('[Upload] Validation failed - sceneName is required for non-material type')
return await failWithCleanup(NextResponse.json(
{ error: '场景不能为空' },
{ status: 400 }
))
}
// 标准字段直接落列,自定义字段与预览图数组进入 metadata
let metadata: string | null = null
if (templateId && !isBuiltinTemplate(templateId)) {
const metadataResult = await prepareMaterialMetadataWithPaidCore({
templateId,
templateFields,
previewUrls,
})
if (!metadataResult.valid) {
return await failWithCleanup(NextResponse.json(
{
error: metadataResult.error,
errorCode: metadataResult.errorCode,
details: metadataResult.details,
},
{ status: 400 }
))
}
metadata = metadataResult.metadata
} else {
const { metadata: rawMetadata } = prepareMaterialData(templateFields, templateId, previewUrls)
metadata = compressMetadata(rawMetadata)
const metadataSize = checkMetadataSize(metadata)
if (!metadataSize.ok) {
return await failWithCleanup(NextResponse.json(
{
error: `自定义字段数据过大,当前约 ${metadataSize.size} 字节,最大允许 ${metadataSize.maxSize} 字节。请减少字段内容后重试。`,
errorCode: 'METADATA_TOO_LARGE',
},
{ status: 400 }
))
}
}
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 await failWithCleanup(NextResponse.json(
{ error: '预览图URL过长,请检查' },
{ status: 400 }
))
}
if (productModel.length > maxStringLength) {
console.error('[Upload] productModel too long:', productModel.length)
return await failWithCleanup(NextResponse.json(
{ error: '产品型号过长,请缩短后重试' },
{ status: 400 }
))
}
// 验证 userId 是否存在(userId 已在前面定义)
if (!userId || typeof userId !== 'string') {
console.error('[Upload] Invalid userId:', userId)
return await failWithCleanup(NextResponse.json(
{ error: '用户ID无效,请重新登录' },
{ status: 401 }
))
}
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,
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...')
// 使用 setImmediate 确保在响应返回后再执行,避免阻塞
setImmediate(async () => {
try {
await enqueueVideoTranscodeJob({
materialId: material.id,
videoSourceUrl: material.videoSourceUrl!,
needsCoverImage: !material.videoCoverUrl,
})
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()
}
uploadedArtifactUrls.length = 0
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()
const debugDetails = isDevelopment ? dbError.message : undefined
const debugErrorType = isDevelopment ? dbError.name : undefined
if (errorMsg.includes('unique constraint') || errorMsg.includes('duplicate')) {
return await failWithCleanup(NextResponse.json(
{
error: '数据已存在,请检查是否重复上传',
details: debugDetails,
errorType: debugErrorType
},
{ 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 await failWithCleanup(NextResponse.json(
{
error: detailMsg,
details: debugDetails,
errorType: debugErrorType
},
{ status: 400 }
))
}
if (errorMsg.includes('required') || errorMsg.includes('not null')) {
return await failWithCleanup(NextResponse.json(
{
error: '必填字段缺失,请检查上传表单',
details: debugDetails,
errorType: debugErrorType
},
{ status: 400 }
))
}
if (errorMsg.includes('value too long') || errorMsg.includes('string data right truncated')) {
return await failWithCleanup(NextResponse.json(
{
error: '字段值过长,请检查产品型号、分类、场景等字段长度',
details: debugDetails,
errorType: debugErrorType
},
{ status: 400 }
))
}
if (errorMsg.includes('invalid input') || errorMsg.includes('invalid value')) {
return await failWithCleanup(NextResponse.json(
{
error: '数据格式不正确,请检查所有字段值',
details: debugDetails,
errorType: debugErrorType
},
{ status: 400 }
))
}
// 对于其他数据库错误,返回详细信息
return await failWithCleanup(NextResponse.json(
{
error: '数据库操作失败',
details: debugDetails,
errorType: debugErrorType,
errorStack: isDevelopment ? 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 await failWithCleanup(NextResponse.json(
{
error: '文件上传到存储失败,请稍后重试',
details: isDevelopment ? error.message : undefined
},
{ status: 500 }
))
}
if (error.message.includes('processImage') || error.message.includes('image')) {
return await failWithCleanup(NextResponse.json(
{
error: '图片处理失败,请检查图片格式是否正确',
details: isDevelopment ? error.message : undefined
},
{ status: 500 }
))
}
if (error.message.includes('database') || error.message.includes('prisma')) {
return await failWithCleanup(NextResponse.json(
{
error: '数据库操作失败,请稍后重试',
details: isDevelopment ? error.message : undefined
},
{ status: 500 }
))
}
}
// Return more specific error message for debugging
const errorMessage = isDevelopment && error instanceof Error
? error.message
: undefined
// 生产环境不返回底层错误细节,避免暴露存储路径、SQL 或依赖栈信息。
return await failWithCleanup(NextResponse.json(
{
error: '上传失败,请稍后再试',
details: errorMessage,
errorType: isDevelopment && error instanceof Error ? error.name : undefined
},
{ status: 500 }
))
} finally {
try {
await parsedUploadForm?.cleanup()
} catch (cleanupError) {
console.error('[Upload] Failed to cleanup temporary upload files:', cleanupError)
}
}
}