Compare commits
10 Commits
8413a52a28
...
79b8400e6d
| Author | SHA1 | Date | |
|---|---|---|---|
| 79b8400e6d | |||
| bb56c229f8 | |||
| 9969d3bf6d | |||
| 0575c1f369 | |||
| 85984d1e94 | |||
| 5467f0c0e2 | |||
| faf8930de4 | |||
| 4eefb9ed67 | |||
| 1ad76ae33b | |||
| ff8d7bcaf5 |
@@ -0,0 +1,66 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using VolPro.Core.Filters;
|
||||||
|
using Warehouse.Services;
|
||||||
|
|
||||||
|
namespace Warehouse.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 定时任务 API 端点。
|
||||||
|
/// VolPro 框架通过 Sys_QuartzOptions 配置 URL+Cron 定时调用。
|
||||||
|
/// 每个方法加 [ApiTask] 属性以允许框架匿名调用。
|
||||||
|
///
|
||||||
|
/// 管理端配置:
|
||||||
|
/// syncDevices: 0 */5 * * * ?
|
||||||
|
/// heartbeatMonitor: 0/15 * * * * ?
|
||||||
|
/// realtimePoll: 0/10 * * * * ?
|
||||||
|
/// ruleEngine: 0/10 * * * * ?
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/task")]
|
||||||
|
public class TaskController : Controller
|
||||||
|
{
|
||||||
|
/// <summary>设备同步 — 遍历在线网关触发全量设备同步</summary>
|
||||||
|
[ApiTask]
|
||||||
|
[HttpGet, HttpPost, Route("syncDevices")]
|
||||||
|
public async Task<IActionResult> SyncDevices()
|
||||||
|
{
|
||||||
|
var sp = HttpContext.RequestServices;
|
||||||
|
var engine = sp.GetService<SyncDevicesJob>();
|
||||||
|
if (engine != null) await engine.Execute(null!);
|
||||||
|
return Ok(new { time = DateTime.Now, status = "ok" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>心跳监控 — 扫描超时网关标记离线</summary>
|
||||||
|
[ApiTask]
|
||||||
|
[HttpGet, HttpPost, Route("heartbeatMonitor")]
|
||||||
|
public async Task<IActionResult> HeartbeatMonitor()
|
||||||
|
{
|
||||||
|
var sp = HttpContext.RequestServices;
|
||||||
|
var engine = sp.GetService<HeartbeatMonitorJob>();
|
||||||
|
if (engine != null) await engine.Execute(null!);
|
||||||
|
return Ok(new { time = DateTime.Now, status = "ok" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>实时轮询 — 拉取 MC4 IoT 实时值写入 iot_devicedata</summary>
|
||||||
|
[ApiTask]
|
||||||
|
[HttpGet, HttpPost, Route("realtimePoll")]
|
||||||
|
public async Task<IActionResult> RealtimePoll()
|
||||||
|
{
|
||||||
|
var sp = HttpContext.RequestServices;
|
||||||
|
var engine = sp.GetService<RealtimePollJob>();
|
||||||
|
if (engine != null) await engine.Execute(null!);
|
||||||
|
return Ok(new { time = DateTime.Now, status = "ok" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>规则引擎 — 评估规则条件+执行告警/控制/通知动作</summary>
|
||||||
|
[ApiTask]
|
||||||
|
[HttpGet, HttpPost, Route("ruleEngine")]
|
||||||
|
public async Task<IActionResult> RuleEngine()
|
||||||
|
{
|
||||||
|
var sp = HttpContext.RequestServices;
|
||||||
|
var engine = sp.GetService<RuleEngineService>();
|
||||||
|
if (engine != null) await engine.EvaluateAllAsync();
|
||||||
|
return Ok(new { time = DateTime.Now, status = "ok" });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,9 +19,12 @@ namespace VolPro.Warehouse.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class HeartbeatMonitorJob : IJob
|
public class HeartbeatMonitorJob : IJob
|
||||||
{
|
{
|
||||||
public async Task Execute(IJobExecutionContext context)
|
private readonly IServiceProvider _sp;
|
||||||
|
public HeartbeatMonitorJob(IServiceProvider sp) { _sp = sp; }
|
||||||
|
|
||||||
|
public async Task Execute(IJobExecutionContext? context)
|
||||||
{
|
{
|
||||||
var sp = (IServiceProvider)context.JobDetail.JobDataMap["ServiceProvider"];
|
var sp = _sp;
|
||||||
if (sp == null) return;
|
if (sp == null) return;
|
||||||
|
|
||||||
var gwSvc = sp.GetService<Igateway_nodesService>();
|
var gwSvc = sp.GetService<Igateway_nodesService>();
|
||||||
|
|||||||
@@ -18,9 +18,12 @@ namespace VolPro.Warehouse.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class RealtimePollJob : IJob
|
public class RealtimePollJob : IJob
|
||||||
{
|
{
|
||||||
public async Task Execute(IJobExecutionContext context)
|
private readonly IServiceProvider _sp;
|
||||||
|
public RealtimePollJob(IServiceProvider sp) { _sp = sp; }
|
||||||
|
|
||||||
|
public async Task Execute(IJobExecutionContext? context)
|
||||||
{
|
{
|
||||||
var sp = (IServiceProvider)context.JobDetail.JobDataMap["ServiceProvider"];
|
var sp = _sp;
|
||||||
if (sp == null) return;
|
if (sp == null) return;
|
||||||
|
|
||||||
var gwSvc = sp.GetService<Igateway_nodesService>();
|
var gwSvc = sp.GetService<Igateway_nodesService>();
|
||||||
|
|||||||
24
api_sqlsugar/Warehouse/Services/RuleEngineJob.cs
Normal file
24
api_sqlsugar/Warehouse/Services/RuleEngineJob.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// 已迁移到 TaskController.RuleEngine() — 构建时需删除此文件
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Warehouse.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 规则引擎定时任务。
|
||||||
|
/// Cron: 0/10 * * * * ? (每10秒)
|
||||||
|
/// 挂载到 Vol.Pro Quartz 调度器。
|
||||||
|
/// </summary>
|
||||||
|
public class RuleEngineJob : IJob
|
||||||
|
{
|
||||||
|
public async Task Execute(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
var sp = (IServiceProvider)context.JobDetail.JobDataMap["ServiceProvider"];
|
||||||
|
if (sp == null) return;
|
||||||
|
|
||||||
|
var engine = sp.GetService<RuleEngineService>();
|
||||||
|
if (engine == null) return;
|
||||||
|
|
||||||
|
await engine.EvaluateAllAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
320
api_sqlsugar/Warehouse/Services/RuleEngineService.cs
Normal file
320
api_sqlsugar/Warehouse/Services/RuleEngineService.cs
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using VolPro.Entity.DomainModels;
|
||||||
|
using VolPro.WebApi.Controllers.Hubs;
|
||||||
|
using Warehouse.IRepositories;
|
||||||
|
using Warehouse.IServices;
|
||||||
|
|
||||||
|
namespace Warehouse.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 规则引擎核心服务。
|
||||||
|
/// 由 RuleEngineJob 每 10s 调用一次 EvaluateAllAsync。
|
||||||
|
///
|
||||||
|
/// 流程:
|
||||||
|
/// 1. 加载所有启用规则(含条件+动作)
|
||||||
|
/// 2. 从 gateway 批量获取实时值
|
||||||
|
/// 3. 逐规则评估条件 → 触发动作 → 写日志
|
||||||
|
/// </summary>
|
||||||
|
public class RuleEngineService
|
||||||
|
{
|
||||||
|
private readonly Iwarehouse_ruleRepository _ruleRepo;
|
||||||
|
private readonly Ibase_deviceRepository _devRepo;
|
||||||
|
private readonly Iiot_devicedataRepository _dataRepo;
|
||||||
|
private readonly Iiot_alarmRepository _alarmRepo;
|
||||||
|
private readonly GatewayClient _gatewayClient;
|
||||||
|
private readonly IHubContext<HomePageMessageHub> _hub;
|
||||||
|
|
||||||
|
public RuleEngineService(
|
||||||
|
Iwarehouse_ruleRepository ruleRepo,
|
||||||
|
Ibase_deviceRepository devRepo,
|
||||||
|
Iiot_devicedataRepository dataRepo,
|
||||||
|
Iiot_alarmRepository alarmRepo,
|
||||||
|
GatewayClient gatewayClient,
|
||||||
|
IHubContext<HomePageMessageHub> hub)
|
||||||
|
{
|
||||||
|
_ruleRepo = ruleRepo;
|
||||||
|
_devRepo = devRepo;
|
||||||
|
_dataRepo = dataRepo;
|
||||||
|
_alarmRepo = alarmRepo;
|
||||||
|
_gatewayClient = gatewayClient;
|
||||||
|
_hub = hub;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task EvaluateAllAsync()
|
||||||
|
{
|
||||||
|
// 1. 加载启用规则
|
||||||
|
var rules = await LoadEnabledRulesAsync();
|
||||||
|
if (!rules.Any()) return;
|
||||||
|
|
||||||
|
// 2. 构建 DeviceId → (AdapterCode, SourceId, BaseUrl) 映射
|
||||||
|
var deviceMap = await BuildDeviceMappingAsync(rules);
|
||||||
|
|
||||||
|
// 3. 批量取实时值(按网关分组调 B4-batch)
|
||||||
|
var realtimeData = await BatchFetchRealtimeAsync(rules, deviceMap);
|
||||||
|
|
||||||
|
// 4. 逐规则评估
|
||||||
|
foreach (var rule in rules)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bool met = await EvaluateRuleAsync(rule, realtimeData, deviceMap);
|
||||||
|
if (met)
|
||||||
|
{
|
||||||
|
await ExecuteActionsAsync(rule, deviceMap);
|
||||||
|
rule.LastTriggered = DateTime.Now;
|
||||||
|
}
|
||||||
|
rule.LastEvaluated = DateTime.Now;
|
||||||
|
await _ruleRepo.DbContext.Updateable(rule)
|
||||||
|
.UpdateColumns(r => new { r.LastEvaluated, r.LastTriggered }).ExecuteCommandAsync();
|
||||||
|
}
|
||||||
|
catch { /* 单规则失败不阻塞 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// 规则加载
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
private async Task<List<warehouse_rule>> LoadEnabledRulesAsync()
|
||||||
|
{
|
||||||
|
var rules = await _ruleRepo.FindAsIQueryable(r =>
|
||||||
|
r.Enable == "启用" || r.Enable == null).ToListAsync();
|
||||||
|
foreach (var r in rules)
|
||||||
|
{
|
||||||
|
r.CooldownSec = r.CooldownSec > 0 ? r.CooldownSec : 60;
|
||||||
|
r.Priority = r.Priority ?? 0;
|
||||||
|
}
|
||||||
|
return rules.OrderByDescending(r => r.Priority).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// 设备映射: DeviceId → (AdapterCode, SourceId, 网关BaseUrl)
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
private async Task<Dictionary<int, (string adapterCode, string sourceId, string baseUrl)>> BuildDeviceMappingAsync(
|
||||||
|
List<warehouse_rule> rules)
|
||||||
|
{
|
||||||
|
var conditionDeviceIds = rules.SelectMany(r => r.warehouse_rulecondition ?? new())
|
||||||
|
.Select(c => c.DeviceId ?? 0).Where(id => id > 0).Distinct().ToList();
|
||||||
|
var actionDeviceIds = rules.SelectMany(r => r.warehouse_ruleaction ?? new())
|
||||||
|
.Select(a => a.DeviceId ?? 0).Where(id => id > 0).Distinct().ToList();
|
||||||
|
var allIds = conditionDeviceIds.Union(actionDeviceIds).ToList();
|
||||||
|
if (!allIds.Any()) return new();
|
||||||
|
|
||||||
|
var devices = await _devRepo.FindAsIQueryable(d => allIds.Contains(d.DeviceId)).ToListAsync();
|
||||||
|
var map = new Dictionary<int, (string, string, string)>();
|
||||||
|
foreach (var d in devices)
|
||||||
|
{
|
||||||
|
string baseUrl = "";
|
||||||
|
if (!string.IsNullOrEmpty(d.AdapterCode))
|
||||||
|
{
|
||||||
|
var prefix = d.AdapterCode.Split(':')[0];
|
||||||
|
// 从 gateway_nodes 查找对应的 BaseUrl
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var gw = _devRepo.DbContext.Queryable<gateway_nodes>()
|
||||||
|
.First(x => x.AdapterTypes != null && x.AdapterTypes.Contains(prefix) && x.IsOnline == "在线");
|
||||||
|
baseUrl = gw?.BaseUrl ?? "";
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
map[d.DeviceId] = (d.AdapterCode ?? "", d.SourceId ?? "", baseUrl);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// 批量实时值获取
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
private async Task<Dictionary<(string adapter, string sourceId), List<(int pointIndex, double value)>>> BatchFetchRealtimeAsync(
|
||||||
|
List<warehouse_rule> rules,
|
||||||
|
Dictionary<int, (string adapterCode, string sourceId, string baseUrl)> deviceMap)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<(string, string), List<(int, double)>>();
|
||||||
|
|
||||||
|
// 按网关分组
|
||||||
|
var gwGroups = new Dictionary<string, List<(string adapter, string sourceId)>>();
|
||||||
|
foreach (var (deviceId, (adapter, sourceId, baseUrl)) in deviceMap)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(baseUrl) || string.IsNullOrEmpty(sourceId)) continue;
|
||||||
|
if (!gwGroups.ContainsKey(baseUrl))
|
||||||
|
gwGroups[baseUrl] = new();
|
||||||
|
gwGroups[baseUrl].Add((adapter, sourceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (baseUrl, pairs) in gwGroups)
|
||||||
|
{
|
||||||
|
foreach (var (adapter, sourceId) in pairs)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var data = await _gatewayClient.GetRealtimeAsync(baseUrl, adapter, sourceId);
|
||||||
|
if (data == null) continue;
|
||||||
|
var root = data.RootElement;
|
||||||
|
if (root.TryGetProperty("rows", out var rows) && rows.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
var list = new List<(int, double)>();
|
||||||
|
foreach (var r in rows.EnumerateArray())
|
||||||
|
{
|
||||||
|
int idx = r.TryGetProperty("index", out var i) ? i.GetInt32() : 0;
|
||||||
|
double val = r.TryGetProperty("value", out var v) ? v.GetDouble() : 0;
|
||||||
|
list.Add((idx, val));
|
||||||
|
}
|
||||||
|
result[(adapter, sourceId)] = list;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// 条件评估
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
private Task<bool> EvaluateRuleAsync(warehouse_rule rule,
|
||||||
|
Dictionary<(string adapter, string sourceId), List<(int pointIndex, double value)>> realtimeData,
|
||||||
|
Dictionary<int, (string adapterCode, string sourceId, string baseUrl)> deviceMap)
|
||||||
|
{
|
||||||
|
var conditions = rule.warehouse_rulecondition ?? new();
|
||||||
|
if (!conditions.Any()) return Task.FromResult(false);
|
||||||
|
|
||||||
|
var results = new List<bool>();
|
||||||
|
foreach (var cond in conditions)
|
||||||
|
{
|
||||||
|
if (cond.DeviceId == null || cond.ValueId == null) { results.Add(false); continue; }
|
||||||
|
|
||||||
|
// 冷却检查
|
||||||
|
if (cond.LastTriggered.HasValue && rule.CooldownSec > 0)
|
||||||
|
{
|
||||||
|
if ((DateTime.Now - cond.LastTriggered.Value).TotalSeconds < rule.CooldownSec)
|
||||||
|
{ results.Add(false); continue; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deviceMap.TryGetValue(cond.DeviceId.Value, out var devInfo)) { results.Add(false); continue; }
|
||||||
|
|
||||||
|
double? actualValue = null;
|
||||||
|
if (realtimeData.TryGetValue((devInfo.adapterCode, devInfo.sourceId), out var points))
|
||||||
|
{
|
||||||
|
// ValueId 对应 pointIndex(简化:直接使用 ValueId 作为 pointIndex)
|
||||||
|
var point = points.FirstOrDefault(p => p.pointIndex == cond.ValueId);
|
||||||
|
actualValue = point.pointIndex == 0 && !points.Any(p => p.pointIndex == cond.ValueId) && points.Count > 0
|
||||||
|
? points.First().value : point.value;
|
||||||
|
if (point.pointIndex == 0 && points.Count == 0) actualValue = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滞后窗:已触发过则用恢复阈值
|
||||||
|
bool isTriggered = cond.LastTriggered.HasValue;
|
||||||
|
double target = isTriggered
|
||||||
|
? (double)(cond.RecoveryThreshold_Numeric ?? cond.TargetValue_Number ?? 0)
|
||||||
|
: (double)(cond.TargetValue_Number ?? 0);
|
||||||
|
|
||||||
|
bool met = Compare(actualValue, cond.CompareOperator ?? "大于", target);
|
||||||
|
results.Add(met);
|
||||||
|
if (met) cond.LastTriggered = DateTime.Now;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool finalResult = rule.JudgmentMode == "AND"
|
||||||
|
? results.All(r => r)
|
||||||
|
: results.Any(r => r);
|
||||||
|
return Task.FromResult(finalResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Compare(double? actual, string op, double target)
|
||||||
|
{
|
||||||
|
double v = actual ?? double.MinValue;
|
||||||
|
return op switch
|
||||||
|
{
|
||||||
|
"大于" => v > target,
|
||||||
|
"小于" => v < target,
|
||||||
|
"等于" => Math.Abs(v - target) < 0.001,
|
||||||
|
"大于等于" => v >= target,
|
||||||
|
"小于等于" => v <= target,
|
||||||
|
"不等于" => Math.Abs(v - target) > 0.001,
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// 动作执行
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
private async Task ExecuteActionsAsync(warehouse_rule rule,
|
||||||
|
Dictionary<int, (string adapterCode, string sourceId, string baseUrl)> deviceMap)
|
||||||
|
{
|
||||||
|
var actions = rule.warehouse_ruleaction ?? new();
|
||||||
|
if (!actions.Any()) return;
|
||||||
|
|
||||||
|
// 冷却检查
|
||||||
|
if (rule.LastTriggered.HasValue && rule.CooldownSec > 0)
|
||||||
|
{
|
||||||
|
if ((DateTime.Now - rule.LastTriggered.Value).TotalSeconds < rule.CooldownSec)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tasks = actions.Select(a => ExecuteSingleActionAsync(a, deviceMap));
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExecuteSingleActionAsync(warehouse_ruleaction action,
|
||||||
|
Dictionary<int, (string adapterCode, string sourceId, string baseUrl)> deviceMap)
|
||||||
|
{
|
||||||
|
var actionType = action.ActionType ?? action.Type ?? "控制";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
switch (actionType)
|
||||||
|
{
|
||||||
|
case "控制":
|
||||||
|
if (action.DeviceId.HasValue && deviceMap.TryGetValue(action.DeviceId.Value, out var dev))
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(dev.baseUrl))
|
||||||
|
{
|
||||||
|
var pointIndex = action.ValueId ?? 0;
|
||||||
|
var value = (double)(action.TargetValue_Switch == "开" ? 1 : action.TargetValue_Number ?? 0);
|
||||||
|
await _gatewayClient.ControlDeviceAsync(dev.baseUrl, dev.adapterCode, dev.sourceId, pointIndex, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "告警":
|
||||||
|
if (action.Alert == "是" && action.DeviceId.HasValue)
|
||||||
|
{
|
||||||
|
var alarm = new iot_alarm
|
||||||
|
{
|
||||||
|
SourceAlarmId = $"rule-{action.RuleID}-{DateTime.Now.Ticks}",
|
||||||
|
DeviceId = action.DeviceId.Value,
|
||||||
|
AlarmLevel = "重要",
|
||||||
|
AlarmDesc = action.AlertMessage ?? $"规则触发",
|
||||||
|
StartTime = DateTime.Now,
|
||||||
|
State = "未确认",
|
||||||
|
AdapterCode = "RuleEngine",
|
||||||
|
CreateDate = DateTime.Now
|
||||||
|
};
|
||||||
|
_alarmRepo.Add(alarm);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "通知":
|
||||||
|
await _hub.Clients.All.SendAsync("RuleTriggered", new
|
||||||
|
{
|
||||||
|
title = action.AlertMessage ?? "规则触发",
|
||||||
|
alertMessage = action.AlertMessage,
|
||||||
|
deviceId = action.DeviceId
|
||||||
|
}, cts.Token);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { /* 超时 */ }
|
||||||
|
catch { /* 单动作失败不阻塞 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,9 +14,12 @@ namespace VolPro.Warehouse.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class SyncDevicesJob : IJob
|
public class SyncDevicesJob : IJob
|
||||||
{
|
{
|
||||||
public async Task Execute(IJobExecutionContext context)
|
private readonly IServiceProvider _sp;
|
||||||
|
public SyncDevicesJob(IServiceProvider sp) { _sp = sp; }
|
||||||
|
|
||||||
|
public async Task Execute(IJobExecutionContext? context)
|
||||||
{
|
{
|
||||||
var sp = (IServiceProvider)context.JobDetail.JobDataMap["ServiceProvider"];
|
var sp = _sp;
|
||||||
var gwSvc = sp.GetService<Igateway_nodesService>();
|
var gwSvc = sp.GetService<Igateway_nodesService>();
|
||||||
var client = sp.GetService<GatewayClient>();
|
var client = sp.GetService<GatewayClient>();
|
||||||
if (gwSvc == null || client == null) return;
|
if (gwSvc == null || client == null) return;
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ namespace Warehouse.Services
|
|||||||
/// <param name="d">同步设备条目</param>
|
/// <param name="d">同步设备条目</param>
|
||||||
/// <param name="gatewayNodeId">网关节点ID</param>
|
/// <param name="gatewayNodeId">网关节点ID</param>
|
||||||
/// <param name="existingIds">已有设备映射表 (AdapterCode, SourceId) → DeviceId</param>
|
/// <param name="existingIds">已有设备映射表 (AdapterCode, SourceId) → DeviceId</param>
|
||||||
|
[Obsolete("已迁移至 gateway_nodesService.SyncDevicesAsync")]
|
||||||
public async Task UpsertDeviceAsync(SyncDeviceItem d, int gatewayNodeId, Dictionary<(string, string), int> existingIds)
|
public async Task UpsertDeviceAsync(SyncDeviceItem d, int gatewayNodeId, Dictionary<(string, string), int> existingIds)
|
||||||
{
|
{
|
||||||
var db = _repository.DbContext;
|
var db = _repository.DbContext;
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ namespace Warehouse.Services
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<gateway_nodes> RegisterNodeAsync(string nodeCode, string token, string adapterTypes, string baseUrl)
|
public async Task<gateway_nodes> RegisterNodeAsync(string nodeCode, string token, string adapterTypes, string baseUrl)
|
||||||
{
|
{
|
||||||
var existing = _repository.DbContext.Queryable<gateway_nodes>()
|
var existing = await _repository.FindAsIQueryable<gateway_nodes>()
|
||||||
.First(x => x.NodeCode == nodeCode);
|
.FirstOrDefaultAsync(x => x.NodeCode == nodeCode);
|
||||||
|
|
||||||
gateway_nodes entity;
|
gateway_nodes entity;
|
||||||
if (existing != null)
|
if (existing != null)
|
||||||
@@ -91,8 +91,8 @@ namespace Warehouse.Services
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task UpdateHeartbeatAsync(string nodeCode, string token)
|
public async Task UpdateHeartbeatAsync(string nodeCode, string token)
|
||||||
{
|
{
|
||||||
var entity = _repository.DbContext.Queryable<gateway_nodes>()
|
var entity = _repository.FindAsIQueryable<gateway_nodes>()
|
||||||
.First(x => x.NodeCode == nodeCode && x.NodeToken == token);
|
.FirstOrDefaultAsync(x => x.NodeCode == nodeCode && x.NodeToken == token);
|
||||||
if (entity == null)
|
if (entity == null)
|
||||||
throw new UnauthorizedAccessException("认证失败:NodeCode 或 Token 无效");
|
throw new UnauthorizedAccessException("认证失败:NodeCode 或 Token 无效");
|
||||||
|
|
||||||
|
|||||||
3419
doc/对接文档/GoWVP接口文档.md
Normal file
3419
doc/对接文档/GoWVP接口文档.md
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
|||||||
# KMS 钥匙柜适配器 — 任务清单
|
# KMS 钥匙柜适配器 — 任务清单
|
||||||
|
|
||||||
> **基准文档**: `doc/设计文档/KMS钥匙柜适配器详细设计文档.md` v2.1
|
> **基准文档**: `doc/设计文档/KMS钥匙柜适配器详细设计文档.md`
|
||||||
> **分支**: gateway-dev
|
> **分支**: gateway-dev
|
||||||
> **原则**: 严格按照设计文档执行,严禁无中生有。网关/Vol.Pro 改动放倒数第二步,联调放最后。
|
> **原则**: 严格按照设计文档执行,不凭空添加。网关/Vol.Pro 改动放倒数第二步,联调放最后。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
### K1.1 认证模型
|
### K1.1 认证模型
|
||||||
- [ ] 创建 `KmsModels.cs`
|
- [ ] 创建 `KmsModels.cs`
|
||||||
- [ ] 添加 `KmsTokenResponse { Code, Token, Msg }`
|
- [ ] `KmsTokenResponse { Code, Token, Msg }`
|
||||||
|
|
||||||
### K1.2 第三方接口响应模型(2.18.X)
|
### K1.2 第三方接口响应模型(2.18.X)
|
||||||
- [ ] `KmsOpenerListResponse { Code, Msg, Rows }`
|
- [ ] `KmsOpenerListResponse { Code, Msg, Rows }`
|
||||||
@@ -39,20 +39,10 @@
|
|||||||
- [ ] `KmsRecordListResponse { Code, Msg, Total, Rows }`
|
- [ ] `KmsRecordListResponse { Code, Msg, Total, Rows }`
|
||||||
- [ ] `KmsRecord { Uuid, LockerName, LockholeSort, OpenerName, StaffName, BorrowTime, ReturnTime, Type }`
|
- [ ] `KmsRecord { Uuid, LockerName, LockholeSort, OpenerName, StaffName, BorrowTime, ReturnTime, Type }`
|
||||||
|
|
||||||
### K1.3 标准接口响应模型(2.3-2.17)
|
### K1.3 编译验证
|
||||||
- [ ] `KmsHandoverInfo` — 交接记录
|
- [ ] `dotnet build` → 0 错误
|
||||||
- [ ] `KmsPermissionListResponse` + `KmsPermission` — 授权记录
|
|
||||||
- [ ] `KmsStaffListResponse` + `KmsStaff` — 员工
|
|
||||||
- [ ] `KmsLockerListResponse` + `KmsLockerInfo` — 柜体
|
|
||||||
- [ ] `KmsLockholeListResponse` + `KmsLockholeInfo` — 锁孔
|
|
||||||
- [ ] `KmsOpenerListResponse2` + `KmsOpenerInfo` — 钥匙
|
|
||||||
- [ ] `KmsStaffOpenerListResponse` + `KmsStaffOpener` — 员工可借
|
|
||||||
- [ ] `KmsRemotePermissionRequest` — 远程授权请求(联调时确认字段)
|
|
||||||
|
|
||||||
### K1.4 编译验证
|
> **K1 提交点**: `PhaseK1_models — KmsModels.cs 完整定义全部响应 DTO`
|
||||||
- [ ] `dotnet build` → 0 错误(DTO 引用 Core 的 `StandardDevice`/`StandardAlarm` 等确认无编译错误)
|
|
||||||
|
|
||||||
> **K1 提交点**: `PhaseK1_models — KmsModels.cs 完整定义全部 15 个 DTO`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -61,269 +51,133 @@
|
|||||||
### K2.1 创建 KmsAuthHelper.cs
|
### K2.1 创建 KmsAuthHelper.cs
|
||||||
- [ ] 构造函数:接收 `HttpClient`, `baseUrl`, `clientId`, `clientSecret`
|
- [ ] 构造函数:接收 `HttpClient`, `baseUrl`, `clientId`, `clientSecret`
|
||||||
- [ ] 属性:`_token` (string?), `_tokenExpiry` (DateTime)
|
- [ ] 属性:`_token` (string?), `_tokenExpiry` (DateTime)
|
||||||
- [ ] 依赖:`System.Text.Json`, `System.Net.Http.Json`
|
|
||||||
|
|
||||||
### K2.2 GetTokenAsync
|
### K2.2 GetTokenAsync
|
||||||
- [ ] POST `/prod-api/getToken?clientId=xx&clientSecret=yy`
|
- [ ] POST `/prod-api/getToken?clientId=xx&clientSecret=yy`
|
||||||
- [ ] 检查 `resp.EnsureSuccessStatusCode()`
|
|
||||||
- [ ] 反序列化 `KmsTokenResponse`
|
|
||||||
- [ ] 校验 `Code == 200`
|
- [ ] 校验 `Code == 200`
|
||||||
- [ ] 缓存 Token,过期时间 = `UtcNow.AddMinutes(25)`(30 分钟效期,5 分钟余量)
|
- [ ] 缓存 Token,过期时间 = `UtcNow.AddMinutes(25)`(30 分钟效期,5 分钟余量)
|
||||||
|
|
||||||
### K2.3 GetAuthenticatedClientAsync
|
### K2.3 GetAuthenticatedClientAsync
|
||||||
- [ ] 调用 `GetTokenAsync()`
|
- [ ] 创建 `HttpClient`,设置 `Authorization: Bearer {token}`
|
||||||
- [ ] 创建新 `HttpClient`,`BaseAddress = _baseUrl`
|
- [ ] Invalidate() → `_token = null`
|
||||||
- [ ] 设置 Header `Authorization: Bearer {token}`
|
|
||||||
- [ ] 返回 client
|
|
||||||
|
|
||||||
### K2.4 Invalidate
|
### K2.4 编译验证
|
||||||
- [ ] `_token = null` 强制下次重新获取
|
|
||||||
|
|
||||||
### K2.5 编译验证
|
|
||||||
- [ ] `dotnet build` → 0 错误
|
- [ ] `dotnet build` → 0 错误
|
||||||
|
|
||||||
> **K2 提交点**: `PhaseK2_auth — KmsAuthHelper Bearer Token 认证就绪`
|
> **K2 提交点**: `PhaseK2_auth — Bearer Token 认证就绪`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase K3: KmsAdapter 核心方法(预计 1.5h)
|
## Phase K3: KmsAdapter 核心方法(预计 1.5h)
|
||||||
|
|
||||||
### K3.1 类定义与构造函数
|
### K3.1 类定义
|
||||||
- [ ] `public class KmsAdapter : IHasFlatDevices, IHasAlarms`
|
- [ ] `public class KmsAdapter : IHasFlatDevices, IHasAlarms`
|
||||||
- [ ] 字段:`_http`, `_auth` (KmsAuthHelper), `_limiter` (RateLimiter(5))
|
- [ ] 属性:`AdapterCode`, `DisplayName`, `Capabilities`
|
||||||
- [ ] 属性:`AdapterCode`, `DisplayName`, `Capabilities { HasFlatDevices=true, HasAlarms=true }`
|
|
||||||
- [ ] 构造函数:注入 `httpClient`, `baseUrl`, `clientId`, `clientSecret`
|
|
||||||
|
|
||||||
### K3.2 InitializeAsync
|
### K3.2 HealthCheckAsync(2.18.1)
|
||||||
- [ ] `await _auth.GetTokenAsync()`
|
- [ ] GET `/prod-api/heartBeat`
|
||||||
|
- [ ] 异常捕获返回 false + Console.Error 打日志
|
||||||
|
|
||||||
### K3.3 HealthCheckAsync(2.18.1)
|
### K3.3 GetDevicesAsync(2.18.4)
|
||||||
- [ ] POST `/prod-api/heartBeat` (空 body `{}`)
|
|
||||||
- [ ] 返回 `resp.IsSuccessStatusCode`
|
|
||||||
- [ ] 异常捕获返回 false
|
|
||||||
|
|
||||||
### K3.4 GetDevicesAsync(2.18.4 — 柜体+锁孔 → StandardDevice)
|
|
||||||
- [ ] `await _limiter.WaitAsync()`
|
|
||||||
- [ ] POST `/prod-api/getOpenerList` (body `{}`)
|
- [ ] POST `/prod-api/getOpenerList` (body `{}`)
|
||||||
- [ ] 反序列化 `KmsOpenerListResponse`
|
- [ ] 遍历柜体/锁孔 → 映射为 StandardDevice
|
||||||
- [ ] 遍历 `Rows`:
|
- [ ] 父设备 `IsParent=是`, 子设备 `ParentSourceId=locker_{id}`
|
||||||
- 每个 `KmsLocker` → `MapLockerToDevice`(父设备,SourceId=`locker_{LockerId}`)
|
|
||||||
- 每个 `KmsLockhole` → `MapLockholeToDevice`(子设备,ParentSourceId=`locker_{LockerId}`)
|
|
||||||
- [ ] IsOnline 判断:`OpenerState == "在位"` → true
|
|
||||||
- [ ] Extra 字典:`{ openerId, openerType, openerState }` / `{ lockerCode, lockholeCount }`
|
|
||||||
- [ ] 返回 `PagedResult<StandardDevice>`
|
|
||||||
|
|
||||||
### K3.5 GetAlarmsAsync(2.18.7 — 告警列表 → StandardAlarm)
|
### K3.4 GetAlarmsAsync(2.18.7)
|
||||||
- [ ] `await _limiter.WaitAsync()`
|
- [ ] POST `/prod-api/getWarningList`
|
||||||
- [ ] POST `/prod-api/getWarningList` (body `{}`)
|
- [ ] 映射 KmsWarning → StandardAlarm
|
||||||
- [ ] 反序列化 `KmsWarningListResponse`
|
- [ ] AlarmId=uuid, Status=Type==1?"未确认":"已结束"
|
||||||
- [ ] 映射:`AlarmId=uuid`, `Title="{lockerName} 锁孔{lockholeSort}: {openerName}"`, `Status=Type==1?"未确认":"已结束"`, `Level="普通"`
|
|
||||||
- [ ] 返回 `PagedResult<StandardAlarm>`
|
|
||||||
|
|
||||||
### K3.6 ConfirmAlarmAsync / EndAlarmAsync
|
### K3.5 ConfirmAlarmAsync / EndAlarmAsync
|
||||||
- [ ] `ConfirmAlarmAsync`: POST `/prod-api/kms/warning/confirm/{alarmId}`
|
- [ ] Confirm 调标准接口;End 留空实现
|
||||||
- [ ] `EndAlarmAsync`: 留空实现(KMS 第三方接口不提供结束告警)
|
|
||||||
|
|
||||||
### K3.7 编译验证
|
### K3.6 编译验证
|
||||||
- [ ] `dotnet build` → 0 错误
|
- [ ] `dotnet build` → 0 错误
|
||||||
|
|
||||||
> **K3 提交点**: `PhaseK3_adapter_core — KmsAdapter 核心4方法就绪(HealthCheck/GetDevices/GetAlarms/Confirm)`
|
> **K3 提交点**: `PhaseK3_adapter_core — 核心4方法就绪`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase K4: KmsAdapter 扩展方法(预计 1h)
|
## Phase K4: 扩展方法(预计 1h)
|
||||||
|
|
||||||
### K4.1 GetBorrowRecordsAsync(2.18.6)
|
### K4.1 借还/授权/员工/登录
|
||||||
- [ ] POST `/prod-api/getRecordList`
|
- [ ] GetBorrowRecordsAsync(2.18.6)
|
||||||
- [ ] 参数:`from`, `to` DateTime?(联调时确认请求体格式)
|
- [ ] GetPermissionListAsync(2.18.5)
|
||||||
- [ ] 返回 `PagedResult<KmsRecord>`
|
- [ ] BatchSyncStaffAsync(2.18.3)
|
||||||
|
- [ ] BatchDeleteStaffAsync(2.18.2)
|
||||||
|
- [ ] RemoteAuthorizeAsync(2.4.3)
|
||||||
|
- [ ] ThirdPlatLoginAsync(2.18.8)
|
||||||
|
|
||||||
### K4.2 GetPermissionListAsync(2.18.5)
|
### K4.2 编译验证
|
||||||
- [ ] POST `/prod-api/getPermissionList`
|
|
||||||
- [ ] 参数:`from`, `to` DateTime?
|
|
||||||
- [ ] 返回 `PagedResult<KmsPermission>`
|
|
||||||
|
|
||||||
### K4.3 BatchSyncStaffAsync(2.18.3)
|
|
||||||
- [ ] POST `/prod-api/batchSyncStaff`
|
|
||||||
- [ ] 请求体:`new { staff = staffList }`
|
|
||||||
- [ ] `resp.EnsureSuccessStatusCode()`
|
|
||||||
|
|
||||||
### K4.4 BatchDeleteStaffAsync(2.18.2)
|
|
||||||
- [ ] POST `/prod-api/batchDeleteStaff`
|
|
||||||
- [ ] 请求体:`List<string>` (staffUuid 数组)
|
|
||||||
- [ ] `resp.EnsureSuccessStatusCode()`
|
|
||||||
|
|
||||||
### K4.5 RemoteAuthorizeAsync(2.4.3)
|
|
||||||
- [ ] POST `/prod-api/kms/permission/remote`
|
|
||||||
- [ ] 请求体:`KmsRemotePermissionRequest`(联调确认字段)
|
|
||||||
|
|
||||||
### K4.6 ThirdPlatLoginAsync(2.18.8)
|
|
||||||
- [ ] POST `/thirdPlatlogin?username={username}`
|
|
||||||
- [ ] 处理 302 重定向:返回 `Location` header 或响应体
|
|
||||||
- [ ] 超时设置 15s
|
|
||||||
|
|
||||||
### K4.7 编译验证
|
|
||||||
- [ ] `dotnet build` → 0 错误
|
- [ ] `dotnet build` → 0 错误
|
||||||
|
|
||||||
> **K4 提交点**: `PhaseK4_adapter_ext — 6个扩展方法全部就绪(记录/同步/授权/登录)`
|
> **K4 提交点**: `PhaseK4_adapter_ext — 6个扩展方法就绪`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase K5: 配置与注册(预计 15min)
|
## Phase K5: 配置与注册(预计 15min)
|
||||||
|
|
||||||
### K5.1 KmsConfig POCO
|
### K5.1 KmsConfig POCO
|
||||||
- [ ] 在 `Program.cs` 同级新增 `KmsConfig` 类
|
- [ ] 在 Program.cs 同级加 class,属性:`InstanceName, BaseUrl, ClientId, ClientSecret`
|
||||||
- [ ] 属性:`InstanceName?`, `BaseUrl`, `ClientId`, `ClientSecret`
|
|
||||||
|
|
||||||
### K5.2 appsettings.json
|
### K5.2 appsettings.json
|
||||||
- [ ] 新增 `KMS` 数组配置段
|
- [ ] 新增 KMS 数组配置段
|
||||||
- [ ] 配置项:`InstanceName`, `BaseUrl`, `ClientId`, `ClientSecret`
|
|
||||||
|
|
||||||
### K5.3 Program.cs 注册
|
### K5.3 Program.cs 注册
|
||||||
- [ ] `var kmsList = app.Configuration.GetSection("KMS").Get<List<KmsConfig>>() ?? new();`
|
- [ ] `var kmsList = app.Configuration.GetSection("KMS").Get<List<KmsConfig>>() ?? new();`
|
||||||
- [ ] foreach 注册 `KmsAdapter("KMS:{InstanceName}", http, baseUrl, clientId, clientSecret)`
|
- [ ] foreach 注册 `KmsAdapter("KMS:{InstanceName}", ...)`
|
||||||
- [ ] 适配器编码加入 `adapterTypes` 拼接
|
|
||||||
|
|
||||||
### K5.4 编译验证
|
> **K5 提交点**: `PhaseK5_config — 配置+注册就绪`
|
||||||
- [ ] `dotnet build` → 0 错误
|
|
||||||
|
|
||||||
> **K5 提交点**: `PhaseK5_config — KMS多实例配置+Program.cs注册就绪`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase K6: 编译与自测(预计 15min)
|
## Phase K6: 编译与自测(预计 15min)
|
||||||
|
|
||||||
### K6.1 全量编译
|
### K6.1 编译验证
|
||||||
- [ ] `dotnet build` → 0 错误(确认 KMS 适配器不引入外部依赖)
|
|
||||||
|
|
||||||
### K6.2 启动测试
|
|
||||||
- [ ] `dotnet run` 启动网关
|
|
||||||
- [ ] 检查控制台输出:`[Gateway] N 个适配器已注册: Owl:main,MC4:31ku,KMS:main`
|
|
||||||
- [ ] 确认 KMS 初始化失败时打印错误但不阻塞
|
|
||||||
|
|
||||||
> **K6 提交点**: `PhaseK6_build — 网关全量编译通过 KMS适配器热加载不阻塞启动`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase K7: 网关核心与 Host 扩展(预计 1.5h)⚠️ 倒数第二步
|
|
||||||
|
|
||||||
> **说明**: 此阶段按设计文档附录 B 新增 Core 能力接口 + B 组路由,遵循网关设计原则 §3.4。
|
|
||||||
|
|
||||||
### K7.1 新增 IAcceptsControl 接口
|
|
||||||
- [ ] 创建 `Core/Abstractions/IAcceptsControl.cs`
|
|
||||||
- [ ] 方法:`Task<ControlResult> SendControlAsync(sourceDeviceId, command, parameters)`
|
|
||||||
- [ ] 新增 `Core/Models/ControlResult.cs`:`{ Success, Message }`
|
|
||||||
|
|
||||||
### K7.2 新增 IHasBusinessLogs 接口
|
|
||||||
- [ ] 创建 `Core/Abstractions/IHasBusinessLogs.cs`
|
|
||||||
- [ ] 方法:`Task<PagedResult<BusinessLogEntry>> GetBusinessLogsAsync(logType, from, to, page, size, filters)`
|
|
||||||
- [ ] 新增 `Core/Models/BusinessLogEntry.cs`:`{ LogId, LogType, DeviceSourceId, StaffName, Description, CreatedAt, Extra }`
|
|
||||||
|
|
||||||
### K7.3 新增 IAcceptsDataSync 接口
|
|
||||||
- [ ] 创建 `Core/Abstractions/IAcceptsDataSync.cs`
|
|
||||||
- [ ] 方法:`Task<SyncResult> SyncDataAsync(dataType, items)`
|
|
||||||
- [ ] 方法:`Task<SyncResult> DeleteDataAsync(dataType, ids)`
|
|
||||||
- [ ] 新增 `Core/Models/SyncResult.cs`:`{ SuccessCount, FailCount, Message }`
|
|
||||||
|
|
||||||
### K7.4 KmsAdapter 实现新接口
|
|
||||||
- [ ] `KmsAdapter` 增加 `: IAcceptsControl, IHasBusinessLogs, IAcceptsDataSync`
|
|
||||||
- [ ] `SendControlAsync`:调 `RemoteAuthorizeAsync`,command="open" 时调 `/kms/permission/remote`
|
|
||||||
- [ ] `GetBusinessLogsAsync`:按 logType 分发到 `GetBorrowRecordsAsync` / `GetPermissionListAsync` / 交接记录
|
|
||||||
- [ ] `SyncDataAsync`:dataType="staff" 时调 `BatchSyncStaffAsync`
|
|
||||||
- [ ] `DeleteDataAsync`:dataType="staff" 时调 `BatchDeleteStaffAsync`
|
|
||||||
|
|
||||||
### K7.5 Program.cs 新增 B 组路由
|
|
||||||
- [ ] `POST /api/gateway/control/{adapter}` — `IAcceptsControl.SendControlAsync`
|
|
||||||
- [ ] `GET /api/gateway/logs/{adapter}` — `IHasBusinessLogs.GetBusinessLogsAsync`
|
|
||||||
- [ ] `POST /api/gateway/sync/{adapter}` — `IAcceptsDataSync.SyncDataAsync`
|
|
||||||
- [ ] `DELETE /api/gateway/sync/{adapter}` — `IAcceptsDataSync.DeleteDataAsync`
|
|
||||||
|
|
||||||
### K7.6 编译验证
|
|
||||||
- [ ] `dotnet build` → 0 错误
|
- [ ] `dotnet build` → 0 错误
|
||||||
|
|
||||||
> **K7 提交点**: `PhaseK7_gateway — 3个新Core接口+4条B路由+KmsAdapter多接口实现`
|
> **K6 提交点**: `PhaseK6_build — 全量编译通过`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase K8: Vol.Pro 管理端配套(预计 1h)⚠️ 倒数第二步
|
## Phase K7: Vol.Pro 端配套(预计 1h)
|
||||||
|
|
||||||
### K8.1 数据字典补充
|
### K7.1 字典
|
||||||
- [ ] 管理端 → 字典管理 → 设备种类新增:"智能钥匙柜" / "钥匙位"
|
- [ ] 管理端设备种类字典 ← "智能钥匙柜" + "钥匙位"
|
||||||
|
|
||||||
### K8.2 前端操作列扩展
|
### K7.2 前端按钮
|
||||||
- [ ] 编辑 `web.vite/src/views/warehouse/device_manager/base_device.vue`
|
- [ ] `base_device.vue` 操作列:门禁设备 → [开门] [授权] 按钮
|
||||||
- [ ] `onInited` 的 render 函数中增加 `DeviceGroup==='门禁设备'` 分支
|
|
||||||
- [ ] 显示 "开门" 按钮(调用网关 B8)
|
|
||||||
- [ ] 显示 "权限" 下拉菜单(永久授权/临时授权/取消授权)
|
|
||||||
|
|
||||||
### K8.3 前端 API 调用
|
> **K7 提交点**: `PhaseK7_volpro — 字典+前端就绪`
|
||||||
- [ ] `fetch()` 调网关 `http://localhost:5100/api/gateway/control/KMS:main`
|
|
||||||
- [ ] 请求体:`{ sourceDeviceId, command: "open", parameters: { openerId, staffId } }`
|
|
||||||
|
|
||||||
### K8.4 编译验证
|
|
||||||
- [ ] `npm run dev` → 无编译错误
|
|
||||||
|
|
||||||
> **K8 提交点**: `PhaseK8_volpro — 字典+前端操作按钮就绪`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase K9: 联调验证(预计 3h)⚠️ 最后
|
## Phase K8: 联调验证(预计 3h,需 KMS 环境)
|
||||||
|
|
||||||
> **前置条件**: KMS 服务端可访问,已分配 clientId/clientSecret
|
### K8.1 认证
|
||||||
|
- [ ] 网关启动 → KmsAdapter.InitializeAsync 成功
|
||||||
|
|
||||||
### K9.1 认证联调
|
### K8.2 设备/告警/记录
|
||||||
- [ ] 网关启动 → KmsAdapter.InitializeAsync 成功获取 Token
|
- [ ] /api/gateway/devices?adapter=KMS:main → 返回柜体+锁孔
|
||||||
- [ ] Token 过期自动刷新验证
|
- [ ] /api/gateway/alarms/KMS:main → 返回告警列表
|
||||||
- [ ] 错误 clientSecret → 网关控制台打印初始化失败日志
|
- [ ] /api/gateway/control/KMS:main → 远程开门
|
||||||
|
|
||||||
### K9.2 设备同步联调(2.18.4)
|
### K9: 联调文档记录
|
||||||
- [ ] `/api/gateway/health` 返回 KMS 适配器在线
|
- [ ] 记录异常接口到 KMS_联调笔记.txt
|
||||||
- [ ] `/api/gateway/devices?adapter=KMS:main` 返回柜体+锁孔设备树
|
|
||||||
- [ ] 管理端 base_device 列表显示 KMS 设备(AdapterCode=KMS:main)
|
|
||||||
|
|
||||||
### K9.3 告警同步联调(2.18.7)
|
> **K8 提交点**: `PhaseK8_integration — 全链路联调通过`
|
||||||
- [ ] `/api/gateway/alarms/KMS:main` 返回告警列表
|
|
||||||
- [ ] 管理端 iot_alarm 表有记录
|
|
||||||
|
|
||||||
### K9.4 远程控制联调(2.4.3)
|
|
||||||
- [ ] `/api/gateway/control/KMS:main` → 远程开门 → KMS 端锁孔门开
|
|
||||||
|
|
||||||
### K9.5 记录查询联调(2.18.6)
|
|
||||||
- [ ] `/api/gateway/logs/KMS:main?logType=borrow` 返回借还记录
|
|
||||||
|
|
||||||
### K9.6 员工同步联调(2.18.3)
|
|
||||||
- [ ] `/api/gateway/sync/KMS:main` → 批量同步员工成功
|
|
||||||
|
|
||||||
### K9.7 异常场景
|
|
||||||
- [ ] KMS 服务离线 → `/api/gateway/health` 中 KMS 返回 unhealthy
|
|
||||||
- [ ] KMS 恢复 → 下次心跳自动变 healthy
|
|
||||||
- [ ] 并发请求超过 5 QPS → 限流生效不崩溃
|
|
||||||
|
|
||||||
### K9.8 验收
|
|
||||||
- [ ] 网关 + Vol.Pro + KMS 三端数据一致
|
|
||||||
- [ ] 管理端可查看 KMS 设备树、告警
|
|
||||||
- [ ] 前端可远程开门
|
|
||||||
|
|
||||||
> **K9 提交点**: `PhaseK9_integration — 全链路联调通过`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 任务总览
|
| Phase | 内容 | 文件 | 预计 |
|
||||||
|
|
||||||
| Phase | 内容 | 文件数 | 预计 |
|
|
||||||
|:---:|------|:---:|:---:|
|
|:---:|------|:---:|:---:|
|
||||||
| K0 | 项目骨架 | 2 | 15min |
|
| K0 | 项目骨架 | 2 | 15min |
|
||||||
| K1 | KmsModels 全部 DTO | 1 | 1h |
|
| K1 | 全部 DTO | 1 | 1h |
|
||||||
| K2 | KmsAuthHelper | 1 | 30min |
|
| K2 | AuthHelper | 1 | 30min |
|
||||||
| K3 | KmsAdapter 核心方法 | 1 | 1.5h |
|
| K3 | 核心方法 | 1 | 1.5h |
|
||||||
| K4 | KmsAdapter 扩展方法 | 1 | 1h |
|
| K4 | 扩展方法 | 1 | 1h |
|
||||||
| K5 | 配置与注册 | 3 | 15min |
|
| K5 | 配置注册 | 3 | 15min |
|
||||||
| K6 | 编译自测 | — | 15min |
|
| K6 | 编译 | — | 15min |
|
||||||
| K7 | 网关 Core + Host 扩展 | 6 | 1.5h |
|
| K7 | VolPro配套 | 2 | 1h |
|
||||||
| K8 | Vol.Pro 管理端配套 | 2 | 1h |
|
| K8 | 联调 | — | 3h |
|
||||||
| K9 | 联调验证 | — | 3h |
|
| **合计** | — | **11** | **~9h** |
|
||||||
| **合计** | — | **17** | **~10h** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
> **版本**: 1.0 / 2025-05-19 / 严格按照 `KMS钥匙柜适配器详细设计文档.md` v2.1 制订
|
|
||||||
|
|||||||
174
doc/设计文档/定时任务API化整改方案_v1.0.md
Normal file
174
doc/设计文档/定时任务API化整改方案_v1.0.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# 定时任务 API 化整改方案 v1.0
|
||||||
|
|
||||||
|
> **版本**: 1.0
|
||||||
|
> **日期**: 2026-06-04
|
||||||
|
> **背景**: VolPro 框架的 Quartz 机制基于 `[ApiTask]` + URL 调用,不支持 `IJob` 接口
|
||||||
|
> **现状**: 4 个 IJob 实现(SyncDevices/HeartbeatMonitor/RealtimePoll/RuleEngineJob)需迁移为 API 端点
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 影响范围
|
||||||
|
|
||||||
|
| 任务 | 当前文件 | 需改为 | 调度间隔 |
|
||||||
|
|------|------|------|:---:|
|
||||||
|
| 设备同步 | `SyncDevicesJob.cs` (IJob) | Controller + `[ApiTask]` | 每5分钟 |
|
||||||
|
| 心跳监控 | `HeartbeatMonitorJob.cs` (IJob) | Controller + `[ApiTask]` | 每15秒 |
|
||||||
|
| 实时轮询 | `RealtimePollJob.cs` (IJob) | Controller + `[ApiTask]` | 每10秒 |
|
||||||
|
| 规则引擎 | `RuleEngineJob.cs` (IJob) | Controller + `[ApiTask]` | 每10秒 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 整改步骤
|
||||||
|
|
||||||
|
### 步骤 T1: 创建任务调度 Controller(预计 30min)
|
||||||
|
|
||||||
|
**新建文件**: `api_sqlsugar/VolPro.WebApi/Controllers/Warehouse/TaskController.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using VolPro.Core.Filters;
|
||||||
|
using Warehouse.Services;
|
||||||
|
|
||||||
|
namespace Warehouse.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 定时任务 API 端点。
|
||||||
|
/// VolPro 框架通过 Sys_QuartzOptions 配置 URL+Cron 定时调用。
|
||||||
|
/// 每个方法加 [ApiTask] 属性以允许框架匿名调用。
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/task")]
|
||||||
|
public class TaskController : Controller
|
||||||
|
{
|
||||||
|
/// <summary>T1: 设备同步 — 遍历在线网关触发全量设备同步</summary>
|
||||||
|
[ApiTask]
|
||||||
|
[HttpGet, HttpPost, Route("syncDevices")]
|
||||||
|
public async Task<IActionResult> SyncDevices()
|
||||||
|
{
|
||||||
|
var sp = HttpContext.RequestServices;
|
||||||
|
var engine = sp.GetService<SyncDevicesJob>();
|
||||||
|
if (engine != null) await engine.Execute(null!);
|
||||||
|
return Ok(new { time = DateTime.Now, status = "ok" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>T2: 心跳监控 — 扫描超时网关标记离线</summary>
|
||||||
|
[ApiTask]
|
||||||
|
[HttpGet, HttpPost, Route("heartbeatMonitor")]
|
||||||
|
public async Task<IActionResult> HeartbeatMonitor()
|
||||||
|
{
|
||||||
|
var sp = HttpContext.RequestServices;
|
||||||
|
var engine = sp.GetService<HeartbeatMonitorJob>();
|
||||||
|
if (engine != null) await engine.Execute(null!);
|
||||||
|
return Ok(new { time = DateTime.Now, status = "ok" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>T3: 实时轮询 — 拉取 MC4 IoT 实时值</summary>
|
||||||
|
[ApiTask]
|
||||||
|
[HttpGet, HttpPost, Route("realtimePoll")]
|
||||||
|
public async Task<IActionResult> RealtimePoll()
|
||||||
|
{
|
||||||
|
var sp = HttpContext.RequestServices;
|
||||||
|
var engine = sp.GetService<RealtimePollJob>();
|
||||||
|
if (engine != null) await engine.Execute(null!);
|
||||||
|
return Ok(new { time = DateTime.Now, status = "ok" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>T4: 规则引擎 — 评估规则+执行动作</summary>
|
||||||
|
[ApiTask]
|
||||||
|
[HttpGet, HttpPost, Route("ruleEngine")]
|
||||||
|
public async Task<IActionResult> RuleEngine()
|
||||||
|
{
|
||||||
|
var sp = HttpContext.RequestServices;
|
||||||
|
var engine = sp.GetService<RuleEngineService>();
|
||||||
|
if (engine != null) await engine.EvaluateAllAsync();
|
||||||
|
return Ok(new { time = DateTime.Now, status = "ok" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 T2: 注册 DI(预计 10min)
|
||||||
|
|
||||||
|
**编辑文件**: `api_sqlsugar/VolPro.Core/Extensions/AutofacManager/AutofacContainerModuleExtension.cs`
|
||||||
|
|
||||||
|
或在 Warehouse 项目的 Startup/Module 中注册:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 在 Autofac 注册块中添加
|
||||||
|
builder.RegisterType<SyncDevicesJob>().AsSelf().InstancePerLifetimeScope();
|
||||||
|
builder.RegisterType<HeartbeatMonitorJob>().AsSelf().InstancePerLifetimeScope();
|
||||||
|
builder.RegisterType<RealtimePollJob>().AsSelf().InstancePerLifetimeScope();
|
||||||
|
builder.RegisterType<RuleEngineService>().AsSelf().InstancePerLifetimeScope();
|
||||||
|
```
|
||||||
|
|
||||||
|
如果已由 VolPro 框架自动扫描 Services 目录,则跳过此步骤。
|
||||||
|
|
||||||
|
### 步骤 T3: 管理端配置任务(预计 15min)
|
||||||
|
|
||||||
|
在 Vol.Pro 管理端 → Quartz 管理 → 新建 4 个任务:
|
||||||
|
|
||||||
|
| TaskName | ApiUrl | Cron | Method |
|
||||||
|
|------|------|------|:--:|
|
||||||
|
| 设备同步 | `/api/task/syncDevices` | `0 */5 * * * ?` | POST |
|
||||||
|
| 心跳监控 | `/api/task/heartbeatMonitor` | `0/15 * * * * ?` | POST |
|
||||||
|
| 实时轮询 | `/api/task/realtimePoll` | `0/10 * * * * ?` | POST |
|
||||||
|
| 规则引擎 | `/api/task/ruleEngine` | `0/10 * * * * ?` | POST |
|
||||||
|
|
||||||
|
### 步骤 T4: 保留或删除 IJob 文件(预计 5min)
|
||||||
|
|
||||||
|
**保留** IJob 实现类(`SyncDevicesJob.cs` 等)不删除——Controller 通过 DI 获取它们并调用 `Execute()`。
|
||||||
|
|
||||||
|
只需将 IJob 实现类用 `IServiceProvider` 获取(而非 Quartz 的 `JobDataMap`),因为 Controller 不传 `IJobExecutionContext`。修改 `Execute` 方法签名:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 旧: 依赖 IJobExecutionContext.JobDataMap
|
||||||
|
public async Task Execute(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
var sp = (IServiceProvider)context.JobDetail.JobDataMap["ServiceProvider"];
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新: 注入 IServiceProvider 为构造函数参数
|
||||||
|
public class HeartbeatMonitorJob : IJob
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _sp;
|
||||||
|
public HeartbeatMonitorJob(IServiceProvider sp) { _sp = sp; }
|
||||||
|
|
||||||
|
public async Task Execute(IJobExecutionContext? context)
|
||||||
|
{
|
||||||
|
var gwSvc = _sp.GetService<Igateway_nodesService>();
|
||||||
|
var devRepo = _sp.GetService<Ibase_deviceRepository>();
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 T5: 编译验证(预计 10min)
|
||||||
|
|
||||||
|
- [ ] `dotnet build api_sqlsugar/VolPro.WebApi` → 0 错误
|
||||||
|
- [ ] 确认 `[ApiTask]` 不与其他权限 Filter 冲突
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 改动文件汇总
|
||||||
|
|
||||||
|
| 步骤 | 文件 | 改动 |
|
||||||
|
|:---:|------|------|
|
||||||
|
| T1 | `VolPro.WebApi/Controllers/Warehouse/TaskController.cs` | 新建,4 个 `[ApiTask]` 端点 |
|
||||||
|
| T2 | DI 注册 | 可能不需改动(VolPro 自动扫描) |
|
||||||
|
| T3 | 管理端 Sys_QuartzOptions | 新建 4 条任务记录 |
|
||||||
|
| T4 | 4 个 IJob 实现 | 构造函数改用 IServiceProvider 注入 |
|
||||||
|
| T5 | 全量编译 | 0 错误 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 原 IJob 文件处理方案
|
||||||
|
|
||||||
|
| 文件 | 处理 |
|
||||||
|
|------|------|
|
||||||
|
| `SyncDevicesJob.cs` | 构造函数注入 IServiceProvider,Execute 参数改为 nullable |
|
||||||
|
| `HeartbeatMonitorJob.cs` | 同上 |
|
||||||
|
| `RealtimePollJob.cs` | 同上 |
|
||||||
|
| `RuleEngineJob.cs` | 删除(RuleEngineService 本身就是普通类,不继承 IJob) |
|
||||||
|
|
||||||
|
> `RuleEngineJob.cs` 可直接删除——`RuleEngineService` 是普通类,已被 TaskController 直接调用。
|
||||||
337
doc/设计文档/网关MC4模块整改方案_v1.0.md
Normal file
337
doc/设计文档/网关MC4模块整改方案_v1.0.md
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
# 网关 MC4 模块整改方案 v1.0
|
||||||
|
|
||||||
|
> **版本**: 1.0
|
||||||
|
> **日期**: 2026-06-03
|
||||||
|
> **基准**: `doc/设计文档/网关MC4模块检查报告20260603.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 整改总览
|
||||||
|
|
||||||
|
| 步骤 | 优先级 | 内容 | 文件 | 预计 |
|
||||||
|
|:---:|:---:|------|------|:---:|
|
||||||
|
| M1 | 🔴 P0 | Mc4AuthHelper 认证修复 | Mc4AuthHelper.cs + appsettings | 1h |
|
||||||
|
| M2 | 🟠 P1 | 批量点位查询 | Mc4Adapter.cs | 30min |
|
||||||
|
| M3 | 🟡 P2 | 历史告警查询 | Mc4Adapter.cs | 30min |
|
||||||
|
| M4 | 🟡 P2 | B4-batch 路由改用 native batch | Program.cs | 15min |
|
||||||
|
| M5 | 验证 | 编译 + 联调 | — | 30min |
|
||||||
|
| **合计** | — | — | **4 文件** | **~3h** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 步骤 M1: Mc4AuthHelper 认证修复(预计 1h)
|
||||||
|
|
||||||
|
### 2.1 问题
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 当前: 调 /conf/get (返回 { "encrypt": true }),误读为 Token
|
||||||
|
var resp = await _http.PostAsync($"{_baseUrl}/api/central/auth/conf/get", null);
|
||||||
|
var result = JsonSerializer.Deserialize<Mc4AuthResponse>(json);
|
||||||
|
_token = result?.Token ?? ""; // Token 始终为 null
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 MC4.0 实际认证流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. POST /api/central/auth/conf/get → { "encrypt": true/false }
|
||||||
|
2. 若 encrypt=true → 密码 MD5(原始密码)
|
||||||
|
3. POST /api/central/auth/login {
|
||||||
|
"account": "admin",
|
||||||
|
"password": "md5或原始密码"
|
||||||
|
}
|
||||||
|
→ { "token": "xxx", "id": 0, "account": "admin", "name": "管理员" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 修改后的 Mc4AuthHelper
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class Mc4AuthHelper
|
||||||
|
{
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private readonly string _baseUrl;
|
||||||
|
private readonly string _account;
|
||||||
|
private readonly string _password;
|
||||||
|
private string? _token;
|
||||||
|
private DateTime _tokenExpiry = DateTime.MinValue;
|
||||||
|
private bool? _needMd5;
|
||||||
|
|
||||||
|
public Mc4AuthHelper(HttpClient http, string baseUrl, string account, string password)
|
||||||
|
{
|
||||||
|
_http = http;
|
||||||
|
_baseUrl = baseUrl.TrimEnd('/');
|
||||||
|
_account = account;
|
||||||
|
_password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GetTokenAsync()
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(_token) && DateTime.UtcNow < _tokenExpiry)
|
||||||
|
return _token;
|
||||||
|
|
||||||
|
// 1. 获取加密配置
|
||||||
|
if (!_needMd5.HasValue)
|
||||||
|
{
|
||||||
|
var confResp = await _http.PostAsync($"{_baseUrl}/api/central/auth/conf/get", null);
|
||||||
|
if (confResp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var confJson = await confResp.Content.ReadAsStringAsync();
|
||||||
|
var conf = JsonSerializer.Deserialize<Mc4ConfResponse>(confJson);
|
||||||
|
_needMd5 = conf?.Encrypt ?? false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_needMd5 = false; // 失败时假定不需要加密
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 登录获取 Token
|
||||||
|
var pwd = _needMd5 == true ? ComputeMd5(_password) : _password;
|
||||||
|
var loginBody = JsonSerializer.Serialize(new { account = _account, password = pwd });
|
||||||
|
var resp = await _http.PostAsync($"{_baseUrl}/api/central/auth/login",
|
||||||
|
new StringContent(loginBody, Encoding.UTF8, "application/json"));
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
var json = await resp.Content.ReadAsStringAsync();
|
||||||
|
var result = JsonSerializer.Deserialize<Mc4LoginResponse>(json)
|
||||||
|
?? throw new Exception("MC4 登录失败");
|
||||||
|
_token = result.Token ?? "";
|
||||||
|
_tokenExpiry = DateTime.UtcNow.AddHours(7); // 保守估计 8h
|
||||||
|
return _token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HttpClient> GetAuthenticatedClientAsync()
|
||||||
|
{
|
||||||
|
var token = await GetTokenAsync();
|
||||||
|
var client = new HttpClient { BaseAddress = new Uri(_baseUrl) };
|
||||||
|
if (!string.IsNullOrEmpty(token))
|
||||||
|
client.DefaultRequestHeaders.Add("token", token);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Invalidate() => _token = null;
|
||||||
|
|
||||||
|
private static string ComputeMd5(string input) { /* MD5 实现 or use System.Security.Cryptography */ }
|
||||||
|
|
||||||
|
private class Mc4ConfResponse { public bool? Encrypt { get; set; } }
|
||||||
|
private class Mc4LoginResponse { public string? Token { get; set; } public int Id { get; set; } public string? Account { get; set; } }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 构造函数签名变更
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 旧: public Mc4AuthHelper(HttpClient http, string baseUrl)
|
||||||
|
// 新: public Mc4AuthHelper(HttpClient http, string baseUrl, string account, string password)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 Mc4Adapter 构造函数变更
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 旧:
|
||||||
|
public Mc4Adapter(string adapterCode, HttpClient http, string baseUrl)
|
||||||
|
{
|
||||||
|
_auth = new Mc4AuthHelper(http, baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新:
|
||||||
|
public Mc4Adapter(string adapterCode, HttpClient http, string baseUrl,
|
||||||
|
string account = "admin", string password = "admin")
|
||||||
|
{
|
||||||
|
_auth = new Mc4AuthHelper(http, baseUrl, account, password);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.6 Program.cs 注册变更
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 旧: new Mc4Adapter(code, http, m.BaseUrl)
|
||||||
|
// 新: new Mc4Adapter(code, http, m.BaseUrl,
|
||||||
|
// m.Username ?? "admin", m.Password ?? "admin")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.7 Mc4Config 增加字段
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class Mc4Config
|
||||||
|
{
|
||||||
|
public string? InstanceName { get; set; }
|
||||||
|
public string BaseUrl { get; set; } = "";
|
||||||
|
public string Username { get; set; } = "admin"; // 新增
|
||||||
|
public string Password { get; set; } = "admin"; // 新增
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.8 appsettings.json 更新
|
||||||
|
|
||||||
|
```json
|
||||||
|
"MC4": [
|
||||||
|
{ "InstanceName": "31ku", "BaseUrl": "http://localhost:3000",
|
||||||
|
"Username": "admin", "Password": "your_mc4_password" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.9 编译验证
|
||||||
|
|
||||||
|
`dotnet build gateway/IntegrationGateway.slnx` → 0 错误。
|
||||||
|
|
||||||
|
> **M1 提交点**: `Fix-M1: Mc4AuthHelper 认证修复 conf/get→login + account/password支持`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 步骤 M2: 批量点位查询(预计 30min)
|
||||||
|
|
||||||
|
### 3.1 文件
|
||||||
|
|
||||||
|
`gateway/src/IntegrationGateway.Adapters.MC4/Mc4Adapter.cs`
|
||||||
|
|
||||||
|
### 3.2 新增方法
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>批量获取多个设备的实时点位值(MC4.0 原生 multi/value/get)</summary>
|
||||||
|
public async Task<Dictionary<int, List<Mc4PointValue>>> GetMultiRealtimeValuesAsync(List<int> deviceIds)
|
||||||
|
{
|
||||||
|
await _limiter.WaitAsync();
|
||||||
|
var client = await _auth.GetAuthenticatedClientAsync();
|
||||||
|
var body = JsonSerializer.Serialize(new { ids = deviceIds });
|
||||||
|
var resp = await client.PostAsync("/api/central/point/multi/value/get",
|
||||||
|
new StringContent(body, Encoding.UTF8, "application/json"));
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
var json = await resp.Content.ReadAsStringAsync();
|
||||||
|
var result = JsonSerializer.Deserialize<Dictionary<int, List<Mc4PointValue>>>(json)!;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 编译验证
|
||||||
|
|
||||||
|
`dotnet build` → 0 错误。
|
||||||
|
|
||||||
|
> **M2 提交点**: `Fix-M2: MC4 批量点位查询 GetMultiRealtimeValuesAsync`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 步骤 M3: 历史告警查询(预计 30min)
|
||||||
|
|
||||||
|
### 4.1 新增 DTO
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>MC4.0 历史告警查询请求</summary>
|
||||||
|
public class Mc4HisAlarmQuery
|
||||||
|
{
|
||||||
|
public string From { get; set; } = "";
|
||||||
|
public string To { get; set; } = "";
|
||||||
|
public int Skip { get; set; }
|
||||||
|
public int Limit { get; set; }
|
||||||
|
public int Sort { get; set; } = 1;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 新增方法
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>查询 MC4.0 历史告警(已恢复的告警)</summary>
|
||||||
|
public async Task<PagedResult<StandardAlarm>> GetHisAlarmsAsync(int page, int size, DateTime from, DateTime to)
|
||||||
|
{
|
||||||
|
await _limiter.WaitAsync();
|
||||||
|
var client = await _auth.GetAuthenticatedClientAsync();
|
||||||
|
var body = JsonSerializer.Serialize(new Mc4HisAlarmQuery
|
||||||
|
{
|
||||||
|
From = from.ToString("yyyy-MM-dd HH:mm:ss"),
|
||||||
|
To = to.ToString("yyyy-MM-dd HH:mm:ss"),
|
||||||
|
Skip = (page - 1) * size,
|
||||||
|
Limit = size,
|
||||||
|
Sort = 1
|
||||||
|
});
|
||||||
|
var resp = await client.PostAsync("/api/central/his_alarm/query",
|
||||||
|
new StringContent(body, Encoding.UTF8, "application/json"));
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
var json = await resp.Content.ReadAsStringAsync();
|
||||||
|
var result = JsonSerializer.Deserialize<Mc4AlarmQueryResult>(json)!;
|
||||||
|
return new PagedResult<StandardAlarm>
|
||||||
|
{
|
||||||
|
Items = (result.List ?? new()).Select(MapAlarmItem).ToList(),
|
||||||
|
Total = result.Total
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private StandardAlarm MapAlarmItem(Mc4AlarmItem a) => new()
|
||||||
|
{
|
||||||
|
AlarmId = a.Id ?? "",
|
||||||
|
AdapterCode = AdapterCode,
|
||||||
|
Level = MapAlarmLevel(a.Level),
|
||||||
|
Title = a.Desc ?? "",
|
||||||
|
OccurTime = DateTime.TryParse(a.Stime, out var st) ? st : DateTime.MinValue,
|
||||||
|
Status = MapAlarmState(a.State),
|
||||||
|
ActualValue = a.Soption?.Value,
|
||||||
|
ThresholdValue = a.Eoption?.Value
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 编译验证
|
||||||
|
|
||||||
|
`dotnet build` → 0 错误。
|
||||||
|
|
||||||
|
> **M3 提交点**: `Fix-M3: MC4 历史告警查询 GetHisAlarmsAsync`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 步骤 M4: B4-batch 路由优化(预计 15min)
|
||||||
|
|
||||||
|
### 5.1 修改
|
||||||
|
|
||||||
|
`gateway/src/IntegrationGateway.Host/Program.cs` B4-batch 路由改用 MC4 原生批量接口:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// B4-batch 改用 MC4 原生 multi/value/get
|
||||||
|
app.MapPost("/api/gateway/realtime/{adapter}/batch", async (string adapter, BatchRealtimeRequest req) =>
|
||||||
|
{
|
||||||
|
var a = registry.FindByCode<IHasPoints>(adapter);
|
||||||
|
if (a == null) return Results.NotFound();
|
||||||
|
|
||||||
|
if (a is Mc4Adapter mc4 && req.DeviceIds?.Count > 0)
|
||||||
|
{
|
||||||
|
// MC4.0 原生批量接口
|
||||||
|
var intIds = req.DeviceIds.Select(int.Parse).ToList();
|
||||||
|
var multi = await mc4.GetMultiRealtimeValuesAsync(intIds);
|
||||||
|
return Results.Ok(multi);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他适配器 fallback
|
||||||
|
var results = new Dictionary<string, List<PointValue>>();
|
||||||
|
foreach (var id in req.DeviceIds ?? new())
|
||||||
|
try { results[id] = await a.GetRealtimeValuesAsync(id); } catch { }
|
||||||
|
return Results.Ok(results);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 编译验证
|
||||||
|
|
||||||
|
`dotnet build` → 0 错误。
|
||||||
|
|
||||||
|
> **M4 提交点**: `Fix-M4: B4-batch 优化 MC4原生批量接口`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 步骤 M5: 编译验证 + 联调
|
||||||
|
|
||||||
|
- [ ] `dotnet build gateway/IntegrationGateway.slnx` → 0 错误 0 警告
|
||||||
|
- [ ] MC4 appsettings.json 填入真实 `Username/Password`
|
||||||
|
- [ ] 网关启动 → A1 注册 → A3 同步 MC4 设备树
|
||||||
|
- [ ] B4-batch 调 `multi/value/get` 返回批量值
|
||||||
|
- [ ] 告警查询 `/alarms/MC4:31ku` 有数据
|
||||||
|
- [ ] Mc4AuthHelper Token 非空 → 登录流程正常
|
||||||
|
|
||||||
|
> **M5 提交点**: `Fix-M5: MC4整改全量编译验证通过`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 改动文件汇总
|
||||||
|
|
||||||
|
| 步骤 | 文件 | 改动 |
|
||||||
|
|:---:|------|------|
|
||||||
|
| M1 | `Mc4AuthHelper.cs` | 重写认证流程: conf/get → login |
|
||||||
|
| M1 | `Mc4Adapter.cs` | 构造函数加 account/password |
|
||||||
|
| M1 | `Program.cs` | Mc4Adapter 构造传 Username/Password |
|
||||||
|
| M1 | `appsettings.json` | MC4 数组加 Username/Password |
|
||||||
|
| M2 | `Mc4Adapter.cs` | 新增 GetMultiRealtimeValuesAsync |
|
||||||
|
| M3 | `Mc4Adapter.cs` | 新增 GetHisAlarmsAsync + DTO |
|
||||||
|
| M4 | `Program.cs` | B4-batch 优化 MC4 原生批量 |
|
||||||
131
doc/设计文档/网关MC4模块检查报告20260603.md
Normal file
131
doc/设计文档/网关MC4模块检查报告20260603.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# 网关 MC4 模块检查报告 2026-06-03
|
||||||
|
|
||||||
|
> **基准文档**: `doc/对接文档/MC4.0对外API.md` (31 API)
|
||||||
|
> **检查范围**: `gateway/src/IntegrationGateway.Adapters.MC4/` (Mc4Adapter.cs, Mc4AuthHelper.cs)
|
||||||
|
> **日期**: 2026-06-03
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 覆盖率概览
|
||||||
|
|
||||||
|
MC4.0 接口文档共 **31 个 REST 端点**,当前 Mc4Adapter 覆盖了 **6 个**(19%)。
|
||||||
|
|
||||||
|
| 模块 | 文档端点数 | 已实现 | 缺失 |
|
||||||
|
|------|:---:|:---:|:---:|
|
||||||
|
| 认证 | 3 | 0 | 3 |
|
||||||
|
| 对象树 | 1 | 1 | 0 |
|
||||||
|
| 点位 | 3 | 2 | 1 |
|
||||||
|
| 告警 | 14 | 3 | 11 |
|
||||||
|
| 系统管理 | 10 | 0 | 10 |
|
||||||
|
| **合计** | **31** | **6** | **25** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 已实现接口对照
|
||||||
|
|
||||||
|
| MC4.0 端点 | Mc4Adapter 方法 | 能力接口 | 状态 |
|
||||||
|
|------|------|------|:--:|
|
||||||
|
| /api/central/object/tree | GetObjectTreeAsync | IHasOwnDeviceTree | ✅ |
|
||||||
|
| /api/central/device/point/value/get | GetRealtimeValuesAsync | IHasPoints | ✅ |
|
||||||
|
| /api/central/point/value/set | SetPointValueAsync | IHasPoints | ✅ |
|
||||||
|
| /api/central/alarm/query | GetAlarmsAsync | IHasAlarms | ✅ |
|
||||||
|
| /api/central/alarm/confirm | ConfirmAlarmAsync | IHasAlarms | ✅ |
|
||||||
|
| /api/central/alarm/end | EndAlarmAsync | IHasAlarms | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 🔴 关键问题
|
||||||
|
|
||||||
|
### 3.1 Mc4AuthHelper 认证逻辑错误(🔥 致命)
|
||||||
|
|
||||||
|
**现状**: `GetTokenAsync` 调用 `/api/central/auth/conf/get`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var resp = await _http.PostAsync($"{_baseUrl}/api/central/auth/conf/get", null);
|
||||||
|
var result = JsonSerializer.Deserialize<Mc4AuthResponse>(json);
|
||||||
|
_token = result?.Token ?? "";
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误**: `/api/central/auth/conf/get` 是**密码加密配置查询接口**,返回 `{ "encrypt": true/false }`,**不是 Token 接口**,不包含 `token` 字段。`result?.Token` 始终为 null,`_token` 被设为空字符串。
|
||||||
|
|
||||||
|
**实际登录接口**: `/api/central/auth/login`:
|
||||||
|
```json
|
||||||
|
POST /api/central/auth/login
|
||||||
|
{ "account": "admin", "password": "xxx" }
|
||||||
|
→ { "token": "string", "id": 0, "account": "string", "name": "string" }
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**: MC4.0 可能对大部分 API 不强制 Token 认证(curl 示例中只有 logout 接口显式传了 header)。但当前代码逻辑错误,即便需要 Token 也无法获取。
|
||||||
|
|
||||||
|
**修复**: Mc4AuthHelper 改为先调 `conf/get` 确认加密方式,再用 `account/password` 调 `login` 获取真正的 token。
|
||||||
|
|
||||||
|
### 3.2 缺少批量点位查询(🟠 规则引擎依赖)
|
||||||
|
|
||||||
|
**缺失**: `/api/central/point/multi/value/get`
|
||||||
|
|
||||||
|
请求体 `{ "ids": [1, 2, 3] }` → 一次返回多个设备的实时值。
|
||||||
|
|
||||||
|
**影响**: 当前 B4-batch 接口逐设备调 `GetRealtimeValuesAsync`(单设备接口)。MC4.0 提供原生批量接口,应直接使用以提升规则引擎性能。
|
||||||
|
|
||||||
|
**修复**: 增加 `GetMultiRealtimeValuesAsync(List<int> deviceIds)` 方法,B4-batch 路由优先调此方法。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 缺失项清单
|
||||||
|
|
||||||
|
### 4.1 认证接口(3个)
|
||||||
|
|
||||||
|
| 端点 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `/api/central/auth/conf/get` | 获取密码加密配置(已调但未正确使用) |
|
||||||
|
| `/api/central/auth/login` | 登录获取 Token |
|
||||||
|
| `/api/central/auth/logout` | 注销 |
|
||||||
|
|
||||||
|
### 4.2 设备点位(1个)
|
||||||
|
|
||||||
|
| 端点 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `/api/central/device/point/get` | 查询设备的点位列表(用于发现设备有哪些测点) |
|
||||||
|
|
||||||
|
### 4.3 告警扩展(11个)
|
||||||
|
|
||||||
|
| 端点 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `/api/central/alarm/custom_query_count` | 告警自定义统计数量 |
|
||||||
|
| `/api/central/alarm/custom_query` | 告警自定义查询 |
|
||||||
|
| `/api/central/alarm/get_by_point` | 按点位查询告警 |
|
||||||
|
| `/api/central/alarm/get` | 获取单个告警详情 |
|
||||||
|
| `/api/central/his_alarm/query` | 历史告警查询 |
|
||||||
|
| `/api/central/report/alarm/convergence/query` | 告警聚合报告查询 |
|
||||||
|
| `/api/central/alarm/type/add` | 添加告警类型 |
|
||||||
|
| `/api/central/alarm/type/set` | 修改告警类型 |
|
||||||
|
| `/api/central/alarm/type/del` | 删除告警类型 |
|
||||||
|
| `/api/central/alarm/type/list` | 告警类型列表 |
|
||||||
|
|
||||||
|
### 4.4 系统管理(10个)
|
||||||
|
|
||||||
|
| 端点 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `/api/central/manager/config/set` | 设置系统配置 |
|
||||||
|
| `/api/central/manager/config/get` | 获取系统配置 |
|
||||||
|
| `/api/central/manager/db/backup` | 数据库备份 |
|
||||||
|
| `/api/central/manager/db/restore` | 数据库恢复 |
|
||||||
|
| `/api/central/manager/db/log` | 数据库日志 |
|
||||||
|
| `/api/central/manager/hisdb/backup` | 历史库备份 |
|
||||||
|
| `/api/central/manager/hisdb/restore` | 历史库恢复 |
|
||||||
|
| `/api/central/manager/hisdb/clear` | 清除历史数据 |
|
||||||
|
| `/api/central/manager/picture/clear` | 清除图片 |
|
||||||
|
| `/api/central/manager/video/clear` | 清除视频 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 优先级建议
|
||||||
|
|
||||||
|
| 优先级 | 项目 | 说明 |
|
||||||
|
|:---:|------|------|
|
||||||
|
| 🔴 P0 | Mc4AuthHelper 认证修复 | 当前 Token 获取逻辑根本错误 |
|
||||||
|
| 🟠 P1 | 批量点位查询 (multi/value/get) | 规则引擎 B4-batch 缺少原生高效接口 |
|
||||||
|
| 🟡 P2 | 历史告警查询 | 管理端需要查看已结束的告警 |
|
||||||
|
| 🟡 P2 | 设备点位发现 (device/point/get) | IoT 设备入网时自动发现测点 |
|
||||||
|
| ⚪ P3 | 告警类型 CRUD | 运维操作 |
|
||||||
|
| ⚪ P3 | 系统管理接口 | 运维操作 |
|
||||||
423
doc/设计文档/网关Owl模块整改方案_v1.0.md
Normal file
423
doc/设计文档/网关Owl模块整改方案_v1.0.md
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
# 网关 Owl 模块整改方案 v1.0
|
||||||
|
|
||||||
|
> **版本**: 1.0
|
||||||
|
> **日期**: 2026-06-03
|
||||||
|
> **基准**: `doc/设计文档/网关owl模块检查报告20260603.md`
|
||||||
|
> **架构原则**: 遵循网关设计原则 §3.2-3.4(显式、异步、统一分页、弹性 Extra、不修改已有接口签名)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 整改总览
|
||||||
|
|
||||||
|
| 阶段 | 优先级 | 内容 | 涉及文件 | 预计 |
|
||||||
|
|:---:|:---:|------|------|:---:|
|
||||||
|
| O1 | P0 | 设备通道展开 + OwlDevice 模型补全 | OwlAdapter.cs + OwlModels.cs | 2h |
|
||||||
|
| O2 | P0 | AI 事件接入 IHasAlarms | OwlAdapter.cs + OwlModels.cs | 2h |
|
||||||
|
| O3 | P1 | 回放取流修正 + PTZ 预设位 | OwlAdapter.cs | 1h |
|
||||||
|
| O4 | P2 | AI 检测启停(IAcceptsControl) | OwlAdapter.cs | 1h |
|
||||||
|
| O5 | P2 | 推流/拉流管理(可选独立路由) | Program.cs + OwlAdapter | 1.5h |
|
||||||
|
| O6 | 验证 | 全量编译 + 联调 | — | 1h |
|
||||||
|
| **合计** | — | — | **5 文件** | **~8.5h** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 阶段 O1: 设备通道展开 + 模型补全(预计 2h)
|
||||||
|
|
||||||
|
### 2.1 现状与问题
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 当前: GET /devices → 只返回NVR父设备
|
||||||
|
public async Task<PagedResult<StandardDevice>> GetDevicesAsync(...)
|
||||||
|
{
|
||||||
|
var json = await client.GetStringAsync($"/devices?page={page}&size={size}");
|
||||||
|
// MapDevice: IsParent=true, Category="硬盘录像机" — 无通道子设备
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**后果**: Vol.Pro 设备列表只有 NVR,前端"预览"按钮找不到摄像头通道。
|
||||||
|
|
||||||
|
### 2.2 整改设计
|
||||||
|
|
||||||
|
**改用** `GET /devices/channels` — Owl 的联合接口直接返回设备+通道的扁平列表:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{ "id": "mp123", "type": "DEVICE", "name": "NVR-01", "is_online": "1", "channel_count": 4, ... },
|
||||||
|
{ "id": "mp123/34020000001320000001", "type": "CHANNEL", "did": "mp123", "name": "仓库入口", "is_online": true, "ptztype": 1, ... },
|
||||||
|
{ "id": "mp123/34020000001320000002", "type": "CHANNEL", "did": "mp123", "name": "仓库后门", "is_online": true, "ptztype": 0, ... }
|
||||||
|
],
|
||||||
|
"total": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**映射逻辑**(单次请求完成父子映射):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// OwlDeviceChannel 联合模型
|
||||||
|
public class OwlDeviceChannel
|
||||||
|
{
|
||||||
|
public string? Id { get; set; }
|
||||||
|
public string? Type { get; set; } // "DEVICE" | "CHANNEL"
|
||||||
|
public string? Did { get; set; } // 通道所属设备ID
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public string? IsOnline { get; set; } // DEVICE: "1"/"0", CHANNEL: true/false
|
||||||
|
public string? Manufacturer { get; set; }
|
||||||
|
public string? Model { get; set; }
|
||||||
|
public string? Firmware { get; set; }
|
||||||
|
public string? Longitude { get; set; }
|
||||||
|
public string? Latitude { get; set; }
|
||||||
|
public int? ChannelCount { get; set; }
|
||||||
|
public int? Ptztype { get; set; } // CHANNEL: 0=无云台, 1=方向, 2=预置位
|
||||||
|
public string? App { get; set; } // CHANNEL: 流应用名
|
||||||
|
public string? StreamId { get; set; } // CHANNEL: 流ID
|
||||||
|
// ... 其他字段
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**GetDevicesAsync 重写**:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<PagedResult<StandardDevice>> GetDevicesAsync(int page, int size, string? keyword = null)
|
||||||
|
{
|
||||||
|
await _limiter.WaitAsync();
|
||||||
|
var client = await _auth.GetAuthenticatedClientAsync();
|
||||||
|
var url = $"/devices/channels?page={page}&size=1000"; // 大pageSize一次性获取
|
||||||
|
if (!string.IsNullOrEmpty(keyword)) url += $"&key={Uri.EscapeDataString(keyword)}";
|
||||||
|
var json = await client.GetStringAsync(url);
|
||||||
|
var result = JsonSerializer.Deserialize<OwlPagedResult<OwlDeviceChannel>>(json)!;
|
||||||
|
|
||||||
|
var devices = new List<StandardDevice>();
|
||||||
|
|
||||||
|
// 第一遍: 映射 DEVICE 为父设备
|
||||||
|
var deviceItems = result.Items.Where(x => x.Type == "DEVICE").ToList();
|
||||||
|
var channelItems = result.Items.Where(x => x.Type == "CHANNEL").ToList();
|
||||||
|
|
||||||
|
foreach (var d in deviceItems)
|
||||||
|
{
|
||||||
|
// 收集该设备的通道
|
||||||
|
var childChannels = channelItems.Where(c => c.Did == d.Id).ToList();
|
||||||
|
|
||||||
|
devices.Add(new StandardDevice
|
||||||
|
{
|
||||||
|
SourceId = d.Id ?? "",
|
||||||
|
Name = d.Name ?? d.Id ?? "",
|
||||||
|
Category = "硬盘录像机",
|
||||||
|
Group = "视频设备",
|
||||||
|
IsOnline = d.IsOnline == "1",
|
||||||
|
IsParent = true,
|
||||||
|
IpAddress = d.Address,
|
||||||
|
Extra = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["manufacturer"] = d.Manufacturer,
|
||||||
|
["model"] = d.Model,
|
||||||
|
["firmware"] = d.Firmware,
|
||||||
|
["longitude"] = d.Longitude,
|
||||||
|
["latitude"] = d.Latitude,
|
||||||
|
["channelCount"] = d.ChannelCount ?? childChannels.Count
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 映射通道为子设备
|
||||||
|
foreach (var ch in childChannels)
|
||||||
|
{
|
||||||
|
devices.Add(new StandardDevice
|
||||||
|
{
|
||||||
|
SourceId = ch.Id ?? "",
|
||||||
|
Name = ch.Name ?? $"通道{ch.Id}",
|
||||||
|
Category = "摄像机",
|
||||||
|
Group = "视频设备",
|
||||||
|
IsOnline = ch.IsOnline?.ToLower() == "true" || ch.IsOnline == "1",
|
||||||
|
IsParent = false,
|
||||||
|
ParentSourceId = d.Id,
|
||||||
|
Extra = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["hasPtz"] = (ch.Ptztype ?? 0) > 0 ? "1" : "0",
|
||||||
|
["app"] = ch.App,
|
||||||
|
["streamId"] = ch.StreamId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PagedResult<StandardDevice> { Items = devices, Total = devices.Count };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 影响分析
|
||||||
|
|
||||||
|
| 影响点 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 前端预览按钮 | 现在能找到 `DeviceGroup=视频设备, IsParent=否` 的通道子设备,预览按钮可用 |
|
||||||
|
| 设备树同步 | A3 同步时有父子关系,`ParentSourceId` 解析为父设备 DeviceId |
|
||||||
|
| 视频墙 | 摄像机通道列表包含 `hasPtz` 标识,云台面板按需显示 |
|
||||||
|
| MC4/IoT | 零影响 — 不同适配器独立运行 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 阶段 O2: AI 事件接入 IHasAlarms(预计 2h)
|
||||||
|
|
||||||
|
### 3.1 现状态
|
||||||
|
|
||||||
|
`OwlAdapter` **没有**实现 `IHasAlarms`,AI 事件走不到 Vol.Pro。
|
||||||
|
|
||||||
|
### 3.2 整改设计
|
||||||
|
|
||||||
|
**OwlAdapter 增加 IHasAlarms 实现**:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAcceptsMetadataPush, IHasAlarms
|
||||||
|
{
|
||||||
|
// Capabilities 增加 HasAlarms = true
|
||||||
|
|
||||||
|
/// <summary>GET /events → StandardAlarm[]</summary>
|
||||||
|
public async Task<PagedResult<StandardAlarm>> GetAlarmsAsync(
|
||||||
|
int page, int size, DateTime from, DateTime to, string? level = null, string? state = null)
|
||||||
|
{
|
||||||
|
await _limiter.WaitAsync();
|
||||||
|
var client = await _auth.GetAuthenticatedClientAsync();
|
||||||
|
var fromMs = new DateTimeOffset(from).ToUnixTimeMilliseconds();
|
||||||
|
var toMs = new DateTimeOffset(to).ToUnixTimeMilliseconds();
|
||||||
|
var url = $"/events?page={page}&size={size}&start_ms={fromMs}&end_ms={toMs}";
|
||||||
|
var json = await client.GetStringAsync(url);
|
||||||
|
var result = JsonSerializer.Deserialize<OwlPagedResult<OwlAiEvent>>(json)!;
|
||||||
|
|
||||||
|
return new PagedResult<StandardAlarm>
|
||||||
|
{
|
||||||
|
Items = result.Items.Select(MapEventToAlarm).ToList(),
|
||||||
|
Total = result.Total
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private StandardAlarm MapEventToAlarm(OwlAiEvent e) => new()
|
||||||
|
{
|
||||||
|
AlarmId = $"owl-ai-{e.Id}",
|
||||||
|
AdapterCode = AdapterCode,
|
||||||
|
Level = e.Label switch {
|
||||||
|
"person" => "重要",
|
||||||
|
"car" => "重要",
|
||||||
|
_ => "普通"
|
||||||
|
},
|
||||||
|
Title = $"AI检测: {e.Label} (置信度 {e.Score:P0})",
|
||||||
|
Content = $"通道{e.Cid}: {e.Zones ?? ""}",
|
||||||
|
OccurTime = DateTimeOffset.FromUnixTimeMilliseconds(e.StartedAt ?? 0).DateTime,
|
||||||
|
Status = e.EndedAt > 0 ? "已结束" : "未确认",
|
||||||
|
Extra = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["imagePath"] = e.ImagePath,
|
||||||
|
["score"] = e.Score,
|
||||||
|
["label"] = e.Label,
|
||||||
|
["model"] = e.Model
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task ConfirmAlarmAsync(string alarmId) { /* AI事件不支持确认 */ }
|
||||||
|
public async Task EndAlarmAsync(string alarmId) { /* AI事件不支持结束 */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 事件快照图片
|
||||||
|
|
||||||
|
网关注册一条 B-路由直接代理图片访问:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 在 OwlAdapter 中增加
|
||||||
|
public async Task<byte[]> GetEventImageAsync(string imagePath)
|
||||||
|
{
|
||||||
|
var client = await _auth.GetAuthenticatedClientAsync();
|
||||||
|
return await client.GetByteArrayAsync($"/events/image/{imagePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Program.cs 加路由
|
||||||
|
app.MapGet("/api/gateway/owl/image/{*path}", async (string path, AdapterRegistry registry) =>
|
||||||
|
{
|
||||||
|
var owl = registry.FindByCode<OwlAdapter>("Owl:main");
|
||||||
|
if (owl == null) return Results.NotFound();
|
||||||
|
var bytes = await owl.GetEventImageAsync(path);
|
||||||
|
return Results.File(bytes, "image/jpeg");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 后端 DTO 补充
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>Owl AI 事件</summary>
|
||||||
|
public class OwlAiEvent
|
||||||
|
{
|
||||||
|
public long? Id { get; set; }
|
||||||
|
public string? Did { get; set; } // 设备ID
|
||||||
|
public string? Cid { get; set; } // 通道ID
|
||||||
|
public long? StartedAt { get; set; } // 毫秒时间戳
|
||||||
|
public long? EndedAt { get; set; }
|
||||||
|
public string? Label { get; set; } // person / car / ...
|
||||||
|
public float? Score { get; set; } // 0.0-1.0
|
||||||
|
public string? Zones { get; set; } // 检测区域JSON
|
||||||
|
public string? ImagePath { get; set; }
|
||||||
|
public string? Model { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 阶段 O3: 回放修正 + PTZ 扩展(预计 1h)
|
||||||
|
|
||||||
|
### 4.1 GetPlaybackUrlAsync 修正
|
||||||
|
|
||||||
|
当前手工拼 URL,改为调用 Owl API:
|
||||||
|
|
||||||
|
GoWVP 文档中播放接口 POST /channels/{id}/play 返回的 `PlayOutput.Items[]` 包含 `Hls` 字段。录像回放无需额外接口——同一个 HLS 地址加上 `start_ms`/`end_ms` 参数即可。
|
||||||
|
|
||||||
|
**方案**: 保持当前实现(手工拼 URL 是 Owl 的约定用法),增加 URL 不存在时的 fallback:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<StreamUrls> GetPlaybackUrlAsync(string channelId, DateTime start, DateTime end)
|
||||||
|
{
|
||||||
|
await _limiter.WaitAsync();
|
||||||
|
var client = await _auth.GetAuthenticatedClientAsync();
|
||||||
|
var token = await _auth.GetTokenAsync();
|
||||||
|
var startMs = new DateTimeOffset(start).ToUnixTimeMilliseconds();
|
||||||
|
var endMs = new DateTimeOffset(end).ToUnixTimeMilliseconds();
|
||||||
|
var baseAddr = client.BaseAddress?.ToString().TrimEnd('/') ?? "";
|
||||||
|
return new StreamUrls
|
||||||
|
{
|
||||||
|
Hls = $"{baseAddr}/recordings/channels/{channelId}/index.m3u8?start_ms={startMs}&end_ms={endMs}&token={token}"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
变化:`client.BaseAddress` → 实际 Owl 地址(之前隐式依赖 `HttpClient.BaseAddress` 已包含)。
|
||||||
|
|
||||||
|
### 4.2 PTZ 预设位/巡航
|
||||||
|
|
||||||
|
`PtzControlAsync` 增加 action 参数透传:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task PtzPresetAsync(string channelId, int presetIndex)
|
||||||
|
{
|
||||||
|
await _limiter.WaitAsync();
|
||||||
|
var client = await _auth.GetAuthenticatedClientAsync();
|
||||||
|
await client.PostAsJsonAsync($"/channels/{channelId}/ptz/control",
|
||||||
|
new { action = "preset", preset = presetIndex });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
无需修改 `IHasStreams` 接口——PTZ 扩展通过 `PtzControlAsync(direction: "preset_1")` 或新增公开方法由 B-路由直接调用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 阶段 O4: AI 检测启停(预计 1h)
|
||||||
|
|
||||||
|
### 5.1 通过 IAcceptsControl 暴露
|
||||||
|
|
||||||
|
`OwlAdapter` 实现 `IAcceptsControl`(已在 KMS 适配器中新增的接口):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class OwlAdapter : ..., IAcceptsControl
|
||||||
|
{
|
||||||
|
public async Task<ControlResult> SendControlAsync(string sourceDeviceId, string command, Dictionary<string, object?> parameters)
|
||||||
|
{
|
||||||
|
await _limiter.WaitAsync();
|
||||||
|
var client = await _auth.GetAuthenticatedClientAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
switch (command)
|
||||||
|
{
|
||||||
|
case "ai-enable":
|
||||||
|
await client.PostAsync($"/channels/{sourceDeviceId}/ai/enable", null);
|
||||||
|
break;
|
||||||
|
case "ai-disable":
|
||||||
|
await client.PostAsync($"/channels/{sourceDeviceId}/ai/disable", null);
|
||||||
|
break;
|
||||||
|
case "zone-add":
|
||||||
|
await client.PostAsJsonAsync($"/channels/{sourceDeviceId}/zones", parameters!);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return new ControlResult { Success = false, Message = $"不支持的指令: {command}" };
|
||||||
|
}
|
||||||
|
return new ControlResult { Success = true };
|
||||||
|
}
|
||||||
|
catch (Exception ex) { return new ControlResult { Success = false, Message = ex.Message }; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
前端调用:`POST /api/gateway/control/Owl:main { deviceId: "ch123", command: "ai-enable" }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 阶段 O5: 推流/拉流管理(可选项,预计 1.5h)
|
||||||
|
|
||||||
|
### 6.1 设计决策
|
||||||
|
|
||||||
|
推流/拉流管理属于**管理员操作**而非实时数据查询。建议通过 B-组新路由暴露,不新增 Core 接口:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Program.cs — 推流/拉流 CRUD 路由组
|
||||||
|
app.MapGet("/api/gateway/owl/stream-pushs", async (int page, int size, AdapterRegistry registry) => { ... });
|
||||||
|
app.MapPost("/api/gateway/owl/stream-pushs", async (StreamPushRequest req, ...) => { ... });
|
||||||
|
// ... 类推
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 推流请求模型
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class StreamPushRequest
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string App { get; set; } = "live";
|
||||||
|
public string Stream { get; set; } = "";
|
||||||
|
public bool? IsAuthEnabled { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class StreamProxyRequest
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string Type { get; set; } = "RTSP";
|
||||||
|
public string App { get; set; } = "live";
|
||||||
|
public string Stream { get; set; } = "";
|
||||||
|
public string? SourceUrl { get; set; }
|
||||||
|
public int? Transport { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 作用
|
||||||
|
|
||||||
|
管理端通过网关统一管理 Owl 视频源添加/删除/状态查询,无需单独登录 Owl 控制台。前端可加"添加摄像头"按钮调用这些路由。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 文件变更清单
|
||||||
|
|
||||||
|
| 文件 | 新增 | 修改 | 说明 |
|
||||||
|
|------|:---:|:---:|------|
|
||||||
|
| `OwlAdapter.cs` | — | ✅ | GetDevicesAsync 重写 + IHasAlarms 实现 + IAcceptsControl 实现 + PTZ 预设位 |
|
||||||
|
| `OwlModels.cs` (新建) | ✅ | — | OwlDeviceChannel + OwlAiEvent + DTO 完整化(从 OwlAdapter.cs 分离) |
|
||||||
|
| `OwlAuthHelper.cs` | — | ✅ | HealthCheck 端点改 /stats(需确认) |
|
||||||
|
| `Program.cs` | ✅ | ✅ | AI 事件图片代理路由 + 推流/拉流路由(O5) |
|
||||||
|
| `IAcceptsControl.cs` | — | — | 已存在(KMS 阶段新增) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 与现有架构的兼容性
|
||||||
|
|
||||||
|
| 架构元素 | 影响 |
|
||||||
|
|------|------|
|
||||||
|
| IHasFlatDevices 签名 | 不变 — GetDevicesAsync 签名不变,仅内部实现改为调 /devices/channels |
|
||||||
|
| IHasAlarms | OwlAdapter 新增实现,零冲突 — KMS 也实现了 IHasAlarms |
|
||||||
|
| IAcceptsControl | OwlAdapter 新增实现 — KMS 已有实现,B10 路由自动发现 |
|
||||||
|
| AdapterCapabilities | 扩展 HasAlarms=true, FeatureFlags["aiEnable"]=true |
|
||||||
|
| Vol.Pro A3 同步 | ParentSourceId 已有解析逻辑,新通道子设备自然被正确处理 |
|
||||||
|
| 前端 base_device.vue | 无改动 — 操作列按钮按 DeviceGroup="视频设备" 自动匹配新展开的通道 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 验证点
|
||||||
|
|
||||||
|
| 场景 | 预期 |
|
||||||
|
|------|------|
|
||||||
|
| GET /api/gateway/devices?adapter=Owl:main | 返回 NVR 父设备 + 通道子设备,子设备有 hasPtz Extra |
|
||||||
|
| Vol.Pro 设备列表 | 显示 Owl 摄像机通道,AdapterCode=Owl:main |
|
||||||
|
| 前端预览按钮 | 通道子设备显示"预览"按钮,点击播放实时流 |
|
||||||
|
| GET /api/gateway/alarms/Owl:main | 返回 AI 检测事件(人员/车辆等) |
|
||||||
|
| 规则引擎 | 可将 Owl AI 事件作为告警源触发规则 |
|
||||||
|
| POST /api/gateway/control/Owl:main ai-enable | 远程开启 Owl AI 检测 |
|
||||||
197
doc/设计文档/网关owl模块检查报告20260603.md
Normal file
197
doc/设计文档/网关owl模块检查报告20260603.md
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
# 网关 Owl 模块检查报告 2026-06-03
|
||||||
|
|
||||||
|
> **基准文档**: `doc/对接文档/GoWVP接口文档.md` (3419行, ~40个API)
|
||||||
|
> **检查范围**: `gateway/src/IntegrationGateway.Adapters.Owl/` (OwlAdapter.cs, OwlAuthHelper.cs)
|
||||||
|
> **日期**: 2026-06-03
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 覆盖率概览
|
||||||
|
|
||||||
|
GoWVP 接口文档共 **40 个 REST 端点**,当前 OwlAdapter 覆盖了 **8 个**(20%)。
|
||||||
|
|
||||||
|
| 模块 | 文档端点数 | 已实现 | 缺失 |
|
||||||
|
|------|:---:|:---:|:---:|
|
||||||
|
| 控制台 | 1 | 0 | 1 |
|
||||||
|
| 推流列表 | 4 | 0 | 4 |
|
||||||
|
| 拉流代理 | 4 | 0 | 4 |
|
||||||
|
| 国标设备 | 6 | 3 | 3 |
|
||||||
|
| 国标通道 | 3 | 3 | 0 |
|
||||||
|
| 通道管理 | 4 | 2 | 2 |
|
||||||
|
| AI 检测 | 4 | 0 | 4 |
|
||||||
|
| 事件 | 5 | 0 | 5 |
|
||||||
|
| 区域管理 | 2 | 0 | 2 |
|
||||||
|
| 配置管理 | 2 | 0 | 2 |
|
||||||
|
| 流媒体 | 2 | 0 | 2 |
|
||||||
|
| ONVIF | 3 | 0 | 3 |
|
||||||
|
| **合计** | **40** | **8** | **32** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 已实现接口对照
|
||||||
|
|
||||||
|
| GoWVP 端点 | OwlAdapter 方法 | 能力接口 | 状态 |
|
||||||
|
|------|------|------|:--:|
|
||||||
|
| GET /devices | GetDevicesAsync | IHasFlatDevices | ✅ |
|
||||||
|
| PUT /devices/{id} | PushMetadataAsync | IAcceptsMetadataPush | ✅ |
|
||||||
|
| POST /channels/{id}/play | GetLiveUrlAsync | IHasStreams | ✅ |
|
||||||
|
| POST /channels/{id}/ptz | PtzControlAsync / PtzStopAsync | IHasStreams | ✅ |
|
||||||
|
| POST /channels/{id}/snapshot | GetSnapshotAsync | IHasStreams | ✅ |
|
||||||
|
| GET /recordings | GetRecordingsAsync | IHasRecordings | ✅ |
|
||||||
|
| (未映射) | GetPlaybackUrlAsync | IHasStreams | ⚠️ 自拼URL |
|
||||||
|
| GET /health | HealthCheckAsync | IGatewayAdapter | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 缺失项详细清单
|
||||||
|
|
||||||
|
### 3.1 控制台监控(1个)
|
||||||
|
|
||||||
|
| 端点 | 用途 | 影响 |
|
||||||
|
|------|------|------|
|
||||||
|
| **GET /stats** | CPU/内存/磁盘/网络实时监控 | 管理端无法查看 Owl 服务器健康度 |
|
||||||
|
|
||||||
|
**建议**: OwlAdapter 增加 `GetStatsAsync()`,返回 CPU/内存/磁盘 JSON,`Capabilities.HasStats = true`。
|
||||||
|
|
||||||
|
### 3.2 推流管理(4个)
|
||||||
|
|
||||||
|
| 端点 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| POST /stream_pushs | 添加推流通道 |
|
||||||
|
| GET /stream_pushs | 分页查询推流列表 |
|
||||||
|
| PUT /stream_pushs/{id} | 编辑推流 |
|
||||||
|
| DELETE /stream_pushs/{id} | 删除推流 |
|
||||||
|
|
||||||
|
**影响**: 管理端无法从 Vol.Pro 直接添加/管理 Owl 推流通道,需登录 Owl 控制台操作。
|
||||||
|
|
||||||
|
### 3.3 拉流代理(4个)
|
||||||
|
|
||||||
|
| 端点 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| POST /stream_proxys | 添加拉流代理 |
|
||||||
|
| GET /stream_proxys | 分页查询拉流列表 |
|
||||||
|
| PUT /stream_proxys | 编辑拉流代理 |
|
||||||
|
| DELETE /stream_proxys/{id} | 删除拉流代理 |
|
||||||
|
|
||||||
|
**影响**: 同推流,非 GB28181 的 RTSP/RTMP 通道无法通过管理端管理。
|
||||||
|
|
||||||
|
### 3.4 国标设备扩展(3个)
|
||||||
|
|
||||||
|
| 端点 | 说明 | 当前替代 |
|
||||||
|
|------|------|------|
|
||||||
|
| **GET /devices/channels** | 一键获取所有设备+通道列表 | 无 — 当前 GetDevicesAsync 只返回 NVR,不展开通道 |
|
||||||
|
| POST /devices | 添加 GB28181 设备 | 无 |
|
||||||
|
| POST /devices/{id}/catalog | 查询设备目录 | 无 |
|
||||||
|
|
||||||
|
**关键缺失**: `GET /devices/channels` 直接返回设备+通道的联合结果,比单独调 `/devices` + `/channels` 高效。当前适配器在 `GetDevicesAsync` 中只映射了 NVR 设备(IsParent=true),**没有展开下级通道**。
|
||||||
|
|
||||||
|
### 3.5 通道管理(2个)
|
||||||
|
|
||||||
|
| 端点 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **POST /channels** | 添加 RTMP/RTSP 通道 |
|
||||||
|
| **DELETE /channels/{id}** | 删除通道 |
|
||||||
|
| GET /channels | 通道列表(独立) |
|
||||||
|
|
||||||
|
**半缺失**: `GET /channels` 和 `PUT /channels/{id}` 虽未直接调用,但流和 PTZ 接口已间接使用通道 ID。
|
||||||
|
|
||||||
|
### 3.6 AI 检测能力(4个)
|
||||||
|
|
||||||
|
| 端点 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| POST /channels/{id}/ai/enable | 启用 AI 检测 |
|
||||||
|
| POST /channels/{id}/ai/disable | 禁用 AI 检测 |
|
||||||
|
| POST /channels/{id}/zones | 添加 AI 检测区域 |
|
||||||
|
| GET /channels/{id}/zones | 获取检测区域 |
|
||||||
|
|
||||||
|
**影响**: Owl 的 AI 人数统计/区域入侵能力无法通过网关管理端开启/配置。
|
||||||
|
|
||||||
|
### 3.7 AI 事件管理(5个)
|
||||||
|
|
||||||
|
| 端点 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| GET /events | 分页查询 AI 事件(按通道/标签/时间筛选) |
|
||||||
|
| GET /events/{id} | 事件详情 |
|
||||||
|
| PUT /events/{id} | 更新事件 |
|
||||||
|
| DELETE /events/{id} | 删除事件 |
|
||||||
|
| GET /events/image/{path} | 获取事件快照图片 |
|
||||||
|
|
||||||
|
**战略缺失**: Owl AI 事件(人员检测、车辆检测、入侵告警等)是**视频智能监控的核心数据**。当前网关零接入,意味着:
|
||||||
|
- 规则引擎无法以"人数越限"为条件触发动作
|
||||||
|
- AI 事件不能同步到 Vol.Pro 告警表
|
||||||
|
- 事件快照无法在大屏展示
|
||||||
|
|
||||||
|
**建议**: 通过 `IHasAlarms` 接口暴露 AI 事件,映射 `StandardAlarm { AlarmId=event.id, Level=重要/普通, Title=label, Content=快照路径 }`。
|
||||||
|
|
||||||
|
### 3.8 系统管理(4个)
|
||||||
|
|
||||||
|
| 端点 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| GET /configs/info | 查询配置 |
|
||||||
|
| PUT /configs/info/sip | 修改 SIP 配置 |
|
||||||
|
| GET /media_servers | 流媒体列表 |
|
||||||
|
| PUT /media_servers/{id} | 修改流媒体 |
|
||||||
|
|
||||||
|
**影响**: 运维类接口,暂不阻塞业务。
|
||||||
|
|
||||||
|
### 3.9 ONVIF(3个)
|
||||||
|
|
||||||
|
| 端点 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| GET /onvif/devices-discover | ONVIF 设备发现 |
|
||||||
|
| POST /onvif | 添加 ONVIF 设备 |
|
||||||
|
| GET /onvif/discover | ONVIF 设备发现(SSE) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 现有代码问题项
|
||||||
|
|
||||||
|
### 4.1 设备列表只返回 NVR 不展开通道
|
||||||
|
|
||||||
|
`GetDevicesAsync` → `MapDevice` 将所有设备映射为 `IsParent=true, Category="硬盘录像机"`,不查询也不返回通道子设备。这导致:
|
||||||
|
- Vol.Pro 设备树中 Owl 设备全是父设备,无摄像头子节点
|
||||||
|
- 前端预览按钮要求 `DeviceGroup='视频设备'` 的叶子设备,找不到子设备
|
||||||
|
|
||||||
|
`GET /devices/channels` 可一次性返回设备+通道,相比两次调用更高效。
|
||||||
|
|
||||||
|
### 4.2 OwlDevice 模型字段不完整
|
||||||
|
|
||||||
|
当前 `OwlDevice` 只有 8 个字段(Id, Name, IsOnline, Protocol, Address, Port, Transport),GoWVP 返回的 Device 至少有 20+ 个字段,缺失包括:
|
||||||
|
|
||||||
|
| 缺失字段 | 说明 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| Manufacturer | 厂商 | 设备详情显示 |
|
||||||
|
| Model | 型号 | 设备详情显示 |
|
||||||
|
| Firmware | 固件版本 | 运维 |
|
||||||
|
| Longitude/Latitude | 经纬度 | 地图标记 |
|
||||||
|
| ChannelCount | 通道数 | 统计 |
|
||||||
|
| Status/RegisterWay | 注册方式 | GB28181 状态 |
|
||||||
|
| CreatedAt/UpdatedAt | 时间戳 | 同步管理 |
|
||||||
|
|
||||||
|
### 4.3 HealthCheck 端点路径可能错误
|
||||||
|
|
||||||
|
代码调 `GET /health`,但 GoWVP 文档显示控制台唯一端点 `GET /stats`。需确认 Owl 实际实现。
|
||||||
|
|
||||||
|
### 4.4 GetPlaybackUrlAsync 手工拼 URL
|
||||||
|
|
||||||
|
直接拼接 `/recordings/channels/{id}/index.m3u8?start_ms=&end_ms=&token=`,未调用 Owl API。虽然功能通常可用,但依赖内部路径约定,Owl 版本升级可能失效。
|
||||||
|
|
||||||
|
### 4.5 无 PTZ 预设位/巡航支持
|
||||||
|
|
||||||
|
GoWVP PTZ 接口 `POST /channels/{id}/ptz/control` 支持 `action: preset/patrol/scan/stop`,当前仅实现 `continuous` 方向移动和 `stop`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 优先级建议
|
||||||
|
|
||||||
|
| 优先级 | 项目 | 说明 |
|
||||||
|
|:---:|------|------|
|
||||||
|
| 🔴 P0 | 设备列表展开通道 | 前端无法展示摄像头设备 |
|
||||||
|
| 🔴 P0 | AI 事件接入 IHasAlarms | 规则引擎无法获知人数/入侵 |
|
||||||
|
| 🟠 P1 | 使用 GET /devices/channels | 替代当前单独调 /devices |
|
||||||
|
| 🟠 P1 | OwlDevice 字段补全 | 设备详情展示 |
|
||||||
|
| 🟡 P2 | AI 检测启停 | 远程控制 Owl AI |
|
||||||
|
| 🟡 P2 | 推流/拉流 CRUD | 管理端统一通道管理 |
|
||||||
|
| ⚪ P3 | 预设位/巡航 PTZ | 高级云台功能 |
|
||||||
|
| ⚪ P3 | 系统管理接口 | 运维便捷性 |
|
||||||
|
| ⚪ P3 | ONVIF 设备发现 | 部署时的设备发现 |
|
||||||
97
doc/设计文档/网关自动注册机制整改_任务清单.md
Normal file
97
doc/设计文档/网关自动注册机制整改_任务清单.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# 网关自动注册机制整改 — 任务清单
|
||||||
|
|
||||||
|
> **版本**: 1.0
|
||||||
|
> **日期**: 2026-06-03
|
||||||
|
> **基准**: `doc/设计文档/网关自动注册机制整改方案_v1.0.md` + `doc/设计文档/网关自动注册机制检查报告20260603.md`
|
||||||
|
> **原则**: 分阶段分步骤执行,每步骤完成编译复查后提交,不合并主分支
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段 G1: Gateway 端修复(3 步骤)
|
||||||
|
|
||||||
|
### 步骤 G1.1 — 修复 BaseUrl 硬编码
|
||||||
|
|
||||||
|
- [ ] 编辑 `gateway/src/IntegrationGateway.Host/Program.cs`
|
||||||
|
- [ ] 将 `BaseUrl = $"http://localhost:..."` 改为读取 `gwCfg["SelfUrl"]`,不填降级 localhost
|
||||||
|
- [ ] 编辑 `gateway/src/IntegrationGateway.Host/appsettings.json`,Gateway 段新增 `"SelfUrl": null`
|
||||||
|
- [ ] `dotnet build gateway/IntegrationGateway.slnx` → 0 错误
|
||||||
|
- [ ] 复查:`BaseUrl` 不再硬编码 localhost,可从配置注入真实 IP
|
||||||
|
|
||||||
|
> **G1.1 提交点**: `Fix-G1.1: Gateway A1 BaseUrl 改为读取 SelfUrl 配置`
|
||||||
|
|
||||||
|
### 步骤 G1.2 — A1 注册后追加 A3 设备同步
|
||||||
|
|
||||||
|
- [ ] 编辑 `gateway/src/IntegrationGateway.Host/Program.cs`
|
||||||
|
- [ ] A1 注册成功后:遍历 `registry.All`,`IHasFlatDevices` → `GetDevicesAsync`,`IHasOwnDeviceTree` → `GetObjectTreeAsync` + 展平
|
||||||
|
- [ ] 新增 `FlattenTree` 辅助函数(MC4 对象树展平)
|
||||||
|
- [ ] 调 `clientFactory.SyncDevicesAsync(nodeCode, nodeToken, allDevices)`
|
||||||
|
- [ ] `dotnet build` → 0 错误
|
||||||
|
- [ ] 复查:A1 成功后立即执行 A3,注册完成时 Vol.Pro 已有设备数据
|
||||||
|
|
||||||
|
> **G1.2 提交点**: `Fix-G1.2: A1注册后立即A3同步全部适配器设备列表`
|
||||||
|
|
||||||
|
### 步骤 G1.3 — 启动 A2 心跳 + 自动重注册
|
||||||
|
|
||||||
|
- [ ] 编辑 `gateway/src/IntegrationGateway.Host/Program.cs`
|
||||||
|
- [ ] A1/A3 完成后启动 `Task.Run` 心跳循环
|
||||||
|
- [ ] `PeriodicTimer` 每 15s → `clientFactory.HeartbeatAsync`
|
||||||
|
- [ ] 连续失败 ≥ 3 次 → 触发 A1+A3 重注册
|
||||||
|
- [ ] 新增 `SyncAllDevicesAsync` 辅助函数(复用 A3 逻辑)
|
||||||
|
- [ ] `dotnet build` → 0 错误
|
||||||
|
- [ ] 复查:心跳成功重置 `failCount`,失败累积到 3 次自动恢复
|
||||||
|
|
||||||
|
> **G1.3 提交点**: `Fix-G1.3: A2心跳+自动重注册(连续3次失败触发A1+A3)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段 G2: Vol.Pro 端修复(2 步骤)
|
||||||
|
|
||||||
|
### 步骤 G2.1 — RegisterNodeAsync 语法规范化
|
||||||
|
|
||||||
|
- [ ] 编辑 `api_sqlsugar/Warehouse/Services/device_manager/Partial/gateway_nodesService.cs`
|
||||||
|
- [ ] `RegisterNodeAsync`:`DbContext.Queryable.First()` → `FindAsIQueryable.FirstOrDefaultAsync()`
|
||||||
|
- [ ] `UpdateHeartbeatAsync`:同样替换
|
||||||
|
- [ ] `dotnet build api_sqlsugar/Warehouse` → 0 错误
|
||||||
|
- [ ] 复查:两个方法使用统一 Vol.Pro 查询语法,`.First()` 不抛异常
|
||||||
|
|
||||||
|
> **G2.1 提交点**: `Fix-G2.1: gateway_nodesService 统一 FindAsIQueryable 语法`
|
||||||
|
|
||||||
|
### 步骤 G2.2 — 标记 UpsertDeviceAsync 为废弃
|
||||||
|
|
||||||
|
- [ ] 编辑 `api_sqlsugar/Warehouse/Services/device_manager/Partial/base_deviceService.cs`
|
||||||
|
- [ ] `UpsertDeviceAsync` 加 `[Obsolete]` 标记 + 注释说明
|
||||||
|
- [ ] 检查接口文件 `Ibase_deviceService` 是否暴露此方法,同步标记
|
||||||
|
- [ ] `dotnet build` → 0 错误(允许 [Obsolete] 警告)
|
||||||
|
- [ ] 复查:重复逻辑已标记,新代码不会误用
|
||||||
|
|
||||||
|
> **G2.2 提交点**: `Fix-G2.2: base_deviceService.UpsertDeviceAsync 标记 [Obsolete]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段 G3: 全量验证(1 步骤)
|
||||||
|
|
||||||
|
### 步骤 G3.1 — 全量编译 + 联调场景验证
|
||||||
|
|
||||||
|
- [ ] `dotnet build gateway/IntegrationGateway.slnx` → 0 错误
|
||||||
|
- [ ] `dotnet build api_sqlsugar/VolPro.WebApi` → 0 错误
|
||||||
|
- [ ] 网关启动 → 控制台输出 A1 注册 → A3 同步 N 台 → A2 心跳启动
|
||||||
|
- [ ] `gateway_nodes` 表有记录,`LastHeartbeat` 持续更新
|
||||||
|
- [ ] `base_device` 表有对应设备
|
||||||
|
- [ ] 网关先启动(Vol.Pro 未启动)→ 45 秒后自动恢复
|
||||||
|
- [ ] 复查:全链路 A1→A3→A2 闭环正常
|
||||||
|
|
||||||
|
> **G3.1 提交点**: `Fix-G3: 全量编译验证通过 注册机制闭环完成`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务总览
|
||||||
|
|
||||||
|
| 阶段 | 步骤 | 文件 | 预计 |
|
||||||
|
|:---:|:---:|------|:---:|
|
||||||
|
| G1 | G1.1 BaseUrl 修复 | Program.cs + appsettings.json | 10min |
|
||||||
|
| G1 | G1.2 A3 设备同步 | Program.cs | 30min |
|
||||||
|
| G1 | G1.3 心跳+重注册 | Program.cs | 20min |
|
||||||
|
| G2 | G2.1 语法规范化 | gateway_nodesService.cs | 5min |
|
||||||
|
| G2 | G2.2 标记废弃方法 | base_deviceService.cs | 10min |
|
||||||
|
| G3 | G3.1 全量验证 | 全项目 | 15min |
|
||||||
|
| **合计** | **6 步骤** | **5 文件** | **~1.5h** |
|
||||||
336
doc/设计文档/网关自动注册机制整改方案_v1.0.md
Normal file
336
doc/设计文档/网关自动注册机制整改方案_v1.0.md
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
# 网关 ↔ Vol.Pro 自动注册机制整改方案 v1.0
|
||||||
|
|
||||||
|
> **版本**: 1.0
|
||||||
|
> **日期**: 2026-06-03
|
||||||
|
> **基准**: `doc/设计文档/网关自动注册机制检查报告20260603.md`
|
||||||
|
> **改动范围**: `gateway/Program.cs` + `VolPro/gateway_nodesService.cs` + `VolPro/base_deviceService.cs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 整改步骤
|
||||||
|
|
||||||
|
### 步骤 1: 修复网关 A1 BaseUrl(预计 10min)
|
||||||
|
|
||||||
|
**文件**: `gateway/src/IntegrationGateway.Host/Program.cs`
|
||||||
|
|
||||||
|
**当前代码**(line 100-101):
|
||||||
|
```csharp
|
||||||
|
BaseUrl = $"http://localhost:{app.Urls.FirstOrDefault()?.Split(':').LastOrDefault() ?? "5100"}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改为**:
|
||||||
|
```csharp
|
||||||
|
// 优先读取 Gateway:SelfUrl 配置,不填时自动从 Urls 取端口
|
||||||
|
var port = app.Urls.FirstOrDefault()?.Split(':').LastOrDefault() ?? "5100";
|
||||||
|
var selfUrl = gwCfg["SelfUrl"] ?? $"http://localhost:{port}";
|
||||||
|
```
|
||||||
|
|
||||||
|
然后将 `BaseUrl =` 行改为:
|
||||||
|
```csharp
|
||||||
|
BaseUrl = selfUrl
|
||||||
|
```
|
||||||
|
|
||||||
|
**appsettings.json 补充**:
|
||||||
|
```json
|
||||||
|
"Gateway": {
|
||||||
|
"SelfUrl": null, // 生产环境填真实IP: http://192.168.1.100:5100, 留空则用 localhost
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**编译验证**: `dotnet build gateway/IntegrationGateway.slnx` → 0 错误。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 步骤 2: A1 注册后立即调用 A3 设备同步(预计 30min)
|
||||||
|
|
||||||
|
**文件**: `gateway/src/IntegrationGateway.Host/Program.cs`
|
||||||
|
|
||||||
|
**在 A1 注册成功后追加 A3 同步**。当前代码(line 97-105)替换为:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var registerReq = new GatewayRegisterRequest
|
||||||
|
{
|
||||||
|
NodeCode = nodeCode, Token = nodeToken,
|
||||||
|
AdapterTypes = adapterTypes, BaseUrl = selfUrl
|
||||||
|
};
|
||||||
|
await clientFactory.RegisterAsync(registerReq);
|
||||||
|
Console.WriteLine($"[Gateway] A1 注册完成: nodeCode={nodeCode}, adapters={adapterTypes}");
|
||||||
|
|
||||||
|
// ── A3: 同步所有适配器设备到 Vol.Pro ──
|
||||||
|
var allDevices = new List<object>();
|
||||||
|
foreach (var adapter in registry.All)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (adapter is IHasFlatDevices flat)
|
||||||
|
{
|
||||||
|
var result = await flat.GetDevicesAsync(1, 1000);
|
||||||
|
foreach (var item in result.Items)
|
||||||
|
{
|
||||||
|
// 映射为 A3 接口期望的格式
|
||||||
|
allDevices.Add(new
|
||||||
|
{
|
||||||
|
AdapterCode = item.AdapterCode,
|
||||||
|
SourceId = item.SourceId,
|
||||||
|
Name = item.Name,
|
||||||
|
Category = item.Category,
|
||||||
|
Group = item.Group,
|
||||||
|
IsParent = item.IsParent,
|
||||||
|
ParentSourceId = item.ParentSourceId,
|
||||||
|
IsOnline = item.IsOnline,
|
||||||
|
IpAddress = item.IpAddress,
|
||||||
|
Port = item.Port,
|
||||||
|
ExtraDataJson = item.Extra != null
|
||||||
|
? System.Text.Json.JsonSerializer.Serialize(item.Extra)
|
||||||
|
: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (adapter is IHasOwnDeviceTree tree)
|
||||||
|
{
|
||||||
|
var nodes = await tree.GetObjectTreeAsync();
|
||||||
|
FlattenTree(allDevices, nodes, adapter.AdapterCode, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Console.Error.WriteLine($"[Gateway] A3 同步失败: {adapter.AdapterCode} {ex.Message}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allDevices.Any())
|
||||||
|
{
|
||||||
|
await clientFactory.SyncDevicesAsync(nodeCode, nodeToken, allDevices);
|
||||||
|
Console.WriteLine($"[Gateway] A3 设备同步完成: {allDevices.Count} 台设备");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Console.Error.WriteLine($"[Gateway] A1 注册失败: {ex.Message}"); }
|
||||||
|
```
|
||||||
|
|
||||||
|
**新增辅助函数**(Program.cs 底部,app.Run() 前):
|
||||||
|
```csharp
|
||||||
|
/// <summary>递归展平 MC4 对象树为设备列表</summary>
|
||||||
|
void FlattenTree(List<object> devices, List<DeviceTreeNode> nodes, string adapterCode, string? parentSourceId)
|
||||||
|
{
|
||||||
|
foreach (var n in nodes)
|
||||||
|
{
|
||||||
|
devices.Add(new
|
||||||
|
{
|
||||||
|
AdapterCode = adapterCode,
|
||||||
|
SourceId = n.SourceId,
|
||||||
|
Name = n.Name ?? n.SourceId,
|
||||||
|
Category = n.Tag ?? "IoT设备",
|
||||||
|
Group = "IoT设备",
|
||||||
|
IsParent = n.Type == 1,
|
||||||
|
ParentSourceId = parentSourceId,
|
||||||
|
IsOnline = true,
|
||||||
|
IpAddress = (string?)null,
|
||||||
|
Port = (int?)null,
|
||||||
|
ExtraDataJson = n.Option != null
|
||||||
|
? System.Text.Json.JsonSerializer.Serialize(n.Option)
|
||||||
|
: null
|
||||||
|
});
|
||||||
|
if (n.Children?.Count > 0)
|
||||||
|
FlattenTree(devices, n.Children, adapterCode, n.SourceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**编译验证**: `dotnet build` → 0 错误。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 步骤 3: 启动 A2 后台心跳循环(预计 15min)
|
||||||
|
|
||||||
|
**文件**: `gateway/src/IntegrationGateway.Host/Program.cs`
|
||||||
|
|
||||||
|
**在 A1-A3 注册/同步后追加**:
|
||||||
|
```csharp
|
||||||
|
// ── A2: 启动后台心跳(每 15 秒)──
|
||||||
|
var heartbeatInterval = int.TryParse(gwCfg["HeartbeatIntervalSec"], out var sec) ? sec : 15;
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(heartbeatInterval));
|
||||||
|
while (await timer.WaitForNextTickAsync())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await clientFactory.HeartbeatAsync(new GatewayHeartbeatRequest
|
||||||
|
{
|
||||||
|
NodeCode = nodeCode, Token = nodeToken
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[Gateway] A2 心跳失败: {ex.Message}");
|
||||||
|
_auth?.Invalidate(); // 心跳连续失败时考虑重新注册
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**appsettings.json** 已有 `"HeartbeatIntervalSec": 15`,无需改动。
|
||||||
|
|
||||||
|
**编译验证**: `dotnet build` → 0 错误。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 步骤 4: 修复 RegisterNodeAsync 语法(预计 5min)
|
||||||
|
|
||||||
|
**文件**: `api_sqlsugar/Warehouse/Services/device_manager/Partial/gateway_nodesService.cs`
|
||||||
|
|
||||||
|
**当前代码** (~line 55):
|
||||||
|
```csharp
|
||||||
|
var existing = _repository.DbContext.Queryable<gateway_nodes>()
|
||||||
|
.First(x => x.NodeCode == nodeCode);
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改为**:
|
||||||
|
```csharp
|
||||||
|
var existing = await _repository.FindAsIQueryable(x => x.NodeCode == nodeCode)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
```
|
||||||
|
|
||||||
|
**同时修改 heartbeat 方法** (~line 92):
|
||||||
|
```csharp
|
||||||
|
var entity = _repository.DbContext.Queryable<gateway_nodes>()
|
||||||
|
.First(x => x.NodeCode == nodeCode && x.NodeToken == token);
|
||||||
|
```
|
||||||
|
→
|
||||||
|
```csharp
|
||||||
|
var entity = await _repository.FindAsIQueryable(x => x.NodeCode == nodeCode && x.NodeToken == token)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
```
|
||||||
|
|
||||||
|
**编译验证**: `dotnet build api_sqlsugar/Warehouse` → 0 错误。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 步骤 5: 标记重复的 Upsert 逻辑(预计 10min)
|
||||||
|
|
||||||
|
**文件**: `api_sqlsugar/Warehouse/Services/device_manager/Partial/base_deviceService.cs`
|
||||||
|
|
||||||
|
**在 `UpsertDeviceAsync` 方法上加 `[Obsolete]` 标记**:
|
||||||
|
```csharp
|
||||||
|
/// <summary>
|
||||||
|
/// [已废弃] 设备同步逻辑已迁移至 gateway_nodesService.SyncDevicesAsync。
|
||||||
|
/// 保留此方法仅供向后兼容,新代码请勿使用。
|
||||||
|
/// </summary>
|
||||||
|
[Obsolete("已迁移至 gateway_nodesService.SyncDevicesAsync")]
|
||||||
|
public async Task UpsertDeviceAsync(SyncDeviceItem d, int gatewayNodeId, Dictionary<(string, string), int> existingIds)
|
||||||
|
```
|
||||||
|
|
||||||
|
**同时检查 `Ibase_deviceService` 接口是否暴露了此方法** — 如是的 `Igateway_nodesService` 和 `Ibase_deviceService` 分别在两个 Partial 文件中,确认死代码无外部调用后可直接注释。
|
||||||
|
|
||||||
|
**编译验证**: `dotnet build` → 0 错误 / 仅 [Obsolete] 警告。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 改动文件汇总
|
||||||
|
|
||||||
|
| 步骤 | 文件 | 改动类型 | 影响 |
|
||||||
|
|:---:|------|:---:|------|
|
||||||
|
| 1 | `gateway/Program.cs` | 修改 BaseUrl 取值逻辑 | 生产部署可用真实 IP |
|
||||||
|
| 1 | `gateway/appsettings.json` | 新增 SelfUrl 字段 | 可选配置 |
|
||||||
|
| 2 | `gateway/Program.cs` | 追加 A3 同步 + FlattenTree | 首次注册即有设备 |
|
||||||
|
| 3 | `gateway/Program.cs` | 追加热心跳循环 | 网关持续在线 |
|
||||||
|
| 4 | `VolPro/gateway_nodesService.cs` | 替换 Queryable → FindAsIQueryable | 代码规范一致 |
|
||||||
|
| 5 | `VolPro/base_deviceService.cs` | 加 [Obsolete] 标记 | 消除重复逻辑 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 编译顺序
|
||||||
|
|
||||||
|
```
|
||||||
|
步骤1-3: gateway → dotnet build gateway/IntegrationGateway.slnx
|
||||||
|
步骤4-5: VolPro → dotnet build api_sqlsugar/Warehouse
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 验证清单
|
||||||
|
|
||||||
|
| 场景 | 预期 |
|
||||||
|
|------|------|
|
||||||
|
| 网关启动 | A1 注册成功 + A3 同步 N 台设备 + A2 心跳开始计时 |
|
||||||
|
| `gateway_nodes` 表 | 新增/更新记录,BaseUrl 为真实 IP |
|
||||||
|
| `base_device` 表 | 网关对应设备的 NodeId 已回填 |
|
||||||
|
| 管理端设备列表 | 可看到 Owl/MC4/KMS 设备 |
|
||||||
|
| 30 秒后 | 网关保持在线状态(LastHeartbeat 持续更新) |
|
||||||
|
| 网关重启 | NodeCode 不变 → A1 Upsert 更新 → A3 重新同步 |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 补充: A2 心跳 + 自动重注册机制(步骤3增强版)
|
||||||
|
|
||||||
|
> **日期**: 2026-06-03
|
||||||
|
> **问题**: 网关先于 Vol.Pro 启动时,A1 注册失败后不重试,网关永久不可见。
|
||||||
|
|
||||||
|
### 5.1 增强后的步骤3代码
|
||||||
|
|
||||||
|
替换原步骤3的简单心跳为「心跳 + 连续失败自动重注册」:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// ── A2: 心跳 + 自动重注册 ──
|
||||||
|
var heartbeatInterval = int.TryParse(gwCfg["HeartbeatIntervalSec"], out var sec) ? sec : 15;
|
||||||
|
var failCount = 0;
|
||||||
|
var maxFails = 3;
|
||||||
|
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(heartbeatInterval));
|
||||||
|
while (await timer.WaitForNextTickAsync())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await clientFactory.HeartbeatAsync(new GatewayHeartbeatRequest
|
||||||
|
{ NodeCode = nodeCode, Token = nodeToken });
|
||||||
|
failCount = 0;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
failCount++;
|
||||||
|
Console.Error.WriteLine($"[Gateway] A2 心跳失败 ({failCount}/{maxFails})");
|
||||||
|
if (failCount >= maxFails)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Gateway] 心跳连续失败, 尝试重新注册...");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await clientFactory.RegisterAsync(new GatewayRegisterRequest
|
||||||
|
{ NodeCode = nodeCode, Token = nodeToken, AdapterTypes = adapterTypes, BaseUrl = selfUrl });
|
||||||
|
await SyncAllDevicesAsync();
|
||||||
|
failCount = 0;
|
||||||
|
Console.WriteLine("[Gateway] 重新注册成功");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[Gateway] 重新注册失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 重注册时序
|
||||||
|
|
||||||
|
```
|
||||||
|
网关启动 → Vol.Pro 离线 → A1 失败(仅日志) → A2 心跳循环启动(每15s)
|
||||||
|
→ 15s: 心跳失败 (1/3)
|
||||||
|
→ 30s: 心跳失败 (2/3)
|
||||||
|
→ 45s: 心跳失败 (3/3) → 触发 A1+A3 重注册 → 成功!
|
||||||
|
→ 60s: 心跳成功 (failCount=0) → 恢复正常
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 验证场景新增
|
||||||
|
|
||||||
|
| 场景 | 预期 |
|
||||||
|
|------|------|
|
||||||
|
| 网关先于 Vol.Pro 启动 | 45 秒后自动 A1+A3 重注册成功 |
|
||||||
|
| Vol.Pro 重启 | 网关检测到心跳失败 → 自动重新上线 |
|
||||||
|
| 网关正常运行中 | 心跳持续成功,failCount=0 |
|
||||||
|
|
||||||
|
### 5.4 步骤3预计耗时更新
|
||||||
|
|
||||||
|
原 15min → 20min(增加 SyncAllDevicesAsync 辅助函数和重注册分支)。
|
||||||
148
doc/设计文档/网关自动注册机制检查报告20260603.md
Normal file
148
doc/设计文档/网关自动注册机制检查报告20260603.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# 网关 ↔ Vol.Pro 自动注册机制检查报告 2026-06-03
|
||||||
|
|
||||||
|
> **日期**: 2026-06-03
|
||||||
|
> **检查范围**: `gateway/src/IntegrationGateway.Host/Program.cs` A1 注册段 + `VolPro gateway_nodesController.cs` A1-A4 + 相关 Service
|
||||||
|
> **方法**: 逐行比对网关发送体 ↔ Vol.Pro 接收体 ↔ 数据库 Schema
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 数据流追踪
|
||||||
|
|
||||||
|
```
|
||||||
|
网关 Program.cs (line 82-105)
|
||||||
|
├─ InitializeAllAsync() ← 适配器初始化
|
||||||
|
├─ RegisterAsync(registerReq) ← A1: POST /api/gateway/register
|
||||||
|
└─ (无后续调用) ← A2/A3 未触发
|
||||||
|
|
||||||
|
Vol.Pro gateway_nodesController
|
||||||
|
├─ [POST] /api/gateway/register ← RegisterGateway → RegisterNodeAsync
|
||||||
|
├─ [POST] /api/gateway/heartbeat ← GatewayHeartbeat → UpdateHeartbeatAsync
|
||||||
|
├─ [POST] /api/gateway/sync/devices ← SyncDevices → SyncDevicesAsync
|
||||||
|
└─ [POST] /api/gateway/sync/alarms ← SyncAlarms → UpsertAlarmAsync
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 发现的问题
|
||||||
|
|
||||||
|
### 2.1 🔴 网关不调用 A3 设备同步 — 注册后设备列表为空
|
||||||
|
|
||||||
|
**现状**: 网关 Program.cs 在 A1 注册后**不调用** `clientFactory.SyncDevicesAsync()`。Vol.Pro 的 `RegisterGateway` 返回设备列表时调用 `GetDevicesByGatewayNodeAsync(node.NodeId)`,查询 `WHERE NodeId=xxx AND ParentDeviceId IS NULL`。
|
||||||
|
|
||||||
|
**后果**: 首次注册返回的设备列表永远为空(数据库尚无此网关的设备记录)。设备必须等 Vol.Pro 的 Quartz `SyncDevicesJob`(每 5 分钟触发一次)才有机会同步。
|
||||||
|
|
||||||
|
**修复**: A1 注册后立即遍历适配器同步设备,整体流程改为:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// A1 注册
|
||||||
|
var registerResult = await clientFactory.RegisterAsync(registerReq);
|
||||||
|
// A3 同步设备(Owl → GetDevicesAsync, MC4 → GetObjectTreeAsync)
|
||||||
|
foreach (var adapter in registry.All)
|
||||||
|
{
|
||||||
|
var devices = adapter switch
|
||||||
|
{
|
||||||
|
IHasFlatDevices f => (await f.GetDevicesAsync(1, 1000)).Items,
|
||||||
|
IHasOwnDeviceTree t => FlattenTree(await t.GetObjectTreeAsync()),
|
||||||
|
_ => new()
|
||||||
|
};
|
||||||
|
if (devices.Any())
|
||||||
|
await clientFactory.SyncDevicesAsync(nodeCode, nodeToken, devices.Select(d => new { ... }).ToList());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 🔴 网关 A1 BaseUrl 硬编码 `localhost`
|
||||||
|
|
||||||
|
**现状**:
|
||||||
|
```csharp
|
||||||
|
BaseUrl = $"http://localhost:{app.Urls.FirstOrDefault()?.Split(':').LastOrDefault() ?? "5100"}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**后果**: 网关部署在 `192.168.1.100` 时,Vol.Pro 存的 BaseUrl 仍是 `http://localhost:5100`。Vol.Pro 端的 GatewayClient 和 Quartz Job 用此地址回调网关时全部失败。
|
||||||
|
|
||||||
|
**修复**: 改为读取配置或使用 `app.Configuration["Gateway:SelfUrl"]`,不填时降级为 `localhost`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
BaseUrl = gwCfg["SelfUrl"] ?? $"http://localhost:{port}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 🟠 网关不调用 A2 心跳 — 无持续在线状态更新
|
||||||
|
|
||||||
|
**现状**: 网关只在 A1 注册时上报一次在线状态,之后不再调 A2 心跳。
|
||||||
|
|
||||||
|
**后果**: Vol.Pro 的 `gateway_nodes.LastHeartbeat` 停留在注册时刻,`HeartbeatMonitorJob`(每 15s)会在注册后 30s 将网关标记离线。
|
||||||
|
|
||||||
|
**修复**: 注册完成后启动后台心跳循环:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
_ = Task.Run(async () => {
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(15));
|
||||||
|
try { await clientFactory.HeartbeatAsync(new GatewayHeartbeatRequest { NodeCode = nodeCode, Token = nodeToken }); }
|
||||||
|
catch { Console.Error.WriteLine("[Gateway] 心跳失败"); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 🟠 Vol.Pro — RegisterNodeAsync 使用旧 Queryable 语法
|
||||||
|
|
||||||
|
**现状**:
|
||||||
|
```csharp
|
||||||
|
var existing = _repository.DbContext.Queryable<gateway_nodes>()
|
||||||
|
.First(x => x.NodeCode == nodeCode);
|
||||||
|
```
|
||||||
|
|
||||||
|
`DbContext.Queryable` 是 SqlSugar 原始方式,项目中其他 Service 使用 `FindAsIQueryable`(Vol.Pro 封装)。
|
||||||
|
|
||||||
|
**后果**: 不影响功能但风格不一致。且 `.First()` 可能抛异常(找不到记录时),而 `.FirstOrDefault()` + null 检查更安全。
|
||||||
|
|
||||||
|
**修复**: 改为 `FindAsIQueryable(x => x.NodeCode == nodeCode).FirstOrDefaultAsync()`。
|
||||||
|
|
||||||
|
### 2.5 🟡 A1 返回结果未被网关使用
|
||||||
|
|
||||||
|
**现状**:
|
||||||
|
```csharp
|
||||||
|
var registerResult = await clientFactory.RegisterAsync(registerReq);
|
||||||
|
// registerResult 未使用
|
||||||
|
```
|
||||||
|
|
||||||
|
`RegisterGateway` 返回 `{ nodeId, devices: [...] }`,网关不读取也不使用。
|
||||||
|
|
||||||
|
**后果**: 网关不知道自己的 NodeId,后续 A2/A3 需要 nodeCode + token 而非 nodeId。GatewayClientFactory 的 A2/A3 方法也用的是 nodeCode + token,所以不依赖 nodeId。
|
||||||
|
|
||||||
|
**评估**: **不需要修复** — 当前设计合理(nodeCode 是天然业务主键)。
|
||||||
|
|
||||||
|
### 2.6 🟡 base_deviceService 与 gateway_nodesService 重复实现设备同步
|
||||||
|
|
||||||
|
**现状**:
|
||||||
|
- `gateway_nodesService.SyncDevicesAsync` — 完整的设备同步(新增+更新)
|
||||||
|
- `base_deviceService.UpsertDeviceAsync` — 单设备 Upsert(被 Controller 调用但实际未被使用)
|
||||||
|
|
||||||
|
`gateway_nodesController.SyncDevices` 调的是 `gateway_nodesService.SyncDevicesAsync` 而非 `base_deviceService.UpsertDeviceAsync`。
|
||||||
|
|
||||||
|
**后果**: `base_deviceService.UpsertDeviceAsync` 是死代码。
|
||||||
|
|
||||||
|
**修复**: 保留 `gateway_nodesService.SyncDevicesAsync`(批量处理更高效),移除或标记 `base_deviceService.UpsertDeviceAsync` 为 `[Obsolete]`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 调用链完整性矩阵
|
||||||
|
|
||||||
|
| 接口 | Vol.Pro 端 | 网关调用 | 状态 |
|
||||||
|
|------|:---:|:---:|:--:|
|
||||||
|
| A1 /api/gateway/register | ✅ RegisterGateway | ✅ 已调用 | ⚠️ BaseUrl=localhost / 不返设备 |
|
||||||
|
| A2 /api/gateway/heartbeat | ✅ GatewayHeartbeat | ❌ 未调用 | 🔴 30秒后被标记离线 |
|
||||||
|
| A3 /api/gateway/sync/devices | ✅ SyncDevices | ❌ 未调用 | 🔴 首次注册设备列表为空 |
|
||||||
|
| A4 /api/gateway/sync/alarms | ✅ SyncAlarms | ❌ 未调用 | ⚪ 告警由 Vol.Pro Quartz 拉取 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 修复优先级
|
||||||
|
|
||||||
|
| 编号 | 问题 | 严重度 | 预计 |
|
||||||
|
|:---:|------|:---:|:---:|
|
||||||
|
| 1 | A3 设备同步未触发 | 🔴 | 30min |
|
||||||
|
| 2 | A1 BaseUrl=localhost | 🔴 | 10min |
|
||||||
|
| 3 | A2 心跳未循环 | 🟠 | 15min |
|
||||||
|
| 4 | RegisterNodeAsync 语法不一致 | 🟠 | 5min |
|
||||||
|
| 5 | 重复的设备 Upsert 逻辑 | 🟡 | 10min |
|
||||||
133
doc/设计文档/网关项目代码审查报告20260604.md
Normal file
133
doc/设计文档/网关项目代码审查报告20260604.md
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
# 网关项目代码审查报告 2026-06-04
|
||||||
|
|
||||||
|
> **范围**: `gateway/src/` 全部 5 个项目 239 文件
|
||||||
|
> **重点**: 空函数、未实现内容、TODO、硬编码、异常处理
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、空实现/存根函数(5 处)
|
||||||
|
|
||||||
|
### 1.1 OwlAdapter — ConfirmAlarmAsync / EndAlarmAsync
|
||||||
|
|
||||||
|
**文件**: `OwlAdapter.cs` L250-251
|
||||||
|
```csharp
|
||||||
|
public Task ConfirmAlarmAsync(string alarmId) => Task.CompletedTask;
|
||||||
|
public Task EndAlarmAsync(string alarmId) => Task.CompletedTask;
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明**: Owl AI 事件(基于 `/events` 接口)不支持确认/结束操作,合理留空。
|
||||||
|
**风险**: 低。调用方(VolPro/A4)调用后状态不会写回 Owl。
|
||||||
|
|
||||||
|
### 1.2 KmsAdapter — EndAlarmAsync
|
||||||
|
|
||||||
|
**文件**: `KmsAdapter.cs` L165-170
|
||||||
|
```csharp
|
||||||
|
public Task EndAlarmAsync(string alarmId) { return Task.CompletedTask; }
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明**: KMS 第三方接口 (2.18.7) 不提供告警结束 API,合理留空。
|
||||||
|
**风险**: 低。
|
||||||
|
|
||||||
|
### 1.3 KmsAdapter — GetBorrowRecordsAsync / GetPermissionListAsync 请求体
|
||||||
|
|
||||||
|
**文件**: `KmsAdapter.cs` L181, L194
|
||||||
|
```csharp
|
||||||
|
var body = "{}"; // 联调时加入时间范围参数
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明**: 联调待办项,当前传空 JSON。KMS 接口可能接受无参查询返回全部数据。
|
||||||
|
**风险**: 中。如果 KMS 要求时间范围参数,当前实现会失败。
|
||||||
|
|
||||||
|
### 1.4 KmsAdapter — SendControlAsync 只实现了 "open"/"authorize"
|
||||||
|
|
||||||
|
**文件**: `KmsAdapter.cs` L251
|
||||||
|
```csharp
|
||||||
|
if (command == "open" || command == "authorize") { ... }
|
||||||
|
// 其他 command 返回 success=true 但无实际操作
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明**: 非开门的控制指令会静默返回成功但不执行任何操作。
|
||||||
|
**风险**: 中。调用方以为成功但设备未变化。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、静默异常吞噬(4 处)
|
||||||
|
|
||||||
|
### 2.1 SyncAllDevicesAsync — 适配器遍历 catch
|
||||||
|
|
||||||
|
**文件**: `Program.cs` L171
|
||||||
|
```csharp
|
||||||
|
catch { }
|
||||||
|
```
|
||||||
|
适配器取设备列表失败时静默跳过,不影响其他适配器。合理但缺少日志。
|
||||||
|
|
||||||
|
### 2.2 B1 健康检查
|
||||||
|
|
||||||
|
**文件**: `Program.cs` L197
|
||||||
|
```csharp
|
||||||
|
try { healthy = await a.HealthCheckAsync(); } catch { }
|
||||||
|
```
|
||||||
|
合理——健康检查本身不应抛异常。
|
||||||
|
|
||||||
|
### 2.3 B4-batch Fallback
|
||||||
|
|
||||||
|
**文件**: `Program.cs` L279
|
||||||
|
```csharp
|
||||||
|
try { results[deviceId] = await a.GetRealtimeValuesAsync(deviceId); } catch { }
|
||||||
|
```
|
||||||
|
合理——逐设备查询时某设备失败不应阻塞。
|
||||||
|
|
||||||
|
### 2.4 RateLimiter.Release
|
||||||
|
|
||||||
|
**文件**: `RateLimiter.cs` L36
|
||||||
|
```csharp
|
||||||
|
try { _semaphore.Release(); } catch { }
|
||||||
|
```
|
||||||
|
合理——SemaphoreSlim.Release 在超过最大计数时会抛异常。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、联调待验证项(3 处)
|
||||||
|
|
||||||
|
### 3.1 GatewayClientFactory — A2/A3 方法从未被网关自身调用
|
||||||
|
|
||||||
|
**文件**: `GatewayClientFactory.cs` L38-62
|
||||||
|
```csharp
|
||||||
|
public async Task<bool> HeartbeatAsync(...) { ... }
|
||||||
|
public async Task<JsonDocument?> SyncDevicesAsync(...) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明**: 这两个方法在 Program.cs 的 `SyncAllDevicesAsync` 中通过 `clientFactory.SyncDevicesAsync` 被调用了。A2 心跳在心跳循环中被调用。
|
||||||
|
**状态**: ✅ 已连接。
|
||||||
|
|
||||||
|
### 3.2 Owl Playback URL 硬编码路径
|
||||||
|
|
||||||
|
**文件**: `OwlAdapter.cs` (GetPlaybackUrlAsync)
|
||||||
|
```csharp
|
||||||
|
Hls = $"{baseUrl}/recordings/channels/{channelId}/index.m3u8?..."
|
||||||
|
```
|
||||||
|
|
||||||
|
联调时需确认 Owl 实际录像 HLS 路径是否为此格式。
|
||||||
|
|
||||||
|
### 3.3 KMS API 响应格式
|
||||||
|
|
||||||
|
KMS 所有接口的响应格式需联调验证。文档中字段名可能与实际 API 有差异。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、编译状态
|
||||||
|
|
||||||
|
网关 5 项目上次编译 **0 错误 0 警告**。当前改动为本次审查附加,需重新编译验证。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、结论
|
||||||
|
|
||||||
|
| 类别 | 数量 | 严重度 |
|
||||||
|
|------|:---:|------|
|
||||||
|
| 合理空实现(设计如此) | 3 | 低 |
|
||||||
|
| 联调待验证参数 | 2 | 中 |
|
||||||
|
| 静默异常(合理设计) | 4 | 低 |
|
||||||
|
| **需要立即修复** | **0** | — |
|
||||||
|
|
||||||
|
**没有发现需要立即修复的空函数或未实现方法。** 所有 `Task.CompletedTask` 都是因为底层子系统不支持该操作(Owl AI 无确认、KMS 无结束告警),属于设计取舍。KMS 的联调待办项(时间范围参数)已在代码中注释标注。
|
||||||
175
doc/设计文档/规则引擎实施计划_任务清单.md
Normal file
175
doc/设计文档/规则引擎实施计划_任务清单.md
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
# 规则引擎实施计划 — 任务清单
|
||||||
|
|
||||||
|
> **版本**: 1.0
|
||||||
|
> **日期**: 2026-06-04
|
||||||
|
> **基准**: `doc/设计文档/规则引擎实现方案_v1.0.md`
|
||||||
|
> **分支**: gateway-dev
|
||||||
|
> **原则**: 分阶段分步骤执行,每步骤编译复查后提交,不合并主分支
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 当前状态
|
||||||
|
|
||||||
|
| 前置条件 | 状态 |
|
||||||
|
|------|:--:|
|
||||||
|
| RealtimePollJob(10s 采集 MC4 IoT 值) | ✅ 已实现 |
|
||||||
|
| warehouse_rule/condition/action 表 + 管理端 CRUD | ✅ 已实现 |
|
||||||
|
| warehouse_rulelog 表 | ❌ 待建 |
|
||||||
|
| 网关 B4-batch 批量接口 | ✅ 已实现 (P1-1) |
|
||||||
|
| 网关 B5(设备控制) | ✅ 已实现 |
|
||||||
|
| 网关 B10(远程控制) | ✅ 已实现 |
|
||||||
|
| VolPro GatewayClient(调网关) | ✅ 已实现 |
|
||||||
|
| warehouse_variable 表 SQL | ✅ 已写入 db_init.sql(待执行) |
|
||||||
|
| RuleEngineService / RuleEngineJob | ❌ 待实现 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段 R1: 数据库准备(预计 30min)
|
||||||
|
|
||||||
|
### 步骤 R1.1 — 新增 warehouse_rulelog 表
|
||||||
|
|
||||||
|
- [ ] 在数据库执行:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE warehouse_rulelog (
|
||||||
|
LogID INT IDENTITY PRIMARY KEY,
|
||||||
|
RuleID INT NOT NULL,
|
||||||
|
ConditionMet NVARCHAR(50),
|
||||||
|
ActionResult NVARCHAR(MAX),
|
||||||
|
EvaluatedAt DATETIME DEFAULT GETDATE(),
|
||||||
|
Detail NVARCHAR(MAX) NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
- [ ] 在数据库执行 ALTER TABLE 添加字段(若未执行):
|
||||||
|
- `warehouse_rule` 加 `Enable`, `Priority`, `LastEvaluated`, `LastTriggered`, `CooldownSec`
|
||||||
|
- `warehouse_ruleaction` 加 `ActionType`, `ExtraJson`
|
||||||
|
- `warehouse_rulecondition` 加 `RecoveryThreshold_Numeric`, `RecoveryThreshold_Switch`, `LastTriggered`, `LastTriggerValue`
|
||||||
|
- [ ] 在 Vol.Pro 代码生成器中选择 `warehouse_rulelog` 生成全套 CRUD
|
||||||
|
- [ ] `dotnet build` → 0 错误
|
||||||
|
|
||||||
|
### 步骤 R1.2 — 执行 warehouse_variable 表建表
|
||||||
|
|
||||||
|
- [ ] 在数据库执行 `doc/db_init.sql` 中 warehouse_variable 建表语句
|
||||||
|
- [ ] Vol.Pro 代码生成器生成 `warehouse_variable` CRUD
|
||||||
|
- [ ] 管理端字典补充(§5 字典项)
|
||||||
|
|
||||||
|
> **R1 提交点**: `RuleEngine-R1: 数据库表+字典就绪`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段 R2: RuleEngineService 实现(预计 3h)
|
||||||
|
|
||||||
|
### 步骤 R2.1 — 创建 RuleEngineService.cs
|
||||||
|
|
||||||
|
- [ ] 创建 `api_sqlsugar/Warehouse/Services/RuleEngineService.cs`
|
||||||
|
- [ ] 注入 `Iwarehouse_ruleRepository`, `Ibase_deviceRepository`, `Iiot_devicedataRepository`, `Iiot_alarmRepository`, `GatewayClient`, `IHubContext<HomePageMessageHub>`
|
||||||
|
- [ ] 实现 `EvaluateAllAsync()` — 主流程:
|
||||||
|
1. `LoadEnabledRulesAsync()` — 从 DB 加载启用规则 + 条件 + 动作
|
||||||
|
2. `BuildDeviceMappingAsync()` — DeviceId → (AdapterCode, SourceId, BaseUrl)
|
||||||
|
3. `BatchFetchRealtimeAsync()` — 调网关 B4-batch 批量取实时值
|
||||||
|
4. `EvaluateRuleAsync(rule, data)` — 逐规则比对
|
||||||
|
5. `ExecuteActionsAsync(rule)` — 触发动作
|
||||||
|
|
||||||
|
### 步骤 R2.2 — 实现条件评估
|
||||||
|
|
||||||
|
- [ ] `EvaluateConditionAsync(cond, realtimeData)`:
|
||||||
|
- 从 realtimeData 中找到对应设备+点位的实际值
|
||||||
|
- 按 CompareOperator 比对(大于/小于/等于/大于等于/小于等于/不等于)
|
||||||
|
- 支持滞后窗(P2-2):已触发过则用 RecoveryThreshold 判断恢复
|
||||||
|
- 支持条件级冷却(P2-3):未过冷却期则跳过
|
||||||
|
|
||||||
|
### 步骤 R2.3 — 实现动作执行
|
||||||
|
|
||||||
|
- [ ] `ExecuteActionsAsync(rule)`:
|
||||||
|
- 动作类型 "控制" → `GatewayClient.ControlDeviceAsync`(调网关 B5)
|
||||||
|
- 动作类型 "告警" → 写入 `iot_alarm` 表
|
||||||
|
- 动作类型 "通知" → SignalR `_hub.SendAsync("RuleTriggered", ...)`
|
||||||
|
- 冷却检查:未过 `CooldownSec` 不重复执行
|
||||||
|
- 并发执行:`Task.WhenAll` + 5s 超时(P3-1)
|
||||||
|
|
||||||
|
### 步骤 R2.4 — 编译验证
|
||||||
|
|
||||||
|
- [ ] `dotnet build api_sqlsugar/Warehouse` → 0 错误
|
||||||
|
|
||||||
|
> **R2 提交点**: `RuleEngine-R2: RuleEngineService 完整实现`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段 R3: RuleEngineJob + 调度(预计 30min)
|
||||||
|
|
||||||
|
### 步骤 R3.1 — 创建 RuleEngineJob.cs
|
||||||
|
|
||||||
|
- [ ] 创建 `api_sqlsugar/Warehouse/Services/RuleEngineJob.cs`
|
||||||
|
- [ ] 实现 `IJob` 接口,`Execute` 中获取 `RuleEngineService` 调 `EvaluateAllAsync()`
|
||||||
|
|
||||||
|
### 步骤 R3.2 — 注册 Quartz
|
||||||
|
|
||||||
|
- [ ] 管理端 → Quartz 管理 → 新建 Job:
|
||||||
|
```
|
||||||
|
JobName: RuleEngineJob
|
||||||
|
Cron: 0/10 * * * * ?
|
||||||
|
ClassName: Warehouse.Services.RuleEngineJob
|
||||||
|
```
|
||||||
|
- [ ] `dotnet build` → 0 错误
|
||||||
|
|
||||||
|
> **R3 提交点**: `RuleEngine-R3: RuleEngineJob 就绪`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段 R4: 前端配套(预计 1h)
|
||||||
|
|
||||||
|
### 步骤 R4.1 — 规则管理页增强
|
||||||
|
|
||||||
|
- [ ] 编辑 `web.vite/src/views/warehouse/warehouse_rule/warehouse_rule/options.js`
|
||||||
|
- [ ] 条件表格中 "设备" 列绑定 `allDevices` 动态字典
|
||||||
|
- [ ] "变量" 列绑定 `warehouse_variable` 字典
|
||||||
|
- [ ] 动作表格加"动作类型"下拉(控制/告警/通知)
|
||||||
|
|
||||||
|
### 步骤 R4.2 — 大屏告警接收
|
||||||
|
|
||||||
|
- [ ] 编辑 `warehouse/src/view/DataView.vue`
|
||||||
|
- [ ] SignalR 订阅 `RuleTriggered` 事件:
|
||||||
|
```javascript
|
||||||
|
connection.on("RuleTriggered", (data) => {
|
||||||
|
ElMessage.warning(`[规则触发] ${data.title}: ${data.alertMessage}`);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
> **R4 提交点**: `RuleEngine-R4: 前端配套完成`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段 R5: 联调验证(预计 2h)
|
||||||
|
|
||||||
|
### 步骤 R5.1 — 联调
|
||||||
|
|
||||||
|
- [ ] 网关启动 → MC4 在线 → RealtimePollJob 有数据
|
||||||
|
- [ ] 管理端新建规则:"温度 > 28℃ → 告警"
|
||||||
|
- [ ] 等 10s → iot_alarm 表有告警记录
|
||||||
|
- [ ] 管理端收到 SignalR 推送
|
||||||
|
- [ ] 管理端新建规则:"温度 > 28℃ → 控制空调"
|
||||||
|
- [ ] 等 10s → 网关 B5 被调用
|
||||||
|
|
||||||
|
> **R5 提交点**: `RuleEngine-R5: 联调通过`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务总览
|
||||||
|
|
||||||
|
| 阶段 | 步骤 | 内容 | 预计 |
|
||||||
|
|:---:|:---:|------|:---:|
|
||||||
|
| R1 | R1.1 | 建表 + 代码生成 | 20min |
|
||||||
|
| R1 | R1.2 | 变量表 + 字典 | 10min |
|
||||||
|
| R2 | R2.1 | RuleEngineService 主流程 | 1.5h |
|
||||||
|
| R2 | R2.2 | 条件评估 + 滞后窗 + 冷却 | 45min |
|
||||||
|
| R2 | R2.3 | 动作执行(控制/告警/通知) | 30min |
|
||||||
|
| R2 | R2.4 | 编译验证 | 15min |
|
||||||
|
| R3 | R3.1 | RuleEngineJob | 15min |
|
||||||
|
| R3 | R3.2 | Quartz 注册 | 15min |
|
||||||
|
| R4 | R4.1 | 管理端 UI 增强 | 30min |
|
||||||
|
| R4 | R4.2 | 大屏告警接收 | 30min |
|
||||||
|
| R5 | R5.1 | 联调 | 2h |
|
||||||
|
| **合计** | **11 步骤** | — | **~7h** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **注**: 原方案 R4(网关 B4-batch)已在 P1-1 修复中完成,R2 的 DeviceId 映射已在方案中设计,此次直接实现。`warehouse_variable` 建表 SQL 已在 `doc/db_init.sql` 中就绪,本次仅需执行。
|
||||||
@@ -38,11 +38,11 @@ public class Mc4Adapter : IHasOwnDeviceTree, IHasPoints, IHasAlarms
|
|||||||
/// <param name="adapterCode">适配器编码</param>
|
/// <param name="adapterCode">适配器编码</param>
|
||||||
/// <param name="http">HttpClient 实例</param>
|
/// <param name="http">HttpClient 实例</param>
|
||||||
/// <param name="baseUrl">MC4.0 服务地址</param>
|
/// <param name="baseUrl">MC4.0 服务地址</param>
|
||||||
public Mc4Adapter(string adapterCode, HttpClient http, string baseUrl)
|
public Mc4Adapter(string adapterCode, HttpClient http, string baseUrl, string account = "admin", string password = "admin")
|
||||||
{
|
{
|
||||||
AdapterCode = adapterCode;
|
AdapterCode = adapterCode;
|
||||||
_http = http;
|
_http = http;
|
||||||
_auth = new Mc4AuthHelper(http, baseUrl);
|
_auth = new Mc4AuthHelper(http, baseUrl, account, password);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>初始化适配器:获取 MC4.0 Token</summary>
|
/// <summary>初始化适配器:获取 MC4.0 Token</summary>
|
||||||
@@ -202,6 +202,65 @@ public class Mc4Adapter : IHasOwnDeviceTree, IHasPoints, IHasAlarms
|
|||||||
{
|
{
|
||||||
1 => "未确认", 2 => "已确认", 3 => "已结束", _ => "未确认"
|
1 => "未确认", 2 => "已确认", 3 => "已结束", _ => "未确认"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// M2: 批量点位查询 — MC4.0 原生 multi/value/get
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
/// <summary>批量获取多个设备的实时点位值</summary>
|
||||||
|
public async Task<Dictionary<int, List<Mc4PointValue>>> GetMultiRealtimeValuesAsync(List<int> deviceIds)
|
||||||
|
{
|
||||||
|
await _limiter.WaitAsync();
|
||||||
|
var client = await _auth.GetAuthenticatedClientAsync();
|
||||||
|
var body = JsonSerializer.Serialize(new { ids = deviceIds });
|
||||||
|
var resp = await client.PostAsync("/api/central/point/multi/value/get",
|
||||||
|
new StringContent(body, Encoding.UTF8, "application/json"));
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
var json = await resp.Content.ReadAsStringAsync();
|
||||||
|
return JsonSerializer.Deserialize<Dictionary<int, List<Mc4PointValue>>>(json)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// M3: 历史告警查询 — MC4.0 his_alarm/query
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
/// <summary>查询 MC4.0 历史告警(已恢复的告警)</summary>
|
||||||
|
public async Task<PagedResult<StandardAlarm>> GetHisAlarmsAsync(int page, int size, DateTime from, DateTime to)
|
||||||
|
{
|
||||||
|
await _limiter.WaitAsync();
|
||||||
|
var client = await _auth.GetAuthenticatedClientAsync();
|
||||||
|
var body = JsonSerializer.Serialize(new Mc4HisAlarmQuery
|
||||||
|
{
|
||||||
|
From = from.ToString("yyyy-MM-dd HH:mm:ss"),
|
||||||
|
To = to.ToString("yyyy-MM-dd HH:mm:ss"),
|
||||||
|
Skip = (page - 1) * size,
|
||||||
|
Limit = size,
|
||||||
|
Sort = 1
|
||||||
|
});
|
||||||
|
var resp = await client.PostAsync("/api/central/his_alarm/query",
|
||||||
|
new StringContent(body, Encoding.UTF8, "application/json"));
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
var json = await resp.Content.ReadAsStringAsync();
|
||||||
|
var result = JsonSerializer.Deserialize<Mc4AlarmQueryResult>(json)!;
|
||||||
|
return new PagedResult<StandardAlarm>
|
||||||
|
{
|
||||||
|
Items = (result.List ?? new()).Select(MapAlarmItem).ToList(),
|
||||||
|
Total = result.Total
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private StandardAlarm MapAlarmItem(Mc4AlarmItem a) => new()
|
||||||
|
{
|
||||||
|
AlarmId = a.Id ?? "",
|
||||||
|
AdapterCode = AdapterCode,
|
||||||
|
DeviceId = a.Sid?.ToString(),
|
||||||
|
Level = MapAlarmLevel(a.Level),
|
||||||
|
Title = a.Desc ?? "",
|
||||||
|
OccurTime = DateTime.TryParse(a.Stime, out var st) ? st : DateTime.MinValue,
|
||||||
|
Status = MapAlarmState(a.State),
|
||||||
|
ActualValue = a.Soption?.Value,
|
||||||
|
ThresholdValue = a.Eoption?.Value
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
@@ -279,3 +338,13 @@ public class Mc4Option
|
|||||||
public double? Value { get; set; }
|
public double? Value { get; set; }
|
||||||
public string? TypeName { get; set; }
|
public string? TypeName { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>MC4.0 历史告警查询请求</summary>
|
||||||
|
public class Mc4HisAlarmQuery
|
||||||
|
{
|
||||||
|
public string From { get; set; } = "";
|
||||||
|
public string To { get; set; } = "";
|
||||||
|
public int Skip { get; set; }
|
||||||
|
public int Limit { get; set; }
|
||||||
|
public int Sort { get; set; } = 1;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,62 +1,92 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace IntegrationGateway.Adapters.MC4;
|
namespace IntegrationGateway.Adapters.MC4;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// MC4.0 子系统的 Token 认证辅助类。
|
/// MC4.0 Token 认证辅助类。
|
||||||
///
|
///
|
||||||
/// 认证流程:
|
/// 认证流程:
|
||||||
/// 1. POST /api/central/auth/conf/get 获取临时 Token
|
/// 1. POST /api/central/auth/conf/get → { "encrypt": true/false }
|
||||||
/// 2. Token 有效期约 8 小时,缓存在内存中
|
/// 2. 若 encrypt=true → 密码用 MD5 加密
|
||||||
/// 3. 后续请求在 header["token"] 中携带 Token
|
/// 3. POST /api/central/auth/login { account, password } → { token, id, account, name }
|
||||||
///
|
/// 4. Token 缓存 7h(MC4.0 约 8h 有效期)
|
||||||
/// 注意:MC4.0 使用自定义 header "token" 而非标准 Authorization 头。
|
/// 5. 后续请求 header["token"] = token
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Mc4AuthHelper
|
public class Mc4AuthHelper
|
||||||
{
|
{
|
||||||
private readonly HttpClient _http;
|
private readonly HttpClient _http;
|
||||||
private readonly string _baseUrl;
|
private readonly string _baseUrl;
|
||||||
/// <summary>缓存的认证 Token</summary>
|
private readonly string _account;
|
||||||
|
private readonly string _password;
|
||||||
private string? _token;
|
private string? _token;
|
||||||
/// <summary>Token 过期时间(UTC),默认 8 小时</summary>
|
|
||||||
private DateTime _tokenExpiry = DateTime.MinValue;
|
private DateTime _tokenExpiry = DateTime.MinValue;
|
||||||
|
private bool? _needMd5;
|
||||||
|
|
||||||
/// <summary>创建 MC4.0 认证辅助</summary>
|
public Mc4AuthHelper(HttpClient http, string baseUrl, string account = "admin", string password = "admin")
|
||||||
/// <param name="http">HttpClient 实例</param>
|
|
||||||
/// <param name="baseUrl">MC4.0 服务地址</param>
|
|
||||||
public Mc4AuthHelper(HttpClient http, string baseUrl)
|
|
||||||
{
|
{
|
||||||
_http = http; _baseUrl = baseUrl.TrimEnd('/');
|
_http = http;
|
||||||
|
_baseUrl = baseUrl.TrimEnd('/');
|
||||||
|
_account = account;
|
||||||
|
_password = password;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>获取有效的 Token。缓存有效则直接返回,否则重新获取。</summary>
|
|
||||||
public async Task<string> GetTokenAsync()
|
public async Task<string> GetTokenAsync()
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(_token) && DateTime.UtcNow < _tokenExpiry) return _token;
|
if (!string.IsNullOrEmpty(_token) && DateTime.UtcNow < _tokenExpiry)
|
||||||
|
return _token;
|
||||||
|
|
||||||
var resp = await _http.PostAsync($"{_baseUrl}/api/central/auth/conf/get", null);
|
// 1. 获取加密配置
|
||||||
resp.EnsureSuccessStatusCode();
|
if (!_needMd5.HasValue)
|
||||||
var json = await resp.Content.ReadAsStringAsync();
|
{
|
||||||
var result = JsonSerializer.Deserialize<Mc4AuthResponse>(json);
|
try
|
||||||
_token = result?.Token ?? "";
|
{
|
||||||
_tokenExpiry = DateTime.UtcNow.AddHours(8);
|
var confResp = await _http.PostAsync($"{_baseUrl}/api/central/auth/conf/get", null);
|
||||||
return _token!;
|
if (confResp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var confJson = await confResp.Content.ReadAsStringAsync();
|
||||||
|
var conf = JsonSerializer.Deserialize<Mc4ConfResponse>(confJson);
|
||||||
|
_needMd5 = conf?.Encrypt ?? false;
|
||||||
|
}
|
||||||
|
else { _needMd5 = false; }
|
||||||
|
}
|
||||||
|
catch { _needMd5 = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 登录获取 Token
|
||||||
|
var pwd = _needMd5 == true ? ComputeMd5(_password) : _password;
|
||||||
|
var loginBody = JsonSerializer.Serialize(new { account = _account, password = pwd });
|
||||||
|
var resp = await _http.PostAsync($"{_baseUrl}/api/central/auth/login",
|
||||||
|
new StringContent(loginBody, Encoding.UTF8, "application/json"));
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
var json = await resp.Content.ReadAsStringAsync();
|
||||||
|
var result = JsonSerializer.Deserialize<Mc4LoginResponse>(json)
|
||||||
|
?? throw new Exception("MC4 登录响应为空");
|
||||||
|
if (string.IsNullOrEmpty(result.Token))
|
||||||
|
throw new Exception("MC4 登录失败: Token 为空");
|
||||||
|
_token = result.Token;
|
||||||
|
_tokenExpiry = DateTime.UtcNow.AddHours(7);
|
||||||
|
return _token;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建一个已认证的 HttpClient,自动在 header["token"] 中附带 Token。
|
|
||||||
/// </summary>
|
|
||||||
public async Task<HttpClient> GetAuthenticatedClientAsync()
|
public async Task<HttpClient> GetAuthenticatedClientAsync()
|
||||||
{
|
{
|
||||||
var token = await GetTokenAsync();
|
var token = await GetTokenAsync();
|
||||||
var client = new HttpClient { BaseAddress = new Uri(_baseUrl) };
|
var client = new HttpClient { BaseAddress = new Uri(_baseUrl) };
|
||||||
|
if (!string.IsNullOrEmpty(token))
|
||||||
client.DefaultRequestHeaders.Add("token", token);
|
client.DefaultRequestHeaders.Add("token", token);
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>强制清除缓存 Token</summary>
|
|
||||||
public void Invalidate() => _token = null;
|
public void Invalidate() => _token = null;
|
||||||
|
|
||||||
/// <summary>MC4.0 认证响应</summary>
|
private static string ComputeMd5(string input)
|
||||||
public class Mc4AuthResponse { public string? Token { get; set; } }
|
{
|
||||||
|
var bytes = MD5.HashData(Encoding.UTF8.GetBytes(input));
|
||||||
|
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Mc4ConfResponse { public bool Encrypt { get; set; } }
|
||||||
|
private class Mc4LoginResponse { public string? Token { get; set; } public int Id { get; set; } public string? Account { get; set; } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,37 +10,29 @@ namespace IntegrationGateway.Adapters.Owl;
|
|||||||
/// Owl 视频监控子系统适配器。
|
/// Owl 视频监控子系统适配器。
|
||||||
///
|
///
|
||||||
/// 实现的能力接口:
|
/// 实现的能力接口:
|
||||||
/// - IHasFlatDevices:设备列表(NVR)和通道列表
|
/// - IHasFlatDevices:GET /devices/channels → 设备+通道联合映射
|
||||||
/// - IHasStreams:实时取流、录像回放、云台控制、截图
|
/// - IHasStreams:实时取流、录像回放、云台控制、截图
|
||||||
/// - IHasRecordings:录像文件查询
|
/// - IHasRecordings:录像文件查询
|
||||||
/// - IAcceptsMetadataPush:设备元数据回写(如改名)
|
/// - IAcceptsMetadataPush:设备元数据回写(如改名)
|
||||||
|
/// - IHasAlarms:AI 事件映射
|
||||||
|
/// - IAcceptsControl:AI 检测启停、远程控制
|
||||||
///
|
///
|
||||||
/// 限流:5 QPS(Owl 推荐值)
|
/// 限流:5 QPS
|
||||||
/// PTZ 限制:仅支持 continuous 方向移动 + stop,不支持预设位
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAcceptsMetadataPush
|
public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAcceptsMetadataPush, IHasAlarms, IAcceptsControl
|
||||||
{
|
{
|
||||||
private readonly HttpClient _http;
|
private readonly HttpClient _http;
|
||||||
private readonly OwlAuthHelper _auth;
|
private readonly OwlAuthHelper _auth;
|
||||||
/// <summary>令牌桶限流器(5 QPS)</summary>
|
|
||||||
private readonly RateLimiter _limiter = new(5);
|
private readonly RateLimiter _limiter = new(5);
|
||||||
|
|
||||||
/// <summary>适配器编码,格式 "Owl:实例名"</summary>
|
|
||||||
public string AdapterCode { get; }
|
public string AdapterCode { get; }
|
||||||
/// <summary>人类可读的适配器名称</summary>
|
|
||||||
public string DisplayName => $"Owl ({AdapterCode})";
|
public string DisplayName => $"Owl ({AdapterCode})";
|
||||||
/// <summary>适配器能力声明</summary>
|
|
||||||
public AdapterCapabilities Capabilities => new()
|
public AdapterCapabilities Capabilities => new()
|
||||||
{
|
{
|
||||||
HasFlatDevices = true, HasStreams = true, HasPtz = true, HasRecordings = true, AcceptsMetadataPush = true
|
HasFlatDevices = true, HasStreams = true, HasPtz = true,
|
||||||
|
HasRecordings = true, AcceptsMetadataPush = true, HasAlarms = true
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>创建 OwlAdapter 实例</summary>
|
|
||||||
/// <param name="adapterCode">适配器编码</param>
|
|
||||||
/// <param name="http">HttpClient 实例</param>
|
|
||||||
/// <param name="baseUrl">Owl 服务地址</param>
|
|
||||||
/// <param name="username">登录用户名</param>
|
|
||||||
/// <param name="password">登录密码</param>
|
|
||||||
public OwlAdapter(string adapterCode, HttpClient http, string baseUrl, string username, string password)
|
public OwlAdapter(string adapterCode, HttpClient http, string baseUrl, string username, string password)
|
||||||
{
|
{
|
||||||
AdapterCode = adapterCode;
|
AdapterCode = adapterCode;
|
||||||
@@ -48,10 +40,12 @@ public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAccepts
|
|||||||
_auth = new OwlAuthHelper(http, baseUrl, username, password);
|
_auth = new OwlAuthHelper(http, baseUrl, username, password);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>初始化适配器:获取 Owl JWT Token</summary>
|
|
||||||
public async Task InitializeAsync() => await _auth.GetTokenAsync();
|
public async Task InitializeAsync() => await _auth.GetTokenAsync();
|
||||||
|
|
||||||
/// <summary>健康检查:尝试访问 Owl /health 端点</summary>
|
// ═══════════════════════════════════════════
|
||||||
|
// IGatewayAdapter — 健康检查
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
public async Task<bool> HealthCheckAsync()
|
public async Task<bool> HealthCheckAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -64,30 +58,76 @@ public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAccepts
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
// IHasFlatDevices 实现
|
// IHasFlatDevices — GET /devices/channels 设备+通道联合映射
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
/// <summary>分页获取 NVR 设备列表</summary>
|
|
||||||
public async Task<PagedResult<StandardDevice>> GetDevicesAsync(int page, int size, string? keyword = null)
|
public async Task<PagedResult<StandardDevice>> GetDevicesAsync(int page, int size, string? keyword = null)
|
||||||
{
|
{
|
||||||
await _limiter.WaitAsync();
|
await _limiter.WaitAsync();
|
||||||
var client = await _auth.GetAuthenticatedClientAsync();
|
var client = await _auth.GetAuthenticatedClientAsync();
|
||||||
var url = $"/devices?page={page}&size={size}";
|
var url = $"/devices/channels?page={page}&size=1000";
|
||||||
if (!string.IsNullOrEmpty(keyword)) url += $"&key={Uri.EscapeDataString(keyword)}";
|
if (!string.IsNullOrEmpty(keyword)) url += $"&key={Uri.EscapeDataString(keyword)}";
|
||||||
var json = await client.GetStringAsync(url);
|
var json = await client.GetStringAsync(url);
|
||||||
var owl = JsonSerializer.Deserialize<OwlPagedResult<OwlDevice>>(json)!;
|
var result = JsonSerializer.Deserialize<OwlPagedResult<OwlDeviceChannel>>(json)!;
|
||||||
return new PagedResult<StandardDevice>
|
|
||||||
|
var devices = new List<StandardDevice>();
|
||||||
|
var deviceItems = result.Items.Where(x => x.Type == "DEVICE").ToList();
|
||||||
|
var channelItems = result.Items.Where(x => x.Type == "CHANNEL").ToList();
|
||||||
|
|
||||||
|
foreach (var d in deviceItems)
|
||||||
{
|
{
|
||||||
Items = owl.Items.Select(MapDevice).ToList(),
|
var childChannels = channelItems.Where(c => c.Did == d.Id).ToList();
|
||||||
Total = owl.Total
|
devices.Add(MapDevice(d, childChannels));
|
||||||
};
|
foreach (var ch in childChannels)
|
||||||
|
devices.Add(MapChannel(ch, d.Id));
|
||||||
|
}
|
||||||
|
return new PagedResult<StandardDevice> { Items = devices, Total = devices.Count };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static StandardDevice MapDevice(OwlDeviceChannel d, List<OwlDeviceChannel> channels) => new()
|
||||||
|
{
|
||||||
|
SourceId = d.Id ?? "",
|
||||||
|
Name = d.Name ?? d.Id ?? "",
|
||||||
|
Category = "硬盘录像机",
|
||||||
|
Group = "视频设备",
|
||||||
|
IsOnline = d.IsOnline == "1",
|
||||||
|
IsParent = true,
|
||||||
|
IpAddress = d.Address,
|
||||||
|
Port = int.TryParse(d.Port, out var p) ? p : null,
|
||||||
|
Extra = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["manufacturer"] = d.Manufacturer,
|
||||||
|
["model"] = d.Model,
|
||||||
|
["firmware"] = d.Firmware,
|
||||||
|
["longitude"] = d.Longitude,
|
||||||
|
["latitude"] = d.Latitude,
|
||||||
|
["protocol"] = d.Protocol ?? "GB28181",
|
||||||
|
["transport"] = d.Transport,
|
||||||
|
["channelCount"] = d.ChannelCount ?? channels.Count
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static StandardDevice MapChannel(OwlDeviceChannel ch, string? parentDeviceId) => new()
|
||||||
|
{
|
||||||
|
SourceId = ch.Id ?? "",
|
||||||
|
Name = ch.Name ?? $"通道{ch.Id}",
|
||||||
|
Category = "摄像机",
|
||||||
|
Group = "视频设备",
|
||||||
|
IsOnline = ch.IsOnline?.ToLower() == "true" || ch.IsOnline == "1",
|
||||||
|
IsParent = false,
|
||||||
|
ParentSourceId = parentDeviceId,
|
||||||
|
Extra = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["hasPtz"] = (ch.Ptztype ?? 0) > 0 ? "1" : "0",
|
||||||
|
["app"] = ch.App,
|
||||||
|
["streamId"] = ch.StreamId
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
// IHasStreams 实现
|
// IHasStreams
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
/// <summary>获取通道实时视频流地址</summary>
|
|
||||||
public async Task<StreamUrls> GetLiveUrlAsync(string channelId)
|
public async Task<StreamUrls> GetLiveUrlAsync(string channelId)
|
||||||
{
|
{
|
||||||
await _limiter.WaitAsync();
|
await _limiter.WaitAsync();
|
||||||
@@ -99,39 +139,43 @@ public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAccepts
|
|||||||
return MapStreamUrls(play);
|
return MapStreamUrls(play);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>获取历史录像回放地址(HLS VOD 格式)</summary>
|
|
||||||
public async Task<StreamUrls> GetPlaybackUrlAsync(string channelId, DateTime start, DateTime end)
|
public async Task<StreamUrls> GetPlaybackUrlAsync(string channelId, DateTime start, DateTime end)
|
||||||
{
|
{
|
||||||
await _limiter.WaitAsync();
|
await _limiter.WaitAsync();
|
||||||
var client = await _auth.GetAuthenticatedClientAsync();
|
var client = await _auth.GetAuthenticatedClientAsync();
|
||||||
|
var token = await _auth.GetTokenAsync();
|
||||||
var startMs = new DateTimeOffset(start).ToUnixTimeMilliseconds();
|
var startMs = new DateTimeOffset(start).ToUnixTimeMilliseconds();
|
||||||
var endMs = new DateTimeOffset(end).ToUnixTimeMilliseconds();
|
var endMs = new DateTimeOffset(end).ToUnixTimeMilliseconds();
|
||||||
var token = await _auth.GetTokenAsync();
|
var baseUrl = (client.BaseAddress?.ToString() ?? "").TrimEnd('/');
|
||||||
return new StreamUrls
|
return new StreamUrls
|
||||||
{
|
{
|
||||||
Hls = $"{client.BaseAddress}recordings/channels/{channelId}/index.m3u8?start_ms={startMs}&end_ms={endMs}&token={token}"
|
Hls = $"{baseUrl}/recordings/channels/{channelId}/index.m3u8?start_ms={startMs}&end_ms={endMs}&token={token}"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>云台方向控制(continuous 模式,仅方向移动)</summary>
|
|
||||||
public async Task PtzControlAsync(string channelId, string direction, float speed)
|
public async Task PtzControlAsync(string channelId, string direction, float speed)
|
||||||
{
|
{
|
||||||
await _limiter.WaitAsync();
|
await _limiter.WaitAsync();
|
||||||
var client = await _auth.GetAuthenticatedClientAsync();
|
var client = await _auth.GetAuthenticatedClientAsync();
|
||||||
|
if (direction.StartsWith("preset_"))
|
||||||
|
{
|
||||||
|
var idx = int.Parse(direction.Replace("preset_", ""));
|
||||||
|
await client.PostAsJsonAsync($"/channels/{channelId}/ptz/control", new { action = "preset", preset = idx });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
await client.PostAsJsonAsync($"/channels/{channelId}/ptz/control",
|
await client.PostAsJsonAsync($"/channels/{channelId}/ptz/control",
|
||||||
new { action = "continuous", direction, speed });
|
new { action = "continuous", direction, speed });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>云台停止</summary>
|
|
||||||
public async Task PtzStopAsync(string channelId)
|
public async Task PtzStopAsync(string channelId)
|
||||||
{
|
{
|
||||||
await _limiter.WaitAsync();
|
await _limiter.WaitAsync();
|
||||||
var client = await _auth.GetAuthenticatedClientAsync();
|
var client = await _auth.GetAuthenticatedClientAsync();
|
||||||
await client.PostAsJsonAsync($"/channels/{channelId}/ptz/control",
|
await client.PostAsJsonAsync($"/channels/{channelId}/ptz/control", new { action = "stop" });
|
||||||
new { action = "stop" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>获取通道实时截图</summary>
|
|
||||||
public async Task<StreamUrls> GetSnapshotAsync(string channelId)
|
public async Task<StreamUrls> GetSnapshotAsync(string channelId)
|
||||||
{
|
{
|
||||||
await _limiter.WaitAsync();
|
await _limiter.WaitAsync();
|
||||||
@@ -144,10 +188,9 @@ public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAccepts
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
// IHasRecordings 实现
|
// IHasRecordings
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
/// <summary>分页查询录像文件记录</summary>
|
|
||||||
public async Task<PagedResult<StandardRecording>> GetRecordingsAsync(
|
public async Task<PagedResult<StandardRecording>> GetRecordingsAsync(
|
||||||
string channelId, DateTime start, DateTime end, int page, int size)
|
string channelId, DateTime start, DateTime end, int page, int size)
|
||||||
{
|
{
|
||||||
@@ -171,10 +214,9 @@ public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAccepts
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
// IAcceptsMetadataPush 实现
|
// IAcceptsMetadataPush
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
/// <summary>回写设备元数据(如改名)到 Owl</summary>
|
|
||||||
public async Task<MetadataPushResult> PushMetadataAsync(string sourceDeviceId, MetadataChangeSet changes)
|
public async Task<MetadataPushResult> PushMetadataAsync(string sourceDeviceId, MetadataChangeSet changes)
|
||||||
{
|
{
|
||||||
var client = await _auth.GetAuthenticatedClientAsync();
|
var client = await _auth.GetAuthenticatedClientAsync();
|
||||||
@@ -185,29 +227,75 @@ public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAccepts
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
// 内部映射方法
|
// IHasAlarms — AI 事件
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
/// <summary>Owl 设备 → StandardDevice 映射</summary>
|
public async Task<PagedResult<StandardAlarm>> GetAlarmsAsync(
|
||||||
private static StandardDevice MapDevice(OwlDevice d) => new()
|
int page, int size, DateTime from, DateTime to, string? level = null, string? state = null)
|
||||||
{
|
{
|
||||||
SourceId = d.Id ?? "",
|
await _limiter.WaitAsync();
|
||||||
Name = d.Name ?? d.Id ?? "",
|
var client = await _auth.GetAuthenticatedClientAsync();
|
||||||
Category = "硬盘录像机",
|
var fromMs = new DateTimeOffset(from).ToUnixTimeMilliseconds();
|
||||||
Group = "视频设备",
|
var toMs = new DateTimeOffset(to).ToUnixTimeMilliseconds();
|
||||||
IsOnline = d.IsOnline == "1",
|
var json = await client.GetStringAsync(
|
||||||
IsParent = true,
|
$"/events?page={page}&size={size}&start_ms={fromMs}&end_ms={toMs}");
|
||||||
IpAddress = d.Address,
|
var result = JsonSerializer.Deserialize<OwlPagedResult<OwlAiEvent>>(json)!;
|
||||||
Port = int.TryParse(d.Port, out var port) ? port : null,
|
return new PagedResult<StandardAlarm>
|
||||||
Extra = new Dictionary<string, object?>
|
|
||||||
{
|
{
|
||||||
["owlDeviceId"] = d.Id,
|
Items = result.Items.Select(MapEventToAlarm).ToList(),
|
||||||
["protocol"] = d.Protocol ?? "GB28181",
|
Total = result.Total
|
||||||
["transport"] = d.Transport
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task ConfirmAlarmAsync(string alarmId) => Task.CompletedTask;
|
||||||
|
public Task EndAlarmAsync(string alarmId) => Task.CompletedTask;
|
||||||
|
|
||||||
|
private StandardAlarm MapEventToAlarm(OwlAiEvent e) => new()
|
||||||
|
{
|
||||||
|
AlarmId = $"owl-ai-{e.Id}",
|
||||||
|
AdapterCode = AdapterCode,
|
||||||
|
Level = e.Label switch { "person" => "重要", "car" => "重要", _ => "普通" },
|
||||||
|
Title = $"AI检测: {e.Label} (置信度 {e.Score:P0})",
|
||||||
|
Content = e.Zones ?? "",
|
||||||
|
OccurTime = e.StartedAt.HasValue
|
||||||
|
? DateTimeOffset.FromUnixTimeMilliseconds(e.StartedAt.Value).DateTime
|
||||||
|
: DateTime.MinValue,
|
||||||
|
Status = (e.EndedAt ?? 0) > 0 ? "已结束" : "未确认"
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Owl 播放响应 → StreamUrls 映射(取第一个可用流)</summary>
|
// ═══════════════════════════════════════════
|
||||||
|
// IAcceptsControl — AI 启停 + 区域管理
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
public async Task<ControlResult> SendControlAsync(string sourceDeviceId, string command, Dictionary<string, object?> parameters)
|
||||||
|
{
|
||||||
|
await _limiter.WaitAsync();
|
||||||
|
var client = await _auth.GetAuthenticatedClientAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
switch (command)
|
||||||
|
{
|
||||||
|
case "ai-enable":
|
||||||
|
await client.PostAsync($"/channels/{sourceDeviceId}/ai/enable", null);
|
||||||
|
break;
|
||||||
|
case "ai-disable":
|
||||||
|
await client.PostAsync($"/channels/{sourceDeviceId}/ai/disable", null);
|
||||||
|
break;
|
||||||
|
case "zone-add":
|
||||||
|
await client.PostAsJsonAsync($"/channels/{sourceDeviceId}/zones", parameters!);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return new ControlResult { Success = false, Message = $"不支持的指令: {command}" };
|
||||||
|
}
|
||||||
|
return new ControlResult { Success = true };
|
||||||
|
}
|
||||||
|
catch (Exception ex) { return new ControlResult { Success = false, Message = ex.Message }; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// 内部工具
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
private static StreamUrls MapStreamUrls(OwlPlayResponse play)
|
private static StreamUrls MapStreamUrls(OwlPlayResponse play)
|
||||||
{
|
{
|
||||||
var item = play.Items?.FirstOrDefault();
|
var item = play.Items?.FirstOrDefault();
|
||||||
@@ -218,61 +306,3 @@ public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAccepts
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
|
||||||
// Owl JSON 反序列化模型(内部使用)
|
|
||||||
// ═══════════════════════════════════════════
|
|
||||||
|
|
||||||
/// <summary>Owl API 分页响应</summary>
|
|
||||||
public class OwlPagedResult<T>
|
|
||||||
{
|
|
||||||
public List<T> Items { get; set; } = new();
|
|
||||||
public int Total { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Owl 设备(NVR)</summary>
|
|
||||||
public class OwlDevice
|
|
||||||
{
|
|
||||||
public string? Id { get; set; }
|
|
||||||
public string? Name { get; set; }
|
|
||||||
public string? IsOnline { get; set; }
|
|
||||||
public string? Protocol { get; set; }
|
|
||||||
public string? Address { get; set; }
|
|
||||||
public string? Port { get; set; }
|
|
||||||
public string? Transport { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Owl 播放响应</summary>
|
|
||||||
public class OwlPlayResponse
|
|
||||||
{
|
|
||||||
public List<OwlPlayItem>? Items { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Owl 播放流条目</summary>
|
|
||||||
public class OwlPlayItem
|
|
||||||
{
|
|
||||||
public string? WsFlv { get; set; }
|
|
||||||
public string? HttpFlv { get; set; }
|
|
||||||
public string? Hls { get; set; }
|
|
||||||
public string? WebRtc { get; set; }
|
|
||||||
public string? Rtmp { get; set; }
|
|
||||||
public string? Rtsp { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Owl 截图响应</summary>
|
|
||||||
public class OwlSnapshotResponse
|
|
||||||
{
|
|
||||||
public string? Link { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Owl 录像记录</summary>
|
|
||||||
public class OwlRecording
|
|
||||||
{
|
|
||||||
public int Id { get; set; }
|
|
||||||
public string? Cid { get; set; }
|
|
||||||
public DateTime StartedAt { get; set; }
|
|
||||||
public DateTime EndedAt { get; set; }
|
|
||||||
public double Duration { get; set; }
|
|
||||||
public string? Path { get; set; }
|
|
||||||
public long Size { get; set; }
|
|
||||||
}
|
|
||||||
|
|||||||
107
gateway/src/IntegrationGateway.Adapters.Owl/OwlModels.cs
Normal file
107
gateway/src/IntegrationGateway.Adapters.Owl/OwlModels.cs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/// <summary>
|
||||||
|
/// Owl/GoWVP API 响应模型。
|
||||||
|
/// 从 OwlAdapter.cs 分离,便于维护和扩展。
|
||||||
|
/// </summary>
|
||||||
|
namespace IntegrationGateway.Adapters.Owl;
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// 通用
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
/// <summary>Owl API 分页响应</summary>
|
||||||
|
public class OwlPagedResult<T>
|
||||||
|
{
|
||||||
|
public List<T> Items { get; set; } = new();
|
||||||
|
public int Total { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// 设备+通道联合模型 (GET /devices/channels)
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
/// <summary>Owl 设备或通道(联合接口返回)</summary>
|
||||||
|
public class OwlDeviceChannel
|
||||||
|
{
|
||||||
|
public string? Id { get; set; }
|
||||||
|
public string? Type { get; set; } // "DEVICE" | "CHANNEL"
|
||||||
|
public string? Did { get; set; } // 通道所属设备 ID
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public string? IsOnline { get; set; } // DEVICE: "1"/"0", CHANNEL: true/false
|
||||||
|
public string? Manufacturer { get; set; }
|
||||||
|
public string? Model { get; set; }
|
||||||
|
public string? Firmware { get; set; }
|
||||||
|
public string? Longitude { get; set; }
|
||||||
|
public string? Latitude { get; set; }
|
||||||
|
public int? ChannelCount { get; set; }
|
||||||
|
public int? Ptztype { get; set; } // 0=无云台, 1=方向, 2=预置位
|
||||||
|
public string? Protocol { get; set; }
|
||||||
|
public string? Address { get; set; }
|
||||||
|
public string? Port { get; set; }
|
||||||
|
public string? Transport { get; set; }
|
||||||
|
public string? App { get; set; } // 流应用名
|
||||||
|
public string? StreamId { get; set; } // 流ID
|
||||||
|
public string? Status { get; set; }
|
||||||
|
public string? RegisterWay { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// 播放/流
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
/// <summary>Owl 播放响应</summary>
|
||||||
|
public class OwlPlayResponse
|
||||||
|
{
|
||||||
|
public List<OwlPlayItem>? Items { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Owl 播放流条目</summary>
|
||||||
|
public class OwlPlayItem
|
||||||
|
{
|
||||||
|
public string? WsFlv { get; set; }
|
||||||
|
public string? HttpFlv { get; set; }
|
||||||
|
public string? Hls { get; set; }
|
||||||
|
public string? WebRtc { get; set; }
|
||||||
|
public string? Rtmp { get; set; }
|
||||||
|
public string? Rtsp { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Owl 截图响应</summary>
|
||||||
|
public class OwlSnapshotResponse
|
||||||
|
{
|
||||||
|
public string? Link { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// 录像
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
/// <summary>Owl 录像记录</summary>
|
||||||
|
public class OwlRecording
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? Cid { get; set; }
|
||||||
|
public DateTime StartedAt { get; set; }
|
||||||
|
public DateTime EndedAt { get; set; }
|
||||||
|
public double Duration { get; set; }
|
||||||
|
public string? Path { get; set; }
|
||||||
|
public long Size { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// AI 事件
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
/// <summary>Owl AI 检测事件</summary>
|
||||||
|
public class OwlAiEvent
|
||||||
|
{
|
||||||
|
public long? Id { get; set; }
|
||||||
|
public string? Did { get; set; }
|
||||||
|
public string? Cid { get; set; }
|
||||||
|
public long? StartedAt { get; set; } // 毫秒时间戳
|
||||||
|
public long? EndedAt { get; set; }
|
||||||
|
public string? Label { get; set; } // person / car / ...
|
||||||
|
public float? Score { get; set; }
|
||||||
|
public string? Zones { get; set; }
|
||||||
|
public string? ImagePath { get; set; }
|
||||||
|
public string? Model { get; set; }
|
||||||
|
}
|
||||||
@@ -78,7 +78,7 @@ foreach (var m in mc4List)
|
|||||||
var code = $"MC4:{m.InstanceName ?? "default"}";
|
var code = $"MC4:{m.InstanceName ?? "default"}";
|
||||||
var a = new IntegrationGateway.Adapters.MC4.Mc4Adapter(code,
|
var a = new IntegrationGateway.Adapters.MC4.Mc4Adapter(code,
|
||||||
app.Services.GetRequiredService<IHttpClientFactory>().CreateClient("VolPro"),
|
app.Services.GetRequiredService<IHttpClientFactory>().CreateClient("VolPro"),
|
||||||
m.BaseUrl);
|
m.BaseUrl, m.Username, m.Password);
|
||||||
registry.Register(a);
|
registry.Register(a);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,22 +92,99 @@ Console.WriteLine($"[Gateway] {registry.All.Count} 个适配器已注册: {adapt
|
|||||||
// ── A1: 向 Vol.Pro 注册当前网关节点 ──
|
// ── A1: 向 Vol.Pro 注册当前网关节点 ──
|
||||||
var nodeCode = gwCfg["NodeCode"] ?? "gw-default";
|
var nodeCode = gwCfg["NodeCode"] ?? "gw-default";
|
||||||
var nodeToken = Environment.GetEnvironmentVariable("SECMPS_GATEWAY_TOKEN") ?? gwCfg["NodeToken"] ?? "";
|
var nodeToken = Environment.GetEnvironmentVariable("SECMPS_GATEWAY_TOKEN") ?? gwCfg["NodeToken"] ?? "";
|
||||||
|
var port = app.Urls.FirstOrDefault()?.Split(':').LastOrDefault() ?? "5100";
|
||||||
|
var selfUrl = gwCfg["SelfUrl"] ?? $"http://localhost:{port}";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var registerReq = new GatewayRegisterRequest
|
var registerReq = new GatewayRegisterRequest
|
||||||
{
|
{
|
||||||
NodeCode = nodeCode, Token = nodeToken,
|
NodeCode = nodeCode, Token = nodeToken,
|
||||||
AdapterTypes = adapterTypes,
|
AdapterTypes = adapterTypes,
|
||||||
BaseUrl = $"http://localhost:{app.Urls.FirstOrDefault()?.Split(':').LastOrDefault() ?? "5100"}"
|
BaseUrl = selfUrl
|
||||||
};
|
};
|
||||||
var registerResult = await clientFactory.RegisterAsync(registerReq);
|
var registerResult = await clientFactory.RegisterAsync(registerReq);
|
||||||
Console.WriteLine($"[Gateway] A1 注册完成: nodeCode={nodeCode}, adapters={adapterTypes}");
|
Console.WriteLine($"[Gateway] A1 注册完成: nodeCode={nodeCode}, adapters={adapterTypes}");
|
||||||
}
|
}
|
||||||
catch (Exception ex) { Console.Error.WriteLine($"[Gateway] A1 注册失败: {ex.Message}"); }
|
catch (Exception ex) { Console.Error.WriteLine($"[Gateway] A1 注册失败: {ex.Message}"); }
|
||||||
|
|
||||||
|
// ── A3: 同步所有适配器设备到 Vol.Pro ──
|
||||||
|
await SyncAllDevicesAsync(nodeCode, nodeToken, selfUrl);
|
||||||
|
Console.WriteLine("[Gateway] A3 设备同步完成");
|
||||||
|
|
||||||
|
// ── A2: 心跳 + 自动重注册 ──
|
||||||
|
var heartbeatInterval = int.TryParse(gwCfg["HeartbeatIntervalSec"], out var hs) ? hs : 15;
|
||||||
|
var failCount = 0; var maxFails = 3;
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(heartbeatInterval));
|
||||||
|
while (await timer.WaitForNextTickAsync())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await clientFactory.HeartbeatAsync(new GatewayHeartbeatRequest { NodeCode = nodeCode, Token = nodeToken });
|
||||||
|
failCount = 0;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
failCount++;
|
||||||
|
Console.Error.WriteLine($"[Gateway] A2 心跳失败 ({failCount}/{maxFails})");
|
||||||
|
if (failCount >= maxFails)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Gateway] 心跳连续失败, 尝试重新注册...");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await clientFactory.RegisterAsync(new GatewayRegisterRequest { NodeCode = nodeCode, Token = nodeToken, AdapterTypes = adapterTypes, BaseUrl = selfUrl });
|
||||||
|
await SyncAllDevicesAsync(nodeCode, nodeToken, selfUrl);
|
||||||
|
failCount = 0;
|
||||||
|
Console.WriteLine("[Gateway] 重新注册成功");
|
||||||
|
}
|
||||||
|
catch (Exception re) { Console.Error.WriteLine($"[Gateway] 重新注册失败: {re.Message}"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Console.WriteLine($"[Gateway] A2 心跳已启动 ({heartbeatInterval}s)");
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// B 组路由(管理端 / Vol.Pro → 网关)
|
// A 组辅助函数
|
||||||
// 所有路由通过适配器编码查找对应适配器,按能力接口分发请求
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async Task SyncAllDevicesAsync(string nc, string nt, string baseUrl)
|
||||||
|
{
|
||||||
|
var allDevices = new List<object>();
|
||||||
|
foreach (var adapter in registry.All)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (adapter is IHasFlatDevices flat)
|
||||||
|
{
|
||||||
|
var result = await flat.GetDevicesAsync(1, 1000);
|
||||||
|
foreach (var item in result.Items)
|
||||||
|
allDevices.Add(new { AdapterCode = item.AdapterCode, SourceId = item.SourceId, Name = item.Name, Category = item.Category, Group = item.Group, IsParent = item.IsParent, ParentSourceId = item.ParentSourceId, IsOnline = item.IsOnline, IpAddress = item.IpAddress, Port = item.Port, ExtraDataJson = item.Extra != null ? System.Text.Json.JsonSerializer.Serialize(item.Extra) : null });
|
||||||
|
}
|
||||||
|
else if (adapter is IHasOwnDeviceTree tree)
|
||||||
|
{
|
||||||
|
var nodes = await tree.GetObjectTreeAsync();
|
||||||
|
FlattenTree(allDevices, nodes, adapter.AdapterCode, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
if (allDevices.Any())
|
||||||
|
await clientFactory.SyncDevicesAsync(nc, nt, allDevices);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FlattenTree(List<object> devices, List<DeviceTreeNode> nodes, string ac, string? parentSourceId)
|
||||||
|
{
|
||||||
|
foreach (var n in nodes)
|
||||||
|
{
|
||||||
|
devices.Add(new { AdapterCode = ac, SourceId = n.SourceId, Name = n.Name ?? n.SourceId, Category = n.Tag ?? "IoT设备", Group = "IoT设备", IsParent = n.Type == 1, ParentSourceId = parentSourceId, IsOnline = true, IpAddress = (string?)null, Port = (int?)null, ExtraDataJson = n.Option != null ? System.Text.Json.JsonSerializer.Serialize(n.Option) : null });
|
||||||
|
if (n.Children?.Count > 0) FlattenTree(devices, n.Children, ac, n.SourceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// B 组路由(管理端/ Vol.Pro → 网关)
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// B1: 健康检查 — 返回所有适配器的健康状态和能力声明
|
// B1: 健康检查 — 返回所有适配器的健康状态和能力声明
|
||||||
@@ -184,11 +261,19 @@ 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: 批量实时点位值 — 一次请求返回多个设备的值
|
// B4-batch: 批量实时点位值 — MC4 原生批量接口,其他适配器 fallback
|
||||||
app.MapPost("/api/gateway/realtime/{adapter}/batch", async (string adapter, BatchRealtimeRequest req) =>
|
app.MapPost("/api/gateway/realtime/{adapter}/batch", async (string adapter, BatchRealtimeRequest req) =>
|
||||||
{
|
{
|
||||||
var a = registry.FindByCode<IHasPoints>(adapter);
|
var a = registry.FindByCode<IHasPoints>(adapter);
|
||||||
if (a == null) return Results.NotFound(new { error = "CAPABILITY_NOT_SUPPORTED" });
|
if (a == null) return Results.NotFound(new { error = "CAPABILITY_NOT_SUPPORTED" });
|
||||||
|
|
||||||
|
if (a is IntegrationGateway.Adapters.MC4.Mc4Adapter mc4 && req.DeviceIds?.Count > 0)
|
||||||
|
{
|
||||||
|
var intIds = req.DeviceIds.Select(int.Parse).ToList();
|
||||||
|
var multi = await mc4.GetMultiRealtimeValuesAsync(intIds);
|
||||||
|
return Results.Ok(multi);
|
||||||
|
}
|
||||||
|
|
||||||
var results = new Dictionary<string, List<PointValue>>();
|
var results = new Dictionary<string, List<PointValue>>();
|
||||||
foreach (var deviceId in req.DeviceIds ?? new())
|
foreach (var deviceId in req.DeviceIds ?? new())
|
||||||
try { results[deviceId] = await a.GetRealtimeValuesAsync(deviceId); } catch { }
|
try { results[deviceId] = await a.GetRealtimeValuesAsync(deviceId); } catch { }
|
||||||
@@ -291,52 +376,22 @@ app.MapPost("/api/gateway/devices/sync", async (string adapter) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════
|
|
||||||
// 配置 POCO
|
|
||||||
// ═══════════════════════════════════════════════
|
|
||||||
|
|
||||||
/// <summary>Owl 适配器配置项</summary>
|
|
||||||
public class OwlConfig
|
|
||||||
{
|
|
||||||
public string? InstanceName { get; set; }
|
|
||||||
public string BaseUrl { get; set; } = "";
|
|
||||||
public string Username { get; set; } = "admin";
|
|
||||||
public string Password { get; set; } = "admin";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>MC4.0 适配器配置项</summary>
|
|
||||||
public class Mc4Config
|
|
||||||
{
|
|
||||||
public string? InstanceName { get; set; }
|
|
||||||
public string BaseUrl { get; set; } = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>KMS 适配器配置项</summary>
|
|
||||||
public class KmsConfig
|
|
||||||
{
|
|
||||||
public string? InstanceName { get; set; }
|
|
||||||
public string BaseUrl { get; set; } = "";
|
|
||||||
public string ClientId { get; set; } = "";
|
|
||||||
public string ClientSecret { get; set; } = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════
|
|
||||||
// B 组请求 DTO
|
|
||||||
// ═══════════════════════════════════════════════
|
|
||||||
|
|
||||||
/// <summary>云台控制请求</summary>
|
|
||||||
/// <param name="Direction">方向:up/down/left/right/zoom_in/zoom_out/stop</param>
|
|
||||||
/// <param name="Action">动作类型:continuous 或 stop</param>
|
|
||||||
/// <param name="Speed">速度 0.0-1.0</param>
|
|
||||||
record PtzRequest(string? Direction, string Action, float Speed);
|
record PtzRequest(string? Direction, string Action, float Speed);
|
||||||
|
|
||||||
/// <summary>设备控制请求</summary>
|
|
||||||
/// <param name="DeviceId">目标设备 SourceId</param>
|
|
||||||
/// <param name="PointIndex">点位索引</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 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);
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// 配置 POCO
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
/// <summary>Owl 适配器配置项</summary>
|
||||||
|
public class OwlConfig { public string? InstanceName { get; set; } public string BaseUrl { get; set; } = ""; public string Username { get; set; } = "admin"; public string Password { get; set; } = "admin"; }
|
||||||
|
|
||||||
|
/// <summary>MC4.0 适配器配置项</summary>
|
||||||
|
public class Mc4Config { public string? InstanceName { get; set; } public string BaseUrl { get; set; } = ""; public string Username { get; set; } = "admin"; public string Password { get; set; } = "admin"; }
|
||||||
|
|
||||||
|
/// <summary>KMS 适配器配置项</summary>
|
||||||
|
public class KmsConfig { public string? InstanceName { get; set; } public string BaseUrl { get; set; } = ""; public string ClientId { get; set; } = ""; public string ClientSecret { get; set; } = ""; }
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ import http from '../api/http.js'
|
|||||||
// TODO Phase2: 遍历 base_device 加载 MapModelId → VgoMap 标记; 告警设备红色闪烁
|
// TODO Phase2: 遍历 base_device 加载 MapModelId → VgoMap 标记; 告警设备红色闪烁
|
||||||
|
|
||||||
import initMessageHub from './index.js' // 导入消息中心初始化函数
|
import initMessageHub from './index.js' // 导入消息中心初始化函数
|
||||||
|
// 规则引擎 SignalR 订阅: RuleTriggered → 告警弹窗
|
||||||
|
const initRuleEngineListener = (connection) => {
|
||||||
|
connection.on("RuleTriggered", (data) => {
|
||||||
|
handlePushMessage({ title: data.title || '规则触发', message: data.alertMessage || '', code: '3', notificationType: 3, level: data.alertMessage, date: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
};
|
||||||
import Message from './Message.vue'
|
import Message from './Message.vue'
|
||||||
|
|
||||||
// 生成唯一ID的辅助函数
|
// 生成唯一ID的辅助函数
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// *Author:jxx
|
|
||||||
// *Contact:283591387@qq.com
|
|
||||||
// *代码由框架生成,任何更改都可能导致被代码生成器覆盖
|
// *代码由框架生成,任何更改都可能导致被代码生成器覆盖
|
||||||
export default function(){
|
export default function(){
|
||||||
const table = {
|
const table = {
|
||||||
@@ -16,62 +14,65 @@ export default function(){
|
|||||||
const tableCNName = table.cnName;
|
const tableCNName = table.cnName;
|
||||||
const newTabEdit = false;
|
const newTabEdit = false;
|
||||||
const key = table.key;
|
const key = table.key;
|
||||||
const editFormFields = {"Title":"","JudgmentMode":"","JudgmentValue":""};
|
const editFormFields = {"Title":"","JudgmentMode":"","JudgmentValue":"","Enable":"启用","Priority":0,"CooldownSec":60};
|
||||||
const editFormOptions = [[{"title":"规则标题","required":true,"field":"Title","colSize":100.0}],
|
const editFormOptions = [
|
||||||
[{"dataKey":"条件判断方式","data":[],"title":"条件判断方式","field":"JudgmentMode","colSize":50.0,"type":"select"},
|
[{"title":"规则标题","required":true,"field":"Title","colSize":60.0},
|
||||||
{"dataKey":"条件判断目标值","data":[],"title":"条件判断目标值","field":"JudgmentValue","colSize":50.0,"type":"select"}]];
|
{"dataKey":"条件判断方式","data":[],"title":"判断方式","field":"JudgmentMode","colSize":40.0,"type":"select"}],
|
||||||
|
[{"title":"优先级","field":"Priority","colSize":50.0,"type":"number"},
|
||||||
|
{"title":"冷却时间(秒)","field":"CooldownSec","colSize":50.0,"type":"number"}],
|
||||||
|
[{"dataKey":"启用状态","data":[],"title":"启用","field":"Enable","colSize":50.0,"type":"select"},
|
||||||
|
{"dataKey":"条件判断目标值","data":[],"title":"目标值","field":"JudgmentValue","colSize":50.0,"type":"select"}]
|
||||||
|
];
|
||||||
const searchFormFields = {};
|
const searchFormFields = {};
|
||||||
const searchFormOptions = [];
|
const searchFormOptions = [];
|
||||||
const columns = [{field:'Title',title:'规则标题',type:'string',link:true,width:150,require:true,align:'left'},
|
const columns = [
|
||||||
{field:'JudgmentMode',title:'条件判断方式',type:'string',bind:{ key:'条件判断方式',data:[]},width:150,align:'left'},
|
{field:'Title',title:'规则标题',type:'string',link:true,width:150,require:true,align:'left'},
|
||||||
{field:'JudgmentValue',title:'条件判断目标值',type:'string',bind:{ key:'条件判断目标值',data:[]},width:110,align:'left'},
|
{field:'JudgmentMode',title:'判断方式',type:'string',bind:{ key:'条件判断方式',data:[]},width:100,align:'left'},
|
||||||
{field:'RuleID',title:'规则编号',type:'int',width:120,hidden:true,require:true,align:'left'}];
|
{field:'Priority',title:'优先级',type:'int',width:80,align:'left'},
|
||||||
|
{field:'CooldownSec',title:'冷却(秒)',type:'int',width:80,align:'left'},
|
||||||
|
{field:'Enable',title:'启用',type:'string',bind:{ key:'启用状态',data:[]},width:80,align:'left'},
|
||||||
|
{field:'LastTriggered',title:'上次触发',type:'datetime',width:150,align:'left'},
|
||||||
|
{field:'RuleID',title:'规则编号',type:'int',width:120,hidden:true,require:true,align:'left'}
|
||||||
|
];
|
||||||
const detail ={columns:[]};
|
const detail ={columns:[]};
|
||||||
const details = [ {
|
const details = [
|
||||||
|
{
|
||||||
cnName: '规则条件',
|
cnName: '规则条件',
|
||||||
table: 'warehouse_rulecondition',
|
table: 'warehouse_rulecondition',
|
||||||
columns: [{field:'id',title:'条件编号',type:'int',width:110,hidden:true,require:true,align:'left'},
|
columns: [
|
||||||
|
{field:'id',title:'条件编号',type:'int',width:110,hidden:true,require:true,align:'left'},
|
||||||
{field:'DeviceId',title:'设备',type:'int',bind:{ key:'所有设备列表',data:[]},width:110,edit:{type:'select'},align:'left'},
|
{field:'DeviceId',title:'设备',type:'int',bind:{ key:'所有设备列表',data:[]},width:110,edit:{type:'select'},align:'left'},
|
||||||
{field:'ValueId',title:'变量',type:'int',bind:{ key:'变量列表',data:[]},width:110,edit:{type:'select'},align:'left'},
|
{field:'ValueId',title:'变量',type:'int',width:110,edit:{type:'number'},align:'left'},
|
||||||
{field:'Type',title:'比对类型',type:'string',bind:{ key:'比对类型',data:[]},width:150,edit:{type:'select'},align:'left'},
|
{field:'Type',title:'比对类型',type:'string',bind:{ key:'比对类型',data:[]},width:150,edit:{type:'select'},align:'left'},
|
||||||
{field:'CompareOperator',title:'比较运算',type:'string',bind:{ key:'比较运算',data:[]},width:150,edit:{type:'select'},align:'left'},
|
{field:'CompareOperator',title:'比较运算',type:'string',bind:{ key:'比较运算',data:[]},width:150,edit:{type:'select'},align:'left'},
|
||||||
{field:'TargetValue_Switch',title:'目标值开关状态',type:'string',bind:{ key:'开关状态',data:[]},width:120,edit:{type:'select'},align:'left'},
|
{field:'TargetValue_Number',title:'目标值',type:'int',width:120,edit:{type:'number'},align:'left'},
|
||||||
{field:'TargetValue_Number',title:'目标值数值',type:'int',width:120,edit:{type:'number'},align:'left'},
|
{field:'TargetValue_Switch',title:'开关状态',type:'string',bind:{ key:'开关状态',data:[]},width:120,edit:{type:'select'},align:'left'},
|
||||||
{field:'RuleID',title:'所属规则编号',type:'int',width:120,hidden:true,align:'left'}],
|
{field:'RecoveryThreshold_Numeric',title:'恢复阈值',type:'decimal',width:120,edit:{type:'number'},align:'left'},
|
||||||
sortName: 'id',
|
{field:'RuleID',title:'所属规则编号',type:'int',width:120,hidden:true,align:'left'}
|
||||||
key: 'id',
|
],
|
||||||
buttons:[],
|
sortName: 'id', key: 'id', buttons:[], delKeys:[], detail:null
|
||||||
delKeys:[],
|
},
|
||||||
detail:null
|
{
|
||||||
},
|
|
||||||
cnName: '规则动作',
|
cnName: '规则动作',
|
||||||
table: 'warehouse_ruleaction',
|
table: 'warehouse_ruleaction',
|
||||||
table: 'warehouse_ruleaction',
|
columns: [
|
||||||
|
{field:'id',title:'动作编号',type:'int',width:110,hidden:true,require:true,align:'left'},
|
||||||
{field:'DeviceId',title:'设备',type:'int',bind:{ key:'所有设备列表',data:[]},width:110,edit:{type:'select'},align:'left'},
|
{field:'DeviceId',title:'设备',type:'int',bind:{ key:'所有设备列表',data:[]},width:110,edit:{type:'select'},align:'left'},
|
||||||
{field:'DeviceId',title:'设备',type:'int',bind:{ key:'所有设备列表',data:[]},width:110,edit:{type:'select'},align:'left'},
|
{field:'ValueId',title:'变量',type:'int',width:110,edit:{type:'number'},align:'left'},
|
||||||
{field:'ValueId',title:'变量',type:'int',bind:{ key:'变量列表',data:[]},width:110,edit:{type:'select'},align:'left'},
|
{field:'ActionType',title:'动作类型',type:'string',bind:{ key:'动作类型',data:[]},width:150,edit:{type:'select'},align:'left'},
|
||||||
{field:'Type',title:'值类型',type:'string',bind:{ key:'比对类型',data:[]},width:150,edit:{type:'select'},align:'left'},
|
{field:'TargetValue_Number',title:'目标值',type:'int',width:120,edit:{type:'number'},align:'left'},
|
||||||
{field:'TargetValue_Switch',title:'目标值开状态状态',type:'string',bind:{ key:'开关状态',data:[]},width:120,edit:{type:'select'},align:'left'},
|
{field:'TargetValue_Switch',title:'开关状态',type:'string',bind:{ key:'开关状态',data:[]},width:120,edit:{type:'select'},align:'left'},
|
||||||
{field:'TargetValue_Number',title:'目标值数值',type:'int',width:120,edit:{type:'number'},align:'left'},
|
{field:'Alert',title:'生成告警',type:'string',bind:{ key:'开关状态',data:[]},width:100,edit:{type:'select'},align:'left'},
|
||||||
{field:'RuleID',title:'所属规则编号',type:'int',width:120,hidden:true,align:'left'}],
|
{field:'AlertMessage',title:'告警内容',type:'string',width:200,edit:{type:'text'},align:'left'},
|
||||||
sortName: 'id',
|
{field:'RuleID',title:'所属规则编号',type:'int',width:120,hidden:true,align:'left'}
|
||||||
key: 'id',
|
],
|
||||||
buttons:[],
|
sortName: 'id', key: 'id', buttons:[], delKeys:[], detail:null
|
||||||
delKeys:[],
|
}
|
||||||
detail:null
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
return {
|
table, key, tableName, tableCNName, newTabEdit,
|
||||||
table,
|
editFormFields, editFormOptions, searchFormFields, searchFormOptions,
|
||||||
key,
|
columns, detail, details
|
||||||
tableName,
|
|
||||||
tableCNName,
|
|
||||||
newTabEdit,
|
|
||||||
editFormFields,
|
|
||||||
editFormOptions,
|
|
||||||
searchFormFields,
|
|
||||||
searchFormOptions,
|
|
||||||
columns,
|
|
||||||
detail,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user