在 MiGu 侧落地日志定时清理与磁盘空间告警。
系统配置可开关清理策略;日志管理页支持查看/保存清理选项。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
using MiGu.Server.Configs;
|
||||
using MiGu.Server.Launcher;
|
||||
|
||||
namespace MiGu.Server.Logs;
|
||||
|
||||
/// <summary>
|
||||
/// 对齐 Simple3 LogCleaner:定时删除 log/ 下过期 *.log,并检测日志盘剩余空间(边沿告警,写日志不弹窗)。
|
||||
/// </summary>
|
||||
public sealed class LogCleanupService : IHostedService, IDisposable
|
||||
{
|
||||
private readonly ConfigStore _store;
|
||||
private readonly Simple3Launcher _launcher;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<LogCleanupService> _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<LogCleanupService> 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);
|
||||
}
|
||||
Reference in New Issue
Block a user