覆盖包库回传、任务下发、CDM 任务同步与报警采集,并为包/任务 ID 与上传文件名加上路径安全校验。 Co-authored-by: Cursor <cursoragent@cursor.com>
516 lines
18 KiB
C#
516 lines
18 KiB
C#
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));
|
||
}
|
||
}
|