using System.Security.Claims; using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using MiGu.Server.Auth; using MiGu.Server.Configs; using MiGu.Server.Launcher; namespace MiGu.Server.Controllers; /// /// 运维白名单网关。运营端(RCSMonitor)通过本控制器执行受控运维动作。 /// /// AR-4:[Authorize] 要求登录,再按 op 白名单 + JWT ops claim 双校验。 /// /// M4 修复(运维操作真实下发 + 审计落库): /// - 旧实现只把动作塞进静态内存队列、永远回 success=true,是「假操作」(前端显示 /// “暂停成功”但内核毫无反应,且重启审计全丢); /// - 现在:命中 Ops:Dispatch 映射的 op 会**真实转发**到 Simple3 反射 execute, /// 按内核返回如实记成功 / 失败;未映射的 op 不再假成功,明确回「未下发」; /// - 审计统一经 落盘(重启不丢)。 /// /// 关于映射: /// - 地图监控选中车辆:ops.car.execute 按管理端「运营维护」carActionByType /// 勾选的 Simple3 方法下发(与管理端同一份 monitor-config)。 /// - 运维操作页的 pause/resume/gohome 等仍走 Ops:Dispatch 显式映射。 /// [ApiController] [Authorize] [Route("api/sl/ops")] public class OpsController : ControllerBase { public const string CarExecuteOp = "ops.car.execute"; public record ExecuteRequest( string OpCode, string TargetId, string? Reason, string? IdempotencyKey, string? Method, Dictionary? Params, int? SiteId); public record ExecuteResponse(bool Ok, string AuditId, string? Message); private static readonly HashSet Whitelist = new(StringComparer.Ordinal) { "ops.car.pause", "ops.car.resume", "ops.car.gohome", "ops.car.resetSession", "ops.car.manualCharge", CarExecuteOp, "ops.task.pause", "ops.task.cancel", "ops.task.reassign", "ops.task.boostPriority", "monitor.note.write" }; private static readonly HashSet GotoMethods = new(StringComparer.OrdinalIgnoreCase) { "Goto", "GotoSite", "WebGotoSite" }; /// /// 地图监控车辆列表快捷动作(上线/下线/结束任务等),与 carActionByType 勾选并集。 /// private static readonly HashSet MapMonitorQuickMethods = new(StringComparer.Ordinal) { "OnlineCar", "OfflineCar", "DisableCar", "EnableCar", "ForceStopUI", "ForceStop", "UIIntercept" }; private readonly OpsAuditStore _audits; private readonly IHttpClientFactory _httpFactory; private readonly InternalTokenStore _internalToken; private readonly Simple3Options _sl; private readonly ILogger _log; private readonly IReadOnlyDictionary _dispatch; public OpsController( OpsAuditStore audits, IHttpClientFactory httpFactory, InternalTokenStore internalToken, IOptions sl, IConfiguration config, ILogger log) { _audits = audits; _httpFactory = httpFactory; _internalToken = internalToken; _sl = sl.Value; _log = log; _dispatch = LoadDispatch(config); } /// 从 appsettings Ops:Dispatch 读取 opCode → "kind:Method" 映射(忽略空值与 _ 注释键)。 private static IReadOnlyDictionary LoadDispatch(IConfiguration config) { var map = new Dictionary(StringComparer.Ordinal); foreach (var kv in config.GetSection("Ops:Dispatch").GetChildren()) { var op = kv.Key; var spec = kv.Value; if (op.StartsWith('_') || string.IsNullOrWhiteSpace(spec)) continue; var parts = spec.Split(':', 2, StringSplitOptions.TrimEntries); if (parts.Length == 2 && parts[0].Length > 0 && parts[1].Length > 0) map[op] = (parts[0], parts[1]); } return map; } [HttpPost("execute")] public async Task> Execute([FromBody] ExecuteRequest req) { if (req is null || string.IsNullOrWhiteSpace(req.OpCode)) return BadRequest(new { message = "opCode 不能为空" }); if (!Whitelist.Contains(req.OpCode)) return BadRequest(new { message = $"非白名单 op:{req.OpCode}" }); // AR-4:JWT ops claim 二次校验 —— (op 在白名单) AND (op 在用户 ops claim);admin 的 "*" 特判通过。 var opsClaim = User.FindFirst("ops")?.Value ?? ""; var userOps = opsClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries); if (!CanRunOp(userOps, req.OpCode, User.FindFirst("scope")?.Value)) return StatusCode(403, new { message = $"当前账号无权执行 {req.OpCode}" }); var user = CurrentUsername(); var scope = User.FindFirst("scope")?.Value ?? "unknown"; // 幂等:同一 IdempotencyKey 若已有成功审计,直接复用上次结果,避免前端重试 / 双击造成重复下发与重复审计。 if (!string.IsNullOrWhiteSpace(req.IdempotencyKey)) { var dup = _audits.FindSuccessByIdempotencyKey(req.IdempotencyKey); if (dup is not null) return Ok(new ExecuteResponse(true, dup.Id, dup.Message ?? "幂等命中:已执行过相同请求,未重复下发")); } // monitor.note.write:运营备注,非内核动作,仅审计。 if (req.OpCode == "monitor.note.write") return Ok(Done(user, scope, req, "ok", req.Reason)); if (req.OpCode == CarExecuteOp) return Ok(await ExecuteConfiguredCarMethodAsync(user, scope, req)); // 未配置映射:不再「假成功」,如实告知未下发(在 appsettings Ops:Dispatch 绑定后即真实生效)。 if (!_dispatch.TryGetValue(req.OpCode, out var map)) return Ok(Done(user, scope, req, "unmapped", $"运维动作 {req.OpCode} 尚未绑定 Simple3 内核方法,已记录审计但未下发。" + $"请在 appsettings.json 的 Ops:Dispatch 配置 \"{req.OpCode}\": \"kind:Method\"。", ok: false)); var numericId = ExtractNumericId(req.TargetId); if (numericId is null) return Ok(Done(user, scope, req, "failed", $"目标 ID『{req.TargetId}』无法解析为数字", ok: false)); var forwarded = await ForwardExecuteAsync(map.Kind, numericId.Value, map.Method, null, user); return Ok(Done(user, scope, req, forwarded.Result, forwarded.Message, ok: forwarded.Ok)); } /// /// 地图监控:按管理端 monitor-config.carActionByType 勾选的方法下发。 /// 账号需具备任意 ops.car.*(或 *);方法名必须落在该车型已配置白名单内。 /// private async Task ExecuteConfiguredCarMethodAsync(string user, string scope, ExecuteRequest req) { var method = (req.Method ?? "").Trim(); if (!IsSafeMethodName(method)) return Done(user, scope, req, "failed", "方法名无效", ok: false); var numericId = ExtractNumericId(req.TargetId); if (numericId is null) return Done(user, scope, req, "failed", $"目标 ID『{req.TargetId}』无法解析为数字", ok: false); var allowed = await LoadAllowedCarMethodsAsync(numericId.Value); if (allowed is null) return Done(user, scope, req, "failed", "无法读取管理端车辆动作配置(monitor-config)", ok: false); allowed.UnionWith(MapMonitorQuickMethods); if (allowed.Count == 0) return Done(user, scope, req, "unmapped", "当前车型未配置动作。请在管理端「配置中心 → 运营维护」按车型勾选。", ok: false); if (!allowed.Contains(method)) return Done(user, scope, req, "failed", $"方法 {method} 不在该车型已配置的动作列表中", ok: false); var siteId = req.SiteId; if (siteId is null && req.Params is not null && req.Params.TryGetValue("siteId", out var rawSite) && int.TryParse(rawSite, out var parsedSite)) siteId = parsedSite; if (siteId is int sid && GotoMethods.Contains(method)) { var gotoResult = await ForwardGotoSiteAsync(numericId.Value, sid, user); if (gotoResult.Ok || !string.Equals(method, "WebGotoSite", StringComparison.OrdinalIgnoreCase)) return Done(user, scope, req, gotoResult.Result, CarAuditMessage(method, gotoResult.Ok ? $"前往站点 {sid}" : gotoResult.Message, gotoResult.Ok), ok: gotoResult.Ok); } var forwarded = await ForwardExecuteAsync("car", numericId.Value, method, req.Params, user); return Done(user, scope, req, forwarded.Result, CarAuditMessage(method, forwarded.Message, forwarded.Ok), ok: forwarded.Ok); } private static string CarAuditMessage(string method, string? detail, bool ok) { if (!ok) return string.IsNullOrWhiteSpace(detail) ? $"执行 {method} 失败" : detail; return string.IsNullOrWhiteSpace(detail) ? $"执行 {method}" : $"{method}:{detail}"; } /// 只返回当前登录人的操作记录,不提供全员查询参数。 [HttpGet("audits")] public IActionResult Audits200() => Ok(_audits.Recent(CurrentUsername())); /// 写一条审计并组装响应(成功时审计落 Reason,失败 / 未下发落具体 message)。 private ExecuteResponse Done(string user, string scope, ExecuteRequest req, string result, string? message, bool ok = true) { var entry = _audits.Append(user, scope, req.OpCode, req.TargetId, result, message ?? req.Reason, req.IdempotencyKey); return new ExecuteResponse(ok, entry.Id, message); } /// 与 YARP 注入 X-Platform-User 同一套取值:登录名,不是用户 id。 private string CurrentUsername() => User.FindFirst("unique_name")?.Value ?? User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.Name) ?? User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous"; /// /// 前端可能传 "C01" / "M03" / "5",抽取「首段」连续数字作为内核对象 id。 /// 取首段而非拼接所有数字,避免 "AGV-12-3" 被误合并成 123。 /// private static int? ExtractNumericId(string? raw) { if (string.IsNullOrWhiteSpace(raw)) return null; var m = System.Text.RegularExpressions.Regex.Match(raw, @"\d+"); return m.Success && int.TryParse(m.Value, out var n) ? n : null; } private static bool ParseSuccess(string body) { try { using var doc = JsonDocument.Parse(body); return doc.RootElement.TryGetProperty("success", out var s) && s.ValueKind == JsonValueKind.True; } catch { return false; } } private static string ExtractMessage(string body) { try { using var doc = JsonDocument.Parse(body); if (doc.RootElement.TryGetProperty("message", out var m) && m.ValueKind == JsonValueKind.String) return m.GetString() ?? ""; } catch { /* ignore,下面回退裁剪原文 */ } return body.Length <= 200 ? body : body[..200] + "…"; } private static bool CanRunOp(string[] userOps, string opCode, string? scope) { if (userOps.Contains("*") || userOps.Contains(opCode)) return true; if (opCode == CarExecuteOp && userOps.Any(o => o.StartsWith("ops.car.", StringComparison.Ordinal))) return true; // 管理面地图监控原先可直调反射;走网关只为记审计,不额外收权。 return opCode == CarExecuteOp && string.Equals(scope, PageCatalog.ScopePlatform, StringComparison.OrdinalIgnoreCase); } private static bool IsSafeMethodName(string method) => method.Length is > 0 and <= 64 && method.All(c => char.IsAsciiLetterOrDigit(c) || c == '_'); private readonly record struct ForwardOutcome(bool Ok, string Result, string? Message); private async Task?> LoadAllowedCarMethodsAsync(int carId) { try { var bundle = await GetLiteJsonAsync($"/projection/reflection/bundle/car/{carId}"); var config = await GetLiteJsonAsync("/projection/reflection/monitor-config"); if (bundle is null || config is null) return null; var typeName = ReadString(bundle, "typeName") ?? ""; var fullTypeName = ReadString(bundle, "fullTypeName") ?? typeName; var map = ReadCarActionByType(config.Value); return LookupCarMethods(map, fullTypeName, typeName); } catch (Exception ex) { _log.LogWarning(ex, "读取 monitor-config 失败 car={CarId}", carId); return null; } } private static Dictionary ReadCarActionByType(JsonElement envelope) { var map = new Dictionary(StringComparer.Ordinal); if (!TryData(envelope, out var data)) return map; if (!data.TryGetProperty("config", out var cfg) || cfg.ValueKind != JsonValueKind.Object) return map; if (!cfg.TryGetProperty("carActionByType", out var cat) || cat.ValueKind != JsonValueKind.Object) return map; foreach (var prop in cat.EnumerateObject()) { if (prop.Value.ValueKind != JsonValueKind.Array) continue; map[prop.Name] = prop.Value.EnumerateArray() .Where(x => x.ValueKind == JsonValueKind.String) .Select(x => x.GetString() ?? "") .Where(x => x.Length > 0) .ToArray(); } return map; } private static HashSet LookupCarMethods(Dictionary map, string fullType, string shortType) { var shortName = shortType; var dot = shortType.LastIndexOf('.'); if (dot >= 0 && dot < shortType.Length - 1) shortName = shortType[(dot + 1)..]; if (map.TryGetValue(fullType, out var exact) && exact.Length > 0) return new HashSet(exact, StringComparer.Ordinal); if (map.TryGetValue(shortType, out var byType) && byType.Length > 0) return new HashSet(byType, StringComparer.Ordinal); if (map.TryGetValue(shortName, out var byShort) && byShort.Length > 0) return new HashSet(byShort, StringComparer.Ordinal); foreach (var kv in map) { if (kv.Value.Length == 0) continue; var k = kv.Key; if (k == fullType || k == shortType || k == shortName) return new HashSet(kv.Value, StringComparer.Ordinal); if (!string.IsNullOrEmpty(fullType) && (fullType.EndsWith('.' + k, StringComparison.Ordinal) || k.EndsWith('.' + shortName, StringComparison.Ordinal))) return new HashSet(kv.Value, StringComparer.Ordinal); if (!string.IsNullOrEmpty(shortName) && k.EndsWith('.' + shortName, StringComparison.Ordinal)) return new HashSet(kv.Value, StringComparer.Ordinal); } return new HashSet(StringComparer.Ordinal); } private static bool TryData(JsonElement envelope, out JsonElement data) { if (envelope.TryGetProperty("data", out data) && data.ValueKind == JsonValueKind.Object) return true; data = envelope; return envelope.ValueKind == JsonValueKind.Object; } private static string? ReadString(JsonElement? envelope, string name) { if (envelope is null) return null; var root = envelope.Value; if (TryData(root, out var data) && data.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String) return v.GetString(); if (root.TryGetProperty(name, out var direct) && direct.ValueKind == JsonValueKind.String) return direct.GetString(); return null; } private async Task GetLiteJsonAsync(string path) { var call = await CallLiteAsync(HttpMethod.Get, path, actor: null); if (!call.Ok) return null; using var doc = JsonDocument.Parse(call.Body); return doc.RootElement.Clone(); } private async Task ForwardGotoSiteAsync(int carId, int siteId, string actor) { var path = $"/projection/reflection/car/{carId}/goto-site?siteId={siteId}"; return await SendLiteExecuteAsync(path, actor); } private async Task ForwardExecuteAsync( string kind, int id, string method, Dictionary? query, string actor) { var path = $"/projection/reflection/execute/{kind}/{id}/{Uri.EscapeDataString(method)}"; if (query is { Count: > 0 }) { var qs = string.Join("&", query .Where(kv => !string.IsNullOrEmpty(kv.Key)) .Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value ?? "")}")); if (qs.Length > 0) path += "?" + qs; } return await SendLiteExecuteAsync(path, actor); } private async Task SendLiteExecuteAsync(string path, string actor) { try { var call = await CallLiteAsync(HttpMethod.Post, path, actor); var success = call.Ok && ParseSuccess(call.Body); return success ? new ForwardOutcome(true, "ok", null) : new ForwardOutcome(false, "failed", $"Simple3 返回 {call.Status}:{ExtractMessage(call.Body)}"); } catch (Exception ex) { _log.LogWarning(ex, "ops 转发 Simple3 失败 path={Path}", path); return new ForwardOutcome(false, "failed", $"下发 Simple3 失败:{ex.GetType().Name}: {ex.Message}"); } } private async Task<(bool Ok, int Status, string Body)> CallLiteAsync(HttpMethod method, string path, string? actor) { var url = $"http://127.0.0.1:{_sl.ProjectionPort}{path}"; using var client = _httpFactory.CreateClient(); client.Timeout = TimeSpan.FromSeconds(8); using var msg = new HttpRequestMessage(method, url); if (!string.IsNullOrEmpty(_internalToken.Token)) msg.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", _internalToken.Token); if (method == HttpMethod.Post) msg.Headers.TryAddWithoutValidation("X-Platform-Confirmed", "1"); if (!string.IsNullOrWhiteSpace(actor)) msg.Headers.TryAddWithoutValidation("X-Platform-User", actor); using var resp = await client.SendAsync(msg); var body = await resp.Content.ReadAsStringAsync(); return (resp.IsSuccessStatusCode, (int)resp.StatusCode, body); } }