WuYongJie f646b71813
Some checks failed
CI Check / check (push) Has been cancelled
chore: 初始化本地仓库并同步最近改动
2025-11-18 17:50:16 +08:00

440 lines
18 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 { 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 }
)
}
}