1077 lines
38 KiB
TypeScript
1077 lines
38 KiB
TypeScript
import { prisma } from '@/lib/prisma'
|
||
import { assignTaskNoOnComplete, validateTaskNo } from '@/lib/product-package'
|
||
import {
|
||
cleanupOnError,
|
||
cleanupTempFiles,
|
||
createZipFromFiles,
|
||
createZipFromZips,
|
||
saveProductPackageAssets,
|
||
} from '@/lib/task-material-processor'
|
||
import { buildStoredUploadPath, getUploadRootDir } from '@/lib/upload-paths'
|
||
import { isRemoteUploadSyncEnabled, syncStoredUploadFileToRemote } from '@/lib/remote-upload-sync'
|
||
import { getOwnedUploadSession, getUploadedFile } from '@/lib/upload-sessions'
|
||
import { createMaterialLog } from '@/lib/material-log'
|
||
import { isTaskDetailFullSetFromUploadGroups } from '@/lib/task-detail-full-set'
|
||
import {
|
||
buildTaskDetailPublicDisplayPath,
|
||
uploadPublicDisplayToS3,
|
||
} from '@/lib/public-display'
|
||
import {
|
||
queueTaskDetailBarcodeImagesUpdatedNotification,
|
||
queueTaskDetailFullSetNotification,
|
||
} from '@/lib/external-auto-listing'
|
||
import path from 'path'
|
||
import { mkdir, readFile, writeFile } from 'fs/promises'
|
||
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
|
||
}
|
||
}
|