import { createHash, randomUUID } from 'crypto' import { promises as fs } from 'fs' import path from 'path' import { prisma } from '@/lib/prisma' import { sanitizeFileName } from '@/lib/image-processor' import { deleteFileIfUnreferenced } from '@/lib/file-cleanup' import { createMaterialLog } from '@/lib/material-log' import { acquireTaskMaterialCompletionLock } from '@/lib/material-upload-cross-process-lock' import { resolveStoredUploadPath } from '@/lib/upload-paths' import { buildPublicDisplayUrl, buildWhiteBackgroundPublicDisplayPath, buildWhiteBackgroundPublicDisplayPathFromAssetPath, uploadPublicDisplayToS3, } from '@/lib/public-display' import { deleteFromS3 } from '@/lib/s3' import { buildWhiteBackgroundFixedFileName, countWhiteBackgroundFixedViews, getAllWhiteBackgroundAssetTypesForSku, getCurrentWhiteBackgroundAssetType, LEGACY_STANDARD_MODEL_WHITE_BG_ASSET_TYPE_PREFIX, getNextWhiteBackgroundOtherIndex, getWhiteBackgroundAssetWhereForSku, getWhiteBackgroundOtherAssetPrefixForSku, getWhiteBackgroundOtherAssetType, getWhiteBackgroundPreviewRelativePathFromAssetPath, getWhiteBackgroundRelativeFilePath, getWhiteBackgroundViewAssetPrefixForSku, getWhiteBackgroundViewAssetType, getWhiteBackgroundZipFileName, groupWhiteBackgroundAssetsBySku, hasCompleteWhiteBackgroundAssetGroup, normalizeWhiteBackgroundSkuName, STANDARD_MODEL_WHITE_BG_VIEWS, type StandardModelWhiteBgKey, } from '@/lib/standard-model-white-background' import type { StandardModelWhiteBackgroundSourceType } from '@/lib/standard-model-white-background-metadata' import { buildWhiteBackgroundWebpVariants } from '@/lib/white-background-preview' import { assertWhiteBackgroundUploadFiles, } from '@/lib/standard-model-white-background-upload-guard' type WhiteBackgroundLockAssertion = () => void const WHITE_BACKGROUND_LOCK_PREFIX = 'white-background' function buildWhiteBackgroundLockIdentity(materialId: string, skuId: string): string { const digest = createHash('sha256') .update(JSON.stringify([String(materialId || ''), String(skuId || '')])) .digest('hex') return `${WHITE_BACKGROUND_LOCK_PREFIX}-${digest}` } function buildAttemptStorageFileName(displayFileName: string, attemptId: string): string { const extension = path.extname(displayFileName) const baseName = path.basename(displayFileName, extension) return `${baseName}--${attemptId}${extension || '.webp'}` } async function syncDirectory(directoryPath: string): Promise { if (process.platform === 'win32') { return } const directoryHandle = await fs.open(directoryPath, 'r') try { await directoryHandle.sync() } finally { await directoryHandle.close() } } async function writeStoredFileAtomically( relativePath: string, contents: Buffer, assertLockHeld: WhiteBackgroundLockAssertion, ): Promise { const absolutePath = resolveStoredUploadPath(relativePath) const directoryPath = path.dirname(absolutePath) const partialPath = `${absolutePath}.partial-${randomUUID()}` let fileHandle: Awaited> | null = null let renamed = false await fs.mkdir(directoryPath, { recursive: true }) try { assertLockHeld() fileHandle = await fs.open(partialPath, 'wx', 0o640) await fileHandle.writeFile(contents) await fileHandle.sync() await fileHandle.close() fileHandle = null assertLockHeld() await fs.rename(partialPath, absolutePath) renamed = true await syncDirectory(directoryPath) } catch (error) { if (fileHandle) { await fileHandle.close().catch(() => undefined) } await fs.unlink(partialPath).catch(() => undefined) if (renamed) { await fs.unlink(absolutePath).catch(() => undefined) } throw error } } export type WhiteBackgroundUploadInput = { materialId: string skuId: string skuName: string sourceType: StandardModelWhiteBackgroundSourceType operatorUserId: string fixedImages: Partial> otherImages?: File[] } export type WhiteBackgroundUploadResult = { whiteBackgroundUploaded: boolean uploadedFixedCount: number fixedCount: number otherCount: number whiteBackgroundCount: number isComplete: boolean sourceType: StandardModelWhiteBackgroundSourceType whiteBackgroundUploadedBy: string whiteBackgroundUploadedAt: string downloadFileName: string } export type WhiteBackgroundCleanupResult = { deletedAssetCount: number deletedFileCount: number failedFileCount: number clearedMetadata: boolean } 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}` ) } function isUsableFile(file: File | null | undefined): file is File { return ( !!file && typeof file.name === 'string' && typeof file.arrayBuffer === 'function' && Number(file.size || 0) > 0 ) } function resolvePreviewUrlFromWhiteBackgroundAsset(asset: { fileUrl?: string | null } | null | undefined): string | null { const fileUrl = String(asset?.fileUrl || '').trim() if (!fileUrl) { return null } const publicDisplayPath = buildWhiteBackgroundPublicDisplayPathFromAssetPath(fileUrl) return publicDisplayPath ? buildPublicDisplayUrl(publicDisplayPath) : fileUrl } function getUploadedFixedViews(fixedImages: Partial>) { return STANDARD_MODEL_WHITE_BG_VIEWS.filter((view) => isUsableFile(fixedImages[view.key])) } async function deleteReplacedAssetFiles( assets: Array<{ id?: string; fileUrl?: string | null }>, assertLockHeld: WhiteBackgroundLockAssertion, ) { let deletedFileCount = 0 let failedFileCount = 0 for (const asset of assets) { if (!asset.fileUrl) { continue } const targets = [ asset.fileUrl, getWhiteBackgroundPreviewRelativePathFromAssetPath(asset.fileUrl), (() => { const publicDisplayPath = buildWhiteBackgroundPublicDisplayPathFromAssetPath(asset.fileUrl) return publicDisplayPath ? buildPublicDisplayUrl(publicDisplayPath) : null })(), ].filter((value): value is string => Boolean(value)) for (const target of targets) { assertLockHeld() const result = await deleteFileIfUnreferenced(target, asset.id ? { excludeAssetIds: [asset.id] } : undefined) if (result.success) { if (!result.skipped) { deletedFileCount += 1 } } else { failedFileCount += 1 } } } return { deletedFileCount, failedFileCount } } function getScopedWhiteBackgroundAssetWhereForSku(skuId: string) { return { OR: [ { assetType: getCurrentWhiteBackgroundAssetType(skuId) }, { assetType: `${LEGACY_STANDARD_MODEL_WHITE_BG_ASSET_TYPE_PREFIX}${skuId}` }, { assetType: { startsWith: getWhiteBackgroundViewAssetPrefixForSku(skuId) } }, { assetType: { startsWith: getWhiteBackgroundOtherAssetPrefixForSku(skuId) } }, ], } } type AttemptPersistenceOutcome = 'committed' | 'rolled-back' | 'unknown' async function resolveAttemptPersistenceOutcome( materialId: string, assetsToCreate: Array<{ assetType: string; fileUrl: string }>, ): Promise { if (assetsToCreate.length === 0) { return 'rolled-back' } try { const persistedAssets = await prisma.materialAsset.findMany({ where: { materialId, fileUrl: { in: assetsToCreate.map((asset) => asset.fileUrl) }, }, select: { fileUrl: true, assetType: true, }, }) if (persistedAssets.length === 0) { return 'rolled-back' } const persistedKeys = new Set( persistedAssets.map((asset) => `${asset.assetType}\u0000${asset.fileUrl}`), ) const allPersisted = assetsToCreate.every((asset) => persistedKeys.has(`${asset.assetType}\u0000${asset.fileUrl}`), ) return allPersisted ? 'committed' : 'unknown' } catch (error) { console.error('确认白底图事务提交状态失败,保留本次唯一产物以避免误删:', error) return 'unknown' } } export async function uploadStandardModelWhiteBackground( input: WhiteBackgroundUploadInput, ): Promise { const releaseLock = await acquireTaskMaterialCompletionLock( buildWhiteBackgroundLockIdentity(input.materialId, input.skuId), ) try { releaseLock.assertHeld() return await uploadStandardModelWhiteBackgroundLocked(input, releaseLock.assertHeld) } finally { await releaseLock() } } async function uploadStandardModelWhiteBackgroundLocked( input: WhiteBackgroundUploadInput, assertLockHeld: WhiteBackgroundLockAssertion, ): Promise { const attemptId = randomUUID() const writtenFilePaths: string[] = [] const uploadedPreviewUrls: string[] = [] let assetsPersisted = false let preserveAttemptOutputs = false try { assertLockHeld() const material = await prisma.material.findUnique({ where: { id: input.materialId }, select: { id: true, materialType: true, productCategoryName: true, categoryName: true, }, }) if (!material) { throw new Error('素材不存在') } if (material.materialType !== '标准模型') { throw new Error('只有标准模型类型支持上传白底图') } const sku = await prisma.materialSku.findFirst({ where: { id: input.skuId, materialId: input.materialId, deletedAt: null, }, select: { id: true, skuName: true }, }) if (!sku) { throw new Error('SKU不存在或已被删除') } const otherImages = (input.otherImages || []).filter(isUsableFile) const uploadedFixedViews = getUploadedFixedViews(input.fixedImages) if (uploadedFixedViews.length === 0 && otherImages.length === 0) { throw new Error('请至少上传 1 张白底图') } // 服务层再次执行门禁,避免外部 API 或未来新增调用方绕过路由校验, // 把超大、高像素或动图直接交给 Sharp。 await assertWhiteBackgroundUploadFiles({ fixedImages: input.fixedImages, otherImages, }) assertLockHeld() const existingAssets = await prisma.materialAsset.findMany({ where: { materialId: input.materialId, ...getWhiteBackgroundAssetWhereForSku(input.skuId), }, select: { id: true, fileUrl: true, assetType: true, sortOrder: true, }, }) const existingWhiteBackgroundGroup = groupWhiteBackgroundAssetsBySku(existingAssets).get(input.skuId) const nextOtherIndex = getNextWhiteBackgroundOtherIndex(existingWhiteBackgroundGroup) const assetTypesToReplace = new Set(getAllWhiteBackgroundAssetTypesForSku(input.skuId)) const normalizedSkuName = normalizeWhiteBackgroundSkuName(input.skuName || sku.skuName) const whiteBackgroundCategoryName = String(material.productCategoryName || '').trim() || String(material.categoryName || '').trim() const assetsToCreate: Array<{ materialId: string assetType: string fileUrl: string fileName: string fileSize: bigint sortOrder: number }> = [] let rightFront45PreviewUrl: string | null = null for (let index = 0; index < STANDARD_MODEL_WHITE_BG_VIEWS.length; index++) { const view = STANDARD_MODEL_WHITE_BG_VIEWS[index] const imageFile = input.fixedImages[view.key] if (!isUsableFile(imageFile)) { continue } const displayFileName = buildWhiteBackgroundFixedFileName( normalizedSkuName, whiteBackgroundCategoryName, view.label, '.webp', ) const diskFileName = buildAttemptStorageFileName(displayFileName, attemptId) const relativePath = getWhiteBackgroundRelativeFilePath(input.materialId, input.skuId, diskFileName) const sourceBuffer = Buffer.from(await imageFile.arrayBuffer()) const { original, preview } = await buildWhiteBackgroundWebpVariants(sourceBuffer) assetTypesToReplace.add(getWhiteBackgroundViewAssetType(input.skuId, view.key)) await writeStoredFileAtomically(relativePath, original, assertLockHeld) writtenFilePaths.push(relativePath) assertLockHeld() const previewUrl = await uploadPublicDisplayToS3( preview, buildWhiteBackgroundPublicDisplayPath(input.materialId, input.skuId, diskFileName), 'image/webp', ) uploadedPreviewUrls.push(previewUrl) if (view.key === 'rightFront45') { rightFront45PreviewUrl = previewUrl } assetsToCreate.push({ materialId: input.materialId, assetType: getWhiteBackgroundViewAssetType(input.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/${buildAttemptStorageFileName(displayFileName, attemptId)}` const relativePath = getWhiteBackgroundRelativeFilePath(input.materialId, input.skuId, diskFileName) const sourceBuffer = Buffer.from(await imageFile.arrayBuffer()) const { original, preview } = await buildWhiteBackgroundWebpVariants(sourceBuffer) await writeStoredFileAtomically(relativePath, original, assertLockHeld) writtenFilePaths.push(relativePath) assertLockHeld() const previewUrl = await uploadPublicDisplayToS3( preview, buildWhiteBackgroundPublicDisplayPath(input.materialId, input.skuId, diskFileName), 'image/webp', ) uploadedPreviewUrls.push(previewUrl) assetsToCreate.push({ materialId: input.materialId, assetType: getWhiteBackgroundOtherAssetType(input.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(input.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() const materialPreviewUrl = rightFront45PreviewUrl || resolvePreviewUrlFromWhiteBackgroundAsset(existingWhiteBackgroundGroup?.fixed.rightFront45) assertLockHeld() try { await prisma.$transaction(async (tx) => { if (assetTypeListToReplace.length > 0) { await tx.materialAsset.deleteMany({ where: { materialId: input.materialId, assetType: { in: assetTypeListToReplace, }, }, }) } if (assetsToCreate.length > 0) { await tx.materialAsset.createMany({ data: assetsToCreate, }) } await tx.materialSku.update({ where: { id: input.skuId }, data: { whiteBackgroundSourceType: input.sourceType, whiteBackgroundUploadedBy: input.operatorUserId, whiteBackgroundUploadedAt: uploadedAt, }, }) if (materialPreviewUrl) { await tx.material.updateMany({ where: { id: input.materialId, previewUrl: '', }, data: { previewUrl: materialPreviewUrl, }, }) } }) assetsPersisted = true } catch (transactionError) { // 一旦事务请求已经发出,在确认数据库明确回滚前都不能删除本次产物。 // 否则“提交成功但响应丢失”会把数据库正在引用的新文件删掉。 preserveAttemptOutputs = true const persistenceOutcome = await resolveAttemptPersistenceOutcome( input.materialId, assetsToCreate, ) if (persistenceOutcome === 'committed') { assetsPersisted = true preserveAttemptOutputs = false } else { preserveAttemptOutputs = persistenceOutcome === 'unknown' throw transactionError } } assertLockHeld() await deleteReplacedAssetFiles(replacedAssets, assertLockHeld) try { await createMaterialLog({ materialId: input.materialId, userId: input.operatorUserId, 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: input.skuId, skuName: normalizedSkuName, sourceType: input.sourceType, whiteBackgroundUploadedBy: input.operatorUserId, whiteBackgroundUploadedAt: uploadedAt.toISOString(), zipFileName: getWhiteBackgroundZipFileName(normalizedSkuName), }, }, }, }) } catch (logError) { console.error('记录白底图上传日志失败:', logError) } return { whiteBackgroundUploaded: whiteBackgroundCount > 0, uploadedFixedCount: uploadedFixedViews.length, fixedCount: nextFixedCount, otherCount: nextOtherCount, whiteBackgroundCount, isComplete, sourceType: input.sourceType, whiteBackgroundUploadedBy: input.operatorUserId, whiteBackgroundUploadedAt: uploadedAt.toISOString(), downloadFileName: getWhiteBackgroundZipFileName(normalizedSkuName), } } catch (error) { if (!assetsPersisted && !preserveAttemptOutputs) { for (const relativePath of writtenFilePaths) { const absolutePath = resolveStoredUploadPath(relativePath) try { await fs.unlink(absolutePath) } catch { // ignore cleanup failure } } for (const previewUrl of uploadedPreviewUrls) { try { await deleteFromS3(previewUrl) } catch { // ignore cleanup failure } } } throw error } } export async function cleanupStandardModelWhiteBackgroundForSku(input: { materialId: string skuId: string operatorUserId: string reason?: string }): Promise { const releaseLock = await acquireTaskMaterialCompletionLock( buildWhiteBackgroundLockIdentity(input.materialId, input.skuId), ) try { releaseLock.assertHeld() return await cleanupStandardModelWhiteBackgroundForSkuLocked(input, releaseLock.assertHeld) } finally { await releaseLock() } } async function cleanupStandardModelWhiteBackgroundForSkuLocked(input: { materialId: string skuId: string operatorUserId: string reason?: string }, assertLockHeld: WhiteBackgroundLockAssertion): Promise { assertLockHeld() const assets = await prisma.materialAsset.findMany({ where: { materialId: input.materialId, ...getScopedWhiteBackgroundAssetWhereForSku(input.skuId), }, select: { id: true, fileUrl: true, assetType: true, sortOrder: true, }, }) assertLockHeld() await prisma.$transaction(async (tx) => { await tx.materialAsset.deleteMany({ where: { materialId: input.materialId, ...getScopedWhiteBackgroundAssetWhereForSku(input.skuId), }, }) await tx.materialSku.update({ where: { id: input.skuId }, data: { whiteBackgroundSourceType: null, whiteBackgroundUploadedBy: null, whiteBackgroundUploadedAt: null, }, }) }) assertLockHeld() const fileResult = await deleteReplacedAssetFiles(assets, assertLockHeld) try { await createMaterialLog({ materialId: input.materialId, userId: input.operatorUserId, action: 'UPDATE', changes: { whiteBackgroundCleanup: { oldValue: null, newValue: { skuId: input.skuId, reason: input.reason || '', deletedAssetCount: assets.length, }, }, }, }) } catch (logError) { console.error('记录白底图清理日志失败:', logError) } return { deletedAssetCount: assets.length, deletedFileCount: fileResult.deletedFileCount, failedFileCount: fileResult.failedFileCount, clearedMetadata: true, } }