668 lines
20 KiB
TypeScript
668 lines
20 KiB
TypeScript
import { prisma } from '@/lib/prisma'
|
|
import { assignTaskNoOnComplete, validateTaskNo } from '@/lib/product-package'
|
|
import {
|
|
cleanupOnError,
|
|
cleanupTempFiles,
|
|
createZipFromFiles,
|
|
createZipFromZips,
|
|
saveProductPackageAssets,
|
|
} from '@/lib/task-material-processor'
|
|
import { buildStoredUploadPath, getUploadRootDir } from '@/lib/upload-paths'
|
|
import { isRemoteUploadSyncEnabled, syncStoredUploadFileToRemote } from '@/lib/remote-upload-sync'
|
|
import { getOwnedUploadSession, getUploadedFile } from '@/lib/upload-sessions'
|
|
import { createMaterialLog } from '@/lib/material-log'
|
|
import { isTaskDetailFullSetFromUploadGroups } from '@/lib/task-detail-full-set'
|
|
import {
|
|
buildTaskDetailPublicDisplayPath,
|
|
uploadPublicDisplayToS3,
|
|
} from '@/lib/public-display'
|
|
import {
|
|
queueTaskDetailBarcodeImagesUpdatedNotification,
|
|
queueTaskDetailFullSetNotification,
|
|
} from '@/lib/external-auto-listing'
|
|
import path from 'path'
|
|
import { mkdir, readFile, writeFile } from 'fs/promises'
|
|
|
|
export interface ProductPackageImageInput {
|
|
uploadId?: string
|
|
fileUrl?: string
|
|
localFilePath?: string
|
|
fileName: string
|
|
fileSize: number
|
|
sortOrder?: number
|
|
skuCode?: string
|
|
barcodes?: string[]
|
|
}
|
|
|
|
export interface ProductPackageSourceInput {
|
|
uploadId?: string
|
|
fileUrl?: string
|
|
localFilePath?: string
|
|
fileName: string
|
|
fileSize: number
|
|
}
|
|
|
|
export interface ProductPackageAssetsInput {
|
|
main?: ProductPackageImageInput[]
|
|
sku?: ProductPackageImageInput[]
|
|
detail?: ProductPackageImageInput[]
|
|
psd?: ProductPackageSourceInput[]
|
|
}
|
|
|
|
export interface CompleteProductPackageUploadInput {
|
|
materialId: string
|
|
taskNo?: string | null
|
|
operatorUserId: string
|
|
assets: ProductPackageAssetsInput
|
|
options?: {
|
|
allowSkuWithoutBarcodes?: boolean
|
|
zipTimeoutMs?: number
|
|
allZipTimeoutMs?: number
|
|
}
|
|
}
|
|
|
|
type PublicProductPackageAsset = Record<string, unknown>
|
|
|
|
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',
|
|
)
|
|
}
|
|
}
|
|
|
|
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>
|
|
return {
|
|
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,
|
|
}
|
|
}
|
|
|
|
async function assertOwnedProductPackageUploadSessions(
|
|
assets: ProductPackageAssetsInput,
|
|
operatorUserId: string,
|
|
) {
|
|
const uploadIds = Array.from(new Set(
|
|
[assets.main, assets.sku, assets.detail, assets.psd]
|
|
.flatMap((group) => group || [])
|
|
.map((asset) => String(asset.uploadId || '').trim())
|
|
.filter(Boolean),
|
|
))
|
|
|
|
for (const uploadId of uploadIds) {
|
|
if (!(await getOwnedUploadSession(uploadId, operatorUserId))) {
|
|
throw new Error(`上传会话不存在、已失效或不属于当前用户: ${uploadId}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
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: ProductPackageImageInput | ProductPackageSourceInput,
|
|
type: 'main' | 'sku' | 'detail' | 'psd',
|
|
) {
|
|
if (asset.localFilePath) {
|
|
return readFile(asset.localFilePath)
|
|
}
|
|
|
|
if (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 getUploadedFile(asset.uploadId)
|
|
if (!filePath) {
|
|
throw new Error(`文件不存在: ${asset.fileName} (${asset.uploadId})`)
|
|
}
|
|
return readFile(filePath)
|
|
}
|
|
|
|
throw new Error(`无效的${type}资产: ${asset.fileName}`)
|
|
}
|
|
|
|
async function collectAssetFiles(
|
|
assets: ProductPackageImageInput[] | undefined,
|
|
prefix: string,
|
|
tempFiles: string[],
|
|
uploadIds: 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 filePath = await getUploadedFile(asset.uploadId)
|
|
if (!filePath) {
|
|
throw new Error(`文件缺失或未上传完成: ${asset.fileName} (${asset.uploadId})`)
|
|
}
|
|
files.push({
|
|
path: filePath,
|
|
name: asset.fileName,
|
|
size: asset.fileSize,
|
|
})
|
|
tempFiles.push(filePath)
|
|
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',
|
|
uploadedPreviewUrls: string[],
|
|
) {
|
|
if (!assets || assets.length === 0) {
|
|
return []
|
|
}
|
|
|
|
const previews: GeneratedProductPackagePreviewAsset[] = []
|
|
const { compressImage, sliceImage } = await import('@/lib/image-processor')
|
|
const sharp = await import('sharp')
|
|
|
|
for (let index = 0; index < assets.length; index += 1) {
|
|
const asset = assets[index]
|
|
let buffer: Buffer
|
|
|
|
buffer = await readAssetBuffer(asset, type)
|
|
|
|
const metadata = await sharp.default(buffer).metadata()
|
|
const shouldSlice = type === 'detail' && metadata.height && metadata.height > 1500
|
|
const buffers = shouldSlice
|
|
? await sliceImage(buffer, 1500, 200)
|
|
: [
|
|
await compressImage(buffer, {
|
|
maxWidth: 800,
|
|
maxHeight: 1200,
|
|
maxSizeKB: 200,
|
|
format: 'webp',
|
|
}),
|
|
]
|
|
|
|
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 = `${type}-preview-${timestamp}-${index}${sliceSuffix}-${randomSuffix}.webp`
|
|
const previewUrl = await uploadPublicDisplayToS3(
|
|
buffers[sliceIndex],
|
|
buildTaskDetailPublicDisplayPath(materialId, type, fileName),
|
|
'image/webp',
|
|
)
|
|
previews.push({
|
|
fileUrl: previewUrl,
|
|
fileName,
|
|
sortOrder: previews.length,
|
|
...(type === 'sku'
|
|
? {
|
|
skuCode: asset.skuCode,
|
|
barcodes: asset.barcodes || [],
|
|
}
|
|
: {}),
|
|
})
|
|
uploadedPreviewUrls.push(previewUrl)
|
|
}
|
|
}
|
|
|
|
return previews
|
|
}
|
|
|
|
async function movePsdFilesToPermanentDir(
|
|
materialId: string,
|
|
assets: ProductPackageSourceInput[] | undefined,
|
|
tempFiles: string[],
|
|
) {
|
|
const psdPermanentPaths: Array<{ filePath: string; fileName: string; fileSize: number }> = []
|
|
|
|
if (!assets || assets.length === 0) {
|
|
return psdPermanentPaths
|
|
}
|
|
|
|
const { rename, copyFile, stat } = await import('fs/promises')
|
|
const psdDir = path.join(getUploadRootDir(), 'materials')
|
|
await mkdir(psdDir, { recursive: true })
|
|
|
|
for (const psd of assets) {
|
|
if (!psd.uploadId) {
|
|
if (!psd.fileUrl && !psd.localFilePath) {
|
|
throw new Error(`PSD 资产参数无效: ${psd.fileName}`)
|
|
}
|
|
|
|
const timestamp = Date.now()
|
|
const sanitizedFileName = psd.fileName.replace(/[\/\\:*?"<>|]/g, '_')
|
|
const permanentFileName = `psd-${materialId}-${timestamp}-${sanitizedFileName}`
|
|
const permanentFilePath = path.join(psdDir, permanentFileName)
|
|
const relativePath = buildStoredUploadPath('materials', permanentFileName)
|
|
|
|
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
|
|
}
|
|
|
|
const tempFilePath = await getUploadedFile(psd.uploadId)
|
|
if (!tempFilePath) {
|
|
throw new Error(`PSD 文件缺失或未上传完成: ${psd.fileName} (${psd.uploadId})`)
|
|
}
|
|
|
|
const timestamp = Date.now()
|
|
const sanitizedFileName = psd.fileName.replace(/[\/\\:*?"<>|]/g, '_')
|
|
const permanentFileName = `psd-${materialId}-${timestamp}-${sanitizedFileName}`
|
|
const permanentFilePath = path.join(psdDir, permanentFileName)
|
|
const relativePath = buildStoredUploadPath('materials', permanentFileName)
|
|
|
|
try {
|
|
await rename(tempFilePath, permanentFilePath)
|
|
} catch {
|
|
await copyFile(tempFilePath, permanentFilePath)
|
|
tempFiles.push(tempFilePath)
|
|
}
|
|
|
|
await syncStoredUploadFileToRemote(permanentFilePath, relativePath, psd.fileSize)
|
|
|
|
psdPermanentPaths.push({
|
|
filePath: relativePath,
|
|
fileName: psd.fileName,
|
|
fileSize: psd.fileSize,
|
|
})
|
|
}
|
|
|
|
return psdPermanentPaths
|
|
}
|
|
|
|
export async function completeProductPackageUpload(input: CompleteProductPackageUploadInput) {
|
|
const { materialId, taskNo, assets, operatorUserId, options } = input
|
|
const tempFiles: string[] = []
|
|
const uploadIds: string[] = []
|
|
const uploadedPreviewUrls: string[] = []
|
|
const createdZipPaths: string[] = []
|
|
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
|
|
|
|
if (!materialId || !assets) {
|
|
throw new Error('缺少必填参数')
|
|
}
|
|
|
|
await assertOwnedProductPackageUploadSessions(assets, operatorUserId)
|
|
|
|
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('任务号格式错误')
|
|
}
|
|
|
|
try {
|
|
const material: any = await prisma.material.findUnique({
|
|
where: { id: materialId },
|
|
include: {
|
|
user: {
|
|
select: {
|
|
realName: true,
|
|
username: true,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
if (!material) {
|
|
throw new Error('素材不存在')
|
|
}
|
|
|
|
const authorName = material.user?.realName || material.user?.username || '未知作者'
|
|
const categoryName = material.categoryName || '未知店铺'
|
|
|
|
const mainFiles = await collectAssetFiles(normalizedAssets.main, 'main', tempFiles, uploadIds)
|
|
const skuFiles = await collectAssetFiles(normalizedAssets.sku, 'sku', tempFiles, uploadIds)
|
|
const detailFiles = await collectAssetFiles(normalizedAssets.detail, 'detail', tempFiles, uploadIds)
|
|
|
|
const previewAssets = {
|
|
main: await generatePreviewsFromAssets(materialId, normalizedAssets.main, 'main', uploadedPreviewUrls),
|
|
sku: await generatePreviewsFromAssets(materialId, normalizedAssets.sku, 'sku', uploadedPreviewUrls),
|
|
detail: await generatePreviewsFromAssets(materialId, normalizedAssets.detail, 'detail', uploadedPreviewUrls),
|
|
}
|
|
|
|
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,
|
|
)
|
|
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,
|
|
)
|
|
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,
|
|
)
|
|
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,
|
|
)
|
|
createdZipPaths.push(zipInfos.all.filePath)
|
|
|
|
const psdPermanentPaths = await movePsdFilesToPermanentDir(materialId, normalizedAssets.psd, tempFiles)
|
|
createdZipPaths.push(...psdPermanentPaths.map((item) => item.filePath))
|
|
|
|
const saveResult = await saveProductPackageAssets(materialId, previewAssets, zipInfos, psdPermanentPaths)
|
|
|
|
const cardPreviewUrl = previewAssets.main[0]?.fileUrl || material.previewUrl
|
|
|
|
await assignTaskNoOnComplete(materialId, normalizedTaskNo)
|
|
await prisma.material.update({
|
|
where: { id: materialId },
|
|
data: {
|
|
previewUrl: cardPreviewUrl,
|
|
isFullSet: derivedIsFullSet,
|
|
},
|
|
})
|
|
|
|
await cleanupTempFiles(tempFiles, uploadIds)
|
|
|
|
await createMaterialLog({
|
|
materialId,
|
|
userId: operatorUserId,
|
|
action: 'UPLOAD',
|
|
changes: {
|
|
uploadType: {
|
|
oldValue: null,
|
|
newValue: '任务-详情',
|
|
},
|
|
taskNo: {
|
|
oldValue: null,
|
|
newValue: normalizedTaskNo,
|
|
},
|
|
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,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
if (derivedIsFullSet) {
|
|
void queueTaskDetailFullSetNotification(materialId)
|
|
}
|
|
|
|
if (saveResult.createdSkuPreviewAssets.some((asset) => asset.barcodes.length > 0)) {
|
|
void queueTaskDetailBarcodeImagesUpdatedNotification(
|
|
materialId,
|
|
saveResult.createdSkuPreviewAssets.map((asset) => asset.id),
|
|
)
|
|
}
|
|
|
|
return {
|
|
materialId,
|
|
taskNo: normalizedTaskNo,
|
|
isFullSet: derivedIsFullSet,
|
|
}
|
|
} catch (error) {
|
|
await cleanupOnError(uploadedPreviewUrls, createdZipPaths, tempFiles, materialId)
|
|
throw error
|
|
}
|
|
}
|