This commit is contained in:
2026-07-27 14:29:46 +08:00
110 changed files with 13942 additions and 2044 deletions
+123
View File
@@ -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 管理端可调。
+4
View File
@@ -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);
+515
View File
@@ -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 });
}
}