1147 lines
46 KiB
TypeScript
1147 lines
46 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, 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 = `<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 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<string, string | undefined> = { 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<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(
|
||
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)
|
||
}
|
||
}
|
||
}
|