/** * 统一的文件清理工具函数 * 用于统一处理 S3 文件和本地文件/目录的删除逻辑 */ import { deleteFromS3, isS3Url } from './s3'; import { promises as fs } from 'fs'; import path from 'path'; import { prisma } from './prisma'; export interface DeleteFileResult { success: boolean; isDirectory?: boolean; error?: string; } export interface DeleteLocalFileOptions { basePath?: string; allowDirectoryDelete?: boolean; } function getFileUrlVariants(fileUrl: string): string[] { const trimmed = fileUrl.trim(); if (!trimmed) { return []; } return Array.from( new Set([ trimmed, trimmed.replace(/\\/g, '/'), trimmed.replace(/\//g, '\\'), ].filter(Boolean)), ); } function getUploadRoot(): string { return path.resolve(process.env.UPLOAD_DIR || path.join(process.cwd(), 'uploads')); } function isPathStrictlyInside(basePath: string, targetPath: string): boolean { const relativePath = path.relative(basePath, targetPath); return ( relativePath.length > 0 && !relativePath.startsWith(`..${path.sep}`) && relativePath !== '..' && !path.isAbsolute(relativePath) ); } function resolveLocalDeleteTarget(filePath: string, basePath?: string): { allowedRoot: string; absolutePath: string; } { const allowedRoot = path.resolve(basePath || getUploadRoot()); const normalizedPath = String(filePath || '').trim().replace(/[\\/]+/g, path.sep); const uploadsPrefix = `${path.sep}uploads${path.sep}`; const relativeUploadsPrefix = `uploads${path.sep}`; let absolutePath: string; if (!basePath && normalizedPath.startsWith(uploadsPrefix)) { absolutePath = path.resolve(allowedRoot, normalizedPath.slice(uploadsPrefix.length)); } else if (!basePath && normalizedPath.startsWith(relativeUploadsPrefix)) { absolutePath = path.resolve(allowedRoot, normalizedPath.slice(relativeUploadsPrefix.length)); } else if (path.isAbsolute(normalizedPath)) { absolutePath = path.resolve(normalizedPath); } else { absolutePath = path.resolve(basePath || process.cwd(), normalizedPath); } return { allowedRoot, absolutePath }; } /** * 删除单个文件或目录(统一入口) * @param fileUrl 文件 URL(S3 URL 或本地路径) * @param basePath 可选,本地文件的基础路径(用于相对路径) * @returns 删除结果 */ export async function deleteFile( fileUrl: string, basePath?: string ): Promise { if (!fileUrl || !fileUrl.trim()) { return { success: false, error: '文件路径为空' }; } try { if (isS3Url(fileUrl)) { // S3 文件 await deleteFromS3(fileUrl); return { success: true }; } else { // 本地文件或目录 return await deleteLocalFile(fileUrl, basePath); } } catch (error: any) { return { success: false, error: error.message || '删除失败', }; } } /** * 删除本地文件或目录 * @param filePath 文件路径(绝对路径或相对路径) * @param basePath 可选,基础路径(用于相对路径) * @returns 删除结果 */ export async function deleteLocalFile( filePath: string, options?: string | DeleteLocalFileOptions, ): Promise { try { const basePath = typeof options === 'string' ? options : options?.basePath; const allowDirectoryDelete = typeof options === 'object' && options?.allowDirectoryDelete === true; const { allowedRoot, absolutePath } = resolveLocalDeleteTarget(filePath, basePath); if (!isPathStrictlyInside(allowedRoot, absolutePath)) { return { success: false, error: '拒绝删除受控上传目录之外的路径', }; } // 检查文件/目录是否存在 let stats; try { stats = await fs.lstat(absolutePath); } catch (err: any) { if (err.code === 'ENOENT') { // 文件/目录不存在,不算错误 return { success: true }; } throw err; } if (stats.isSymbolicLink()) { return { success: false, error: '拒绝删除符号链接', }; } const [realAllowedRoot, realTargetPath] = await Promise.all([ fs.realpath(allowedRoot), fs.realpath(absolutePath), ]); if (!isPathStrictlyInside(realAllowedRoot, realTargetPath)) { return { success: false, error: '拒绝删除受控上传目录之外的路径', }; } // 根据类型删除 if (stats.isDirectory()) { if (!allowDirectoryDelete) { return { success: false, isDirectory: true, error: '目录递归删除必须由受信任调用方明确允许', }; } await fs.rm(absolutePath, { recursive: true, force: true }); return { success: true, isDirectory: true }; } else { // 文件:直接删除 await fs.unlink(absolutePath); return { success: true, isDirectory: false }; } } catch (error: any) { if (error.code === 'ENOENT') { // 文件/目录不存在,不算错误 return { success: true }; } return { success: false, error: error.message || '删除失败', }; } } /** * 批量删除文件 * @param fileUrls 文件 URL 列表 * @param basePath 可选,本地文件的基础路径 * @returns 删除统计 */ export async function deleteFiles( fileUrls: string[], basePath?: string ): Promise<{ deletedCount: number; failedCount: number; errors: Array<{ fileUrl: string; error: string }>; }> { const uniqueFiles = Array.from(new Set(fileUrls.filter(f => f && f.trim()))); let deletedCount = 0; let failedCount = 0; const errors: Array<{ fileUrl: string; error: string }> = []; for (const fileUrl of uniqueFiles) { const result = await deleteFile(fileUrl, basePath); if (result.success) { deletedCount++; } else { failedCount++; errors.push({ fileUrl, error: result.error || '删除失败', }); } } return { deletedCount, failedCount, errors }; } export async function hasActiveFileReferences( fileUrl: string, options?: { excludeAssetIds?: string[]; excludeSkuIds?: string[]; excludeMaterialIds?: string[]; } ): Promise { if (!fileUrl || !fileUrl.trim()) { return false; } const variants = getFileUrlVariants(fileUrl); if (variants.length === 0) { return false; } const assetWhere: any = { fileUrl: { in: variants }, }; if (options?.excludeAssetIds?.length) { assetWhere.NOT = { id: { in: options.excludeAssetIds } }; } const assetRefs = await prisma.materialAsset.count({ where: assetWhere }); if (assetRefs > 0) { return true; } const skuWhere: any = { deletedAt: null, OR: [ { filePath: { in: variants } }, { previewUrl: { in: variants } }, ], }; if (options?.excludeSkuIds?.length) { skuWhere.NOT = { id: { in: options.excludeSkuIds } }; } const skuRefs = await prisma.materialSku.count({ where: skuWhere }); if (skuRefs > 0) { return true; } const materialWhere: any = { deletedAt: null, OR: [ { previewUrl: { in: variants } }, { zipFilePath: { in: variants } }, { videoSourceUrl: { in: variants } }, { videoPreview540Url: { in: variants } }, { videoPreviewClipUrl: { in: variants } }, { videoCoverUrl: { in: variants } }, ...variants.map((variant) => ({ displayImages: { has: variant }, })), ], }; if (options?.excludeMaterialIds?.length) { materialWhere.NOT = { id: { in: options.excludeMaterialIds } }; } const materialRefs = await prisma.material.count({ where: materialWhere }); return materialRefs > 0; } export async function deleteFileIfUnreferenced( fileUrl: string, options?: { excludeAssetIds?: string[]; excludeSkuIds?: string[]; excludeMaterialIds?: string[]; basePath?: string; allowPhysicalDelete?: boolean; } ): Promise { if (options?.allowPhysicalDelete === false) { return { success: true, skipped: true, reason: 'physical_delete_disabled', }; } const referenced = await hasActiveFileReferences(fileUrl, options); if (referenced) { return { success: true, skipped: true, reason: 'referenced_elsewhere', }; } const result = await deleteFile(fileUrl, options?.basePath); return { ...result, skipped: false, }; }