import { prisma } from '@/lib/prisma'; import { createMaterialLog } from '@/lib/material-log'; import { acquireTaskMaterialCompletionLock, acquireTaskMaterialUploadSessionLocks, type MaterialUploadStorageLockRelease, } from '@/lib/material-upload-cross-process-lock'; import { MaterialUploadRequestError } from '@/lib/material-upload-request-guard'; import { inspectTaskMaterialCompletionAfterError } from '@/lib/task-material-completion-safety'; import { cleanupOnError, cleanupTempFiles, createZipFromFiles, generatePreviewImages, resolveOwnedTaskMaterialSourceFiles, saveTaskMaterialAssetsAndComplete, TaskMaterialSourceValidationError, TASK_MATERIAL_ZIP_COMPRESSION_LEVEL, TASK_MATERIAL_ZIP_TIMEOUT_MS, type TaskMaterialSourceAssetInput, } from '@/lib/task-material-processor'; export class TaskImageMaterialCompletionError extends Error { constructor(message: string, readonly status: number) { super(message); this.name = 'TaskImageMaterialCompletionError'; } } export interface CompleteTaskImageMaterialInput { materialId: string; userId: string; materialType: string; fallbackProductModel: string; assets: TaskMaterialSourceAssetInput[]; taskNo?: string | null; dingtalkApprovalNo?: string | null; compressOptions?: { maxWidth?: number; maxHeight?: number; maxSizeKB?: number; }; } export interface CompleteTaskImageMaterialResult { materialId: string; alreadyCompleted?: boolean; warning?: string; } function throwCompletionError(message: string, status: number): never { throw new TaskImageMaterialCompletionError(message, status); } /** * 五类任务图片共用的安全完成状态机。 * * 长时间 ZIP/图片处理期间数据库保持 uploading,可在进程异常退出后自然重试; * 同一素材由共享盘上的 per-material flock 跨蓝绿容器互斥。只有全部派生文件 * 验证成功后,资产、任务号和 completed 状态才在同一事务提交。 */ export async function completeTaskImageMaterial( input: CompleteTaskImageMaterialInput, ): Promise { const materialId = String(input.materialId || '').trim(); const assets = Array.isArray(input.assets) ? input.assets : []; if (!materialId) { throwCompletionError('缺少素材编号', 400); } if (assets.length === 0) { throwCompletionError('至少需要上传一张图片', 400); } let releaseCompletionLock: MaterialUploadStorageLockRelease | null = null; let releaseUploadSessionLocks: MaterialUploadStorageLockRelease | null = null; let completed = false; let zipFilePath: string | null = null; const uploadedPreviewUrls: string[] = []; const uploadIds = assets.map((asset) => String(asset?.uploadId || '').trim()); let resolved: Awaited> | null = null; try { const initialMaterial = await prisma.material.findUnique({ where: { id: materialId }, select: { id: true, userId: true, materialType: true, taskStatus: true, deletedAt: true, }, }); if (!initialMaterial || initialMaterial.deletedAt) { throwCompletionError('素材不存在', 404); } if (initialMaterial.userId !== input.userId) { throwCompletionError('无权限操作此素材', 403); } if (initialMaterial.materialType !== input.materialType) { throwCompletionError('素材类型不匹配', 400); } releaseCompletionLock = await acquireTaskMaterialCompletionLock(materialId); releaseUploadSessionLocks = await acquireTaskMaterialUploadSessionLocks(uploadIds); // 拿到跨容器锁后必须重新读取,前一次查询只用于鉴权,不能作为状态依据。 const material = await prisma.material.findUnique({ where: { id: materialId }, select: { id: true, userId: true, materialType: true, productModel: true, taskStatus: true, previewUrl: true, zipFilePath: true, deletedAt: true, assets: { where: { assetType: { in: ['preview', 'zip'] } }, select: { assetType: true }, }, }, }); if (!material || material.deletedAt) { throwCompletionError('素材不存在', 404); } if (material.userId !== input.userId || material.materialType !== input.materialType) { throwCompletionError('素材归属或类型已变化,请刷新后重试', 409); } if (material.taskStatus === 'completed') { const assetTypes = new Set(material.assets.map((asset) => asset.assetType)); if ( !String(material.previewUrl || '').trim() || !String(material.zipFilePath || '').trim() || !assetTypes.has('preview') || !assetTypes.has('zip') ) { throwCompletionError('素材完成记录不完整,服务器已保留文件,请联系管理员核查', 409); } releaseUploadSessionLocks.assertHeld(); await cleanupTempFiles([], uploadIds).catch((cleanupError) => { console.warn(`[${input.materialType}] 幂等请求确认已完成,但源会话清理失败:`, cleanupError); }); return { materialId, alreadyCompleted: true }; } const isLegacyEmptyUpload = ( material.taskStatus == null && !material.previewUrl && !material.zipFilePath ); if (material.taskStatus !== 'uploading' && !isLegacyEmptyUpload) { throwCompletionError('该素材正在处理或状态异常,请稍后刷新', 409); } const resolvedSources = await resolveOwnedTaskMaterialSourceFiles({ assets, userId: input.userId, }); resolved = resolvedSources; const zipInfo = await createZipFromFiles( resolvedSources.files, materialId, material.productModel || input.fallbackProductModel, input.materialType, 'all', undefined, undefined, TASK_MATERIAL_ZIP_TIMEOUT_MS, TASK_MATERIAL_ZIP_COMPRESSION_LEVEL, ); zipFilePath = zipInfo.filePath; const previewUrls = await generatePreviewImages( assets.map((asset, index) => ({ uploadId: String(asset.uploadId || '').trim(), fileName: String(asset.fileName || `image-${index + 1}`), filePath: resolvedSources.files[index]?.path, })), input.materialType, input.compressOptions, (previewUrl) => uploadedPreviewUrls.push(previewUrl), ); if (uploadedPreviewUrls.length === 0) { uploadedPreviewUrls.push(...previewUrls); } if (uploadedPreviewUrls.length !== assets.length) { throw new Error('预览图生成数量不完整,本次素材未保存'); } releaseCompletionLock.assertHeld(); releaseUploadSessionLocks.assertHeld(); await saveTaskMaterialAssetsAndComplete({ materialId, previewUrls: uploadedPreviewUrls, zipInfo, taskNo: input.taskNo, dingtalkApprovalNo: input.dingtalkApprovalNo, userId: input.userId, materialType: input.materialType, }); releaseCompletionLock.assertHeld(); releaseUploadSessionLocks.assertHeld(); completed = true; // completed 已由数据库确认;清理失败只能记录告警,不能反向删除正式资产。 await cleanupTempFiles(resolved.tempFiles, resolved.uploadIds).catch((cleanupError) => { console.warn(`[${input.materialType}] 上传已完成,但源会话清理失败:`, cleanupError); }); await createMaterialLog({ materialId, userId: input.userId, action: 'UPLOAD', changes: { uploadType: { oldValue: null, newValue: input.materialType }, previewCount: { oldValue: 0, newValue: uploadedPreviewUrls.length }, zipFileName: { oldValue: null, newValue: zipInfo.fileName }, }, }).catch((logError) => { console.error(`[${input.materialType}] 上传已完成,但日志记录失败:`, logError); }); return { materialId }; } catch (error) { if (releaseCompletionLock && !completed) { const completionState = await inspectTaskMaterialCompletionAfterError(materialId); if (completionState.state === 'completed') { releaseUploadSessionLocks?.assertHeld(); await cleanupTempFiles(resolved?.tempFiles || [], resolved?.uploadIds || uploadIds).catch((cleanupError) => { console.warn(`[${input.materialType}] 数据库已完成,但源会话清理失败:`, cleanupError); }); return { materialId, warning: '素材已保存,但响应确认阶段出现异常', }; } if (completionState.state === 'completed_incomplete') { console.error(`[${input.materialType}] 数据库标记已完成,但预览/ZIP/资产证据不完整:`, completionState); throw new TaskImageMaterialCompletionError( '素材完成记录不完整,服务器已保留文件,请联系管理员核查', 409, ); } if (completionState.state === 'unknown') { console.error(`[${input.materialType}] 无法确认数据库最终状态,保留全部文件:`, completionState.error); throw new TaskImageMaterialCompletionError( '完成状态暂时无法确认,服务器已保留上传文件,请勿重复提交', 500, ); } // 只有仍处于可重试上传态时,才删除本次生成的预览和 ZIP。 // 源文件、分片、上传会话和素材记录全部保留,用户无需重新上传。 if ( completionState.state === 'other' && (completionState.taskStatus === 'uploading' || completionState.taskStatus == null) ) { releaseCompletionLock.assertHeld(); await cleanupOnError( uploadedPreviewUrls, zipFilePath, [], null, [], false, ); } else { console.warn(`[${input.materialType}] 素材状态已由其它流程接管,保留全部文件:`, completionState); } } if ( error instanceof TaskImageMaterialCompletionError || error instanceof TaskMaterialSourceValidationError || error instanceof MaterialUploadRequestError ) { throw error; } throw new TaskImageMaterialCompletionError( error instanceof Error ? error.message : '完成上传失败', 500, ); } finally { await releaseUploadSessionLocks?.(); await releaseCompletionLock?.(); } }