/** * 任务类型素材的通用处理逻辑 * 用于:任务-3D原图、任务-AI原图、任务-实拍原图、任务-视频截图、任务-详情 */ import { prisma } from '@/lib/prisma'; import { deleteFromS3 } from '@/lib/s3'; import { buildMaterialPublicDisplayPath, uploadPublicDisplayToS3, } from '@/lib/public-display'; import { compressImage } from '@/lib/image-processor'; import { buildStoredUploadPath, getUploadRootDir, resolveStoredUploadPath } from '@/lib/upload-paths'; import { deleteStoredUploadFileFromRemote, syncStoredUploadFileToRemote, } from '@/lib/remote-upload-sync'; import { randomUUID } from 'crypto'; import { lstat, mkdir, open, rename, unlink, rm } from 'fs/promises'; import { createWriteStream } from 'fs'; import path from 'path'; import archiver from 'archiver'; import type { Prisma } from '@prisma/client'; import { Transform } from 'stream'; import { acquireMaterialUploadArchiveLock, acquireMaterialUploadStorageLock, type MaterialUploadStorageLockRelease, } from '@/lib/material-upload-cross-process-lock'; import { acquireUploadMergeDiskReservation } from '@/lib/material-upload-merge'; import { materialUploadMergeRequestLimiter } from '@/lib/material-upload-request-guard'; import { readBoundedRegularFile } from '@/lib/bounded-file-reader'; import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy'; import { finalizeTaskMaterialOnComplete } from '@/lib/product-package'; // 资产类型常量 export const ASSET_TYPE = { PREVIEW: 'preview', MAIN_PREVIEW: 'main-preview', SKU_PREVIEW: 'sku-preview', DETAIL_PREVIEW: 'detail-preview', ZIP: 'zip', MAIN: 'main', SKU: 'sku', DETAIL: 'detail', ALL: 'all', PSD: 'psd', } as const; export interface ProductPackagePreviewAssetInput { fileUrl: string; fileName: string; sortOrder?: number; skuCode?: string; barcodes?: string[]; } export interface SavedProductPackageSkuPreviewAsset { id: string; fileUrl: string; fileName: string | null; sortOrder: number; skuCode: string | null; createdAt: Date; updatedAt: Date; barcodes: string[]; } export interface ProductPackageAssetGroups { main: ProductPackagePreviewAssetInput[]; sku: ProductPackagePreviewAssetInput[]; detail: ProductPackagePreviewAssetInput[]; } export interface ProductPackageZipInfos { 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 }; } export interface ProductPackagePsdInfo { filePath: string; fileName: string; fileSize: number; } function normalizeMaterialAssetFileUrl(value: unknown): string { if (typeof value === 'string') { return value; } if (value && typeof value === 'object') { const nestedFileUrl = (value as { fileUrl?: unknown }).fileUrl; if (typeof nestedFileUrl === 'string') { return nestedFileUrl; } } return String(value || ''); } export function resolveUniqueZipEntryName( originalName: string | null | undefined, index: number, usedNames: Set, ): string { const fallbackName = `file-${String(index + 1).padStart(3, '0')}`; const safeName = String(originalName || fallbackName) .replace(/[\0\r\n]/g, ' ') .replace(/[\/\\]+/g, '_') .trim() || fallbackName; const addIfUnique = (candidate: string) => { const key = candidate.toLowerCase(); if (usedNames.has(key)) { return false; } usedNames.add(key); return true; }; if (addIfUnique(safeName)) { return safeName; } const ext = path.extname(safeName); const baseName = (ext ? safeName.slice(0, -ext.length) : safeName) || fallbackName; for (let duplicateIndex = 2; duplicateIndex < 10000; duplicateIndex += 1) { const candidate = `${baseName}-${duplicateIndex}${ext}`; if (addIfUnique(candidate)) { return candidate; } } const lastResortName = `${baseName}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}${ext}`; usedNames.add(lastResortName.toLowerCase()); return lastResortName; } interface SafeZipEntry { path: string; name: string; size: number; } const ZIP_MINIMUM_OVERHEAD_BYTES = 16 * 1024 * 1024; const ZIP_OUTPUT_OVERHEAD_RATIO = 0.05; export const TASK_MATERIAL_PREVIEW_SOURCE_MAX_BYTES = 100 * 1024 * 1024; export const TASK_MATERIAL_ZIP_TIMEOUT_MS = 30 * 60 * 1000; export const TASK_MATERIAL_ZIP_COMPRESSION_LEVEL = 0; export class TaskMaterialSourceValidationError extends Error { constructor(message: string, readonly status: number = 400) { super(message); this.name = 'TaskMaterialSourceValidationError'; } } export interface TaskMaterialSourceAssetInput { uploadId?: unknown; fileName?: unknown; fileSize?: unknown; } /** * 任务图片完成接口不能信任客户端声明的大小。这里以合并后的普通文件为准, * 统一限制数量、单文件和总字节数,并拒绝重复 uploadId 与符号链接。 */ export async function resolveOwnedTaskMaterialSourceFiles(input: { assets: TaskMaterialSourceAssetInput[]; userId: string; }): Promise<{ files: Array<{ path: string; name: string; size: number }>; uploadIds: string[]; tempFiles: string[]; }> { const assets = Array.isArray(input.assets) ? input.assets : []; if (assets.length === 0) { throw new TaskMaterialSourceValidationError('至少需要上传一张图片'); } if (assets.length > TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup) { throw new TaskMaterialSourceValidationError( `图片最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup} 张,请分批提交`, ); } const uploadIds = assets.map((asset) => String(asset?.uploadId || '').trim()); if (uploadIds.some((uploadId) => !uploadId)) { throw new TaskMaterialSourceValidationError('上传会话编号无效'); } if (new Set(uploadIds).size !== uploadIds.length) { throw new TaskMaterialSourceValidationError('上传会话编号重复,请重新选择文件'); } const { getOwnedUploadedFile } = await import('@/lib/upload-sessions'); const files: Array<{ path: string; name: string; size: number }> = []; const tempFiles: string[] = []; let totalSourceBytes = 0; for (let index = 0; index < assets.length; index += 1) { const asset = assets[index]; const uploadId = uploadIds[index]; const fileName = String(asset?.fileName || `image-${index + 1}`).trim() || `image-${index + 1}`; const filePath = await getOwnedUploadedFile(uploadId, input.userId); if (!filePath) { throw new TaskMaterialSourceValidationError(`找不到上传文件: ${fileName}`); } const fileStats = await lstat(filePath); if (fileStats.isSymbolicLink() || !fileStats.isFile() || fileStats.size <= 0) { throw new TaskMaterialSourceValidationError(`上传文件无效: ${fileName}`); } if (fileStats.size > TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes) { throw new TaskMaterialSourceValidationError( `图片「${fileName}」超过单图 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes / 1024 / 1024)}MB 限制`, 413, ); } totalSourceBytes += fileStats.size; if (!Number.isSafeInteger(totalSourceBytes) || totalSourceBytes > TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceBytes) { throw new TaskMaterialSourceValidationError('本次图片总大小超过 1GB,请分批提交', 413); } files.push({ path: filePath, name: fileName, size: fileStats.size }); tempFiles.push(filePath); } return { files, uploadIds, tempFiles }; } async function inspectSafeZipEntries( entries: Array<{ path: string; name: string }>, ): Promise<{ entries: SafeZipEntry[]; maximumOutputBytes: number }> { const inspected: SafeZipEntry[] = []; let totalBytes = 0; for (const entry of entries) { const stats = await lstat(entry.path); if (stats.isSymbolicLink() || !stats.isFile() || stats.size < 0) { throw new Error(`文件不是可打包的普通文件: ${entry.path}`); } totalBytes += stats.size; if (!Number.isSafeInteger(totalBytes)) { throw new Error('待打包文件总大小超出安全范围'); } inspected.push({ ...entry, size: stats.size }); } const overheadBytes = Math.max( ZIP_MINIMUM_OVERHEAD_BYTES, Math.ceil(totalBytes * ZIP_OUTPUT_OVERHEAD_RATIO), ); const maximumOutputBytes = totalBytes + overheadBytes; if (!Number.isSafeInteger(maximumOutputBytes) || maximumOutputBytes <= 0) { throw new Error('ZIP 输出大小超出安全范围'); } return { entries: inspected, maximumOutputBytes }; } async function createZipAtomically(input: { entries: SafeZipEntry[]; outputPath: string; maximumOutputBytes: number; timeoutMs: number; compressionLevel: number; }): Promise { const partialPath = `${input.outputPath}.partial-${randomUUID()}`; let releaseMergeSlot: (() => void) | null = null; let releaseArchiveLock: MaterialUploadStorageLockRelease | null = null; let releaseCrossProcessLock: MaterialUploadStorageLockRelease | null = null; let releaseStorageReservation: (() => void) | null = null; try { releaseMergeSlot = await materialUploadMergeRequestLimiter.acquire(); // 大 ZIP 之间跨容器串行,但只持全局 shared 存储锁: // 分片可以继续短临界区发布,而 complete/清理的 exclusive // 操作仍会等 ZIP 完整落盘。锁顺序固定为 archive -> storage。 releaseArchiveLock = await acquireMaterialUploadArchiveLock(); releaseCrossProcessLock = await acquireMaterialUploadStorageLock('shared'); releaseStorageReservation = await acquireUploadMergeDiskReservation( path.dirname(input.outputPath), input.maximumOutputBytes, ); try { await lstat(input.outputPath); throw new Error(`ZIP 目标文件已存在,拒绝覆盖: ${input.outputPath}`); } catch (error: any) { if (error?.code !== 'ENOENT') { throw error; } } await new Promise((resolve, reject) => { const output = createWriteStream(partialPath, { flags: 'wx' }); const archive = archiver('zip', { zlib: { level: Math.max(0, Math.min(9, Math.round(input.compressionLevel))) }, }); let emittedBytes = 0; let settled = false; const limiter = new Transform({ transform(chunk, _encoding, callback) { emittedBytes += Buffer.byteLength(chunk); if (emittedBytes > input.maximumOutputBytes) { callback(new Error('ZIP 输出超过预留空间上限')); return; } callback(null, chunk); }, }); const finish = (error?: Error) => { if (settled) { return; } settled = true; clearTimeout(timeout); if (error) { archive.abort(); output.destroy(); limiter.destroy(); reject(error); } else { resolve(); } }; const timeout = setTimeout(() => { finish(new Error(`打包超时(${Math.ceil(input.timeoutMs / 1000)}秒)`)); }, input.timeoutMs); timeout.unref?.(); output.once('close', () => finish()); output.once('error', (error) => finish(new Error(`写入文件失败: ${error.message}`))); limiter.once('error', (error) => finish(error)); archive.once('error', (error) => finish(new Error(`打包失败: ${error.message}`))); archive.on('warning', (error: any) => { // 任务类素材要求所有条目完整。即使只是 ENOENT warning,也不能生成 // 缺文件的 ZIP 后继续写数据库或同步远端。 finish(error instanceof Error ? error : new Error(String(error))); }); archive.pipe(limiter).pipe(output); const usedNames = new Set(); input.entries.forEach((entry, index) => { const zipEntryName = resolveUniqueZipEntryName(entry.name, index, usedNames); archive.file(entry.path, { name: zipEntryName }); }); void archive.finalize().catch((error: Error) => finish(error)); }); const syncHandle = await open(partialPath, 'r+'); try { await syncHandle.sync(); } finally { await syncHandle.close(); } const partialStats = await lstat(partialPath); if ( partialStats.isSymbolicLink() || !partialStats.isFile() || partialStats.size <= 0 || partialStats.size > input.maximumOutputBytes ) { throw new Error('ZIP 输出文件校验失败'); } releaseCrossProcessLock.assertHeld(); await rename(partialPath, input.outputPath); return partialStats.size; } catch (error) { await rm(partialPath, { force: true }).catch(() => undefined); throw error; } finally { releaseStorageReservation?.(); await releaseCrossProcessLock?.(); await releaseArchiveLock?.(); releaseMergeSlot?.(); } } /** * 从chunk临时目录打包文件成zip */ export async function createZipFromFiles( files: Array<{ path: string; name: string; size: number }>, materialId: string, productModel: string, materialType: string, zipType: string = 'all', // 'main', 'sku', 'detail', 'all' authorName?: string, // 作者名(上传人姓名) categoryName?: string, // 店铺名 timeoutMs: number = 30000, compressionLevel: number = 9, ): Promise<{ filePath: string; fileName: string; fileSize: number }> { if (!files || files.length === 0) { throw new Error('没有文件需要打包'); } // 创建目标目录 const uploadsDir = path.join(getUploadRootDir(), 'materials'); await mkdir(uploadsDir, { recursive: true }); // 生成zip文件名和路径(保留中文,只清理文件系统不安全字符) const timestamp = Date.now(); // 只清理文件系统不安全的字符:/ \ : * ? " < > | const sanitizedProductModel = productModel.replace(/[\/\\:*?"<>|]/g, '_'); // 如果提供了作者名和店铺名,使用新的命名格式:SPU型号_作者名_店铺名_类型.zip let zipFileName: string; if (authorName && categoryName && zipType !== 'all') { const sanitizedAuthorName = authorName.replace(/[\/\\:*?"<>|]/g, '_'); const sanitizedCategoryName = categoryName.replace(/[\/\\:*?"<>|]/g, '_'); zipFileName = `${sanitizedProductModel}_${sanitizedAuthorName}_${sanitizedCategoryName}_${zipType}_${materialId}_${timestamp}.zip`; } else { // 保持原有命名格式(用于all.zip或其他情况) zipFileName = `${sanitizedProductModel}_${zipType}_${materialId}_${timestamp}.zip`; } const zipFilePath = path.join(uploadsDir, zipFileName); const relativeZipPath = buildStoredUploadPath('materials', zipFileName); console.log(`[Zip] Creating ${zipType} zip file for ${materialType}:`, zipFilePath); console.log(`[Zip] Files count:`, files.length); const inspected = await inspectSafeZipEntries( files.map((file) => ({ path: file.path, name: file.name })), ); const fileSize = await createZipAtomically({ entries: inspected.entries, outputPath: zipFilePath, maximumOutputBytes: inspected.maximumOutputBytes, timeoutMs, compressionLevel, }); try { await syncStoredUploadFileToRemote(zipFilePath, relativeZipPath, fileSize); } catch (error) { await deleteStoredUploadFileFromRemote(relativeZipPath).catch(() => undefined); await rm(zipFilePath, { force: true }).catch(() => undefined); throw error; } console.log(`[Zip] ${zipType} zip file created successfully: ${fileSize} bytes`); return { filePath: relativeZipPath, fileName: zipFileName, fileSize, }; } /** * 打包已有的zip文件成一个总zip(用于任务-详情的all.zip) * 注意:不再打包PSD文件,PSD文件单独存储和下载 */ export async function createZipFromZips( zipFiles: Array<{ path: string; name: string }>, materialId: string, productModel: string, materialType: string, timeoutMs: number = 60000, compressionLevel: number = 9, ): Promise<{ filePath: string; fileName: string; fileSize: number }> { if (zipFiles.length === 0) { throw new Error('没有文件需要打包'); } // 创建目标目录 const uploadsDir = path.join(getUploadRootDir(), 'materials'); await mkdir(uploadsDir, { recursive: true }); // 生成zip文件名和路径 const timestamp = Date.now(); const sanitizedProductModel = productModel.replace(/[\/\\:*?"<>|]/g, '_'); const zipFileName = `${sanitizedProductModel}_all_${materialId}_${timestamp}.zip`; const zipFilePath = path.join(uploadsDir, zipFileName); const relativeZipPath = buildStoredUploadPath('materials', zipFileName); console.log(`[Zip] Creating all.zip for ${materialType}:`, zipFilePath); console.log(`[Zip] Zip files count:`, zipFiles.length); const inspected = await inspectSafeZipEntries( zipFiles.map((file) => ({ path: path.isAbsolute(file.path) ? file.path : resolveStoredUploadPath(file.path), name: file.name, })), ); const fileSize = await createZipAtomically({ entries: inspected.entries, outputPath: zipFilePath, maximumOutputBytes: inspected.maximumOutputBytes, timeoutMs, compressionLevel, }); try { await syncStoredUploadFileToRemote(zipFilePath, relativeZipPath, fileSize); } catch (error) { await deleteStoredUploadFileFromRemote(relativeZipPath).catch(() => undefined); await rm(zipFilePath, { force: true }).catch(() => undefined); throw error; } console.log(`[Zip] all.zip created successfully: ${fileSize} bytes`); return { filePath: relativeZipPath, fileName: zipFileName, fileSize, }; } /** * 为图片生成预览图并上传到S3 * 注意:必须为所有图片生成预览图,如果任何一张失败则整个流程失败 */ export async function generatePreviewImages( uploadIds: Array<{ uploadId: string; fileName: string; filePath?: string }>, materialType: string, compressOptions?: { maxWidth?: number; maxHeight?: number; maxSizeKB?: number; }, onPreviewUploaded?: (previewUrl: string) => void, ): Promise { if (!uploadIds || uploadIds.length === 0) { return []; } const { getUploadedFile } = await import('@/lib/upload-sessions'); const uploadedPreviewUrls: string[] = []; const errors: string[] = []; // 默认压缩参数(任务-3D原图使用) const defaultOptions = { maxWidth: 1440, maxHeight: 2400, maxSizeKB: 500, }; // 合并压缩参数 const options = { ...defaultOptions, ...compressOptions }; console.log(`[Preview] Generating ${uploadIds.length} preview images for ${materialType}...`); console.log(`[Preview] Compress options:`, options); for (let i = 0; i < uploadIds.length; i++) { const item = uploadIds[i]; try { // 完成状态机优先传入已做 owner + lstat 校验的固定路径,避免长处理期间 // 再次从无 owner 参数的会话解析同一个 uploadId。旧内部调用仍可按 uploadId 解析。 const filePath = item.filePath || await getUploadedFile(item.uploadId); if (!filePath) { const error = `文件不存在: ${item.fileName}`; console.error(`[Preview] ${error}`); errors.push(error); continue; } const buffer = await readBoundedRegularFile(filePath, { maxBytes: TASK_MATERIAL_PREVIEW_SOURCE_MAX_BYTES, label: `预览源图片 ${item.fileName}`, }); // 验证是否为图片 if (!buffer || buffer.length === 0) { const error = `文件为空: ${item.fileName}`; console.error(`[Preview] ${error}`); errors.push(error); continue; } // 压缩并上传预览图 const compressedBuffer = await compressImage(buffer, { maxWidth: options.maxWidth, maxHeight: options.maxHeight, maxSizeKB: options.maxSizeKB, format: 'webp', }); const timestamp = Date.now(); const randomSuffix = Math.random().toString(36).substring(2, 9); const fileName = `preview-${timestamp}-${i}-${randomSuffix}.webp`; const previewUrl = await uploadPublicDisplayToS3( compressedBuffer, buildMaterialPublicDisplayPath(fileName), 'image/webp', ); uploadedPreviewUrls.push(previewUrl); onPreviewUploaded?.(previewUrl); console.log(`[Preview] Generated preview ${i + 1}/${uploadIds.length}:`, previewUrl); } catch (err) { const error = `生成预览图失败 ${item.fileName}: ${err instanceof Error ? err.message : String(err)}`; console.error(`[Preview] ${error}`); errors.push(error); } } // 检查是否所有预览图都生成成功 if (errors.length > 0) { console.error(`[Preview] Failed to generate ${errors.length} preview images:`); errors.forEach(err => console.error(` - ${err}`)); throw new Error(`预览图生成失败:${errors.length}/${uploadIds.length} 张失败。详情:${errors.join('; ')}`); } // 验证生成的预览图数量是否与原图数量一致 if (uploadedPreviewUrls.length !== uploadIds.length) { throw new Error(`预览图数量不匹配:期望 ${uploadIds.length} 张,实际生成 ${uploadedPreviewUrls.length} 张`); } console.log(`[Preview] Successfully generated all ${uploadedPreviewUrls.length} preview images`); return uploadedPreviewUrls; } /** * 清理临时文件和目录 */ export async function cleanupTempFiles( tempFiles: string[], uploadIds: string[] ): Promise { if (uploadIds.length === 0) { for (const filePath of tempFiles) { try { await unlink(filePath); } catch (err) { console.warn(`[Cleanup] Failed to delete standalone temp file: ${filePath}`, err); } } return; } // 分片/assembled 文件只能在 completed seal 下通过跨容器独占锁清理。 // helper 会同时删除 temp、assembled、内存和 Redis 会话,并保留 tombstone // 挡住迟到分片;这里绝不能先 unlink 或 rm。 const { cleanupConsumedUploadSession } = await import('@/lib/temp-cleanup'); const failures: string[] = []; for (const uploadId of uploadIds) { try { const result = await cleanupConsumedUploadSession(uploadId); if (!result.success) { failures.push(`${uploadId}: ${result.code || result.error || '状态不允许清理'}`); } } catch (err) { failures.push(`${uploadId}: ${err instanceof Error ? err.message : String(err)}`); } } if (failures.length > 0) { throw new Error(`上传会话受控清理未完成:${failures.join('; ')}`); } } /** * 清理失败时的资源(S3文件、本地zip、临时文件、数据库记录) */ export async function cleanupOnError( uploadedPreviewUrls: string[], zipFilePaths: string | string[] | null, tempFiles: string[], materialId: string | null, uploadIds: string[] = [], deleteMaterial = true, deleteMaterialCondition?: Record, ): Promise { console.log('[Cleanup] Starting error cleanup...'); // 清理已上传的S3预览图 if (uploadedPreviewUrls.length > 0) { console.log(`[Cleanup] Cleaning up ${uploadedPreviewUrls.length} S3 preview images...`); for (const fileUrl of uploadedPreviewUrls) { try { await deleteFromS3(fileUrl); console.log(`[Cleanup] Deleted S3 file: ${fileUrl}`); } catch (cleanupError) { console.error(`[Cleanup] Failed to delete S3 file ${fileUrl}:`, cleanupError); } } } // 清理本地zip文件(支持单个或多个) if (zipFilePaths) { const paths = Array.isArray(zipFilePaths) ? zipFilePaths : [zipFilePaths]; console.log(`[Cleanup] Cleaning up ${paths.length} local zip files...`); for (const zipFilePath of paths) { try { await deleteStoredUploadFileFromRemote(zipFilePath); } catch (cleanupError) { console.error('[Cleanup] Failed to delete remote uploads file:', cleanupError); } try { const fullPath = resolveStoredUploadPath(zipFilePath); await unlink(fullPath); console.log(`[Cleanup] Deleted local zip file: ${fullPath}`); } catch (cleanupError) { console.error('[Cleanup] Failed to delete local zip file:', cleanupError); } } } // assembled/temp/分片/会话必须统一走受控清理。uploadIds 存在时, // helper 只会在 completed seal 下持跨容器独占锁删除,并保留 tombstone // 阻止迟到分片复活目录;绝不能在这里先 unlink assembled 文件。 if (tempFiles.length > 0 || uploadIds.length > 0) { try { await cleanupTempFiles(tempFiles, uploadIds); } catch (cleanupError) { // 失败清理是 best-effort:状态不确定时保留数据,同时不覆盖原始 // 业务异常,便于用户重试和后续排障。 console.warn('[Cleanup] Controlled upload-session cleanup was not completed; files retained:', cleanupError); } } // 删除素材记录 if (materialId && deleteMaterial) { try { if (deleteMaterialCondition) { const result = await prisma.material.deleteMany({ where: { id: materialId, ...deleteMaterialCondition } as any, }); if (result.count === 1) { console.log(`[Cleanup] Deleted conditionally claimed material record: ${materialId}`); } else { console.warn(`[Cleanup] Skipped material deletion because claim state changed: ${materialId}`); } } else { await prisma.material.delete({ where: { id: materialId }, }); console.log(`[Cleanup] Deleted material record: ${materialId}`); } } catch (cleanupError) { console.error('[Cleanup] Failed to delete material record:', cleanupError); } } console.log('[Cleanup] Error cleanup completed'); } /** * 保存资产记录到数据库 */ export async function saveAssets( materialId: string, previewUrls: string[], zipInfo: { filePath: string; fileName: string; fileSize: number } | null ): Promise { await prisma.$transaction(async (tx) => { await applySimpleTaskMaterialAssets(tx, materialId, previewUrls, zipInfo); }); console.log(`[Database] Saved ${previewUrls.length + (zipInfo ? 1 : 0)} asset records`); } async function applySimpleTaskMaterialAssets( tx: Prisma.TransactionClient, materialId: string, previewUrls: string[], zipInfo: { filePath: string; fileName: string; fileSize: number } | null, ): Promise { const assetRecords: Array<{ materialId: string; assetType: string; fileUrl: string; fileName?: string; fileSize?: bigint; sortOrder?: number; }> = []; // 添加预览图资产 if (previewUrls.length > 0) { previewUrls.forEach((url, index) => { assetRecords.push({ materialId, assetType: ASSET_TYPE.PREVIEW, fileUrl: url, fileName: `preview-${index}.webp`, sortOrder: index, }); }); } // 添加zip资产 if (zipInfo) { assetRecords.push({ materialId, assetType: ASSET_TYPE.ZIP, fileUrl: zipInfo.filePath, fileName: zipInfo.fileName, fileSize: BigInt(zipInfo.fileSize), sortOrder: 0, }); } if (assetRecords.length > 0) { await tx.materialAsset.createMany({ data: assetRecords, }); } const firstPreview = previewUrls[0] || ''; await tx.material.update({ where: { id: materialId }, data: { previewUrl: firstPreview, zipFilePath: zipInfo?.filePath || '', zipFileName: zipInfo?.fileName || '', zipFileSize: zipInfo?.fileSize ? BigInt(zipInfo.fileSize) : BigInt(0), }, }); } /** * 简单任务图片的资产、任务号与 completed 状态必须在同一数据库事务提交。 * 事务确认不确定时,调用方会重新读取 taskStatus 并保留所有文件。 */ export async function saveTaskMaterialAssetsAndComplete(input: { materialId: string; previewUrls: string[]; zipInfo: { filePath: string; fileName: string; fileSize: number } | null; taskNo?: string | null; dingtalkApprovalNo?: string | null; userId?: string; materialType?: string; }): Promise { await finalizeTaskMaterialOnComplete({ materialId: input.materialId, taskNo: input.taskNo, dingtalkApprovalNo: input.dingtalkApprovalNo, expectedTaskStatuses: ['uploading', null], expectedUserId: input.userId, expectedMaterialType: input.materialType, applyCompletion: async (tx) => { await applySimpleTaskMaterialAssets( tx, input.materialId, input.previewUrls, input.zipInfo, ); }, }); console.log( `[Database] Atomically completed task material with ${input.previewUrls.length + (input.zipInfo ? 1 : 0)} assets`, ); } /** * 保存任务-详情的资产记录到数据库 * 包括:预览图(S3)、分类zip(本地)、总zip(本地)、PSD(本地) */ export async function saveProductPackageAssets( materialId: string, previewAssets: ProductPackageAssetGroups, zipInfos: ProductPackageZipInfos, psdInfos?: ProductPackagePsdInfo[], options?: { transaction?: Prisma.TransactionClient; replaceExistingAssets?: boolean; materialUpdate?: { previewUrl: string; isFullSet: boolean; }; }, ): Promise<{ createdSkuPreviewAssets: SavedProductPackageSkuPreviewAsset[] }> { const assetRecords: Array<{ materialId: string; assetType: string; fileUrl: string; fileName?: string; fileSize?: bigint; sortOrder?: number; }> = []; // 添加预览图资产(所有预览图) let previewIndex = 0; // 主图预览 previewAssets.main.forEach((asset, index) => { assetRecords.push({ materialId, assetType: ASSET_TYPE.MAIN_PREVIEW, fileUrl: normalizeMaterialAssetFileUrl(asset.fileUrl), fileName: asset.fileName || `main-preview-${index}.webp`, sortOrder: previewIndex++, }); }); // SKU图预览 const skuPreviewRecords = previewAssets.sku.map((asset, index) => ({ materialId, assetType: ASSET_TYPE.SKU_PREVIEW, fileUrl: normalizeMaterialAssetFileUrl(asset.fileUrl), fileName: asset.fileName || `sku-preview-${index}.webp`, sortOrder: previewIndex++, skuCode: asset.skuCode?.trim() || null, barcodes: Array.from(new Set((asset.barcodes || []).filter(Boolean))), })); // 详情图预览 previewAssets.detail.forEach((asset, index) => { assetRecords.push({ materialId, assetType: ASSET_TYPE.DETAIL_PREVIEW, fileUrl: normalizeMaterialAssetFileUrl(asset.fileUrl), fileName: asset.fileName || `detail-preview-${index}.webp`, sortOrder: previewIndex++, }); }); // 添加分类zip资产 if (zipInfos.main) { assetRecords.push({ materialId, assetType: ASSET_TYPE.MAIN, fileUrl: zipInfos.main.filePath, fileName: zipInfos.main.fileName, fileSize: BigInt(zipInfos.main.fileSize), sortOrder: 0, }); } if (zipInfos.sku) { assetRecords.push({ materialId, assetType: ASSET_TYPE.SKU, fileUrl: zipInfos.sku.filePath, fileName: zipInfos.sku.fileName, fileSize: BigInt(zipInfos.sku.fileSize), sortOrder: 0, }); } if (zipInfos.detail) { assetRecords.push({ materialId, assetType: ASSET_TYPE.DETAIL, fileUrl: zipInfos.detail.filePath, fileName: zipInfos.detail.fileName, fileSize: BigInt(zipInfos.detail.fileSize), sortOrder: 0, }); } // 添加总zip资产 assetRecords.push({ materialId, assetType: ASSET_TYPE.ALL, fileUrl: zipInfos.all.filePath, fileName: zipInfos.all.fileName, fileSize: BigInt(zipInfos.all.fileSize), sortOrder: 0, }); // 添加PSD资产 if (psdInfos && psdInfos.length > 0) { psdInfos.forEach((psdInfo, index) => { assetRecords.push({ materialId, assetType: ASSET_TYPE.PSD, fileUrl: psdInfo.filePath, fileName: psdInfo.fileName, fileSize: BigInt(psdInfo.fileSize), sortOrder: index, }); }); } const applyAssets = async (tx: Prisma.TransactionClient) => { // uploading 状态的重试可能面对旧版本留下的半成品记录。先在同一事务内 // 清掉旧资产再重建;后续任一步失败都会整体回滚,不会重复叠加资产。 if (options?.replaceExistingAssets) { await tx.materialAsset.deleteMany({ where: { materialId } }); } // 批量创建资产记录 if (assetRecords.length > 0) { await tx.materialAsset.createMany({ data: assetRecords, }); } // 更新素材记录(zip信息,previewUrl在Step 6中更新) await tx.material.update({ where: { id: materialId }, data: { zipFilePath: zipInfos.all.filePath, zipFileName: zipInfos.all.fileName, zipFileSize: BigInt(zipInfos.all.fileSize), }, }); const createdAssets: SavedProductPackageSkuPreviewAsset[] = []; for (const skuPreviewRecord of skuPreviewRecords) { const createdAsset = await tx.materialAsset.create({ data: { materialId: skuPreviewRecord.materialId, assetType: skuPreviewRecord.assetType, fileUrl: skuPreviewRecord.fileUrl, fileName: skuPreviewRecord.fileName, sortOrder: skuPreviewRecord.sortOrder, skuCode: skuPreviewRecord.skuCode, }, select: { id: true, fileUrl: true, fileName: true, sortOrder: true, createdAt: true, updatedAt: true, }, }); if (skuPreviewRecord.barcodes.length > 0) { await tx.materialAssetBarcode.createMany({ data: skuPreviewRecord.barcodes.map((barcode) => ({ assetId: createdAsset.id, barcode, })), }); } createdAssets.push({ ...createdAsset, skuCode: skuPreviewRecord.skuCode, barcodes: skuPreviewRecord.barcodes, }); } if (options?.materialUpdate) { await tx.material.update({ where: { id: materialId }, data: { previewUrl: options.materialUpdate.previewUrl, isFullSet: options.materialUpdate.isFullSet, }, }); } return createdAssets; }; const createdSkuPreviewAssets = options?.transaction ? await applyAssets(options.transaction) : await prisma.$transaction(applyAssets); if (!options?.transaction) { console.log(`[Database] Saved ${assetRecords.length + createdSkuPreviewAssets.length} asset records for product package`); } return { createdSkuPreviewAssets }; } /** * 任务-详情的资产、卡片预览、ZIP、任务号和 completed 状态必须一次提交。 * 文件生成仍在事务外完成;调用方遇到事务响应确认异常时必须重读完整状态, * 不能仅凭本函数抛错就删除文件。 */ export async function saveProductPackageAssetsAndComplete(input: { materialId: string; previewAssets: ProductPackageAssetGroups; zipInfos: ProductPackageZipInfos; psdInfos?: ProductPackagePsdInfo[]; cardPreviewUrl: string; isFullSet: boolean; taskNo?: string | null; dingtalkApprovalNo?: string | null; userId: string; materialType?: string; }): Promise<{ createdSkuPreviewAssets: SavedProductPackageSkuPreviewAsset[] }> { let saveResult: { createdSkuPreviewAssets: SavedProductPackageSkuPreviewAsset[] } = { createdSkuPreviewAssets: [], }; await finalizeTaskMaterialOnComplete({ materialId: input.materialId, taskNo: input.taskNo, dingtalkApprovalNo: input.dingtalkApprovalNo, expectedTaskStatuses: ['uploading', null], expectedUserId: input.userId, expectedMaterialType: input.materialType || '任务-详情', applyCompletion: async (tx) => { saveResult = await saveProductPackageAssets( input.materialId, input.previewAssets, input.zipInfos, input.psdInfos, { transaction: tx, replaceExistingAssets: true, materialUpdate: { previewUrl: input.cardPreviewUrl, isFullSet: input.isFullSet, }, }, ); }, }); console.log('[Database] Atomically completed product package'); return saveResult; }