新增车辆表及OTA权限与逻辑优化
新增车辆任务与报警表,完善实体与DbContext配置。细化OTA权限校验,增强回传会话IP安全。优化OTA上传与设置面板,调度器支持重启恢复。报警采集逻辑支持历史分段。
This commit is contained in:
@@ -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", "系统 · 权限与角色管理"),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
// 消失 → 恢复
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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* 表 = 信任所有前置转发头,适合「反代与本服务同机 /
|
||||
|
||||
Reference in New Issue
Block a user