From 8413a52a281d2347eb293a372e72cceb2e3962a9 Mon Sep 17 00:00:00 2001 From: g82tt Date: Wed, 3 Jun 2026 17:39:26 +0800 Subject: [PATCH] =?UTF-8?q?Fix-F2-F4:=20B4-batch+=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E7=A6=BB=E7=BA=BF+=E5=87=AD=E6=8D=AE=E5=AE=89=E5=85=A8+?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E5=9C=B0=E5=9D=80+=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E6=97=A5=E5=BF=97+=E6=BB=9E=E5=90=8E=E7=AA=97+console=E6=B8=85?= =?UTF-8?q?=E7=90=86+API=E7=BB=9F=E4=B8=80+Swagger+=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + .../Warehouse/Services/HeartbeatMonitorJob.cs | 52 +++++--- doc/db_init.sql | 30 +++++ .../KmsAdapter.cs | 2 +- .../Mc4Adapter.cs | 2 +- .../OwlAdapter.cs | 2 +- .../IntegrationGateway.Host.csproj | 4 + .../src/IntegrationGateway.Host/Program.cs | 18 +++ web.vite/src/api/gateway.js | 126 ++++++++++++++++++ .../warehouse/device_manager/base_device.vue | 2 +- 10 files changed, 220 insertions(+), 22 deletions(-) create mode 100644 web.vite/src/api/gateway.js diff --git a/.gitignore b/.gitignore index 441ff6b..2e015ba 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ temp.json *.bak tmp.bat report_form_rollup-plugin-visualizer.html + +**/bin/ +**/obj/ +appsettings.Production.json \ No newline at end of file diff --git a/api_sqlsugar/Warehouse/Services/HeartbeatMonitorJob.cs b/api_sqlsugar/Warehouse/Services/HeartbeatMonitorJob.cs index 28e2a08..95ee851 100644 --- a/api_sqlsugar/Warehouse/Services/HeartbeatMonitorJob.cs +++ b/api_sqlsugar/Warehouse/Services/HeartbeatMonitorJob.cs @@ -3,8 +3,10 @@ using Microsoft.Extensions.DependencyInjection; using Warehouse.IServices; using VolPro.Entity.DomainModels; using System; +using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; +using Warehouse.IRepositories; namespace VolPro.Warehouse.Services; @@ -12,46 +14,60 @@ namespace VolPro.Warehouse.Services; /// 心跳超时检测任务。扫描心跳超时 30 秒的网关节点,标记为离线, /// 并级联标记该节点下所有设备为离线。 /// Cron 建议: 每 15 秒 ("0/15 * * * * ?") +/// +/// 设备与网关的关联通过 AdapterCode 前缀匹配(如设备 AdapterCode="MC4:31ku" 匹配网关 AdapterTypes="MC4:31ku")。 /// public class HeartbeatMonitorJob : IJob { public async Task Execute(IJobExecutionContext context) { var sp = (IServiceProvider)context.JobDetail.JobDataMap["ServiceProvider"]; + if (sp == null) return; + var gwSvc = sp.GetService(); - var devSvc = sp.GetService(); - if (gwSvc == null) return; + var gwRepo = sp.GetService(); + var devRepo = sp.GetService(); + if (gwSvc == null || gwRepo == null || devRepo == null) return; var timeout = DateTime.Now.AddSeconds(-30); // 扫描心跳超时的网关(当前在线但心跳超时) var offlineNodes = await gwSvc.FindAsIQueryable( - x => x.IsOnline == "在线" && x.LastHeartbeat < timeout) - .ToListAsync(); + x => x.IsOnline == "在线" && x.LastHeartbeat < timeout).ToListAsync(); foreach (var node in offlineNodes) { // 标记网关离线 node.IsOnline = "离线"; - await gwSvc.FindAsIQueryable(x => x.NodeId == node.NodeId) - .FirstAsync(); // 确保实体被跟踪 - // 直接通过 DbContext 更新 - var dbProp = gwSvc.GetType().BaseType?.GetProperty("DbContext"); - if (dbProp != null) continue; // fallback: 通过 FindAsIQueryable 重新获取更新 - + try { gwRepo.Update(node); } catch { } Console.WriteLine($"[HeartbeatMonitorJob] 网关 {node.NodeCode} 心跳超时,标记离线"); - // 级联标记该网关下所有设备离线 - if (devSvc != null) + // 级联标记该网关下所有设备离线(批量 SQL) + try { - var devices = await devSvc.FindAsIQueryable( - x => x.NodeId == node.NodeId && x.IsOnline == "在线") - .ToListAsync(); - foreach (var dev in devices) + var adapterPrefixes = (node.AdapterTypes ?? "") + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(t => t.Trim()).ToList(); + + if (adapterPrefixes.Any()) { - dev.IsOnline = "离线"; + var allDevices = await devRepo.FindAsIQueryable( + d => d.IsOnline == "在线").ToListAsync(); + var matched = allDevices + .Where(d => adapterPrefixes.Any(p => (d.AdapterCode ?? "").StartsWith(p))) + .ToList(); + + if (matched.Any()) + { + foreach (var dev in matched) dev.IsOnline = "离线"; + devRepo.UpdateRange(matched); + Console.WriteLine($"[HeartbeatMonitorJob] 级联 {matched.Count} 台设备离线"); + } } - Console.WriteLine($"[HeartbeatMonitorJob] 级联 {devices.Count} 台设备离线"); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[HeartbeatMonitorJob] 级联离线失败: {ex.Message}"); } } } diff --git a/doc/db_init.sql b/doc/db_init.sql index 584489e..e98bee9 100644 --- a/doc/db_init.sql +++ b/doc/db_init.sql @@ -161,3 +161,33 @@ CREATE TABLE gateway_nodes ( UNIQUE INDEX IX_Code (NodeCode), INDEX IX_Online (IsOnline) ) COMMENT '网关节点注册表'; + + +-- ═══════════════════════════════════════════════ +-- SecMPS 规则引擎: warehouse_variable 变量定义表 (P1-6) +-- ================================================= +-- 规则条件/动作的 ValueId 绑定到此表的 VariableId +-- DeviceId 关联 base_device.DeviceId +-- ================================================= + +CREATE TABLE warehouse_variable ( + VariableId INT IDENTITY(1,1) PRIMARY KEY, + DeviceId INT NOT NULL, + VariableName NVARCHAR(255) NOT NULL, -- 温度/湿度/人数 + PointIndex INT DEFAULT 0, -- MC4 pointIndex / Owl 统计量编码 + Unit NVARCHAR(50) NULL, -- ℃/%/人 + SortOrder INT DEFAULT 0 +); + +CREATE INDEX IX_warehouse_variable_DeviceId ON warehouse_variable (DeviceId); + + +-- F3.2 规则引擎滞后窗 (hysteresis) +ALTER TABLE warehouse_rulecondition ADD + RecoveryThreshold_Numeric DECIMAL(18,2) NULL, + RecoveryThreshold_Switch NVARCHAR(50) NULL; + +-- F3.3 条件级冷却 +ALTER TABLE warehouse_rulecondition ADD + LastTriggered DATETIME NULL, + LastTriggerValue DECIMAL(18,2) NULL; diff --git a/gateway/src/IntegrationGateway.Adapters.Kms/KmsAdapter.cs b/gateway/src/IntegrationGateway.Adapters.Kms/KmsAdapter.cs index 671114d..b63f43d 100644 --- a/gateway/src/IntegrationGateway.Adapters.Kms/KmsAdapter.cs +++ b/gateway/src/IntegrationGateway.Adapters.Kms/KmsAdapter.cs @@ -61,7 +61,7 @@ public class KmsAdapter : IHasFlatDevices, IHasAlarms, IAcceptsControl, IHasBusi var resp = await client.GetAsync("/prod-api/heartBeat"); return resp.IsSuccessStatusCode; } - catch { return false; } + catch (Exception ex) { Console.Error.WriteLine($"[{AdapterCode}] HealthCheck 失败: {ex.Message}"); return false; } } // ═══════════════════════════════════════════ diff --git a/gateway/src/IntegrationGateway.Adapters.MC4/Mc4Adapter.cs b/gateway/src/IntegrationGateway.Adapters.MC4/Mc4Adapter.cs index 9041b0b..2caff4c 100644 --- a/gateway/src/IntegrationGateway.Adapters.MC4/Mc4Adapter.cs +++ b/gateway/src/IntegrationGateway.Adapters.MC4/Mc4Adapter.cs @@ -57,7 +57,7 @@ public class Mc4Adapter : IHasOwnDeviceTree, IHasPoints, IHasAlarms var resp = await client.PostAsync("/api/central/auth/conf/get", null); return resp.IsSuccessStatusCode; } - catch { return false; } + catch (Exception ex) { Console.Error.WriteLine($"[{AdapterCode}] HealthCheck 失败: {ex.Message}"); return false; } } // ═══════════════════════════════════════════ diff --git a/gateway/src/IntegrationGateway.Adapters.Owl/OwlAdapter.cs b/gateway/src/IntegrationGateway.Adapters.Owl/OwlAdapter.cs index ab0098c..b8e3c26 100644 --- a/gateway/src/IntegrationGateway.Adapters.Owl/OwlAdapter.cs +++ b/gateway/src/IntegrationGateway.Adapters.Owl/OwlAdapter.cs @@ -60,7 +60,7 @@ public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAccepts var resp = await client.GetAsync("/health"); return resp.IsSuccessStatusCode; } - catch { return false; } + catch (Exception ex) { Console.Error.WriteLine($"[{AdapterCode}] HealthCheck 失败: {ex.Message}"); return false; } } // ═══════════════════════════════════════════ diff --git a/gateway/src/IntegrationGateway.Host/IntegrationGateway.Host.csproj b/gateway/src/IntegrationGateway.Host/IntegrationGateway.Host.csproj index 30b0c4f..a42a44d 100644 --- a/gateway/src/IntegrationGateway.Host/IntegrationGateway.Host.csproj +++ b/gateway/src/IntegrationGateway.Host/IntegrationGateway.Host.csproj @@ -7,6 +7,10 @@ + + + + net8.0 enable diff --git a/gateway/src/IntegrationGateway.Host/Program.cs b/gateway/src/IntegrationGateway.Host/Program.cs index f7bf2f2..b4141e7 100644 --- a/gateway/src/IntegrationGateway.Host/Program.cs +++ b/gateway/src/IntegrationGateway.Host/Program.cs @@ -14,6 +14,9 @@ using IntegrationGateway.Core.Models; var builder = WebApplication.CreateBuilder(args); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + // ── 注册 HttpClient 工厂 ── // 命名客户端 "VolPro":用于调用 Vol.Pro A 组接口和适配器内部 HTTP 请求 // 连接池:最多 10 个并发连接,5 分钟生命周期 @@ -30,6 +33,9 @@ builder.Services.AddHttpClient("VolPro", c => builder.Services.AddCors(o => o.AddDefaultPolicy(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader())); var app = builder.Build(); +app.UseSwagger(); +app.UseSwaggerUI(); + app.UseCors(); // ── 读取配置 ── @@ -178,6 +184,17 @@ app.MapGet("/api/gateway/realtime/{adapter}/{deviceId}", async (string adapter, return Results.Ok(await a.GetRealtimeValuesAsync(deviceId)); }); +// B4-batch: 批量实时点位值 — 一次请求返回多个设备的值 +app.MapPost("/api/gateway/realtime/{adapter}/batch", async (string adapter, BatchRealtimeRequest req) => +{ + var a = registry.FindByCode(adapter); + if (a == null) return Results.NotFound(new { error = "CAPABILITY_NOT_SUPPORTED" }); + var results = new Dictionary>(); + foreach (var deviceId in req.DeviceIds ?? new()) + try { results[deviceId] = await a.GetRealtimeValuesAsync(deviceId); } catch { } + return Results.Ok(results); +}); + // B5: 设备控制 — 向 IoT 设备下发控制指令 app.MapPost("/api/gateway/realtime/{adapter}/control", async (string adapter, ControlRequest req) => { @@ -319,6 +336,7 @@ record PtzRequest(string? Direction, string Action, float Speed); /// 点位索引 /// 目标值 record ControlRequest(string? DeviceId, int PointIndex, double Value); +record BatchRealtimeRequest(List? DeviceIds); record GatewayControlRequest(string? DeviceId, string? Command, Dictionary? Parameters); record SyncRequest(string? DataType, List? Items); record SyncDeleteRequest(string? DataType, List? Ids); diff --git a/web.vite/src/api/gateway.js b/web.vite/src/api/gateway.js new file mode 100644 index 0000000..509a872 --- /dev/null +++ b/web.vite/src/api/gateway.js @@ -0,0 +1,126 @@ +/** + * 网关 B 组接口封装 + * 所有网关 API 调用使用 fetch() 直连,不走 Vol.Pro 后端中转 + */ + +const GW_BASE = 'http://192.168.3.108:5100' + +/** GET 请求网关 */ +export async function gwGet(url: string): Promise { + const resp = await fetch(`${GW_BASE}${url}`) + if (!resp.ok) throw new Error(`网关请求失败: ${resp.status}`) + return resp.json() +} + +/** POST 请求网关 */ +export async function gwPost(url: string, body?: object): Promise { + const resp = await fetch(`${GW_BASE}${url}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined + }) + if (!resp.ok) throw new Error(`网关请求失败: ${resp.status}`) + return resp.json() +} + +// ═══════════════════════════════════════════ +// 数据模型 +// ═══════════════════════════════════════════ + +/** 统一设备模型(对应网关 StandardDevice) */ +export interface StandardDevice { + deviceId: number + adapterCode: string + sourceId: string + name: string + category: string + group: string + isParent: boolean + parentSourceId?: string + isOnline: boolean + ipAddress?: string + port?: number + extra?: Record +} + +/** 摄像机视图模型 */ +export interface Camera { + id: string + name: string + location: string + status: 'online' | 'offline' + adapterCode: string + sourceId: string + streamUrl?: string + hasPtz: boolean +} + +/** 视频流地址集合 */ +export interface StreamUrls { + wsFlv?: string + httpFlv?: string + hls?: string + webRtc?: string + rtmp?: string + rtsp?: string +} + +/** 分页容器 */ +export interface PagedResult { + items: T[] + total: number +} + +/** 实时点位值 */ +export interface PointValue { + sourceDeviceId: string + pointIndex: number + value: number + updateTime?: string + interval: number +} + +/** 告警模型 */ +export interface StandardAlarm { + alarmId: string + deviceId?: string + adapterCode: string + level: string + title: string + content?: string + occurTime: string + status: string + actualValue?: number + thresholdValue?: number +} + +/** 设备树节点 */ +export interface DeviceTreeNode { + id: number + sourceId: string + name: string + type: number + objectType: number + tag?: string + option?: Record + children: DeviceTreeNode[] +} + +/** StandardDevice → Camera 映射 */ +export function toCamera(d: StandardDevice): Camera { + return { + id: d.sourceId, + name: d.name, + location: d.extra?.location || '', + status: d.isOnline ? 'online' : 'offline', + adapterCode: d.adapterCode, + sourceId: d.sourceId, + hasPtz: d.extra?.hasPtz === '1' || d.extra?.hasPtz === true + } +} + +/** 从网关获取设备列表并映射为 Camera[] */ +export async function fetchCameras(adapter: string): Promise { + const data = await gwGet(`/api/gateway/devices?adapter=${adapter}&page=1&size=100`) + return (data.items || []).map(toCamera) +} diff --git a/web.vite/src/views/warehouse/device_manager/base_device.vue b/web.vite/src/views/warehouse/device_manager/base_device.vue index c57d41b..623c96f 100644 --- a/web.vite/src/views/warehouse/device_manager/base_device.vue +++ b/web.vite/src/views/warehouse/device_manager/base_device.vue @@ -82,7 +82,7 @@ const { proxy } = getCurrentInstance() const { table, editFormFields, editFormOptions, searchFormFields, searchFormOptions, columns, detail, details } = reactive(viewOptions()) - const GW = 'http://localhost:5100' + const GW = window.apiConfig?.gatewayUrl || 'http://localhost:5100' let gridRef, _timer; //── 对话框状态 ──