diff --git a/MiGu.Server/Configs/OpsAuditStore.cs b/MiGu.Server/Configs/OpsAuditStore.cs new file mode 100644 index 0000000..dbf826e --- /dev/null +++ b/MiGu.Server/Configs/OpsAuditStore.cs @@ -0,0 +1,128 @@ +using System.Text.Json; +using MiGu.Server.Infra; + +namespace MiGu.Server.Configs; + +/// +/// 运维操作审计存储:内存队列 + data/ops-audit.json 原子持久化。 +/// +/// 取代 OpsController 旧的「纯静态 ConcurrentQueue」——那种实现进程一重启审计全丢, +/// 且无法满足「运维动作可追溯」的合规诉求。这里启动时回载历史,写入走原子落盘, +/// 仅保留最近 条避免无限增长。 +/// +public sealed class OpsAuditStore +{ + public sealed record AuditEntry( + string Id, + DateTimeOffset Ts, + string User, + string Scope, + string OpCode, + string Target, + string Result, + string? Message, + string? IdempotencyKey = null); + + private const int MaxEntries = 500; + + private readonly object _gate = new(); + private readonly string _file; + private readonly ILogger _logger; + private readonly List _entries = new(); + private long _seq; + + private readonly JsonSerializerOptions _json = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + public OpsAuditStore(IWebHostEnvironment env, ILogger logger) + { + _logger = logger; + var dir = Path.Combine(env.ContentRootPath, "data"); + Directory.CreateDirectory(dir); + _file = Path.Combine(dir, "ops-audit.json"); + Load(); + } + + private void Load() + { + try + { + if (!File.Exists(_file)) return; + var list = JsonSerializer.Deserialize>(File.ReadAllText(_file), _json); + if (list is { Count: > 0 }) + { + _entries.AddRange(list.Count > MaxEntries ? list.GetRange(list.Count - MaxEntries, MaxEntries) : list); + // 续上序号,避免重启后 id 从 A000001 重新开始造成重复。 + _seq = _entries.Select(e => ParseSeq(e.Id)).DefaultIfEmpty(0).Max(); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "ops 审计文件 {File} 加载失败,将以空记录开始并备份损坏文件", _file); + AtomicFile.BackupCorrupt(_file); + } + } + + private static long ParseSeq(string id) => long.TryParse(id.TrimStart('A'), out var n) ? n : 0; + + /// 追加一条审计并原子落盘。返回生成的条目(含 Id)。 + public AuditEntry Append(string user, string scope, string opCode, string target, string result, string? message, string? idempotencyKey = null) + { + lock (_gate) + { + var entry = new AuditEntry( + $"A{++_seq:D6}", DateTimeOffset.UtcNow, + user, scope, opCode, target, result, message, idempotencyKey); + _entries.Add(entry); + if (_entries.Count > MaxEntries) + _entries.RemoveRange(0, _entries.Count - MaxEntries); + Persist(); + return entry; + } + } + + /// + /// 查找指定幂等键最近一条「成功(ok)」审计,用于对重复下发去重。无则返回 null。 + /// 仅匹配成功记录:上次失败的请求允许重试重新下发。 + /// + public AuditEntry? FindSuccessByIdempotencyKey(string? key) + { + if (string.IsNullOrWhiteSpace(key)) return null; + lock (_gate) + { + for (var i = _entries.Count - 1; i >= 0; i--) + { + var e = _entries[i]; + if (e.Result == "ok" && string.Equals(e.IdempotencyKey, key, StringComparison.Ordinal)) + return e; + } + return null; + } + } + + /// 最近的审计(倒序,最新在前)。 + public IReadOnlyList Recent() + { + lock (_gate) + { + var copy = new List(_entries); + copy.Reverse(); + return copy; + } + } + + private void Persist() + { + try + { + AtomicFile.WriteAllText(_file, JsonSerializer.Serialize(_entries, _json)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "ops 审计持久化到 {File} 失败", _file); + } + } +} diff --git a/MiGu.Server/Controllers/OpsController.cs b/MiGu.Server/Controllers/OpsController.cs index ae335ff..6d11299 100644 --- a/MiGu.Server/Controllers/OpsController.cs +++ b/MiGu.Server/Controllers/OpsController.cs @@ -1,17 +1,30 @@ -using System.Collections.Concurrent; -using System.Security.Claims; +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; /// -/// 运维白名单网关(占位)。真实落地时按架构 §5.1 + §10.5: -/// - 校验 scope=RCSMonitor 是否允许该 op; -/// - YARP 转发到 SimpleLite /api/ops/*; -/// - 写 OpsAuditLog(simple_main.db) + 写 OpsLogs(platform.db)。 +/// 运维白名单网关。运营端(RCSMonitor)通过本控制器执行受控运维动作。 /// -/// AR-4: 全 class 加 [Authorize] —— 至少要求登录,再按 op 白名单 + JWT ops claim 双校验。 +/// AR-4:[Authorize] 要求登录,再按 op 白名单 + JWT ops claim 双校验。 +/// +/// M4 修复(运维操作真实下发 + 审计落库): +/// - 旧实现只把动作塞进静态内存队列、永远回 success=true,是「假操作」(前端显示 +/// “暂停成功”但内核毫无反应,且重启审计全丢); +/// - 现在:命中 Ops:Dispatch 映射的 op 会**真实转发**到 SimpleLite 反射 execute, +/// 按内核返回如实记成功 / 失败;未映射的 op 不再假成功,明确回「未下发」; +/// - 审计统一经 落盘(重启不丢)。 +/// +/// 关于映射:运营语义(暂停 / 恢复 / 回库 / 重置会话 / 手动充电)与 SimpleLite 内核反射 +/// 方法(OnlineCar/OfflineCar/Repair/Blown/Reset… 见 Car.cs [MethodMember])并非 +/// 一一对应。为避免「猜错方法名 → 误操作车辆」,默认不预置车辆映射,由部署方在 +/// appsettings.json Ops:Dispatch 显式配置 "opCode": "kind:Method" 后即真实下发。 /// [ApiController] [Authorize] @@ -20,10 +33,6 @@ 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 Audits = new(); - private static long _seq = 0; private static readonly HashSet Whitelist = new(StringComparer.Ordinal) { @@ -32,14 +41,54 @@ public class OpsController : ControllerBase "ops.task.boostPriority", "monitor.note.write" }; - [HttpPost("execute")] - public ActionResult Execute([FromBody] ExecuteRequest req) + private readonly OpsAuditStore _audits; + private readonly IHttpClientFactory _httpFactory; + private readonly InternalTokenStore _internalToken; + private readonly SimpleLiteOptions _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 二次校验 —— JWT 颁发时已写入用户被授权的 ops 列表(空格分隔), - // 这里要求 (op 在白名单) AND (op 在用户 ops claim);admin 的 "*" 会被特判通过。 + // AR-4:JWT 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)) @@ -48,17 +97,98 @@ public class OpsController : ControllerBase 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)); + // 幂等:同一 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)); + + // 未配置映射:不再「假成功」,如实告知未下发(在 appsettings Ops:Dispatch 绑定后即真实生效)。 + if (!_dispatch.TryGetValue(req.OpCode, out var map)) + return Ok(Done(user, scope, req, "unmapped", + $"运维动作 {req.OpCode} 尚未绑定 SimpleLite 内核方法,已记录审计但未下发。" + + $"请在 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)); + + // 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); + 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")); } [HttpGet("audits")] - public IActionResult Audits200() + public IActionResult Audits200() => Ok(_audits.Recent()); + + /// 写一条审计并组装响应(成功时审计落 Reason,失败 / 未下发落具体 message)。 + private ExecuteResponse Done(string user, string scope, ExecuteRequest req, string result, string? message, bool ok = true) { - return Ok(Audits.Reverse()); + var entry = _audits.Append(user, scope, req.OpCode, req.TargetId, result, message ?? req.Reason, req.IdempotencyKey); + return new ExecuteResponse(ok, entry.Id, message); + } + + /// + /// 前端可能传 "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] + "…"; } } diff --git a/MiGu.Server/appsettings.json b/MiGu.Server/appsettings.json index 21edc58..4f6b949 100644 --- a/MiGu.Server/appsettings.json +++ b/MiGu.Server/appsettings.json @@ -39,10 +39,16 @@ "ops": { "Password": "ops" } } }, + "_comment_Ops": "运维操作真实下发映射(M4)。opCode → 'kind:Method',Method 必须是 SimpleLite 反射 [MethodMember] 的真实方法名(车辆 kind=car,见 Car.cs:OnlineCar/OfflineCar/DisableCar/EnableCar/Repair/Blown/Reset 等;任务 kind=mission)。运营白名单的 pause/resume/gohome/manualCharge 内核暂无一一对应方法——留空则仅记审计并向前端如实返回『未下发』,按现场内核能力填写后即真实生效。示例: 'ops.car.gohome': 'car:Reset'", + "Ops": { + "Dispatch": { + } + }, "ReverseProxy": { "Routes": { "sl-route": { "ClusterId": "sl-cluster", + "AuthorizationPolicy": "AnyAuthed", "Match": { "Path": "/api/sl/{**catch-all}" }, "Transforms": [ { "PathRemovePrefix": "/api/sl" }