调整 .gitignore 以适配 backends 目录结构
将 MiGu.Server 相关忽略规则迁移至 backends/MiGu.Server,并新增对 .tmp-build* 和 /.cursor/rules 的忽略,优化敏感数据与临时文件的管理。
This commit is contained in:
@@ -1,18 +0,0 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record ChargePriorityRule(string Id, string Condition, double Weight);
|
||||
|
||||
public record ChargePolicy(
|
||||
bool AllowMidTaskCharge,
|
||||
int IdleChargeAfterSec,
|
||||
List<ChargePriorityRule> Priority)
|
||||
{
|
||||
public static ChargePolicy Default() => new(
|
||||
AllowMidTaskCharge: false,
|
||||
IdleChargeAfterSec: 300,
|
||||
Priority: new List<ChargePriorityRule>
|
||||
{
|
||||
new("CP1", "soc<0.2", 100),
|
||||
new("CP2", "idle>5min", 30)
|
||||
});
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
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 列表/空字符串规整为安全默认值,
|
||||
/// 供 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);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record CustomWidget(
|
||||
string Id, string Name, string SchemaJson, string LayoutJson,
|
||||
List<string> BindToScopes);
|
||||
|
||||
public record CustomWidgetConfig(List<CustomWidget> Items)
|
||||
{
|
||||
public static CustomWidgetConfig Default() => new(
|
||||
Items: new List<CustomWidget>
|
||||
{
|
||||
new("widget-call-button", "呼叫按钮",
|
||||
"{\"fields\":[{\"name\":\"siteId\"}]}",
|
||||
"{\"x\":0,\"y\":0,\"w\":2,\"h\":1}",
|
||||
new List<string> { "RCSMonitor" })
|
||||
});
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
/// <summary>
|
||||
/// 配置向导「可选项目录」与「选型 → 平台能力」映射的单一事实来源。
|
||||
///
|
||||
/// <para>前端向导从 <c>GET /api/wizard/options</c> 拿到这些可选项渲染勾选框;后端
|
||||
/// <see cref="ModuleToPages"/> 在「按选型裁剪菜单」时把模块
|
||||
/// 映射为 PageCatalog 的可见页 id。集中定义,避免前后端各写一份导致漂移。</para>
|
||||
/// </summary>
|
||||
public static class DeploymentCatalog
|
||||
{
|
||||
/// <summary>一个可勾选项。<see cref="Id"/> 是稳定机器标识,<see cref="Group"/> 用于前端分组展示。</summary>
|
||||
public record Option(string Id, string Name, string Group, string Description);
|
||||
|
||||
/// <summary>导航方式(多选,一等维度)。id 与 <see cref="DeploymentProfile.NavKindToSceneId"/> 的 key 对齐。</summary>
|
||||
public static readonly IReadOnlyList<Option> NavigationKinds = new[]
|
||||
{
|
||||
new Option("magnetic", "磁导航", "navigation", "磁条循迹 + 地标 / RFID 定位"),
|
||||
new Option("qrcode", "二维码导航", "navigation", "二维码地标 + 码值地图"),
|
||||
new Option("laser", "激光导航", "navigation", "反光板 / SLAM + 激光避障"),
|
||||
};
|
||||
|
||||
/// <summary>功能模块(多选)。暂定保留 WMS / PTL 两项,后续按需扩展(参考 RIOT 的 WMS/WCS/MES/APS 分层)。</summary>
|
||||
public static readonly IReadOnlyList<Option> Modules = new[]
|
||||
{
|
||||
new Option("wms", "WMS 仓储管理", "module", "库位 / 库存 / 出入库管理"),
|
||||
new Option("ptl", "PTL 拣选系统", "module", "Pick-to-Light 亮灯拣选与播种"),
|
||||
};
|
||||
|
||||
/// <summary>功能模块 → PageCatalog 页面 Key 列表的映射。PTL 暂无专属配置页,未纳入映射(选中不影响菜单)。</summary>
|
||||
public static readonly IReadOnlyDictionary<string, string[]> ModuleToPages =
|
||||
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["wms"] = new[] { "admin-config-location" },
|
||||
};
|
||||
|
||||
/// <summary>所有「可被选型控制」的页面 Key(Module → 页 映射值的并集)。</summary>
|
||||
public static IReadOnlyCollection<string> TailorablePages()
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var v in ModuleToPages.Values) foreach (var p in v) set.Add(p);
|
||||
return set;
|
||||
}
|
||||
|
||||
/// <summary>当前部署画像下被「点亮」的可裁剪页(已启用 Module 映射到的页)。</summary>
|
||||
public static IReadOnlyCollection<string> EnabledPages(DeploymentProfile dp)
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (dp == null) return set;
|
||||
foreach (var m in dp.Modules ?? new List<string>())
|
||||
if (ModuleToPages.TryGetValue(m, out var ps)) foreach (var p in ps) set.Add(p);
|
||||
return set;
|
||||
}
|
||||
|
||||
/// <summary>当前部署画像下应隐藏的页(可裁剪但未被点亮)。向导未完成(Configured=false)则不隐藏任何页。</summary>
|
||||
public static IReadOnlyCollection<string> HiddenPages(DeploymentProfile dp)
|
||||
{
|
||||
if (dp == null || !dp.Configured) return Array.Empty<string>();
|
||||
var enabled = EnabledPages(dp);
|
||||
return TailorablePages().Where(p => !enabled.Contains(p)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从 RBAC 计算出的可见页集合中移除「被部署画像隐藏」的页 —— 这是「按选型裁剪菜单」的核心。
|
||||
/// 不在任何映射中的页不受影响;向导未完成时原样返回(避免首登空菜单)。
|
||||
/// </summary>
|
||||
public static List<string> FilterPagesByDeployment(IEnumerable<string> pages, DeploymentProfile dp)
|
||||
{
|
||||
var input = (pages ?? Enumerable.Empty<string>()).ToList();
|
||||
if (dp == null || !dp.Configured) return input;
|
||||
var hidden = new HashSet<string>(HiddenPages(dp), StringComparer.OrdinalIgnoreCase);
|
||||
return input.Where(p => !hidden.Contains(p)).ToList();
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
/// <summary>
|
||||
/// 部署画像 —— 登录后「配置向导」的结果,对应 ConfigStore 的 <c>deployment</c> section
|
||||
/// (持久化于 <c>data/config-deployment.json</c>)。
|
||||
///
|
||||
/// <para>它是「按选型裁剪」的单一事实来源:</para>
|
||||
/// <list type="number">
|
||||
/// <item><see cref="Configured"/>=false 时,登录返回 <c>NeedsWizard=true</c>,前端跳转配置向导;</item>
|
||||
/// <item><see cref="NavigationKinds"/> 经 <see cref="ToActiveSceneIds"/> 映射为 SimpleLite 场景 id,
|
||||
/// 由 Launcher 写入 <c>plugins/active-scenes.json</c> / 透传 <c>--scenes</c>,驱动内核「选择性加载」导航场景插件;</item>
|
||||
/// <item><see cref="Modules"/> 驱动平台菜单与能力裁剪(PageCatalog 可见页)。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <param name="Configured">向导是否已完成。false = 登录后强制进入配置向导。</param>
|
||||
/// <param name="PlatformType">平台主定位(仅用于 UI 标题/默认值),如 <c>standard</c> / <c>wcs</c> / <c>wms-wcs</c> / <c>custom</c>。</param>
|
||||
/// <param name="Modules">启用的功能模块(多选),暂定 <c>wms</c> / <c>ptl</c>。</param>
|
||||
/// <param name="NavigationKinds">导航方式(多选,一等维度),取值 <c>magnetic</c> / <c>qrcode</c> / <c>laser</c>,与 SimpleCore.NavKind 对齐。</param>
|
||||
/// <param name="Scenarios">选用的业务场景模板 id(来自 ScenarioTemplateConfig),如 <c>tpl-sps</c> / <c>tpl-pack</c>。</param>
|
||||
/// <param name="UpdatedBy">最近一次保存向导的用户名(审计用)。</param>
|
||||
public record DeploymentProfile(
|
||||
bool Configured,
|
||||
string PlatformType,
|
||||
List<string> Modules,
|
||||
List<string> NavigationKinds,
|
||||
List<string> Scenarios,
|
||||
string UpdatedBy)
|
||||
{
|
||||
public static DeploymentProfile Default() => new(
|
||||
Configured: false,
|
||||
PlatformType: "standard",
|
||||
Modules: new List<string>(),
|
||||
NavigationKinds: new List<string>(),
|
||||
Scenarios: new List<string>(),
|
||||
UpdatedBy: "");
|
||||
|
||||
/// <summary>
|
||||
/// 导航方式 → SimpleLite 场景 id 映射。与 <c>scene.json.id</c>、<c>SimpleCore.Navigation.NavKind</c>
|
||||
/// 以及内核 <c>active-scenes.json.activeScenes</c> 一致;这是平台与内核之间「导航选型」的契约约定。
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<string, string> NavKindToSceneId =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["magnetic"] = "scene.magnetic",
|
||||
["qrcode"] = "scene.qrcode",
|
||||
["laser"] = "scene.laser",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 把已选 <see cref="NavigationKinds"/> 映射为 SimpleLite 激活场景 id 列表(去重、忽略未知项、保持选择顺序)。
|
||||
/// 供 Launcher 写 <c>active-scenes.json</c> / 拼 <c>--scenes</c> 参数使用。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ToActiveSceneIds()
|
||||
{
|
||||
var result = new List<string>();
|
||||
if (NavigationKinds == null) return result;
|
||||
foreach (var k in NavigationKinds)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(k)) continue;
|
||||
if (NavKindToSceneId.TryGetValue(k.Trim(), out var sceneId) && !result.Contains(sceneId))
|
||||
result.Add(sceneId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record DeviceDriverBinding(string Id, string DeviceType, string DriverName, string Version);
|
||||
|
||||
public record DeviceInstance(
|
||||
string Id, string Name, string DeviceType, string Protocol, string Address,
|
||||
string DriverId, bool Enabled);
|
||||
|
||||
public record DeviceHealthPolicy(int HeartbeatSec, int OfflineSec);
|
||||
|
||||
public record AlarmRule(string Level, string Condition);
|
||||
public record AlarmPolicy(bool Enabled, List<AlarmRule> Rules);
|
||||
|
||||
public record DeviceManagementConfig(
|
||||
List<DeviceDriverBinding> Drivers,
|
||||
List<DeviceInstance> Devices,
|
||||
DeviceHealthPolicy HealthPolicy,
|
||||
AlarmPolicy AlarmPolicy)
|
||||
{
|
||||
public static DeviceManagementConfig Default() => new(
|
||||
Drivers: new List<DeviceDriverBinding>
|
||||
{
|
||||
new("drv-elev", "电梯", "OpcUaElevatorDriver", "1.2.0"),
|
||||
new("drv-chrg", "充电桩", "ModbusChargerDriver", "1.0.5"),
|
||||
new("drv-cam", "摄像头", "OnvifCameraDriver", "2.1.0")
|
||||
},
|
||||
Devices: new List<DeviceInstance>
|
||||
{
|
||||
new("dev-elev-1", "#1 电梯", "电梯", "opc-ua", "opc.tcp://10.0.2.20:4840", "drv-elev", true),
|
||||
new("dev-chrg-1", "充电桩-A1", "充电桩", "modbus-tcp", "10.0.2.30:502", "drv-chrg", true)
|
||||
},
|
||||
HealthPolicy: new DeviceHealthPolicy(5, 30),
|
||||
AlarmPolicy: new AlarmPolicy(true, new List<AlarmRule>
|
||||
{
|
||||
new("warn", "offline>30s"),
|
||||
new("error", "driverException")
|
||||
}));
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record WidgetGrantDto(string WidgetId, string Visibility);
|
||||
|
||||
public record AuthRole(
|
||||
string Id, string Name, string Scope,
|
||||
List<string> Permissions,
|
||||
List<WidgetGrantDto> WidgetGrants);
|
||||
|
||||
public record AuthUser(string Id, string Username, List<string> Roles, bool Enabled);
|
||||
|
||||
public record AuthRoleConfig(List<AuthRole> Roles, List<AuthUser> Users)
|
||||
{
|
||||
public static AuthRoleConfig Default() => new(
|
||||
Roles: new List<AuthRole>
|
||||
{
|
||||
new("role-admin", "管理员", "Platform",
|
||||
new List<string> { "*" },
|
||||
new List<WidgetGrantDto>()),
|
||||
new("role-ops", "运营", "RCSMonitor",
|
||||
new List<string>
|
||||
{
|
||||
"ops.car.pause", "ops.car.resume", "ops.car.gohome",
|
||||
"ops.task.pause", "ops.task.cancel", "ops.task.reassign",
|
||||
"ops.task.boostPriority", "monitor.note.write"
|
||||
},
|
||||
new List<WidgetGrantDto>
|
||||
{
|
||||
new("MapEditor", "readonly"),
|
||||
new("CadToolbar", "hidden")
|
||||
})
|
||||
},
|
||||
Users: new List<AuthUser>
|
||||
{
|
||||
new("u-admin", "admin", new List<string> { "role-admin" }, true),
|
||||
new("u-ops", "ops", new List<string> { "role-ops" }, true)
|
||||
});
|
||||
}
|
||||
|
||||
public record EffectivePermissions(
|
||||
string UserId,
|
||||
int Version,
|
||||
List<string> AllowedOps,
|
||||
List<WidgetGrantDto> VisibleWidgets,
|
||||
List<string> AllowedPages);
|
||||
@@ -1,16 +0,0 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record EndpointDescriptor(string Id, string Name, string Url, bool Enabled);
|
||||
|
||||
public record ExternalIntegrations(
|
||||
List<EndpointDescriptor> Mes,
|
||||
List<EndpointDescriptor> Wms,
|
||||
List<EndpointDescriptor> Rcs,
|
||||
List<EndpointDescriptor> Ptl)
|
||||
{
|
||||
public static ExternalIntegrations Default() => new(
|
||||
Mes: new List<EndpointDescriptor> { new("mes-1", "MES 主线", "http://mes.lan/api", true) },
|
||||
Wms: new List<EndpointDescriptor> { new("wms-1", "WMS 仓储", "http://wms.lan/api", true) },
|
||||
Rcs: new List<EndpointDescriptor>(),
|
||||
Ptl: new List<EndpointDescriptor> { new("ptl-1", "PTL 拣选", "http://ptl.lan/api", true) });
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record FleetGroup(string Id, string Name, string Floor, string Region, List<string> CarIds);
|
||||
public record OtaPolicy(bool Enabled, int BatchSize, bool RollbackOnFail);
|
||||
public record BatchOpsPolicy(bool ConfirmationRequired, int MaxBatch);
|
||||
public record NetworkDiagPolicy(int RttThresholdMs, double PacketLossThreshold);
|
||||
|
||||
public record FleetLifecycleConfig(
|
||||
List<FleetGroup> Groups,
|
||||
OtaPolicy Ota,
|
||||
BatchOpsPolicy BatchOps,
|
||||
NetworkDiagPolicy NetworkDiag)
|
||||
{
|
||||
public static FleetLifecycleConfig Default() => new(
|
||||
Groups: new List<FleetGroup>
|
||||
{
|
||||
new("G-A", "A 区车队", "F1", "A", new List<string> { "C01", "C02", "C03" }),
|
||||
new("G-B", "B 区车队", "F1", "B", new List<string> { "C04", "C05" })
|
||||
},
|
||||
Ota: new OtaPolicy(true, 2, true),
|
||||
BatchOps: new BatchOpsPolicy(true, 10),
|
||||
NetworkDiag: new NetworkDiagPolicy(80, 0.02));
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record Location(string Id, string Code, string Name, string SiteId, int Capacity, int Occupied);
|
||||
public record InventoryRule(string Id, string ItemType, int MinQty, int MaxQty);
|
||||
|
||||
public record LocationManagement(
|
||||
List<Location> Locations,
|
||||
List<InventoryRule> InventoryRules)
|
||||
{
|
||||
public static LocationManagement Default() => new(
|
||||
Locations: new List<Location>
|
||||
{
|
||||
new("L01", "A-01", "A 区货架 1", "S001", 20, 12),
|
||||
new("L02", "A-02", "A 区货架 2", "S002", 20, 7),
|
||||
new("L03", "B-01", "B 区缓存", "S003", 30, 25)
|
||||
},
|
||||
InventoryRules: new List<InventoryRule>
|
||||
{
|
||||
new("IR1", "PalletA", 5, 30)
|
||||
});
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using MiGu.Server.Infra;
|
||||
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
/// <summary>
|
||||
/// 运维操作审计存储:内存队列 + <c>data/ops-audit.json</c> 原子持久化。
|
||||
///
|
||||
/// 取代 OpsController 旧的「纯静态 ConcurrentQueue」——那种实现进程一重启审计全丢,
|
||||
/// 且无法满足「运维动作可追溯」的合规诉求。这里启动时回载历史,写入走原子落盘,
|
||||
/// 仅保留最近 <see cref="MaxEntries"/> 条避免无限增长。
|
||||
/// </summary>
|
||||
public sealed class OpsAuditStore
|
||||
{
|
||||
public sealed record AuditEntry(
|
||||
string Id,
|
||||
DateTimeOffset Ts,
|
||||
string User,
|
||||
string Scope,
|
||||
string OpCode,
|
||||
string Target,
|
||||
string Result,
|
||||
string? Message,
|
||||
string? IdempotencyKey = null);
|
||||
|
||||
private const int MaxEntries = 500;
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly string _file;
|
||||
private readonly ILogger<OpsAuditStore> _logger;
|
||||
private readonly List<AuditEntry> _entries = new();
|
||||
private long _seq;
|
||||
|
||||
private readonly JsonSerializerOptions _json = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public OpsAuditStore(IWebHostEnvironment env, ILogger<OpsAuditStore> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
var dir = Path.Combine(env.ContentRootPath, "data");
|
||||
Directory.CreateDirectory(dir);
|
||||
_file = Path.Combine(dir, "ops-audit.json");
|
||||
Load();
|
||||
}
|
||||
|
||||
private void Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_file)) return;
|
||||
var list = JsonSerializer.Deserialize<List<AuditEntry>>(File.ReadAllText(_file), _json);
|
||||
if (list is { Count: > 0 })
|
||||
{
|
||||
_entries.AddRange(list.Count > MaxEntries ? list.GetRange(list.Count - MaxEntries, MaxEntries) : list);
|
||||
// 续上序号,避免重启后 id 从 A000001 重新开始造成重复。
|
||||
_seq = _entries.Select(e => ParseSeq(e.Id)).DefaultIfEmpty(0).Max();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "ops 审计文件 {File} 加载失败,将以空记录开始并备份损坏文件", _file);
|
||||
AtomicFile.BackupCorrupt(_file);
|
||||
}
|
||||
}
|
||||
|
||||
private static long ParseSeq(string id) => long.TryParse(id.TrimStart('A'), out var n) ? n : 0;
|
||||
|
||||
/// <summary>追加一条审计并原子落盘。返回生成的条目(含 Id)。</summary>
|
||||
public AuditEntry Append(string user, string scope, string opCode, string target, string result, string? message, string? idempotencyKey = null)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var entry = new AuditEntry(
|
||||
$"A{++_seq:D6}", DateTimeOffset.UtcNow,
|
||||
user, scope, opCode, target, result, message, idempotencyKey);
|
||||
_entries.Add(entry);
|
||||
if (_entries.Count > MaxEntries)
|
||||
_entries.RemoveRange(0, _entries.Count - MaxEntries);
|
||||
Persist();
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查找指定幂等键最近一条「成功(ok)」审计,用于对重复下发去重。无则返回 null。
|
||||
/// 仅匹配成功记录:上次失败的请求允许重试重新下发。
|
||||
/// </summary>
|
||||
public AuditEntry? FindSuccessByIdempotencyKey(string? key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key)) return null;
|
||||
lock (_gate)
|
||||
{
|
||||
for (var i = _entries.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var e = _entries[i];
|
||||
if (e.Result == "ok" && string.Equals(e.IdempotencyKey, key, StringComparison.Ordinal))
|
||||
return e;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>最近的审计(倒序,最新在前)。</summary>
|
||||
public IReadOnlyList<AuditEntry> Recent()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var copy = new List<AuditEntry>(_entries);
|
||||
copy.Reverse();
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
|
||||
private void Persist()
|
||||
{
|
||||
try
|
||||
{
|
||||
AtomicFile.WriteAllText(_file, JsonSerializer.Serialize(_entries, _json));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "ops 审计持久化到 {File} 失败", _file);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record PlaybackPolicy(int RetentionDays, int SamplingHz);
|
||||
public record LogRetention(int HotDays, int ColdDays);
|
||||
public record VersionPolicy(int KeepReleases);
|
||||
public record MonitorPanelPolicy(string[] PropertyKeys, string[] StatusKeys, string[] ActionKeys);
|
||||
public record MonitorOpsPolicy(
|
||||
MonitorPanelPolicy Car,
|
||||
MonitorPanelPolicy Site,
|
||||
MonitorPanelPolicy Track,
|
||||
Dictionary<string, string[]> CarActionByType);
|
||||
|
||||
public record OpsConfig(
|
||||
PlaybackPolicy Playback,
|
||||
LogRetention LogRetention,
|
||||
VersionPolicy Version,
|
||||
MonitorOpsPolicy Monitor)
|
||||
{
|
||||
public static OpsConfig Default() => new(
|
||||
Playback: new PlaybackPolicy(30, 5),
|
||||
LogRetention: new LogRetention(7, 180),
|
||||
Version: new VersionPolicy(5),
|
||||
Monitor: new MonitorOpsPolicy(
|
||||
Car: new MonitorPanelPolicy(Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>()),
|
||||
Site: new MonitorPanelPolicy(Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>()),
|
||||
Track: new MonitorPanelPolicy(Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>()),
|
||||
CarActionByType: new Dictionary<string, string[]>()));
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record AvoidanceRule(string Id, string ZoneId, string Rule);
|
||||
public record ZoneSpeedLimit(string ZoneId, double MaxSpeedMps);
|
||||
|
||||
public record RoutingPolicy(
|
||||
string Algorithm,
|
||||
Dictionary<string, double> Weights,
|
||||
List<AvoidanceRule> Avoidance,
|
||||
List<ZoneSpeedLimit> ZoneSpeedLimits)
|
||||
{
|
||||
public static RoutingPolicy Default() => new(
|
||||
Algorithm: "astar",
|
||||
Weights: new Dictionary<string, double>
|
||||
{
|
||||
["distance"] = 1.0,
|
||||
["congestion"] = 0.5,
|
||||
["turnPenalty"] = 0.2
|
||||
},
|
||||
Avoidance: new List<AvoidanceRule> { new("AV1", "Z-NORTH", "no-entry-while-loading") },
|
||||
ZoneSpeedLimits: new List<ZoneSpeedLimit> { new("Z-NARROW", 0.5) });
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record ScenarioTemplate(string Id, string Name, string Category, string Version, string BaselineJson);
|
||||
public record TemplateDslPolicy(bool Enabled, string SchemaVersion);
|
||||
public record LowCodePolicy(bool Enabled, string Editor);
|
||||
public record TemplateVersionPolicy(int KeepVersions, bool AllowRollback);
|
||||
|
||||
public record ScenarioTemplateConfig(
|
||||
List<ScenarioTemplate> Templates,
|
||||
TemplateDslPolicy DslPolicy,
|
||||
LowCodePolicy LowCode,
|
||||
TemplateVersionPolicy VersionPolicy)
|
||||
{
|
||||
public static ScenarioTemplateConfig Default() => new(
|
||||
Templates: new List<ScenarioTemplate>
|
||||
{
|
||||
new("tpl-sps", "SPS 物料配送", "SPS", "1.0.0", "{}"),
|
||||
new("tpl-pack", "电池 Pack 自动化产线", "BatteryPack", "1.0.0", "{}"),
|
||||
new("tpl-loop", "环线运行", "Loop", "1.0.0", "{}"),
|
||||
new("tpl-p2p", "点对点柔性搬运", "P2P", "1.0.0", "{}")
|
||||
},
|
||||
DslPolicy: new TemplateDslPolicy(true, "1"),
|
||||
LowCode: new LowCodePolicy(false, "json"),
|
||||
VersionPolicy: new TemplateVersionPolicy(10, true));
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record LogPolicy(string Level, int RollDays, int MaxSizeMB);
|
||||
public record SecurityPolicy(int JwtExpireMin, bool EnableSwagger, List<string> CorsWhitelist);
|
||||
|
||||
public record SystemConfig(int DispatchLoopHz, LogPolicy Log, SecurityPolicy Security)
|
||||
{
|
||||
public static SystemConfig Default() => new(
|
||||
DispatchLoopHz: 50,
|
||||
Log: new LogPolicy("info", 7, 256),
|
||||
Security: new SecurityPolicy(1440, false, new List<string> { "http://localhost:5173" }));
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record TaskAllocationPolicy(
|
||||
string Mode,
|
||||
bool LoadBalance,
|
||||
int MaxQueuePerCar)
|
||||
{
|
||||
public static TaskAllocationPolicy Default() => new(
|
||||
Mode: "leastLoad",
|
||||
LoadBalance: true,
|
||||
MaxQueuePerCar: 3);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record IntersectionPolicy(string Id, List<string> SiteIds, string Mode);
|
||||
public record ZoneMutex(string Id, List<string> ZoneIds);
|
||||
public record DynamicYield(string Id, string From, string To, string Condition);
|
||||
|
||||
public record TrafficRule(
|
||||
List<IntersectionPolicy> Intersections,
|
||||
List<ZoneMutex> Mutex,
|
||||
List<DynamicYield> Yields)
|
||||
{
|
||||
public static TrafficRule Default() => new(
|
||||
Intersections: new List<IntersectionPolicy> { new("IX1", new List<string> { "S006", "S007" }, "mutex") },
|
||||
Mutex: new List<ZoneMutex> { new("MZ1", new List<string> { "Z-CROSS" }) },
|
||||
Yields: new List<DynamicYield> { new("YD1", "A 区", "B 区", "priority<peer") });
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record FaultReportPolicy(bool Enabled, List<string> EmailTo);
|
||||
public record AutoRepairPolicy(bool Enabled, int CooldownSec);
|
||||
|
||||
public record VehicleMaintenancePolicy(
|
||||
double LowBatteryThreshold,
|
||||
double CriticalBatteryThreshold,
|
||||
FaultReportPolicy FaultReport,
|
||||
AutoRepairPolicy AutoRepair)
|
||||
{
|
||||
public static VehicleMaintenancePolicy Default() => new(
|
||||
LowBatteryThreshold: 0.3,
|
||||
CriticalBatteryThreshold: 0.15,
|
||||
FaultReport: new FaultReportPolicy(true, new List<string> { "ops@example.com" }),
|
||||
AutoRepair: new AutoRepairPolicy(false, 600));
|
||||
}
|
||||
Reference in New Issue
Block a user