using System.Collections.Concurrent; using System.Text.Json; using MiGu.Server.Infra; namespace MiGu.Server.Configs; /// /// 配置中心存储(内存 + JSON 文件持久化占位)。 /// 前 14 个 section 对应 ARCHITECTURE.md §9 的 13+1 维度;外加 deployment —— 登录后「配置向导」的部署画像。 /// 真实落地时由 SimpleShared.Persistence 接入 EF Core,并配合 YARP 下发至 SimpleLite。 /// 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 _mem = new(StringComparer.OrdinalIgnoreCase); private readonly string _dataDir; private readonly ILogger _logger; private readonly JsonSerializerOptions _jsonOpts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true }; public ConfigStore(IWebHostEnvironment env, ILogger 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 List() => AllSections.Select(Get); /// /// 强类型读取部署画像(deployment section)。兼容 Payload 为 (默认值场景) /// 或 (已持久化场景)两种形态,并把 null 列表/空字符串规整为安全默认值, /// 供 AuthController(NeedsWizard)/ WizardController / Launcher 直接使用。 /// 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(), dp.NavigationKinds ?? new List(), dp.Scenarios ?? new List(), dp.UpdatedBy ?? ""); } /// 以强类型保存部署画像(统一经 走版本/持久化/camelCase 序列化)。 public Envelope PutDeployment(DeploymentProfile profile) { var el = JsonSerializer.SerializeToElement(profile, _jsonOpts); return Put("deployment", el); } private DeploymentProfile SafeDeserializeDeployment(JsonElement el) { try { return el.Deserialize(_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 { ["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"); }