feat: 迁入 MiGu.Server、平台前端与车辆列表 reflection 回退
从 Simple-FR 拆出 Platform.Server 并重命名为 MiGu.Server;frontends 源码与构建脚本迁入本仓库。地图监控在 projection/cars 失败或为空时回退 reflection 车辆列表;Simple 仓库已移除旧 Platform.Server。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MiGu.Server.Auth;
|
||||
using MiGu.Server.Configs;
|
||||
using MiGu.Server.Launcher;
|
||||
|
||||
namespace MiGu.Server.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
public class AuthController : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 登录请求体。
|
||||
/// 会话 N+1(启动反转):新增 <see cref="LaunchMode"/>。前端登录页让用户选 "WebOnly" / "DesktopAndWeb";
|
||||
/// MiGu.Server 据此拉起 SimpleLite 子进程并透传 <c>--display-mode=web|web+local</c>。
|
||||
/// 历史调用方不传该字段时默认 "DesktopAndWeb"(与之前 web+local 默认行为一致,向后兼容)。
|
||||
/// </summary>
|
||||
public record LoginRequest(string Username, string Password, string Scope, string? LaunchMode = null);
|
||||
|
||||
/// <summary>
|
||||
/// 登录响应。
|
||||
/// 会话 N+1 增量字段:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>RunMode</c>:根据 SimpleLite 真实拉起结果回填(WebEnabled / WebOnly / Detached)。Detached 表示后端未能拉起 SimpleLite,前端可降级展示。</item>
|
||||
/// <item><c>LaunchStatus</c>:<see cref="SimpleLiteLauncher.LaunchResult.Status"/> 枚举字符串,前端用于精细化提示。</item>
|
||||
/// <item><c>LaunchWarning</c>:可空告警文本;非空时前端应该弹消息条告知用户。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public record LoginResponse(
|
||||
string Token,
|
||||
AuthUserDto User,
|
||||
string Scope,
|
||||
string RunMode,
|
||||
EffectivePermissions EffectivePermissions,
|
||||
string? LaunchStatus = null,
|
||||
string? LaunchWarning = null);
|
||||
|
||||
public record AuthUserDto(string Id, string Username, string DisplayName, List<string> Roles);
|
||||
|
||||
/// <summary>
|
||||
/// 当前会话身份的轻量摘要。
|
||||
/// 用途:前端路由守卫在受保护路由首次进入前调用 <c>GET /api/auth/me</c>,
|
||||
/// 用 [Authorize] 实校验本地 token 是否仍被服务端接受(MiGu.Server 重启后
|
||||
/// JWT secret 可能已重生 → 老 token 会被拒),同时刷新 user / scope / runMode / perm。
|
||||
/// 与 <see cref="LoginResponse"/> 的区别:不返回 Token(client 已有,重发反而易触发竞态);
|
||||
/// 不返回 LaunchStatus/Warning(那是登录时一次性的 SimpleLite 拉起结果)。
|
||||
/// </summary>
|
||||
public record MeResponse(
|
||||
AuthUserDto User,
|
||||
string Scope,
|
||||
string RunMode,
|
||||
EffectivePermissions EffectivePermissions);
|
||||
|
||||
private static readonly string[] PlatformOps = { "*" };
|
||||
|
||||
private static readonly string[] RcsOps =
|
||||
{
|
||||
"ops.car.pause", "ops.car.resume", "ops.car.gohome", "ops.car.resetSession",
|
||||
"ops.car.manualCharge", "ops.task.pause", "ops.task.cancel", "ops.task.reassign",
|
||||
"ops.task.boostPriority", "monitor.note.write"
|
||||
};
|
||||
|
||||
private const string CookieName = "simple.auth.token";
|
||||
|
||||
private readonly UserStore _users;
|
||||
private readonly JwtIssuer _jwt;
|
||||
private readonly SimpleLiteLauncher _launcher;
|
||||
private readonly ILogger<AuthController> _log;
|
||||
|
||||
public AuthController(UserStore users, JwtIssuer jwt, SimpleLiteLauncher launcher, ILogger<AuthController> log)
|
||||
{
|
||||
_users = users;
|
||||
_jwt = jwt;
|
||||
_launcher = launcher;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<LoginResponse>> Login([FromBody] LoginRequest req)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Username))
|
||||
return BadRequest(new { message = "用户名不能为空" });
|
||||
if (req.Scope is not ("Platform" or "RCSMonitor"))
|
||||
return BadRequest(new { message = "无效 scope" });
|
||||
|
||||
// AR-3: 真密码校验 —— 替代会话21 点名的「完全不验密码」漏洞。
|
||||
// UserStore.Verify 对不存在用户和密码错都返回 null,防用户名枚举。
|
||||
var rec = _users.Verify(req.Username, req.Password);
|
||||
if (rec == null)
|
||||
return Unauthorized(new { message = "用户名或密码错误" });
|
||||
|
||||
// scope 与角色匹配检查:admin 默认 Platform;ops 默认 RCSMonitor。
|
||||
// 如果 ops 想登录 Platform scope,目前直接拒绝;后续可以加 role-platform-impersonate 之类。
|
||||
if (!CanUseScope(rec, req.Scope))
|
||||
return StatusCode(403, new { message = $"账号 {rec.Username} 没有访问 {req.Scope} 的权限" });
|
||||
|
||||
// 会话 N+1(启动反转):按 LaunchMode 拉起 SimpleLite 子进程。
|
||||
// - 历史前端不带该字段 → 默认 DesktopAndWeb(保持向后兼容的 web+local 行为)。
|
||||
// - WebOnly → SimpleLite 启动时只起 WebTerminal,不会弹本地桌面窗口。
|
||||
// - DesktopAndWeb → SimpleLite 同时起 LocalTerminal + WebTerminal。
|
||||
// - SimpleLiteLauncher 内部幂等:第二次/第 N 次登录不会重复拉起;子进程退出后下一次登录可重启。
|
||||
// PERF-A8 修复:MaybeStart 内部包含同步 WaitForProjectionReady(最多 ReadinessTimeoutMs,默认 8s)。
|
||||
// 走 Task.Run 把它扔到线程池,让登录请求自身的请求处理线程释放回 ASP.NET,避免高并发下挤兑。
|
||||
var launchMode = NormalizeLaunchMode(req.LaunchMode);
|
||||
SimpleLiteLauncher.LaunchResult? launchResult = null;
|
||||
try
|
||||
{
|
||||
launchResult = await Task.Run(() => _launcher.MaybeStart(launchMode));
|
||||
_log.LogInformation("SimpleLite launch result for user={User} launchMode={Mode}: Started={Started} Status={Status} Detail={Detail}",
|
||||
rec.Username, launchMode, launchResult.Value.Started, launchResult.Value.Status, launchResult.Value.Detail);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 启动 SimpleLite 失败不应阻断登录:用户至少能进 Platform 看状态页面排查。
|
||||
_log.LogError(ex, "SimpleLite launch threw for user={User} launchMode={Mode}", rec.Username, launchMode);
|
||||
}
|
||||
|
||||
// A7 修复:runMode 跟 result.Started 走 ——
|
||||
// - launcher 没起来 / 抛异常 → "Detached",前端显示降级状态而不是"假装 SimpleLite 在跑"。
|
||||
// - 跑起来了 → 优先按 launcher 回传的真实 DisplayMode 映射,避免 AlreadyRunning 时被本次请求的 launchMode 误导。
|
||||
// - ReusingExisting 时既有 SimpleLite 实际模式未知,保守归 WebEnabled(旧实例最可能带本地窗口)。
|
||||
var runMode = ResolveRunMode(launchResult, launchMode);
|
||||
|
||||
var (allowedOps, widgets) = BuildPermissions(req.Scope);
|
||||
var perm = new EffectivePermissions(rec.Id, 1, allowedOps.ToList(), widgets.ToList());
|
||||
var roles = rec.Roles.Concat(new[] { req.Scope == "Platform" ? "role-platform" : "role-rcs-monitor" }).Distinct().ToList();
|
||||
|
||||
var token = _jwt.Issue(rec.Id, rec.Username, req.Scope, roles, allowedOps);
|
||||
SetAuthCookie(token);
|
||||
|
||||
var user = new AuthUserDto(rec.Id, rec.Username, rec.DisplayName, roles);
|
||||
return Ok(new LoginResponse(token, user, req.Scope, runMode, perm,
|
||||
LaunchStatus: launchResult?.Status,
|
||||
LaunchWarning: launchResult?.Warning));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A7 修复:根据 Launcher 真实结果决定 RunMode。
|
||||
/// 之前的逻辑直接按 launchMode 映射,导致 SimpleLite 没起也假装"WebEnabled",前端 RunMode 角标骗人。
|
||||
/// </summary>
|
||||
private static string ResolveRunMode(SimpleLiteLauncher.LaunchResult? result, string launchMode)
|
||||
{
|
||||
if (result is not { Started: true })
|
||||
return "Detached";
|
||||
// ReusingExisting 时 DisplayMode = "external",无法确认本地窗口是否存在,保守按 WebEnabled。
|
||||
if (result.Value.Status == "ReusingExisting")
|
||||
return "WebEnabled";
|
||||
if (!string.IsNullOrEmpty(result.Value.DisplayMode))
|
||||
{
|
||||
if (result.Value.DisplayMode.Equals("web", StringComparison.OrdinalIgnoreCase))
|
||||
return "WebOnly";
|
||||
if (result.Value.DisplayMode.Contains("local", StringComparison.OrdinalIgnoreCase))
|
||||
return "WebEnabled";
|
||||
}
|
||||
return launchMode == "WebOnly" ? "WebOnly" : "WebEnabled";
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult Logout()
|
||||
{
|
||||
Response.Cookies.Delete(CookieName);
|
||||
return Ok(new { ok = true });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用本地持有的 token / Cookie 重新拉一次当前身份。失败(token 过期、签名变更、用户被删等)由
|
||||
/// [Authorize] 自动回 401,前端 axios 拦截器在 http.ts:57 会清 localStorage + 跳 /login。
|
||||
/// 用途:解决「MiGu.Server 随机 secret 重启 → 老 token 失效 → 前端 isAuthed 仍为 true 误放行」的窗口。
|
||||
/// </summary>
|
||||
[HttpGet("me")]
|
||||
[Authorize]
|
||||
public ActionResult<MeResponse> Me()
|
||||
{
|
||||
var username = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var scope = User.FindFirstValue("scope");
|
||||
|
||||
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(scope))
|
||||
return Unauthorized(new { message = "身份无效" });
|
||||
if (scope is not ("Platform" or "RCSMonitor"))
|
||||
return Unauthorized(new { message = "无效 scope" });
|
||||
|
||||
var rec = _users.Find(username);
|
||||
if (rec == null)
|
||||
return Unauthorized(new { message = "账号已失效" });
|
||||
|
||||
// 账号当前是否还允许这个 scope —— 比如运维把 admin 的角色去掉了,也要在这里及时回 403。
|
||||
if (!CanUseScope(rec, scope))
|
||||
return StatusCode(403, new { message = $"账号 {rec.Username} 没有访问 {scope} 的权限" });
|
||||
|
||||
var (allowedOps, widgets) = BuildPermissions(scope);
|
||||
var perm = new EffectivePermissions(rec.Id, 1, allowedOps.ToList(), widgets.ToList());
|
||||
var roles = rec.Roles.Concat(new[] { scope == "Platform" ? "role-platform" : "role-rcs-monitor" })
|
||||
.Distinct().ToList();
|
||||
var user = new AuthUserDto(rec.Id, rec.Username, rec.DisplayName, roles);
|
||||
|
||||
// RunMode 推断:与 SwitchScope 保持一致 —— 复用 Launcher 记录的 LastLaunchMode,
|
||||
// 不重新拉起 SimpleLite。
|
||||
var last = _launcher.LastLaunchMode;
|
||||
string runMode;
|
||||
if (string.IsNullOrEmpty(last))
|
||||
runMode = "Detached";
|
||||
else if (last == SimpleLiteLauncher.ExternalReuseLaunchMode)
|
||||
runMode = "WebEnabled";
|
||||
else
|
||||
runMode = last.Contains("local", StringComparison.OrdinalIgnoreCase) ? "WebEnabled" : "WebOnly";
|
||||
|
||||
return Ok(new MeResponse(user, scope, runMode, perm));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用同一身份切换 scope 并重发 token + perms。
|
||||
/// AR-6: 替代前端 stores/auth.ts 里硬编码改 allowedOps 的客户端伪权限。
|
||||
/// 当前服务端只放行账号的 DefaultScope,以及 admin 类账号显式允许的额外 scope。
|
||||
/// </summary>
|
||||
[HttpPost("switch-scope")]
|
||||
[Authorize]
|
||||
public ActionResult<LoginResponse> SwitchScope([FromBody] SwitchScopeRequest req)
|
||||
{
|
||||
if (req.Scope is not ("Platform" or "RCSMonitor"))
|
||||
return BadRequest(new { message = "无效 scope" });
|
||||
|
||||
var username = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(username))
|
||||
return Unauthorized(new { message = "身份无效" });
|
||||
|
||||
var rec = _users.Find(username);
|
||||
if (rec == null)
|
||||
return Unauthorized(new { message = "账号已失效" });
|
||||
|
||||
if (!CanUseScope(rec, req.Scope))
|
||||
return StatusCode(403, new { message = $"账号 {rec.Username} 没有访问 {req.Scope} 的权限" });
|
||||
|
||||
var (allowedOps, widgets) = BuildPermissions(req.Scope);
|
||||
var perm = new EffectivePermissions(rec.Id, 1, allowedOps.ToList(), widgets.ToList());
|
||||
var roles = rec.Roles.Concat(new[] { req.Scope == "Platform" ? "role-platform" : "role-rcs-monitor" }).Distinct().ToList();
|
||||
var token = _jwt.Issue(rec.Id, rec.Username, req.Scope, roles, allowedOps);
|
||||
SetAuthCookie(token);
|
||||
|
||||
// 复用首次登录确定的 LaunchMode:SwitchScope 不重新选启动模式(也不应该重启 SimpleLite)。
|
||||
// 三种情况:
|
||||
// 1) Launcher 已记录 "web" / "web+local" → 按 displayMode 反推 runMode。
|
||||
// 2) Launcher 记录 ExternalReuseLaunchMode(既有 SimpleLite 复用)→ 模式未知,保守 WebEnabled。
|
||||
// 3) Launcher 没拉起 / LastLaunchMode = null → Detached(前端降级展示)。
|
||||
var last = _launcher.LastLaunchMode;
|
||||
string runMode;
|
||||
if (string.IsNullOrEmpty(last))
|
||||
runMode = "Detached";
|
||||
else if (last == SimpleLiteLauncher.ExternalReuseLaunchMode)
|
||||
runMode = "WebEnabled";
|
||||
else
|
||||
runMode = last.Contains("local", StringComparison.OrdinalIgnoreCase) ? "WebEnabled" : "WebOnly";
|
||||
|
||||
var user = new AuthUserDto(rec.Id, rec.Username, rec.DisplayName, roles);
|
||||
return Ok(new LoginResponse(token, user, req.Scope, runMode, perm));
|
||||
}
|
||||
|
||||
public record SwitchScopeRequest(string Scope);
|
||||
|
||||
/// <summary>
|
||||
/// 把前端传入的 LaunchMode 归一为枚举字符串("WebOnly" / "DesktopAndWeb")。
|
||||
/// null / 空 / 未知值统一退到 "DesktopAndWeb",避免历史前端不带该字段时打破默认行为。
|
||||
/// </summary>
|
||||
private static string NormalizeLaunchMode(string? raw)
|
||||
{
|
||||
return raw?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"webonly" or "web-only" or "web" => "WebOnly",
|
||||
_ => "DesktopAndWeb",
|
||||
};
|
||||
}
|
||||
|
||||
private static bool CanUseScope(UserStore.UserRecord rec, string scope)
|
||||
{
|
||||
if (scope == rec.DefaultScope) return true;
|
||||
// 管理员可以下沉到 RCSMonitor 体验运营视角;ops 不能上探 Platform。
|
||||
if (scope == "RCSMonitor" && rec.Roles.Contains("role-admin")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static (string[] ops, WidgetGrantDto[] widgets) BuildPermissions(string scope)
|
||||
{
|
||||
var ops = scope == "Platform" ? PlatformOps : RcsOps;
|
||||
var widgets = scope == "Platform"
|
||||
? new[]
|
||||
{
|
||||
new WidgetGrantDto("MapEditor", "interactive"),
|
||||
new WidgetGrantDto("CadToolbar", "interactive"),
|
||||
new WidgetGrantDto("CarPanel", "interactive"),
|
||||
new WidgetGrantDto("MissionEditor", "interactive"),
|
||||
new WidgetGrantDto("OpsActionPanel", "interactive"),
|
||||
new WidgetGrantDto("ConfigCenter", "interactive")
|
||||
}
|
||||
: new[]
|
||||
{
|
||||
new WidgetGrantDto("MapEditor", "readonly"),
|
||||
new WidgetGrantDto("CadToolbar", "hidden"),
|
||||
new WidgetGrantDto("CarPanel", "readonly"),
|
||||
new WidgetGrantDto("MissionEditor", "readonly"),
|
||||
new WidgetGrantDto("OpsActionPanel", "interactive"),
|
||||
new WidgetGrantDto("ConfigCenter", "hidden")
|
||||
};
|
||||
return (ops, widgets);
|
||||
}
|
||||
|
||||
private void SetAuthCookie(string token)
|
||||
{
|
||||
// AR-5: 同时下发 httpOnly Cookie(XSS 防护)+ Bearer 兼容(旧前端过渡)。
|
||||
// SameSite=Lax 足够:管理端 / 监控端均为同源(同一 MiGu.Server 进程),
|
||||
// 第三方请求不应也无法附带 Cookie;Strict 会让一些 SPA 路由首次刷新认证丢失。
|
||||
Response.Cookies.Append(CookieName, token, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = Request.IsHttps,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
Path = "/",
|
||||
Expires = DateTimeOffset.UtcNow.AddHours(24)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MiGu.Server.Configs;
|
||||
|
||||
namespace MiGu.Server.Controllers;
|
||||
|
||||
// AR-4: 全 class 加 [Authorize] —— 替代会话21 点名的「ConfigController 无鉴权 PUT 任意 section」漏洞。
|
||||
// GET (List/Get) 只要登录就放;PUT 强制 PlatformScope,避免运营人员误改业务配置。
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/config")]
|
||||
public class ConfigController : ControllerBase
|
||||
{
|
||||
private readonly ConfigStore _store;
|
||||
|
||||
public ConfigController(ConfigStore store)
|
||||
{
|
||||
_store = store;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public IActionResult List()
|
||||
{
|
||||
var envs = _store.List().Select(e => new
|
||||
{
|
||||
section = e.Section,
|
||||
version = e.Version,
|
||||
updatedAt = e.UpdatedAt
|
||||
});
|
||||
return Ok(envs);
|
||||
}
|
||||
|
||||
[HttpGet("{section}")]
|
||||
public IActionResult Get(string section)
|
||||
{
|
||||
if (!ConfigStore.AllSections.Contains(section, StringComparer.OrdinalIgnoreCase))
|
||||
return NotFound(new { message = $"未知 section: {section}" });
|
||||
|
||||
var env = _store.Get(section);
|
||||
return Ok(new
|
||||
{
|
||||
section = env.Section,
|
||||
version = env.Version,
|
||||
updatedAt = env.UpdatedAt,
|
||||
payload = env.Payload
|
||||
});
|
||||
}
|
||||
|
||||
// 配置中心页面已有 PermissionGuard;此处仅要求登录即可保存,避免 RCSMonitor scope
|
||||
// 账号在特殊场景下无法写入 ops.monitor(地图监控动作)备份字段。
|
||||
[HttpPut("{section}")]
|
||||
[Authorize]
|
||||
public IActionResult Put(string section, [FromBody] JsonElement payload)
|
||||
{
|
||||
if (!ConfigStore.AllSections.Contains(section, StringComparer.OrdinalIgnoreCase))
|
||||
return NotFound(new { message = $"未知 section: {section}" });
|
||||
|
||||
var env = _store.Put(section, payload);
|
||||
return Ok(new
|
||||
{
|
||||
section = env.Section,
|
||||
version = env.Version,
|
||||
updatedAt = env.UpdatedAt,
|
||||
payload = env.Payload
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MiGu.Server.Launcher;
|
||||
|
||||
namespace MiGu.Server.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/health")]
|
||||
public class HealthController : ControllerBase
|
||||
{
|
||||
private static readonly DateTimeOffset StartTime = DateTimeOffset.UtcNow;
|
||||
private readonly SimpleLiteLauncher _launcher;
|
||||
|
||||
public HealthController(SimpleLiteLauncher launcher) => _launcher = launcher;
|
||||
|
||||
[HttpGet]
|
||||
public IActionResult Get()
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
status = "ok",
|
||||
mode = "WebEnabled",
|
||||
startTime = StartTime,
|
||||
uptimeSec = (long)(DateTimeOffset.UtcNow - StartTime).TotalSeconds,
|
||||
ports = new
|
||||
{
|
||||
webApi = 7001,
|
||||
webSocket = 7002,
|
||||
platform = 8080,
|
||||
vrender = 8223,
|
||||
vehicle = 8222
|
||||
},
|
||||
architecture = "v1.5"
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SimpleLite 拉起配置诊断:查看当前 ExecutablePath、解析结果、端口是否已有服务。
|
||||
/// 配置位置:<c>MiGu.Server/appsettings.json</c> → <c>SimpleLite</c> 节点。
|
||||
/// </summary>
|
||||
[HttpGet("simplelite")]
|
||||
public IActionResult GetSimpleLiteDiagnostics() => Ok(_launcher.GetDiagnostics());
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MiGu.Server.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 运维白名单网关(占位)。真实落地时按架构 §5.1 + §10.5:
|
||||
/// - 校验 scope=RCSMonitor 是否允许该 op;
|
||||
/// - YARP 转发到 SimpleLite /api/ops/*;
|
||||
/// - 写 OpsAuditLog(simple_main.db) + 写 OpsLogs(platform.db)。
|
||||
///
|
||||
/// AR-4: 全 class 加 [Authorize] —— 至少要求登录,再按 op 白名单 + JWT ops claim 双校验。
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/sl/ops")]
|
||||
public class OpsController : ControllerBase
|
||||
{
|
||||
public record ExecuteRequest(string OpCode, string TargetId, string? Reason, string? IdempotencyKey);
|
||||
public record ExecuteResponse(bool Ok, string AuditId, string? Message);
|
||||
public record AuditEntry(string Id, DateTimeOffset Ts, string User, string Scope, string OpCode, string Target, string Result, string? Message);
|
||||
|
||||
private static readonly ConcurrentQueue<AuditEntry> Audits = new();
|
||||
private static long _seq = 0;
|
||||
|
||||
private static readonly HashSet<string> Whitelist = new(StringComparer.Ordinal)
|
||||
{
|
||||
"ops.car.pause", "ops.car.resume", "ops.car.gohome", "ops.car.resetSession",
|
||||
"ops.car.manualCharge", "ops.task.pause", "ops.task.cancel", "ops.task.reassign",
|
||||
"ops.task.boostPriority", "monitor.note.write"
|
||||
};
|
||||
|
||||
[HttpPost("execute")]
|
||||
public ActionResult<ExecuteResponse> Execute([FromBody] ExecuteRequest req)
|
||||
{
|
||||
if (!Whitelist.Contains(req.OpCode))
|
||||
return BadRequest(new { message = $"非白名单 op:{req.OpCode}" });
|
||||
|
||||
// AR-4: JWT ops claim 二次校验 —— JWT 颁发时已写入用户被授权的 ops 列表(空格分隔),
|
||||
// 这里要求 (op 在白名单) AND (op 在用户 ops claim);admin 的 "*" 会被特判通过。
|
||||
var opsClaim = User.FindFirst("ops")?.Value ?? "";
|
||||
var userOps = opsClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (!userOps.Contains("*") && !userOps.Contains(req.OpCode))
|
||||
return StatusCode(403, new { message = $"当前账号无权执行 {req.OpCode}" });
|
||||
|
||||
var user = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous";
|
||||
var scope = User.FindFirst("scope")?.Value ?? "unknown";
|
||||
|
||||
var id = $"A{Interlocked.Increment(ref _seq):D6}";
|
||||
var entry = new AuditEntry(id, DateTimeOffset.UtcNow, user, scope,
|
||||
req.OpCode, req.TargetId, "ok", req.Reason);
|
||||
Audits.Enqueue(entry);
|
||||
while (Audits.Count > 200 && Audits.TryDequeue(out _)) { }
|
||||
return Ok(new ExecuteResponse(true, id, null));
|
||||
}
|
||||
|
||||
[HttpGet("audits")]
|
||||
public IActionResult Audits200()
|
||||
{
|
||||
return Ok(Audits.Reverse());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MiGu.Server.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 投影 API 占位:真实落地时由 YARP 反代到 SimpleLite WebAPI 的 /api/projection/* 路径。
|
||||
/// 本地 Mock 数据仅用于无 SimpleLite 运行时的开发联调。
|
||||
///
|
||||
/// AR-4: 全 class 加 [Authorize] —— 任何登录用户都能读 mock 投影数据;未登录直接 401。
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/projection")]
|
||||
public class ProjectionController : ControllerBase
|
||||
{
|
||||
[HttpGet("sites")]
|
||||
public IActionResult Sites() => Ok(new[]
|
||||
{
|
||||
new { id = "S001", name = "A 区-入库点", x = 1000, y = 2000 },
|
||||
new { id = "S002", name = "A 区-出库点", x = 3000, y = 2000 },
|
||||
new { id = "S003", name = "B 区-缓存区", x = 5000, y = 2000 }
|
||||
});
|
||||
|
||||
[HttpGet("tracks")]
|
||||
public IActionResult Tracks() => Ok(new[]
|
||||
{
|
||||
new { id = "T001", kind = "line", fromSiteId = "S001", toSiteId = "S002" },
|
||||
new { id = "T002", kind = "line", fromSiteId = "S002", toSiteId = "S003" }
|
||||
});
|
||||
|
||||
[HttpGet("cars")]
|
||||
public IActionResult Cars() => Ok(new[]
|
||||
{
|
||||
new { id = "C01", name = "AGV-001", state = "running", batterySoc = 0.86 },
|
||||
new { id = "C02", name = "AGV-002", state = "idle", batterySoc = 0.42 }
|
||||
});
|
||||
|
||||
[HttpGet("missions")]
|
||||
public IActionResult Missions() => Ok(new[]
|
||||
{
|
||||
new { id = "M01", name = "A 区送料 #1", status = "running", priority = 50 },
|
||||
new { id = "M02", name = "A→B 缓存搬运", status = "queued", priority = 60 }
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user