Files
Migu2.0/MiGu.Server/Fleet/AlarmCollector.cs
T
wei.wu 125372b157 移除历史状态归一化及相关遗留逻辑
- 移除 LegacyStatusNormalizationMigrator,仅保留 AreaLayoutModeDefaultMigrator
- 删除 WmsDispatchStatus 枚举、常量和全局 using
- 统一所有 DbContext 注入为 MiGuDbContext,移除 PlatformDbContext
- 服务实体操作统一用 IEditableRepository<T>,移除本地实现
- 移除 WmsService 的 MigrateLegacyAsync 方法
- 精简接口模型,移除部分字段和请求体
- 前端保存容器时 status 字段取自已有数据,移除写死默认值
- 更新文档,去除旧说明,统一数据库初始化流程
2026-08-03 09:41:03 +08:00

259 lines
8.9 KiB
C#

using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using MiGu.Server.Auth;
using MiGu.Server.Launcher;
using MiGu.Server.Persistence;
namespace MiGu.Server.Fleet;
/// <summary>
/// 单例:轮询 SimpleLite 车辆 + 每车状态,读取「车体_AlarmInfo/车体_AlarmLevel」并对帐进 platform.db(vehicle_alarms)。
/// 出现→开 active;文案变→更新;消失→置 cleared 并记录恢复时间/时长。永不删=完整历史;SimpleLite 离线仍可查最近记录。
/// </summary>
public sealed class AlarmCollector
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IHttpClientFactory _httpFactory;
private readonly SimpleLiteOptions _sl;
private readonly InternalTokenStore _token;
private readonly ILogger<AlarmCollector> _log;
private readonly SemaphoreSlim _gate = new(1, 1);
private static readonly JsonSerializerOptions JsonOpt = new() { PropertyNameCaseInsensitive = true };
public volatile bool Online;
public DateTimeOffset? LastSyncAt { get; private set; }
public AlarmCollector(
IServiceScopeFactory scopeFactory,
IHttpClientFactory httpFactory,
IOptions<SimpleLiteOptions> sl,
InternalTokenStore token,
ILogger<AlarmCollector> log)
{
_scopeFactory = scopeFactory;
_httpFactory = httpFactory;
_sl = sl.Value;
_token = token;
_log = log;
}
private sealed class CurrentAlarm
{
public int CarId;
public string CarName = "";
public string Info = "";
public int Level;
}
public async Task<bool> SyncOnceAsync(CancellationToken ct)
{
if (!await _gate.WaitAsync(0, ct)) return Online;
try
{
var cars = await FetchCarsAsync(ct);
if (cars == null)
{
Online = false;
return false;
}
var current = new Dictionary<int, CurrentAlarm>();
await Parallel.ForEachAsync(
cars,
new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct },
async (car, token) =>
{
var (info, level) = await FetchCarAlarmAsync(car.Id, token);
if (string.IsNullOrWhiteSpace(info)) return;
lock (current)
{
current[car.Id] = new CurrentAlarm { CarId = car.Id, CarName = car.Name, Info = info, Level = level };
}
});
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<MiGuDbContext>();
await ReconcileAsync(db, current, ct);
}
catch (Exception ex)
{
_log.LogDebug(ex, "alarm reconcile failed");
}
Online = true;
LastSyncAt = DateTimeOffset.UtcNow;
return true;
}
finally
{
_gate.Release();
}
}
private sealed class CarRow
{
public int Id;
public string Name = "";
}
private async Task<List<CarRow>?> FetchCarsAsync(CancellationToken ct)
{
var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222;
try
{
using var client = CreateClient();
using var resp = await client.SendAsync(Req($"http://127.0.0.1:{port}/projection/cars"), ct);
if (!resp.IsSuccessStatusCode) return null;
var text = await resp.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(text);
if (doc.RootElement.ValueKind != JsonValueKind.Array) return new();
var list = new List<CarRow>();
foreach (var el in doc.RootElement.EnumerateArray())
{
var id = el.TryGetProperty("rawId", out var rid) && rid.TryGetInt32(out var n) ? n : 0;
if (id <= 0) continue;
var name = el.TryGetProperty("name", out var nm) ? nm.GetString() ?? "" : "";
list.Add(new CarRow { Id = id, Name = name });
}
return list;
}
catch (Exception ex)
{
_log.LogDebug(ex, "alarm fetch cars failed");
return null;
}
}
private async Task<(string info, int level)> FetchCarAlarmAsync(int carId, CancellationToken ct)
{
var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222;
try
{
using var client = CreateClient();
using var resp = await client.SendAsync(Req($"http://127.0.0.1:{port}/projection/reflection/status/car/{carId}"), ct);
if (!resp.IsSuccessStatusCode) return ("", 0);
var text = await resp.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(text);
if (!doc.RootElement.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array)
return ("", 0);
string info = "";
var level = 0;
foreach (var kv in data.EnumerateArray())
{
var key = kv.TryGetProperty("key", out var k) ? k.GetString() : null;
var val = kv.TryGetProperty("value", out var v) ? v.GetString() : null;
if (key == "车体_AlarmInfo" || key == "AlarmInfo") info = val ?? "";
else if (key == "车体_AlarmLevel" || key == "AlarmLevel") int.TryParse(val, out level);
}
info = info.Trim();
if (info is "0" or "/" or "-") info = "";
return (info, level);
}
catch
{
return ("", 0);
}
}
private static async Task ReconcileAsync(MiGuDbContext db, Dictionary<int, CurrentAlarm> current, CancellationToken ct)
{
var now = DateTimeOffset.UtcNow;
var active = await db.VehicleAlarms.Where(a => a.Status == "active").ToListAsync(ct);
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))
{
var same =
string.Equals(rec.Info, cur.Info, StringComparison.Ordinal) &&
rec.Level == cur.Level;
if (same)
{
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
});
}
// 消失 → 恢复
foreach (var rec in active)
{
if (current.ContainsKey(rec.CarId)) continue;
rec.Status = "cleared";
rec.ResolvedAt = now;
rec.DurationSecs = (long)Math.Max(0, (now - rec.FirstAt).TotalSeconds);
}
await db.SaveChangesAsync(ct);
}
private HttpClient CreateClient()
{
var c = _httpFactory.CreateClient();
c.Timeout = TimeSpan.FromSeconds(10);
return c;
}
private HttpRequestMessage Req(string url)
{
var req = new HttpRequestMessage(HttpMethod.Get, url);
var token = _token.Token;
if (!string.IsNullOrEmpty(token))
req.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", token);
return req;
}
}
/// <summary>后台循环:定时采集车辆报警到 platform.db。</summary>
public sealed class AlarmCollectorService : BackgroundService
{
private readonly AlarmCollector _collector;
private readonly ILogger<AlarmCollectorService> _log;
public AlarmCollectorService(AlarmCollector collector, ILogger<AlarmCollectorService> log)
{
_collector = collector;
_log = log;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try { await Task.Delay(TimeSpan.FromSeconds(4), stoppingToken); }
catch { return; }
while (!stoppingToken.IsCancellationRequested)
{
try { await _collector.SyncOnceAsync(stoppingToken); }
catch (Exception ex) { _log.LogDebug(ex, "alarm collector loop error"); }
try { await Task.Delay(TimeSpan.FromSeconds(8), stoppingToken); }
catch { break; }
}
}
}