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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zhaowei.huang
2026-07-26 11:31:51 +08:00
co-authored by Cursor
parent f4f4cd1d1b
commit 4223a572c5
25 changed files with 3552 additions and 6 deletions
+2
View File
@@ -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),
+123
View File
@@ -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;
/// <summary>车队运维健康探针(延迟走 WatchDog TCP)+ CDM 任务平台侧快照读取。</summary>
[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<ActionResult<List<FleetHealthRowDto>>> Health(CancellationToken ct)
=> Ok(await _health.GetAsync(ct));
/// <summary>
/// CDM 搬运任务列表(读平台快照库 cdm_tasks)。在线时先即时同步一次拿最新,
/// SimpleLite 关闭时回退最近快照,并通过 online/lastSyncAt 告知前端数据是否滞后。
/// </summary>
[HttpGet("tasks")]
public async Task<ActionResult<object>> 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
});
}
/// <summary>
/// 车辆报警列表(读平台记录 vehicle_alarms)。含活跃 + 历史;SimpleLite 离线时回退最近记录,
/// 通过 online/lastSyncAt 告知数据是否滞后。activeOnly=true 仅返回未恢复的报警。
/// </summary>
[HttpGet("alarms")]
public async Task<ActionResult<object>> 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
});
}
}
+515
View File
@@ -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<OtaOptions> 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<OtaSettings> GetSettings() => _store.GetSettings();
[HttpPut("settings")]
public ActionResult<OtaSettings> 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<object> 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<List<OtaPackageInfo>> ListPackages() => _store.ListPackages();
[HttpGet("packages/{id}")]
public ActionResult<OtaPackageInfo> 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<OtaTarget> 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<ActionResult<object>> 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<ActionResult<OtaPackageInfo>> 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<ActionResult<List<OtaVehicleRow>>> 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<ActionResult<object>> Latency(CancellationToken ct)
{
var settings = _store.GetSettings();
if (!settings.LatencyEnabled)
return Ok(new { enabled = false, items = Array.Empty<object>() });
var cars = await _vehicles.ListCarsAsync(ct);
var items = new List<object>();
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<List<OtaJob>> ListJobs([FromQuery] int take = 100) => _store.ListJobs(take);
[HttpGet("jobs/{id}")]
public ActionResult<OtaJob> 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<OtaJob> 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<OtaJob> 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<ActionResult<object>> 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<OtaJob> 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<ActionResult<OtaJob>> CustomFile(
[FromForm] string carIds,
[FromForm] string remotePath,
[FromForm] string? restartOps,
[FromForm] int? restartOp,
[FromForm] List<IFormFile>? files,
IFormFile? file,
CancellationToken ct)
{
if (DenyWrite(out var denied)) return denied;
var uploadFiles = new List<IFormFile>();
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<int>();
if (!string.IsNullOrWhiteSpace(restartOps))
{
try
{
ops = System.Text.Json.JsonSerializer.Deserialize<List<int>>(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<OtaCustomFileItem>();
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<string> ParseCarIds(string carIds)
{
var trimmed = carIds.Trim();
if (trimmed.StartsWith("[", StringComparison.Ordinal))
{
try
{
var ids = System.Text.Json.JsonSerializer.Deserialize<List<string>>(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<string, string> BuildMatch(OtaTarget target, OtaAppVersions? m, OtaAppVersions? d, OtaAppVersions? c)
{
var map = new Dictionary<string, string>(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));
}
}
@@ -0,0 +1,108 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MiGu.Server.Ota;
namespace MiGu.Server.Controllers;
/// <summary>
/// WatchDog 回传包接收端。
/// WatchDog 写死 POST 到 http://{config.serverIP}:8000/upload-mdcs/{routeKey}
/// 必须与参考 Electron Express :8000 路径一致;/api/ota/receive/* 仅作兼容别名。
/// </summary>
[ApiController]
[AllowAnonymous]
public class OtaReceiveController : ControllerBase
{
private readonly OtaStore _store;
private readonly ILogger<OtaReceiveController> _log;
public OtaReceiveController(OtaStore store, ILogger<OtaReceiveController> log)
{
_store = store;
_log = log;
}
[HttpGet("/hello")]
[HttpGet("/api/ota/receive/hello")]
public IActionResult Hello() => Ok("ok");
/// <summary>WatchDog 官方路径:/upload-mdcs/{Medullaexe|...}</summary>
[HttpPost("/upload-mdcs/{routeKey}")]
[HttpPost("/api/ota/receive/upload-mdcs/{routeKey}")]
[HttpPost("/api/ota/receive/upload-mdcs{routeKey}")]
[RequestSizeLimit(512_000_000)]
public Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IFormFile?> 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;
}
}
+251
View File
@@ -0,0 +1,251 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using MiGu.Server.Auth;
using MiGu.Server.Launcher;
using MiGu.Server.Persistence;
namespace MiGu.Server.Fleet;
/// <summary>
/// 单例:轮询 SimpleLite 车辆 + 每车状态,读取「车体_AlarmInfo/车体_AlarmLevel」并对帐进 platform.db(vehicle_alarms)。
/// 出现→开 active;文案变→更新;消失→置 cleared 并记录恢复时间/时长。永不删=完整历史;SimpleLite 离线仍可查最近记录。
/// </summary>
public sealed class AlarmCollector
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IHttpClientFactory _httpFactory;
private readonly SimpleLiteOptions _sl;
private readonly InternalTokenStore _token;
private readonly ILogger<AlarmCollector> _log;
private readonly SemaphoreSlim _gate = new(1, 1);
private static readonly JsonSerializerOptions JsonOpt = new() { PropertyNameCaseInsensitive = true };
public volatile bool Online;
public DateTimeOffset? LastSyncAt { get; private set; }
public AlarmCollector(
IServiceScopeFactory scopeFactory,
IHttpClientFactory httpFactory,
IOptions<SimpleLiteOptions> sl,
InternalTokenStore token,
ILogger<AlarmCollector> log)
{
_scopeFactory = scopeFactory;
_httpFactory = httpFactory;
_sl = sl.Value;
_token = token;
_log = log;
}
private sealed class CurrentAlarm
{
public int CarId;
public string CarName = "";
public string Info = "";
public int Level;
}
public async Task<bool> SyncOnceAsync(CancellationToken ct)
{
if (!await _gate.WaitAsync(0, ct)) return Online;
try
{
var cars = await FetchCarsAsync(ct);
if (cars == null)
{
Online = false;
return false;
}
var current = new Dictionary<int, CurrentAlarm>();
await Parallel.ForEachAsync(
cars,
new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct },
async (car, token) =>
{
var (info, level) = await FetchCarAlarmAsync(car.Id, token);
if (string.IsNullOrWhiteSpace(info)) return;
lock (current)
{
current[car.Id] = new CurrentAlarm { CarId = car.Id, CarName = car.Name, Info = info, Level = level };
}
});
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<PlatformDbContext>();
await ReconcileAsync(db, current, ct);
}
catch (Exception ex)
{
_log.LogDebug(ex, "alarm reconcile failed");
}
Online = true;
LastSyncAt = DateTimeOffset.UtcNow;
return true;
}
finally
{
_gate.Release();
}
}
private sealed class CarRow
{
public int Id;
public string Name = "";
}
private async Task<List<CarRow>?> FetchCarsAsync(CancellationToken ct)
{
var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222;
try
{
using var client = CreateClient();
using var resp = await client.SendAsync(Req($"http://127.0.0.1:{port}/projection/cars"), ct);
if (!resp.IsSuccessStatusCode) return null;
var text = await resp.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(text);
if (doc.RootElement.ValueKind != JsonValueKind.Array) return new();
var list = new List<CarRow>();
foreach (var el in doc.RootElement.EnumerateArray())
{
var id = el.TryGetProperty("rawId", out var rid) && rid.TryGetInt32(out var n) ? n : 0;
if (id <= 0) continue;
var name = el.TryGetProperty("name", out var nm) ? nm.GetString() ?? "" : "";
list.Add(new CarRow { Id = id, Name = name });
}
return list;
}
catch (Exception ex)
{
_log.LogDebug(ex, "alarm fetch cars failed");
return null;
}
}
private async Task<(string info, int level)> FetchCarAlarmAsync(int carId, CancellationToken ct)
{
var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222;
try
{
using var client = CreateClient();
using var resp = await client.SendAsync(Req($"http://127.0.0.1:{port}/projection/reflection/status/car/{carId}"), ct);
if (!resp.IsSuccessStatusCode) return ("", 0);
var text = await resp.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(text);
if (!doc.RootElement.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array)
return ("", 0);
string info = "";
var level = 0;
foreach (var kv in data.EnumerateArray())
{
var key = kv.TryGetProperty("key", out var k) ? k.GetString() : null;
var val = kv.TryGetProperty("value", out var v) ? v.GetString() : null;
if (key == "车体_AlarmInfo" || key == "AlarmInfo") info = val ?? "";
else if (key == "车体_AlarmLevel" || key == "AlarmLevel") int.TryParse(val, out level);
}
info = info.Trim();
if (info is "0" or "/" or "-") info = "";
return (info, level);
}
catch
{
return ("", 0);
}
}
private static async Task ReconcileAsync(PlatformDbContext db, Dictionary<int, CurrentAlarm> current, CancellationToken ct)
{
var now = DateTimeOffset.UtcNow;
var active = await db.VehicleAlarms.Where(a => a.Status == "active").ToListAsync(ct);
var activeByCar = new Dictionary<int, VehicleAlarmRecord>();
foreach (var a in active) activeByCar[a.CarId] = a; // 每车取一条 active
// 出现 / 更新
foreach (var cur in current.Values)
{
if (activeByCar.TryGetValue(cur.CarId, out var rec))
{
rec.Info = cur.Info;
rec.Level = cur.Level;
rec.CarName = cur.CarName;
rec.LastAt = now;
}
else
{
db.VehicleAlarms.Add(new VehicleAlarmRecord
{
CarId = cur.CarId,
CarName = cur.CarName,
Info = cur.Info,
Level = cur.Level,
Status = "active",
FirstAt = now,
LastAt = now
});
}
}
// 消失 → 恢复
foreach (var rec in active)
{
if (current.ContainsKey(rec.CarId)) continue;
rec.Status = "cleared";
rec.ResolvedAt = now;
rec.DurationSecs = (long)Math.Max(0, (now - rec.FirstAt).TotalSeconds);
}
await db.SaveChangesAsync(ct);
}
private HttpClient CreateClient()
{
var c = _httpFactory.CreateClient();
c.Timeout = TimeSpan.FromSeconds(10);
return c;
}
private HttpRequestMessage Req(string url)
{
var req = new HttpRequestMessage(HttpMethod.Get, url);
var token = _token.Token;
if (!string.IsNullOrEmpty(token))
req.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", token);
return req;
}
}
/// <summary>后台循环:定时采集车辆报警到 platform.db。</summary>
public sealed class AlarmCollectorService : BackgroundService
{
private readonly AlarmCollector _collector;
private readonly ILogger<AlarmCollectorService> _log;
public AlarmCollectorService(AlarmCollector collector, ILogger<AlarmCollectorService> log)
{
_collector = collector;
_log = log;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try { await Task.Delay(TimeSpan.FromSeconds(4), stoppingToken); }
catch { return; }
while (!stoppingToken.IsCancellationRequested)
{
try { await _collector.SyncOnceAsync(stoppingToken); }
catch (Exception ex) { _log.LogDebug(ex, "alarm collector loop error"); }
try { await Task.Delay(TimeSpan.FromSeconds(8), stoppingToken); }
catch { break; }
}
}
}
+33
View File
@@ -0,0 +1,33 @@
namespace MiGu.Server.Fleet;
/// <summary>
/// CDM 搬运任务的平台侧快照(表 cdm_tasks)。
/// 以任务 Id 为主键;SimpleLite/StandardScene 把终态任务从自身 JSON 里删除,这里则永久保留=完整历史,
/// 且 SimpleLite 关闭后平台仍可从本表读取最近快照。
/// </summary>
public sealed class CdmTaskRecord
{
public string Id { get; set; } = "";
public string? TaskId { get; set; }
public int MissionId { get; set; }
public string MissionName { get; set; } = "";
public string MissionTypeName { get; set; } = "";
public int SrcSiteId { get; set; }
public string SrcLabel { get; set; } = "";
public int DstSiteId { get; set; }
public string DstLabel { get; set; } = "";
public string Status { get; set; } = "";
public string StatusCode { get; set; } = "";
public int? CarId { get; set; }
public string? CarName { get; set; }
public int Priority { get; set; }
/// <summary>下发/开始/结束时间:直接存投影返回的 ISO 字符串(可空)。</summary>
public string? CreateTime { get; set; }
public string? StartTime { get; set; }
public string? FinishTime { get; set; }
public string? StuckReason { get; set; }
public bool Overdue { get; set; }
/// <summary>平台首次/最近一次同步到该任务的时间。</summary>
public DateTimeOffset FirstSeenAt { get; set; }
public DateTimeOffset LastSeenAt { get; set; }
}
+205
View File
@@ -0,0 +1,205 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using MiGu.Server.Auth;
using MiGu.Server.Launcher;
using MiGu.Server.Persistence;
namespace MiGu.Server.Fleet;
/// <summary>投影 /projection/deliveries 返回的单行(camelCase)。</summary>
public sealed class CdmTaskDto
{
public string id { get; set; } = "";
public string? taskId { get; set; }
public int missionId { get; set; }
public string missionName { get; set; } = "";
public string missionTypeName { get; set; } = "";
public int srcSiteId { get; set; }
public string srcLabel { get; set; } = "";
public int dstSiteId { get; set; }
public string dstLabel { get; set; } = "";
public string status { get; set; } = "";
public string statusCode { get; set; } = "";
public int? carId { get; set; }
public string? carName { get; set; }
public int priority { get; set; }
public string? createTime { get; set; }
public string? startTime { get; set; }
public string? finishTime { get; set; }
public string? stuckReason { get; set; }
public bool overdue { get; set; }
}
/// <summary>
/// 单例:从 SimpleLite 投影拉取 CDM 任务并 upsert 到 platform.db(cdm_tasks),永不删除=保留历史。
/// 同时维护「SimpleLite 是否在线 / 最近同步时间」,供任务页离线降级展示。
/// </summary>
public sealed class CdmTaskSyncer
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IHttpClientFactory _httpFactory;
private readonly SimpleLiteOptions _sl;
private readonly InternalTokenStore _token;
private readonly ILogger<CdmTaskSyncer> _log;
private readonly SemaphoreSlim _gate = new(1, 1);
private static readonly JsonSerializerOptions JsonOpt = new() { PropertyNameCaseInsensitive = true };
public volatile bool Online;
public DateTimeOffset? LastSyncAt { get; private set; }
public int LastCount { get; private set; }
public CdmTaskSyncer(
IServiceScopeFactory scopeFactory,
IHttpClientFactory httpFactory,
IOptions<SimpleLiteOptions> sl,
InternalTokenStore token,
ILogger<CdmTaskSyncer> log)
{
_scopeFactory = scopeFactory;
_httpFactory = httpFactory;
_sl = sl.Value;
_token = token;
_log = log;
}
/// <summary>拉取 + 落库一次。并发调用时若已有同步在进行则直接跳过(返回当前在线状态)。</summary>
public async Task<bool> SyncOnceAsync(CancellationToken ct)
{
if (!await _gate.WaitAsync(0, ct)) return Online;
try
{
var dtos = await FetchAsync(ct);
if (dtos == null)
{
Online = false;
return false;
}
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<PlatformDbContext>();
await UpsertAsync(db, dtos, ct);
}
catch (Exception ex)
{
// 拉取成功即视为在线;落库失败只记日志,不影响在线判定
_log.LogDebug(ex, "cdm upsert failed");
}
Online = true;
LastSyncAt = DateTimeOffset.UtcNow;
LastCount = dtos.Count;
return true;
}
finally
{
_gate.Release();
}
}
private async Task<List<CdmTaskDto>?> FetchAsync(CancellationToken ct)
{
var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222;
try
{
using var client = _httpFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(10);
using var req = new HttpRequestMessage(
HttpMethod.Get,
$"http://127.0.0.1:{port}/projection/deliveries?includeFinished=true&includeAborted=true");
var token = _token.Token;
if (!string.IsNullOrEmpty(token))
req.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", token);
using var resp = await client.SendAsync(req, ct);
if (!resp.IsSuccessStatusCode) return null;
var text = await resp.Content.ReadAsStringAsync(ct);
return JsonSerializer.Deserialize<List<CdmTaskDto>>(text, JsonOpt) ?? new List<CdmTaskDto>();
}
catch (Exception ex)
{
_log.LogDebug(ex, "cdm fetch failed");
return null;
}
}
private static async Task UpsertAsync(PlatformDbContext db, IReadOnlyList<CdmTaskDto> dtos, CancellationToken ct)
{
var valid = dtos.Where(d => !string.IsNullOrWhiteSpace(d.id)).ToList();
if (valid.Count == 0) return;
var now = DateTimeOffset.UtcNow;
var ids = valid.Select(d => d.id).ToList();
var existing = await db.CdmTasks.Where(t => ids.Contains(t.Id)).ToDictionaryAsync(t => t.Id, ct);
foreach (var d in valid)
{
if (existing.TryGetValue(d.id, out var rec))
{
Map(d, rec);
rec.LastSeenAt = now;
}
else
{
var created = new CdmTaskRecord { Id = d.id, FirstSeenAt = now, LastSeenAt = now };
Map(d, created);
db.CdmTasks.Add(created);
}
}
await db.SaveChangesAsync(ct);
}
private static void Map(CdmTaskDto d, CdmTaskRecord rec)
{
rec.TaskId = string.IsNullOrWhiteSpace(d.taskId) ? null : d.taskId;
rec.MissionId = d.missionId;
rec.MissionName = d.missionName ?? "";
rec.MissionTypeName = d.missionTypeName ?? "";
rec.SrcSiteId = d.srcSiteId;
rec.SrcLabel = d.srcLabel ?? "";
rec.DstSiteId = d.dstSiteId;
rec.DstLabel = d.dstLabel ?? "";
rec.Status = d.status ?? "";
rec.StatusCode = d.statusCode ?? "";
rec.CarId = d.carId;
rec.CarName = d.carName;
rec.Priority = d.priority;
rec.CreateTime = d.createTime;
rec.StartTime = d.startTime;
rec.FinishTime = d.finishTime;
rec.StuckReason = d.stuckReason;
rec.Overdue = d.overdue;
}
}
/// <summary>后台循环:定时把 CDM 任务同步进 platform.db,保证无人打开页面时也能捕获终态历史。</summary>
public sealed class CdmTaskSyncService : BackgroundService
{
private readonly CdmTaskSyncer _syncer;
private readonly ILogger<CdmTaskSyncService> _log;
public CdmTaskSyncService(CdmTaskSyncer syncer, ILogger<CdmTaskSyncService> log)
{
_syncer = syncer;
_log = log;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try { await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken); }
catch { return; }
while (!stoppingToken.IsCancellationRequested)
{
try { await _syncer.SyncOnceAsync(stoppingToken); }
catch (Exception ex) { _log.LogDebug(ex, "cdm sync loop error"); }
try { await Task.Delay(TimeSpan.FromSeconds(8), stoppingToken); }
catch { break; }
}
}
}
+21
View File
@@ -0,0 +1,21 @@
namespace MiGu.Server.Fleet;
/// <summary>与 SimpleLite GET /projection/fleet/health 行对齐,供车队运维前端消费。</summary>
public sealed class FleetHealthRowDto
{
public int CarId { get; set; }
public string? CarName { get; set; }
public string? Ip { get; set; }
public string? OnboardUrl { get; set; }
public int? LatencyMs { get; set; }
public bool? Reachable { get; set; }
public string? ProbedAt { get; set; }
public double? UptimeSecs { get; set; }
public double? AlarmActiveSecs { get; set; }
public double? FaultRatePercent { get; set; }
public bool? IsAlarmActive { get; set; }
public double? CpuPercent { get; set; }
public double? MemPercent { get; set; }
/// <summary>latency 探测通道:watchdog | onboard | none</summary>
public string? LatencySource { get; set; }
}
+169
View File
@@ -0,0 +1,169 @@
using System.Text.Json;
using Microsoft.Extensions.Options;
using MiGu.Server.Auth;
using MiGu.Server.Launcher;
using MiGu.Server.Ota;
namespace MiGu.Server.Fleet;
/// <summary>
/// 车队健康:保留 SimpleLite 的 CPU/故障率等,延迟改为对 WatchDog(:9776) 做 TCP RTT。
/// SimpleLite /fleet/health 探测的是车载 HTTP :8081,多数现场未开该端口会假超时 2000ms。
/// </summary>
public sealed class FleetHealthService
{
private readonly IHttpClientFactory _httpFactory;
private readonly SimpleLiteOptions _sl;
private readonly InternalTokenStore _token;
private readonly WatchDogClient _wd;
private readonly ILogger<FleetHealthService> _log;
private static readonly JsonSerializerOptions JsonOpt = new()
{
PropertyNameCaseInsensitive = true
};
public FleetHealthService(
IHttpClientFactory httpFactory,
IOptions<SimpleLiteOptions> sl,
InternalTokenStore token,
WatchDogClient wd,
ILogger<FleetHealthService> log)
{
_httpFactory = httpFactory;
_sl = sl.Value;
_token = token;
_wd = wd;
_log = log;
}
public async Task<List<FleetHealthRowDto>> GetAsync(CancellationToken ct)
{
var rows = await FetchSimpleLiteHealthAsync(ct);
if (rows.Count == 0)
rows = await BuildRowsFromCarsAsync(ct);
await Parallel.ForEachAsync(
rows,
new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct },
async (row, token) =>
{
if (string.IsNullOrWhiteSpace(row.Ip))
{
// 无 IP 时保留 SimpleLite 原探测结果
row.LatencySource ??= row.LatencyMs != null ? "onboard" : "none";
return;
}
// 取两次 TCP 连接的较小值,降低偶发握手抖动
var a = await _wd.MeasureRttMsAsync(row.Ip, token);
var b = await _wd.MeasureRttMsAsync(row.Ip, token);
int? rtt = (a, b) switch
{
(null, null) => null,
(int x, null) => x,
(null, int y) => y,
(int x, int y) => Math.Min(x, y)
};
if (rtt != null)
{
row.LatencyMs = rtt;
row.Reachable = true;
row.LatencySource = "watchdog";
row.ProbedAt = DateTimeOffset.UtcNow.ToString("O");
}
else
{
// WatchDog 不通:若 SimpleLite 车载探测成功则保留,否则标不可达
if (row.Reachable == true && row.LatencyMs is > 0 and < 2000)
{
row.LatencySource = "onboard";
}
else
{
row.Reachable = false;
row.LatencyMs = null;
row.LatencySource = "watchdog";
row.ProbedAt = DateTimeOffset.UtcNow.ToString("O");
}
}
});
return rows;
}
private async Task<List<FleetHealthRowDto>> FetchSimpleLiteHealthAsync(CancellationToken ct)
{
var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222;
try
{
using var client = _httpFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(20);
using var req = new HttpRequestMessage(HttpMethod.Get, $"http://127.0.0.1:{port}/projection/fleet/health");
var token = _token.Token;
if (!string.IsNullOrEmpty(token))
req.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", token);
using var resp = await client.SendAsync(req, ct);
if (!resp.IsSuccessStatusCode) return new();
var text = await resp.Content.ReadAsStringAsync(ct);
var list = JsonSerializer.Deserialize<List<FleetHealthRowDto>>(text, JsonOpt);
return list ?? new();
}
catch (Exception ex)
{
_log.LogDebug(ex, "fleet/health from SimpleLite failed");
return new();
}
}
private async Task<List<FleetHealthRowDto>> BuildRowsFromCarsAsync(CancellationToken ct)
{
var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222;
try
{
using var client = _httpFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(10);
using var req = new HttpRequestMessage(HttpMethod.Get, $"http://127.0.0.1:{port}/projection/cars");
var token = _token.Token;
if (!string.IsNullOrEmpty(token))
req.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", token);
using var resp = await client.SendAsync(req, ct);
if (!resp.IsSuccessStatusCode) return new();
var text = await resp.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(text);
if (doc.RootElement.ValueKind != JsonValueKind.Array) return new();
var list = new List<FleetHealthRowDto>();
foreach (var el in doc.RootElement.EnumerateArray())
{
var rawId = el.TryGetProperty("rawId", out var rid) && rid.TryGetInt32(out var id)
? id
: 0;
if (rawId <= 0) continue;
var ip = el.TryGetProperty("ip", out var ipEl) ? ipEl.GetString() : null;
var name = el.TryGetProperty("name", out var nEl) ? nEl.GetString() : null;
var onboard = el.TryGetProperty("onboardUrl", out var oEl) ? oEl.GetString() : null;
if (string.IsNullOrEmpty(onboard) && !string.IsNullOrEmpty(ip))
onboard = $"http://{ip}:8081";
list.Add(new FleetHealthRowDto
{
CarId = rawId,
CarName = name,
Ip = ip,
OnboardUrl = onboard
});
}
return list;
}
catch (Exception ex)
{
_log.LogDebug(ex, "projection/cars fallback for fleet health failed");
return new();
}
}
}
+29
View File
@@ -0,0 +1,29 @@
namespace MiGu.Server.Fleet;
/// <summary>
/// 车辆报警的平台侧记录(表 vehicle_alarms)。
/// SimpleLite 只在 SSE/状态里给出「当前是否报警 + 文案」,无历史;平台按车对帐:
/// 出现报警→开一条 active 记录,报警文案变化→更新,报警消失→置为 cleared 并记录恢复时间/持续时长。
/// 永不删除=完整历史,重启/刷新不丢,SimpleLite 离线也可查。
/// </summary>
public sealed class VehicleAlarmRecord
{
public string Id { get; set; } = Guid.NewGuid().ToString("D");
public int CarId { get; set; }
public string CarName { get; set; } = "";
/// <summary>报警文案(车体_AlarmInfo)。</summary>
public string Info { get; set; } = "";
/// <summary>报警级别(车体_AlarmLevel,未知为 0)。</summary>
public int Level { get; set; }
/// <summary>active | cleared</summary>
public string Status { get; set; } = "active";
public DateTimeOffset FirstAt { get; set; }
public DateTimeOffset LastAt { get; set; }
public DateTimeOffset? ResolvedAt { get; set; }
/// <summary>持续时长(秒),恢复后写入。</summary>
public long? DurationSecs { get; set; }
/// <summary>预留:平台侧确认(不代表车端消警)。</summary>
public bool Acknowledged { get; set; }
public DateTimeOffset? AcknowledgedAt { get; set; }
public string? AcknowledgedBy { get; set; }
}
+26
View File
@@ -0,0 +1,26 @@
using System.Security.Cryptography;
namespace MiGu.Server.Ota;
public static class OtaHash
{
/// <summary>与参考 OTA 工具一致:MD5 → Base64,并去掉 '-'。</summary>
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<byte> 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];
}
}
+433
View File
@@ -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<OtaJobRunner> _log;
private readonly ConcurrentDictionary<string, CancellationTokenSource> _running = new();
public OtaJobRunner(OtaStore store, WatchDogClient wd, OtaVehicleSource vehicles, ILogger<OtaJobRunner> 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<OtaCustomFileItem> 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<int> { 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<string> { "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<string> { $"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<int> { 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<OtaCustomFileItem> 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<OtaCustomFileItem>
{
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<int> { 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";
}
}
+144
View File
@@ -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;
/// <summary>skip | confirm</summary>
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<string, OtaFileArtifact> 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<string, OtaFileArtifact> 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<string, string>? 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; } = "";
/// <summary>sync | customFile | configPush</summary>
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<string> CarIds { get; set; } = new();
public List<string> Components { get; set; } = new();
public bool RequireLatencyCheck { get; set; }
public int DoneSteps { get; set; }
public int TotalSteps { get; set; }
public List<OtaJobStep> 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<OtaCustomFileItem> CustomFiles { get; set; } = new();
public List<int> CustomRestartOps { get; set; } = new() { -1 };
// configPush
public string? ConfigApp { get; set; }
public string? ConfigJson { get; set; }
}
public sealed class CreateSyncJobRequest
{
public List<string> CarIds { get; set; } = new();
public List<string>? Components { get; set; }
public bool? RequireLatencyCheck { get; set; }
}
public sealed class PullPackageRequest
{
public string CarId { get; set; } = "";
}
public sealed class CreateCustomFileJobRequest
{
public List<string> CarIds { get; set; } = new();
public string RemotePath { get; set; } = "";
/// <summary>兼容旧单值;优先用 RestartOps。</summary>
public int RestartOp { get; set; } = -1;
/// <summary>-1 不重启;0 Medulla1 Clumsy2 Detour3 WatchDog。可多选。</summary>
public List<int>? 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<string> CarIds { get; set; } = new();
public string App { get; set; } = "";
public string Json { get; set; } = "";
public bool? RequireLatencyCheck { get; set; }
}
+24
View File
@@ -0,0 +1,24 @@
namespace MiGu.Server.Ota;
public sealed class OtaOptions
{
/// <summary>相对 ContentRoot 或绝对路径;默认 data/ota</summary>
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;
/// <summary>
/// WatchDog 回传监听端口(写死连 :8000)。MiGu 会额外监听该端口并挂 /upload-mdcs/*。
/// </summary>
public int ReceivePort { get; set; } = 8000;
/// <summary>
/// 可选:本机对车辆可见的管理面基址(如 http://192.168.1.10:8080)。
/// 注意:现网 WatchDog 忽略 getmdcsexe 的 server 参数,仍回传到 config.serverIP:ReceivePort。
/// </summary>
public string? PublicBaseUrl { get; set; }
}
+53
View File
@@ -0,0 +1,53 @@
namespace MiGu.Server.Ota;
/// <summary>MDC 组件文件映射(对齐参考工具 MDCPath.json)。</summary>
public static class OtaPathMap
{
public static readonly IReadOnlyDictionary<string, string[]> Default = new Dictionary<string, string[]>(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;
}
}
+368
View File
@@ -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<OtaStore> _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<string, string> _pullByIp =
new(StringComparer.OrdinalIgnoreCase);
private long _jobSeq;
public OtaStore(IWebHostEnvironment env, IOptions<OtaOptions> options, ILogger<OtaStore> 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<OtaSettings>(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<OtaTarget>(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<OtaSettings>(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;
}
}
/// <summary>把 WatchDog 回传连接的远端 IP 归一(去掉 IPv6 映射前缀,如 ::ffff:192.168.1.13)。</summary>
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<OtaPackageInfo> ListPackages()
{
if (!Directory.Exists(PackagesDir)) return new();
var list = new List<OtaPackageInfo>();
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<OtaJob>(File.ReadAllText(path), _json);
}
catch (Exception ex)
{
_log.LogWarning(ex, "job load failed {Id}", id);
return null;
}
}
public List<OtaJob> ListJobs(int take = 100)
{
if (!Directory.Exists(JobsDir)) return new();
return Directory.GetFiles(JobsDir, "*.json")
.Select(f =>
{
try { return JsonSerializer.Deserialize<OtaJob>(File.ReadAllText(f), _json); }
catch { return null; }
})
.Where(j => j != null)
.Cast<OtaJob>()
.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;
}
/// <summary>拒绝路径段(含 .. / 分隔符),只允许单层文件名。</summary>
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;
}
}
+141
View File
@@ -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<OtaVehicleSource> _log;
public OtaVehicleSource(
IHttpClientFactory httpFactory,
IOptions<SimpleLiteOptions> sl,
InternalTokenStoreAccessor token,
ILogger<OtaVehicleSource> log)
{
_httpFactory = httpFactory;
_sl = sl.Value;
_token = token;
_log = log;
}
public async Task<List<OtaVehicleRow>> 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<List<OtaVehicleRow>> 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<List<OtaVehicleRow>> 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<OtaVehicleRow>();
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<OtaVehicleRow> 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<OtaVehicleRow>();
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;
}
}
/// <summary>避免 Ota 层直接依赖 Auth 命名空间循环;薄包装 InternalTokenStore。</summary>
public sealed class InternalTokenStoreAccessor
{
private readonly Auth.InternalTokenStore _store;
public InternalTokenStoreAccessor(Auth.InternalTokenStore store) => _store = store;
public string Token => _store.Token;
}
+289
View File
@@ -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<WatchDogClient> _log;
private readonly JsonSerializerOptions _json = new() { PropertyNameCaseInsensitive = true };
public WatchDogClient(IHttpClientFactory httpFactory, IOptions<OtaOptions> opt, ILogger<WatchDogClient> 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<int?> 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<string, string> { ["path"] = remotePath }, ct);
}
private async Task UploadFileAsync(string url, string localPath, string fileName, int bandwidthKbps, Dictionary<string, string>? 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<string> 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}");
}
}
/// <summary>简易限速流:按字节/秒节流读取。</summary>
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<int> 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);
}
}
}
@@ -27,6 +27,8 @@ public sealed class PlatformDbContext : DbContext
public DbSet<WmsTransportTaskHistory> WmsTransportTaskHistories => Set<WmsTransportTaskHistory>();
public DbSet<SimpleField> SimpleFields => Set<SimpleField>();
public DbSet<UserDashboardShortcut> UserDashboardShortcuts => Set<UserDashboardShortcut>();
public DbSet<MiGu.Server.Fleet.CdmTaskRecord> CdmTasks => Set<MiGu.Server.Fleet.CdmTaskRecord>();
public DbSet<MiGu.Server.Fleet.VehicleAlarmRecord> VehicleAlarms => Set<MiGu.Server.Fleet.VehicleAlarmRecord>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -85,6 +87,12 @@ public sealed class PlatformDbContext : DbContext
modelBuilder.Entity<Material>().HasIndex(x => new { x.LifecycleStatus, x.UpdatedAt });
modelBuilder.Entity<Material>().HasIndex(x => x.TypeCode);
modelBuilder.Entity<ContainerLocation>().HasIndex(x => x.ContainerId).IsUnique();
// 库位占用 1:1:同一 Storage LocationId 同时只能有一条未删除记录(Car 等其它类型不限)
modelBuilder.Entity<ContainerLocation>()
.HasIndex(x => x.LocationId)
.IsUnique()
.HasFilter("LocationType = 'Storage' AND IsDeleted = 0")
.HasDatabaseName("IX_wms_container_locations_StorageLocationId");
modelBuilder.Entity<ContainerLocation>().HasIndex(x => new { x.LocationType, x.LocationId });
modelBuilder.Entity<ContainerMaterial>().HasIndex(x => x.MaterialId).IsUnique();
modelBuilder.Entity<ContainerMaterial>().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<MiGu.Server.Fleet.VehicleAlarmRecord>();
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<MiGu.Server.Fleet.CdmTaskRecord>();
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)
@@ -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 ''");
}
/// <summary>为已存在的数据库补建 vehicle_alarms 表(车辆报警平台侧记录,幂等)。</summary>
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<Microsoft.EntityFrameworkCore.Storage.IRelationalDatabaseCreator>();
await creator.CreateTablesAsync();
}
}
/// <summary>为已存在的数据库补建 cdm_tasks 表(CDM 搬运任务平台侧快照,幂等)。</summary>
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<Microsoft.EntityFrameworkCore.Storage.IRelationalDatabaseCreator>();
await creator.CreateTablesAsync();
}
}
/// <summary>为已存在的数据库补建 simple_fields 表(幂等)。</summary>
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();
+59 -5
View File
@@ -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")))
// 管理面默认 :8080WatchDog 拉包回传写死 :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-historyWatchDog 写死回传)。
// 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<Microsoft.AspNetCore.Http.Features.FormOptions>(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<InternalTokenStore>();
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<SimpleLiteOptions>(builder.Configuration.GetSection("SimpleLite"));
builder.Services.AddSingleton<SimpleLiteLauncher>();
// OTAWatchDog 编排):包库 / 任务 / 出站客户端
builder.Services.Configure<OtaOptions>(builder.Configuration.GetSection("Ota"));
builder.Services.AddSingleton<OtaStore>();
builder.Services.AddSingleton<InternalTokenStoreAccessor>();
builder.Services.AddSingleton<OtaVehicleSource>();
builder.Services.AddSingleton<WatchDogClient>();
builder.Services.AddSingleton<OtaJobRunner>();
builder.Services.AddSingleton<MiGu.Server.Fleet.FleetHealthService>();
builder.Services.AddSingleton<MiGu.Server.Fleet.CdmTaskSyncer>();
builder.Services.AddHostedService<MiGu.Server.Fleet.CdmTaskSyncService>();
builder.Services.AddSingleton<MiGu.Server.Fleet.AlarmCollector>();
builder.Services.AddHostedService<MiGu.Server.Fleet.AlarmCollectorService>();
builder.Services.AddHttpClient(nameof(WatchDogClient));
var app = builder.Build();
await app.Services.EnsurePlatformDatabaseAsync();
+1 -1
View File
@@ -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"
}
+18
View File
@@ -44,6 +44,15 @@
"Dispatch": {
}
},
"_comment_Ota": "车辆 OTA:包与任务落盘 DataRootWatchDog: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",
@@ -0,0 +1,117 @@
# 迷榖 OTAWatchDog)实现计划
> **状态:** 核心任务已落地(后端 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` | Taboverview \| 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` 通过。
### 任务 2WatchDogClient + 车辆源 + 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 通过。
### 任务 3JobRunner + 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 编排 | 23 |
| 包库/目标/任务/设置/延迟 | 3、5 |
| 配置同步/自定义文件 | 3、5 |
| 审计 | 3 |
| 验收 §9 | 6 |
@@ -0,0 +1,258 @@
# 迷榖车辆运维 OTAWatchDog)设计规格
**日期:** 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 |
- 版本标识:文件 **MD5Base64,去除 `-`**,与 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}` | 单车 JSONmedulla |
| POST | `/api/ota/config/push` | JSON 多车下发(走 job |
| GET | `/api/ota/latency` | 按需批量 RTT(受设置开关约束) |
权限:JWT/RBACPlatform 全量写;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 内按钮开关;开才检测 |