2026-05-07 16:37:22 +08:00

380 lines
13 KiB
TypeScript

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<Record<StandardModelWhiteBgKey, File | null>>)
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<string>(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<Record<StandardModelWhiteBgKey, { assetType: string }>> = {
...(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 })
}
}