gongxiangp2/lib/file-cleanup.ts

317 lines
8.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 统一的文件清理工具函数
* 用于统一处理 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<DeleteFileResult> {
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<DeleteFileResult> {
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<boolean> {
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<DeleteFileResult & { skipped?: boolean; reason?: string }> {
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,
};
}