using System.Text.Json; using Microsoft.Extensions.Options; using MiGu.Server.Auth; using MiGu.Server.Launcher; using MiGu.Server.Ota; namespace MiGu.Server.Fleet; /// /// 车队健康:保留 Simple3 的 CPU/故障率等,延迟改为对 WatchDog(:9776) 做 TCP RTT。 /// Simple3 /fleet/health 探测的是车载 HTTP :8081,多数现场未开该端口会假超时 2000ms。 /// public sealed class FleetHealthService { private readonly IHttpClientFactory _httpFactory; private readonly Simple3Options _sl; private readonly InternalTokenStore _token; private readonly WatchDogClient _wd; private readonly ILogger _log; private static readonly JsonSerializerOptions JsonOpt = new() { PropertyNameCaseInsensitive = true }; public FleetHealthService( IHttpClientFactory httpFactory, IOptions sl, InternalTokenStore token, WatchDogClient wd, ILogger log) { _httpFactory = httpFactory; _sl = sl.Value; _token = token; _wd = wd; _log = log; } public async Task> GetAsync(CancellationToken ct) { var rows = await FetchSimple3HealthAsync(ct); if (rows.Count == 0) rows = await BuildRowsFromCarsAsync(ct); await Parallel.ForEachAsync( rows, new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct }, async (row, token) => { if (string.IsNullOrWhiteSpace(row.Ip)) { // 无 IP 时保留 Simple3 原探测结果 row.LatencySource ??= row.LatencyMs != null ? "onboard" : "none"; return; } // 取两次 TCP 连接的较小值,降低偶发握手抖动 var a = await _wd.MeasureRttMsAsync(row.Ip, token); var b = await _wd.MeasureRttMsAsync(row.Ip, token); int? rtt = (a, b) switch { (null, null) => null, (int x, null) => x, (null, int y) => y, (int x, int y) => Math.Min(x, y) }; if (rtt != null) { row.LatencyMs = rtt; row.Reachable = true; row.LatencySource = "watchdog"; row.ProbedAt = DateTimeOffset.UtcNow.ToString("O"); } else { // WatchDog 不通:若 Simple3 车载探测成功则保留,否则标不可达 if (row.Reachable == true && row.LatencyMs is > 0 and < 2000) { row.LatencySource = "onboard"; } else { row.Reachable = false; row.LatencyMs = null; row.LatencySource = "watchdog"; row.ProbedAt = DateTimeOffset.UtcNow.ToString("O"); } } }); return rows; } private async Task> FetchSimple3HealthAsync(CancellationToken ct) { var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222; try { using var client = _httpFactory.CreateClient(); client.Timeout = TimeSpan.FromSeconds(20); using var req = new HttpRequestMessage(HttpMethod.Get, $"http://127.0.0.1:{port}/projection/fleet/health"); var token = _token.Token; if (!string.IsNullOrEmpty(token)) req.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", token); using var resp = await client.SendAsync(req, ct); if (!resp.IsSuccessStatusCode) return new(); var text = await resp.Content.ReadAsStringAsync(ct); var list = JsonSerializer.Deserialize>(text, JsonOpt); return list ?? new(); } catch (Exception ex) { _log.LogDebug(ex, "fleet/health from Simple3 failed"); return new(); } } private async Task> BuildRowsFromCarsAsync(CancellationToken ct) { var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222; try { using var client = _httpFactory.CreateClient(); client.Timeout = TimeSpan.FromSeconds(10); using var req = new HttpRequestMessage(HttpMethod.Get, $"http://127.0.0.1:{port}/projection/cars"); var token = _token.Token; if (!string.IsNullOrEmpty(token)) req.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", token); using var resp = await client.SendAsync(req, ct); if (!resp.IsSuccessStatusCode) return new(); var text = await resp.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(text); if (doc.RootElement.ValueKind != JsonValueKind.Array) return new(); var list = new List(); foreach (var el in doc.RootElement.EnumerateArray()) { var rawId = el.TryGetProperty("rawId", out var rid) && rid.TryGetInt32(out var id) ? id : 0; if (rawId <= 0) continue; var ip = el.TryGetProperty("ip", out var ipEl) ? ipEl.GetString() : null; var name = el.TryGetProperty("name", out var nEl) ? nEl.GetString() : null; var onboard = el.TryGetProperty("onboardUrl", out var oEl) ? oEl.GetString() : null; if (string.IsNullOrEmpty(onboard) && !string.IsNullOrEmpty(ip)) onboard = $"http://{ip}:8081"; list.Add(new FleetHealthRowDto { CarId = rawId, CarName = name, Ip = ip, OnboardUrl = onboard }); } return list; } catch (Exception ex) { _log.LogDebug(ex, "projection/cars fallback for fleet health failed"); return new(); } } }