CDiskClean 开发历程 —— 文件清理_5种方式_实现方案参考

2 阅读9分钟

前言

这篇博客,我打算结合我最近正在开发的 C盘管理工具 内部的功能实现,给各位读者一些在实现方式上的参考,文章较长,但会很详细,请做好心理准备。

这篇文章是 文件清理模块中 清理方式的实现方案参考。

问题引入

C盘管理工具,无非就是两部分,一块是监控,一块是清理,做这个应用的初衷就是奔着清理去的。所以清理非常重要。而清理,需要提前确定 可能包含清理项的目录列表清理方式。所以,会分两篇讲,上一篇讲了 目录列表的解析以及搭建CDiskClean 开发历程 —— 目录树解析的实现方案参考),这一篇讲 五种清理方式的实现方案

清理的需求,表面上是"把文件删掉腾出空间",但实际使用中会发现不同场景要的处理完全不同:

删错了想反悔 → 需要 回收站

明确的垃圾文件 → 需要 永久删除

C盘放不下但文件还有用 → 需要 移动到其他盘

体积大、不常用、要归档 → 需要 压缩

软件依赖原路径、移动就"失联" → 需要 mkLink 软链接

所以本文不打算做"一种删除方式打天下"的方案,而是提供一套 可插拔的清理方式框架,把 5 种方式全部接入,并解决它们共同的三个问题:安全校验、占用重试、防自扰

前置知识

回收站与 SHFileOperation

回收站 是 Windows 提供的"可撤销删除"机制,删除的文件没有真正释放空间,而是进入了回收站。C# 标准库没有现成的 API,需要 P/Invoke 调用 shell32.dll 中的 SHFileOperation

