新增车辆表及OTA权限与逻辑优化

新增车辆任务与报警表,完善实体与DbContext配置。细化OTA权限校验,增强回传会话IP安全。优化OTA上传与设置面板,调度器支持重启恢复。报警采集逻辑支持历史分段。
This commit is contained in:
2026-07-27 15:12:14 +08:00
parent bc3e9c5172
commit 1f72488a8d
20 changed files with 2392 additions and 53 deletions
+27 -4
View File
@@ -48,7 +48,10 @@ public class OtaController : ControllerBase
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));
return set.Contains("*")
|| set.Contains("ops.ota")
|| set.Contains("ops.ota.write")
|| set.Any(o => o.StartsWith("ops.ota.", StringComparison.OrdinalIgnoreCase));
}
private bool DenyWrite(out ActionResult denied)
@@ -147,12 +150,32 @@ public class OtaController : ControllerBase
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 deadline = DateTime.UtcNow.AddSeconds(90);
var idleAfterReceive = TimeSpan.FromSeconds(8);
var lastCount = 0;
DateTime? lastProgressAt = null;
while (DateTime.UtcNow < deadline)
{
ct.ThrowIfCancellationRequested();
var info = _store.ScanPackage(pkgId);
if (info.Components.Count > 0) break;
if (info.Components.Count > lastCount)
{
lastCount = info.Components.Count;
lastProgressAt = DateTime.UtcNow;
}
else if (_store.TryGetLastReceiveAt(car.Ip, out var recvAt) &&
(lastProgressAt == null || recvAt > lastProgressAt.Value))
{
lastProgressAt = recvAt;
}
if (lastCount > 0 &&
lastProgressAt != null &&
DateTime.UtcNow - lastProgressAt.Value >= idleAfterReceive)
break;
await Task.Delay(500, ct);
}
_store.ClearActivePull(car.Ip);
@@ -8,11 +8,14 @@ namespace MiGu.Server.Controllers;
/// WatchDog 回传包接收端。
/// WatchDog 写死 POST 到 http://{config.serverIP}:8000/upload-mdcs/{routeKey}
/// 必须与参考 Electron Express :8000 路径一致;/api/ota/receive/* 仅作兼容别名。
/// 会话校验使用 TCP 对端 IP(见 Program 中 TcpRemoteIp),忽略可伪造的 X-Forwarded-For。
/// </summary>
[ApiController]
[AllowAnonymous]
public class OtaReceiveController : ControllerBase
{
public const string TcpRemoteIpItemKey = "TcpRemoteIp";
private readonly OtaStore _store;
private readonly ILogger<OtaReceiveController> _log;
@@ -40,15 +43,15 @@ public class OtaReceiveController : ControllerBase
[RequestSizeLimit(512_000_000)]
public async Task<IActionResult> UploadHistory(string routeKey, CancellationToken ct)
{
var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
var ip = ResolveTcpRemoteIp();
if (!_store.TryGetActivePullId(ip, out _))
{
_log.LogWarning("OTA history rejected without active pull session from {Ip}", ip);
_log.LogWarning("OTA history rejected without active pull session from {Ip}", ip ?? "unknown");
return BadRequest("no active pull session");
}
var day = DateTime.Now.ToString("yyyy-MM-dd");
var dir = Path.Combine(_store.HistoryDir, day, SafeFileName(ip, "unknown"));
var dir = Path.Combine(_store.HistoryDir, day, SafeFileName(ip ?? "unknown", "unknown"));
Directory.CreateDirectory(dir);
var file = await ReadFirstFileAsync(ct);
if (file == null || file.Length == 0) return BadRequest("empty");
@@ -58,6 +61,7 @@ public class OtaReceiveController : ControllerBase
var path = Path.Combine(dir, safeName);
await using var fs = System.IO.File.Create(path);
await file.CopyToAsync(fs, ct);
_store.NotePullReceive(ip);
_log.LogInformation("OTA history receive {Route} -> {Path} ({Len})", routeKey, path, file.Length);
return Ok(new { ok = true });
}
@@ -66,7 +70,7 @@ public class OtaReceiveController : ControllerBase
{
try
{
var clientIp = HttpContext.Connection.RemoteIpAddress?.ToString();
var clientIp = ResolveTcpRemoteIp();
if (!_store.TryGetActivePullId(clientIp, out _))
{
_log.LogWarning("OTA mdcs rejected without active pull session from {Ip}", clientIp ?? "unknown");
@@ -80,8 +84,9 @@ public class OtaReceiveController : ControllerBase
Directory.CreateDirectory(Path.GetDirectoryName(dest)!);
await using (var fs = System.IO.File.Create(dest))
await file.CopyToAsync(fs, ct);
_store.NotePullReceive(clientIp);
_log.LogInformation("OTA mdcs receive {Route} -> {Dest} ({Len})", routeKey, dest, file.Length);
return Ok(new { ok = true, path = dest });
return Ok(new { ok = true });
}
catch (Exception ex)
{
@@ -90,6 +95,14 @@ public class OtaReceiveController : ControllerBase
}
}
/// <summary>优先取 ForwardedHeaders 之前写入的 TCP 对端 IP,避免 X-Forwarded-For 投毒。</summary>
private string? ResolveTcpRemoteIp()
{
if (HttpContext.Items.TryGetValue(TcpRemoteIpItemKey, out var boxed) && boxed is string s && !string.IsNullOrWhiteSpace(s))
return s;
return HttpContext.Connection.RemoteIpAddress?.ToString();
}
private async Task<IFormFile?> ReadFirstFileAsync(CancellationToken ct)
{
if (!Request.HasFormContentType) return null;
@@ -33,6 +33,8 @@ public class RbacController : ControllerBase
new("ops.task.cancel", "任务 · 取消"),
new("ops.task.reassign", "任务 · 改派"),
new("ops.task.boostPriority", "任务 · 提升优先级"),
new("ops.ota", "OTA · 运维读写"),
new("ops.ota.write", "OTA · 写操作"),
new("monitor.note.write", "监控 · 写运营备注"),
new("auth.manage", "系统 · 权限与角色管理"),
};
+24 -17
View File
@@ -168,29 +168,36 @@ public sealed class AlarmCollector
var activeByCar = new Dictionary<int, VehicleAlarmRecord>();
foreach (var a in active) activeByCar[a.CarId] = a; // 每车取一条 active
// 出现 / 更新
// 出现 / 更新:文案或级别变化时先 clear 旧记录再开新 active,保留分段历史
foreach (var cur in current.Values)
{
if (activeByCar.TryGetValue(cur.CarId, out var rec))
{
rec.Info = cur.Info;
rec.Level = cur.Level;
rec.CarName = cur.CarName;
rec.LastAt = now;
}
else
{
db.VehicleAlarms.Add(new VehicleAlarmRecord
var same =
string.Equals(rec.Info, cur.Info, StringComparison.Ordinal) &&
rec.Level == cur.Level;
if (same)
{
CarId = cur.CarId,
CarName = cur.CarName,
Info = cur.Info,
Level = cur.Level,
Status = "active",
FirstAt = now,
LastAt = now
});
rec.CarName = cur.CarName;
rec.LastAt = now;
continue;
}
rec.Status = "cleared";
rec.ResolvedAt = now;
rec.DurationSecs = (long)Math.Max(0, (now - rec.FirstAt).TotalSeconds);
}
db.VehicleAlarms.Add(new VehicleAlarmRecord
{
CarId = cur.CarId,
CarName = cur.CarName,
Info = cur.Info,
Level = cur.Level,
Status = "active",
FirstAt = now,
LastAt = now
});
}
// 消失 → 恢复
-33
View File
@@ -1,33 +0,0 @@
namespace MiGu.Server.Fleet;
/// <summary>
/// CDM 搬运任务的平台侧快照(表 cdm_tasks)。
/// 以任务 Id 为主键;SimpleLite/StandardScene 把终态任务从自身 JSON 里删除,这里则永久保留=完整历史,
/// 且 SimpleLite 关闭后平台仍可从本表读取最近快照。
/// </summary>
public sealed class CdmTaskRecord
{
public string Id { get; set; } = "";
public string? TaskId { get; set; }
public int MissionId { get; set; }
public string MissionName { get; set; } = "";
public string MissionTypeName { get; set; } = "";
public int SrcSiteId { get; set; }
public string SrcLabel { get; set; } = "";
public int DstSiteId { get; set; }
public string DstLabel { get; set; } = "";
public string Status { get; set; } = "";
public string StatusCode { get; set; } = "";
public int? CarId { get; set; }
public string? CarName { get; set; }
public int Priority { get; set; }
/// <summary>下发/开始/结束时间:直接存投影返回的 ISO 字符串(可空)。</summary>
public string? CreateTime { get; set; }
public string? StartTime { get; set; }
public string? FinishTime { get; set; }
public string? StuckReason { get; set; }
public bool Overdue { get; set; }
/// <summary>平台首次/最近一次同步到该任务的时间。</summary>
public DateTimeOffset FirstSeenAt { get; set; }
public DateTimeOffset LastSeenAt { get; set; }
}
-29
View File
@@ -1,29 +0,0 @@
namespace MiGu.Server.Fleet;
/// <summary>
/// 车辆报警的平台侧记录(表 vehicle_alarms)。
/// SimpleLite 只在 SSE/状态里给出「当前是否报警 + 文案」,无历史;平台按车对帐:
/// 出现报警→开一条 active 记录,报警文案变化→更新,报警消失→置为 cleared 并记录恢复时间/持续时长。
/// 永不删除=完整历史,重启/刷新不丢,SimpleLite 离线也可查。
/// </summary>
public sealed class VehicleAlarmRecord
{
public string Id { get; set; } = Guid.NewGuid().ToString("D");
public int CarId { get; set; }
public string CarName { get; set; } = "";
/// <summary>报警文案(车体_AlarmInfo)。</summary>
public string Info { get; set; } = "";
/// <summary>报警级别(车体_AlarmLevel,未知为 0)。</summary>
public int Level { get; set; }
/// <summary>active | cleared</summary>
public string Status { get; set; } = "active";
public DateTimeOffset FirstAt { get; set; }
public DateTimeOffset LastAt { get; set; }
public DateTimeOffset? ResolvedAt { get; set; }
/// <summary>持续时长(秒),恢复后写入。</summary>
public long? DurationSecs { get; set; }
/// <summary>预留:平台侧确认(不代表车端消警)。</summary>
public bool Acknowledged { get; set; }
public DateTimeOffset? AcknowledgedAt { get; set; }
public string? AcknowledgedBy { get; set; }
}
+2
View File
@@ -17,3 +17,5 @@ global using WmsTransportRule = MiGu.DB.Domains.Transport.WmsTransportRule;
global using WmsTransportTask = MiGu.DB.Domains.Transport.WmsTransportTask;
global using WmsTransportReservation = MiGu.DB.Domains.Transport.WmsTransportReservation;
global using WmsTransportTaskHistory = MiGu.DB.Domains.Transport.WmsTransportTaskHistory;
global using CdmTaskRecord = MiGu.DB.Domains.Fleet.CdmTaskRecord;
global using VehicleAlarmRecord = MiGu.DB.Domains.Fleet.VehicleAlarmRecord;
+29
View File
@@ -16,6 +16,35 @@ public sealed class OtaJobRunner
_wd = wd;
_vehicles = vehicles;
_log = log;
RecoverInterruptedJobs();
}
/// <summary>进程重启后把落盘中非终态 job 标为 failed,避免 UI 永久显示 running。</summary>
private void RecoverInterruptedJobs()
{
try
{
foreach (var job in _store.ListJobs(500))
{
if (job.Status is not ("running" or "pending")) continue;
job.Status = "failed";
job.Message = string.IsNullOrWhiteSpace(job.Message)
? "进程重启,任务中断"
: job.Message;
job.FinishedAt = DateTimeOffset.UtcNow;
foreach (var step in job.Steps.Where(s => s.Status is "pending" or "running"))
{
step.Status = "failed";
step.Error ??= "进程重启,任务中断";
}
_store.SaveJob(job);
_log.LogWarning("OTA job {Id} marked failed after process restart", job.Id);
}
}
catch (Exception ex)
{
_log.LogWarning(ex, "OTA interrupted job recovery failed");
}
}
public OtaJob EnqueueSync(CreateSyncJobRequest req, string? user)
+3 -1
View File
@@ -6,9 +6,11 @@ public sealed class OtaSettings
public int MaxCar { get; set; } = 2;
public bool LatencyEnabled { get; set; }
public int RttThresholdMs { get; set; } = 200;
/// <summary>skip | confirm</summary>
/// <summary>skip | allow(历史值 confirm 视为 allow</summary>
public string OverThreshold { get; set; } = "skip";
/// <summary>保留字段:备份尚未实现,仅反序列化兼容。</summary>
public int BackupPeriodMinutes { get; set; } = 60;
/// <summary>保留字段:备份尚未实现,仅反序列化兼容。</summary>
public bool BackupExe { get; set; }
public string? NewVersionName { get; set; }
}
+24 -7
View File
@@ -24,6 +24,8 @@ public sealed class OtaStore
private string? _lastPullId;
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, string> _pullByIp =
new(StringComparer.OrdinalIgnoreCase);
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, DateTime> _pullLastReceiveUtc =
new(StringComparer.OrdinalIgnoreCase);
private long _jobSeq;
public OtaStore(IWebHostEnvironment env, IOptions<OtaOptions> options, ILogger<OtaStore> log)
@@ -144,29 +146,44 @@ public sealed class OtaStore
lock (_gate)
{
var ip = NormalizeIp(sourceIp);
// 必须匹配会话 IP;禁止无 IP 时回退到最近一次拉包(可被伪造/误写)。
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 NotePullReceive(string? sourceIp)
{
var ip = NormalizeIp(sourceIp);
if (ip == null) return;
_pullLastReceiveUtc[ip] = DateTime.UtcNow;
}
public bool TryGetLastReceiveAt(string? sourceIp, out DateTime utc)
{
var ip = NormalizeIp(sourceIp);
if (ip != null && _pullLastReceiveUtc.TryGetValue(ip, out utc))
return true;
utc = default;
return false;
}
public void ClearActivePull(string? sourceIp = null)
{
lock (_gate)
{
var ip = NormalizeIp(sourceIp);
if (ip != null) _pullByIp.TryRemove(ip, out _);
if (ip != null)
{
_pullByIp.TryRemove(ip, out _);
_pullLastReceiveUtc.TryRemove(ip, out _);
}
if (_pullByIp.IsEmpty) _lastPullId = null;
}
}
+8
View File
@@ -316,6 +316,14 @@ if (app.Environment.IsDevelopment())
app.UseSwaggerUI();
}
// OTA 回传会话按 TCP 对端 IP 校验;必须在 ForwardedHeaders 改写 RemoteIpAddress 之前捕获。
app.Use(async (ctx, next) =>
{
ctx.Items[MiGu.Server.Controllers.OtaReceiveController.TcpRemoteIpItemKey] =
ctx.Connection.RemoteIpAddress?.ToString();
await next();
});
// M6:在反向代理 / 负载均衡后运行时,根据 X-Forwarded-Proto 还原真实 scheme,让
// Request.IsHttps 正确 → 登录 Cookie 的 Secure 标志在生产 HTTPS 下能正确置位。
// 默认(未配置 KnownProxies):清空 Known* 表 = 信任所有前置转发头,适合「反代与本服务同机 /