覆盖包库回传、任务下发、CDM 任务同步与报警采集,并为包/任务 ID 与上传文件名加上路径安全校验。 Co-authored-by: Cursor <cursoragent@cursor.com>
434 lines
17 KiB
C#
434 lines
17 KiB
C#
using System.Collections.Concurrent;
|
||
|
||
namespace MiGu.Server.Ota;
|
||
|
||
public sealed class OtaJobRunner
|
||
{
|
||
private readonly OtaStore _store;
|
||
private readonly WatchDogClient _wd;
|
||
private readonly OtaVehicleSource _vehicles;
|
||
private readonly ILogger<OtaJobRunner> _log;
|
||
private readonly ConcurrentDictionary<string, CancellationTokenSource> _running = new();
|
||
|
||
public OtaJobRunner(OtaStore store, WatchDogClient wd, OtaVehicleSource vehicles, ILogger<OtaJobRunner> log)
|
||
{
|
||
_store = store;
|
||
_wd = wd;
|
||
_vehicles = vehicles;
|
||
_log = log;
|
||
}
|
||
|
||
public OtaJob EnqueueSync(CreateSyncJobRequest req, string? user)
|
||
{
|
||
var target = _store.GetTarget() ?? throw new InvalidOperationException("未设置目标版本,请先在版本库激活");
|
||
var components = (req.Components is { Count: > 0 } ? req.Components : OtaPathMap.ComponentKeys.ToList())
|
||
.Where(c => target.Components.ContainsKey(c))
|
||
.ToList();
|
||
if (components.Count == 0) throw new InvalidOperationException("目标版本中无选定组件");
|
||
if (req.CarIds.Count == 0) throw new InvalidOperationException("请选择车辆");
|
||
|
||
var settings = _store.GetSettings();
|
||
var job = new OtaJob
|
||
{
|
||
Id = _store.NextJobId(),
|
||
Kind = "sync",
|
||
Status = "pending",
|
||
CreatedAt = DateTimeOffset.UtcNow,
|
||
CreatedBy = user,
|
||
PackageId = target.PackageId,
|
||
CarIds = req.CarIds.ToList(),
|
||
Components = components,
|
||
RequireLatencyCheck = req.RequireLatencyCheck ?? settings.LatencyEnabled
|
||
};
|
||
foreach (var carId in job.CarIds)
|
||
foreach (var comp in components)
|
||
job.Steps.Add(new OtaJobStep { CarId = carId, Component = comp, Status = "pending" });
|
||
job.TotalSteps = job.Steps.Count;
|
||
_store.SaveJob(job);
|
||
_ = Task.Run(() => RunAsync(job.Id));
|
||
return job;
|
||
}
|
||
|
||
public OtaJob EnqueueCustomFile(CreateCustomFileJobRequest req, IReadOnlyList<OtaCustomFileItem> files, string? user)
|
||
{
|
||
if (req.CarIds.Count == 0) throw new InvalidOperationException("请选择车辆");
|
||
if (files.Count == 0) throw new InvalidOperationException("请至少添加一个本地文件");
|
||
if (string.IsNullOrWhiteSpace(req.RemotePath)) throw new InvalidOperationException("请填写小车内目标路径");
|
||
var settings = _store.GetSettings();
|
||
var ops = req.RestartOps is { Count: > 0 }
|
||
? req.RestartOps.Distinct().ToList()
|
||
: new List<int> { req.RestartOp };
|
||
if (ops.Count == 0) ops.Add(-1);
|
||
|
||
var job = new OtaJob
|
||
{
|
||
Id = _store.NextJobId(),
|
||
Kind = "customFile",
|
||
Status = "pending",
|
||
CreatedAt = DateTimeOffset.UtcNow,
|
||
CreatedBy = user,
|
||
CarIds = req.CarIds.ToList(),
|
||
CustomFileName = files[0].FileName,
|
||
CustomRemotePath = req.RemotePath,
|
||
CustomRestartOp = ops[0],
|
||
CustomLocalPath = files[0].LocalPath,
|
||
CustomFiles = files.ToList(),
|
||
CustomRestartOps = ops,
|
||
RequireLatencyCheck = req.RequireLatencyCheck ?? settings.LatencyEnabled,
|
||
Components = new List<string> { "custom" }
|
||
};
|
||
foreach (var carId in job.CarIds)
|
||
job.Steps.Add(new OtaJobStep { CarId = carId, Component = $"custom×{files.Count}", Status = "pending" });
|
||
job.TotalSteps = job.Steps.Count;
|
||
_store.SaveJob(job);
|
||
_ = Task.Run(() => RunAsync(job.Id));
|
||
return job;
|
||
}
|
||
|
||
public OtaJob EnqueueConfigPush(CreateConfigPushRequest req, string? user)
|
||
{
|
||
if (req.CarIds.Count == 0) throw new InvalidOperationException("请选择车辆");
|
||
var app = req.App.Trim().ToLowerInvariant();
|
||
if (app is not ("medulla" or "detour" or "clumsy"))
|
||
throw new InvalidOperationException("app 须为 medulla|detour|clumsy");
|
||
var settings = _store.GetSettings();
|
||
var job = new OtaJob
|
||
{
|
||
Id = _store.NextJobId(),
|
||
Kind = "configPush",
|
||
Status = "pending",
|
||
CreatedAt = DateTimeOffset.UtcNow,
|
||
CreatedBy = user,
|
||
CarIds = req.CarIds.ToList(),
|
||
ConfigApp = app,
|
||
ConfigJson = req.Json,
|
||
RequireLatencyCheck = req.RequireLatencyCheck ?? settings.LatencyEnabled,
|
||
Components = new List<string> { $"config:{app}" }
|
||
};
|
||
foreach (var carId in job.CarIds)
|
||
job.Steps.Add(new OtaJobStep { CarId = carId, Component = $"config:{app}", Status = "pending" });
|
||
job.TotalSteps = job.Steps.Count;
|
||
_store.SaveJob(job);
|
||
_ = Task.Run(() => RunAsync(job.Id));
|
||
return job;
|
||
}
|
||
|
||
public bool Cancel(string jobId)
|
||
{
|
||
var job = _store.GetJob(jobId);
|
||
if (job == null) return false;
|
||
if (job.Status is "succeeded" or "failed" or "partial" or "cancelled") return false;
|
||
// 正在运行:只发取消信号,由 RunAsync 统一收尾,避免与运行线程并发写同一 job 文件。
|
||
if (_running.TryGetValue(jobId, out var cts))
|
||
{
|
||
cts.Cancel();
|
||
return true;
|
||
}
|
||
// 尚未开始(Task.Run 排队中):直接落盘取消;RunAsync 启动时有 status 守卫会跳过执行。
|
||
foreach (var step in job.Steps.Where(s => s.Status == "pending"))
|
||
{
|
||
step.Status = "skipped";
|
||
step.Error = "已取消";
|
||
}
|
||
job.Status = "cancelled";
|
||
job.FinishedAt = DateTimeOffset.UtcNow;
|
||
job.DoneSteps = job.Steps.Count(s => s.Status is "succeeded" or "failed" or "skipped");
|
||
_store.SaveJob(job);
|
||
return true;
|
||
}
|
||
|
||
public OtaJob? RetryFailed(string jobId, string? user)
|
||
{
|
||
var old = _store.GetJob(jobId);
|
||
if (old == null) return null;
|
||
var failedCars = old.Steps.Where(s => s.Status == "failed").Select(s => s.CarId).Distinct().ToList();
|
||
if (failedCars.Count == 0) throw new InvalidOperationException("没有失败项可重试");
|
||
|
||
return old.Kind switch
|
||
{
|
||
"sync" => EnqueueSync(new CreateSyncJobRequest
|
||
{
|
||
CarIds = failedCars,
|
||
Components = old.Components,
|
||
RequireLatencyCheck = old.RequireLatencyCheck
|
||
}, user),
|
||
"customFile" when ResolveCustomFiles(old).Count > 0 =>
|
||
EnqueueCustomFile(new CreateCustomFileJobRequest
|
||
{
|
||
CarIds = failedCars,
|
||
RemotePath = old.CustomRemotePath ?? "",
|
||
RestartOps = old.CustomRestartOps is { Count: > 0 }
|
||
? old.CustomRestartOps
|
||
: new List<int> { old.CustomRestartOp ?? -1 },
|
||
RequireLatencyCheck = old.RequireLatencyCheck
|
||
}, ResolveCustomFiles(old), user),
|
||
"configPush" => EnqueueConfigPush(new CreateConfigPushRequest
|
||
{
|
||
CarIds = failedCars,
|
||
App = old.ConfigApp ?? "",
|
||
Json = old.ConfigJson ?? "",
|
||
RequireLatencyCheck = old.RequireLatencyCheck
|
||
}, user),
|
||
_ => throw new InvalidOperationException("无法重试该任务类型或文件已丢失")
|
||
};
|
||
}
|
||
|
||
private async Task RunAsync(string jobId)
|
||
{
|
||
var cts = new CancellationTokenSource();
|
||
if (!_running.TryAdd(jobId, cts)) return;
|
||
// 单一内存实例贯穿全程;所有「改状态 + 落盘」都在 jobLock 下串行,杜绝并发车批次的丢更新。
|
||
var jobLock = new object();
|
||
try
|
||
{
|
||
var job = _store.GetJob(jobId);
|
||
if (job == null) return;
|
||
// 守卫:排队期间被取消 / 已终结的任务不再执行。
|
||
if (job.Status is "cancelled" or "succeeded" or "failed" or "partial") return;
|
||
var settings = _store.GetSettings();
|
||
var cars = await _vehicles.ListCarsAsync(cts.Token);
|
||
var byId = cars.ToDictionary(c => c.Id, StringComparer.OrdinalIgnoreCase);
|
||
|
||
job.Status = job.RequireLatencyCheck ? "probing" : "running";
|
||
_store.SaveJob(job);
|
||
|
||
// 解析 IP
|
||
foreach (var step in job.Steps)
|
||
{
|
||
if (byId.TryGetValue(step.CarId, out var car))
|
||
step.Ip = car.Ip;
|
||
}
|
||
|
||
if (job.RequireLatencyCheck)
|
||
{
|
||
var threshold = settings.RttThresholdMs;
|
||
var overMode = settings.OverThreshold;
|
||
foreach (var carId in job.CarIds.Distinct())
|
||
{
|
||
var ip = job.Steps.FirstOrDefault(s => s.CarId == carId)?.Ip;
|
||
if (string.IsNullOrEmpty(ip))
|
||
{
|
||
SkipCar(job, carId, "无 IP");
|
||
continue;
|
||
}
|
||
var rtt = await _wd.MeasureRttMsAsync(ip, cts.Token);
|
||
if (rtt == null || rtt > threshold)
|
||
{
|
||
if (string.Equals(overMode, "skip", StringComparison.OrdinalIgnoreCase) || rtt == null)
|
||
SkipCar(job, carId, rtt == null ? "延迟探测失败" : $"RTT {rtt}ms > {threshold}ms");
|
||
}
|
||
}
|
||
_store.SaveJob(job);
|
||
}
|
||
|
||
job.Status = "running";
|
||
_store.SaveJob(job);
|
||
|
||
var maxCar = Math.Max(1, settings.MaxCar);
|
||
var carGroups = job.CarIds.Distinct()
|
||
.Where(id => job.Steps.Any(s => s.CarId == id && s.Status == "pending"))
|
||
.Chunk(maxCar);
|
||
|
||
foreach (var batch in carGroups)
|
||
{
|
||
if (cts.IsCancellationRequested) break;
|
||
var tasks = batch.Select(carId => RunCarAsync(job, jobLock, carId, settings.BandwidthKbps, cts.Token));
|
||
await Task.WhenAll(tasks);
|
||
}
|
||
|
||
lock (jobLock)
|
||
{
|
||
if (cts.IsCancellationRequested)
|
||
{
|
||
foreach (var s in job.Steps.Where(s => s.Status is "pending" or "running"))
|
||
{
|
||
s.Status = "skipped";
|
||
s.Error ??= "已取消";
|
||
}
|
||
job.Status = "cancelled";
|
||
job.FinishedAt = DateTimeOffset.UtcNow;
|
||
Recalc(job);
|
||
}
|
||
else
|
||
{
|
||
Finalize(job);
|
||
}
|
||
_store.SaveJob(job);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log.LogError(ex, "OTA job {Id} crashed", jobId);
|
||
var job = _store.GetJob(jobId);
|
||
if (job != null)
|
||
{
|
||
job.Status = "failed";
|
||
job.Message = ex.Message;
|
||
job.FinishedAt = DateTimeOffset.UtcNow;
|
||
_store.SaveJob(job);
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
_running.TryRemove(jobId, out _);
|
||
cts.Dispose();
|
||
}
|
||
}
|
||
|
||
private static void SkipCar(OtaJob job, string carId, string reason)
|
||
{
|
||
foreach (var step in job.Steps.Where(s => s.CarId == carId && s.Status == "pending"))
|
||
{
|
||
step.Status = "skipped";
|
||
step.Error = reason;
|
||
}
|
||
}
|
||
|
||
private static List<OtaCustomFileItem> ResolveCustomFiles(OtaJob job)
|
||
{
|
||
if (job.CustomFiles is { Count: > 0 })
|
||
return job.CustomFiles.Where(f => File.Exists(f.LocalPath)).ToList();
|
||
if (!string.IsNullOrEmpty(job.CustomLocalPath) && File.Exists(job.CustomLocalPath))
|
||
{
|
||
return new List<OtaCustomFileItem>
|
||
{
|
||
new()
|
||
{
|
||
LocalPath = job.CustomLocalPath,
|
||
FileName = job.CustomFileName ?? Path.GetFileName(job.CustomLocalPath)
|
||
}
|
||
};
|
||
}
|
||
return new();
|
||
}
|
||
|
||
private async Task UploadCustomFilesForCarAsync(OtaJob job, string ip, int bandwidth, CancellationToken ct)
|
||
{
|
||
var files = ResolveCustomFiles(job);
|
||
if (files.Count == 0) throw new InvalidOperationException("自定义文件已丢失");
|
||
var remote = job.CustomRemotePath ?? "";
|
||
var ops = job.CustomRestartOps is { Count: > 0 }
|
||
? job.CustomRestartOps
|
||
: new List<int> { job.CustomRestartOp ?? -1 };
|
||
|
||
// 非末文件一律 -1;末文件按所选重启项依次再传(对齐 CarOTA.App)
|
||
for (var i = 0; i < files.Count; i++)
|
||
{
|
||
var f = files[i];
|
||
var isLast = i == files.Count - 1;
|
||
if (!isLast)
|
||
{
|
||
await _wd.UploadCustomFileAsync(ip, f.LocalPath, f.FileName, remote, -1, bandwidth, ct);
|
||
continue;
|
||
}
|
||
|
||
if (ops.Count == 1 && ops[0] == -1)
|
||
{
|
||
await _wd.UploadCustomFileAsync(ip, f.LocalPath, f.FileName, remote, -1, bandwidth, ct);
|
||
}
|
||
else
|
||
{
|
||
foreach (var op in ops.Where(o => o != -1).DefaultIfEmpty(-1))
|
||
await _wd.UploadCustomFileAsync(ip, f.LocalPath, f.FileName, remote, op, bandwidth, ct);
|
||
}
|
||
}
|
||
}
|
||
|
||
private async Task RunCarAsync(OtaJob job, object jobLock, string carId, int bandwidth, CancellationToken ct)
|
||
{
|
||
// 只处理本车 step;同批其他车任务并行修改各自 step,共享同一 job 实例,写盘统一在 jobLock 下串行。
|
||
var steps = job.Steps.Where(s => s.CarId == carId && s.Status == "pending").ToList();
|
||
if (steps.Count == 0) return;
|
||
var ip = steps[0].Ip;
|
||
if (string.IsNullOrEmpty(ip))
|
||
{
|
||
lock (jobLock)
|
||
{
|
||
foreach (var s in steps) { s.Status = "failed"; s.Error = "无 IP"; }
|
||
Recalc(job);
|
||
_store.SaveJob(job);
|
||
}
|
||
return;
|
||
}
|
||
|
||
foreach (var step in steps)
|
||
{
|
||
if (ct.IsCancellationRequested)
|
||
{
|
||
lock (jobLock)
|
||
{
|
||
step.Status = "skipped";
|
||
step.Error = "已取消";
|
||
Recalc(job);
|
||
_store.SaveJob(job);
|
||
}
|
||
continue;
|
||
}
|
||
lock (jobLock)
|
||
{
|
||
step.Status = "running";
|
||
Recalc(job);
|
||
_store.SaveJob(job);
|
||
}
|
||
try
|
||
{
|
||
switch (job.Kind)
|
||
{
|
||
case "sync":
|
||
{
|
||
var target = _store.GetTarget() ?? throw new InvalidOperationException("目标版本丢失");
|
||
if (!target.Components.TryGetValue(step.Component, out var art))
|
||
throw new InvalidOperationException($"组件 {step.Component} 不在目标中");
|
||
await _wd.UploadComponentAsync(ip, step.Component, art.Path, art.FileName, bandwidth, ct);
|
||
break;
|
||
}
|
||
case "customFile":
|
||
await UploadCustomFilesForCarAsync(job, ip, bandwidth, ct);
|
||
break;
|
||
case "configPush":
|
||
await _wd.PutJsonAsync(ip, job.ConfigApp!, job.ConfigJson!, ct);
|
||
break;
|
||
}
|
||
lock (jobLock)
|
||
{
|
||
step.Status = "succeeded";
|
||
step.Error = null;
|
||
Recalc(job);
|
||
_store.SaveJob(job);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
lock (jobLock)
|
||
{
|
||
step.Status = ct.IsCancellationRequested ? "skipped" : "failed";
|
||
step.Error = ct.IsCancellationRequested ? "已取消" : ex.Message;
|
||
Recalc(job);
|
||
_store.SaveJob(job);
|
||
}
|
||
_log.LogWarning(ex, "OTA step fail {Job} {Car} {Comp}", job.Id, carId, step.Component);
|
||
}
|
||
}
|
||
}
|
||
|
||
private static void Recalc(OtaJob job)
|
||
{
|
||
job.DoneSteps = job.Steps.Count(s => s.Status is "succeeded" or "failed" or "skipped");
|
||
}
|
||
|
||
private static void Finalize(OtaJob job)
|
||
{
|
||
Recalc(job);
|
||
job.FinishedAt = DateTimeOffset.UtcNow;
|
||
var anyFail = job.Steps.Any(s => s.Status == "failed");
|
||
var anyOk = job.Steps.Any(s => s.Status == "succeeded");
|
||
var anyPending = job.Steps.Any(s => s.Status is "pending" or "running");
|
||
if (job.Status == "cancelled") return;
|
||
if (anyPending) job.Status = "partial";
|
||
else if (anyFail && anyOk) job.Status = "partial";
|
||
else if (anyFail) job.Status = "failed";
|
||
else if (anyOk) job.Status = "succeeded";
|
||
else job.Status = "cancelled";
|
||
}
|
||
}
|