merge
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,26 @@ public class HealthController : ControllerBase
|
||||
[Authorize]
|
||||
public IActionResult GetSimpleLiteDiagnostics() => Ok(_launcher.GetDiagnostics());
|
||||
|
||||
/// <summary>关闭本机全部 SimpleLite 进程。仅 Platform 管理端可调。</summary>
|
||||
[HttpPost("simplelite/stop")]
|
||||
[Authorize(Policy = "PlatformScope")]
|
||||
public IActionResult StopSimpleLite()
|
||||
{
|
||||
var killed = _launcher.StopAll();
|
||||
var diag = _launcher.GetDiagnostics();
|
||||
return Ok(new { killed, diagnostics = diag });
|
||||
}
|
||||
|
||||
/// <summary>关闭并重新拉起 SimpleLite(不同步 DLL)。仅 Platform 管理端可调。</summary>
|
||||
[HttpPost("simplelite/restart")]
|
||||
[Authorize(Policy = "PlatformScope")]
|
||||
public IActionResult RestartSimpleLite([FromQuery] string launchMode = "webonly")
|
||||
{
|
||||
var result = _launcher.Restart(launchMode);
|
||||
var diag = _launcher.GetDiagnostics();
|
||||
return Ok(new { restart = result, diagnostics = diag });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 关闭 SimpleLite、同步最新 DLL、重新拉起。用于「前往站点」API 缺失时一键更新。
|
||||
/// 会终止本机全部 SimpleLite 进程并重启,仅 Platform 管理端可调。
|
||||
|
||||
@@ -133,6 +133,10 @@ public class OpsController : ControllerBase
|
||||
// 即使 SimpleLite 默认放行 loopback,也带上 internal token,兼容其严格模式(AllowLoopback=false)。
|
||||
if (!string.IsNullOrEmpty(_internalToken.Token))
|
||||
msg.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", _internalToken.Token);
|
||||
// 运维面板已对 needConfirm 动作做过二次确认;内核 RequiresPlatformConfirm 方法需此头。
|
||||
msg.Headers.TryAddWithoutValidation("X-Platform-Confirmed", "1");
|
||||
if (!string.IsNullOrWhiteSpace(user))
|
||||
msg.Headers.TryAddWithoutValidation("X-Platform-User", user);
|
||||
using var resp = await client.SendAsync(msg);
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
var success = resp.IsSuccessStatusCode && ParseSuccess(body);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -36,22 +36,24 @@ public sealed class SimpleFieldController : ControllerBase
|
||||
/// 新增单条字段;同车型 + 字段类型下 key 不可重复
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "PlatformScope")]
|
||||
public Task<SimpleField> Create([FromBody] SimpleFieldRequest req) => _service.SaveAsync(req);
|
||||
|
||||
/// <summary>
|
||||
/// 按 id 更新单条字段
|
||||
/// </summary>
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Policy = "PlatformScope")]
|
||||
public Task<SimpleField> Update(Guid id, [FromBody] SimpleFieldRequest req) => _service.SaveAsync(req with { Id = id });
|
||||
|
||||
/// <summary>
|
||||
/// 按 id 删除单条字段
|
||||
/// </summary>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Policy = "PlatformScope")]
|
||||
public async Task<IActionResult> Delete(Guid id)
|
||||
{
|
||||
await _service.DeleteAsync(id);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -61,10 +63,10 @@ public sealed class SimpleFieldController : ControllerBase
|
||||
/// 返回实际写入条数 <c>{ count }</c>。
|
||||
/// </summary>
|
||||
[HttpPost("batch")]
|
||||
[Authorize(Policy = "PlatformScope")]
|
||||
public async Task<IActionResult> SaveBatch([FromBody] SimpleFieldBatchRequest req)
|
||||
{
|
||||
var count = await _service.SaveBatchAsync(req);
|
||||
|
||||
return Ok(new { count });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MiGu.Server.Auth;
|
||||
using MiGu.Server.Launcher;
|
||||
using MiGu.Server.Persistence;
|
||||
|
||||
namespace MiGu.Server.Fleet;
|
||||
|
||||
/// <summary>
|
||||
/// 单例:轮询 SimpleLite 车辆 + 每车状态,读取「车体_AlarmInfo/车体_AlarmLevel」并对帐进 platform.db(vehicle_alarms)。
|
||||
/// 出现→开 active;文案变→更新;消失→置 cleared 并记录恢复时间/时长。永不删=完整历史;SimpleLite 离线仍可查最近记录。
|
||||
/// </summary>
|
||||
public sealed class AlarmCollector
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly SimpleLiteOptions _sl;
|
||||
private readonly InternalTokenStore _token;
|
||||
private readonly ILogger<AlarmCollector> _log;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpt = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
public volatile bool Online;
|
||||
public DateTimeOffset? LastSyncAt { get; private set; }
|
||||
|
||||
public AlarmCollector(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IHttpClientFactory httpFactory,
|
||||
IOptions<SimpleLiteOptions> sl,
|
||||
InternalTokenStore token,
|
||||
ILogger<AlarmCollector> log)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_httpFactory = httpFactory;
|
||||
_sl = sl.Value;
|
||||
_token = token;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
private sealed class CurrentAlarm
|
||||
{
|
||||
public int CarId;
|
||||
public string CarName = "";
|
||||
public string Info = "";
|
||||
public int Level;
|
||||
}
|
||||
|
||||
public async Task<bool> SyncOnceAsync(CancellationToken ct)
|
||||
{
|
||||
if (!await _gate.WaitAsync(0, ct)) return Online;
|
||||
try
|
||||
{
|
||||
var cars = await FetchCarsAsync(ct);
|
||||
if (cars == null)
|
||||
{
|
||||
Online = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
var current = new Dictionary<int, CurrentAlarm>();
|
||||
await Parallel.ForEachAsync(
|
||||
cars,
|
||||
new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct },
|
||||
async (car, token) =>
|
||||
{
|
||||
var (info, level) = await FetchCarAlarmAsync(car.Id, token);
|
||||
if (string.IsNullOrWhiteSpace(info)) return;
|
||||
lock (current)
|
||||
{
|
||||
current[car.Id] = new CurrentAlarm { CarId = car.Id, CarName = car.Name, Info = info, Level = level };
|
||||
}
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<PlatformDbContext>();
|
||||
await ReconcileAsync(db, current, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogDebug(ex, "alarm reconcile failed");
|
||||
}
|
||||
|
||||
Online = true;
|
||||
LastSyncAt = DateTimeOffset.UtcNow;
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CarRow
|
||||
{
|
||||
public int Id;
|
||||
public string Name = "";
|
||||
}
|
||||
|
||||
private async Task<List<CarRow>?> FetchCarsAsync(CancellationToken ct)
|
||||
{
|
||||
var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222;
|
||||
try
|
||||
{
|
||||
using var client = CreateClient();
|
||||
using var resp = await client.SendAsync(Req($"http://127.0.0.1:{port}/projection/cars"), ct);
|
||||
if (!resp.IsSuccessStatusCode) return null;
|
||||
var text = await resp.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Array) return new();
|
||||
var list = new List<CarRow>();
|
||||
foreach (var el in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
var id = el.TryGetProperty("rawId", out var rid) && rid.TryGetInt32(out var n) ? n : 0;
|
||||
if (id <= 0) continue;
|
||||
var name = el.TryGetProperty("name", out var nm) ? nm.GetString() ?? "" : "";
|
||||
list.Add(new CarRow { Id = id, Name = name });
|
||||
}
|
||||
return list;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogDebug(ex, "alarm fetch cars failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(string info, int level)> FetchCarAlarmAsync(int carId, CancellationToken ct)
|
||||
{
|
||||
var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222;
|
||||
try
|
||||
{
|
||||
using var client = CreateClient();
|
||||
using var resp = await client.SendAsync(Req($"http://127.0.0.1:{port}/projection/reflection/status/car/{carId}"), ct);
|
||||
if (!resp.IsSuccessStatusCode) return ("", 0);
|
||||
var text = await resp.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
if (!doc.RootElement.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array)
|
||||
return ("", 0);
|
||||
|
||||
string info = "";
|
||||
var level = 0;
|
||||
foreach (var kv in data.EnumerateArray())
|
||||
{
|
||||
var key = kv.TryGetProperty("key", out var k) ? k.GetString() : null;
|
||||
var val = kv.TryGetProperty("value", out var v) ? v.GetString() : null;
|
||||
if (key == "车体_AlarmInfo" || key == "AlarmInfo") info = val ?? "";
|
||||
else if (key == "车体_AlarmLevel" || key == "AlarmLevel") int.TryParse(val, out level);
|
||||
}
|
||||
|
||||
info = info.Trim();
|
||||
if (info is "0" or "/" or "-") info = "";
|
||||
return (info, level);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return ("", 0);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReconcileAsync(PlatformDbContext db, Dictionary<int, CurrentAlarm> current, CancellationToken ct)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var active = await db.VehicleAlarms.Where(a => a.Status == "active").ToListAsync(ct);
|
||||
var activeByCar = new Dictionary<int, VehicleAlarmRecord>();
|
||||
foreach (var a in active) activeByCar[a.CarId] = a; // 每车取一条 active
|
||||
|
||||
// 出现 / 更新
|
||||
foreach (var cur in current.Values)
|
||||
{
|
||||
if (activeByCar.TryGetValue(cur.CarId, out var rec))
|
||||
{
|
||||
rec.Info = cur.Info;
|
||||
rec.Level = cur.Level;
|
||||
rec.CarName = cur.CarName;
|
||||
rec.LastAt = now;
|
||||
}
|
||||
else
|
||||
{
|
||||
db.VehicleAlarms.Add(new VehicleAlarmRecord
|
||||
{
|
||||
CarId = cur.CarId,
|
||||
CarName = cur.CarName,
|
||||
Info = cur.Info,
|
||||
Level = cur.Level,
|
||||
Status = "active",
|
||||
FirstAt = now,
|
||||
LastAt = now
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 消失 → 恢复
|
||||
foreach (var rec in active)
|
||||
{
|
||||
if (current.ContainsKey(rec.CarId)) continue;
|
||||
rec.Status = "cleared";
|
||||
rec.ResolvedAt = now;
|
||||
rec.DurationSecs = (long)Math.Max(0, (now - rec.FirstAt).TotalSeconds);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private HttpClient CreateClient()
|
||||
{
|
||||
var c = _httpFactory.CreateClient();
|
||||
c.Timeout = TimeSpan.FromSeconds(10);
|
||||
return c;
|
||||
}
|
||||
|
||||
private HttpRequestMessage Req(string url)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
var token = _token.Token;
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
req.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", token);
|
||||
return req;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>后台循环:定时采集车辆报警到 platform.db。</summary>
|
||||
public sealed class AlarmCollectorService : BackgroundService
|
||||
{
|
||||
private readonly AlarmCollector _collector;
|
||||
private readonly ILogger<AlarmCollectorService> _log;
|
||||
|
||||
public AlarmCollectorService(AlarmCollector collector, ILogger<AlarmCollectorService> log)
|
||||
{
|
||||
_collector = collector;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(4), stoppingToken); }
|
||||
catch { return; }
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try { await _collector.SyncOnceAsync(stoppingToken); }
|
||||
catch (Exception ex) { _log.LogDebug(ex, "alarm collector loop error"); }
|
||||
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(8), stoppingToken); }
|
||||
catch { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace MiGu.Server.Fleet;
|
||||
|
||||
/// <summary>
|
||||
/// CDM 搬运任务的平台侧快照(表 cdm_tasks)。
|
||||
/// 以任务 Id 为主键;SimpleLite/StandardScene 把终态任务从自身 JSON 里删除,这里则永久保留=完整历史,
|
||||
/// 且 SimpleLite 关闭后平台仍可从本表读取最近快照。
|
||||
/// </summary>
|
||||
public sealed class CdmTaskRecord
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string? TaskId { get; set; }
|
||||
public int MissionId { get; set; }
|
||||
public string MissionName { get; set; } = "";
|
||||
public string MissionTypeName { get; set; } = "";
|
||||
public int SrcSiteId { get; set; }
|
||||
public string SrcLabel { get; set; } = "";
|
||||
public int DstSiteId { get; set; }
|
||||
public string DstLabel { get; set; } = "";
|
||||
public string Status { get; set; } = "";
|
||||
public string StatusCode { get; set; } = "";
|
||||
public int? CarId { get; set; }
|
||||
public string? CarName { get; set; }
|
||||
public int Priority { get; set; }
|
||||
/// <summary>下发/开始/结束时间:直接存投影返回的 ISO 字符串(可空)。</summary>
|
||||
public string? CreateTime { get; set; }
|
||||
public string? StartTime { get; set; }
|
||||
public string? FinishTime { get; set; }
|
||||
public string? StuckReason { get; set; }
|
||||
public bool Overdue { get; set; }
|
||||
/// <summary>平台首次/最近一次同步到该任务的时间。</summary>
|
||||
public DateTimeOffset FirstSeenAt { get; set; }
|
||||
public DateTimeOffset LastSeenAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MiGu.Server.Auth;
|
||||
using MiGu.Server.Launcher;
|
||||
using MiGu.Server.Persistence;
|
||||
|
||||
namespace MiGu.Server.Fleet;
|
||||
|
||||
/// <summary>投影 /projection/deliveries 返回的单行(camelCase)。</summary>
|
||||
public sealed class CdmTaskDto
|
||||
{
|
||||
public string id { get; set; } = "";
|
||||
public string? taskId { get; set; }
|
||||
public int missionId { get; set; }
|
||||
public string missionName { get; set; } = "";
|
||||
public string missionTypeName { get; set; } = "";
|
||||
public int srcSiteId { get; set; }
|
||||
public string srcLabel { get; set; } = "";
|
||||
public int dstSiteId { get; set; }
|
||||
public string dstLabel { get; set; } = "";
|
||||
public string status { get; set; } = "";
|
||||
public string statusCode { get; set; } = "";
|
||||
public int? carId { get; set; }
|
||||
public string? carName { get; set; }
|
||||
public int priority { get; set; }
|
||||
public string? createTime { get; set; }
|
||||
public string? startTime { get; set; }
|
||||
public string? finishTime { get; set; }
|
||||
public string? stuckReason { get; set; }
|
||||
public bool overdue { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 单例:从 SimpleLite 投影拉取 CDM 任务并 upsert 到 platform.db(cdm_tasks),永不删除=保留历史。
|
||||
/// 同时维护「SimpleLite 是否在线 / 最近同步时间」,供任务页离线降级展示。
|
||||
/// </summary>
|
||||
public sealed class CdmTaskSyncer
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly SimpleLiteOptions _sl;
|
||||
private readonly InternalTokenStore _token;
|
||||
private readonly ILogger<CdmTaskSyncer> _log;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpt = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
public volatile bool Online;
|
||||
public DateTimeOffset? LastSyncAt { get; private set; }
|
||||
public int LastCount { get; private set; }
|
||||
|
||||
public CdmTaskSyncer(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IHttpClientFactory httpFactory,
|
||||
IOptions<SimpleLiteOptions> sl,
|
||||
InternalTokenStore token,
|
||||
ILogger<CdmTaskSyncer> log)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_httpFactory = httpFactory;
|
||||
_sl = sl.Value;
|
||||
_token = token;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>拉取 + 落库一次。并发调用时若已有同步在进行则直接跳过(返回当前在线状态)。</summary>
|
||||
public async Task<bool> SyncOnceAsync(CancellationToken ct)
|
||||
{
|
||||
if (!await _gate.WaitAsync(0, ct)) return Online;
|
||||
try
|
||||
{
|
||||
var dtos = await FetchAsync(ct);
|
||||
if (dtos == null)
|
||||
{
|
||||
Online = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<PlatformDbContext>();
|
||||
await UpsertAsync(db, dtos, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 拉取成功即视为在线;落库失败只记日志,不影响在线判定
|
||||
_log.LogDebug(ex, "cdm upsert failed");
|
||||
}
|
||||
|
||||
Online = true;
|
||||
LastSyncAt = DateTimeOffset.UtcNow;
|
||||
LastCount = dtos.Count;
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<CdmTaskDto>?> FetchAsync(CancellationToken ct)
|
||||
{
|
||||
var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222;
|
||||
try
|
||||
{
|
||||
using var client = _httpFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
using var req = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
$"http://127.0.0.1:{port}/projection/deliveries?includeFinished=true&includeAborted=true");
|
||||
var token = _token.Token;
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
req.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", token);
|
||||
|
||||
using var resp = await client.SendAsync(req, ct);
|
||||
if (!resp.IsSuccessStatusCode) return null;
|
||||
var text = await resp.Content.ReadAsStringAsync(ct);
|
||||
return JsonSerializer.Deserialize<List<CdmTaskDto>>(text, JsonOpt) ?? new List<CdmTaskDto>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogDebug(ex, "cdm fetch failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task UpsertAsync(PlatformDbContext db, IReadOnlyList<CdmTaskDto> dtos, CancellationToken ct)
|
||||
{
|
||||
var valid = dtos.Where(d => !string.IsNullOrWhiteSpace(d.id)).ToList();
|
||||
if (valid.Count == 0) return;
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var ids = valid.Select(d => d.id).ToList();
|
||||
var existing = await db.CdmTasks.Where(t => ids.Contains(t.Id)).ToDictionaryAsync(t => t.Id, ct);
|
||||
|
||||
foreach (var d in valid)
|
||||
{
|
||||
if (existing.TryGetValue(d.id, out var rec))
|
||||
{
|
||||
Map(d, rec);
|
||||
rec.LastSeenAt = now;
|
||||
}
|
||||
else
|
||||
{
|
||||
var created = new CdmTaskRecord { Id = d.id, FirstSeenAt = now, LastSeenAt = now };
|
||||
Map(d, created);
|
||||
db.CdmTasks.Add(created);
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static void Map(CdmTaskDto d, CdmTaskRecord rec)
|
||||
{
|
||||
rec.TaskId = string.IsNullOrWhiteSpace(d.taskId) ? null : d.taskId;
|
||||
rec.MissionId = d.missionId;
|
||||
rec.MissionName = d.missionName ?? "";
|
||||
rec.MissionTypeName = d.missionTypeName ?? "";
|
||||
rec.SrcSiteId = d.srcSiteId;
|
||||
rec.SrcLabel = d.srcLabel ?? "";
|
||||
rec.DstSiteId = d.dstSiteId;
|
||||
rec.DstLabel = d.dstLabel ?? "";
|
||||
rec.Status = d.status ?? "";
|
||||
rec.StatusCode = d.statusCode ?? "";
|
||||
rec.CarId = d.carId;
|
||||
rec.CarName = d.carName;
|
||||
rec.Priority = d.priority;
|
||||
rec.CreateTime = d.createTime;
|
||||
rec.StartTime = d.startTime;
|
||||
rec.FinishTime = d.finishTime;
|
||||
rec.StuckReason = d.stuckReason;
|
||||
rec.Overdue = d.overdue;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>后台循环:定时把 CDM 任务同步进 platform.db,保证无人打开页面时也能捕获终态历史。</summary>
|
||||
public sealed class CdmTaskSyncService : BackgroundService
|
||||
{
|
||||
private readonly CdmTaskSyncer _syncer;
|
||||
private readonly ILogger<CdmTaskSyncService> _log;
|
||||
|
||||
public CdmTaskSyncService(CdmTaskSyncer syncer, ILogger<CdmTaskSyncService> log)
|
||||
{
|
||||
_syncer = syncer;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken); }
|
||||
catch { return; }
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try { await _syncer.SyncOnceAsync(stoppingToken); }
|
||||
catch (Exception ex) { _log.LogDebug(ex, "cdm sync loop error"); }
|
||||
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(8), stoppingToken); }
|
||||
catch { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace MiGu.Server.Fleet;
|
||||
|
||||
/// <summary>与 SimpleLite GET /projection/fleet/health 行对齐,供车队运维前端消费。</summary>
|
||||
public sealed class FleetHealthRowDto
|
||||
{
|
||||
public int CarId { get; set; }
|
||||
public string? CarName { get; set; }
|
||||
public string? Ip { get; set; }
|
||||
public string? OnboardUrl { get; set; }
|
||||
public int? LatencyMs { get; set; }
|
||||
public bool? Reachable { get; set; }
|
||||
public string? ProbedAt { get; set; }
|
||||
public double? UptimeSecs { get; set; }
|
||||
public double? AlarmActiveSecs { get; set; }
|
||||
public double? FaultRatePercent { get; set; }
|
||||
public bool? IsAlarmActive { get; set; }
|
||||
public double? CpuPercent { get; set; }
|
||||
public double? MemPercent { get; set; }
|
||||
/// <summary>latency 探测通道:watchdog | onboard | none</summary>
|
||||
public string? LatencySource { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MiGu.Server.Auth;
|
||||
using MiGu.Server.Launcher;
|
||||
using MiGu.Server.Ota;
|
||||
|
||||
namespace MiGu.Server.Fleet;
|
||||
|
||||
/// <summary>
|
||||
/// 车队健康:保留 SimpleLite 的 CPU/故障率等,延迟改为对 WatchDog(:9776) 做 TCP RTT。
|
||||
/// SimpleLite /fleet/health 探测的是车载 HTTP :8081,多数现场未开该端口会假超时 2000ms。
|
||||
/// </summary>
|
||||
public sealed class FleetHealthService
|
||||
{
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly SimpleLiteOptions _sl;
|
||||
private readonly InternalTokenStore _token;
|
||||
private readonly WatchDogClient _wd;
|
||||
private readonly ILogger<FleetHealthService> _log;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpt = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public FleetHealthService(
|
||||
IHttpClientFactory httpFactory,
|
||||
IOptions<SimpleLiteOptions> sl,
|
||||
InternalTokenStore token,
|
||||
WatchDogClient wd,
|
||||
ILogger<FleetHealthService> log)
|
||||
{
|
||||
_httpFactory = httpFactory;
|
||||
_sl = sl.Value;
|
||||
_token = token;
|
||||
_wd = wd;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public async Task<List<FleetHealthRowDto>> GetAsync(CancellationToken ct)
|
||||
{
|
||||
var rows = await FetchSimpleLiteHealthAsync(ct);
|
||||
if (rows.Count == 0)
|
||||
rows = await BuildRowsFromCarsAsync(ct);
|
||||
|
||||
await Parallel.ForEachAsync(
|
||||
rows,
|
||||
new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct },
|
||||
async (row, token) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(row.Ip))
|
||||
{
|
||||
// 无 IP 时保留 SimpleLite 原探测结果
|
||||
row.LatencySource ??= row.LatencyMs != null ? "onboard" : "none";
|
||||
return;
|
||||
}
|
||||
|
||||
// 取两次 TCP 连接的较小值,降低偶发握手抖动
|
||||
var a = await _wd.MeasureRttMsAsync(row.Ip, token);
|
||||
var b = await _wd.MeasureRttMsAsync(row.Ip, token);
|
||||
int? rtt = (a, b) switch
|
||||
{
|
||||
(null, null) => null,
|
||||
(int x, null) => x,
|
||||
(null, int y) => y,
|
||||
(int x, int y) => Math.Min(x, y)
|
||||
};
|
||||
|
||||
if (rtt != null)
|
||||
{
|
||||
row.LatencyMs = rtt;
|
||||
row.Reachable = true;
|
||||
row.LatencySource = "watchdog";
|
||||
row.ProbedAt = DateTimeOffset.UtcNow.ToString("O");
|
||||
}
|
||||
else
|
||||
{
|
||||
// WatchDog 不通:若 SimpleLite 车载探测成功则保留,否则标不可达
|
||||
if (row.Reachable == true && row.LatencyMs is > 0 and < 2000)
|
||||
{
|
||||
row.LatencySource = "onboard";
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Reachable = false;
|
||||
row.LatencyMs = null;
|
||||
row.LatencySource = "watchdog";
|
||||
row.ProbedAt = DateTimeOffset.UtcNow.ToString("O");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private async Task<List<FleetHealthRowDto>> FetchSimpleLiteHealthAsync(CancellationToken ct)
|
||||
{
|
||||
var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222;
|
||||
try
|
||||
{
|
||||
using var client = _httpFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(20);
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, $"http://127.0.0.1:{port}/projection/fleet/health");
|
||||
var token = _token.Token;
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
req.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", token);
|
||||
|
||||
using var resp = await client.SendAsync(req, ct);
|
||||
if (!resp.IsSuccessStatusCode) return new();
|
||||
|
||||
var text = await resp.Content.ReadAsStringAsync(ct);
|
||||
var list = JsonSerializer.Deserialize<List<FleetHealthRowDto>>(text, JsonOpt);
|
||||
return list ?? new();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogDebug(ex, "fleet/health from SimpleLite failed");
|
||||
return new();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<FleetHealthRowDto>> BuildRowsFromCarsAsync(CancellationToken ct)
|
||||
{
|
||||
var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222;
|
||||
try
|
||||
{
|
||||
using var client = _httpFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, $"http://127.0.0.1:{port}/projection/cars");
|
||||
var token = _token.Token;
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
req.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", token);
|
||||
|
||||
using var resp = await client.SendAsync(req, ct);
|
||||
if (!resp.IsSuccessStatusCode) return new();
|
||||
|
||||
var text = await resp.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Array) return new();
|
||||
|
||||
var list = new List<FleetHealthRowDto>();
|
||||
foreach (var el in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
var rawId = el.TryGetProperty("rawId", out var rid) && rid.TryGetInt32(out var id)
|
||||
? id
|
||||
: 0;
|
||||
if (rawId <= 0) continue;
|
||||
var ip = el.TryGetProperty("ip", out var ipEl) ? ipEl.GetString() : null;
|
||||
var name = el.TryGetProperty("name", out var nEl) ? nEl.GetString() : null;
|
||||
var onboard = el.TryGetProperty("onboardUrl", out var oEl) ? oEl.GetString() : null;
|
||||
if (string.IsNullOrEmpty(onboard) && !string.IsNullOrEmpty(ip))
|
||||
onboard = $"http://{ip}:8081";
|
||||
list.Add(new FleetHealthRowDto
|
||||
{
|
||||
CarId = rawId,
|
||||
CarName = name,
|
||||
Ip = ip,
|
||||
OnboardUrl = onboard
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogDebug(ex, "projection/cars fallback for fleet health failed");
|
||||
return new();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace MiGu.Server.Fleet;
|
||||
|
||||
/// <summary>
|
||||
/// 车辆报警的平台侧记录(表 vehicle_alarms)。
|
||||
/// SimpleLite 只在 SSE/状态里给出「当前是否报警 + 文案」,无历史;平台按车对帐:
|
||||
/// 出现报警→开一条 active 记录,报警文案变化→更新,报警消失→置为 cleared 并记录恢复时间/持续时长。
|
||||
/// 永不删除=完整历史,重启/刷新不丢,SimpleLite 离线也可查。
|
||||
/// </summary>
|
||||
public sealed class VehicleAlarmRecord
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("D");
|
||||
public int CarId { get; set; }
|
||||
public string CarName { get; set; } = "";
|
||||
/// <summary>报警文案(车体_AlarmInfo)。</summary>
|
||||
public string Info { get; set; } = "";
|
||||
/// <summary>报警级别(车体_AlarmLevel,未知为 0)。</summary>
|
||||
public int Level { get; set; }
|
||||
/// <summary>active | cleared</summary>
|
||||
public string Status { get; set; } = "active";
|
||||
public DateTimeOffset FirstAt { get; set; }
|
||||
public DateTimeOffset LastAt { get; set; }
|
||||
public DateTimeOffset? ResolvedAt { get; set; }
|
||||
/// <summary>持续时长(秒),恢复后写入。</summary>
|
||||
public long? DurationSecs { get; set; }
|
||||
/// <summary>预留:平台侧确认(不代表车端消警)。</summary>
|
||||
public bool Acknowledged { get; set; }
|
||||
public DateTimeOffset? AcknowledgedAt { get; set; }
|
||||
public string? AcknowledgedBy { get; set; }
|
||||
}
|
||||
@@ -349,11 +349,10 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
/// <summary>写 active-scenes.json 的结果(供向导保存接口回显)。</summary>
|
||||
public readonly record struct ActiveScenesWriteResult(bool Ok, string? Path, string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// 关闭所有 SimpleLite 进程、同步 obj→bin 最新 DLL,再按 launchMode 重新拉起。
|
||||
/// </summary>
|
||||
public LaunchResult RestartForUpdate(string launchMode = "webonly")
|
||||
/// <summary>终止本机全部 SimpleLite 进程并清理 MiGu.Server 侧托管引用。</summary>
|
||||
public int StopAll()
|
||||
{
|
||||
var killed = 0;
|
||||
foreach (var proc in Process.GetProcessesByName("SimpleLite"))
|
||||
{
|
||||
try
|
||||
@@ -361,12 +360,13 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
if (!proc.HasExited)
|
||||
{
|
||||
proc.Kill(entireProcessTree: true);
|
||||
_log.LogInformation("[SimpleLite] restart-for-update: killed pid={Pid}", proc.Id);
|
||||
killed++;
|
||||
_log.LogInformation("[SimpleLite] stop: killed pid={Pid}", proc.Id);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning("[SimpleLite] restart-for-update: kill pid={Pid} failed: {Msg}", proc.Id, ex.Message);
|
||||
_log.LogWarning("[SimpleLite] stop: kill pid={Pid} failed: {Msg}", proc.Id, ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -374,7 +374,7 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
Thread.Sleep(1500);
|
||||
if (killed > 0) Thread.Sleep(1500);
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
@@ -382,6 +382,23 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
_lastLaunchMode = null;
|
||||
}
|
||||
|
||||
return killed;
|
||||
}
|
||||
|
||||
/// <summary>关闭全部 SimpleLite 后按 launchMode 重新拉起(不同步 DLL)。</summary>
|
||||
public LaunchResult Restart(string launchMode = "webonly")
|
||||
{
|
||||
StopAll();
|
||||
return MaybeStart(launchMode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 关闭所有 SimpleLite 进程、同步 obj→bin 最新 DLL,再按 launchMode 重新拉起。
|
||||
/// </summary>
|
||||
public LaunchResult RestartForUpdate(string launchMode = "webonly")
|
||||
{
|
||||
StopAll();
|
||||
|
||||
var synced = SimpleLiteBuildSync.TrySyncFromObjToBin(_env.ContentRootPath, _log);
|
||||
var result = MaybeStart(launchMode);
|
||||
if (!synced && result.Warning == null)
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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 Medulla;1 Clumsy;2 Detour;3 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
-5
@@ -7,6 +7,7 @@ using MiGu.Server.Auth;
|
||||
using MiGu.Server.Configs;
|
||||
using MiGu.Server.Launcher;
|
||||
using MiGu.Server.OpenApi;
|
||||
using MiGu.Server.Ota;
|
||||
using MiGu.Server.Persistence;
|
||||
using Yarp.ReverseProxy.Transforms;
|
||||
|
||||
@@ -31,12 +32,27 @@ var builder = WebApplication.CreateBuilder(new WebApplicationOptions
|
||||
ContentRootPath = FindSourceContentRoot(AppContext.BaseDirectory) ?? AppContext.BaseDirectory
|
||||
});
|
||||
|
||||
if (string.IsNullOrWhiteSpace(builder.Configuration["urls"])
|
||||
&& string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ASPNETCORE_URLS"))
|
||||
&& string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("DOTNET_URLS")))
|
||||
// 管理面默认 :8080;WatchDog 拉包回传写死 :8000/upload-mdcs/*,必须额外监听 ReceivePort。
|
||||
{
|
||||
// 直接运行 MiGu.Server.exe 不读取 launchSettings.json;保持与 dotnet run / 文档一致默认监听 8080。
|
||||
builder.WebHost.UseUrls("http://0.0.0.0:8080");
|
||||
var receivePort = builder.Configuration.GetValue("Ota:ReceivePort", 8000);
|
||||
var urls = builder.Configuration["urls"]
|
||||
?? Environment.GetEnvironmentVariable("ASPNETCORE_URLS")
|
||||
?? Environment.GetEnvironmentVariable("DOTNET_URLS");
|
||||
if (string.IsNullOrWhiteSpace(urls))
|
||||
urls = "http://0.0.0.0:8080";
|
||||
|
||||
var parts = urls.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var hasReceive = parts.Any(u =>
|
||||
u.Contains($":{receivePort}", StringComparison.OrdinalIgnoreCase)
|
||||
|| u.EndsWith($":{receivePort}/", StringComparison.OrdinalIgnoreCase));
|
||||
if (!hasReceive)
|
||||
{
|
||||
urls = string.Join(';', parts.Append($"http://0.0.0.0:{receivePort}"));
|
||||
// 安全提示:该端口挂匿名 upload-mdcs/upload-history(WatchDog 写死回传)。
|
||||
// upload-mdcs 仅在有进行中的拉取会话时可写入,其余管理端点仍需 JWT。请确保本机处于可信内网。
|
||||
Console.WriteLine($"[MiGu.Server] OTA 回传端口 {receivePort} 已监听(0.0.0.0):匿名接收车辆包,仅限可信内网。");
|
||||
}
|
||||
builder.WebHost.UseUrls(urls);
|
||||
}
|
||||
|
||||
// S2 根治 (会话45):让 MiGu.Server 启动时自动优先把 frontends/apps/simple-platform-vue/dist/
|
||||
@@ -95,6 +111,11 @@ builder.Services.AddControllers()
|
||||
opt.JsonSerializerOptions.DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull;
|
||||
opt.JsonSerializerOptions.WriteIndented = false;
|
||||
});
|
||||
// WatchDog 回传 M/D/C 可较大;放宽 multipart 默认 128MB 限制
|
||||
builder.Services.Configure<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>();
|
||||
|
||||
// OTA(WatchDog 编排):包库 / 任务 / 出站客户端
|
||||
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();
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -868,6 +868,20 @@ public sealed class WmsService
|
||||
entity.GetType().Name, entity.Id, version);
|
||||
}
|
||||
|
||||
private static bool IsUniqueConstraintViolation(DbUpdateException ex)
|
||||
{
|
||||
for (Exception? e = ex; e != null; e = e.InnerException)
|
||||
{
|
||||
var msg = e.Message;
|
||||
if (msg.Contains("UNIQUE constraint failed", StringComparison.OrdinalIgnoreCase)
|
||||
|| msg.Contains("unique index", StringComparison.OrdinalIgnoreCase)
|
||||
|| msg.Contains("duplicate key", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void EnsureUnlocked(EntityBase entity)
|
||||
{
|
||||
if (entity.IsLock)
|
||||
|
||||
@@ -70,8 +70,11 @@ public sealed class WmsTransportPlanner
|
||||
.Where(x => x.LocationType == ContainerLocationTypes.Storage).ToListAsync();
|
||||
var materials = await _db.Materials.AsNoTracking().Where(x => x.Enabled).ToListAsync();
|
||||
var containerMaterials = await _db.ContainerMaterials.AsNoTracking().ToListAsync();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var activeReservations = await _db.WmsTransportReservations.AsNoTracking()
|
||||
.Where(x => x.Status == WmsReservationStatuses.Active).ToListAsync();
|
||||
.Where(x => x.Status == WmsReservationStatuses.Active
|
||||
&& (x.ExpiresAt == null || x.ExpiresAt > now))
|
||||
.ToListAsync();
|
||||
var activeTasks = await _db.WmsTransportTasks.AsNoTracking()
|
||||
.Where(x => WmsTransportTaskStatuses.Active.Contains(x.Status)).ToListAsync();
|
||||
|
||||
|
||||
@@ -166,10 +166,27 @@ public sealed class WmsTransportTaskService
|
||||
|
||||
public async Task<WmsTransportTask> DispatchAsync(Guid taskId, string actor)
|
||||
{
|
||||
var task = await FindTaskAsync(taskId);
|
||||
if (task.Status != WmsTransportTaskStatuses.Reserved)
|
||||
throw new InvalidOperationException("只有 Reserved 状态的任务可以下发");
|
||||
// 原子抢占:仅一条 Reserved 且未在下发中的任务能进入 Dispatching,避免双发
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var claimed = await _db.WmsTransportTasks
|
||||
.Where(x => x.Id == taskId
|
||||
&& x.Status == WmsTransportTaskStatuses.Reserved
|
||||
&& x.DispatchStatus != "Dispatching"
|
||||
&& x.DispatchStatus != "Dispatched")
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(x => x.DispatchStatus, "Dispatching")
|
||||
.SetProperty(x => x.UpdatedAt, now)
|
||||
.SetProperty(x => x.UpdatedBy, actor));
|
||||
|
||||
if (claimed == 0)
|
||||
{
|
||||
var existing = await FindTaskAsync(taskId);
|
||||
if (existing.Status != WmsTransportTaskStatuses.Reserved)
|
||||
throw new InvalidOperationException("只有 Reserved 状态的任务可以下发");
|
||||
throw new InvalidOperationException("任务正在下发中,请勿重复操作");
|
||||
}
|
||||
|
||||
var task = await FindTaskAsync(taskId);
|
||||
var source = await _db.Storages.AsNoTracking().FirstAsync(x => x.Id == task.SourceStorageId);
|
||||
var target = await _db.Storages.AsNoTracking().FirstAsync(x => x.Id == task.TargetStorageId);
|
||||
var container = await _db.Containers.AsNoTracking().FirstAsync(x => x.Id == task.ContainerId);
|
||||
@@ -195,14 +212,36 @@ public sealed class WmsTransportTaskService
|
||||
return task;
|
||||
}
|
||||
|
||||
private async Task ExpireStaleReservationsAsync()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var rows = await _db.WmsTransportReservations
|
||||
.Where(x => x.Status == WmsReservationStatuses.Active
|
||||
&& x.ExpiresAt != null
|
||||
&& x.ExpiresAt < now)
|
||||
.ToListAsync();
|
||||
if (rows.Count == 0) return;
|
||||
|
||||
foreach (var row in rows)
|
||||
row.Status = WmsReservationStatuses.Expired;
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task EnsureNoActiveReservationAsync(Guid containerId, Guid targetStorageId)
|
||||
{
|
||||
await ExpireStaleReservationsAsync();
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (await _db.WmsTransportReservations.AnyAsync(x =>
|
||||
x.Status == WmsReservationStatuses.Active && x.ContainerId == containerId))
|
||||
x.Status == WmsReservationStatuses.Active
|
||||
&& (x.ExpiresAt == null || x.ExpiresAt > now)
|
||||
&& x.ContainerId == containerId))
|
||||
throw new InvalidOperationException("容器已被其他任务预占");
|
||||
|
||||
if (await _db.WmsTransportReservations.AnyAsync(x =>
|
||||
x.Status == WmsReservationStatuses.Active && x.TargetStorageId == targetStorageId))
|
||||
x.Status == WmsReservationStatuses.Active
|
||||
&& (x.ExpiresAt == null || x.ExpiresAt > now)
|
||||
&& x.TargetStorageId == targetStorageId))
|
||||
throw new InvalidOperationException("目标库位已被其他任务预占");
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,15 @@
|
||||
"Dispatch": {
|
||||
}
|
||||
},
|
||||
"_comment_Ota": "车辆 OTA:包与任务落盘 DataRoot;WatchDog:9776。拉包时车会 POST 到 WatchDog 配置的 serverIP:ReceivePort/upload-mdcs/*(默认 8000,与旧 Electron OTA 一致)。请把各车 watch_dog.json 的 serverIP 设为本机局域网 IP。",
|
||||
"Ota": {
|
||||
"DataRoot": "data/ota",
|
||||
"WatchDogPort": 9776,
|
||||
"ReceivePort": 8000,
|
||||
"RequestTimeoutMs": 60000,
|
||||
"UploadTimeoutMs": 600000,
|
||||
"PublicBaseUrl": ""
|
||||
},
|
||||
"_comment_ReverseProxy": "sl-route 兜底 AnyAuthed(投影只读 + SSE)。管理面路径(map-edit / ai-config / reflection 写操作)单独拆路由挂 PlatformScope,防止运营账号经反代直达地图编辑与任意反射调用。",
|
||||
"ReverseProxy": {
|
||||
"Routes": {
|
||||
@@ -65,6 +74,15 @@
|
||||
{ "PathRemovePrefix": "/api/sl" }
|
||||
]
|
||||
},
|
||||
"sl-assistant-route": {
|
||||
"ClusterId": "sl-cluster",
|
||||
"AuthorizationPolicy": "PlatformScope",
|
||||
"Order": -2,
|
||||
"Match": { "Path": "/api/sl/projection/assistant/{**catch-all}" },
|
||||
"Transforms": [
|
||||
{ "PathRemovePrefix": "/api/sl" }
|
||||
]
|
||||
},
|
||||
"sl-reflection-selection-route": {
|
||||
"ClusterId": "sl-cluster",
|
||||
"AuthorizationPolicy": "AnyAuthed",
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"section": "deployment",
|
||||
"version": 7,
|
||||
"updatedAt": "2026-06-09T01:43:46.9419888+00:00",
|
||||
"payload": {
|
||||
"configured": true,
|
||||
"platformType": "standard",
|
||||
"modules": [
|
||||
"wms"
|
||||
],
|
||||
"navigationKinds": [
|
||||
"qrcode",
|
||||
"laser"
|
||||
],
|
||||
"scenarios": [
|
||||
"tpl-p2p"
|
||||
],
|
||||
"updatedBy": "admin"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user