系统配置可开关清理策略;日志管理页支持查看/保存清理选项。 Co-authored-by: Cursor <cursoragent@cursor.com>
289 lines
11 KiB
C#
289 lines
11 KiB
C#
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 列表/空字符串规整为安全默认值,
|
||
/// 供 AuthController(NeedsWizard)/ 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");
|
||
}
|