Files
Migu2.0/MiGu.Server/Controllers/OtaController.cs
T
zhaowei.huangandCursor 4223a572c5 新增 OTA WatchDog 编排与车队健康/报警后端。
覆盖包库回传、任务下发、CDM 任务同步与报警采集,并为包/任务 ID 与上传文件名加上路径安全校验。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 11:31:51 +08:00

516 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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));
}
}