241 lines
5.9 KiB
TypeScript
241 lines
5.9 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;
|
||
}
|
||
|
||
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)),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 删除单个文件或目录(统一入口)
|
||
* @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,
|
||
basePath?: string
|
||
): Promise<DeleteFileResult> {
|
||
try {
|
||
// 处理路径
|
||
let absolutePath: string;
|
||
if (path.isAbsolute(filePath)) {
|
||
absolutePath = filePath;
|
||
} else if (filePath.startsWith('/')) {
|
||
absolutePath = filePath;
|
||
} else {
|
||
absolutePath = path.join(basePath || process.cwd(), filePath);
|
||
}
|
||
|
||
// 检查文件/目录是否存在
|
||
let stats;
|
||
try {
|
||
stats = await fs.stat(absolutePath);
|
||
} catch (err: any) {
|
||
if (err.code === 'ENOENT') {
|
||
// 文件/目录不存在,不算错误
|
||
return { success: true };
|
||
}
|
||
throw err;
|
||
}
|
||
|
||
// 根据类型删除
|
||
if (stats.isDirectory()) {
|
||
// 目录:递归删除
|
||
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;
|
||
}
|
||
): Promise<DeleteFileResult & { skipped?: boolean; reason?: string }> {
|
||
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,
|
||
};
|
||
}
|