merge
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user