/** * 统一的素材文件清理逻辑 * 根据不同的素材类型,收集所有需要删除的文件和关联记录。 */ import path from 'path' import { buildPublicDisplayUrl, buildWhiteBackgroundPublicDisplayPathFromAssetPath, } from './public-display' import { prisma } from './prisma' import { LEGACY_STANDARD_MODEL_WHITE_BG_ASSET_TYPE, LEGACY_STANDARD_MODEL_WHITE_BG_ASSET_TYPE_PREFIX, STANDARD_MODEL_WHITE_BG_OTHER_ASSET_TYPE_PREFIX, STANDARD_MODEL_WHITE_BG_ASSET_TYPE_PREFIX, STANDARD_MODEL_WHITE_BG_VIEW_ASSET_TYPE_PREFIX, } from './standard-model-white-background' export interface MaterialFileInfo { fileUrls: string[] directories: string[] relatedRecords?: { assets?: number skus?: number } } export function normalizeDisplayImages(displayImages: unknown): string[] { if (!displayImages) { return [] } if (Array.isArray(displayImages)) { return displayImages.filter( (img): img is string => typeof img === 'string' && img.trim().length > 0, ) } if (typeof displayImages === 'string') { try { const parsed = JSON.parse(displayImages) if (Array.isArray(parsed)) { return parsed.filter( (img): img is string => typeof img === 'string' && img.trim().length > 0, ) } } catch { // 旧数据可能直接存成单个 URL 字符串,这里降级兼容。 } return displayImages.trim() ? [displayImages] : [] } return [] } /** * 收集素材的所有文件路径 * @param materialId 素材 ID * @param material 素材对象,可选;不传时会自动查询 */ export async function collectMaterialFiles( materialId: string, material?: any, ): Promise { const fileUrls: string[] = [] const directories: string[] = [] const relatedRecords: { assets?: number; skus?: number } = {} if (!material) { material = await prisma.material.findUnique({ where: { id: materialId }, }) } if (!material) { return { fileUrls: [], directories: [], relatedRecords } } const materialType = String((material as any).materialType || '') if ((material as any).zipFilePath) { fileUrls.push((material as any).zipFilePath) } if ( (material as any).videoSourceUrl && (material as any).videoSourceUrl !== (material as any).zipFilePath ) { fileUrls.push((material as any).videoSourceUrl) } if ((material as any).previewUrl) { fileUrls.push((material as any).previewUrl) } if ((material as any).videoPreview540Url) { fileUrls.push((material as any).videoPreview540Url) } if ((material as any).videoPreviewClipUrl) { fileUrls.push((material as any).videoPreviewClipUrl) } if ((material as any).videoCoverUrl) { fileUrls.push((material as any).videoCoverUrl) } fileUrls.push(...normalizeDisplayImages((material as any).displayImages)) // 任务类素材都可能存在 MaterialAsset 记录。 const taskMaterialTypes = ['任务-详情', '任务-页面', '任务-其他', '任务-3D原图', '任务-AI原图', '任务-实拍原图', '任务-视频截图', '证书-检测报告']; if (taskMaterialTypes.includes(materialType)) { const assets = await prisma.materialAsset.findMany({ where: { materialId }, select: { fileUrl: true }, }) relatedRecords.assets = assets.length for (const asset of assets) { if (asset.fileUrl) { fileUrls.push(asset.fileUrl) } } if (materialType === '任务-详情' || materialType === '任务-页面') { directories.push(path.join(process.cwd(), 'uploads', 'product-package', materialId)) } } if (materialType === '标准模型') { const skus = await prisma.materialSku.findMany({ where: { materialId, }, select: { filePath: true, previewUrl: true, }, }) relatedRecords.skus = skus.length for (const sku of skus) { if (sku.filePath) { fileUrls.push(sku.filePath) } if (sku.previewUrl) { fileUrls.push(sku.previewUrl) } } // 兼容旧版整素材白底图 ZIP 键,以及新版 SKU 级白底图 ZIP 键。 const whiteBgAssets = await prisma.materialAsset.findMany({ where: { materialId, OR: [ { assetType: LEGACY_STANDARD_MODEL_WHITE_BG_ASSET_TYPE }, { assetType: { startsWith: LEGACY_STANDARD_MODEL_WHITE_BG_ASSET_TYPE_PREFIX } }, { assetType: { startsWith: STANDARD_MODEL_WHITE_BG_ASSET_TYPE_PREFIX } }, { assetType: { startsWith: STANDARD_MODEL_WHITE_BG_VIEW_ASSET_TYPE_PREFIX } }, { assetType: { startsWith: STANDARD_MODEL_WHITE_BG_OTHER_ASSET_TYPE_PREFIX } }, ], }, select: { fileUrl: true }, }) relatedRecords.assets = whiteBgAssets.length; for (const asset of whiteBgAssets) { if (asset.fileUrl) { fileUrls.push(asset.fileUrl) const publicDisplayPath = buildWhiteBackgroundPublicDisplayPathFromAssetPath(asset.fileUrl) const publicDisplayUrl = publicDisplayPath ? buildPublicDisplayUrl(publicDisplayPath) : null if (publicDisplayUrl) { fileUrls.push(publicDisplayUrl) } } } directories.push(path.join(process.cwd(), 'uploads', 'skus', materialId, 'white-background')) } return { fileUrls: Array.from(new Set(fileUrls.filter((fileUrl) => fileUrl && fileUrl.trim()))), directories: Array.from(new Set(directories.filter((dir) => dir && dir.trim()))), relatedRecords, } } /** * 删除素材关联记录 * @param materialId 素材 ID * @param materialType 素材类型 */ export async function deleteMaterialRelatedRecords( materialId: string, materialType: string, ): Promise<{ deletedAssets?: number; deletedSkus?: number }> { const result: { deletedAssets?: number; deletedSkus?: number } = {} const taskMaterialTypes = ['任务-详情', '任务-页面', '任务-其他', '任务-3D原图', '任务-AI原图', '任务-实拍原图', '任务-视频截图', '证书-检测报告']; if (taskMaterialTypes.includes(materialType)) { const deleteResult = await prisma.materialAsset.deleteMany({ where: { materialId }, }) result.deletedAssets = deleteResult.count } if (materialType === '标准模型') { const deleteSkuResult = await prisma.materialSku.deleteMany({ where: { materialId }, }) result.deletedSkus = deleteSkuResult.count const deleteWhiteBgResult = await prisma.materialAsset.deleteMany({ where: { materialId, OR: [ { assetType: LEGACY_STANDARD_MODEL_WHITE_BG_ASSET_TYPE }, { assetType: { startsWith: LEGACY_STANDARD_MODEL_WHITE_BG_ASSET_TYPE_PREFIX } }, { assetType: { startsWith: STANDARD_MODEL_WHITE_BG_ASSET_TYPE_PREFIX } }, { assetType: { startsWith: STANDARD_MODEL_WHITE_BG_VIEW_ASSET_TYPE_PREFIX } }, { assetType: { startsWith: STANDARD_MODEL_WHITE_BG_OTHER_ASSET_TYPE_PREFIX } }, ], }, }) result.deletedAssets = (result.deletedAssets || 0) + deleteWhiteBgResult.count } return result }