Files
Migu2.0/MiGu.Server/Controllers/RbacController.cs
T
黄兆尉andCursor c5d5f7a726 重构页面目录与 RBAC:扁平配置入口并对齐管理/运营页映射。
下线运营总览幽灵页,补 ExpandKeysForScope / PlatformToMonitor,同步导航与角色配置 UI。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 17:50:02 +08:00

199 lines
8.6 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.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MiGu.Server.Auth;
using MiGu.Server.Configs;
namespace MiGu.Server.Controllers;
/// <summary>
/// RBAC 管理端:用户 / 角色 / 权限页面分配。整个控制器要求 <c>RbacAdmin</c> 策略
/// JWT 的 ops claim 含 <c>*</c> 或 <c>auth.manage</c>),即只有「超级管理员」类账号可访问。
///
/// 对应前端「平台配置中心 → 权限与角色」页(/admin/config/auth)。
/// </summary>
[ApiController]
[Authorize(Policy = "RbacAdmin")]
[Route("api/rbac")]
public class RbacController : ControllerBase
{
public sealed record OpDef(string Code, string Label);
public sealed record WidgetDef(string Id, string Label);
/// <summary>可分配的操作码候选(管理端配置角色时下拉/勾选用)。</summary>
private static readonly OpDef[] KnownOps =
{
new("*", "全部操作(通配)"),
new("ops.car.pause", "车辆 · 暂停"),
new("ops.car.resume", "车辆 · 恢复"),
new("ops.car.gohome", "车辆 · 回库"),
new("ops.car.resetSession", "车辆 · 重置会话"),
new("ops.car.manualCharge", "车辆 · 手动充电"),
new("ops.car.execute", "车辆 · 地图监控动作(按管理端配置)"),
new("ops.task.pause", "任务 · 暂停"),
new("ops.task.cancel", "任务 · 取消"),
new("ops.task.reassign", "任务 · 改派"),
new("ops.task.boostPriority", "任务 · 提升优先级"),
new("ops.ota", "OTA · 运维读写"),
new("ops.ota.write", "OTA · 写操作"),
new("monitor.note.write", "监控 · 写运营备注"),
new("auth.manage", "系统 · 权限与角色管理"),
};
/// <summary>可配置可见性的控件候选。</summary>
private static readonly WidgetDef[] KnownWidgets =
{
new("MapEditor", "地图编辑器"),
new("CadToolbar", "CAD 工具栏"),
new("CarPanel", "车辆面板"),
new("MissionEditor", "任务编辑器"),
new("OpsActionPanel", "运维操作面板"),
new("ConfigCenter", "配置中心"),
};
private readonly RbacStore _store;
private readonly ILogger<RbacController> _log;
public RbacController(RbacStore store, ILogger<RbacController> log)
{
_store = store;
_log = log;
}
/// <summary>权限「字典」:页面清单 + 可选操作码 + 可选控件 + scope 选项。前端角色编辑器据此渲染勾选项。</summary>
[HttpGet("catalog")]
public IActionResult Catalog()
{
var actorPages = ActorPlatformPages();
var grantablePlatform = PageCatalog.GrantablePlatformPages(actorPages).OrderBy(x => x).ToList();
var grantableMonitor = PageCatalog.GrantableMonitorPages(actorPages).OrderBy(x => x).ToList();
return Ok(new
{
pages = PageCatalog.All,
ops = KnownOps,
widgets = KnownWidgets,
scopes = new[]
{
new { value = PageCatalog.ScopePlatform, label = "管理权限 (Platform)" },
new { value = PageCatalog.ScopeMonitor, label = "运营权限 (RCSMonitor)" },
new { value = PageCatalog.Wildcard, label = "通用 (全部域)" },
},
// 当前登录管理员可勾选的页面(运营端由管理端已有页映射而来)。
grantablePages = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase)
{
[PageCatalog.ScopePlatform] = grantablePlatform,
[PageCatalog.ScopeMonitor] = grantableMonitor,
[PageCatalog.Wildcard] = grantablePlatform.Concat(grantableMonitor).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x).ToList(),
},
platformToMonitor = PageCatalog.PlatformToMonitor
.Select(kv => new { platform = kv.Key, monitor = kv.Value })
.ToList(),
monitorOnlyPages = PageCatalog.MonitorOnlyPages,
});
}
// ───────────────────────── 角色 ─────────────────────────
[HttpGet("roles")]
public IActionResult ListRoles() => Ok(_store.ListRoles());
[HttpPost("roles")]
public IActionResult CreateRole([FromBody] SaveRoleRequest req) => Guard(() =>
Ok(_store.CreateRole(ClampPages(req))));
[HttpPut("roles/{id}")]
public IActionResult UpdateRole(string id, [FromBody] SaveRoleRequest req) => Guard(() =>
Ok(_store.UpdateRole(id, ClampPages(req))));
[HttpDelete("roles/{id}")]
public IActionResult DeleteRole(string id) => Guard(() =>
{
_store.DeleteRole(id);
return Ok(new { ok = true });
});
// ───────────────────────── 用户 ─────────────────────────
[HttpGet("users")]
public IActionResult ListUsers() => Ok(_store.ListUsers());
[HttpPost("users")]
public IActionResult CreateUser([FromBody] CreateUserRequest req) => Guard(() => Ok(_store.CreateUser(req)));
[HttpPut("users/{id}")]
public IActionResult UpdateUser(string id, [FromBody] UpdateUserRequest req) => Guard(() =>
{
// 自我保护:禁止把当前登录账号自己停用,避免管理员把自己锁在门外。
if (id == CurrentUserId() && req.Enabled == false)
return (IActionResult)BadRequest(new { message = "不能停用当前登录的账号" });
return Ok(_store.UpdateUser(id, req));
});
[HttpPut("users/{id}/password")]
public IActionResult SetPassword(string id, [FromBody] SetPasswordRequest req) => Guard(() =>
{
_store.SetPassword(id, req.Password);
return Ok(new { ok = true });
});
[HttpDelete("users/{id}")]
public IActionResult DeleteUser(string id) => Guard(() =>
{
if (id == CurrentUserId())
return (IActionResult)BadRequest(new { message = "不能删除当前登录的账号" });
_store.DeleteUser(id);
return Ok(new { ok = true });
});
// ───────────────────────── 工具 ─────────────────────────
/// <summary>统一把 <see cref="RbacException"/> 翻译成 400 + message,其余异常向上抛。</summary>
private IActionResult Guard(Func<IActionResult> action)
{
try { return action(); }
catch (RbacException ex) { return BadRequest(new { message = ex.Message }); }
}
private string? CurrentUserId() =>
User.FindFirstValue("sub") ?? User.FindFirstValue(ClaimTypes.NameIdentifier);
/// <summary>当前登录管理员在管理端的有效页面集合。</summary>
private List<string> ActorPlatformPages()
{
var id = CurrentUserId();
if (string.IsNullOrEmpty(id)) return new();
var user = _store.FindUserById(id);
if (user is null) return new();
return _store.ComputeEffective(user, PageCatalog.ScopePlatform).Pages;
}
/// <summary>
/// 保存角色时按「当前管理员可授页面」裁剪:运营端页只能选自管理端已有页的映射 + 运营专属页。
/// 通配 <c>*</c> 仅当可授集合已覆盖该域全部页面时才保留。
/// </summary>
private SaveRoleRequest ClampPages(SaveRoleRequest req)
{
var scope = string.IsNullOrWhiteSpace(req.Scope) ? PageCatalog.ScopePlatform : req.Scope.Trim();
var grantable = PageCatalog.GrantablePagesForRoleScope(scope, ActorPlatformPages());
var pages = req.Pages ?? new List<string>();
if (pages.Contains(PageCatalog.Wildcard, StringComparer.OrdinalIgnoreCase))
{
var scopeKeys = scope == PageCatalog.Wildcard
? PageCatalog.All.Select(p => p.Key).ToList()
: PageCatalog.KeysForScope(scope).ToList();
if (scopeKeys.All(k => grantable.Contains(k)))
return req with { Pages = new List<string> { PageCatalog.Wildcard } };
return req with
{
Pages = scopeKeys.Where(k => grantable.Contains(k)).Distinct(StringComparer.OrdinalIgnoreCase).ToList()
};
}
var clamped = pages
.Select(PageCatalog.NormalizeKey)
.Where(p => p == PageCatalog.Wildcard || (PageCatalog.IsValidKey(p) && grantable.Contains(p)))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return req with { Pages = clamped };
}
}