Files
Migu2.0/MiGu.Server/Configs/ConfigStore.cs
T
zhaowei.huangandCursor e77ee818f1 feat(server/rbac): 权限页聚合迁移、最后管理员保护与配置原子落盘
PageCatalog 将 admin-config 重构为 6 大聚合分类,并加旧 key 别名兼容旧链接;
RbacStore 接入原子写 + 损坏备份,新增「最后管理员」保护,避免操作后系统无任何在岗管理员;
ConfigStore 改用 AtomicFile 持久化;新增 Infra/AtomicFile 原子写工具(临时名带 GUID 防并发)。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 16:09:01 +08:00

237 lines
9.5 KiB
C#
Raw 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 下发至 SimpleLite。
/// </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);
}
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");
}