import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { promises as fs } from 'fs' import path from 'path' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { sanitizeFileName } from '@/lib/image-processor' import { deleteFileIfUnreferenced } from '@/lib/file-cleanup' import { buildPublicDisplayUrl, buildWhiteBackgroundPublicDisplayPath, buildWhiteBackgroundPublicDisplayPathFromAssetPath, uploadPublicDisplayToS3, } from '@/lib/public-display' import { deleteFromS3 } from '@/lib/s3' import { buildWhiteBackgroundFixedFileName, countWhiteBackgroundFixedViews, getAllWhiteBackgroundAssetTypesForSku, getNextWhiteBackgroundOtherIndex, getWhiteBackgroundAssetWhereForSku, getWhiteBackgroundOtherAssetType, getWhiteBackgroundPreviewRelativePathFromAssetPath, getWhiteBackgroundRelativeDirectory, getWhiteBackgroundRelativeFilePath, getWhiteBackgroundViewAssetType, getWhiteBackgroundZipFileName, groupWhiteBackgroundAssetsBySku, hasCompleteWhiteBackgroundAssetGroup, normalizeWhiteBackgroundSkuName, STANDARD_MODEL_WHITE_BG_OTHER_FIELD_NAME, STANDARD_MODEL_WHITE_BG_VIEWS, type StandardModelWhiteBgKey, } from '@/lib/standard-model-white-background' import { normalizeStandardModelWhiteBackgroundSourceType } from '@/lib/standard-model-white-background-metadata' import { createMaterialLog } from '@/lib/material-log' import { buildWhiteBackgroundWebpVariants } from '@/lib/white-background-preview' export const runtime = 'nodejs' function resolveSafeOtherBaseName(file: File, index: number): string { const ext = path.extname(file.name) const originalBaseName = path.basename(file.name, ext) return ( sanitizeFileName(originalBaseName || `other-${index}`) .replace(/\s+/g, '-') .toLowerCase() || `other-${index}` ) } export async function POST( request: NextRequest, { params }: { params: { id: string } }, ) { const writtenFilePaths: string[] = [] const uploadedPreviewUrls: string[] = [] let assetsPersisted = false try { const session = await getServerSession(authOptions) if (!session?.user || !(session.user as any).id) { return NextResponse.json({ error: '请先登录' }, { status: 401 }) } const materialId = params.id const userId = (session.user as any).id const material = await prisma.material.findUnique({ where: { id: materialId }, select: { id: true, materialType: true, userId: true, productCategoryName: true, categoryName: true, }, }) if (!material) { return NextResponse.json({ error: '素材不存在' }, { status: 404 }) } if (material.materialType !== '标准模型') { return NextResponse.json({ error: '只有标准模型类型支持上传白底图' }, { status: 400 }) } const formData = await request.formData() const skuId = ((formData.get('skuId') as string) || '').trim() const skuName = ((formData.get('skuName') as string) || '').trim() const sourceType = normalizeStandardModelWhiteBackgroundSourceType( (formData.get('sourceType') as string) || '', ) if (!skuId) { return NextResponse.json({ error: '缺少SKU ID' }, { status: 400 }) } if (!sourceType) { return NextResponse.json({ error: '白底图出图方式必填,且只能是 实拍、渲染、AI' }, { status: 400 }) } const sku = await prisma.materialSku.findFirst({ where: { id: skuId, materialId, deletedAt: null, }, select: { id: true, skuName: true }, }) if (!sku) { return NextResponse.json({ error: 'SKU不存在或已被删除' }, { status: 404 }) } const whiteBackgroundImages = STANDARD_MODEL_WHITE_BG_VIEWS.reduce((acc, view) => { acc[view.key] = formData.get(view.fieldName) as File | null return acc }, {} as Partial>) const otherImages = formData .getAll(STANDARD_MODEL_WHITE_BG_OTHER_FIELD_NAME) .filter((file): file is File => { return ( !!file && typeof (file as File).name === 'string' && typeof (file as File).arrayBuffer === 'function' && Number((file as File).size || 0) > 0 ) }) const uploadedFixedViews = STANDARD_MODEL_WHITE_BG_VIEWS.filter((view) => !!whiteBackgroundImages[view.key]) if (uploadedFixedViews.length === 0 && otherImages.length === 0) { return NextResponse.json( { error: '请至少上传 1 张白底图' }, { status: 400 }, ) } const existingAssets = await prisma.materialAsset.findMany({ where: { materialId, ...getWhiteBackgroundAssetWhereForSku(skuId), }, select: { id: true, fileUrl: true, assetType: true, sortOrder: true, }, }) const existingWhiteBackgroundGroup = groupWhiteBackgroundAssetsBySku(existingAssets).get(skuId) const nextOtherIndex = getNextWhiteBackgroundOtherIndex(existingWhiteBackgroundGroup) const assetTypesToReplace = new Set(getAllWhiteBackgroundAssetTypesForSku(skuId)) const normalizedSkuName = normalizeWhiteBackgroundSkuName(skuName || sku.skuName) const whiteBackgroundCategoryName = String(material.productCategoryName || '').trim() || String(material.categoryName || '').trim() const uploadDir = path.join( process.cwd(), ...getWhiteBackgroundRelativeDirectory(materialId, skuId).split('/'), ) await fs.mkdir(path.join(uploadDir, 'other'), { recursive: true }) const assetsToCreate: Array<{ materialId: string assetType: string fileUrl: string fileName: string fileSize: bigint sortOrder: number }> = [] for (let index = 0; index < STANDARD_MODEL_WHITE_BG_VIEWS.length; index++) { const view = STANDARD_MODEL_WHITE_BG_VIEWS[index] const imageFile = whiteBackgroundImages[view.key] if (!imageFile) { continue } const displayFileName = buildWhiteBackgroundFixedFileName( normalizedSkuName, whiteBackgroundCategoryName, view.label, '.webp', ) const diskFileName = displayFileName const relativePath = getWhiteBackgroundRelativeFilePath(materialId, skuId, diskFileName) const absolutePath = path.join(process.cwd(), ...relativePath.split('/')) const sourceBuffer = Buffer.from(await imageFile.arrayBuffer()) const { original, preview } = await buildWhiteBackgroundWebpVariants(sourceBuffer) assetTypesToReplace.add(getWhiteBackgroundViewAssetType(skuId, view.key)) await fs.mkdir(path.dirname(absolutePath), { recursive: true }) await fs.writeFile(absolutePath, original) writtenFilePaths.push(relativePath) const previewUrl = await uploadPublicDisplayToS3( preview, buildWhiteBackgroundPublicDisplayPath(materialId, skuId, diskFileName), 'image/webp', ) uploadedPreviewUrls.push(previewUrl) assetsToCreate.push({ materialId, assetType: getWhiteBackgroundViewAssetType(skuId, view.key), fileUrl: relativePath, fileName: displayFileName, fileSize: BigInt(original.length), sortOrder: index, }) } for (let index = 0; index < otherImages.length; index++) { const imageFile = otherImages[index] const otherIndex = nextOtherIndex + index const safeBaseName = resolveSafeOtherBaseName(imageFile, otherIndex) const displayFileName = `${String(otherIndex).padStart(2, '0')}-${safeBaseName}.webp` const diskFileName = `other/${displayFileName}` const relativePath = getWhiteBackgroundRelativeFilePath(materialId, skuId, diskFileName) const absolutePath = path.join(process.cwd(), ...relativePath.split('/')) const sourceBuffer = Buffer.from(await imageFile.arrayBuffer()) const { original, preview } = await buildWhiteBackgroundWebpVariants(sourceBuffer) await fs.mkdir(path.dirname(absolutePath), { recursive: true }) await fs.writeFile(absolutePath, original) writtenFilePaths.push(relativePath) const previewUrl = await uploadPublicDisplayToS3( preview, buildWhiteBackgroundPublicDisplayPath(materialId, skuId, diskFileName), 'image/webp', ) uploadedPreviewUrls.push(previewUrl) assetsToCreate.push({ materialId, assetType: getWhiteBackgroundOtherAssetType(skuId, otherIndex), fileUrl: relativePath, fileName: displayFileName, fileSize: BigInt(original.length), sortOrder: 1000 + otherIndex - 1, }) } const assetTypeListToReplace = Array.from(assetTypesToReplace) const replacedAssets = existingAssets.filter((asset) => assetTypeListToReplace.includes(asset.assetType)) const nextFixed: Partial> = { ...(existingWhiteBackgroundGroup?.fixed || {}), } uploadedFixedViews.forEach((view) => { nextFixed[view.key] = { assetType: getWhiteBackgroundViewAssetType(skuId, view.key), } }) const nextFixedCount = countWhiteBackgroundFixedViews({ fixed: nextFixed }) const nextOtherCount = (existingWhiteBackgroundGroup?.other.length || 0) + otherImages.length const whiteBackgroundCount = nextFixedCount + nextOtherCount const isComplete = hasCompleteWhiteBackgroundAssetGroup({ fixed: nextFixed }) && nextFixedCount === STANDARD_MODEL_WHITE_BG_VIEWS.length const uploadedAt = new Date() await prisma.$transaction(async (tx) => { if (assetTypeListToReplace.length > 0) { await tx.materialAsset.deleteMany({ where: { materialId, assetType: { in: assetTypeListToReplace, }, }, }) } if (assetsToCreate.length > 0) { await tx.materialAsset.createMany({ data: assetsToCreate, }) } await tx.materialSku.update({ where: { id: skuId }, data: { whiteBackgroundSourceType: sourceType, whiteBackgroundUploadedBy: userId, whiteBackgroundUploadedAt: uploadedAt, }, }) }) assetsPersisted = true for (const asset of replacedAssets) { if (!asset.fileUrl) { continue } await deleteFileIfUnreferenced(asset.fileUrl) const previewRelativePath = getWhiteBackgroundPreviewRelativePathFromAssetPath(asset.fileUrl) if (previewRelativePath) { await deleteFileIfUnreferenced(previewRelativePath) } const publicDisplayPath = buildWhiteBackgroundPublicDisplayPathFromAssetPath(asset.fileUrl) const publicDisplayUrl = publicDisplayPath ? buildPublicDisplayUrl(publicDisplayPath) : null if (publicDisplayUrl) { await deleteFileIfUnreferenced(publicDisplayUrl) } } try { await createMaterialLog({ materialId, userId, action: 'UPDATE', changes: { whiteBackgroundStorageMode: { oldValue: existingAssets.length > 0 ? 'replace' : null, newValue: 'per-image-assets', }, whiteBackgroundSummary: { oldValue: null, newValue: { uploadedFixedCount: uploadedFixedViews.length, fixedCount: nextFixedCount, otherCount: nextOtherCount, skuId, skuName: normalizedSkuName, sourceType, whiteBackgroundUploadedBy: userId, whiteBackgroundUploadedAt: uploadedAt.toISOString(), zipFileName: getWhiteBackgroundZipFileName(normalizedSkuName), }, }, }, }) } catch (logError) { console.error('记录白底图上传日志失败:', logError) } return NextResponse.json({ success: true, whiteBackgroundUploaded: whiteBackgroundCount > 0, uploadedFixedCount: uploadedFixedViews.length, fixedCount: nextFixedCount, otherCount: nextOtherCount, whiteBackgroundCount, isComplete, sourceType, whiteBackgroundUploadedBy: userId, whiteBackgroundUploadedAt: uploadedAt.toISOString(), downloadFileName: getWhiteBackgroundZipFileName(normalizedSkuName), }) } catch (error) { if (!assetsPersisted) { for (const relativePath of writtenFilePaths) { const absolutePath = path.join(process.cwd(), ...relativePath.split('/')) try { await fs.unlink(absolutePath) } catch { // ignore cleanup failure } } for (const previewUrl of uploadedPreviewUrls) { try { await deleteFromS3(previewUrl) } catch { // ignore cleanup failure } } } console.error('上传白底图失败:', error) return NextResponse.json({ error: '上传白底图失败' }, { status: 500 }) } }