新增 OTA WatchDog 编排与车队健康/报警后端。
覆盖包库回传、任务下发、CDM 任务同步与报警采集,并为包/任务 ID 与上传文件名加上路径安全校验。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MiGu.Server.Auth;
|
||||
using MiGu.Server.Launcher;
|
||||
using MiGu.Server.Persistence;
|
||||
|
||||
namespace MiGu.Server.Fleet;
|
||||
|
||||
/// <summary>
|
||||
/// 单例:轮询 SimpleLite 车辆 + 每车状态,读取「车体_AlarmInfo/车体_AlarmLevel」并对帐进 platform.db(vehicle_alarms)。
|
||||
/// 出现→开 active;文案变→更新;消失→置 cleared 并记录恢复时间/时长。永不删=完整历史;SimpleLite 离线仍可查最近记录。
|
||||
/// </summary>
|
||||
public sealed class AlarmCollector
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly SimpleLiteOptions _sl;
|
||||
private readonly InternalTokenStore _token;
|
||||
private readonly ILogger<AlarmCollector> _log;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpt = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
public volatile bool Online;
|
||||
public DateTimeOffset? LastSyncAt { get; private set; }
|
||||
|
||||
public AlarmCollector(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IHttpClientFactory httpFactory,
|
||||
IOptions<SimpleLiteOptions> sl,
|
||||
InternalTokenStore token,
|
||||
ILogger<AlarmCollector> log)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_httpFactory = httpFactory;
|
||||
_sl = sl.Value;
|
||||
_token = token;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
private sealed class CurrentAlarm
|
||||
{
|
||||
public int CarId;
|
||||
public string CarName = "";
|
||||
public string Info = "";
|
||||
public int Level;
|
||||
}
|
||||
|
||||
public async Task<bool> SyncOnceAsync(CancellationToken ct)
|
||||
{
|
||||
if (!await _gate.WaitAsync(0, ct)) return Online;
|
||||
try
|
||||
{
|
||||
var cars = await FetchCarsAsync(ct);
|
||||
if (cars == null)
|
||||
{
|
||||
Online = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
var current = new Dictionary<int, CurrentAlarm>();
|
||||
await Parallel.ForEachAsync(
|
||||
cars,
|
||||
new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct },
|
||||
async (car, token) =>
|
||||
{
|
||||
var (info, level) = await FetchCarAlarmAsync(car.Id, token);
|
||||
if (string.IsNullOrWhiteSpace(info)) return;
|
||||
lock (current)
|
||||
{
|
||||
current[car.Id] = new CurrentAlarm { CarId = car.Id, CarName = car.Name, Info = info, Level = level };
|
||||
}
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<PlatformDbContext>();
|
||||
await ReconcileAsync(db, current, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogDebug(ex, "alarm reconcile failed");
|
||||
}
|
||||
|
||||
Online = true;
|
||||
LastSyncAt = DateTimeOffset.UtcNow;
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CarRow
|
||||
{
|
||||
public int Id;
|
||||
public string Name = "";
|
||||
}
|
||||
|
||||
private async Task<List<CarRow>?> FetchCarsAsync(CancellationToken ct)
|
||||
{
|
||||
var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222;
|
||||
try
|
||||
{
|
||||
using var client = CreateClient();
|
||||
using var resp = await client.SendAsync(Req($"http://127.0.0.1:{port}/projection/cars"), ct);
|
||||
if (!resp.IsSuccessStatusCode) return null;
|
||||
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<CarRow>();
|
||||
foreach (var el in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
var id = el.TryGetProperty("rawId", out var rid) && rid.TryGetInt32(out var n) ? n : 0;
|
||||
if (id <= 0) continue;
|
||||
var name = el.TryGetProperty("name", out var nm) ? nm.GetString() ?? "" : "";
|
||||
list.Add(new CarRow { Id = id, Name = name });
|
||||
}
|
||||
return list;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogDebug(ex, "alarm fetch cars failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(string info, int level)> FetchCarAlarmAsync(int carId, CancellationToken ct)
|
||||
{
|
||||
var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222;
|
||||
try
|
||||
{
|
||||
using var client = CreateClient();
|
||||
using var resp = await client.SendAsync(Req($"http://127.0.0.1:{port}/projection/reflection/status/car/{carId}"), ct);
|
||||
if (!resp.IsSuccessStatusCode) return ("", 0);
|
||||
var text = await resp.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
if (!doc.RootElement.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array)
|
||||
return ("", 0);
|
||||
|
||||
string info = "";
|
||||
var level = 0;
|
||||
foreach (var kv in data.EnumerateArray())
|
||||
{
|
||||
var key = kv.TryGetProperty("key", out var k) ? k.GetString() : null;
|
||||
var val = kv.TryGetProperty("value", out var v) ? v.GetString() : null;
|
||||
if (key == "车体_AlarmInfo" || key == "AlarmInfo") info = val ?? "";
|
||||
else if (key == "车体_AlarmLevel" || key == "AlarmLevel") int.TryParse(val, out level);
|
||||
}
|
||||
|
||||
info = info.Trim();
|
||||
if (info is "0" or "/" or "-") info = "";
|
||||
return (info, level);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return ("", 0);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReconcileAsync(PlatformDbContext db, Dictionary<int, CurrentAlarm> current, CancellationToken ct)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var active = await db.VehicleAlarms.Where(a => a.Status == "active").ToListAsync(ct);
|
||||
var activeByCar = new Dictionary<int, VehicleAlarmRecord>();
|
||||
foreach (var a in active) activeByCar[a.CarId] = a; // 每车取一条 active
|
||||
|
||||
// 出现 / 更新
|
||||
foreach (var cur in current.Values)
|
||||
{
|
||||
if (activeByCar.TryGetValue(cur.CarId, out var rec))
|
||||
{
|
||||
rec.Info = cur.Info;
|
||||
rec.Level = cur.Level;
|
||||
rec.CarName = cur.CarName;
|
||||
rec.LastAt = now;
|
||||
}
|
||||
else
|
||||
{
|
||||
db.VehicleAlarms.Add(new VehicleAlarmRecord
|
||||
{
|
||||
CarId = cur.CarId,
|
||||
CarName = cur.CarName,
|
||||
Info = cur.Info,
|
||||
Level = cur.Level,
|
||||
Status = "active",
|
||||
FirstAt = now,
|
||||
LastAt = now
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 消失 → 恢复
|
||||
foreach (var rec in active)
|
||||
{
|
||||
if (current.ContainsKey(rec.CarId)) continue;
|
||||
rec.Status = "cleared";
|
||||
rec.ResolvedAt = now;
|
||||
rec.DurationSecs = (long)Math.Max(0, (now - rec.FirstAt).TotalSeconds);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private HttpClient CreateClient()
|
||||
{
|
||||
var c = _httpFactory.CreateClient();
|
||||
c.Timeout = TimeSpan.FromSeconds(10);
|
||||
return c;
|
||||
}
|
||||
|
||||
private HttpRequestMessage Req(string url)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
var token = _token.Token;
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
req.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", token);
|
||||
return req;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>后台循环:定时采集车辆报警到 platform.db。</summary>
|
||||
public sealed class AlarmCollectorService : BackgroundService
|
||||
{
|
||||
private readonly AlarmCollector _collector;
|
||||
private readonly ILogger<AlarmCollectorService> _log;
|
||||
|
||||
public AlarmCollectorService(AlarmCollector collector, ILogger<AlarmCollectorService> log)
|
||||
{
|
||||
_collector = collector;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(4), stoppingToken); }
|
||||
catch { return; }
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try { await _collector.SyncOnceAsync(stoppingToken); }
|
||||
catch (Exception ex) { _log.LogDebug(ex, "alarm collector loop error"); }
|
||||
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(8), stoppingToken); }
|
||||
catch { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace MiGu.Server.Fleet;
|
||||
|
||||
/// <summary>
|
||||
/// CDM 搬运任务的平台侧快照(表 cdm_tasks)。
|
||||
/// 以任务 Id 为主键;SimpleLite/StandardScene 把终态任务从自身 JSON 里删除,这里则永久保留=完整历史,
|
||||
/// 且 SimpleLite 关闭后平台仍可从本表读取最近快照。
|
||||
/// </summary>
|
||||
public sealed class CdmTaskRecord
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string? TaskId { get; set; }
|
||||
public int MissionId { get; set; }
|
||||
public string MissionName { get; set; } = "";
|
||||
public string MissionTypeName { get; set; } = "";
|
||||
public int SrcSiteId { get; set; }
|
||||
public string SrcLabel { get; set; } = "";
|
||||
public int DstSiteId { get; set; }
|
||||
public string DstLabel { get; set; } = "";
|
||||
public string Status { get; set; } = "";
|
||||
public string StatusCode { get; set; } = "";
|
||||
public int? CarId { get; set; }
|
||||
public string? CarName { get; set; }
|
||||
public int Priority { get; set; }
|
||||
/// <summary>下发/开始/结束时间:直接存投影返回的 ISO 字符串(可空)。</summary>
|
||||
public string? CreateTime { get; set; }
|
||||
public string? StartTime { get; set; }
|
||||
public string? FinishTime { get; set; }
|
||||
public string? StuckReason { get; set; }
|
||||
public bool Overdue { get; set; }
|
||||
/// <summary>平台首次/最近一次同步到该任务的时间。</summary>
|
||||
public DateTimeOffset FirstSeenAt { get; set; }
|
||||
public DateTimeOffset LastSeenAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MiGu.Server.Auth;
|
||||
using MiGu.Server.Launcher;
|
||||
using MiGu.Server.Persistence;
|
||||
|
||||
namespace MiGu.Server.Fleet;
|
||||
|
||||
/// <summary>投影 /projection/deliveries 返回的单行(camelCase)。</summary>
|
||||
public sealed class CdmTaskDto
|
||||
{
|
||||
public string id { get; set; } = "";
|
||||
public string? taskId { get; set; }
|
||||
public int missionId { get; set; }
|
||||
public string missionName { get; set; } = "";
|
||||
public string missionTypeName { get; set; } = "";
|
||||
public int srcSiteId { get; set; }
|
||||
public string srcLabel { get; set; } = "";
|
||||
public int dstSiteId { get; set; }
|
||||
public string dstLabel { get; set; } = "";
|
||||
public string status { get; set; } = "";
|
||||
public string statusCode { get; set; } = "";
|
||||
public int? carId { get; set; }
|
||||
public string? carName { get; set; }
|
||||
public int priority { get; set; }
|
||||
public string? createTime { get; set; }
|
||||
public string? startTime { get; set; }
|
||||
public string? finishTime { get; set; }
|
||||
public string? stuckReason { get; set; }
|
||||
public bool overdue { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 单例:从 SimpleLite 投影拉取 CDM 任务并 upsert 到 platform.db(cdm_tasks),永不删除=保留历史。
|
||||
/// 同时维护「SimpleLite 是否在线 / 最近同步时间」,供任务页离线降级展示。
|
||||
/// </summary>
|
||||
public sealed class CdmTaskSyncer
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly SimpleLiteOptions _sl;
|
||||
private readonly InternalTokenStore _token;
|
||||
private readonly ILogger<CdmTaskSyncer> _log;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpt = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
public volatile bool Online;
|
||||
public DateTimeOffset? LastSyncAt { get; private set; }
|
||||
public int LastCount { get; private set; }
|
||||
|
||||
public CdmTaskSyncer(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IHttpClientFactory httpFactory,
|
||||
IOptions<SimpleLiteOptions> sl,
|
||||
InternalTokenStore token,
|
||||
ILogger<CdmTaskSyncer> log)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_httpFactory = httpFactory;
|
||||
_sl = sl.Value;
|
||||
_token = token;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>拉取 + 落库一次。并发调用时若已有同步在进行则直接跳过(返回当前在线状态)。</summary>
|
||||
public async Task<bool> SyncOnceAsync(CancellationToken ct)
|
||||
{
|
||||
if (!await _gate.WaitAsync(0, ct)) return Online;
|
||||
try
|
||||
{
|
||||
var dtos = await FetchAsync(ct);
|
||||
if (dtos == null)
|
||||
{
|
||||
Online = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<PlatformDbContext>();
|
||||
await UpsertAsync(db, dtos, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 拉取成功即视为在线;落库失败只记日志,不影响在线判定
|
||||
_log.LogDebug(ex, "cdm upsert failed");
|
||||
}
|
||||
|
||||
Online = true;
|
||||
LastSyncAt = DateTimeOffset.UtcNow;
|
||||
LastCount = dtos.Count;
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<CdmTaskDto>?> FetchAsync(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/deliveries?includeFinished=true&includeAborted=true");
|
||||
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 null;
|
||||
var text = await resp.Content.ReadAsStringAsync(ct);
|
||||
return JsonSerializer.Deserialize<List<CdmTaskDto>>(text, JsonOpt) ?? new List<CdmTaskDto>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogDebug(ex, "cdm fetch failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task UpsertAsync(PlatformDbContext db, IReadOnlyList<CdmTaskDto> dtos, CancellationToken ct)
|
||||
{
|
||||
var valid = dtos.Where(d => !string.IsNullOrWhiteSpace(d.id)).ToList();
|
||||
if (valid.Count == 0) return;
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var ids = valid.Select(d => d.id).ToList();
|
||||
var existing = await db.CdmTasks.Where(t => ids.Contains(t.Id)).ToDictionaryAsync(t => t.Id, ct);
|
||||
|
||||
foreach (var d in valid)
|
||||
{
|
||||
if (existing.TryGetValue(d.id, out var rec))
|
||||
{
|
||||
Map(d, rec);
|
||||
rec.LastSeenAt = now;
|
||||
}
|
||||
else
|
||||
{
|
||||
var created = new CdmTaskRecord { Id = d.id, FirstSeenAt = now, LastSeenAt = now };
|
||||
Map(d, created);
|
||||
db.CdmTasks.Add(created);
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static void Map(CdmTaskDto d, CdmTaskRecord rec)
|
||||
{
|
||||
rec.TaskId = string.IsNullOrWhiteSpace(d.taskId) ? null : d.taskId;
|
||||
rec.MissionId = d.missionId;
|
||||
rec.MissionName = d.missionName ?? "";
|
||||
rec.MissionTypeName = d.missionTypeName ?? "";
|
||||
rec.SrcSiteId = d.srcSiteId;
|
||||
rec.SrcLabel = d.srcLabel ?? "";
|
||||
rec.DstSiteId = d.dstSiteId;
|
||||
rec.DstLabel = d.dstLabel ?? "";
|
||||
rec.Status = d.status ?? "";
|
||||
rec.StatusCode = d.statusCode ?? "";
|
||||
rec.CarId = d.carId;
|
||||
rec.CarName = d.carName;
|
||||
rec.Priority = d.priority;
|
||||
rec.CreateTime = d.createTime;
|
||||
rec.StartTime = d.startTime;
|
||||
rec.FinishTime = d.finishTime;
|
||||
rec.StuckReason = d.stuckReason;
|
||||
rec.Overdue = d.overdue;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>后台循环:定时把 CDM 任务同步进 platform.db,保证无人打开页面时也能捕获终态历史。</summary>
|
||||
public sealed class CdmTaskSyncService : BackgroundService
|
||||
{
|
||||
private readonly CdmTaskSyncer _syncer;
|
||||
private readonly ILogger<CdmTaskSyncService> _log;
|
||||
|
||||
public CdmTaskSyncService(CdmTaskSyncer syncer, ILogger<CdmTaskSyncService> log)
|
||||
{
|
||||
_syncer = syncer;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken); }
|
||||
catch { return; }
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try { await _syncer.SyncOnceAsync(stoppingToken); }
|
||||
catch (Exception ex) { _log.LogDebug(ex, "cdm sync loop error"); }
|
||||
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(8), stoppingToken); }
|
||||
catch { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace MiGu.Server.Fleet;
|
||||
|
||||
/// <summary>与 SimpleLite GET /projection/fleet/health 行对齐,供车队运维前端消费。</summary>
|
||||
public sealed class FleetHealthRowDto
|
||||
{
|
||||
public int CarId { get; set; }
|
||||
public string? CarName { get; set; }
|
||||
public string? Ip { get; set; }
|
||||
public string? OnboardUrl { get; set; }
|
||||
public int? LatencyMs { get; set; }
|
||||
public bool? Reachable { get; set; }
|
||||
public string? ProbedAt { get; set; }
|
||||
public double? UptimeSecs { get; set; }
|
||||
public double? AlarmActiveSecs { get; set; }
|
||||
public double? FaultRatePercent { get; set; }
|
||||
public bool? IsAlarmActive { get; set; }
|
||||
public double? CpuPercent { get; set; }
|
||||
public double? MemPercent { get; set; }
|
||||
/// <summary>latency 探测通道:watchdog | onboard | none</summary>
|
||||
public string? LatencySource { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MiGu.Server.Auth;
|
||||
using MiGu.Server.Launcher;
|
||||
using MiGu.Server.Ota;
|
||||
|
||||
namespace MiGu.Server.Fleet;
|
||||
|
||||
/// <summary>
|
||||
/// 车队健康:保留 SimpleLite 的 CPU/故障率等,延迟改为对 WatchDog(:9776) 做 TCP RTT。
|
||||
/// SimpleLite /fleet/health 探测的是车载 HTTP :8081,多数现场未开该端口会假超时 2000ms。
|
||||
/// </summary>
|
||||
public sealed class FleetHealthService
|
||||
{
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly SimpleLiteOptions _sl;
|
||||
private readonly InternalTokenStore _token;
|
||||
private readonly WatchDogClient _wd;
|
||||
private readonly ILogger<FleetHealthService> _log;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpt = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public FleetHealthService(
|
||||
IHttpClientFactory httpFactory,
|
||||
IOptions<SimpleLiteOptions> sl,
|
||||
InternalTokenStore token,
|
||||
WatchDogClient wd,
|
||||
ILogger<FleetHealthService> log)
|
||||
{
|
||||
_httpFactory = httpFactory;
|
||||
_sl = sl.Value;
|
||||
_token = token;
|
||||
_wd = wd;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public async Task<List<FleetHealthRowDto>> GetAsync(CancellationToken ct)
|
||||
{
|
||||
var rows = await FetchSimpleLiteHealthAsync(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 时保留 SimpleLite 原探测结果
|
||||
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 不通:若 SimpleLite 车载探测成功则保留,否则标不可达
|
||||
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<List<FleetHealthRowDto>> FetchSimpleLiteHealthAsync(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<List<FleetHealthRowDto>>(text, JsonOpt);
|
||||
return list ?? new();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogDebug(ex, "fleet/health from SimpleLite failed");
|
||||
return new();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<FleetHealthRowDto>> 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<FleetHealthRowDto>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace MiGu.Server.Fleet;
|
||||
|
||||
/// <summary>
|
||||
/// 车辆报警的平台侧记录(表 vehicle_alarms)。
|
||||
/// SimpleLite 只在 SSE/状态里给出「当前是否报警 + 文案」,无历史;平台按车对帐:
|
||||
/// 出现报警→开一条 active 记录,报警文案变化→更新,报警消失→置为 cleared 并记录恢复时间/持续时长。
|
||||
/// 永不删除=完整历史,重启/刷新不丢,SimpleLite 离线也可查。
|
||||
/// </summary>
|
||||
public sealed class VehicleAlarmRecord
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("D");
|
||||
public int CarId { get; set; }
|
||||
public string CarName { get; set; } = "";
|
||||
/// <summary>报警文案(车体_AlarmInfo)。</summary>
|
||||
public string Info { get; set; } = "";
|
||||
/// <summary>报警级别(车体_AlarmLevel,未知为 0)。</summary>
|
||||
public int Level { get; set; }
|
||||
/// <summary>active | cleared</summary>
|
||||
public string Status { get; set; } = "active";
|
||||
public DateTimeOffset FirstAt { get; set; }
|
||||
public DateTimeOffset LastAt { get; set; }
|
||||
public DateTimeOffset? ResolvedAt { get; set; }
|
||||
/// <summary>持续时长(秒),恢复后写入。</summary>
|
||||
public long? DurationSecs { get; set; }
|
||||
/// <summary>预留:平台侧确认(不代表车端消警)。</summary>
|
||||
public bool Acknowledged { get; set; }
|
||||
public DateTimeOffset? AcknowledgedAt { get; set; }
|
||||
public string? AcknowledgedBy { get; set; }
|
||||
}
|
||||
Reference in New Issue
Block a user