新增 OTA WatchDog 编排与车队健康/报警后端。

覆盖包库回传、任务下发、CDM 任务同步与报警采集,并为包/任务 ID 与上传文件名加上路径安全校验。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zhaowei.huang
2026-07-26 11:31:51 +08:00
co-authored by Cursor
parent f4f4cd1d1b
commit 4223a572c5
25 changed files with 3552 additions and 6 deletions
+26
View File
@@ -0,0 +1,26 @@
using System.Security.Cryptography;
namespace MiGu.Server.Ota;
public static class OtaHash
{
/// <summary>与参考 OTA 工具一致:MD5 → Base64,并去掉 '-'。</summary>
public static string OfFile(string path)
{
using var fs = File.OpenRead(path);
var hash = MD5.HashData(fs);
return Convert.ToBase64String(hash).Replace("-", "", StringComparison.Ordinal);
}
public static string OfBytes(ReadOnlySpan<byte> bytes)
{
var hash = MD5.HashData(bytes);
return Convert.ToBase64String(hash).Replace("-", "", StringComparison.Ordinal);
}
public static string Short(string? hash, int len = 8)
{
if (string.IsNullOrEmpty(hash)) return "—";
return hash.Length <= len ? hash : hash[..len];
}
}
+433
View File
@@ -0,0 +1,433 @@
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";
}
}
+144
View File
@@ -0,0 +1,144 @@
namespace MiGu.Server.Ota;
public sealed class OtaSettings
{
public int BandwidthKbps { get; set; }
public int MaxCar { get; set; } = 2;
public bool LatencyEnabled { get; set; }
public int RttThresholdMs { get; set; } = 200;
/// <summary>skip | confirm</summary>
public string OverThreshold { get; set; } = "skip";
public int BackupPeriodMinutes { get; set; } = 60;
public bool BackupExe { get; set; }
public string? NewVersionName { get; set; }
}
public sealed class OtaFileArtifact
{
public string Hash { get; set; } = "";
public string Path { get; set; } = "";
public string FileName { get; set; } = "";
public string? Time { get; set; }
public long Size { get; set; }
}
public sealed class OtaTarget
{
public string PackageId { get; set; } = "";
public string? Name { get; set; }
public DateTimeOffset ActivatedAt { get; set; }
public Dictionary<string, OtaFileArtifact> Components { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}
public sealed class OtaPackageInfo
{
public string Id { get; set; } = "";
public string? SourceIp { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public long TotalBytes { get; set; }
public bool IsTarget { get; set; }
public Dictionary<string, OtaFileArtifact> Components { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}
public sealed class OtaComponentVersion
{
public string? Version { get; set; }
public string? Time { get; set; }
}
public sealed class OtaAppVersions
{
public OtaComponentVersion? Exe { get; set; }
public OtaComponentVersion? Dll { get; set; }
public OtaComponentVersion? Pdb { get; set; }
}
public sealed class OtaVehicleRow
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public string? Ip { get; set; }
public string? State { get; set; }
public string? Group { get; set; }
public bool Reachable { get; set; }
public int? RttMs { get; set; }
public OtaAppVersions? Medulla { get; set; }
public OtaAppVersions? Detour { get; set; }
public OtaAppVersions? Clumsy { get; set; }
public Dictionary<string, string>? Match { get; set; }
}
public sealed class OtaJobStep
{
public string CarId { get; set; } = "";
public string? Ip { get; set; }
public string Component { get; set; } = "";
public string Status { get; set; } = "pending";
public string? Error { get; set; }
}
public sealed class OtaJob
{
public string Id { get; set; } = "";
/// <summary>sync | customFile | configPush</summary>
public string Kind { get; set; } = "sync";
public string Status { get; set; } = "pending";
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? FinishedAt { get; set; }
public string? CreatedBy { get; set; }
public string? PackageId { get; set; }
public List<string> CarIds { get; set; } = new();
public List<string> Components { get; set; } = new();
public bool RequireLatencyCheck { get; set; }
public int DoneSteps { get; set; }
public int TotalSteps { get; set; }
public List<OtaJobStep> Steps { get; set; } = new();
public string? Message { get; set; }
// customFile
public string? CustomFileName { get; set; }
public string? CustomRemotePath { get; set; }
public int? CustomRestartOp { get; set; }
public string? CustomLocalPath { get; set; }
public List<OtaCustomFileItem> CustomFiles { get; set; } = new();
public List<int> CustomRestartOps { get; set; } = new() { -1 };
// configPush
public string? ConfigApp { get; set; }
public string? ConfigJson { get; set; }
}
public sealed class CreateSyncJobRequest
{
public List<string> CarIds { get; set; } = new();
public List<string>? Components { get; set; }
public bool? RequireLatencyCheck { get; set; }
}
public sealed class PullPackageRequest
{
public string CarId { get; set; } = "";
}
public sealed class CreateCustomFileJobRequest
{
public List<string> CarIds { get; set; } = new();
public string RemotePath { get; set; } = "";
/// <summary>兼容旧单值;优先用 RestartOps。</summary>
public int RestartOp { get; set; } = -1;
/// <summary>-1 不重启;0 Medulla1 Clumsy2 Detour3 WatchDog。可多选。</summary>
public List<int>? RestartOps { get; set; }
public bool? RequireLatencyCheck { get; set; }
}
public sealed class OtaCustomFileItem
{
public string LocalPath { get; set; } = "";
public string FileName { get; set; } = "";
}
public sealed class CreateConfigPushRequest
{
public List<string> CarIds { get; set; } = new();
public string App { get; set; } = "";
public string Json { get; set; } = "";
public bool? RequireLatencyCheck { get; set; }
}
+24
View File
@@ -0,0 +1,24 @@
namespace MiGu.Server.Ota;
public sealed class OtaOptions
{
/// <summary>相对 ContentRoot 或绝对路径;默认 data/ota</summary>
public string DataRoot { get; set; } = "data/ota";
public int WatchDogPort { get; set; } = 9776;
public int RequestTimeoutMs { get; set; } = 60_000;
public int UploadTimeoutMs { get; set; } = 600_000;
/// <summary>
/// WatchDog 回传监听端口(写死连 :8000)。MiGu 会额外监听该端口并挂 /upload-mdcs/*。
/// </summary>
public int ReceivePort { get; set; } = 8000;
/// <summary>
/// 可选:本机对车辆可见的管理面基址(如 http://192.168.1.10:8080)。
/// 注意:现网 WatchDog 忽略 getmdcsexe 的 server 参数,仍回传到 config.serverIP:ReceivePort。
/// </summary>
public string? PublicBaseUrl { get; set; }
}
+53
View File
@@ -0,0 +1,53 @@
namespace MiGu.Server.Ota;
/// <summary>MDC 组件文件映射(对齐参考工具 MDCPath.json)。</summary>
public static class OtaPathMap
{
public static readonly IReadOnlyDictionary<string, string[]> Default = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["M"] = new[] { "Medulla.exe", "plugins\\CartActivator.dll", "plugins\\CartActivator.pdb" },
["D"] = new[] { "Detour.exe" },
["C"] = new[] { "ClumsyConsole.exe", "FG2305014_C.dll", "FG2305014_C.pdb" }
};
public static readonly string[] ComponentKeys =
{
"M.exe", "M.dll", "M.pdb", "D.exe", "C.exe", "C.dll", "C.pdb"
};
public static string? RelPathFor(string componentKey)
{
return componentKey switch
{
"M.exe" => "M/Medulla.exe",
"M.dll" => "M/plugins/CartActivator.dll",
"M.pdb" => "M/plugins/CartActivator.pdb",
"D.exe" => "D/Detour.exe",
"C.exe" => "C/ClumsyConsole.exe",
"C.dll" => "C/FG2305014_C.dll",
"C.pdb" => "C/FG2305014_C.pdb",
_ => null
};
}
public static string? WatchDogUpdatePath(string componentKey) => componentKey switch
{
"M.exe" => "updateMedullaExecutable",
"M.dll" => "updateMedullaDll",
"M.pdb" => "UpdateMedullaPdb",
"D.exe" => "updateDetourExecutable",
"C.exe" => "updateClumsyExecutable",
"C.dll" => "updateClumsyDll",
"C.pdb" => "UpdateClumsyPdb",
_ => null
};
public static string AppFolder(string componentKey) => componentKey.StartsWith('M') ? "M"
: componentKey.StartsWith('D') ? "D" : "C";
public static string ExtKey(string componentKey)
{
var i = componentKey.IndexOf('.');
return i >= 0 ? componentKey[(i + 1)..] : componentKey;
}
}
+368
View File
@@ -0,0 +1,368 @@
using System.Text.Json;
using MiGu.Server.Infra;
using Microsoft.Extensions.Options;
namespace MiGu.Server.Ota;
public sealed class OtaStore
{
private readonly object _gate = new();
private readonly ILogger<OtaStore> _log;
private readonly JsonSerializerOptions _json = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
public string Root { get; }
public string PackagesDir { get; }
public string JobsDir { get; }
public string HistoryDir { get; }
public string TargetFile { get; }
public string SettingsFile { get; }
private string? _lastPullId;
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, string> _pullByIp =
new(StringComparer.OrdinalIgnoreCase);
private long _jobSeq;
public OtaStore(IWebHostEnvironment env, IOptions<OtaOptions> options, ILogger<OtaStore> log)
{
_log = log;
var cfg = options.Value.DataRoot;
Root = Path.IsPathRooted(cfg) ? cfg : Path.Combine(env.ContentRootPath, cfg);
PackagesDir = Path.Combine(Root, "packages");
JobsDir = Path.Combine(Root, "jobs");
HistoryDir = Path.Combine(Root, "history");
TargetFile = Path.Combine(Root, "target.json");
SettingsFile = Path.Combine(Root, "settings.json");
Directory.CreateDirectory(PackagesDir);
Directory.CreateDirectory(JobsDir);
Directory.CreateDirectory(HistoryDir);
}
public OtaSettings GetSettings()
{
lock (_gate)
{
if (!File.Exists(SettingsFile)) return new OtaSettings();
try
{
return JsonSerializer.Deserialize<OtaSettings>(File.ReadAllText(SettingsFile), _json) ?? new OtaSettings();
}
catch (Exception ex)
{
_log.LogWarning(ex, "OTA settings load failed");
return new OtaSettings();
}
}
}
public OtaSettings SaveSettings(OtaSettings settings)
{
lock (_gate)
{
AtomicFile.WriteAllText(SettingsFile, JsonSerializer.Serialize(settings, _json));
return settings;
}
}
public OtaTarget? GetTarget()
{
lock (_gate)
{
if (!File.Exists(TargetFile)) return null;
try
{
return JsonSerializer.Deserialize<OtaTarget>(File.ReadAllText(TargetFile), _json);
}
catch (Exception ex)
{
_log.LogWarning(ex, "OTA target load failed");
return null;
}
}
}
public void SetTarget(OtaTarget target)
{
lock (_gate)
{
AtomicFile.WriteAllText(TargetFile, JsonSerializer.Serialize(target, _json));
var settings = GetSettingsUnlocked();
if (!string.IsNullOrWhiteSpace(target.Name))
{
settings.NewVersionName = target.Name;
AtomicFile.WriteAllText(SettingsFile, JsonSerializer.Serialize(settings, _json));
}
}
}
private OtaSettings GetSettingsUnlocked()
{
if (!File.Exists(SettingsFile)) return new OtaSettings();
try
{
return JsonSerializer.Deserialize<OtaSettings>(File.ReadAllText(SettingsFile), _json) ?? new OtaSettings();
}
catch
{
return new OtaSettings();
}
}
public string BeginPullPackage(string? sourceIp)
{
lock (_gate)
{
var baseId = $"{DateTime.UtcNow:yyyyMMddHHmmssfff}({SafeIdPart(sourceIp ?? "upload")})";
var id = baseId;
var dir = Path.Combine(PackagesDir, id);
for (var i = 1; Directory.Exists(dir); i++)
{
id = $"{baseId}-{i:D2}";
dir = Path.Combine(PackagesDir, id);
}
Directory.CreateDirectory(dir);
foreach (var app in new[] { "M", "D", "C" })
Directory.CreateDirectory(Path.Combine(dir, app));
Directory.CreateDirectory(Path.Combine(dir, "M", "plugins"));
var ip = NormalizeIp(sourceIp);
if (ip != null) _pullByIp[ip] = id;
_lastPullId = id;
return id;
}
}
public string? ActivePullId
{
get { lock (_gate) return _lastPullId; }
}
public bool TryGetActivePullId(string? sourceIp, out string? id)
{
lock (_gate)
{
var ip = NormalizeIp(sourceIp);
if (ip != null && _pullByIp.TryGetValue(ip, out var byIp))
{
id = byIp;
return true;
}
if (ip == null && _lastPullId != null)
{
id = _lastPullId;
return true;
}
id = null;
return false;
}
}
public void ClearActivePull(string? sourceIp = null)
{
lock (_gate)
{
var ip = NormalizeIp(sourceIp);
if (ip != null) _pullByIp.TryRemove(ip, out _);
if (_pullByIp.IsEmpty) _lastPullId = null;
}
}
/// <summary>把 WatchDog 回传连接的远端 IP 归一(去掉 IPv6 映射前缀,如 ::ffff:192.168.1.13)。</summary>
private static string? NormalizeIp(string? ip)
{
if (string.IsNullOrWhiteSpace(ip)) return null;
if (System.Net.IPAddress.TryParse(ip, out var addr))
return (addr.IsIPv4MappedToIPv6 ? addr.MapToIPv4() : addr).ToString();
return ip.Trim();
}
public string ResolveReceivePath(string routeKey, string? clientIp = null)
{
// routeKey examples: Medullaexe, Medulladll, Detourexe, ClumsyConsoleexe, ClumsyConsoledll
// Do not fall back to the latest session for a known-but-unregistered client.
if (!TryGetActivePullId(clientIp, out var id) || id == null)
throw new InvalidOperationException("无进行中的拉取会话");
var dir = Path.Combine(PackagesDir, id);
return routeKey.ToLowerInvariant() switch
{
"medullaexe" => Path.Combine(dir, "M", "Medulla.exe"),
"medulladll" => Path.Combine(dir, "M", "plugins", "CartActivator.dll"),
"medullapdb" => Path.Combine(dir, "M", "plugins", "CartActivator.pdb"),
"detourexe" => Path.Combine(dir, "D", "Detour.exe"),
"clumsyconsoleexe" or "clumsyexe" => Path.Combine(dir, "C", "ClumsyConsole.exe"),
"clumsyconsoledll" or "clumsydll" => Path.Combine(dir, "C", "FG2305014_C.dll"),
"clumsyconsolepdb" or "clumsypdb" => Path.Combine(dir, "C", "FG2305014_C.pdb"),
_ => throw new ArgumentException($"未知接收路由: {routeKey}")
};
}
public OtaPackageInfo ScanPackage(string id)
{
var dir = ResolveUnder(PackagesDir, id);
if (!Directory.Exists(dir)) throw new DirectoryNotFoundException(id);
var info = new OtaPackageInfo
{
Id = id,
CreatedAt = Directory.GetCreationTimeUtc(dir),
SourceIp = ExtractSourceIp(id)
};
long total = 0;
foreach (var key in OtaPathMap.ComponentKeys)
{
var rel = OtaPathMap.RelPathFor(key);
if (rel == null) continue;
var path = Path.Combine(dir, rel.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(path)) continue;
var fi = new FileInfo(path);
total += fi.Length;
info.Components[key] = new OtaFileArtifact
{
Hash = OtaHash.OfFile(path),
Path = path,
FileName = Path.GetFileName(path),
Time = fi.LastWriteTime.ToString("yyyy-MM-dd HH:mm:ss"),
Size = fi.Length
};
}
info.TotalBytes = total;
var target = GetTarget();
info.IsTarget = target != null && string.Equals(target.PackageId, id, StringComparison.Ordinal);
return info;
}
public List<OtaPackageInfo> ListPackages()
{
if (!Directory.Exists(PackagesDir)) return new();
var list = new List<OtaPackageInfo>();
foreach (var dir in Directory.GetDirectories(PackagesDir).OrderByDescending(d => d))
{
try { list.Add(ScanPackage(Path.GetFileName(dir))); }
catch (Exception ex) { _log.LogDebug(ex, "skip package {Dir}", dir); }
}
return list;
}
public void DeletePackage(string id)
{
lock (_gate)
{
var target = GetTarget();
if (target != null && string.Equals(target.PackageId, id, StringComparison.Ordinal))
throw new InvalidOperationException("不能删除当前目标版本");
var dir = ResolveUnder(PackagesDir, id);
if (Directory.Exists(dir)) Directory.Delete(dir, true);
}
}
public OtaTarget ActivatePackage(string id, string? name)
{
var pkg = ScanPackage(id);
if (pkg.Components.Count == 0)
throw new InvalidOperationException("包内无有效组件文件");
var safeId = RequireSafeId(id);
var target = new OtaTarget
{
PackageId = safeId,
Name = name ?? safeId,
ActivatedAt = DateTimeOffset.UtcNow,
Components = pkg.Components
};
SetTarget(target);
return target;
}
public string PackageDir(string id) => ResolveUnder(PackagesDir, id);
public void SaveJob(OtaJob job)
{
lock (_gate)
{
var path = ResolveUnder(JobsDir, $"{RequireSafeId(job.Id)}.json");
AtomicFile.WriteAllText(path, JsonSerializer.Serialize(job, _json));
}
}
public OtaJob? GetJob(string id)
{
var path = ResolveUnder(JobsDir, $"{RequireSafeId(id)}.json");
if (!File.Exists(path)) return null;
try
{
return JsonSerializer.Deserialize<OtaJob>(File.ReadAllText(path), _json);
}
catch (Exception ex)
{
_log.LogWarning(ex, "job load failed {Id}", id);
return null;
}
}
public List<OtaJob> ListJobs(int take = 100)
{
if (!Directory.Exists(JobsDir)) return new();
return Directory.GetFiles(JobsDir, "*.json")
.Select(f =>
{
try { return JsonSerializer.Deserialize<OtaJob>(File.ReadAllText(f), _json); }
catch { return null; }
})
.Where(j => j != null)
.Cast<OtaJob>()
.OrderByDescending(j => j.CreatedAt)
.Take(take)
.ToList();
}
public string NextJobId()
{
// 进程内自增序号保证唯一(同秒也不会撞 ID → 不会两个 job 写同一文件)。
var seq = System.Threading.Interlocked.Increment(ref _jobSeq);
return $"J{DateTime.UtcNow:yyyyMMddHHmmss}-{seq:D4}";
}
private static string? ExtractSourceIp(string id)
{
var open = id.IndexOf('(');
var close = id.IndexOf(')');
if (open >= 0 && close > open) return id[(open + 1)..close];
return null;
}
private static string SafeIdPart(string raw)
{
var safe = raw.Trim();
foreach (var ch in Path.GetInvalidFileNameChars())
safe = safe.Replace(ch, '_');
return string.IsNullOrWhiteSpace(safe) ? "upload" : safe;
}
/// <summary>拒绝路径段(含 .. / 分隔符),只允许单层文件名。</summary>
private static string RequireSafeId(string id)
{
if (string.IsNullOrWhiteSpace(id))
throw new ArgumentException("无效标识");
var trimmed = id.Trim();
if (trimmed is "." or ".."
|| trimmed.Contains('/') || trimmed.Contains('\\')
|| trimmed.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
throw new ArgumentException("无效标识");
return trimmed;
}
private string ResolveUnder(string root, string id)
{
var safe = RequireSafeId(id);
var fullRoot = Path.GetFullPath(root);
var full = Path.GetFullPath(Path.Combine(fullRoot, safe));
var prefix = fullRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
+ Path.DirectorySeparatorChar;
if (!full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(full, fullRoot, StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("无效标识");
return full;
}
}
+141
View File
@@ -0,0 +1,141 @@
using System.Text.Json;
using Microsoft.Extensions.Options;
using MiGu.Server.Launcher;
namespace MiGu.Server.Ota;
public sealed class OtaVehicleSource
{
private readonly IHttpClientFactory _httpFactory;
private readonly SimpleLiteOptions _sl;
private readonly InternalTokenStoreAccessor _token;
private readonly ILogger<OtaVehicleSource> _log;
public OtaVehicleSource(
IHttpClientFactory httpFactory,
IOptions<SimpleLiteOptions> sl,
InternalTokenStoreAccessor token,
ILogger<OtaVehicleSource> log)
{
_httpFactory = httpFactory;
_sl = sl.Value;
_token = token;
_log = log;
}
public async Task<List<OtaVehicleRow>> ListCarsAsync(CancellationToken ct)
{
var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222;
var cars = await TryProjectionAsync(port, ct);
if (cars.Count == 0)
cars = await TryAgvListAsync(port, ct);
return cars;
}
private async Task<List<OtaVehicleRow>> TryProjectionAsync(int port, CancellationToken ct)
{
try
{
using var client = _httpFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(8);
using var req = new HttpRequestMessage(HttpMethod.Get, $"http://127.0.0.1:{port}/projection/cars");
var token = _token.Token;
if (!string.IsNullOrEmpty(token))
req.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", token);
using var resp = await client.SendAsync(req, ct);
if (!resp.IsSuccessStatusCode) return new();
var text = await resp.Content.ReadAsStringAsync(ct);
return ParseCars(text);
}
catch (Exception ex)
{
_log.LogDebug(ex, "projection/cars failed");
return new();
}
}
private async Task<List<OtaVehicleRow>> TryAgvListAsync(int port, CancellationToken ct)
{
try
{
using var client = _httpFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(8);
using var resp = await client.GetAsync($"http://127.0.0.1:{port}/api/agv/list", ct);
if (!resp.IsSuccessStatusCode) return new();
var text = await resp.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(text);
var root = doc.RootElement;
var arr = root.ValueKind == JsonValueKind.Array ? root
: root.TryGetProperty("data", out var d) ? d
: root.TryGetProperty("items", out var i) ? i
: default;
if (arr.ValueKind != JsonValueKind.Array) return new();
var list = new List<OtaVehicleRow>();
foreach (var el in arr.EnumerateArray())
{
var id = GetStr(el, "agv_id", "id", "carId") ?? "";
var name = GetStr(el, "agv_name", "name") ?? id;
var ip = GetStr(el, "agv_ip", "ip");
var state = GetStr(el, "status", "state");
if (string.IsNullOrEmpty(id) && string.IsNullOrEmpty(ip)) continue;
list.Add(new OtaVehicleRow
{
Id = string.IsNullOrEmpty(id) ? ip! : id,
Name = name,
Ip = ip,
State = state
});
}
return list;
}
catch (Exception ex)
{
_log.LogDebug(ex, "agv/list failed");
return new();
}
}
private static List<OtaVehicleRow> ParseCars(string text)
{
using var doc = JsonDocument.Parse(text);
var root = doc.RootElement;
var arr = root.ValueKind == JsonValueKind.Array ? root
: root.TryGetProperty("cars", out var c) ? c
: root.TryGetProperty("items", out var i) ? i
: root.TryGetProperty("data", out var d) ? d
: default;
if (arr.ValueKind != JsonValueKind.Array) return new();
var list = new List<OtaVehicleRow>();
foreach (var el in arr.EnumerateArray())
{
var id = GetStr(el, "id", "carId", "rawId") ?? "";
var name = GetStr(el, "name") ?? id;
var ip = GetStr(el, "ip");
var state = GetStr(el, "state", "status");
var group = GetStr(el, "group");
if (string.IsNullOrEmpty(id)) continue;
list.Add(new OtaVehicleRow { Id = id, Name = name, Ip = ip, State = state, Group = group });
}
return list;
}
private static string? GetStr(JsonElement el, params string[] names)
{
foreach (var n in names)
{
if (el.TryGetProperty(n, out var p) && p.ValueKind == JsonValueKind.String)
return p.GetString();
if (el.TryGetProperty(n, out p) && p.ValueKind is JsonValueKind.Number)
return p.ToString();
}
return null;
}
}
/// <summary>避免 Ota 层直接依赖 Auth 命名空间循环;薄包装 InternalTokenStore。</summary>
public sealed class InternalTokenStoreAccessor
{
private readonly Auth.InternalTokenStore _store;
public InternalTokenStoreAccessor(Auth.InternalTokenStore store) => _store = store;
public string Token => _store.Token;
}
+289
View File
@@ -0,0 +1,289 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Options;
namespace MiGu.Server.Ota;
public sealed class WatchDogClient
{
private readonly IHttpClientFactory _httpFactory;
private readonly OtaOptions _opt;
private readonly ILogger<WatchDogClient> _log;
private readonly JsonSerializerOptions _json = new() { PropertyNameCaseInsensitive = true };
public WatchDogClient(IHttpClientFactory httpFactory, IOptions<OtaOptions> opt, ILogger<WatchDogClient> log)
{
_httpFactory = httpFactory;
_opt = opt.Value;
_log = log;
}
private HttpClient CreateClient(int? timeoutMs = null)
{
var c = _httpFactory.CreateClient(nameof(WatchDogClient));
c.Timeout = TimeSpan.FromMilliseconds(timeoutMs ?? _opt.RequestTimeoutMs);
return c;
}
private string Base(string ip) => $"http://{ip}:{_opt.WatchDogPort}";
public async Task<(bool Ok, OtaAppVersions? M, OtaAppVersions? D, OtaAppVersions? C, string? Error)> GetMdcInfoAsync(string ip, CancellationToken ct)
{
try
{
using var client = CreateClient();
using var resp = await client.GetAsync($"{Base(ip)}/getMDCInfo", ct);
if (!resp.IsSuccessStatusCode)
return (false, null, null, null, $"HTTP {(int)resp.StatusCode}");
var text = await resp.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(text);
var root = doc.RootElement;
return (true, ParseApp(root, "Medulla"), ParseApp(root, "Detour"), ParseApp(root, "Clumsy"), null);
}
catch (Exception ex)
{
return (false, null, null, null, ex.Message);
}
}
private static OtaAppVersions? ParseApp(JsonElement root, string name)
{
if (!root.TryGetProperty(name, out var app) && !root.TryGetProperty(name.ToLowerInvariant(), out app))
return null;
return new OtaAppVersions
{
Exe = ParseComp(app, "exe"),
Dll = ParseComp(app, "dll"),
Pdb = ParseComp(app, "pdb")
};
}
private static OtaComponentVersion? ParseComp(JsonElement app, string key)
{
if (!app.TryGetProperty(key, out var c)) return null;
string? ver = null;
string? time = null;
if (c.ValueKind == JsonValueKind.Object)
{
if (c.TryGetProperty("version", out var v))
ver = v.ValueKind == JsonValueKind.String ? v.GetString() : v.ToString();
if (c.TryGetProperty("time", out var t))
time = t.GetString();
}
else if (c.ValueKind == JsonValueKind.String)
{
ver = c.GetString();
}
return new OtaComponentVersion { Version = ver, Time = time };
}
public async Task<int?> MeasureRttMsAsync(string ip, CancellationToken ct)
{
try
{
var sw = Stopwatch.StartNew();
using var tcp = new TcpClient();
using var reg = ct.Register(() => { try { tcp.Close(); } catch { /* ignore */ } });
var connectTask = tcp.ConnectAsync(ip, _opt.WatchDogPort);
var done = await Task.WhenAny(connectTask, Task.Delay(Math.Min(3000, _opt.RequestTimeoutMs), ct));
if (done != connectTask || !tcp.Connected) return null;
await connectTask;
sw.Stop();
return (int)sw.ElapsedMilliseconds;
}
catch
{
return null;
}
}
public async Task TriggerPullAsync(string ip, string serverBaseUrl, string time, CancellationToken ct)
{
// 现网 WatchDog 忽略 server 查询参数,固定 POST 到 http://{config.serverIP}:8000/upload-mdcs/{key}。
// serverBaseUrl 仅作日志/未来兼容;真正要通必须:车上 serverIP=本机局域网 IP,且本机监听 ReceivePort。
using var client = CreateClient(_opt.UploadTimeoutMs);
var url = $"{Base(ip)}/getmdcsexe?time={Uri.EscapeDataString(time)}&server={Uri.EscapeDataString(serverBaseUrl.TrimEnd('/'))}";
try
{
using var resp = await client.GetAsync(url, ct);
var body = (await resp.Content.ReadAsStringAsync(ct)).Trim();
_log.LogInformation("getmdcsexe {Ip} -> {Code} body={Body} (WatchDog will POST to its config.serverIP:{Port}/upload-mdcs/*; expect receiver {Base})",
ip, (int)resp.StatusCode, body.Length > 200 ? body[..200] : body, _opt.ReceivePort, serverBaseUrl);
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException($"WatchDog getmdcsexe HTTP {(int)resp.StatusCode}: {body}");
if (body.Contains("请配置", StringComparison.Ordinal)
|| body.Equals("false", StringComparison.OrdinalIgnoreCase)
|| body.Equals("\"false\"", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
"WatchDog 拒绝拉包或回传失败。请确认:1) 车已配置 Medulla/Detour/Clumsy 路径;" +
$"2) watch_dog.json 的 serverIP 指向本机局域网 IP(车将 POST 到 serverIP:{_opt.ReceivePort}/upload-mdcs/*);" +
$"3) 本机已监听 :{_opt.ReceivePort}。WatchDog 返回:{body}");
}
}
catch (InvalidOperationException)
{
throw;
}
catch (Exception ex)
{
_log.LogWarning(ex, "getmdcsexe failed {Ip}", ip);
throw;
}
}
public async Task UploadComponentAsync(string ip, string componentKey, string localPath, string fileName, int bandwidthKbps, CancellationToken ct)
{
var endpoint = OtaPathMap.WatchDogUpdatePath(componentKey)
?? throw new ArgumentException($"未知组件 {componentKey}");
await UploadFileAsync($"{Base(ip)}/{endpoint}", localPath, fileName, bandwidthKbps, null, ct);
}
public async Task UploadCustomFileAsync(string ip, string localPath, string fileName, string remotePath, int restartOp, int bandwidthKbps, CancellationToken ct)
{
var url = $"{Base(ip)}/updateFile/{Uri.EscapeDataString(fileName)}/{restartOp}/";
await UploadFileAsync(url, localPath, fileName, bandwidthKbps, new Dictionary<string, string> { ["path"] = remotePath }, ct);
}
private async Task UploadFileAsync(string url, string localPath, string fileName, int bandwidthKbps, Dictionary<string, string>? extraFields, CancellationToken ct)
{
using var client = CreateClient(_opt.UploadTimeoutMs);
await using var fs = File.OpenRead(localPath);
Stream contentStream = fs;
if (bandwidthKbps > 0)
contentStream = new ThrottledStream(fs, bandwidthKbps * 1024L);
using var form = new MultipartFormDataContent();
if (extraFields != null)
{
foreach (var (k, v) in extraFields)
form.Add(new StringContent(v, Encoding.UTF8), k);
}
var streamContent = new StreamContent(contentStream);
form.Add(streamContent, "file", fileName);
using var resp = await client.PostAsync(url, form, ct);
if (!resp.IsSuccessStatusCode)
{
var body = await resp.Content.ReadAsStringAsync(ct);
throw new InvalidOperationException($"上传失败 HTTP {(int)resp.StatusCode}: {body}");
}
}
public async Task<string> GetJsonAsync(string ip, string app, CancellationToken ct)
{
var path = app.ToLowerInvariant() switch
{
"medulla" => "getMedullajson",
"detour" => "getDetourjson",
"clumsy" => "getClumsyjson",
_ => throw new ArgumentException("app 须为 medulla|detour|clumsy")
};
using var client = CreateClient();
using var resp = await client.GetAsync($"{Base(ip)}/{path}", ct);
resp.EnsureSuccessStatusCode();
return await resp.Content.ReadAsStringAsync(ct);
}
public async Task PutJsonAsync(string ip, string app, string json, CancellationToken ct)
{
var path = app.ToLowerInvariant() switch
{
"medulla" => "updateMedullajson",
"detour" => "updateDetourjson",
"clumsy" => "updateClumsyjson",
_ => throw new ArgumentException("app 须为 medulla|detour|clumsy")
};
using var client = CreateClient();
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var resp = await client.PostAsync($"{Base(ip)}/{path}", content, ct);
if (!resp.IsSuccessStatusCode)
{
var body = await resp.Content.ReadAsStringAsync(ct);
throw new InvalidOperationException($"更新 JSON 失败 HTTP {(int)resp.StatusCode}: {body}");
}
}
/// <summary>简易限速流:按字节/秒节流读取。</summary>
private sealed class ThrottledStream : Stream
{
private readonly Stream _inner;
private readonly long _bytesPerSecond;
private long _windowBytes;
private long _windowStart = Environment.TickCount64;
public ThrottledStream(Stream inner, long bytesPerSecond)
{
_inner = inner;
_bytesPerSecond = Math.Max(1024, bytesPerSecond);
}
public override bool CanRead => _inner.CanRead;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => _inner.Length;
public override long Position { get => _inner.Position; set => throw new NotSupportedException(); }
public override void Flush() => _inner.Flush();
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override int Read(byte[] buffer, int offset, int count)
{
var n = _inner.Read(buffer, offset, count);
if (n > 0) Throttle(n);
return n;
}
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
var n = await _inner.ReadAsync(buffer.AsMemory(offset, count), cancellationToken);
if (n > 0) await ThrottleAsync(n, cancellationToken);
return n;
}
private void Throttle(int n)
{
_windowBytes += n;
var elapsed = Environment.TickCount64 - _windowStart;
if (elapsed < 1) elapsed = 1;
var allowed = _bytesPerSecond * elapsed / 1000;
if (_windowBytes > allowed)
{
var wait = (int)((_windowBytes - allowed) * 1000 / _bytesPerSecond);
if (wait > 0) Thread.Sleep(Math.Min(wait, 2000));
}
if (elapsed >= 1000)
{
_windowBytes = 0;
_windowStart = Environment.TickCount64;
}
}
private async Task ThrottleAsync(int n, CancellationToken ct)
{
_windowBytes += n;
var elapsed = Environment.TickCount64 - _windowStart;
if (elapsed < 1) elapsed = 1;
var allowed = _bytesPerSecond * elapsed / 1000;
if (_windowBytes > allowed)
{
var wait = (int)((_windowBytes - allowed) * 1000 / _bytesPerSecond);
if (wait > 0) await Task.Delay(Math.Min(wait, 2000), ct);
}
if (elapsed >= 1000)
{
_windowBytes = 0;
_windowStart = Environment.TickCount64;
}
}
protected override void Dispose(bool disposing)
{
// 不释放 inner(由调用方 using FileStream
base.Dispose(disposing);
}
}
}