/** * 任务-详情素材类型 - 核心业务逻辑 */ import { prisma } from './prisma'; import type { Prisma } from '@prisma/client'; import path from 'path'; import { getDingtalkApprovalNoError, normalizeDingtalkApprovalNo, } from './dingtalk-approval-no'; import { LIST_VISIBLE_MATERIAL_DATA } from './material-query'; // 任务号格式:TASK + 12-18位数字 + 可选的多个子任务号(-2位数字,可重复) // 示例: // TASK202601030184 - 基础任务号(无子任务) // TASK202601071437194808-01 - 一级子任务 // TASK202601141713525624-01-01 - 二级子任务(任务号-子任务-子任务) // TASK202601141713525624-01-01-01 - 三级子任务(可无限层级) const TASK_NO_REGEX = /^TASK\d{12,18}(-\d{2})*$/; const DELETED_TASK_NO_SUFFIX_REGEX = /_DELETED_\d+_\d+$/; /** * 按规范化任务号串行化最终占用动作。锁函数返回 void,必须藏在子查询中, * 只让 Prisma 反序列化普通整数列。 */ export async function acquireTaskNoAssignmentLock( tx: Prisma.TransactionClient, normalizedTaskNo: string, ) { await tx.$queryRaw` SELECT 1 AS locked FROM ( SELECT pg_advisory_xact_lock(hashtext(${'task-no:' + normalizedTaskNo})) ) AS lock_result `; } // 任务状态 export const TASK_STATUS = { UPLOADING: 'uploading', COMPLETED: 'completed', FETCHED: 'fetched', } as const; export type TaskStatus = typeof TASK_STATUS[keyof typeof TASK_STATUS]; // 资产类型 export const ASSET_TYPE = { PREVIEW: 'preview', // 预览图(S3) ZIP: 'zip', // 压缩包(本地) MAIN: 'main', // 主图(zip或S3) SKU: 'sku', // SKU图(zip或S3) DETAIL: 'detail', // 详情图(zip或S3) ALL: 'all', // 全部压缩包(本地) PSD: 'psd', // PSD源文件(本地) } as const; export type AssetType = typeof ASSET_TYPE[keyof typeof ASSET_TYPE]; /** * 校验任务号格式 */ export function validateTaskNo(taskNo: string): boolean { return TASK_NO_REGEX.test(taskNo); } /** * 从历史软删除 taskNo 中恢复原始任务号 */ export function getOriginalTaskNo(taskNo?: string | null): string | null { if (!taskNo) { return null; } const trimmed = taskNo.trim(); if (!trimmed) { return null; } return trimmed.replace(DELETED_TASK_NO_SUFFIX_REGEX, ''); } /** * 检查任务号是否已存在(排除已删除的素材) */ export async function isTaskNoExists(taskNo: string): Promise { const material = await prisma.material.findFirst({ where: { taskNo, deletedAt: null, // 只检查未删除的素材 }, select: { id: true }, }); return !!material; } /** * 更新任务状态 */ export async function updateTaskStatus( materialId: string, status: TaskStatus, options?: { taskFetchedAt?: Date } ): Promise { await prisma.material.update({ where: { id: materialId }, data: { taskStatus: status, ...(options?.taskFetchedAt && { taskFetchedAt: options.taskFetchedAt }), }, }); } /** * 通过任务号获取素材 */ export async function getMaterialByTaskNo(taskNo: string) { return prisma.material.findFirst({ where: { taskNo, deletedAt: null }, include: { assets: { orderBy: { sortOrder: 'asc' }, }, user: { select: { id: true, username: true, realName: true, }, }, }, }); } /** * 获取素材的资产列表(按类型分组) * 兼容新旧两种资产类型格式 */ export async function getAssetsByMaterialId(materialId: string) { const assets = await prisma.materialAsset.findMany({ where: { materialId }, orderBy: { sortOrder: 'asc' }, }); return { main: assets.filter(a => a.assetType === ASSET_TYPE.MAIN), sku: assets.filter(a => a.assetType === ASSET_TYPE.SKU), detail: assets.filter(a => a.assetType === ASSET_TYPE.DETAIL), psd: assets.find(a => a.assetType === ASSET_TYPE.PSD) || null, }; } /** * 添加资产 */ export async function addAsset(data: { materialId: string; assetType: AssetType; fileUrl: string; fileName?: string; fileSize?: bigint; sortOrder?: number; }) { return prisma.materialAsset.create({ data: { materialId: data.materialId, assetType: data.assetType, fileUrl: data.fileUrl, fileName: data.fileName, fileSize: data.fileSize || BigInt(0), sortOrder: data.sortOrder || 0, }, }); } /** * 批量添加资产 */ export async function addAssets(assets: Array<{ materialId: string; assetType: AssetType; fileUrl: string; fileName?: string; fileSize?: bigint; sortOrder?: number; }>) { return prisma.materialAsset.createMany({ data: assets.map(a => ({ materialId: a.materialId, assetType: a.assetType, fileUrl: a.fileUrl, fileName: a.fileName, fileSize: a.fileSize || BigInt(0), sortOrder: a.sortOrder || 0, })), }); } /** * 删除资产 */ export async function deleteAsset(assetId: string) { return prisma.materialAsset.delete({ where: { id: assetId }, }); } /** * 更新资产排序 */ export async function updateAssetSortOrder( orders: Array<{ assetId: string; sortOrder: number }> ) { const updates = orders.map(({ assetId, sortOrder }) => prisma.materialAsset.update({ where: { id: assetId }, data: { sortOrder }, }) ); return prisma.$transaction(updates); } /** * 获取素材封面图(用于卡片展示) * 优先使用第一张主图,没有则使用第一张SKU图 * 兼容新旧两种资产类型格式 */ export async function getMaterialCoverUrl(materialId: string): Promise { // 先找主图(新逻辑) const mainAsset = await prisma.materialAsset.findFirst({ where: { materialId, assetType: ASSET_TYPE.MAIN }, orderBy: { sortOrder: 'asc' }, select: { fileUrl: true }, }); if (mainAsset) { return mainAsset.fileUrl; } // 没有主图,找SKU图(新逻辑) const skuAsset = await prisma.materialAsset.findFirst({ where: { materialId, assetType: ASSET_TYPE.SKU }, orderBy: { sortOrder: 'asc' }, select: { fileUrl: true }, }); return skuAsset?.fileUrl || null; } /** * 软删除素材(通过任务号) */ export async function softDeleteByTaskNo(taskNo: string) { return prisma.material.updateMany({ where: { taskNo, deletedAt: null, }, data: { deletedAt: new Date() }, }); } export async function getMaterialById(materialId: string) { return prisma.material.findFirst({ where: { id: materialId, deletedAt: null }, include: { assets: { orderBy: { sortOrder: 'asc' }, }, user: { select: { id: true, username: true, realName: true, }, }, }, }) } /** * 兼容旧调用名:任务号复用完成后只盘点并记录旧软删素材,不执行物理 GC。 * * 旧素材可能与其它记录共享文件 URL,而且文件系统/S3 删除与数据库级联无法 * 组成同一原子事务。自动清理会在任一阶段失败时留下不可恢复的数据缺口, * 因此这里明确 fail-safe:保留文件、关联记录和 Material 根记录,交给独立、 * 可审计且具备引用检查的离线流程处理。 */ export async function purgeDeletedMaterialsByTaskNo(taskNo: string): Promise<{ deletedMaterials: number; deletedFiles: number; failedFiles: number; }> { const deletedMaterials = await prisma.material.findMany({ where: { taskNo, deletedAt: { not: null }, }, select: { id: true, materialType: true, deletedAt: true, }, }); if (deletedMaterials.length > 0) { console.warn('[ProductPackageGC] 自动物理 GC 已禁用;旧软删素材及其文件已保留,需交由独立审计流程处理:', { taskNo, deferredMaterials: deletedMaterials.length, materialIds: deletedMaterials.map((material) => material.id), }); } // 保持历史返回结构;所有计数为 0 表示本请求没有发起任何物理删除。 return { deletedMaterials: 0, deletedFiles: 0, failedFiles: 0, }; } async function reportDeferredDeletedMaterialsAfterCompletion(taskNo: string) { try { return await purgeDeletedMaterialsByTaskNo(taskNo); } catch (error) { // 新素材已持久完成,旧数据盘点失败不得把成功结果改判为失败。 // 本路径不会因盘点失败而尝试删除任何文件或数据库记录。 console.error('[ProductPackageGC] 新素材已完成,旧软删素材盘点失败;自动物理 GC 仍保持禁用:', { taskNo, error, }); return { deletedMaterials: 0, deletedFiles: 0, failedFiles: 1, }; } } /** * 在素材成功完成后再写入任务关联字段,避免失败中的半成品占用任务号 */ export async function assignTaskNoOnComplete( materialId: string, taskNo?: string | null, dingtalkApprovalNo?: string | null, ): Promise<{ deletedMaterials: number; deletedFiles: number; failedFiles: number; } | null> { return finalizeTaskMaterialOnComplete({ materialId, taskNo, dingtalkApprovalNo, }); } /** * 把任务号占用、任务资产最终写入和 completed 状态放进同一事务。 * 文件生成发生在事务外;调用方只有在所有派生文件已经验证完成后才调用。 */ export async function finalizeTaskMaterialOnComplete(input: { materialId: string; taskNo?: string | null; dingtalkApprovalNo?: string | null; applyCompletion?: (tx: Prisma.TransactionClient) => Promise; expectedTaskStatuses?: Array; expectedUserId?: string; expectedMaterialType?: string; }): Promise<{ deletedMaterials: number; deletedFiles: number; failedFiles: number; } | null> { const { materialId, taskNo, dingtalkApprovalNo, applyCompletion, expectedTaskStatuses, expectedUserId, expectedMaterialType, } = input; const normalizedTaskNo = taskNo?.trim(); const hasDingtalkApprovalNo = dingtalkApprovalNo !== undefined; const normalizedDingtalkApprovalNo = normalizeDingtalkApprovalNo(dingtalkApprovalNo); const dingtalkApprovalNoError = getDingtalkApprovalNoError(normalizedDingtalkApprovalNo); if (dingtalkApprovalNoError) { throw new Error(dingtalkApprovalNoError); } const assertExpectedCompletionState = async ( tx: Prisma.TransactionClient, ) => { if (!expectedTaskStatuses || expectedTaskStatuses.length === 0) { return; } // 行锁把“验证可完成状态”和后续资产/完成状态写入放在同一临界区。 // 不能在 applyCompletion 之后再用 previewUrl='' 作 CAS;事务自己的预览更新 // 会让 legacy null 状态把自己挡住。 const rows = await tx.$queryRaw>` SELECT "task_status" AS "taskStatus", "previewUrl", "zipFilePath", "deletedAt", "userId", "materialType" FROM "materials" WHERE "id" = ${materialId} FOR UPDATE `; const material = rows[0]; const matchesExpectedStatus = Boolean(material) && expectedTaskStatuses.some((taskStatus) => { if (taskStatus == null) { return material.taskStatus == null && !material.previewUrl && !material.zipFilePath; } return material.taskStatus === taskStatus; }); if ( !material || material.deletedAt || !matchesExpectedStatus || (expectedUserId && material.userId !== expectedUserId) || (expectedMaterialType && material.materialType !== expectedMaterialType) ) { throw new Error('素材完成状态已变化,本次结果未写入,请刷新后重试'); } }; if (!normalizedTaskNo) { await prisma.$transaction(async (tx) => { await assertExpectedCompletionState(tx); await applyCompletion?.(tx); await tx.material.update({ where: { id: materialId }, data: { ...(hasDingtalkApprovalNo ? { dingtalkApprovalNo: normalizedDingtalkApprovalNo } : {}), taskStatus: TASK_STATUS.COMPLETED, ...LIST_VISIBLE_MATERIAL_DATA, } as any, }); }); return null; } if (!validateTaskNo(normalizedTaskNo)) { throw new Error('任务号格式错误'); } const existingMaterial = await prisma.material.findFirst({ where: { taskNo: normalizedTaskNo, deletedAt: null, NOT: { id: materialId }, } as any, select: { id: true, productModel: true, materialType: true, }, }); if (existingMaterial) { throw new Error( `任务号 ${normalizedTaskNo} 已存在,对应素材:${existingMaterial.productModel}(${existingMaterial.materialType})` ); } await prisma.$transaction(async (tx) => { await acquireTaskNoAssignmentLock(tx, normalizedTaskNo); // 这是并发完成时决定谁能最终占用任务号的权威门禁。 const concurrentOwner = await tx.material.findFirst({ where: { taskNo: normalizedTaskNo, deletedAt: null, NOT: { id: materialId }, } as any, select: { id: true, productModel: true, materialType: true, }, }); if (concurrentOwner) { throw new Error( `任务号 ${normalizedTaskNo} 已存在,对应素材:${concurrentOwner.productModel}(${concurrentOwner.materialType})` ); } await assertExpectedCompletionState(tx); await applyCompletion?.(tx); await tx.material.update({ where: { id: materialId }, data: { taskNo: normalizedTaskNo, ...(hasDingtalkApprovalNo ? { dingtalkApprovalNo: normalizedDingtalkApprovalNo } : {}), taskStatus: TASK_STATUS.COMPLETED, ...LIST_VISIBLE_MATERIAL_DATA, } as any, }); }); // 新素材完整提交后只盘点并记录旧软删数据;不发起物理 GC, // 不回滚、也不遮蔽已经完成的新素材。 return reportDeferredDeletedMaterialsAfterCompletion(normalizedTaskNo); } /** * 通用上传入口中的任务素材必须把 Material、资产、任务号和 completed * 状态一次提交。任何资产写入失败都会回滚整条新素材,不能留下“已完成但 * 缺资产”的半成品。 */ export async function createTaskMaterialOnComplete(input: { materialData: Record; assets: Array<{ assetType: string; fileUrl: string; fileName: string; fileSize: bigint; sortOrder: number; }>; taskNo?: string | null; dingtalkApprovalNo?: string | null; }) { const normalizedTaskNo = String(input.taskNo || '').trim(); if (normalizedTaskNo && !validateTaskNo(normalizedTaskNo)) { throw new Error('任务号格式错误'); } const normalizedDingtalkApprovalNo = normalizeDingtalkApprovalNo(input.dingtalkApprovalNo); const dingtalkApprovalNoError = getDingtalkApprovalNoError(normalizedDingtalkApprovalNo); if (dingtalkApprovalNoError) { throw new Error(dingtalkApprovalNoError); } const material = await prisma.$transaction(async (tx) => { if (normalizedTaskNo) { await acquireTaskNoAssignmentLock(tx, normalizedTaskNo); const concurrentOwner = await tx.material.findFirst({ where: { taskNo: normalizedTaskNo, deletedAt: null, }, select: { id: true, productModel: true, materialType: true, }, }); if (concurrentOwner) { throw new Error( `任务号 ${normalizedTaskNo} 已存在,对应素材:${concurrentOwner.productModel}(${concurrentOwner.materialType})`, ); } } const createdMaterial = await tx.material.create({ data: { ...input.materialData, ...(normalizedTaskNo ? { taskNo: normalizedTaskNo } : {}), dingtalkApprovalNo: normalizedDingtalkApprovalNo, taskStatus: TASK_STATUS.COMPLETED, ...LIST_VISIBLE_MATERIAL_DATA, } as any, include: { user: { select: { username: true }, }, }, }); if (input.assets.length > 0) { await tx.materialAsset.createMany({ data: input.assets.map((asset) => ({ ...asset, materialId: createdMaterial.id, })), }); } return createdMaterial; }); if (normalizedTaskNo) { await reportDeferredDeletedMaterialsAfterCompletion(normalizedTaskNo); } return material; } /** * 保留旧调用名,避免其它尚未迁移的调用方绕回非原子实现。 */ export async function createTaskOtherMaterialOnComplete(input: Parameters< typeof createTaskMaterialOnComplete >[0]) { return createTaskMaterialOnComplete(input); } /** * 清理任务-详情素材的所有文件(用于上传失败时的回滚) * @param materialId 素材ID * @param fileUrls 可选,如果提供则直接清理这些文件(用于资产记录还未创建的情况) */ export async function cleanupProductPackageFiles( materialId: string, fileUrls?: string[] ): Promise<{ deletedFiles: number; failedFiles: number; }> { const { deleteFromS3, isS3Url } = await import('./s3'); const fs = await import('fs/promises'); let deletedFiles = 0; let failedFiles = 0; try { let filesToDelete: string[] = []; if (fileUrls && fileUrls.length > 0) { // 如果提供了文件 URL 列表,直接使用(资产记录可能还未创建) filesToDelete = fileUrls; } else { // 否则查询数据库中的资产文件 const assets = await prisma.materialAsset.findMany({ where: { materialId }, select: { fileUrl: true }, }); filesToDelete = assets.map(a => a.fileUrl).filter(Boolean); } // 删除所有文件(使用统一的文件删除工具) const { deleteFile } = await import('./file-cleanup'); for (const fileUrl of filesToDelete) { if (!fileUrl) continue; const result = await deleteFile(fileUrl); if (result.success) { deletedFiles++; const fileType = result.isDirectory ? 'directory' : 'file'; console.log(`[Cleanup] Deleted ${fileType}: ${fileUrl}`); } else { failedFiles++; console.error(`[Cleanup] Failed to delete file: ${fileUrl}`, result.error); } } // 删除 PSD 文件目录(如果存在) const psdDir = path.join(process.cwd(), 'uploads', 'product-package', materialId); try { await fs.rm(psdDir, { recursive: true, force: true }); console.log(`[Cleanup] Deleted PSD directory: ${psdDir}`); } catch (err: any) { if (err.code !== 'ENOENT') { console.warn(`[Cleanup] Failed to delete PSD directory: ${psdDir}`, err); } } // 删除资产记录(如果存在) try { await prisma.materialAsset.deleteMany({ where: { materialId }, }); } catch (err) { // 资产记录可能不存在,忽略错误 console.warn(`[Cleanup] Failed to delete asset records (may not exist):`, err); } // 删除素材记录 try { await prisma.material.delete({ where: { id: materialId }, }); } catch (err) { console.warn(`[Cleanup] Failed to delete material record: ${materialId}`, err); } console.log(`[Cleanup] Cleaned up product package material ${materialId}: ${deletedFiles} files deleted, ${failedFiles} failed`); } catch (error) { console.error(`[Cleanup] Error cleaning up product package material ${materialId}:`, error); throw error; } return { deletedFiles, failedFiles }; }