2026-05-28 23:27:15 +08:00

1112 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { validateTemplateId } from '@/lib/template-validator'
import { validateSubmittedTemplateFields } from '@/lib/template-field-validation'
import { prepareMaterialData, compressMetadata, checkMetadataSize } from '@/lib/field-data-storage'
import { canUploadMaterial } 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 = `<svg width="600" height="400" xmlns="http://www.w3.org/2000/svg"><rect width="100%" height="100%" fill="#1e293b"/><circle cx="300" cy="180" r="60" fill="#3b82f6" opacity="0.8"/><polygon points="285,155 285,205 325,180" fill="#ffffff"/><text x="50%" y="75%" 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')}`
})()
const MATERIAL_PLACEHOLDER = (() => {
const svg = `<svg width="600" height="400" xmlns="http://www.w3.org/2000/svg"><rect width="100%" height="100%" fill="#f8fafc"/><rect x="180" y="90" width="240" height="180" rx="24" fill="#e2e8f0" stroke="#94a3b8" stroke-width="6"/><rect x="220" y="130" width="160" height="28" rx="10" fill="#cbd5e1"/><rect x="220" y="176" width="108" height="20" rx="10" fill="#cbd5e1"/><text x="50%" y="80%" font-size="20" fill="#475569" 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')}`
})()
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')}`
})()
type UploadFileHandle = {
name: string
type: string
size: number
filepath?: string
createReadStream: () => Readable
readBuffer: () => Promise<Buffer>
}
type ParsedUploadForm = {
getText: (key: string) => string | null
getFile: (key: string) => UploadFileHandle | null
getFiles: (key: string) => UploadFileHandle[]
cleanup: () => Promise<void>
}
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<ParsedUploadForm> {
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<string, string[]>()
const fileFields = new Map<string, UploadFileHandle[]>()
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<ParsedUploadForm> {
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<string, string>
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<string, string>()
const fileFields = new Map<string, UploadFileHandle[]>()
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<ParsedUploadForm> {
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 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
}
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<string, any> = {}
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(
{
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 = 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(
{ 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 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(
{ 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 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
const { metadata: rawMetadata } = prepareMaterialData(templateFields, templateId, previewUrls)
const 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()
if (errorMsg.includes('unique constraint') || errorMsg.includes('duplicate')) {
return await failWithCleanup(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 await failWithCleanup(NextResponse.json(
{
error: detailMsg,
details: dbError.message,
errorType: dbError.name
},
{ status: 400 }
))
}
if (errorMsg.includes('required') || errorMsg.includes('not null')) {
return await failWithCleanup(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 await failWithCleanup(NextResponse.json(
{
error: '字段值过长,请检查产品型号、分类、场景等字段长度',
details: dbError.message,
errorType: dbError.name
},
{ status: 400 }
))
}
if (errorMsg.includes('invalid input') || errorMsg.includes('invalid value')) {
return await failWithCleanup(NextResponse.json(
{
error: '数据格式不正确,请检查所有字段值',
details: dbError.message,
errorType: dbError.name
},
{ status: 400 }
))
}
// 对于其他数据库错误,返回详细信息
return await failWithCleanup(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 await failWithCleanup(NextResponse.json(
{
error: '文件上传到存储失败,请稍后重试',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
},
{ status: 500 }
))
}
if (error.message.includes('processImage') || error.message.includes('image')) {
return await failWithCleanup(NextResponse.json(
{
error: '图片处理失败,请检查图片格式是否正确',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
},
{ status: 500 }
))
}
if (error.message.includes('database') || error.message.includes('prisma')) {
return await failWithCleanup(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 await failWithCleanup(NextResponse.json(
{
error: '上传失败,请稍后再试',
details: errorMessage, // 始终返回错误详情,帮助排查问题
errorType: error instanceof Error ? error.name : 'UnknownError'
},
{ status: 500 }
))
} finally {
try {
await parsedUploadForm?.cleanup()
} catch (cleanupError) {
console.error('[Upload] Failed to cleanup temporary upload files:', cleanupError)
}
}
}