gongxiangp2/lib/product-package-completion.ts

1077 lines
38 KiB
TypeScript
Raw 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 { assignTaskNoOnComplete, validateTaskNo } from '@/lib/product-package'
import {
cleanupOnError,
cleanupTempFiles,
createZipFromFiles,
createZipFromZips,
saveProductPackageAssets,
} from '@/lib/task-material-processor'
import { buildStoredUploadPath, getUploadRootDir } from '@/lib/upload-paths'
import { isRemoteUploadSyncEnabled, syncStoredUploadFileToRemote } from '@/lib/remote-upload-sync'
import { getOwnedUploadSession, getUploadedFile } from '@/lib/upload-sessions'
import { createMaterialLog } from '@/lib/material-log'
import { isTaskDetailFullSetFromUploadGroups } from '@/lib/task-detail-full-set'
import {
buildTaskDetailPublicDisplayPath,
uploadPublicDisplayToS3,
} from '@/lib/public-display'
import {
queueTaskDetailBarcodeImagesUpdatedNotification,
queueTaskDetailFullSetNotification,
} from '@/lib/external-auto-listing'
import path from 'path'
import { mkdir, readFile, writeFile } from 'fs/promises'
import { normalizeTaskDetailSourceType } from '@/lib/task-detail-source-type'
import {
getDingtalkApprovalNoError,
normalizeDingtalkApprovalNo,
} from '@/lib/dingtalk-approval-no'
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
onProgress?: (update: { stage: string; progress: number; message?: string }) => Promise<void> | void
}
}
type ProductPackageReadableAsset = ProductPackageImageInput | ProductPackageSourceInput | ProductPackagePreviewInput
type PublicProductPackageAsset = Record<string, unknown>
function parsePublicAssetGroup(
value: unknown,
group: 'main' | 'sku' | 'detail' | 'psd',
): ProductPackageImageInput[] | ProductPackageSourceInput[] | undefined {
if (value == null) {
return undefined
}
if (!Array.isArray(value)) {
throw new Error(`${group} 资产格式错误`)
}
return value.map((rawAsset, index) => {
if (!rawAsset || typeof rawAsset !== 'object' || Array.isArray(rawAsset)) {
throw new Error(`${group} 第 ${index + 1} 个资产格式错误`)
}
const asset = rawAsset as PublicProductPackageAsset
if (
Object.prototype.hasOwnProperty.call(asset, 'localFilePath') ||
Object.prototype.hasOwnProperty.call(asset, 'fileUrl')
) {
throw new Error('公开上传接口只允许使用 uploadId')
}
const uploadId = typeof asset.uploadId === 'string' ? asset.uploadId.trim() : ''
const fileName = typeof asset.fileName === 'string' ? asset.fileName.trim() : ''
const fileSize = Number(asset.fileSize)
if (!uploadId || !fileName || !Number.isFinite(fileSize) || fileSize < 0) {
throw new Error(`${group} 第 ${index + 1} 个资产参数无效`)
}
const parsedAsset: ProductPackageImageInput | ProductPackageSourceInput = {
uploadId,
fileName,
fileSize,
}
if (group !== 'psd') {
const imageAsset = parsedAsset as ProductPackageImageInput
if (Number.isInteger(asset.sortOrder) && Number(asset.sortOrder) >= 0) {
imageAsset.sortOrder = Number(asset.sortOrder)
}
if (typeof asset.skuCode === 'string') {
imageAsset.skuCode = asset.skuCode
}
if (Array.isArray(asset.barcodes)) {
imageAsset.barcodes = asset.barcodes.filter(
(barcode): barcode is string => typeof barcode === 'string',
)
}
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>
return {
main: parsePublicAssetGroup(assets.main, 'main') as ProductPackageImageInput[] | undefined,
sku: parsePublicAssetGroup(assets.sku, 'sku') as ProductPackageImageInput[] | undefined,
detail: parsePublicAssetGroup(assets.detail, 'detail') as ProductPackageImageInput[] | undefined,
psd: parsePublicAssetGroup(assets.psd, 'psd') as ProductPackageSourceInput[] | undefined,
}
}
async function assertOwnedProductPackageUploadSessions(
assets: ProductPackageAssetsInput,
operatorUserId: string,
) {
const imageAssets = [assets.main || [], assets.sku || [], assets.detail || []].flat()
const allAssets = [...imageAssets, ...(assets.psd || [])]
const seenUploadIds = new Set<string>()
const maxImageBytes = 100 * 1024 * 1024
const configuredMaxPreviewBytes = Number(process.env.TASK_DETAIL_PREVIEW_MAX_BYTES)
const maxPreviewBytes = Number.isFinite(configuredMaxPreviewBytes) && configuredMaxPreviewBytes > 0
? configuredMaxPreviewBytes
: 10 * 1024 * 1024
const maxTotalSourceBytes = 1024 * 1024 * 1024
const maxTotalPreviewBytes = 512 * 1024 * 1024
const maxTotalPreviewCount = 1000
let totalSourceBytes = 0
let totalPreviewBytes = 0
let totalPreviewCount = 0
for (const asset of allAssets) {
const uploadId = String(asset.uploadId || '').trim()
if (!uploadId) {
// 内部历史导入仍可传受信任的本地路径/已签名地址;公开 HTTP 路由在
// parsePublicProductPackageAssets 阶段已经拒绝这两类来源。
if (!('fileUrl' in asset && asset.fileUrl) && !('localFilePath' in asset && asset.localFilePath)) {
throw new Error(`资产缺少 uploadId: ${asset.fileName}`)
}
if (imageAssets.includes(asset) && asset.fileSize > maxImageBytes) {
throw new Error(`图片 ${asset.fileName} 超过单图 ${Math.round(maxImageBytes / 1024 / 1024)}MB 限制`)
}
totalSourceBytes += Math.max(0, asset.fileSize)
continue
}
if (seenUploadIds.has(uploadId)) {
throw new Error(`上传会话重复使用: ${asset.fileName}`)
}
const session = await getOwnedUploadSession(uploadId, operatorUserId)
if (!session) {
throw new Error(`上传会话不存在、已失效或不属于当前用户: ${uploadId}`)
}
seenUploadIds.add(uploadId)
if (session.fileSize !== asset.fileSize) {
throw new Error(`文件大小校验失败,请重新上传: ${asset.fileName}`)
}
if (imageAssets.includes(asset) && session.fileSize > maxImageBytes) {
throw new Error(`图片 ${asset.fileName} 超过单图 ${Math.round(maxImageBytes / 1024 / 1024)}MB 限制`)
}
totalSourceBytes += 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}`)
}
totalSourceBytes += previewSession.fileSize
if (!seenUploadIds.has(preview.uploadId)) seenUploadIds.add(preview.uploadId)
}
}
if (totalSourceBytes > maxTotalSourceBytes) {
throw new Error('本次上传素材总大小超过 1GB,请分批提交')
}
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 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 readFile(filePath)
}
throw new Error(`无效的${type}资产: ${asset.fileName}`)
}
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',
uploadedPreviewUrls: string[],
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 getServerPreviewTools = async () => {
if (!compressImage || !sliceImage || !sharp) {
const imageProcessor = await import('@/lib/image-processor')
compressImage = imageProcessor.compressImage
sliceImage = imageProcessor.sliceImage
sharp = await import('sharp') as unknown as { default: typeof import('sharp') }
}
return { compressImage, sliceImage, sharp }
}
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 buffer = await readAssetBuffer(previewAsset, type, assembledFiles, tempFiles)
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 uploadPublicDisplayToS3(
buffer,
buildTaskDetailPublicDisplayPath(materialId, type, `client-${Date.now()}-${index}-${sliceIndex}-${fileName}`),
'image/webp',
)
previews.push({
fileUrl: previewUrl,
fileName,
sortOrder: previews.length,
...(type === 'sku'
? {
skuCode: asset.skuCode,
barcodes: asset.barcodes || [],
}
: {}),
})
uploadedPreviewUrls.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 previewTools.sharp!.default(buffer, {
limitInputPixels: 60_000_000,
sequentialRead: true,
failOn: 'error',
}).metadata()
const shouldSlice = type === 'detail' && metadata.height && metadata.height > 1500
const buffers = shouldSlice
? await previewTools.sliceImage!(buffer, 1500, 200)
: [
await previewTools.compressImage!(buffer, {
maxWidth: 800,
maxHeight: 1200,
maxSizeKB: 200,
format: 'webp',
}),
]
for (let sliceIndex = 0; sliceIndex < buffers.length; sliceIndex += 1) {
const timestamp = Date.now()
const randomSuffix = Math.random().toString(36).substring(2, 9)
const sliceSuffix = buffers.length > 1 ? `-slice${sliceIndex}` : ''
const fileName = `${type}-preview-${timestamp}-${index}${sliceSuffix}-${randomSuffix}.webp`
const previewUrl = await uploadPublicDisplayToS3(
buffers[sliceIndex],
buildTaskDetailPublicDisplayPath(materialId, type, fileName),
'image/webp',
)
previews.push({
fileUrl: previewUrl,
fileName,
sortOrder: previews.length,
...(type === 'sku'
? {
skuCode: asset.skuCode,
barcodes: asset.barcodes || [],
}
: {}),
})
uploadedPreviewUrls.push(previewUrl)
}
}
return previews
}
async function movePsdFilesToPermanentDir(
materialId: string,
assets: ProductPackageSourceInput[] | undefined,
tempFiles: string[],
assembledFiles?: Map<string, string>,
) {
const psdPermanentPaths: Array<{ filePath: string; fileName: string; fileSize: number }> = []
if (!assets || assets.length === 0) {
return psdPermanentPaths
}
const { rename, copyFile, stat } = await import('fs/promises')
const psdDir = path.join(getUploadRootDir(), 'materials')
await mkdir(psdDir, { recursive: true })
for (const psd of assets) {
if (!psd.uploadId) {
if (!psd.fileUrl && !psd.localFilePath) {
throw new Error(`PSD 资产参数无效: ${psd.fileName}`)
}
const timestamp = Date.now()
const sanitizedFileName = psd.fileName.replace(/[\/\\:*?"<>|]/g, '_')
const permanentFileName = `psd-${materialId}-${timestamp}-${sanitizedFileName}`
const permanentFilePath = path.join(psdDir, permanentFileName)
const relativePath = buildStoredUploadPath('materials', permanentFileName)
if (psd.localFilePath) {
const sourceStat = await stat(psd.localFilePath)
const expectedSize = psd.fileSize || sourceStat.size
if (isRemoteUploadSyncEnabled()) {
await syncStoredUploadFileToRemote(psd.localFilePath, relativePath, expectedSize)
} else {
await copyFile(psd.localFilePath, permanentFilePath)
}
psdPermanentPaths.push({
filePath: relativePath,
fileName: psd.fileName,
fileSize: expectedSize,
})
continue
}
const tempFilePath = await downloadToTempFile('psd', psd)
await copyFile(tempFilePath, permanentFilePath)
await syncStoredUploadFileToRemote(permanentFilePath, relativePath, psd.fileSize)
tempFiles.push(tempFilePath)
psdPermanentPaths.push({
filePath: relativePath,
fileName: psd.fileName,
fileSize: psd.fileSize,
})
continue
}
const 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)
const timestamp = Date.now()
const sanitizedFileName = psd.fileName.replace(/[\/\\:*?"<>|]/g, '_')
const permanentFileName = `psd-${materialId}-${timestamp}-${sanitizedFileName}`
const permanentFilePath = path.join(psdDir, permanentFileName)
const relativePath = buildStoredUploadPath('materials', permanentFileName)
try {
await rename(tempFilePath, permanentFilePath)
} catch {
await copyFile(tempFilePath, permanentFilePath)
tempFiles.push(tempFilePath)
}
await syncStoredUploadFileToRemote(permanentFilePath, relativePath, psd.fileSize)
psdPermanentPaths.push({
filePath: relativePath,
fileName: psd.fileName,
fileSize: psd.fileSize,
})
}
return psdPermanentPaths
}
export async function completeProductPackageUpload(input: CompleteProductPackageUploadInput) {
const { materialId, taskNo, dingtalkApprovalNo, assets, operatorUserId, options } = input
const tempFiles: string[] = []
const uploadIds: string[] = []
const assembledFiles = new Map<string, string>()
const uploadedPreviewUrls: string[] = []
const createdZipPaths: string[] = []
let persistentStateCommitted = false
let databaseSaveCompleted = false
let originalMaterialState: {
previewUrl: string
zipFilePath: string
zipFileName: string
zipFileSize: bigint
taskNo: string | null
dingtalkApprovalNo: string | null
taskStatus: string | null
isFullSet: boolean
} | null = null
let canCompensateFreshMaterial = false
const startedAt = Date.now()
const reportProgress = async (stage: string, progress: number, message?: string) => {
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)
}
}
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('缺少必填参数')
}
await assertOwnedProductPackageUploadSessions(assets, operatorUserId)
await reportProgress('validating', 5)
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 {
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('素材不存在')
}
originalMaterialState = {
previewUrl: material.previewUrl || '',
zipFilePath: material.zipFilePath || '',
zipFileName: material.zipFileName || '',
zipFileSize: typeof material.zipFileSize === 'bigint' ? material.zipFileSize : BigInt(material.zipFileSize || 0),
taskNo: material.taskNo || null,
dingtalkApprovalNo: material.dingtalkApprovalNo || null,
taskStatus: material.taskStatus || null,
isFullSet: Boolean(material.isFullSet),
}
// 初始化接口创建的素材没有任何持久化文件。只有这种“新素材”允许自动补偿,
// 避免重试旧素材时误删原有资产。
let originalAssetCount = 0
if (originalMaterialState.taskStatus === 'uploading') {
originalAssetCount = await prisma.materialAsset.count({ where: { materialId } })
}
canCompensateFreshMaterial = Boolean(
!originalMaterialState.previewUrl &&
!originalMaterialState.zipFilePath &&
!originalMaterialState.taskNo &&
(originalMaterialState.taskStatus === null ||
(originalMaterialState.taskStatus === 'uploading' && originalAssetCount === 0)),
)
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, assembledFiles, tempFiles, uploadIds),
sku: await generatePreviewsFromAssets(materialId, normalizedAssets.sku, 'sku', uploadedPreviewUrls, assembledFiles, tempFiles, uploadIds),
detail: await generatePreviewsFromAssets(materialId, normalizedAssets.detail, 'detail', uploadedPreviewUrls, 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, assembledFiles)
createdZipPaths.push(...psdPermanentPaths.map((item) => item.filePath))
await reportProgress('database', 85)
const saveResult = await saveProductPackageAssets(materialId, previewAssets, zipInfos, psdPermanentPaths)
databaseSaveCompleted = true
const cardPreviewUrl = previewAssets.main[0]?.fileUrl || material.previewUrl
// 先写入非完成标记的数据,最后由 assignTaskNoOnComplete 写入
// taskStatus=completed,作为后台任务可恢复的提交标记。
await prisma.material.update({
where: { id: materialId },
data: {
previewUrl: cardPreviewUrl,
isFullSet: derivedIsFullSet,
},
})
await assignTaskNoOnComplete(
materialId,
normalizedTaskNo,
normalizedDingtalkApprovalNo,
)
persistentStateCommitted = true
await reportProgress('database', 93)
await cleanupTempFiles(tempFiles, uploadIds)
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 (persistentStateCommitted) {
console.error('[ProductPackage] post-commit error; skip destructive cleanup:', error)
throw error
}
let databaseCompensated = !databaseSaveCompleted
if (databaseSaveCompleted && canCompensateFreshMaterial && originalMaterialState) {
try {
await prisma.$transaction(async (tx) => {
await tx.materialAsset.deleteMany({ where: { materialId } })
await tx.material.update({
where: { id: materialId },
data: {
previewUrl: originalMaterialState!.previewUrl,
zipFilePath: originalMaterialState!.zipFilePath,
zipFileName: originalMaterialState!.zipFileName,
zipFileSize: originalMaterialState!.zipFileSize,
taskNo: originalMaterialState!.taskNo,
dingtalkApprovalNo: originalMaterialState!.dingtalkApprovalNo,
taskStatus: originalMaterialState!.taskStatus,
isFullSet: originalMaterialState!.isFullSet,
},
})
})
databaseCompensated = true
console.warn(`[ProductPackage] compensated partial database save material=${materialId}`)
} catch (compensationError) {
// 数据库补偿失败时保留已写入的文件,避免数据库仍指向已被删除的 ZIP/S3。
databaseCompensated = false
console.error('[ProductPackage] database compensation failed; keep committed files:', compensationError)
}
}
await cleanupOnError(
databaseCompensated ? uploadedPreviewUrls : [],
databaseCompensated ? createdZipPaths : [],
tempFiles,
materialId,
uploadIds,
!options?.preserveMaterialOnError,
)
throw error
}
}