[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern int SHFileOperation(ref SHFILEOPSTRUCT lpFileOp);

如果抄录的时候发现没有 SHFILEOPSTRUCT 也不用慌,我正打算把代码发出来:

这部分的代码和DLL文件相关联,内部字段一般不支持自定义的修改,直接抄录就行

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct SHFILEOPSTRUCT
{
    public IntPtr hwnd; // 窗口句柄
    public uint wFunc; // 操作类型
    public string pFrom; // 源文件路径(多个路径使用 \0 分隔,末尾必须双\0)-
    public string pTo; // 目标文件路径(对于删除操作不使用)
    public ushort fFlags; // 操作标志
    public IntPtr fAnyOperationsAborted; // 是否中止操作
    public IntPtr hNameMappings; // 文件名映射
    public string lpszProgressTitle; // 进度窗口标题
}

[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern int SHFileOperation(ref SHFILEOPSTRUCT lpFileOp);

// 操作:删除
private const uint FO_DELETE = 3;
// 允许撤销 → 放入回收站【关键标识】
private const ushort FOF_ALLOWUNDO = 0x0040;
// 不弹出确认对话框
private const ushort FOF_NOCONFIRMATION = 0x0010;
// 不显示进度窗口
private const ushort FOF_SILENT = 0x0004;

其中最重要的几个结构体字段与标志:

wFunc【字段】操作类型,删除为 FO_DELETE = 3

FOF_ALLOWUNDO【标志】允许撤销 → 放入回收站的关键标识,不加它就成了不可恢复的物理删除

FOF_NOCONFIRMATION【标志】不弹出确认对话框

FOF_SILENT【标志】不显示进度窗口

pFrom【字段】源路径,末尾必须拼接双空字符 \0\0,这是 Windows Shell API 的约定,漏写会导致路径解析失败

ZipFile 压缩

System.IO.Compression 是 .NET 内置的压缩命名空间,不需要引入第三方库。

ZipFile.CreateFromDirectory(dir, zipPath) 【方法】直接把整棵目录树打包成 zip

ZipFile.Open(zipPath, ZipArchiveMode.Create) 【方法】手动创建压缩包,配合 CreateEntryFromFile 把单个文件塞进去

CompressionLevel.Optimal【枚举】压缩质量与速度的平衡档

硬链接与目录联接

mkLink 是 Windows 的链接命令,分两种玩法:

目录联接(junction,mklink /J:原位置放一个"快捷方式"指向新位置,无需管理员权限、支持跨卷,任何程序访问原路径都会被透明转发

文件硬链接(mklink /H:同一卷内为文件创建第二个目录项,数据只有一份,两个路径都能用;但要求源与目标处于同一磁盘卷,且直接调 cmd.exe 需要管理员权限

这里有个坑:cmd.exe 的 mklink /H内部命令Process.Start 调用它受 UAC 限制。文件硬链接其实有更干净的替代 —— Win32 原生 API:

[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CreateHardLinkW(
    string lpFileName,
    string lpExistingFileName,
    IntPtr lpSecurityAttributes);

无需管理员权限,调用后原路径即刻可用。

Path 文件操作相关

构建移动/压缩逻辑的基础,和上一篇目录树解析用到的是同一套:

Path.getFullPath(string) 获取绝对路径,防止传入相对路径

Path.getPathRoot(string) 获取磁盘根目录 <-> 判断源与目标是否同盘区(硬链接与 Directory.Move 的行为都依赖它)

Path.combine(string,string) 对路径进行合并

Path.getExtension(string) 拆分扩展名,配合 GetFileNameWithoutExtension 做重名后缀处理

思路整理:

五种清理方式,本质上是同一套骨架的五个"动作",所以先把骨架设计好:

  • 统一入口ExecuteSingle(entry, method, targetDir) 一个方法分发所有方式,新增方式只需加一个枚举值 + 一个 case。
  • 安全校验先行:清理能删能移,必须有一道防线拒绝"程序自身目录 / 驱动器根目录 / Windows 系统目录",否则一次误操作就是灾难。解决方案:IsProtectedPath 统一前置校验。
  • 占用重试:Windows 下文件被编辑器、杀毒软件、搜索索引占用的概率极高,一次失败就报错体验很差。解决方案:MoveWithRetry 最多重试 8 次(约 14 秒),每次失败先回收本程序滞留的文件句柄再等待。
  • 重名冲突:移动到目标目录时目标目录里可能已有同名文件,直接覆盖是危险的。解决方案:GetUniquePath 自动追加时间戳+序号。
  • 跨卷移动Directory.Move 不支持跨卷,而清理场景恰恰多数是"C盘 → D盘"。解决方案:同卷直接 Move,跨卷先 CopyDirectory 复制再 DeleteDirectoryWithRetry 删除。
  • 防自扰:程序自己就有文件监控,清理动作会被"自己监听到自己",污染统计。解决方案:清理期间登记原路径快照与目标目录,监控服务产生记录前先问一句 ShouldIgnoreEvent(path)

具体实现

CleanupMethod 清理方式枚举

一切的起点,五种方式用一个枚举表达:

/// <summary>
/// 清理方式
/// </summary>
public enum CleanupMethod
{
    RecycleBin,        // 回收站
    PermanentDelete,   // 永久删除
    Move,              // 移动
    Compress,          // 压缩
    Mklink             // mkLink
}

配套两个静态辅助,UI 层全靠它们渲染:

public static string GetMethodDisplayName(CleanupMethod method) => method switch
{
    CleanupMethod.RecycleBin => "回收站",
    CleanupMethod.PermanentDelete => "永久删除",
    CleanupMethod.Move => "移动",
    CleanupMethod.Compress => "压缩",
    CleanupMethod.Mklink => "mkLink",
    _ => "未知"
};

/// <summary>清理方式是否需要目标目录</summary>
public static bool RequiresTarget(CleanupMethod method) =>
    method is CleanupMethod.Move or CleanupMethod.Compress or CleanupMethod.Mklink;

RequiresTarget 把"需要目标目录的方式"统一暴露给 UI,选择方式时动态显示/隐藏目标目录输入框,避免用户选回收站还要填个不相关的目录。

安全校验 IsProtectedPath

清理的底线,任何方式执行前都必须过这一关

private static bool IsProtectedPath(string path, out string reason)
{
    var full = Path.GetFullPath(path).TrimEnd('\\');
    var appDir = Path.GetFullPath(AppDomain.CurrentDomain.BaseDirectory).TrimEnd('\\');

    // ① 程序自身目录:绝不允许清理自己
    if (full.Equals(appDir, StringComparison.OrdinalIgnoreCase) ||
        full.StartsWith(appDir + "\\", StringComparison.OrdinalIgnoreCase))
    {
        reason = "程序自身目录";
        return true;
    }

    // ② 驱动器根目录(如 C:\):长度<=3 且以冒号结尾
    if (full.Length <= 3 && full.EndsWith(":"))
    {
        reason = "驱动器根目录";
        return true;
    }

    // ③ 系统目录
    string[] systemRoots =
    {
        @"C:\Windows", @"C:\Program Files", @"C:\Program Files (x86)", @"C:\ProgramData"
    };
    foreach (var root in systemRoots)
    {
        if (full.Equals(root, StringComparison.OrdinalIgnoreCase))
        {
            reason = "系统目录";
            return true;
        }
    }

    reason = "";
    return false;
}

判断全是前缀匹配而不是子串匹配,appDir + "\\" 的写法可以精确到"目录边界",避免 C:\WindowsC:\Windows10Backup 这样的路径误伤。

ExecuteSingle 统一分发

安全校验通过后,进入方式分发。同时做两个前置处理:路径不存在视为成功(整目录删除后其子项已消失,不该再报错),异常分类兜底(单项失败不影响整批继续):

private static bool ExecuteSingle(
    CleanupFileEntry entry,
    CleanupMethod method,
    string? targetDir,
    out string? error,
    out long freed)
{
    error = null;
    freed = 0;

    var full = Path.GetFullPath(entry.FullPath);

    // 安全校验:受保护路径拒绝清理
    if (IsProtectedPath(full, out var reason))
    {
        error = $"受保护路径({reason}),已拒绝清理";
        return false;
    }

    // 文件/目录已不存在视为成功(例如整目录删除后子项已被移除)
    if (!File.Exists(full) && !Directory.Exists(full))
    {
        error = "文件已不存在";
        return true;
    }

    try
    {
        switch (method)
        {
            case CleanupMethod.RecycleBin:
                if (!FileRecycleHelper.SendToRecycleBin(full, showConfirmDialog: false))
                {
                    error = "移入回收站失败";
                    return false;
                }
                break;

            case CleanupMethod.PermanentDelete:
                if (entry.IsDirectory)
                    Directory.Delete(full, true);
                else
                    File.Delete(full);
                freed = entry.SizeBytes;
                break;

            case CleanupMethod.Move:
                MoveToTarget(full, entry, targetDir);
                break;

            case CleanupMethod.Compress:
                freed = CompressToTarget(full, entry, targetDir);
                // 压缩成功后删除原文件,达到释放空间的目的
                if (entry.IsDirectory)
                    Directory.Delete(full, true);
                else
                    File.Delete(full);
                break;

            case CleanupMethod.Mklink:
                MklinkToTarget(full, entry, targetDir);
                break;

            default:
                error = "未知的清理方式";
                return false;
        }
    }
    catch (IOException ex) { error = $"文件被占用或操作失败: {ex.Message}"; return false; }
    catch (UnauthorizedAccessException ex) { error = $"无权限访问: {ex.Message}"; return false; }
    catch (Exception ex) { error = ex.Message; return false; }

    return true;
}

freed(释放空间)只在两种方式下有意义:永久删除 = 文件大小;压缩 = 原大小 − 压缩包大小。其余方式记 0,UI 层据此决定是否展示"释放空间"提示。


方式一:回收站删除

FileRecycleHelper 回收站封装

/// <summary>
/// 文件移入回收站工具(Win32 SHFileOperation)
/// </summary>
public static class FileRecycleHelper
{
    // 操作:删除
    private const uint FO_DELETE = 3;
    // 允许撤销 → 放入回收站【关键标识】
    private const ushort FOF_ALLOWUNDO = 0x0040;
    // 不弹出确认对话框
    private const ushort FOF_NOCONFIRMATION = 0x0010;
    // 不显示进度窗口
    private const ushort FOF_SILENT = 0x0004;

    public static bool SendToRecycleBin(string path, bool showConfirmDialog = true, bool showProgress = false)
    {
        if (!File.Exists(path) && !Directory.Exists(path))
        {
            return false;
        }

        SHFILEOPSTRUCT op = new SHFILEOPSTRUCT();
        op.hwnd = IntPtr.Zero;
        op.wFunc = FO_DELETE;
        // ⚠️必须拼接双空字符 "\0\0",Windows Shell API要求
        op.pFrom = path + "\0";

        ushort flags = FOF_ALLOWUNDO;

        if (!showConfirmDialog) flags |= FOF_NOCONFIRMATION;
        if (!showProgress)      flags |= FOF_SILENT;

        op.fFlags = flags;

        int ret = SHFileOperation(ref op);
        // 返回0 代表操作正常完成
        return ret == 0;
    }
}

同样的结构还支持批量删除pFromstring.Join("\0", paths) + "\0" 把多个路径用空字符连接,一次调用全部进入回收站,省去逐条 P/Invoke 的开销。

方式二:永久删除

最简单的实现,但不可恢复,所以对安全校验的依赖最重:

case CleanupMethod.PermanentDelete:
    if (entry.IsDirectory)
        Directory.Delete(full, true);   // 递归删除整棵目录树
    else
        File.Delete(full);
    freed = entry.SizeBytes;            // 释放空间 = 条目大小
    break;

Directory.Delete(path, true) 的第二个参数是 recursive,必须为 true 才能删除非空目录。同时它不会处理只读文件,所以这个方式主要针对明确的垃圾文件,配合前面的 IsProtectedPath 一起使用。


方式三:移动到目标目录

MoveToTarget 移动入口

private static void MoveToTarget(string full, CleanupFileEntry entry, string? targetDir)
{
    if (string.IsNullOrWhiteSpace(targetDir)) throw new ArgumentException("未指定目标目录");
    Directory.CreateDirectory(targetDir);
    var dest = GetUniquePath(targetDir, entry.Name);   // 重名自动加时间戳后缀
    MoveWithRetry(full, dest, entry.IsDirectory);      // 占用自动重试
}

GetUniquePath 重名规避

目标目录可能已存在同名文件,直接覆盖是危险的,这里生成 文件名_时间戳_序号 后缀:

private static string GetUniquePath(string dir, string name)
{
    var candidate = Path.Combine(dir, name);
    if (!File.Exists(candidate) && !Directory.Exists(candidate))
        return candidate;

    var ext = Path.GetExtension(name);
    var stem = Path.GetFileNameWithoutExtension(name);
    for (int i = 1; i < 1000; i++)
    {
        candidate = Path.Combine(dir, $"{stem}_{DateTime.Now:yyyyMMddHHmmss}_{i}{ext}");
        if (!File.Exists(candidate) && !Directory.Exists(candidate))
            return candidate;
    }
    return Path.Combine(dir, $"{stem}_{Guid.NewGuid():N}{ext}");   // 理论兜底
}

MoveWithRetry 占用重试

Windows 下文件被编辑器、杀毒软件、搜索索引占用是常态,这里做了最多 8 次、累计约 14 秒的重试:

private static void MoveWithRetry(string source, string destination, bool isDirectory)
{
    IOException? lastError = null;
    const int maxAttempts = 8;

    for (int attempt = 1; attempt <= maxAttempts; attempt++)
    {
        try
        {
            if (isDirectory)
            {
                // 检查是否跨卷
                bool sameVolume = string.Equals(
                    Path.GetPathRoot(source),
                    Path.GetPathRoot(destination),
                    StringComparison.OrdinalIgnoreCase);

                if (sameVolume)
                {
                    Directory.Move(source, destination);
                }
                else
                {
                    // 跨卷:复制整个目录树,然后删除源
                    CopyDirectory(source, destination);
                    DeleteDirectoryWithRetry(source);
                }
            }
            else
            {
                File.Move(source, destination);
            }
            return;
        }
        catch (IOException ex) when (attempt < maxAttempts)
        {
            lastError = ex;
            ReleaseFileLocks();          // 回收本程序可能滞留的文件句柄
            Thread.Sleep(500 * attempt); // 退避等待
        }
        catch (IOException ex)
        {
            lastError = ex;
        }
    }

    throw new IOException(
        $"文件或目录被占用或移动失败,重试 {maxAttempts} 次(约 14 秒)后仍未成功。" +
        $"可能被编辑器/杀毒软件/搜索索引等占用,请关闭后重试。{lastError?.Message}",
        lastError);
}

两个关键点:ReleaseFileLocks() 通过 GC.Collect() + WaitForPendingFinalizers() 回收本程序自己滞留的文件句柄(比如刚扫描完还没来得及释放的 FileStream);退避等待 500ms * attempt,给占用方(如杀毒软件扫描)留出释放时间,而不是死等固定时长。

跨卷删除源目录同样带重试与强制属性清理:

/// <summary>强制删除目录(会先清除只读/系统属性)</summary>
private static void ForceDeleteDirectory(string path)
{
    foreach (string subDir in Directory.GetDirectories(path))
        ForceDeleteDirectory(subDir);

    foreach (string file in Directory.GetFiles(path))
    {
        // 移除只读、系统、隐藏等属性
        File.SetAttributes(file, FileAttributes.Normal);
        File.Delete(file);
    }

    Directory.Delete(path, false);
}

方式四:压缩到目标目录

CompressToTarget 压缩实现

private static long CompressToTarget(string full, CleanupFileEntry entry, string? targetDir)
{
    if (string.IsNullOrWhiteSpace(targetDir)) throw new ArgumentException("未指定目标目录");
    Directory.CreateDirectory(targetDir);
    var zipPath = GetUniquePath(targetDir, entry.Name + ".zip");   // 同名 zip 也走重名规避

    if (entry.IsDirectory)
    {
        // 目录:直接打包整棵目录树
        ZipFile.CreateFromDirectory(full, zipPath, CompressionLevel.Optimal, false);
    }
    else
    {
        // 单个文件:放入 zip 根目录
        using (var zip = ZipFile.Open(zipPath, ZipArchiveMode.Create))
        {
            zip.CreateEntryFromFile(full, entry.Name);
        }
    }

    var compressedSize = new FileInfo(zipPath).Length;
    return Math.Max(0, entry.SizeBytes - compressedSize);
}

压缩完成后由 ExecuteSingle 删除原文件,达到释放空间的目的。释放空间按 原大小 − 压缩包大小 估算,Math.Max(0, ...) 防止极端压缩率下出现负值,UI 上同时提示"选中文件总大小"供用户决策。


方式五:mkLink 软链接

移动方式有个痛点:移动后原路径失效,依赖绝对路径的软件就"失联"了。mkLink 方式把文件迁到目标目录,再在原位置创建链接,原路径继续可用,程序无感知。

MklinkToTarget 迁移 + 建链

private static void MklinkToTarget(string full, CleanupFileEntry entry, string? targetDir)
{
    if (string.IsNullOrWhiteSpace(targetDir)) throw new ArgumentException("未指定目标目录");
    Directory.CreateDirectory(targetDir);
    var dest = GetUniquePath(targetDir, entry.Name);

    if (entry.IsDirectory)
    {
        // 目录使用联接(junction /J),无需管理员权限且支持跨卷
        MoveWithRetry(full, dest, true);
        if (!RunCmd($"mklink /J \"{full}\" \"{dest}\""))
            throw RollbackAfterLinkFailure(dest, full, true, "创建目录联接失败");
    }
    else
    {
        // 文件硬链接要求源与目标同一磁盘卷
        if (!string.Equals(Path.GetPathRoot(full), Path.GetPathRoot(targetDir), StringComparison.OrdinalIgnoreCase))
            throw new IOException("硬链接要求源与目标位于同一磁盘卷,请改用其他清理方式");

        MoveWithRetry(full, dest, false);
        // 使用 Win32 原生硬链接 API(替代 cmd.exe mklink /H),无需管理员权限
        if (!CreateHardLinkW(full, dest, IntPtr.Zero))
        {
            var win32Error = Marshal.GetLastWin32Error();
            throw RollbackAfterLinkFailure(dest, full, false,
                $"创建文件硬链接失败(Win32 错误码 {win32Error})");
        }
    }
}

两个选择逻辑值得单独说:目录必须用 mklink /J(junction),因为硬链接不适用于目录;文件CreateHardLinkW 替代 cmd.exe 的 mklink /H,规避了管理员权限问题,Marshal.GetLastWin32Error() 还能拿到精确的错误码用于提示。

链接失败自动回滚

"先移动、后建链"的流程存在中间状态风险 —— 链接失败时数据已经不在原位置了。所以建链失败必须回滚

private static IOException RollbackAfterLinkFailure(
    string movedPath,
    string originalPath,
    bool isDirectory,
    string message)
{
    try
    {
        if (!File.Exists(originalPath) && !Directory.Exists(originalPath))
        {
            MoveWithRetry(movedPath, originalPath, isDirectory);
            return new IOException($"{message}: {originalPath};已恢复原位置");
        }
    }
    catch
    {
        // 返回的错误会明确告知数据保留位置。
    }

    return new IOException($"{message}: {originalPath};数据保留在目标位置: {movedPath}");
}

回滚也不成功时,错误信息会明确指出数据保留在目标位置,绝不静默丢数据 —— 用户永远知道自己的文件在哪。


监控联动:防止"自己监听到自己"

程序本身有 FSW/ETW 实时监控,清理产生的文件操作如果不处理,会把统计和提醒搅成一锅粥。方案是清理期间登记快照,监控侧先问再记:

/// <summary>清理开始前调用:登记被清理的原路径与目标目录</summary>
public void BeginCleanup(IEnumerable<CleanupFileEntry> entries, string? targetDir)
{
    _activePathSnapshot = entries
        .Select(e => Path.GetFullPath(e.FullPath))
        .Distinct(StringComparer.OrdinalIgnoreCase)
        .ToArray();
    _activeTargetDir = string.IsNullOrWhiteSpace(targetDir)
        ? null
        : Path.GetFullPath(targetDir);
}

public void EndCleanup()
{
    _activePathSnapshot = Array.Empty<string>();
    _activeTargetDir = null;
}

/// <summary>
/// 监控服务在产生记录前调用:路径位于本次清理的原路径(或其子项)内、
/// 或位于目标目录内,都应忽略,避免"自己监听到自己"污染统计与提醒。
/// </summary>
public bool ShouldIgnoreEvent(string path)
{
    if (string.IsNullOrEmpty(path)) return false;

    var target = _activeTargetDir;
    if (target != null && IsPathInside(path, target)) return true;

    foreach (var p in _activePathSnapshot)
    {
        if (IsPathInside(path, p)) return true;
    }
    return false;
}

private static bool IsPathInside(string path, string parent)
{
    var full = path.TrimEnd('\\');
    var root = parent.TrimEnd('\\');
    if (full.Equals(root, StringComparison.OrdinalIgnoreCase)) return true;
    return full.StartsWith(root + "\\", StringComparison.OrdinalIgnoreCase);
}

volatile 字段保证多线程可见性(清理在后台线程执行,监控在另一个线程查询);IsPathInsideTrimEnd('\\') + "\\"目录边界匹配,避免 C:\Data 误伤 C:\Database 这种"路径前缀陷阱"。

清理留痕

每一次清理(无论成败)都写入 CleanupRecords 表,可追溯、可定位:

private void SaveRecord(CleanupFileEntry entry, CleanupMethod method, bool success, string? message)
{
    try
    {
        _databaseService.SaveCleanupRecord(new CleanupRecord
        {
            CleanupTime = DateTime.Now,
            FullPath = entry.FullPath,
            FileName = entry.Name,
            SizeBytes = entry.IsDirectory ? (long?)null : entry.SizeBytes,
            Method = GetMethodDisplayName(method),
            Success = success,
            Message = message
        });
    }
    catch (Exception ex)
    {
        Debug.WriteLine($"保存清理记录失败: {ex.Message}");
    }
}

UI 上清理历史支持右键复制路径、回收站记录可直接打开回收站定位 —— 这正是"每次清理都有迹可循"的价值。

总结 ——From DeepSeek

本文以 C盘管理工具的文件清理模块为背景,提供了一套可插拔、安全、防自扰的五种清理方式实现方案。核心思路是:

  1. 统一分发CleanupMethod 枚举 + ExecuteSingle 分发,五种方式共用一套安全校验、重试与留痕骨架,新增方式只需加一个枚举值和一个 case;
  2. 安全为先IsProtectedPath 对程序自身目录、驱动器根目录、系统目录一律拒绝,路径存在性检查兜底"整目录删除后子项"的场景,异常分类捕获保证单项失败不影响整批;
  3. 占用与冲突处理MoveWithRetry 8 次退避重试 + 句柄回收,GetUniquePath 时间戳后缀规避重名,跨卷移动先复制后删除;
  4. 按场景选方式:回收站(可反悔)/ 永久删除(精确释放)/ 移动(完整释放)/ 压缩(归档省空间)/ mkLink(保原路径可用,链接失败自动回滚);
  5. 防自扰与留痕BeginCleanup 快照 + ShouldIgnoreEvent 让监控忽略本次清理的自身事件,SaveRecord 把每次清理落库可追溯。

该方案兼顾功能完整性与数据安全,适合作为同类工具的参考实现;