gongxiangp2/lib/product-package-completion.ts

1669 lines
60 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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?.()
}
}