1669 lines
60 KiB
TypeScript
1669 lines
60 KiB
TypeScript
import { prisma } from '@/lib/prisma'
|
||
import { validateTaskNo } from '@/lib/product-package'
|
||
import {
|
||
cleanupOnError,
|
||
cleanupTempFiles,
|
||
createZipFromFiles,
|
||
createZipFromZips,
|
||
buildTaskDetailPreviewNameForZipEntry,
|
||
resolveProductPackageZipEntryNames,
|
||
saveProductPackageAssetsAndComplete,
|
||
type ProductPackageAssetGroups,
|
||
type ProductPackagePsdInfo,
|
||
type ProductPackageZipInfos,
|
||
type SavedProductPackageSkuPreviewAsset,
|
||
} from '@/lib/task-material-processor'
|
||
import { buildStoredUploadPath, getUploadRootDir } from '@/lib/upload-paths'
|
||
import { isRemoteUploadSyncEnabled, syncStoredUploadFileToRemote } from '@/lib/remote-upload-sync'
|
||
import {
|
||
getOwnedUploadSession,
|
||
getUploadedFile,
|
||
refreshOwnedUploadSessionTtls,
|
||
} from '@/lib/upload-sessions'
|
||
import { createMaterialLog } from '@/lib/material-log'
|
||
import { isTaskDetailFullSetFromUploadGroups } from '@/lib/task-detail-full-set'
|
||
import {
|
||
queueTaskDetailBarcodeImagesUpdatedNotification,
|
||
queueTaskDetailFullSetNotification,
|
||
} from '@/lib/external-auto-listing'
|
||
import { deleteFileIfUnreferenced } from '@/lib/file-cleanup'
|
||
import path from 'path'
|
||
import { mkdir, readFile, writeFile } from 'fs/promises'
|
||
import { randomBytes } from 'crypto'
|
||
import { normalizeTaskDetailSourceType } from '@/lib/task-detail-source-type'
|
||
import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy'
|
||
import { saveTaskDetailPreview } from '@/lib/task-detail-preview-storage'
|
||
import { withImageProcessingSlot } from '@/lib/image-processing-limiter'
|
||
import {
|
||
getDingtalkApprovalNoError,
|
||
normalizeDingtalkApprovalNo,
|
||
} from '@/lib/dingtalk-approval-no'
|
||
import {
|
||
acquireTaskMaterialCompletionLock,
|
||
acquireTaskMaterialUploadSessionLocks,
|
||
type MaterialUploadStorageLockRelease,
|
||
} from '@/lib/material-upload-cross-process-lock'
|
||
|
||
export interface ProductPackageImageInput {
|
||
uploadId?: string
|
||
fileUrl?: string
|
||
localFilePath?: string
|
||
fileName: string
|
||
fileSize: number
|
||
sortOrder?: number
|
||
skuCode?: string
|
||
barcodes?: string[]
|
||
previewAssets?: ProductPackagePreviewInput[]
|
||
}
|
||
|
||
export interface ProductPackageSourceInput {
|
||
uploadId?: string
|
||
fileUrl?: string
|
||
localFilePath?: string
|
||
fileName: string
|
||
fileSize: number
|
||
}
|
||
|
||
export interface ProductPackagePreviewInput {
|
||
uploadId: string
|
||
fileName: string
|
||
fileSize: number
|
||
width?: number
|
||
height?: number
|
||
kind?: string
|
||
sourceName?: string
|
||
sourceIndex?: number
|
||
sliceIndex?: number
|
||
sliceCount?: number
|
||
}
|
||
|
||
export interface ProductPackageAssetsInput {
|
||
main?: ProductPackageImageInput[]
|
||
sku?: ProductPackageImageInput[]
|
||
detail?: ProductPackageImageInput[]
|
||
psd?: ProductPackageSourceInput[]
|
||
}
|
||
|
||
export interface CompleteProductPackageUploadInput {
|
||
materialId: string
|
||
taskNo?: string | null
|
||
dingtalkApprovalNo?: string | null
|
||
operatorUserId: string
|
||
assets: ProductPackageAssetsInput
|
||
options?: {
|
||
allowSkuWithoutBarcodes?: boolean
|
||
zipTimeoutMs?: number
|
||
allZipTimeoutMs?: number
|
||
zipCompressionLevel?: number
|
||
preserveMaterialOnError?: boolean
|
||
signal?: AbortSignal
|
||
assertExecutionCurrent?: () => Promise<void> | void
|
||
onProgress?: (update: { stage: string; progress: number; message?: string }) => Promise<void> | void
|
||
}
|
||
}
|
||
|
||
const TASK_DETAIL_SERVER_SOURCE_MAX_WIDTH = 12_000
|
||
const TASK_DETAIL_SERVER_SOURCE_MAX_HEIGHT =
|
||
TASK_DETAIL_UPLOAD_LIMITS.maxSliceHeight * TASK_DETAIL_UPLOAD_LIMITS.maxPreviewCountPerImage
|
||
const TASK_DETAIL_SERVER_SOURCE_MAX_PIXELS = 60_000_000
|
||
|
||
type ProductPackageReadableAsset = ProductPackageImageInput | ProductPackageSourceInput | ProductPackagePreviewInput
|
||
|
||
type PublicProductPackageAsset = Record<string, unknown>
|
||
|
||
/**
|
||
* 服务端与后台 worker 共用的任务详情上传容量门禁。
|
||
*
|
||
* 客户端校验只改善交互,不能作为安全边界;这里必须在 HTTP 入队前以及后台
|
||
* 真正处理前各执行一次,避免直接构造请求或篡改数据库任务绕过数量/大小限制。
|
||
*/
|
||
export function assertTaskDetailUploadAssetLimits(assets: ProductPackageAssetsInput) {
|
||
const imageGroups: Array<[string, ProductPackageImageInput[]]> = [
|
||
['主图', assets.main || []],
|
||
['SKU 图', assets.sku || []],
|
||
['详情图', assets.detail || []],
|
||
]
|
||
|
||
for (const [label, group] of imageGroups) {
|
||
if (group.length > TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup) {
|
||
throw new Error(`${label}最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup} 张,请分批提交`)
|
||
}
|
||
}
|
||
|
||
const imageAssets = imageGroups.flatMap(([, group]) => group)
|
||
const psdAssets = assets.psd || []
|
||
if (imageAssets.length > TASK_DETAIL_UPLOAD_LIMITS.maxTotalImageCount) {
|
||
throw new Error(`本次图片最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxTotalImageCount} 张,请分批提交`)
|
||
}
|
||
if (psdAssets.length > TASK_DETAIL_UPLOAD_LIMITS.maxPsdFileCount) {
|
||
throw new Error(`PSD 源文件最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxPsdFileCount} 个,请分批提交`)
|
||
}
|
||
|
||
const sourceAssets = [...imageAssets, ...psdAssets]
|
||
if (sourceAssets.length > TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceFileCount) {
|
||
throw new Error(`本次原图和源文件最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceFileCount} 个,请分批提交`)
|
||
}
|
||
|
||
let totalImageBytes = 0
|
||
let totalPsdBytes = 0
|
||
for (const asset of sourceAssets) {
|
||
if (!Number.isFinite(asset.fileSize) || asset.fileSize <= 0) {
|
||
throw new Error(`文件大小无效: ${asset.fileName}`)
|
||
}
|
||
const isImage = imageAssets.includes(asset as ProductPackageImageInput)
|
||
if (isImage && asset.fileSize > TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes) {
|
||
throw new Error(
|
||
`图片 ${asset.fileName} 超过单图 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes / 1024 / 1024)}MB 限制`,
|
||
)
|
||
}
|
||
if (!isImage && asset.fileSize > TASK_DETAIL_UPLOAD_LIMITS.maxSinglePsdBytes) {
|
||
throw new Error(
|
||
`源文件 ${asset.fileName} 超过 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxSinglePsdBytes / 1024 / 1024 / 1024)}GB`,
|
||
)
|
||
}
|
||
if (isImage) totalImageBytes += asset.fileSize
|
||
else totalPsdBytes += asset.fileSize
|
||
}
|
||
if (totalImageBytes > TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceBytes) {
|
||
throw new Error('本次图片总大小超过 1GB,请分批提交')
|
||
}
|
||
if (totalPsdBytes > TASK_DETAIL_UPLOAD_LIMITS.maxTotalPsdBytes) {
|
||
throw new Error(
|
||
`本次源文件总大小超过 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxTotalPsdBytes / 1024 / 1024 / 1024)}GB,请分批提交`,
|
||
)
|
||
}
|
||
|
||
let totalPreviewCount = 0
|
||
let totalPreviewBytes = 0
|
||
for (const asset of imageAssets) {
|
||
const previews = asset.previewAssets || []
|
||
if (previews.length > TASK_DETAIL_UPLOAD_LIMITS.maxPreviewCountPerImage) {
|
||
throw new Error(
|
||
`图片 ${asset.fileName} 的预览切片超过 ${TASK_DETAIL_UPLOAD_LIMITS.maxPreviewCountPerImage} 张限制`,
|
||
)
|
||
}
|
||
|
||
const slicePreviews = previews.filter((preview) => preview.kind === 'slice')
|
||
if (
|
||
(slicePreviews.length > 0 && slicePreviews.length !== previews.length)
|
||
|| (slicePreviews.length === 0 && previews.length > 1)
|
||
) {
|
||
throw new Error(`图片 ${asset.fileName} 的预览来源数量无效`)
|
||
}
|
||
if (slicePreviews.length > 0) {
|
||
const expectedCount = slicePreviews[0].sliceCount
|
||
if (!Number.isInteger(expectedCount) || expectedCount !== slicePreviews.length) {
|
||
throw new Error(`图片 ${asset.fileName} 的预览切片数量校验失败`)
|
||
}
|
||
const indices = slicePreviews
|
||
.map((preview) => preview.sliceIndex)
|
||
.sort((a, b) => (a ?? 0) - (b ?? 0))
|
||
if (indices.some((value, index) => value !== index)) {
|
||
throw new Error(`图片 ${asset.fileName} 的预览切片顺序校验失败`)
|
||
}
|
||
}
|
||
|
||
for (const preview of previews) {
|
||
totalPreviewCount += 1
|
||
totalPreviewBytes += preview.fileSize
|
||
if (totalPreviewCount > TASK_DETAIL_UPLOAD_LIMITS.maxTotalPreviewCount) {
|
||
throw new Error('本次客户端预览图数量过多,请分批上传')
|
||
}
|
||
if (
|
||
!Number.isFinite(preview.fileSize)
|
||
|| preview.fileSize <= 0
|
||
|| preview.fileSize > TASK_DETAIL_UPLOAD_LIMITS.maxSinglePreviewBytes
|
||
) {
|
||
throw new Error(`客户端预览图 ${preview.fileName} 超出大小限制`)
|
||
}
|
||
if (preview.kind !== 'preview' && preview.kind !== 'slice') {
|
||
throw new Error(`客户端预览图 ${preview.fileName} 类型无效`)
|
||
}
|
||
if (!Number.isInteger(preview.width) || !Number.isInteger(preview.height)) {
|
||
throw new Error(`客户端预览图 ${preview.fileName} 必须申报真实宽高`)
|
||
}
|
||
|
||
const width = preview.width as number
|
||
const height = preview.height as number
|
||
const isSlice = preview.kind === 'slice'
|
||
const maxWidth = isSlice
|
||
? TASK_DETAIL_UPLOAD_LIMITS.maxSliceWidth
|
||
: TASK_DETAIL_UPLOAD_LIMITS.maxPreviewWidth
|
||
const maxHeight = isSlice
|
||
? TASK_DETAIL_UPLOAD_LIMITS.maxSliceHeight
|
||
: TASK_DETAIL_UPLOAD_LIMITS.maxPreviewHeight
|
||
const maxPixels = isSlice
|
||
? TASK_DETAIL_UPLOAD_LIMITS.maxSlicePixels
|
||
: TASK_DETAIL_UPLOAD_LIMITS.maxPreviewPixels
|
||
if (width < 1 || height < 1 || width > maxWidth || height > maxHeight || width * height > maxPixels) {
|
||
throw new Error(`客户端预览图 ${preview.fileName} 尺寸超出限制`)
|
||
}
|
||
}
|
||
}
|
||
|
||
if (totalPreviewBytes > TASK_DETAIL_UPLOAD_LIMITS.maxTotalPreviewBytes) {
|
||
throw new Error('本次客户端预览图总大小超过 512MB,请分批提交')
|
||
}
|
||
}
|
||
|
||
type SharpModule = { default: typeof import('sharp') }
|
||
|
||
/** @internal 直接读取文件头元数据,避免为了校验客户端预览把完整文件载入内存。 */
|
||
export async function assertClientPreviewWebpMetadata(
|
||
previewAsset: ProductPackagePreviewInput,
|
||
filePath: string,
|
||
providedSharp?: SharpModule,
|
||
) {
|
||
const sharpModule = providedSharp
|
||
|| await import('sharp') as unknown as SharpModule
|
||
let metadata: import('sharp').Metadata
|
||
try {
|
||
metadata = await withImageProcessingSlot(() =>
|
||
sharpModule.default(filePath, {
|
||
limitInputPixels: TASK_DETAIL_UPLOAD_LIMITS.maxSlicePixels,
|
||
sequentialRead: true,
|
||
failOn: 'error',
|
||
}).metadata(),
|
||
)
|
||
} catch {
|
||
throw new Error(`客户端预览图 ${previewAsset.fileName} 不是可安全读取的 WebP`)
|
||
}
|
||
|
||
if (metadata.format !== 'webp') {
|
||
throw new Error(`客户端预览图 ${previewAsset.fileName} 内容不是有效 WebP`)
|
||
}
|
||
const width = Number(metadata.width)
|
||
const height = Number(metadata.height)
|
||
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
||
throw new Error(`客户端预览图 ${previewAsset.fileName} 真实尺寸无效`)
|
||
}
|
||
const frameCount = Number(metadata.pages ?? 1)
|
||
if (!Number.isInteger(frameCount) || frameCount !== TASK_DETAIL_UPLOAD_LIMITS.maxPreviewAnimationFrames) {
|
||
throw new Error(`客户端预览图 ${previewAsset.fileName} 不允许使用动画 WebP`)
|
||
}
|
||
|
||
const isSlice = previewAsset.kind === 'slice'
|
||
const maxWidth = isSlice
|
||
? TASK_DETAIL_UPLOAD_LIMITS.maxSliceWidth
|
||
: TASK_DETAIL_UPLOAD_LIMITS.maxPreviewWidth
|
||
const maxHeight = isSlice
|
||
? TASK_DETAIL_UPLOAD_LIMITS.maxSliceHeight
|
||
: TASK_DETAIL_UPLOAD_LIMITS.maxPreviewHeight
|
||
const maxPixels = isSlice
|
||
? TASK_DETAIL_UPLOAD_LIMITS.maxSlicePixels
|
||
: TASK_DETAIL_UPLOAD_LIMITS.maxPreviewPixels
|
||
if (width > maxWidth || height > maxHeight || width * height > maxPixels) {
|
||
throw new Error(`客户端预览图 ${previewAsset.fileName} 真实尺寸超出限制`)
|
||
}
|
||
if (width !== previewAsset.width || height !== previewAsset.height) {
|
||
throw new Error(`客户端预览图 ${previewAsset.fileName} 申报尺寸与真实尺寸不一致`)
|
||
}
|
||
}
|
||
|
||
function parsePublicAssetGroup(
|
||
value: unknown,
|
||
group: 'main' | 'sku' | 'detail' | 'psd',
|
||
): ProductPackageImageInput[] | ProductPackageSourceInput[] | undefined {
|
||
if (value == null) {
|
||
return undefined
|
||
}
|
||
|
||
if (!Array.isArray(value)) {
|
||
throw new Error(`${group} 资产格式错误`)
|
||
}
|
||
|
||
return value.map((rawAsset, index) => {
|
||
if (!rawAsset || typeof rawAsset !== 'object' || Array.isArray(rawAsset)) {
|
||
throw new Error(`${group} 第 ${index + 1} 个资产格式错误`)
|
||
}
|
||
|
||
const asset = rawAsset as PublicProductPackageAsset
|
||
if (
|
||
Object.prototype.hasOwnProperty.call(asset, 'localFilePath') ||
|
||
Object.prototype.hasOwnProperty.call(asset, 'fileUrl')
|
||
) {
|
||
throw new Error('公开上传接口只允许使用 uploadId')
|
||
}
|
||
|
||
const uploadId = typeof asset.uploadId === 'string' ? asset.uploadId.trim() : ''
|
||
const fileName = typeof asset.fileName === 'string' ? asset.fileName.trim() : ''
|
||
const fileSize = Number(asset.fileSize)
|
||
|
||
if (!uploadId || !fileName || !Number.isFinite(fileSize) || fileSize < 0) {
|
||
throw new Error(`${group} 第 ${index + 1} 个资产参数无效`)
|
||
}
|
||
|
||
const parsedAsset: ProductPackageImageInput | ProductPackageSourceInput = {
|
||
uploadId,
|
||
fileName,
|
||
fileSize,
|
||
}
|
||
|
||
if (group !== 'psd') {
|
||
const imageAsset = parsedAsset as ProductPackageImageInput
|
||
if (Number.isInteger(asset.sortOrder) && Number(asset.sortOrder) >= 0) {
|
||
imageAsset.sortOrder = Number(asset.sortOrder)
|
||
}
|
||
if (typeof asset.skuCode === 'string') {
|
||
imageAsset.skuCode = asset.skuCode
|
||
}
|
||
if (Array.isArray(asset.barcodes)) {
|
||
imageAsset.barcodes = asset.barcodes.filter(
|
||
(barcode): barcode is string => typeof barcode === 'string',
|
||
)
|
||
}
|
||
|
||
const rawPreviewAssets = Array.isArray(asset.previewAssets) ? asset.previewAssets : []
|
||
const previewAssets = rawPreviewAssets.map((rawPreviewAsset, previewIndex) => {
|
||
if (!rawPreviewAsset || typeof rawPreviewAsset !== 'object' || Array.isArray(rawPreviewAsset)) {
|
||
throw new Error(`${group} 第 ${index + 1} 个预览图 ${previewIndex + 1} 格式错误`)
|
||
}
|
||
|
||
const preview = rawPreviewAsset as PublicProductPackageAsset
|
||
if (
|
||
Object.prototype.hasOwnProperty.call(preview, 'localFilePath') ||
|
||
Object.prototype.hasOwnProperty.call(preview, 'fileUrl')
|
||
) {
|
||
throw new Error('公开上传接口只允许使用预览 uploadId')
|
||
}
|
||
const previewUploadId = typeof preview.uploadId === 'string' ? preview.uploadId.trim() : ''
|
||
const previewFileName = typeof preview.fileName === 'string' ? preview.fileName.trim() : ''
|
||
const previewFileSize = Number(preview.fileSize)
|
||
if (!previewUploadId || !previewFileName || !Number.isFinite(previewFileSize) || previewFileSize < 0) {
|
||
throw new Error(`${group} 第 ${index + 1} 个预览图参数无效`)
|
||
}
|
||
if (!/\.webp$/i.test(previewFileName)) {
|
||
throw new Error(`${group} 第 ${index + 1} 个预览图必须是 WebP 格式`)
|
||
}
|
||
|
||
const parsedPreview: ProductPackagePreviewInput = {
|
||
uploadId: previewUploadId,
|
||
fileName: previewFileName,
|
||
fileSize: previewFileSize,
|
||
}
|
||
for (const field of ['width', 'height', 'sourceIndex', 'sliceIndex', 'sliceCount'] as const) {
|
||
if (Number.isInteger(preview[field]) && Number(preview[field]) >= 0) {
|
||
parsedPreview[field] = Number(preview[field])
|
||
}
|
||
}
|
||
for (const field of ['kind', 'sourceName'] as const) {
|
||
if (typeof preview[field] === 'string' && preview[field].trim()) {
|
||
parsedPreview[field] = preview[field].trim()
|
||
}
|
||
}
|
||
return parsedPreview
|
||
})
|
||
|
||
const previewUploadId = typeof asset.previewUploadId === 'string' ? asset.previewUploadId.trim() : ''
|
||
if (previewAssets.length > 0) {
|
||
imageAsset.previewAssets = previewAssets
|
||
} else if (previewUploadId) {
|
||
const previewFileName = typeof asset.previewFileName === 'string'
|
||
? asset.previewFileName.trim()
|
||
: `${fileName.replace(/\.[^.]+$/, '')}-preview.webp`
|
||
const previewFileSize = Number(asset.previewFileSize)
|
||
if (!previewFileName || !Number.isFinite(previewFileSize) || previewFileSize < 0) {
|
||
throw new Error(`${group} 第 ${index + 1} 个预览图参数无效`)
|
||
}
|
||
imageAsset.previewAssets = [{
|
||
uploadId: previewUploadId,
|
||
fileName: previewFileName,
|
||
fileSize: previewFileSize,
|
||
}]
|
||
}
|
||
}
|
||
|
||
return parsedAsset
|
||
})
|
||
}
|
||
|
||
export function parsePublicProductPackageAssets(value: unknown): ProductPackageAssetsInput {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||
throw new Error('assets 参数格式错误')
|
||
}
|
||
|
||
const assets = value as Record<string, unknown>
|
||
const parsedAssets = {
|
||
main: parsePublicAssetGroup(assets.main, 'main') as ProductPackageImageInput[] | undefined,
|
||
sku: parsePublicAssetGroup(assets.sku, 'sku') as ProductPackageImageInput[] | undefined,
|
||
detail: parsePublicAssetGroup(assets.detail, 'detail') as ProductPackageImageInput[] | undefined,
|
||
psd: parsePublicAssetGroup(assets.psd, 'psd') as ProductPackageSourceInput[] | undefined,
|
||
}
|
||
assertTaskDetailUploadAssetLimits(parsedAssets)
|
||
return parsedAssets
|
||
}
|
||
|
||
/** @internal 后台执行前按真实上传会话复核所有权和文件大小。 */
|
||
export async function assertOwnedProductPackageUploadSessions(
|
||
assets: ProductPackageAssetsInput,
|
||
operatorUserId: string,
|
||
) {
|
||
assertTaskDetailUploadAssetLimits(assets)
|
||
const imageAssets = [assets.main || [], assets.sku || [], assets.detail || []].flat()
|
||
const allAssets = [...imageAssets, ...(assets.psd || [])]
|
||
const seenUploadIds = new Set<string>()
|
||
const maxImageBytes = TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes
|
||
const maxPsdBytes = TASK_DETAIL_UPLOAD_LIMITS.maxSinglePsdBytes
|
||
const configuredMaxPreviewBytes = Number(process.env.TASK_DETAIL_PREVIEW_MAX_BYTES)
|
||
const maxPreviewBytes = Number.isFinite(configuredMaxPreviewBytes) && configuredMaxPreviewBytes > 0
|
||
? configuredMaxPreviewBytes
|
||
: 10 * 1024 * 1024
|
||
const maxTotalImageBytes = TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceBytes
|
||
const maxTotalPsdBytes = TASK_DETAIL_UPLOAD_LIMITS.maxTotalPsdBytes
|
||
const maxTotalPreviewBytes = TASK_DETAIL_UPLOAD_LIMITS.maxTotalPreviewBytes
|
||
const maxTotalPreviewCount = TASK_DETAIL_UPLOAD_LIMITS.maxTotalPreviewCount
|
||
let totalImageBytes = 0
|
||
let totalPsdBytes = 0
|
||
let totalPreviewBytes = 0
|
||
let totalPreviewCount = 0
|
||
|
||
for (const asset of allAssets) {
|
||
const uploadId = String(asset.uploadId || '').trim()
|
||
const isImage = imageAssets.includes(asset)
|
||
if (!uploadId) {
|
||
// 内部历史导入仍可传受信任的本地路径/已签名地址;公开 HTTP 路由在
|
||
// parsePublicProductPackageAssets 阶段已经拒绝这两类来源。
|
||
if (!('fileUrl' in asset && asset.fileUrl) && !('localFilePath' in asset && asset.localFilePath)) {
|
||
throw new Error(`资产缺少 uploadId: ${asset.fileName}`)
|
||
}
|
||
if (isImage && asset.fileSize > maxImageBytes) {
|
||
throw new Error(`图片 ${asset.fileName} 超过单图 ${Math.round(maxImageBytes / 1024 / 1024)}MB 限制`)
|
||
}
|
||
if (!isImage && asset.fileSize > maxPsdBytes) {
|
||
throw new Error(
|
||
`源文件 ${asset.fileName} 超过 ${Math.round(maxPsdBytes / 1024 / 1024 / 1024)}GB`,
|
||
)
|
||
}
|
||
if (isImage) totalImageBytes += Math.max(0, asset.fileSize)
|
||
else totalPsdBytes += Math.max(0, asset.fileSize)
|
||
continue
|
||
}
|
||
if (seenUploadIds.has(uploadId)) {
|
||
throw new Error(`上传会话重复使用: ${asset.fileName}`)
|
||
}
|
||
const session = await getOwnedUploadSession(uploadId, operatorUserId)
|
||
if (!session) {
|
||
console.warn('[ProductPackage] 上传会话不可用', { uploadId, fileName: asset.fileName, operatorUserId })
|
||
throw new Error(`「${asset.fileName}」上传已过期,请重新选择这个文件后再提交`)
|
||
}
|
||
seenUploadIds.add(uploadId)
|
||
if (session.fileSize !== asset.fileSize) {
|
||
throw new Error(`文件大小校验失败,请重新上传: ${asset.fileName}`)
|
||
}
|
||
if (isImage && session.fileSize > maxImageBytes) {
|
||
throw new Error(`图片 ${asset.fileName} 超过单图 ${Math.round(maxImageBytes / 1024 / 1024)}MB 限制`)
|
||
}
|
||
if (!isImage && session.fileSize > maxPsdBytes) {
|
||
throw new Error(
|
||
`源文件 ${asset.fileName} 超过 ${Math.round(maxPsdBytes / 1024 / 1024 / 1024)}GB`,
|
||
)
|
||
}
|
||
if (isImage) totalImageBytes += session.fileSize
|
||
else totalPsdBytes += session.fileSize
|
||
}
|
||
|
||
for (const asset of imageAssets) {
|
||
const previews = asset.previewAssets || []
|
||
if (previews.length > 60) {
|
||
throw new Error(`图片 ${asset.fileName} 的预览切片超过 60 张限制`)
|
||
}
|
||
const slicePreviews = previews.filter((preview) => preview.kind === 'slice')
|
||
if (
|
||
(slicePreviews.length > 0 && slicePreviews.length !== previews.length)
|
||
|| (slicePreviews.length === 0 && previews.length > 1)
|
||
) {
|
||
throw new Error(`图片 ${asset.fileName} 的预览来源数量无效`)
|
||
}
|
||
if (slicePreviews.length > 0) {
|
||
const expectedCount = slicePreviews[0].sliceCount
|
||
if (!Number.isInteger(expectedCount) || expectedCount !== slicePreviews.length) {
|
||
throw new Error(`图片 ${asset.fileName} 的预览切片数量校验失败`)
|
||
}
|
||
const indices = slicePreviews.map((preview) => preview.sliceIndex).sort((a, b) => (a ?? 0) - (b ?? 0))
|
||
if (indices.some((value, index) => value !== index)) {
|
||
throw new Error(`图片 ${asset.fileName} 的预览切片顺序校验失败`)
|
||
}
|
||
}
|
||
|
||
for (const preview of previews) {
|
||
if (seenUploadIds.has(preview.uploadId)) {
|
||
throw new Error(`客户端预览图上传会话重复使用: ${preview.fileName}`)
|
||
}
|
||
seenUploadIds.add(preview.uploadId)
|
||
totalPreviewCount += 1
|
||
totalPreviewBytes += preview.fileSize
|
||
if (totalPreviewCount > maxTotalPreviewCount) {
|
||
throw new Error('本次客户端预览图数量过多,请分批上传')
|
||
}
|
||
if (preview.fileSize <= 0 || preview.fileSize > maxPreviewBytes) {
|
||
throw new Error(`客户端预览图 ${preview.fileName} 超出大小限制`)
|
||
}
|
||
if (preview.kind !== 'preview' && preview.kind !== 'slice') {
|
||
throw new Error(`客户端预览图 ${preview.fileName} 类型无效`)
|
||
}
|
||
if (preview.width !== undefined && (preview.width < 1 || preview.width > 16000)) {
|
||
throw new Error(`客户端预览图 ${preview.fileName} 尺寸无效`)
|
||
}
|
||
if (preview.height !== undefined && (preview.height < 1 || preview.height > 16000)) {
|
||
throw new Error(`客户端预览图 ${preview.fileName} 尺寸无效`)
|
||
}
|
||
const previewSession = await getOwnedUploadSession(preview.uploadId, operatorUserId)
|
||
if (!previewSession || previewSession.fileSize !== preview.fileSize) {
|
||
throw new Error(`预览图上传会话校验失败: ${preview.fileName}`)
|
||
}
|
||
}
|
||
}
|
||
|
||
if (totalImageBytes > maxTotalImageBytes) {
|
||
throw new Error('本次图片总大小超过 1GB,请分批提交')
|
||
}
|
||
if (totalPsdBytes > maxTotalPsdBytes) {
|
||
throw new Error(
|
||
`本次源文件总大小超过 ${Math.round(maxTotalPsdBytes / 1024 / 1024 / 1024)}GB,请分批提交`,
|
||
)
|
||
}
|
||
if (totalPreviewBytes > maxTotalPreviewBytes) {
|
||
throw new Error('本次客户端预览图总大小超过 512MB,请分批提交')
|
||
}
|
||
}
|
||
|
||
interface GeneratedProductPackagePreviewAsset {
|
||
fileUrl: string
|
||
fileName: string
|
||
sortOrder: number
|
||
skuCode?: string
|
||
barcodes?: string[]
|
||
}
|
||
|
||
function normalizeBarcodeValue(barcode: string | null | undefined) {
|
||
return String(barcode || '').trim()
|
||
}
|
||
|
||
function normalizeSkuPreviewAssets(
|
||
assets: ProductPackageImageInput[] | undefined,
|
||
options?: { allowSkuWithoutBarcodes?: boolean },
|
||
) {
|
||
if (!assets || assets.length === 0) {
|
||
return []
|
||
}
|
||
|
||
const barcodeToAssetName = new Map<string, string>()
|
||
|
||
return assets.map((asset, index) => {
|
||
const skuCode = String(asset.skuCode || '').trim()
|
||
if (!skuCode) {
|
||
throw new Error(`第 ${index + 1} 张 SKU 预览图缺少 SKU 编号`)
|
||
}
|
||
|
||
const barcodes = Array.from(
|
||
new Set((asset.barcodes || []).map((barcode) => normalizeBarcodeValue(barcode)).filter(Boolean)),
|
||
)
|
||
|
||
if (barcodes.length === 0 && !options?.allowSkuWithoutBarcodes) {
|
||
throw new Error(`SKU 预览图 ${asset.fileName} 至少需要 1 个产品条码`)
|
||
}
|
||
|
||
for (const barcode of barcodes) {
|
||
const existingOwner = barcodeToAssetName.get(barcode)
|
||
if (existingOwner && existingOwner !== asset.fileName) {
|
||
throw new Error(`产品条码 ${barcode} 不能同时绑定到 ${existingOwner} 和 ${asset.fileName}`)
|
||
}
|
||
|
||
barcodeToAssetName.set(barcode, asset.fileName)
|
||
}
|
||
|
||
return {
|
||
...asset,
|
||
skuCode,
|
||
barcodes,
|
||
}
|
||
})
|
||
}
|
||
|
||
async function writeAssetToTempFile(
|
||
prefix: string,
|
||
asset: ProductPackageImageInput | ProductPackageSourceInput,
|
||
buffer: Buffer,
|
||
) {
|
||
const tempPath = path.join(
|
||
process.cwd(),
|
||
'uploads',
|
||
'temp',
|
||
`${prefix}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}-${asset.fileName}`,
|
||
)
|
||
|
||
await mkdir(path.dirname(tempPath), { recursive: true })
|
||
await writeFile(tempPath, buffer)
|
||
return tempPath
|
||
}
|
||
|
||
async function downloadToTempFile(prefix: string, asset: ProductPackageImageInput | ProductPackageSourceInput) {
|
||
if (asset.localFilePath) {
|
||
const buffer = await readFile(asset.localFilePath)
|
||
return writeAssetToTempFile(prefix, asset, buffer)
|
||
}
|
||
|
||
if (!asset.fileUrl) {
|
||
throw new Error(`缺少文件地址: ${asset.fileName}`)
|
||
}
|
||
|
||
const response = await fetch(asset.fileUrl)
|
||
if (!response.ok) {
|
||
throw new Error(`下载文件失败: ${asset.fileName}`)
|
||
}
|
||
|
||
const buffer = Buffer.from(await response.arrayBuffer())
|
||
return writeAssetToTempFile(prefix, asset, buffer)
|
||
}
|
||
|
||
async function readAssetBuffer(
|
||
asset: ProductPackageReadableAsset,
|
||
type: 'main' | 'sku' | 'detail' | 'psd',
|
||
assembledFiles?: Map<string, string>,
|
||
tempFiles?: string[],
|
||
) {
|
||
if ('localFilePath' in asset && asset.localFilePath) {
|
||
return readFile(asset.localFilePath)
|
||
}
|
||
|
||
if ('fileUrl' in asset && asset.fileUrl) {
|
||
const response = await fetch(asset.fileUrl)
|
||
if (!response.ok) {
|
||
throw new Error(`下载${type}预览原图失败: ${asset.fileName}`)
|
||
}
|
||
return Buffer.from(await response.arrayBuffer())
|
||
}
|
||
|
||
if (asset.uploadId) {
|
||
const filePath = await resolveUploadedAssetPath(
|
||
{ uploadId: asset.uploadId, fileName: asset.fileName },
|
||
assembledFiles,
|
||
tempFiles,
|
||
)
|
||
return readFile(filePath)
|
||
}
|
||
|
||
throw new Error(`无效的${type}资产: ${asset.fileName}`)
|
||
}
|
||
|
||
async function resolveUploadedAssetPath(
|
||
asset: { uploadId: string; fileName: string },
|
||
assembledFiles?: Map<string, string>,
|
||
tempFiles?: string[],
|
||
) {
|
||
const cachedPath = assembledFiles?.get(asset.uploadId)
|
||
const filePath = cachedPath || await getUploadedFile(asset.uploadId)
|
||
if (!filePath) {
|
||
throw new Error(`文件不存在: ${asset.fileName} (${asset.uploadId})`)
|
||
}
|
||
assembledFiles?.set(asset.uploadId, filePath)
|
||
if (tempFiles && !tempFiles.includes(filePath)) {
|
||
tempFiles.push(filePath)
|
||
}
|
||
return filePath
|
||
}
|
||
|
||
async function collectAssetFiles(
|
||
assets: ProductPackageImageInput[] | undefined,
|
||
prefix: string,
|
||
tempFiles: string[],
|
||
uploadIds: string[],
|
||
assembledFiles?: Map<string, string>,
|
||
) {
|
||
const files: Array<{ path: string; name: string; size: number }> = []
|
||
|
||
if (!assets || assets.length === 0) {
|
||
return files
|
||
}
|
||
|
||
for (const asset of assets) {
|
||
if (asset.uploadId) {
|
||
const cachedPath = assembledFiles?.get(asset.uploadId)
|
||
const filePath = cachedPath || await getUploadedFile(asset.uploadId)
|
||
if (!filePath) {
|
||
throw new Error(`文件缺失或未上传完成: ${asset.fileName} (${asset.uploadId})`)
|
||
}
|
||
assembledFiles?.set(asset.uploadId, filePath)
|
||
files.push({
|
||
path: filePath,
|
||
name: asset.fileName,
|
||
size: asset.fileSize,
|
||
})
|
||
if (!tempFiles.includes(filePath)) {
|
||
tempFiles.push(filePath)
|
||
}
|
||
if (!uploadIds.includes(asset.uploadId)) {
|
||
uploadIds.push(asset.uploadId)
|
||
}
|
||
continue
|
||
}
|
||
|
||
if (!asset.fileUrl && !asset.localFilePath) {
|
||
throw new Error(`资产参数无效: ${asset.fileName}`)
|
||
}
|
||
|
||
const filePath = await downloadToTempFile(prefix, asset)
|
||
files.push({
|
||
path: filePath,
|
||
name: asset.fileName,
|
||
size: asset.fileSize,
|
||
})
|
||
tempFiles.push(filePath)
|
||
}
|
||
|
||
return files
|
||
}
|
||
|
||
async function generatePreviewsFromAssets(
|
||
materialId: string,
|
||
assets: ProductPackageImageInput[] | undefined,
|
||
type: 'main' | 'sku' | 'detail',
|
||
sourceEntryNames: string[],
|
||
uploadedPreviewUrls: string[],
|
||
privatePreviewPaths: string[],
|
||
isPrivate: boolean,
|
||
assembledFiles?: Map<string, string>,
|
||
tempFiles?: string[],
|
||
uploadIds?: string[],
|
||
) {
|
||
if (!assets || assets.length === 0) {
|
||
return []
|
||
}
|
||
|
||
const previews: GeneratedProductPackagePreviewAsset[] = []
|
||
let compressImage: typeof import('@/lib/image-processor').compressImage | undefined
|
||
let sliceImage: typeof import('@/lib/image-processor').sliceImage | undefined
|
||
let sharp: { default: typeof import('sharp') } | undefined
|
||
|
||
const getSharp = async () => {
|
||
if (!sharp) {
|
||
sharp = await import('sharp') as unknown as { default: typeof import('sharp') }
|
||
}
|
||
return sharp
|
||
}
|
||
|
||
const getServerPreviewTools = async () => {
|
||
if (!compressImage || !sliceImage) {
|
||
const imageProcessor = await import('@/lib/image-processor')
|
||
compressImage = imageProcessor.compressImage
|
||
sliceImage = imageProcessor.sliceImage
|
||
}
|
||
return { compressImage, sliceImage, sharp: await getSharp() }
|
||
}
|
||
|
||
const safePreviewFileName = (fileName: string, index: number, sliceIndex: number) => {
|
||
const safeName = fileName.replace(/[\/\\:*?"<>|\0\r\n]/g, '_').trim()
|
||
return safeName || `${type}-preview-${index}-${sliceIndex}.webp`
|
||
}
|
||
|
||
for (let index = 0; index < assets.length; index += 1) {
|
||
const asset = assets[index]
|
||
const sourceEntryName = sourceEntryNames[index]
|
||
if (!sourceEntryName) throw new Error(`图片 ${asset.fileName} 缺少 ZIP 来源条目`)
|
||
const sourceBaseName = path.parse(sourceEntryName).name
|
||
|
||
if (asset.previewAssets && asset.previewAssets.length > 0) {
|
||
for (let sliceIndex = 0; sliceIndex < asset.previewAssets.length; sliceIndex += 1) {
|
||
const previewAsset = asset.previewAssets[sliceIndex]
|
||
if (uploadIds && !uploadIds.includes(previewAsset.uploadId)) {
|
||
uploadIds.push(previewAsset.uploadId)
|
||
}
|
||
const previewFilePath = await resolveUploadedAssetPath(previewAsset, assembledFiles, tempFiles)
|
||
await assertClientPreviewWebpMetadata(previewAsset, previewFilePath, await getSharp())
|
||
const buffer = await readFile(previewFilePath)
|
||
const configuredMaxPreviewBytes = Number(process.env.TASK_DETAIL_PREVIEW_MAX_BYTES)
|
||
const maxPreviewBytes = Number.isFinite(configuredMaxPreviewBytes) && configuredMaxPreviewBytes > 0
|
||
? configuredMaxPreviewBytes
|
||
: 10 * 1024 * 1024
|
||
if (buffer.length > maxPreviewBytes) {
|
||
throw new Error(`客户端预览图 ${previewAsset.fileName} 不能超过 ${Math.ceil(maxPreviewBytes / 1024 / 1024)}MB`)
|
||
}
|
||
if (!/\.webp$/i.test(previewAsset.fileName)) {
|
||
throw new Error(`客户端预览图 ${previewAsset.fileName} 必须是 WebP 格式`)
|
||
}
|
||
if (
|
||
buffer.length < 12
|
||
|| buffer.toString('ascii', 0, 4) !== 'RIFF'
|
||
|| buffer.toString('ascii', 8, 12) !== 'WEBP'
|
||
) {
|
||
throw new Error(`客户端预览图 ${previewAsset.fileName} 内容不是有效 WebP`)
|
||
}
|
||
|
||
const canonicalName = buildTaskDetailPreviewNameForZipEntry(sourceEntryName, previewAsset, sliceIndex)
|
||
const fileName = safePreviewFileName(canonicalName, index, sliceIndex)
|
||
const previewUrl = await saveTaskDetailPreview(
|
||
buffer,
|
||
materialId,
|
||
type,
|
||
`client-${Date.now()}-${index}-${sliceIndex}-${fileName}`,
|
||
isPrivate,
|
||
)
|
||
previews.push({
|
||
fileUrl: previewUrl,
|
||
fileName,
|
||
sortOrder: previews.length,
|
||
...(type === 'sku'
|
||
? {
|
||
skuCode: asset.skuCode,
|
||
barcodes: asset.barcodes || [],
|
||
}
|
||
: {}),
|
||
})
|
||
if (/^https?:\/\//i.test(previewUrl)) {
|
||
uploadedPreviewUrls.push(previewUrl)
|
||
} else {
|
||
privatePreviewPaths.push(previewUrl)
|
||
}
|
||
}
|
||
continue
|
||
}
|
||
|
||
const previewTools = await getServerPreviewTools()
|
||
let buffer: Buffer
|
||
|
||
buffer = await readAssetBuffer(asset, type, assembledFiles, tempFiles)
|
||
|
||
if (buffer.length > 200 * 1024 * 1024) {
|
||
throw new Error(`预览源图片 ${asset.fileName} 不能超过 200MB`)
|
||
}
|
||
const metadata = await withImageProcessingSlot(() =>
|
||
previewTools.sharp!.default(buffer, {
|
||
limitInputPixels: TASK_DETAIL_SERVER_SOURCE_MAX_PIXELS,
|
||
sequentialRead: true,
|
||
failOn: 'error',
|
||
}).metadata(),
|
||
)
|
||
const shouldSlice = type === 'detail' && metadata.height && metadata.height > 1500
|
||
const buffers = shouldSlice
|
||
? await previewTools.sliceImage!(
|
||
buffer,
|
||
TASK_DETAIL_UPLOAD_LIMITS.maxSliceHeight,
|
||
200,
|
||
TASK_DETAIL_UPLOAD_LIMITS.maxSliceWidth,
|
||
{
|
||
allowAnimatedFirstFrame: true,
|
||
maxInputWidth: TASK_DETAIL_SERVER_SOURCE_MAX_WIDTH,
|
||
maxInputHeight: TASK_DETAIL_SERVER_SOURCE_MAX_HEIGHT,
|
||
maxInputPixels: TASK_DETAIL_SERVER_SOURCE_MAX_PIXELS,
|
||
},
|
||
)
|
||
: [
|
||
await previewTools.compressImage!(buffer, {
|
||
maxWidth: 800,
|
||
maxHeight: 1200,
|
||
maxSizeKB: 200,
|
||
format: 'webp',
|
||
allowAnimatedFirstFrame: true,
|
||
maxInputWidth: TASK_DETAIL_SERVER_SOURCE_MAX_WIDTH,
|
||
maxInputHeight: TASK_DETAIL_SERVER_SOURCE_MAX_HEIGHT,
|
||
maxInputPixels: TASK_DETAIL_SERVER_SOURCE_MAX_PIXELS,
|
||
}),
|
||
]
|
||
|
||
for (let sliceIndex = 0; sliceIndex < buffers.length; sliceIndex += 1) {
|
||
const timestamp = Date.now()
|
||
const randomSuffix = Math.random().toString(36).substring(2, 9)
|
||
const sliceSuffix = buffers.length > 1 ? `-slice${sliceIndex}` : ''
|
||
const fileName = `${sourceBaseName}__srv-${timestamp}-${index}${sliceSuffix}-${randomSuffix}.webp`
|
||
const previewUrl = await saveTaskDetailPreview(
|
||
buffers[sliceIndex],
|
||
materialId,
|
||
type,
|
||
fileName,
|
||
isPrivate,
|
||
)
|
||
previews.push({
|
||
fileUrl: previewUrl,
|
||
fileName,
|
||
sortOrder: previews.length,
|
||
...(type === 'sku'
|
||
? {
|
||
skuCode: asset.skuCode,
|
||
barcodes: asset.barcodes || [],
|
||
}
|
||
: {}),
|
||
})
|
||
if (/^https?:\/\//i.test(previewUrl)) {
|
||
uploadedPreviewUrls.push(previewUrl)
|
||
} else {
|
||
privatePreviewPaths.push(previewUrl)
|
||
}
|
||
}
|
||
}
|
||
|
||
return previews
|
||
}
|
||
|
||
async function cleanupGeneratedPrivatePreviews(fileUrls: string[]): Promise<void> {
|
||
const uniquePaths = Array.from(new Set(fileUrls.filter(Boolean)))
|
||
await Promise.allSettled(
|
||
uniquePaths.map((fileUrl) =>
|
||
deleteFileIfUnreferenced(fileUrl).catch(() => undefined),
|
||
),
|
||
)
|
||
}
|
||
|
||
/** @internal 同名 PSD 也必须生成不同的持久化路径。 */
|
||
export function buildPsdPermanentFileName(
|
||
materialId: string,
|
||
originalFileName: string,
|
||
assetIndex: number,
|
||
timestamp = Date.now(),
|
||
entropy = randomBytes(6).toString('hex'),
|
||
) {
|
||
const sanitizedFileName = originalFileName.replace(/[\/\\:*?"<>|]/g, '_')
|
||
return `psd-${materialId}-${timestamp}-${assetIndex}-${entropy}-${sanitizedFileName}`
|
||
}
|
||
|
||
async function movePsdFilesToPermanentDir(
|
||
materialId: string,
|
||
assets: ProductPackageSourceInput[] | undefined,
|
||
tempFiles: string[],
|
||
uploadIds: string[],
|
||
assembledFiles?: Map<string, string>,
|
||
) {
|
||
const psdPermanentPaths: Array<{ filePath: string; fileName: string; fileSize: number }> = []
|
||
|
||
if (!assets || assets.length === 0) {
|
||
return psdPermanentPaths
|
||
}
|
||
|
||
const { copyFile, stat } = await import('fs/promises')
|
||
const psdDir = path.join(getUploadRootDir(), 'materials')
|
||
await mkdir(psdDir, { recursive: true })
|
||
|
||
for (let assetIndex = 0; assetIndex < assets.length; assetIndex += 1) {
|
||
const psd = assets[assetIndex]
|
||
const permanentFileName = buildPsdPermanentFileName(materialId, psd.fileName, assetIndex)
|
||
const permanentFilePath = path.join(psdDir, permanentFileName)
|
||
const relativePath = buildStoredUploadPath('materials', permanentFileName)
|
||
|
||
if (!psd.uploadId) {
|
||
if (!psd.fileUrl && !psd.localFilePath) {
|
||
throw new Error(`PSD 资产参数无效: ${psd.fileName}`)
|
||
}
|
||
|
||
if (psd.localFilePath) {
|
||
const sourceStat = await stat(psd.localFilePath)
|
||
const expectedSize = psd.fileSize || sourceStat.size
|
||
|
||
if (isRemoteUploadSyncEnabled()) {
|
||
await syncStoredUploadFileToRemote(psd.localFilePath, relativePath, expectedSize)
|
||
} else {
|
||
await copyFile(psd.localFilePath, permanentFilePath)
|
||
}
|
||
|
||
psdPermanentPaths.push({
|
||
filePath: relativePath,
|
||
fileName: psd.fileName,
|
||
fileSize: expectedSize,
|
||
})
|
||
continue
|
||
}
|
||
|
||
const tempFilePath = await downloadToTempFile('psd', psd)
|
||
await copyFile(tempFilePath, permanentFilePath)
|
||
await syncStoredUploadFileToRemote(permanentFilePath, relativePath, psd.fileSize)
|
||
tempFiles.push(tempFilePath)
|
||
psdPermanentPaths.push({
|
||
filePath: relativePath,
|
||
fileName: psd.fileName,
|
||
fileSize: psd.fileSize,
|
||
})
|
||
continue
|
||
}
|
||
|
||
if (!uploadIds.includes(psd.uploadId)) {
|
||
uploadIds.push(psd.uploadId)
|
||
}
|
||
|
||
const cachedPath = assembledFiles?.get(psd.uploadId)
|
||
const tempFilePath = cachedPath || await getUploadedFile(psd.uploadId)
|
||
if (!tempFilePath) {
|
||
throw new Error(`PSD 文件缺失或未上传完成: ${psd.fileName} (${psd.uploadId})`)
|
||
}
|
||
assembledFiles?.set(psd.uploadId, tempFilePath)
|
||
|
||
// 数据库最终事务提交前绝不能移动/消费用户源文件。这里始终复制到正式
|
||
// PSD 路径;成功提交后再由 cleanupTempFiles 受控清理上传会话。
|
||
await copyFile(tempFilePath, permanentFilePath)
|
||
|
||
await syncStoredUploadFileToRemote(permanentFilePath, relativePath, psd.fileSize)
|
||
|
||
psdPermanentPaths.push({
|
||
filePath: relativePath,
|
||
fileName: psd.fileName,
|
||
fileSize: psd.fileSize,
|
||
})
|
||
}
|
||
|
||
return psdPermanentPaths
|
||
}
|
||
|
||
function collectProductPackageUploadIds(assets: ProductPackageAssetsInput): string[] {
|
||
const uploadIds: string[] = []
|
||
const imageAssets = [assets.main || [], assets.sku || [], assets.detail || []].flat()
|
||
for (const asset of [...imageAssets, ...(assets.psd || [])]) {
|
||
const uploadId = String(asset.uploadId || '').trim()
|
||
if (uploadId) uploadIds.push(uploadId)
|
||
}
|
||
for (const asset of imageAssets) {
|
||
for (const preview of asset.previewAssets || []) {
|
||
const uploadId = String(preview.uploadId || '').trim()
|
||
if (uploadId) uploadIds.push(uploadId)
|
||
}
|
||
}
|
||
return Array.from(new Set(uploadIds)).sort()
|
||
}
|
||
|
||
type ExpectedProductPackageAsset = {
|
||
assetType: string
|
||
fileUrl: string
|
||
fileName: string | null
|
||
fileSize: bigint
|
||
sortOrder: number
|
||
skuCode: string | null
|
||
barcodes: string[]
|
||
}
|
||
|
||
type ProductPackageCommitExpectation = {
|
||
taskNo: string | null
|
||
dingtalkApprovalNo: string | null
|
||
previewUrl: string
|
||
zipFilePath: string
|
||
zipFileName: string
|
||
zipFileSize: bigint
|
||
isFullSet: boolean
|
||
assets: ExpectedProductPackageAsset[]
|
||
}
|
||
|
||
type ProductPackageCommitInspection =
|
||
| { state: 'completed'; createdSkuPreviewAssets: SavedProductPackageSkuPreviewAsset[] }
|
||
| { state: 'completed_incomplete' }
|
||
| { state: 'retryable'; taskStatus: string | null }
|
||
| { state: 'other'; taskStatus: string | null | undefined }
|
||
| { state: 'unknown'; error: unknown }
|
||
|
||
function buildProductPackageCommitExpectation(input: {
|
||
previewAssets: ProductPackageAssetGroups
|
||
zipInfos: ProductPackageZipInfos
|
||
psdInfos: ProductPackagePsdInfo[]
|
||
taskNo: string | null
|
||
dingtalkApprovalNo: string | null
|
||
previewUrl: string
|
||
isFullSet: boolean
|
||
}): ProductPackageCommitExpectation {
|
||
const assets: ExpectedProductPackageAsset[] = []
|
||
let previewIndex = 0
|
||
const pushPreview = (
|
||
assetType: string,
|
||
asset: ProductPackageAssetGroups['main'][number],
|
||
fallbackName: string,
|
||
) => {
|
||
assets.push({
|
||
assetType,
|
||
fileUrl: String(asset.fileUrl),
|
||
fileName: asset.fileName || fallbackName,
|
||
fileSize: BigInt(0),
|
||
sortOrder: previewIndex++,
|
||
skuCode: asset.skuCode?.trim() || null,
|
||
barcodes: Array.from(new Set((asset.barcodes || []).filter(Boolean))).sort(),
|
||
})
|
||
}
|
||
|
||
input.previewAssets.main.forEach((asset, index) => pushPreview('main-preview', asset, `main-preview-${index}.webp`))
|
||
input.previewAssets.sku.forEach((asset, index) => pushPreview('sku-preview', asset, `sku-preview-${index}.webp`))
|
||
input.previewAssets.detail.forEach((asset, index) => pushPreview('detail-preview', asset, `detail-preview-${index}.webp`))
|
||
|
||
const pushStored = (
|
||
assetType: string,
|
||
info: { filePath: string; fileName: string; fileSize: number },
|
||
sortOrder = 0,
|
||
) => assets.push({
|
||
assetType,
|
||
fileUrl: info.filePath,
|
||
fileName: info.fileName,
|
||
fileSize: BigInt(info.fileSize),
|
||
sortOrder,
|
||
skuCode: null,
|
||
barcodes: [],
|
||
})
|
||
if (input.zipInfos.main) pushStored('main', input.zipInfos.main)
|
||
if (input.zipInfos.sku) pushStored('sku', input.zipInfos.sku)
|
||
if (input.zipInfos.detail) pushStored('detail', input.zipInfos.detail)
|
||
pushStored('all', input.zipInfos.all)
|
||
input.psdInfos.forEach((info, index) => pushStored('psd', info, index))
|
||
|
||
return {
|
||
taskNo: input.taskNo,
|
||
dingtalkApprovalNo: input.dingtalkApprovalNo,
|
||
previewUrl: input.previewUrl,
|
||
zipFilePath: input.zipInfos.all.filePath,
|
||
zipFileName: input.zipInfos.all.fileName,
|
||
zipFileSize: BigInt(input.zipInfos.all.fileSize),
|
||
isFullSet: input.isFullSet,
|
||
assets,
|
||
}
|
||
}
|
||
|
||
function productPackageAssetKey(asset: ExpectedProductPackageAsset): string {
|
||
return JSON.stringify([
|
||
asset.assetType,
|
||
asset.fileUrl,
|
||
asset.fileName,
|
||
asset.fileSize.toString(),
|
||
asset.sortOrder,
|
||
asset.skuCode,
|
||
[...asset.barcodes].sort(),
|
||
])
|
||
}
|
||
|
||
async function inspectProductPackageCommit(
|
||
materialId: string,
|
||
expected: ProductPackageCommitExpectation,
|
||
): Promise<ProductPackageCommitInspection> {
|
||
try {
|
||
const material = await prisma.material.findUnique({
|
||
where: { id: materialId },
|
||
select: {
|
||
taskStatus: true,
|
||
taskNo: true,
|
||
dingtalkApprovalNo: true,
|
||
previewUrl: true,
|
||
zipFilePath: true,
|
||
zipFileName: true,
|
||
zipFileSize: true,
|
||
isFullSet: true,
|
||
assets: {
|
||
select: {
|
||
id: true,
|
||
assetType: true,
|
||
fileUrl: true,
|
||
fileName: true,
|
||
fileSize: true,
|
||
sortOrder: true,
|
||
skuCode: true,
|
||
createdAt: true,
|
||
updatedAt: true,
|
||
barcodes: { select: { barcode: true } },
|
||
},
|
||
},
|
||
},
|
||
})
|
||
if (material?.taskStatus === 'completed') {
|
||
const actualAssets = material.assets.map((asset) => ({
|
||
assetType: asset.assetType,
|
||
fileUrl: String(asset.fileUrl),
|
||
fileName: asset.fileName,
|
||
fileSize: typeof asset.fileSize === 'bigint' ? asset.fileSize : BigInt(asset.fileSize || 0),
|
||
sortOrder: asset.sortOrder,
|
||
skuCode: asset.skuCode?.trim() || null,
|
||
barcodes: asset.barcodes.map((item) => item.barcode).filter(Boolean).sort(),
|
||
}))
|
||
const expectedKeys = expected.assets.map(productPackageAssetKey).sort()
|
||
const actualKeys = actualAssets.map(productPackageAssetKey).sort()
|
||
const fieldsMatch = (
|
||
(material.taskNo?.trim() || null) === expected.taskNo
|
||
&& (material.dingtalkApprovalNo?.trim() || null) === expected.dingtalkApprovalNo
|
||
&& String(material.previewUrl || '') === expected.previewUrl
|
||
&& String(material.zipFilePath || '') === expected.zipFilePath
|
||
&& String(material.zipFileName || '') === expected.zipFileName
|
||
&& (typeof material.zipFileSize === 'bigint' ? material.zipFileSize : BigInt(material.zipFileSize || 0)) === expected.zipFileSize
|
||
&& Boolean(material.isFullSet) === expected.isFullSet
|
||
)
|
||
if (fieldsMatch && JSON.stringify(actualKeys) === JSON.stringify(expectedKeys)) {
|
||
return {
|
||
state: 'completed',
|
||
createdSkuPreviewAssets: material.assets
|
||
.filter((asset) => asset.assetType === 'sku-preview')
|
||
.map((asset) => ({
|
||
id: asset.id,
|
||
fileUrl: String(asset.fileUrl),
|
||
fileName: asset.fileName,
|
||
sortOrder: asset.sortOrder,
|
||
skuCode: asset.skuCode?.trim() || null,
|
||
createdAt: asset.createdAt,
|
||
updatedAt: asset.updatedAt,
|
||
barcodes: asset.barcodes.map((item) => item.barcode).filter(Boolean).sort(),
|
||
})),
|
||
}
|
||
}
|
||
return { state: 'completed_incomplete' }
|
||
}
|
||
if (material?.taskStatus === 'uploading' || material?.taskStatus == null) {
|
||
return { state: 'retryable', taskStatus: material?.taskStatus ?? null }
|
||
}
|
||
return { state: 'other', taskStatus: material?.taskStatus }
|
||
} catch (error) {
|
||
return { state: 'unknown', error }
|
||
}
|
||
}
|
||
|
||
export async function completeProductPackageUpload(input: CompleteProductPackageUploadInput) {
|
||
const { materialId, taskNo, dingtalkApprovalNo, assets, operatorUserId, options } = input
|
||
const tempFiles: string[] = []
|
||
const uploadIds: string[] = []
|
||
const assembledFiles = new Map<string, string>()
|
||
const uploadedPreviewUrls: string[] = []
|
||
const privatePreviewPaths: string[] = []
|
||
const createdZipPaths: string[] = []
|
||
const requestedUploadIds = collectProductPackageUploadIds(assets)
|
||
let persistentStateCommitted = false
|
||
let completionTransactionAttempted = false
|
||
let commitExpectation: ProductPackageCommitExpectation | null = null
|
||
let releaseCompletionLock: MaterialUploadStorageLockRelease | null = null
|
||
let releaseUploadSessionLocks: MaterialUploadStorageLockRelease | null = null
|
||
const startedAt = Date.now()
|
||
const assertExecutionCurrent = async () => {
|
||
if (options?.signal?.aborted) {
|
||
const reason = options.signal.reason
|
||
throw reason instanceof Error ? reason : new Error(String(reason || '任务处理已取消'))
|
||
}
|
||
await options?.assertExecutionCurrent?.()
|
||
}
|
||
const reportProgress = async (stage: string, progress: number, message?: string) => {
|
||
await assertExecutionCurrent()
|
||
console.log(
|
||
`[ProductPackage] material=${materialId} stage=${stage} progress=${progress} elapsedMs=${Date.now() - startedAt}`,
|
||
)
|
||
try {
|
||
await options?.onProgress?.({ stage, progress, message })
|
||
} catch (error) {
|
||
console.warn('[ProductPackage] progress update skipped:', error)
|
||
}
|
||
await assertExecutionCurrent()
|
||
}
|
||
const normalizedAssets = {
|
||
main: assets.main || [],
|
||
sku: normalizeSkuPreviewAssets(assets.sku, options),
|
||
detail: assets.detail || [],
|
||
psd: assets.psd || [],
|
||
}
|
||
const derivedIsFullSet = isTaskDetailFullSetFromUploadGroups(normalizedAssets)
|
||
|
||
const zipTimeoutMs = options?.zipTimeoutMs && options.zipTimeoutMs > 0 ? options.zipTimeoutMs : 30000
|
||
const allZipTimeoutMs = options?.allZipTimeoutMs && options.allZipTimeoutMs > 0 ? options.allZipTimeoutMs : 60000
|
||
const configuredCompressionLevel = Number(
|
||
options?.zipCompressionLevel ?? process.env.TASK_DETAIL_ZIP_COMPRESSION_LEVEL ?? 1,
|
||
)
|
||
const zipCompressionLevel = Number.isFinite(configuredCompressionLevel)
|
||
? Math.max(0, Math.min(9, Math.round(configuredCompressionLevel)))
|
||
: 1
|
||
|
||
if (!materialId || !assets) {
|
||
throw new Error('缺少必填参数')
|
||
}
|
||
|
||
const totalImages =
|
||
normalizedAssets.main.length + normalizedAssets.sku.length + normalizedAssets.detail.length
|
||
if (totalImages === 0) {
|
||
throw new Error('请至少上传一张图片')
|
||
}
|
||
|
||
const normalizedTaskNo = taskNo?.trim() || null
|
||
if (normalizedTaskNo && !validateTaskNo(normalizedTaskNo)) {
|
||
throw new Error('任务号格式错误')
|
||
}
|
||
const normalizedDingtalkApprovalNo = normalizeDingtalkApprovalNo(dingtalkApprovalNo)
|
||
const dingtalkApprovalNoError = getDingtalkApprovalNoError(normalizedDingtalkApprovalNo)
|
||
if (dingtalkApprovalNoError) {
|
||
throw new Error(dingtalkApprovalNoError)
|
||
}
|
||
|
||
try {
|
||
releaseCompletionLock = await acquireTaskMaterialCompletionLock(materialId)
|
||
if (requestedUploadIds.length > 0) {
|
||
releaseUploadSessionLocks = await acquireTaskMaterialUploadSessionLocks(requestedUploadIds)
|
||
await refreshOwnedUploadSessionTtls(requestedUploadIds)
|
||
}
|
||
await assertOwnedProductPackageUploadSessions(assets, operatorUserId)
|
||
await reportProgress('validating', 5)
|
||
await reportProgress('validating', 10)
|
||
const material: any = await prisma.material.findUnique({
|
||
where: { id: materialId },
|
||
include: {
|
||
user: {
|
||
select: {
|
||
realName: true,
|
||
username: true,
|
||
},
|
||
},
|
||
},
|
||
})
|
||
|
||
if (!material) {
|
||
throw new Error('素材不存在')
|
||
}
|
||
if (material.userId && material.userId !== operatorUserId) {
|
||
throw new Error('无权限操作此素材')
|
||
}
|
||
if (material.taskStatus === 'completed') {
|
||
throw new Error('素材已经完成,请勿重复提交')
|
||
}
|
||
const isLegacyEmptyUpload = (
|
||
material.taskStatus == null
|
||
&& !material.previewUrl
|
||
&& !material.zipFilePath
|
||
)
|
||
if (material.taskStatus !== 'uploading' && !isLegacyEmptyUpload) {
|
||
throw new Error('素材状态已变化,请刷新后重试')
|
||
}
|
||
|
||
if (
|
||
material.materialType === '任务-详情' &&
|
||
!normalizeTaskDetailSourceType(material.taskDetailSourceType)
|
||
) {
|
||
throw new Error('任务-详情必须选择出图方式(实拍、渲染或 AI)')
|
||
}
|
||
|
||
const authorName = material.user?.realName || material.user?.username || '未知作者'
|
||
const categoryName = material.categoryName || '未知店铺'
|
||
|
||
await reportProgress('assembling', 15)
|
||
const mainFiles = await collectAssetFiles(normalizedAssets.main, 'main', tempFiles, uploadIds, assembledFiles)
|
||
const skuFiles = await collectAssetFiles(normalizedAssets.sku, 'sku', tempFiles, uploadIds, assembledFiles)
|
||
const detailFiles = await collectAssetFiles(normalizedAssets.detail, 'detail', tempFiles, uploadIds, assembledFiles)
|
||
const mainEntryNames = resolveProductPackageZipEntryNames(mainFiles.map((file) => file.name))
|
||
const skuEntryNames = resolveProductPackageZipEntryNames(skuFiles.map((file) => file.name))
|
||
const detailEntryNames = resolveProductPackageZipEntryNames(detailFiles.map((file) => file.name))
|
||
for (const [files, names] of [[mainFiles, mainEntryNames], [skuFiles, skuEntryNames], [detailFiles, detailEntryNames]] as const) {
|
||
files.forEach((file, index) => { file.name = names[index] })
|
||
}
|
||
await reportProgress('assembling', 30)
|
||
|
||
const previewAssets = {
|
||
main: await generatePreviewsFromAssets(
|
||
materialId,
|
||
normalizedAssets.main,
|
||
'main',
|
||
mainEntryNames,
|
||
uploadedPreviewUrls,
|
||
privatePreviewPaths,
|
||
Boolean(material.taskDetailPrivate),
|
||
assembledFiles,
|
||
tempFiles,
|
||
uploadIds,
|
||
),
|
||
sku: await generatePreviewsFromAssets(
|
||
materialId,
|
||
normalizedAssets.sku,
|
||
'sku',
|
||
skuEntryNames,
|
||
uploadedPreviewUrls,
|
||
privatePreviewPaths,
|
||
Boolean(material.taskDetailPrivate),
|
||
assembledFiles,
|
||
tempFiles,
|
||
uploadIds,
|
||
),
|
||
detail: await generatePreviewsFromAssets(
|
||
materialId,
|
||
normalizedAssets.detail,
|
||
'detail',
|
||
detailEntryNames,
|
||
uploadedPreviewUrls,
|
||
privatePreviewPaths,
|
||
Boolean(material.taskDetailPrivate),
|
||
assembledFiles,
|
||
tempFiles,
|
||
uploadIds,
|
||
),
|
||
}
|
||
await reportProgress('previews', 55)
|
||
|
||
const zipInfos: {
|
||
main?: { filePath: string; fileName: string; fileSize: number }
|
||
sku?: { filePath: string; fileName: string; fileSize: number }
|
||
detail?: { filePath: string; fileName: string; fileSize: number }
|
||
all: { filePath: string; fileName: string; fileSize: number }
|
||
} = {} as any
|
||
|
||
const zipFilesForAll: Array<{ path: string; name: string }> = []
|
||
|
||
if (mainFiles.length > 0) {
|
||
zipInfos.main = await createZipFromFiles(
|
||
mainFiles,
|
||
materialId,
|
||
material.productModel || 'product',
|
||
'任务-详情',
|
||
'主图',
|
||
authorName,
|
||
categoryName,
|
||
zipTimeoutMs,
|
||
zipCompressionLevel,
|
||
)
|
||
createdZipPaths.push(zipInfos.main.filePath)
|
||
zipFilesForAll.push({ path: zipInfos.main.filePath, name: zipInfos.main.fileName })
|
||
}
|
||
|
||
if (skuFiles.length > 0) {
|
||
zipInfos.sku = await createZipFromFiles(
|
||
skuFiles,
|
||
materialId,
|
||
material.productModel || 'product',
|
||
'任务-详情',
|
||
'SKU图',
|
||
authorName,
|
||
categoryName,
|
||
zipTimeoutMs,
|
||
zipCompressionLevel,
|
||
)
|
||
createdZipPaths.push(zipInfos.sku.filePath)
|
||
zipFilesForAll.push({ path: zipInfos.sku.filePath, name: zipInfos.sku.fileName })
|
||
}
|
||
|
||
if (detailFiles.length > 0) {
|
||
zipInfos.detail = await createZipFromFiles(
|
||
detailFiles,
|
||
materialId,
|
||
material.productModel || 'product',
|
||
'任务-详情',
|
||
'详情图',
|
||
authorName,
|
||
categoryName,
|
||
zipTimeoutMs,
|
||
zipCompressionLevel,
|
||
)
|
||
createdZipPaths.push(zipInfos.detail.filePath)
|
||
zipFilesForAll.push({ path: zipInfos.detail.filePath, name: zipInfos.detail.fileName })
|
||
}
|
||
|
||
zipInfos.all = await createZipFromZips(
|
||
zipFilesForAll,
|
||
materialId,
|
||
material.productModel || 'product',
|
||
'任务-详情',
|
||
allZipTimeoutMs,
|
||
zipCompressionLevel,
|
||
)
|
||
createdZipPaths.push(zipInfos.all.filePath)
|
||
await reportProgress('zips', 80)
|
||
|
||
const psdPermanentPaths = await movePsdFilesToPermanentDir(
|
||
materialId,
|
||
normalizedAssets.psd,
|
||
tempFiles,
|
||
uploadIds,
|
||
assembledFiles,
|
||
)
|
||
createdZipPaths.push(...psdPermanentPaths.map((item) => item.filePath))
|
||
await reportProgress('database', 85)
|
||
|
||
const cardPreviewUrl = (
|
||
previewAssets.main[0]?.fileUrl
|
||
|| previewAssets.sku[0]?.fileUrl
|
||
|| previewAssets.detail[0]?.fileUrl
|
||
|| material.previewUrl
|
||
)
|
||
commitExpectation = buildProductPackageCommitExpectation({
|
||
previewAssets,
|
||
zipInfos,
|
||
psdInfos: psdPermanentPaths,
|
||
taskNo: normalizedTaskNo,
|
||
dingtalkApprovalNo: normalizedDingtalkApprovalNo,
|
||
previewUrl: cardPreviewUrl,
|
||
isFullSet: derivedIsFullSet,
|
||
})
|
||
|
||
releaseCompletionLock.assertHeld()
|
||
releaseUploadSessionLocks?.assertHeld()
|
||
await assertExecutionCurrent()
|
||
completionTransactionAttempted = true
|
||
const saveResult = await saveProductPackageAssetsAndComplete({
|
||
materialId,
|
||
previewAssets,
|
||
zipInfos,
|
||
psdInfos: psdPermanentPaths,
|
||
cardPreviewUrl,
|
||
isFullSet: derivedIsFullSet,
|
||
taskNo: normalizedTaskNo,
|
||
dingtalkApprovalNo: normalizedDingtalkApprovalNo,
|
||
userId: operatorUserId,
|
||
materialType: '任务-详情',
|
||
})
|
||
persistentStateCommitted = true
|
||
await reportProgress('database', 93)
|
||
|
||
releaseCompletionLock.assertHeld()
|
||
releaseUploadSessionLocks?.assertHeld()
|
||
await cleanupTempFiles(tempFiles, uploadIds).catch((cleanupError) => {
|
||
// completed 已确认,源会话清理失败只记录告警;绝不能反向删除正式资产。
|
||
console.warn('[ProductPackage] upload completed but source-session cleanup failed:', cleanupError)
|
||
})
|
||
await reportProgress('cleanup', 97)
|
||
|
||
try {
|
||
await createMaterialLog({
|
||
materialId,
|
||
userId: operatorUserId,
|
||
action: 'UPLOAD',
|
||
changes: {
|
||
uploadType: {
|
||
oldValue: null,
|
||
newValue: '任务-详情',
|
||
},
|
||
taskNo: {
|
||
oldValue: null,
|
||
newValue: normalizedTaskNo,
|
||
},
|
||
dingtalkApprovalNo: {
|
||
oldValue: null,
|
||
newValue: normalizedDingtalkApprovalNo,
|
||
},
|
||
isFullSet: {
|
||
oldValue: false,
|
||
newValue: derivedIsFullSet,
|
||
},
|
||
assetCounts: {
|
||
oldValue: null,
|
||
newValue: {
|
||
main: mainFiles.length,
|
||
sku: skuFiles.length,
|
||
detail: detailFiles.length,
|
||
psd: normalizedAssets.psd.length,
|
||
},
|
||
},
|
||
zipSizes: {
|
||
oldValue: null,
|
||
newValue: {
|
||
main: zipInfos.main?.fileSize || 0,
|
||
sku: zipInfos.sku?.fileSize || 0,
|
||
detail: zipInfos.detail?.fileSize || 0,
|
||
all: zipInfos.all.fileSize,
|
||
},
|
||
},
|
||
},
|
||
})
|
||
} catch (logError) {
|
||
// 主数据已经提交,日志失败不能再回滚 ZIP/S3,避免产生“数据库完成但文件被删”的坏素材。
|
||
console.error('[ProductPackage] material log write failed after commit:', logError)
|
||
}
|
||
|
||
if (derivedIsFullSet) {
|
||
void queueTaskDetailFullSetNotification(materialId).catch((notificationError) => {
|
||
console.error('[ProductPackage] full-set notification failed:', notificationError)
|
||
})
|
||
}
|
||
|
||
if (saveResult.createdSkuPreviewAssets.some((asset) => asset.barcodes.length > 0)) {
|
||
void queueTaskDetailBarcodeImagesUpdatedNotification(
|
||
materialId,
|
||
saveResult.createdSkuPreviewAssets.map((asset) => asset.id),
|
||
).catch((notificationError) => {
|
||
console.error('[ProductPackage] barcode notification failed:', notificationError)
|
||
})
|
||
}
|
||
|
||
await reportProgress('completed', 100)
|
||
|
||
return {
|
||
materialId,
|
||
taskNo: normalizedTaskNo,
|
||
dingtalkApprovalNo: normalizedDingtalkApprovalNo,
|
||
isFullSet: derivedIsFullSet,
|
||
}
|
||
} catch (error) {
|
||
if ((completionTransactionAttempted || persistentStateCommitted) && commitExpectation) {
|
||
const inspection = await inspectProductPackageCommit(materialId, commitExpectation)
|
||
if (inspection.state === 'completed') {
|
||
try {
|
||
releaseCompletionLock?.assertHeld()
|
||
releaseUploadSessionLocks?.assertHeld()
|
||
await cleanupTempFiles(tempFiles, uploadIds)
|
||
} catch (cleanupError) {
|
||
console.warn('[ProductPackage] commit confirmed but source-session cleanup failed:', cleanupError)
|
||
}
|
||
if (inspection.createdSkuPreviewAssets.some((asset) => asset.barcodes.length > 0)) {
|
||
void queueTaskDetailBarcodeImagesUpdatedNotification(
|
||
materialId,
|
||
inspection.createdSkuPreviewAssets.map((asset) => asset.id),
|
||
).catch((notificationError) => {
|
||
console.error('[ProductPackage] barcode notification failed after commit recovery:', notificationError)
|
||
})
|
||
}
|
||
if (derivedIsFullSet) {
|
||
void queueTaskDetailFullSetNotification(materialId).catch((notificationError) => {
|
||
console.error('[ProductPackage] full-set notification failed after commit recovery:', notificationError)
|
||
})
|
||
}
|
||
console.warn('[ProductPackage] transaction commit confirmed by database reread after response error:', error)
|
||
return {
|
||
materialId,
|
||
taskNo: normalizedTaskNo,
|
||
dingtalkApprovalNo: normalizedDingtalkApprovalNo,
|
||
isFullSet: derivedIsFullSet,
|
||
warning: '素材已保存,但响应确认阶段出现异常',
|
||
}
|
||
}
|
||
if (inspection.state === 'completed_incomplete') {
|
||
console.error('[ProductPackage] completed state is incomplete; preserve all files:', error)
|
||
throw new Error('素材完成记录不完整,服务器已保留全部文件,请联系管理员核查')
|
||
}
|
||
if (inspection.state === 'unknown') {
|
||
console.error('[ProductPackage] database final state is unknown; preserve all files:', inspection.error)
|
||
throw new Error('完成状态暂时无法确认,服务器已保留上传文件,请勿重复提交')
|
||
}
|
||
if (inspection.state === 'other') {
|
||
console.warn('[ProductPackage] material state was taken over; preserve all files:', inspection)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// 只有确认事务未提交(或根本尚未尝试提交)时才清理本次派生物。
|
||
// tempFiles/uploadIds 是用户源会话,失败时永远保留以便原地重试。
|
||
await cleanupOnError(
|
||
uploadedPreviewUrls,
|
||
createdZipPaths,
|
||
[],
|
||
null,
|
||
[],
|
||
false,
|
||
)
|
||
await cleanupGeneratedPrivatePreviews(privatePreviewPaths)
|
||
throw error
|
||
} finally {
|
||
await releaseUploadSessionLocks?.()
|
||
await releaseCompletionLock?.()
|
||
}
|
||
}
|