Fix-F2-F4: B4-batch+批量离线+凭据安全+前端地址+异常日志+滞后窗+console清理+API统一+Swagger+文档同步
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -13,3 +13,7 @@ temp.json
|
|||||||
*.bak
|
*.bak
|
||||||
tmp.bat
|
tmp.bat
|
||||||
report_form_rollup-plugin-visualizer.html
|
report_form_rollup-plugin-visualizer.html
|
||||||
|
|
||||||
|
**/bin/
|
||||||
|
**/obj/
|
||||||
|
appsettings.Production.json
|
||||||
@@ -3,8 +3,10 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Warehouse.IServices;
|
using Warehouse.IServices;
|
||||||
using VolPro.Entity.DomainModels;
|
using VolPro.Entity.DomainModels;
|
||||||
using System;
|
using System;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Warehouse.IRepositories;
|
||||||
|
|
||||||
namespace VolPro.Warehouse.Services;
|
namespace VolPro.Warehouse.Services;
|
||||||
|
|
||||||
@@ -12,46 +14,60 @@ namespace VolPro.Warehouse.Services;
|
|||||||
/// 心跳超时检测任务。扫描心跳超时 30 秒的网关节点,标记为离线,
|
/// 心跳超时检测任务。扫描心跳超时 30 秒的网关节点,标记为离线,
|
||||||
/// 并级联标记该节点下所有设备为离线。
|
/// 并级联标记该节点下所有设备为离线。
|
||||||
/// Cron 建议: 每 15 秒 ("0/15 * * * * ?")
|
/// Cron 建议: 每 15 秒 ("0/15 * * * * ?")
|
||||||
|
///
|
||||||
|
/// 设备与网关的关联通过 AdapterCode 前缀匹配(如设备 AdapterCode="MC4:31ku" 匹配网关 AdapterTypes="MC4:31ku")。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class HeartbeatMonitorJob : IJob
|
public class HeartbeatMonitorJob : IJob
|
||||||
{
|
{
|
||||||
public async Task Execute(IJobExecutionContext context)
|
public async Task Execute(IJobExecutionContext context)
|
||||||
{
|
{
|
||||||
var sp = (IServiceProvider)context.JobDetail.JobDataMap["ServiceProvider"];
|
var sp = (IServiceProvider)context.JobDetail.JobDataMap["ServiceProvider"];
|
||||||
|
if (sp == null) return;
|
||||||
|
|
||||||
var gwSvc = sp.GetService<Igateway_nodesService>();
|
var gwSvc = sp.GetService<Igateway_nodesService>();
|
||||||
var devSvc = sp.GetService<Ibase_deviceService>();
|
var gwRepo = sp.GetService<Igateway_nodesRepository>();
|
||||||
if (gwSvc == null) return;
|
var devRepo = sp.GetService<Ibase_deviceRepository>();
|
||||||
|
if (gwSvc == null || gwRepo == null || devRepo == null) return;
|
||||||
|
|
||||||
var timeout = DateTime.Now.AddSeconds(-30);
|
var timeout = DateTime.Now.AddSeconds(-30);
|
||||||
|
|
||||||
// 扫描心跳超时的网关(当前在线但心跳超时)
|
// 扫描心跳超时的网关(当前在线但心跳超时)
|
||||||
var offlineNodes = await gwSvc.FindAsIQueryable(
|
var offlineNodes = await gwSvc.FindAsIQueryable(
|
||||||
x => x.IsOnline == "在线" && x.LastHeartbeat < timeout)
|
x => x.IsOnline == "在线" && x.LastHeartbeat < timeout).ToListAsync();
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
foreach (var node in offlineNodes)
|
foreach (var node in offlineNodes)
|
||||||
{
|
{
|
||||||
// 标记网关离线
|
// 标记网关离线
|
||||||
node.IsOnline = "离线";
|
node.IsOnline = "离线";
|
||||||
await gwSvc.FindAsIQueryable(x => x.NodeId == node.NodeId)
|
try { gwRepo.Update(node); } catch { }
|
||||||
.FirstAsync(); // 确保实体被跟踪
|
|
||||||
// 直接通过 DbContext 更新
|
|
||||||
var dbProp = gwSvc.GetType().BaseType?.GetProperty("DbContext");
|
|
||||||
if (dbProp != null) continue; // fallback: 通过 FindAsIQueryable 重新获取更新
|
|
||||||
|
|
||||||
Console.WriteLine($"[HeartbeatMonitorJob] 网关 {node.NodeCode} 心跳超时,标记离线");
|
Console.WriteLine($"[HeartbeatMonitorJob] 网关 {node.NodeCode} 心跳超时,标记离线");
|
||||||
|
|
||||||
// 级联标记该网关下所有设备离线
|
// 级联标记该网关下所有设备离线(批量 SQL)
|
||||||
if (devSvc != null)
|
try
|
||||||
{
|
{
|
||||||
var devices = await devSvc.FindAsIQueryable(
|
var adapterPrefixes = (node.AdapterTypes ?? "")
|
||||||
x => x.NodeId == node.NodeId && x.IsOnline == "在线")
|
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||||
.ToListAsync();
|
.Select(t => t.Trim()).ToList();
|
||||||
foreach (var dev in devices)
|
|
||||||
|
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}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,3 +161,33 @@ CREATE TABLE gateway_nodes (
|
|||||||
UNIQUE INDEX IX_Code (NodeCode),
|
UNIQUE INDEX IX_Code (NodeCode),
|
||||||
INDEX IX_Online (IsOnline)
|
INDEX IX_Online (IsOnline)
|
||||||
) COMMENT '网关节点注册表';
|
) 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;
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ public class KmsAdapter : IHasFlatDevices, IHasAlarms, IAcceptsControl, IHasBusi
|
|||||||
var resp = await client.GetAsync("/prod-api/heartBeat");
|
var resp = await client.GetAsync("/prod-api/heartBeat");
|
||||||
return resp.IsSuccessStatusCode;
|
return resp.IsSuccessStatusCode;
|
||||||
}
|
}
|
||||||
catch { return false; }
|
catch (Exception ex) { Console.Error.WriteLine($"[{AdapterCode}] HealthCheck 失败: {ex.Message}"); return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ public class Mc4Adapter : IHasOwnDeviceTree, IHasPoints, IHasAlarms
|
|||||||
var resp = await client.PostAsync("/api/central/auth/conf/get", null);
|
var resp = await client.PostAsync("/api/central/auth/conf/get", null);
|
||||||
return resp.IsSuccessStatusCode;
|
return resp.IsSuccessStatusCode;
|
||||||
}
|
}
|
||||||
catch { return false; }
|
catch (Exception ex) { Console.Error.WriteLine($"[{AdapterCode}] HealthCheck 失败: {ex.Message}"); return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAccepts
|
|||||||
var resp = await client.GetAsync("/health");
|
var resp = await client.GetAsync("/health");
|
||||||
return resp.IsSuccessStatusCode;
|
return resp.IsSuccessStatusCode;
|
||||||
}
|
}
|
||||||
catch { return false; }
|
catch (Exception ex) { Console.Error.WriteLine($"[{AdapterCode}] HealthCheck 失败: {ex.Message}"); return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
<ProjectReference Include="..\IntegrationGateway.Adapters.Kms\IntegrationGateway.Adapters.Kms.csproj" />
|
<ProjectReference Include="..\IntegrationGateway.Adapters.Kms\IntegrationGateway.Adapters.Kms.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ using IntegrationGateway.Core.Models;
|
|||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
builder.Services.AddSwaggerGen();
|
||||||
|
|
||||||
// ── 注册 HttpClient 工厂 ──
|
// ── 注册 HttpClient 工厂 ──
|
||||||
// 命名客户端 "VolPro":用于调用 Vol.Pro A 组接口和适配器内部 HTTP 请求
|
// 命名客户端 "VolPro":用于调用 Vol.Pro A 组接口和适配器内部 HTTP 请求
|
||||||
// 连接池:最多 10 个并发连接,5 分钟生命周期
|
// 连接池:最多 10 个并发连接,5 分钟生命周期
|
||||||
@@ -30,6 +33,9 @@ builder.Services.AddHttpClient("VolPro", c =>
|
|||||||
builder.Services.AddCors(o => o.AddDefaultPolicy(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()));
|
builder.Services.AddCors(o => o.AddDefaultPolicy(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()));
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI();
|
||||||
|
|
||||||
app.UseCors();
|
app.UseCors();
|
||||||
|
|
||||||
// ── 读取配置 ──
|
// ── 读取配置 ──
|
||||||
@@ -178,6 +184,17 @@ app.MapGet("/api/gateway/realtime/{adapter}/{deviceId}", async (string adapter,
|
|||||||
return Results.Ok(await a.GetRealtimeValuesAsync(deviceId));
|
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<IHasPoints>(adapter);
|
||||||
|
if (a == null) return Results.NotFound(new { error = "CAPABILITY_NOT_SUPPORTED" });
|
||||||
|
var results = new Dictionary<string, List<PointValue>>();
|
||||||
|
foreach (var deviceId in req.DeviceIds ?? new())
|
||||||
|
try { results[deviceId] = await a.GetRealtimeValuesAsync(deviceId); } catch { }
|
||||||
|
return Results.Ok(results);
|
||||||
|
});
|
||||||
|
|
||||||
// B5: 设备控制 — 向 IoT 设备下发控制指令
|
// B5: 设备控制 — 向 IoT 设备下发控制指令
|
||||||
app.MapPost("/api/gateway/realtime/{adapter}/control", async (string adapter, ControlRequest req) =>
|
app.MapPost("/api/gateway/realtime/{adapter}/control", async (string adapter, ControlRequest req) =>
|
||||||
{
|
{
|
||||||
@@ -319,6 +336,7 @@ record PtzRequest(string? Direction, string Action, float Speed);
|
|||||||
/// <param name="PointIndex">点位索引</param>
|
/// <param name="PointIndex">点位索引</param>
|
||||||
/// <param name="Value">目标值</param>
|
/// <param name="Value">目标值</param>
|
||||||
record ControlRequest(string? DeviceId, int PointIndex, double Value);
|
record ControlRequest(string? DeviceId, int PointIndex, double Value);
|
||||||
|
record BatchRealtimeRequest(List<string>? DeviceIds);
|
||||||
record GatewayControlRequest(string? DeviceId, string? Command, Dictionary<string, object?>? Parameters);
|
record GatewayControlRequest(string? DeviceId, string? Command, Dictionary<string, object?>? Parameters);
|
||||||
record SyncRequest(string? DataType, List<object>? Items);
|
record SyncRequest(string? DataType, List<object>? Items);
|
||||||
record SyncDeleteRequest(string? DataType, List<string>? Ids);
|
record SyncDeleteRequest(string? DataType, List<string>? Ids);
|
||||||
|
|||||||
126
web.vite/src/api/gateway.js
Normal file
126
web.vite/src/api/gateway.js
Normal file
@@ -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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 摄像机视图模型 */
|
||||||
|
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<T> {
|
||||||
|
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<string, any>
|
||||||
|
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<Camera[]> {
|
||||||
|
const data = await gwGet(`/api/gateway/devices?adapter=${adapter}&page=1&size=100`)
|
||||||
|
return (data.items || []).map(toCamera)
|
||||||
|
}
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
const { proxy } = getCurrentInstance()
|
const { proxy } = getCurrentInstance()
|
||||||
const { table, editFormFields, editFormOptions, searchFormFields, searchFormOptions, columns, detail, details } = reactive(viewOptions())
|
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;
|
let gridRef, _timer;
|
||||||
|
|
||||||
//── 对话框状态 ──
|
//── 对话框状态 ──
|
||||||
|
|||||||
Reference in New Issue
Block a user