Files
黄兆尉andCursor 79c9e36d89 在 MiGu 侧落地日志定时清理与磁盘空间告警。
系统配置可开关清理策略;日志管理页支持查看/保存清理选项。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 17:48:25 +08:00

289 lines
11 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Concurrent;
using System.Text.Json;
using MiGu.Server.Infra;
namespace MiGu.Server.Configs;
/// <summary>
/// 配置中心存储(内存 + JSON 文件持久化占位)。
/// 前 14 个 section 对应 ARCHITECTURE.md §9 的 13+1 维度;外加 deployment —— 登录后「配置向导」的部署画像。
/// 真实落地时由 SimpleShared.Persistence 接入 EF Core,并配合 YARP 下发至 Simple3。
/// </summary>
public sealed class ConfigStore
{
public static readonly string[] AllSections =
{
"system", "integrations", "routing", "vehicle", "charge", "task",
"traffic", "auth", "device", "fleet", "scenario", "location", "ops", "widget",
"deployment"
};
public sealed record Envelope(string Section, int Version, DateTimeOffset UpdatedAt, object Payload);
private readonly ConcurrentDictionary<string, Envelope> _mem = new(StringComparer.OrdinalIgnoreCase);
private readonly string _dataDir;
private readonly ILogger<ConfigStore> _logger;
private readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
public ConfigStore(IWebHostEnvironment env, ILogger<ConfigStore> logger)
{
_logger = logger;
_dataDir = Path.Combine(env.ContentRootPath, "data");
Directory.CreateDirectory(_dataDir);
SeedAndLoad();
}
public Envelope Get(string section)
{
section = section.ToLowerInvariant();
if (_mem.TryGetValue(section, out var env))
{
if (section == "ops") return MergeOpsEnvelope(env);
return env;
}
var def = NewDefault(section);
_mem[section] = def;
Persist(def);
return def;
}
public Envelope Put(string section, JsonElement payload)
{
section = section.ToLowerInvariant();
if (!AllSections.Contains(section))
throw new ArgumentException($"未知 section: {section}");
var prev = _mem.TryGetValue(section, out var p) ? p : null;
var version = (prev?.Version ?? 0) + 1;
// payload 保留 JsonElement 原样;序列化器会按 camelCase 输出
var env = new Envelope(section, version, DateTimeOffset.UtcNow, JsonElementToObject(payload));
if (section == "ops") env = MergeOpsEnvelope(env);
_mem[section] = env;
Persist(env);
return env;
}
public IEnumerable<Envelope> List() => AllSections.Select(Get);
/// <summary>
/// 强类型读取部署画像(<c>deployment</c> section)。兼容 Payload 为 <see cref="DeploymentProfile"/>(默认值场景)
/// 或 <see cref="JsonElement"/>(已持久化场景)两种形态,并把 null 列表/空字符串规整为安全默认值,
/// 供 AuthControllerNeedsWizard/ WizardController / Launcher 直接使用。
/// </summary>
public DeploymentProfile GetDeployment()
{
var env = Get("deployment");
var dp = env.Payload switch
{
DeploymentProfile d => d,
JsonElement el => SafeDeserializeDeployment(el),
_ => DeploymentProfile.Default()
};
return new DeploymentProfile(
dp.Configured,
string.IsNullOrWhiteSpace(dp.PlatformType) ? "standard" : dp.PlatformType,
dp.Modules ?? new List<string>(),
dp.NavigationKinds ?? new List<string>(),
dp.Scenarios ?? new List<string>(),
dp.UpdatedBy ?? "");
}
/// <summary>以强类型保存部署画像(统一经 <see cref="Put"/> 走版本/持久化/camelCase 序列化)。</summary>
public Envelope PutDeployment(DeploymentProfile profile)
{
var el = JsonSerializer.SerializeToElement(profile, _jsonOpts);
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(); }
catch (Exception ex)
{
_logger.LogWarning(ex, "反序列化 deployment 失败,回退默认值");
return DeploymentProfile.Default();
}
}
private static object JsonElementToObject(JsonElement el)
{
// 简化:直接保留 JsonElement,让 STJ 在响应时再原样写回
return el.Clone();
}
private void SeedAndLoad()
{
foreach (var s in AllSections)
{
var file = FilePath(s);
if (File.Exists(file))
{
try
{
var json = File.ReadAllText(file);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var version = root.TryGetProperty("version", out var v) && v.ValueKind == JsonValueKind.Number
? v.GetInt32() : 1;
var updated = root.TryGetProperty("updatedAt", out var u) && u.ValueKind == JsonValueKind.String
? DateTimeOffset.Parse(u.GetString()!) : DateTimeOffset.UtcNow;
var payload = root.TryGetProperty("payload", out var pl)
? (object)pl.Clone()
: DefaultPayload(s);
_mem[s] = new Envelope(s, version, updated, payload);
continue;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "加载 {Section} 失败,回退到默认值", s);
// S2:疑似损坏的配置先备份,避免随后写入的默认值把用户配置永久冲掉。
var bak = AtomicFile.BackupCorrupt(file);
if (bak != null) _logger.LogWarning("已备份疑似损坏的 {Section} 配置到 {Backup}", s, bak);
}
}
_mem[s] = NewDefault(s);
Persist(_mem[s]);
}
}
private Envelope NewDefault(string section)
{
return new Envelope(section, 1, DateTimeOffset.UtcNow, DefaultPayload(section));
}
private static object DefaultPayload(string section) => section switch
{
"system" => SystemConfig.Default(),
"integrations" => ExternalIntegrations.Default(),
"routing" => RoutingPolicy.Default(),
"vehicle" => VehicleMaintenancePolicy.Default(),
"charge" => ChargePolicy.Default(),
"task" => TaskAllocationPolicy.Default(),
"traffic" => TrafficRule.Default(),
"auth" => AuthRoleConfig.Default(),
"device" => DeviceManagementConfig.Default(),
"fleet" => FleetLifecycleConfig.Default(),
"scenario" => ScenarioTemplateConfig.Default(),
"location" => LocationManagement.Default(),
"ops" => OpsConfig.Default(),
"widget" => CustomWidgetConfig.Default(),
"deployment" => DeploymentProfile.Default(),
_ => new { }
};
private void Persist(Envelope env)
{
try
{
var toWrite = env.Section == "ops" ? MergeOpsEnvelope(env) : env;
var root = new Dictionary<string, object?>
{
["section"] = toWrite.Section,
["version"] = toWrite.Version,
["updatedAt"] = toWrite.UpdatedAt,
["payload"] = toWrite.Payload
};
var json = JsonSerializer.Serialize(root, _jsonOpts);
AtomicFile.WriteAllText(FilePath(env.Section), json);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "持久化 {Section} 失败", env.Section);
}
}
private static Envelope MergeOpsEnvelope(Envelope env)
{
if (env.Payload is not JsonElement el || el.ValueKind != JsonValueKind.Object)
return env;
var def = OpsConfig.Default();
var merged = JsonSerializer.SerializeToElement(new
{
playback = ReadSection(el, "playback") ?? def.Playback,
logRetention = ReadSection(el, "logRetention") ?? def.LogRetention,
version = ReadSection(el, "version") ?? def.Version,
monitor = MergeOpsMonitor(el, def.Monitor)
}, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
return env with { Payload = merged.Clone() };
}
private static object? ReadSection(JsonElement root, string name) =>
root.TryGetProperty(name, out var p) ? p.Clone() : null;
private static JsonElement MergeOpsMonitor(JsonElement root, MonitorOpsPolicy def)
{
var opts = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
if (!root.TryGetProperty("monitor", out var m) || m.ValueKind != JsonValueKind.Object)
return JsonSerializer.SerializeToElement(def, opts);
var car = m.TryGetProperty("car", out var c) ? c.Clone() : JsonSerializer.SerializeToElement(def.Car, opts);
var site = m.TryGetProperty("site", out var s) ? s.Clone() : JsonSerializer.SerializeToElement(def.Site, opts);
var track = m.TryGetProperty("track", out var t) ? t.Clone() : JsonSerializer.SerializeToElement(def.Track, opts);
var carActionByType = m.TryGetProperty("carActionByType", out var cat)
? cat.Clone()
: JsonSerializer.SerializeToElement(def.CarActionByType, opts);
return JsonSerializer.SerializeToElement(new { car, site, track, carActionByType }, opts);
}
private string FilePath(string section) => Path.Combine(_dataDir, $"config-{section}.json");
}