From 996ac02c0b65a8ebb01bb3b0b4cd326fd980e017 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sun, 3 May 2026 23:25:40 +0800 Subject: [PATCH] =?UTF-8?q?=E3=80=90=E5=90=8C=E6=AD=A5=E3=80=91BOOT=20?= =?UTF-8?q?=E5=92=8C=20CLOUD=20=E7=9A=84=E5=8A=9F=E8=83=BD=EF=BC=88iot?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../enums/alert/IotAlertReceiveTypeEnum.java | 20 +- .../iot/enums/rule/IotDataSinkTypeEnum.java | 4 +- .../IotSceneRuleConditionOperatorEnum.java | 2 - .../iot/core/util/IotDeviceMessageUtils.java | 106 +++++-- .../core/util/IotDeviceMessageUtilsTest.java | 146 ++++++++- .../yudao-module-iot-server/pom.xml | 7 + .../config/IotAbstractDataSinkConfig.java | 1 + .../config/IotDataSinkDatabaseConfig.java | 42 +++ .../thingmodel/model/ThingModelEvent.java | 1 - .../dal/tdengine/IotDevicePropertyMapper.java | 1 + .../service/alert/IotAlertRecordService.java | 7 +- .../alert/IotAlertRecordServiceImpl.java | 16 +- .../message/IotDeviceMessageServiceImpl.java | 13 +- .../IotDevicePropertyServiceImpl.java | 26 +- .../rule/data/IotDataRuleServiceImpl.java | 65 ++++- .../action/IotDataRuleCacheableAction.java | 15 +- .../action/IotDatabaseDataRuleAction.java | 84 ++++++ .../data/action/IotMqttDataRuleAction.java | 108 +++++++ .../action/IotWebSocketDataRuleAction.java | 3 +- .../action/websocket/IotWebSocketClient.java | 12 +- .../rule/scene/IotSceneRuleTimeHelper.java | 9 +- .../IotAlertTriggerSceneRuleAction.java | 82 +++++- .../matcher/IotSceneRuleMatcherHelper.java | 23 +- .../IotDevicePropertyConditionMatcher.java | 8 +- .../IotDeviceEventPostTriggerMatcher.java | 34 ++- .../IotDeviceServiceInvokeTriggerMatcher.java | 4 +- .../mapper/device/IotDeviceMessageMapper.xml | 6 +- .../mapper/device/IotDevicePropertyMapper.xml | 2 +- .../IotDeviceMessageServiceImplTest.java | 276 ++++++++++++++++++ .../IotDevicePropertyServiceImplTest.java | 204 +++++++++++++ .../rule/data/IotDataRuleServiceImplTest.java | 235 +++++++++++++++ .../action/IotDatabaseDataRuleActionTest.java | 65 +++++ .../action/IotMqttDataRuleActionTest.java | 63 ++++ .../data/action/IotTcpDataRuleActionTest.java | 13 +- .../scene/IotSceneRuleServiceSimpleTest.java | 18 +- ...ceneRuleTimerConditionIntegrationTest.java | 9 +- .../IotAlertTriggerSceneRuleActionTest.java | 272 +++++++++++++++++ .../IotCurrentTimeConditionMatcherTest.java | 20 +- ...IotDevicePropertyConditionMatcherTest.java | 2 - .../IotDeviceStateConditionMatcherTest.java | 2 - .../IotDeviceEventPostTriggerMatcherTest.java | 2 - ...tDevicePropertyPostTriggerMatcherTest.java | 2 - ...DeviceServiceInvokeTriggerMatcherTest.java | 2 - ...otDeviceStateUpdateTriggerMatcherTest.java | 2 - .../trigger/IotTimerTriggerMatcherTest.java | 2 - .../src/test/resources/sql/clean.sql | 1 + .../src/test/resources/sql/create_tables.sql | 16 + 47 files changed, 1898 insertions(+), 155 deletions(-) create mode 100644 yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotDataSinkDatabaseConfig.java create mode 100644 yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotDatabaseDataRuleAction.java create mode 100644 yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotMqttDataRuleAction.java create mode 100644 yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImplTest.java create mode 100644 yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImplTest.java create mode 100644 yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImplTest.java create mode 100644 yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotDatabaseDataRuleActionTest.java create mode 100644 yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotMqttDataRuleActionTest.java create mode 100644 yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotAlertTriggerSceneRuleActionTest.java diff --git a/yudao-module-iot/yudao-module-iot-api/src/main/java/cn/iocoder/yudao/module/iot/enums/alert/IotAlertReceiveTypeEnum.java b/yudao-module-iot/yudao-module-iot-api/src/main/java/cn/iocoder/yudao/module/iot/enums/alert/IotAlertReceiveTypeEnum.java index d70aea5c6..c29ff2581 100644 --- a/yudao-module-iot/yudao-module-iot-api/src/main/java/cn/iocoder/yudao/module/iot/enums/alert/IotAlertReceiveTypeEnum.java +++ b/yudao-module-iot/yudao-module-iot-api/src/main/java/cn/iocoder/yudao/module/iot/enums/alert/IotAlertReceiveTypeEnum.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.iot.enums.alert; +import cn.hutool.core.util.ArrayUtil; import cn.iocoder.yudao.framework.common.core.ArrayValuable; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -15,12 +16,21 @@ import java.util.Arrays; @Getter public enum IotAlertReceiveTypeEnum implements ArrayValuable { - SMS(1), // 短信 - MAIL(2), // 邮箱 - NOTIFY(3); // 站内信 + SMS(1, "iot_alert_sms"), // 短信 + MAIL(2, "iot_alert_mail"), // 邮箱 + NOTIFY(3, "iot_alert_notify"); // 站内信 // TODO 待实现(欢迎 pull request):webhook 4 + /** + * 接收方式 + */ private final Integer type; + /** + * 模板编号 + * + * 关联 SmsTemplateDO / MailTemplateDO / NotifyTemplateDO 的 code 属性 + */ + private final String templateCode; public static final Integer[] ARRAYS = Arrays.stream(values()).map(IotAlertReceiveTypeEnum::getType).toArray(Integer[]::new); @@ -29,4 +39,8 @@ public enum IotAlertReceiveTypeEnum implements ArrayValuable { return ARRAYS; } + public static IotAlertReceiveTypeEnum of(Integer type) { + return ArrayUtil.firstMatch(item -> item.getType().equals(type), values()); + } + } diff --git a/yudao-module-iot/yudao-module-iot-api/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotDataSinkTypeEnum.java b/yudao-module-iot/yudao-module-iot-api/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotDataSinkTypeEnum.java index 96b477d69..c8f8aa09e 100644 --- a/yudao-module-iot/yudao-module-iot-api/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotDataSinkTypeEnum.java +++ b/yudao-module-iot/yudao-module-iot-api/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotDataSinkTypeEnum.java @@ -19,9 +19,9 @@ public enum IotDataSinkTypeEnum implements ArrayValuable { TCP(2, "TCP"), WEBSOCKET(3, "WebSocket"), - MQTT(10, "MQTT"), // TODO @puhui999:待实现; + MQTT(10, "MQTT"), - DATABASE(20, "Database"), // TODO @puhui999:待实现; + DATABASE(20, "Database"), REDIS(21, "Redis"), ROCKETMQ(30, "RocketMQ"), diff --git a/yudao-module-iot/yudao-module-iot-api/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotSceneRuleConditionOperatorEnum.java b/yudao-module-iot/yudao-module-iot-api/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotSceneRuleConditionOperatorEnum.java index 9bf90cff6..ae4997efa 100644 --- a/yudao-module-iot/yudao-module-iot-api/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotSceneRuleConditionOperatorEnum.java +++ b/yudao-module-iot/yudao-module-iot-api/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotSceneRuleConditionOperatorEnum.java @@ -36,13 +36,11 @@ public enum IotSceneRuleConditionOperatorEnum implements ArrayValuable { // ========== 特殊:不放在字典里 ========== - // TODO @puhui999:@芋艿:需要测试下 DATE_TIME_GREATER_THAN("date_time_>", "#source > #value"), // 在时间之后:时间戳 DATE_TIME_LESS_THAN("date_time_<", "#source < #value"), // 在时间之前:时间戳 DATE_TIME_BETWEEN("date_time_between", // 在时间之间:时间戳 "(#source >= #values.get(0)) && (#source <= #values.get(1))"), - // TODO @puhui999:@芋艿:需要测试下 TIME_GREATER_THAN("time_>", "#source.isAfter(#value)"), // 在当日时间之后:HH:mm:ss TIME_LESS_THAN("time_<", "#source.isBefore(#value)"), // 在当日时间之前:HH:mm:ss TIME_BETWEEN("time_between", // 在当日时间之间:HH:mm:ss diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtils.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtils.java index b7d9894f0..c49c4a0b9 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtils.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtils.java @@ -1,15 +1,17 @@ package cn.iocoder.yudao.module.iot.core.util; import cn.hutool.core.lang.Assert; -import cn.hutool.core.map.MapUtil; import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.ReflectUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.system.SystemUtil; import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import java.util.LinkedHashSet; import java.util.Map; +import java.util.Set; /** * IoT 设备【消息】的工具类 @@ -54,22 +56,59 @@ public class IotDeviceMessageUtils { * @param message 消息 * @return 标识符 */ - @SuppressWarnings("unchecked") public static String getIdentifier(IotDeviceMessage message) { - if (message.getParams() == null) { + if (message == null || message.getParams() == null) { return null; } + Object params = message.getParams(); if (StrUtil.equalsAny(message.getMethod(), IotDeviceMessageMethodEnum.EVENT_POST.getMethod(), IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod())) { - Map params = (Map) message.getParams(); - return MapUtil.getStr(params, "identifier"); - } else if (StrUtil.equalsAny(message.getMethod(), IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod())) { - Map params = (Map) message.getParams(); - return MapUtil.getStr(params, "state"); + return StrUtil.toStringOrNull(readField(params, "identifier")); + } else if (StrUtil.equalsAny(message.getMethod(), IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod())) { + return StrUtil.toStringOrNull(readField(params, "state")); } return null; } + /** + * 从 params 中读取字段值,兼容 Map 和 POJO(DTO)两种形态 + * + * Why:MQ 消息经 JSON 反序列化后 params 是 Map,但本地总线场景 producer 可能直接传 DTO 对象(如 IotDeviceEventPostReqDTO), + * matcher 必须同时支持两种形态,避免事件触发器在同 JVM 内部消息总线下匹配失败 + */ + private static Object readField(Object params, String fieldName) { + if (params == null) { + return null; + } + if (params instanceof Map) { + return ((Map) params).get(fieldName); + } + try { + return ReflectUtil.getFieldValue(params, fieldName); + } catch (Exception ignored) { + return null; + } + } + + /** + * 获取属性上报消息中包含的所有属性标识符 + * + * 仅支持扁平结构:{ temperature: 25.5, humidity: 60 },顶层 key 即属性标识符 + * + * @param message 设备消息 + * @return 属性标识符集合,不为 null + */ + public static Set getPropertyIdentifiers(IotDeviceMessage message) { + if (message == null) { + return new LinkedHashSet<>(); + } + Map params = parseParamsToMap(message.getParams()); + if (params == null) { + return new LinkedHashSet<>(); + } + return new LinkedHashSet<>(params.keySet()); + } + /** * 判断消息中是否包含指定的标识符 *

@@ -82,8 +121,9 @@ public class IotDeviceMessageUtils { * @param identifier 要检查的标识符 * @return 是否包含 */ + @SuppressWarnings("unchecked") public static boolean containsIdentifier(IotDeviceMessage message, String identifier) { - if (message.getParams() == null || StrUtil.isBlank(identifier)) { + if (message == null || message.getParams() == null || StrUtil.isBlank(identifier)) { return false; } // EVENT_POST / SERVICE_INVOKE / STATE_UPDATE:使用原有逻辑 @@ -91,10 +131,17 @@ public class IotDeviceMessageUtils { if (messageIdentifier != null) { return identifier.equals(messageIdentifier); } - // PROPERTY_POST:检查 params 中是否包含该属性 key + // PROPERTY_POST:检查 params 中是否包含该属性 key(支持扁平和嵌套 properties 结构) if (StrUtil.equals(message.getMethod(), IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod())) { Map params = parseParamsToMap(message.getParams()); - return params != null && params.containsKey(identifier); + if (params == null) { + return false; + } + if (params.containsKey(identifier)) { + return true; + } + Object properties = params.get("properties"); + return properties instanceof Map && ((Map) properties).containsKey(identifier); } return false; } @@ -132,9 +179,6 @@ public class IotDeviceMessageUtils { /** * 从设备消息中提取指定标识符的属性值 - * - 支持多种消息格式和属性值提取策略 - * - 兼容现有的消息结构 - * - 提供统一的属性值提取接口 *

* 支持的提取策略(按优先级顺序): * 1. 直接值:如果 params 不是 Map,直接返回该值(适用于简单消息) @@ -150,7 +194,7 @@ public class IotDeviceMessageUtils { */ @SuppressWarnings("unchecked") public static Object extractPropertyValue(IotDeviceMessage message, String identifier) { - Object params = message.getParams(); + Object params = message != null ? message.getParams() : null; if (params == null) { return null; } @@ -206,6 +250,19 @@ public class IotDeviceMessageUtils { return null; } + /** + * 从设备事件上报消息中提取事件值 + *

+ * 事件上报的 params 结构为:{"identifier": "xxx", "value": ...},事件值即 value 字段。 + * value 可能是标量(字符串/数字/布尔),也可能是结构体(如告警事件 {level, message}) + * + * @param message 设备消息 + * @return 事件值,如果未找到则返回 null + */ + public static Object extractEventValue(IotDeviceMessage message) { + return readField(message != null ? message.getParams() : null, "value"); + } + /** * 从服务调用消息中提取输入参数 *

@@ -220,23 +277,16 @@ public class IotDeviceMessageUtils { */ @SuppressWarnings("unchecked") public static Map extractServiceInputParams(IotDeviceMessage message) { - // 1. 参数校验 + if (message == null || message.getParams() == null) { + return null; + } Object params = message.getParams(); - if (params == null) { - return null; - } - if (!(params instanceof Map)) { - return null; - } - Map paramsMap = (Map) params; - - // 尝试从 inputData 字段获取 - Object inputData = paramsMap.get("inputData"); + // 兼容 Map 和 POJO(如 IotDeviceServiceInvokeReqDTO)两种 params 形态 + Object inputData = readField(params, "inputData"); if (inputData instanceof Map) { return (Map) inputData; } - // 尝试从 inputParams 字段获取 - Object inputParams = paramsMap.get("inputParams"); + Object inputParams = readField(params, "inputParams"); if (inputParams instanceof Map) { return (Map) inputParams; } diff --git a/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtilsTest.java b/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtilsTest.java index b0d39be51..a0c4650d8 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtilsTest.java +++ b/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtilsTest.java @@ -2,10 +2,12 @@ package cn.iocoder.yudao.module.iot.core.util; import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.topic.event.IotDeviceEventPostReqDTO; import org.junit.jupiter.api.Test; import java.util.HashMap; import java.util.Map; +import java.util.Set; import static org.junit.jupiter.api.Assertions.*; @@ -71,7 +73,7 @@ public class IotDeviceMessageUtilsTest { @Test public void testExtractPropertyValue_valueField() { - // 测试 value 字段 + // 测试 value 字段(策略 5) IotDeviceMessage message = new IotDeviceMessage(); Map params = new HashMap<>(); params.put("identifier", "temperature"); @@ -84,7 +86,7 @@ public class IotDeviceMessageUtilsTest { @Test public void testExtractPropertyValue_singleValueMap() { - // 测试单值 Map(包含 identifier 和一个值) + // 测试单值 Map(策略 6:包含 identifier 和一个其他字段) IotDeviceMessage message = new IotDeviceMessage(); Map params = new HashMap<>(); params.put("identifier", "temperature"); @@ -139,6 +141,88 @@ public class IotDeviceMessageUtilsTest { assertEquals(25.5, result); // 应该返回直接标识符的值 } + // ========== extractEventValue 测试 ========== + + @Test + public void testExtractEventValue_scalar() { + // 标量事件值:{identifier: "gzzt", value: "normal"} + IotDeviceMessage message = new IotDeviceMessage(); + Map params = new HashMap<>(); + params.put("identifier", "gzzt"); + params.put("value", "normal"); + message.setParams(params); + + Object result = IotDeviceMessageUtils.extractEventValue(message); + assertEquals("normal", result); + } + + @Test + public void testExtractEventValue_struct() { + // 结构体事件值:{identifier: "alarm", value: {level: "high", message: "..."}} + IotDeviceMessage message = new IotDeviceMessage(); + Map eventValue = new HashMap<>(); + eventValue.put("level", "high"); + eventValue.put("message", "over temperature"); + + Map params = new HashMap<>(); + params.put("identifier", "alarm"); + params.put("value", eventValue); + message.setParams(params); + + Object result = IotDeviceMessageUtils.extractEventValue(message); + assertEquals(eventValue, result); + } + + @Test + public void testExtractEventValue_nullParams() { + IotDeviceMessage message = new IotDeviceMessage(); + message.setParams(null); + + Object result = IotDeviceMessageUtils.extractEventValue(message); + assertNull(result); + } + + @Test + public void testExtractEventValue_paramsWithoutValueField() { + // params 是字符串等非结构化对象,无 value 字段,应返回 null + IotDeviceMessage message = new IotDeviceMessage(); + message.setParams("not a map"); + + Object result = IotDeviceMessageUtils.extractEventValue(message); + assertNull(result); + } + + @Test + public void testExtractEventValue_missingValueField() { + IotDeviceMessage message = new IotDeviceMessage(); + Map params = new HashMap<>(); + params.put("identifier", "gzzt"); + message.setParams(params); + + Object result = IotDeviceMessageUtils.extractEventValue(message); + assertNull(result); + } + + @Test + public void testExtractEventValue_pojoParams() { + // 本地总线场景:params 是 IotDeviceEventPostReqDTO POJO(未经 JSON 反序列化),应能反射取到 value + IotDeviceMessage message = new IotDeviceMessage(); + message.setParams(IotDeviceEventPostReqDTO.of("gzzt", "normal")); + + Object result = IotDeviceMessageUtils.extractEventValue(message); + assertEquals("normal", result); + } + + @Test + public void testGetIdentifier_eventPostPojoParams() { + // 本地总线场景:EVENT_POST 消息 params 是 DTO POJO,仍应能解析出 identifier + IotDeviceMessage message = new IotDeviceMessage(); + message.setMethod(IotDeviceMessageMethodEnum.EVENT_POST.getMethod()); + message.setParams(IotDeviceEventPostReqDTO.of("gzzt", "normal")); + + assertEquals("gzzt", IotDeviceMessageUtils.getIdentifier(message)); + } + // ========== notContainsIdentifier 测试 ========== /** @@ -206,4 +290,62 @@ public class IotDeviceMessageUtilsTest { assertTrue(notContainsResult); assertEquals(!containsResult, notContainsResult); } + + // ========== getPropertyIdentifiers 测试 ========== + + @Test + public void testGetPropertyIdentifiers_flatStructure() { + // 扁平结构:顶层 key 即标识符 + IotDeviceMessage message = new IotDeviceMessage(); + Map params = new HashMap<>(); + params.put("temperature", 25.5); + params.put("humidity", 60); + message.setParams(params); + + Set identifiers = IotDeviceMessageUtils.getPropertyIdentifiers(message); + assertEquals(2, identifiers.size()); + assertTrue(identifiers.contains("temperature")); + assertTrue(identifiers.contains("humidity")); + } + + @Test + public void testGetPropertyIdentifiers_nullMessage() { + // 入参为 null:返回空集合 + Set identifiers = IotDeviceMessageUtils.getPropertyIdentifiers(null); + assertNotNull(identifiers); + assertTrue(identifiers.isEmpty()); + } + + @Test + public void testGetPropertyIdentifiers_nullParams() { + // params 为 null:返回空集合 + IotDeviceMessage message = new IotDeviceMessage(); + message.setParams(null); + + Set identifiers = IotDeviceMessageUtils.getPropertyIdentifiers(message); + assertTrue(identifiers.isEmpty()); + } + + @Test + public void testGetPropertyIdentifiers_emptyParams() { + // params 为空 Map:返回空集合 + IotDeviceMessage message = new IotDeviceMessage(); + message.setParams(new HashMap<>()); + + Set identifiers = IotDeviceMessageUtils.getPropertyIdentifiers(message); + assertTrue(identifiers.isEmpty()); + } + + @Test + public void testGetPropertyIdentifiers_jsonStringParams() { + // params 为 JSON 字符串:parseParamsToMap 解析后正常提取顶层标识符 + IotDeviceMessage message = new IotDeviceMessage(); + message.setParams("{\"temperature\":25.5,\"humidity\":60}"); + + Set identifiers = IotDeviceMessageUtils.getPropertyIdentifiers(message); + assertEquals(2, identifiers.size()); + assertTrue(identifiers.contains("temperature")); + assertTrue(identifiers.contains("humidity")); + } + } diff --git a/yudao-module-iot/yudao-module-iot-server/pom.xml b/yudao-module-iot/yudao-module-iot-server/pom.xml index 35acffcb8..3f2664594 100644 --- a/yudao-module-iot/yudao-module-iot-server/pom.xml +++ b/yudao-module-iot/yudao-module-iot-server/pom.xml @@ -135,6 +135,13 @@ test + + + org.eclipse.paho + org.eclipse.paho.client.mqttv3 + true + + diff --git a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotAbstractDataSinkConfig.java b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotAbstractDataSinkConfig.java index b42e1c0a4..0b7b4fa44 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotAbstractDataSinkConfig.java +++ b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotAbstractDataSinkConfig.java @@ -20,6 +20,7 @@ import lombok.Data; @JsonSubTypes.Type(value = IotDataSinkTcpConfig.class, name = "2"), @JsonSubTypes.Type(value = IotDataSinkWebSocketConfig.class, name = "3"), @JsonSubTypes.Type(value = IotDataSinkMqttConfig.class, name = "10"), + @JsonSubTypes.Type(value = IotDataSinkDatabaseConfig.class, name = "20"), @JsonSubTypes.Type(value = IotDataSinkRedisConfig.class, name = "21"), @JsonSubTypes.Type(value = IotDataSinkRocketMQConfig.class, name = "30"), @JsonSubTypes.Type(value = IotDataSinkRabbitMQConfig.class, name = "31"), diff --git a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotDataSinkDatabaseConfig.java b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotDataSinkDatabaseConfig.java new file mode 100644 index 000000000..22abb98b4 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotDataSinkDatabaseConfig.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.iot.dal.dataobject.rule.config; + +import lombok.Data; + +/** + * IoT Database 配置 {@link IotAbstractDataSinkConfig} 实现类 + * + * 通过 JDBC 连接数据库,将设备消息写入指定表。 + * 支持 MySQL、PostgreSQL、Oracle、SQL Server、DM 达梦等数据库, + * HikariCP 会根据 JDBC URL 自动加载对应的驱动。 + * + * @author HUIHUI + */ +@Data +public class IotDataSinkDatabaseConfig extends IotAbstractDataSinkConfig { + + /** + * JDBC 连接地址 + * + * 例如:jdbc:mysql://localhost:3306/iot_data + * 例如:jdbc:postgresql://localhost:5432/iot_data + * 例如:jdbc:dm://localhost:5236/iot_data + * + * HikariCP 会根据 URL 自动检测并加载对应的 JDBC 驱动 + */ + private String jdbcUrl; + /** + * 数据库用户名 + */ + private String username; + /** + * 数据库密码 + */ + private String password; + /** + * 目标表名 + * + * 设备消息将以固定结构写入该表 + */ + private String tableName; + +} diff --git a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/thingmodel/model/ThingModelEvent.java b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/thingmodel/model/ThingModelEvent.java index 4d8537001..913ae28e1 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/thingmodel/model/ThingModelEvent.java +++ b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/thingmodel/model/ThingModelEvent.java @@ -9,7 +9,6 @@ import lombok.Data; import java.util.List; -// TODO @puhui999:感觉这个,是不是放到 dal 里会好点?(讨论下,先不改哈) /** * IoT 物模型中的事件 * diff --git a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/dal/tdengine/IotDevicePropertyMapper.java b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/dal/tdengine/IotDevicePropertyMapper.java index a43dcd765..e20aa4142 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/dal/tdengine/IotDevicePropertyMapper.java +++ b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/dal/tdengine/IotDevicePropertyMapper.java @@ -81,6 +81,7 @@ public interface IotDevicePropertyMapper { void insert(@Param("device") IotDeviceDO device, @Param("properties") Map properties, + @Param("ts") Long ts, @Param("reportTime") Long reportTime); List selectListByHistory(@Param("reqVO") IotDevicePropertyHistoryListReqVO reqVO); diff --git a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/alert/IotAlertRecordService.java b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/alert/IotAlertRecordService.java index 68a2da97c..8733b340f 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/alert/IotAlertRecordService.java +++ b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/alert/IotAlertRecordService.java @@ -5,6 +5,8 @@ import cn.iocoder.yudao.module.iot.controller.admin.alert.vo.recrod.IotAlertReco import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertConfigDO; import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertRecordDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import jakarta.annotation.Nullable; import jakarta.validation.constraints.NotNull; import java.util.Collection; @@ -58,8 +60,11 @@ public interface IotAlertRecordService { * @param config 告警配置 * @param sceneRuleId 场景规则编号 * @param deviceMessage 设备消息,可为空 + * @param device 设备信息,可为空;用于回填产品编号、设备编号 * @return 告警记录编号 */ - Long createAlertRecord(IotAlertConfigDO config, Long sceneRuleId, IotDeviceMessage deviceMessage); + @SuppressWarnings("UnusedReturnValue") + Long createAlertRecord(IotAlertConfigDO config, Long sceneRuleId, + @Nullable IotDeviceMessage deviceMessage, @Nullable IotDeviceDO device); } \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/alert/IotAlertRecordServiceImpl.java b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/alert/IotAlertRecordServiceImpl.java index 34a673a4b..1a4e353f0 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/alert/IotAlertRecordServiceImpl.java +++ b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/alert/IotAlertRecordServiceImpl.java @@ -8,7 +8,6 @@ import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertConfigDO; import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertRecordDO; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; import cn.iocoder.yudao.module.iot.dal.mysql.alert.IotAlertRecordMapper; -import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; @@ -28,9 +27,6 @@ public class IotAlertRecordServiceImpl implements IotAlertRecordService { @Resource private IotAlertRecordMapper alertRecordMapper; - @Resource - private IotDeviceService deviceService; - @Override public IotAlertRecordDO getAlertRecord(Long id) { return alertRecordMapper.selectById(id); @@ -57,20 +53,18 @@ public class IotAlertRecordServiceImpl implements IotAlertRecordService { } @Override - public Long createAlertRecord(IotAlertConfigDO config, Long sceneRuleId, IotDeviceMessage message) { + public Long createAlertRecord(IotAlertConfigDO config, Long sceneRuleId, + IotDeviceMessage message, IotDeviceDO device) { // 构建告警记录 IotAlertRecordDO.IotAlertRecordDOBuilder builder = IotAlertRecordDO.builder() .configId(config.getId()).configName(config.getName()).configLevel(config.getLevel()) .sceneRuleId(sceneRuleId).processStatus(false); if (message != null) { builder.deviceMessage(message); - // 填充设备信息 - IotDeviceDO device = deviceService.getDeviceFromCache(message.getDeviceId()); - if (device != null) { - builder.productId(device.getProductId()).deviceId(device.getId()); - } } - + if (device != null) { + builder.productId(device.getProductId()).deviceId(device.getId()); + } // 插入记录 IotAlertRecordDO record = builder.build(); alertRecordMapper.insert(record); diff --git a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImpl.java b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImpl.java index 24a5bb91b..7bfef2a06 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImpl.java +++ b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImpl.java @@ -95,7 +95,18 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { if (messageDO.getData() != null) { messageDO.setData(JsonUtils.toJsonString(messageDO.getData())); } - deviceMessageMapper.insert(messageDO); + if (messageDO.getTs() == null) { + messageDO.setTs(System.currentTimeMillis()); + } + try { + deviceMessageMapper.insert(messageDO); + } catch (Exception ex) { + // 特殊:@Async 方法的异常默认会被 handler 吞掉,这里显式记录便于排查 + log.error("[createDeviceLogAsync][消息日志写入失败 deviceId({}) messageId({}) paramsLen({}) dataLen({})]", + messageDO.getDeviceId(), messageDO.getId(), + StrUtil.length((String) messageDO.getParams()), + StrUtil.length((String) messageDO.getData()), ex); + } } @Override diff --git a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImpl.java b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImpl.java index afc90429b..93c73f576 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImpl.java +++ b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImpl.java @@ -4,6 +4,7 @@ import cn.hutool.core.collection.CollUtil; import cn.hutool.core.convert.Convert; import cn.hutool.core.date.LocalDateTimeUtil; import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.ObjUtil; import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; @@ -149,33 +150,38 @@ public class IotDevicePropertyServiceImpl implements IotDevicePropertyService { List thingModels = thingModelService.getThingModelListByProductIdFromCache(device.getProductId()); Map properties = new HashMap<>(); params.forEach((key, value) -> { - IotThingModelDO thingModel = CollUtil.findOne(thingModels, o -> o.getIdentifier().equals(key)); + // 忽略大小写匹配物模型,避免设备上报的 key 与 identifier 大小写不一致导致丢失 + IotThingModelDO thingModel = CollUtil.findOne(thingModels, + o -> StrUtil.equalsIgnoreCase(o.getIdentifier(), (CharSequence) key)); if (thingModel == null || thingModel.getProperty() == null) { log.error("[saveDeviceProperty][消息({}) 的属性({}) 不存在]", message, key); return; } + String identifier = thingModel.getIdentifier(); // 统一以物模型 identifier 作为 key,避免大小写问题 String dataType = thingModel.getProperty().getDataType(); if (ObjectUtils.equalsAny(dataType, IotDataSpecsDataTypeEnum.STRUCT.getDataType(), IotDataSpecsDataTypeEnum.ARRAY.getDataType())) { // 特殊:STRUCT 和 ARRAY 类型,在 TDengine 里,有没对应数据类型,只能通过 JSON 来存储 - properties.put((String) key, JsonUtils.toJsonString(value)); + properties.put(identifier, JsonUtils.toJsonString(value)); } else if (IotDataSpecsDataTypeEnum.INT.getDataType().equals(dataType)) { - properties.put((String) key, Convert.toInt(value)); + properties.put(identifier, Convert.toInt(value)); } else if (IotDataSpecsDataTypeEnum.FLOAT.getDataType().equals(dataType)) { - properties.put((String) key, Convert.toFloat(value)); + properties.put(identifier, Convert.toFloat(value)); } else if (IotDataSpecsDataTypeEnum.DOUBLE.getDataType().equals(dataType)) { - properties.put((String) key, Convert.toDouble(value)); - } else if (IotDataSpecsDataTypeEnum.BOOL.getDataType().equals(dataType)) { - properties.put((String) key, Convert.toByte(value)); - } else { - properties.put((String) key, value); + properties.put(identifier, Convert.toDouble(value)); + } else if (IotDataSpecsDataTypeEnum.BOOL.getDataType().equals(dataType)) { + properties.put(identifier, Convert.toBool(value, false) ? (byte) 1 : (byte) 0); + } else { + properties.put(identifier, value); } }); if (CollUtil.isEmpty(properties)) { log.error("[saveDeviceProperty][消息({}) 没有合法的属性]", message); } else { // 2.1 保存设备属性【数据】 - devicePropertyMapper.insert(device, properties, LocalDateTimeUtil.toEpochMilli(message.getReportTime())); + devicePropertyMapper.insert(device, properties, + System.currentTimeMillis(), + LocalDateTimeUtil.toEpochMilli(message.getReportTime())); // 2.2 保存设备属性【日志】 Map properties2 = convertMap(properties.entrySet(), Map.Entry::getKey, entry -> diff --git a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImpl.java b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImpl.java index ed52067cc..ccde54ec8 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImpl.java +++ b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImpl.java @@ -10,6 +10,7 @@ import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; import cn.iocoder.yudao.framework.common.util.spring.SpringUtils; import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.rule.IotDataRulePageReqVO; import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.rule.IotDataRuleSaveReqVO; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; @@ -212,34 +213,76 @@ public class IotDataRuleServiceImpl implements IotDataRuleService { @Override public void executeDataRule(IotDeviceMessage message) { try { - // 1. 获取匹配的数据流转规则 Long deviceId = message.getDeviceId(); String method = message.getMethod(); - String identifier = IotDeviceMessageUtils.getIdentifier(message); - List rules = getSelf().getDataRuleListByConditionFromCache(deviceId, method, identifier); - if (CollUtil.isEmpty(rules)) { + // 1. 匹配命中的规则 + List matchedRules; + Object identifierForLog; + if (IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod().equals(method)) { + // 属性上报:params 含多个属性 key,每个 key 都可能命中规则 + Set identifiers = IotDeviceMessageUtils.getPropertyIdentifiers(message); + matchedRules = matchPropertyPostDataRules(deviceId, method, identifiers); + identifierForLog = identifiers; + } else { + // 其他消息(事件 / 服务调用 / 状态):单一 identifier + String identifier = IotDeviceMessageUtils.getIdentifier(message); + matchedRules = getSelf().getDataRuleListByConditionFromCache(deviceId, method, identifier); + identifierForLog = identifier; + } + if (CollUtil.isEmpty(matchedRules)) { log.debug("[executeDataRule][设备({}) 方法({}) 标识符({}) 没有匹配的数据流转规则]", - deviceId, method, identifier); + deviceId, method, identifierForLog); return; } log.info("[executeDataRule][设备({}) 方法({}) 标识符({}) 匹配到 {} 条数据流转规则]", - deviceId, method, identifier, rules.size()); + deviceId, method, identifierForLog, matchedRules.size()); - // 2. 遍历规则,执行数据流转 - rules.forEach(rule -> executeDataRule(message, rule)); + // 2. 跨规则去重 sink,避免多条规则命中同一数据目的时重复推送 + Set processedSinkIds = new HashSet<>(); + matchedRules.forEach(rule -> executeDataRule(message, rule, processedSinkIds)); } catch (Exception e) { log.error("[executeDataRule][消息({}) 执行数据流转规则异常]", message, e); } } + /** + * 匹配属性上报场景下命中的数据流转规则 + * + * 同一规则可能同时匹配「任意属性」与具体 identifier,按 ruleId 去重后返回 + * + * @param deviceId 设备编号 + * @param method 消息方法 + * @param identifiers 上报消息中包含的属性标识符集合 + * @return 命中的数据流转规则列表 + */ + private List matchPropertyPostDataRules(Long deviceId, String method, Set identifiers) { + LinkedHashMap matchedRuleMap = new LinkedHashMap<>(); + // 情况一:先匹配未填 identifier 的「任意属性」规则,默认就匹配 + collectMatchedRules(matchedRuleMap, deviceId, method, null); + // 情况二:再针对每个上报的属性标识符匹配限定具体 identifier 的规则 + identifiers.forEach(identifier -> collectMatchedRules(matchedRuleMap, deviceId, method, identifier)); + return new ArrayList<>(matchedRuleMap.values()); + } + + private void collectMatchedRules(Map matchedRuleMap, + Long deviceId, String method, String identifier) { + getSelf().getDataRuleListByConditionFromCache(deviceId, method, identifier) + .forEach(rule -> matchedRuleMap.putIfAbsent(rule.getId(), rule)); + } + /** * 为指定规则的所有数据目的执行数据流转 * - * @param message 设备消息 - * @param rule 数据流转规则 + * @param message 设备消息 + * @param rule 数据流转规则 + * @param processedSinkIds 已处理的数据目的编号集合,跨规则去重,避免同一数据目的被重复推送 */ - private void executeDataRule(IotDeviceMessage message, IotDataRuleDO rule) { + private void executeDataRule(IotDeviceMessage message, IotDataRuleDO rule, Set processedSinkIds) { rule.getSinkIds().forEach(sinkId -> { + // 同一消息下,多条规则命中同一数据目的时只推送一次 + if (!processedSinkIds.add(sinkId)) { + return; + } try { // 获取数据目的配置 IotDataSinkDO dataSink = dataSinkService.getDataSinkFromCache(sinkId); diff --git a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotDataRuleCacheableAction.java b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotDataRuleCacheableAction.java index cc282e1b8..4739743c0 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotDataRuleCacheableAction.java +++ b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotDataRuleCacheableAction.java @@ -12,9 +12,6 @@ import lombok.extern.slf4j.Slf4j; import java.time.Duration; -// TODO @芋艿:数据库 -// TODO @芋艿:mqtt - /** * 可缓存的 {@link IotDataRuleAction} 抽象实现 * @@ -77,6 +74,18 @@ public abstract class IotDataRuleCacheableAction implements Io return PRODUCER_CACHE.get(config); } + /** + * 使指定配置的 Producer 缓存失效 + * + * 当子类检测到 Producer 不可用时(如连接断开),可调用此方法踢出缓存, + * 下次 {@link #getProducer(Object)} 调用将触发 {@link #initProducer(Object)} 重新创建。 + * + * @param config 配置信息 + */ + protected void invalidateProducer(Config config) { + PRODUCER_CACHE.invalidate(config); + } + /** * 初始化生产者 * diff --git a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotDatabaseDataRuleAction.java b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotDatabaseDataRuleAction.java new file mode 100644 index 000000000..8168df47a --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotDatabaseDataRuleAction.java @@ -0,0 +1,84 @@ +package cn.iocoder.yudao.module.iot.service.rule.data.action; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkDatabaseConfig; +import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Database 的 {@link IotDataRuleAction} 实现类 + * + * 通过 JDBC 连接数据库,将设备消息写入指定表。 + * 支持 MySQL、PostgreSQL、Oracle、SQL Server、DM 达梦等数据库, + * HikariCP 会根据 JDBC URL 自动加载对应的驱动。 + * + * @author HUIHUI + */ +@Component +@Slf4j +public class IotDatabaseDataRuleAction extends + IotDataRuleCacheableAction { + + @Override + public Integer getType() { + return IotDataSinkTypeEnum.DATABASE.getType(); + } + + @Override + public void execute(IotDeviceMessage message, IotDataSinkDatabaseConfig config) throws Exception { + try { + // 1. 获取或创建 JdbcTemplate + JdbcTemplate jdbcTemplate = getProducer(config); + + // 2. 构建并执行 INSERT SQL + String sql = StrUtil.format( + "INSERT INTO {} (id, device_id, tenant_id, method, report_time, data, create_time) VALUES (?, ?, ?, ?, ?, ?, NOW())", + config.getTableName()); + String messageJson = JsonUtils.toJsonString(message); + jdbcTemplate.update(sql, + message.getId(), + message.getDeviceId(), + message.getTenantId(), + message.getMethod(), + message.getReportTime(), + messageJson); + log.info("[execute][message({}) config({}) 写入数据库成功,table: {}]", + message.getId(), config, config.getTableName()); + } catch (Exception e) { + log.error("[execute][message({}) config({}) 写入数据库失败]", message, config, e); + throw e; + } + } + + @Override + protected JdbcTemplate initProducer(IotDataSinkDatabaseConfig config) throws Exception { + // 使用 HikariCP 连接池,HikariCP 会根据 JDBC URL 自动检测并加载对应的数据库驱动 + HikariConfig hikariConfig = new HikariConfig(); + hikariConfig.setJdbcUrl(config.getJdbcUrl()); + hikariConfig.setUsername(config.getUsername()); + hikariConfig.setPassword(config.getPassword()); + // 连接池配置 + hikariConfig.setMaximumPoolSize(5); // 数据流转场景,不需要太多连接 + hikariConfig.setMinimumIdle(1); + hikariConfig.setConnectionTimeout(10000); // 连接超时 10 秒 + hikariConfig.setIdleTimeout(300000); // 空闲超时 5 分钟 + hikariConfig.setMaxLifetime(600000); // 最大生命周期 10 分钟 + HikariDataSource dataSource = new HikariDataSource(hikariConfig); + log.info("[initProducer][数据库连接池创建成功,jdbcUrl: {}]", config.getJdbcUrl()); + return new JdbcTemplate(dataSource); + } + + @Override + protected void closeProducer(JdbcTemplate producer) throws Exception { + if (producer.getDataSource() instanceof HikariDataSource) { + ((HikariDataSource) producer.getDataSource()).close(); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotMqttDataRuleAction.java b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotMqttDataRuleAction.java new file mode 100644 index 000000000..2e5d896f9 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotMqttDataRuleAction.java @@ -0,0 +1,108 @@ +package cn.iocoder.yudao.module.iot.service.rule.data.action; + +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkMqttConfig; +import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttConnectOptions; +import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; + +/** + * MQTT 的 {@link IotDataRuleAction} 实现类 + * + * @author HUIHUI + */ +@ConditionalOnClass(name = "org.eclipse.paho.client.mqttv3.MqttClient") +@Component +@Slf4j +public class IotMqttDataRuleAction extends + IotDataRuleCacheableAction { + + /** + * 默认 QoS 等级(至少一次) + */ + private static final int DEFAULT_QOS = 1; + + @Override + public Integer getType() { + return IotDataSinkTypeEnum.MQTT.getType(); + } + + @Override + public void execute(IotDeviceMessage message, IotDataSinkMqttConfig config) throws Exception { + try { + // 1. 获取或创建 MqttClient + MqttClient mqttClient = getProducer(config); + + // 2.1 检查连接状态,如果断开则踢出缓存并重新创建 + if (!mqttClient.isConnected()) { + log.warn("[execute][MQTT 连接已断开,重新创建客户端,服务器: {}]", config.getUrl()); + invalidateProducer(config); // 踢出旧的断连客户端,触发 closeProducer + mqttClient = getProducer(config); // 触发 initProducer 创建全新连接 + } + + // 2.2 构建并发送消息 + MqttMessage mqttMessage = new MqttMessage(JsonUtils.toJsonString(message).getBytes(StandardCharsets.UTF_8)); + mqttMessage.setQos(DEFAULT_QOS); + mqttClient.publish(config.getTopic(), mqttMessage); + log.info("[execute][message({}) 发送成功,MQTT 服务器: {},topic: {}]", + message.getId(), config.getUrl(), config.getTopic()); + } catch (Exception e) { + log.error("[execute][message({}) 发送失败,MQTT 服务器: {}]", + message.getId(), config.getUrl(), e); + throw e; + } + } + + @Override + protected MqttClient initProducer(IotDataSinkMqttConfig config) throws Exception { + // 1. 创建 MqttClient,使用内存持久化 + // 拼接时间戳后缀,避免多个规则指向同一 Broker 时 clientId 冲突 + String clientId = config.getClientId() + "_" + System.currentTimeMillis(); + MqttClient mqttClient = new MqttClient(config.getUrl(), clientId, new MemoryPersistence()); + + // 2. 连接到 MQTT Broker + mqttClient.connect(buildConnectOptions(config)); + log.info("[initProducer][MQTT 客户端创建并连接成功,服务器: {},clientId: {}]", + config.getUrl(), clientId); + return mqttClient; + } + + @Override + protected void closeProducer(MqttClient producer) throws Exception { + if (producer.isConnected()) { + producer.disconnect(); + } + producer.close(); + } + + /** + * 构建 MQTT 连接选项 + * + * @param config MQTT 配置 + * @return 连接选项 + */ + private MqttConnectOptions buildConnectOptions(IotDataSinkMqttConfig config) { + MqttConnectOptions options = new MqttConnectOptions(); + options.setCleanSession(true); + options.setConnectionTimeout(10); // 连接超时 10 秒 + options.setKeepAliveInterval(20); // 心跳间隔 20 秒 + // 注意:不开启 automaticReconnect,由 execute() 中的 isConnected() 手动控制重连,避免竞争 + // 设置认证信息(如果有) + if (config.getUsername() != null) { + options.setUserName(config.getUsername()); + } + if (config.getPassword() != null) { + options.setPassword(config.getPassword().toCharArray()); + } + return options; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotWebSocketDataRuleAction.java b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotWebSocketDataRuleAction.java index 747164243..ea473ac05 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotWebSocketDataRuleAction.java +++ b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotWebSocketDataRuleAction.java @@ -97,10 +97,11 @@ public class IotWebSocketDataRuleAction extends } } - // TODO @puhui999:为什么这里要加锁呀? /** * 使用锁进行重连,保证同一服务器地址的重连操作线程安全 * + * 加锁原因:多线程并发发送消息时,多个线程可能同时检测到连接断开并尝试重连,使用锁 + 双重检查保证同一服务器地址只有一个线程执行重连操作 + * * @param webSocketClient WebSocket 客户端 * @param config 配置信息 */ diff --git a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/websocket/IotWebSocketClient.java b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/websocket/IotWebSocketClient.java index 8eba72373..0eacb4279 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/websocket/IotWebSocketClient.java +++ b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/websocket/IotWebSocketClient.java @@ -33,6 +33,13 @@ public class IotWebSocketClient { private volatile WebSocket webSocket; private final AtomicBoolean connected = new AtomicBoolean(false); + /** + * WebSocket 正常关闭状态码 + * + * @see RFC 6455 - 定义的状态码 + */ + private static final int NORMAL_CLOSURE_STATUS = 1000; + public IotWebSocketClient(String serverUrl, Integer connectTimeoutMs, Integer sendTimeoutMs, String dataFormat) { this.serverUrl = serverUrl; this.connectTimeoutMs = connectTimeoutMs != null ? connectTimeoutMs : IotDataSinkWebSocketConfig.DEFAULT_CONNECT_TIMEOUT_MS; @@ -123,9 +130,8 @@ public class IotWebSocketClient { public void close() { try { if (webSocket != null) { - // 发送正常关闭帧,状态码 1000 表示正常关闭 - // TODO @puhui999:有没 1000 的枚举哈?在 okhttp 里 - webSocket.close(1000, "客户端主动关闭"); + // 发送正常关闭帧 + webSocket.close(NORMAL_CLOSURE_STATUS, "客户端主动关闭"); webSocket = null; } if (okHttpClient != null) { diff --git a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleTimeHelper.java b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleTimeHelper.java index df1ac239b..01ddd5775 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleTimeHelper.java +++ b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleTimeHelper.java @@ -6,6 +6,7 @@ import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum; import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.condition.IotCurrentTimeConditionMatcher; import cn.iocoder.yudao.module.iot.service.rule.scene.timer.IotTimerConditionEvaluator; +import lombok.experimental.UtilityClass; import lombok.extern.slf4j.Slf4j; import java.time.LocalDateTime; @@ -22,6 +23,7 @@ import java.util.List; * @author HUIHUI */ @Slf4j +@UtilityClass public class IotSceneRuleTimeHelper { /** @@ -34,11 +36,6 @@ public class IotSceneRuleTimeHelper { */ private static final DateTimeFormatter TIME_FORMATTER_SHORT = DateTimeFormatter.ofPattern("HH:mm"); - // TODO @puhui999:可以使用 lombok 简化 - private IotSceneRuleTimeHelper() { - // 工具类,禁止实例化 - } - /** * 判断是否为日期时间操作符 * @@ -136,7 +133,6 @@ public class IotSceneRuleTimeHelper { } long startTimestamp = Long.parseLong(timestampRange.get(0).trim()); long endTimestamp = Long.parseLong(timestampRange.get(1).trim()); - // TODO @puhui999:hutool 里,看看有没 between 方法 return currentTimestamp >= startTimestamp && currentTimestamp <= endTimestamp; } @@ -188,7 +184,6 @@ public class IotSceneRuleTimeHelper { } LocalTime startTime = parseTime(timeRange.get(0).trim()); LocalTime endTime = parseTime(timeRange.get(1).trim()); - // TODO @puhui999:hutool 里,看看有没 between 方法 return !currentTime.isBefore(startTime) && !currentTime.isAfter(endTime); } diff --git a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotAlertTriggerSceneRuleAction.java b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotAlertTriggerSceneRuleAction.java index 5ff4a61dd..bc264366f 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotAlertTriggerSceneRuleAction.java +++ b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotAlertTriggerSceneRuleAction.java @@ -1,21 +1,34 @@ package cn.iocoder.yudao.module.iot.service.rule.scene.action; import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.date.DatePattern; +import cn.hutool.core.date.LocalDateTimeUtil; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.dict.core.DictFrameworkUtils; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertConfigDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.enums.DictTypeConstants; +import cn.iocoder.yudao.module.iot.enums.alert.IotAlertReceiveTypeEnum; import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleActionTypeEnum; import cn.iocoder.yudao.module.iot.service.alert.IotAlertConfigService; import cn.iocoder.yudao.module.iot.service.alert.IotAlertRecordService; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; import cn.iocoder.yudao.module.system.api.mail.MailSendApi; +import cn.iocoder.yudao.module.system.api.mail.dto.MailSendSingleToUserReqDTO; import cn.iocoder.yudao.module.system.api.notify.NotifyMessageSendApi; +import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDTO; import cn.iocoder.yudao.module.system.api.sms.SmsSendApi; +import cn.iocoder.yudao.module.system.api.sms.dto.send.SmsSendSingleToUserReqDTO; +import jakarta.annotation.Nullable; import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import javax.annotation.Nullable; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * IoT 告警触发的 {@link IotSceneRuleAction} 实现类 @@ -23,12 +36,15 @@ import java.util.List; * @author 芋道源码 */ @Component +@Slf4j public class IotAlertTriggerSceneRuleAction implements IotSceneRuleAction { @Resource private IotAlertConfigService alertConfigService; @Resource private IotAlertRecordService alertRecordService; + @Resource + private IotDeviceService deviceService; @Resource private SmsSendApi smsSendApi; @@ -45,19 +61,67 @@ public class IotAlertTriggerSceneRuleAction implements IotSceneRuleAction { if (CollUtil.isEmpty(alertConfigs)) { return; } + // 获得设备信息 + IotDeviceDO device = message != null ? deviceService.getDeviceFromCache(message.getDeviceId()) : null; alertConfigs.forEach(alertConfig -> { - // 记录告警记录,传递场景规则ID - alertRecordService.createAlertRecord(alertConfig, rule.getId(), message); + // 创建告警记录 + alertRecordService.createAlertRecord(alertConfig, rule.getId(), message, device); // 发送告警消息 - sendAlertMessage(alertConfig, message); + sendAlertMessage(alertConfig, message, device); }); } - private void sendAlertMessage(IotAlertConfigDO config, IotDeviceMessage deviceMessage) { - // TODO @芋艿:等场景联动开发完,再实现 - // TODO @芋艿:短信 - // TODO @芋艿:邮箱 - // TODO @芋艿:站内信 + private void sendAlertMessage(IotAlertConfigDO config, + @Nullable IotDeviceMessage deviceMessage, + @Nullable IotDeviceDO device) { + if (CollUtil.isEmpty(config.getReceiveUserIds()) || CollUtil.isEmpty(config.getReceiveTypes())) { + return; + } + Map templateParams = buildTemplateParams(config, deviceMessage, device); + config.getReceiveUserIds().forEach(userId -> + config.getReceiveTypes().forEach(receiveType -> sendAlertMessageToUser(userId, receiveType, templateParams))); + } + + /** + * 按指定接收方式,给单个用户发送告警消息 + */ + private void sendAlertMessageToUser(Long userId, Integer receiveType, Map templateParams) { + IotAlertReceiveTypeEnum typeEnum = IotAlertReceiveTypeEnum.of(receiveType); + if (typeEnum == null) { + return; + } + try { + switch (typeEnum) { + case SMS: + smsSendApi.sendSingleSmsToAdmin(new SmsSendSingleToUserReqDTO().setUserId(userId) + .setTemplateCode(typeEnum.getTemplateCode()).setTemplateParams(templateParams)); + break; + case MAIL: + mailSendApi.sendSingleMailToAdmin(new MailSendSingleToUserReqDTO().setUserId(userId) + .setTemplateCode(typeEnum.getTemplateCode()).setTemplateParams(templateParams)); + break; + case NOTIFY: + notifyMessageSendApi.sendSingleMessageToAdmin(new NotifySendSingleToUserReqDTO().setUserId(userId) + .setTemplateCode(typeEnum.getTemplateCode()).setTemplateParams(templateParams)); + break; + } + } catch (Exception ex) { + log.error("[sendAlertMessageToUser][用户({}) 模板参数({}) 发送 {} 告警失败]", + userId, templateParams, typeEnum, ex); + } + } + + private Map buildTemplateParams(IotAlertConfigDO config, + @Nullable IotDeviceMessage deviceMessage, + @Nullable IotDeviceDO device) { + Map params = new HashMap<>(); + params.put("configName", config.getName()); + params.put("configDescription", config.getDescription()); + params.put("configLevel", DictFrameworkUtils.parseDictDataLabel(DictTypeConstants.ALERT_LEVEL, config.getLevel())); + params.put("deviceName", device != null ? device.getDeviceName() : null); + params.put("reportTime", deviceMessage != null + ? LocalDateTimeUtil.format(deviceMessage.getReportTime(), DatePattern.NORM_DATETIME_PATTERN) : null); + return params; } @Override diff --git a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/IotSceneRuleMatcherHelper.java b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/IotSceneRuleMatcherHelper.java index 937add3bb..5d62bab91 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/IotSceneRuleMatcherHelper.java +++ b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/IotSceneRuleMatcherHelper.java @@ -142,6 +142,7 @@ public final class IotSceneRuleMatcherHelper { * @param trigger 触发器配置 * @return 是否有效 */ + @SuppressWarnings("BooleanMethodIsAlwaysInverted") public static boolean isBasicTriggerValid(IotSceneRuleDO.Trigger trigger) { return trigger != null && trigger.getType() != null; } @@ -152,6 +153,7 @@ public final class IotSceneRuleMatcherHelper { * @param trigger 触发器配置 * @return 是否有效 */ + @SuppressWarnings("BooleanMethodIsAlwaysInverted") public static boolean isTriggerOperatorAndValueValid(IotSceneRuleDO.Trigger trigger) { return StrUtil.isNotBlank(trigger.getOperator()) && StrUtil.isNotBlank(trigger.getValue()); } @@ -163,7 +165,9 @@ public final class IotSceneRuleMatcherHelper { * @param trigger 触发器配置 */ public static void logTriggerMatchSuccess(IotDeviceMessage message, IotSceneRuleDO.Trigger trigger) { - log.debug("[isMatched][message({}) trigger({}) 匹配触发器成功]", message.getRequestId(), trigger.getType()); + log.debug("[isMatched][message({}) trigger({}) 匹配触发器成功]", + message != null ? message.getRequestId() : null, + trigger != null ? trigger.getType() : null); } /** @@ -174,7 +178,10 @@ public final class IotSceneRuleMatcherHelper { * @param reason 失败原因 */ public static void logTriggerMatchFailure(IotDeviceMessage message, IotSceneRuleDO.Trigger trigger, String reason) { - log.debug("[isMatched][message({}) trigger({}) reason({}) 匹配触发器失败]", message.getRequestId(), trigger.getType(), reason); + log.debug("[isMatched][message({}) trigger({}) reason({}) 匹配触发器失败]", + message != null ? message.getRequestId() : null, + trigger != null ? trigger.getType() : null, + reason); } // ========== 【条件】相关工具方法 ========== @@ -185,6 +192,7 @@ public final class IotSceneRuleMatcherHelper { * @param condition 触发条件 * @return 是否有效 */ + @SuppressWarnings("BooleanMethodIsAlwaysInverted") public static boolean isBasicConditionValid(IotSceneRuleDO.TriggerCondition condition) { return condition != null && condition.getType() != null; } @@ -195,6 +203,7 @@ public final class IotSceneRuleMatcherHelper { * @param condition 触发条件 * @return 是否有效 */ + @SuppressWarnings("BooleanMethodIsAlwaysInverted") public static boolean isConditionOperatorAndParamValid(IotSceneRuleDO.TriggerCondition condition) { return StrUtil.isNotBlank(condition.getOperator()) && StrUtil.isNotBlank(condition.getParam()); } @@ -206,7 +215,9 @@ public final class IotSceneRuleMatcherHelper { * @param condition 触发条件 */ public static void logConditionMatchSuccess(IotDeviceMessage message, IotSceneRuleDO.TriggerCondition condition) { - log.debug("[isMatched][message({}) condition({}) 匹配条件成功]", message.getRequestId(), condition.getType()); + log.debug("[isMatched][message({}) condition({}) 匹配条件成功]", + message != null ? message.getRequestId() : null, + condition != null ? condition.getType() : null); } /** @@ -217,7 +228,10 @@ public final class IotSceneRuleMatcherHelper { * @param reason 失败原因 */ public static void logConditionMatchFailure(IotDeviceMessage message, IotSceneRuleDO.TriggerCondition condition, String reason) { - log.debug("[isMatched][message({}) condition({}) reason({}) 匹配条件失败]", message.getRequestId(), condition.getType(), reason); + log.debug("[isMatched][message({}) condition({}) reason({}) 匹配条件失败]", + message != null ? message.getRequestId() : null, + condition != null ? condition.getType() : null, + reason); } // ========== 【通用】工具方法 ========== @@ -229,6 +243,7 @@ public final class IotSceneRuleMatcherHelper { * @param actualIdentifier 实际的标识符 * @return 是否匹配 */ + @SuppressWarnings("BooleanMethodIsAlwaysInverted") public static boolean isIdentifierMatched(String expectedIdentifier, String actualIdentifier) { return StrUtil.isNotBlank(expectedIdentifier) && expectedIdentifier.equals(actualIdentifier); } diff --git a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/IotDevicePropertyConditionMatcher.java b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/IotDevicePropertyConditionMatcher.java index c130c5543..5741b95a6 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/IotDevicePropertyConditionMatcher.java +++ b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/IotDevicePropertyConditionMatcher.java @@ -30,10 +30,10 @@ public class IotDevicePropertyConditionMatcher implements IotSceneRuleConditionM return false; } - // 1.2 检查标识符是否匹配 - String messageIdentifier = IotDeviceMessageUtils.getIdentifier(message); - if (!IotSceneRuleMatcherHelper.isIdentifierMatched(condition.getIdentifier(), messageIdentifier)) { - IotSceneRuleMatcherHelper.logConditionMatchFailure(message, condition, "标识符不匹配,期望: " + condition.getIdentifier() + ", 实际: " + messageIdentifier); + // 1.2 检查消息中是否包含条件指定的属性标识符 + // 注意:属性上报可能同时上报多个属性,所以需要判断 condition.getIdentifier() 是否在 message 的 params 中 + if (IotDeviceMessageUtils.notContainsIdentifier(message, condition.getIdentifier())) { + IotSceneRuleMatcherHelper.logConditionMatchFailure(message, condition, "消息中不包含属性: " + condition.getIdentifier()); return false; } diff --git a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceEventPostTriggerMatcher.java b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceEventPostTriggerMatcher.java index c8c08831e..d997e46e1 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceEventPostTriggerMatcher.java +++ b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceEventPostTriggerMatcher.java @@ -1,14 +1,20 @@ package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.trigger; import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum; import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherHelper; import org.springframework.stereotype.Component; +import java.util.Collection; +import java.util.Map; +import java.util.Objects; + /** * 设备事件上报触发器匹配器:处理设备事件上报的触发器匹配逻辑 * @@ -45,16 +51,22 @@ public class IotDeviceEventPostTriggerMatcher implements IotSceneRuleTriggerMatc return false; } - // 2. 对于事件触发器,通常不需要检查操作符和值,只要事件发生即匹配 - // 但如果配置了操作符和值,则需要进行条件匹配 + // 2. 对于事件触发器,通常不需要检查操作符和值,只要事件发生即匹配。但如果配置了操作符和值,则需要进行条件匹配 if (StrUtil.isNotBlank(trigger.getOperator()) && StrUtil.isNotBlank(trigger.getValue())) { - Object eventParams = message.getParams(); - if (eventParams == null) { - IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "消息中事件参数为空"); + Object eventValue = IotDeviceMessageUtils.extractEventValue(message); + if (eventValue == null) { + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "消息中事件值为空"); return false; } - boolean matched = IotSceneRuleMatcherHelper.evaluateCondition(eventParams, trigger.getOperator(), trigger.getValue()); + boolean matched; + if (eventValue instanceof Map || eventValue instanceof Collection) { + // 结构体/数组事件值:把比较值按 JSON 解析后整体相等比较;HashMap.equals 与 key 顺序无关;仅支持 = / != + matched = matchStructuredEventValue(eventValue, trigger); + } else { + // 标量事件值(字符串/数字/布尔):走 SpEL,支持 = != > < 等运算 + matched = IotSceneRuleMatcherHelper.evaluateCondition(eventValue, trigger.getOperator(), trigger.getValue()); + } if (!matched) { IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "事件数据条件不匹配"); return false; @@ -65,6 +77,16 @@ public class IotDeviceEventPostTriggerMatcher implements IotSceneRuleTriggerMatc return true; } + private boolean matchStructuredEventValue(Object eventValue, IotSceneRuleDO.Trigger trigger) { + // 比较值非合法 JSON 时返回 null,结构体场景下视为不匹配 + Object expected = JsonUtils.parseObjectQuietly(trigger.getValue(), Object.class); + if (expected == null) { + return false; + } + boolean equal = Objects.equals(eventValue, expected); + return IotSceneRuleConditionOperatorEnum.NOT_EQUALS.getOperator().equals(trigger.getOperator()) != equal; + } + @Override public int getPriority() { return 30; // 中等优先级 diff --git a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceServiceInvokeTriggerMatcher.java b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceServiceInvokeTriggerMatcher.java index 642fb5ecb..842d08125 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceServiceInvokeTriggerMatcher.java +++ b/yudao-module-iot/yudao-module-iot-server/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceServiceInvokeTriggerMatcher.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.trigger; +import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; @@ -73,8 +74,7 @@ public class IotDeviceServiceInvokeTriggerMatcher implements IotSceneRuleTrigger private boolean matchParameterCondition(IotDeviceMessage message, IotSceneRuleDO.Trigger trigger) { // 1.1 从消息中提取服务调用的输入参数 Map inputParams = IotDeviceMessageUtils.extractServiceInputParams(message); - // TODO @puhui999:要考虑 empty 的情况么? - if (inputParams == null) { + if (CollUtil.isEmpty(inputParams)) { IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "消息中缺少服务输入参数"); return false; } diff --git a/yudao-module-iot/yudao-module-iot-server/src/main/resources/mapper/device/IotDeviceMessageMapper.xml b/yudao-module-iot/yudao-module-iot-server/src/main/resources/mapper/device/IotDeviceMessageMapper.xml index deef23b5c..1292ca63a 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/main/resources/mapper/device/IotDeviceMessageMapper.xml +++ b/yudao-module-iot/yudao-module-iot-server/src/main/resources/mapper/device/IotDeviceMessageMapper.xml @@ -16,8 +16,8 @@ identifier NCHAR(100), request_id NCHAR(50), method NCHAR(100), - params NCHAR(2048), - data NCHAR(2048), + params VARCHAR(8192), + data VARCHAR(8192), code INT, msg NCHAR(256) ) TAGS ( @@ -38,7 +38,7 @@ USING device_message TAGS (#{deviceId}) VALUES ( - NOW, #{id}, #{reportTime}, #{tenantId}, #{serverId}, + #{ts}, #{id}, #{reportTime}, #{tenantId}, #{serverId}, #{upstream}, #{reply}, #{identifier}, #{requestId}, #{method}, #{params}, #{data}, #{code}, #{msg} ) diff --git a/yudao-module-iot/yudao-module-iot-server/src/main/resources/mapper/device/IotDevicePropertyMapper.xml b/yudao-module-iot/yudao-module-iot-server/src/main/resources/mapper/device/IotDevicePropertyMapper.xml index 94da3feb4..cc68ab87b 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/main/resources/mapper/device/IotDevicePropertyMapper.xml +++ b/yudao-module-iot/yudao-module-iot-server/src/main/resources/mapper/device/IotDevicePropertyMapper.xml @@ -55,7 +55,7 @@ ) VALUES - (NOW, #{reportTime}, + (#{ts}, #{reportTime}, #{value} diff --git a/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImplTest.java b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImplTest.java new file mode 100644 index 000000000..78bf74d15 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImplTest.java @@ -0,0 +1,276 @@ +package cn.iocoder.yudao.module.iot.service.device.message; + +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.message.IotDeviceMessagePageReqVO; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.mq.producer.IotDeviceMessageProducer; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO; +import cn.iocoder.yudao.module.iot.dal.tdengine.IotDeviceMessageMapper; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; +import cn.iocoder.yudao.module.iot.service.ota.IotOtaTaskRecordService; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.*; + +/** + * {@link IotDeviceMessageServiceImpl} 的单元测试 + * + * 注:TDengine 数据源没有 embedded 替代,mapper 与依赖 service 走 mock; + * handleUpstreamDeviceMessage 与 sendDeviceMessage 下行成功路径依赖 SpringUtil.getBean 的自调用 + * createDeviceLogAsync,更适合放到集成测试,本类不展开。 + * + * @author 芋道源码 + */ +public class IotDeviceMessageServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private IotDeviceMessageServiceImpl service; + + @Mock + private IotDeviceService deviceService; + @Mock + private IotDevicePropertyService devicePropertyService; + @Mock + private IotOtaTaskRecordService otaTaskRecordService; + @Mock + private IotDeviceMessageMapper deviceMessageMapper; + @Mock + private IotDeviceMessageProducer deviceMessageProducer; + + // ========== defineDeviceMessageStable ========== + + @Test + public void testDefineDeviceMessageStable_whenTableExists_skipCreate() { + // 准备:showSTable 返回非空 → 表已存在 + when(deviceMessageMapper.showSTable()).thenReturn("device_message"); + + // 调用 + service.defineDeviceMessageStable(); + + // 断言:跳过 createSTable + verify(deviceMessageMapper, never()).createSTable(); + } + + @Test + public void testDefineDeviceMessageStable_whenTableMissing_create() { + // 准备:showSTable 返回空 → 表不存在 + when(deviceMessageMapper.showSTable()).thenReturn(""); + + // 调用 + service.defineDeviceMessageStable(); + + // 断言:触发 createSTable + verify(deviceMessageMapper, times(1)).createSTable(); + } + + // ========== createDeviceLogAsync ========== + + @Test + public void testCreateDeviceLogAsync_tsFallback_whenNull() { + // 准备:构造一条 ts 为 null 的消息 + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()); + long before = System.currentTimeMillis(); + + // 调用 + service.createDeviceLogAsync(message); + long after = System.currentTimeMillis(); + + // 断言:mapper.insert 接收到的 messageDO 已被填上 ts,值在 [before, after] 区间 + ArgumentCaptor captor = ArgumentCaptor.forClass(IotDeviceMessageDO.class); + verify(deviceMessageMapper).insert(captor.capture()); + Long actualTs = captor.getValue().getTs(); + assertNotNull(actualTs, "ts 不应为空"); + assertTrue(actualTs >= before && actualTs <= after, + "ts 应在调用前后区间内; 实际 = " + actualTs); + } + + @Test + public void testCreateDeviceLogAsync_swallowMapperException() { + // 准备:mapper.insert 抛异常,验证 @Async 方法内部 try/catch 兜底,不向上抛 + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()); + doThrow(new RuntimeException("DB unavailable")).when(deviceMessageMapper).insert(any()); + + // 调用 & 断言 + assertDoesNotThrow(() -> service.createDeviceLogAsync(message)); + verify(deviceMessageMapper).insert(any(IotDeviceMessageDO.class)); + } + + // ========== sendDeviceMessage ========== + + @Test + public void testSendDeviceMessage_upstream_publishToProducer() { + // 准备:上行消息(PROPERTY_POST) + IotDeviceDO device = buildDevice(); + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()); + + // 调用 + IotDeviceMessage result = service.sendDeviceMessage(message, device); + + // 断言:走 producer.sendDeviceMessage,不进入下行链路 + assertSame(message, result); + verify(deviceMessageProducer, times(1)).sendDeviceMessage(message); + verify(deviceMessageProducer, never()).sendDeviceMessageToGateway(any(), any()); + verify(devicePropertyService, never()).getDeviceServerId(any()); + } + + @Test + public void testSendDeviceMessage_downstream_serverIdMissing_throwException() { + // 准备:下行消息(SERVICE_INVOKE);devicePropertyService 也查不到 serverId + IotDeviceDO device = buildDevice(); + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod()); + when(devicePropertyService.getDeviceServerId(device.getId())).thenReturn(null); + + // 调用 & 断言:抛 DEVICE_DOWNSTREAM_FAILED_SERVER_ID_NULL + ServiceException ex = assertThrows(ServiceException.class, + () -> service.sendDeviceMessage(message, device)); + assertEquals(1_050_003_007, ex.getCode().intValue()); + verify(deviceMessageProducer, never()).sendDeviceMessageToGateway(any(), any()); + } + + // ========== getDeviceMessagePage ========== + + @Test + public void testGetDeviceMessagePage_normal() { + // 准备 + IotDeviceMessagePageReqVO reqVO = new IotDeviceMessagePageReqVO(); + reqVO.setPageNo(1); + reqVO.setPageSize(10); + IotDeviceMessageDO record = new IotDeviceMessageDO().setId("msg-1"); + Page page = new Page<>(1, 10, 1L); + page.setRecords(Collections.singletonList(record)); + when(deviceMessageMapper.selectPage(any(), eq(reqVO))).thenReturn(page); + + // 调用 + PageResult result = service.getDeviceMessagePage(reqVO); + + // 断言 + assertEquals(1L, result.getTotal()); + assertEquals(1, result.getList().size()); + assertEquals("msg-1", result.getList().get(0).getId()); + } + + @Test + public void testGetDeviceMessagePage_whenTableMissing_returnEmpty() { + // 准备:mapper 抛 "Table does not exist" → 视为表未创建,返回空结果 + IotDeviceMessagePageReqVO reqVO = new IotDeviceMessagePageReqVO(); + reqVO.setPageNo(1); + reqVO.setPageSize(10); + when(deviceMessageMapper.selectPage(any(), any())) + .thenThrow(new RuntimeException("Table does not exist")); + + // 调用 + PageResult result = service.getDeviceMessagePage(reqVO); + + // 断言 + assertEquals(0L, result.getTotal()); + assertTrue(result.getList().isEmpty()); + } + + @Test + public void testGetDeviceMessagePage_otherException_rethrow() { + // 准备:mapper 抛非「表不存在」的异常 → 应向上抛 + IotDeviceMessagePageReqVO reqVO = new IotDeviceMessagePageReqVO(); + reqVO.setPageNo(1); + reqVO.setPageSize(10); + when(deviceMessageMapper.selectPage(any(), any())) + .thenThrow(new RuntimeException("Connection refused")); + + // 调用 & 断言 + assertThrows(RuntimeException.class, () -> service.getDeviceMessagePage(reqVO)); + } + + // ========== getDeviceMessageListByRequestIdsAndReply ========== + + @Test + public void testGetDeviceMessageListByRequestIdsAndReply_emptyIds_returnEmpty() { + // 调用 & 断言:requestIds 为空直接返回空列表,不查 DB + List result = service.getDeviceMessageListByRequestIdsAndReply( + 1L, Collections.emptyList(), true); + + assertTrue(result.isEmpty()); + verify(deviceMessageMapper, never()).selectListByRequestIdsAndReply(any(), any(), any()); + } + + @Test + public void testGetDeviceMessageListByRequestIdsAndReply_normal_delegateToMapper() { + // 准备 + List requestIds = Collections.singletonList("req-1"); + IotDeviceMessageDO record = new IotDeviceMessageDO().setId("msg-1"); + when(deviceMessageMapper.selectListByRequestIdsAndReply(1L, requestIds, true)) + .thenReturn(Collections.singletonList(record)); + + // 调用 + List result = service.getDeviceMessageListByRequestIdsAndReply( + 1L, requestIds, true); + + // 断言 + assertEquals(1, result.size()); + assertEquals("msg-1", result.get(0).getId()); + } + + // ========== getDeviceMessageCount ========== + + @Test + public void testGetDeviceMessageCount_whenCreateTimeNull_passNullToMapper() { + // 准备 + when(deviceMessageMapper.selectCountByCreateTime(isNull())).thenReturn(123L); + + // 调用 + Long count = service.getDeviceMessageCount(null); + + // 断言 + assertEquals(123L, count); + verify(deviceMessageMapper).selectCountByCreateTime(isNull()); + } + + @Test + public void testGetDeviceMessageCount_withCreateTime_passEpochMilli() { + // 准备:service 内部用 LocalDateTimeUtil.toEpochMilli 做转换,断言时也用同函数得到期望值 + LocalDateTime createTime = LocalDateTime.of(2026, 1, 1, 0, 0); + long expectedMs = LocalDateTimeUtil.toEpochMilli(createTime); + when(deviceMessageMapper.selectCountByCreateTime(expectedMs)).thenReturn(456L); + + // 调用 + Long count = service.getDeviceMessageCount(createTime); + + // 断言 + assertEquals(456L, count); + } + + // ========== 辅助方法 ========== + + /** 构造一条最简消息(指定 method 决定上下行分支) */ + private IotDeviceMessage buildMessage(String method) { + IotDeviceMessage message = new IotDeviceMessage(); + message.setId("msg-1"); + message.setDeviceId(2L); + message.setMethod(method); + message.setParams(new HashMap<>()); + return message; + } + + /** 构造最简设备 */ + private IotDeviceDO buildDevice() { + return IotDeviceDO.builder().id(2L).build(); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImplTest.java b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImplTest.java new file mode 100644 index 000000000..8f87cb89f --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImplTest.java @@ -0,0 +1,204 @@ +package cn.iocoder.yudao.module.iot.service.device.property; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDevicePropertyDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.IotThingModelDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.model.ThingModelProperty; +import cn.iocoder.yudao.module.iot.dal.redis.device.DevicePropertyRedisDAO; +import cn.iocoder.yudao.module.iot.dal.tdengine.IotDevicePropertyMapper; +import cn.iocoder.yudao.module.iot.enums.thingmodel.IotDataSpecsDataTypeEnum; +import cn.iocoder.yudao.module.iot.service.thingmodel.IotThingModelService; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +import static java.util.Collections.singletonList; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link IotDevicePropertyServiceImpl} 的单元测试 + * + * @author 芋道源码 + */ +public class IotDevicePropertyServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private IotDevicePropertyServiceImpl service; + + @Mock + private IotThingModelService thingModelService; + @Mock + private IotDevicePropertyMapper devicePropertyMapper; + @Mock + private DevicePropertyRedisDAO deviceDataRedisDAO; + + @Test + public void testSaveDeviceProperty_identifierCaseInsensitive() { + // 准备参数:物模型 identifier 是 "LightStatus",设备上报的 key 是 "LIGHTSTATUS"(全大写) + IotDeviceDO device = buildDevice(); + IotThingModelDO thingModel = buildThingModel("LightStatus", IotDataSpecsDataTypeEnum.INT.getDataType()); + Map params = new HashMap<>(); + params.put("LIGHTSTATUS", 100); + IotDeviceMessage message = buildMessage(params); + + // mock 行为 + when(thingModelService.getThingModelListByProductIdFromCache(device.getProductId())) + .thenReturn(singletonList(thingModel)); + + // 调用 + service.saveDeviceProperty(device, message); + + // 断言:properties 落库 / 入缓存时 key 应为物模型 identifier "LightStatus",而不是上报的 "LIGHTSTATUS" + Map dbProperties = captureMapperInsertProperties(); + assertTrue(dbProperties.containsKey("LightStatus")); + assertFalse(dbProperties.containsKey("LIGHTSTATUS")); + assertEquals(100, dbProperties.get("LightStatus")); + + Map redisProperties = captureRedisPutAllProperties(device.getId()); + assertTrue(redisProperties.containsKey("LightStatus")); + assertFalse(redisProperties.containsKey("LIGHTSTATUS")); + } + + @Test + public void testSaveDeviceProperty_identifierNotInThingModel() { + // 准备参数:上报的 key 在物模型里完全不存在(连忽略大小写都匹配不到) + IotDeviceDO device = buildDevice(); + IotThingModelDO thingModel = buildThingModel("LightStatus", IotDataSpecsDataTypeEnum.INT.getDataType()); + Map params = new HashMap<>(); + params.put("UnknownProperty", 1); + IotDeviceMessage message = buildMessage(params); + + // mock 行为 + when(thingModelService.getThingModelListByProductIdFromCache(device.getProductId())) + .thenReturn(singletonList(thingModel)); + + // 调用 + service.saveDeviceProperty(device, message); + + // 断言:没有合法属性,不会写入 TDengine 与 Redis + verify(devicePropertyMapper, never()).insert(any(), any(), anyLong(), anyLong()); + verify(deviceDataRedisDAO, never()).putAll(anyLong(), any()); + } + + @Test + public void testSaveDeviceProperty_boolFromBooleanTrue() { + // 准备参数:物模型为 BOOL,设备上报原生 boolean true + assertBoolValueConvertedToByte(true, (byte) 1); + } + + @Test + public void testSaveDeviceProperty_boolFromBooleanFalse() { + // 准备参数:物模型为 BOOL,设备上报原生 boolean false + assertBoolValueConvertedToByte(false, (byte) 0); + } + + @Test + public void testSaveDeviceProperty_boolFromStringTrue() { + // 准备参数:物模型为 BOOL,设备上报字符串 "true" + assertBoolValueConvertedToByte("true", (byte) 1); + } + + @Test + public void testSaveDeviceProperty_boolFromStringFalse() { + // 准备参数:物模型为 BOOL,设备上报字符串 "false" + assertBoolValueConvertedToByte("false", (byte) 0); + } + + @Test + public void testSaveDeviceProperty_boolFromNumberOne() { + // 准备参数:物模型为 BOOL,设备上报数字 1 + assertBoolValueConvertedToByte(1, (byte) 1); + } + + @Test + public void testSaveDeviceProperty_boolFromNumberZero() { + // 准备参数:物模型为 BOOL,设备上报数字 0 + assertBoolValueConvertedToByte(0, (byte) 0); + } + + /** + * 校验 BOOL 类型属性上报后,最终落到 properties Map 的值类型与数值 + */ + private void assertBoolValueConvertedToByte(Object reportedValue, byte expected) { + // 准备参数 + IotDeviceDO device = buildDevice(); + IotThingModelDO thingModel = buildThingModel("PowerSwitch", IotDataSpecsDataTypeEnum.BOOL.getDataType()); + Map params = new HashMap<>(); + params.put("PowerSwitch", reportedValue); + IotDeviceMessage message = buildMessage(params); + + // mock 行为 + when(thingModelService.getThingModelListByProductIdFromCache(device.getProductId())) + .thenReturn(singletonList(thingModel)); + + // 调用:不能抛异常 + assertDoesNotThrow(() -> service.saveDeviceProperty(device, message)); + + // 断言:写入的 value 是 byte 类型,且值匹配 + Map dbProperties = captureMapperInsertProperties(); + Object actual = dbProperties.get("PowerSwitch"); + assertTrue(actual instanceof Byte, "BOOL 属性应被转为 Byte 类型,实际为 " + (actual == null ? "null" : actual.getClass())); + assertEquals(expected, actual); + } + + // ========== 辅助方法 ========== + + /** + * 构造一个最简 IotDeviceDO,只设置测试需要的 id 与 productId + */ + private IotDeviceDO buildDevice() { + return IotDeviceDO.builder().id(1L).productId(2L).build(); + } + + /** + * 构造物模型;只填 saveDeviceProperty 链路用到的 identifier + property.dataType + */ + private IotThingModelDO buildThingModel(String identifier, String dataType) { + ThingModelProperty property = new ThingModelProperty(); + property.setIdentifier(identifier); + property.setDataType(dataType); + return IotThingModelDO.builder().identifier(identifier).property(property).build(); + } + + /** + * 构造一条属性上报消息 + */ + private IotDeviceMessage buildMessage(Map params) { + IotDeviceMessage message = new IotDeviceMessage(); + message.setMethod(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()); + message.setParams(params); + message.setReportTime(LocalDateTime.now()); + return message; + } + + /** + * 抓取 mapper.insert 的 properties 入参 + */ + @SuppressWarnings("unchecked") + private Map captureMapperInsertProperties() { + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(devicePropertyMapper).insert(any(IotDeviceDO.class), captor.capture(), anyLong(), anyLong()); + return captor.getValue(); + } + + /** + * 抓取 redisDAO.putAll 的 properties 入参 + */ + @SuppressWarnings("unchecked") + private Map captureRedisPutAllProperties(Long deviceId) { + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(deviceDataRedisDAO).putAll(eq(deviceId), captor.capture()); + return captor.getValue(); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImplTest.java b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImplTest.java new file mode 100644 index 000000000..3bdc8c318 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImplTest.java @@ -0,0 +1,235 @@ +package cn.iocoder.yudao.module.iot.service.rule.data; + +import cn.hutool.core.map.MapUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotDataRuleDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotDataSinkDO; +import cn.iocoder.yudao.module.iot.dal.mysql.rule.IotDataRuleMapper; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.service.product.IotProductService; +import cn.iocoder.yudao.module.iot.service.rule.data.action.IotDataRuleAction; +import cn.iocoder.yudao.module.iot.service.thingmodel.IotThingModelService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Map; + +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static java.util.Collections.singletonList; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link IotDataRuleServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(IotDataRuleServiceImpl.class) +class IotDataRuleServiceImplTest extends BaseDbUnitTest { + + @Resource + private IotDataRuleServiceImpl dataRuleService; + + @Resource + private IotDataRuleMapper dataRuleMapper; + + @MockitoBean + private IotDataSinkService dataSinkService; + @MockitoBean + private IotDataRuleAction dataRuleAction; + @MockitoBean + private IotProductService productService; + @MockitoBean + private IotDeviceService deviceService; + @MockitoBean + private IotThingModelService thingModelService; + + @Test + public void testExecuteDataRule_propertyPost_singleIdentifierMatched() { + // 准备参数 + Long deviceId = randomLongId(); + String identifier = "temperature"; + IotDeviceMessage message = createPropertyPostMessage(deviceId, + MapUtil.builder().put(identifier, 25.5).build()); + // mock 数据:插入一条限定 identifier=temperature 的规则 + Long sinkId = randomLongId(); + insertEnabledRule(deviceId, IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), identifier, sinkId); + // mock 方法 + IotDataSinkDO sink = mockEnabledSink(sinkId); + + // 调用 + dataRuleService.executeDataRule(message); + + // 断言:sink action 被调用一次 + verify(dataRuleAction).execute(eq(message), eq(sink)); + } + + @Test + public void testExecuteDataRule_propertyPost_multiIdentifierOneMatched() { + // 准备参数:上报 {temperature, humidity},规则只限定 humidity + Long deviceId = randomLongId(); + IotDeviceMessage message = createPropertyPostMessage(deviceId, + MapUtil.builder().put("temperature", 25.5).put("humidity", 60).build()); + // mock 数据 + Long sinkId = randomLongId(); + insertEnabledRule(deviceId, IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), "humidity", sinkId); + // mock 方法 + IotDataSinkDO sink = mockEnabledSink(sinkId); + + // 调用 + dataRuleService.executeDataRule(message); + + // 断言 + verify(dataRuleAction).execute(eq(message), eq(sink)); + } + + @Test + public void testExecuteDataRule_propertyPost_multiIdentifierDeduped() { + // 准备参数:上报 {temperature, humidity},规则 identifier=null 不限定属性 + Long deviceId = randomLongId(); + IotDeviceMessage message = createPropertyPostMessage(deviceId, + MapUtil.builder().put("temperature", 25.5).put("humidity", 60).build()); + // mock 数据:identifier=null 时两个属性 key 都会命中同一条规则,需在 sink 调用前去重 + Long sinkId = randomLongId(); + insertEnabledRule(deviceId, IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), null, sinkId); + // mock 方法 + IotDataSinkDO sink = mockEnabledSink(sinkId); + + // 调用 + dataRuleService.executeDataRule(message); + + // 断言:去重后只触发一次,而不是 2 次 + verify(dataRuleAction).execute(eq(message), eq(sink)); + } + + @Test + public void testExecuteDataRule_propertyPost_multiRuleSameSinkDeduped() { + // 准备参数:上报 {temperature, humidity},两条规则分别命中不同 identifier,但都指向同一 sink + Long deviceId = randomLongId(); + IotDeviceMessage message = createPropertyPostMessage(deviceId, + MapUtil.builder().put("temperature", 25.5).put("humidity", 60).build()); + // mock 数据:插入两条规则,identifier 分别为 temperature 与 humidity,sinkId 相同 + Long sinkId = randomLongId(); + insertEnabledRule(deviceId, IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), "temperature", sinkId); + insertEnabledRule(deviceId, IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), "humidity", sinkId); + // mock 方法 + IotDataSinkDO sink = mockEnabledSink(sinkId); + + // 调用 + dataRuleService.executeDataRule(message); + + // 断言:跨规则去重后,sink action 只触发一次,而不是 2 次 + verify(dataRuleAction).execute(eq(message), eq(sink)); + } + + @Test + public void testExecuteDataRule_propertyPost_emptyParamsMatchesWildcardRule() { + // 准备参数:上报空属性,规则 identifier=null 不限定属性,按"任意 property report 都同步"语义应命中 + Long deviceId = randomLongId(); + IotDeviceMessage message = createPropertyPostMessage(deviceId, + MapUtil.builder().build()); + // mock 数据 + Long sinkId = randomLongId(); + insertEnabledRule(deviceId, IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), null, sinkId); + // mock 方法 + IotDataSinkDO sink = mockEnabledSink(sinkId); + + // 调用 + dataRuleService.executeDataRule(message); + + // 断言 + verify(dataRuleAction).execute(eq(message), eq(sink)); + } + + @Test + public void testExecuteDataRule_propertyPost_noIdentifierMatched() { + // 准备参数:上报 {temperature},规则限定 humidity + Long deviceId = randomLongId(); + IotDeviceMessage message = createPropertyPostMessage(deviceId, + MapUtil.builder().put("temperature", 25.5).build()); + // mock 数据 + Long sinkId = randomLongId(); + insertEnabledRule(deviceId, IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), "humidity", sinkId); + + // 调用 + dataRuleService.executeDataRule(message); + + // 断言:sink action 不应被调用 + verify(dataRuleAction, never()).execute(any(), any()); + } + + @Test + public void testExecuteDataRule_eventPost_singleIdentifierMatched() { + // 准备参数:事件触发器走单 identifier 路径(与改动前行为保持一致) + Long deviceId = randomLongId(); + String identifier = "alarm"; + IotDeviceMessage message = randomPojo(IotDeviceMessage.class, o -> { + o.setDeviceId(deviceId); + o.setMethod(IotDeviceMessageMethodEnum.EVENT_POST.getMethod()); + o.setParams(MapUtil.builder() + .put("identifier", identifier).put("value", "fired").build()); + }); + // mock 数据 + Long sinkId = randomLongId(); + insertEnabledRule(deviceId, IotDeviceMessageMethodEnum.EVENT_POST.getMethod(), identifier, sinkId); + // mock 方法 + IotDataSinkDO sink = mockEnabledSink(sinkId); + + // 调用 + dataRuleService.executeDataRule(message); + + // 断言 + verify(dataRuleAction).execute(eq(message), eq(sink)); + } + + // ========== 辅助方法 ========== + + private IotDeviceMessage createPropertyPostMessage(Long deviceId, Map params) { + return randomPojo(IotDeviceMessage.class, o -> { + o.setDeviceId(deviceId); + o.setMethod(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()); + o.setParams(params); + }); + } + + /** + * 向 H2 中插入一条启用状态的数据流转规则,命中后会路由到 {@code sinkId} + */ + private void insertEnabledRule(Long deviceId, String method, String identifier, Long sinkId) { + IotDataRuleDO.SourceConfig config = randomPojo(IotDataRuleDO.SourceConfig.class, o -> { + o.setDeviceId(deviceId); + o.setMethod(method); + o.setIdentifier(identifier); + }); + IotDataRuleDO rule = randomPojo(IotDataRuleDO.class, o -> { + o.setId(null); + o.setStatus(CommonStatusEnum.ENABLE.getStatus()); + o.setSourceConfigs(singletonList(config)); + o.setSinkIds(singletonList(sinkId)); + }); + dataRuleMapper.insert(rule); + } + + /** + * 构造一个启用状态的数据流转目的并塞入对应 mock;返回 sink 用于断言 + */ + private IotDataSinkDO mockEnabledSink(Long sinkId) { + IotDataSinkDO sink = randomPojo(IotDataSinkDO.class, o -> { + o.setId(sinkId); + o.setStatus(CommonStatusEnum.ENABLE.getStatus()); + }); + when(dataSinkService.getDataSinkFromCache(sinkId)).thenReturn(sink); + when(dataRuleAction.getType()).thenReturn(sink.getType()); + return sink; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotDatabaseDataRuleActionTest.java b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotDatabaseDataRuleActionTest.java new file mode 100644 index 000000000..8d84f8e64 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotDatabaseDataRuleActionTest.java @@ -0,0 +1,65 @@ +package cn.iocoder.yudao.module.iot.service.rule.data.action; + +import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum; +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.jdbc.core.JdbcTemplate; + +import javax.sql.DataSource; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +/** + * {@link IotDatabaseDataRuleAction} 的单元测试 + * + * @author HUIHUI + */ +class IotDatabaseDataRuleActionTest { + + private IotDatabaseDataRuleAction databaseDataRuleAction; + + @Mock + private JdbcTemplate jdbcTemplate; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + databaseDataRuleAction = new IotDatabaseDataRuleAction(); + } + + @Test + public void testGetType() { + // 调用 & 断言:返回 Database 类型枚举值 + assertEquals(IotDataSinkTypeEnum.DATABASE.getType(), databaseDataRuleAction.getType()); + } + + @Test + public void testCloseProducer_whenHikari() throws Exception { + // 准备:底层是 HikariDataSource + HikariDataSource hikari = mock(HikariDataSource.class); + when(jdbcTemplate.getDataSource()).thenReturn(hikari); + + // 调用 + databaseDataRuleAction.closeProducer(jdbcTemplate); + + // 断言:HikariDataSource 被关闭 + verify(hikari, times(1)).close(); + } + + @Test + public void testCloseProducer_whenNotHikari() throws Exception { + // 准备:底层不是 HikariDataSource,避免误调 close + DataSource other = mock(DataSource.class); + when(jdbcTemplate.getDataSource()).thenReturn(other); + + // 调用 & 断言:不抛异常,且不会尝试关闭非 Hikari 数据源 + assertDoesNotThrow(() -> databaseDataRuleAction.closeProducer(jdbcTemplate)); + verifyNoInteractions(other); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotMqttDataRuleActionTest.java b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotMqttDataRuleActionTest.java new file mode 100644 index 000000000..b3e4b8dad --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotMqttDataRuleActionTest.java @@ -0,0 +1,63 @@ +package cn.iocoder.yudao.module.iot.service.rule.data.action; + +import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum; +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +/** + * {@link IotMqttDataRuleAction} 的单元测试 + * + * @author HUIHUI + */ +class IotMqttDataRuleActionTest { + + private IotMqttDataRuleAction mqttDataRuleAction; + + @Mock + private MqttClient mqttClient; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + mqttDataRuleAction = new IotMqttDataRuleAction(); + } + + @Test + public void testGetType() { + // 调用 & 断言:返回 MQTT 类型枚举值 + assertEquals(IotDataSinkTypeEnum.MQTT.getType(), mqttDataRuleAction.getType()); + } + + @Test + public void testCloseProducer_whenConnected() throws Exception { + // 准备:连接中状态 + when(mqttClient.isConnected()).thenReturn(true); + + // 调用 + mqttDataRuleAction.closeProducer(mqttClient); + + // 断言:先 disconnect 再 close + verify(mqttClient, times(1)).disconnect(); + verify(mqttClient, times(1)).close(); + } + + @Test + public void testCloseProducer_whenAlreadyDisconnected() throws Exception { + // 准备:已断开状态 + when(mqttClient.isConnected()).thenReturn(false); + + // 调用 + mqttDataRuleAction.closeProducer(mqttClient); + + // 断言:跳过 disconnect,仅 close + verify(mqttClient, never()).disconnect(); + verify(mqttClient, times(1)).close(); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotTcpDataRuleActionTest.java b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotTcpDataRuleActionTest.java index 0df800317..cbbbd7b9d 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotTcpDataRuleActionTest.java +++ b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotTcpDataRuleActionTest.java @@ -5,7 +5,6 @@ import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkTcpConfig; import cn.iocoder.yudao.module.iot.service.rule.data.action.tcp.IotTcpClient; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -18,7 +17,6 @@ import static org.mockito.Mockito.*; * * @author HUIHUI */ -@Disabled // TODO @puhui999:单测有报错,先屏蔽 class IotTcpDataRuleActionTest { private IotTcpDataRuleAction tcpDataRuleAction; @@ -44,9 +42,8 @@ class IotTcpDataRuleActionTest { assertEquals(expectedType, actualType); } - // TODO @puhui999:_ 后面是小写哈,单测的命名规则。 @Test - public void testInitProducer_Success() throws Exception { + public void testInitProducer_success() throws Exception { // 准备参数 IotDataSinkTcpConfig config = new IotDataSinkTcpConfig(); config.setHost("localhost"); @@ -62,7 +59,7 @@ class IotTcpDataRuleActionTest { } @Test - public void testInitProducer_InvalidHost() { + public void testInitProducer_invalidHost() { // 准备参数 IotDataSinkTcpConfig config = new IotDataSinkTcpConfig(); config.setHost(""); @@ -80,7 +77,7 @@ class IotTcpDataRuleActionTest { } @Test - public void testInitProducer_InvalidPort() { + public void testInitProducer_invalidPort() { // 准备参数 IotDataSinkTcpConfig config = new IotDataSinkTcpConfig(); config.setHost("localhost"); @@ -107,7 +104,7 @@ class IotTcpDataRuleActionTest { } @Test - public void testExecute_WithValidConfig() { + public void testExecute_withValidConfig() { // 准备参数 IotDeviceMessage message = IotDeviceMessage.requestOf("thing.property.report", "{\"temperature\": 25.5, \"humidity\": 60}"); @@ -127,7 +124,7 @@ class IotTcpDataRuleActionTest { } @Test - public void testConfig_DefaultValues() { + public void testConfig_defaultValues() { // 准备参数 IotDataSinkTcpConfig config = new IotDataSinkTcpConfig(); diff --git a/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceSimpleTest.java b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceSimpleTest.java index 1914d78ac..c33748334 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceSimpleTest.java +++ b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceSimpleTest.java @@ -5,11 +5,12 @@ import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.scene.IotSceneRuleSaveReqVO; import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; import cn.iocoder.yudao.module.iot.dal.mysql.rule.IotSceneRuleMapper; -import cn.iocoder.yudao.module.iot.framework.job.core.IotSchedulerManager; import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; import cn.iocoder.yudao.module.iot.service.product.IotProductService; import cn.iocoder.yudao.module.iot.service.rule.scene.action.IotSceneRuleAction; -import org.junit.jupiter.api.Disabled; +import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherManager; +import cn.iocoder.yudao.module.iot.service.rule.scene.timer.IotSceneRuleTimerHandler; +import cn.iocoder.yudao.module.iot.service.rule.scene.timer.IotTimerConditionEvaluator; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -30,7 +31,6 @@ import static org.mockito.Mockito.*; * * @author 芋道源码 */ -@Disabled // TODO @puhui999:单测有报错,先屏蔽 public class IotSceneRuleServiceSimpleTest extends BaseMockitoUnitTest { @InjectMocks @@ -43,7 +43,13 @@ public class IotSceneRuleServiceSimpleTest extends BaseMockitoUnitTest { private List sceneRuleActions; @Mock - private IotSchedulerManager schedulerManager; + private IotSceneRuleTimerHandler timerHandler; + + @Mock + private IotTimerConditionEvaluator timerConditionEvaluator; + + @Mock + private IotSceneRuleMatcherManager sceneRuleMatcherManager; @Mock private IotProductService productService; @@ -52,7 +58,7 @@ public class IotSceneRuleServiceSimpleTest extends BaseMockitoUnitTest { private IotDeviceService deviceService; @Test - public void testCreateScene_Rule_success() { + public void testCreateScene_rule_success() { // 准备参数 IotSceneRuleSaveReqVO createReqVO = randomPojo(IotSceneRuleSaveReqVO.class, o -> { o.setId(null); @@ -78,7 +84,7 @@ public class IotSceneRuleServiceSimpleTest extends BaseMockitoUnitTest { } @Test - public void testUpdateScene_Rule_success() { + public void testUpdateScene_rule_success() { // 准备参数 Long id = randomLongId(); IotSceneRuleSaveReqVO updateReqVO = randomPojo(IotSceneRuleSaveReqVO.class, o -> { diff --git a/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleTimerConditionIntegrationTest.java b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleTimerConditionIntegrationTest.java index 7f4ec70d6..2a68a0d69 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleTimerConditionIntegrationTest.java +++ b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleTimerConditionIntegrationTest.java @@ -14,8 +14,10 @@ import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; import cn.iocoder.yudao.module.iot.service.rule.scene.action.IotSceneRuleAction; +import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherManager; import cn.iocoder.yudao.module.iot.service.rule.scene.timer.IotSceneRuleTimerHandler; import cn.iocoder.yudao.module.iot.service.rule.scene.timer.IotTimerConditionEvaluator; +import cn.iocoder.yudao.module.iot.service.product.IotProductService; import org.junit.jupiter.api.*; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -41,7 +43,6 @@ import static org.mockito.Mockito.*; * * @author HUIHUI */ -@Disabled // TODO @puhui999:单测有报错,先屏蔽 public class IotSceneRuleTimerConditionIntegrationTest extends BaseMockitoUnitTest { @InjectMocks @@ -62,6 +63,12 @@ public class IotSceneRuleTimerConditionIntegrationTest extends BaseMockitoUnitTe @Mock private IotSceneRuleTimerHandler timerHandler; + @Mock + private IotSceneRuleMatcherManager sceneRuleMatcherManager; + + @Mock + private IotProductService productService; + private IotTimerConditionEvaluator timerConditionEvaluator; // 测试常量 diff --git a/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotAlertTriggerSceneRuleActionTest.java b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotAlertTriggerSceneRuleActionTest.java new file mode 100644 index 000000000..6bce06974 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotAlertTriggerSceneRuleActionTest.java @@ -0,0 +1,272 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene.action; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.dict.core.DictFrameworkUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertConfigDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.enums.alert.IotAlertReceiveTypeEnum; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleActionTypeEnum; +import cn.iocoder.yudao.module.iot.service.alert.IotAlertConfigService; +import cn.iocoder.yudao.module.iot.service.alert.IotAlertRecordService; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; +import cn.iocoder.yudao.module.system.api.mail.MailSendApi; +import cn.iocoder.yudao.module.system.api.mail.dto.MailSendSingleToUserReqDTO; +import cn.iocoder.yudao.module.system.api.notify.NotifyMessageSendApi; +import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDTO; +import cn.iocoder.yudao.module.system.api.sms.SmsSendApi; +import cn.iocoder.yudao.module.system.api.sms.dto.send.SmsSendSingleToUserReqDTO; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; + +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link IotAlertTriggerSceneRuleAction} 的单元测试 + * + * @author 芋道源码 + */ +public class IotAlertTriggerSceneRuleActionTest extends BaseMockitoUnitTest { + + @InjectMocks + private IotAlertTriggerSceneRuleAction action; + + @Mock + private IotAlertConfigService alertConfigService; + @Mock + private IotAlertRecordService alertRecordService; + @Mock + private IotDeviceService deviceService; + + @Mock + private SmsSendApi smsSendApi; + @Mock + private MailSendApi mailSendApi; + @Mock + private NotifyMessageSendApi notifyMessageSendApi; + + @Test + public void testGetType() { + // 调用并断言 + assertEquals(IotSceneRuleActionTypeEnum.ALERT_TRIGGER, action.getType()); + } + + @Test + public void testExecute_noAlertConfigs() throws Exception { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + IotSceneRuleDO rule = randomPojo(IotSceneRuleDO.class); + IotSceneRuleDO.Action actionConfig = randomPojo(IotSceneRuleDO.Action.class); + + // mock 行为:返回空列表 + when(alertConfigService.getAlertConfigListBySceneRuleIdAndStatus(rule.getId(), CommonStatusEnum.ENABLE.getStatus())) + .thenReturn(Collections.emptyList()); + + // 调用 + action.execute(message, rule, actionConfig); + + // 断言:不查设备、不创建记录、不发消息 + verify(deviceService, never()).getDeviceFromCache(anyLong()); + verify(alertRecordService, never()).createAlertRecord(any(), any(), any(), any()); + verify(smsSendApi, never()).sendSingleSmsToAdmin(any()); + verify(mailSendApi, never()).sendSingleMailToAdmin(any()); + verify(notifyMessageSendApi, never()).sendSingleMessageToAdmin(any()); + } + + @Test + public void testExecute_deviceTrigger_sendAllChannels() throws Exception { + // 准备参数 + Long userId = randomLongId(); + IotDeviceMessage message = createDeviceMessage(); + IotSceneRuleDO rule = randomPojo(IotSceneRuleDO.class); + IotSceneRuleDO.Action actionConfig = randomPojo(IotSceneRuleDO.Action.class); + IotAlertConfigDO config = randomPojo(IotAlertConfigDO.class, c -> { + c.setReceiveUserIds(Collections.singletonList(userId)); + c.setReceiveTypes(Arrays.asList( + IotAlertReceiveTypeEnum.SMS.getType(), + IotAlertReceiveTypeEnum.MAIL.getType(), + IotAlertReceiveTypeEnum.NOTIFY.getType())); + }); + IotDeviceDO device = randomPojo(IotDeviceDO.class); + + // mock 行为 + when(alertConfigService.getAlertConfigListBySceneRuleIdAndStatus(rule.getId(), CommonStatusEnum.ENABLE.getStatus())) + .thenReturn(Collections.singletonList(config)); + when(deviceService.getDeviceFromCache(message.getDeviceId())).thenReturn(device); + + // 调用(mockStatic 需包住整个调用链;buildTemplateParams 内有 DictFrameworkUtils 静态调用) + try (MockedStatic dictMock = mockStatic(DictFrameworkUtils.class)) { + dictMock.when(() -> DictFrameworkUtils.parseDictDataLabel(any(), any(Integer.class))) + .thenReturn("WARN"); + action.execute(message, rule, actionConfig); + } + + // 断言:设备只查一次 + verify(deviceService, times(1)).getDeviceFromCache(message.getDeviceId()); + // 断言:告警记录创建一次,参数透传 + verify(alertRecordService, times(1)) + .createAlertRecord(eq(config), eq(rule.getId()), eq(message), eq(device)); + // 断言:三条通道各发一次,模板编号匹配 + ArgumentCaptor smsCaptor = ArgumentCaptor.forClass(SmsSendSingleToUserReqDTO.class); + verify(smsSendApi, times(1)).sendSingleSmsToAdmin(smsCaptor.capture()); + assertEquals(userId, smsCaptor.getValue().getUserId()); + assertEquals(IotAlertReceiveTypeEnum.SMS.getTemplateCode(), smsCaptor.getValue().getTemplateCode()); + ArgumentCaptor mailCaptor = ArgumentCaptor.forClass(MailSendSingleToUserReqDTO.class); + verify(mailSendApi, times(1)).sendSingleMailToAdmin(mailCaptor.capture()); + assertEquals(IotAlertReceiveTypeEnum.MAIL.getTemplateCode(), mailCaptor.getValue().getTemplateCode()); + ArgumentCaptor notifyCaptor = ArgumentCaptor.forClass(NotifySendSingleToUserReqDTO.class); + verify(notifyMessageSendApi, times(1)).sendSingleMessageToAdmin(notifyCaptor.capture()); + assertEquals(IotAlertReceiveTypeEnum.NOTIFY.getTemplateCode(), notifyCaptor.getValue().getTemplateCode()); + } + + @Test + public void testExecute_timerTrigger_skipDeviceLookup() throws Exception { + // 准备参数:定时触发,message 为 null + Long userId = randomLongId(); + IotSceneRuleDO rule = randomPojo(IotSceneRuleDO.class); + IotSceneRuleDO.Action actionConfig = randomPojo(IotSceneRuleDO.Action.class); + IotAlertConfigDO config = randomPojo(IotAlertConfigDO.class, c -> { + c.setReceiveUserIds(Collections.singletonList(userId)); + c.setReceiveTypes(Collections.singletonList(IotAlertReceiveTypeEnum.NOTIFY.getType())); + }); + + // mock 行为 + when(alertConfigService.getAlertConfigListBySceneRuleIdAndStatus(rule.getId(), CommonStatusEnum.ENABLE.getStatus())) + .thenReturn(Collections.singletonList(config)); + + // 调用 + try (MockedStatic dictMock = mockStatic(DictFrameworkUtils.class)) { + dictMock.when(() -> DictFrameworkUtils.parseDictDataLabel(any(), any(Integer.class))) + .thenReturn("INFO"); + action.execute(null, rule, actionConfig); + } + + // 断言:跳过设备查询;message 与 device 都用 null 创建告警记录 + verify(deviceService, never()).getDeviceFromCache(anyLong()); + verify(alertRecordService, times(1)) + .createAlertRecord(eq(config), eq(rule.getId()), eq(null), eq(null)); + verify(notifyMessageSendApi, times(1)).sendSingleMessageToAdmin(any(NotifySendSingleToUserReqDTO.class)); + } + + @Test + public void testExecute_emptyReceiveUsers_skipSend() throws Exception { + // 准备参数:接收用户为空 + IotDeviceMessage message = createDeviceMessage(); + IotSceneRuleDO rule = randomPojo(IotSceneRuleDO.class); + IotSceneRuleDO.Action actionConfig = randomPojo(IotSceneRuleDO.Action.class); + IotAlertConfigDO config = randomPojo(IotAlertConfigDO.class, c -> { + c.setReceiveUserIds(Collections.emptyList()); + c.setReceiveTypes(Collections.singletonList(IotAlertReceiveTypeEnum.SMS.getType())); + }); + IotDeviceDO device = randomPojo(IotDeviceDO.class); + + // mock 行为 + when(alertConfigService.getAlertConfigListBySceneRuleIdAndStatus(rule.getId(), CommonStatusEnum.ENABLE.getStatus())) + .thenReturn(Collections.singletonList(config)); + when(deviceService.getDeviceFromCache(message.getDeviceId())).thenReturn(device); + + // 调用 + action.execute(message, rule, actionConfig); + + // 断言:告警记录仍然创建,但不发送任何消息 + verify(alertRecordService, times(1)) + .createAlertRecord(eq(config), eq(rule.getId()), eq(message), eq(device)); + verify(smsSendApi, never()).sendSingleSmsToAdmin(any()); + } + + @Test + public void testExecute_unknownReceiveType_skipSend() throws Exception { + // 准备参数:接收类型为未知值 + Long userId = randomLongId(); + IotDeviceMessage message = createDeviceMessage(); + IotSceneRuleDO rule = randomPojo(IotSceneRuleDO.class); + IotSceneRuleDO.Action actionConfig = randomPojo(IotSceneRuleDO.Action.class); + IotAlertConfigDO config = randomPojo(IotAlertConfigDO.class, c -> { + c.setReceiveUserIds(Collections.singletonList(userId)); + c.setReceiveTypes(Collections.singletonList(99)); + }); + IotDeviceDO device = randomPojo(IotDeviceDO.class); + + // mock 行为 + when(alertConfigService.getAlertConfigListBySceneRuleIdAndStatus(rule.getId(), CommonStatusEnum.ENABLE.getStatus())) + .thenReturn(Collections.singletonList(config)); + when(deviceService.getDeviceFromCache(message.getDeviceId())).thenReturn(device); + + // 调用 + try (MockedStatic dictMock = mockStatic(DictFrameworkUtils.class)) { + dictMock.when(() -> DictFrameworkUtils.parseDictDataLabel(any(), any(Integer.class))) + .thenReturn("WARN"); + action.execute(message, rule, actionConfig); + } + + // 断言:未知类型不发送 + verify(smsSendApi, never()).sendSingleSmsToAdmin(any()); + verify(mailSendApi, never()).sendSingleMailToAdmin(any()); + verify(notifyMessageSendApi, never()).sendSingleMessageToAdmin(any()); + } + + @Test + public void testExecute_smsFailure_doesNotBlockOthers() throws Exception { + // 准备参数 + Long userId = randomLongId(); + IotDeviceMessage message = createDeviceMessage(); + IotSceneRuleDO rule = randomPojo(IotSceneRuleDO.class); + IotSceneRuleDO.Action actionConfig = randomPojo(IotSceneRuleDO.Action.class); + IotAlertConfigDO config = randomPojo(IotAlertConfigDO.class, c -> { + c.setReceiveUserIds(Collections.singletonList(userId)); + c.setReceiveTypes(Arrays.asList( + IotAlertReceiveTypeEnum.SMS.getType(), + IotAlertReceiveTypeEnum.MAIL.getType())); + }); + IotDeviceDO device = randomPojo(IotDeviceDO.class); + + // mock 行为:sms 抛异常 + when(alertConfigService.getAlertConfigListBySceneRuleIdAndStatus(rule.getId(), CommonStatusEnum.ENABLE.getStatus())) + .thenReturn(Collections.singletonList(config)); + when(deviceService.getDeviceFromCache(message.getDeviceId())).thenReturn(device); + when(smsSendApi.sendSingleSmsToAdmin(any())).thenThrow(new RuntimeException("sms 渠道异常")); + + // 调用 + try (MockedStatic dictMock = mockStatic(DictFrameworkUtils.class)) { + dictMock.when(() -> DictFrameworkUtils.parseDictDataLabel(any(), any(Integer.class))) + .thenReturn("ERROR"); + action.execute(message, rule, actionConfig); + } + + // 断言:sms 抛错时邮件依旧发送 + verify(smsSendApi, times(1)).sendSingleSmsToAdmin(any()); + verify(mailSendApi, times(1)).sendSingleMailToAdmin(any()); + } + + /** + * 创建带 reportTime 的设备消息 + */ + private IotDeviceMessage createDeviceMessage() { + IotDeviceMessage message = new IotDeviceMessage(); + message.setId(randomString()); + message.setDeviceId(randomLongId()); + message.setReportTime(LocalDateTime.now()); + return message; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/IotCurrentTimeConditionMatcherTest.java b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/IotCurrentTimeConditionMatcherTest.java index 25c2ff5fa..1bc7c11de 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/IotCurrentTimeConditionMatcherTest.java +++ b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/IotCurrentTimeConditionMatcherTest.java @@ -6,7 +6,6 @@ import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum; import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionTypeEnum; import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotBaseConditionMatcherTest; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import java.time.LocalDateTime; @@ -21,7 +20,6 @@ import static org.junit.jupiter.api.Assertions.*; * * @author HUIHUI */ -@Disabled // TODO @puhui999:单测有报错,先屏蔽 public class IotCurrentTimeConditionMatcherTest extends IotBaseConditionMatcherTest { private IotCurrentTimeConditionMatcher matcher; @@ -61,7 +59,7 @@ public class IotCurrentTimeConditionMatcherTest extends IotBaseConditionMatcherT // ========== 时间戳条件测试 ========== @Test - public void testMatches_DateTimeGreaterThan_success() { + public void testMatches_dateTimeGreaterThan_success() { // 准备参数 IotDeviceMessage message = createDeviceMessage(); long pastTimestamp = LocalDateTime.now().minusHours(1).toEpochSecond(ZoneOffset.of("+8")); @@ -78,7 +76,7 @@ public class IotCurrentTimeConditionMatcherTest extends IotBaseConditionMatcherT } @Test - public void testMatches_DateTimeGreaterThan_fail() { + public void testMatches_dateTimeGreaterThan_fail() { // 准备参数 IotDeviceMessage message = createDeviceMessage(); long futureTimestamp = LocalDateTime.now().plusHours(1).toEpochSecond(ZoneOffset.of("+8")); @@ -95,7 +93,7 @@ public class IotCurrentTimeConditionMatcherTest extends IotBaseConditionMatcherT } @Test - public void testMatches_DateTimeLessThan_success() { + public void testMatches_dateTimeLessThan_success() { // 准备参数 IotDeviceMessage message = createDeviceMessage(); long futureTimestamp = LocalDateTime.now().plusHours(1).toEpochSecond(ZoneOffset.of("+8")); @@ -112,7 +110,7 @@ public class IotCurrentTimeConditionMatcherTest extends IotBaseConditionMatcherT } @Test - public void testMatches_DateTimeBetween_success() { + public void testMatches_dateTimeBetween_success() { // 准备参数 IotDeviceMessage message = createDeviceMessage(); long startTimestamp = LocalDateTime.now().minusHours(1).toEpochSecond(ZoneOffset.of("+8")); @@ -130,7 +128,7 @@ public class IotCurrentTimeConditionMatcherTest extends IotBaseConditionMatcherT } @Test - public void testMatches_DateTimeBetween_fail() { + public void testMatches_dateTimeBetween_fail() { // 准备参数 IotDeviceMessage message = createDeviceMessage(); long startTimestamp = LocalDateTime.now().plusHours(1).toEpochSecond(ZoneOffset.of("+8")); @@ -150,7 +148,7 @@ public class IotCurrentTimeConditionMatcherTest extends IotBaseConditionMatcherT // ========== 当日时间条件测试 ========== @Test - public void testMatches_TimeGreaterThan_earlyMorning() { + public void testMatches_timeGreaterThan_earlyMorning() { // 准备参数 IotDeviceMessage message = createDeviceMessage(); IotSceneRuleDO.TriggerCondition condition = createTimeCondition( @@ -167,7 +165,7 @@ public class IotCurrentTimeConditionMatcherTest extends IotBaseConditionMatcherT } @Test - public void testMatches_TimeLessThan_lateNight() { + public void testMatches_timeLessThan_lateNight() { // 准备参数 IotDeviceMessage message = createDeviceMessage(); IotSceneRuleDO.TriggerCondition condition = createTimeCondition( @@ -184,7 +182,7 @@ public class IotCurrentTimeConditionMatcherTest extends IotBaseConditionMatcherT } @Test - public void testMatches_TimeBetween_allDay() { + public void testMatches_timeBetween_allDay() { // 准备参数 IotDeviceMessage message = createDeviceMessage(); IotSceneRuleDO.TriggerCondition condition = createTimeCondition( @@ -200,7 +198,7 @@ public class IotCurrentTimeConditionMatcherTest extends IotBaseConditionMatcherT } @Test - public void testMatches_TimeBetween_workingHours() { + public void testMatches_timeBetween_workingHours() { // 准备参数 IotDeviceMessage message = createDeviceMessage(); IotSceneRuleDO.TriggerCondition condition = createTimeCondition( diff --git a/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/IotDevicePropertyConditionMatcherTest.java b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/IotDevicePropertyConditionMatcherTest.java index a4abe163e..5a4099556 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/IotDevicePropertyConditionMatcherTest.java +++ b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/IotDevicePropertyConditionMatcherTest.java @@ -6,7 +6,6 @@ import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum; import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionTypeEnum; import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotBaseConditionMatcherTest; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import java.util.HashMap; @@ -20,7 +19,6 @@ import static org.junit.jupiter.api.Assertions.*; * * @author HUIHUI */ -@Disabled // TODO @puhui999:单测有报错,先屏蔽 public class IotDevicePropertyConditionMatcherTest extends IotBaseConditionMatcherTest { private IotDevicePropertyConditionMatcher matcher; diff --git a/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/IotDeviceStateConditionMatcherTest.java b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/IotDeviceStateConditionMatcherTest.java index b83e0b089..07041fce9 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/IotDeviceStateConditionMatcherTest.java +++ b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/IotDeviceStateConditionMatcherTest.java @@ -7,7 +7,6 @@ import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum; import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionTypeEnum; import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotBaseConditionMatcherTest; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; @@ -19,7 +18,6 @@ import static org.junit.jupiter.api.Assertions.*; * * @author HUIHUI */ -@Disabled // TODO @puhui999:单测有报错,先屏蔽 public class IotDeviceStateConditionMatcherTest extends IotBaseConditionMatcherTest { private IotDeviceStateConditionMatcher matcher; diff --git a/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceEventPostTriggerMatcherTest.java b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceEventPostTriggerMatcherTest.java index b29f3c371..9cf51421f 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceEventPostTriggerMatcherTest.java +++ b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceEventPostTriggerMatcherTest.java @@ -7,7 +7,6 @@ import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotBaseConditionMatcherTest; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import java.util.HashMap; @@ -23,7 +22,6 @@ import static org.junit.jupiter.api.Assertions.*; * * @author HUIHUI */ -@Disabled // TODO @puhui999:单测有报错,先屏蔽 public class IotDeviceEventPostTriggerMatcherTest extends IotBaseConditionMatcherTest { private IotDeviceEventPostTriggerMatcher matcher; diff --git a/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDevicePropertyPostTriggerMatcherTest.java b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDevicePropertyPostTriggerMatcherTest.java index 825c7bf71..fb155763a 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDevicePropertyPostTriggerMatcherTest.java +++ b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDevicePropertyPostTriggerMatcherTest.java @@ -8,7 +8,6 @@ import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum; import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotBaseConditionMatcherTest; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import java.util.HashMap; @@ -25,7 +24,6 @@ import static org.junit.jupiter.api.Assertions.*; * * @author HUIHUI */ -@Disabled // TODO @puhui999:单测有报错,先屏蔽 public class IotDevicePropertyPostTriggerMatcherTest extends IotBaseConditionMatcherTest { private IotDevicePropertyPostTriggerMatcher matcher; diff --git a/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceServiceInvokeTriggerMatcherTest.java b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceServiceInvokeTriggerMatcherTest.java index f2f436e1f..a6b2b0ae0 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceServiceInvokeTriggerMatcherTest.java +++ b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceServiceInvokeTriggerMatcherTest.java @@ -7,7 +7,6 @@ import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotBaseConditionMatcherTest; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import java.util.HashMap; @@ -23,7 +22,6 @@ import static org.junit.jupiter.api.Assertions.*; * * @author HUIHUI */ -@Disabled // TODO @puhui999:单测有报错,先屏蔽 public class IotDeviceServiceInvokeTriggerMatcherTest extends IotBaseConditionMatcherTest { private IotDeviceServiceInvokeTriggerMatcher matcher; diff --git a/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceStateUpdateTriggerMatcherTest.java b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceStateUpdateTriggerMatcherTest.java index 79511aaa9..ad40c9ff4 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceStateUpdateTriggerMatcherTest.java +++ b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceStateUpdateTriggerMatcherTest.java @@ -8,7 +8,6 @@ import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum; import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotBaseConditionMatcherTest; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; @@ -19,7 +18,6 @@ import static org.junit.jupiter.api.Assertions.*; * * @author HUIHUI */ -@Disabled // TODO @puhui999:单测有报错,先屏蔽 public class IotDeviceStateUpdateTriggerMatcherTest extends IotBaseConditionMatcherTest { private IotDeviceStateUpdateTriggerMatcher matcher; diff --git a/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotTimerTriggerMatcherTest.java b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotTimerTriggerMatcherTest.java index 9473cd0a4..df47e6c28 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotTimerTriggerMatcherTest.java +++ b/yudao-module-iot/yudao-module-iot-server/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotTimerTriggerMatcherTest.java @@ -5,7 +5,6 @@ import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotBaseConditionMatcherTest; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; @@ -17,7 +16,6 @@ import static org.junit.jupiter.api.Assertions.*; * * @author HUIHUI */ -@Disabled // TODO @puhui999:单测有报错,先屏蔽 public class IotTimerTriggerMatcherTest extends IotBaseConditionMatcherTest { private IotTimerTriggerMatcher matcher; diff --git a/yudao-module-iot/yudao-module-iot-server/src/test/resources/sql/clean.sql b/yudao-module-iot/yudao-module-iot-server/src/test/resources/sql/clean.sql index ae1c5e515..454a57ffa 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/test/resources/sql/clean.sql +++ b/yudao-module-iot/yudao-module-iot-server/src/test/resources/sql/clean.sql @@ -8,3 +8,4 @@ DELETE FROM "iot_alert_record"; DELETE FROM "iot_ota_firmware"; DELETE FROM "iot_ota_task"; DELETE FROM "iot_ota_record"; +DELETE FROM "iot_data_rule"; diff --git a/yudao-module-iot/yudao-module-iot-server/src/test/resources/sql/create_tables.sql b/yudao-module-iot/yudao-module-iot-server/src/test/resources/sql/create_tables.sql index 306c66b5e..0bd2ad25d 100644 --- a/yudao-module-iot/yudao-module-iot-server/src/test/resources/sql/create_tables.sql +++ b/yudao-module-iot/yudao-module-iot-server/src/test/resources/sql/create_tables.sql @@ -180,3 +180,19 @@ CREATE TABLE IF NOT EXISTS "iot_ota_record" ( "tenant_id" bigint NOT NULL DEFAULT '0', PRIMARY KEY ("id") ) COMMENT 'IoT OTA 升级记录表'; + +CREATE TABLE IF NOT EXISTS "iot_data_rule" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "name" varchar(128) NOT NULL, + "description" varchar(256) DEFAULT '', + "status" int NOT NULL, + "source_configs" varchar(10000) NOT NULL, + "sink_ids" varchar(512) NOT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT '0', + PRIMARY KEY ("id") +) COMMENT 'IoT 数据流转规则';