运维网关支持按车辆配置方法真实下发,并按用户过滤审计。

新增 ops.car.execute 路径与前端运维操作/选中面板联动。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
黄兆尉
2026-08-26 17:50:34 +08:00
co-authored by Cursor
parent c5d5f7a726
commit c9e835aaf7
7 changed files with 681 additions and 217 deletions
+262 -44
View File
@@ -17,34 +17,58 @@ namespace MiGu.Server.Controllers;
/// M4 修复(运维操作真实下发 + 审计落库):
/// - 旧实现只把动作塞进静态内存队列、永远回 success=true,是「假操作」(前端显示
/// “暂停成功”但内核毫无反应,且重启审计全丢);
/// - 现在:命中 <c>Ops:Dispatch</c> 映射的 op 会**真实转发**到 SimpleLite 反射 execute
/// - 现在:命中 <c>Ops:Dispatch</c> 映射的 op 会**真实转发**到 Simple3 反射 execute
/// 按内核返回如实记成功 / 失败;未映射的 op 不再假成功,明确回「未下发」;
/// - 审计统一经 <see cref="OpsAuditStore"/> 落盘(重启不丢)。
///
/// 关于映射:运营语义(暂停 / 恢复 / 回库 / 重置会话 / 手动充电)与 SimpleLite 内核反射
/// 方法(OnlineCar/OfflineCar/Repair/Blown/Reset… 见 Car.cs <c>[MethodMember]</c>)并非
/// 一一对应。为避免「猜错方法名 → 误操作车辆」,默认不预置车辆映射,由部署方在
/// appsettings.json <c>Ops:Dispatch</c> 显式配置 <c>"opCode": "kind:Method"</c> 后即真实下发
/// 关于映射:
/// - 地图监控选中车辆:<c>ops.car.execute</c> 按管理端「运营维护」<c>carActionByType</c>
/// 勾选的 Simple3 方法下发(与管理端同一份 monitor-config)。
/// - 运维操作页的 pause/resume/gohome 等仍走 <c>Ops:Dispatch</c> 显式映射
/// </summary>
[ApiController]
[Authorize]
[Route("api/sl/ops")]
public class OpsController : ControllerBase
{
public record ExecuteRequest(string OpCode, string TargetId, string? Reason, string? IdempotencyKey);
public const string CarExecuteOp = "ops.car.execute";
public record ExecuteRequest(
string OpCode,
string TargetId,
string? Reason,
string? IdempotencyKey,
string? Method,
Dictionary<string, string>? Params,
int? SiteId);
public record ExecuteResponse(bool Ok, string AuditId, string? Message);
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.car.manualCharge", CarExecuteOp,
"ops.task.pause", "ops.task.cancel", "ops.task.reassign",
"ops.task.boostPriority", "monitor.note.write"
};
private static readonly HashSet<string> GotoMethods = new(StringComparer.OrdinalIgnoreCase)
{
"Goto", "GotoSite", "WebGotoSite"
};
/// <summary>
/// 地图监控车辆列表快捷动作(上线/下线/结束任务等),与 carActionByType 勾选并集。
/// </summary>
private static readonly HashSet<string> 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 SimpleLiteOptions _sl;
private readonly Simple3Options _sl;
private readonly ILogger<OpsController> _log;
private readonly IReadOnlyDictionary<string, (string Kind, string Method)> _dispatch;
@@ -52,7 +76,7 @@ public class OpsController : ControllerBase
OpsAuditStore audits,
IHttpClientFactory httpFactory,
InternalTokenStore internalToken,
IOptions<SimpleLiteOptions> sl,
IOptions<Simple3Options> sl,
IConfiguration config,
ILogger<OpsController> log)
{
@@ -91,10 +115,10 @@ public class OpsController : ControllerBase
// AR-4JWT ops claim 二次校验 —— (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))
if (!CanRunOp(userOps, req.OpCode, User.FindFirst("scope")?.Value))
return StatusCode(403, new { message = $"当前账号无权执行 {req.OpCode}" });
var user = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous";
var user = CurrentUsername();
var scope = User.FindFirst("scope")?.Value ?? "unknown";
// 幂等:同一 IdempotencyKey 若已有成功审计,直接复用上次结果,避免前端重试 / 双击造成重复下发与重复审计。
@@ -109,10 +133,13 @@ public class OpsController : ControllerBase
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} 尚未绑定 SimpleLite 内核方法,已记录审计但未下发。" +
$"运维动作 {req.OpCode} 尚未绑定 Simple3 内核方法,已记录审计但未下发。" +
$"请在 appsettings.json 的 Ops:Dispatch 配置 \"{req.OpCode}\": \"kind:Method\"。",
ok: false));
@@ -120,41 +147,62 @@ public class OpsController : ControllerBase
if (numericId is null)
return Ok(Done(user, scope, req, "failed", $"目标 ID『{req.TargetId}』无法解析为数字", ok: false));
// M4:真实转发到 SimpleLite 反射 execute(与前端 reflectionApi.execute 同路径,本机直连 8222)。
string result;
string? message;
try
{
var url = $"http://127.0.0.1:{_sl.ProjectionPort}/projection/reflection/execute/" +
$"{map.Kind}/{numericId}/{Uri.EscapeDataString(map.Method)}";
using var client = _httpFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(8);
using var msg = new HttpRequestMessage(HttpMethod.Post, url);
// 即使 SimpleLite 默认放行 loopback,也带上 internal token,兼容其严格模式(AllowLoopback=false)。
if (!string.IsNullOrEmpty(_internalToken.Token))
msg.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", _internalToken.Token);
// 运维面板已对 needConfirm 动作做过二次确认;内核 RequiresPlatformConfirm 方法需此头。
msg.Headers.TryAddWithoutValidation("X-Platform-Confirmed", "1");
if (!string.IsNullOrWhiteSpace(user))
msg.Headers.TryAddWithoutValidation("X-Platform-User", user);
using var resp = await client.SendAsync(msg);
var body = await resp.Content.ReadAsStringAsync();
var success = resp.IsSuccessStatusCode && ParseSuccess(body);
result = success ? "ok" : "failed";
message = success ? null : $"SimpleLite 返回 {(int)resp.StatusCode}{ExtractMessage(body)}";
}
catch (Exception ex)
{
result = "failed";
message = $"下发 SimpleLite 失败:{ex.GetType().Name}: {ex.Message}";
_log.LogWarning(ex, "ops execute 转发失败 op={Op} target={Target}", req.OpCode, req.TargetId);
}
return Ok(Done(user, scope, req, result, message, ok: result == "ok"));
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));
}
/// <summary>
/// 地图监控:按管理端 monitor-config.carActionByType 勾选的方法下发。
/// 账号需具备任意 <c>ops.car.*</c>(或 *);方法名必须落在该车型已配置白名单内。
/// </summary>
private async Task<ExecuteResponse> 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}";
}
/// <summary>只返回当前登录人的操作记录,不提供全员查询参数。</summary>
[HttpGet("audits")]
public IActionResult Audits200() => Ok(_audits.Recent());
public IActionResult Audits200() => Ok(_audits.Recent(CurrentUsername()));
/// <summary>写一条审计并组装响应(成功时审计落 Reason,失败 / 未下发落具体 message)。</summary>
private ExecuteResponse Done(string user, string scope, ExecuteRequest req, string result, string? message, bool ok = true)
@@ -163,6 +211,14 @@ public class OpsController : ControllerBase
return new ExecuteResponse(ok, entry.Id, message);
}
/// <summary>与 YARP 注入 X-Platform-User 同一套取值:登录名,不是用户 id。</summary>
private string CurrentUsername() =>
User.FindFirst("unique_name")?.Value
?? User.Identity?.Name
?? User.FindFirstValue(ClaimTypes.Name)
?? User.FindFirstValue(ClaimTypes.NameIdentifier)
?? "anonymous";
/// <summary>
/// 前端可能传 "C01" / "M03" / "5",抽取「首段」连续数字作为内核对象 id。
/// 取首段而非拼接所有数字,避免 "AGV-12-3" 被误合并成 123。
@@ -195,4 +251,166 @@ public class OpsController : ControllerBase
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<HashSet<string>?> 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<string, string[]> ReadCarActionByType(JsonElement envelope)
{
var map = new Dictionary<string, string[]>(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<string> LookupCarMethods(Dictionary<string, string[]> 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<string>(exact, StringComparer.Ordinal);
if (map.TryGetValue(shortType, out var byType) && byType.Length > 0)
return new HashSet<string>(byType, StringComparer.Ordinal);
if (map.TryGetValue(shortName, out var byShort) && byShort.Length > 0)
return new HashSet<string>(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<string>(kv.Value, StringComparer.Ordinal);
if (!string.IsNullOrEmpty(fullType) && (fullType.EndsWith('.' + k, StringComparison.Ordinal) || k.EndsWith('.' + shortName, StringComparison.Ordinal)))
return new HashSet<string>(kv.Value, StringComparer.Ordinal);
if (!string.IsNullOrEmpty(shortName) && k.EndsWith('.' + shortName, StringComparison.Ordinal))
return new HashSet<string>(kv.Value, StringComparer.Ordinal);
}
return new HashSet<string>(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<JsonElement?> 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<ForwardOutcome> 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<ForwardOutcome> ForwardExecuteAsync(
string kind, int id, string method, Dictionary<string, string>? 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<ForwardOutcome> 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);
}
}