从 Simple-FR 拆出 Platform.Server 并重命名为 MiGu.Server;frontends 源码与构建脚本迁入本仓库。地图监控在 projection/cars 失败或为空时回退 reflection 车辆列表;Simple 仓库已移除旧 Platform.Server。 Co-authored-by: Cursor <cursoragent@cursor.com>
191 lines
7.3 KiB
C#
191 lines
7.3 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Text.Json;
|
|
|
|
namespace MiGu.Server.Configs;
|
|
|
|
/// <summary>
|
|
/// 配置中心存储(内存 + JSON 文件持久化占位)。
|
|
/// 14 个 section 对应 ARCHITECTURE.md §9 的 13+1 维度。
|
|
/// 真实落地时由 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"
|
|
};
|
|
|
|
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);
|
|
|
|
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);
|
|
}
|
|
}
|
|
_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(),
|
|
_ => 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);
|
|
File.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");
|
|
}
|