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;
+ }
+ }
+}