在 MiGu 侧落地日志定时清理与磁盘空间告警。

系统配置可开关清理策略;日志管理页支持查看/保存清理选项。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
黄兆尉
2026-08-26 17:48:25 +08:00
co-authored by Cursor
parent 4180140ae4
commit 79c9e36d89
12 changed files with 856 additions and 150 deletions
+53 -1
View File
@@ -7,7 +7,7 @@ namespace MiGu.Server.Configs;
/// <summary>
/// 配置中心存储(内存 + JSON 文件持久化占位)。
/// 前 14 个 section 对应 ARCHITECTURE.md §9 的 13+1 维度;外加 deployment —— 登录后「配置向导」的部署画像。
/// 真实落地时由 SimpleShared.Persistence 接入 EF Core,并配合 YARP 下发至 SimpleLite
/// 真实落地时由 SimpleShared.Persistence 接入 EF Core,并配合 YARP 下发至 Simple3
/// </summary>
public sealed class ConfigStore
{
@@ -99,6 +99,58 @@ public sealed class ConfigStore
return Put("deployment", el);
}
public LogCleanupOptions GetLogCleanup()
{
var env = Get("system");
return env.Payload switch
{
SystemConfig sc => (sc.LogCleanup ?? new LogCleanupOptions()).Clamp(),
JsonElement el => ParseLogCleanup(el),
_ => LogCleanupOptions.CreateDefaults()
};
}
public Envelope PutLogCleanup(LogCleanupOptions options)
{
var opt = (options ?? new LogCleanupOptions()).Clamp();
Dictionary<string, JsonElement> dict;
try
{
var current = Get("system").Payload;
var el = current is JsonElement je
? je
: JsonSerializer.SerializeToElement(current, _jsonOpts);
dict = el.ValueKind == JsonValueKind.Object
? JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(el.GetRawText()) ?? new()
: new Dictionary<string, JsonElement>();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "读取 system 配置失败,按空对象合并 logCleanup");
dict = new Dictionary<string, JsonElement>();
}
dict["logCleanup"] = JsonSerializer.SerializeToElement(opt, _jsonOpts);
var merged = JsonSerializer.SerializeToElement(dict, _jsonOpts);
return Put("system", merged);
}
private LogCleanupOptions ParseLogCleanup(JsonElement el)
{
try
{
if (el.ValueKind == JsonValueKind.Object && el.TryGetProperty("logCleanup", out var nested))
return (nested.Deserialize<LogCleanupOptions>(_jsonOpts) ?? new LogCleanupOptions()).Clamp();
var sc = el.Deserialize<SystemConfig>(_jsonOpts);
return (sc?.LogCleanup ?? new LogCleanupOptions()).Clamp();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "反序列化 logCleanup 失败,回退默认值");
return LogCleanupOptions.CreateDefaults();
}
}
private DeploymentProfile SafeDeserializeDeployment(JsonElement el)
{
try { return el.Deserialize<DeploymentProfile>(_jsonOpts) ?? DeploymentProfile.Default(); }
+39
View File
@@ -0,0 +1,39 @@
namespace MiGu.Server.Configs;
/// <summary>
/// 日志清理与磁盘告警(system.logCleanup)。字段对齐 Simple3 <c>LogCleanupOptions</c>。
/// </summary>
public sealed class LogCleanupOptions
{
/// <summary>是否启用后台自动清理。关闭后仅「立即清理」可手动触发。默认 true。</summary>
public bool Enabled { get; set; } = true;
/// <summary>保留天数:删除 log/ 下 LastWriteTime 早于「现在 N 天」的 *.log。默认 30,最小 1。</summary>
public int RetentionDays { get; set; } = 30;
/// <summary>后台清理间隔(小时)。默认 24;&lt;= 0 归一为 1。</summary>
public int CheckIntervalHours { get; set; } = 24;
/// <summary>启动时先执行一次清理。默认 true。</summary>
public bool RunOnStartup { get; set; } = true;
/// <summary>是否启用磁盘剩余空间不足告警。默认 true。</summary>
public bool DiskAlertEnabled { get; set; } = true;
/// <summary>日志所在盘剩余低于该值(GB)时告警。默认 5。</summary>
public double DiskFreeAlertGB { get; set; } = 5;
/// <summary>磁盘检测间隔(分钟)。默认 30;&lt;= 0 归一为 30。</summary>
public int DiskCheckIntervalMinutes { get; set; } = 30;
public static LogCleanupOptions CreateDefaults() => new();
public LogCleanupOptions Clamp()
{
RetentionDays = Math.Clamp(RetentionDays, 1, 3650);
CheckIntervalHours = Math.Max(1, CheckIntervalHours);
if (DiskFreeAlertGB <= 0) DiskFreeAlertGB = 5;
DiskCheckIntervalMinutes = DiskCheckIntervalMinutes <= 0 ? 30 : DiskCheckIntervalMinutes;
return this;
}
}
+4 -8
View File
@@ -1,12 +1,8 @@
namespace MiGu.Server.Configs;
public record LogPolicy(string Level, int RollDays, int MaxSizeMB);
public record SecurityPolicy(int JwtExpireMin, bool EnableSwagger, List<string> CorsWhitelist);
public record SystemConfig(int DispatchLoopHz, LogPolicy Log, SecurityPolicy Security)
public record SystemConfig
{
public static SystemConfig Default() => new(
DispatchLoopHz: 50,
Log: new LogPolicy("info", 7, 256),
Security: new SecurityPolicy(1440, false, new List<string> { "http://localhost:5173" }));
public LogCleanupOptions LogCleanup { get; init; } = new();
public static SystemConfig Default() => new();
}
+6 -1
View File
@@ -2,6 +2,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MiGu.Server.Configs;
using MiGu.Server.Logs;
namespace MiGu.Server.Controllers;
@@ -13,10 +14,12 @@ namespace MiGu.Server.Controllers;
public class ConfigController : ControllerBase
{
private readonly ConfigStore _store;
private readonly LogCleanupService _cleanup;
public ConfigController(ConfigStore store)
public ConfigController(ConfigStore store, LogCleanupService cleanup)
{
_store = store;
_cleanup = cleanup;
}
[HttpGet]
@@ -66,6 +69,8 @@ public class ConfigController : ControllerBase
}
var env = _store.Put(section, payload);
if (string.Equals(section, "system", StringComparison.OrdinalIgnoreCase))
_cleanup.ApplySchedule();
return Ok(new
{
section = env.Section,
+60 -12
View File
@@ -2,18 +2,20 @@ using System.Globalization;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MiGu.Server.Configs;
using MiGu.Server.Launcher;
using MiGu.Server.Logs;
namespace MiGu.Server.Controllers;
/// <summary>
/// 平台「日志管理」后端:把 SimpleLite 内核 <c>SimpleCore.Library.Diagnosis</c> 的落盘日志
/// 平台「日志管理」后端:把 Simple3 内核 <c>SimpleCore.Library.Diagnosis</c> 的落盘日志
/// <c>Diagnosis.Post</c> / <c>Diagnosis.Log</c> 写入的 <c>log/**/*.log</c>,俗称 DLog)暴露给
/// platform-vue 的配置中心「日志管理」页查看。对齐 SimpleLite 桌面端 <c>SimpleLite/UI/LogViewer.cs</c>
/// platform-vue 的配置中心「日志管理」页查看。对齐 Simple3 桌面端 <c>Simple3/UI/LogViewer.cs</c>
/// 的两区设计(诊断条目表 + 落盘文件表),并在 Web 端额外提供「按标签合订」视图。
///
/// 数据来源:日志是 SimpleLite 进程在其工作目录写出的历史文件,<b>不依赖 SimpleLite 是否在运行</b>
/// MiGu.Server 通过 <see cref="SimpleLiteLauncher.ResolveWorkingDirectory"/> 定位工作目录后直接读
/// 数据来源:日志是 Simple3 进程在其工作目录写出的历史文件,<b>不依赖 Simple3 是否在运行</b>
/// MiGu.Server 通过 <see cref="Simple3Launcher.ResolveWorkingDirectory"/> 定位工作目录后直接读
/// <c>{工作目录}/log/</c>。可用 appsettings <c>Logs:Root</c> 显式覆盖日志根目录。
///
/// 落盘行格式(见 Diagnosis.Log):<c>[{prefix}yyyy/MM/dd-HH:mm:ss.fff] >{tag}: {content}</c>
@@ -59,15 +61,21 @@ public sealed class LogsController : ControllerBase
private static readonly Regex NumericFieldRegex =
new(@"(?<k>[A-Za-z_\u4e00-\u9fff][\w\u4e00-\u9fff\.]*)\s*[=:]\s*(?<v>-?\d+(?:\.\d+)?)", RegexOptions.Compiled);
private readonly SimpleLiteLauncher _launcher;
private readonly Simple3Launcher _launcher;
private readonly IConfiguration _config;
private readonly ILogger<LogsController> _log;
private readonly LogCleanupService _cleanup;
public LogsController(SimpleLiteLauncher launcher, IConfiguration config, ILogger<LogsController> log)
public LogsController(
Simple3Launcher launcher,
IConfiguration config,
ILogger<LogsController> log,
LogCleanupService cleanup)
{
_launcher = launcher;
_config = config;
_log = log;
_cleanup = cleanup;
}
// ─────────────────────────────────────────────────────────── 概览 ──
@@ -78,14 +86,15 @@ public sealed class LogsController : ControllerBase
{
var (root, workdir, error) = ResolveLogRoot();
if (root == null)
return Ok(new { exists = false, root = (string?)null, workingDirectory = workdir, message = error });
return Ok(new { exists = false, root = (string?)null, workingDirectory = workdir, message = error, disk = DiskDto() });
if (!Directory.Exists(root))
return Ok(new
{
exists = false, root, workingDirectory = workdir,
totalFiles = 0, totalBytes = 0L, days = Array.Empty<object>(),
message = "日志目录尚未生成(SimpleLite 产生落盘日志后会自动创建 log 目录)。"
message = "日志目录尚未生成(Simple3 产生落盘日志后会自动创建 log 目录)。",
disk = DiskDto()
});
var files = EnumerateLogFiles(root);
@@ -101,7 +110,32 @@ public sealed class LogsController : ControllerBase
totalFiles = files.Count,
totalBytes = files.Sum(f => f.Bytes),
latestFileTime = files.Count > 0 ? files.Max(f => f.Mtime) : (DateTime?)null,
days
days,
disk = DiskDto()
});
}
/// <summary>日志清理配置(对齐 Simple3 logCleanup)。</summary>
[HttpGet("cleanup-config")]
public IActionResult GetCleanupConfig() => Ok(_cleanup.GetOptions());
/// <summary>保存日志清理配置并立即重排后台定时器。</summary>
[HttpPut("cleanup-config")]
public IActionResult PutCleanupConfig([FromBody] LogCleanupOptions body)
=> Ok(_cleanup.SaveOptions(body ?? new LogCleanupOptions()));
/// <summary>按当前保留天数立即清理一次过期 *.log。</summary>
[HttpPost("cleanup-now")]
public IActionResult CleanupNow()
{
var r = _cleanup.CleanNow();
if (r.Error != null)
return Problem(r.Error, statusCode: 500);
return Ok(new
{
deletedFiles = r.DeletedFiles,
freedBytes = r.FreedBytes,
freedMB = Math.Round(r.FreedMB, 1)
});
}
@@ -151,7 +185,7 @@ public sealed class LogsController : ControllerBase
root = normRoot, exists = false, path = "", parent = (string?)null,
dirCount = 0, fileCount = 0,
dirs = Array.Empty<object>(), files = Array.Empty<object>(),
message = "日志目录尚未生成(SimpleLite 产生落盘日志后会自动创建 log 目录)。"
message = "日志目录尚未生成(Simple3 产生落盘日志后会自动创建 log 目录)。"
});
var target = SafeResolveDir(normRoot, path);
@@ -592,9 +626,23 @@ public sealed class LogsController : ControllerBase
return PhysicalFile(full, "application/octet-stream", downloadName);
}
private object DiskDto()
{
var d = _cleanup.GetDiskStatus();
return new
{
known = d.Known,
drive = d.Drive,
freeGB = Math.Round(d.FreeGB, 1),
alertEnabled = d.AlertEnabled,
alertGB = d.AlertGB,
belowThreshold = d.BelowThreshold
};
}
// ─────────────────────────────────────────────────────── helpers ──
/// <summary>定位日志根:优先 appsettings <c>Logs:Root</c>,否则取 SimpleLite 工作目录下的 <c>log</c>。</summary>
/// <summary>定位日志根:优先 appsettings <c>Logs:Root</c>,否则取 Simple3 工作目录下的 <c>log</c>。</summary>
private (string? root, string? workdir, string? error) ResolveLogRoot()
{
var overrideRoot = _config["Logs:Root"];
@@ -607,7 +655,7 @@ public sealed class LogsController : ControllerBase
var wd = _launcher.ResolveWorkingDirectory();
if (string.IsNullOrWhiteSpace(wd))
return (null, null,
"未能定位 SimpleLite 工作目录,无法读取日志。请在 appsettings.json 配置 SimpleLite:WorkingDirectory" +
"未能定位 Simple3 工作目录,无法读取日志。请在 appsettings.json 配置 Simple3:WorkingDirectory" +
"或显式设置 Logs:Root 指向日志根目录。");
return (Path.GetFullPath(Path.Combine(wd, "log")), wd, null);
+270
View File
@@ -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);
}
+3 -3
View File
@@ -1,7 +1,7 @@
{
"section": "system",
"version": 1,
"updatedAt": "2026-06-08T09:43:37.4470202+00:00",
"version": 2,
"updatedAt": "2026-08-21T08:06:13.7109039+00:00",
"payload": {
"dispatchLoopHz": 50,
"log": {
@@ -11,7 +11,7 @@
},
"security": {
"jwtExpireMin": 1440,
"enableSwagger": false,
"enableSwagger": true,
"corsWhitelist": [
"http://localhost:5173"
]