Compare commits
3 Commits
79b8400e6d
...
29e12a235a
| Author | SHA1 | Date | |
|---|---|---|---|
| 29e12a235a | |||
| fa170e55a9 | |||
| 85600d0c80 |
@@ -1,66 +1,80 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using VolPro.Core.Filters;
|
using VolPro.Core.Filters;
|
||||||
using Warehouse.Services;
|
using Warehouse.IServices;
|
||||||
|
|
||||||
namespace Warehouse.Controllers;
|
namespace Warehouse.Controllers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 定时任务 API 端点。
|
/// 定时任务 API 端点。
|
||||||
/// VolPro 框架通过 Sys_QuartzOptions 配置 URL+Cron 定时调用。
|
/// Vol.Pro 框架通过 Sys_QuartzOptions 表配置 URL+Cron 定时调用。
|
||||||
/// 每个方法加 [ApiTask] 属性以允许框架匿名调用。
|
/// 每个方法加 [ApiTask] 属性以允许框架匿名调用。
|
||||||
///
|
///
|
||||||
/// 管理端配置:
|
/// 不在 Controller 层注入具体业务类——通过 HttpContext.RequestServices 按需解析,
|
||||||
/// syncDevices: 0 */5 * * * ?
|
/// 避免 Controller 构造函数的 DI 依赖链过长。
|
||||||
/// heartbeatMonitor: 0/15 * * * * ?
|
|
||||||
/// realtimePoll: 0/10 * * * * ?
|
|
||||||
/// ruleEngine: 0/10 * * * * ?
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
|
||||||
[Route("api/task")]
|
[Route("api/task")]
|
||||||
public class TaskController : Controller
|
public class TaskController : Controller
|
||||||
{
|
{
|
||||||
/// <summary>设备同步 — 遍历在线网关触发全量设备同步</summary>
|
/// <summary>T1: 设备同步 — 遍历在线网关触发全量设备同步(每5分钟)</summary>
|
||||||
[ApiTask]
|
[ApiTask]
|
||||||
[HttpGet, HttpPost, Route("syncDevices")]
|
[HttpGet, HttpPost, Route("syncDevices")]
|
||||||
public async Task<IActionResult> SyncDevices()
|
public async Task<IActionResult> SyncDevices()
|
||||||
{
|
{
|
||||||
var sp = HttpContext.RequestServices;
|
var sp = HttpContext.RequestServices;
|
||||||
var engine = sp.GetService<SyncDevicesJob>();
|
if (sp.GetService<Igateway_nodesService>() == null)
|
||||||
if (engine != null) await engine.Execute(null!);
|
return StatusCode(500, new { error = "服务未注册: gateway_nodesService" });
|
||||||
|
|
||||||
|
// 复用 SyncDevicesJob 的核心流程(Job 内部自行创建 GatewayClient)
|
||||||
|
var job = new VolPro.Warehouse.Services.SyncDevicesJob(sp);
|
||||||
|
await job.Execute(null!);
|
||||||
return Ok(new { time = DateTime.Now, status = "ok" });
|
return Ok(new { time = DateTime.Now, status = "ok" });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>心跳监控 — 扫描超时网关标记离线</summary>
|
/// <summary>T2: 心跳监控 — 扫描超时网关标记离线(每15秒)</summary>
|
||||||
[ApiTask]
|
[ApiTask]
|
||||||
[HttpGet, HttpPost, Route("heartbeatMonitor")]
|
[HttpGet, HttpPost, Route("heartbeatMonitor")]
|
||||||
public async Task<IActionResult> HeartbeatMonitor()
|
public async Task<IActionResult> HeartbeatMonitor()
|
||||||
{
|
{
|
||||||
var sp = HttpContext.RequestServices;
|
var sp = HttpContext.RequestServices;
|
||||||
var engine = sp.GetService<HeartbeatMonitorJob>();
|
var gwSvc = sp.GetService<Igateway_nodesService>();
|
||||||
if (engine != null) await engine.Execute(null!);
|
if (gwSvc == null)
|
||||||
|
return StatusCode(500, new { error = "服务未注册: gateway_nodesService" });
|
||||||
|
|
||||||
|
var job = new VolPro.Warehouse.Services.HeartbeatMonitorJob(sp);
|
||||||
|
await job.Execute(null!);
|
||||||
return Ok(new { time = DateTime.Now, status = "ok" });
|
return Ok(new { time = DateTime.Now, status = "ok" });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>实时轮询 — 拉取 MC4 IoT 实时值写入 iot_devicedata</summary>
|
/// <summary>T3: 实时轮询 — 拉取 MC4 IoT 实时值(每10秒)</summary>
|
||||||
[ApiTask]
|
[ApiTask]
|
||||||
[HttpGet, HttpPost, Route("realtimePoll")]
|
[HttpGet, HttpPost, Route("realtimePoll")]
|
||||||
public async Task<IActionResult> RealtimePoll()
|
public async Task<IActionResult> RealtimePoll()
|
||||||
{
|
{
|
||||||
var sp = HttpContext.RequestServices;
|
var sp = HttpContext.RequestServices;
|
||||||
var engine = sp.GetService<RealtimePollJob>();
|
var gwSvc = sp.GetService<Igateway_nodesService>();
|
||||||
if (engine != null) await engine.Execute(null!);
|
if (gwSvc == null)
|
||||||
|
return StatusCode(500, new { error = "服务未注册: gateway_nodesService" });
|
||||||
|
|
||||||
|
var job = new VolPro.Warehouse.Services.RealtimePollJob(sp);
|
||||||
|
await job.Execute(null!);
|
||||||
return Ok(new { time = DateTime.Now, status = "ok" });
|
return Ok(new { time = DateTime.Now, status = "ok" });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>规则引擎 — 评估规则条件+执行告警/控制/通知动作</summary>
|
/// <summary>T4: 规则引擎 — 评估规则+执行动作(每10秒)</summary>
|
||||||
[ApiTask]
|
[ApiTask]
|
||||||
[HttpGet, HttpPost, Route("ruleEngine")]
|
[HttpGet, HttpPost, Route("ruleEngine")]
|
||||||
public async Task<IActionResult> RuleEngine()
|
public async Task<IActionResult> RuleEngine()
|
||||||
{
|
{
|
||||||
var sp = HttpContext.RequestServices;
|
var sp = HttpContext.RequestServices;
|
||||||
var engine = sp.GetService<RuleEngineService>();
|
var ruleRepo = sp.GetService<Warehouse.IRepositories.Iwarehouse_ruleRepository>();
|
||||||
if (engine != null) await engine.EvaluateAllAsync();
|
if (ruleRepo == null)
|
||||||
|
return StatusCode(500, new { error = "服务未注册: Iwarehouse_ruleRepository" });
|
||||||
|
|
||||||
|
var engine = new Warehouse.Services.RuleEngineService(ruleRepo);
|
||||||
|
await engine.EvaluateAllAsync();
|
||||||
return Ok(new { time = DateTime.Now, status = "ok" });
|
return Ok(new { time = DateTime.Now, status = "ok" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,8 @@ using System.Linq;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using VolPro.Entity.DomainModels;
|
using VolPro.Entity.DomainModels;
|
||||||
using Warehouse.IRepositories;
|
using Warehouse.IRepositories;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using System.Net.Http;
|
||||||
using Warehouse.IServices;
|
using Warehouse.IServices;
|
||||||
|
|
||||||
namespace VolPro.Warehouse.Services;
|
namespace VolPro.Warehouse.Services;
|
||||||
@@ -29,7 +31,9 @@ public class RealtimePollJob : IJob
|
|||||||
var gwSvc = sp.GetService<Igateway_nodesService>();
|
var gwSvc = sp.GetService<Igateway_nodesService>();
|
||||||
var devRepo = sp.GetService<Ibase_deviceRepository>();
|
var devRepo = sp.GetService<Ibase_deviceRepository>();
|
||||||
var dataRepo = sp.GetService<Iiot_devicedataRepository>();
|
var dataRepo = sp.GetService<Iiot_devicedataRepository>();
|
||||||
var gatewayClient = sp.GetService<GatewayClient>();
|
var httpFactory = sp.GetService<IHttpClientFactory>();
|
||||||
|
var config = sp.GetService<IConfiguration>();
|
||||||
|
var gatewayClient = httpFactory != null ? new GatewayClient(httpFactory, config!) : null;
|
||||||
if (gwSvc == null || devRepo == null || dataRepo == null || gatewayClient == null) return;
|
if (gwSvc == null || devRepo == null || dataRepo == null || gatewayClient == null) return;
|
||||||
|
|
||||||
// 1. 查在线 MC4 网关
|
// 1. 查在线 MC4 网关
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
// 已迁移到 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,320 +1,29 @@
|
|||||||
using Microsoft.AspNetCore.SignalR;
|
// ═══════════════════════════════════════════
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
// RuleEngineService — 待实体字段就绪后启用。
|
||||||
|
// 阻塞原因: warehouse_rule.Enable/LastTriggered/CooldownSec
|
||||||
|
// warehouse_rulecondition.LastTriggered/RecoveryThreshold_Numeric
|
||||||
|
// warehouse_ruleaction.ActionType 等字段在实体类中不存在
|
||||||
|
// 修复顺序: SQL ALTER TABLE → VolPro 代码生成器 → 移除本桩恢复完整实现
|
||||||
|
// 完整实现见 git history: 提交 "RuleEngine-R2-R4: RuleEngineService+RuleEngineJob"
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using VolPro.Entity.DomainModels;
|
|
||||||
using VolPro.WebApi.Controllers.Hubs;
|
|
||||||
using Warehouse.IRepositories;
|
using Warehouse.IRepositories;
|
||||||
using Warehouse.IServices;
|
|
||||||
|
|
||||||
namespace Warehouse.Services;
|
namespace Warehouse.Services;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 规则引擎核心服务。
|
|
||||||
/// 由 RuleEngineJob 每 10s 调用一次 EvaluateAllAsync。
|
|
||||||
///
|
|
||||||
/// 流程:
|
|
||||||
/// 1. 加载所有启用规则(含条件+动作)
|
|
||||||
/// 2. 从 gateway 批量获取实时值
|
|
||||||
/// 3. 逐规则评估条件 → 触发动作 → 写日志
|
|
||||||
/// </summary>
|
|
||||||
public class RuleEngineService
|
public class RuleEngineService
|
||||||
{
|
{
|
||||||
private readonly Iwarehouse_ruleRepository _ruleRepo;
|
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(
|
public RuleEngineService(Iwarehouse_ruleRepository ruleRepo)
|
||||||
Iwarehouse_ruleRepository ruleRepo,
|
|
||||||
Ibase_deviceRepository devRepo,
|
|
||||||
Iiot_devicedataRepository dataRepo,
|
|
||||||
Iiot_alarmRepository alarmRepo,
|
|
||||||
GatewayClient gatewayClient,
|
|
||||||
IHubContext<HomePageMessageHub> hub)
|
|
||||||
{
|
{
|
||||||
_ruleRepo = ruleRepo;
|
_ruleRepo = ruleRepo;
|
||||||
_devRepo = devRepo;
|
|
||||||
_dataRepo = dataRepo;
|
|
||||||
_alarmRepo = alarmRepo;
|
|
||||||
_gatewayClient = gatewayClient;
|
|
||||||
_hub = hub;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task EvaluateAllAsync()
|
public Task EvaluateAllAsync()
|
||||||
{
|
{
|
||||||
// 1. 加载启用规则
|
throw new NotImplementedException(
|
||||||
var rules = await LoadEnabledRulesAsync();
|
"RuleEngineService 待实体字段就绪。步骤: SQL ALTER TABLE → 代码生成器 → git revert 本桩。");
|
||||||
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 { /* 单动作失败不阻塞 */ }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using Quartz;
|
using Quartz;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using System.Net.Http;
|
||||||
using Warehouse.IServices;
|
using Warehouse.IServices;
|
||||||
using VolPro.Entity.DomainModels;
|
using VolPro.Entity.DomainModels;
|
||||||
using System;
|
using System;
|
||||||
@@ -21,7 +23,9 @@ public class SyncDevicesJob : IJob
|
|||||||
{
|
{
|
||||||
var sp = _sp;
|
var sp = _sp;
|
||||||
var gwSvc = sp.GetService<Igateway_nodesService>();
|
var gwSvc = sp.GetService<Igateway_nodesService>();
|
||||||
var client = sp.GetService<GatewayClient>();
|
var httpFactory = sp.GetService<IHttpClientFactory>();
|
||||||
|
var config = sp.GetService<IConfiguration>();
|
||||||
|
var client = httpFactory != null ? new GatewayClient(httpFactory, config!) : null;
|
||||||
if (gwSvc == null || client == null) return;
|
if (gwSvc == null || client == null) return;
|
||||||
|
|
||||||
// 遍历所有在线且启用的网关
|
// 遍历所有在线且启用的网关
|
||||||
|
|||||||
@@ -24,10 +24,13 @@ using System.Text.Json;
|
|||||||
|
|
||||||
namespace Warehouse.Services
|
namespace Warehouse.Services
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// gateway_nodes 业务逻辑(partial)。注册/心跳/设备同步。
|
||||||
|
/// </summary>
|
||||||
public partial class gateway_nodesService
|
public partial class gateway_nodesService
|
||||||
{
|
{
|
||||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||||
private readonly Igateway_nodesRepository _repository;//访问数据库
|
private readonly Igateway_nodesRepository _repository;
|
||||||
|
|
||||||
[ActivatorUtilitiesConstructor]
|
[ActivatorUtilitiesConstructor]
|
||||||
public gateway_nodesService(
|
public gateway_nodesService(
|
||||||
@@ -38,24 +41,22 @@ namespace Warehouse.Services
|
|||||||
{
|
{
|
||||||
_httpContextAccessor = httpContextAccessor;
|
_httpContextAccessor = httpContextAccessor;
|
||||||
_repository = dbRepository;
|
_repository = dbRepository;
|
||||||
//多租户会用到这init代码,其他情况可以不用
|
|
||||||
//base.Init(dbRepository);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 网关注册(Upsert)。
|
/// 网关注册(Upsert)。
|
||||||
/// NodeCode 匹配则更新适配器类型/地址/在线状态并返回已有 NodeId,
|
/// NodeCode 匹配则更新适配器类型/地址/在线状态;
|
||||||
/// NodeCode 不匹配且 Token 验证通过则插入新记录。
|
/// NodeCode 不匹配且 Token 验证通过则插入新记录。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Obsolete("由 A1 API Controller 自动调用,不建议手动调用")]
|
||||||
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 = await _repository.FindAsIQueryable<gateway_nodes>()
|
var existingList = await _repository.FindAsIQueryable(x => x.NodeCode == nodeCode).ToListAsync();
|
||||||
.FirstOrDefaultAsync(x => x.NodeCode == nodeCode);
|
var existing = existingList.FirstOrDefault();
|
||||||
|
|
||||||
gateway_nodes entity;
|
gateway_nodes entity;
|
||||||
if (existing != null)
|
if (existing != null)
|
||||||
{
|
{
|
||||||
// 已存在:验证Token,更新网关上报字段
|
|
||||||
if (existing.NodeToken != token)
|
if (existing.NodeToken != token)
|
||||||
throw new UnauthorizedAccessException("NodeToken 不匹配");
|
throw new UnauthorizedAccessException("NodeToken 不匹配");
|
||||||
|
|
||||||
@@ -68,7 +69,6 @@ namespace Warehouse.Services
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// 新节点:直接插入
|
|
||||||
entity = new gateway_nodes
|
entity = new gateway_nodes
|
||||||
{
|
{
|
||||||
NodeCode = nodeCode,
|
NodeCode = nodeCode,
|
||||||
@@ -89,10 +89,11 @@ namespace Warehouse.Services
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 心跳更新。更新 LastHeartbeat 并标记在线。
|
/// 心跳更新。更新 LastHeartbeat 并标记在线。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Obsolete("由 A2 API Controller 自动调用,不建议手动调用")]
|
||||||
public async Task UpdateHeartbeatAsync(string nodeCode, string token)
|
public async Task UpdateHeartbeatAsync(string nodeCode, string token)
|
||||||
{
|
{
|
||||||
var entity = _repository.FindAsIQueryable<gateway_nodes>()
|
var entityList = await _repository.FindAsIQueryable(x => x.NodeCode == nodeCode && x.NodeToken == token).ToListAsync();
|
||||||
.FirstOrDefaultAsync(x => x.NodeCode == nodeCode && x.NodeToken == token);
|
var entity = entityList.FirstOrDefault();
|
||||||
if (entity == null)
|
if (entity == null)
|
||||||
throw new UnauthorizedAccessException("认证失败:NodeCode 或 Token 无效");
|
throw new UnauthorizedAccessException("认证失败:NodeCode 或 Token 无效");
|
||||||
|
|
||||||
@@ -102,15 +103,15 @@ namespace Warehouse.Services
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 设备数据同步。按照字段分治原则写入 base_device:
|
/// 设备数据同步。按字段分治原则写入 base_device:
|
||||||
/// 首次入库写全量,后续仅更新网关字段(IsOnline/ExtraData/ParentDeviceId等)。
|
/// 首次入库写全量,后续仅更新网关字段。
|
||||||
/// parentSourceId 解析为 ParentDeviceId。
|
/// parentSourceId 解析为 ParentDeviceId。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Obsolete("由 A3 API Controller 自动调用,不建议手动调用")]
|
||||||
public async Task<(int added, int updated)> SyncDevicesAsync(int gatewayNodeId, List<SyncDeviceItem> devices)
|
public async Task<(int added, int updated)> SyncDevicesAsync(int gatewayNodeId, List<SyncDeviceItem> devices)
|
||||||
{
|
{
|
||||||
var db = _repository.DbContext;
|
var db = _repository.DbContext;
|
||||||
|
|
||||||
// 批量查询已有设备映射表(用于 parentSourceId → ParentDeviceId 解析)
|
|
||||||
var adapterCodes = devices.Select(d => d.AdapterCode).Distinct().ToList();
|
var adapterCodes = devices.Select(d => d.AdapterCode).Distinct().ToList();
|
||||||
var existingIds = db.Queryable<base_device>()
|
var existingIds = db.Queryable<base_device>()
|
||||||
.Where(x => x.NodeId == gatewayNodeId && adapterCodes.Contains(x.AdapterCode))
|
.Where(x => x.NodeId == gatewayNodeId && adapterCodes.Contains(x.AdapterCode))
|
||||||
@@ -124,7 +125,6 @@ namespace Warehouse.Services
|
|||||||
existingIds.TryGetValue(key, out var existingId);
|
existingIds.TryGetValue(key, out var existingId);
|
||||||
bool isNew = existingId == 0;
|
bool isNew = existingId == 0;
|
||||||
|
|
||||||
// 解析 parentSourceId → ParentDeviceId
|
|
||||||
int? parentDeviceId = null;
|
int? parentDeviceId = null;
|
||||||
if (!string.IsNullOrEmpty(d.ParentSourceId))
|
if (!string.IsNullOrEmpty(d.ParentSourceId))
|
||||||
{
|
{
|
||||||
@@ -134,7 +134,6 @@ namespace Warehouse.Services
|
|||||||
|
|
||||||
if (isNew)
|
if (isNew)
|
||||||
{
|
{
|
||||||
// 首次入库写全量
|
|
||||||
var entity = new base_device
|
var entity = new base_device
|
||||||
{
|
{
|
||||||
DeviceName = d.Name ?? $"DEV_{d.SourceId}",
|
DeviceName = d.Name ?? $"DEV_{d.SourceId}",
|
||||||
@@ -158,7 +157,6 @@ namespace Warehouse.Services
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// 已有记录:仅更新网关字段
|
|
||||||
var entity = db.Queryable<base_device>().InSingle(existingId);
|
var entity = db.Queryable<base_device>().InSingle(existingId);
|
||||||
if (entity != null)
|
if (entity != null)
|
||||||
{
|
{
|
||||||
@@ -178,7 +176,7 @@ namespace Warehouse.Services
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>网关同步设备条目(A3 接口接收的数据模型)</summary>
|
/// <summary>网关同步设备条目</summary>
|
||||||
public class SyncDeviceItem
|
public class SyncDeviceItem
|
||||||
{
|
{
|
||||||
public string AdapterCode { get; set; } = "";
|
public string AdapterCode { get; set; } = "";
|
||||||
|
|||||||
608
doc/代码审核/代码审核20260604.md
Normal file
608
doc/代码审核/代码审核20260604.md
Normal file
@@ -0,0 +1,608 @@
|
|||||||
|
# warehouse 客户端代码深度审核报告(含完整修复方案)
|
||||||
|
|
||||||
|
> 日期: 2026-06-04 | 项目: warehouse/ | 扫描: 38 源文件, ~12,000 行 | 问题: 70 项
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. `api/http.js` — 9 项
|
||||||
|
|
||||||
|
### H1 [🔴] `lang_storage_key` 未定义
|
||||||
|
|
||||||
|
**位置**: 第 134 行
|
||||||
|
```javascript
|
||||||
|
function setHeaderLang(_header) {
|
||||||
|
let langType = localStorage.getItem(lang_storage_key) // ← 未定义!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**后果**: 运行时报错 `lang_storage_key is not defined`,语言功能完全失效。
|
||||||
|
**修复** — 在函数上方加常量:
|
||||||
|
```javascript
|
||||||
|
const lang_storage_key = 'lang'
|
||||||
|
```
|
||||||
|
|
||||||
|
### H2 [🔴] `replaceToken` 未定义
|
||||||
|
|
||||||
|
**位置**: 第 107 行
|
||||||
|
```javascript
|
||||||
|
function checkResponse(res) { if (res.headers.vol_exp == '1') { replaceToken() } }
|
||||||
|
```
|
||||||
|
**后果**: Token 过期后调用未定义函数,静默失败。
|
||||||
|
**修复** — 在 `checkResponse` 前追加:
|
||||||
|
```javascript
|
||||||
|
function replaceToken() {
|
||||||
|
store.dispatch('clearUserInfo')
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**跨文件影响**: 需确认 `store/index.js` 有 `clearUserInfo` mutation(已存在)。
|
||||||
|
|
||||||
|
### H3 [🔴] `toLogin` 未定义
|
||||||
|
|
||||||
|
**位置**: 第 77 行
|
||||||
|
```javascript
|
||||||
|
if (error.response.status == '401') { toLogin() }
|
||||||
|
```
|
||||||
|
**修复** — 在文件顶部 import router 后追加:
|
||||||
|
```javascript
|
||||||
|
import router from '@/router'
|
||||||
|
function toLogin() { router.push('/login') }
|
||||||
|
```
|
||||||
|
**跨文件影响**: 需确认 `router/index.ts` 导出 router 实例。
|
||||||
|
|
||||||
|
### H4 [🟠] 降级地址硬编码
|
||||||
|
|
||||||
|
**位置**: 第 25-33 行
|
||||||
|
```javascript
|
||||||
|
axios.defaults.baseURL = 'http://192.168.3.108:9100/'
|
||||||
|
dataViewUrl = 'http://192.168.3.108:9200/'
|
||||||
|
```
|
||||||
|
**修复**:
|
||||||
|
```javascript
|
||||||
|
axios.defaults.baseURL = window.location.origin
|
||||||
|
dataViewUrl = (window as any).apiConfig?.dataViewUrl || window.location.origin
|
||||||
|
```
|
||||||
|
|
||||||
|
### H5 [🟠] `get()` 参数 `param` 未使用
|
||||||
|
|
||||||
|
**位置**: 第 176 行
|
||||||
|
```javascript
|
||||||
|
function get(url, param, loading, config) { axios.get(url, config) }
|
||||||
|
```
|
||||||
|
**修复**:
|
||||||
|
```javascript
|
||||||
|
function get(url, param, loading, config) {
|
||||||
|
const cfg = { ...config }
|
||||||
|
if (param) cfg.params = param
|
||||||
|
// ... 其余不变
|
||||||
|
axios.get(url, cfg).then(...)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### H6 [🟡] `closeLoading` 冗余
|
||||||
|
|
||||||
|
**位置**: 第 92-101 行
|
||||||
|
```javascript
|
||||||
|
if (loadingInstance) loadingInstance.close()
|
||||||
|
if (loadingStatus) { loadingStatus = false; if (loadingInstance) loadingInstance.close() }
|
||||||
|
```
|
||||||
|
**修复**:
|
||||||
|
```javascript
|
||||||
|
loadingStatus = false
|
||||||
|
loadingInstance?.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
### H7 [🟡] `alert()` 弹窗
|
||||||
|
|
||||||
|
**位置**: 第 199 行
|
||||||
|
```javascript
|
||||||
|
alert('http.js未配置大屏url地址')
|
||||||
|
```
|
||||||
|
**修复**:
|
||||||
|
```javascript
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
ElMessage.error('未配置大屏URL地址')
|
||||||
|
```
|
||||||
|
|
||||||
|
### H8 [🟡] 无类型安全
|
||||||
|
|
||||||
|
**修复** — 在文件头部加 JSDoc(不改变运行时):
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
* @param {string} url
|
||||||
|
* @param {object} [params]
|
||||||
|
* @param {boolean|string} [loading]
|
||||||
|
* @param {object} [config]
|
||||||
|
* @returns {Promise<T>}
|
||||||
|
*/
|
||||||
|
function post(url, params, loading, config) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### H9 [⚪] 文件臃肿 (409行)
|
||||||
|
|
||||||
|
**修复** — 拆为 3 文件:
|
||||||
|
- `api/http-client.ts` — Axios 实例 + baseURL + 拦截器 (80行)
|
||||||
|
- `api/http-auth.ts` — getToken/replaceToken/toLogin (40行)
|
||||||
|
- `api/http-loading.ts` — showLoading/closeLoading (20行)
|
||||||
|
|
||||||
|
`api/http.js` 改为:
|
||||||
|
```javascript
|
||||||
|
import { createHttpClient } from './http-client'
|
||||||
|
export default createHttpClient()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. `api/gateway.ts` — 3 项
|
||||||
|
|
||||||
|
### GW1 [🟠] 网关地址硬编码
|
||||||
|
|
||||||
|
**位置**: 第 5 行 `const GW_BASE = 'http://192.168.3.108:5100'`
|
||||||
|
|
||||||
|
**修复**:
|
||||||
|
```typescript
|
||||||
|
const GW_BASE = (window as any).apiConfig?.gatewayUrl || 'http://localhost:5100'
|
||||||
|
```
|
||||||
|
|
||||||
|
### GW2 [🟡] `fetch()` 无超时
|
||||||
|
|
||||||
|
**修复** — 完整重写 `gwGet`(`gwPost` 同理):
|
||||||
|
```typescript
|
||||||
|
export async function gwGet(url: string, timeoutMs = 10000): Promise<any> {
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs)
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${GW_BASE}${url}`, { signal: ctrl.signal })
|
||||||
|
if (!resp.ok) throw new Error(`网关请求失败: ${resp.status}`)
|
||||||
|
return resp.json()
|
||||||
|
} finally { clearTimeout(timer) }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GW3 [🟡] 模型映射混入API层
|
||||||
|
|
||||||
|
**修复** — 新建 `warehouse/src/services/cameraService.ts`:
|
||||||
|
```typescript
|
||||||
|
import { gwGet, type Camera, type StandardDevice } from '@/api/gateway'
|
||||||
|
|
||||||
|
export function toCamera(d: StandardDevice): Camera { ... }
|
||||||
|
export async function fetchCameras(adapter: string): Promise<Camera[]> { ... }
|
||||||
|
```
|
||||||
|
**跨文件影响**: `Live.vue`, `VideoWall.vue`, `History.vue` 的 import 改为 `from '@/services/cameraService'`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. `api/buttons.js` — 1 项
|
||||||
|
|
||||||
|
### B1 [🟡] Element UI 旧版图标语法
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
icon: 'el-icon-search'
|
||||||
|
```
|
||||||
|
Element Plus ≥2.0 已废弃字符串图标。
|
||||||
|
|
||||||
|
**修复** — 改为组件引用:
|
||||||
|
```javascript
|
||||||
|
import { Search, Plus, Edit, DocumentCopy, Delete, Check, Finished, Top, Bottom, Printer } from '@element-plus/icons-vue'
|
||||||
|
import { shallowRef } from 'vue'
|
||||||
|
|
||||||
|
const buttons = [
|
||||||
|
{ name:'查询', icon: shallowRef(Search), ... },
|
||||||
|
{ name:'新建', icon: shallowRef(Plus), ... },
|
||||||
|
// ... 其余同理
|
||||||
|
]
|
||||||
|
```
|
||||||
|
**跨文件影响**: 使用 buttons 的组件需确认其渲染逻辑支持组件引用(通常通过 `<component :is="btn.icon" />`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. `api/permission.js` — 2 项
|
||||||
|
|
||||||
|
### PE1 [🟠] 权限缺失时静默放行
|
||||||
|
|
||||||
|
**位置**: 第 24 行
|
||||||
|
```javascript
|
||||||
|
if (!permission) { permission = { permission: ['Search'] } }
|
||||||
|
```
|
||||||
|
**修复**:
|
||||||
|
```javascript
|
||||||
|
if (!permission) { return [] }
|
||||||
|
```
|
||||||
|
空数组使所有按钮不可见(安全默认)。
|
||||||
|
|
||||||
|
### PE2 [🟡] `to401` 空实现
|
||||||
|
|
||||||
|
**修复**:
|
||||||
|
```javascript
|
||||||
|
import router from '@/router'
|
||||||
|
function to401() { router.push('/401') }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. `router/index.ts` — 5 项
|
||||||
|
|
||||||
|
### R1 [🟠] 40+ 条路由指向同一组件
|
||||||
|
|
||||||
|
所有菜单子项渲染 `Index.vue`,用户看到重复空白页。
|
||||||
|
|
||||||
|
**修复** — 3 步方案:
|
||||||
|
1. 为已实现的页面保留独立路由(VideoWall/AlarmRecord/AccessRecord 等已存在)
|
||||||
|
2. 其余指向一个占位组件:
|
||||||
|
```typescript
|
||||||
|
{ path: "/index/goods/list", component: () => import("@/view/Placeholder.vue") }
|
||||||
|
```
|
||||||
|
3. `Placeholder.vue`:
|
||||||
|
```vue
|
||||||
|
<template><el-empty description="功能开发中" /></template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### R2 [🟡] `/new-dv` 不要求认证
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{ path:"/new-dv", meta:{ requiresAuth: false } }
|
||||||
|
```
|
||||||
|
**修复**: 改为 `requiresAuth: true`。
|
||||||
|
|
||||||
|
### R3 [🟡] 缺少 beforeEach 守卫
|
||||||
|
|
||||||
|
**修复** — 在 `router/index.ts` 末尾追加:
|
||||||
|
```typescript
|
||||||
|
router.beforeEach((to, from, next) => {
|
||||||
|
if (to.meta.requiresAuth !== false) {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (token) next()
|
||||||
|
else next('/login')
|
||||||
|
} else { next() }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### R4 [⚪] `@ts-ignore` 绕过 store 类型
|
||||||
|
|
||||||
|
**修复**: 创建 `warehouse/src/types/store.d.ts`:
|
||||||
|
```typescript
|
||||||
|
declare module '@/store' {
|
||||||
|
const store: import('vuex').Store<any>
|
||||||
|
export default store
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### R5 [⚪] 仓库/货物/出入库路由 20+ 条未使用
|
||||||
|
|
||||||
|
**修复**: 删除。若后续需要可从 git 恢复。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. `main.ts` — 1 项
|
||||||
|
|
||||||
|
### M1 [🟡] 暗色模式写死
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
app.use(ElementPlus, { dark: true })
|
||||||
|
```
|
||||||
|
**修复**:
|
||||||
|
```typescript
|
||||||
|
const dark = localStorage.getItem('dark-mode') !== 'false'
|
||||||
|
app.use(ElementPlus, { dark })
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. `view/index.js` — 6 项
|
||||||
|
|
||||||
|
### SI1 [🔴] `displayedMessageIds` Set 无限增长
|
||||||
|
|
||||||
|
**位置**: 第 8 行 `let displayedMessageIds = new Set()`
|
||||||
|
**后果**: 运行数天后 Set 包含成千上万条 ID → 内存泄漏。
|
||||||
|
|
||||||
|
**修复** — 改为 LRU 缓存(保留最近 500 条):
|
||||||
|
```javascript
|
||||||
|
class LruSet {
|
||||||
|
#set = new Set()
|
||||||
|
#max = 500
|
||||||
|
add(v) { if (this.#set.has(v)) return; this.#set.add(v); if (this.#set.size > this.#max) { this.#set.delete(this.#set.values().next().value) } }
|
||||||
|
has(v) { return this.#set.has(v) }
|
||||||
|
}
|
||||||
|
const displayedMessageIds = new LruSet()
|
||||||
|
```
|
||||||
|
|
||||||
|
### SI2 [🟠] 消息队列 3s 延迟堆积
|
||||||
|
|
||||||
|
**修复**: 删除 `messageQueue`/`processMessageQueue`,直接调 `receive`:
|
||||||
|
```javascript
|
||||||
|
connection.on("ReceiveHomePageMessage", function (data) {
|
||||||
|
if (displayedMessageIds.has(data.id)) return
|
||||||
|
displayedMessageIds.add(data.id)
|
||||||
|
receive(data)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### SI3 [🟠] connection 启动无重试
|
||||||
|
|
||||||
|
`connection.start().catch(...)` 失败后永远不重试。
|
||||||
|
|
||||||
|
**修复**:
|
||||||
|
```javascript
|
||||||
|
async function startWithRetry(retries = 5) {
|
||||||
|
for (let i = 0; i < retries; i++) {
|
||||||
|
try { await connection.start(); return }
|
||||||
|
catch (e) { console.warn(`SignalR retry ${i+1}/${retries}: ${e.message}`); await new Promise(r => setTimeout(r, 2000)) }
|
||||||
|
}
|
||||||
|
console.error('SignalR connection failed after retries')
|
||||||
|
}
|
||||||
|
startWithRetry()
|
||||||
|
```
|
||||||
|
|
||||||
|
### SI4 [🟡] `console.log` 残留 6 处
|
||||||
|
|
||||||
|
**修复**: 全部替换为 `if (import.meta.env.DEV) console.log(...)`。
|
||||||
|
|
||||||
|
### SI5 [🟡] `receive` 被双重调用
|
||||||
|
|
||||||
|
`processMessageQueue` 和 `ReceiveHomePageMessage` 都调了 `receive`。
|
||||||
|
|
||||||
|
**修复**: SI2 删除了 `processMessageQueue` 后此问题自动消除。
|
||||||
|
|
||||||
|
### SI6 [🟡] 用户信息获取失败静默
|
||||||
|
|
||||||
|
**修复**:
|
||||||
|
```javascript
|
||||||
|
http.post("api/user/GetCurrentUserInfo").then(...).catch(error => {
|
||||||
|
console.error('获取用户信息失败,SignalR未启动:', error)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8-28: 视图层文件(21 个 Vue 文件)
|
||||||
|
|
||||||
|
### 通用修复模式(适用于所有 Mock 页面)
|
||||||
|
|
||||||
|
以下 17 个页面全 Mock —— 统一修复方案:
|
||||||
|
|
||||||
|
```
|
||||||
|
AccessRecord.vue, AlarmRecord.vue, EmergencyAlarmRecord.vue,
|
||||||
|
KeyInfo.vue, KeyApply.vue, EnvVarManagement.vue,
|
||||||
|
PatrolLog.vue, ScheduleManagement.vue, PathManagement.vue,
|
||||||
|
DroneManagement.vue, dataview.vue, CarApply.vue, CarManager.vue,
|
||||||
|
DeviceStatus.vue(V), VisitorsManagement.vue, VisitCarManagement.vue
|
||||||
|
```
|
||||||
|
|
||||||
|
**统一修复**: 每个页面增加网关 API 调用骨架,Mock 数据降级为 fallback:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 以 AlarmRecord.vue 为例
|
||||||
|
import { gwGet } from '@/api/gateway'
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const data = await gwGet('/api/gateway/alarms/Owl:main?page=1&size=100')
|
||||||
|
alarmData.value = data.items.map((a: StandardAlarm) => ({
|
||||||
|
id: a.alarmId, alarmTime: a.occurTime, deviceName: a.title,
|
||||||
|
location: a.deviceId, status: a.status, imageUrl: '/images/placeholder.png'
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
alarmData.value = getMockAlarmData() // fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 单个页面专项修复
|
||||||
|
|
||||||
|
**DataView.vue** (1840行):
|
||||||
|
- DV1: 告警等级 → 改用 `level` 字段或网关 `StandardAlarm.level` 值
|
||||||
|
- DV2: `setTimeout` 加 `clearTimeout`:
|
||||||
|
```typescript
|
||||||
|
const timers = new Set<number>()
|
||||||
|
onBeforeUnmount(() => timers.forEach(t => clearTimeout(t)))
|
||||||
|
// 使用时: timers.add(setTimeout(...))
|
||||||
|
```
|
||||||
|
- DV6: `originalData: JSON.parse(JSON.stringify(data))` 避免循环引用
|
||||||
|
|
||||||
|
**DeviceInfo.vue** (1300行):
|
||||||
|
- DI1: 对接网关 B4 获取真实在线率
|
||||||
|
- DI2: `randomVideoImage` → `gwGet('/api/gateway/streams/.../live')`
|
||||||
|
- DI3: `handleTurnOn` → `gwPost('/api/gateway/realtime/.../control', { deviceId, pointIndex, value })`
|
||||||
|
|
||||||
|
**Live.vue** / **VideoWall.vue** / **History.vue**:
|
||||||
|
- LV2/VH2: `setInterval` 加清理:
|
||||||
|
```typescript
|
||||||
|
const timer = setInterval(updateTime, 1000)
|
||||||
|
onBeforeUnmount(() => clearInterval(timer))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Main.vue**:
|
||||||
|
- MA1: icon 改为 `@element-plus/icons-vue` 组件引用
|
||||||
|
- MA2: Menu 配置提取:
|
||||||
|
```typescript
|
||||||
|
const menuItems = [
|
||||||
|
{ index:'1', icon: VideoCamera, label:'视频监控', children:[
|
||||||
|
{ index:'/index/video/videowall', label:'视频墙' }
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
- MA5: 统一 `import { useMapStore } from '@/stores/mapStore'`
|
||||||
|
|
||||||
|
**Map.vue**:
|
||||||
|
- MP1: `const m = /#\/(\d+)/.exec(location.hash); const mapId = m?.[1] || 'default'`
|
||||||
|
|
||||||
|
**Index.vue**:
|
||||||
|
- IN1: `const mapId = import.meta.env.VITE_MAP_ID || 'default'`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 29-31: 组件层(3 文件)
|
||||||
|
|
||||||
|
### Filter.vue [🟡]
|
||||||
|
|
||||||
|
**console.log 8 处**: 同 SI4,改为条件输出。
|
||||||
|
|
||||||
|
**硬件设备图标映射** 提取为常量:
|
||||||
|
```typescript
|
||||||
|
const DEVICE_ICONS: Record<string, string> = {
|
||||||
|
'摄像头':'/images/dataview/deviceinfo/camera.png',
|
||||||
|
'门禁':'/images/dataview/deviceinfo/access.png',
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fence.vue [🟡]
|
||||||
|
|
||||||
|
**硬编码仓库名**: `['1号库','2号库','12号库']`
|
||||||
|
**修复**: 从 store 或配置注入:
|
||||||
|
```typescript
|
||||||
|
const warehouseNames = inject('warehouseNames', ['1号库'])
|
||||||
|
const inFencePoints = store.polygonDataAll.filter(p => warehouseNames.includes(p.name))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 32-34: 状态管理层(3 文件)
|
||||||
|
|
||||||
|
### SM1 [🔴] 两份 `useMapStore` 同名
|
||||||
|
|
||||||
|
**文件**: `stores/mapStore.js` 和 `store/useMapStore.js` 都 `defineStore('map', ...)`
|
||||||
|
|
||||||
|
**修复** — 选择一份保留(推荐 `stores/mapStore.js` 功能更全),另一份删除:
|
||||||
|
```bash
|
||||||
|
git rm warehouse/src/store/useMapStore.js
|
||||||
|
```
|
||||||
|
**跨文件影响** — 修改以下文件的 import:
|
||||||
|
- `Map.vue` line 9: `'../store/useMapStore'` → `'../stores/mapStore'`
|
||||||
|
- `Index.vue` line 10: `'../stores/mapStore'` (已对)
|
||||||
|
- `Fence.vue` line 4: `'../store/useMapStore'` → `'../stores/mapStore'`
|
||||||
|
- `Filter.vue` line 3: `'../store/useMapStore'` → `'../stores/mapStore'`
|
||||||
|
- `DataView.vue` line 10: `'../stores/mapStore'` (已对)
|
||||||
|
|
||||||
|
### ST1 [🟠] `getServiceList` getter 忽略参数
|
||||||
|
|
||||||
|
**位置**: `store/index.js`
|
||||||
|
```javascript
|
||||||
|
getServiceList: (state) => (path) => { return state.serviceList || [] }
|
||||||
|
```
|
||||||
|
**修复**: 如果不需要按 path 过滤则简化为:
|
||||||
|
```javascript
|
||||||
|
getServiceList: (state) => state.serviceList || []
|
||||||
|
```
|
||||||
|
|
||||||
|
### ST3 [🟡] `test` mutation 返回 `113344`
|
||||||
|
|
||||||
|
**位置**: `store/index.js` line 49
|
||||||
|
```javascript
|
||||||
|
test(state) { return 113344 } // 调试代码
|
||||||
|
```
|
||||||
|
**修复**: 删除此 mutation。
|
||||||
|
|
||||||
|
### ST4 [⚪] `setPermission` 数组 push 会叠加
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
if (data instanceof Array) { state.permission.push(...data) }
|
||||||
|
```
|
||||||
|
每次调用追加而非替换。**修复**: 始终替换 `state.permission = data`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 35: 项目结构 — 4 项
|
||||||
|
|
||||||
|
### PS1/PS2 [🟡] 过期副本文件
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rm warehouse/src/view/DataView\ copy.vue
|
||||||
|
git rm warehouse/src/view/Map.vue.bak
|
||||||
|
```
|
||||||
|
|
||||||
|
### PS3 [🟡] 文档放在源码目录
|
||||||
|
```bash
|
||||||
|
mkdir -p warehouse/doc
|
||||||
|
mv warehouse/src/view/intercom/TODO_*.md warehouse/doc/
|
||||||
|
```
|
||||||
|
|
||||||
|
### PS4 [🟠] Vuex + Pinia 共存
|
||||||
|
|
||||||
|
**修复** — 迁移 Vuex → Pinia:
|
||||||
|
1. 新建 `stores/authStore.js`(替代 Vuex 的 userInfo/token/permission)
|
||||||
|
2. 迁移 `store/index.js` 中的 `setUserInfo`/`getToken`/`getPermission` 到 Pinia
|
||||||
|
3. `npm uninstall vuex`
|
||||||
|
4. 修改 `http.js`/`Login.vue`/`permission.js` 从 `@/stores/authStore` 导入
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 36: 全局问题 — 7 项
|
||||||
|
|
||||||
|
### G1 [🟠] Mock 覆盖率 86%
|
||||||
|
|
||||||
|
22 页面中仅 3 个对接网关。**分 4 阶段对接**:
|
||||||
|
|
||||||
|
| 阶段 | 页面 | 网关接口 | 预计 |
|
||||||
|
|:--:|------|------|:--:|
|
||||||
|
| 1 | 告警页 (AlarmRecord/EmergencyAlarm) | B8 (GET /alarms) | 2h |
|
||||||
|
| 2 | 环境变量 (EnvVarManagement) | B4 (GET /realtime) | 2h |
|
||||||
|
| 3 | 门禁/钥匙 (AccessRecord/KeyInfo) | B2 (GET /devices) + B11 (GET /logs) | 3h |
|
||||||
|
| 4 | 巡更/无人机/车辆/访客 | B2 设备列表 | 1h |
|
||||||
|
|
||||||
|
### G2 [🟡] 硬编码IP散布6+文件
|
||||||
|
|
||||||
|
**修复** — 创建 `.env.development`:
|
||||||
|
```
|
||||||
|
VITE_GATEWAY_URL=http://localhost:5100
|
||||||
|
VITE_VOLPRO_URL=http://localhost:9100
|
||||||
|
```
|
||||||
|
各文件改为:
|
||||||
|
```typescript
|
||||||
|
const GW_BASE = import.meta.env.VITE_GATEWAY_URL || 'http://localhost:5100'
|
||||||
|
```
|
||||||
|
|
||||||
|
### G3 [🟡] JS/TS 混用
|
||||||
|
|
||||||
|
**修复**: 7 个 `.js` → `.ts`(逐文件迁移,不改运行时逻辑):
|
||||||
|
```
|
||||||
|
api/http.js → api/http.ts
|
||||||
|
api/buttons.js → api/buttons.ts
|
||||||
|
api/permission.js → api/permission.ts
|
||||||
|
view/index.js → view/index.ts
|
||||||
|
stores/mapStore.js → stores/mapStore.ts
|
||||||
|
router/viewGird.js → router/viewGird.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### G4 [🟡] `window.*` 全局变量
|
||||||
|
|
||||||
|
**修复**: `window.$map` → Store 管理(已存在 `mapStore.setMap`)
|
||||||
|
```typescript
|
||||||
|
// Map.vue: 删除 window.$map = map,保留 store.setMap(map)
|
||||||
|
// 其他文件: 改 window.$map → const { map } = useMapStore()
|
||||||
|
```
|
||||||
|
|
||||||
|
### G5 [🟡] `console.log` 残留 30+ 处
|
||||||
|
|
||||||
|
**修复** — 一行全局替换:
|
||||||
|
```bash
|
||||||
|
# 将所有 console.log 改为条件输出
|
||||||
|
find warehouse/src -name "*.vue" -o -name "*.ts" -o -name "*.js" | xargs sed -i 's/console\.log(/if(import\.meta\.env\.DEV)console.log(/g'
|
||||||
|
```
|
||||||
|
|
||||||
|
### G6 [⚪] 无全局错误边界
|
||||||
|
|
||||||
|
**修复** — `main.ts`:
|
||||||
|
```typescript
|
||||||
|
app.config.errorHandler = (err) => {
|
||||||
|
console.error('Global error:', err)
|
||||||
|
ElMessage.error('系统异常,请刷新页面')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### G7 [⚪] `counter.ts` 模板残留
|
||||||
|
|
||||||
|
**修复**: 删除 `warehouse/src/stores/counter.ts`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行优先级
|
||||||
|
|
||||||
|
| 批次 | 项 | 文件 | 预计 | 影响 |
|
||||||
|
|:--:|------|------|:--:|------|
|
||||||
|
| 🔴P0 | H1+H2+H3+SI1+SM1 | 5 | 2h | 修复运行时错误 |
|
||||||
|
| 🟠P1 | SI3+PE1+R1+R3+G1(告警) | 6 | 4h | 功能可用性 |
|
||||||
|
| 🟡P2 | GW1+GW2+H5+H6+DV1+LV2 | 6 | 3h | 代码质量 |
|
||||||
|
| ⚪P3 | PS+console+G6+CT1 | 5 | 1h | 整洁性 |
|
||||||
|
|
||||||
|
> **总计**: 70 项 / 预估 10h。P0 批 5 项必须在联调前完成。
|
||||||
424
doc/设计文档/VolPro网关相关接口文档_v1.0.md
Normal file
424
doc/设计文档/VolPro网关相关接口文档_v1.0.md
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
# VolPro.WebApi 网关相关接口文档
|
||||||
|
|
||||||
|
> **版本**: 1.0
|
||||||
|
> **日期**: 2026-06-04
|
||||||
|
> **基址**: `http://{host}:{port}`(默认 `http://localhost:9100`)
|
||||||
|
> **内容类型**: `application/json`
|
||||||
|
> **接口来源**: `api_sqlsugar/VolPro.WebApi/Controllers/Warehouse/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
1. [A 组 — 网关注册通信](#1-a-组--网关注册通信)
|
||||||
|
- A1: 网关注册 `POST /api/gateway/register`
|
||||||
|
- A2: 心跳上报 `POST /api/gateway/heartbeat`
|
||||||
|
- A3: 设备同步 `POST /api/gateway/sync/devices`
|
||||||
|
- A4: 告警同步 `POST /api/gateway/sync/alarms`
|
||||||
|
2. [设备管理](#2-设备管理)
|
||||||
|
- 区域树 `GET /api/DeviceManager/GetRegionTree`
|
||||||
|
- 点位设备 `GET /api/DeviceManager/GetDevicesByPoint`
|
||||||
|
3. [定时任务](#3-定时任务)
|
||||||
|
- 设备同步任务 `POST /api/task/syncDevices`
|
||||||
|
- 心跳监控任务 `POST /api/task/heartbeatMonitor`
|
||||||
|
- 实时轮询任务 `POST /api/task/realtimePoll`
|
||||||
|
- 规则引擎任务 `POST /api/task/ruleEngine`
|
||||||
|
4. [错误代码](#4-错误代码)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. A 组 — 网关注册通信
|
||||||
|
|
||||||
|
> A 组接口是 VolPro 向网关暴露的管理端点,由网关主动调用。所有 A 组使用 `[AllowAnonymous]` + `NodeToken` 二次认证,不走 VolPro JWT 体系。
|
||||||
|
>
|
||||||
|
> **实现文件**: `Controllers/Warehouse/Partial/gateway_nodesController.cs`
|
||||||
|
|
||||||
|
### A1: 网关注册(Upsert)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/gateway/register
|
||||||
|
```
|
||||||
|
|
||||||
|
网关启动时调用,注册自身节点信息。NodeCode 已存在则更新,不存在则插入。返回当前网关的已有设备列表供网关对比差异。
|
||||||
|
|
||||||
|
**请求头**: `Content-Type: application/json`
|
||||||
|
|
||||||
|
**请求体 (GatewayRegisterRequest)**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| `NodeCode` | string | ✅ | 网关节点编码,如 `gw-31ku` |
|
||||||
|
| `Token` | string | ✅ | 认证令牌(由环境变量 `SECMPS_GATEWAY_TOKEN` 注入) |
|
||||||
|
| `AdapterTypes` | string | ✅ | 适配器类型列表(逗号分隔),如 `Owl:main,MC4:31ku` |
|
||||||
|
| `BaseUrl` | string | ✅ | 网关自身地址,如 `http://192.168.1.10:5100` |
|
||||||
|
|
||||||
|
**返回参数**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `nodeId` | int | 网关节点 ID(base_device.NodeId 外键) |
|
||||||
|
| `devices` | array | 该网关已有的设备列表 |
|
||||||
|
|
||||||
|
**devices[] 条目**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `deviceId` | int | 设备自增 ID |
|
||||||
|
| `deviceName` | string | 设备名称 |
|
||||||
|
| `adapterCode` | string | 适配器编码 |
|
||||||
|
| `sourceId` | string | 子系统设备原始 ID |
|
||||||
|
| `deviceCategory` | string | 设备种类 |
|
||||||
|
| `deviceGroup` | string | 设备分组 |
|
||||||
|
| `isParent` | string | 是否父设备("是"/"否") |
|
||||||
|
| `isOnline` | string | 是否在线("在线"/"离线") |
|
||||||
|
| `extraData` | string? | 扩展数据 JSON |
|
||||||
|
|
||||||
|
**返回示例**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nodeId": 1,
|
||||||
|
"devices": [
|
||||||
|
{ "deviceId": 10, "deviceName": "NVR-1", "adapterCode": "Owl:main", "sourceId": "nvr_001", "deviceCategory": "硬盘录像机", "deviceGroup": "视频设备", "isParent": "是", "isOnline": "在线" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误响应**:
|
||||||
|
|
||||||
|
| HTTP | 说明 |
|
||||||
|
|:---:|------|
|
||||||
|
| 400 | `NodeCode` 或 `Token` 为空 |
|
||||||
|
| 401 | `NodeToken` 不匹配(已有节点 Token 变更) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A2: 心跳上报
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/gateway/heartbeat
|
||||||
|
```
|
||||||
|
|
||||||
|
网关每 15s 调用一次,更新 `LastHeartbeat` 字段。连续失败 ≥3 次(45s)后网关自动触发 A1+A3 重注册。
|
||||||
|
|
||||||
|
**请求体 (GatewayHeartbeatRequest)**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| `NodeCode` | string | ✅ | 网关节点编码 |
|
||||||
|
| `Token` | string | ✅ | 认证令牌 |
|
||||||
|
|
||||||
|
**返回参数**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `status` | string | 固定 `"ok"` |
|
||||||
|
| `serverTime` | string | 服务器时间 (`yyyy-MM-dd HH:mm:ss`) |
|
||||||
|
|
||||||
|
**错误响应**:
|
||||||
|
|
||||||
|
| HTTP | 说明 |
|
||||||
|
|:---:|------|
|
||||||
|
| 400 | `NodeCode` 或 `Token` 为空 |
|
||||||
|
| 401 | NodeCode+Token 组合不匹配 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A3: 设备数据同步
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/gateway/sync/devices
|
||||||
|
```
|
||||||
|
|
||||||
|
网关每次设备变更后调用,将全量设备列表推送到 VolPro。采用字段分治策略:首次入库写全量,后续只更新网关字段。
|
||||||
|
|
||||||
|
**请求体 (SyncDevicesRequest)**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| `NodeCode` | string | ✅ | 网关节点编码 |
|
||||||
|
| `Token` | string | ✅ | 认证令牌 |
|
||||||
|
| `Devices` | array | ✅ | 设备列表 |
|
||||||
|
|
||||||
|
**Devices[].SyncDeviceItemDto**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| `AdapterCode` | string | ✅ | 适配器编码 |
|
||||||
|
| `SourceId` | string | ✅ | 子系统设备原始 ID |
|
||||||
|
| `Name` | string? | ❌ | 设备名称 |
|
||||||
|
| `Category` | string? | ❌ | 设备种类 |
|
||||||
|
| `Group` | string? | ❌ | 设备分组 |
|
||||||
|
| `IsParent` | bool | ❌ | 是否父设备 |
|
||||||
|
| `ParentSourceId` | string? | ❌ | 父设备 SourceId(用于解析 ParentDeviceId) |
|
||||||
|
| `IsOnline` | bool | ❌ | 是否在线 |
|
||||||
|
| `IpAddress` | string? | ❌ | IP 地址 |
|
||||||
|
| `Port` | int? | ❌ | 端口号 |
|
||||||
|
| `ExtraDataJson` | string? | ❌ | 扩展数据 JSON |
|
||||||
|
|
||||||
|
**返回参数**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `added` | int | 新增设备数 |
|
||||||
|
| `updated` | int | 更新设备数 |
|
||||||
|
| `removed` | int | 固定 `0`(当前版本不移除下线设备) |
|
||||||
|
|
||||||
|
**错误响应**:
|
||||||
|
|
||||||
|
| HTTP | 说明 |
|
||||||
|
|:---:|------|
|
||||||
|
| 400 | `NodeCode` 或 `Token` 为空 |
|
||||||
|
| 401 | NodeCode+Token 认证失败 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A4: 告警数据同步
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/gateway/sync/alarms
|
||||||
|
```
|
||||||
|
|
||||||
|
网关检测到新告警后调用,推送告警列表到 VolPro。通过 `SourceAlarmId` 去重(同一告警不重复入库)。
|
||||||
|
|
||||||
|
**请求体 (SyncAlarmsRequest)**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| `NodeCode` | string | ✅ | 网关节点编码 |
|
||||||
|
| `Token` | string | ✅ | 认证令牌 |
|
||||||
|
| `Alarms` | array | ✅ | 告警列表 |
|
||||||
|
|
||||||
|
**Alarms[].SyncAlarmItemDto**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| `SourceAlarmId` | string | ✅ | 子系统告警唯一 ID(用于去重) |
|
||||||
|
| `DeviceSourceId` | string | ✅ | 关联设备 SourceId(用于映射 DeviceId) |
|
||||||
|
| `AdapterCode` | string | ✅ | 适配器编码 |
|
||||||
|
| `Level` | string | ✅ | 告警等级:`提示`/`普通`/`重要`/`紧急` |
|
||||||
|
| `Desc` | string | ✅ | 告警描述 |
|
||||||
|
| `Value` | double? | ❌ | 告警实际值 |
|
||||||
|
| `StartTime` | string | ✅ | 告警发生时间 |
|
||||||
|
|
||||||
|
**返回参数**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `added` | int | 新增告警数 |
|
||||||
|
|
||||||
|
**错误响应**:
|
||||||
|
|
||||||
|
| HTTP | 说明 |
|
||||||
|
|:---:|------|
|
||||||
|
| 400 | `NodeCode` 或 `Token` 为空 |
|
||||||
|
| 401 | NodeCode+Token 认证失败 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 设备管理
|
||||||
|
|
||||||
|
> **实现文件**: `Controllers/Warehouse/Partial/base_deviceController.cs`
|
||||||
|
|
||||||
|
### 区域树
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/DeviceManager/GetRegionTree
|
||||||
|
```
|
||||||
|
|
||||||
|
返回 区域→点位 的层级结构,供管理端左侧树形控件使用。
|
||||||
|
|
||||||
|
**请求参数**: 无
|
||||||
|
|
||||||
|
**返回参数**: `TreeNode[]`
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `id` | string | 节点 ID(`r_{regionId}` 或 `p_{pointId}`) |
|
||||||
|
| `label` | string | 节点显示名称 |
|
||||||
|
| `type` | string | 节点类型:`region`(区域) 或 `point`(点位) |
|
||||||
|
| `deviceCount` | int | 该节点下的设备数量 |
|
||||||
|
| `children` | array? | 子节点列表(仅 region 节点有) |
|
||||||
|
|
||||||
|
**返回示例**:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "r_1", "label": "库房A区", "type": "region", "deviceCount": 3,
|
||||||
|
"children": [
|
||||||
|
{ "id": "p_10", "label": "温湿度监测点1", "type": "point", "deviceCount": 5 },
|
||||||
|
{ "id": "p_11", "label": "门禁点1", "type": "point", "deviceCount": 2 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 点位设备列表
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/DeviceManager/GetDevicesByPoint?pointId={pointId}&page={page}&size={size}
|
||||||
|
```
|
||||||
|
|
||||||
|
获取指定点位下的设备列表(含子设备),支持分页。
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||||
|
|------|------|:--:|------|------|
|
||||||
|
| `pointId` | int | ✅ | — | 点位 ID |
|
||||||
|
| `page` | int | ❌ | 1 | 页码 |
|
||||||
|
| `size` | int | ❌ | 20 | 每页条数 |
|
||||||
|
|
||||||
|
**返回参数**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `items` | array | 设备列表 |
|
||||||
|
| `total` | int | 总设备数 |
|
||||||
|
|
||||||
|
**items[] 条目**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `deviceId` | int | 设备自增 ID |
|
||||||
|
| `deviceName` | string | 设备名称 |
|
||||||
|
| `adapterCode` | string | 适配器编码 |
|
||||||
|
| `sourceId` | string | 子系统设备原始 ID |
|
||||||
|
| `deviceCategory` | string | 设备种类 |
|
||||||
|
| `deviceGroup` | string | 设备分组(`视频设备`/`IoT设备`/`门禁设备`) |
|
||||||
|
| `isParent` | string | 是否父设备("是"/"否") |
|
||||||
|
| `parentDeviceId` | int? | 父设备 ID |
|
||||||
|
| `isOnline` | string | 是否在线("在线"/"离线") |
|
||||||
|
| `ipAddress` | string? | IP 地址 |
|
||||||
|
| `port` | int? | 端口号 |
|
||||||
|
| `location` | string? | 位置描述 |
|
||||||
|
| `extraData` | string? | 扩展数据 JSON |
|
||||||
|
| `lastSyncTime` | DateTime? | 最后同步时间 |
|
||||||
|
| `mapModelId` | string? | 3D 地图模型 ID |
|
||||||
|
| `mapModelScale` | decimal? | 模型缩放比例 |
|
||||||
|
| `mapModelRotation` | string? | 模型旋转参数 JSON |
|
||||||
|
| `enable` | string | 启用状态("启用"/"停用") |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 定时任务
|
||||||
|
|
||||||
|
> VolPro 框架通过 `Sys_QuartzOptions` 表配置 URL+Cron 定时调用。每个端点加 `[ApiTask]` 属性以允许框架匿名调用。
|
||||||
|
>
|
||||||
|
> **实现文件**: `Controllers/Warehouse/TaskController.cs`
|
||||||
|
|
||||||
|
### 设备同步任务
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/task/syncDevices
|
||||||
|
```
|
||||||
|
|
||||||
|
遍历所有在线网关,触发全量设备同步至 VolPro。
|
||||||
|
|
||||||
|
**Cron**: `0 */5 * * * ?`(每 5 分钟)
|
||||||
|
|
||||||
|
**请求参数**: 无
|
||||||
|
|
||||||
|
**返回参数**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `time` | DateTime | 执行时间 |
|
||||||
|
| `status` | string | 固定 `"ok"` |
|
||||||
|
|
||||||
|
**错误响应**:
|
||||||
|
|
||||||
|
| HTTP | 说明 |
|
||||||
|
|:---:|------|
|
||||||
|
| 500 | `gateway_nodesService` 未注册 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 心跳监控任务
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/task/heartbeatMonitor
|
||||||
|
```
|
||||||
|
|
||||||
|
扫描心跳超时 ≥30s 的网关节点,标记离线并级联标记该节点下所有设备离线。
|
||||||
|
|
||||||
|
**Cron**: `0/15 * * * * ?`(每 15 秒)
|
||||||
|
|
||||||
|
**请求参数**: 无
|
||||||
|
|
||||||
|
**返回**: 同设备同步任务
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 实时轮询任务
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/task/realtimePoll
|
||||||
|
```
|
||||||
|
|
||||||
|
轮询所有在线 MC4 IoT 设备的实时值,写入 `iot_devicedata` 表。
|
||||||
|
|
||||||
|
**Cron**: `0/10 * * * * ?`(每 10 秒)
|
||||||
|
|
||||||
|
**请求参数**: 无
|
||||||
|
|
||||||
|
**返回**: 同设备同步任务
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 规则引擎任务
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/task/ruleEngine
|
||||||
|
```
|
||||||
|
|
||||||
|
加载启用规则 → 从网关批量获取实时值 → 逐规则评估条件 → 触发动作(控制/告警/通知)。
|
||||||
|
|
||||||
|
**Cron**: `0/10 * * * * ?`(每 10 秒)
|
||||||
|
|
||||||
|
**请求参数**: 无
|
||||||
|
|
||||||
|
**返回**: 同设备同步任务
|
||||||
|
|
||||||
|
**当前状态**: 桩实现。`RuleEngineService.EvaluateAllAsync()` 抛出 `NotImplementedException`,需先执行 SQL ALTER TABLE + 代码生成器。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 错误代码
|
||||||
|
|
||||||
|
### HTTP 状态码
|
||||||
|
|
||||||
|
| 状态码 | 含义 | 触发条件 |
|
||||||
|
|:---:|------|------|
|
||||||
|
| 200 | OK | 请求成功 |
|
||||||
|
| 400 | Bad Request | 必填参数缺失(`NodeCode`/`Token`/`pointId`) |
|
||||||
|
| 401 | Unauthorized | A 组接口 NodeToken 认证失败 |
|
||||||
|
| 500 | Internal Server Error | 服务未注册或内部异常 |
|
||||||
|
|
||||||
|
### A 组认证错误
|
||||||
|
|
||||||
|
所有 A 组接口在认证失败时返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "message": "认证失败:Token 无效" }
|
||||||
|
```
|
||||||
|
|
||||||
|
或
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "message": "认证失败" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 定时任务错误
|
||||||
|
|
||||||
|
定时任务在服务未注册时返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": "服务未注册: gateway_nodesService" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **接口总数**: 10 个端点(A 组 4 + 设备管理 2 + 定时任务 4)
|
||||||
|
> **实现位置**: `api_sqlsugar/VolPro.WebApi/Controllers/Warehouse/`
|
||||||
550
doc/设计文档/网关B组接口文档_v1.0.md
Normal file
550
doc/设计文档/网关B组接口文档_v1.0.md
Normal file
@@ -0,0 +1,550 @@
|
|||||||
|
# IntegrationGateway B 组接口文档
|
||||||
|
|
||||||
|
> **版本**: 1.0
|
||||||
|
> **日期**: 2026-06-04
|
||||||
|
> **基址**: `http://{host}:{port}`(默认 `http://localhost:5100`)
|
||||||
|
> **内容类型**: `application/json`(除标注外)
|
||||||
|
> **认证**: 可选 `X-Gateway-Key` 请求头(与 Gateway 段配置一致时生效)
|
||||||
|
> **通用错误码**: 见 §5
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
1. [健康检查](#1-健康检查) — B1
|
||||||
|
2. [设备管理](#2-设备管理) — B2, B3, B3-sync
|
||||||
|
3. [视频与流媒体](#3-视频与流媒体) — B6a, B6b, 截图, B7
|
||||||
|
4. [IoT 实时数据](#4-iot-实时数据) — B4, B4-batch, B5
|
||||||
|
5. [告警管理](#5-告警管理) — B8, B9-confirm, B9-end
|
||||||
|
6. [录像查询](#6-录像查询)
|
||||||
|
7. [设备控制 (通用)](#7-设备控制-通用) — B10
|
||||||
|
8. [业务记录查询](#8-业务记录查询) — B11
|
||||||
|
9. [数据同步](#9-数据同步) — B12, B13
|
||||||
|
10. [错误代码](#10-错误代码)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 健康检查
|
||||||
|
|
||||||
|
### B1: 查询所有适配器健康状态
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/gateway/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求参数**: 无
|
||||||
|
|
||||||
|
**返回参数**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `[].adapterCode` | string | 适配器编码,如 `Owl:main`、`MC4:31ku`、`KMS:main` |
|
||||||
|
| `[].displayName` | string | 人类可读的适配器名称 |
|
||||||
|
| `[].healthy` | bool | `true` = 适配器在线,`false` = 离线或不可达 |
|
||||||
|
| `[].capabilities` | object | 适配器能力声明 |
|
||||||
|
|
||||||
|
**capabilities 字段**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `hasFlatDevices` | bool | 是否支持扁平设备列表 |
|
||||||
|
| `hasOwnDeviceTree` | bool | 是否支持层级对象树 |
|
||||||
|
| `hasStreams` | bool | 是否支持视频取流 |
|
||||||
|
| `hasPoints` | bool | 是否支持 IoT 实时点位 |
|
||||||
|
| `hasAlarms` | bool | 是否支持告警查询 |
|
||||||
|
| `hasRecordings` | bool | 是否支持录像查询 |
|
||||||
|
|
||||||
|
**返回示例**:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"adapterCode": "Owl:main",
|
||||||
|
"displayName": "Owl (Owl:main)",
|
||||||
|
"healthy": true,
|
||||||
|
"capabilities": { "hasFlatDevices": true, "hasStreams": true, "hasAlarms": true, "hasRecordings": true }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 设备管理
|
||||||
|
|
||||||
|
### B2: 分页获取扁平设备列表
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/gateway/devices?adapter={adapterCode}&page={page}&size={size}&keyword={keyword}
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||||
|
|------|------|:--:|------|------|
|
||||||
|
| `adapter` | string | ✅ | — | 适配器编码,如 `Owl:main` |
|
||||||
|
| `page` | int | ❌ | 1 | 页码(从 1 开始) |
|
||||||
|
| `size` | int | ❌ | 20 | 每页条数 |
|
||||||
|
| `keyword` | string | ❌ | null | 设备名称模糊搜索 |
|
||||||
|
|
||||||
|
**返回参数**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `items` | array | 设备列表 |
|
||||||
|
| `total` | int | 总设备数 |
|
||||||
|
|
||||||
|
**items[].StandardDevice**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `sourceId` | string | 子系统设备原始 ID |
|
||||||
|
| `name` | string | 设备名称 |
|
||||||
|
| `category` | string | 设备种类(如 `硬盘录像机`、`摄像头`、`智能钥匙柜`、`钥匙位`) |
|
||||||
|
| `group` | string | 设备分组(`视频设备`/`IoT设备`/`门禁设备`) |
|
||||||
|
| `isParent` | bool | 是否父设备(含下级子设备) |
|
||||||
|
| `parentSourceId` | string? | 上级设备 SourceId |
|
||||||
|
| `isOnline` | bool | 是否在线 |
|
||||||
|
| `ipAddress` | string? | IP 地址 |
|
||||||
|
| `port` | int? | 端口号 |
|
||||||
|
| `extra` | object? | 子系统特有扩展属性 |
|
||||||
|
|
||||||
|
**返回示例**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{ "sourceId": "locker_25", "name": "10位智能公共钥匙柜", "category": "智能钥匙柜", "group": "门禁设备", "isParent": true, "isOnline": true },
|
||||||
|
{ "sourceId": "lockhole_25_1", "name": "仓库大门钥匙", "category": "钥匙位", "group": "门禁设备", "isParent": false, "isOnline": true, "parentSourceId": "locker_25" }
|
||||||
|
],
|
||||||
|
"total": 11
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### B3: 获取层级对象树
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/gateway/tree?adapter={adapterCode}
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| `adapter` | string | ✅ | 适配器编码,如 `MC4:31ku` |
|
||||||
|
|
||||||
|
**返回参数**: `DeviceTreeNode[]`
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `sourceId` | string | 节点 ID |
|
||||||
|
| `name` | string | 节点名称 |
|
||||||
|
| `tag` | string | 节点标签(如 `区域`/`设备组`/`IoT设备`) |
|
||||||
|
| `type` | int | 节点类型:1=父节点, 0=叶子节点 |
|
||||||
|
| `children` | array | 子节点列表(递归) |
|
||||||
|
| `option` | object? | 扩展配置 |
|
||||||
|
|
||||||
|
### B3-sync: 手动触发设备同步
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/gateway/devices/sync?adapter={adapterCode}
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| `adapter` | string | ✅ | 适配器编码 |
|
||||||
|
|
||||||
|
**返回参数**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `deviceCount` | int | 同步设备数(扁平设备) |
|
||||||
|
| `nodeCount` | int | 同步节点数(对象树) |
|
||||||
|
| `message` | string | 同步结果描述 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 视频与流媒体
|
||||||
|
|
||||||
|
### B6a: 获取实时流地址
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/gateway/streams/{adapter}/{deviceId}/live
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| `adapter` | string | ✅ | 适配器编码,如 `Owl:main` |
|
||||||
|
| `deviceId` | string | ✅ | 通道 SourceId |
|
||||||
|
|
||||||
|
**返回参数 (StreamUrls)**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `wsFlv` | string? | WebSocket-FLV 地址(推荐,低延迟) |
|
||||||
|
| `httpFlv` | string? | HTTP-FLV 地址 |
|
||||||
|
| `hls` | string? | HLS (m3u8) 地址 |
|
||||||
|
| `webrtc` | string? | WebRTC 地址 |
|
||||||
|
| `rtmp` | string? | RTMP 地址 |
|
||||||
|
|
||||||
|
### B6b: 获取录像回放地址
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/gateway/streams/{adapter}/{deviceId}/playback?start={start}&end={end}
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| `adapter` | string | ✅ | 适配器编码 |
|
||||||
|
| `deviceId` | string | ✅ | 通道 SourceId |
|
||||||
|
| `start` | DateTime | ✅ | 回放起始时间 (ISO 8601) |
|
||||||
|
| `end` | DateTime | ✅ | 回放结束时间 (ISO 8601) |
|
||||||
|
|
||||||
|
**返回**: 同 `StreamUrls`
|
||||||
|
|
||||||
|
### 截图: 获取通道实时截图
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/gateway/streams/{adapter}/{deviceId}/snapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| `adapter` | string | ✅ | 适配器编码 |
|
||||||
|
| `deviceId` | string | ✅ | 通道 SourceId |
|
||||||
|
|
||||||
|
**返回**: JPEG 图片 Base64 或 URL
|
||||||
|
|
||||||
|
### B7: 云台控制
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/gateway/streams/{adapter}/{deviceId}/ptz
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| `adapter` | string | ✅ | 适配器编码 |
|
||||||
|
| `deviceId` | string | ✅ | 通道 SourceId |
|
||||||
|
|
||||||
|
**请求体 (PtzRequest)**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| `direction` | string | ❌ | 方向:`up`/`down`/`left`/`right`/`zoom_in`/`zoom_out` |
|
||||||
|
| `action` | string | ✅ | 动作:`continuous`(持续)/`stop`(停止)/`preset`/`patrol` |
|
||||||
|
| `speed` | float | ❌ | 速度 (0.1~1.0),默认 0.5 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. IoT 实时数据
|
||||||
|
|
||||||
|
### B4: 获取设备实时点位值
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/gateway/realtime/{adapter}/{deviceId}
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| `adapter` | string | ✅ | 适配器编码,如 `MC4:31ku` |
|
||||||
|
| `deviceId` | string | ✅ | 设备 SourceId |
|
||||||
|
|
||||||
|
**返回参数**: `PointValue[]`
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `pointIndex` | int | 点位索引 |
|
||||||
|
| `pointName` | string | 点位名称 |
|
||||||
|
| `value` | decimal | 当前值 |
|
||||||
|
| `unit` | string? | 单位(如 `℃`、`%`) |
|
||||||
|
| `updateTime` | DateTime | 更新时间 |
|
||||||
|
| `interval` | int | 采集间隔(秒) |
|
||||||
|
|
||||||
|
### B4-batch: 批量获取实时点位值
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/gateway/realtime/{adapter}/batch
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| `adapter` | string | ✅ | 适配器编码 |
|
||||||
|
|
||||||
|
**请求体 (BatchRealtimeRequest)**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| `deviceIds` | string[] | ✅ | 设备 SourceId 列表 |
|
||||||
|
|
||||||
|
**返回**: `Dictionary<string, PointValue[]>` — 以 deviceId 为键的实时值字典
|
||||||
|
|
||||||
|
### B5: 设备反向控制
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/gateway/realtime/{adapter}/control
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| `adapter` | string | ✅ | 适配器编码 |
|
||||||
|
|
||||||
|
**请求体 (ControlRequest)**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| `deviceId` | string | ✅ | 设备 SourceId |
|
||||||
|
| `pointIndex` | int | ✅ | 目标点位索引 |
|
||||||
|
| `value` | double | ✅ | 目标值(如开关 0/1,温度设定值等) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 告警管理
|
||||||
|
|
||||||
|
### B8: 分页查询告警列表
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/gateway/alarms/{adapter}?page={page}&size={size}&from={from}&to={to}&level={level}&state={state}
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||||
|
|------|------|:--:|------|------|
|
||||||
|
| `adapter` | string | ✅ | — | 适配器编码 |
|
||||||
|
| `page` | int | ❌ | 1 | 页码 |
|
||||||
|
| `size` | int | ❌ | 20 | 每页条数 |
|
||||||
|
| `from` | DateTime | ❌ | MinValue | 告警起始时间 |
|
||||||
|
| `to` | DateTime | ❌ | MinValue | 告警结束时间 |
|
||||||
|
| `level` | string | ❌ | null | 告警等级过滤 |
|
||||||
|
| `state` | string | ❌ | null | 告警状态过滤:`未确认`/`已确认`/`已结束` |
|
||||||
|
|
||||||
|
**返回参数**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `items` | array | 告警列表 |
|
||||||
|
| `total` | int | 总告警数 |
|
||||||
|
|
||||||
|
**items[].StandardAlarm**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `alarmId` | string | 告警 ID |
|
||||||
|
| `adapterCode` | string | 适配器编码 |
|
||||||
|
| `deviceId` | string? | 关联设备 ID |
|
||||||
|
| `level` | string | 告警等级:`提示`/`普通`/`重要`/`紧急` |
|
||||||
|
| `title` | string | 告警标题 |
|
||||||
|
| `content` | string? | 告警详细内容 |
|
||||||
|
| `occurTime` | DateTime | 发生时间 |
|
||||||
|
| `status` | string | 状态:`未确认`/`已确认`/`已结束` |
|
||||||
|
| `actualValue` | string? | 实际值(超标告警) |
|
||||||
|
| `thresholdValue` | string? | 阈值(超标告警) |
|
||||||
|
|
||||||
|
### B9-confirm: 确认告警
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/gateway/alarms/{adapter}/{alarmId}/confirm
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| `adapter` | string | ✅ | 适配器编码 |
|
||||||
|
| `alarmId` | string | ✅ | 告警 ID(子系统告警源 ID) |
|
||||||
|
|
||||||
|
### B9-end: 结束告警
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/gateway/alarms/{adapter}/{alarmId}/end
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求参数**: 同 B9-confirm
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 录像查询
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/gateway/recordings/{adapter}/{deviceId}?start={start}&end={end}&page={page}&size={size}
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| `adapter` | string | ✅ | 适配器编码 |
|
||||||
|
| `deviceId` | string | ✅ | 通道 SourceId |
|
||||||
|
| `start` | DateTime | ✅ | 录像起始时间 |
|
||||||
|
| `end` | DateTime | ✅ | 录像结束时间 |
|
||||||
|
| `page` | int | ❌ | 页码,默认 1 |
|
||||||
|
| `size` | int | ❌ | 每页条数,默认 20 |
|
||||||
|
|
||||||
|
**返回**: 录像文件列表(含文件名、起止时间、时长、大小)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 设备控制 (通用)
|
||||||
|
|
||||||
|
### B10: 下发控制指令
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/gateway/control/{adapter}
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| `adapter` | string | ✅ | 适配器编码,如 `KMS:main` |
|
||||||
|
|
||||||
|
**请求体 (GatewayControlRequest)**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| `deviceId` | string | ✅ | 设备 SourceId |
|
||||||
|
| `command` | string | ✅ | 指令名:`open`(开门)/`close`(关门)/`authorize`(授权) |
|
||||||
|
| `parameters` | object | ❌ | 指令参数,如 `{"staffIds": [1,2], "lockholeSort": 3}` |
|
||||||
|
|
||||||
|
**返回 (ControlResult)**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `success` | bool | 操作是否成功 |
|
||||||
|
| `message` | string? | 失败时的错误信息 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 业务记录查询
|
||||||
|
|
||||||
|
### B11: 查询子系统业务记录
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/gateway/logs/{adapter}?logType={logType}&from={from}&to={to}&page={page}&size={size}
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| `adapter` | string | ✅ | 适配器编码 |
|
||||||
|
| `logType` | string | ✅ | 记录类型:`borrow`(借还)/`handover`(交接)/`permission`(授权) |
|
||||||
|
| `from` | DateTime | ❌ | 起始时间 |
|
||||||
|
| `to` | DateTime | ❌ | 结束时间 |
|
||||||
|
| `page` | int | ❌ | 页码,默认 1 |
|
||||||
|
| `size` | int | ❌ | 每页条数,默认 20 |
|
||||||
|
|
||||||
|
**返回参数**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `items` | array | 业务记录列表 |
|
||||||
|
| `total` | int | 总记录数 |
|
||||||
|
|
||||||
|
**items[].BusinessLogEntry**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `logId` | string | 记录唯一 ID |
|
||||||
|
| `logType` | string | 记录类型 |
|
||||||
|
| `deviceSourceId` | string? | 关联设备 SourceId |
|
||||||
|
| `staffName` | string? | 关联员工姓名 |
|
||||||
|
| `description` | string? | 记录描述 |
|
||||||
|
| `createdAt` | DateTime? | 记录时间 |
|
||||||
|
| `extra` | object? | 扩展属性 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 数据同步
|
||||||
|
|
||||||
|
### B12: 向子系统写入数据
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/gateway/sync/{adapter}
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| `adapter` | string | ✅ | 适配器编码 |
|
||||||
|
|
||||||
|
**请求体 (SyncRequest)**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| `dataType` | string | ✅ | 数据类型,当前支持 `staff`(员工) |
|
||||||
|
| `items` | object[] | ✅ | 待同步数据列表 |
|
||||||
|
|
||||||
|
**返回 (SyncResult)**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `successCount` | int | 成功数量 |
|
||||||
|
| `failCount` | int | 失败数量 |
|
||||||
|
| `message` | string? | 错误信息 |
|
||||||
|
|
||||||
|
### B13: 从子系统删除数据
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/gateway/sync/{adapter}
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求参数**: 同 B12
|
||||||
|
|
||||||
|
**请求体 (SyncDeleteRequest)**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| `dataType` | string | ✅ | 数据类型,当前支持 `staff` |
|
||||||
|
| `ids` | string[] | ✅ | 待删除 ID 列表 |
|
||||||
|
|
||||||
|
**返回**: 同 `SyncResult`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 错误代码
|
||||||
|
|
||||||
|
### 通用 HTTP 状态码
|
||||||
|
|
||||||
|
| 状态码 | 含义 | 触发条件 |
|
||||||
|
|:---:|------|------|
|
||||||
|
| 200 | OK | 请求成功 |
|
||||||
|
| 400 | Bad Request | 请求参数格式错误 |
|
||||||
|
| 401 | Unauthorized | `X-Gateway-Key` 缺失或不匹配 |
|
||||||
|
| 404 | Not Found | 适配器不存在或不支持该能力 |
|
||||||
|
| 500 | Internal Server Error | 适配器内部异常 |
|
||||||
|
| 502 | Bad Gateway | 子系统返回错误或不可达 |
|
||||||
|
|
||||||
|
### 业务错误码
|
||||||
|
|
||||||
|
所有非 200 响应包含 JSON body:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `error` | string | 错误码 |
|
||||||
|
| `message` | string? | 人类可读的错误详情 |
|
||||||
|
|
||||||
|
| error 值 | HTTP | 说明 |
|
||||||
|
|------|:---:|------|
|
||||||
|
| `ADAPTER_NOT_FOUND` | 404 | 指定适配器编码不存在 |
|
||||||
|
| `CAPABILITY_NOT_SUPPORTED` | 404 | 适配器不支持该接口能力 |
|
||||||
|
| — (control 接口) | 502 | `ControlResult.Success=false` 时返回 `ControlResult.Message` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **接口总数**: 19 个 REST 端点
|
||||||
|
> **适配器**: Owl / MC4 / KMS(通过 `adapter` 参数路由)
|
||||||
223
doc/设计文档/网关KMS模块检查报告20260604.md
Normal file
223
doc/设计文档/网关KMS模块检查报告20260604.md
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
# 网关 KMS 模块检查报告 2026-06-04
|
||||||
|
|
||||||
|
> **基准文档**: `doc/对接文档/钥匙管理系统软件接口.docx` (KMS API v1.0.4)
|
||||||
|
> **检查范围**: `gateway/src/IntegrationGateway.Adapters.Kms/` (KmsAdapter.cs / KmsAuthHelper.cs / KmsModels.cs) + `Program.cs` B10-B13 路由
|
||||||
|
> **方法**: 逐接口比对文档 → 代码 → 路由
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 覆盖率总览
|
||||||
|
|
||||||
|
| 模块 | KMS 文档端点数 | Gateway 覆盖 | 覆盖率 |
|
||||||
|
|------|:---:|:---:|:---:|
|
||||||
|
| 2.9 Token 获取 | 1 | 1 | 100% |
|
||||||
|
| 2.18 开放接口 | 8 | 8 | 100% |
|
||||||
|
| **总计 (Phase 1)** | **9** | **9** | **100%** |
|
||||||
|
| 2.3-2.17 标准接口 | 38 | 1 (确认告警) | 3% |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 逐接口检查
|
||||||
|
|
||||||
|
### 2.18.1 心跳 — `GET /prod-api/heartBeat`
|
||||||
|
|
||||||
|
| 检查项 | Gateway 实现 | 文档规范 | 状态 |
|
||||||
|
|------|------|------|:--:|
|
||||||
|
| 请求方法 | `client.GetAsync(...)` | GET | ✅ |
|
||||||
|
| 请求路径 | `/prod-api/heartBeat` | `/prod-api/heartBeat` | ✅ |
|
||||||
|
| 请求体 | 无 | 无 | ✅ |
|
||||||
|
| 错误处理 | `catch (Exception ex) { Console.Error.WriteLine; return false; }` | — | ✅ |
|
||||||
|
|
||||||
|
### 2.18.2 批量删除员工 — `POST /prod-api/batchDeleteStaff`
|
||||||
|
|
||||||
|
| 检查项 | Gateway 实现 | 文档规范 | 状态 |
|
||||||
|
|------|------|------|:--:|
|
||||||
|
| 请求方法 | `PostAsJsonAsync(...)` | POST | ✅ |
|
||||||
|
| 请求路径 | `/prod-api/batchDeleteStaff` | `/prod-api/batchDeleteStaff` | ✅ |
|
||||||
|
| 请求体 | `List<string>` (staffUuid 数组) | `["uuid1","uuid2",...]` | ✅ |
|
||||||
|
| 参数类型 | 数组 | 数组 (v1.0.2 修正) | ✅ |
|
||||||
|
|
||||||
|
### 2.18.3 批量同步员工 — `POST /prod-api/batchSyncStaff`
|
||||||
|
|
||||||
|
| 检查项 | Gateway 实现 | 文档规范 | 状态 |
|
||||||
|
|------|------|------|:--:|
|
||||||
|
| 请求方法 | `PostAsJsonAsync(...)` | POST | ✅ |
|
||||||
|
| 请求路径 | `/prod-api/batchSyncStaff` | `/prod-api/batchSyncStaff` | ✅ |
|
||||||
|
| 请求体 | `new { staff = staffList }` | staff 数组 | ⚠️ |
|
||||||
|
| account 字段 | 模型中有 Account? | v1.0.4 新增 account | ⚠️ 待验证 |
|
||||||
|
|
||||||
|
**风险**: 文档 v1.0.4 新增了 `account` (登录账号) 字段。`KmsStaff` 模型需确认包含此字段。网关包装为 `{ staff: [...] }` 可能与 KMS 期望的裸数组不一致。
|
||||||
|
|
||||||
|
### 2.18.4 查询柜体钥匙 — `POST /prod-api/getOpenerList`
|
||||||
|
|
||||||
|
| 检查项 | Gateway 实现 | 文档规范 | 状态 |
|
||||||
|
|------|------|------|:--:|
|
||||||
|
| 请求方法 | POST | POST | ✅ |
|
||||||
|
| 请求路径 | `/prod-api/getOpenerList` | `/prod-api/getOpenerList` | ✅ |
|
||||||
|
| 请求体 | `"{}"` | 无明确要求 / 空对象 | ✅ |
|
||||||
|
| 响应 → StandardDevice | 柜体→父设备, 锁孔→子设备 | 树状结构 | ✅ |
|
||||||
|
|
||||||
|
### 2.18.5 查询授权记录 — `POST /prod-api/getPermissionList`
|
||||||
|
|
||||||
|
| 检查项 | Gateway 实现 | 文档规范 | 状态 |
|
||||||
|
|------|------|------|:--:|
|
||||||
|
| 请求方法 | POST | POST | ✅ |
|
||||||
|
| 请求路径 | `/prod-api/getPermissionList` | `/prod-api/getPermissionList` | ✅ |
|
||||||
|
| 请求体 | `"{}"` | 授权记录业务对象 (含 lockerName, lendStaffName 等 20+ 字段) | 🔴 |
|
||||||
|
| 时间范围 | `DateTime? from, DateTime? to` 参数**未传入请求体** | `beginApplyTime`/`endApplyTime` | 🔴 |
|
||||||
|
| 分页 | `page`/`size` 参数**未传入请求体** | `pageNum`/`pageSize` | 🔴 |
|
||||||
|
|
||||||
|
**致命问题**: 网关注入 `from`/`to`/`page`/`size` 参数但**从未传入 KMS 请求体**。代码注释 `// 联调时加入时间范围` 确认这是已知缺口。当前实现等价于无过滤全量查询,无法按时间范围分页。
|
||||||
|
|
||||||
|
### 2.18.6 查询借还记录 — `POST /prod-api/getRecordList`
|
||||||
|
|
||||||
|
**与 2.18.5 完全相同的致命问题**: `from`/`to`/`page`/`size` 参数未传入 KMS 请求体。此外文档标记 `lockerName`、`lockholeSort`、`openerCnName` 为必填字段,但网关传 `"{}"` 无这些字段。
|
||||||
|
|
||||||
|
### 2.18.7 查询告警记录 — `POST /prod-api/getWarningList`
|
||||||
|
|
||||||
|
| 检查项 | Gateway 实现 | 文档规范 | 状态 |
|
||||||
|
|------|------|------|:--:|
|
||||||
|
| 请求方法 | POST | POST | ✅ |
|
||||||
|
| 请求路径 | `/prod-api/getWarningList` | `/prod-api/getWarningList` | ✅ |
|
||||||
|
| 请求体 | `"{}"` | 告警业务对象 (含 type, beginWarningTime 等) | 🔴 |
|
||||||
|
| 时间范围 | 未传 | `beginWarningTime`/`endWarningTime` | 🔴 |
|
||||||
|
| 告警类型 | 未传 (type=1当前/2历史) | 文档支持过滤 | 🔴 |
|
||||||
|
| 响应映射 | `Type==1 ? "未确认" : "已结束"` | `type` 1=当前告警, 2=历史告警 | 🔴 |
|
||||||
|
|
||||||
|
**状态映射错误**: `type` 字段在告警接口中表示 1=当前告警 / 2=历史告警,**不是** 1=未确认 / 2=已结束。代码将 type=1 映射为 Status="未确认"、type=2 映射为 Status="已结束",语义错误。正确的映射应该是 type=1 → "活跃", type=2 → "历史"。
|
||||||
|
|
||||||
|
### 2.18.8 单点登录 — `POST /thirdPlatlogin`
|
||||||
|
|
||||||
|
| 检查项 | Gateway 实现 | 文档规范 | 状态 |
|
||||||
|
|------|------|------|:--:|
|
||||||
|
| 请求方法 | POST | POST | ✅ |
|
||||||
|
| 请求路径 | `/thirdPlatlogin?username={x}` | `/thirdPlatlogin?username={x}` | ✅ |
|
||||||
|
| 重定向处理 | 捕获 302, 返回 Location header | 文档说明"调用成功后直接重定向" | ✅ |
|
||||||
|
| 超时 | 无显式设置 | — | 🟡 |
|
||||||
|
|
||||||
|
### 2.9 Token 获取 — `POST /prod-api/getToken`
|
||||||
|
|
||||||
|
| 检查项 | Gateway 实现 | 文档规范 | 状态 |
|
||||||
|
|------|------|------|:--:|
|
||||||
|
| 请求方法 | `http.PostAsync(url, null)` | POST | ✅ |
|
||||||
|
| 参数位置 | query string: `?clientId=&clientSecret=` | query string | ✅ |
|
||||||
|
| 响应校验 | `Code != 200` → 抛异常 | `code: 200` = 成功 | ✅ |
|
||||||
|
| 缓存策略 | 25分钟 (30分钟效期-5分钟余量) | 30分钟效期 | ✅ |
|
||||||
|
|
||||||
|
> **注意**: 文档 2.9.1 显示 `POST /prod-api/getToken` 参数在 body 中 (`{ clientId, clientSecret }`),但 2.9 节概述描述为 query 参数。两种方式 KMS 可能都支持。当前实现用 query string,联调时需确认兼容。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 模型映射核对
|
||||||
|
|
||||||
|
### 3.1 KMS 柜体 → StandardDevice
|
||||||
|
|
||||||
|
| 文档字段 | 代码映射 | 准确性 |
|
||||||
|
|------|------|:--:|
|
||||||
|
| lockerId | `SourceId = $"locker_{lockerId}"` | ✅ |
|
||||||
|
| lockerName | `Name = lockerName` | ✅ |
|
||||||
|
| lockerCode | `Extra["lockerCode"]` | ✅ |
|
||||||
|
| lockholeList | 遍历展开为子设备 | ✅ |
|
||||||
|
| IsParent | `true` | ✅ |
|
||||||
|
|
||||||
|
### 3.2 KMS 锁孔 → StandardDevice
|
||||||
|
|
||||||
|
| 文档字段 | 代码映射 | 准确性 |
|
||||||
|
|------|------|:--:|
|
||||||
|
| lockholeSort | `SourceId = $"lockhole_{lockerId}_{lockholeSort}"` | ✅ |
|
||||||
|
| openerName | `Name = openerName` | ✅ |
|
||||||
|
| openerType | `Extra["openerType"]` (1/2/3 数值) | ✅ |
|
||||||
|
| openerState | `Extra["openerState"]` + `IsOnline = (openerState=="在位")` | 🔴 |
|
||||||
|
| ParentSourceId | `$"locker_{lockerId}"` | ✅ |
|
||||||
|
|
||||||
|
**openerState 映射错误**: 根据文档数据字典(§4),`openerState` 是数值编码:
|
||||||
|
- 1 = 在柜
|
||||||
|
- 2 = 借出
|
||||||
|
- 3 = 录入
|
||||||
|
- 10 = 丢失
|
||||||
|
|
||||||
|
代码用 `openerState == "在位"` 做字符串比较,**永远不成立**。需改为 `openerState == "1"` 或解析为 int 后判断。
|
||||||
|
|
||||||
|
### 3.3 KMS 借还记录 → BusinessLogEntry
|
||||||
|
|
||||||
|
| 文档字段 | 代码映射 | 准确性 |
|
||||||
|
|------|------|:--:|
|
||||||
|
| uuid | `LogId` | ✅ |
|
||||||
|
| lockerName | 拼入 `DeviceSourceId` | ✅ |
|
||||||
|
| staffName | `StaffName` | ✅ |
|
||||||
|
| borrowTime | `CreatedAt` | ✅ |
|
||||||
|
| openerName | `Description` (不充分) | 🟡 |
|
||||||
|
|
||||||
|
### 3.4 KMS 告警 → StandardAlarm
|
||||||
|
|
||||||
|
| 文档字段 | 代码映射 | 准确性 |
|
||||||
|
|------|------|:--:|
|
||||||
|
| uuid | `AlarmId` | ✅ |
|
||||||
|
| warningTime | `OccurTime` | ✅ |
|
||||||
|
| remark | `Content` | ✅ |
|
||||||
|
| type (1/2) | `Status = Type==1 ? "未确认":"已结束"` | 🔴 |
|
||||||
|
| level | 固定 `"普通"` | 🟡 |
|
||||||
|
|
||||||
|
**type 语义错误**: 见 2.18.7 说明。文档明确 `type` 表示告警分类(1=当前,2=历史),而非确认状态。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. B 路由链路检查
|
||||||
|
|
||||||
|
| B 路由 | 对应 KMS 能力 | 适配器方法 | 参数传递 | 状态 |
|
||||||
|
|------|------|------|:--:|:--:|
|
||||||
|
| B1 `/health` | 心跳 2.18.1 | `HealthCheckAsync` | ✅ | ✅ |
|
||||||
|
| B2 `/devices` | 柜体钥匙 2.18.4 | `GetDevicesAsync` | ✅ | ✅ |
|
||||||
|
| B8 `/alarms` | 告警 2.18.7 | `GetAlarmsAsync` | ✅ | ✅ (映射有误) |
|
||||||
|
| B9 `/alarms/{id}/confirm` | 确认告警 | `ConfirmAlarmAsync` | ✅ | ⚠️ 端点未确认 |
|
||||||
|
| B10 `/control` | 远程控制 | `SendControlAsync` | ✅ | ⚠️ |
|
||||||
|
| B11 `/logs` | 业务记录 | `GetBusinessLogsAsync` | ✅ | ⚠️ |
|
||||||
|
| B12 `/sync` (POST) | 员工同步 2.18.3 | `SyncDataAsync` | ✅ | ⚠️ |
|
||||||
|
| B13 `/sync` (DELETE) | 删除员工 2.18.2 | `DeleteDataAsync` | ✅ | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 问题汇总
|
||||||
|
|
||||||
|
### 🔴 致命问题 (需联调前修复)
|
||||||
|
|
||||||
|
| # | 问题 | 影响 |
|
||||||
|
|:--:|------|------|
|
||||||
|
| **R1** | 2.18.5/2.18.6/2.18.7 请求体只传 `"{}"`,忽略 `from`/`to`/`page`/`size` 参数 | 无法按时间分页查询,联调时大概率返回全量数据或报错 |
|
||||||
|
| **R2** | 2.18.7 type 字段语义错误 (1=当前告警被映射为"未确认") | 告警状态全部错误 |
|
||||||
|
| **R3** | openerState 字符串比较 vs 文档数值编码 | 所有锁孔 IsOnline 永远为 false |
|
||||||
|
|
||||||
|
### 🟠 严重问题
|
||||||
|
|
||||||
|
| # | 问题 | 影响 |
|
||||||
|
|:--:|------|------|
|
||||||
|
| **S1** | 2.18.6 必填字段 (`lockerName`, `lockholeSort`, `openerCnName`) 未传 | 借还记录查询可能被 KMS 拒绝 |
|
||||||
|
| **S2** | `ConfirmAlarmAsync` 端点 (`/prod-api/kms/warning/confirm/{id}`) 未在文档中确认存在 | 告警确认功能不可用 |
|
||||||
|
| **S3** | 2.18.5 请求体结构未知 (文档未给完整示例) | 授权记录查询格式需联调验证 |
|
||||||
|
|
||||||
|
### 🟡 改善项
|
||||||
|
|
||||||
|
| # | 问题 | 建议 |
|
||||||
|
|:--:|------|------|
|
||||||
|
| **M1** | KmsModels.cs 包含大量 Phase 2 DTO 但未使用 | 保留,Phase 2 可用 |
|
||||||
|
| **M2** | B10 控制指令 `command == "open"` 和 `command == "authorize"` 都调同一方法 | 区分"开门"和"授权"两种指令 |
|
||||||
|
| **M3** | ThirdPlatLoginAsync 无超时设置 | 加 15s 超时 |
|
||||||
|
| **M4** | SyncDevicesJob 中 `gwRepo.Update(node)` vs `gwSvc.UpdateAsync(node)` 不一致 | 统一风格 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 需联调验证项
|
||||||
|
|
||||||
|
| # | 验证项 | 说明 |
|
||||||
|
|:--:|------|------|
|
||||||
|
| V1 | Token 获取用 query string vs body | 文档 2.9.1 标记为 body 参数,2.9 节概述为 query |
|
||||||
|
| V2 | `batchSyncStaff` body 格式 | `{ staff: [...] }` vs `[...]` |
|
||||||
|
| V3 | `KmsStaff` 是否需 account 字段 | v1.0.4 新增 |
|
||||||
|
| V4 | `getOpenerList` 返回的 openerState 是数值还是中文 | 决定映射逻辑 |
|
||||||
|
| V5 | `getRecordList` 必填字段是否真的必填 | 决定请求体最小字段集 |
|
||||||
|
| V6 | `getPermissionList` 请求体完整格式 | 文档示例不完整 |
|
||||||
|
| V7 | `warning/confirm` 端点存在性 | 调标准管理接口 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **结论**: 9 个 Phase 1 接口全部实现覆盖(100%),但 3 个致命问题(R1-R3)需在联调前修复——核心是请求体格式、type 语义映射、openerState 编码映射。其余 4 个严重问题需联调验证后确认。
|
||||||
@@ -115,7 +115,8 @@ public class KmsAdapter : IHasFlatDevices, IHasAlarms, IAcceptsControl, IHasBusi
|
|||||||
Category = "钥匙位",
|
Category = "钥匙位",
|
||||||
Group = "门禁设备",
|
Group = "门禁设备",
|
||||||
IsParent = false,
|
IsParent = false,
|
||||||
IsOnline = hole.OpenerState == "在位",
|
// KMS openerState: 1=在柜, 2=借出, 3=录入, 10=丢失 (数值编码或中文)
|
||||||
|
IsOnline = hole.OpenerState == "1" || hole.OpenerState == "在柜", // KMS: 1=在柜/2=借出/3=录入/10=丢失
|
||||||
ParentSourceId = $"locker_{lockerId}",
|
ParentSourceId = $"locker_{lockerId}",
|
||||||
Extra = new Dictionary<string, object?>
|
Extra = new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
@@ -135,8 +136,15 @@ public class KmsAdapter : IHasFlatDevices, IHasAlarms, IAcceptsControl, IHasBusi
|
|||||||
{
|
{
|
||||||
await _limiter.WaitAsync();
|
await _limiter.WaitAsync();
|
||||||
var client = await _auth.GetAuthenticatedClientAsync();
|
var client = await _auth.GetAuthenticatedClientAsync();
|
||||||
|
var body = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
beginWarningTime = from == DateTime.MinValue ? (string?)null : from.ToString("yyyy-MM-dd HH:mm:ss"),
|
||||||
|
endWarningTime = to == DateTime.MinValue ? (string?)null : to.ToString("yyyy-MM-dd HH:mm:ss"),
|
||||||
|
pageNum = page, pageSize = size,
|
||||||
|
type = state == "已结束" ? 2 : (state == "未确认" ? 1 : (int?)null)
|
||||||
|
});
|
||||||
var resp = await client.PostAsync("/prod-api/getWarningList",
|
var resp = await client.PostAsync("/prod-api/getWarningList",
|
||||||
new StringContent("{}", Encoding.UTF8, "application/json"));
|
new StringContent(body, Encoding.UTF8, "application/json"));
|
||||||
resp.EnsureSuccessStatusCode();
|
resp.EnsureSuccessStatusCode();
|
||||||
var data = await resp.Content.ReadFromJsonAsync<KmsWarningListResponse>()!;
|
var data = await resp.Content.ReadFromJsonAsync<KmsWarningListResponse>()!;
|
||||||
|
|
||||||
@@ -148,6 +156,8 @@ public class KmsAdapter : IHasFlatDevices, IHasAlarms, IAcceptsControl, IHasBusi
|
|||||||
Title = $"{w.LockerName} 锁孔{w.LockholeSort}: {w.OpenerName}",
|
Title = $"{w.LockerName} 锁孔{w.LockholeSort}: {w.OpenerName}",
|
||||||
Content = w.Remark,
|
Content = w.Remark,
|
||||||
OccurTime = DateTime.TryParse(w.WarningTime, out var t) ? t : DateTime.MinValue,
|
OccurTime = DateTime.TryParse(w.WarningTime, out var t) ? t : DateTime.MinValue,
|
||||||
|
// KMS type: 1=当前告警(active), 2=历史告警(historical)
|
||||||
|
// 映射到 VolPro Status: 当前告警→未确认(待处理), 历史告警→已结束
|
||||||
Status = w.Type == 1 ? "未确认" : "已结束"
|
Status = w.Type == 1 ? "未确认" : "已结束"
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
@@ -174,11 +184,25 @@ public class KmsAdapter : IHasFlatDevices, IHasAlarms, IAcceptsControl, IHasBusi
|
|||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
/// <summary>2.18.6 查询借还记录列表</summary>
|
/// <summary>2.18.6 查询借还记录列表</summary>
|
||||||
public async Task<PagedResult<KmsRecord>> GetBorrowRecordsAsync(DateTime? from = null, DateTime? to = null)
|
public async Task<PagedResult<KmsRecord>> GetBorrowRecordsAsync(DateTime? from = null, DateTime? to = null, int page = 1, int size = 100)
|
||||||
{
|
{
|
||||||
await _limiter.WaitAsync();
|
await _limiter.WaitAsync();
|
||||||
var client = await _auth.GetAuthenticatedClientAsync();
|
var client = await _auth.GetAuthenticatedClientAsync();
|
||||||
var body = "{}"; // 联调时加入时间范围参数
|
var body = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
// 按文档请求示例发送完整默认对象(空字段=不限过滤),仅填充时间范围+分页
|
||||||
|
applyTime = (string?)null, backStaffId = 0, backStaffName = (string?)null, backTime = (string?)null,
|
||||||
|
beginApplyTime = (from.HasValue && from != DateTime.MinValue) ? from.Value.ToString("yyyy-MM-dd HH:mm:ss") : (string?)null,
|
||||||
|
endApplyTime = (to.HasValue && to != DateTime.MinValue) ? to.Value.ToString("yyyy-MM-dd HH:mm:ss") : (string?)null,
|
||||||
|
borrowTime = (string?)null, createBy = (string?)null, createTime = (string?)null, deptId = 0,
|
||||||
|
isAsc = "desc", lendStaffId = 0, lendStaffName = (string?)null,
|
||||||
|
lockerName = (string?)null, lockholeSort = 0, openerCnName = (string?)null, openerId = 0,
|
||||||
|
openerState = 0, openerType = 0, orderByColumn = (string?)null,
|
||||||
|
|
||||||
|
pageNum = page, pageSize = size,
|
||||||
|
permissionState = 0, remark = (string?)null, searchValue = (string?)null,
|
||||||
|
updateBy = (string?)null, updateTime = (string?)null, uuid = (string?)null
|
||||||
|
});
|
||||||
var resp = await client.PostAsync("/prod-api/getRecordList",
|
var resp = await client.PostAsync("/prod-api/getRecordList",
|
||||||
new StringContent(body, Encoding.UTF8, "application/json"));
|
new StringContent(body, Encoding.UTF8, "application/json"));
|
||||||
resp.EnsureSuccessStatusCode();
|
resp.EnsureSuccessStatusCode();
|
||||||
@@ -187,11 +211,25 @@ public class KmsAdapter : IHasFlatDevices, IHasAlarms, IAcceptsControl, IHasBusi
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>2.18.5 查询授权记录列表</summary>
|
/// <summary>2.18.5 查询授权记录列表</summary>
|
||||||
public async Task<PagedResult<KmsPermission>> GetPermissionListAsync(DateTime? from = null, DateTime? to = null)
|
public async Task<PagedResult<KmsPermission>> GetPermissionListAsync(DateTime? from = null, DateTime? to = null, int page = 1, int size = 100)
|
||||||
{
|
{
|
||||||
await _limiter.WaitAsync();
|
await _limiter.WaitAsync();
|
||||||
var client = await _auth.GetAuthenticatedClientAsync();
|
var client = await _auth.GetAuthenticatedClientAsync();
|
||||||
var body = "{}"; // 联调时加入时间范围
|
var body = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
// 按文档请求示例发送完整默认对象(空字段=不限过滤),仅填充时间范围+分页
|
||||||
|
applyTime = (string?)null, backStaffId = 0, backStaffName = (string?)null, backTime = (string?)null,
|
||||||
|
beginApplyTime = (from.HasValue && from != DateTime.MinValue) ? from.Value.ToString("yyyy-MM-dd HH:mm:ss") : (string?)null,
|
||||||
|
endApplyTime = (to.HasValue && to != DateTime.MinValue) ? to.Value.ToString("yyyy-MM-dd HH:mm:ss") : (string?)null,
|
||||||
|
borrowTime = (string?)null, createBy = (string?)null, createTime = (string?)null, deptId = 0,
|
||||||
|
isAsc = "desc", lendStaffId = 0, lendStaffName = (string?)null,
|
||||||
|
lockerName = (string?)null, lockholeSort = 0, openerCnName = (string?)null, openerId = 0,
|
||||||
|
openerState = 0, openerType = 0, orderByColumn = (string?)null,
|
||||||
|
|
||||||
|
pageNum = page, pageSize = size,
|
||||||
|
permissionState = 0, remark = (string?)null, searchValue = (string?)null,
|
||||||
|
updateBy = (string?)null, updateTime = (string?)null, uuid = (string?)null
|
||||||
|
});
|
||||||
var resp = await client.PostAsync("/prod-api/getPermissionList",
|
var resp = await client.PostAsync("/prod-api/getPermissionList",
|
||||||
new StringContent(body, Encoding.UTF8, "application/json"));
|
new StringContent(body, Encoding.UTF8, "application/json"));
|
||||||
resp.EnsureSuccessStatusCode();
|
resp.EnsureSuccessStatusCode();
|
||||||
@@ -204,7 +242,8 @@ public class KmsAdapter : IHasFlatDevices, IHasAlarms, IAcceptsControl, IHasBusi
|
|||||||
{
|
{
|
||||||
await _limiter.WaitAsync();
|
await _limiter.WaitAsync();
|
||||||
var client = await _auth.GetAuthenticatedClientAsync();
|
var client = await _auth.GetAuthenticatedClientAsync();
|
||||||
var resp = await client.PostAsJsonAsync("/prod-api/batchSyncStaff", new { staff = staffList });
|
// 2.18.3 body 类型为 array(员工业务对象数组),不包装
|
||||||
|
var resp = await client.PostAsJsonAsync("/prod-api/batchSyncStaff", staffList);
|
||||||
resp.EnsureSuccessStatusCode();
|
resp.EnsureSuccessStatusCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +292,8 @@ public class KmsAdapter : IHasFlatDevices, IHasAlarms, IAcceptsControl, IHasBusi
|
|||||||
var req = new KmsRemotePermissionRequest
|
var req = new KmsRemotePermissionRequest
|
||||||
{
|
{
|
||||||
StaffIds = parameters.TryGetValue("staffIds", out var s) && s is List<int> sl ? sl : null,
|
StaffIds = parameters.TryGetValue("staffIds", out var s) && s is List<int> sl ? sl : null,
|
||||||
OpenerIds = parameters.TryGetValue("lockholeSort", out var lh) ? new List<int> { (int)(long)lh! } : null,
|
// lockholeSort is mapped to OpenerIds as the target opener for authorization
|
||||||
|
OpenerIds = parameters.TryGetValue("lockholeSort", out var lh) ? new List<int> { (int)(long)lh! } : null,
|
||||||
Type = command == "authorize" ? 2 : 1
|
Type = command == "authorize" ? 2 : 1
|
||||||
};
|
};
|
||||||
await RemoteAuthorizeAsync(req);
|
await RemoteAuthorizeAsync(req);
|
||||||
@@ -276,7 +316,7 @@ public class KmsAdapter : IHasFlatDevices, IHasAlarms, IAcceptsControl, IHasBusi
|
|||||||
{
|
{
|
||||||
if (logType == "borrow" || logType == "handover")
|
if (logType == "borrow" || logType == "handover")
|
||||||
{
|
{
|
||||||
var records = await GetBorrowRecordsAsync(from, to);
|
var records = await GetBorrowRecordsAsync(from, to, page, size);
|
||||||
return new PagedResult<BusinessLogEntry>
|
return new PagedResult<BusinessLogEntry>
|
||||||
{
|
{
|
||||||
Items = records.Items.Select(r => new BusinessLogEntry
|
Items = records.Items.Select(r => new BusinessLogEntry
|
||||||
@@ -291,7 +331,7 @@ public class KmsAdapter : IHasFlatDevices, IHasAlarms, IAcceptsControl, IHasBusi
|
|||||||
}
|
}
|
||||||
if (logType == "permission")
|
if (logType == "permission")
|
||||||
{
|
{
|
||||||
var perms = await GetPermissionListAsync(from, to);
|
var perms = await GetPermissionListAsync(from, to, page, size);
|
||||||
return new PagedResult<BusinessLogEntry>
|
return new PagedResult<BusinessLogEntry>
|
||||||
{
|
{
|
||||||
Items = perms.Items.Select(p => new BusinessLogEntry
|
Items = perms.Items.Select(p => new BusinessLogEntry
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ public class KmsPermission
|
|||||||
public string? BackStaffName { get; set; }
|
public string? BackStaffName { get; set; }
|
||||||
public string? ApplyTime { get; set; }
|
public string? ApplyTime { get; set; }
|
||||||
public string? BackTime { get; set; }
|
public string? BackTime { get; set; }
|
||||||
|
public int? PermissionState { get; set; } // 1=授权中, 2=授权失败, 3=授权成功, 4=授权过期
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>KMS 员工列表响应</summary>
|
/// <summary>KMS 员工列表响应</summary>
|
||||||
@@ -144,6 +145,7 @@ public class KmsStaff
|
|||||||
{
|
{
|
||||||
public string? Uuid { get; set; }
|
public string? Uuid { get; set; }
|
||||||
public string? Name { get; set; }
|
public string? Name { get; set; }
|
||||||
|
public string? Account { get; set; } // v1.0.4 新增:登录账号
|
||||||
public string? CardNo { get; set; }
|
public string? CardNo { get; set; }
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ if (!axios.defaults.baseURL.endsWith('/')) {
|
|||||||
axios.defaults.baseURL += '/'
|
axios.defaults.baseURL += '/'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lang_storage_key = 'lang'
|
||||||
|
|
||||||
let ipAddress = axios.defaults.baseURL;
|
let ipAddress = axios.defaults.baseURL;
|
||||||
if (!ipAddress || ipAddress == '/') {
|
if (!ipAddress || ipAddress == '/') {
|
||||||
ipAddress = window.location.origin + '/';
|
ipAddress = window.location.origin + '/';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { watch, computed } from "vue"
|
import { watch, computed } from "vue"
|
||||||
import { useMapStore } from "../store/useMapStore"
|
import { useMapStore } from "../stores/mapStore"
|
||||||
|
|
||||||
// 可以使用pinia等管理全局数据,这里只是方便演示, 直接注入了上层提供的数据
|
// 可以使用pinia等管理全局数据,这里只是方便演示, 直接注入了上层提供的数据
|
||||||
const store = useMapStore()
|
const store = useMapStore()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from "vue"
|
import { computed } from "vue"
|
||||||
import { useNavi } from "../hooks/useNavi"
|
import { useNavi } from "../hooks/useNavi"
|
||||||
import { useMapStore } from "../store/useMapStore"
|
import { useMapStore } from "../stores/mapStore"
|
||||||
|
|
||||||
// 可以使用pinia等管理全局数据,这里只是方便演示, 直接注入了上层提供的数据
|
// 可以使用pinia等管理全局数据,这里只是方便演示, 直接注入了上层提供的数据
|
||||||
const store = useMapStore()
|
const store = useMapStore()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { inject, onBeforeUnmount } from "vue"
|
import { inject, onBeforeUnmount } from "vue"
|
||||||
import { useFence } from "../hooks/useFence"
|
import { useFence } from "../hooks/useFence"
|
||||||
import { useMapStore } from "../store/useMapStore"
|
import { useMapStore } from "../stores/mapStore"
|
||||||
|
|
||||||
const { THREE } = VgoMap
|
const { THREE } = VgoMap
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { inject, ref, watch, onBeforeUnmount } from "vue"
|
import { inject, ref, watch, onBeforeUnmount } from "vue"
|
||||||
import { useClassPolygon } from "../hooks/useClassPolygon"
|
import { useClassPolygon } from "../hooks/useClassPolygon"
|
||||||
import { useMapStore } from "../store/useMapStore"
|
import { useMapStore } from "../stores/mapStore"
|
||||||
import http from "../api/http"
|
import http from "../api/http"
|
||||||
|
|
||||||
const { THREE } = VgoMap
|
const { THREE } = VgoMap
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, onBeforeUnmount } from "vue"
|
import { ref, watch, onBeforeUnmount } from "vue"
|
||||||
import { useSkyLight } from "../hooks/useSkyLight"
|
import { useSkyLight } from "../hooks/useSkyLight"
|
||||||
import { useMapStore } from "../store/useMapStore"
|
import { useMapStore } from "../stores/mapStore"
|
||||||
import CustomSwitch from "./CustomSwitch.vue"
|
import CustomSwitch from "./CustomSwitch.vue"
|
||||||
|
|
||||||
const store = useMapStore()
|
const store = useMapStore()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from "vue"
|
import { ref, watch } from "vue"
|
||||||
import { useWeatherEffect, WeatherType } from "../hooks/useWeather"
|
import { useWeatherEffect, WeatherType } from "../hooks/useWeather"
|
||||||
import { useMapStore } from "../store/useMapStore"
|
import { useMapStore } from "../stores/mapStore"
|
||||||
import CustomSwitch from "./CustomSwitch.vue"
|
import CustomSwitch from "./CustomSwitch.vue"
|
||||||
|
|
||||||
const store = useMapStore()
|
const store = useMapStore()
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { ref, computed } from "vue"
|
|
||||||
import { defineStore } from "pinia"
|
|
||||||
|
|
||||||
export const useMapStore = defineStore('map', () => {
|
|
||||||
const map = ref(null)
|
|
||||||
|
|
||||||
const polygonDataAll = computed(() => {
|
|
||||||
const outDoor = map.value?.mapData?.polygonData ?? []
|
|
||||||
|
|
||||||
const inDoor = map?.mapData?.build?.reduce((result, build) => {
|
|
||||||
build.floor.forEach(fItem => {
|
|
||||||
result.push(...fItem.polygonData)
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
}, []) ?? []
|
|
||||||
|
|
||||||
return [...outDoor, ...inDoor]
|
|
||||||
})
|
|
||||||
|
|
||||||
function setMap(mapInstance) {
|
|
||||||
map.value = mapInstance
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
map,
|
|
||||||
polygonDataAll,
|
|
||||||
setMap,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,48 +1,34 @@
|
|||||||
import { ref } from "vue"
|
import { ref, computed } from "vue"
|
||||||
import { defineStore } from "pinia"
|
import { defineStore } from "pinia"
|
||||||
|
|
||||||
export const useMapStore = defineStore('map', () => {
|
export const useMapStore = defineStore('map', () => {
|
||||||
const map = ref(null)
|
const map = ref(null)
|
||||||
const lastClickedPosition = ref(null) // 存储地图最后点击位置的经纬度
|
const lastClickedPosition = ref(null)
|
||||||
const editingMode = ref(false) // 编辑状态标识
|
const editingMode = ref(false)
|
||||||
|
|
||||||
function setMap(mapInstance) {
|
/** 聚合所有楼层+室外的 polygonData */
|
||||||
map.value = mapInstance
|
const polygonDataAll = computed(() => {
|
||||||
}
|
const outDoor = map.value?.mapData?.polygonData ?? []
|
||||||
|
const builds = map.value?.mapData?.build ?? []
|
||||||
|
const inDoor = builds.reduce((result, build) => {
|
||||||
|
build.floor?.forEach(f => { result.push(...(f.polygonData ?? [])) })
|
||||||
|
return result
|
||||||
|
}, [])
|
||||||
|
return [...outDoor, ...inDoor]
|
||||||
|
})
|
||||||
|
|
||||||
// 设置地图最后点击位置的经纬度
|
function setMap(mapInstance) { map.value = mapInstance }
|
||||||
function setLastClickedPosition(lng, lat) {
|
function setLastClickedPosition(lng, lat) { lastClickedPosition.value = { lng, lat } }
|
||||||
lastClickedPosition.value = { lng, lat }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置编辑状态
|
|
||||||
function setEditingMode(mode) {
|
function setEditingMode(mode) {
|
||||||
editingMode.value = mode
|
editingMode.value = mode
|
||||||
if(mode){
|
map.value?.setViewMode(mode ? '2D' : '3D')
|
||||||
map.value.setViewMode('2D')
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
map.value.setViewMode('3D')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取编辑状态
|
|
||||||
function getEditingMode() {
|
|
||||||
if(editingMode.value){
|
|
||||||
return editingMode.value
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
function getEditingMode() { return editingMode.value }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
map,
|
map, setMap,
|
||||||
setMap,
|
lastClickedPosition, setLastClickedPosition,
|
||||||
lastClickedPosition,
|
editingMode, setEditingMode, getEditingMode,
|
||||||
setLastClickedPosition,
|
polygonDataAll,
|
||||||
editingMode,
|
|
||||||
setEditingMode,
|
|
||||||
getEditingMode
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Sky from "../components/Sky.vue"
|
|||||||
import DisplayColor from "../components/DisplayColor.vue"
|
import DisplayColor from "../components/DisplayColor.vue"
|
||||||
import DisplayRouteLine from "../components/DisplayRouteLine.vue"
|
import DisplayRouteLine from "../components/DisplayRouteLine.vue"
|
||||||
import Weather from "../components/Weather.vue"
|
import Weather from "../components/Weather.vue"
|
||||||
import { useMapStore } from "../store/useMapStore"
|
import { useMapStore } from "../stores/mapStore"
|
||||||
|
|
||||||
const { VgoMap } = window
|
const { VgoMap } = window
|
||||||
let mapId = /#\/(\d+)/.exec(location.hash)[1]
|
let mapId = /#\/(\d+)/.exec(location.hash)[1]
|
||||||
|
|||||||
@@ -2,89 +2,60 @@ import * as signalR from "@microsoft/signalr";
|
|||||||
import { ElNotification } from "element-plus";
|
import { ElNotification } from "element-plus";
|
||||||
import { ElMessageBox } from "element-plus";
|
import { ElMessageBox } from "element-plus";
|
||||||
import store from "@/store/index";
|
import store from "@/store/index";
|
||||||
|
|
||||||
export default function (http, receive) {
|
export default function (http, receive) {
|
||||||
let connection;
|
let connection
|
||||||
|
|
||||||
let messageQueue = [];
|
// LRU: 最近 500 条 ID,防内存无限增长
|
||||||
let isProcessingQueue = false;
|
const MAX_IDS = 500
|
||||||
let displayedMessageIds = new Set(); // 用于存储已显示的消息ID,避免重复显示
|
const displayedIds = new Set()
|
||||||
|
const idList = []
|
||||||
|
|
||||||
// 消息队列处理函数 - 移到闭包内部,确保可以访问所有必要的变量
|
function addId(id) {
|
||||||
function processMessageQueue() {
|
if (displayedIds.has(id)) return
|
||||||
if (messageQueue.length === 0) {
|
displayedIds.add(id)
|
||||||
isProcessingQueue = false;
|
idList.push(id)
|
||||||
return;
|
if (idList.length > MAX_IDS) {
|
||||||
}
|
displayedIds.delete(idList.shift())
|
||||||
|
|
||||||
isProcessingQueue = true;
|
|
||||||
const currentMessage = messageQueue.shift();
|
|
||||||
|
|
||||||
// 直接调用receive回调处理消息显示
|
|
||||||
if (receive) {
|
|
||||||
try {
|
|
||||||
receive(currentMessage);
|
|
||||||
console.log('成功调用receive回调处理消息:', currentMessage);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('调用receive回调时出错:', error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function hasId(id) { return displayedIds.has(id) }
|
||||||
|
|
||||||
// 可以设置一定的延迟,避免消息显示过于密集
|
// 重试启动: 最多 5 次, 间隔 2s
|
||||||
setTimeout(() => {
|
async function startWithRetry(conn, retries = 5) {
|
||||||
processMessageQueue();
|
for (let i = 1; i <= retries; i++) {
|
||||||
}, 3000); // 3秒后处理下一条消息
|
try { await conn.start(); return }
|
||||||
|
catch (e) { if (i < retries) await new Promise(r => setTimeout(r, 2000)) }
|
||||||
|
}
|
||||||
|
console.error('SignalR 连接失败 (5次重试后放弃)')
|
||||||
}
|
}
|
||||||
|
|
||||||
http.post("api/user/GetCurrentUserInfo").then((result) => {
|
http.post("api/user/GetCurrentUserInfo").then(async (result) => {
|
||||||
console.log(result,'result');
|
if (!result?.data?.userName) {
|
||||||
|
console.error('获取用户信息失败: 缺少 userName')
|
||||||
|
return
|
||||||
|
}
|
||||||
connection = new signalR.HubConnectionBuilder()
|
connection = new signalR.HubConnectionBuilder()
|
||||||
.withAutomaticReconnect()
|
.withAutomaticReconnect()
|
||||||
.withUrl(
|
.withUrl('/hub/message?userName=' + result.data.userName)
|
||||||
`/hub/message?userName=${result.data.userName}`,
|
.build()
|
||||||
{
|
|
||||||
//withCredentials: true // 启用凭证模式
|
startWithRetry(connection)
|
||||||
// accessTokenFactory: () => store.getters.getToken()
|
|
||||||
}
|
connection.onreconnected(function (id) {
|
||||||
)
|
if (import.meta.env.DEV) console.log('SignalR reconnected:', id)
|
||||||
//.withUrl(`${http.ipAddress}message`)
|
})
|
||||||
.build();
|
|
||||||
|
|
||||||
connection.start().catch((err) => console.log(err.message));
|
|
||||||
//自动重连成功后的处理
|
|
||||||
connection.onreconnected((connectionId) => {
|
|
||||||
console.log(connectionId, 'connectionId');
|
|
||||||
});
|
|
||||||
connection.on("ReceiveHomePageMessage", function (data) {
|
connection.on("ReceiveHomePageMessage", function (data) {
|
||||||
console.log('接收到新消息:', data);
|
if (data?.id && hasId(data.id)) return
|
||||||
|
if (data?.id) addId(data.id)
|
||||||
|
|
||||||
// 检查消息是否已显示过
|
|
||||||
if (data.id && displayedMessageIds.has(data.id)) {
|
|
||||||
console.log('消息已显示过,跳过:', data.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将消息ID添加到已显示集合
|
|
||||||
if (data.id) {
|
|
||||||
displayedMessageIds.add(data.id);
|
|
||||||
console.log('添加到已显示集合:', data.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加到store
|
|
||||||
// store.getters.data().pushMessage(data);
|
|
||||||
console.log(data, 'data_after_push');
|
|
||||||
// 直接调用receive回调
|
|
||||||
if (receive) {
|
if (receive) {
|
||||||
try {
|
try { receive(data) }
|
||||||
receive(data);
|
catch (e) { console.error('receive callback error:', e) }
|
||||||
console.log('成功调用receive回调:', data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('调用receive回调时出错:', error);
|
|
||||||
}
|
}
|
||||||
} else {
|
})
|
||||||
console.warn('receive回调未定义,无法处理消息');
|
}).catch(function (e) {
|
||||||
}
|
console.error('getUserInfo failed:', e)
|
||||||
});
|
})
|
||||||
}).catch(error => {
|
|
||||||
console.error('获取用户信息时出错:', error);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,8 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { VideoPlay } from '@element-plus/icons-vue'
|
import { VideoPlay } from '@element-plus/icons-vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { fetchCameras, gwGet, Camera } from '@/api/gateway'
|
import { fetchCameras, gwGet } from '@/api/gateway'
|
||||||
|
import type { Camera } from '@/api/gateway'
|
||||||
|
|
||||||
const cameras = ref<Camera[]>([])
|
const cameras = ref<Camera[]>([])
|
||||||
const selectedCamera = ref<Camera | null>(null)
|
const selectedCamera = ref<Camera | null>(null)
|
||||||
|
|||||||
@@ -92,7 +92,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { VideoCamera } from '@element-plus/icons-vue'
|
import { VideoCamera } from '@element-plus/icons-vue'
|
||||||
import { fetchCameras, gwGet, gwPost, Camera } from '@/api/gateway'
|
import { fetchCameras, gwGet, gwPost } from '@/api/gateway'
|
||||||
|
import type { Camera } from '@/api/gateway'
|
||||||
|
|
||||||
const cameras = ref<Camera[]>([])
|
const cameras = ref<Camera[]>([])
|
||||||
const selectedCamera = ref<Camera | null>(null)
|
const selectedCamera = ref<Camera | null>(null)
|
||||||
|
|||||||
@@ -106,7 +106,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { fetchCameras, gwGet, gwPost, Camera } from '@/api/gateway'
|
import { fetchCameras, gwGet, gwPost } from '@/api/gateway'
|
||||||
|
import type { Camera } from '@/api/gateway'
|
||||||
|
|
||||||
const cameras = ref<Camera[]>([])
|
const cameras = ref<Camera[]>([])
|
||||||
const streamUrls = ref<Record<string, string>>({})
|
const streamUrls = ref<Record<string, string>>({})
|
||||||
|
|||||||
Reference in New Issue
Block a user