WuYongJie 63ec6b7dfb fix: 修复排行榜按钮消失、tag排序和输入框浅色模式问题
1. 排行榜悬浮窗关闭后小按钮消失问题
   - 修复面板居中模式关闭时按钮位置错误
   - 保存并恢复按钮的居中状态

2. 首页tag按钮按素材数量排序
   - 添加按素材数量降序排序逻辑

3. 上传弹窗输入框浅色模式文字不可见
   - 添加 text-slate-900 确保浅色模式下文字可见
   - 添加 placeholder 样式确保占位符正确显示
2025-12-14 21:41:29 +08:00

920 lines
39 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 } from '@/lib/s3'
import { processImage, getProcessedFileName, sliceImage } from '@/lib/image-processor'
import { parseAudioMetadata, isAudioFile } from '@/lib/audio-processor'
import { validateTemplateId } from '@/lib/template-validator'
import path from 'path'
import { randomBytes } from 'crypto'
const MATERIAL_ID_PREFIX = 'FLNXI'
type MaterialKind = 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'
// 音频素材占位符预览图
const AUDIO_PLACEHOLDER = (() => {
const svg = `<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')}`
})()
async function generateMaterialId() {
const now = new Date()
const dateStr = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
].join('')
for (let attempt = 0; attempt < 5; attempt += 1) {
const randomSuffix = Math.floor(Math.random() * 100000)
.toString()
.padStart(5, '0')
const candidateId = `${MATERIAL_ID_PREFIX}${dateStr}${randomSuffix}`
const exists = await prisma.material.findUnique({
where: { id: candidateId },
select: { id: true },
})
if (!exists) {
return candidateId
}
}
const fallbackSuffix = Date.now().toString().slice(-5)
return `${MATERIAL_ID_PREFIX}${dateStr}${fallbackSuffix}`
}
// 生成唯一文件名(避免并发冲突)
function generateUniqueFileName(prefix: string, ext: string): string {
const timestamp = Date.now()
const randomId = randomBytes(8).toString('hex') // 16个字符的随机字符串
return `${prefix}-${timestamp}-${randomId}${ext}`
}
// Configure route to handle large files
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
// Increase timeout for large file uploads
export const maxDuration = 10800 // 3 hours timeout (10800 seconds)
const VIDEO_PENDING_PLACEHOLDER = (() => {
const svg = `<svg width="600" height="400" xmlns="http://www.w3.org/2000/svg"><rect width="100%" height="100%" fill="#0f172a"/><text x="50%" y="46%" font-size="42" fill="#ffffff" text-anchor="middle" font-family="PingFang SC, 'Microsoft YaHei', 'Segoe UI', sans-serif">等待转码</text><text x="50%" y="62%" font-size="20" fill="#94a3b8" text-anchor="middle" font-family="PingFang SC, 'Microsoft YaHei', 'Segoe UI', sans-serif">预览生成中…</text></svg>`
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
})()
export async function POST(req: NextRequest) {
try {
console.log('[Upload] Starting upload process...')
const session = await getServerSession(authOptions)
console.log('[Upload] Session checked:', !!session)
if (!session) {
return NextResponse.json(
{ error: '请先登录' },
{ status: 401 }
)
}
console.log('[Upload] Parsing form data...')
const formData = await req.formData()
const normalizeInput = (value: FormDataEntryValue | null) => {
if (!value) return null
const trimmed = value.toString().trim()
return trimmed ? trimmed : null
}
const previewImage = formData.get('previewImage') as File // 向后兼容:第一张预览图
const previewImages = formData.getAll('previewImages') as File[] // 多预览图(最多5张)
const displayImage = formData.get('displayImage') as File // PSD素材的展示图片
const zipFile = formData.get('zipFile') as File
const productModel = formData.get('productModel') as string
const categoryName = formData.get('categoryName') as string
const sceneName = formData.get('sceneName') as string
const platformName = formData.get('platformName') as string || '' // 平台分类(可选)
const materialType = (formData.get('materialType') as string) || '3DMAX'
const rawMaterialKind = (formData.get('materialKind') as string) || ''
const allowedMaterialKinds: MaterialKind[] = ['IMAGE', 'VIDEO', 'AUDIO', 'OTHER']
const normalizedKind = rawMaterialKind
? rawMaterialKind.toUpperCase()
: materialType === '视频'
? 'VIDEO'
: materialType === '音频'
? 'AUDIO'
: 'IMAGE'
const materialKind: MaterialKind = allowedMaterialKinds.includes(normalizedKind as MaterialKind)
? (normalizedKind as MaterialKind)
: materialType === '视频'
? 'VIDEO'
: materialType === '音频'
? 'AUDIO'
: 'IMAGE'
const isVideoMaterial = materialKind === 'VIDEO'
const isAudioMaterial = materialKind === 'AUDIO'
const renderer = (formData.get('renderer') as string) || 'CR'
const brandName = normalizeInput(formData.get('brandName'))
const seriesName = normalizeInput(formData.get('seriesName'))
const skuCode = normalizeInput(formData.get('skuCode'))
const materialTexture = normalizeInput(formData.get('materialTexture'))
const renderTime = normalizeInput(formData.get('renderTime'))
const imageResolution = normalizeInput(formData.get('imageResolution'))
const colorMode = normalizeInput(formData.get('colorMode'))
let audioDuration = normalizeInput(formData.get('audioDuration'))
const audioCategory = normalizeInput(formData.get('audioCategory'))
const audioScene = normalizeInput(formData.get('audioScene'))
// 作者名称:优先使用表单提交的值(如音频元数据中的艺术家),否则使用上传者用户名
const submittedAuthorName = normalizeInput(formData.get('authorName'))
let audioUrl: string | null = null // 音频文件URL,用于在线播放
// 模板相关字段
const templateId = formData.get('templateId') as string | null
const templateFieldsStr = formData.get('templateFields') as string | null
let templateFields: Record<string, any> = {}
if (templateFieldsStr) {
try {
templateFields = JSON.parse(templateFieldsStr)
} catch (error) {
console.error('[Upload] Failed to parse templateFields:', error)
}
}
console.log('[Upload] Form data received:', {
hasPreviewImage: !!previewImage,
previewImageSize: previewImage?.size,
hasDisplayImage: !!displayImage,
displayImageSize: displayImage?.size,
hasZipFile: !!zipFile,
zipFileSize: zipFile?.size,
productModel,
categoryName,
sceneName,
materialType
})
console.log('[Upload] Material type/kind:', materialType, materialKind)
// Validation - 基础字段验证(材质类型不需要分类和场景)
if (materialType !== '材质' && (!productModel || !categoryName || !sceneName)) {
console.log('[Upload] Validation failed - missing required fields')
return NextResponse.json(
{ error: '产品型号、分类和场景都是必填的' },
{ status: 400 }
)
}
// 材质类型只需要产品型号
if (materialType === '材质' && !productModel) {
console.log('[Upload] Validation failed - material type requires product model')
return NextResponse.json(
{ error: '请填写材质名称' },
{ status: 400 }
)
}
// 素材文件验证(模特类型和材质类型素材文件可选)
if (materialType !== '模特' && materialType !== '材质' && !zipFile) {
console.log('[Upload] Validation failed - missing zip file')
return NextResponse.json(
{ error: '请上传素材文件' },
{ status: 400 }
)
}
// 对于视频类型,必须有视频文件
if (isVideoMaterial && !zipFile) {
console.log('[Upload] Validation failed - video requires video file')
return NextResponse.json(
{ error: '请上传视频文件' },
{ status: 400 }
)
}
// 对于PSD类型,必须有展示图片
if (materialType === 'PSD' && !displayImage) {
console.log('[Upload] Validation failed - PSD requires display image')
return NextResponse.json(
{ error: 'PSD素材必须上传展示图片' },
{ status: 400 }
)
}
// 处理多预览图(优先使用previewImages,如果没有则使用previewImage向后兼容)
const imagesToProcess = previewImages.length > 0 ? previewImages : (previewImage ? [previewImage] : [])
// 对于视频类型,封面图是可选的(如果没有上传,转码任务会自动从视频中截取)
// 对于音频类型,预览图是可选的(使用占位符)
// 对于非PSD、非模特、非视频、非材质、非音频类型,必须有预览图(包括3DMAX等类型)
if (materialType !== 'PSD' && materialType !== '视频' && materialType !== '材质' && materialType !== '音频' && imagesToProcess.length === 0) {
console.log('[Upload] Validation failed - non-PSD/Video/Material/Audio types require preview image')
return NextResponse.json(
{ error: '请上传预览图' },
{ status: 400 }
)
}
// 获取用户信息作为作者花名
const userId = (session.user as any).id
console.log('[Upload] Fetching user info... Session user ID:', userId, 'Type:', typeof userId)
const user = await prisma.user.findUnique({
where: { id: userId },
select: { username: true },
})
if (!user) {
console.log('[Upload] User not found for ID:', userId)
return NextResponse.json(
{ error: '用户不存在' },
{ status: 404 }
)
}
// 作者名称:优先使用表单提交的值(如音频元数据中的艺术家),否则使用上传者用户名
const authorName = submittedAuthorName || user.username
console.log('[Upload] User found:', user.username, 'Author name:', authorName)
let previewUrl: string = ''
let previewUrls: string[] = [] // 多预览图URLs
let hasUserUploadedCover = false // 标记用户是否实际上传了封面图(用于视频素材)
// 处理多预览图(非PSD、非模特、非材质类型,以及视频类型需要封面)
const shouldProcessPreviewImages =
imagesToProcess.length > 0 && (isVideoMaterial || (materialType !== 'PSD' && materialType !== '材质'))
if (shouldProcessPreviewImages) {
const allowedImageTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']
const maxPreviewSize = 50 * 1024 * 1024 // 50MB
// 验证所有预览图
for (const img of imagesToProcess) {
if (img.size > maxPreviewSize) {
console.log(`[Upload] Preview image too large: ${(img.size / 1024 / 1024).toFixed(2)}MB`)
return NextResponse.json(
{ error: `预览图过大,请使用小于50MB的图片(当前:${(img.size / 1024 / 1024).toFixed(2)}MB)` },
{ status: 400 }
)
}
if (!allowedImageTypes.includes(img.type)) {
console.log(`[Upload] Invalid image type: ${img.type}`)
return NextResponse.json(
{ error: `不支持的图片格式:${img.type},请使用 JPG、PNG、WebP 或 GIF 格式` },
{ status: 400 }
)
}
}
// 处理所有预览图
console.log(`[Upload] Processing ${imagesToProcess.length} preview images...`)
try {
for (let i = 0; i < imagesToProcess.length; i++) {
const img = imagesToProcess[i]
console.log(`[Upload] Processing preview image ${i + 1}/${imagesToProcess.length}... (${img.type}, ${(img.size / 1024).toFixed(2)}KB)`)
try {
const originalBuffer = Buffer.from(await img.arrayBuffer())
console.log(`[Upload] Preview image ${i + 1} buffer size: ${originalBuffer.length} bytes`)
const processedBuffer = await processImage(originalBuffer, {
maxSizeKB: 300,
maxWidth: 600,
maxHeight: 800,
format: 'webp',
})
console.log(`[Upload] Preview image ${i + 1} processed, size: ${processedBuffer.length} bytes`)
const processedFileName = getProcessedFileName(img.name)
const fileExt = path.extname(processedFileName)
const previewFileName = generateUniqueFileName(`preview-${i}`, fileExt)
console.log(`[Upload] Uploading preview image ${i + 1} to S3: ${previewFileName}`)
const uploadedUrl = await uploadToS3(processedBuffer, previewFileName, 'image/webp')
previewUrls.push(uploadedUrl)
console.log(`[Upload] Preview image ${i + 1} uploaded to S3:`, uploadedUrl)
} catch (singleImageError) {
console.error(`[Upload] Error processing preview image ${i + 1}:`, singleImageError)
if (singleImageError instanceof Error) {
console.error(`[Upload] Error details: ${singleImageError.message}`)
console.error(`[Upload] Error stack: ${singleImageError.stack}`)
}
throw new Error(`预览图 ${i + 1} 处理失败:${singleImageError instanceof Error ? singleImageError.message : '未知错误'}`)
}
}
// 第一张作为主预览图(向后兼容)
previewUrl = previewUrls[0] || ''
hasUserUploadedCover = previewUrl.length > 0 // 用户实际上传了封面图
console.log('[Upload] All preview images processed successfully, previewUrl:', previewUrl ? `${previewUrl.substring(0, 50)}...` : 'EMPTY')
} catch (imageError) {
console.error('[Upload] Preview image processing error:', imageError)
if (imageError instanceof Error) {
console.error('[Upload] Preview image error name:', imageError.name)
console.error('[Upload] Preview image error message:', imageError.message)
console.error('[Upload] Preview image error stack:', imageError.stack)
}
return NextResponse.json(
{
error: `预览图处理失败:${imageError instanceof Error ? imageError.message : '未知错误'}`,
details: imageError instanceof Error ? imageError.stack : undefined,
errorType: imageError instanceof Error ? imageError.name : 'UnknownError'
},
{ status: 500 }
)
}
}
// Handle PSD display images (切割和上传展示图片)
let displayImages: string[] = []
if (materialType === 'PSD' && displayImage) {
console.log('[Upload] Processing PSD display image...')
try {
// 读取展示图片
const displayImageBuffer = Buffer.from(await displayImage.arrayBuffer())
// 切割图片(按1500px高度)
console.log('[Upload] Slicing display image (max height: 1500px)...')
const slicedBuffers = await sliceImage(displayImageBuffer, 1500)
console.log(`[Upload] Image sliced into ${slicedBuffers.length} parts`)
// 上传每个切片到S3
for (let i = 0; i < slicedBuffers.length; i++) {
const sliceFileName = generateUniqueFileName(`display-${i + 1}`, '.webp')
const sliceUrl = await uploadToS3(slicedBuffers[i], sliceFileName, 'image/webp')
displayImages.push(sliceUrl)
console.log(`[Upload] Display image slice ${i + 1}/${slicedBuffers.length} uploaded to S3:`, sliceUrl)
}
// 如果没有预览图,使用第一张展示图片作为预览图
if (!previewImage && displayImages.length > 0) {
previewUrl = displayImages[0]
console.log('[Upload] Using first display image as preview:', previewUrl)
}
} catch (displayImageError) {
console.error('[Upload] Display image processing error:', displayImageError)
return NextResponse.json(
{ error: `PSD展示图片处理失败:${displayImageError instanceof Error ? displayImageError.message : '未知错误'}` },
{ status: 500 }
)
}
}
// Save zip/PSD/视频 file to S3 (if exists - 模特类型和材质类型可以没有素材文件)
let zipFileName = ''
let zipFilePath = ''
let zipFileSize = BigInt(0)
if (!zipFile) {
console.log('[Upload] No file provided (Model/Material type with images only), skipping file save...')
} else {
console.log('[Upload] Uploading original file to S3...')
try {
const zipFileExt = path.extname(zipFile.name)
zipFileName = generateUniqueFileName('material', zipFileExt)
// 读取并上传素材文件(包括视频文件和音频文件)
const zipBuffer = Buffer.from(await zipFile.arrayBuffer())
zipFileSize = BigInt(zipBuffer.length) // 记录文件大小(字节)
const contentType = zipFile.type || 'application/octet-stream'
zipFilePath = await uploadToS3(zipBuffer, zipFileName, contentType)
console.log('[Upload] File uploaded to bucket:', zipFilePath, `(${(Number(zipFileSize) / 1024 / 1024).toFixed(2)} MB)`)
// 如果是音频文件,自动解析音频时长并记录音频URL
if (isAudioFile(contentType)) {
console.log('[Upload] Audio file detected, parsing metadata...')
// 记录音频文件URL用于在线播放
audioUrl = zipFilePath
try {
const audioMetadata = await parseAudioMetadata(zipBuffer, contentType)
if (audioMetadata) {
// 使用解析的时长覆盖表单提交的值
audioDuration = audioMetadata.durationFormatted
console.log('[Upload] Audio metadata parsed:', {
duration: audioMetadata.duration,
durationFormatted: audioMetadata.durationFormatted,
format: audioMetadata.format,
bitrate: audioMetadata.bitrate,
sampleRate: audioMetadata.sampleRate,
})
}
} catch (audioError) {
console.error('[Upload] Failed to parse audio metadata:', audioError)
// 音频解析失败不影响上传,继续处理
}
}
} catch (fileError) {
console.error('[Upload] File save error:', fileError)
if (fileError instanceof Error) {
console.error('[Upload] File save error message:', fileError.message)
console.error('[Upload] File save error stack:', fileError.stack)
}
return NextResponse.json(
{ error: `文件保存失败:${fileError instanceof Error ? fileError.message : '未知错误'}` },
{ status: 500 }
)
}
}
// Create material record
console.log('[Upload] Creating database record...')
console.log('[Upload] Preview URL status:', {
hasPreviewUrl: !!previewUrl,
previewUrlLength: previewUrl?.length || 0,
previewUrlsCount: previewUrls.length,
materialType,
imagesToProcessCount: imagesToProcess.length,
isVideoMaterial,
})
// 验证必填字段 - previewUrl是数据库必填字段
// 对于视频素材,如果用户没有上传封面图,使用占位符(转码任务会自动生成封面图)
// 对于音频素材,预览图可选,使用音频占位符
if (!previewUrl || previewUrl.trim() === '') {
if (isVideoMaterial) {
// 视频素材没有上传封面图时,使用占位符URL(转码任务会自动生成)
previewUrl = VIDEO_PENDING_PLACEHOLDER
console.log('[Upload] Video material without cover image, using placeholder')
} else if (isAudioMaterial) {
// 音频素材使用音频占位符
previewUrl = AUDIO_PLACEHOLDER
console.log('[Upload] Audio material without cover image, using audio placeholder')
} else {
console.error('[Upload] Validation failed - previewUrl is required but empty', {
materialType,
hasPreviewImages: imagesToProcess.length > 0,
previewUrlsCount: previewUrls.length,
hasDisplayImages: displayImages.length > 0,
})
return NextResponse.json(
{ error: '预览图处理失败,请重新上传预览图。如果已上传预览图,可能是图片处理出错,请检查图片格式是否正确' },
{ status: 400 }
)
}
}
if (!productModel || productModel.trim() === '') {
console.error('[Upload] Validation failed - productModel is required')
return NextResponse.json(
{ error: '产品型号不能为空' },
{ status: 400 }
)
}
if (materialType !== '材质' && (!categoryName || categoryName.trim() === '')) {
console.error('[Upload] Validation failed - categoryName is required for non-material type')
return NextResponse.json(
{ error: '分类不能为空' },
{ status: 400 }
)
}
if (materialType !== '材质' && (!sceneName || sceneName.trim() === '')) {
console.error('[Upload] Validation failed - sceneName is required for non-material type')
return NextResponse.json(
{ error: '场景不能为空' },
{ status: 400 }
)
}
// 构建 metadata,包含模板字段值和多预览图URLs
let metadata: string | null = null
const metadataObj: any = {}
if (templateId && Object.keys(templateFields).length > 0) {
metadataObj.templateId = templateId
metadataObj.templateFields = templateFields
}
if (previewUrls.length > 0) {
metadataObj.previewUrls = previewUrls
}
if (Object.keys(metadataObj).length > 0) {
metadata = JSON.stringify(metadataObj)
}
console.log('[Upload] Material data to create:', {
previewUrl: previewUrl ? `${previewUrl.substring(0, 50)}...` : 'EMPTY',
previewUrlLength: previewUrl?.length || 0,
productModel,
categoryName: materialType === '材质' ? '通用' : categoryName,
sceneName: materialType === '材质' ? '通用' : sceneName,
materialType,
materialKind,
renderer,
authorName,
hasMetadata: !!metadata,
hasTemplateId: !!templateId,
templateId,
userId: (session.user as any).id,
zipFileName,
zipFileSize: zipFileSize.toString(),
previewUrlsCount: previewUrls.length,
})
// 验证并清理数据,确保符合数据库约束
const finalCategoryName = materialType === '材质' ? '通用' : (categoryName || '')
const finalSceneName = materialType === '材质' ? '通用' : (sceneName || '')
const finalPlatformName = platformName || ''
const finalZipFileName = zipFileName || ''
const finalZipFilePath = zipFilePath || ''
// 验证字符串长度(PostgreSQL TEXT 类型理论上无限制,但为了安全起见检查)
const maxStringLength = 10000 // 设置一个合理的最大长度
if (previewUrl.length > maxStringLength) {
console.error('[Upload] previewUrl too long:', previewUrl.length)
return NextResponse.json(
{ error: '预览图URL过长,请检查' },
{ status: 400 }
)
}
if (productModel.length > maxStringLength) {
console.error('[Upload] productModel too long:', productModel.length)
return NextResponse.json(
{ error: '产品型号过长,请缩短后重试' },
{ status: 400 }
)
}
// 验证 userId 是否存在(userId 已在前面定义)
if (!userId || typeof userId !== 'string') {
console.error('[Upload] Invalid userId:', userId)
return NextResponse.json(
{ error: '用户ID无效,请重新登录' },
{ status: 401 }
)
}
// 验证模板ID(如果提供)
if (templateId) {
console.log('[Upload] Validating template:', templateId)
const validation = await validateTemplateId(templateId, {
checkStatus: true, // 检查模板状态
logPerformance: true, // 记录性能日志
})
if (!validation.valid) {
console.error('[Upload] Template validation failed:', {
templateId,
error: validation.error,
errorCode: validation.errorCode,
validationTime: validation.validationTime,
})
return NextResponse.json(
{
error: validation.error || '模板验证失败',
errorCode: validation.errorCode,
},
{ status: 400 }
)
}
console.log('[Upload] Template validation passed:', {
templateId,
isBuiltin: validation.isBuiltin,
templateKey: validation.templateKey,
templateName: validation.templateName,
validationTime: validation.validationTime,
})
}
try {
const materialId = await generateMaterialId()
const material = await prisma.material.create({
data: {
id: materialId,
previewUrl: previewUrl.substring(0, maxStringLength), // 确保不超过最大长度
// previewUrls: previewUrls.length > 0 ? JSON.stringify(previewUrls) : null, // 暂时存储在metadata中,等数据库迁移后启用
productModel: productModel.substring(0, maxStringLength),
brandName: brandName ? brandName.substring(0, maxStringLength) : null,
seriesName: seriesName ? seriesName.substring(0, maxStringLength) : null,
skuCode: skuCode ? skuCode.substring(0, maxStringLength) : null,
materialTexture: materialTexture ? materialTexture.substring(0, maxStringLength) : null,
categoryName: finalCategoryName.substring(0, maxStringLength),
sceneName: finalSceneName.substring(0, maxStringLength),
platformName: finalPlatformName.substring(0, maxStringLength),
materialType,
// @ts-ignore - materialKind 字段已在 schema 中定义,Prisma Client 类型可能需要 IDE 重启后更新
materialKind,
renderer,
renderTime: renderTime ? renderTime.substring(0, maxStringLength) : null,
imageResolution: imageResolution ? imageResolution.substring(0, maxStringLength) : null,
colorMode: colorMode ? colorMode.substring(0, maxStringLength) : null,
// @ts-ignore - 音频字段已在数据库中添加,Prisma Client 类型可能需要重新生成
audioDuration: audioDuration ? audioDuration.substring(0, maxStringLength) : null,
// @ts-ignore
audioCategory: audioCategory ? audioCategory.substring(0, maxStringLength) : null,
// @ts-ignore
audioScene: audioScene ? audioScene.substring(0, maxStringLength) : null,
// @ts-ignore
audioUrl: audioUrl ? audioUrl.substring(0, maxStringLength) : null,
authorName: authorName.substring(0, maxStringLength),
// 素材归属:默认归属团队空间,createdByUserId 记录上传者
ownerType: 'TEAM',
ownerId: 'default-team',
createdByUserId: userId,
zipFileName: finalZipFileName.substring(0, maxStringLength),
zipFilePath: finalZipFilePath.substring(0, maxStringLength),
zipFileSize,
displayImages: JSON.stringify(displayImages).substring(0, maxStringLength * 10), // displayImages 可能较长
metadata: metadata ? metadata.substring(0, maxStringLength * 10) : null, // metadata 可能较长
templateId: templateId || null,
videoSourceUrl: isVideoMaterial && finalZipFilePath ? finalZipFilePath.substring(0, maxStringLength) : null,
videoPreview540Url: isVideoMaterial ? null : null,
videoPreviewClipUrl: isVideoMaterial ? null : null,
// 如果用户上传了封面图就使用,否则为null(转码任务会自动从视频中截取)
// 注意:只有当用户实际上传了封面图时才设置,占位符不算
videoCoverUrl: isVideoMaterial && hasUserUploadedCover ? previewUrl.substring(0, maxStringLength) : null,
videoProcessingStatus: isVideoMaterial ? 'pending' : null,
videoDuration: isVideoMaterial ? null : null,
userId,
},
include: {
user: {
select: {
username: true,
},
},
},
})
console.log('[Upload] Database record created:', material.id)
// 记录上传日志
await createMaterialLog({
materialId: material.id,
userId,
action: 'UPLOAD',
})
// 如果是视频素材,异步触发转码任务
if (isVideoMaterial && finalZipFilePath) {
console.log('[Upload] Video material detected, triggering transcode task...')
// 直接调用转码队列,而不是通过HTTP API(避免网络连接问题)
// 使用 setImmediate 确保在响应返回后再执行,避免阻塞
setImmediate(async () => {
try {
// 动态导入转码相关模块(避免循环依赖和初始化问题)
const { transcodeQueue } = await import('@/lib/video-transcode-queue')
// 定义转码任务函数(与 transcode/route.ts 中的逻辑一致)
const processVideoTranscodeTask = async (
materialId: string,
videoSourceUrl: string,
needsCoverImage: boolean,
retryCount: number = 0
): Promise<void> => {
const { processVideoTranscode } = await import('@/lib/video-processor')
const { tmpdir } = await import('os')
const MAX_RETRIES = parseInt(process.env.VIDEO_TRANSCODE_MAX_RETRIES || '2', 10)
try {
console.log(`[Transcode] Starting transcode task for material ${materialId}${retryCount > 0 ? ` (retry ${retryCount}/${MAX_RETRIES})` : ''}...`)
const outputDir = tmpdir()
const result = await processVideoTranscode({
inputPath: videoSourceUrl,
outputDir,
materialId,
needsCoverImage,
})
// 更新数据库
const updateData: any = {
videoProcessingStatus: 'done',
videoPreview540Url: result.preview540Url,
videoPreviewClipUrl: result.clipUrl,
videoDuration: result.duration,
}
// 如果用户没有上传封面图,使用自动截取的封面图
if (needsCoverImage && result.coverUrl) {
updateData.videoCoverUrl = result.coverUrl
// 同时更新 previewUrl(如果当前是占位符)
const material = await prisma.material.findUnique({
where: { id: materialId },
select: { previewUrl: true },
})
if (material?.previewUrl?.startsWith('data:image')) {
updateData.previewUrl = result.coverUrl
}
}
await prisma.material.update({
where: { id: materialId },
data: updateData,
})
console.log(`[Transcode] Transcode task completed for material ${materialId}`)
} catch (error) {
console.error(`[Transcode] Transcode task failed for material ${materialId}:`, error)
// 重试机制
if (retryCount < MAX_RETRIES) {
const delay = Math.min(1000 * Math.pow(2, retryCount), 30000)
console.log(`[Transcode] Retrying transcode task for material ${materialId} after ${delay}ms...`)
await new Promise((resolve) => setTimeout(resolve, delay))
return processVideoTranscodeTask(materialId, videoSourceUrl, needsCoverImage, retryCount + 1)
} else {
console.error(`[Transcode] Max retries (${MAX_RETRIES}) reached for material ${materialId}, marking as failed`)
throw error
}
}
}
// 检查是否需要生成封面图
const needsCoverImage = !material.videoCoverUrl
// 添加到转码队列
transcodeQueue.add(
material.id,
material.videoSourceUrl!,
needsCoverImage,
processVideoTranscodeTask
).catch((error) => {
console.error(`[Upload] Transcode task failed for material ${material.id}:`, error)
// 更新状态为 failed
prisma.material.update({
where: { id: material.id },
data: { videoProcessingStatus: 'failed' },
}).catch((dbError) => {
console.error(`[Upload] Failed to update status to failed:`, dbError)
})
})
console.log('[Upload] Transcode task added to queue for material:', material.id)
} catch (error) {
console.error('[Upload] Failed to trigger transcode task:', error)
// 转码任务触发失败不影响上传成功,但更新状态为failed
try {
await prisma.material.update({
where: { id: material.id },
data: { videoProcessingStatus: 'failed' },
})
} catch (dbError) {
console.error('[Upload] Failed to update material status to failed:', dbError)
}
}
})
}
// 转换BigInt为字符串以支持JSON序列化
const materialData = {
...material,
zipFileSize: material.zipFileSize.toString()
}
console.log('[Upload] Upload completed successfully!')
return NextResponse.json(
{ message: '上传成功', material: materialData },
{ status: 201 }
)
} catch (dbError) {
console.error('[Upload] Database error:', dbError)
if (dbError instanceof Error) {
console.error('[Upload] Database error name:', dbError.name)
console.error('[Upload] Database error message:', dbError.message)
console.error('[Upload] Database error stack:', dbError.stack)
// 记录创建数据时的所有字段值(用于调试)
console.error('[Upload] Failed data:', {
previewUrl: previewUrl ? `${previewUrl.substring(0, 50)}...` : 'EMPTY',
previewUrlLength: previewUrl?.length || 0,
productModel,
categoryName: materialType === '材质' ? '通用' : categoryName,
sceneName: materialType === '材质' ? '通用' : sceneName,
materialType,
renderer,
authorName,
userId: (session.user as any).id,
templateId,
zipFileName,
zipFileSize: zipFileSize.toString(),
hasMetadata: !!metadata,
})
// 提供更具体的数据库错误信息
const errorMsg = dbError.message.toLowerCase()
if (errorMsg.includes('unique constraint') || errorMsg.includes('duplicate')) {
return NextResponse.json(
{
error: '数据已存在,请检查是否重复上传',
details: dbError.message,
errorType: dbError.name
},
{ status: 400 }
)
}
if (errorMsg.includes('foreign key constraint') || errorMsg.includes('foreign key')) {
// 检查是哪个外键失败
let detailMsg = '关联数据不存在'
if (errorMsg.includes('userid') || errorMsg.includes('user')) {
detailMsg = `用户ID不存在:${(session.user as any).id}`
} else if (errorMsg.includes('templateid') || errorMsg.includes('template')) {
detailMsg = `模板ID不存在:${templateId}`
}
return NextResponse.json(
{
error: detailMsg,
details: dbError.message,
errorType: dbError.name
},
{ status: 400 }
)
}
if (errorMsg.includes('required') || errorMsg.includes('not null')) {
return NextResponse.json(
{
error: `必填字段缺失:${dbError.message}`,
details: dbError.message,
errorType: dbError.name
},
{ status: 400 }
)
}
if (errorMsg.includes('value too long') || errorMsg.includes('string data right truncated')) {
return NextResponse.json(
{
error: '字段值过长,请检查产品型号、分类、场景等字段长度',
details: dbError.message,
errorType: dbError.name
},
{ status: 400 }
)
}
if (errorMsg.includes('invalid input') || errorMsg.includes('invalid value')) {
return NextResponse.json(
{
error: '数据格式不正确,请检查所有字段值',
details: dbError.message,
errorType: dbError.name
},
{ status: 400 }
)
}
// 对于其他数据库错误,返回详细信息
return NextResponse.json(
{
error: '数据库操作失败',
details: dbError.message,
errorType: dbError.name,
errorStack: process.env.NODE_ENV === 'development' ? dbError.stack : undefined
},
{ status: 500 }
)
}
throw dbError // 重新抛出,让外层catch处理
}
} catch (error) {
console.error('[Upload] Upload error:', error)
// Log detailed error information
if (error instanceof Error) {
console.error('[Upload] Error name:', error.name)
console.error('[Upload] Error message:', error.message)
console.error('[Upload] Error stack:', error.stack)
// 根据错误类型提供更具体的错误信息
if (error.message.includes('S3') || error.message.includes('upload')) {
return NextResponse.json(
{
error: '文件上传到存储失败,请稍后重试',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
},
{ status: 500 }
)
}
if (error.message.includes('processImage') || error.message.includes('image')) {
return NextResponse.json(
{
error: '图片处理失败,请检查图片格式是否正确',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
},
{ status: 500 }
)
}
if (error.message.includes('database') || error.message.includes('prisma')) {
return NextResponse.json(
{
error: '数据库操作失败,请稍后重试',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
},
{ status: 500 }
)
}
}
// Return more specific error message for debugging
const errorMessage = error instanceof Error ? error.message : '上传失败,请稍后再试'
// 在生产环境也返回错误详情,帮助排查问题
return NextResponse.json(
{
error: '上传失败,请稍后再试',
details: errorMessage, // 始终返回错误详情,帮助排查问题
errorType: error instanceof Error ? error.name : 'UnknownError'
},
{ status: 500 }
)
}
}