380 lines
13 KiB
TypeScript
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 })
|
|
}
|
|
}
|