gongxiangp2/lib/standard-model-white-background-service.ts
chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

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,
}
}