新增车辆表及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
@@ -1,9 +1,8 @@
namespace MiGu.Server.Fleet; namespace MiGu.DB.Domains.Fleet;
/// <summary> /// <summary>
/// CDM 搬运任务的平台侧快照(表 cdm_tasks)。 /// CDM 搬运任务的平台侧快照(表 cdm_tasks)。
/// 以任务 Id 为主键;SimpleLite/StandardScene 把终态任务从自身 JSON 里删除,这里则永久保留=完整历史 /// 以任务 Id 为主键;SimpleLite/StandardScene 把终态任务从自身 JSON 里删除,这里则永久保留=完整历史
/// 且 SimpleLite 关闭后平台仍可从本表读取最近快照。
/// </summary> /// </summary>
public sealed class CdmTaskRecord public sealed class CdmTaskRecord
{ {
@@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MiGu.DB.Domains.Fleet;
public sealed class CdmTaskRecordConfiguration : IEntityTypeConfiguration<CdmTaskRecord>
{
public void Configure(EntityTypeBuilder<CdmTaskRecord> e)
{
e.ToTable("cdm_tasks");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id").HasMaxLength(64);
e.Property(x => x.TaskId).HasColumnName("task_id").HasMaxLength(128).IsRequired(false);
e.Property(x => x.MissionId).HasColumnName("mission_id");
e.Property(x => x.MissionName).HasColumnName("mission_name").HasMaxLength(128);
e.Property(x => x.MissionTypeName).HasColumnName("mission_type").HasMaxLength(128);
e.Property(x => x.SrcSiteId).HasColumnName("src_site_id");
e.Property(x => x.SrcLabel).HasColumnName("src_label").HasMaxLength(256);
e.Property(x => x.DstSiteId).HasColumnName("dst_site_id");
e.Property(x => x.DstLabel).HasColumnName("dst_label").HasMaxLength(256);
e.Property(x => x.Status).HasColumnName("status").HasMaxLength(32);
e.Property(x => x.StatusCode).HasColumnName("status_code").HasMaxLength(32);
e.Property(x => x.CarId).HasColumnName("car_id").IsRequired(false);
e.Property(x => x.CarName).HasColumnName("car_name").HasMaxLength(128).IsRequired(false);
e.Property(x => x.Priority).HasColumnName("priority");
e.Property(x => x.CreateTime).HasColumnName("create_time").HasMaxLength(40).IsRequired(false);
e.Property(x => x.StartTime).HasColumnName("start_time").HasMaxLength(40).IsRequired(false);
e.Property(x => x.FinishTime).HasColumnName("finish_time").HasMaxLength(40).IsRequired(false);
e.Property(x => x.StuckReason).HasColumnName("stuck_reason").HasMaxLength(512).IsRequired(false);
e.Property(x => x.Overdue).HasColumnName("overdue");
e.Property(x => x.FirstSeenAt).HasColumnName("first_seen_at");
e.Property(x => x.LastSeenAt).HasColumnName("last_seen_at");
e.HasIndex(x => x.StatusCode);
e.HasIndex(x => x.CreateTime);
}
}
public sealed class VehicleAlarmRecordConfiguration : IEntityTypeConfiguration<VehicleAlarmRecord>
{
public void Configure(EntityTypeBuilder<VehicleAlarmRecord> e)
{
e.ToTable("vehicle_alarms");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id").HasMaxLength(36);
e.Property(x => x.CarId).HasColumnName("car_id");
e.Property(x => x.CarName).HasColumnName("car_name").HasMaxLength(128);
e.Property(x => x.Info).HasColumnName("info").HasColumnType("text");
e.Property(x => x.Level).HasColumnName("level");
e.Property(x => x.Status).HasColumnName("status").HasMaxLength(16);
e.Property(x => x.FirstAt).HasColumnName("first_at");
e.Property(x => x.LastAt).HasColumnName("last_at");
e.Property(x => x.ResolvedAt).HasColumnName("resolved_at").IsRequired(false);
e.Property(x => x.DurationSecs).HasColumnName("duration_secs").IsRequired(false);
e.Property(x => x.Acknowledged).HasColumnName("acknowledged");
e.Property(x => x.AcknowledgedAt).HasColumnName("acknowledged_at").IsRequired(false);
e.Property(x => x.AcknowledgedBy).HasColumnName("acknowledged_by").HasMaxLength(128).IsRequired(false);
e.HasIndex(x => new { x.CarId, x.Status });
e.HasIndex(x => x.Status);
e.HasIndex(x => x.FirstAt);
}
}
@@ -1,10 +1,8 @@
namespace MiGu.Server.Fleet; namespace MiGu.DB.Domains.Fleet;
/// <summary> /// <summary>
/// 车辆报警的平台侧记录(表 vehicle_alarms)。 /// 车辆报警的平台侧记录(表 vehicle_alarms)。
/// SimpleLite 只在 SSE/状态里给出「当前是否报警 + 文案」,无历史;平台按车对帐: /// 出现报警→开一条 active 记录;文案/级别变化→先 clear 再建新 active;消失→cleared。
/// 出现报警→开一条 active 记录,报警文案变化→更新,报警消失→置为 cleared 并记录恢复时间/持续时长。
/// 永不删除=完整历史,重启/刷新不丢,SimpleLite 离线也可查。
/// </summary> /// </summary>
public sealed class VehicleAlarmRecord public sealed class VehicleAlarmRecord
{ {
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MiGu.DB.Domains.Dashboard; using MiGu.DB.Domains.Dashboard;
using MiGu.DB.Domains.Fleet;
using MiGu.DB.Domains.SimpleFields; using MiGu.DB.Domains.SimpleFields;
using MiGu.DB.Domains.Transport; using MiGu.DB.Domains.Transport;
using MiGu.DB.Domains.Wms; using MiGu.DB.Domains.Wms;
@@ -25,4 +26,6 @@ public partial class MiGuDbContext
public DbSet<WmsTransportTaskHistory> WmsTransportTaskHistories => Set<WmsTransportTaskHistory>(); public DbSet<WmsTransportTaskHistory> WmsTransportTaskHistories => Set<WmsTransportTaskHistory>();
public DbSet<SimpleField> SimpleFields => Set<SimpleField>(); public DbSet<SimpleField> SimpleFields => Set<SimpleField>();
public DbSet<UserDashboardShortcut> UserDashboardShortcuts => Set<UserDashboardShortcut>(); public DbSet<UserDashboardShortcut> UserDashboardShortcuts => Set<UserDashboardShortcut>();
public DbSet<CdmTaskRecord> CdmTasks => Set<CdmTaskRecord>();
public DbSet<VehicleAlarmRecord> VehicleAlarms => Set<VehicleAlarmRecord>();
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,103 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MiGu.DB.Migrations.Sqlite
{
/// <inheritdoc />
public partial class AddFleetTables : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "cdm_tasks",
columns: table => new
{
id = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
task_id = table.Column<string>(type: "TEXT", maxLength: 128, nullable: true),
mission_id = table.Column<int>(type: "INTEGER", nullable: false),
mission_name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
mission_type = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
src_site_id = table.Column<int>(type: "INTEGER", nullable: false),
src_label = table.Column<string>(type: "TEXT", maxLength: 256, nullable: false),
dst_site_id = table.Column<int>(type: "INTEGER", nullable: false),
dst_label = table.Column<string>(type: "TEXT", maxLength: 256, nullable: false),
status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
status_code = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
car_id = table.Column<int>(type: "INTEGER", nullable: true),
car_name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: true),
priority = table.Column<int>(type: "INTEGER", nullable: false),
create_time = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
start_time = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
finish_time = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
stuck_reason = table.Column<string>(type: "TEXT", maxLength: 512, nullable: true),
overdue = table.Column<bool>(type: "INTEGER", nullable: false),
first_seen_at = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
last_seen_at = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_cdm_tasks", x => x.id);
});
migrationBuilder.CreateTable(
name: "vehicle_alarms",
columns: table => new
{
id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
car_id = table.Column<int>(type: "INTEGER", nullable: false),
car_name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
info = table.Column<string>(type: "text", nullable: false),
level = table.Column<int>(type: "INTEGER", nullable: false),
status = table.Column<string>(type: "TEXT", maxLength: 16, nullable: false),
first_at = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
last_at = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
resolved_at = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
duration_secs = table.Column<long>(type: "INTEGER", nullable: true),
acknowledged = table.Column<bool>(type: "INTEGER", nullable: false),
acknowledged_at = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
acknowledged_by = table.Column<string>(type: "TEXT", maxLength: 128, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_vehicle_alarms", x => x.id);
});
migrationBuilder.CreateIndex(
name: "IX_cdm_tasks_create_time",
table: "cdm_tasks",
column: "create_time");
migrationBuilder.CreateIndex(
name: "IX_cdm_tasks_status_code",
table: "cdm_tasks",
column: "status_code");
migrationBuilder.CreateIndex(
name: "IX_vehicle_alarms_car_id_status",
table: "vehicle_alarms",
columns: new[] { "car_id", "status" });
migrationBuilder.CreateIndex(
name: "IX_vehicle_alarms_first_at",
table: "vehicle_alarms",
column: "first_at");
migrationBuilder.CreateIndex(
name: "IX_vehicle_alarms_status",
table: "vehicle_alarms",
column: "status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "cdm_tasks");
migrationBuilder.DropTable(
name: "vehicle_alarms");
}
}
}
@@ -45,6 +45,202 @@ namespace MiGu.DB.Migrations.Sqlite
b.ToTable("user_dashboard_shortcuts", (string)null); b.ToTable("user_dashboard_shortcuts", (string)null);
}); });
modelBuilder.Entity("MiGu.DB.Domains.Fleet.CdmTaskRecord", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<int?>("CarId")
.HasColumnType("INTEGER")
.HasColumnName("car_id");
b.Property<string>("CarName")
.HasMaxLength(128)
.HasColumnType("TEXT")
.HasColumnName("car_name");
b.Property<string>("CreateTime")
.HasMaxLength(40)
.HasColumnType("TEXT")
.HasColumnName("create_time");
b.Property<string>("DstLabel")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT")
.HasColumnName("dst_label");
b.Property<int>("DstSiteId")
.HasColumnType("INTEGER")
.HasColumnName("dst_site_id");
b.Property<string>("FinishTime")
.HasMaxLength(40)
.HasColumnType("TEXT")
.HasColumnName("finish_time");
b.Property<string>("FirstSeenAt")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("TEXT")
.HasColumnName("first_seen_at");
b.Property<string>("LastSeenAt")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("TEXT")
.HasColumnName("last_seen_at");
b.Property<int>("MissionId")
.HasColumnType("INTEGER")
.HasColumnName("mission_id");
b.Property<string>("MissionName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.HasColumnName("mission_name");
b.Property<string>("MissionTypeName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.HasColumnName("mission_type");
b.Property<bool>("Overdue")
.HasColumnType("INTEGER")
.HasColumnName("overdue");
b.Property<int>("Priority")
.HasColumnType("INTEGER")
.HasColumnName("priority");
b.Property<string>("SrcLabel")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT")
.HasColumnName("src_label");
b.Property<int>("SrcSiteId")
.HasColumnType("INTEGER")
.HasColumnName("src_site_id");
b.Property<string>("StartTime")
.HasMaxLength(40)
.HasColumnType("TEXT")
.HasColumnName("start_time");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("StatusCode")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT")
.HasColumnName("status_code");
b.Property<string>("StuckReason")
.HasMaxLength(512)
.HasColumnType("TEXT")
.HasColumnName("stuck_reason");
b.Property<string>("TaskId")
.HasMaxLength(128)
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("CreateTime");
b.HasIndex("StatusCode");
b.ToTable("cdm_tasks", (string)null);
});
modelBuilder.Entity("MiGu.DB.Domains.Fleet.VehicleAlarmRecord", b =>
{
b.Property<string>("Id")
.HasMaxLength(36)
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Acknowledged")
.HasColumnType("INTEGER")
.HasColumnName("acknowledged");
b.Property<string>("AcknowledgedAt")
.HasMaxLength(40)
.HasColumnType("TEXT")
.HasColumnName("acknowledged_at");
b.Property<string>("AcknowledgedBy")
.HasMaxLength(128)
.HasColumnType("TEXT")
.HasColumnName("acknowledged_by");
b.Property<int>("CarId")
.HasColumnType("INTEGER")
.HasColumnName("car_id");
b.Property<string>("CarName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.HasColumnName("car_name");
b.Property<long?>("DurationSecs")
.HasColumnType("INTEGER")
.HasColumnName("duration_secs");
b.Property<string>("FirstAt")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("TEXT")
.HasColumnName("first_at");
b.Property<string>("Info")
.IsRequired()
.HasColumnType("text")
.HasColumnName("info");
b.Property<string>("LastAt")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("TEXT")
.HasColumnName("last_at");
b.Property<int>("Level")
.HasColumnType("INTEGER")
.HasColumnName("level");
b.Property<string>("ResolvedAt")
.HasMaxLength(40)
.HasColumnType("TEXT")
.HasColumnName("resolved_at");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT")
.HasColumnName("status");
b.HasKey("Id");
b.HasIndex("FirstAt");
b.HasIndex("Status");
b.HasIndex("CarId", "Status");
b.ToTable("vehicle_alarms", (string)null);
});
modelBuilder.Entity("MiGu.DB.Domains.SimpleFields.SimpleField", b => modelBuilder.Entity("MiGu.DB.Domains.SimpleFields.SimpleField", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
+27 -4
View File
@@ -48,7 +48,10 @@ public class OtaController : ControllerBase
if (string.Equals(Scope, "Platform", StringComparison.OrdinalIgnoreCase)) return true; if (string.Equals(Scope, "Platform", StringComparison.OrdinalIgnoreCase)) return true;
var ops = User.FindFirst("ops")?.Value ?? ""; var ops = User.FindFirst("ops")?.Value ?? "";
var set = ops.Split(' ', StringSplitOptions.RemoveEmptyEntries); 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) private bool DenyWrite(out ActionResult denied)
@@ -147,12 +150,32 @@ public class OtaController : ControllerBase
var receiveBase = $"{baseUrl.TrimEnd('/')}/api/ota/receive"; var receiveBase = $"{baseUrl.TrimEnd('/')}/api/ota/receive";
var time = DateTime.Now.ToString("yyyyMMddHHmmss"); var time = DateTime.Now.ToString("yyyyMMddHHmmss");
await _wd.TriggerPullAsync(car.Ip, receiveBase, time, ct); await _wd.TriggerPullAsync(car.Ip, receiveBase, time, ct);
// 等待文件落盘 // 等待文件落盘:绝对超时 + 收到文件后的空闲窗口,避免首个组件到达就清会话导致半包
await Task.Delay(1500, 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); 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); await Task.Delay(500, ct);
} }
_store.ClearActivePull(car.Ip); _store.ClearActivePull(car.Ip);
@@ -8,11 +8,14 @@ namespace MiGu.Server.Controllers;
/// WatchDog 回传包接收端。 /// WatchDog 回传包接收端。
/// WatchDog 写死 POST 到 http://{config.serverIP}:8000/upload-mdcs/{routeKey} /// WatchDog 写死 POST 到 http://{config.serverIP}:8000/upload-mdcs/{routeKey}
/// 必须与参考 Electron Express :8000 路径一致;/api/ota/receive/* 仅作兼容别名。 /// 必须与参考 Electron Express :8000 路径一致;/api/ota/receive/* 仅作兼容别名。
/// 会话校验使用 TCP 对端 IP(见 Program 中 TcpRemoteIp),忽略可伪造的 X-Forwarded-For。
/// </summary> /// </summary>
[ApiController] [ApiController]
[AllowAnonymous] [AllowAnonymous]
public class OtaReceiveController : ControllerBase public class OtaReceiveController : ControllerBase
{ {
public const string TcpRemoteIpItemKey = "TcpRemoteIp";
private readonly OtaStore _store; private readonly OtaStore _store;
private readonly ILogger<OtaReceiveController> _log; private readonly ILogger<OtaReceiveController> _log;
@@ -40,15 +43,15 @@ public class OtaReceiveController : ControllerBase
[RequestSizeLimit(512_000_000)] [RequestSizeLimit(512_000_000)]
public async Task<IActionResult> UploadHistory(string routeKey, CancellationToken ct) public async Task<IActionResult> UploadHistory(string routeKey, CancellationToken ct)
{ {
var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; var ip = ResolveTcpRemoteIp();
if (!_store.TryGetActivePullId(ip, out _)) 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"); return BadRequest("no active pull session");
} }
var day = DateTime.Now.ToString("yyyy-MM-dd"); 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); Directory.CreateDirectory(dir);
var file = await ReadFirstFileAsync(ct); var file = await ReadFirstFileAsync(ct);
if (file == null || file.Length == 0) return BadRequest("empty"); if (file == null || file.Length == 0) return BadRequest("empty");
@@ -58,6 +61,7 @@ public class OtaReceiveController : ControllerBase
var path = Path.Combine(dir, safeName); var path = Path.Combine(dir, safeName);
await using var fs = System.IO.File.Create(path); await using var fs = System.IO.File.Create(path);
await file.CopyToAsync(fs, ct); await file.CopyToAsync(fs, ct);
_store.NotePullReceive(ip);
_log.LogInformation("OTA history receive {Route} -> {Path} ({Len})", routeKey, path, file.Length); _log.LogInformation("OTA history receive {Route} -> {Path} ({Len})", routeKey, path, file.Length);
return Ok(new { ok = true }); return Ok(new { ok = true });
} }
@@ -66,7 +70,7 @@ public class OtaReceiveController : ControllerBase
{ {
try try
{ {
var clientIp = HttpContext.Connection.RemoteIpAddress?.ToString(); var clientIp = ResolveTcpRemoteIp();
if (!_store.TryGetActivePullId(clientIp, out _)) if (!_store.TryGetActivePullId(clientIp, out _))
{ {
_log.LogWarning("OTA mdcs rejected without active pull session from {Ip}", clientIp ?? "unknown"); _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)!); Directory.CreateDirectory(Path.GetDirectoryName(dest)!);
await using (var fs = System.IO.File.Create(dest)) await using (var fs = System.IO.File.Create(dest))
await file.CopyToAsync(fs, ct); await file.CopyToAsync(fs, ct);
_store.NotePullReceive(clientIp);
_log.LogInformation("OTA mdcs receive {Route} -> {Dest} ({Len})", routeKey, dest, file.Length); _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) 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) private async Task<IFormFile?> ReadFirstFileAsync(CancellationToken ct)
{ {
if (!Request.HasFormContentType) return null; if (!Request.HasFormContentType) return null;
@@ -33,6 +33,8 @@ public class RbacController : ControllerBase
new("ops.task.cancel", "任务 · 取消"), new("ops.task.cancel", "任务 · 取消"),
new("ops.task.reassign", "任务 · 改派"), new("ops.task.reassign", "任务 · 改派"),
new("ops.task.boostPriority", "任务 · 提升优先级"), new("ops.task.boostPriority", "任务 · 提升优先级"),
new("ops.ota", "OTA · 运维读写"),
new("ops.ota.write", "OTA · 写操作"),
new("monitor.note.write", "监控 · 写运营备注"), new("monitor.note.write", "监控 · 写运营备注"),
new("auth.manage", "系统 · 权限与角色管理"), new("auth.manage", "系统 · 权限与角色管理"),
}; };
+24 -17
View File
@@ -168,29 +168,36 @@ public sealed class AlarmCollector
var activeByCar = new Dictionary<int, VehicleAlarmRecord>(); var activeByCar = new Dictionary<int, VehicleAlarmRecord>();
foreach (var a in active) activeByCar[a.CarId] = a; // 每车取一条 active foreach (var a in active) activeByCar[a.CarId] = a; // 每车取一条 active
// 出现 / 更新 // 出现 / 更新:文案或级别变化时先 clear 旧记录再开新 active,保留分段历史
foreach (var cur in current.Values) foreach (var cur in current.Values)
{ {
if (activeByCar.TryGetValue(cur.CarId, out var rec)) if (activeByCar.TryGetValue(cur.CarId, out var rec))
{ {
rec.Info = cur.Info; var same =
rec.Level = cur.Level; string.Equals(rec.Info, cur.Info, StringComparison.Ordinal) &&
rec.CarName = cur.CarName; rec.Level == cur.Level;
rec.LastAt = now; if (same)
}
else
{
db.VehicleAlarms.Add(new VehicleAlarmRecord
{ {
CarId = cur.CarId, rec.CarName = cur.CarName;
CarName = cur.CarName, rec.LastAt = now;
Info = cur.Info, continue;
Level = cur.Level, }
Status = "active",
FirstAt = now, rec.Status = "cleared";
LastAt = now 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
});
} }
// 消失 → 恢复 // 消失 → 恢复
+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 WmsTransportTask = MiGu.DB.Domains.Transport.WmsTransportTask;
global using WmsTransportReservation = MiGu.DB.Domains.Transport.WmsTransportReservation; global using WmsTransportReservation = MiGu.DB.Domains.Transport.WmsTransportReservation;
global using WmsTransportTaskHistory = MiGu.DB.Domains.Transport.WmsTransportTaskHistory; 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; _wd = wd;
_vehicles = vehicles; _vehicles = vehicles;
_log = log; _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) 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 int MaxCar { get; set; } = 2;
public bool LatencyEnabled { get; set; } public bool LatencyEnabled { get; set; }
public int RttThresholdMs { get; set; } = 200; public int RttThresholdMs { get; set; } = 200;
/// <summary>skip | confirm</summary> /// <summary>skip | allow(历史值 confirm 视为 allow</summary>
public string OverThreshold { get; set; } = "skip"; public string OverThreshold { get; set; } = "skip";
/// <summary>保留字段:备份尚未实现,仅反序列化兼容。</summary>
public int BackupPeriodMinutes { get; set; } = 60; public int BackupPeriodMinutes { get; set; } = 60;
/// <summary>保留字段:备份尚未实现,仅反序列化兼容。</summary>
public bool BackupExe { get; set; } public bool BackupExe { get; set; }
public string? NewVersionName { get; set; } public string? NewVersionName { get; set; }
} }
+24 -7
View File
@@ -24,6 +24,8 @@ public sealed class OtaStore
private string? _lastPullId; private string? _lastPullId;
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, string> _pullByIp = private readonly System.Collections.Concurrent.ConcurrentDictionary<string, string> _pullByIp =
new(StringComparer.OrdinalIgnoreCase); new(StringComparer.OrdinalIgnoreCase);
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, DateTime> _pullLastReceiveUtc =
new(StringComparer.OrdinalIgnoreCase);
private long _jobSeq; private long _jobSeq;
public OtaStore(IWebHostEnvironment env, IOptions<OtaOptions> options, ILogger<OtaStore> log) public OtaStore(IWebHostEnvironment env, IOptions<OtaOptions> options, ILogger<OtaStore> log)
@@ -144,29 +146,44 @@ public sealed class OtaStore
lock (_gate) lock (_gate)
{ {
var ip = NormalizeIp(sourceIp); var ip = NormalizeIp(sourceIp);
// 必须匹配会话 IP;禁止无 IP 时回退到最近一次拉包(可被伪造/误写)。
if (ip != null && _pullByIp.TryGetValue(ip, out var byIp)) if (ip != null && _pullByIp.TryGetValue(ip, out var byIp))
{ {
id = byIp; id = byIp;
return true; return true;
} }
if (ip == null && _lastPullId != null)
{
id = _lastPullId;
return true;
}
id = null; id = null;
return false; 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) public void ClearActivePull(string? sourceIp = null)
{ {
lock (_gate) lock (_gate)
{ {
var ip = NormalizeIp(sourceIp); 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; if (_pullByIp.IsEmpty) _lastPullId = null;
} }
} }
+8
View File
@@ -316,6 +316,14 @@ if (app.Environment.IsDevelopment())
app.UseSwaggerUI(); 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,让 // M6:在反向代理 / 负载均衡后运行时,根据 X-Forwarded-Proto 还原真实 scheme,让
// Request.IsHttps 正确 → 登录 Cookie 的 Secure 标志在生产 HTTPS 下能正确置位。 // Request.IsHttps 正确 → 登录 Cookie 的 Secure 标志在生产 HTTPS 下能正确置位。
// 默认(未配置 KnownProxies):清空 Known* 表 = 信任所有前置转发头,适合「反代与本服务同机 / // 默认(未配置 KnownProxies):清空 Known* 表 = 信任所有前置转发头,适合「反代与本服务同机 /
@@ -13,6 +13,10 @@ const http: AxiosInstance = axios.create({
}) })
http.interceptors.request.use((config: InternalAxiosRequestConfig) => { http.interceptors.request.use((config: InternalAxiosRequestConfig) => {
// FormData 必须由浏览器自动带 multipart boundary;清掉默认 application/json。
if (typeof FormData !== 'undefined' && config.data instanceof FormData) {
config.headers.delete('Content-Type')
}
// 双轨:优先用 localStorage 里的 token(过渡期 fallback),Cookie 会自动带; // 双轨:优先用 localStorage 里的 token(过渡期 fallback),Cookie 会自动带;
// 后端首先看 Authorization: Bearer,没有再看 Cookie,两路任一通过即可。 // 后端首先看 Authorization: Bearer,没有再看 Cookie,两路任一通过即可。
const token = localStorage.getItem('simple.auth.token') const token = localStorage.getItem('simple.auth.token')
@@ -47,7 +47,6 @@ export async function uploadOtaPackage(file: File): Promise<OtaPackageInfo> {
const form = new FormData() const form = new FormData()
form.append('file', file) form.append('file', file)
const { data } = await http.post<OtaPackageInfo>('/ota/packages/upload', form, { const { data } = await http.post<OtaPackageInfo>('/ota/packages/upload', form, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 300000 timeout: 300000
}) })
return data return data
@@ -116,7 +115,6 @@ export async function pushOtaCustomFile(opts: {
form.append('restartOps', JSON.stringify(opts.restartOps.length ? opts.restartOps : [-1])) form.append('restartOps', JSON.stringify(opts.restartOps.length ? opts.restartOps : [-1]))
for (const f of opts.files) form.append('files', f) for (const f of opts.files) form.append('files', f)
const { data } = await http.post<OtaJob>('/ota/custom-file', form, { const { data } = await http.post<OtaJob>('/ota/custom-file', form, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 300000 timeout: 300000
}) })
return data return data
@@ -148,7 +148,7 @@ const auth = useAuthStore()
const canWrite = computed(() => { const canWrite = computed(() => {
if (auth.scope === 'Platform') return true if (auth.scope === 'Platform') return true
const ops = auth.effectivePermissions?.allowedOps ?? [] const ops = auth.effectivePermissions?.allowedOps ?? []
return ops.includes('*') || ops.some((o) => o === 'ops.ota' || o.startsWith('ops.ota.')) return ops.includes('*') || ops.some((o) => o === 'ops.ota' || o === 'ops.ota.write' || o.startsWith('ops.ota.'))
}) })
// Tab URL ?tab= /config/vehicle/config/fleet 线 // Tab URL ?tab= /config/vehicle/config/fleet 线
@@ -21,18 +21,10 @@
<el-form-item label="超限策略"> <el-form-item label="超限策略">
<el-radio-group v-model="form.overThreshold"> <el-radio-group v-model="form.overThreshold">
<el-radio value="skip">跳过该车</el-radio> <el-radio value="skip">跳过该车</el-radio>
<el-radio value="confirm">仍允许需确认</el-radio> <el-radio value="confirm">仍允许下发</el-radio>
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<h4>备份</h4>
<el-form-item label="周期 (分钟)">
<el-input-number v-model="form.backupPeriodMinutes" :min="0" :max="10080" />
</el-form-item>
<el-form-item label="备份可执行文件">
<el-switch v-model="form.backupExe" />
</el-form-item>
<h4>展示</h4> <h4>展示</h4>
<el-form-item label="目标版本名称"> <el-form-item label="目标版本名称">
<el-input v-model="form.newVersionName" placeholder="可选显示名" /> <el-input v-model="form.newVersionName" placeholder="可选显示名" />