系统配置可开关清理策略;日志管理页支持查看/保存清理选项。 Co-authored-by: Cursor <cursoragent@cursor.com>
271 lines
8.4 KiB
C#
271 lines
8.4 KiB
C#
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);
|
|
}
|