Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
668 lines
21 KiB
TypeScript
668 lines
21 KiB
TypeScript
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<void> {
|
|
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<void> {
|
|
const absolutePath = resolveStoredUploadPath(relativePath)
|
|
const directoryPath = path.dirname(absolutePath)
|
|
const partialPath = `${absolutePath}.partial-${randomUUID()}`
|
|
let fileHandle: Awaited<ReturnType<typeof fs.open>> | 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<Record<StandardModelWhiteBgKey, File | null>>
|
|
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<Record<StandardModelWhiteBgKey, File | null>>) {
|
|
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<AttemptPersistenceOutcome> {
|
|
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<WhiteBackgroundUploadResult> {
|
|
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<WhiteBackgroundUploadResult> {
|
|
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<string>(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<Record<StandardModelWhiteBgKey, { assetType: string }>> = {
|
|
...(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<WhiteBackgroundCleanupResult> {
|
|
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<WhiteBackgroundCleanupResult> {
|
|
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,
|
|
}
|
|
}
|