Files
wei.wu 1f72488a8d 新增车辆表及OTA权限与逻辑优化
新增车辆任务与报警表,完善实体与DbContext配置。细化OTA权限校验,增强回传会话IP安全。优化OTA上传与设置面板,调度器支持重启恢复。报警采集逻辑支持历史分段。
2026-07-27 15:12:14 +08:00

539 lines
19 KiB
C#
Raw Permalink 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.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<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);
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<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));
}
}