import { prisma } from '@/lib/prisma' import { validateTaskNo } from '@/lib/product-package' import { cleanupOnError, cleanupTempFiles, createZipFromFiles, createZipFromZips, 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 onProgress?: (update: { stage: string; progress: number; message?: string }) => Promise | 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 /** * 服务端与后台 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) { 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 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() 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) { 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() 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, 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, 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, ) { 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', uploadedPreviewUrls: string[], privatePreviewPaths: string[], isPrivate: boolean, assembledFiles?: Map, 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] 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 fileName = safePreviewFileName(previewAsset.fileName, 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 = `${type}-preview-${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 { 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, ) { 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 { 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() 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) await reportProgress('assembling', 30) const previewAssets = { main: await generatePreviewsFromAssets( materialId, normalizedAssets.main, 'main', uploadedPreviewUrls, privatePreviewPaths, Boolean(material.taskDetailPrivate), assembledFiles, tempFiles, uploadIds, ), sku: await generatePreviewsFromAssets( materialId, normalizedAssets.sku, 'sku', uploadedPreviewUrls, privatePreviewPaths, Boolean(material.taskDetailPrivate), assembledFiles, tempFiles, uploadIds, ), detail: await generatePreviewsFromAssets( materialId, normalizedAssets.detail, 'detail', 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?.() } }