From 4223a572c5d0cb2dbf5e173ee5205bb05ada6ada Mon Sep 17 00:00:00 2001 From: "zhaowei.huang" <228127304@qq.com> Date: Sat, 25 Jul 2026 18:44:26 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20OTA=20WatchDog=20=E7=BC=96?= =?UTF-8?q?=E6=8E=92=E4=B8=8E=E8=BD=A6=E9=98=9F=E5=81=A5=E5=BA=B7/?= =?UTF-8?q?=E6=8A=A5=E8=AD=A6=E5=90=8E=E7=AB=AF=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖包库回传、任务下发、CDM 任务同步与报警采集,并为包/任务 ID 与上传文件名加上路径安全校验。 Co-authored-by: Cursor --- MiGu.Server/Auth/PageCatalog.cs | 2 + MiGu.Server/Controllers/FleetController.cs | 123 +++++ MiGu.Server/Controllers/OtaController.cs | 515 ++++++++++++++++++ .../Controllers/OtaReceiveController.cs | 108 ++++ MiGu.Server/Fleet/AlarmCollector.cs | 251 +++++++++ MiGu.Server/Fleet/CdmTaskRecord.cs | 33 ++ MiGu.Server/Fleet/CdmTaskSync.cs | 205 +++++++ MiGu.Server/Fleet/FleetHealthModels.cs | 21 + MiGu.Server/Fleet/FleetHealthService.cs | 169 ++++++ MiGu.Server/Fleet/VehicleAlarmRecord.cs | 29 + MiGu.Server/Ota/OtaHash.cs | 26 + MiGu.Server/Ota/OtaJobRunner.cs | 433 +++++++++++++++ MiGu.Server/Ota/OtaModels.cs | 144 +++++ MiGu.Server/Ota/OtaOptions.cs | 24 + MiGu.Server/Ota/OtaPathMap.cs | 53 ++ MiGu.Server/Ota/OtaStore.cs | 368 +++++++++++++ MiGu.Server/Ota/OtaVehicleSource.cs | 141 +++++ MiGu.Server/Ota/WatchDogClient.cs | 289 ++++++++++ MiGu.Server/Persistence/PlatformDbContext.cs | 63 +++ .../Persistence/PlatformPersistence.cs | 102 ++++ MiGu.Server/Program.cs | 64 ++- MiGu.Server/Properties/launchSettings.json | 2 +- MiGu.Server/appsettings.json | 18 + .../plans/2026-07-19-migu-ota-watchdog.md | 117 ++++ .../2026-07-19-migu-ota-watchdog-design.md | 258 +++++++++ 25 files changed, 3552 insertions(+), 6 deletions(-) create mode 100644 MiGu.Server/Controllers/FleetController.cs create mode 100644 MiGu.Server/Controllers/OtaController.cs create mode 100644 MiGu.Server/Controllers/OtaReceiveController.cs create mode 100644 MiGu.Server/Fleet/AlarmCollector.cs create mode 100644 MiGu.Server/Fleet/CdmTaskRecord.cs create mode 100644 MiGu.Server/Fleet/CdmTaskSync.cs create mode 100644 MiGu.Server/Fleet/FleetHealthModels.cs create mode 100644 MiGu.Server/Fleet/FleetHealthService.cs create mode 100644 MiGu.Server/Fleet/VehicleAlarmRecord.cs create mode 100644 MiGu.Server/Ota/OtaHash.cs create mode 100644 MiGu.Server/Ota/OtaJobRunner.cs create mode 100644 MiGu.Server/Ota/OtaModels.cs create mode 100644 MiGu.Server/Ota/OtaOptions.cs create mode 100644 MiGu.Server/Ota/OtaPathMap.cs create mode 100644 MiGu.Server/Ota/OtaStore.cs create mode 100644 MiGu.Server/Ota/OtaVehicleSource.cs create mode 100644 MiGu.Server/Ota/WatchDogClient.cs create mode 100644 docs/superpowers/plans/2026-07-19-migu-ota-watchdog.md create mode 100644 docs/superpowers/specs/2026-07-19-migu-ota-watchdog-design.md diff --git a/MiGu.Server/Auth/PageCatalog.cs b/MiGu.Server/Auth/PageCatalog.cs index 41eaeb6..e37191f 100644 --- a/MiGu.Server/Auth/PageCatalog.cs +++ b/MiGu.Server/Auth/PageCatalog.cs @@ -30,6 +30,8 @@ public static class PageCatalog // ── 管理端 / Platform:概览 ── new("admin-dashboard", "总览", "概览", ScopePlatform), new("admin-map-monitor", "地图监控", "概览", ScopePlatform), + new("admin-tasks", "任务管理", "概览", ScopePlatform), + new("admin-alarms", "报警管理", "概览", ScopePlatform), // ── 管理端 / Platform:设计与编排 ── new("admin-maps", "地图管理", "设计与编排", ScopePlatform), diff --git a/MiGu.Server/Controllers/FleetController.cs b/MiGu.Server/Controllers/FleetController.cs new file mode 100644 index 0000000..cea75a9 --- /dev/null +++ b/MiGu.Server/Controllers/FleetController.cs @@ -0,0 +1,123 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using MiGu.Server.Fleet; +using MiGu.Server.Persistence; + +namespace MiGu.Server.Controllers; + +/// 车队运维健康探针(延迟走 WatchDog TCP)+ CDM 任务平台侧快照读取。 +[ApiController] +[Authorize] +[Route("api/fleet")] +public sealed class FleetController : ControllerBase +{ + private readonly FleetHealthService _health; + private readonly CdmTaskSyncer _cdmSyncer; + private readonly AlarmCollector _alarmCollector; + private readonly PlatformDbContext _db; + + public FleetController(FleetHealthService health, CdmTaskSyncer cdmSyncer, AlarmCollector alarmCollector, PlatformDbContext db) + { + _health = health; + _cdmSyncer = cdmSyncer; + _alarmCollector = alarmCollector; + _db = db; + } + + [HttpGet("health")] + public async Task>> Health(CancellationToken ct) + => Ok(await _health.GetAsync(ct)); + + /// + /// CDM 搬运任务列表(读平台快照库 cdm_tasks)。在线时先即时同步一次拿最新, + /// SimpleLite 关闭时回退最近快照,并通过 online/lastSyncAt 告知前端数据是否滞后。 + /// + [HttpGet("tasks")] + public async Task> Tasks([FromQuery] int limit = 1000, CancellationToken ct = default) + { + await _cdmSyncer.SyncOnceAsync(ct); + + var take = Math.Clamp(limit, 1, 5000); + var tasks = await _db.CdmTasks.AsNoTracking() + .OrderByDescending(t => t.CreateTime) + .Take(take) + .Select(t => new + { + id = t.Id, + taskId = t.TaskId, + missionId = t.MissionId, + missionName = t.MissionName, + missionTypeName = t.MissionTypeName, + srcSiteId = t.SrcSiteId, + srcLabel = t.SrcLabel, + dstSiteId = t.DstSiteId, + dstLabel = t.DstLabel, + status = t.Status, + statusCode = t.StatusCode, + carId = t.CarId, + carName = t.CarName, + priority = t.Priority, + createTime = t.CreateTime, + startTime = t.StartTime, + finishTime = t.FinishTime, + stuckReason = t.StuckReason, + overdue = t.Overdue + }) + .ToListAsync(ct); + + return Ok(new + { + online = _cdmSyncer.Online, + lastSyncAt = _cdmSyncer.LastSyncAt, + count = tasks.Count, + tasks + }); + } + + /// + /// 车辆报警列表(读平台记录 vehicle_alarms)。含活跃 + 历史;SimpleLite 离线时回退最近记录, + /// 通过 online/lastSyncAt 告知数据是否滞后。activeOnly=true 仅返回未恢复的报警。 + /// + [HttpGet("alarms")] + public async Task> Alarms( + [FromQuery] int limit = 2000, + [FromQuery] bool activeOnly = false, + CancellationToken ct = default) + { + // 在线时先即时对帐一次,保证打开页面能拿到最新活跃报警。 + await _alarmCollector.SyncOnceAsync(ct); + + var take = Math.Clamp(limit, 1, 10000); + var query = _db.VehicleAlarms.AsNoTracking().AsQueryable(); + if (activeOnly) query = query.Where(a => a.Status == "active"); + + var alarms = await query + .OrderByDescending(a => a.Status == "active") + .ThenByDescending(a => a.LastAt) + .Take(take) + .Select(a => new + { + id = a.Id, + carId = a.CarId, + carName = a.CarName, + info = a.Info, + level = a.Level, + status = a.Status, + firstAt = a.FirstAt, + lastAt = a.LastAt, + resolvedAt = a.ResolvedAt, + durationSecs = a.DurationSecs, + acknowledged = a.Acknowledged + }) + .ToListAsync(ct); + + return Ok(new + { + online = _alarmCollector.Online, + lastSyncAt = _alarmCollector.LastSyncAt, + count = alarms.Count, + alarms + }); + } +} diff --git a/MiGu.Server/Controllers/OtaController.cs b/MiGu.Server/Controllers/OtaController.cs new file mode 100644 index 0000000..f2f4185 --- /dev/null +++ b/MiGu.Server/Controllers/OtaController.cs @@ -0,0 +1,515 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using MiGu.Server.Configs; +using MiGu.Server.Ota; + +namespace MiGu.Server.Controllers; + +[ApiController] +[Authorize] +[Route("api/ota")] +public class OtaController : ControllerBase +{ + private readonly OtaStore _store; + private readonly WatchDogClient _wd; + private readonly OtaVehicleSource _vehicles; + private readonly OtaJobRunner _jobs; + private readonly OpsAuditStore _audits; + private readonly OtaOptions _opt; + + public OtaController( + OtaStore store, + WatchDogClient wd, + OtaVehicleSource vehicles, + OtaJobRunner jobs, + OpsAuditStore audits, + IOptions opt) + { + _store = store; + _wd = wd; + _vehicles = vehicles; + _jobs = jobs; + _audits = audits; + _opt = opt.Value; + } + + private string UserName => + User.FindFirst("unique_name")?.Value + ?? User.Identity?.Name + ?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? "unknown"; + + private string Scope => User.FindFirst("scope")?.Value ?? ""; + + private bool CanWrite() + { + if (string.Equals(Scope, "Platform", StringComparison.OrdinalIgnoreCase)) return true; + var ops = User.FindFirst("ops")?.Value ?? ""; + var set = ops.Split(' ', StringSplitOptions.RemoveEmptyEntries); + return set.Contains("*") || set.Any(o => o.StartsWith("ops.ota", StringComparison.OrdinalIgnoreCase)); + } + + private bool DenyWrite(out ActionResult denied) + { + if (CanWrite()) { denied = null!; return false; } + denied = StatusCode(StatusCodes.Status403Forbidden, new { message = "需要 Platform 或 ops.ota.* 权限" }); + return true; + } + + private void Audit(string op, string target, string result, string? msg = null) => + _audits.Append(UserName, Scope, op, target, result, msg); + + [HttpGet("settings")] + public ActionResult GetSettings() => _store.GetSettings(); + + [HttpPut("settings")] + public ActionResult PutSettings([FromBody] OtaSettings settings) + { + if (DenyWrite(out var denied)) return denied; + var saved = _store.SaveSettings(settings); + Audit("ops.ota.settings", "settings", "ok"); + return saved; + } + + [HttpGet("target")] + public ActionResult GetTarget() + { + var t = _store.GetTarget(); + if (t == null) return Ok(new { target = (OtaTarget?)null }); + return Ok(new + { + target = t, + summary = t.Components.ToDictionary( + kv => kv.Key, + kv => OtaHash.Short(kv.Value.Hash)) + }); + } + + [HttpGet("packages")] + public ActionResult> ListPackages() => _store.ListPackages(); + + [HttpGet("packages/{id}")] + public ActionResult GetPackage(string id) + { + try { return _store.ScanPackage(id); } + catch (DirectoryNotFoundException) { return NotFound(); } + catch (ArgumentException ex) { return BadRequest(new { message = ex.Message }); } + } + + [HttpPost("packages/{id}/activate")] + public ActionResult Activate(string id, [FromQuery] string? name = null) + { + if (DenyWrite(out var denied)) return denied; + try + { + var t = _store.ActivatePackage(id, name); + Audit("ops.ota.activate", id, "ok"); + return t; + } + catch (Exception ex) + { + Audit("ops.ota.activate", id, "fail", ex.Message); + return BadRequest(new { message = ex.Message }); + } + } + + [HttpDelete("packages/{id}")] + public IActionResult DeletePackage(string id) + { + if (DenyWrite(out var denied)) return denied; + try + { + _store.DeletePackage(id); + Audit("ops.ota.package.delete", id, "ok"); + return NoContent(); + } + catch (Exception ex) + { + return BadRequest(new { message = ex.Message }); + } + } + + [HttpPost("packages/pull")] + public async Task> Pull([FromBody] PullPackageRequest req, CancellationToken ct) + { + if (DenyWrite(out var denied)) return denied; + var cars = await _vehicles.ListCarsAsync(ct); + var car = cars.FirstOrDefault(c => string.Equals(c.Id, req.CarId, StringComparison.OrdinalIgnoreCase)); + if (car == null || string.IsNullOrEmpty(car.Ip)) + return BadRequest(new { message = "车辆不存在或无 IP" }); + + var pkgId = _store.BeginPullPackage(car.Ip); + try + { + var baseUrl = ResolvePublicBase(); + var receiveBase = $"{baseUrl.TrimEnd('/')}/api/ota/receive"; + var time = DateTime.Now.ToString("yyyyMMddHHmmss"); + await _wd.TriggerPullAsync(car.Ip, receiveBase, time, ct); + // 等待文件落盘 + await Task.Delay(1500, ct); + for (var i = 0; i < 40; i++) + { + var info = _store.ScanPackage(pkgId); + if (info.Components.Count > 0) break; + await Task.Delay(500, ct); + } + _store.ClearActivePull(car.Ip); + var result = _store.ScanPackage(pkgId); + if (result.Components.Count == 0) + { + Audit("ops.ota.package.pull", req.CarId, "empty", pkgId); + return BadRequest(new + { + message = + $"未收到任何组件文件(包 {pkgId} 为 0 B)。" + + $"WatchDog 会固定 POST 到 http://{{serverIP}}:{_opt.ReceivePort}/upload-mdcs/{{组件}}," + + "请把该车 watch_dog.json 的 serverIP 设为本机局域网 IP,并确认本机已监听该端口;" + + "同时确认车上已配置 Medulla/Detour/Clumsy 路径。", + packageId = pkgId, + receivePort = _opt.ReceivePort + }); + } + Audit("ops.ota.package.pull", req.CarId, "ok", pkgId); + return Ok(result); + } + catch (Exception ex) + { + _store.ClearActivePull(car.Ip); + Audit("ops.ota.package.pull", req.CarId, "fail", ex.Message); + return BadRequest(new { message = ex.Message, packageId = pkgId }); + } + } + + [HttpPost("packages/upload")] + [RequestSizeLimit(512_000_000)] + public async Task> Upload(IFormFile? file, CancellationToken ct) + { + if (DenyWrite(out var denied)) return denied; + if (file == null || file.Length == 0) + return BadRequest(new { message = "请上传文件(zip 或单文件)" }); + + var pkgId = _store.BeginPullPackage("upload"); + var dir = _store.PackageDir(pkgId); + try + { + var name = Path.GetFileName(file.FileName); + if (string.IsNullOrWhiteSpace(name)) + return BadRequest(new { message = "文件名无效" }); + if (name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + { + var zipPath = Path.Combine(dir, name); + await using (var fs = System.IO.File.Create(zipPath)) + await file.CopyToAsync(fs, ct); + System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, dir, true); + System.IO.File.Delete(zipPath); + NormalizeUploadLayout(dir); + } + else + { + // 单文件:按扩展名猜放到 M/D/C + var dest = GuessDest(dir, name); + Directory.CreateDirectory(Path.GetDirectoryName(dest)!); + await using var fs = System.IO.File.Create(dest); + await file.CopyToAsync(fs, ct); + } + _store.ClearActivePull("upload"); + var info = _store.ScanPackage(pkgId); + Audit("ops.ota.package.upload", pkgId, "ok"); + return info; + } + catch (Exception ex) + { + _store.ClearActivePull("upload"); + return BadRequest(new { message = ex.Message }); + } + } + + [HttpGet("vehicles")] + public async Task>> Vehicles([FromQuery] bool? latency, CancellationToken ct) + { + var settings = _store.GetSettings(); + var doLatency = latency ?? settings.LatencyEnabled; + var target = _store.GetTarget(); + var cars = await _vehicles.ListCarsAsync(ct); + + await Parallel.ForEachAsync(cars, new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct }, async (car, token) => + { + if (string.IsNullOrEmpty(car.Ip)) + { + car.Reachable = false; + return; + } + if (doLatency) + car.RttMs = await _wd.MeasureRttMsAsync(car.Ip, token); + + var (ok, m, d, c, _) = await _wd.GetMdcInfoAsync(car.Ip, token); + car.Reachable = ok; + car.Medulla = m; + car.Detour = d; + car.Clumsy = c; + if (target != null) + car.Match = BuildMatch(target, m, d, c); + }); + + return cars; + } + + [HttpGet("latency")] + public async Task> Latency(CancellationToken ct) + { + var settings = _store.GetSettings(); + if (!settings.LatencyEnabled) + return Ok(new { enabled = false, items = Array.Empty() }); + var cars = await _vehicles.ListCarsAsync(ct); + var items = new List(); + foreach (var car in cars) + { + if (string.IsNullOrEmpty(car.Ip)) continue; + var rtt = await _wd.MeasureRttMsAsync(car.Ip, ct); + items.Add(new { car.Id, car.Ip, rttMs = rtt, over = rtt == null || rtt > settings.RttThresholdMs }); + } + return Ok(new { enabled = true, thresholdMs = settings.RttThresholdMs, items }); + } + + [HttpGet("jobs")] + public ActionResult> ListJobs([FromQuery] int take = 100) => _store.ListJobs(take); + + [HttpGet("jobs/{id}")] + public ActionResult GetJob(string id) + { + try + { + var j = _store.GetJob(id); + return j == null ? NotFound() : j; + } + catch (ArgumentException ex) + { + return BadRequest(new { message = ex.Message }); + } + } + + [HttpPost("jobs")] + public ActionResult CreateJob([FromBody] CreateSyncJobRequest req) + { + if (DenyWrite(out var denied)) return denied; + try + { + var job = _jobs.EnqueueSync(req, UserName); + Audit("ops.ota.job.create", job.Id, "ok", $"cars={req.CarIds.Count}"); + return job; + } + catch (Exception ex) + { + return BadRequest(new { message = ex.Message }); + } + } + + [HttpPost("jobs/{id}/cancel")] + public IActionResult Cancel(string id) + { + if (DenyWrite(out var denied)) return denied; + var ok = _jobs.Cancel(id); + Audit("ops.ota.job.cancel", id, ok ? "ok" : "noop"); + return ok ? Ok(new { ok = true }) : BadRequest(new { message = "无法取消" }); + } + + [HttpPost("jobs/{id}/retry")] + public ActionResult Retry(string id) + { + if (DenyWrite(out var denied)) return denied; + try + { + var job = _jobs.RetryFailed(id, UserName); + if (job == null) return NotFound(); + Audit("ops.ota.job.retry", id, "ok", job.Id); + return job; + } + catch (Exception ex) + { + return BadRequest(new { message = ex.Message }); + } + } + + [HttpGet("config/{carId}/{app}")] + public async Task> GetConfig(string carId, string app, CancellationToken ct) + { + var cars = await _vehicles.ListCarsAsync(ct); + var car = cars.FirstOrDefault(c => string.Equals(c.Id, carId, StringComparison.OrdinalIgnoreCase)); + if (car?.Ip == null) return BadRequest(new { message = "车辆无 IP" }); + try + { + var json = await _wd.GetJsonAsync(car.Ip, app, ct); + return Ok(new { carId, app, json }); + } + catch (Exception ex) + { + return BadRequest(new { message = ex.Message }); + } + } + + [HttpPost("config/push")] + public ActionResult ConfigPush([FromBody] CreateConfigPushRequest req) + { + if (DenyWrite(out var denied)) return denied; + try + { + var job = _jobs.EnqueueConfigPush(req, UserName); + Audit("ops.ota.config.push", job.Id, "ok", req.App); + return job; + } + catch (Exception ex) + { + return BadRequest(new { message = ex.Message }); + } + } + + [HttpPost("custom-file")] + [RequestSizeLimit(512_000_000)] + public async Task> CustomFile( + [FromForm] string carIds, + [FromForm] string remotePath, + [FromForm] string? restartOps, + [FromForm] int? restartOp, + [FromForm] List? files, + IFormFile? file, + CancellationToken ct) + { + if (DenyWrite(out var denied)) return denied; + var uploadFiles = new List(); + if (files is { Count: > 0 }) uploadFiles.AddRange(files.Where(f => f.Length > 0)); + if (file is { Length: > 0 }) uploadFiles.Add(file); + if (uploadFiles.Count == 0) + return BadRequest(new { message = "请至少选择一个文件" }); + + var ids = ParseCarIds(carIds); + var ops = new List(); + if (!string.IsNullOrWhiteSpace(restartOps)) + { + try + { + ops = System.Text.Json.JsonSerializer.Deserialize>(restartOps) ?? new(); + } + catch { /* ignore */ } + } + if (ops.Count == 0 && restartOp.HasValue) ops.Add(restartOp.Value); + if (ops.Count == 0) ops.Add(-1); + + var tmpDir = Path.Combine(_store.Root, "uploads", $"{DateTime.UtcNow:yyyyMMddHHmmssfff}-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tmpDir); + var items = new List(); + foreach (var f in uploadFiles) + { + var safe = Path.GetFileName(f.FileName); + var local = Path.Combine(tmpDir, $"{items.Count}_{safe}"); + await using (var fs = System.IO.File.Create(local)) + await f.CopyToAsync(fs, ct); + items.Add(new OtaCustomFileItem { LocalPath = local, FileName = safe }); + } + + try + { + var job = _jobs.EnqueueCustomFile(new CreateCustomFileJobRequest + { + CarIds = ids, + RemotePath = remotePath, + RestartOps = ops + }, items, UserName); + Audit("ops.ota.customFile", job.Id, "ok", string.Join(",", items.Select(i => i.FileName))); + return job; + } + catch (Exception ex) + { + return BadRequest(new { message = ex.Message }); + } + } + + private string ResolvePublicBase() + { + if (!string.IsNullOrWhiteSpace(_opt.PublicBaseUrl)) + return _opt.PublicBaseUrl.TrimEnd('/'); + return $"{Request.Scheme}://{Request.Host}"; + } + + private static List ParseCarIds(string carIds) + { + var trimmed = carIds.Trim(); + if (trimmed.StartsWith("[", StringComparison.Ordinal)) + { + try + { + var ids = System.Text.Json.JsonSerializer.Deserialize>(trimmed); + if (ids != null) + return ids.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).ToList(); + } + catch + { + // Fall back to comma-separated form data below. + } + } + + return trimmed + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .ToList(); + } + + private static Dictionary BuildMatch(OtaTarget target, OtaAppVersions? m, OtaAppVersions? d, OtaAppVersions? c) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + void One(string key, string? vehicleHash) + { + if (!target.Components.TryGetValue(key, out var art)) + { + map[key] = "missing-target"; + return; + } + if (string.IsNullOrEmpty(vehicleHash)) { map[key] = "unknown"; return; } + map[key] = string.Equals(vehicleHash, art.Hash, StringComparison.Ordinal) ? "match" : "mismatch"; + } + One("M.exe", m?.Exe?.Version); + One("M.dll", m?.Dll?.Version); + One("M.pdb", m?.Pdb?.Version); + One("D.exe", d?.Exe?.Version); + One("C.exe", c?.Exe?.Version); + One("C.dll", c?.Dll?.Version); + One("C.pdb", c?.Pdb?.Version); + return map; + } + + private static void NormalizeUploadLayout(string dir) + { + // 若 zip 根下直接是 M/D/C 或 Medulla.exe,尽量归位 + var medulla = Directory.GetFiles(dir, "Medulla.exe", SearchOption.AllDirectories).FirstOrDefault(); + if (medulla != null) + { + var dest = Path.Combine(dir, "M", "Medulla.exe"); + if (!string.Equals(medulla, dest, StringComparison.OrdinalIgnoreCase)) + { + Directory.CreateDirectory(Path.GetDirectoryName(dest)!); + System.IO.File.Copy(medulla, dest, true); + } + } + var detour = Directory.GetFiles(dir, "Detour.exe", SearchOption.AllDirectories).FirstOrDefault(); + if (detour != null) + { + var dest = Path.Combine(dir, "D", "Detour.exe"); + if (!string.Equals(detour, dest, StringComparison.OrdinalIgnoreCase)) + { + Directory.CreateDirectory(Path.GetDirectoryName(dest)!); + System.IO.File.Copy(detour, dest, true); + } + } + } + + private static string GuessDest(string dir, string fileName) + { + var n = fileName.ToLowerInvariant(); + if (n.Contains("medulla") && n.EndsWith(".exe")) return Path.Combine(dir, "M", "Medulla.exe"); + if (n.Contains("cartactivator") && n.EndsWith(".dll")) return Path.Combine(dir, "M", "plugins", "CartActivator.dll"); + if (n.Contains("detour")) return Path.Combine(dir, "D", "Detour.exe"); + if (n.Contains("clumsy") && n.EndsWith(".exe")) return Path.Combine(dir, "C", "ClumsyConsole.exe"); + return Path.Combine(dir, "M", Path.GetFileName(fileName)); + } +} diff --git a/MiGu.Server/Controllers/OtaReceiveController.cs b/MiGu.Server/Controllers/OtaReceiveController.cs new file mode 100644 index 0000000..17a1d43 --- /dev/null +++ b/MiGu.Server/Controllers/OtaReceiveController.cs @@ -0,0 +1,108 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MiGu.Server.Ota; + +namespace MiGu.Server.Controllers; + +/// +/// WatchDog 回传包接收端。 +/// WatchDog 写死 POST 到 http://{config.serverIP}:8000/upload-mdcs/{routeKey}, +/// 必须与参考 Electron Express :8000 路径一致;/api/ota/receive/* 仅作兼容别名。 +/// +[ApiController] +[AllowAnonymous] +public class OtaReceiveController : ControllerBase +{ + private readonly OtaStore _store; + private readonly ILogger _log; + + public OtaReceiveController(OtaStore store, ILogger log) + { + _store = store; + _log = log; + } + + [HttpGet("/hello")] + [HttpGet("/api/ota/receive/hello")] + public IActionResult Hello() => Ok("ok"); + + /// WatchDog 官方路径:/upload-mdcs/{Medullaexe|...} + [HttpPost("/upload-mdcs/{routeKey}")] + [HttpPost("/api/ota/receive/upload-mdcs/{routeKey}")] + [HttpPost("/api/ota/receive/upload-mdcs{routeKey}")] + [RequestSizeLimit(512_000_000)] + public Task UploadMdcs(string routeKey, CancellationToken ct) + => SaveAsync(routeKey, ct); + + [HttpPost("/upload-history/{routeKey}")] + [HttpPost("/api/ota/receive/upload-history/{routeKey}")] + [HttpPost("/api/ota/receive/upload-history{routeKey}")] + [RequestSizeLimit(512_000_000)] + public async Task UploadHistory(string routeKey, CancellationToken ct) + { + var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + if (!_store.TryGetActivePullId(ip, out _)) + { + _log.LogWarning("OTA history rejected without active pull session from {Ip}", ip); + return BadRequest("no active pull session"); + } + + var day = DateTime.Now.ToString("yyyy-MM-dd"); + var dir = Path.Combine(_store.HistoryDir, day, SafeFileName(ip, "unknown")); + Directory.CreateDirectory(dir); + var file = await ReadFirstFileAsync(ct); + if (file == null || file.Length == 0) return BadRequest("empty"); + // 净化文件名:routeKey / FileName 都可能含路径分隔符,必须 GetFileName 防穿越。 + var rawName = string.IsNullOrWhiteSpace(file.FileName) ? routeKey : file.FileName; + var safeName = SafeFileName(rawName, "unnamed"); + var path = Path.Combine(dir, safeName); + await using var fs = System.IO.File.Create(path); + await file.CopyToAsync(fs, ct); + _log.LogInformation("OTA history receive {Route} -> {Path} ({Len})", routeKey, path, file.Length); + return Ok(new { ok = true }); + } + + private async Task SaveAsync(string routeKey, CancellationToken ct) + { + try + { + var clientIp = HttpContext.Connection.RemoteIpAddress?.ToString(); + if (!_store.TryGetActivePullId(clientIp, out _)) + { + _log.LogWarning("OTA mdcs rejected without active pull session from {Ip}", clientIp ?? "unknown"); + return BadRequest("no active pull session"); + } + + var file = await ReadFirstFileAsync(ct); + if (file == null || file.Length == 0) return BadRequest("empty file"); + + var dest = _store.ResolveReceivePath(routeKey, clientIp); + Directory.CreateDirectory(Path.GetDirectoryName(dest)!); + await using (var fs = System.IO.File.Create(dest)) + await file.CopyToAsync(fs, ct); + _log.LogInformation("OTA mdcs receive {Route} -> {Dest} ({Len})", routeKey, dest, file.Length); + return Ok(new { ok = true, path = dest }); + } + catch (Exception ex) + { + _log.LogWarning(ex, "OTA receive failed {Route}", routeKey); + return BadRequest(ex.Message); + } + } + + private async Task ReadFirstFileAsync(CancellationToken ct) + { + if (!Request.HasFormContentType) return null; + var form = await Request.ReadFormAsync(ct); + return form.Files.FirstOrDefault(); + } + + private static string SafeFileName(string raw, string fallback) + { + var safe = Path.GetFileName(raw); + if (string.IsNullOrWhiteSpace(safe)) safe = fallback; + foreach (var ch in Path.GetInvalidFileNameChars()) + safe = safe.Replace(ch, '_'); + return string.IsNullOrWhiteSpace(safe) ? fallback : safe; + } +} diff --git a/MiGu.Server/Fleet/AlarmCollector.cs b/MiGu.Server/Fleet/AlarmCollector.cs new file mode 100644 index 0000000..6d527fa --- /dev/null +++ b/MiGu.Server/Fleet/AlarmCollector.cs @@ -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; + +/// +/// 单例:轮询 SimpleLite 车辆 + 每车状态,读取「车体_AlarmInfo/车体_AlarmLevel」并对帐进 platform.db(vehicle_alarms)。 +/// 出现→开 active;文案变→更新;消失→置 cleared 并记录恢复时间/时长。永不删=完整历史;SimpleLite 离线仍可查最近记录。 +/// +public sealed class AlarmCollector +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly IHttpClientFactory _httpFactory; + private readonly SimpleLiteOptions _sl; + private readonly InternalTokenStore _token; + private readonly ILogger _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 sl, + InternalTokenStore token, + ILogger 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 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(); + 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(); + 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?> 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(); + 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 current, CancellationToken ct) + { + var now = DateTimeOffset.UtcNow; + var active = await db.VehicleAlarms.Where(a => a.Status == "active").ToListAsync(ct); + var activeByCar = new Dictionary(); + 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; + } +} + +/// 后台循环:定时采集车辆报警到 platform.db。 +public sealed class AlarmCollectorService : BackgroundService +{ + private readonly AlarmCollector _collector; + private readonly ILogger _log; + + public AlarmCollectorService(AlarmCollector collector, ILogger 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; } + } + } +} diff --git a/MiGu.Server/Fleet/CdmTaskRecord.cs b/MiGu.Server/Fleet/CdmTaskRecord.cs new file mode 100644 index 0000000..69a5973 --- /dev/null +++ b/MiGu.Server/Fleet/CdmTaskRecord.cs @@ -0,0 +1,33 @@ +namespace MiGu.Server.Fleet; + +/// +/// CDM 搬运任务的平台侧快照(表 cdm_tasks)。 +/// 以任务 Id 为主键;SimpleLite/StandardScene 把终态任务从自身 JSON 里删除,这里则永久保留=完整历史, +/// 且 SimpleLite 关闭后平台仍可从本表读取最近快照。 +/// +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; } + /// 下发/开始/结束时间:直接存投影返回的 ISO 字符串(可空)。 + 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; } + /// 平台首次/最近一次同步到该任务的时间。 + public DateTimeOffset FirstSeenAt { get; set; } + public DateTimeOffset LastSeenAt { get; set; } +} diff --git a/MiGu.Server/Fleet/CdmTaskSync.cs b/MiGu.Server/Fleet/CdmTaskSync.cs new file mode 100644 index 0000000..1609426 --- /dev/null +++ b/MiGu.Server/Fleet/CdmTaskSync.cs @@ -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; + +/// 投影 /projection/deliveries 返回的单行(camelCase)。 +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; } +} + +/// +/// 单例:从 SimpleLite 投影拉取 CDM 任务并 upsert 到 platform.db(cdm_tasks),永不删除=保留历史。 +/// 同时维护「SimpleLite 是否在线 / 最近同步时间」,供任务页离线降级展示。 +/// +public sealed class CdmTaskSyncer +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly IHttpClientFactory _httpFactory; + private readonly SimpleLiteOptions _sl; + private readonly InternalTokenStore _token; + private readonly ILogger _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 sl, + InternalTokenStore token, + ILogger log) + { + _scopeFactory = scopeFactory; + _httpFactory = httpFactory; + _sl = sl.Value; + _token = token; + _log = log; + } + + /// 拉取 + 落库一次。并发调用时若已有同步在进行则直接跳过(返回当前在线状态)。 + public async Task 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(); + 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?> 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>(text, JsonOpt) ?? new List(); + } + catch (Exception ex) + { + _log.LogDebug(ex, "cdm fetch failed"); + return null; + } + } + + private static async Task UpsertAsync(PlatformDbContext db, IReadOnlyList 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; + } +} + +/// 后台循环:定时把 CDM 任务同步进 platform.db,保证无人打开页面时也能捕获终态历史。 +public sealed class CdmTaskSyncService : BackgroundService +{ + private readonly CdmTaskSyncer _syncer; + private readonly ILogger _log; + + public CdmTaskSyncService(CdmTaskSyncer syncer, ILogger 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; } + } + } +} diff --git a/MiGu.Server/Fleet/FleetHealthModels.cs b/MiGu.Server/Fleet/FleetHealthModels.cs new file mode 100644 index 0000000..0a229db --- /dev/null +++ b/MiGu.Server/Fleet/FleetHealthModels.cs @@ -0,0 +1,21 @@ +namespace MiGu.Server.Fleet; + +/// 与 SimpleLite GET /projection/fleet/health 行对齐,供车队运维前端消费。 +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; } + /// latency 探测通道:watchdog | onboard | none + public string? LatencySource { get; set; } +} diff --git a/MiGu.Server/Fleet/FleetHealthService.cs b/MiGu.Server/Fleet/FleetHealthService.cs new file mode 100644 index 0000000..4eab899 --- /dev/null +++ b/MiGu.Server/Fleet/FleetHealthService.cs @@ -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; + +/// +/// 车队健康:保留 SimpleLite 的 CPU/故障率等,延迟改为对 WatchDog(:9776) 做 TCP RTT。 +/// SimpleLite /fleet/health 探测的是车载 HTTP :8081,多数现场未开该端口会假超时 2000ms。 +/// +public sealed class FleetHealthService +{ + private readonly IHttpClientFactory _httpFactory; + private readonly SimpleLiteOptions _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 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> 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>(text, JsonOpt); + return list ?? new(); + } + catch (Exception ex) + { + _log.LogDebug(ex, "fleet/health from SimpleLite 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(); + } + } +} diff --git a/MiGu.Server/Fleet/VehicleAlarmRecord.cs b/MiGu.Server/Fleet/VehicleAlarmRecord.cs new file mode 100644 index 0000000..a24ad3f --- /dev/null +++ b/MiGu.Server/Fleet/VehicleAlarmRecord.cs @@ -0,0 +1,29 @@ +namespace MiGu.Server.Fleet; + +/// +/// 车辆报警的平台侧记录(表 vehicle_alarms)。 +/// SimpleLite 只在 SSE/状态里给出「当前是否报警 + 文案」,无历史;平台按车对帐: +/// 出现报警→开一条 active 记录,报警文案变化→更新,报警消失→置为 cleared 并记录恢复时间/持续时长。 +/// 永不删除=完整历史,重启/刷新不丢,SimpleLite 离线也可查。 +/// +public sealed class VehicleAlarmRecord +{ + public string Id { get; set; } = Guid.NewGuid().ToString("D"); + public int CarId { get; set; } + public string CarName { get; set; } = ""; + /// 报警文案(车体_AlarmInfo)。 + public string Info { get; set; } = ""; + /// 报警级别(车体_AlarmLevel,未知为 0)。 + public int Level { get; set; } + /// active | cleared + public string Status { get; set; } = "active"; + public DateTimeOffset FirstAt { get; set; } + public DateTimeOffset LastAt { get; set; } + public DateTimeOffset? ResolvedAt { get; set; } + /// 持续时长(秒),恢复后写入。 + public long? DurationSecs { get; set; } + /// 预留:平台侧确认(不代表车端消警)。 + public bool Acknowledged { get; set; } + public DateTimeOffset? AcknowledgedAt { get; set; } + public string? AcknowledgedBy { get; set; } +} diff --git a/MiGu.Server/Ota/OtaHash.cs b/MiGu.Server/Ota/OtaHash.cs new file mode 100644 index 0000000..cf75a6f --- /dev/null +++ b/MiGu.Server/Ota/OtaHash.cs @@ -0,0 +1,26 @@ +using System.Security.Cryptography; + +namespace MiGu.Server.Ota; + +public static class OtaHash +{ + /// 与参考 OTA 工具一致:MD5 → Base64,并去掉 '-'。 + public static string OfFile(string path) + { + using var fs = File.OpenRead(path); + var hash = MD5.HashData(fs); + return Convert.ToBase64String(hash).Replace("-", "", StringComparison.Ordinal); + } + + public static string OfBytes(ReadOnlySpan bytes) + { + var hash = MD5.HashData(bytes); + return Convert.ToBase64String(hash).Replace("-", "", StringComparison.Ordinal); + } + + public static string Short(string? hash, int len = 8) + { + if (string.IsNullOrEmpty(hash)) return "—"; + return hash.Length <= len ? hash : hash[..len]; + } +} diff --git a/MiGu.Server/Ota/OtaJobRunner.cs b/MiGu.Server/Ota/OtaJobRunner.cs new file mode 100644 index 0000000..32fbffd --- /dev/null +++ b/MiGu.Server/Ota/OtaJobRunner.cs @@ -0,0 +1,433 @@ +using System.Collections.Concurrent; + +namespace MiGu.Server.Ota; + +public sealed class OtaJobRunner +{ + private readonly OtaStore _store; + private readonly WatchDogClient _wd; + private readonly OtaVehicleSource _vehicles; + private readonly ILogger _log; + private readonly ConcurrentDictionary _running = new(); + + public OtaJobRunner(OtaStore store, WatchDogClient wd, OtaVehicleSource vehicles, ILogger log) + { + _store = store; + _wd = wd; + _vehicles = vehicles; + _log = log; + } + + public OtaJob EnqueueSync(CreateSyncJobRequest req, string? user) + { + var target = _store.GetTarget() ?? throw new InvalidOperationException("未设置目标版本,请先在版本库激活"); + var components = (req.Components is { Count: > 0 } ? req.Components : OtaPathMap.ComponentKeys.ToList()) + .Where(c => target.Components.ContainsKey(c)) + .ToList(); + if (components.Count == 0) throw new InvalidOperationException("目标版本中无选定组件"); + if (req.CarIds.Count == 0) throw new InvalidOperationException("请选择车辆"); + + var settings = _store.GetSettings(); + var job = new OtaJob + { + Id = _store.NextJobId(), + Kind = "sync", + Status = "pending", + CreatedAt = DateTimeOffset.UtcNow, + CreatedBy = user, + PackageId = target.PackageId, + CarIds = req.CarIds.ToList(), + Components = components, + RequireLatencyCheck = req.RequireLatencyCheck ?? settings.LatencyEnabled + }; + foreach (var carId in job.CarIds) + foreach (var comp in components) + job.Steps.Add(new OtaJobStep { CarId = carId, Component = comp, Status = "pending" }); + job.TotalSteps = job.Steps.Count; + _store.SaveJob(job); + _ = Task.Run(() => RunAsync(job.Id)); + return job; + } + + public OtaJob EnqueueCustomFile(CreateCustomFileJobRequest req, IReadOnlyList files, string? user) + { + if (req.CarIds.Count == 0) throw new InvalidOperationException("请选择车辆"); + if (files.Count == 0) throw new InvalidOperationException("请至少添加一个本地文件"); + if (string.IsNullOrWhiteSpace(req.RemotePath)) throw new InvalidOperationException("请填写小车内目标路径"); + var settings = _store.GetSettings(); + var ops = req.RestartOps is { Count: > 0 } + ? req.RestartOps.Distinct().ToList() + : new List { req.RestartOp }; + if (ops.Count == 0) ops.Add(-1); + + var job = new OtaJob + { + Id = _store.NextJobId(), + Kind = "customFile", + Status = "pending", + CreatedAt = DateTimeOffset.UtcNow, + CreatedBy = user, + CarIds = req.CarIds.ToList(), + CustomFileName = files[0].FileName, + CustomRemotePath = req.RemotePath, + CustomRestartOp = ops[0], + CustomLocalPath = files[0].LocalPath, + CustomFiles = files.ToList(), + CustomRestartOps = ops, + RequireLatencyCheck = req.RequireLatencyCheck ?? settings.LatencyEnabled, + Components = new List { "custom" } + }; + foreach (var carId in job.CarIds) + job.Steps.Add(new OtaJobStep { CarId = carId, Component = $"custom×{files.Count}", Status = "pending" }); + job.TotalSteps = job.Steps.Count; + _store.SaveJob(job); + _ = Task.Run(() => RunAsync(job.Id)); + return job; + } + + public OtaJob EnqueueConfigPush(CreateConfigPushRequest req, string? user) + { + if (req.CarIds.Count == 0) throw new InvalidOperationException("请选择车辆"); + var app = req.App.Trim().ToLowerInvariant(); + if (app is not ("medulla" or "detour" or "clumsy")) + throw new InvalidOperationException("app 须为 medulla|detour|clumsy"); + var settings = _store.GetSettings(); + var job = new OtaJob + { + Id = _store.NextJobId(), + Kind = "configPush", + Status = "pending", + CreatedAt = DateTimeOffset.UtcNow, + CreatedBy = user, + CarIds = req.CarIds.ToList(), + ConfigApp = app, + ConfigJson = req.Json, + RequireLatencyCheck = req.RequireLatencyCheck ?? settings.LatencyEnabled, + Components = new List { $"config:{app}" } + }; + foreach (var carId in job.CarIds) + job.Steps.Add(new OtaJobStep { CarId = carId, Component = $"config:{app}", Status = "pending" }); + job.TotalSteps = job.Steps.Count; + _store.SaveJob(job); + _ = Task.Run(() => RunAsync(job.Id)); + return job; + } + + public bool Cancel(string jobId) + { + var job = _store.GetJob(jobId); + if (job == null) return false; + if (job.Status is "succeeded" or "failed" or "partial" or "cancelled") return false; + // 正在运行:只发取消信号,由 RunAsync 统一收尾,避免与运行线程并发写同一 job 文件。 + if (_running.TryGetValue(jobId, out var cts)) + { + cts.Cancel(); + return true; + } + // 尚未开始(Task.Run 排队中):直接落盘取消;RunAsync 启动时有 status 守卫会跳过执行。 + foreach (var step in job.Steps.Where(s => s.Status == "pending")) + { + step.Status = "skipped"; + step.Error = "已取消"; + } + job.Status = "cancelled"; + job.FinishedAt = DateTimeOffset.UtcNow; + job.DoneSteps = job.Steps.Count(s => s.Status is "succeeded" or "failed" or "skipped"); + _store.SaveJob(job); + return true; + } + + public OtaJob? RetryFailed(string jobId, string? user) + { + var old = _store.GetJob(jobId); + if (old == null) return null; + var failedCars = old.Steps.Where(s => s.Status == "failed").Select(s => s.CarId).Distinct().ToList(); + if (failedCars.Count == 0) throw new InvalidOperationException("没有失败项可重试"); + + return old.Kind switch + { + "sync" => EnqueueSync(new CreateSyncJobRequest + { + CarIds = failedCars, + Components = old.Components, + RequireLatencyCheck = old.RequireLatencyCheck + }, user), + "customFile" when ResolveCustomFiles(old).Count > 0 => + EnqueueCustomFile(new CreateCustomFileJobRequest + { + CarIds = failedCars, + RemotePath = old.CustomRemotePath ?? "", + RestartOps = old.CustomRestartOps is { Count: > 0 } + ? old.CustomRestartOps + : new List { old.CustomRestartOp ?? -1 }, + RequireLatencyCheck = old.RequireLatencyCheck + }, ResolveCustomFiles(old), user), + "configPush" => EnqueueConfigPush(new CreateConfigPushRequest + { + CarIds = failedCars, + App = old.ConfigApp ?? "", + Json = old.ConfigJson ?? "", + RequireLatencyCheck = old.RequireLatencyCheck + }, user), + _ => throw new InvalidOperationException("无法重试该任务类型或文件已丢失") + }; + } + + private async Task RunAsync(string jobId) + { + var cts = new CancellationTokenSource(); + if (!_running.TryAdd(jobId, cts)) return; + // 单一内存实例贯穿全程;所有「改状态 + 落盘」都在 jobLock 下串行,杜绝并发车批次的丢更新。 + var jobLock = new object(); + try + { + var job = _store.GetJob(jobId); + if (job == null) return; + // 守卫:排队期间被取消 / 已终结的任务不再执行。 + if (job.Status is "cancelled" or "succeeded" or "failed" or "partial") return; + var settings = _store.GetSettings(); + var cars = await _vehicles.ListCarsAsync(cts.Token); + var byId = cars.ToDictionary(c => c.Id, StringComparer.OrdinalIgnoreCase); + + job.Status = job.RequireLatencyCheck ? "probing" : "running"; + _store.SaveJob(job); + + // 解析 IP + foreach (var step in job.Steps) + { + if (byId.TryGetValue(step.CarId, out var car)) + step.Ip = car.Ip; + } + + if (job.RequireLatencyCheck) + { + var threshold = settings.RttThresholdMs; + var overMode = settings.OverThreshold; + foreach (var carId in job.CarIds.Distinct()) + { + var ip = job.Steps.FirstOrDefault(s => s.CarId == carId)?.Ip; + if (string.IsNullOrEmpty(ip)) + { + SkipCar(job, carId, "无 IP"); + continue; + } + var rtt = await _wd.MeasureRttMsAsync(ip, cts.Token); + if (rtt == null || rtt > threshold) + { + if (string.Equals(overMode, "skip", StringComparison.OrdinalIgnoreCase) || rtt == null) + SkipCar(job, carId, rtt == null ? "延迟探测失败" : $"RTT {rtt}ms > {threshold}ms"); + } + } + _store.SaveJob(job); + } + + job.Status = "running"; + _store.SaveJob(job); + + var maxCar = Math.Max(1, settings.MaxCar); + var carGroups = job.CarIds.Distinct() + .Where(id => job.Steps.Any(s => s.CarId == id && s.Status == "pending")) + .Chunk(maxCar); + + foreach (var batch in carGroups) + { + if (cts.IsCancellationRequested) break; + var tasks = batch.Select(carId => RunCarAsync(job, jobLock, carId, settings.BandwidthKbps, cts.Token)); + await Task.WhenAll(tasks); + } + + lock (jobLock) + { + if (cts.IsCancellationRequested) + { + foreach (var s in job.Steps.Where(s => s.Status is "pending" or "running")) + { + s.Status = "skipped"; + s.Error ??= "已取消"; + } + job.Status = "cancelled"; + job.FinishedAt = DateTimeOffset.UtcNow; + Recalc(job); + } + else + { + Finalize(job); + } + _store.SaveJob(job); + } + } + catch (Exception ex) + { + _log.LogError(ex, "OTA job {Id} crashed", jobId); + var job = _store.GetJob(jobId); + if (job != null) + { + job.Status = "failed"; + job.Message = ex.Message; + job.FinishedAt = DateTimeOffset.UtcNow; + _store.SaveJob(job); + } + } + finally + { + _running.TryRemove(jobId, out _); + cts.Dispose(); + } + } + + private static void SkipCar(OtaJob job, string carId, string reason) + { + foreach (var step in job.Steps.Where(s => s.CarId == carId && s.Status == "pending")) + { + step.Status = "skipped"; + step.Error = reason; + } + } + + private static List ResolveCustomFiles(OtaJob job) + { + if (job.CustomFiles is { Count: > 0 }) + return job.CustomFiles.Where(f => File.Exists(f.LocalPath)).ToList(); + if (!string.IsNullOrEmpty(job.CustomLocalPath) && File.Exists(job.CustomLocalPath)) + { + return new List + { + new() + { + LocalPath = job.CustomLocalPath, + FileName = job.CustomFileName ?? Path.GetFileName(job.CustomLocalPath) + } + }; + } + return new(); + } + + private async Task UploadCustomFilesForCarAsync(OtaJob job, string ip, int bandwidth, CancellationToken ct) + { + var files = ResolveCustomFiles(job); + if (files.Count == 0) throw new InvalidOperationException("自定义文件已丢失"); + var remote = job.CustomRemotePath ?? ""; + var ops = job.CustomRestartOps is { Count: > 0 } + ? job.CustomRestartOps + : new List { job.CustomRestartOp ?? -1 }; + + // 非末文件一律 -1;末文件按所选重启项依次再传(对齐 CarOTA.App) + for (var i = 0; i < files.Count; i++) + { + var f = files[i]; + var isLast = i == files.Count - 1; + if (!isLast) + { + await _wd.UploadCustomFileAsync(ip, f.LocalPath, f.FileName, remote, -1, bandwidth, ct); + continue; + } + + if (ops.Count == 1 && ops[0] == -1) + { + await _wd.UploadCustomFileAsync(ip, f.LocalPath, f.FileName, remote, -1, bandwidth, ct); + } + else + { + foreach (var op in ops.Where(o => o != -1).DefaultIfEmpty(-1)) + await _wd.UploadCustomFileAsync(ip, f.LocalPath, f.FileName, remote, op, bandwidth, ct); + } + } + } + + private async Task RunCarAsync(OtaJob job, object jobLock, string carId, int bandwidth, CancellationToken ct) + { + // 只处理本车 step;同批其他车任务并行修改各自 step,共享同一 job 实例,写盘统一在 jobLock 下串行。 + var steps = job.Steps.Where(s => s.CarId == carId && s.Status == "pending").ToList(); + if (steps.Count == 0) return; + var ip = steps[0].Ip; + if (string.IsNullOrEmpty(ip)) + { + lock (jobLock) + { + foreach (var s in steps) { s.Status = "failed"; s.Error = "无 IP"; } + Recalc(job); + _store.SaveJob(job); + } + return; + } + + foreach (var step in steps) + { + if (ct.IsCancellationRequested) + { + lock (jobLock) + { + step.Status = "skipped"; + step.Error = "已取消"; + Recalc(job); + _store.SaveJob(job); + } + continue; + } + lock (jobLock) + { + step.Status = "running"; + Recalc(job); + _store.SaveJob(job); + } + try + { + switch (job.Kind) + { + case "sync": + { + var target = _store.GetTarget() ?? throw new InvalidOperationException("目标版本丢失"); + if (!target.Components.TryGetValue(step.Component, out var art)) + throw new InvalidOperationException($"组件 {step.Component} 不在目标中"); + await _wd.UploadComponentAsync(ip, step.Component, art.Path, art.FileName, bandwidth, ct); + break; + } + case "customFile": + await UploadCustomFilesForCarAsync(job, ip, bandwidth, ct); + break; + case "configPush": + await _wd.PutJsonAsync(ip, job.ConfigApp!, job.ConfigJson!, ct); + break; + } + lock (jobLock) + { + step.Status = "succeeded"; + step.Error = null; + Recalc(job); + _store.SaveJob(job); + } + } + catch (Exception ex) + { + lock (jobLock) + { + step.Status = ct.IsCancellationRequested ? "skipped" : "failed"; + step.Error = ct.IsCancellationRequested ? "已取消" : ex.Message; + Recalc(job); + _store.SaveJob(job); + } + _log.LogWarning(ex, "OTA step fail {Job} {Car} {Comp}", job.Id, carId, step.Component); + } + } + } + + private static void Recalc(OtaJob job) + { + job.DoneSteps = job.Steps.Count(s => s.Status is "succeeded" or "failed" or "skipped"); + } + + private static void Finalize(OtaJob job) + { + Recalc(job); + job.FinishedAt = DateTimeOffset.UtcNow; + var anyFail = job.Steps.Any(s => s.Status == "failed"); + var anyOk = job.Steps.Any(s => s.Status == "succeeded"); + var anyPending = job.Steps.Any(s => s.Status is "pending" or "running"); + if (job.Status == "cancelled") return; + if (anyPending) job.Status = "partial"; + else if (anyFail && anyOk) job.Status = "partial"; + else if (anyFail) job.Status = "failed"; + else if (anyOk) job.Status = "succeeded"; + else job.Status = "cancelled"; + } +} diff --git a/MiGu.Server/Ota/OtaModels.cs b/MiGu.Server/Ota/OtaModels.cs new file mode 100644 index 0000000..24fdc4f --- /dev/null +++ b/MiGu.Server/Ota/OtaModels.cs @@ -0,0 +1,144 @@ +namespace MiGu.Server.Ota; + +public sealed class OtaSettings +{ + public int BandwidthKbps { get; set; } + public int MaxCar { get; set; } = 2; + public bool LatencyEnabled { get; set; } + public int RttThresholdMs { get; set; } = 200; + /// skip | confirm + public string OverThreshold { get; set; } = "skip"; + public int BackupPeriodMinutes { get; set; } = 60; + public bool BackupExe { get; set; } + public string? NewVersionName { get; set; } +} + +public sealed class OtaFileArtifact +{ + public string Hash { get; set; } = ""; + public string Path { get; set; } = ""; + public string FileName { get; set; } = ""; + public string? Time { get; set; } + public long Size { get; set; } +} + +public sealed class OtaTarget +{ + public string PackageId { get; set; } = ""; + public string? Name { get; set; } + public DateTimeOffset ActivatedAt { get; set; } + public Dictionary Components { get; set; } = new(StringComparer.OrdinalIgnoreCase); +} + +public sealed class OtaPackageInfo +{ + public string Id { get; set; } = ""; + public string? SourceIp { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public long TotalBytes { get; set; } + public bool IsTarget { get; set; } + public Dictionary Components { get; set; } = new(StringComparer.OrdinalIgnoreCase); +} + +public sealed class OtaComponentVersion +{ + public string? Version { get; set; } + public string? Time { get; set; } +} + +public sealed class OtaAppVersions +{ + public OtaComponentVersion? Exe { get; set; } + public OtaComponentVersion? Dll { get; set; } + public OtaComponentVersion? Pdb { get; set; } +} + +public sealed class OtaVehicleRow +{ + public string Id { get; set; } = ""; + public string Name { get; set; } = ""; + public string? Ip { get; set; } + public string? State { get; set; } + public string? Group { get; set; } + public bool Reachable { get; set; } + public int? RttMs { get; set; } + public OtaAppVersions? Medulla { get; set; } + public OtaAppVersions? Detour { get; set; } + public OtaAppVersions? Clumsy { get; set; } + public Dictionary? Match { get; set; } +} + +public sealed class OtaJobStep +{ + public string CarId { get; set; } = ""; + public string? Ip { get; set; } + public string Component { get; set; } = ""; + public string Status { get; set; } = "pending"; + public string? Error { get; set; } +} + +public sealed class OtaJob +{ + public string Id { get; set; } = ""; + /// sync | customFile | configPush + public string Kind { get; set; } = "sync"; + public string Status { get; set; } = "pending"; + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset? FinishedAt { get; set; } + public string? CreatedBy { get; set; } + public string? PackageId { get; set; } + public List CarIds { get; set; } = new(); + public List Components { get; set; } = new(); + public bool RequireLatencyCheck { get; set; } + public int DoneSteps { get; set; } + public int TotalSteps { get; set; } + public List Steps { get; set; } = new(); + public string? Message { get; set; } + // customFile + public string? CustomFileName { get; set; } + public string? CustomRemotePath { get; set; } + public int? CustomRestartOp { get; set; } + public string? CustomLocalPath { get; set; } + public List CustomFiles { get; set; } = new(); + public List CustomRestartOps { get; set; } = new() { -1 }; + // configPush + public string? ConfigApp { get; set; } + public string? ConfigJson { get; set; } +} + +public sealed class CreateSyncJobRequest +{ + public List CarIds { get; set; } = new(); + public List? Components { get; set; } + public bool? RequireLatencyCheck { get; set; } +} + +public sealed class PullPackageRequest +{ + public string CarId { get; set; } = ""; +} + +public sealed class CreateCustomFileJobRequest +{ + public List CarIds { get; set; } = new(); + public string RemotePath { get; set; } = ""; + /// 兼容旧单值;优先用 RestartOps。 + public int RestartOp { get; set; } = -1; + /// -1 不重启;0 Medulla;1 Clumsy;2 Detour;3 WatchDog。可多选。 + public List? RestartOps { get; set; } + public bool? RequireLatencyCheck { get; set; } +} + +public sealed class OtaCustomFileItem +{ + public string LocalPath { get; set; } = ""; + public string FileName { get; set; } = ""; +} + +public sealed class CreateConfigPushRequest +{ + public List CarIds { get; set; } = new(); + public string App { get; set; } = ""; + public string Json { get; set; } = ""; + public bool? RequireLatencyCheck { get; set; } +} diff --git a/MiGu.Server/Ota/OtaOptions.cs b/MiGu.Server/Ota/OtaOptions.cs new file mode 100644 index 0000000..abea524 --- /dev/null +++ b/MiGu.Server/Ota/OtaOptions.cs @@ -0,0 +1,24 @@ +namespace MiGu.Server.Ota; + +public sealed class OtaOptions +{ + /// 相对 ContentRoot 或绝对路径;默认 data/ota + public string DataRoot { get; set; } = "data/ota"; + + public int WatchDogPort { get; set; } = 9776; + + public int RequestTimeoutMs { get; set; } = 60_000; + + public int UploadTimeoutMs { get; set; } = 600_000; + + /// + /// WatchDog 回传监听端口(写死连 :8000)。MiGu 会额外监听该端口并挂 /upload-mdcs/*。 + /// + public int ReceivePort { get; set; } = 8000; + + /// + /// 可选:本机对车辆可见的管理面基址(如 http://192.168.1.10:8080)。 + /// 注意:现网 WatchDog 忽略 getmdcsexe 的 server 参数,仍回传到 config.serverIP:ReceivePort。 + /// + public string? PublicBaseUrl { get; set; } +} diff --git a/MiGu.Server/Ota/OtaPathMap.cs b/MiGu.Server/Ota/OtaPathMap.cs new file mode 100644 index 0000000..5c0e6eb --- /dev/null +++ b/MiGu.Server/Ota/OtaPathMap.cs @@ -0,0 +1,53 @@ +namespace MiGu.Server.Ota; + +/// MDC 组件文件映射(对齐参考工具 MDCPath.json)。 +public static class OtaPathMap +{ + public static readonly IReadOnlyDictionary Default = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["M"] = new[] { "Medulla.exe", "plugins\\CartActivator.dll", "plugins\\CartActivator.pdb" }, + ["D"] = new[] { "Detour.exe" }, + ["C"] = new[] { "ClumsyConsole.exe", "FG2305014_C.dll", "FG2305014_C.pdb" } + }; + + public static readonly string[] ComponentKeys = + { + "M.exe", "M.dll", "M.pdb", "D.exe", "C.exe", "C.dll", "C.pdb" + }; + + public static string? RelPathFor(string componentKey) + { + return componentKey switch + { + "M.exe" => "M/Medulla.exe", + "M.dll" => "M/plugins/CartActivator.dll", + "M.pdb" => "M/plugins/CartActivator.pdb", + "D.exe" => "D/Detour.exe", + "C.exe" => "C/ClumsyConsole.exe", + "C.dll" => "C/FG2305014_C.dll", + "C.pdb" => "C/FG2305014_C.pdb", + _ => null + }; + } + + public static string? WatchDogUpdatePath(string componentKey) => componentKey switch + { + "M.exe" => "updateMedullaExecutable", + "M.dll" => "updateMedullaDll", + "M.pdb" => "UpdateMedullaPdb", + "D.exe" => "updateDetourExecutable", + "C.exe" => "updateClumsyExecutable", + "C.dll" => "updateClumsyDll", + "C.pdb" => "UpdateClumsyPdb", + _ => null + }; + + public static string AppFolder(string componentKey) => componentKey.StartsWith('M') ? "M" + : componentKey.StartsWith('D') ? "D" : "C"; + + public static string ExtKey(string componentKey) + { + var i = componentKey.IndexOf('.'); + return i >= 0 ? componentKey[(i + 1)..] : componentKey; + } +} diff --git a/MiGu.Server/Ota/OtaStore.cs b/MiGu.Server/Ota/OtaStore.cs new file mode 100644 index 0000000..55b22eb --- /dev/null +++ b/MiGu.Server/Ota/OtaStore.cs @@ -0,0 +1,368 @@ +using System.Text.Json; +using MiGu.Server.Infra; +using Microsoft.Extensions.Options; + +namespace MiGu.Server.Ota; + +public sealed class OtaStore +{ + private readonly object _gate = new(); + private readonly ILogger _log; + private readonly JsonSerializerOptions _json = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + public string Root { get; } + public string PackagesDir { get; } + public string JobsDir { get; } + public string HistoryDir { get; } + public string TargetFile { get; } + public string SettingsFile { get; } + + private string? _lastPullId; + private readonly System.Collections.Concurrent.ConcurrentDictionary _pullByIp = + new(StringComparer.OrdinalIgnoreCase); + private long _jobSeq; + + public OtaStore(IWebHostEnvironment env, IOptions options, ILogger log) + { + _log = log; + var cfg = options.Value.DataRoot; + Root = Path.IsPathRooted(cfg) ? cfg : Path.Combine(env.ContentRootPath, cfg); + PackagesDir = Path.Combine(Root, "packages"); + JobsDir = Path.Combine(Root, "jobs"); + HistoryDir = Path.Combine(Root, "history"); + TargetFile = Path.Combine(Root, "target.json"); + SettingsFile = Path.Combine(Root, "settings.json"); + Directory.CreateDirectory(PackagesDir); + Directory.CreateDirectory(JobsDir); + Directory.CreateDirectory(HistoryDir); + } + + public OtaSettings GetSettings() + { + lock (_gate) + { + if (!File.Exists(SettingsFile)) return new OtaSettings(); + try + { + return JsonSerializer.Deserialize(File.ReadAllText(SettingsFile), _json) ?? new OtaSettings(); + } + catch (Exception ex) + { + _log.LogWarning(ex, "OTA settings load failed"); + return new OtaSettings(); + } + } + } + + public OtaSettings SaveSettings(OtaSettings settings) + { + lock (_gate) + { + AtomicFile.WriteAllText(SettingsFile, JsonSerializer.Serialize(settings, _json)); + return settings; + } + } + + public OtaTarget? GetTarget() + { + lock (_gate) + { + if (!File.Exists(TargetFile)) return null; + try + { + return JsonSerializer.Deserialize(File.ReadAllText(TargetFile), _json); + } + catch (Exception ex) + { + _log.LogWarning(ex, "OTA target load failed"); + return null; + } + } + } + + public void SetTarget(OtaTarget target) + { + lock (_gate) + { + AtomicFile.WriteAllText(TargetFile, JsonSerializer.Serialize(target, _json)); + var settings = GetSettingsUnlocked(); + if (!string.IsNullOrWhiteSpace(target.Name)) + { + settings.NewVersionName = target.Name; + AtomicFile.WriteAllText(SettingsFile, JsonSerializer.Serialize(settings, _json)); + } + } + } + + private OtaSettings GetSettingsUnlocked() + { + if (!File.Exists(SettingsFile)) return new OtaSettings(); + try + { + return JsonSerializer.Deserialize(File.ReadAllText(SettingsFile), _json) ?? new OtaSettings(); + } + catch + { + return new OtaSettings(); + } + } + + public string BeginPullPackage(string? sourceIp) + { + lock (_gate) + { + var baseId = $"{DateTime.UtcNow:yyyyMMddHHmmssfff}({SafeIdPart(sourceIp ?? "upload")})"; + var id = baseId; + var dir = Path.Combine(PackagesDir, id); + for (var i = 1; Directory.Exists(dir); i++) + { + id = $"{baseId}-{i:D2}"; + dir = Path.Combine(PackagesDir, id); + } + Directory.CreateDirectory(dir); + foreach (var app in new[] { "M", "D", "C" }) + Directory.CreateDirectory(Path.Combine(dir, app)); + Directory.CreateDirectory(Path.Combine(dir, "M", "plugins")); + var ip = NormalizeIp(sourceIp); + if (ip != null) _pullByIp[ip] = id; + _lastPullId = id; + return id; + } + } + + public string? ActivePullId + { + get { lock (_gate) return _lastPullId; } + } + + public bool TryGetActivePullId(string? sourceIp, out string? id) + { + lock (_gate) + { + var ip = NormalizeIp(sourceIp); + if (ip != null && _pullByIp.TryGetValue(ip, out var byIp)) + { + id = byIp; + return true; + } + + if (ip == null && _lastPullId != null) + { + id = _lastPullId; + return true; + } + + id = null; + return false; + } + } + + public void ClearActivePull(string? sourceIp = null) + { + lock (_gate) + { + var ip = NormalizeIp(sourceIp); + if (ip != null) _pullByIp.TryRemove(ip, out _); + if (_pullByIp.IsEmpty) _lastPullId = null; + } + } + + /// 把 WatchDog 回传连接的远端 IP 归一(去掉 IPv6 映射前缀,如 ::ffff:192.168.1.13)。 + private static string? NormalizeIp(string? ip) + { + if (string.IsNullOrWhiteSpace(ip)) return null; + if (System.Net.IPAddress.TryParse(ip, out var addr)) + return (addr.IsIPv4MappedToIPv6 ? addr.MapToIPv4() : addr).ToString(); + return ip.Trim(); + } + + public string ResolveReceivePath(string routeKey, string? clientIp = null) + { + // routeKey examples: Medullaexe, Medulladll, Detourexe, ClumsyConsoleexe, ClumsyConsoledll + // Do not fall back to the latest session for a known-but-unregistered client. + if (!TryGetActivePullId(clientIp, out var id) || id == null) + throw new InvalidOperationException("无进行中的拉取会话"); + var dir = Path.Combine(PackagesDir, id); + return routeKey.ToLowerInvariant() switch + { + "medullaexe" => Path.Combine(dir, "M", "Medulla.exe"), + "medulladll" => Path.Combine(dir, "M", "plugins", "CartActivator.dll"), + "medullapdb" => Path.Combine(dir, "M", "plugins", "CartActivator.pdb"), + "detourexe" => Path.Combine(dir, "D", "Detour.exe"), + "clumsyconsoleexe" or "clumsyexe" => Path.Combine(dir, "C", "ClumsyConsole.exe"), + "clumsyconsoledll" or "clumsydll" => Path.Combine(dir, "C", "FG2305014_C.dll"), + "clumsyconsolepdb" or "clumsypdb" => Path.Combine(dir, "C", "FG2305014_C.pdb"), + _ => throw new ArgumentException($"未知接收路由: {routeKey}") + }; + } + + public OtaPackageInfo ScanPackage(string id) + { + var dir = ResolveUnder(PackagesDir, id); + if (!Directory.Exists(dir)) throw new DirectoryNotFoundException(id); + var info = new OtaPackageInfo + { + Id = id, + CreatedAt = Directory.GetCreationTimeUtc(dir), + SourceIp = ExtractSourceIp(id) + }; + long total = 0; + foreach (var key in OtaPathMap.ComponentKeys) + { + var rel = OtaPathMap.RelPathFor(key); + if (rel == null) continue; + var path = Path.Combine(dir, rel.Replace('/', Path.DirectorySeparatorChar)); + if (!File.Exists(path)) continue; + var fi = new FileInfo(path); + total += fi.Length; + info.Components[key] = new OtaFileArtifact + { + Hash = OtaHash.OfFile(path), + Path = path, + FileName = Path.GetFileName(path), + Time = fi.LastWriteTime.ToString("yyyy-MM-dd HH:mm:ss"), + Size = fi.Length + }; + } + info.TotalBytes = total; + var target = GetTarget(); + info.IsTarget = target != null && string.Equals(target.PackageId, id, StringComparison.Ordinal); + return info; + } + + public List ListPackages() + { + if (!Directory.Exists(PackagesDir)) return new(); + var list = new List(); + foreach (var dir in Directory.GetDirectories(PackagesDir).OrderByDescending(d => d)) + { + try { list.Add(ScanPackage(Path.GetFileName(dir))); } + catch (Exception ex) { _log.LogDebug(ex, "skip package {Dir}", dir); } + } + return list; + } + + public void DeletePackage(string id) + { + lock (_gate) + { + var target = GetTarget(); + if (target != null && string.Equals(target.PackageId, id, StringComparison.Ordinal)) + throw new InvalidOperationException("不能删除当前目标版本"); + var dir = ResolveUnder(PackagesDir, id); + if (Directory.Exists(dir)) Directory.Delete(dir, true); + } + } + + public OtaTarget ActivatePackage(string id, string? name) + { + var pkg = ScanPackage(id); + if (pkg.Components.Count == 0) + throw new InvalidOperationException("包内无有效组件文件"); + var safeId = RequireSafeId(id); + var target = new OtaTarget + { + PackageId = safeId, + Name = name ?? safeId, + ActivatedAt = DateTimeOffset.UtcNow, + Components = pkg.Components + }; + SetTarget(target); + return target; + } + + public string PackageDir(string id) => ResolveUnder(PackagesDir, id); + + public void SaveJob(OtaJob job) + { + lock (_gate) + { + var path = ResolveUnder(JobsDir, $"{RequireSafeId(job.Id)}.json"); + AtomicFile.WriteAllText(path, JsonSerializer.Serialize(job, _json)); + } + } + + public OtaJob? GetJob(string id) + { + var path = ResolveUnder(JobsDir, $"{RequireSafeId(id)}.json"); + if (!File.Exists(path)) return null; + try + { + return JsonSerializer.Deserialize(File.ReadAllText(path), _json); + } + catch (Exception ex) + { + _log.LogWarning(ex, "job load failed {Id}", id); + return null; + } + } + + public List ListJobs(int take = 100) + { + if (!Directory.Exists(JobsDir)) return new(); + return Directory.GetFiles(JobsDir, "*.json") + .Select(f => + { + try { return JsonSerializer.Deserialize(File.ReadAllText(f), _json); } + catch { return null; } + }) + .Where(j => j != null) + .Cast() + .OrderByDescending(j => j.CreatedAt) + .Take(take) + .ToList(); + } + + public string NextJobId() + { + // 进程内自增序号保证唯一(同秒也不会撞 ID → 不会两个 job 写同一文件)。 + var seq = System.Threading.Interlocked.Increment(ref _jobSeq); + return $"J{DateTime.UtcNow:yyyyMMddHHmmss}-{seq:D4}"; + } + + private static string? ExtractSourceIp(string id) + { + var open = id.IndexOf('('); + var close = id.IndexOf(')'); + if (open >= 0 && close > open) return id[(open + 1)..close]; + return null; + } + + private static string SafeIdPart(string raw) + { + var safe = raw.Trim(); + foreach (var ch in Path.GetInvalidFileNameChars()) + safe = safe.Replace(ch, '_'); + return string.IsNullOrWhiteSpace(safe) ? "upload" : safe; + } + + /// 拒绝路径段(含 .. / 分隔符),只允许单层文件名。 + private static string RequireSafeId(string id) + { + if (string.IsNullOrWhiteSpace(id)) + throw new ArgumentException("无效标识"); + var trimmed = id.Trim(); + if (trimmed is "." or ".." + || trimmed.Contains('/') || trimmed.Contains('\\') + || trimmed.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + throw new ArgumentException("无效标识"); + return trimmed; + } + + private string ResolveUnder(string root, string id) + { + var safe = RequireSafeId(id); + var fullRoot = Path.GetFullPath(root); + var full = Path.GetFullPath(Path.Combine(fullRoot, safe)); + var prefix = fullRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + if (!full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + && !string.Equals(full, fullRoot, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("无效标识"); + return full; + } +} diff --git a/MiGu.Server/Ota/OtaVehicleSource.cs b/MiGu.Server/Ota/OtaVehicleSource.cs new file mode 100644 index 0000000..bc50cbf --- /dev/null +++ b/MiGu.Server/Ota/OtaVehicleSource.cs @@ -0,0 +1,141 @@ +using System.Text.Json; +using Microsoft.Extensions.Options; +using MiGu.Server.Launcher; + +namespace MiGu.Server.Ota; + +public sealed class OtaVehicleSource +{ + private readonly IHttpClientFactory _httpFactory; + private readonly SimpleLiteOptions _sl; + private readonly InternalTokenStoreAccessor _token; + private readonly ILogger _log; + + public OtaVehicleSource( + IHttpClientFactory httpFactory, + IOptions sl, + InternalTokenStoreAccessor token, + ILogger log) + { + _httpFactory = httpFactory; + _sl = sl.Value; + _token = token; + _log = log; + } + + public async Task> ListCarsAsync(CancellationToken ct) + { + var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222; + var cars = await TryProjectionAsync(port, ct); + if (cars.Count == 0) + cars = await TryAgvListAsync(port, ct); + return cars; + } + + private async Task> TryProjectionAsync(int port, CancellationToken ct) + { + try + { + using var client = _httpFactory.CreateClient(); + client.Timeout = TimeSpan.FromSeconds(8); + 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); + return ParseCars(text); + } + catch (Exception ex) + { + _log.LogDebug(ex, "projection/cars failed"); + return new(); + } + } + + private async Task> TryAgvListAsync(int port, CancellationToken ct) + { + try + { + using var client = _httpFactory.CreateClient(); + client.Timeout = TimeSpan.FromSeconds(8); + using var resp = await client.GetAsync($"http://127.0.0.1:{port}/api/agv/list", ct); + if (!resp.IsSuccessStatusCode) return new(); + var text = await resp.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(text); + var root = doc.RootElement; + var arr = root.ValueKind == JsonValueKind.Array ? root + : root.TryGetProperty("data", out var d) ? d + : root.TryGetProperty("items", out var i) ? i + : default; + if (arr.ValueKind != JsonValueKind.Array) return new(); + var list = new List(); + foreach (var el in arr.EnumerateArray()) + { + var id = GetStr(el, "agv_id", "id", "carId") ?? ""; + var name = GetStr(el, "agv_name", "name") ?? id; + var ip = GetStr(el, "agv_ip", "ip"); + var state = GetStr(el, "status", "state"); + if (string.IsNullOrEmpty(id) && string.IsNullOrEmpty(ip)) continue; + list.Add(new OtaVehicleRow + { + Id = string.IsNullOrEmpty(id) ? ip! : id, + Name = name, + Ip = ip, + State = state + }); + } + return list; + } + catch (Exception ex) + { + _log.LogDebug(ex, "agv/list failed"); + return new(); + } + } + + private static List ParseCars(string text) + { + using var doc = JsonDocument.Parse(text); + var root = doc.RootElement; + var arr = root.ValueKind == JsonValueKind.Array ? root + : root.TryGetProperty("cars", out var c) ? c + : root.TryGetProperty("items", out var i) ? i + : root.TryGetProperty("data", out var d) ? d + : default; + if (arr.ValueKind != JsonValueKind.Array) return new(); + var list = new List(); + foreach (var el in arr.EnumerateArray()) + { + var id = GetStr(el, "id", "carId", "rawId") ?? ""; + var name = GetStr(el, "name") ?? id; + var ip = GetStr(el, "ip"); + var state = GetStr(el, "state", "status"); + var group = GetStr(el, "group"); + if (string.IsNullOrEmpty(id)) continue; + list.Add(new OtaVehicleRow { Id = id, Name = name, Ip = ip, State = state, Group = group }); + } + return list; + } + + private static string? GetStr(JsonElement el, params string[] names) + { + foreach (var n in names) + { + if (el.TryGetProperty(n, out var p) && p.ValueKind == JsonValueKind.String) + return p.GetString(); + if (el.TryGetProperty(n, out p) && p.ValueKind is JsonValueKind.Number) + return p.ToString(); + } + return null; + } +} + +/// 避免 Ota 层直接依赖 Auth 命名空间循环;薄包装 InternalTokenStore。 +public sealed class InternalTokenStoreAccessor +{ + private readonly Auth.InternalTokenStore _store; + public InternalTokenStoreAccessor(Auth.InternalTokenStore store) => _store = store; + public string Token => _store.Token; +} diff --git a/MiGu.Server/Ota/WatchDogClient.cs b/MiGu.Server/Ota/WatchDogClient.cs new file mode 100644 index 0000000..3c523d4 --- /dev/null +++ b/MiGu.Server/Ota/WatchDogClient.cs @@ -0,0 +1,289 @@ +using System.Diagnostics; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; + +namespace MiGu.Server.Ota; + +public sealed class WatchDogClient +{ + private readonly IHttpClientFactory _httpFactory; + private readonly OtaOptions _opt; + private readonly ILogger _log; + private readonly JsonSerializerOptions _json = new() { PropertyNameCaseInsensitive = true }; + + public WatchDogClient(IHttpClientFactory httpFactory, IOptions opt, ILogger log) + { + _httpFactory = httpFactory; + _opt = opt.Value; + _log = log; + } + + private HttpClient CreateClient(int? timeoutMs = null) + { + var c = _httpFactory.CreateClient(nameof(WatchDogClient)); + c.Timeout = TimeSpan.FromMilliseconds(timeoutMs ?? _opt.RequestTimeoutMs); + return c; + } + + private string Base(string ip) => $"http://{ip}:{_opt.WatchDogPort}"; + + public async Task<(bool Ok, OtaAppVersions? M, OtaAppVersions? D, OtaAppVersions? C, string? Error)> GetMdcInfoAsync(string ip, CancellationToken ct) + { + try + { + using var client = CreateClient(); + using var resp = await client.GetAsync($"{Base(ip)}/getMDCInfo", ct); + if (!resp.IsSuccessStatusCode) + return (false, null, null, null, $"HTTP {(int)resp.StatusCode}"); + var text = await resp.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(text); + var root = doc.RootElement; + return (true, ParseApp(root, "Medulla"), ParseApp(root, "Detour"), ParseApp(root, "Clumsy"), null); + } + catch (Exception ex) + { + return (false, null, null, null, ex.Message); + } + } + + private static OtaAppVersions? ParseApp(JsonElement root, string name) + { + if (!root.TryGetProperty(name, out var app) && !root.TryGetProperty(name.ToLowerInvariant(), out app)) + return null; + return new OtaAppVersions + { + Exe = ParseComp(app, "exe"), + Dll = ParseComp(app, "dll"), + Pdb = ParseComp(app, "pdb") + }; + } + + private static OtaComponentVersion? ParseComp(JsonElement app, string key) + { + if (!app.TryGetProperty(key, out var c)) return null; + string? ver = null; + string? time = null; + if (c.ValueKind == JsonValueKind.Object) + { + if (c.TryGetProperty("version", out var v)) + ver = v.ValueKind == JsonValueKind.String ? v.GetString() : v.ToString(); + if (c.TryGetProperty("time", out var t)) + time = t.GetString(); + } + else if (c.ValueKind == JsonValueKind.String) + { + ver = c.GetString(); + } + return new OtaComponentVersion { Version = ver, Time = time }; + } + + public async Task MeasureRttMsAsync(string ip, CancellationToken ct) + { + try + { + var sw = Stopwatch.StartNew(); + using var tcp = new TcpClient(); + using var reg = ct.Register(() => { try { tcp.Close(); } catch { /* ignore */ } }); + var connectTask = tcp.ConnectAsync(ip, _opt.WatchDogPort); + var done = await Task.WhenAny(connectTask, Task.Delay(Math.Min(3000, _opt.RequestTimeoutMs), ct)); + if (done != connectTask || !tcp.Connected) return null; + await connectTask; + sw.Stop(); + return (int)sw.ElapsedMilliseconds; + } + catch + { + return null; + } + } + + public async Task TriggerPullAsync(string ip, string serverBaseUrl, string time, CancellationToken ct) + { + // 现网 WatchDog 忽略 server 查询参数,固定 POST 到 http://{config.serverIP}:8000/upload-mdcs/{key}。 + // serverBaseUrl 仅作日志/未来兼容;真正要通必须:车上 serverIP=本机局域网 IP,且本机监听 ReceivePort。 + using var client = CreateClient(_opt.UploadTimeoutMs); + var url = $"{Base(ip)}/getmdcsexe?time={Uri.EscapeDataString(time)}&server={Uri.EscapeDataString(serverBaseUrl.TrimEnd('/'))}"; + try + { + using var resp = await client.GetAsync(url, ct); + var body = (await resp.Content.ReadAsStringAsync(ct)).Trim(); + _log.LogInformation("getmdcsexe {Ip} -> {Code} body={Body} (WatchDog will POST to its config.serverIP:{Port}/upload-mdcs/*; expect receiver {Base})", + ip, (int)resp.StatusCode, body.Length > 200 ? body[..200] : body, _opt.ReceivePort, serverBaseUrl); + if (!resp.IsSuccessStatusCode) + throw new InvalidOperationException($"WatchDog getmdcsexe HTTP {(int)resp.StatusCode}: {body}"); + if (body.Contains("请配置", StringComparison.Ordinal) + || body.Equals("false", StringComparison.OrdinalIgnoreCase) + || body.Equals("\"false\"", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "WatchDog 拒绝拉包或回传失败。请确认:1) 车已配置 Medulla/Detour/Clumsy 路径;" + + $"2) watch_dog.json 的 serverIP 指向本机局域网 IP(车将 POST 到 serverIP:{_opt.ReceivePort}/upload-mdcs/*);" + + $"3) 本机已监听 :{_opt.ReceivePort}。WatchDog 返回:{body}"); + } + } + catch (InvalidOperationException) + { + throw; + } + catch (Exception ex) + { + _log.LogWarning(ex, "getmdcsexe failed {Ip}", ip); + throw; + } + } + + public async Task UploadComponentAsync(string ip, string componentKey, string localPath, string fileName, int bandwidthKbps, CancellationToken ct) + { + var endpoint = OtaPathMap.WatchDogUpdatePath(componentKey) + ?? throw new ArgumentException($"未知组件 {componentKey}"); + await UploadFileAsync($"{Base(ip)}/{endpoint}", localPath, fileName, bandwidthKbps, null, ct); + } + + public async Task UploadCustomFileAsync(string ip, string localPath, string fileName, string remotePath, int restartOp, int bandwidthKbps, CancellationToken ct) + { + var url = $"{Base(ip)}/updateFile/{Uri.EscapeDataString(fileName)}/{restartOp}/"; + await UploadFileAsync(url, localPath, fileName, bandwidthKbps, new Dictionary { ["path"] = remotePath }, ct); + } + + private async Task UploadFileAsync(string url, string localPath, string fileName, int bandwidthKbps, Dictionary? extraFields, CancellationToken ct) + { + using var client = CreateClient(_opt.UploadTimeoutMs); + await using var fs = File.OpenRead(localPath); + Stream contentStream = fs; + if (bandwidthKbps > 0) + contentStream = new ThrottledStream(fs, bandwidthKbps * 1024L); + + using var form = new MultipartFormDataContent(); + if (extraFields != null) + { + foreach (var (k, v) in extraFields) + form.Add(new StringContent(v, Encoding.UTF8), k); + } + var streamContent = new StreamContent(contentStream); + form.Add(streamContent, "file", fileName); + + using var resp = await client.PostAsync(url, form, ct); + if (!resp.IsSuccessStatusCode) + { + var body = await resp.Content.ReadAsStringAsync(ct); + throw new InvalidOperationException($"上传失败 HTTP {(int)resp.StatusCode}: {body}"); + } + } + + public async Task GetJsonAsync(string ip, string app, CancellationToken ct) + { + var path = app.ToLowerInvariant() switch + { + "medulla" => "getMedullajson", + "detour" => "getDetourjson", + "clumsy" => "getClumsyjson", + _ => throw new ArgumentException("app 须为 medulla|detour|clumsy") + }; + using var client = CreateClient(); + using var resp = await client.GetAsync($"{Base(ip)}/{path}", ct); + resp.EnsureSuccessStatusCode(); + return await resp.Content.ReadAsStringAsync(ct); + } + + public async Task PutJsonAsync(string ip, string app, string json, CancellationToken ct) + { + var path = app.ToLowerInvariant() switch + { + "medulla" => "updateMedullajson", + "detour" => "updateDetourjson", + "clumsy" => "updateClumsyjson", + _ => throw new ArgumentException("app 须为 medulla|detour|clumsy") + }; + using var client = CreateClient(); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var resp = await client.PostAsync($"{Base(ip)}/{path}", content, ct); + if (!resp.IsSuccessStatusCode) + { + var body = await resp.Content.ReadAsStringAsync(ct); + throw new InvalidOperationException($"更新 JSON 失败 HTTP {(int)resp.StatusCode}: {body}"); + } + } + + /// 简易限速流:按字节/秒节流读取。 + private sealed class ThrottledStream : Stream + { + private readonly Stream _inner; + private readonly long _bytesPerSecond; + private long _windowBytes; + private long _windowStart = Environment.TickCount64; + + public ThrottledStream(Stream inner, long bytesPerSecond) + { + _inner = inner; + _bytesPerSecond = Math.Max(1024, bytesPerSecond); + } + + public override bool CanRead => _inner.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => _inner.Length; + public override long Position { get => _inner.Position; set => throw new NotSupportedException(); } + public override void Flush() => _inner.Flush(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override int Read(byte[] buffer, int offset, int count) + { + var n = _inner.Read(buffer, offset, count); + if (n > 0) Throttle(n); + return n; + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + var n = await _inner.ReadAsync(buffer.AsMemory(offset, count), cancellationToken); + if (n > 0) await ThrottleAsync(n, cancellationToken); + return n; + } + + private void Throttle(int n) + { + _windowBytes += n; + var elapsed = Environment.TickCount64 - _windowStart; + if (elapsed < 1) elapsed = 1; + var allowed = _bytesPerSecond * elapsed / 1000; + if (_windowBytes > allowed) + { + var wait = (int)((_windowBytes - allowed) * 1000 / _bytesPerSecond); + if (wait > 0) Thread.Sleep(Math.Min(wait, 2000)); + } + if (elapsed >= 1000) + { + _windowBytes = 0; + _windowStart = Environment.TickCount64; + } + } + + private async Task ThrottleAsync(int n, CancellationToken ct) + { + _windowBytes += n; + var elapsed = Environment.TickCount64 - _windowStart; + if (elapsed < 1) elapsed = 1; + var allowed = _bytesPerSecond * elapsed / 1000; + if (_windowBytes > allowed) + { + var wait = (int)((_windowBytes - allowed) * 1000 / _bytesPerSecond); + if (wait > 0) await Task.Delay(Math.Min(wait, 2000), ct); + } + if (elapsed >= 1000) + { + _windowBytes = 0; + _windowStart = Environment.TickCount64; + } + } + + protected override void Dispose(bool disposing) + { + // 不释放 inner(由调用方 using FileStream) + base.Dispose(disposing); + } + } +} diff --git a/MiGu.Server/Persistence/PlatformDbContext.cs b/MiGu.Server/Persistence/PlatformDbContext.cs index 4b83d0e..1e2d345 100644 --- a/MiGu.Server/Persistence/PlatformDbContext.cs +++ b/MiGu.Server/Persistence/PlatformDbContext.cs @@ -27,6 +27,8 @@ public sealed class PlatformDbContext : DbContext public DbSet WmsTransportTaskHistories => Set(); public DbSet SimpleFields => Set(); public DbSet UserDashboardShortcuts => Set(); + public DbSet CdmTasks => Set(); + public DbSet VehicleAlarms => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -85,6 +87,12 @@ public sealed class PlatformDbContext : DbContext modelBuilder.Entity().HasIndex(x => new { x.LifecycleStatus, x.UpdatedAt }); modelBuilder.Entity().HasIndex(x => x.TypeCode); modelBuilder.Entity().HasIndex(x => x.ContainerId).IsUnique(); + // 库位占用 1:1:同一 Storage LocationId 同时只能有一条未删除记录(Car 等其它类型不限) + modelBuilder.Entity() + .HasIndex(x => x.LocationId) + .IsUnique() + .HasFilter("LocationType = 'Storage' AND IsDeleted = 0") + .HasDatabaseName("IX_wms_container_locations_StorageLocationId"); modelBuilder.Entity().HasIndex(x => new { x.LocationType, x.LocationId }); modelBuilder.Entity().HasIndex(x => x.MaterialId).IsUnique(); modelBuilder.Entity().HasIndex(x => x.ContainerId); @@ -103,6 +111,61 @@ public sealed class PlatformDbContext : DbContext ConfigureSimpleField(modelBuilder); ConfigureUserDashboardShortcut(modelBuilder); + ConfigureCdmTask(modelBuilder); + ConfigureVehicleAlarm(modelBuilder); + } + + private static void ConfigureVehicleAlarm(ModelBuilder modelBuilder) + { + var e = modelBuilder.Entity(); + e.ToTable("vehicle_alarms"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id").HasMaxLength(36); + e.Property(x => x.CarId).HasColumnName("car_id"); + e.Property(x => x.CarName).HasColumnName("car_name").HasMaxLength(128); + e.Property(x => x.Info).HasColumnName("info").HasColumnType("text"); + e.Property(x => x.Level).HasColumnName("level"); + e.Property(x => x.Status).HasColumnName("status").HasMaxLength(16); + e.Property(x => x.FirstAt).HasColumnName("first_at"); + e.Property(x => x.LastAt).HasColumnName("last_at"); + e.Property(x => x.ResolvedAt).HasColumnName("resolved_at").IsRequired(false); + e.Property(x => x.DurationSecs).HasColumnName("duration_secs").IsRequired(false); + e.Property(x => x.Acknowledged).HasColumnName("acknowledged"); + e.Property(x => x.AcknowledgedAt).HasColumnName("acknowledged_at").IsRequired(false); + e.Property(x => x.AcknowledgedBy).HasColumnName("acknowledged_by").HasMaxLength(128).IsRequired(false); + e.HasIndex(x => new { x.CarId, x.Status }); + e.HasIndex(x => x.Status); + e.HasIndex(x => x.FirstAt); + } + + private static void ConfigureCdmTask(ModelBuilder modelBuilder) + { + var e = modelBuilder.Entity(); + e.ToTable("cdm_tasks"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id").HasMaxLength(64); + e.Property(x => x.TaskId).HasColumnName("task_id").HasMaxLength(128).IsRequired(false); + e.Property(x => x.MissionId).HasColumnName("mission_id"); + e.Property(x => x.MissionName).HasColumnName("mission_name").HasMaxLength(128); + e.Property(x => x.MissionTypeName).HasColumnName("mission_type").HasMaxLength(128); + e.Property(x => x.SrcSiteId).HasColumnName("src_site_id"); + e.Property(x => x.SrcLabel).HasColumnName("src_label").HasMaxLength(256); + e.Property(x => x.DstSiteId).HasColumnName("dst_site_id"); + e.Property(x => x.DstLabel).HasColumnName("dst_label").HasMaxLength(256); + e.Property(x => x.Status).HasColumnName("status").HasMaxLength(32); + e.Property(x => x.StatusCode).HasColumnName("status_code").HasMaxLength(32); + e.Property(x => x.CarId).HasColumnName("car_id").IsRequired(false); + e.Property(x => x.CarName).HasColumnName("car_name").HasMaxLength(128).IsRequired(false); + e.Property(x => x.Priority).HasColumnName("priority"); + e.Property(x => x.CreateTime).HasColumnName("create_time").HasMaxLength(40).IsRequired(false); + e.Property(x => x.StartTime).HasColumnName("start_time").HasMaxLength(40).IsRequired(false); + e.Property(x => x.FinishTime).HasColumnName("finish_time").HasMaxLength(40).IsRequired(false); + e.Property(x => x.StuckReason).HasColumnName("stuck_reason").HasMaxLength(512).IsRequired(false); + e.Property(x => x.Overdue).HasColumnName("overdue"); + e.Property(x => x.FirstSeenAt).HasColumnName("first_seen_at"); + e.Property(x => x.LastSeenAt).HasColumnName("last_seen_at"); + e.HasIndex(x => x.StatusCode); + e.HasIndex(x => x.CreateTime); } private static void ConfigureUserDashboardShortcut(ModelBuilder modelBuilder) diff --git a/MiGu.Server/Persistence/PlatformPersistence.cs b/MiGu.Server/Persistence/PlatformPersistence.cs index f4b45e6..5ef45b6 100644 --- a/MiGu.Server/Persistence/PlatformPersistence.cs +++ b/MiGu.Server/Persistence/PlatformPersistence.cs @@ -59,6 +59,8 @@ public static class PlatformPersistence await EnsureUserDashboardShortcutsTableAsync(db); await EnsureWmsTransportSchemaAsync(db); await EnsureWmsStructureSchemaAsync(db); + await EnsureCdmTasksTableAsync(db); + await EnsureVehicleAlarmsTableAsync(db); await MigrateWmsLegacyAsync(scope.ServiceProvider); } @@ -185,6 +187,83 @@ public static class PlatformPersistence await EnsureSqliteColumnAsync(db, "wms_container_materials", "BoundAt", "TEXT NOT NULL DEFAULT ''"); } + /// 为已存在的数据库补建 vehicle_alarms 表(车辆报警平台侧记录,幂等)。 + private static async Task EnsureVehicleAlarmsTableAsync(PlatformDbContext db) + { + if (db.Database.IsSqlite()) + { + await db.Database.ExecuteSqlRawAsync(""" + CREATE TABLE IF NOT EXISTS vehicle_alarms ( + id TEXT NOT NULL CONSTRAINT PK_vehicle_alarms PRIMARY KEY, + car_id INTEGER NOT NULL DEFAULT 0, + car_name TEXT NOT NULL DEFAULT '', + info TEXT NOT NULL DEFAULT '', + level INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + first_at TEXT NOT NULL, + last_at TEXT NOT NULL, + resolved_at TEXT, + duration_secs INTEGER, + acknowledged INTEGER NOT NULL DEFAULT 0, + acknowledged_at TEXT, + acknowledged_by TEXT + ); + """); + await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_vehicle_alarms_car_status ON vehicle_alarms (car_id, status);"); + await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_vehicle_alarms_status ON vehicle_alarms (status);"); + await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_vehicle_alarms_first_at ON vehicle_alarms (first_at);"); + return; + } + + if (!await TableExistsAsync(db, "vehicle_alarms")) + { + var creator = db.GetService(); + await creator.CreateTablesAsync(); + } + } + + /// 为已存在的数据库补建 cdm_tasks 表(CDM 搬运任务平台侧快照,幂等)。 + private static async Task EnsureCdmTasksTableAsync(PlatformDbContext db) + { + if (db.Database.IsSqlite()) + { + await db.Database.ExecuteSqlRawAsync(""" + CREATE TABLE IF NOT EXISTS cdm_tasks ( + id TEXT NOT NULL CONSTRAINT PK_cdm_tasks PRIMARY KEY, + task_id TEXT, + mission_id INTEGER NOT NULL DEFAULT 0, + mission_name TEXT NOT NULL DEFAULT '', + mission_type TEXT NOT NULL DEFAULT '', + src_site_id INTEGER NOT NULL DEFAULT 0, + src_label TEXT NOT NULL DEFAULT '', + dst_site_id INTEGER NOT NULL DEFAULT 0, + dst_label TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT '', + status_code TEXT NOT NULL DEFAULT '', + car_id INTEGER, + car_name TEXT, + priority INTEGER NOT NULL DEFAULT 0, + create_time TEXT, + start_time TEXT, + finish_time TEXT, + stuck_reason TEXT, + overdue INTEGER NOT NULL DEFAULT 0, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL + ); + """); + await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_cdm_tasks_status_code ON cdm_tasks (status_code);"); + await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_cdm_tasks_create_time ON cdm_tasks (create_time);"); + return; + } + + if (!await TableExistsAsync(db, "cdm_tasks")) + { + var creator = db.GetService(); + await creator.CreateTablesAsync(); + } + } + /// 为已存在的数据库补建 simple_fields 表(幂等)。 private static async Task EnsureSimpleFieldsTableAsync(PlatformDbContext db) { @@ -339,10 +418,33 @@ public static class PlatformPersistence SnapshotJson TEXT NOT NULL ); """); + + // 库位占用唯一约束(幂等);若库内已有重复占用会创建失败,不阻断启动 + try + { + await db.Database.ExecuteSqlRawAsync(""" + CREATE UNIQUE INDEX IF NOT EXISTS IX_wms_container_locations_StorageLocationId + ON wms_container_locations (LocationId) + WHERE LocationType = 'Storage' AND IsDeleted = 0; + """); + } + catch (Exception ex) + { + Console.Error.WriteLine( + $"[WMS] 无法创建库位占用唯一索引 IX_wms_container_locations_StorageLocationId(可能已有重复占用): {ex.Message}"); + } } private static async Task EnsureSqliteColumnAsync(PlatformDbContext db, string table, string column, string definition) { + // 仅允许内部迁移调用方传入的标识符;拒绝注入用分隔符/空白。 + static bool IsSafeIdent(string s) => + !string.IsNullOrEmpty(s) && s.All(ch => char.IsAsciiLetterOrDigit(ch) || ch == '_'); + if (!IsSafeIdent(table) || !IsSafeIdent(column) + || definition.Contains(';') || definition.Contains("--") + || !System.Text.RegularExpressions.Regex.IsMatch(definition, @"^[A-Za-z0-9_()'.,\s]+$")) + throw new ArgumentException("unsafe sqlite migration identifier"); + var conn = db.Database.GetDbConnection(); if (conn.State != System.Data.ConnectionState.Open) await conn.OpenAsync(); diff --git a/MiGu.Server/Program.cs b/MiGu.Server/Program.cs index b26ffdb..ecdd69d 100644 --- a/MiGu.Server/Program.cs +++ b/MiGu.Server/Program.cs @@ -7,6 +7,7 @@ using MiGu.Server.Auth; using MiGu.Server.Configs; using MiGu.Server.Launcher; using MiGu.Server.OpenApi; +using MiGu.Server.Ota; using MiGu.Server.Persistence; using Yarp.ReverseProxy.Transforms; @@ -31,12 +32,27 @@ var builder = WebApplication.CreateBuilder(new WebApplicationOptions ContentRootPath = FindSourceContentRoot(AppContext.BaseDirectory) ?? AppContext.BaseDirectory }); -if (string.IsNullOrWhiteSpace(builder.Configuration["urls"]) - && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ASPNETCORE_URLS")) - && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("DOTNET_URLS"))) +// 管理面默认 :8080;WatchDog 拉包回传写死 :8000/upload-mdcs/*,必须额外监听 ReceivePort。 { - // 直接运行 MiGu.Server.exe 不读取 launchSettings.json;保持与 dotnet run / 文档一致默认监听 8080。 - builder.WebHost.UseUrls("http://0.0.0.0:8080"); + var receivePort = builder.Configuration.GetValue("Ota:ReceivePort", 8000); + var urls = builder.Configuration["urls"] + ?? Environment.GetEnvironmentVariable("ASPNETCORE_URLS") + ?? Environment.GetEnvironmentVariable("DOTNET_URLS"); + if (string.IsNullOrWhiteSpace(urls)) + urls = "http://0.0.0.0:8080"; + + var parts = urls.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var hasReceive = parts.Any(u => + u.Contains($":{receivePort}", StringComparison.OrdinalIgnoreCase) + || u.EndsWith($":{receivePort}/", StringComparison.OrdinalIgnoreCase)); + if (!hasReceive) + { + urls = string.Join(';', parts.Append($"http://0.0.0.0:{receivePort}")); + // 安全提示:该端口挂匿名 upload-mdcs/upload-history(WatchDog 写死回传)。 + // upload-mdcs 仅在有进行中的拉取会话时可写入,其余管理端点仍需 JWT。请确保本机处于可信内网。 + Console.WriteLine($"[MiGu.Server] OTA 回传端口 {receivePort} 已监听(0.0.0.0):匿名接收车辆包,仅限可信内网。"); + } + builder.WebHost.UseUrls(urls); } // S2 根治 (会话45):让 MiGu.Server 启动时自动优先把 frontends/apps/simple-platform-vue/dist/ @@ -95,6 +111,11 @@ builder.Services.AddControllers() opt.JsonSerializerOptions.DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull; opt.JsonSerializerOptions.WriteIndented = false; }); +// WatchDog 回传 M/D/C 可较大;放宽 multipart 默认 128MB 限制 +builder.Services.Configure(o => +{ + o.MultipartBodyLengthLimit = 512_000_000; +}); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => @@ -207,6 +228,25 @@ builder.Services.AddReverseProxy() var store = rt.HttpContext.RequestServices.GetRequiredService(); rt.ProxyRequest.Headers.Remove("X-Platform-Internal-Token"); rt.ProxyRequest.Headers.Add("X-Platform-Internal-Token", store.Token); + + var user = rt.HttpContext.User; + var username = user.FindFirst("unique_name")?.Value + ?? user.Identity?.Name + ?? user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value + ?? user.FindFirst("sub")?.Value; + var userId = user.FindFirst("sub")?.Value + ?? user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + var scope = user.FindFirst("scope")?.Value; + + rt.ProxyRequest.Headers.Remove("X-Platform-User"); + rt.ProxyRequest.Headers.Remove("X-Platform-User-Id"); + rt.ProxyRequest.Headers.Remove("X-Platform-Scope"); + if (!string.IsNullOrWhiteSpace(username)) + rt.ProxyRequest.Headers.TryAddWithoutValidation("X-Platform-User", username); + if (!string.IsNullOrWhiteSpace(userId)) + rt.ProxyRequest.Headers.TryAddWithoutValidation("X-Platform-User-Id", userId); + if (!string.IsNullOrWhiteSpace(scope)) + rt.ProxyRequest.Headers.TryAddWithoutValidation("X-Platform-Scope", scope); return ValueTask.CompletedTask; }); }); @@ -224,6 +264,20 @@ builder.Services.AddPlatformPersistence(builder.Configuration); builder.Services.Configure(builder.Configuration.GetSection("SimpleLite")); builder.Services.AddSingleton(); +// OTA(WatchDog 编排):包库 / 任务 / 出站客户端 +builder.Services.Configure(builder.Configuration.GetSection("Ota")); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); +builder.Services.AddHttpClient(nameof(WatchDogClient)); + var app = builder.Build(); await app.Services.EnsurePlatformDatabaseAsync(); diff --git a/MiGu.Server/Properties/launchSettings.json b/MiGu.Server/Properties/launchSettings.json index 5a626a4..42eafe7 100644 --- a/MiGu.Server/Properties/launchSettings.json +++ b/MiGu.Server/Properties/launchSettings.json @@ -5,7 +5,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "http://0.0.0.0:8080", + "applicationUrl": "http://0.0.0.0:8080;http://0.0.0.0:8000", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/MiGu.Server/appsettings.json b/MiGu.Server/appsettings.json index 2c97ef8..346519e 100644 --- a/MiGu.Server/appsettings.json +++ b/MiGu.Server/appsettings.json @@ -44,6 +44,15 @@ "Dispatch": { } }, + "_comment_Ota": "车辆 OTA:包与任务落盘 DataRoot;WatchDog:9776。拉包时车会 POST 到 WatchDog 配置的 serverIP:ReceivePort/upload-mdcs/*(默认 8000,与旧 Electron OTA 一致)。请把各车 watch_dog.json 的 serverIP 设为本机局域网 IP。", + "Ota": { + "DataRoot": "data/ota", + "WatchDogPort": 9776, + "ReceivePort": 8000, + "RequestTimeoutMs": 60000, + "UploadTimeoutMs": 600000, + "PublicBaseUrl": "" + }, "_comment_ReverseProxy": "sl-route 兜底 AnyAuthed(投影只读 + SSE)。管理面路径(map-edit / ai-config / reflection 写操作)单独拆路由挂 PlatformScope,防止运营账号经反代直达地图编辑与任意反射调用。", "ReverseProxy": { "Routes": { @@ -65,6 +74,15 @@ { "PathRemovePrefix": "/api/sl" } ] }, + "sl-assistant-route": { + "ClusterId": "sl-cluster", + "AuthorizationPolicy": "PlatformScope", + "Order": -2, + "Match": { "Path": "/api/sl/projection/assistant/{**catch-all}" }, + "Transforms": [ + { "PathRemovePrefix": "/api/sl" } + ] + }, "sl-reflection-selection-route": { "ClusterId": "sl-cluster", "AuthorizationPolicy": "AnyAuthed", diff --git a/docs/superpowers/plans/2026-07-19-migu-ota-watchdog.md b/docs/superpowers/plans/2026-07-19-migu-ota-watchdog.md new file mode 100644 index 0000000..a019cf6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-migu-ota-watchdog.md @@ -0,0 +1,117 @@ +# 迷榖 OTA(WatchDog)实现计划 + +> **状态:** 核心任务已落地(后端 API + 前端工作台)。联调 WatchDog 实车需现场验证。 +> **面向 AI 代理的工作者:** 按任务顺序实现;每完成一大任务做一次验证。规格:`docs/superpowers/specs/2026-07-19-migu-ota-watchdog-design.md`。 + +**目标:** 车辆运维改为「运维总览 + OTA」,平台编排 WatchDog 完成包库/下发/任务/配置/自定义文件/设置与延迟检测。 +**架构:** MiGu.Server `api/ota/*` 出站调 WatchDog `:9776`;包与任务落盘 `data/ota/`;前端 `OtaWorkbenchView` 只调平台 API。 +**技术栈:** ASP.NET Core 8、Vue 3、Element Plus、HttpClient、现有 JWT/RBAC。 + +--- + +## 文件结构 + +### 后端(新建) + +| 文件 | 职责 | +|------|------| +| `MiGu.Server/Ota/OtaOptions.cs` | DataRoot、WatchDogPort、超时、本机接收端口 | +| `MiGu.Server/Ota/OtaModels.cs` | Package/Target/Job/Settings/VehicleRow DTO | +| `MiGu.Server/Ota/OtaPathMap.cs` | MDC 路径与组件键 | +| `MiGu.Server/Ota/OtaHash.cs` | MD5 Base64 去 `-` | +| `MiGu.Server/Ota/OtaStore.cs` | packages/target/jobs/settings/history 读写 | +| `MiGu.Server/Ota/WatchDogClient.cs` | getMDCInfo、update*、get*json、getmdcsexe、latency | +| `MiGu.Server/Ota/OtaPackageReceiver.cs` | 供 WatchDog 回传 upload-mdcs* 的内部端点宿主或同进程路由 | +| `MiGu.Server/Ota/OtaJobRunner.cs` | 分批、限速、状态机、重试/取消 | +| `MiGu.Server/Ota/OtaVehicleSource.cs` | 从 SimpleLite projection 取车列表 | +| `MiGu.Server/Controllers/OtaController.cs` | `/api/ota/*` | +| `MiGu.Server/Controllers/OtaReceiveController.cs` | WatchDog 回传 `/api/ota/receive/upload-mdcs*` | + +### 后端(修改) + +| 文件 | 变更 | +|------|------| +| `MiGu.Server/Program.cs` | 注册 OTA 服务 | +| `MiGu.Server/appsettings.json` | `Ota` 节 | + +### 前端(新建) + +| 文件 | 职责 | +|------|------| +| `src/api/ota.ts` | API 客户端 | +| `src/types/ota.ts` | 类型 | +| `src/views/shared/ota/OtaWorkbenchView.vue` | 左导航壳 + 顶条 | +| `src/views/shared/ota/OtaVehiclesPane.vue` | 车辆升级 | +| `src/views/shared/ota/OtaPackagesPane.vue` | 版本库 | +| `src/views/shared/ota/OtaJobsPane.vue` | 任务中心 | +| `src/views/shared/ota/OtaConfigPane.vue` | 配置同步 | +| `src/views/shared/ota/OtaCustomFilePane.vue` | 自定义文件 | +| `src/views/shared/ota/OtaSettingsPane.vue` | 设置 | +| `src/composables/useOtaWorkbench.ts` | 目标版本、设置、刷新 | + +### 前端(修改) + +| 文件 | 变更 | +|------|------| +| `src/views/shared/VehicleHubView.vue` | Tab:overview \| ota;重定向旧 tab | + +--- + +## 任务 + +### 任务 1:后端基础(Options / Models / Store / Hash) + +1. 创建 `OtaOptions`、`OtaModels`、`OtaPathMap`、`OtaHash`、`OtaStore`。 +2. Settings 默认:bandwidth=0(不限)、maxCar=2、latencyEnabled=false、rttThresholdMs=200、overThreshold=`skip`、backupPeriodMinutes=60、backupExe=false。 +3. `Program.cs` + `appsettings.json` 注册。 +4. 验证:`dotnet build MiGu.Server/MiGu.Server.csproj` 通过。 + +### 任务 2:WatchDogClient + 车辆源 + Receive + +1. `WatchDogClient`:MDCInfo、组件上传(multipart)、JSON get/put、触发 getmdcsexe、TCP/HTTP RTT。 +2. `OtaVehicleSource`:HttpClient 调 SimpleLite `http://127.0.0.1:8222/api/agv/list` 或 projection cars(与现有一致优先 projection)。 +3. `OtaReceiveController`:接收 upload-mdcs* 写入当前 pull 会话目录。 +4. 验证:build 通过。 + +### 任务 3:JobRunner + OtaController API + +实现规格 §4.2 全部端点;JobRunner 支持 sync/custom/config 三类 job;审计写入 `OpsAuditStore` 或 `data/ota/audit.json`。 +验证:build 通过;手动 curl GET settings/packages。 + +### 任务 4:前端 API + 类型 + Hub Tab 切换 + +1. `types/ota.ts`、`api/ota.ts`。 +2. `VehicleHubView` 仅 overview/ota;挂载 `OtaWorkbenchView`。 +3. 验证:前端 typecheck/dev 可加载。 + +### 任务 5:OTA 工作台 UI(六子页) + +按规格 §5 实现各 Pane;延迟检测开关在车辆升级工具条。 +验证:页面可切换、设置可保存、无目标时下发被拦截。 + +### 任务 6:联调与验收 + +对照规格 §9 验收清单;修明显 bug。 + +--- + +## 关键实现约定 + +- MD5:`Convert.ToBase64String(MD5.HashData(bytes)).Replace("-", "")`(与参考工具一致则对照其实现)。 +- 组件键:`M.exe` `M.dll` `M.pdb` `D.exe` `C.exe` `C.dll` `C.pdb`。 +- Job kind:`sync` | `customFile` | `configPush`。 +- 进度字段:`doneSteps` / `totalSteps`;每车每组件 `status`:pending|running|succeeded|failed|skipped。 +- 拉取包:平台记录 `pendingPullId` + 本机可达 URL,调车 `getmdcsexe?time=`;车推到 `/api/ota/receive/...`。 + +--- + +## 规格覆盖自检 + +| 规格项 | 任务 | +|--------|------| +| IA / 删维护策略与生命周期 | 4 | +| WatchDog 编排 | 2–3 | +| 包库/目标/任务/设置/延迟 | 3、5 | +| 配置同步/自定义文件 | 3、5 | +| 审计 | 3 | +| 验收 §9 | 6 | diff --git a/docs/superpowers/specs/2026-07-19-migu-ota-watchdog-design.md b/docs/superpowers/specs/2026-07-19-migu-ota-watchdog-design.md new file mode 100644 index 0000000..fae2b69 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-migu-ota-watchdog-design.md @@ -0,0 +1,258 @@ +# 迷榖车辆运维 OTA(WatchDog)设计规格 + +**日期:** 2026-07-19 +**状态:** 已批准并实现中 +**范围:** 在迷榖「车辆运维」中交付完整 OTA 能力;车上协议沿用 WatchDog;管理面由 MiGu.Server 编排。 + +--- + +## 1. 背景与目标 + +### 1.1 参考实现 + +`E:\Work\FRLD\OTA\ota` 为 Electron OTA 管理工具:版本拉取/管理、多车 M/D/C 同步、自定义文件、配置 JSON 下发、带宽限速与分批。车上依赖 WatchDog(`:9776`)。参考工具缺少任务状态机、可靠进度与正式回滚。 + +### 1.2 迷榖现状 + +- 「车辆运维」=`VehicleHubView`:运维总览 / 维护策略 / 车队生命周期。 +- OTA 仅为 `OtaPolicy`(enabled / batchSize / rollbackOnFail)只读占位,无包库、下发、进度或审计。 + +### 1.3 目标 + +1. 车辆运维顶部仅保留 **运维总览** 与 **OTA**。 +2. 删除「维护策略」「车队生命周期」Tab 及维护策略配置页(本期不迁移)。 +3. OTA 全面对齐参考工具能力,并补齐平台侧任务进度、失败重试、审计。 +4. 车上协议 **沿用 WatchDog**;浏览器不直连车辆。 +5. UI 按迷榖运维工作台语言重做,不照搬 Electron 通用后台壳。 + +### 1.4 非目标(本期) + +- 差分/增量包、签名验签、A/B 双分区 +- 空闲时段自动升级 +- 浏览器直连 WatchDog +- 维护策略配置的任何入口保留或迁移 + +--- + +## 2. 架构决策 + +**选定:方案 A — 平台编排。** + + +| 组件 | 职责 | +| ------------------ | ---------------------------- | +| 前端 | 仅调用 `/api/ota/`*,展示对照/进度/设置 | +| MiGu.Server OTA 模块 | 包存储、目标版本、任务状态机、分批、限速、延迟探测、审计 | +| 车队名单 | 复用现有投影/车辆列表(IP、名称、状态) | +| WatchDog `:9776` | 装包、读版本、回传包、读写 JSON、自定义文件 | + + +--- + +## 3. 信息架构 + +### 3.1 车辆运维 Tab + + +| Tab | 内容 | +| ---- | ------------------ | +| 运维总览 | 保持现有:健康卡片、维护态、车队分配 | +| OTA | 新工作台 | + + +- 路由:`/admin/config/vehicle-hub?tab=ota`(监控侧同理)。 +- 旧 `?tab=maintenance` / `?tab=fleet` 深链重定向到 `ota`。 + +### 3.2 OTA 子导航(左侧) + +1. **车辆升级** — 版本对照、勾选、分组件/全量同步、延迟检测开关 +2. **版本库** — 从车拉取 / 本地上传、设为目标、清理 +3. **任务中心** — 进行中/历史、进度、重试、取消 +4. **配置同步** — Medulla/Detour/Clumsy JSON 浏览、编辑、多车下发 +5. **自定义文件** — 文件 + 车上路径 + 重启策略 +6. **设置** — 带宽、并发、备份、延迟检测阈值与门禁策略 + +### 3.3 与运维总览的边界 + + +| 能力 | Tab | +| ------------------------------------ | ---- | +| 健康、电量、报警、维护态、车队分配、开车上页 | 运维总览 | +| 版本对照、包库、下发、JSON/自定义文件、任务、OTA 设置、延迟检测 | OTA | + + +运维总览不强制展示完整 MDC 哈希;若后续加「版本落后」标记,仅作跳转 OTA 的入口。 + +--- + +## 4. 后端:存储、API、任务 + +### 4.1 存储布局 + +根目录:`MiGu.Server/data/ota/`(可配置) + + +| 路径 | 用途 | +| ---------------- | ----------------------- | +| `packages/{id}/` | 一次拉取或上传的 M/D/C 文件树 | +| `target.json` | 当前目标版本(等价参考 `ota.json`) | +| `jobs/` | 任务元数据与事件日志 | +| `history/` | 定时备份(JSON ± exe) | + + +- 版本标识:文件 **MD5(Base64,去除 `-`)**,与 WatchDog `getMDCInfo` 对齐。 +- 组件映射:内置/可配置 `MDCPath`(Medulla / Detour / Clumsy 的 exe·dll·pdb)。 + +### 4.2 API + + +| 方法 | 路径 | 作用 | +| ------- | ------------------------------------- | ------------------------ | +| GET | `/api/ota/vehicles` | 车列表 + 车上 MDC 版本 +(可选)RTT | +| POST | `/api/ota/packages/pull` | 从指定车拉取包 | +| POST | `/api/ota/packages/upload` | 管理端上传包 | +| GET | `/api/ota/packages` | 版本库列表 | +| POST | `/api/ota/packages/{id}/activate` | 设为目标版本 | +| DELETE | `/api/ota/packages/{id}` | 清理(当前目标不可删) | +| GET | `/api/ota/target` | 当前目标 | +| POST | `/api/ota/jobs` | 创建下发任务(全量/组件/自定义文件/JSON) | +| GET | `/api/ota/jobs` · `/jobs/{id}` | 列表与详情进度 | +| POST | `/api/ota/jobs/{id}/retry` · `cancel` | 重试失败项 / 取消未开始 | +| GET/PUT | `/api/ota/settings` | 带宽、maxCar、备份、延迟检测 | +| GET/PUT | `/api/ota/config/{carId}/{app}` | 单车 JSON(medulla | +| POST | `/api/ota/config/push` | JSON 多车下发(走 job) | +| GET | `/api/ota/latency` | 按需批量 RTT(受设置开关约束) | + + +权限:JWT/RBAC;Platform 全量写;Monitor 可读 + 受控执行(`ops.ota.`*)。写操作进入审计。 + +### 4.3 任务状态机 + +``` +pending → probing(可选) → running → succeeded + ↘ failed | partial + ↘ cancelled +``` + +- **分批:** `maxCar`(兼容原 `ota.batchSize` 语义) +- **限速:** 服务端上传流按 `bandwidth` kb/s 节流 +- **进度:** 按「车 × 组件」;SSE 或短轮询推到任务中心 +- **重试:** 仅重跑失败车辆/组件 +- **取消:** 仅 `pending` / 未开始批次;已在传的组件尽量完成并标记 +- **延迟门禁:** 任务可带 `requireLatencyCheck`;超阈值按设置跳过或二次确认后仍下发 + +### 4.4 WatchDog 映射 + + +| 能力 | WatchDog | +| ---- | --------------------------------------------------------- | +| 读版本 | `GET /getMDCInfo` | +| 拉包 | `GET /getmdcsexe` → 车推到平台接收端(或平台主动拉,实现时选更稳方案) | +| 下发 | `/updateMedullaExecutable` 等 + `/updateFile/{name}/{op}/` | +| JSON | `get*json` / `update*json` | +| 备份 | `gethistoryexe` + 平台 `history/` | + + +参考工具 Express `:8000` 接收能力收进 MiGu(内部端点,仅供 WatchDog 回传)。服务端对 WatchDog 统一超时与有限重试。 + +--- + +## 5. UI 设计 + +**Design Read:** 工业 B2B 运维工作台;延续运维总览玻璃卡片 + 状态色 + JetBrains Mono;密度偏驾驶舱。 +**Dial:** Variance 4 / Motion 3 / Density 7。使用 `--mg-`* 与 `--mg-status-`*。 + +### 5.1 骨架 + +左子导航 + 右工作区;顶条固定:**当前目标版本摘要**(M/D/C 短哈希 + 名称)+ 进行中任务角标。 + +### 5.2 车辆升级 + +- 工具条:搜索、车队筛选、「仅显示不一致」、**网络延迟检测开关**、刷新、同步全部/分组件 +- 主表:勾选 | 车名/ID | IP | Medulla | Detour | Clumsy | RTT | 维护态 + - 与目标一致 → 绿;不一致 → 琥珀;不可达 → 灰 + - 延迟检测关:RTT 为「—」;开:数值 + 超阈值着色 +- 有勾选时底栏粘性操作条:已选数、预计批次、开始下发(二次确认) + +### 5.3 版本库 + +包列表(时间、来源 IP、体积、是否目标)+ 包内文件树与哈希;操作:拉取、上传、激活、删除。 + +### 5.4 任务中心 + +进行中(车×组件进度)+ 历史;详情含错误摘要、重试失败项、取消未开始。 + +### 5.5 配置同步 / 自定义文件 / 设置 + +- 配置:选车 → JSON 树 → 编辑 → 多车下发(进任务) +- 自定义文件:文件、车上路径、重启策略(无/M/D/C/WatchDog)、多车 → 任务 +- 设置:传输(带宽、maxCar)、延迟检测(默认开关、RTT 阈值、超限策略)、备份、展示名 + +### 5.6 交互原则 + +- 下发二次确认,写清目标版本与车辆数 +- 延迟开且超阈值:默认排除并提示;设置可改为「仍允许但确认」 +- 任务进度可离页后续看(持久化) +- 动效克制:进度与状态点过渡即可 + +--- + +## 6. 错误处理与审计 + + +| 场景 | 行为 | +| --------------- | ---------------------------- | +| WatchDog 不可达/超时 | 该车失败,不阻塞同批其他车;任务可为 `partial` | +| 上传中断/哈希不匹配 | 组件级失败;可按失败项重试 | +| 延迟超阈值 | 依设置跳过或确认后下发 | +| 无目标版本却同步 | 前端拦截 + API 400 | +| 磁盘满/包损坏 | 拉取/上传失败,不激活残包 | +| 任务取消 | 仅未开始批次 | + + +审计覆盖:激活目标、创建/取消/重试任务、改设置、推 JSON/自定义文件(操作者、时间、摘要)。 + +--- + +## 7. 前端改动要点(实现指引) + +- `VehicleHubView`:Tab 改为 `overview` | `ota`;移除 `VehicleMaintenanceView` / `FleetLifecycleView` 挂载。 +- 新增 `views/.../OtaWorkbenchView.vue`(及子页/composables/api)。 +- 新增 `src/api/ota.ts` 对接 `/api/ota/`*。 +- 路由/深链:`maintenance`/`fleet` → `ota`。 +- 删除或停用对维护策略页、FleetLifecycle 只读 OTA 页的导航依赖;`FleetLifecycleConfig.ota` 可迁移到 `/api/ota/settings` 后废弃只读 UI。 + +--- + +## 8. 后端改动要点(实现指引) + +- 新增 OTA Controller / Service / 存储 / Job runner / WatchDog HttpClient。 +- 配置项:OTA 数据根路径、WatchDog 端口(默认 9776)、超时。 +- RBAC:`ops.ota.`*;与现有 Ops 审计集成或并行 OTA audit store。 +- 包接收端点替代原 Express `:8000` 的 `upload-mdcs`* / `upload-history`*。 + +--- + +## 9. 验收标准 + +1. 车辆运维仅见「运维总览」「OTA」;旧 Tab 深链落到 OTA。 +2. 可从车拉取或上传包,激活为目标,在车辆升级页看到绿/琥珀对照。 +3. 可分批全量或分组件下发;任务中心可见进度;失败可重试;可取消未开始。 +4. 延迟检测开关生效:关不探测;开显示 RTT 并按阈值门禁。 +5. 配置 JSON 多车下发、自定义文件同步可用。 +6. 设置可持久化(带宽、并发、备份、延迟策略)。 +7. 写操作有审计;浏览器不直连 `:9776`。 + +--- + +## 10. 已确认决策摘要 + + +| 决策 | 选择 | +| ------ | ---------------------- | +| 车上协议 | WatchDog | +| IA | 运维总览 + OTA;去掉维护策略与生命周期 | +| 维护策略配置 | 本期删除 | +| 功能范围 | 全面对齐参考工具 + 平台任务/进度/重试 | +| 架构 | MiGu.Server 平台编排 | +| 延迟检测 | OTA 内按钮开关;开才检测 |