using MiGu.Server.Configs; using MiGu.Server.Launcher; namespace MiGu.Server.Logs; /// /// 对齐 Simple3 LogCleaner:定时删除 log/ 下过期 *.log,并检测日志盘剩余空间(边沿告警,写日志不弹窗)。 /// public sealed class LogCleanupService : IHostedService, IDisposable { private readonly ConfigStore _store; private readonly Simple3Launcher _launcher; private readonly IConfiguration _config; private readonly ILogger _log; private readonly object _cleanLock = new(); private readonly object _scheduleLock = new(); private Timer? _cleanTimer; private Timer? _diskTimer; private bool _diskBelow; public LogCleanupService( ConfigStore store, Simple3Launcher launcher, IConfiguration config, ILogger log) { _store = store; _launcher = launcher; _config = config; _log = log; } public Task StartAsync(CancellationToken cancellationToken) { try { var opt = _store.GetLogCleanup(); if (opt.Enabled && opt.RunOnStartup) RunCleanSafely(opt, "启动清理"); ApplySchedule(); CheckDisk(opt); } catch (Exception ex) { _log.LogWarning(ex, "LogCleanupService 启动失败"); } return Task.CompletedTask; } public Task StopAsync(CancellationToken cancellationToken) { lock (_scheduleLock) { _cleanTimer?.Dispose(); _cleanTimer = null; _diskTimer?.Dispose(); _diskTimer = null; } return Task.CompletedTask; } public void Dispose() { _cleanTimer?.Dispose(); _diskTimer?.Dispose(); } public LogCleanupOptions GetOptions() => _store.GetLogCleanup(); public LogCleanupOptions SaveOptions(LogCleanupOptions options) { var saved = _store.PutLogCleanup(options); _ = saved; var opt = _store.GetLogCleanup(); ApplySchedule(); CheckDisk(opt, forceReeval: true); return opt; } public void ApplySchedule() { var opt = _store.GetLogCleanup(); lock (_scheduleLock) { _cleanTimer?.Dispose(); _cleanTimer = null; if (opt.Enabled) { var hours = opt.CheckIntervalHours <= 0 ? 1 : opt.CheckIntervalHours; var period = TimeSpan.FromHours(hours); _cleanTimer = new Timer(_ => RunCleanSafely(_store.GetLogCleanup(), "定时清理"), null, period, period); } _diskTimer?.Dispose(); var mins = opt.DiskCheckIntervalMinutes <= 0 ? 30 : opt.DiskCheckIntervalMinutes; var diskPeriod = TimeSpan.FromMinutes(mins); _diskTimer = new Timer(_ => CheckDisk(_store.GetLogCleanup()), null, diskPeriod, diskPeriod); } } public CleanResult CleanNow() { var opt = _store.GetLogCleanup(); var r = CleanOnce(opt); if (r.Error != null) _log.LogWarning("立即清理失败:{Error}", r.Error); else if (r.DeletedFiles > 0) _log.LogInformation("立即清理删除 {Count} 个日志文件,释放 {Mb:F1} MB", r.DeletedFiles, r.FreedMB); else _log.LogInformation("立即清理完成:没有过期日志"); return r; } public DiskStatus GetDiskStatus() { var opt = _store.GetLogCleanup(); var known = TryGetFreeGB(out var freeGB, out var drive); var below = known && opt.DiskAlertEnabled && freeGB < opt.DiskFreeAlertGB; return new DiskStatus(known, drive, freeGB, opt.DiskAlertEnabled, opt.DiskFreeAlertGB, below); } private void RunCleanSafely(LogCleanupOptions opt, string reason) { if (!opt.Enabled && reason != "立即清理") return; var r = CleanOnce(opt); if (r.Error != null) _log.LogWarning("[{Reason}] 失败:{Error}", reason, r.Error); else if (r.DeletedFiles > 0) _log.LogInformation("[{Reason}] 删除 {Count} 个日志文件,释放 {Mb:F1} MB", reason, r.DeletedFiles, r.FreedMB); } private CleanResult CleanOnce(LogCleanupOptions opt) { opt.Clamp(); lock (_cleanLock) { try { var (root, _, error) = ResolveLogRoot(); if (root == null) return new CleanResult(0, 0, error ?? "无法定位日志目录"); if (!Directory.Exists(root)) return new CleanResult(0, 0, null); var cutoff = DateTime.Now.AddDays(-opt.RetentionDays); var deleted = 0; long freed = 0; foreach (var f in Directory.EnumerateFiles(root, "*.log", SearchOption.AllDirectories)) { try { var fi = new FileInfo(f); if (fi.LastWriteTime >= cutoff) continue; var len = fi.Length; fi.Delete(); deleted++; freed += len; } catch { /* 占用/权限:跳过 */ } } TryRemoveEmptyDirs(root); return new CleanResult(deleted, freed, null); } catch (Exception ex) { return new CleanResult(0, 0, ex.Message); } } } private static void TryRemoveEmptyDirs(string root) { try { foreach (var dir in Directory.EnumerateDirectories(root, "*", SearchOption.AllDirectories) .OrderByDescending(d => d.Length)) { try { if (!Directory.EnumerateFileSystemEntries(dir).Any()) Directory.Delete(dir, false); } catch { /* 忽略单目录失败 */ } } } catch { /* 忽略枚举失败 */ } } private void CheckDisk(LogCleanupOptions opt, bool forceReeval = false) { if (forceReeval) _diskBelow = false; if (!opt.DiskAlertEnabled) { _diskBelow = false; return; } if (!TryGetFreeGB(out var freeGB, out var drive)) return; if (freeGB < opt.DiskFreeAlertGB) { if (_diskBelow) return; _diskBelow = true; _log.LogWarning( "磁盘剩余空间不足:{Drive} 仅剩 {Free:F1} GB(低于阈值 {Threshold:F0} GB)。请及时清理磁盘,或在系统配置中调低日志保留天数 / 立即清理。", drive, freeGB, opt.DiskFreeAlertGB); } else { _diskBelow = false; } } private bool TryGetFreeGB(out double freeGB, out string driveName) { freeGB = 0; driveName = ""; var (root, _, _) = ResolveLogRoot(); if (root == null) return false; try { var driveRoot = Path.GetPathRoot(root); if (string.IsNullOrEmpty(driveRoot)) return false; var di = new DriveInfo(driveRoot); driveName = di.Name; freeGB = di.AvailableFreeSpace / 1024.0 / 1024.0 / 1024.0; return true; } catch { return false; } } private (string? root, string? workdir, string? error) ResolveLogRoot() { var overrideRoot = _config["Logs:Root"]; if (!string.IsNullOrWhiteSpace(overrideRoot)) { var r = Path.GetFullPath(overrideRoot); return (r, Path.GetDirectoryName(r), null); } var wd = _launcher.ResolveWorkingDirectory(); if (string.IsNullOrWhiteSpace(wd)) return (null, null, "未能定位 Simple3 工作目录"); return (Path.GetFullPath(Path.Combine(wd, "log")), wd, null); } public readonly record struct CleanResult(int DeletedFiles, long FreedBytes, string? Error) { public double FreedMB => FreedBytes / 1024.0 / 1024.0; } public readonly record struct DiskStatus( bool Known, string Drive, double FreeGB, bool AlertEnabled, double AlertGB, bool BelowThreshold); }