2026-06-03 18:41:52 +08:00

350 lines
11 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, 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 }
)
}
}