440 lines
18 KiB
TypeScript
440 lines
18 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
||
import { getServerSession } from 'next-auth'
|
||
import { authOptions } from '@/lib/auth'
|
||
import { prisma } from '@/lib/prisma'
|
||
import { uploadToS3 } from '@/lib/s3'
|
||
import { processImage, getProcessedFileName, sliceImage } from '@/lib/image-processor'
|
||
import { writeFile, mkdir } from 'fs/promises'
|
||
import path from 'path'
|
||
import { randomBytes } from 'crypto'
|
||
|
||
// 生成唯一文件名(避免并发冲突)
|
||
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)
|
||
|
||
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 displayImage = formData.get('displayImage') as File // PSD素材的展示图片
|
||
const modelImages = formData.getAll('modelImages') as File[] // 模特类型的多张展示图片
|
||
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 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'))
|
||
|
||
console.log('[Upload] Form data received:', {
|
||
hasPreviewImage: !!previewImage,
|
||
previewImageSize: previewImage?.size,
|
||
hasDisplayImage: !!displayImage,
|
||
displayImageSize: displayImage?.size,
|
||
modelImagesCount: modelImages.length,
|
||
hasZipFile: !!zipFile,
|
||
zipFileSize: zipFile?.size,
|
||
productModel,
|
||
categoryName,
|
||
sceneName,
|
||
materialType
|
||
})
|
||
|
||
console.log('[Upload] Material type:', materialType)
|
||
|
||
// 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 (materialType === '视频' && !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 }
|
||
)
|
||
}
|
||
|
||
// 对于模特类型,必须有至少一张展示图片
|
||
if (materialType === '模特' && modelImages.length === 0) {
|
||
console.log('[Upload] Validation failed - Model requires model images')
|
||
return NextResponse.json(
|
||
{ error: '模特素材必须上传至少一张展示图片' },
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
|
||
// 对于视频类型,必须有预览图(封面)
|
||
if (materialType === '视频' && !previewImage) {
|
||
console.log('[Upload] Validation failed - video requires preview image')
|
||
return NextResponse.json(
|
||
{ error: '视频素材需要上传预览图(封面)' },
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
|
||
// 对于非PSD、非模特、非视频类型,必须有预览图
|
||
if (materialType !== 'PSD' && materialType !== '模特' && materialType !== '视频' && materialType !== '材质' && !previewImage) {
|
||
console.log('[Upload] Validation failed - non-PSD/Model/Video requires preview image')
|
||
return NextResponse.json(
|
||
{ error: '请上传预览图' },
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
|
||
// 获取用户信息作为作者花名
|
||
const userId = (session.user as any).id
|
||
console.log('[Upload] Fetching user info... Session user ID:', userId, 'Type:', typeof userId)
|
||
const user = await prisma.user.findUnique({
|
||
where: { id: userId },
|
||
select: { username: true },
|
||
})
|
||
|
||
if (!user) {
|
||
console.log('[Upload] User not found for ID:', userId)
|
||
return NextResponse.json(
|
||
{ error: '用户不存在' },
|
||
{ status: 404 }
|
||
)
|
||
}
|
||
|
||
const authorName = user.username
|
||
console.log('[Upload] User found:', authorName)
|
||
|
||
let previewUrl: string = ''
|
||
|
||
// 处理预览图(非PSD、非模特、非材质类型,以及视频类型需要封面)
|
||
if (materialType !== 'PSD' && materialType !== '模特' && materialType !== '材质' && previewImage) {
|
||
// 验证预览图大小(限制50MB)
|
||
const maxPreviewSize = 50 * 1024 * 1024 // 50MB
|
||
if (previewImage.size > maxPreviewSize) {
|
||
console.log(`[Upload] Preview image too large: ${(previewImage.size / 1024 / 1024).toFixed(2)}MB`)
|
||
return NextResponse.json(
|
||
{ error: `预览图过大,请使用小于50MB的图片(当前:${(previewImage.size / 1024 / 1024).toFixed(2)}MB)` },
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
|
||
// 验证预览图格式
|
||
const allowedImageTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']
|
||
if (!allowedImageTypes.includes(previewImage.type)) {
|
||
console.log(`[Upload] Invalid image type: ${previewImage.type}`)
|
||
return NextResponse.json(
|
||
{ error: `不支持的图片格式:${previewImage.type},请使用 JPG、PNG、WebP 或 GIF 格式` },
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
|
||
// 处理预览图:压缩到300KB以内,转换为webP,缩放适配300x400显示
|
||
console.log(`[Upload] Processing preview image... (${previewImage.type}, ${(previewImage.size / 1024).toFixed(2)}KB)`)
|
||
try {
|
||
const originalBuffer = Buffer.from(await previewImage.arrayBuffer())
|
||
const processedBuffer = await processImage(originalBuffer, {
|
||
maxSizeKB: 300,
|
||
maxWidth: 600,
|
||
maxHeight: 800,
|
||
format: 'webp',
|
||
})
|
||
console.log('[Upload] Preview image processed successfully')
|
||
|
||
// Upload processed preview image to S3
|
||
console.log('[Upload] Uploading preview image to S3...')
|
||
const processedFileName = getProcessedFileName(previewImage.name)
|
||
const fileExt = path.extname(processedFileName)
|
||
const previewFileName = generateUniqueFileName('preview', fileExt)
|
||
previewUrl = await uploadToS3(processedBuffer, previewFileName, 'image/webp')
|
||
console.log('[Upload] Preview image uploaded to S3:', previewUrl)
|
||
} catch (imageError) {
|
||
console.error('[Upload] Preview image processing error:', imageError)
|
||
return NextResponse.json(
|
||
{ error: `预览图处理失败:${imageError instanceof Error ? imageError.message : '未知错误'}` },
|
||
{ status: 500 }
|
||
)
|
||
}
|
||
}
|
||
|
||
// 处理视频类型的预览图(封面)
|
||
if (materialType === '视频' && previewImage) {
|
||
// 验证预览图大小(限制50MB)
|
||
const maxPreviewSize = 50 * 1024 * 1024 // 50MB
|
||
if (previewImage.size > maxPreviewSize) {
|
||
console.log(`[Upload] Preview image too large: ${(previewImage.size / 1024 / 1024).toFixed(2)}MB`)
|
||
return NextResponse.json(
|
||
{ error: `预览图过大,请使用小于50MB的图片(当前:${(previewImage.size / 1024 / 1024).toFixed(2)}MB)` },
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
|
||
// 验证预览图格式
|
||
const allowedImageTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']
|
||
if (!allowedImageTypes.includes(previewImage.type)) {
|
||
console.log(`[Upload] Invalid image type: ${previewImage.type}`)
|
||
return NextResponse.json(
|
||
{ error: `不支持的图片格式:${previewImage.type},请使用 JPG、PNG、WebP 或 GIF 格式` },
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
|
||
// 处理预览图:压缩到300KB以内,转换为webP,缩放适配300x400显示
|
||
console.log(`[Upload] Processing video preview image (cover)... (${previewImage.type}, ${(previewImage.size / 1024).toFixed(2)}KB)`)
|
||
try {
|
||
const originalBuffer = Buffer.from(await previewImage.arrayBuffer())
|
||
const processedBuffer = await processImage(originalBuffer, {
|
||
maxSizeKB: 300,
|
||
maxWidth: 600,
|
||
maxHeight: 800,
|
||
format: 'webp',
|
||
})
|
||
console.log('[Upload] Video preview image processed successfully')
|
||
|
||
// Upload processed preview image to S3
|
||
console.log('[Upload] Uploading video preview image to S3...')
|
||
const processedFileName = getProcessedFileName(previewImage.name)
|
||
const fileExt = path.extname(processedFileName)
|
||
const previewFileName = generateUniqueFileName('preview', fileExt)
|
||
previewUrl = await uploadToS3(processedBuffer, previewFileName, 'image/webp')
|
||
console.log('[Upload] Video preview image uploaded to S3:', previewUrl)
|
||
} catch (imageError) {
|
||
console.error('[Upload] Video preview image processing error:', imageError)
|
||
return NextResponse.json(
|
||
{ error: `视频封面处理失败:${imageError instanceof Error ? imageError.message : '未知错误'}` },
|
||
{ 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 }
|
||
)
|
||
}
|
||
}
|
||
|
||
// Handle Model display images (模特类型的多图处理)
|
||
if (materialType === '模特' && modelImages.length > 0) {
|
||
console.log(`[Upload] Processing ${modelImages.length} model images...`)
|
||
try {
|
||
// 处理每张模特图片
|
||
for (let i = 0; i < modelImages.length; i++) {
|
||
const modelImage = modelImages[i]
|
||
console.log(`[Upload] Processing model image ${i + 1}/${modelImages.length}...`)
|
||
|
||
// 读取图片
|
||
const imageBuffer = Buffer.from(await modelImage.arrayBuffer())
|
||
|
||
// 切割图片(按1500px高度)
|
||
const slicedBuffers = await sliceImage(imageBuffer, 1500)
|
||
console.log(`[Upload] Model image ${i + 1} sliced into ${slicedBuffers.length} parts`)
|
||
|
||
// 上传每个切片到S3
|
||
for (let j = 0; j < slicedBuffers.length; j++) {
|
||
const sliceFileName = generateUniqueFileName(`model-${i + 1}-${j + 1}`, '.webp')
|
||
const sliceUrl = await uploadToS3(slicedBuffers[j], sliceFileName, 'image/webp')
|
||
displayImages.push(sliceUrl)
|
||
console.log(`[Upload] Model image ${i + 1} slice ${j + 1}/${slicedBuffers.length} uploaded to S3:`, sliceUrl)
|
||
}
|
||
}
|
||
|
||
// 使用第一张图片作为预览图
|
||
if (displayImages.length > 0) {
|
||
previewUrl = displayImages[0]
|
||
console.log('[Upload] Using first model image as preview:', previewUrl)
|
||
}
|
||
} catch (modelImageError) {
|
||
console.error('[Upload] Model image processing error:', modelImageError)
|
||
return NextResponse.json(
|
||
{ error: `模特图片处理失败:${modelImageError instanceof Error ? modelImageError.message : '未知错误'}` },
|
||
{ status: 500 }
|
||
)
|
||
}
|
||
}
|
||
|
||
// Save zip/PSD/视频 file locally (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] Saving file locally...')
|
||
const uploadDir = process.env.UPLOAD_DIR || './uploads'
|
||
await mkdir(uploadDir, { recursive: true })
|
||
|
||
const zipFileExt = path.extname(zipFile.name)
|
||
zipFileName = generateUniqueFileName('material', zipFileExt)
|
||
zipFilePath = path.join(uploadDir, zipFileName)
|
||
|
||
// 读取并保存素材文件(包括视频文件)
|
||
const zipBuffer = Buffer.from(await zipFile.arrayBuffer())
|
||
await writeFile(zipFilePath, zipBuffer)
|
||
zipFileSize = BigInt(zipBuffer.length) // 记录文件大小(字节)
|
||
console.log('[Upload] File saved:', zipFilePath, `(${(Number(zipFileSize) / 1024 / 1024).toFixed(2)} MB)`)
|
||
}
|
||
|
||
// Create material record
|
||
console.log('[Upload] Creating database record...')
|
||
const material = await prisma.material.create({
|
||
data: {
|
||
previewUrl,
|
||
productModel,
|
||
brandName,
|
||
seriesName,
|
||
skuCode,
|
||
materialTexture,
|
||
categoryName: materialType === '材质' ? '通用' : categoryName, // 材质类型使用默认分类
|
||
sceneName: materialType === '材质' ? '通用' : sceneName, // 材质类型使用默认场景
|
||
platformName: platformName || '', // 平台分类(可选)
|
||
materialType,
|
||
renderer,
|
||
renderTime,
|
||
imageResolution,
|
||
colorMode,
|
||
authorName,
|
||
zipFileName,
|
||
zipFilePath,
|
||
zipFileSize,
|
||
displayImages: JSON.stringify(displayImages), // PSD/模特展示图片URLs数组
|
||
userId: (session.user as any).id,
|
||
},
|
||
include: {
|
||
user: {
|
||
select: {
|
||
username: true,
|
||
},
|
||
},
|
||
},
|
||
})
|
||
console.log('[Upload] Database record created:', material.id)
|
||
|
||
// 转换BigInt为字符串以支持JSON序列化
|
||
const materialData = {
|
||
...material,
|
||
zipFileSize: material.zipFileSize.toString()
|
||
}
|
||
|
||
console.log('[Upload] Upload completed successfully!')
|
||
return NextResponse.json(
|
||
{ message: '上传成功', material: materialData },
|
||
{ status: 201 }
|
||
)
|
||
} catch (error) {
|
||
console.error('Upload error:', error)
|
||
|
||
// Log detailed error information
|
||
if (error instanceof Error) {
|
||
console.error('Error name:', error.name)
|
||
console.error('Error message:', error.message)
|
||
console.error('Error stack:', error.stack)
|
||
}
|
||
|
||
// Return more specific error message for debugging
|
||
const errorMessage = error instanceof Error ? error.message : '上传失败,请稍后再试'
|
||
|
||
return NextResponse.json(
|
||
{
|
||
error: '上传失败,请稍后再试',
|
||
details: process.env.NODE_ENV === 'development' ? errorMessage : undefined
|
||
},
|
||
{ status: 500 }
|
||
)
|
||
}
|
||
}
|
||
|