diff --git a/MiGu.Server/Auth/PageCatalog.cs b/MiGu.Server/Auth/PageCatalog.cs index df82dc2..01daf5d 100644 --- a/MiGu.Server/Auth/PageCatalog.cs +++ b/MiGu.Server/Auth/PageCatalog.cs @@ -30,7 +30,6 @@ public static class PageCatalog // ── 管理端 / Platform:概览 ── new("admin-dashboard", "总览", "概览", ScopePlatform), new("admin-map-monitor", "地图监控", "概览", ScopePlatform), - new("admin-playback", "调度回放", "概览", ScopePlatform), // ── 管理端 / Platform:设计与编排 ── new("admin-maps", "地图管理", "设计与编排", ScopePlatform), @@ -40,23 +39,15 @@ public static class PageCatalog new("admin-cars", "车辆管理", "设计与编排", ScopePlatform), new("admin-processes", "进程管理", "设计与编排", ScopePlatform), new("admin-scripts", "脚本管理", "设计与编排", ScopePlatform), - new("admin-missions", "任务编排", "设计与编排", ScopePlatform), + new("admin-task-templates", "任务编排", "设计与编排", ScopePlatform), - // ── 管理端 / Platform:平台配置中心 ── - new("admin-config-system", "系统级配置", "平台配置中心", ScopePlatform), - new("admin-config-integrations", "外部系统对接", "平台配置中心", ScopePlatform), - new("admin-config-routing", "路径规划", "平台配置中心", ScopePlatform), + // ── 管理端 / Platform:平台配置中心(聚合页,每个 Key 对齐前端聚合路由 route.name) ── + new("admin-config-strategy", "调度策略", "平台配置中心", ScopePlatform), new("admin-vehicle-hub", "车辆运维", "平台配置中心", ScopePlatform), - new("admin-config-charge", "充电策略", "平台配置中心", ScopePlatform), - new("admin-config-task", "任务分配", "平台配置中心", ScopePlatform), - new("admin-config-traffic", "交通管制", "平台配置中心", ScopePlatform), - new("admin-config-auth", "权限与角色", "平台配置中心", ScopePlatform), - new("admin-config-device", "设备接入", "平台配置中心", ScopePlatform), - new("admin-config-scenario", "场景模板", "平台配置中心", ScopePlatform), - new("admin-config-location", "库位管理", "平台配置中心", ScopePlatform), - new("admin-config-ops", "运营维护", "平台配置中心", ScopePlatform), - new("admin-config-widget", "自定义控件", "平台配置中心", ScopePlatform), - new("admin-config-map-monitor", "地图监控配置", "平台配置中心", ScopePlatform), + new("admin-config-facility", "设备与库位", "平台配置中心", ScopePlatform), + new("admin-config-business", "业务与集成", "平台配置中心", ScopePlatform), + new("admin-config-ops-center", "运维与回放", "平台配置中心", ScopePlatform), + new("admin-config-system-center", "系统与权限", "平台配置中心", ScopePlatform), // ── 运营端 / RCSMonitor ── new("monitor-dashboard", "运营总览", "运营监控", ScopeMonitor), @@ -75,6 +66,22 @@ public static class PageCatalog { ["admin-config-vehicle"] = "admin-vehicle-hub", ["admin-config-fleet"] = "admin-vehicle-hub", + // 会话 16:平台配置中心入口按业务收敛为 6 个聚合页,旧 Key 迁移到对应聚合页 Key。 + ["admin-config-routing"] = "admin-config-strategy", + ["admin-config-task"] = "admin-config-strategy", + ["admin-config-traffic"] = "admin-config-strategy", + ["admin-config-charge"] = "admin-config-strategy", + ["admin-config-device"] = "admin-config-facility", + ["admin-config-location"] = "admin-config-facility", + ["admin-config-integrations"] = "admin-config-business", + ["admin-config-scenario"] = "admin-config-business", + ["admin-config-widget"] = "admin-config-business", + ["admin-playback"] = "admin-config-ops-center", + ["admin-config-ops"] = "admin-config-ops-center", + ["admin-config-logs"] = "admin-config-ops-center", + ["admin-config-map-monitor"] = "admin-config-ops-center", + ["admin-config-system"] = "admin-config-system-center", + ["admin-config-auth"] = "admin-config-system-center", }; /// 判断页面 Key 是否合法(用于角色保存时过滤掉脏数据 / 已下线页面)。 diff --git a/MiGu.Server/Auth/RbacStore.cs b/MiGu.Server/Auth/RbacStore.cs index 1f6ce27..11efef2 100644 --- a/MiGu.Server/Auth/RbacStore.cs +++ b/MiGu.Server/Auth/RbacStore.cs @@ -2,6 +2,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; using MiGu.Server.Configs; +using MiGu.Server.Infra; namespace MiGu.Server.Auth; @@ -67,6 +68,11 @@ public sealed class RbacStore catch (Exception ex) { _logger.LogError(ex, "RBAC 文件 {File} 解析失败,回退到默认 seed。", _file); + // S2:先备份疑似损坏的 rbac.json,避免随后默认 seed 的写入把自定义用户 / 角色 + // 永久冲掉(损坏 → 静默重置成 admin/ops 弱口令是高危场景)。 + var bak = AtomicFile.BackupCorrupt(_file); + if (bak != null) + _logger.LogWarning("已备份疑似损坏的 RBAC 文件到 {Backup},请人工核查后恢复自定义数据。", bak); } } @@ -90,6 +96,7 @@ public sealed class RbacStore r.Ops = (r.Ops ?? new()).Distinct().ToList(); r.WidgetGrants ??= new(); if (string.IsNullOrWhiteSpace(r.Scope)) r.Scope = PageCatalog.ScopePlatform; + BackfillKnownPageMigrations(r); } foreach (var u in snap.Users) { @@ -98,6 +105,23 @@ public sealed class RbacStore return snap; } + private static void BackfillKnownPageMigrations(RbacRole r) + { + if (!string.Equals(r.Scope, PageCatalog.ScopePlatform, StringComparison.OrdinalIgnoreCase) + && r.Scope != PageCatalog.Wildcard) return; + + // 任务编排页(admin-task-templates,原任务模板 / WorkflowEditor)与脚本、进程管理同属一组编排能力。 + // 旧的 admin-missions 入口已下线:上面 Normalize 会按 PageCatalog 过滤掉历史角色里的该 key。 + // 这里在角色已有进程 + 脚本入口时补齐「任务编排」菜单权限。 + var hasProcessAndScript = + r.Pages.Contains("admin-processes", StringComparer.OrdinalIgnoreCase) + && r.Pages.Contains("admin-scripts", StringComparer.OrdinalIgnoreCase); + if (!r.Pages.Contains(PageCatalog.Wildcard) + && !r.Pages.Contains("admin-task-templates", StringComparer.OrdinalIgnoreCase) + && hasProcessAndScript) + r.Pages.Add("admin-task-templates"); + } + private RbacSnapshot SeedDefault(IConfiguration config) { var adminPwd = config["Auth:Users:admin:Password"] ?? "admin"; @@ -123,7 +147,7 @@ public sealed class RbacStore { Id = RoleOpsId, Name = "运营人员", Description = "运营监控端默认角色:可执行运维操作、查看监控", Scope = PageCatalog.ScopeMonitor, - Pages = new() { "monitor-dashboard", "monitor-map", "monitor-ops", "monitor-notes" }, + Pages = new() { "monitor-dashboard", "monitor-vehicle-hub", "monitor-map", "monitor-ops", "monitor-notes" }, Ops = new() { "ops.car.pause", "ops.car.resume", "ops.car.gohome", "ops.car.resetSession", @@ -154,7 +178,7 @@ public sealed class RbacStore { try { - File.WriteAllText(_file, JsonSerializer.Serialize(_snapshot, _jsonOpts)); + AtomicFile.WriteAllText(_file, JsonSerializer.Serialize(_snapshot, _jsonOpts)); } catch (Exception ex) { @@ -290,9 +314,15 @@ public sealed class RbacStore lock (_gate) { var u = _snapshot.Users.FirstOrDefault(x => x.Id == id) ?? throw new RbacException("用户不存在"); + var oldName = u.DisplayName; + var oldRoles = u.RoleIds; + var oldEnabled = u.Enabled; if (req.DisplayName is not null) u.DisplayName = req.DisplayName.Trim(); if (req.RoleIds is not null) u.RoleIds = FilterExistingRoles(req.RoleIds); if (req.Enabled is bool en) u.Enabled = en; + // M5:若本次改动(改角色 / 停用)导致系统再无有效管理员,则回滚后报错。 + try { EnsureAdminRemainsNoLock(); } + catch { u.DisplayName = oldName; u.RoleIds = oldRoles; u.Enabled = oldEnabled; throw; } Persist(); return ToDto(u); } @@ -316,7 +346,11 @@ public sealed class RbacStore lock (_gate) { var u = _snapshot.Users.FirstOrDefault(x => x.Id == id) ?? throw new RbacException("用户不存在"); - _snapshot.Users.Remove(u); + var idx = _snapshot.Users.IndexOf(u); + _snapshot.Users.RemoveAt(idx); + // M5:删除后若系统再无有效管理员,则恢复并报错。 + try { EnsureAdminRemainsNoLock(); } + catch { _snapshot.Users.Insert(idx, u); throw; } Persist(); } } @@ -353,12 +387,21 @@ public sealed class RbacStore lock (_gate) { var role = _snapshot.Roles.FirstOrDefault(r => r.Id == id) ?? throw new RbacException("角色不存在"); + var backup = Clone(role); role.Name = req.Name.Trim(); role.Description = req.Description?.Trim() ?? ""; role.Scope = scope; role.Pages = SanitizePages(req.Pages); role.Ops = req.Ops?.Distinct().ToList() ?? new(); role.WidgetGrants = req.WidgetGrants ?? new(); + // M5:若本次改动(如去掉角色的 "*"/auth.manage)导致系统再无有效管理员,则回滚。 + try { EnsureAdminRemainsNoLock(); } + catch + { + role.Name = backup.Name; role.Description = backup.Description; role.Scope = backup.Scope; + role.Pages = backup.Pages; role.Ops = backup.Ops; role.WidgetGrants = backup.WidgetGrants; + throw; + } Persist(); return Clone(role); } @@ -392,6 +435,29 @@ public sealed class RbacStore user.RoleIds.Select(id => _snapshot.Roles.FirstOrDefault(r => r.Id == id)) .Where(r => r is not null).Select(r => r!).ToList(); + private const string OpAuthManage = "auth.manage"; + + /// + /// 该用户当前是否为「有效系统管理员」:启用 且 至少一个角色的 Ops 含 "*" 或 "auth.manage"。 + /// 用于 M5「最后管理员」保护。 + /// + private bool IsActiveAdminNoLock(RbacUser u) + { + if (!u.Enabled) return false; + foreach (var r in RolesOf(u)) + if (r.Ops.Contains("*") || r.Ops.Contains(OpAuthManage)) return true; + return false; + } + + private int CountActiveAdminsNoLock() => _snapshot.Users.Count(IsActiveAdminNoLock); + + /// 校验修改应用后系统仍至少有一名有效管理员,否则抛异常(调用方负责回滚内存改动)。 + private void EnsureAdminRemainsNoLock() + { + if (CountActiveAdminsNoLock() == 0) + throw new RbacException("该操作会使系统再无任何具备管理权限(auth.manage)的启用账号,已阻止。请至少保留一名超级管理员。"); + } + private List FilterExistingRoles(List? roleIds) => (roleIds ?? new()).Where(id => _snapshot.Roles.Any(r => r.Id == id)).Distinct().ToList(); diff --git a/MiGu.Server/Configs/ConfigStore.cs b/MiGu.Server/Configs/ConfigStore.cs index 1fcb379..da99bdf 100644 --- a/MiGu.Server/Configs/ConfigStore.cs +++ b/MiGu.Server/Configs/ConfigStore.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Text.Json; +using MiGu.Server.Infra; namespace MiGu.Server.Configs; @@ -139,6 +140,9 @@ public sealed class ConfigStore 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); @@ -184,7 +188,7 @@ public sealed class ConfigStore ["payload"] = toWrite.Payload }; var json = JsonSerializer.Serialize(root, _jsonOpts); - File.WriteAllText(FilePath(env.Section), json); + AtomicFile.WriteAllText(FilePath(env.Section), json); } catch (Exception ex) { diff --git a/MiGu.Server/Infra/AtomicFile.cs b/MiGu.Server/Infra/AtomicFile.cs new file mode 100644 index 0000000..d27199f --- /dev/null +++ b/MiGu.Server/Infra/AtomicFile.cs @@ -0,0 +1,58 @@ +namespace MiGu.Server.Infra; + +/// +/// 原子写文件工具:先写同目录临时文件,再用 File.Replace / File.Move 整体替换目标, +/// 避免 File.WriteAllText 写到一半进程崩溃 / 断电导致目标文件被截断成「半个 JSON」。 +/// +/// 用于 rbac.json / config-*.json / ops-audit.json 等关键持久化文件 —— 这些文件一旦损坏, +/// 加载时会被当成「解析失败」回退默认 seed,进而静默丢失自定义用户 / 角色 / 配置。 +/// +public static class AtomicFile +{ + public static void WriteAllText(string path, string contents) + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + + // 临时名带 GUID:保证同一目标文件的并发写各用独立临时文件,互不覆盖(即便调用方未加锁)。 + var tmp = $"{path}.{Guid.NewGuid():N}.tmp"; + try + { + File.WriteAllText(tmp, contents); + + try + { + if (File.Exists(path)) + File.Replace(tmp, path, null); + else + File.Move(tmp, path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // 个别环境(杀软锁定 / 跨卷)File.Replace 会失败:退化为覆盖复制兜底(仍优于半截写入)。 + File.Copy(tmp, path, true); + } + } + finally + { + // 兜底清理:File.Replace/Move 成功时 tmp 已不存在;其余异常路径下避免遗留临时文件累积。 + try { if (File.Exists(tmp)) File.Delete(tmp); } catch { /* ignore */ } + } + } + + /// 把疑似损坏的文件复制一份带时间戳的备份(不抛异常)。返回备份路径或 null。 + public static string? BackupCorrupt(string path) + { + try + { + if (!File.Exists(path)) return null; + var bak = $"{path}.corrupt-{DateTime.UtcNow:yyyyMMddHHmmss}"; + File.Copy(path, bak, true); + return bak; + } + catch + { + return null; + } + } +}