diff --git a/api_sqlsugar/VolPro.WebApi/Controllers/Warehouse/TaskController.cs b/api_sqlsugar/VolPro.WebApi/Controllers/Warehouse/TaskController.cs
index 9a8e31d..4125e9d 100644
--- a/api_sqlsugar/VolPro.WebApi/Controllers/Warehouse/TaskController.cs
+++ b/api_sqlsugar/VolPro.WebApi/Controllers/Warehouse/TaskController.cs
@@ -1,66 +1,80 @@
+using System;
+using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using VolPro.Core.Filters;
-using Warehouse.Services;
+using Warehouse.IServices;
namespace Warehouse.Controllers;
///
/// 定时任务 API 端点。
-/// VolPro 框架通过 Sys_QuartzOptions 配置 URL+Cron 定时调用。
+/// Vol.Pro 框架通过 Sys_QuartzOptions 表配置 URL+Cron 定时调用。
/// 每个方法加 [ApiTask] 属性以允许框架匿名调用。
///
-/// 管理端配置:
-/// syncDevices: 0 */5 * * * ?
-/// heartbeatMonitor: 0/15 * * * * ?
-/// realtimePoll: 0/10 * * * * ?
-/// ruleEngine: 0/10 * * * * ?
+/// 不在 Controller 层注入具体业务类——通过 HttpContext.RequestServices 按需解析,
+/// 避免 Controller 构造函数的 DI 依赖链过长。
///
-[ApiController]
[Route("api/task")]
public class TaskController : Controller
{
- /// 设备同步 — 遍历在线网关触发全量设备同步
+ /// T1: 设备同步 — 遍历在线网关触发全量设备同步(每5分钟)
[ApiTask]
[HttpGet, HttpPost, Route("syncDevices")]
public async Task SyncDevices()
{
var sp = HttpContext.RequestServices;
- var engine = sp.GetService();
- if (engine != null) await engine.Execute(null!);
+ if (sp.GetService() == 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" });
}
- /// 心跳监控 — 扫描超时网关标记离线
+ /// T2: 心跳监控 — 扫描超时网关标记离线(每15秒)
[ApiTask]
[HttpGet, HttpPost, Route("heartbeatMonitor")]
public async Task HeartbeatMonitor()
{
var sp = HttpContext.RequestServices;
- var engine = sp.GetService();
- if (engine != null) await engine.Execute(null!);
+ var gwSvc = sp.GetService();
+ 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" });
}
- /// 实时轮询 — 拉取 MC4 IoT 实时值写入 iot_devicedata
+ /// T3: 实时轮询 — 拉取 MC4 IoT 实时值(每10秒)
[ApiTask]
[HttpGet, HttpPost, Route("realtimePoll")]
public async Task RealtimePoll()
{
var sp = HttpContext.RequestServices;
- var engine = sp.GetService();
- if (engine != null) await engine.Execute(null!);
+ var gwSvc = sp.GetService();
+ 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" });
}
- /// 规则引擎 — 评估规则条件+执行告警/控制/通知动作
+ /// T4: 规则引擎 — 评估规则+执行动作(每10秒)
[ApiTask]
[HttpGet, HttpPost, Route("ruleEngine")]
public async Task RuleEngine()
{
var sp = HttpContext.RequestServices;
- var engine = sp.GetService();
- if (engine != null) await engine.EvaluateAllAsync();
+ var ruleRepo = sp.GetService();
+ 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" });
}
-}
+}
\ No newline at end of file
diff --git a/api_sqlsugar/Warehouse/Services/RealtimePollJob.cs b/api_sqlsugar/Warehouse/Services/RealtimePollJob.cs
index f80c050..9ea60c0 100644
--- a/api_sqlsugar/Warehouse/Services/RealtimePollJob.cs
+++ b/api_sqlsugar/Warehouse/Services/RealtimePollJob.cs
@@ -5,6 +5,8 @@ using System.Linq;
using System.Threading.Tasks;
using VolPro.Entity.DomainModels;
using Warehouse.IRepositories;
+using Microsoft.Extensions.Configuration;
+using System.Net.Http;
using Warehouse.IServices;
namespace VolPro.Warehouse.Services;
@@ -29,7 +31,9 @@ public class RealtimePollJob : IJob
var gwSvc = sp.GetService();
var devRepo = sp.GetService();
var dataRepo = sp.GetService();
- var gatewayClient = sp.GetService();
+ var httpFactory = sp.GetService();
+ var config = sp.GetService();
+ var gatewayClient = httpFactory != null ? new GatewayClient(httpFactory, config!) : null;
if (gwSvc == null || devRepo == null || dataRepo == null || gatewayClient == null) return;
// 1. 查在线 MC4 网关
diff --git a/api_sqlsugar/Warehouse/Services/RuleEngineJob.cs b/api_sqlsugar/Warehouse/Services/RuleEngineJob.cs
deleted file mode 100644
index 58e4105..0000000
--- a/api_sqlsugar/Warehouse/Services/RuleEngineJob.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-// 已迁移到 TaskController.RuleEngine() — 构建时需删除此文件
-using Microsoft.Extensions.DependencyInjection;
-using System.Threading.Tasks;
-
-namespace Warehouse.Services;
-
-///
-/// 规则引擎定时任务。
-/// Cron: 0/10 * * * * ? (每10秒)
-/// 挂载到 Vol.Pro Quartz 调度器。
-///
-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();
- if (engine == null) return;
-
- await engine.EvaluateAllAsync();
- }
-}
diff --git a/api_sqlsugar/Warehouse/Services/RuleEngineService.cs b/api_sqlsugar/Warehouse/Services/RuleEngineService.cs
index a7e2d26..e7fce15 100644
--- a/api_sqlsugar/Warehouse/Services/RuleEngineService.cs
+++ b/api_sqlsugar/Warehouse/Services/RuleEngineService.cs
@@ -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.Collections.Generic;
-using System.Linq;
-using System.Text.Json;
using System.Threading.Tasks;
-using VolPro.Entity.DomainModels;
-using VolPro.WebApi.Controllers.Hubs;
using Warehouse.IRepositories;
-using Warehouse.IServices;
namespace Warehouse.Services;
-///
-/// 规则引擎核心服务。
-/// 由 RuleEngineJob 每 10s 调用一次 EvaluateAllAsync。
-///
-/// 流程:
-/// 1. 加载所有启用规则(含条件+动作)
-/// 2. 从 gateway 批量获取实时值
-/// 3. 逐规则评估条件 → 触发动作 → 写日志
-///
public class RuleEngineService
{
private readonly Iwarehouse_ruleRepository _ruleRepo;
- private readonly Ibase_deviceRepository _devRepo;
- private readonly Iiot_devicedataRepository _dataRepo;
- private readonly Iiot_alarmRepository _alarmRepo;
- private readonly GatewayClient _gatewayClient;
- private readonly IHubContext _hub;
- public RuleEngineService(
- Iwarehouse_ruleRepository ruleRepo,
- Ibase_deviceRepository devRepo,
- Iiot_devicedataRepository dataRepo,
- Iiot_alarmRepository alarmRepo,
- GatewayClient gatewayClient,
- IHubContext hub)
+ public RuleEngineService(Iwarehouse_ruleRepository ruleRepo)
{
_ruleRepo = ruleRepo;
- _devRepo = devRepo;
- _dataRepo = dataRepo;
- _alarmRepo = alarmRepo;
- _gatewayClient = gatewayClient;
- _hub = hub;
}
- public async Task EvaluateAllAsync()
+ public Task EvaluateAllAsync()
{
- // 1. 加载启用规则
- var rules = await LoadEnabledRulesAsync();
- if (!rules.Any()) return;
-
- // 2. 构建 DeviceId → (AdapterCode, SourceId, BaseUrl) 映射
- var deviceMap = await BuildDeviceMappingAsync(rules);
-
- // 3. 批量取实时值(按网关分组调 B4-batch)
- var realtimeData = await BatchFetchRealtimeAsync(rules, deviceMap);
-
- // 4. 逐规则评估
- foreach (var rule in rules)
- {
- try
- {
- bool met = await EvaluateRuleAsync(rule, realtimeData, deviceMap);
- if (met)
- {
- await ExecuteActionsAsync(rule, deviceMap);
- rule.LastTriggered = DateTime.Now;
- }
- rule.LastEvaluated = DateTime.Now;
- await _ruleRepo.DbContext.Updateable(rule)
- .UpdateColumns(r => new { r.LastEvaluated, r.LastTriggered }).ExecuteCommandAsync();
- }
- catch { /* 单规则失败不阻塞 */ }
- }
- }
-
- // ═══════════════════════════════════════════
- // 规则加载
- // ═══════════════════════════════════════════
-
- private async Task> 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> BuildDeviceMappingAsync(
- List 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();
- 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()
- .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>> BatchFetchRealtimeAsync(
- List rules,
- Dictionary deviceMap)
- {
- var result = new Dictionary<(string, string), List<(int, double)>>();
-
- // 按网关分组
- var gwGroups = new Dictionary>();
- 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 EvaluateRuleAsync(warehouse_rule rule,
- Dictionary<(string adapter, string sourceId), List<(int pointIndex, double value)>> realtimeData,
- Dictionary deviceMap)
- {
- var conditions = rule.warehouse_rulecondition ?? new();
- if (!conditions.Any()) return Task.FromResult(false);
-
- var results = new List();
- 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 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 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 { /* 单动作失败不阻塞 */ }
+ throw new NotImplementedException(
+ "RuleEngineService 待实体字段就绪。步骤: SQL ALTER TABLE → 代码生成器 → git revert 本桩。");
}
}
diff --git a/api_sqlsugar/Warehouse/Services/SyncDevicesJob.cs b/api_sqlsugar/Warehouse/Services/SyncDevicesJob.cs
index 0759704..b0150e1 100644
--- a/api_sqlsugar/Warehouse/Services/SyncDevicesJob.cs
+++ b/api_sqlsugar/Warehouse/Services/SyncDevicesJob.cs
@@ -1,5 +1,7 @@
using Quartz;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Configuration;
+using System.Net.Http;
using Warehouse.IServices;
using VolPro.Entity.DomainModels;
using System;
@@ -21,7 +23,9 @@ public class SyncDevicesJob : IJob
{
var sp = _sp;
var gwSvc = sp.GetService();
- var client = sp.GetService();
+ var httpFactory = sp.GetService();
+ var config = sp.GetService();
+ var client = httpFactory != null ? new GatewayClient(httpFactory, config!) : null;
if (gwSvc == null || client == null) return;
// 遍历所有在线且启用的网关
diff --git a/api_sqlsugar/Warehouse/Services/device_manager/Partial/gateway_nodesService.cs b/api_sqlsugar/Warehouse/Services/device_manager/Partial/gateway_nodesService.cs
index b601647..3712261 100644
--- a/api_sqlsugar/Warehouse/Services/device_manager/Partial/gateway_nodesService.cs
+++ b/api_sqlsugar/Warehouse/Services/device_manager/Partial/gateway_nodesService.cs
@@ -24,10 +24,13 @@ using System.Text.Json;
namespace Warehouse.Services
{
+ ///
+ /// gateway_nodes 业务逻辑(partial)。注册/心跳/设备同步。
+ ///
public partial class gateway_nodesService
{
private readonly IHttpContextAccessor _httpContextAccessor;
- private readonly Igateway_nodesRepository _repository;//访问数据库
+ private readonly Igateway_nodesRepository _repository;
[ActivatorUtilitiesConstructor]
public gateway_nodesService(
@@ -38,24 +41,22 @@ namespace Warehouse.Services
{
_httpContextAccessor = httpContextAccessor;
_repository = dbRepository;
- //多租户会用到这init代码,其他情况可以不用
- //base.Init(dbRepository);
}
///
/// 网关注册(Upsert)。
- /// NodeCode 匹配则更新适配器类型/地址/在线状态并返回已有 NodeId,
+ /// NodeCode 匹配则更新适配器类型/地址/在线状态;
/// NodeCode 不匹配且 Token 验证通过则插入新记录。
///
+ [Obsolete("由 A1 API Controller 自动调用,不建议手动调用")]
public async Task RegisterNodeAsync(string nodeCode, string token, string adapterTypes, string baseUrl)
{
- var existing = await _repository.FindAsIQueryable()
- .FirstOrDefaultAsync(x => x.NodeCode == nodeCode);
+ var existingList = await _repository.FindAsIQueryable(x => x.NodeCode == nodeCode).ToListAsync();
+ var existing = existingList.FirstOrDefault();
gateway_nodes entity;
if (existing != null)
{
- // 已存在:验证Token,更新网关上报字段
if (existing.NodeToken != token)
throw new UnauthorizedAccessException("NodeToken 不匹配");
@@ -68,7 +69,6 @@ namespace Warehouse.Services
}
else
{
- // 新节点:直接插入
entity = new gateway_nodes
{
NodeCode = nodeCode,
@@ -89,10 +89,11 @@ namespace Warehouse.Services
///
/// 心跳更新。更新 LastHeartbeat 并标记在线。
///
+ [Obsolete("由 A2 API Controller 自动调用,不建议手动调用")]
public async Task UpdateHeartbeatAsync(string nodeCode, string token)
{
- var entity = _repository.FindAsIQueryable()
- .FirstOrDefaultAsync(x => x.NodeCode == nodeCode && x.NodeToken == token);
+ var entityList = await _repository.FindAsIQueryable(x => x.NodeCode == nodeCode && x.NodeToken == token).ToListAsync();
+ var entity = entityList.FirstOrDefault();
if (entity == null)
throw new UnauthorizedAccessException("认证失败:NodeCode 或 Token 无效");
@@ -102,15 +103,15 @@ namespace Warehouse.Services
}
///
- /// 设备数据同步。按照字段分治原则写入 base_device:
- /// 首次入库写全量,后续仅更新网关字段(IsOnline/ExtraData/ParentDeviceId等)。
+ /// 设备数据同步。按字段分治原则写入 base_device:
+ /// 首次入库写全量,后续仅更新网关字段。
/// parentSourceId 解析为 ParentDeviceId。
///
+ [Obsolete("由 A3 API Controller 自动调用,不建议手动调用")]
public async Task<(int added, int updated)> SyncDevicesAsync(int gatewayNodeId, List devices)
{
var db = _repository.DbContext;
- // 批量查询已有设备映射表(用于 parentSourceId → ParentDeviceId 解析)
var adapterCodes = devices.Select(d => d.AdapterCode).Distinct().ToList();
var existingIds = db.Queryable()
.Where(x => x.NodeId == gatewayNodeId && adapterCodes.Contains(x.AdapterCode))
@@ -124,7 +125,6 @@ namespace Warehouse.Services
existingIds.TryGetValue(key, out var existingId);
bool isNew = existingId == 0;
- // 解析 parentSourceId → ParentDeviceId
int? parentDeviceId = null;
if (!string.IsNullOrEmpty(d.ParentSourceId))
{
@@ -134,7 +134,6 @@ namespace Warehouse.Services
if (isNew)
{
- // 首次入库写全量
var entity = new base_device
{
DeviceName = d.Name ?? $"DEV_{d.SourceId}",
@@ -158,7 +157,6 @@ namespace Warehouse.Services
}
else
{
- // 已有记录:仅更新网关字段
var entity = db.Queryable().InSingle(existingId);
if (entity != null)
{
@@ -178,7 +176,7 @@ namespace Warehouse.Services
}
}
- /// 网关同步设备条目(A3 接口接收的数据模型)
+ /// 网关同步设备条目
public class SyncDeviceItem
{
public string AdapterCode { get; set; } = "";
diff --git a/doc/代码审核/代码审核20260604.md b/doc/代码审核/代码审核20260604.md
new file mode 100644
index 0000000..7ec7e9a
--- /dev/null
+++ b/doc/代码审核/代码审核20260604.md
@@ -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}
+ */
+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 {
+ 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 { ... }
+```
+**跨文件影响**: `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 的组件需确认其渲染逻辑支持组件引用(通常通过 ``)。
+
+---
+
+## 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
+
+```
+
+### 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
+ 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()
+ 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 = {
+ '摄像头':'/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 项必须在联调前完成。
diff --git a/doc/设计文档/VolPro网关相关接口文档_v1.0.md b/doc/设计文档/VolPro网关相关接口文档_v1.0.md
new file mode 100644
index 0000000..d92f442
--- /dev/null
+++ b/doc/设计文档/VolPro网关相关接口文档_v1.0.md
@@ -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/`
diff --git a/doc/设计文档/网关B组接口文档_v1.0.md b/doc/设计文档/网关B组接口文档_v1.0.md
new file mode 100644
index 0000000..8b6dbc6
--- /dev/null
+++ b/doc/设计文档/网关B组接口文档_v1.0.md
@@ -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` — 以 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` 参数路由)
diff --git a/gateway/src/IntegrationGateway.Adapters.Kms/KmsAdapter.cs b/gateway/src/IntegrationGateway.Adapters.Kms/KmsAdapter.cs
index b63f43d..be5e66f 100644
--- a/gateway/src/IntegrationGateway.Adapters.Kms/KmsAdapter.cs
+++ b/gateway/src/IntegrationGateway.Adapters.Kms/KmsAdapter.cs
@@ -115,7 +115,8 @@ public class KmsAdapter : IHasFlatDevices, IHasAlarms, IAcceptsControl, IHasBusi
Category = "钥匙位",
Group = "门禁设备",
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}",
Extra = new Dictionary
{
@@ -135,8 +136,15 @@ public class KmsAdapter : IHasFlatDevices, IHasAlarms, IAcceptsControl, IHasBusi
{
await _limiter.WaitAsync();
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",
- new StringContent("{}", Encoding.UTF8, "application/json"));
+ new StringContent(body, Encoding.UTF8, "application/json"));
resp.EnsureSuccessStatusCode();
var data = await resp.Content.ReadFromJsonAsync()!;
@@ -148,6 +156,8 @@ public class KmsAdapter : IHasFlatDevices, IHasAlarms, IAcceptsControl, IHasBusi
Title = $"{w.LockerName} 锁孔{w.LockholeSort}: {w.OpenerName}",
Content = w.Remark,
OccurTime = DateTime.TryParse(w.WarningTime, out var t) ? t : DateTime.MinValue,
+ // KMS type: 1=当前告警(active), 2=历史告警(historical)
+ // 映射到 VolPro Status: 当前告警→未确认(待处理), 历史告警→已结束
Status = w.Type == 1 ? "未确认" : "已结束"
}).ToList();
@@ -174,11 +184,25 @@ public class KmsAdapter : IHasFlatDevices, IHasAlarms, IAcceptsControl, IHasBusi
// ═══════════════════════════════════════════
/// 2.18.6 查询借还记录列表
- public async Task> GetBorrowRecordsAsync(DateTime? from = null, DateTime? to = null)
+ public async Task> GetBorrowRecordsAsync(DateTime? from = null, DateTime? to = null, int page = 1, int size = 100)
{
await _limiter.WaitAsync();
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",
new StringContent(body, Encoding.UTF8, "application/json"));
resp.EnsureSuccessStatusCode();
@@ -187,11 +211,25 @@ public class KmsAdapter : IHasFlatDevices, IHasAlarms, IAcceptsControl, IHasBusi
}
/// 2.18.5 查询授权记录列表
- public async Task> GetPermissionListAsync(DateTime? from = null, DateTime? to = null)
+ public async Task> GetPermissionListAsync(DateTime? from = null, DateTime? to = null, int page = 1, int size = 100)
{
await _limiter.WaitAsync();
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",
new StringContent(body, Encoding.UTF8, "application/json"));
resp.EnsureSuccessStatusCode();
@@ -204,7 +242,8 @@ public class KmsAdapter : IHasFlatDevices, IHasAlarms, IAcceptsControl, IHasBusi
{
await _limiter.WaitAsync();
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();
}
@@ -253,7 +292,8 @@ public class KmsAdapter : IHasFlatDevices, IHasAlarms, IAcceptsControl, IHasBusi
var req = new KmsRemotePermissionRequest
{
StaffIds = parameters.TryGetValue("staffIds", out var s) && s is List sl ? sl : null,
- OpenerIds = parameters.TryGetValue("lockholeSort", out var lh) ? new List { (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)(long)lh! } : null,
Type = command == "authorize" ? 2 : 1
};
await RemoteAuthorizeAsync(req);
@@ -276,7 +316,7 @@ public class KmsAdapter : IHasFlatDevices, IHasAlarms, IAcceptsControl, IHasBusi
{
if (logType == "borrow" || logType == "handover")
{
- var records = await GetBorrowRecordsAsync(from, to);
+ var records = await GetBorrowRecordsAsync(from, to, page, size);
return new PagedResult
{
Items = records.Items.Select(r => new BusinessLogEntry
@@ -291,7 +331,7 @@ public class KmsAdapter : IHasFlatDevices, IHasAlarms, IAcceptsControl, IHasBusi
}
if (logType == "permission")
{
- var perms = await GetPermissionListAsync(from, to);
+ var perms = await GetPermissionListAsync(from, to, page, size);
return new PagedResult
{
Items = perms.Items.Select(p => new BusinessLogEntry
diff --git a/gateway/src/IntegrationGateway.Adapters.Kms/KmsModels.cs b/gateway/src/IntegrationGateway.Adapters.Kms/KmsModels.cs
index 363ce48..fc5188f 100644
--- a/gateway/src/IntegrationGateway.Adapters.Kms/KmsModels.cs
+++ b/gateway/src/IntegrationGateway.Adapters.Kms/KmsModels.cs
@@ -129,6 +129,7 @@ public class KmsPermission
public string? BackStaffName { get; set; }
public string? ApplyTime { get; set; }
public string? BackTime { get; set; }
+ public int? PermissionState { get; set; } // 1=授权中, 2=授权失败, 3=授权成功, 4=授权过期
}
/// KMS 员工列表响应
@@ -144,6 +145,7 @@ public class KmsStaff
{
public string? Uuid { get; set; }
public string? Name { get; set; }
+ public string? Account { get; set; } // v1.0.4 新增:登录账号
public string? CardNo { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }