gongxiangp2/lib/product-package-completion.ts
chenpei 9cd3c0f231 feat: 任务详情无条码开关 + 上传面板信息密度优化 + 历史导入/脚本补全
- 任务详情上传支持"本批素材无条码"开关,跳过条码必填校验
- SKU卡片/主图网格从4列改5列,图片高度压缩,去掉冗余标签和提示
- 详情图/PSD上传区去掉大占位框,改成紧凑按钮+标签
- 补充历史导入相关脚本和工具文件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 10:01:08 +08:00

572 lines
17 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 { getUploadedFile } from '@/lib/upload-sessions'
import { createMaterialLog } from '@/lib/material-log'
import { isTaskDetailFullSetFromUploadGroups } from '@/lib/task-detail-full-set'
import {
buildTaskDetailPublicDisplayPath,
uploadPublicDisplayToS3,
} from '@/lib/public-display'
import {
queueTaskDetailBarcodeImagesUpdatedNotification,
queueTaskDetailFullSetNotification,
} from '@/lib/external-auto-listing'
import path from 'path'
import { mkdir, readFile, writeFile } from 'fs/promises'
export interface ProductPackageImageInput {
uploadId?: string
fileUrl?: string
localFilePath?: string
fileName: string
fileSize: number
sortOrder?: number
skuCode?: string
barcodes?: string[]
}
export interface ProductPackageSourceInput {
uploadId?: string
fileUrl?: string
localFilePath?: string
fileName: string
fileSize: number
}
export interface CompleteProductPackageUploadInput {
materialId: string
taskNo?: string | null
operatorUserId: string
assets: {
main?: ProductPackageImageInput[]
sku?: ProductPackageImageInput[]
detail?: ProductPackageImageInput[]
psd?: ProductPackageSourceInput[]
}
options?: {
allowSkuWithoutBarcodes?: boolean
zipTimeoutMs?: number
allZipTimeoutMs?: number
}
}
interface GeneratedProductPackagePreviewAsset {
fileUrl: string
fileName: string
sortOrder: number
skuCode?: string
barcodes?: string[]
}
function normalizeBarcodeValue(barcode: string | null | undefined) {
return String(barcode || '').trim()
}
function normalizeSkuPreviewAssets(
assets: ProductPackageImageInput[] | undefined,
options?: { allowSkuWithoutBarcodes?: boolean },
) {
if (!assets || assets.length === 0) {
return []
}
const barcodeToAssetName = new Map<string, string>()
return assets.map((asset, index) => {
const skuCode = String(asset.skuCode || '').trim()
if (!skuCode) {
throw new Error(`第 ${index + 1} 张 SKU 预览图缺少 SKU 编号`)
}
const barcodes = Array.from(
new Set((asset.barcodes || []).map((barcode) => normalizeBarcodeValue(barcode)).filter(Boolean)),
)
if (barcodes.length === 0 && !options?.allowSkuWithoutBarcodes) {
throw new Error(`SKU 预览图 ${asset.fileName} 至少需要 1 个产品条码`)
}
for (const barcode of barcodes) {
const existingOwner = barcodeToAssetName.get(barcode)
if (existingOwner && existingOwner !== asset.fileName) {
throw new Error(`产品条码 ${barcode} 不能同时绑定到 ${existingOwner} 和 ${asset.fileName}`)
}
barcodeToAssetName.set(barcode, asset.fileName)
}
return {
...asset,
skuCode,
barcodes,
}
})
}
async function writeAssetToTempFile(
prefix: string,
asset: ProductPackageImageInput | ProductPackageSourceInput,
buffer: Buffer,
) {
const tempPath = path.join(
process.cwd(),
'uploads',
'temp',
`${prefix}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}-${asset.fileName}`,
)
await mkdir(path.dirname(tempPath), { recursive: true })
await writeFile(tempPath, buffer)
return tempPath
}
async function downloadToTempFile(prefix: string, asset: ProductPackageImageInput | ProductPackageSourceInput) {
if (asset.localFilePath) {
const buffer = await readFile(asset.localFilePath)
return writeAssetToTempFile(prefix, asset, buffer)
}
if (!asset.fileUrl) {
throw new Error(`缺少文件地址: ${asset.fileName}`)
}
const response = await fetch(asset.fileUrl)
if (!response.ok) {
throw new Error(`下载文件失败: ${asset.fileName}`)
}
const buffer = Buffer.from(await response.arrayBuffer())
return writeAssetToTempFile(prefix, asset, buffer)
}
async function readAssetBuffer(
asset: ProductPackageImageInput | ProductPackageSourceInput,
type: 'main' | 'sku' | 'detail' | 'psd',
) {
if (asset.localFilePath) {
return readFile(asset.localFilePath)
}
if (asset.fileUrl) {
const response = await fetch(asset.fileUrl)
if (!response.ok) {
throw new Error(`下载${type}预览原图失败: ${asset.fileName}`)
}
return Buffer.from(await response.arrayBuffer())
}
if (asset.uploadId) {
const filePath = await getUploadedFile(asset.uploadId)
if (!filePath) {
throw new Error(`文件不存在: ${asset.fileName} (${asset.uploadId})`)
}
return readFile(filePath)
}
throw new Error(`无效的${type}资产: ${asset.fileName}`)
}
async function collectAssetFiles(
assets: ProductPackageImageInput[] | undefined,
prefix: string,
tempFiles: string[],
uploadIds: string[],
) {
const files: Array<{ path: string; name: string; size: number }> = []
if (!assets || assets.length === 0) {
return files
}
for (const asset of assets) {
if (asset.uploadId) {
const filePath = await getUploadedFile(asset.uploadId)
if (!filePath) {
throw new Error(`文件缺失或未上传完成: ${asset.fileName} (${asset.uploadId})`)
}
files.push({
path: filePath,
name: asset.fileName,
size: asset.fileSize,
})
tempFiles.push(filePath)
uploadIds.push(asset.uploadId)
continue
}
if (!asset.fileUrl && !asset.localFilePath) {
throw new Error(`资产参数无效: ${asset.fileName}`)
}
const filePath = await downloadToTempFile(prefix, asset)
files.push({
path: filePath,
name: asset.fileName,
size: asset.fileSize,
})
tempFiles.push(filePath)
}
return files
}
async function generatePreviewsFromAssets(
materialId: string,
assets: ProductPackageImageInput[] | undefined,
type: 'main' | 'sku' | 'detail',
uploadedPreviewUrls: string[],
) {
if (!assets || assets.length === 0) {
return []
}
const previews: GeneratedProductPackagePreviewAsset[] = []
const { compressImage, sliceImage } = await import('@/lib/image-processor')
const sharp = await import('sharp')
for (let index = 0; index < assets.length; index += 1) {
const asset = assets[index]
let buffer: Buffer
buffer = await readAssetBuffer(asset, type)
const metadata = await sharp.default(buffer).metadata()
const shouldSlice = type === 'detail' && metadata.height && metadata.height > 1500
const buffers = shouldSlice
? await sliceImage(buffer, 1500, 200)
: [
await compressImage(buffer, {
maxWidth: 800,
maxHeight: 1200,
maxSizeKB: 200,
format: 'webp',
}),
]
for (let sliceIndex = 0; sliceIndex < buffers.length; sliceIndex += 1) {
const timestamp = Date.now()
const randomSuffix = Math.random().toString(36).substring(2, 9)
const sliceSuffix = buffers.length > 1 ? `-slice${sliceIndex}` : ''
const fileName = `${type}-preview-${timestamp}-${index}${sliceSuffix}-${randomSuffix}.webp`
const previewUrl = await uploadPublicDisplayToS3(
buffers[sliceIndex],
buildTaskDetailPublicDisplayPath(materialId, type, fileName),
'image/webp',
)
previews.push({
fileUrl: previewUrl,
fileName,
sortOrder: previews.length,
...(type === 'sku'
? {
skuCode: asset.skuCode,
barcodes: asset.barcodes || [],
}
: {}),
})
uploadedPreviewUrls.push(previewUrl)
}
}
return previews
}
async function movePsdFilesToPermanentDir(
materialId: string,
assets: ProductPackageSourceInput[] | undefined,
tempFiles: string[],
) {
const psdPermanentPaths: Array<{ filePath: string; fileName: string; fileSize: number }> = []
if (!assets || assets.length === 0) {
return psdPermanentPaths
}
const { rename, copyFile, stat } = await import('fs/promises')
const psdDir = path.join(getUploadRootDir(), 'materials')
await mkdir(psdDir, { recursive: true })
for (const psd of assets) {
if (!psd.uploadId) {
if (!psd.fileUrl && !psd.localFilePath) {
throw new Error(`PSD 资产参数无效: ${psd.fileName}`)
}
const timestamp = Date.now()
const sanitizedFileName = psd.fileName.replace(/[\/\\:*?"<>|]/g, '_')
const permanentFileName = `psd-${materialId}-${timestamp}-${sanitizedFileName}`
const permanentFilePath = path.join(psdDir, permanentFileName)
const relativePath = buildStoredUploadPath('materials', permanentFileName)
if (psd.localFilePath) {
const sourceStat = await stat(psd.localFilePath)
const expectedSize = psd.fileSize || sourceStat.size
if (isRemoteUploadSyncEnabled()) {
await syncStoredUploadFileToRemote(psd.localFilePath, relativePath, expectedSize)
} else {
await copyFile(psd.localFilePath, permanentFilePath)
}
psdPermanentPaths.push({
filePath: relativePath,
fileName: psd.fileName,
fileSize: expectedSize,
})
continue
}
const tempFilePath = await downloadToTempFile('psd', psd)
await copyFile(tempFilePath, permanentFilePath)
await syncStoredUploadFileToRemote(permanentFilePath, relativePath, psd.fileSize)
tempFiles.push(tempFilePath)
psdPermanentPaths.push({
filePath: relativePath,
fileName: psd.fileName,
fileSize: psd.fileSize,
})
continue
}
const tempFilePath = await getUploadedFile(psd.uploadId)
if (!tempFilePath) {
throw new Error(`PSD 文件缺失或未上传完成: ${psd.fileName} (${psd.uploadId})`)
}
const timestamp = Date.now()
const sanitizedFileName = psd.fileName.replace(/[\/\\:*?"<>|]/g, '_')
const permanentFileName = `psd-${materialId}-${timestamp}-${sanitizedFileName}`
const permanentFilePath = path.join(psdDir, permanentFileName)
const relativePath = buildStoredUploadPath('materials', permanentFileName)
try {
await rename(tempFilePath, permanentFilePath)
} catch {
await copyFile(tempFilePath, permanentFilePath)
tempFiles.push(tempFilePath)
}
await syncStoredUploadFileToRemote(permanentFilePath, relativePath, psd.fileSize)
psdPermanentPaths.push({
filePath: relativePath,
fileName: psd.fileName,
fileSize: psd.fileSize,
})
}
return psdPermanentPaths
}
export async function completeProductPackageUpload(input: CompleteProductPackageUploadInput) {
const { materialId, taskNo, assets, operatorUserId, options } = input
const tempFiles: string[] = []
const uploadIds: string[] = []
const uploadedPreviewUrls: string[] = []
const createdZipPaths: string[] = []
const normalizedAssets = {
main: assets.main || [],
sku: normalizeSkuPreviewAssets(assets.sku, options),
detail: assets.detail || [],
psd: assets.psd || [],
}
const derivedIsFullSet = isTaskDetailFullSetFromUploadGroups(normalizedAssets)
const zipTimeoutMs = options?.zipTimeoutMs && options.zipTimeoutMs > 0 ? options.zipTimeoutMs : 30000
const allZipTimeoutMs = options?.allZipTimeoutMs && options.allZipTimeoutMs > 0 ? options.allZipTimeoutMs : 60000
if (!materialId || !assets) {
throw new Error('缺少必填参数')
}
const totalImages =
normalizedAssets.main.length + normalizedAssets.sku.length + normalizedAssets.detail.length
if (totalImages === 0) {
throw new Error('请至少上传一张图片')
}
const normalizedTaskNo = taskNo?.trim() || null
if (normalizedTaskNo && !validateTaskNo(normalizedTaskNo)) {
throw new Error('任务号格式错误')
}
try {
const material: any = await prisma.material.findUnique({
where: { id: materialId },
include: {
user: {
select: {
realName: true,
username: true,
},
},
},
})
if (!material) {
throw new Error('素材不存在')
}
const authorName = material.user?.realName || material.user?.username || '未知作者'
const categoryName = material.categoryName || '未知店铺'
const mainFiles = await collectAssetFiles(normalizedAssets.main, 'main', tempFiles, uploadIds)
const skuFiles = await collectAssetFiles(normalizedAssets.sku, 'sku', tempFiles, uploadIds)
const detailFiles = await collectAssetFiles(normalizedAssets.detail, 'detail', tempFiles, uploadIds)
const previewAssets = {
main: await generatePreviewsFromAssets(materialId, normalizedAssets.main, 'main', uploadedPreviewUrls),
sku: await generatePreviewsFromAssets(materialId, normalizedAssets.sku, 'sku', uploadedPreviewUrls),
detail: await generatePreviewsFromAssets(materialId, normalizedAssets.detail, 'detail', uploadedPreviewUrls),
}
const zipInfos: {
main?: { filePath: string; fileName: string; fileSize: number }
sku?: { filePath: string; fileName: string; fileSize: number }
detail?: { filePath: string; fileName: string; fileSize: number }
all: { filePath: string; fileName: string; fileSize: number }
} = {} as any
const zipFilesForAll: Array<{ path: string; name: string }> = []
if (mainFiles.length > 0) {
zipInfos.main = await createZipFromFiles(
mainFiles,
materialId,
material.productModel || 'product',
'任务-详情',
'主图',
authorName,
categoryName,
zipTimeoutMs,
)
createdZipPaths.push(zipInfos.main.filePath)
zipFilesForAll.push({ path: zipInfos.main.filePath, name: zipInfos.main.fileName })
}
if (skuFiles.length > 0) {
zipInfos.sku = await createZipFromFiles(
skuFiles,
materialId,
material.productModel || 'product',
'任务-详情',
'SKU图',
authorName,
categoryName,
zipTimeoutMs,
)
createdZipPaths.push(zipInfos.sku.filePath)
zipFilesForAll.push({ path: zipInfos.sku.filePath, name: zipInfos.sku.fileName })
}
if (detailFiles.length > 0) {
zipInfos.detail = await createZipFromFiles(
detailFiles,
materialId,
material.productModel || 'product',
'任务-详情',
'详情图',
authorName,
categoryName,
zipTimeoutMs,
)
createdZipPaths.push(zipInfos.detail.filePath)
zipFilesForAll.push({ path: zipInfos.detail.filePath, name: zipInfos.detail.fileName })
}
zipInfos.all = await createZipFromZips(
zipFilesForAll,
materialId,
material.productModel || 'product',
'任务-详情',
allZipTimeoutMs,
)
createdZipPaths.push(zipInfos.all.filePath)
const psdPermanentPaths = await movePsdFilesToPermanentDir(materialId, normalizedAssets.psd, tempFiles)
createdZipPaths.push(...psdPermanentPaths.map((item) => item.filePath))
const saveResult = await saveProductPackageAssets(materialId, previewAssets, zipInfos, psdPermanentPaths)
const cardPreviewUrl = previewAssets.main[0]?.fileUrl || material.previewUrl
await assignTaskNoOnComplete(materialId, normalizedTaskNo)
await prisma.material.update({
where: { id: materialId },
data: {
previewUrl: cardPreviewUrl,
isFullSet: derivedIsFullSet,
},
})
await cleanupTempFiles(tempFiles, uploadIds)
await createMaterialLog({
materialId,
userId: operatorUserId,
action: 'UPLOAD',
changes: {
uploadType: {
oldValue: null,
newValue: '任务-详情',
},
taskNo: {
oldValue: null,
newValue: normalizedTaskNo,
},
isFullSet: {
oldValue: false,
newValue: derivedIsFullSet,
},
assetCounts: {
oldValue: null,
newValue: {
main: mainFiles.length,
sku: skuFiles.length,
detail: detailFiles.length,
psd: normalizedAssets.psd.length,
},
},
zipSizes: {
oldValue: null,
newValue: {
main: zipInfos.main?.fileSize || 0,
sku: zipInfos.sku?.fileSize || 0,
detail: zipInfos.detail?.fileSize || 0,
all: zipInfos.all.fileSize,
},
},
},
})
if (derivedIsFullSet) {
void queueTaskDetailFullSetNotification(materialId)
}
if (saveResult.createdSkuPreviewAssets.some((asset) => asset.barcodes.length > 0)) {
void queueTaskDetailBarcodeImagesUpdatedNotification(
materialId,
saveResult.createdSkuPreviewAssets.map((asset) => asset.id),
)
}
return {
materialId,
taskNo: normalizedTaskNo,
isFullSet: derivedIsFullSet,
}
} catch (error) {
await cleanupOnError(uploadedPreviewUrls, createdZipPaths, tempFiles, materialId)
throw error
}
}