using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using MiGu.Server.Configs; using MiGu.Server.Ota; namespace MiGu.Server.Controllers; [ApiController] [Authorize] [Route("api/ota")] public class OtaController : ControllerBase { private readonly OtaStore _store; private readonly WatchDogClient _wd; private readonly OtaVehicleSource _vehicles; private readonly OtaJobRunner _jobs; private readonly OpsAuditStore _audits; private readonly OtaOptions _opt; public OtaController( OtaStore store, WatchDogClient wd, OtaVehicleSource vehicles, OtaJobRunner jobs, OpsAuditStore audits, IOptions opt) { _store = store; _wd = wd; _vehicles = vehicles; _jobs = jobs; _audits = audits; _opt = opt.Value; } private string UserName => User.FindFirst("unique_name")?.Value ?? User.Identity?.Name ?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "unknown"; private string Scope => User.FindFirst("scope")?.Value ?? ""; private bool CanWrite() { if (string.Equals(Scope, "Platform", StringComparison.OrdinalIgnoreCase)) return true; var ops = User.FindFirst("ops")?.Value ?? ""; var set = ops.Split(' ', StringSplitOptions.RemoveEmptyEntries); return set.Contains("*") || set.Contains("ops.ota") || set.Contains("ops.ota.write") || set.Any(o => o.StartsWith("ops.ota.", StringComparison.OrdinalIgnoreCase)); } private bool DenyWrite(out ActionResult denied) { if (CanWrite()) { denied = null!; return false; } denied = StatusCode(StatusCodes.Status403Forbidden, new { message = "需要 Platform 或 ops.ota.* 权限" }); return true; } private void Audit(string op, string target, string result, string? msg = null) => _audits.Append(UserName, Scope, op, target, result, msg); [HttpGet("settings")] public ActionResult GetSettings() => _store.GetSettings(); [HttpPut("settings")] public ActionResult PutSettings([FromBody] OtaSettings settings) { if (DenyWrite(out var denied)) return denied; var saved = _store.SaveSettings(settings); Audit("ops.ota.settings", "settings", "ok"); return saved; } [HttpGet("target")] public ActionResult GetTarget() { var t = _store.GetTarget(); if (t == null) return Ok(new { target = (OtaTarget?)null }); return Ok(new { target = t, summary = t.Components.ToDictionary( kv => kv.Key, kv => OtaHash.Short(kv.Value.Hash)) }); } [HttpGet("packages")] public ActionResult> ListPackages() => _store.ListPackages(); [HttpGet("packages/{id}")] public ActionResult GetPackage(string id) { try { return _store.ScanPackage(id); } catch (DirectoryNotFoundException) { return NotFound(); } catch (ArgumentException ex) { return BadRequest(new { message = ex.Message }); } } [HttpPost("packages/{id}/activate")] public ActionResult Activate(string id, [FromQuery] string? name = null) { if (DenyWrite(out var denied)) return denied; try { var t = _store.ActivatePackage(id, name); Audit("ops.ota.activate", id, "ok"); return t; } catch (Exception ex) { Audit("ops.ota.activate", id, "fail", ex.Message); return BadRequest(new { message = ex.Message }); } } [HttpDelete("packages/{id}")] public IActionResult DeletePackage(string id) { if (DenyWrite(out var denied)) return denied; try { _store.DeletePackage(id); Audit("ops.ota.package.delete", id, "ok"); return NoContent(); } catch (Exception ex) { return BadRequest(new { message = ex.Message }); } } [HttpPost("packages/pull")] public async Task> Pull([FromBody] PullPackageRequest req, CancellationToken ct) { if (DenyWrite(out var denied)) return denied; var cars = await _vehicles.ListCarsAsync(ct); var car = cars.FirstOrDefault(c => string.Equals(c.Id, req.CarId, StringComparison.OrdinalIgnoreCase)); if (car == null || string.IsNullOrEmpty(car.Ip)) return BadRequest(new { message = "车辆不存在或无 IP" }); var pkgId = _store.BeginPullPackage(car.Ip); try { var baseUrl = ResolvePublicBase(); var receiveBase = $"{baseUrl.TrimEnd('/')}/api/ota/receive"; var time = DateTime.Now.ToString("yyyyMMddHHmmss"); await _wd.TriggerPullAsync(car.Ip, receiveBase, time, ct); // 等待文件落盘:绝对超时 + 收到文件后的空闲窗口,避免首个组件到达就清会话导致半包 await Task.Delay(1500, ct); var deadline = DateTime.UtcNow.AddSeconds(90); var idleAfterReceive = TimeSpan.FromSeconds(8); var lastCount = 0; DateTime? lastProgressAt = null; while (DateTime.UtcNow < deadline) { ct.ThrowIfCancellationRequested(); var info = _store.ScanPackage(pkgId); if (info.Components.Count > lastCount) { lastCount = info.Components.Count; lastProgressAt = DateTime.UtcNow; } else if (_store.TryGetLastReceiveAt(car.Ip, out var recvAt) && (lastProgressAt == null || recvAt > lastProgressAt.Value)) { lastProgressAt = recvAt; } if (lastCount > 0 && lastProgressAt != null && DateTime.UtcNow - lastProgressAt.Value >= idleAfterReceive) break; await Task.Delay(500, ct); } _store.ClearActivePull(car.Ip); var result = _store.ScanPackage(pkgId); if (result.Components.Count == 0) { Audit("ops.ota.package.pull", req.CarId, "empty", pkgId); return BadRequest(new { message = $"未收到任何组件文件(包 {pkgId} 为 0 B)。" + $"WatchDog 会固定 POST 到 http://{{serverIP}}:{_opt.ReceivePort}/upload-mdcs/{{组件}}," + "请把该车 watch_dog.json 的 serverIP 设为本机局域网 IP,并确认本机已监听该端口;" + "同时确认车上已配置 Medulla/Detour/Clumsy 路径。", packageId = pkgId, receivePort = _opt.ReceivePort }); } Audit("ops.ota.package.pull", req.CarId, "ok", pkgId); return Ok(result); } catch (Exception ex) { _store.ClearActivePull(car.Ip); Audit("ops.ota.package.pull", req.CarId, "fail", ex.Message); return BadRequest(new { message = ex.Message, packageId = pkgId }); } } [HttpPost("packages/upload")] [RequestSizeLimit(512_000_000)] public async Task> Upload(IFormFile? file, CancellationToken ct) { if (DenyWrite(out var denied)) return denied; if (file == null || file.Length == 0) return BadRequest(new { message = "请上传文件(zip 或单文件)" }); var pkgId = _store.BeginPullPackage("upload"); var dir = _store.PackageDir(pkgId); try { var name = Path.GetFileName(file.FileName); if (string.IsNullOrWhiteSpace(name)) return BadRequest(new { message = "文件名无效" }); if (name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) { var zipPath = Path.Combine(dir, name); await using (var fs = System.IO.File.Create(zipPath)) await file.CopyToAsync(fs, ct); System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, dir, true); System.IO.File.Delete(zipPath); NormalizeUploadLayout(dir); } else { // 单文件:按扩展名猜放到 M/D/C var dest = GuessDest(dir, name); Directory.CreateDirectory(Path.GetDirectoryName(dest)!); await using var fs = System.IO.File.Create(dest); await file.CopyToAsync(fs, ct); } _store.ClearActivePull("upload"); var info = _store.ScanPackage(pkgId); Audit("ops.ota.package.upload", pkgId, "ok"); return info; } catch (Exception ex) { _store.ClearActivePull("upload"); return BadRequest(new { message = ex.Message }); } } [HttpGet("vehicles")] public async Task>> Vehicles([FromQuery] bool? latency, CancellationToken ct) { var settings = _store.GetSettings(); var doLatency = latency ?? settings.LatencyEnabled; var target = _store.GetTarget(); var cars = await _vehicles.ListCarsAsync(ct); await Parallel.ForEachAsync(cars, new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct }, async (car, token) => { if (string.IsNullOrEmpty(car.Ip)) { car.Reachable = false; return; } if (doLatency) car.RttMs = await _wd.MeasureRttMsAsync(car.Ip, token); var (ok, m, d, c, _) = await _wd.GetMdcInfoAsync(car.Ip, token); car.Reachable = ok; car.Medulla = m; car.Detour = d; car.Clumsy = c; if (target != null) car.Match = BuildMatch(target, m, d, c); }); return cars; } [HttpGet("latency")] public async Task> Latency(CancellationToken ct) { var settings = _store.GetSettings(); if (!settings.LatencyEnabled) return Ok(new { enabled = false, items = Array.Empty() }); var cars = await _vehicles.ListCarsAsync(ct); var items = new List(); foreach (var car in cars) { if (string.IsNullOrEmpty(car.Ip)) continue; var rtt = await _wd.MeasureRttMsAsync(car.Ip, ct); items.Add(new { car.Id, car.Ip, rttMs = rtt, over = rtt == null || rtt > settings.RttThresholdMs }); } return Ok(new { enabled = true, thresholdMs = settings.RttThresholdMs, items }); } [HttpGet("jobs")] public ActionResult> ListJobs([FromQuery] int take = 100) => _store.ListJobs(take); [HttpGet("jobs/{id}")] public ActionResult GetJob(string id) { try { var j = _store.GetJob(id); return j == null ? NotFound() : j; } catch (ArgumentException ex) { return BadRequest(new { message = ex.Message }); } } [HttpPost("jobs")] public ActionResult CreateJob([FromBody] CreateSyncJobRequest req) { if (DenyWrite(out var denied)) return denied; try { var job = _jobs.EnqueueSync(req, UserName); Audit("ops.ota.job.create", job.Id, "ok", $"cars={req.CarIds.Count}"); return job; } catch (Exception ex) { return BadRequest(new { message = ex.Message }); } } [HttpPost("jobs/{id}/cancel")] public IActionResult Cancel(string id) { if (DenyWrite(out var denied)) return denied; var ok = _jobs.Cancel(id); Audit("ops.ota.job.cancel", id, ok ? "ok" : "noop"); return ok ? Ok(new { ok = true }) : BadRequest(new { message = "无法取消" }); } [HttpPost("jobs/{id}/retry")] public ActionResult Retry(string id) { if (DenyWrite(out var denied)) return denied; try { var job = _jobs.RetryFailed(id, UserName); if (job == null) return NotFound(); Audit("ops.ota.job.retry", id, "ok", job.Id); return job; } catch (Exception ex) { return BadRequest(new { message = ex.Message }); } } [HttpGet("config/{carId}/{app}")] public async Task> GetConfig(string carId, string app, CancellationToken ct) { var cars = await _vehicles.ListCarsAsync(ct); var car = cars.FirstOrDefault(c => string.Equals(c.Id, carId, StringComparison.OrdinalIgnoreCase)); if (car?.Ip == null) return BadRequest(new { message = "车辆无 IP" }); try { var json = await _wd.GetJsonAsync(car.Ip, app, ct); return Ok(new { carId, app, json }); } catch (Exception ex) { return BadRequest(new { message = ex.Message }); } } [HttpPost("config/push")] public ActionResult ConfigPush([FromBody] CreateConfigPushRequest req) { if (DenyWrite(out var denied)) return denied; try { var job = _jobs.EnqueueConfigPush(req, UserName); Audit("ops.ota.config.push", job.Id, "ok", req.App); return job; } catch (Exception ex) { return BadRequest(new { message = ex.Message }); } } [HttpPost("custom-file")] [RequestSizeLimit(512_000_000)] public async Task> CustomFile( [FromForm] string carIds, [FromForm] string remotePath, [FromForm] string? restartOps, [FromForm] int? restartOp, [FromForm] List? files, IFormFile? file, CancellationToken ct) { if (DenyWrite(out var denied)) return denied; var uploadFiles = new List(); if (files is { Count: > 0 }) uploadFiles.AddRange(files.Where(f => f.Length > 0)); if (file is { Length: > 0 }) uploadFiles.Add(file); if (uploadFiles.Count == 0) return BadRequest(new { message = "请至少选择一个文件" }); var ids = ParseCarIds(carIds); var ops = new List(); if (!string.IsNullOrWhiteSpace(restartOps)) { try { ops = System.Text.Json.JsonSerializer.Deserialize>(restartOps) ?? new(); } catch { /* ignore */ } } if (ops.Count == 0 && restartOp.HasValue) ops.Add(restartOp.Value); if (ops.Count == 0) ops.Add(-1); var tmpDir = Path.Combine(_store.Root, "uploads", $"{DateTime.UtcNow:yyyyMMddHHmmssfff}-{Guid.NewGuid():N}"); Directory.CreateDirectory(tmpDir); var items = new List(); foreach (var f in uploadFiles) { var safe = Path.GetFileName(f.FileName); var local = Path.Combine(tmpDir, $"{items.Count}_{safe}"); await using (var fs = System.IO.File.Create(local)) await f.CopyToAsync(fs, ct); items.Add(new OtaCustomFileItem { LocalPath = local, FileName = safe }); } try { var job = _jobs.EnqueueCustomFile(new CreateCustomFileJobRequest { CarIds = ids, RemotePath = remotePath, RestartOps = ops }, items, UserName); Audit("ops.ota.customFile", job.Id, "ok", string.Join(",", items.Select(i => i.FileName))); return job; } catch (Exception ex) { return BadRequest(new { message = ex.Message }); } } private string ResolvePublicBase() { if (!string.IsNullOrWhiteSpace(_opt.PublicBaseUrl)) return _opt.PublicBaseUrl.TrimEnd('/'); return $"{Request.Scheme}://{Request.Host}"; } private static List ParseCarIds(string carIds) { var trimmed = carIds.Trim(); if (trimmed.StartsWith("[", StringComparison.Ordinal)) { try { var ids = System.Text.Json.JsonSerializer.Deserialize>(trimmed); if (ids != null) return ids.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).ToList(); } catch { // Fall back to comma-separated form data below. } } return trimmed .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Where(x => !string.IsNullOrWhiteSpace(x)) .ToList(); } private static Dictionary BuildMatch(OtaTarget target, OtaAppVersions? m, OtaAppVersions? d, OtaAppVersions? c) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); void One(string key, string? vehicleHash) { if (!target.Components.TryGetValue(key, out var art)) { map[key] = "missing-target"; return; } if (string.IsNullOrEmpty(vehicleHash)) { map[key] = "unknown"; return; } map[key] = string.Equals(vehicleHash, art.Hash, StringComparison.Ordinal) ? "match" : "mismatch"; } One("M.exe", m?.Exe?.Version); One("M.dll", m?.Dll?.Version); One("M.pdb", m?.Pdb?.Version); One("D.exe", d?.Exe?.Version); One("C.exe", c?.Exe?.Version); One("C.dll", c?.Dll?.Version); One("C.pdb", c?.Pdb?.Version); return map; } private static void NormalizeUploadLayout(string dir) { // 若 zip 根下直接是 M/D/C 或 Medulla.exe,尽量归位 var medulla = Directory.GetFiles(dir, "Medulla.exe", SearchOption.AllDirectories).FirstOrDefault(); if (medulla != null) { var dest = Path.Combine(dir, "M", "Medulla.exe"); if (!string.Equals(medulla, dest, StringComparison.OrdinalIgnoreCase)) { Directory.CreateDirectory(Path.GetDirectoryName(dest)!); System.IO.File.Copy(medulla, dest, true); } } var detour = Directory.GetFiles(dir, "Detour.exe", SearchOption.AllDirectories).FirstOrDefault(); if (detour != null) { var dest = Path.Combine(dir, "D", "Detour.exe"); if (!string.Equals(detour, dest, StringComparison.OrdinalIgnoreCase)) { Directory.CreateDirectory(Path.GetDirectoryName(dest)!); System.IO.File.Copy(detour, dest, true); } } } private static string GuessDest(string dir, string fileName) { var n = fileName.ToLowerInvariant(); if (n.Contains("medulla") && n.EndsWith(".exe")) return Path.Combine(dir, "M", "Medulla.exe"); if (n.Contains("cartactivator") && n.EndsWith(".dll")) return Path.Combine(dir, "M", "plugins", "CartActivator.dll"); if (n.Contains("detour")) return Path.Combine(dir, "D", "Detour.exe"); if (n.Contains("clumsy") && n.EndsWith(".exe")) return Path.Combine(dir, "C", "ClumsyConsole.exe"); return Path.Combine(dir, "M", Path.GetFileName(fileName)); } }