317 lines
8.3 KiB
TypeScript
317 lines
8.3 KiB
TypeScript
/**
|
||
* 统一的文件清理工具函数
|
||
* 用于统一处理 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,
|
||
};
|
||
}
|