/** * 任务类型素材的通用处理逻辑 * 用于:任务-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 { mkdir, unlink, readFile, rm } from 'fs/promises'; import { createWriteStream } from 'fs'; import path from 'path'; import archiver from 'archiver'; // 资产类型常量 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[]; } 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; } /** * 从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, ): Promise<{ filePath: string; fileName: string; fileSize: number }> { if (!files || files.length === 0) { throw new Error('没有文件需要打包'); } // 验证所有文件路径存在 const { access } = await import('fs/promises'); for (const file of files) { try { await access(file.path); } catch (error) { throw new Error(`文件不存在: ${file.path}`); } } // 创建目标目录 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); return new Promise((resolve, reject) => { let output: any = null; let archive: any = null; const timeout = setTimeout(() => { cleanup(); reject(new Error(`打包超时(${Math.ceil(timeoutMs / 1000)}秒)`)); }, timeoutMs); // 清理函数 const cleanup = () => { try { clearTimeout(timeout); if (output) { output.removeAllListeners(); output.destroy(); output = null; // ✅ 置空引用 } if (archive) { archive.removeAllListeners(); archive.destroy(); archive = null; // ✅ 置空引用 } } catch (err) { console.error('[Zip Cleanup] Error:', err); } }; try { // 创建输出流 output = createWriteStream(zipFilePath); archive = archiver('zip', { zlib: { level: 9 } // 最高压缩级别 }); // 监听完成事件 output.on('close', async () => { clearTimeout(timeout); const fileSize = archive.pointer(); console.log(`[Zip] ${zipType} zip file created successfully: ${fileSize} bytes`); try { await syncStoredUploadFileToRemote(zipFilePath, relativeZipPath, fileSize); cleanup(); resolve({ filePath: relativeZipPath, fileName: zipFileName, fileSize }); } catch (error) { cleanup(); reject(error); } }); // 监听错误 archive.on('error', (err: Error) => { clearTimeout(timeout); console.error('[Zip] Archive error:', err); cleanup(); reject(new Error(`打包失败: ${err.message}`)); }); output.on('error', (err: Error) => { clearTimeout(timeout); console.error('[Zip] Output stream error:', err); cleanup(); reject(new Error(`写入文件失败: ${err.message}`)); }); // 监听警告 archive.on('warning', (err: any) => { if (err.code === 'ENOENT') { console.warn('[Zip] Warning:', err); } else { clearTimeout(timeout); cleanup(); reject(err); } }); // 连接archive到输出流 archive.pipe(output); // 添加每个文件到zip。上传文件可能来自多个文件夹,文件名会重复; // ZIP 内同名条目在很多解压软件中会互相覆盖,因此这里必须保证条目名唯一。 const usedZipEntryNames = new Set(); files.forEach((file, index) => { const zipEntryName = resolveUniqueZipEntryName(file.name, index, usedZipEntryNames); if (zipEntryName !== file.name) { console.warn(`[Zip] Duplicate or unsafe file name adjusted: ${file.name} -> ${zipEntryName}`); } console.log(`[Zip] Adding file: ${zipEntryName} (${file.size} bytes)`); archive.file(file.path, { name: zipEntryName }); }); // 完成压缩 archive.finalize(); } catch (error) { clearTimeout(timeout); console.error('[Zip] Error:', error); reject(error); } }); } /** * 打包已有的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, ): Promise<{ filePath: string; fileName: string; fileSize: number }> { if (zipFiles.length === 0) { throw new Error('没有文件需要打包'); } // 验证所有文件路径存在 const { access } = await import('fs/promises'); for (const file of zipFiles) { try { const fullPath = path.isAbsolute(file.path) ? file.path : resolveStoredUploadPath(file.path); await access(fullPath); } catch (error) { throw new Error(`文件不存在: ${file.path}`); } } // 创建目标目录 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); return new Promise((resolve, reject) => { let output: any = null; let archive: any = null; const timeout = setTimeout(() => { cleanup(); reject(new Error(`打包超时(${Math.ceil(timeoutMs / 1000)}秒)`)); }, timeoutMs); // 清理函数 const cleanup = () => { try { clearTimeout(timeout); if (output) { output.removeAllListeners(); output.destroy(); output = null; // ✅ 置空引用 } if (archive) { archive.removeAllListeners(); archive.destroy(); archive = null; // ✅ 置空引用 } } catch (err) { console.error('[Zip Cleanup] Error:', err); } }; try { output = createWriteStream(zipFilePath); archive = archiver('zip', { zlib: { level: 9 } // 最高压缩级别 }); output.on('close', async () => { clearTimeout(timeout); const fileSize = archive.pointer(); console.log(`[Zip] all.zip created successfully: ${fileSize} bytes`); try { await syncStoredUploadFileToRemote(zipFilePath, relativeZipPath, fileSize); cleanup(); resolve({ filePath: relativeZipPath, fileName: zipFileName, fileSize }); } catch (error) { cleanup(); reject(error); } }); archive.on('error', (err: Error) => { clearTimeout(timeout); console.error('[Zip] Archive error:', err); cleanup(); reject(new Error(`打包失败: ${err.message}`)); }); output.on('error', (err: Error) => { clearTimeout(timeout); console.error('[Zip] Output stream error:', err); cleanup(); reject(new Error(`写入文件失败: ${err.message}`)); }); archive.on('warning', (err: any) => { if (err.code === 'ENOENT') { console.warn('[Zip] Warning:', err); } else { clearTimeout(timeout); cleanup(); reject(err); } }); archive.pipe(output); // 只添加zip文件,不添加PSD文件 for (const file of zipFiles) { const fullPath = path.isAbsolute(file.path) ? file.path : resolveStoredUploadPath(file.path); console.log(`[Zip] Adding zip file: ${file.name}`); archive.file(fullPath, { name: file.name }); } archive.finalize(); } catch (error) { clearTimeout(timeout); console.error('[Zip] Error:', error); reject(error); } }); } /** * 为图片生成预览图并上传到S3 * 注意:必须为所有图片生成预览图,如果任何一张失败则整个流程失败 */ export async function generatePreviewImages( uploadIds: Array<{ uploadId: string; fileName: string }>, materialType: string, compressOptions?: { maxWidth?: number; maxHeight?: number; maxSizeKB?: number; } ): 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 { const filePath = await getUploadedFile(item.uploadId); if (!filePath) { const error = `文件不存在: ${item.fileName}`; console.error(`[Preview] ${error}`); errors.push(error); continue; } const buffer = await readFile(filePath); // 验证是否为图片 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); 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 (tempFiles.length > 0) { console.log(`[Cleanup] Cleaning up ${tempFiles.length} temporary files...`); for (const filePath of tempFiles) { try { await unlink(filePath); console.log(`[Cleanup] Deleted temp file: ${filePath}`); } catch (err) { console.warn(`[Cleanup] Failed to delete temp file: ${filePath}`, err); } } } // 清理chunk临时目录 if (uploadIds.length > 0) { console.log(`[Cleanup] Cleaning up ${uploadIds.length} chunk temp directories...`); for (const uploadId of uploadIds) { const tempDir = path.join(process.env.UPLOAD_DIR || './uploads', 'temp', uploadId); try { await rm(tempDir, { recursive: true, force: true }); console.log(`[Cleanup] Deleted chunk temp dir: ${tempDir}`); } catch (err) { console.warn(`[Cleanup] Failed to delete chunk temp dir: ${tempDir}`); } } } // 清理Redis上传会话(关键修复:防止孤立会话堆积) if (uploadIds.length > 0) { console.log(`[Cleanup] Cleaning up ${uploadIds.length} Redis upload sessions...`); const { deleteUploadSession } = await import('@/lib/upload-sessions-redis'); for (const uploadId of uploadIds) { try { await deleteUploadSession(uploadId); console.log(`[Cleanup] Deleted Redis session: ${uploadId}`); } catch (err) { console.warn(`[Cleanup] Failed to delete Redis session: ${uploadId}`, err); } } } } /** * 清理失败时的资源(S3文件、本地zip、临时文件、数据库记录) */ export async function cleanupOnError( uploadedPreviewUrls: string[], zipFilePaths: string | string[] | null, tempFiles: string[], materialId: string | null ): 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); } } } // 清理临时文件 if (tempFiles.length > 0) { for (const filePath of tempFiles) { try { await unlink(filePath); } catch (cleanupError) { console.warn(`[Cleanup] Failed to delete temp file: ${filePath}`); } } } // 删除素材记录 if (materialId) { try { 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 { 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, }); } // 使用事务保存 await prisma.$transaction(async (tx) => { // 批量创建资产记录 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), }, }); }); console.log(`[Database] Saved ${assetRecords.length} asset records`); } /** * 保存任务-详情的资产记录到数据库 * 包括:预览图(S3)、分类zip(本地)、总zip(本地)、PSD(本地) */ export async function saveProductPackageAssets( materialId: string, previewAssets: { main: ProductPackagePreviewAssetInput[]; sku: ProductPackagePreviewAssetInput[]; detail: ProductPackagePreviewAssetInput[]; }, 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 }; }, psdInfos?: Array<{ filePath: string; fileName: string; fileSize: number }> ): 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 createdSkuPreviewAssets = await prisma.$transaction(async (tx) => { // 批量创建资产记录 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, }, 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, }); } return createdAssets; }); console.log(`[Database] Saved ${assetRecords.length + createdSkuPreviewAssets.length} asset records for product package`); return { createdSkuPreviewAssets }; }