350 lines
11 KiB
TypeScript
350 lines
11 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, deleteFromS3 } from '@/lib/s3'
|
||
import { processImage, getProcessedFileName } from '@/lib/image-processor'
|
||
import { isBuiltinTemplate } from '@/lib/template-validator'
|
||
import { validateSubmittedTemplateFields } from '@/lib/template-field-validation'
|
||
import {
|
||
prepareMaterialData,
|
||
compressMetadata,
|
||
readAllFieldValues,
|
||
checkMetadataSize,
|
||
} from '@/lib/field-data-storage'
|
||
import { prepareMaterialMetadataWithPaidCore } from '@/lib/license'
|
||
import { canManageMaterial, getMaterialPermissionActor } from '@/lib/material-permissions'
|
||
|
||
export async function PUT(req: NextRequest) {
|
||
try {
|
||
const session = await getServerSession(authOptions)
|
||
|
||
if (!session?.user) {
|
||
return NextResponse.json(
|
||
{ error: '请先登录' },
|
||
{ status: 401 }
|
||
)
|
||
}
|
||
|
||
const formData = await req.formData()
|
||
const normalizeInput = (value: FormDataEntryValue | null) => {
|
||
if (!value) return null
|
||
const trimmed = value.toString().trim()
|
||
return trimmed ? trimmed : null
|
||
}
|
||
|
||
const id = formData.get('id') as string
|
||
const productModel = formData.get('productModel') as string
|
||
const categoryName = formData.get('categoryName') as string
|
||
const sceneName = formData.get('sceneName') as string
|
||
const materialType = (formData.get('materialType') as string) || '3DMAX'
|
||
const renderer = (formData.get('renderer') as string) || 'CR'
|
||
const authorName = formData.get('authorName') as string
|
||
const previewImage = formData.get('previewImage') as File | null
|
||
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'))
|
||
// 音频相关字段
|
||
const audioDuration = normalizeInput(formData.get('audioDuration'))
|
||
const audioCategory = normalizeInput(formData.get('audioCategory'))
|
||
const audioScene = normalizeInput(formData.get('audioScene'))
|
||
const templateId = normalizeInput(formData.get('templateId'))
|
||
const templateFieldsStr = formData.get('templateFields') as string | null
|
||
let templateFields: Record<string, any> = {}
|
||
|
||
if (templateFieldsStr) {
|
||
try {
|
||
templateFields = JSON.parse(templateFieldsStr)
|
||
} catch (error) {
|
||
console.error('[MaterialsUpdate] Failed to parse templateFields:', error)
|
||
return NextResponse.json(
|
||
{ error: '模板字段格式无效' },
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
}
|
||
|
||
// Validation - 音频素材不需要 categoryName 和 sceneName
|
||
const isAudioMaterial = materialType === '音频'
|
||
if (!id || !productModel || !materialType || !authorName) {
|
||
return NextResponse.json(
|
||
{ error: '所有字段都是必填的' },
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
// 非音频素材需要 categoryName 和 sceneName
|
||
if (!isAudioMaterial && (!categoryName || !sceneName)) {
|
||
return NextResponse.json(
|
||
{ error: '店铺和场景是必填的' },
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
|
||
// Get existing material
|
||
const existingMaterial = await prisma.material.findFirst({
|
||
where: {
|
||
id,
|
||
deletedAt: null,
|
||
},
|
||
include: {
|
||
user: {
|
||
select: {
|
||
id: true,
|
||
department: true,
|
||
},
|
||
},
|
||
},
|
||
})
|
||
|
||
if (!existingMaterial) {
|
||
return NextResponse.json(
|
||
{ error: '素材不存在' },
|
||
{ status: 404 }
|
||
)
|
||
}
|
||
|
||
const actor = await getMaterialPermissionActor(session)
|
||
if (!(await canManageMaterial(existingMaterial, actor))) {
|
||
return NextResponse.json(
|
||
{ error: '您没有权限编辑此素材' },
|
||
{ status: 403 }
|
||
)
|
||
}
|
||
const userId = actor!.id
|
||
|
||
let previewUrl = existingMaterial.previewUrl
|
||
let uploadedPreviewUrl: string | null = null
|
||
const oldPreviewUrl = existingMaterial.previewUrl
|
||
|
||
// If new preview image is uploaded
|
||
if (previewImage && previewImage.size > 0) {
|
||
// 处理预览图:压缩到300KB以内,转换为webP,缩放到600px
|
||
const originalBuffer = Buffer.from(await previewImage.arrayBuffer())
|
||
const processedBuffer = await processImage(originalBuffer, {
|
||
maxSizeKB: 300,
|
||
maxWidth: 600,
|
||
format: 'webp',
|
||
})
|
||
|
||
// Upload processed preview image to S3
|
||
const processedFileName = getProcessedFileName(previewImage.name)
|
||
const previewFileName = `preview-${Date.now()}-${processedFileName}`
|
||
uploadedPreviewUrl = await uploadToS3(
|
||
processedBuffer,
|
||
previewFileName,
|
||
'image/webp'
|
||
)
|
||
previewUrl = uploadedPreviewUrl
|
||
}
|
||
|
||
// 构建更新数据,根据素材类型决定哪些字段需要更新
|
||
const updateData: any = {
|
||
previewUrl,
|
||
productModel,
|
||
brandName,
|
||
seriesName,
|
||
skuCode,
|
||
materialTexture,
|
||
materialType,
|
||
authorName,
|
||
}
|
||
|
||
const effectiveTemplateId = templateId ?? existingMaterial.templateId ?? null
|
||
const currentMaterialFieldValues = {
|
||
...readAllFieldValues(existingMaterial as Record<string, unknown>),
|
||
productModel,
|
||
brandName,
|
||
seriesName,
|
||
skuCode,
|
||
materialTexture,
|
||
categoryName,
|
||
sceneName,
|
||
materialType,
|
||
renderer,
|
||
renderTime,
|
||
imageResolution,
|
||
colorMode,
|
||
authorName,
|
||
audioDuration,
|
||
audioCategory,
|
||
audioScene,
|
||
}
|
||
const mergedTemplateFields = {
|
||
...currentMaterialFieldValues,
|
||
...templateFields,
|
||
}
|
||
|
||
const templateFieldsValidation = await validateSubmittedTemplateFields({
|
||
templateId: effectiveTemplateId,
|
||
templateFields: mergedTemplateFields,
|
||
allowUnknownKeys: true,
|
||
})
|
||
|
||
if (!templateFieldsValidation.valid) {
|
||
return NextResponse.json(
|
||
{
|
||
error: templateFieldsValidation.error,
|
||
errorCode: templateFieldsValidation.errorCode,
|
||
details: templateFieldsValidation.details,
|
||
},
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
|
||
templateFields = templateFieldsValidation.templateFields
|
||
|
||
let metadata: string | null = null
|
||
if (effectiveTemplateId && !isBuiltinTemplate(effectiveTemplateId)) {
|
||
const metadataResult = await prepareMaterialMetadataWithPaidCore({
|
||
templateId: effectiveTemplateId,
|
||
templateFields,
|
||
existingMetadata: existingMaterial.metadata,
|
||
})
|
||
if (!metadataResult.valid) {
|
||
return NextResponse.json(
|
||
{
|
||
error: metadataResult.error,
|
||
errorCode: metadataResult.errorCode,
|
||
details: metadataResult.details,
|
||
},
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
metadata = metadataResult.metadata
|
||
} else {
|
||
const { metadata: rawMetadata } = prepareMaterialData(
|
||
templateFields,
|
||
effectiveTemplateId,
|
||
undefined,
|
||
existingMaterial.metadata
|
||
)
|
||
metadata = compressMetadata(rawMetadata)
|
||
const metadataSize = checkMetadataSize(metadata)
|
||
if (!metadataSize.ok) {
|
||
return NextResponse.json(
|
||
{
|
||
error: `自定义字段数据过大,当前约 ${metadataSize.size} 字节,最大允许 ${metadataSize.maxSize} 字节。请减少字段内容后重试。`,
|
||
errorCode: 'METADATA_TOO_LARGE',
|
||
},
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
}
|
||
|
||
updateData.templateId = effectiveTemplateId
|
||
updateData.metadata = metadata
|
||
|
||
// 非音频素材更新这些字段
|
||
if (!isAudioMaterial) {
|
||
updateData.categoryName = categoryName
|
||
updateData.sceneName = sceneName
|
||
updateData.renderer = renderer
|
||
updateData.renderTime = renderTime
|
||
updateData.imageResolution = imageResolution
|
||
updateData.colorMode = colorMode
|
||
} else {
|
||
// 音频素材更新音频相关字段
|
||
updateData.audioDuration = audioDuration
|
||
updateData.audioCategory = audioCategory
|
||
updateData.audioScene = audioScene
|
||
}
|
||
|
||
// Update material record
|
||
let material
|
||
try {
|
||
material = await prisma.material.update({
|
||
where: { id },
|
||
data: updateData,
|
||
include: {
|
||
user: {
|
||
select: {
|
||
username: true,
|
||
},
|
||
},
|
||
},
|
||
})
|
||
} catch (updateError) {
|
||
if (uploadedPreviewUrl) {
|
||
try {
|
||
await deleteFromS3(uploadedPreviewUrl)
|
||
} catch (cleanupError) {
|
||
console.error('Error deleting newly uploaded preview after update failure:', cleanupError)
|
||
}
|
||
}
|
||
throw updateError
|
||
}
|
||
|
||
if (uploadedPreviewUrl && oldPreviewUrl && oldPreviewUrl !== uploadedPreviewUrl) {
|
||
try {
|
||
await deleteFromS3(oldPreviewUrl)
|
||
} catch (error) {
|
||
console.error('Error deleting old preview after update:', error)
|
||
}
|
||
}
|
||
|
||
// 记录变更日志
|
||
const changedFields: Record<string, { oldValue: any; newValue: any }> = {}
|
||
const collectChange = (key: string, newValue: any) => {
|
||
const oldValue = (existingMaterial as any)[key]
|
||
const normOld = oldValue ?? null
|
||
const normNew = newValue ?? null
|
||
if (normOld === null && normNew === null) return
|
||
if (String(normOld) !== String(normNew)) {
|
||
changedFields[key] = { oldValue: normOld, newValue: normNew }
|
||
}
|
||
}
|
||
|
||
collectChange('previewUrl', previewUrl)
|
||
collectChange('productModel', productModel)
|
||
collectChange('brandName', brandName)
|
||
collectChange('seriesName', seriesName)
|
||
collectChange('skuCode', skuCode)
|
||
collectChange('materialTexture', materialTexture)
|
||
collectChange('categoryName', categoryName)
|
||
collectChange('sceneName', sceneName)
|
||
collectChange('materialType', materialType)
|
||
collectChange('templateId', effectiveTemplateId)
|
||
collectChange('renderer', renderer)
|
||
collectChange('renderTime', renderTime)
|
||
collectChange('imageResolution', imageResolution)
|
||
collectChange('colorMode', colorMode)
|
||
collectChange('authorName', authorName)
|
||
// 音频相关字段
|
||
collectChange('audioDuration', audioDuration)
|
||
collectChange('audioCategory', audioCategory)
|
||
collectChange('audioScene', audioScene)
|
||
|
||
if (Object.keys(changedFields).length > 0) {
|
||
await createMaterialLog({
|
||
materialId: id,
|
||
userId,
|
||
action: 'UPDATE',
|
||
changes: changedFields,
|
||
})
|
||
}
|
||
|
||
// 转换BigInt为字符串
|
||
const materialData = {
|
||
...material,
|
||
zipFileSize: material.zipFileSize.toString()
|
||
}
|
||
|
||
return NextResponse.json(
|
||
{ message: '更新成功', material: materialData },
|
||
{ status: 200 }
|
||
)
|
||
} catch (error) {
|
||
console.error('Update error:', error)
|
||
return NextResponse.json(
|
||
{ error: '更新失败,请稍后再试' },
|
||
{ status: 500 }
|
||
)
|
||
}
|
||
}
|
||
|