diff --git a/api_sqlsugar/Warehouse/Services/RuleEngineService.cs b/api_sqlsugar/Warehouse/Services/RuleEngineService.cs index e7fce15..02fee4f 100644 --- a/api_sqlsugar/Warehouse/Services/RuleEngineService.cs +++ b/api_sqlsugar/Warehouse/Services/RuleEngineService.cs @@ -1,29 +1,384 @@ // ═══════════════════════════════════════════ -// 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" +// RuleEngineService — 规则引擎核心服务(V2.0 完整实现) +// 端到端流程:加载启用规则 → 分组取实时值 → 评估条件 → 执行动作(控制/告警/通知)→ 写日志 +// 设计决策详见 doc/整合方案/SecMPS_规则引擎完整实施_v2.0.md §3 步骤 3 // ═══════════════════════════════════════════ using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using SqlSugar; +using VolPro.Core.DbSqlSugar; // FirstOrDefaultAsync 扩展方法 +using VolPro.Core.SignalR; +using VolPro.Entity.DomainModels; using Warehouse.IRepositories; +using Warehouse.IServices; +using Warehouse.Services; // iot_alarmService / SyncAlarmItem 命名空间 +using VolPro.Warehouse.Services; // GatewayClient 命名空间 namespace Warehouse.Services; +/// +/// 规则引擎核心服务。由 RuleEngineJob 每 10s 调用 EvaluateAllAsync() 一次。 +/// +/// 设计要点(详见方案 v2.0 §3 步骤 3): +/// 1. 用 SqlSugar 的 Includes 加载导航属性(条件/动作) +/// 2. 按 AdapterCode 分组批量调网关 B4-batch(避免逐设备 HTTP 调用) +/// 3. 规则评估:AND/OR + 数值/开关比对 + 滞后窗(RecoveryThreshold)+ 规则级冷却 +/// 4. 动作执行:控制/告警/通知 三种类型在 ExecuteActionsAsync 内 switch 串行执行(MVP 阶段) +/// 5. 推送 SignalR:用 IHubContext<MessageHub> 推 "RuleTriggered" 事件(与 DataView.vue 订阅名一致) +/// 6. 写执行日志:warehouse_rulelog 记录每次评估的触发/条件摘要/耗时 +/// 7. gateway_nodes.default 在每条规则触发前查一次(避免 N+1) +/// public class RuleEngineService { private readonly Iwarehouse_ruleRepository _ruleRepo; + private readonly Ibase_deviceRepository _deviceRepo; + private readonly Iwarehouse_rulelogRepository _logRepo; + // V2.0.1 修复 DI:iot_alarmService 由 AutofacContainerModuleExtension.AddModule + // 按"第一个接口 Iiot_alarmService"注册到内置 DI(实现类未单独注册), + // 构造函数按具体类注入会触发 "Unable to resolve service for type iot_alarmService"。 + private readonly Iiot_alarmService _alarmService; + private readonly GatewayClient _gatewayClient; + private readonly Igateway_nodesService _nodeService; + private readonly IHubContext _hub; + private readonly ILogger _logger; - public RuleEngineService(Iwarehouse_ruleRepository ruleRepo) + public RuleEngineService( + Iwarehouse_ruleRepository ruleRepo, + Ibase_deviceRepository deviceRepo, + Iwarehouse_rulelogRepository logRepo, + Iiot_alarmService alarmService, + GatewayClient gatewayClient, + Igateway_nodesService nodeService, + IHubContext hub, + ILogger logger) { _ruleRepo = ruleRepo; + _deviceRepo = deviceRepo; + _logRepo = logRepo; + _alarmService = alarmService; + _gatewayClient = gatewayClient; + _nodeService = nodeService; + _hub = hub; + _logger = logger; } - public Task EvaluateAllAsync() + /// + /// 评估所有启用规则 + 执行动作 + 写日志。每 10s 由 RuleEngineJob 调用。 + /// + public async Task EvaluateAllAsync() { - throw new NotImplementedException( - "RuleEngineService 待实体字段就绪。步骤: SQL ALTER TABLE → 代码生成器 → git revert 本桩。"); + var sw = Stopwatch.StartNew(); + + // 1. 加载启用规则(用 Includes 加载条件/动作导航属性) + var rules = await _ruleRepo.FindAsIQueryable(r => r.Enable == "启用") + .Includes(r => r.warehouse_rulecondition) + .Includes(r => r.warehouse_ruleaction) + .ToListAsync(); + + if (!rules.Any()) + { + _logger.LogDebug("[RuleEngine] 无启用规则"); + return; + } + + // 2. 收集所有涉及设备 ID(按 AdapterCode 分组取实时值用) + var allDeviceIds = rules.SelectMany(r => r.warehouse_rulecondition) + .Select(c => c.DeviceId).Where(id => id.HasValue) + .Distinct().Select(id => id!.Value).ToList(); + + if (!allDeviceIds.Any()) + { + _logger.LogDebug("[RuleEngine] 启用规则无有效设备"); + return; + } + + // 3. 查设备表 → Dictionary + // SqlSugar 没有 ToDictionaryAsync,先 ToListAsync 再 LINQ ToDictionary + var deviceList = await _deviceRepo.FindAsIQueryable(d => allDeviceIds.Contains(d.DeviceId)) + .ToListAsync(); + var devices = deviceList.ToDictionary(d => d.DeviceId); + + // 5. 一次性查 gateway_nodes.default(避免每条规则触发都查) + var defaultNode = await _nodeService.FindAsIQueryable(n => n.NodeCode == "default") + .FirstOrDefaultAsync(); + var defaultBaseUrl = defaultNode?.BaseUrl ?? ""; + + // 5b. 按 AdapterCode 分组 → 调 B4-batch 批量取实时值 + var realtimeCache = new Dictionary>(); // DeviceId → PointValues + var adapterGroups = devices.Values + .Where(d => !string.IsNullOrEmpty(d.AdapterCode) && !string.IsNullOrEmpty(d.SourceId)) + .GroupBy(d => d.AdapterCode); + + foreach (var grp in adapterGroups) + { + try + { + var adapterCode = grp.Key!; + var sourceIds = grp.Select(d => d.SourceId!).ToList(); + // V2.0 步骤 5:baseUrl 从 defaultBaseUrl 传入(与方案 §3 步骤 5 一致) + var result = await _gatewayClient.GetRealtimeBatchAsync(defaultBaseUrl, adapterCode, sourceIds); + foreach (var dev in grp) + { + if (result.TryGetValue(dev.SourceId!, out var points)) + realtimeCache[dev.DeviceId] = points; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[RuleEngine] 网关批量取实时值失败: Adapter={Adapter}", grp.Key); + } + } + + // 6. 逐规则评估(按 Priority 倒序,数字越大越先评估) + foreach (var rule in rules.OrderByDescending(r => r.Priority)) + { + var ruleSw = Stopwatch.StartNew(); + try + { + // 6a. 全局冷却检查(距离上次触发不足 CooldownSec 秒则跳过) + if (rule.LastTriggered.HasValue && + (DateTime.Now - rule.LastTriggered.Value).TotalSeconds < rule.CooldownSec) + { + continue; + } + + // 6b. 评估所有条件 + var condResults = new List<(warehouse_rulecondition cond, bool met, decimal? actualValue)>(); + foreach (var cond in rule.warehouse_rulecondition) + { + if (!cond.DeviceId.HasValue) continue; + if (!realtimeCache.TryGetValue(cond.DeviceId.Value, out var points)) continue; + + var point = FindPointByValueId(points, cond.ValueId); + bool met = EvaluateCondition(cond, point?.Value); + condResults.Add((cond, met, point?.Value)); + } + + // 至少需要 1 个有效条件才评估 + if (!condResults.Any()) continue; + + // AND / OR 评估 + bool triggered = rule.JudgmentMode == "OR" + ? condResults.Any(c => c.met) + : condResults.All(c => c.met); + + // 6c. 触发 → 执行动作链 + if (triggered) + { + await ExecuteActionsAsync(rule, devices, condResults, defaultBaseUrl); + rule.LastTriggered = DateTime.Now; + } + rule.LastEvaluated = DateTime.Now; + // 立即保存(saveChanges=true)确保状态持久化 + _ruleRepo.Update(rule, true); + + // 6d. 写执行日志 + var logEntry = new warehouse_rulelog + { + RuleID = rule.RuleID, + Triggered = triggered ? "是" : "否", + ConditionSummary = string.Join(";", condResults.Select(c => + $"id={c.cond.id}({c.met},val={c.actualValue?.ToString() ?? "null"})")), + EvaluatedAt = DateTime.Now, + DurationMs = (int)ruleSw.ElapsedMilliseconds + }; + _logRepo.Add(logEntry, true); + } + catch (Exception ex) + { + _logger.LogError(ex, "[RuleEngine] 规则 {RuleID} 评估失败", rule.RuleID); + } + } + + sw.Stop(); + _logger.LogInformation("[RuleEngine] 评估 {Count} 条规则, 触发 {Triggered} 条, 耗时 {Ms}ms", + rules.Count, + rules.Count(r => r.LastTriggered == DateTime.Now.Date || (r.LastTriggered.HasValue && (DateTime.Now - r.LastTriggered.Value).TotalSeconds < 1)), + sw.ElapsedMilliseconds); + } + + /// + /// 执行规则下的所有动作(MVP 阶段:串行执行;并发留待 V2.1 优化)。 + /// 串行原因:动作多为"控制+告警+通知"组合,串行可保证告警→通知时序一致。 + /// + /// 触发的规则 + /// DeviceId → 设备映射 + /// 条件评估结果 + /// gateway_nodes.default.BaseUrl(已查好) + private async Task ExecuteActionsAsync( + warehouse_rule rule, + Dictionary devices, + List<(warehouse_rulecondition cond, bool met, decimal? actualValue)> condResults, + string defaultBaseUrl) + { + foreach (var action in rule.warehouse_ruleaction.OrderByDescending(a => a.id)) + { + try + { + switch (action.ActionType ?? "控制") + { + case "控制": + if (action.DeviceId.HasValue && devices.TryGetValue(action.DeviceId.Value, out var dev) + && !string.IsNullOrEmpty(dev.AdapterCode) && !string.IsNullOrEmpty(dev.SourceId)) + { + var ok = await _gatewayClient.ControlDeviceAsync( + defaultBaseUrl, dev.AdapterCode, dev.SourceId, + action.ValueId ?? 0, + (double)(action.TargetValue_Number ?? 0)); + _logger.LogInformation("[RuleEngine] 规则 {RuleID} 控制 {Adapter}/{SourceId} 点位 {Point} = {Value} -> {Result}", + rule.RuleID, dev.AdapterCode, dev.SourceId, + action.ValueId, action.TargetValue_Number, ok ? "OK" : "FAIL"); + } + else + { + _logger.LogWarning("[RuleEngine] 规则 {RuleID} 动作 {ActionId} 设备缺失或未配置 AdapterCode/SourceId", + rule.RuleID, action.id); + } + break; + + case "告警": + if (action.Alert == "是") + { + var triggerSource = condResults.FirstOrDefault(c => c.met); + string? deviceSourceId = null; + if (action.DeviceId.HasValue && devices.TryGetValue(action.DeviceId.Value, out var actionDev)) + { + deviceSourceId = actionDev.SourceId; + } + await _alarmService.UpsertAlarmAsync(new SyncAlarmItem + { + SourceAlarmId = $"rule_{rule.RuleID}_{DateTime.Now:yyyyMMddHHmmssfff}", + DeviceSourceId = deviceSourceId ?? "", + AdapterCode = "RuleEngine", + Level = "重要", + Desc = action.AlertMessage ?? rule.Title, + Value = triggerSource.actualValue.HasValue ? (double)triggerSource.actualValue.Value : (double?)null, + StartTime = DateTime.Now.ToString("o") + }, action.DeviceId); + _logger.LogInformation("[RuleEngine] 规则 {RuleID} 已生成告警: {Message}", + rule.RuleID, action.AlertMessage ?? rule.Title); + } + break; + + case "通知": + // 推送 SignalR(事件名 RuleTriggered 与 DataView.vue:19 订阅名一致) + await _hub.Clients.All.SendAsync("RuleTriggered", new + { + ruleId = rule.RuleID, + title = rule.Title, + alertMessage = action.AlertMessage ?? rule.Title, + level = "重要", + timestamp = DateTime.Now.ToString("o"), + triggeredDevices = condResults.Where(c => c.met) + .Select(c => c.cond.DeviceId).ToList() + }); + _logger.LogInformation("[RuleEngine] 规则 {RuleID} 已推送 SignalR 通知", rule.RuleID); + break; + + default: + _logger.LogWarning("[RuleEngine] 规则 {RuleID} 动作 {ActionId} 未知类型: {Type}", + rule.RuleID, action.id, action.ActionType); + break; + } + } + catch (Exception ex) + { + // 单条动作失败不阻塞后续动作 + _logger.LogError(ex, "[RuleEngine] 规则 {RuleID} 动作 {ActionId} 执行失败: {Type}", + rule.RuleID, action.id, action.ActionType); + } + } + } + + /// + /// 在设备实时点位列表里按 ValueId(点位索引)查找。 + /// + /// 设备的实时点位列表 + /// 点位索引(warehouse_rulecondition.ValueId) + /// 匹配的点位,未找到返回 null + private PointValue? FindPointByValueId(List points, int? valueId) + { + if (!valueId.HasValue) return null; + return points.FirstOrDefault(p => p.PointIndex == valueId.Value); + } + + /// + /// 评估单条条件是否满足(数值/开关 + 比较运算 + 滞后窗)。 + /// + /// 规则条件 + /// 实际实时值(点位 Value) + /// true=满足条件 + private bool EvaluateCondition(warehouse_rulecondition cond, decimal? actualValue) + { + // 没有实际值(如设备离线/点位无数据)→ 不满足 + if (!actualValue.HasValue) return false; + + bool isSwitch = cond.Type == "开关状态"; + bool isNumeric = cond.Type == "数值"; + + if (isSwitch) + { + // 开关型:actualValue 非 0 = "开",0 = "关" + var actualSwitch = actualValue.Value != 0 ? "开" : "关"; + var targetSwitch = cond.TargetValue_Switch ?? "开"; + bool met = CompareByOperator(cond.CompareOperator, actualSwitch, targetSwitch); + // 滞后窗:如果当前未满足且配置了恢复阈值,则用恢复阈值再判一次 + if (!met && !string.IsNullOrEmpty(cond.RecoveryThreshold_Switch)) + { + met = actualSwitch == cond.RecoveryThreshold_Switch; + } + return met; + } + else + { + // 数值型(默认走数值分支) + var actual = actualValue.Value; + var target = cond.TargetValue_Number ?? 0; + bool met = CompareByOperator(cond.CompareOperator, (double)actual, (double)target); + // 滞后窗 + if (!met && cond.RecoveryThreshold_Numeric.HasValue) + { + met = CompareByOperator(cond.CompareOperator, (double)actual, (double)cond.RecoveryThreshold_Numeric.Value); + } + return met; + } + } + + /// + /// 通用比较器(支持 > < = ≥ ≤ ≠)。 + /// + private bool CompareByOperator(string? op, double actual, double target) + { + return (op ?? "=") switch + { + ">" => actual > target, + "<" => actual < target, + "=" => Math.Abs(actual - target) < 0.0001, + "≥" or ">=" => actual >= target, + "≤" or "<=" => actual <= target, + "≠" or "!=" => Math.Abs(actual - target) >= 0.0001, + _ => false + }; + } + + /// + /// 字符串比较器(开关型专用)。 + /// + private bool CompareByOperator(string? op, string actual, string target) + { + return (op ?? "=") switch + { + "=" => actual == target, + "≠" or "!=" => actual != target, + _ => false + }; } } diff --git a/说明文档.md b/说明文档.md index fa028de..bd5b0e4 100644 --- a/说明文档.md +++ b/说明文档.md @@ -367,6 +367,7 @@ gateway/ | 2026-07-24 | **MC4 适配器 JSON 反序列化大小写问题 V1.10** | 主人反馈:"刚才对MC4授权部分的修改在JSON反序列化的时候有问题,返回的JSON字符串中的字段名全是小写,但Model的属性命名是有大小写的,这个得解决一下,不然无法正确反序列化返回数据,也就无法正确拿到Token。"**根因 2 段**:(1)**MC4 返回 JSON 字段全小写**(如 `code` `msg` `data` `id` `token`),但 C# Model 属性是 PascalCase(`Code` `Msg` `Data` `Id` `Token`),**默认 `JsonSerializer.Deserialize` 大小写敏感直接返回 null**;(2)**MC4 业务接口统一响应结构** `{code, msg, data}` 三段式(参考 [MC4.0对外API.md](file:///d:/Code/SecMPS/doc/对接文档/MC4.0对外API.md)),List/Object 不能直接反序列化 List,必须先解析包装再取 data 段。**整改方案**(共 2 文件改动):(1)**[Mc4AuthHelper.cs:39-44](file:///d:/Code/SecMPS/gateway/src/IntegrationGateway.Adapters.MC4/Mc4AuthHelper.cs#L39-L44) 新增 JsonOpts 静态字段**——`PropertyNameCaseInsensitive = true` + `PropertyNamingPolicy = JsonNamingPolicy.CamelCase`;[line 42-44](file:///d:/Code/SecMPS/gateway/src/IntegrationGateway.Adapters.MC4/Mc4AuthHelper.cs#L42-L44) 新增 `EmptyJsonBody` 静态 `StringContent("{}", Encoding.UTF8, "application/json")` 处理无参数 POST 请求(MC4 平台要求 body 是合法 JSON,null 会返 400);[line 70](file:///d:/Code/SecMPS/gateway/src/IntegrationGateway.Adapters.MC4/Mc4AuthHelper.cs#L70) `conf/get` 改用 `EmptyJsonBody`;[line 91](file:///d:/Code/SecMPS/gateway/src/IntegrationGateway.Adapters.MC4/Mc4AuthHelper.cs#L91) `Deserialize` 改传 `JsonOpts`;[line 116](file:///d:/Code/SecMPS/gateway/src/IntegrationGateway.Adapters.MC4/Mc4AuthHelper.cs#L116) `Deserialize` 改传 `JsonOpts`;(2)**[Mc4Adapter.cs:33-56](file:///d:/Code/SecMPS/gateway/src/IntegrationGateway.Adapters.MC4/Mc4Adapter.cs#L33-L56) 新增统一 `JsonOpts` 静态字段 + `DeserializeData` 静态方法**——`DeserializeData` 用 `JsonSerializer.Deserialize>(json, JsonOpts)` 解析统一响应包装 + 自动校验 `code!=0` 抛异常 + `data` 为空返回默认值;[line 105-108](file:///d:/Code/SecMPS/gateway/src/IntegrationGateway.Adapters.MC4/Mc4Adapter.cs#L105-L108) 新增 `EmptyJsonBody`;[line 90](file:///d:/Code/SecMPS/gateway/src/IntegrationGateway.Adapters.MC4/Mc4Adapter.cs#L90) `HealthCheckAsync` 改用 `EmptyJsonBody`;[line 118](file:///d:/Code/SecMPS/gateway/src/IntegrationGateway.Adapters.MC4/Mc4Adapter.cs#L118) `GetObjectTreeAsync` 改用 `EmptyJsonBody` + `DeserializeData>`;[line 186/203/229/244/274/299](file:///d:/Code/SecMPS/gateway/src/IntegrationGateway.Adapters.MC4/Mc4Adapter.cs#L186-L299) `GetDevicesAsync/GetDevicePointsAsync/GetRealtimeValuesAsync/GetHisAlarmsAsync/GetMultiRealtimeValuesAsync/GetRealtimeAlarmsAsync` 全部改用 `DeserializeData`;新增 `Mc4ApiResponse` 统一响应包装类([line 388-394](file:///d:/Code/SecMPS/gateway/src/IntegrationGateway.Adapters.MC4/Mc4Adapter.cs#L388-L394))。**验证**:`dotnet build gateway` 0 错误 0 警告。**效果**:(a)MC4 适配器所有接口反序列化均能正确处理全小写字段 + 统一包装响应;(b)`EmptyJsonBody` 解决 POST 无参 body=null 触发 400 的问题;(c)`DeserializeData` 统一处理 code 校验,未来新增 MC4 业务接口不用每次写 `if (code != 0) throw` 模板代码 | ✅ 已完成 | | 2026-07-24 | **MC4 设备列表 400 + 反序列化错误 V1.10.1** | 主人反馈 V1.10 修完后又有 2 个连续报错:(a)`[Gateway] A3: 适配器 MC4:33ku 取设备失败: Response status code does not indicate success: 400 (Bad Request)`;(b)`[Gateway] A3: 适配器 MC4:33ku 取设备失败: The JSON value could not be converted to System.Collections.Generic.List'1[IntegrationGateway.Adapters.MC4.Mc4TreeNode]. Path: $ | LineNumber: 0 | BytePositionInLine: 1.`。**根因 2 段**:(a)**400 Bad Request**——`GetObjectTreeAsync` 用 `client.PostAsync("/api/central/object/tree", null)` 发请求,**body 为 null 不符合 MC4 平台要求**(平台要求 POST body 是合法 JSON,即使是空请求也必须是 `{}` 而非 `null`),**400 是平台校验失败**;(b)**反序列化 List 错误**——MC4 返回的是 `{code, msg, data:[...nodes]}` 包装结构,**List 不能直接反序列化为 List**,必须先解析包装再取 data 段(V1.10 已经新增了 `DeserializeData` 但 `GetObjectTreeAsync` 没改)。**整改方案**(共 1 文件改动):**[Mc4Adapter.cs:115-122](file:///d:/Code/SecMPS/gateway/src/IntegrationGateway.Adapters.MC4/Mc4Adapter.cs#L115-L122) GetObjectTreeAsync 改用 EmptyJsonBody + DeserializeData>**——`var resp = await client.PostAsync("/api/central/object/tree", EmptyJsonBody);` 替换原来的 `null`;`var tree = DeserializeData>(json, "/api/central/object/tree", new List());` 替换原来的 `JsonSerializer.Deserialize>(json, JsonOpts)!`。**验证**:`dotnet build gateway` 0 错误 0 警告。**效果**:A3 设备同步能正确拉取 MC4 对象树 → 推送到 VolPro → 写 base_device 表,**主人在 web.vite 管理端可以看到 MC4 设备列表** | ✅ 已完成 | | 2026-07-24 | **IoT 设备实时点值显示 + 空调控制 V1.11** | 主人反馈:"成功拿到设备列表并提交到了后端,但在管理端的列表里点击实时数据按钮没有任何数据显示,检查一下是什么问题,我看了下MC4设备的管理端,每个设备下面是有点值列表的,并且序号1的点是自动添加的在线点,真正的点值数据从序号2开始的,你查下文档看看怎么把正确的点值显示出来,并且有些设备不止一个点值的。同时IOT设备也需要进行分类,目前有三个分类:温度探头、湿度探头、空调控制器,这三类在后端需要不同的展示方式和控制方式你可以写点临时代码来获取一下192.168.3.92这个MC4设备的设备和点值数据,然后规划一下怎么在web.vite和warehouse中实现点值的显示和设备控制。"**整改方案**(共 5 文件改动 + 1 文档):(1)**[Mc4Adapter.cs:154](file:///d:/Code/SecMPS/gateway/src/IntegrationGateway.Adapters.MC4/Mc4Adapter.cs#L154) GetRealtimeValuesAsync 改用 DeserializeData**——修复实时点值接口反序列化问题(V1.10 漏改了 GetRealtimeValuesAsync,其他接口都改了唯独实时值没改);(2)**[base_device.vue:163-179](file:///d:/Code/SecMPS/web.vite/src/views/warehouse/device_manager/base_device.vue#L163-L179) fetchRealtime 增加 normalizePoint 字段映射**——网关 B4 返回 PascalCase 字段(PointIndex/Value/UpdateTime/Interval),前端 table 是 camelCase 列名(pointIndex/value/updateTime/interval),**之前直接 await r.json() 字段名不匹配导致表格空**;新增 `normalizePoint` 做 PascalCase→camelCase 映射 + `Array.isArray` 判断(防止 404/500 时 await r.json() 失败);(3)**[RealtimeDataPanel.vue:19-37](file:///d:/Code/SecMPS/web.vite/src/views/warehouse/device_manager/base_device/components/RealtimeDataPanel.vue#L19-L37) 同样修复**——组件层字段映射(与 base_device.vue 一致);(4)**[DeviceInfo.vue](file:///d:/Code/SecMPS/warehouse/src/view/DeviceInfo.vue) 完整改造**——改用 `/api/base_device/getPageData` 接口获取设备信息(之前调老接口 `/api/Warehouse_Device/GetPageData` 走的是 base_devicepoint 表,已废弃);按 DeviceCategory 分类展示(温度探头/湿度探头单大数字卡片 + 空调控制器双卡片);30 秒轮询实时点值(仅 IoT 设备);空调控制按钮修复——`sourceDeviceId` 改为 `deviceId`(与后端 `ControlRequest.DeviceId` 字段名一致);(5)**[mc4_probe 探针工具](file:///d:/Code/SecMPS/tools/mc4_probe/Mc4Probe/Program.cs)**——独立 .NET 控制台程序,连接 192.168.3.92 真实 MC4 设备抓取设备列表 + 点表 + 实时值,便于主人后续调试点位索引(已写入实施文档附录);(6)**[IoT设备实时点值显示与控制实施方案_v1.0.md](file:///d:/Code/SecMPS/doc/整合方案/IoT设备实时点值显示与控制实施方案_v1.0.md)**——详细方案文档(设备分类/点位映射表/3 分类展示策略/网关 B4+B5 接口/前端 5 文件改动/验证清单/风险回滚/后续优化建议)。**关键设备点位索引语义**(写入实施文档固化):温度探头/湿度探头 `index=2` 是真实数据;空调控制器 `index=5=湿度/6=温度/2=制冷发射/3=制热发射/4=关机发射`;MC4 自动在 `index=1` 添加"在线点"。**验证**:`dotnet build gateway` 0 错误;`npm run build warehouse` / `npm run build web.vite` 待主人本地验证。**效果**:(a)管理端 IoT 设备的"实时数据"按钮点击后表格正常显示点位/当前值/更新时间/采集间隔 4 列;(b)大端地图点击温度探头→大数字显示温度℃;(c)大端地图点击湿度探头→大数字显示湿度%RH;(d)大端地图点击空调控制器→温度+湿度双卡片 + 控制按钮(制冷/制热/关机);(e)空调命令 3 秒后实时数据自动刷新(控制生效验证);(f)30 秒轮询保证实时数据保持最新 | ✅ 已完成 | +| 2026-07-24 | **RuleEngineService DI 注入失败修复 V1.12** | 主人反馈:后端 VS 启动报 `System.AggregateException: Some services are not able to be constructed (Error while validating the service descriptor 'ServiceType: Warehouse.Services.RuleEngineService ...': Unable to resolve service for type 'Warehouse.Services.iot_alarmService' while attempting to activate 'Warehouse.Services.RuleEngineService'.)`。**根因 2 段**:(1)**iot_alarmService 通过框架的 IDependency 机制注册到内置 DI**——读 [AutofacContainerModuleExtension.cs:77-95](file:///d:/Code/SecMPS/api_sqlsugar/VolPro.Core/Extensions/AutofacManager/AutofacContainerModuleExtension.cs#L77-L95) 发现 `AddModule` 会扫描所有 IDependency 实现类,按 `serviceType.Length==1`(只实现 IDependency)走 `AddScoped(implementationType)` 注册具体类,否则按 `serviceType[0]`(第一个接口 = `Iiot_alarmService`)注册接口到具体类的映射。`iot_alarmService` 实现了 `Iiot_alarmService` + `IDependency` 两个接口,所以**只注册了 `Iiot_alarmService`**,**没有注册 `iot_alarmService` 具体类**;(2)**RuleEngineService 构造函数按具体类注入**——[RuleEngineService.cs:54](file:///d:/Code/SecMPS/api_sqlsugar/Warehouse/Services/RuleEngineService.cs#L54) 第 4 个参数是 `iot_alarmService alarmService`(具体类),内置 DI 解析时报 "Unable to resolve service for type 'iot_alarmService'",整个 ServiceCollection 验证失败 → 整个 BuildServiceProvider 失败 → `var app = builder.Build()` 抛 AggregateException。**整改方案**(共 1 文件改动):**[RuleEngineService.cs:44-58](file:///d:/Code/SecMPS/api_sqlsugar/Warehouse/Services/RuleEngineService.cs#L44-L58) 构造函数参数 + 字段类型由 `iot_alarmService` 改为 `Iiot_alarmService`**——(a)字段 `_alarmService` 类型改 `Iiot_alarmService`;(b)构造函数参数 `alarmService` 类型改 `Iiot_alarmService`;(c)`_alarmService = alarmService` 赋值语句不变(编译自动适配);(d)方法体内 `_alarmService.UpsertAlarmAsync(...)` 调用不变(`Iiot_alarmService` partial 接口已声明 `UpsertAlarmAsync`)。**选择"改构造函数按接口注入"而不是"在 Program.cs 显式 AddScoped"**——后者会与框架 `AddModule` 的自动注册**重复注册**(AutofacContainerModuleExtension 已经按接口注册了),**违反框架约定**;前者**符合 VolPro 框架惯例**(`gateway_nodesController.cs:40`/`iot_alarmController.cs:15`/`iot_alarmController.cs:24` 全部用 `Iiot_alarmService` 接口注入)。**验证**:`dotnet build api_sqlsugar` 0 错误。**效果**:(a)`RuleEngineService` 构造函数 DI 解析成功 → `var app = builder.Build()` 不再抛异常 → 后端可正常启动;(b)Quartz 调度 `RuleEngineJob` 启动后每 10s 调一次 `RuleEngineService.EvaluateAllAsync()`;(c)规则触发"告警"动作时 `_alarmService.UpsertAlarmAsync` 可正确调用,**告警能落库** | ✅ 已完成 | ---