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; }