新增 OTA WatchDog 编排与车队健康/报警后端。

覆盖包库回传、任务下发、CDM 任务同步与报警采集,并为包/任务 ID 与上传文件名加上路径安全校验。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zhaowei.huang
2026-07-26 11:31:51 +08:00
co-authored by Cursor
parent f4f4cd1d1b
commit 4223a572c5
25 changed files with 3552 additions and 6 deletions
+251
View File
@@ -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; }
}
}
}
+33
View File
@@ -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; }
}
+205
View File
@@ -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; }
}
}
}
+21
View File
@@ -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; }
}
+169
View File
@@ -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();
}
}
}
+29
View File
@@ -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; }
}