Commit f3878c30560afc789236a26e72a44b0c199cc3e2
Committed by
Gitee
!1505 perf(iot):【场景联动】优化
Merge pull request !1505 from puhui999/feature/iot
Showing
19 changed files
with
2121 additions
and
244 deletions
yudao-dependencies/pom.xml
| @@ -76,6 +76,7 @@ | @@ -76,6 +76,7 @@ | ||
| 76 | <jimubi.version>2.3.0</jimubi.version> | 76 | <jimubi.version>2.3.0</jimubi.version> |
| 77 | <weixin-java.version>4.7.9-20251224.161447</weixin-java.version> | 77 | <weixin-java.version>4.7.9-20251224.161447</weixin-java.version> |
| 78 | <alipay-sdk-java.version>4.40.607.ALL</alipay-sdk-java.version> | 78 | <alipay-sdk-java.version>4.40.607.ALL</alipay-sdk-java.version> |
| 79 | + <okhttp.version>4.12.0</okhttp.version> | ||
| 79 | </properties> | 80 | </properties> |
| 80 | 81 | ||
| 81 | <dependencyManagement> | 82 | <dependencyManagement> |
| @@ -653,6 +654,19 @@ | @@ -653,6 +654,19 @@ | ||
| 653 | <artifactId>org.eclipse.paho.client.mqttv3</artifactId> | 654 | <artifactId>org.eclipse.paho.client.mqttv3</artifactId> |
| 654 | <version>${mqtt.version}</version> | 655 | <version>${mqtt.version}</version> |
| 655 | </dependency> | 656 | </dependency> |
| 657 | + | ||
| 658 | + <!-- OkHttp --> | ||
| 659 | + <dependency> | ||
| 660 | + <groupId>com.squareup.okhttp3</groupId> | ||
| 661 | + <artifactId>okhttp</artifactId> | ||
| 662 | + <version>${okhttp.version}</version> | ||
| 663 | + </dependency> | ||
| 664 | + <dependency> | ||
| 665 | + <groupId>com.squareup.okhttp3</groupId> | ||
| 666 | + <artifactId>mockwebserver</artifactId> | ||
| 667 | + <version>${okhttp.version}</version> | ||
| 668 | + <scope>test</scope> | ||
| 669 | + </dependency> | ||
| 656 | </dependencies> | 670 | </dependencies> |
| 657 | </dependencyManagement> | 671 | </dependencyManagement> |
| 658 | 672 |
yudao-module-iot/yudao-module-iot-biz/pom.xml
| @@ -73,6 +73,17 @@ | @@ -73,6 +73,17 @@ | ||
| 73 | <artifactId>yudao-spring-boot-starter-excel</artifactId> | 73 | <artifactId>yudao-spring-boot-starter-excel</artifactId> |
| 74 | </dependency> | 74 | </dependency> |
| 75 | 75 | ||
| 76 | + <!-- OkHttp --> | ||
| 77 | + <dependency> | ||
| 78 | + <groupId>com.squareup.okhttp3</groupId> | ||
| 79 | + <artifactId>okhttp</artifactId> | ||
| 80 | + </dependency> | ||
| 81 | + <dependency> | ||
| 82 | + <groupId>com.squareup.okhttp3</groupId> | ||
| 83 | + <artifactId>mockwebserver</artifactId> | ||
| 84 | + <scope>test</scope> | ||
| 85 | + </dependency> | ||
| 86 | + | ||
| 76 | <!-- 消息队列相关 --> | 87 | <!-- 消息队列相关 --> |
| 77 | <!-- TODO @芋艿:临时打开 --> | 88 | <!-- TODO @芋艿:临时打开 --> |
| 78 | <dependency> | 89 | <dependency> |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/redis/RedisKeyConstants.java
| @@ -84,4 +84,12 @@ public interface RedisKeyConstants { | @@ -84,4 +84,12 @@ public interface RedisKeyConstants { | ||
| 84 | */ | 84 | */ |
| 85 | String SCENE_RULE_LIST = "iot:scene_rule_list"; | 85 | String SCENE_RULE_LIST = "iot:scene_rule_list"; |
| 86 | 86 | ||
| 87 | + /** | ||
| 88 | + * WebSocket 连接分布式锁 | ||
| 89 | + * <p> | ||
| 90 | + * KEY 格式:websocket_connect_lock:${serverUrl} | ||
| 91 | + * 用于保证 WebSocket 重连操作的线程安全 | ||
| 92 | + */ | ||
| 93 | + String WEBSOCKET_CONNECT_LOCK = "iot:websocket_connect_lock:%s"; | ||
| 94 | + | ||
| 87 | } | 95 | } |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotTcpDataRuleAction.java
| @@ -43,7 +43,6 @@ public class IotTcpDataRuleAction extends | @@ -43,7 +43,6 @@ public class IotTcpDataRuleAction extends | ||
| 43 | config.getConnectTimeoutMs(), | 43 | config.getConnectTimeoutMs(), |
| 44 | config.getReadTimeoutMs(), | 44 | config.getReadTimeoutMs(), |
| 45 | config.getSsl(), | 45 | config.getSsl(), |
| 46 | - config.getSslCertPath(), | ||
| 47 | config.getDataFormat() | 46 | config.getDataFormat() |
| 48 | ); | 47 | ); |
| 49 | // 2.2 连接服务器 | 48 | // 2.2 连接服务器 |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotWebSocketDataRuleAction.java
| @@ -8,6 +8,10 @@ import cn.iocoder.yudao.module.iot.service.rule.data.action.websocket.IotWebSock | @@ -8,6 +8,10 @@ import cn.iocoder.yudao.module.iot.service.rule.data.action.websocket.IotWebSock | ||
| 8 | import lombok.extern.slf4j.Slf4j; | 8 | import lombok.extern.slf4j.Slf4j; |
| 9 | import org.springframework.stereotype.Component; | 9 | import org.springframework.stereotype.Component; |
| 10 | 10 | ||
| 11 | +import java.util.concurrent.ConcurrentHashMap; | ||
| 12 | +import java.util.concurrent.TimeUnit; | ||
| 13 | +import java.util.concurrent.locks.ReentrantLock; | ||
| 14 | + | ||
| 11 | /** | 15 | /** |
| 12 | * WebSocket 的 {@link IotDataRuleAction} 实现类 | 16 | * WebSocket 的 {@link IotDataRuleAction} 实现类 |
| 13 | * <p> | 17 | * <p> |
| @@ -22,6 +26,18 @@ import org.springframework.stereotype.Component; | @@ -22,6 +26,18 @@ import org.springframework.stereotype.Component; | ||
| 22 | public class IotWebSocketDataRuleAction extends | 26 | public class IotWebSocketDataRuleAction extends |
| 23 | IotDataRuleCacheableAction<IotDataSinkWebSocketConfig, IotWebSocketClient> { | 27 | IotDataRuleCacheableAction<IotDataSinkWebSocketConfig, IotWebSocketClient> { |
| 24 | 28 | ||
| 29 | + /** | ||
| 30 | + * 锁等待超时时间(毫秒) | ||
| 31 | + */ | ||
| 32 | + private static final long LOCK_WAIT_TIME_MS = 5000; | ||
| 33 | + | ||
| 34 | + /** | ||
| 35 | + * 重连锁,key 为 WebSocket 服务器地址 | ||
| 36 | + * <p> | ||
| 37 | + * WebSocket 连接是与特定服务器实例绑定的,使用单机锁即可保证重连的线程安全 | ||
| 38 | + */ | ||
| 39 | + private final ConcurrentHashMap<String, ReentrantLock> reconnectLocks = new ConcurrentHashMap<>(); | ||
| 40 | + | ||
| 25 | @Override | 41 | @Override |
| 26 | public Integer getType() { | 42 | public Integer getType() { |
| 27 | return IotDataSinkTypeEnum.WEBSOCKET.getType(); | 43 | return IotDataSinkTypeEnum.WEBSOCKET.getType(); |
| @@ -62,12 +78,11 @@ public class IotWebSocketDataRuleAction extends | @@ -62,12 +78,11 @@ public class IotWebSocketDataRuleAction extends | ||
| 62 | protected void execute(IotDeviceMessage message, IotDataSinkWebSocketConfig config) throws Exception { | 78 | protected void execute(IotDeviceMessage message, IotDataSinkWebSocketConfig config) throws Exception { |
| 63 | try { | 79 | try { |
| 64 | // 1.1 获取或创建 WebSocket 客户端 | 80 | // 1.1 获取或创建 WebSocket 客户端 |
| 65 | - // TODO @puhui999:需要加锁,保证必须连接上; | ||
| 66 | IotWebSocketClient webSocketClient = getProducer(config); | 81 | IotWebSocketClient webSocketClient = getProducer(config); |
| 67 | - // 1.2 检查连接状态,如果断开则重新连接 | 82 | + |
| 83 | + // 1.2 检查连接状态,如果断开则使用分布式锁保证重连的线程安全 | ||
| 68 | if (!webSocketClient.isConnected()) { | 84 | if (!webSocketClient.isConnected()) { |
| 69 | - log.warn("[execute][WebSocket 连接已断开,尝试重新连接,服务器: {}]", config.getServerUrl()); | ||
| 70 | - webSocketClient.connect(); | 85 | + reconnectWithLock(webSocketClient, config); |
| 71 | } | 86 | } |
| 72 | 87 | ||
| 73 | // 2.1 发送消息 | 88 | // 2.1 发送消息 |
| @@ -82,4 +97,33 @@ public class IotWebSocketDataRuleAction extends | @@ -82,4 +97,33 @@ public class IotWebSocketDataRuleAction extends | ||
| 82 | } | 97 | } |
| 83 | } | 98 | } |
| 84 | 99 | ||
| 100 | + /** | ||
| 101 | + * 使用锁进行重连,保证同一服务器地址的重连操作线程安全 | ||
| 102 | + * | ||
| 103 | + * @param webSocketClient WebSocket 客户端 | ||
| 104 | + * @param config 配置信息 | ||
| 105 | + */ | ||
| 106 | + private void reconnectWithLock(IotWebSocketClient webSocketClient, IotDataSinkWebSocketConfig config) throws Exception { | ||
| 107 | + ReentrantLock lock = reconnectLocks.computeIfAbsent(config.getServerUrl(), k -> new ReentrantLock()); | ||
| 108 | + boolean acquired = false; | ||
| 109 | + try { | ||
| 110 | + acquired = lock.tryLock(LOCK_WAIT_TIME_MS, TimeUnit.MILLISECONDS); | ||
| 111 | + if (!acquired) { | ||
| 112 | + throw new RuntimeException("获取 WebSocket 重连锁超时,服务器: " + config.getServerUrl()); | ||
| 113 | + } | ||
| 114 | + // 双重检查:获取锁后再次检查连接状态,避免重复连接 | ||
| 115 | + if (!webSocketClient.isConnected()) { | ||
| 116 | + log.warn("[reconnectWithLock][WebSocket 连接已断开,尝试重新连接,服务器: {}]", config.getServerUrl()); | ||
| 117 | + webSocketClient.connect(); | ||
| 118 | + } | ||
| 119 | + } catch (InterruptedException e) { | ||
| 120 | + Thread.currentThread().interrupt(); | ||
| 121 | + throw new RuntimeException("获取 WebSocket 重连锁被中断,服务器: " + config.getServerUrl(), e); | ||
| 122 | + } finally { | ||
| 123 | + if (acquired && lock.isHeldByCurrentThread()) { | ||
| 124 | + lock.unlock(); | ||
| 125 | + } | ||
| 126 | + } | ||
| 127 | + } | ||
| 128 | + | ||
| 85 | } | 129 | } |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/tcp/IotTcpClient.java
| 1 | package cn.iocoder.yudao.module.iot.service.rule.data.action.tcp; | 1 | package cn.iocoder.yudao.module.iot.service.rule.data.action.tcp; |
| 2 | 2 | ||
| 3 | +import cn.hutool.core.util.ObjUtil; | ||
| 3 | import cn.iocoder.yudao.framework.common.util.json.JsonUtils; | 4 | import cn.iocoder.yudao.framework.common.util.json.JsonUtils; |
| 4 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | 5 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 5 | import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkTcpConfig; | 6 | import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkTcpConfig; |
| @@ -31,8 +32,6 @@ public class IotTcpClient { | @@ -31,8 +32,6 @@ public class IotTcpClient { | ||
| 31 | private final Integer connectTimeoutMs; | 32 | private final Integer connectTimeoutMs; |
| 32 | private final Integer readTimeoutMs; | 33 | private final Integer readTimeoutMs; |
| 33 | private final Boolean ssl; | 34 | private final Boolean ssl; |
| 34 | - // TODO @puhui999:sslCertPath 是不是没在用? | ||
| 35 | - private final String sslCertPath; | ||
| 36 | private final String dataFormat; | 35 | private final String dataFormat; |
| 37 | 36 | ||
| 38 | private Socket socket; | 37 | private Socket socket; |
| @@ -41,15 +40,13 @@ public class IotTcpClient { | @@ -41,15 +40,13 @@ public class IotTcpClient { | ||
| 41 | private final AtomicBoolean connected = new AtomicBoolean(false); | 40 | private final AtomicBoolean connected = new AtomicBoolean(false); |
| 42 | 41 | ||
| 43 | public IotTcpClient(String host, Integer port, Integer connectTimeoutMs, Integer readTimeoutMs, | 42 | public IotTcpClient(String host, Integer port, Integer connectTimeoutMs, Integer readTimeoutMs, |
| 44 | - Boolean ssl, String sslCertPath, String dataFormat) { | 43 | + Boolean ssl, String dataFormat) { |
| 45 | this.host = host; | 44 | this.host = host; |
| 46 | this.port = port; | 45 | this.port = port; |
| 47 | this.connectTimeoutMs = connectTimeoutMs != null ? connectTimeoutMs : IotDataSinkTcpConfig.DEFAULT_CONNECT_TIMEOUT_MS; | 46 | this.connectTimeoutMs = connectTimeoutMs != null ? connectTimeoutMs : IotDataSinkTcpConfig.DEFAULT_CONNECT_TIMEOUT_MS; |
| 48 | this.readTimeoutMs = readTimeoutMs != null ? readTimeoutMs : IotDataSinkTcpConfig.DEFAULT_READ_TIMEOUT_MS; | 47 | this.readTimeoutMs = readTimeoutMs != null ? readTimeoutMs : IotDataSinkTcpConfig.DEFAULT_READ_TIMEOUT_MS; |
| 49 | this.ssl = ssl != null ? ssl : IotDataSinkTcpConfig.DEFAULT_SSL; | 48 | this.ssl = ssl != null ? ssl : IotDataSinkTcpConfig.DEFAULT_SSL; |
| 50 | - this.sslCertPath = sslCertPath; | ||
| 51 | - // TODO @puhui999:可以使用 StrUtil.defaultIfBlank 方法简化 | ||
| 52 | - this.dataFormat = dataFormat != null ? dataFormat : IotDataSinkTcpConfig.DEFAULT_DATA_FORMAT; | 49 | + this.dataFormat = ObjUtil.defaultIfBlank(dataFormat, IotDataSinkTcpConfig.DEFAULT_DATA_FORMAT); |
| 53 | } | 50 | } |
| 54 | 51 | ||
| 55 | /** | 52 | /** |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/websocket/IotWebSocketClient.java
| @@ -4,13 +4,9 @@ import cn.iocoder.yudao.framework.common.util.json.JsonUtils; | @@ -4,13 +4,9 @@ import cn.iocoder.yudao.framework.common.util.json.JsonUtils; | ||
| 4 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | 4 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 5 | import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkWebSocketConfig; | 5 | import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkWebSocketConfig; |
| 6 | import lombok.extern.slf4j.Slf4j; | 6 | import lombok.extern.slf4j.Slf4j; |
| 7 | +import okhttp3.*; | ||
| 7 | 8 | ||
| 8 | -import java.net.URI; | ||
| 9 | -import java.net.http.HttpClient; | ||
| 10 | -import java.net.http.WebSocket; | ||
| 11 | -import java.time.Duration; | ||
| 12 | -import java.util.concurrent.CompletableFuture; | ||
| 13 | -import java.util.concurrent.CompletionStage; | 9 | +import java.util.concurrent.CountDownLatch; |
| 14 | import java.util.concurrent.TimeUnit; | 10 | import java.util.concurrent.TimeUnit; |
| 15 | import java.util.concurrent.atomic.AtomicBoolean; | 11 | import java.util.concurrent.atomic.AtomicBoolean; |
| 16 | 12 | ||
| @@ -19,21 +15,23 @@ import java.util.concurrent.atomic.AtomicBoolean; | @@ -19,21 +15,23 @@ import java.util.concurrent.atomic.AtomicBoolean; | ||
| 19 | * <p> | 15 | * <p> |
| 20 | * 负责与外部 WebSocket 服务器建立连接并发送设备消息 | 16 | * 负责与外部 WebSocket 服务器建立连接并发送设备消息 |
| 21 | * 支持 ws:// 和 wss:// 协议,支持 JSON 和 TEXT 数据格式 | 17 | * 支持 ws:// 和 wss:// 协议,支持 JSON 和 TEXT 数据格式 |
| 22 | - * 基于 Java 11+ 内置的 java.net.http.WebSocket 实现 | 18 | + * 基于 OkHttp WebSocket 实现,兼容 JDK 8+ |
| 19 | + * <p> | ||
| 20 | + * 注意:该类的线程安全由调用方(IotWebSocketDataRuleAction)通过分布式锁保证 | ||
| 23 | * | 21 | * |
| 24 | * @author HUIHUI | 22 | * @author HUIHUI |
| 25 | */ | 23 | */ |
| 26 | @Slf4j | 24 | @Slf4j |
| 27 | -public class IotWebSocketClient implements WebSocket.Listener { | 25 | +public class IotWebSocketClient { |
| 28 | 26 | ||
| 29 | private final String serverUrl; | 27 | private final String serverUrl; |
| 30 | private final Integer connectTimeoutMs; | 28 | private final Integer connectTimeoutMs; |
| 31 | private final Integer sendTimeoutMs; | 29 | private final Integer sendTimeoutMs; |
| 32 | private final String dataFormat; | 30 | private final String dataFormat; |
| 33 | 31 | ||
| 34 | - private WebSocket webSocket; | 32 | + private OkHttpClient okHttpClient; |
| 33 | + private volatile WebSocket webSocket; | ||
| 35 | private final AtomicBoolean connected = new AtomicBoolean(false); | 34 | private final AtomicBoolean connected = new AtomicBoolean(false); |
| 36 | - private final StringBuilder messageBuffer = new StringBuilder(); | ||
| 37 | 35 | ||
| 38 | public IotWebSocketClient(String serverUrl, Integer connectTimeoutMs, Integer sendTimeoutMs, String dataFormat) { | 36 | public IotWebSocketClient(String serverUrl, Integer connectTimeoutMs, Integer sendTimeoutMs, String dataFormat) { |
| 39 | this.serverUrl = serverUrl; | 37 | this.serverUrl = serverUrl; |
| @@ -44,8 +42,9 @@ public class IotWebSocketClient implements WebSocket.Listener { | @@ -44,8 +42,9 @@ public class IotWebSocketClient implements WebSocket.Listener { | ||
| 44 | 42 | ||
| 45 | /** | 43 | /** |
| 46 | * 连接到 WebSocket 服务器 | 44 | * 连接到 WebSocket 服务器 |
| 45 | + * <p> | ||
| 46 | + * 注意:调用方需要通过分布式锁保证并发安全 | ||
| 47 | */ | 47 | */ |
| 48 | - @SuppressWarnings("resource") | ||
| 49 | public void connect() throws Exception { | 48 | public void connect() throws Exception { |
| 50 | if (connected.get()) { | 49 | if (connected.get()) { |
| 51 | log.warn("[connect][WebSocket 客户端已经连接,无需重复连接]"); | 50 | log.warn("[connect][WebSocket 客户端已经连接,无需重复连接]"); |
| @@ -53,17 +52,32 @@ public class IotWebSocketClient implements WebSocket.Listener { | @@ -53,17 +52,32 @@ public class IotWebSocketClient implements WebSocket.Listener { | ||
| 53 | } | 52 | } |
| 54 | 53 | ||
| 55 | try { | 54 | try { |
| 56 | - HttpClient httpClient = HttpClient.newBuilder() | ||
| 57 | - .connectTimeout(Duration.ofMillis(connectTimeoutMs)) | 55 | + // 创建 OkHttpClient |
| 56 | + okHttpClient = new OkHttpClient.Builder() | ||
| 57 | + .connectTimeout(connectTimeoutMs, TimeUnit.MILLISECONDS) | ||
| 58 | + .readTimeout(sendTimeoutMs, TimeUnit.MILLISECONDS) | ||
| 59 | + .writeTimeout(sendTimeoutMs, TimeUnit.MILLISECONDS) | ||
| 60 | + .build(); | ||
| 61 | + | ||
| 62 | + // 创建 WebSocket 请求 | ||
| 63 | + Request request = new Request.Builder() | ||
| 64 | + .url(serverUrl) | ||
| 58 | .build(); | 65 | .build(); |
| 59 | 66 | ||
| 60 | - CompletableFuture<WebSocket> future = httpClient.newWebSocketBuilder() | ||
| 61 | - .connectTimeout(Duration.ofMillis(connectTimeoutMs)) | ||
| 62 | - .buildAsync(URI.create(serverUrl), this); | 67 | + // 使用 CountDownLatch 等待连接完成 |
| 68 | + CountDownLatch connectLatch = new CountDownLatch(1); | ||
| 69 | + AtomicBoolean connectSuccess = new AtomicBoolean(false); | ||
| 70 | + | ||
| 71 | + // 创建 WebSocket 连接 | ||
| 72 | + webSocket = okHttpClient.newWebSocket(request, new IotWebSocketListener(connectLatch, connectSuccess)); | ||
| 63 | 73 | ||
| 64 | // 等待连接完成 | 74 | // 等待连接完成 |
| 65 | - webSocket = future.get(connectTimeoutMs, TimeUnit.MILLISECONDS); | ||
| 66 | - connected.set(true); | 75 | + boolean await = connectLatch.await(connectTimeoutMs, TimeUnit.MILLISECONDS); |
| 76 | + if (!await || !connectSuccess.get()) { | ||
| 77 | + close(); | ||
| 78 | + throw new Exception("WebSocket 连接超时或失败,服务器地址: " + serverUrl); | ||
| 79 | + } | ||
| 80 | + | ||
| 67 | log.info("[connect][WebSocket 客户端连接成功,服务器地址: {}]", serverUrl); | 81 | log.info("[connect][WebSocket 客户端连接成功,服务器地址: {}]", serverUrl); |
| 68 | } catch (Exception e) { | 82 | } catch (Exception e) { |
| 69 | close(); | 83 | close(); |
| @@ -72,36 +86,6 @@ public class IotWebSocketClient implements WebSocket.Listener { | @@ -72,36 +86,6 @@ public class IotWebSocketClient implements WebSocket.Listener { | ||
| 72 | } | 86 | } |
| 73 | } | 87 | } |
| 74 | 88 | ||
| 75 | - @Override | ||
| 76 | - public void onOpen(WebSocket webSocket) { | ||
| 77 | - log.debug("[onOpen][WebSocket 连接已打开]"); | ||
| 78 | - webSocket.request(1); | ||
| 79 | - } | ||
| 80 | - | ||
| 81 | - @Override | ||
| 82 | - public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) { | ||
| 83 | - messageBuffer.append(data); | ||
| 84 | - if (last) { | ||
| 85 | - log.debug("[onText][收到 WebSocket 消息: {}]", messageBuffer); | ||
| 86 | - messageBuffer.setLength(0); | ||
| 87 | - } | ||
| 88 | - webSocket.request(1); | ||
| 89 | - return null; | ||
| 90 | - } | ||
| 91 | - | ||
| 92 | - @Override | ||
| 93 | - public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) { | ||
| 94 | - connected.set(false); | ||
| 95 | - log.info("[onClose][WebSocket 连接已关闭,状态码: {},原因: {}]", statusCode, reason); | ||
| 96 | - return null; | ||
| 97 | - } | ||
| 98 | - | ||
| 99 | - @Override | ||
| 100 | - public void onError(WebSocket webSocket, Throwable error) { | ||
| 101 | - connected.set(false); | ||
| 102 | - log.error("[onError][WebSocket 发生错误]", error); | ||
| 103 | - } | ||
| 104 | - | ||
| 105 | /** | 89 | /** |
| 106 | * 发送设备消息 | 90 | * 发送设备消息 |
| 107 | * | 91 | * |
| @@ -109,7 +93,8 @@ public class IotWebSocketClient implements WebSocket.Listener { | @@ -109,7 +93,8 @@ public class IotWebSocketClient implements WebSocket.Listener { | ||
| 109 | * @throws Exception 发送异常 | 93 | * @throws Exception 发送异常 |
| 110 | */ | 94 | */ |
| 111 | public void sendMessage(IotDeviceMessage message) throws Exception { | 95 | public void sendMessage(IotDeviceMessage message) throws Exception { |
| 112 | - if (!connected.get() || webSocket == null) { | 96 | + WebSocket ws = this.webSocket; |
| 97 | + if (!connected.get() || ws == null) { | ||
| 113 | throw new IllegalStateException("WebSocket 客户端未连接"); | 98 | throw new IllegalStateException("WebSocket 客户端未连接"); |
| 114 | } | 99 | } |
| 115 | 100 | ||
| @@ -121,9 +106,11 @@ public class IotWebSocketClient implements WebSocket.Listener { | @@ -121,9 +106,11 @@ public class IotWebSocketClient implements WebSocket.Listener { | ||
| 121 | messageData = message.toString(); | 106 | messageData = message.toString(); |
| 122 | } | 107 | } |
| 123 | 108 | ||
| 124 | - // 发送消息并等待完成 | ||
| 125 | - CompletableFuture<WebSocket> future = webSocket.sendText(messageData, true); | ||
| 126 | - future.get(sendTimeoutMs, TimeUnit.MILLISECONDS); | 109 | + // 发送消息 |
| 110 | + boolean success = ws.send(messageData); | ||
| 111 | + if (!success) { | ||
| 112 | + throw new Exception("WebSocket 发送消息失败,消息队列已满或连接已关闭"); | ||
| 113 | + } | ||
| 127 | log.debug("[sendMessage][发送消息成功,设备 ID: {},消息长度: {}]", | 114 | log.debug("[sendMessage][发送消息成功,设备 ID: {},消息长度: {}]", |
| 128 | message.getDeviceId(), messageData.length()); | 115 | message.getDeviceId(), messageData.length()); |
| 129 | } catch (Exception e) { | 116 | } catch (Exception e) { |
| @@ -136,18 +123,17 @@ public class IotWebSocketClient implements WebSocket.Listener { | @@ -136,18 +123,17 @@ public class IotWebSocketClient implements WebSocket.Listener { | ||
| 136 | * 关闭连接 | 123 | * 关闭连接 |
| 137 | */ | 124 | */ |
| 138 | public void close() { | 125 | public void close() { |
| 139 | - if (!connected.get() && webSocket == null) { | ||
| 140 | - return; | ||
| 141 | - } | ||
| 142 | - | ||
| 143 | try { | 126 | try { |
| 144 | if (webSocket != null) { | 127 | if (webSocket != null) { |
| 145 | - webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "客户端主动关闭") | ||
| 146 | - .orTimeout(5, TimeUnit.SECONDS) | ||
| 147 | - .exceptionally(e -> { | ||
| 148 | - log.warn("[close][发送关闭帧失败]", e); | ||
| 149 | - return null; | ||
| 150 | - }); | 128 | + // 发送正常关闭帧,状态码 1000 表示正常关闭 |
| 129 | + webSocket.close(1000, "客户端主动关闭"); | ||
| 130 | + webSocket = null; | ||
| 131 | + } | ||
| 132 | + if (okHttpClient != null) { | ||
| 133 | + // 关闭连接池和调度器 | ||
| 134 | + okHttpClient.dispatcher().executorService().shutdown(); | ||
| 135 | + okHttpClient.connectionPool().evictAll(); | ||
| 136 | + okHttpClient = null; | ||
| 151 | } | 137 | } |
| 152 | connected.set(false); | 138 | connected.set(false); |
| 153 | log.info("[close][WebSocket 客户端连接已关闭,服务器地址: {}]", serverUrl); | 139 | log.info("[close][WebSocket 客户端连接已关闭,服务器地址: {}]", serverUrl); |
| @@ -174,4 +160,50 @@ public class IotWebSocketClient implements WebSocket.Listener { | @@ -174,4 +160,50 @@ public class IotWebSocketClient implements WebSocket.Listener { | ||
| 174 | '}'; | 160 | '}'; |
| 175 | } | 161 | } |
| 176 | 162 | ||
| 163 | + /** | ||
| 164 | + * OkHttp WebSocket 监听器 | ||
| 165 | + */ | ||
| 166 | + private class IotWebSocketListener extends WebSocketListener { | ||
| 167 | + | ||
| 168 | + private final CountDownLatch connectLatch; | ||
| 169 | + private final AtomicBoolean connectSuccess; | ||
| 170 | + | ||
| 171 | + public IotWebSocketListener(CountDownLatch connectLatch, AtomicBoolean connectSuccess) { | ||
| 172 | + this.connectLatch = connectLatch; | ||
| 173 | + this.connectSuccess = connectSuccess; | ||
| 174 | + } | ||
| 175 | + | ||
| 176 | + @Override | ||
| 177 | + public void onOpen(WebSocket webSocket, Response response) { | ||
| 178 | + connected.set(true); | ||
| 179 | + connectSuccess.set(true); | ||
| 180 | + connectLatch.countDown(); | ||
| 181 | + log.info("[onOpen][WebSocket 连接已打开,服务器: {}]", serverUrl); | ||
| 182 | + } | ||
| 183 | + | ||
| 184 | + @Override | ||
| 185 | + public void onMessage(WebSocket webSocket, String text) { | ||
| 186 | + log.debug("[onMessage][收到消息: {}]", text); | ||
| 187 | + } | ||
| 188 | + | ||
| 189 | + @Override | ||
| 190 | + public void onClosing(WebSocket webSocket, int code, String reason) { | ||
| 191 | + connected.set(false); | ||
| 192 | + log.info("[onClosing][WebSocket 正在关闭,code: {}, reason: {}]", code, reason); | ||
| 193 | + } | ||
| 194 | + | ||
| 195 | + @Override | ||
| 196 | + public void onClosed(WebSocket webSocket, int code, String reason) { | ||
| 197 | + connected.set(false); | ||
| 198 | + log.info("[onClosed][WebSocket 已关闭,code: {}, reason: {}]", code, reason); | ||
| 199 | + } | ||
| 200 | + | ||
| 201 | + @Override | ||
| 202 | + public void onFailure(WebSocket webSocket, Throwable t, Response response) { | ||
| 203 | + connected.set(false); | ||
| 204 | + connectLatch.countDown(); // 确保连接失败时也释放等待 | ||
| 205 | + log.error("[onFailure][WebSocket 连接失败]", t); | ||
| 206 | + } | ||
| 207 | + } | ||
| 208 | + | ||
| 177 | } | 209 | } |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceImpl.java
| @@ -23,6 +23,7 @@ import cn.iocoder.yudao.module.iot.service.product.IotProductService; | @@ -23,6 +23,7 @@ import cn.iocoder.yudao.module.iot.service.product.IotProductService; | ||
| 23 | import cn.iocoder.yudao.module.iot.service.rule.scene.action.IotSceneRuleAction; | 23 | import cn.iocoder.yudao.module.iot.service.rule.scene.action.IotSceneRuleAction; |
| 24 | import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherManager; | 24 | import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherManager; |
| 25 | import cn.iocoder.yudao.module.iot.service.rule.scene.timer.IotSceneRuleTimerHandler; | 25 | import cn.iocoder.yudao.module.iot.service.rule.scene.timer.IotSceneRuleTimerHandler; |
| 26 | +import cn.iocoder.yudao.module.iot.service.rule.scene.timer.IotTimerConditionEvaluator; | ||
| 26 | import jakarta.annotation.Resource; | 27 | import jakarta.annotation.Resource; |
| 27 | import lombok.extern.slf4j.Slf4j; | 28 | import lombok.extern.slf4j.Slf4j; |
| 28 | import org.springframework.cache.annotation.CacheEvict; | 29 | import org.springframework.cache.annotation.CacheEvict; |
| @@ -62,6 +63,8 @@ public class IotSceneRuleServiceImpl implements IotSceneRuleService { | @@ -62,6 +63,8 @@ public class IotSceneRuleServiceImpl implements IotSceneRuleService { | ||
| 62 | private List<IotSceneRuleAction> sceneRuleActions; | 63 | private List<IotSceneRuleAction> sceneRuleActions; |
| 63 | @Resource | 64 | @Resource |
| 64 | private IotSceneRuleTimerHandler timerHandler; | 65 | private IotSceneRuleTimerHandler timerHandler; |
| 66 | + @Resource | ||
| 67 | + private IotTimerConditionEvaluator timerConditionEvaluator; | ||
| 65 | 68 | ||
| 66 | @Override | 69 | @Override |
| 67 | @CacheEvict(value = RedisKeyConstants.SCENE_RULE_LIST, allEntries = true) | 70 | @CacheEvict(value = RedisKeyConstants.SCENE_RULE_LIST, allEntries = true) |
| @@ -222,19 +225,99 @@ public class IotSceneRuleServiceImpl implements IotSceneRuleService { | @@ -222,19 +225,99 @@ public class IotSceneRuleServiceImpl implements IotSceneRuleService { | ||
| 222 | return; | 225 | return; |
| 223 | } | 226 | } |
| 224 | // 1.2 判断是否有定时触发器,避免脏数据 | 227 | // 1.2 判断是否有定时触发器,避免脏数据 |
| 225 | - IotSceneRuleDO.Trigger config = CollUtil.findOne(scene.getTriggers(), | 228 | + IotSceneRuleDO.Trigger timerTrigger = CollUtil.findOne(scene.getTriggers(), |
| 226 | trigger -> ObjUtil.equals(trigger.getType(), IotSceneRuleTriggerTypeEnum.TIMER.getType())); | 229 | trigger -> ObjUtil.equals(trigger.getType(), IotSceneRuleTriggerTypeEnum.TIMER.getType())); |
| 227 | - if (config == null) { | 230 | + if (timerTrigger == null) { |
| 228 | log.error("[executeSceneRuleByTimer][规则场景({}) 不存在定时触发器]", scene); | 231 | log.error("[executeSceneRuleByTimer][规则场景({}) 不存在定时触发器]", scene); |
| 229 | return; | 232 | return; |
| 230 | } | 233 | } |
| 231 | 234 | ||
| 232 | - // 2. 执行规则场景 | 235 | + // 2. 评估条件组(新增逻辑) |
| 236 | + log.info("[executeSceneRuleByTimer][规则场景({}) 开始评估条件组]", id); | ||
| 237 | + if (!evaluateTimerConditionGroups(scene, timerTrigger)) { | ||
| 238 | + log.info("[executeSceneRuleByTimer][规则场景({}) 条件组不满足,跳过执行]", id); | ||
| 239 | + return; | ||
| 240 | + } | ||
| 241 | + log.info("[executeSceneRuleByTimer][规则场景({}) 条件组评估通过,准备执行动作]", id); | ||
| 242 | + | ||
| 243 | + // 3. 执行规则场景 | ||
| 233 | TenantUtils.execute(scene.getTenantId(), | 244 | TenantUtils.execute(scene.getTenantId(), |
| 234 | () -> executeSceneRuleAction(null, ListUtil.toList(scene))); | 245 | () -> executeSceneRuleAction(null, ListUtil.toList(scene))); |
| 235 | } | 246 | } |
| 236 | 247 | ||
| 237 | /** | 248 | /** |
| 249 | + * 评估定时触发器的条件组 | ||
| 250 | + * | ||
| 251 | + * @param scene 场景规则 | ||
| 252 | + * @param trigger 定时触发器 | ||
| 253 | + * @return 是否满足条件 | ||
| 254 | + */ | ||
| 255 | + private boolean evaluateTimerConditionGroups(IotSceneRuleDO scene, IotSceneRuleDO.Trigger trigger) { | ||
| 256 | + // 1. 如果没有条件组,直接返回 true(直接执行动作) | ||
| 257 | + if (CollUtil.isEmpty(trigger.getConditionGroups())) { | ||
| 258 | + log.debug("[evaluateTimerConditionGroups][规则场景({}) 无条件组配置,直接执行]", scene.getId()); | ||
| 259 | + return true; | ||
| 260 | + } | ||
| 261 | + | ||
| 262 | + // 2. 条件组之间是 OR 关系,任一条件组满足即可 | ||
| 263 | + for (List<IotSceneRuleDO.TriggerCondition> conditionGroup : trigger.getConditionGroups()) { | ||
| 264 | + if (evaluateSingleConditionGroup(scene, conditionGroup)) { | ||
| 265 | + log.debug("[evaluateTimerConditionGroups][规则场景({}) 条件组匹配成功]", scene.getId()); | ||
| 266 | + return true; | ||
| 267 | + } | ||
| 268 | + } | ||
| 269 | + | ||
| 270 | + // 3. 所有条件组都不满足 | ||
| 271 | + log.debug("[evaluateTimerConditionGroups][规则场景({}) 所有条件组都不满足]", scene.getId()); | ||
| 272 | + return false; | ||
| 273 | + } | ||
| 274 | + | ||
| 275 | + /** | ||
| 276 | + * 评估单个条件组 | ||
| 277 | + * | ||
| 278 | + * @param scene 场景规则 | ||
| 279 | + * @param conditionGroup 条件组 | ||
| 280 | + * @return 是否满足条件 | ||
| 281 | + */ | ||
| 282 | + private boolean evaluateSingleConditionGroup(IotSceneRuleDO scene, | ||
| 283 | + List<IotSceneRuleDO.TriggerCondition> conditionGroup) { | ||
| 284 | + // 1. 空条件组视为满足 | ||
| 285 | + if (CollUtil.isEmpty(conditionGroup)) { | ||
| 286 | + return true; | ||
| 287 | + } | ||
| 288 | + | ||
| 289 | + // 2. 条件之间是 AND 关系,所有条件都必须满足 | ||
| 290 | + for (IotSceneRuleDO.TriggerCondition condition : conditionGroup) { | ||
| 291 | + if (!evaluateTimerCondition(scene, condition)) { | ||
| 292 | + log.debug("[evaluateSingleConditionGroup][规则场景({}) 条件({}) 不满足]", | ||
| 293 | + scene.getId(), condition); | ||
| 294 | + return false; | ||
| 295 | + } | ||
| 296 | + } | ||
| 297 | + | ||
| 298 | + return true; | ||
| 299 | + } | ||
| 300 | + | ||
| 301 | + /** | ||
| 302 | + * 评估单个条件(定时触发器专用) | ||
| 303 | + * | ||
| 304 | + * @param scene 场景规则 | ||
| 305 | + * @param condition 条件 | ||
| 306 | + * @return 是否满足条件 | ||
| 307 | + */ | ||
| 308 | + private boolean evaluateTimerCondition(IotSceneRuleDO scene, IotSceneRuleDO.TriggerCondition condition) { | ||
| 309 | + try { | ||
| 310 | + boolean result = timerConditionEvaluator.evaluate(condition); | ||
| 311 | + log.debug("[evaluateTimerCondition][规则场景({}) 条件类型({}) 评估结果: {}]", | ||
| 312 | + scene.getId(), condition.getType(), result); | ||
| 313 | + return result; | ||
| 314 | + } catch (Exception e) { | ||
| 315 | + log.error("[evaluateTimerCondition][规则场景({}) 条件评估异常]", scene.getId(), e); | ||
| 316 | + return false; | ||
| 317 | + } | ||
| 318 | + } | ||
| 319 | + | ||
| 320 | + /** | ||
| 238 | * 基于消息,获得匹配的规则场景列表 | 321 | * 基于消息,获得匹配的规则场景列表 |
| 239 | * | 322 | * |
| 240 | * @param message 设备消息 | 323 | * @param message 设备消息 |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleTimeHelper.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.service.rule.scene; | ||
| 2 | + | ||
| 3 | +import cn.hutool.core.lang.Assert; | ||
| 4 | +import cn.hutool.core.text.CharPool; | ||
| 5 | +import cn.hutool.core.util.StrUtil; | ||
| 6 | +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum; | ||
| 7 | +import lombok.extern.slf4j.Slf4j; | ||
| 8 | + | ||
| 9 | +import java.time.LocalDateTime; | ||
| 10 | +import java.time.LocalTime; | ||
| 11 | +import java.time.ZoneId; | ||
| 12 | +import java.time.format.DateTimeFormatter; | ||
| 13 | +import java.util.List; | ||
| 14 | + | ||
| 15 | +/** | ||
| 16 | + * IoT 场景规则时间匹配工具类 | ||
| 17 | + * <p> | ||
| 18 | + * 提供时间条件匹配的通用方法,供 {@link cn.iocoder.yudao.module.iot.service.rule.scene.matcher.condition.IotCurrentTimeConditionMatcher} | ||
| 19 | + * 和 {@link cn.iocoder.yudao.module.iot.service.rule.scene.timer.IotTimerConditionEvaluator} 共同使用。 | ||
| 20 | + * | ||
| 21 | + * @author HUIHUI | ||
| 22 | + */ | ||
| 23 | +@Slf4j | ||
| 24 | +public class IotSceneRuleTimeHelper { | ||
| 25 | + | ||
| 26 | + /** | ||
| 27 | + * 时间格式化器 - HH:mm:ss | ||
| 28 | + */ | ||
| 29 | + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss"); | ||
| 30 | + | ||
| 31 | + /** | ||
| 32 | + * 时间格式化器 - HH:mm | ||
| 33 | + */ | ||
| 34 | + private static final DateTimeFormatter TIME_FORMATTER_SHORT = DateTimeFormatter.ofPattern("HH:mm"); | ||
| 35 | + | ||
| 36 | + private IotSceneRuleTimeHelper() { | ||
| 37 | + // 工具类,禁止实例化 | ||
| 38 | + } | ||
| 39 | + | ||
| 40 | + /** | ||
| 41 | + * 判断是否为日期时间操作符 | ||
| 42 | + * | ||
| 43 | + * @param operatorEnum 操作符枚举 | ||
| 44 | + * @return 是否为日期时间操作符 | ||
| 45 | + */ | ||
| 46 | + public static boolean isDateTimeOperator(IotSceneRuleConditionOperatorEnum operatorEnum) { | ||
| 47 | + return operatorEnum == IotSceneRuleConditionOperatorEnum.DATE_TIME_GREATER_THAN | ||
| 48 | + || operatorEnum == IotSceneRuleConditionOperatorEnum.DATE_TIME_LESS_THAN | ||
| 49 | + || operatorEnum == IotSceneRuleConditionOperatorEnum.DATE_TIME_BETWEEN; | ||
| 50 | + } | ||
| 51 | + | ||
| 52 | + /** | ||
| 53 | + * 判断是否为时间操作符(包括日期时间操作符和当日时间操作符) | ||
| 54 | + * | ||
| 55 | + * @param operatorEnum 操作符枚举 | ||
| 56 | + * @return 是否为时间操作符 | ||
| 57 | + */ | ||
| 58 | + public static boolean isTimeOperator(IotSceneRuleConditionOperatorEnum operatorEnum) { | ||
| 59 | + return operatorEnum != IotSceneRuleConditionOperatorEnum.TIME_GREATER_THAN | ||
| 60 | + && operatorEnum != IotSceneRuleConditionOperatorEnum.TIME_LESS_THAN | ||
| 61 | + && operatorEnum != IotSceneRuleConditionOperatorEnum.TIME_BETWEEN | ||
| 62 | + && !isDateTimeOperator(operatorEnum); | ||
| 63 | + } | ||
| 64 | + | ||
| 65 | + /** | ||
| 66 | + * 执行时间匹配逻辑 | ||
| 67 | + * | ||
| 68 | + * @param operatorEnum 操作符枚举 | ||
| 69 | + * @param param 参数值 | ||
| 70 | + * @return 是否匹配 | ||
| 71 | + */ | ||
| 72 | + public static boolean executeTimeMatching(IotSceneRuleConditionOperatorEnum operatorEnum, String param) { | ||
| 73 | + try { | ||
| 74 | + LocalDateTime now = LocalDateTime.now(); | ||
| 75 | + if (isDateTimeOperator(operatorEnum)) { | ||
| 76 | + // 日期时间匹配(时间戳,秒级) | ||
| 77 | + long currentTimestamp = now.atZone(ZoneId.systemDefault()).toEpochSecond(); | ||
| 78 | + return matchDateTime(currentTimestamp, operatorEnum, param); | ||
| 79 | + } else { | ||
| 80 | + // 当日时间匹配(HH:mm:ss) | ||
| 81 | + return matchTime(now.toLocalTime(), operatorEnum, param); | ||
| 82 | + } | ||
| 83 | + } catch (Exception e) { | ||
| 84 | + log.error("[executeTimeMatching][operatorEnum({}) param({}) 时间匹配异常]", operatorEnum, param, e); | ||
| 85 | + return false; | ||
| 86 | + } | ||
| 87 | + } | ||
| 88 | + | ||
| 89 | + /** | ||
| 90 | + * 匹配日期时间(时间戳,秒级) | ||
| 91 | + * | ||
| 92 | + * @param currentTimestamp 当前时间戳 | ||
| 93 | + * @param operatorEnum 操作符枚举 | ||
| 94 | + * @param param 参数值 | ||
| 95 | + * @return 是否匹配 | ||
| 96 | + */ | ||
| 97 | + public static boolean matchDateTime(long currentTimestamp, IotSceneRuleConditionOperatorEnum operatorEnum, | ||
| 98 | + String param) { | ||
| 99 | + try { | ||
| 100 | + // DATE_TIME_BETWEEN 需要解析两个时间戳,单独处理 | ||
| 101 | + if (operatorEnum == IotSceneRuleConditionOperatorEnum.DATE_TIME_BETWEEN) { | ||
| 102 | + return matchDateTimeBetween(currentTimestamp, param); | ||
| 103 | + } | ||
| 104 | + // 其他操作符只需要解析一个时间戳 | ||
| 105 | + long targetTimestamp = Long.parseLong(param); | ||
| 106 | + switch (operatorEnum) { | ||
| 107 | + case DATE_TIME_GREATER_THAN: | ||
| 108 | + return currentTimestamp > targetTimestamp; | ||
| 109 | + case DATE_TIME_LESS_THAN: | ||
| 110 | + return currentTimestamp < targetTimestamp; | ||
| 111 | + default: | ||
| 112 | + log.warn("[matchDateTime][operatorEnum({}) 不支持的日期时间操作符]", operatorEnum); | ||
| 113 | + return false; | ||
| 114 | + } | ||
| 115 | + } catch (Exception e) { | ||
| 116 | + log.error("[matchDateTime][operatorEnum({}) param({}) 日期时间匹配异常]", operatorEnum, param, e); | ||
| 117 | + return false; | ||
| 118 | + } | ||
| 119 | + } | ||
| 120 | + | ||
| 121 | + /** | ||
| 122 | + * 匹配日期时间区间 | ||
| 123 | + * | ||
| 124 | + * @param currentTimestamp 当前时间戳 | ||
| 125 | + * @param param 参数值(格式:startTimestamp,endTimestamp) | ||
| 126 | + * @return 是否匹配 | ||
| 127 | + */ | ||
| 128 | + public static boolean matchDateTimeBetween(long currentTimestamp, String param) { | ||
| 129 | + List<String> timestampRange = StrUtil.splitTrim(param, CharPool.COMMA); | ||
| 130 | + if (timestampRange.size() != 2) { | ||
| 131 | + log.warn("[matchDateTimeBetween][param({}) 时间戳区间参数格式错误]", param); | ||
| 132 | + return false; | ||
| 133 | + } | ||
| 134 | + long startTimestamp = Long.parseLong(timestampRange.get(0).trim()); | ||
| 135 | + long endTimestamp = Long.parseLong(timestampRange.get(1).trim()); | ||
| 136 | + return currentTimestamp >= startTimestamp && currentTimestamp <= endTimestamp; | ||
| 137 | + } | ||
| 138 | + | ||
| 139 | + /** | ||
| 140 | + * 匹配当日时间(HH:mm:ss 或 HH:mm) | ||
| 141 | + * | ||
| 142 | + * @param currentTime 当前时间 | ||
| 143 | + * @param operatorEnum 操作符枚举 | ||
| 144 | + * @param param 参数值 | ||
| 145 | + * @return 是否匹配 | ||
| 146 | + */ | ||
| 147 | + public static boolean matchTime(LocalTime currentTime, IotSceneRuleConditionOperatorEnum operatorEnum, | ||
| 148 | + String param) { | ||
| 149 | + try { | ||
| 150 | + // TIME_BETWEEN 需要解析两个时间,单独处理 | ||
| 151 | + if (operatorEnum == IotSceneRuleConditionOperatorEnum.TIME_BETWEEN) { | ||
| 152 | + return matchTimeBetween(currentTime, param); | ||
| 153 | + } | ||
| 154 | + // 其他操作符只需要解析一个时间 | ||
| 155 | + LocalTime targetTime = parseTime(param); | ||
| 156 | + switch (operatorEnum) { | ||
| 157 | + case TIME_GREATER_THAN: | ||
| 158 | + return currentTime.isAfter(targetTime); | ||
| 159 | + case TIME_LESS_THAN: | ||
| 160 | + return currentTime.isBefore(targetTime); | ||
| 161 | + default: | ||
| 162 | + log.warn("[matchTime][operatorEnum({}) 不支持的时间操作符]", operatorEnum); | ||
| 163 | + return false; | ||
| 164 | + } | ||
| 165 | + } catch (Exception e) { | ||
| 166 | + log.error("[matchTime][operatorEnum({}) param({}) 时间解析异常]", operatorEnum, param, e); | ||
| 167 | + return false; | ||
| 168 | + } | ||
| 169 | + } | ||
| 170 | + | ||
| 171 | + /** | ||
| 172 | + * 匹配时间区间 | ||
| 173 | + * | ||
| 174 | + * @param currentTime 当前时间 | ||
| 175 | + * @param param 参数值(格式:startTime,endTime) | ||
| 176 | + * @return 是否匹配 | ||
| 177 | + */ | ||
| 178 | + public static boolean matchTimeBetween(LocalTime currentTime, String param) { | ||
| 179 | + List<String> timeRange = StrUtil.splitTrim(param, CharPool.COMMA); | ||
| 180 | + if (timeRange.size() != 2) { | ||
| 181 | + log.warn("[matchTimeBetween][param({}) 时间区间参数格式错误]", param); | ||
| 182 | + return false; | ||
| 183 | + } | ||
| 184 | + LocalTime startTime = parseTime(timeRange.get(0).trim()); | ||
| 185 | + LocalTime endTime = parseTime(timeRange.get(1).trim()); | ||
| 186 | + return !currentTime.isBefore(startTime) && !currentTime.isAfter(endTime); | ||
| 187 | + } | ||
| 188 | + | ||
| 189 | + /** | ||
| 190 | + * 解析时间字符串 | ||
| 191 | + * 支持 HH:mm 和 HH:mm:ss 两种格式 | ||
| 192 | + * | ||
| 193 | + * @param timeStr 时间字符串 | ||
| 194 | + * @return 解析后的 LocalTime | ||
| 195 | + */ | ||
| 196 | + public static LocalTime parseTime(String timeStr) { | ||
| 197 | + Assert.isFalse(StrUtil.isBlank(timeStr), "时间字符串不能为空"); | ||
| 198 | + try { | ||
| 199 | + // 尝试不同的时间格式 | ||
| 200 | + if (timeStr.length() == 5) { // HH:mm | ||
| 201 | + return LocalTime.parse(timeStr, TIME_FORMATTER_SHORT); | ||
| 202 | + } else if (timeStr.length() == 8) { // HH:mm:ss | ||
| 203 | + return LocalTime.parse(timeStr, TIME_FORMATTER); | ||
| 204 | + } else { | ||
| 205 | + throw new IllegalArgumentException("时间格式长度不正确,期望 HH:mm 或 HH:mm:ss 格式"); | ||
| 206 | + } | ||
| 207 | + } catch (Exception e) { | ||
| 208 | + log.error("[parseTime][timeStr({}) 时间格式解析失败]", timeStr, e); | ||
| 209 | + throw new IllegalArgumentException("时间格式无效: " + timeStr, e); | ||
| 210 | + } | ||
| 211 | + } | ||
| 212 | + | ||
| 213 | +} |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/IotCurrentTimeConditionMatcher.java
| 1 | package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.condition; | 1 | package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.condition; |
| 2 | 2 | ||
| 3 | -import cn.hutool.core.lang.Assert; | ||
| 4 | -import cn.hutool.core.text.CharPool; | ||
| 5 | -import cn.hutool.core.util.StrUtil; | ||
| 6 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | 3 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 7 | import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; | 4 | import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; |
| 8 | import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum; | 5 | import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum; |
| 9 | import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionTypeEnum; | 6 | import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionTypeEnum; |
| 7 | +import cn.iocoder.yudao.module.iot.service.rule.scene.IotSceneRuleTimeHelper; | ||
| 10 | import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherHelper; | 8 | import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherHelper; |
| 11 | import lombok.extern.slf4j.Slf4j; | 9 | import lombok.extern.slf4j.Slf4j; |
| 12 | import org.springframework.stereotype.Component; | 10 | import org.springframework.stereotype.Component; |
| 13 | 11 | ||
| 14 | -import java.time.LocalDateTime; | ||
| 15 | -import java.time.LocalTime; | ||
| 16 | -import java.time.format.DateTimeFormatter; | ||
| 17 | -import java.util.List; | ||
| 18 | - | ||
| 19 | /** | 12 | /** |
| 20 | * 当前时间条件匹配器:处理时间相关的子条件匹配逻辑 | 13 | * 当前时间条件匹配器:处理时间相关的子条件匹配逻辑 |
| 21 | * | 14 | * |
| @@ -25,16 +18,6 @@ import java.util.List; | @@ -25,16 +18,6 @@ import java.util.List; | ||
| 25 | @Slf4j | 18 | @Slf4j |
| 26 | public class IotCurrentTimeConditionMatcher implements IotSceneRuleConditionMatcher { | 19 | public class IotCurrentTimeConditionMatcher implements IotSceneRuleConditionMatcher { |
| 27 | 20 | ||
| 28 | - /** | ||
| 29 | - * 时间格式化器 - HH:mm:ss | ||
| 30 | - */ | ||
| 31 | - private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss"); | ||
| 32 | - | ||
| 33 | - /** | ||
| 34 | - * 时间格式化器 - HH:mm | ||
| 35 | - */ | ||
| 36 | - private static final DateTimeFormatter TIME_FORMATTER_SHORT = DateTimeFormatter.ofPattern("HH:mm"); | ||
| 37 | - | ||
| 38 | @Override | 21 | @Override |
| 39 | public IotSceneRuleConditionTypeEnum getSupportedConditionType() { | 22 | public IotSceneRuleConditionTypeEnum getSupportedConditionType() { |
| 40 | return IotSceneRuleConditionTypeEnum.CURRENT_TIME; | 23 | return IotSceneRuleConditionTypeEnum.CURRENT_TIME; |
| @@ -62,13 +45,13 @@ public class IotCurrentTimeConditionMatcher implements IotSceneRuleConditionMatc | @@ -62,13 +45,13 @@ public class IotCurrentTimeConditionMatcher implements IotSceneRuleConditionMatc | ||
| 62 | return false; | 45 | return false; |
| 63 | } | 46 | } |
| 64 | 47 | ||
| 65 | - if (!isTimeOperator(operatorEnum)) { | 48 | + if (IotSceneRuleTimeHelper.isTimeOperator(operatorEnum)) { |
| 66 | IotSceneRuleMatcherHelper.logConditionMatchFailure(message, condition, "不支持的时间操作符: " + operator); | 49 | IotSceneRuleMatcherHelper.logConditionMatchFailure(message, condition, "不支持的时间操作符: " + operator); |
| 67 | return false; | 50 | return false; |
| 68 | } | 51 | } |
| 69 | 52 | ||
| 70 | // 2.1 执行时间匹配 | 53 | // 2.1 执行时间匹配 |
| 71 | - boolean matched = executeTimeMatching(operatorEnum, condition.getParam()); | 54 | + boolean matched = IotSceneRuleTimeHelper.executeTimeMatching(operatorEnum, condition.getParam()); |
| 72 | 55 | ||
| 73 | // 2.2 记录匹配结果 | 56 | // 2.2 记录匹配结果 |
| 74 | if (matched) { | 57 | if (matched) { |
| @@ -80,145 +63,6 @@ public class IotCurrentTimeConditionMatcher implements IotSceneRuleConditionMatc | @@ -80,145 +63,6 @@ public class IotCurrentTimeConditionMatcher implements IotSceneRuleConditionMatc | ||
| 80 | return matched; | 63 | return matched; |
| 81 | } | 64 | } |
| 82 | 65 | ||
| 83 | - /** | ||
| 84 | - * 执行时间匹配逻辑 | ||
| 85 | - * 直接实现时间条件匹配,不使用 Spring EL 表达式 | ||
| 86 | - */ | ||
| 87 | - private boolean executeTimeMatching(IotSceneRuleConditionOperatorEnum operatorEnum, String param) { | ||
| 88 | - try { | ||
| 89 | - LocalDateTime now = LocalDateTime.now(); | ||
| 90 | - | ||
| 91 | - if (isDateTimeOperator(operatorEnum)) { | ||
| 92 | - // 日期时间匹配(时间戳) | ||
| 93 | - long currentTimestamp = now.toEpochSecond(java.time.ZoneOffset.of("+8")); | ||
| 94 | - return matchDateTime(currentTimestamp, operatorEnum, param); | ||
| 95 | - } else { | ||
| 96 | - // 当日时间匹配(HH:mm:ss) | ||
| 97 | - return matchTime(now.toLocalTime(), operatorEnum, param); | ||
| 98 | - } | ||
| 99 | - } catch (Exception e) { | ||
| 100 | - log.error("[executeTimeMatching][operatorEnum({}) param({}) 时间匹配异常]", operatorEnum, param, e); | ||
| 101 | - return false; | ||
| 102 | - } | ||
| 103 | - } | ||
| 104 | - | ||
| 105 | - /** | ||
| 106 | - * 判断是否为日期时间操作符 | ||
| 107 | - */ | ||
| 108 | - private boolean isDateTimeOperator(IotSceneRuleConditionOperatorEnum operatorEnum) { | ||
| 109 | - return operatorEnum == IotSceneRuleConditionOperatorEnum.DATE_TIME_GREATER_THAN || | ||
| 110 | - operatorEnum == IotSceneRuleConditionOperatorEnum.DATE_TIME_LESS_THAN || | ||
| 111 | - operatorEnum == IotSceneRuleConditionOperatorEnum.DATE_TIME_BETWEEN; | ||
| 112 | - } | ||
| 113 | - | ||
| 114 | - /** | ||
| 115 | - * 判断是否为时间操作符 | ||
| 116 | - */ | ||
| 117 | - private boolean isTimeOperator(IotSceneRuleConditionOperatorEnum operatorEnum) { | ||
| 118 | - return operatorEnum == IotSceneRuleConditionOperatorEnum.TIME_GREATER_THAN || | ||
| 119 | - operatorEnum == IotSceneRuleConditionOperatorEnum.TIME_LESS_THAN || | ||
| 120 | - operatorEnum == IotSceneRuleConditionOperatorEnum.TIME_BETWEEN || | ||
| 121 | - isDateTimeOperator(operatorEnum); | ||
| 122 | - } | ||
| 123 | - | ||
| 124 | - /** | ||
| 125 | - * 匹配日期时间(时间戳) | ||
| 126 | - * 直接实现时间戳比较逻辑 | ||
| 127 | - */ | ||
| 128 | - private boolean matchDateTime(long currentTimestamp, IotSceneRuleConditionOperatorEnum operatorEnum, String param) { | ||
| 129 | - try { | ||
| 130 | - long targetTimestamp = Long.parseLong(param); | ||
| 131 | - switch (operatorEnum) { | ||
| 132 | - case DATE_TIME_GREATER_THAN: | ||
| 133 | - return currentTimestamp > targetTimestamp; | ||
| 134 | - case DATE_TIME_LESS_THAN: | ||
| 135 | - return currentTimestamp < targetTimestamp; | ||
| 136 | - case DATE_TIME_BETWEEN: | ||
| 137 | - return matchDateTimeBetween(currentTimestamp, param); | ||
| 138 | - default: | ||
| 139 | - log.warn("[matchDateTime][operatorEnum({}) 不支持的日期时间操作符]", operatorEnum); | ||
| 140 | - return false; | ||
| 141 | - } | ||
| 142 | - } catch (Exception e) { | ||
| 143 | - log.error("[matchDateTime][operatorEnum({}) param({}) 日期时间匹配异常]", operatorEnum, param, e); | ||
| 144 | - return false; | ||
| 145 | - } | ||
| 146 | - } | ||
| 147 | - | ||
| 148 | - /** | ||
| 149 | - * 匹配日期时间区间 | ||
| 150 | - */ | ||
| 151 | - private boolean matchDateTimeBetween(long currentTimestamp, String param) { | ||
| 152 | - List<String> timestampRange = StrUtil.splitTrim(param, CharPool.COMMA); | ||
| 153 | - if (timestampRange.size() != 2) { | ||
| 154 | - log.warn("[matchDateTimeBetween][param({}) 时间戳区间参数格式错误]", param); | ||
| 155 | - return false; | ||
| 156 | - } | ||
| 157 | - long startTimestamp = Long.parseLong(timestampRange.get(0).trim()); | ||
| 158 | - long endTimestamp = Long.parseLong(timestampRange.get(1).trim()); | ||
| 159 | - return currentTimestamp >= startTimestamp && currentTimestamp <= endTimestamp; | ||
| 160 | - } | ||
| 161 | - | ||
| 162 | - /** | ||
| 163 | - * 匹配当日时间(HH:mm:ss) | ||
| 164 | - * 直接实现时间比较逻辑 | ||
| 165 | - */ | ||
| 166 | - private boolean matchTime(LocalTime currentTime, IotSceneRuleConditionOperatorEnum operatorEnum, String param) { | ||
| 167 | - try { | ||
| 168 | - LocalTime targetTime = parseTime(param); | ||
| 169 | - switch (operatorEnum) { | ||
| 170 | - case TIME_GREATER_THAN: | ||
| 171 | - return currentTime.isAfter(targetTime); | ||
| 172 | - case TIME_LESS_THAN: | ||
| 173 | - return currentTime.isBefore(targetTime); | ||
| 174 | - case TIME_BETWEEN: | ||
| 175 | - return matchTimeBetween(currentTime, param); | ||
| 176 | - default: | ||
| 177 | - log.warn("[matchTime][operatorEnum({}) 不支持的时间操作符]", operatorEnum); | ||
| 178 | - return false; | ||
| 179 | - } | ||
| 180 | - } catch (Exception e) { | ||
| 181 | - log.error("[matchTime][][operatorEnum({}) param({}) 时间解析异常]", operatorEnum, param, e); | ||
| 182 | - return false; | ||
| 183 | - } | ||
| 184 | - } | ||
| 185 | - | ||
| 186 | - /** | ||
| 187 | - * 匹配时间区间 | ||
| 188 | - */ | ||
| 189 | - private boolean matchTimeBetween(LocalTime currentTime, String param) { | ||
| 190 | - List<String> timeRange = StrUtil.splitTrim(param, CharPool.COMMA); | ||
| 191 | - if (timeRange.size() != 2) { | ||
| 192 | - log.warn("[matchTimeBetween][param({}) 时间区间参数格式错误]", param); | ||
| 193 | - return false; | ||
| 194 | - } | ||
| 195 | - LocalTime startTime = parseTime(timeRange.get(0).trim()); | ||
| 196 | - LocalTime endTime = parseTime(timeRange.get(1).trim()); | ||
| 197 | - return !currentTime.isBefore(startTime) && !currentTime.isAfter(endTime); | ||
| 198 | - } | ||
| 199 | - | ||
| 200 | - /** | ||
| 201 | - * 解析时间字符串 | ||
| 202 | - * 支持 HH:mm 和 HH:mm:ss 两种格式 | ||
| 203 | - */ | ||
| 204 | - private LocalTime parseTime(String timeStr) { | ||
| 205 | - Assert.isFalse(StrUtil.isBlank(timeStr), "时间字符串不能为空"); | ||
| 206 | - | ||
| 207 | - try { | ||
| 208 | - // 尝试不同的时间格式 | ||
| 209 | - if (timeStr.length() == 5) { // HH:mm | ||
| 210 | - return LocalTime.parse(timeStr, TIME_FORMATTER_SHORT); | ||
| 211 | - } else if (timeStr.length() == 8) { // HH:mm:ss | ||
| 212 | - return LocalTime.parse(timeStr, TIME_FORMATTER); | ||
| 213 | - } else { | ||
| 214 | - throw new IllegalArgumentException("时间格式长度不正确,期望 HH:mm 或 HH:mm:ss 格式"); | ||
| 215 | - } | ||
| 216 | - } catch (Exception e) { | ||
| 217 | - log.error("[parseTime][timeStr({}) 时间格式解析失败]", timeStr, e); | ||
| 218 | - throw new IllegalArgumentException("时间格式无效: " + timeStr, e); | ||
| 219 | - } | ||
| 220 | - } | ||
| 221 | - | ||
| 222 | @Override | 66 | @Override |
| 223 | public int getPriority() { | 67 | public int getPriority() { |
| 224 | return 40; // 较低优先级 | 68 | return 40; // 较低优先级 |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDevicePropertyPostTriggerMatcher.java
| @@ -38,8 +38,7 @@ public class IotDevicePropertyPostTriggerMatcher implements IotSceneRuleTriggerM | @@ -38,8 +38,7 @@ public class IotDevicePropertyPostTriggerMatcher implements IotSceneRuleTriggerM | ||
| 38 | 38 | ||
| 39 | // 1.3 检查消息中是否包含触发器指定的属性标识符 | 39 | // 1.3 检查消息中是否包含触发器指定的属性标识符 |
| 40 | // 注意:属性上报可能同时上报多个属性,所以需要判断 trigger.getIdentifier() 是否在 message 的 params 中 | 40 | // 注意:属性上报可能同时上报多个属性,所以需要判断 trigger.getIdentifier() 是否在 message 的 params 中 |
| 41 | - // TODO @puhui999:可以考虑 notXXX 方法,简化代码(尽量取反) | ||
| 42 | - if (!IotDeviceMessageUtils.containsIdentifier(message, trigger.getIdentifier())) { | 41 | + if (IotDeviceMessageUtils.notContainsIdentifier(message, trigger.getIdentifier())) { |
| 43 | IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "消息中不包含属性: " + | 42 | IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "消息中不包含属性: " + |
| 44 | trigger.getIdentifier()); | 43 | trigger.getIdentifier()); |
| 45 | return false; | 44 | return false; |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceServiceInvokeTriggerMatcher.java
| 1 | package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.trigger; | 1 | package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.trigger; |
| 2 | 2 | ||
| 3 | +import cn.hutool.core.util.StrUtil; | ||
| 3 | import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | 4 | import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; |
| 4 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | 5 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 5 | import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; | 6 | import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; |
| @@ -8,6 +9,8 @@ import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; | @@ -8,6 +9,8 @@ import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; | ||
| 8 | import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherHelper; | 9 | import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherHelper; |
| 9 | import org.springframework.stereotype.Component; | 10 | import org.springframework.stereotype.Component; |
| 10 | 11 | ||
| 12 | +import java.util.Map; | ||
| 13 | + | ||
| 11 | /** | 14 | /** |
| 12 | * 设备服务调用触发器匹配器:处理设备服务调用的触发器匹配逻辑 | 15 | * 设备服务调用触发器匹配器:处理设备服务调用的触发器匹配逻辑 |
| 13 | * | 16 | * |
| @@ -42,13 +45,58 @@ public class IotDeviceServiceInvokeTriggerMatcher implements IotSceneRuleTrigger | @@ -42,13 +45,58 @@ public class IotDeviceServiceInvokeTriggerMatcher implements IotSceneRuleTrigger | ||
| 42 | return false; | 45 | return false; |
| 43 | } | 46 | } |
| 44 | 47 | ||
| 45 | - // 2. 对于服务调用触发器,通常只需要匹配服务标识符即可 | ||
| 46 | - // 不需要检查操作符和值,因为服务调用本身就是触发条件 | ||
| 47 | - // TODO @puhui999: 服务调用时校验输入参数是否匹配条件? | 48 | + // 2. 检查是否配置了参数条件 |
| 49 | + if (hasParameterCondition(trigger)) { | ||
| 50 | + return matchParameterCondition(message, trigger); | ||
| 51 | + } | ||
| 52 | + | ||
| 53 | + // 3. 无参数条件时,标识符匹配即成功 | ||
| 48 | IotSceneRuleMatcherHelper.logTriggerMatchSuccess(message, trigger); | 54 | IotSceneRuleMatcherHelper.logTriggerMatchSuccess(message, trigger); |
| 49 | return true; | 55 | return true; |
| 50 | } | 56 | } |
| 51 | 57 | ||
| 58 | + /** | ||
| 59 | + * 判断触发器是否配置了参数条件 | ||
| 60 | + * | ||
| 61 | + * @param trigger 触发器配置 | ||
| 62 | + * @return 是否配置了参数条件 | ||
| 63 | + */ | ||
| 64 | + private boolean hasParameterCondition(IotSceneRuleDO.Trigger trigger) { | ||
| 65 | + return StrUtil.isNotBlank(trigger.getOperator()) && StrUtil.isNotBlank(trigger.getValue()); | ||
| 66 | + } | ||
| 67 | + | ||
| 68 | + /** | ||
| 69 | + * 匹配参数条件 | ||
| 70 | + * | ||
| 71 | + * @param message 设备消息 | ||
| 72 | + * @param trigger 触发器配置 | ||
| 73 | + * @return 是否匹配 | ||
| 74 | + */ | ||
| 75 | + private boolean matchParameterCondition(IotDeviceMessage message, IotSceneRuleDO.Trigger trigger) { | ||
| 76 | + // 从消息中提取服务调用的输入参数 | ||
| 77 | + Map<String, Object> inputParams = IotDeviceMessageUtils.extractServiceInputParams(message); | ||
| 78 | + if (inputParams == null) { | ||
| 79 | + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "消息中缺少服务输入参数"); | ||
| 80 | + return false; | ||
| 81 | + } | ||
| 82 | + | ||
| 83 | + // 获取要匹配的参数值(使用 identifier 作为参数名) | ||
| 84 | + Object paramValue = inputParams.get(trigger.getIdentifier()); | ||
| 85 | + if (paramValue == null) { | ||
| 86 | + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "服务输入参数中缺少指定参数: " + trigger.getIdentifier()); | ||
| 87 | + return false; | ||
| 88 | + } | ||
| 89 | + | ||
| 90 | + // 使用条件评估器进行匹配 | ||
| 91 | + boolean matched = IotSceneRuleMatcherHelper.evaluateCondition(paramValue, trigger.getOperator(), trigger.getValue()); | ||
| 92 | + if (matched) { | ||
| 93 | + IotSceneRuleMatcherHelper.logTriggerMatchSuccess(message, trigger); | ||
| 94 | + } else { | ||
| 95 | + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "服务输入参数条件不匹配"); | ||
| 96 | + } | ||
| 97 | + return matched; | ||
| 98 | + } | ||
| 99 | + | ||
| 52 | @Override | 100 | @Override |
| 53 | public int getPriority() { | 101 | public int getPriority() { |
| 54 | return 40; // 较低优先级 | 102 | return 40; // 较低优先级 |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/timer/IotTimerConditionEvaluator.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.service.rule.scene.timer; | ||
| 2 | + | ||
| 3 | +import cn.hutool.core.util.StrUtil; | ||
| 4 | +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; | ||
| 5 | +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDevicePropertyDO; | ||
| 6 | +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; | ||
| 7 | +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum; | ||
| 8 | +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionTypeEnum; | ||
| 9 | +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; | ||
| 10 | +import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; | ||
| 11 | +import cn.iocoder.yudao.module.iot.service.rule.scene.IotSceneRuleTimeHelper; | ||
| 12 | +import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherHelper; | ||
| 13 | +import jakarta.annotation.Resource; | ||
| 14 | +import lombok.extern.slf4j.Slf4j; | ||
| 15 | +import org.springframework.stereotype.Component; | ||
| 16 | + | ||
| 17 | +import java.util.Map; | ||
| 18 | + | ||
| 19 | +/** | ||
| 20 | + * IoT 定时触发器条件评估器 | ||
| 21 | + * <p> | ||
| 22 | + * 与设备触发器不同,定时触发器没有设备消息上下文, | ||
| 23 | + * 需要主动查询设备属性和状态来评估条件。 | ||
| 24 | + * | ||
| 25 | + * @author HUIHUI | ||
| 26 | + */ | ||
| 27 | +@Component | ||
| 28 | +@Slf4j | ||
| 29 | +public class IotTimerConditionEvaluator { | ||
| 30 | + | ||
| 31 | + @Resource | ||
| 32 | + private IotDevicePropertyService devicePropertyService; | ||
| 33 | + | ||
| 34 | + @Resource | ||
| 35 | + private IotDeviceService deviceService; | ||
| 36 | + | ||
| 37 | + /** | ||
| 38 | + * 评估条件 | ||
| 39 | + * | ||
| 40 | + * @param condition 条件配置 | ||
| 41 | + * @return 是否满足条件 | ||
| 42 | + */ | ||
| 43 | + public boolean evaluate(IotSceneRuleDO.TriggerCondition condition) { | ||
| 44 | + // 1. 基础参数校验 | ||
| 45 | + if (condition == null || condition.getType() == null) { | ||
| 46 | + log.warn("[evaluate][条件为空或类型为空]"); | ||
| 47 | + return false; | ||
| 48 | + } | ||
| 49 | + | ||
| 50 | + // 2. 根据条件类型分发到具体的评估方法 | ||
| 51 | + IotSceneRuleConditionTypeEnum conditionType = | ||
| 52 | + IotSceneRuleConditionTypeEnum.typeOf(condition.getType()); | ||
| 53 | + if (conditionType == null) { | ||
| 54 | + log.warn("[evaluate][未知的条件类型: {}]", condition.getType()); | ||
| 55 | + return false; | ||
| 56 | + } | ||
| 57 | + | ||
| 58 | + switch (conditionType) { | ||
| 59 | + case DEVICE_PROPERTY: | ||
| 60 | + return evaluateDevicePropertyCondition(condition); | ||
| 61 | + case DEVICE_STATE: | ||
| 62 | + return evaluateDeviceStateCondition(condition); | ||
| 63 | + case CURRENT_TIME: | ||
| 64 | + return evaluateCurrentTimeCondition(condition); | ||
| 65 | + default: | ||
| 66 | + log.warn("[evaluate][未知的条件类型: {}]", conditionType); | ||
| 67 | + return false; | ||
| 68 | + } | ||
| 69 | + } | ||
| 70 | + | ||
| 71 | + /** | ||
| 72 | + * 评估设备属性条件 | ||
| 73 | + * | ||
| 74 | + * @param condition 条件配置 | ||
| 75 | + * @return 是否满足条件 | ||
| 76 | + */ | ||
| 77 | + private boolean evaluateDevicePropertyCondition(IotSceneRuleDO.TriggerCondition condition) { | ||
| 78 | + // 1. 校验必要参数 | ||
| 79 | + if (condition.getDeviceId() == null) { | ||
| 80 | + log.debug("[evaluateDevicePropertyCondition][设备ID为空]"); | ||
| 81 | + return false; | ||
| 82 | + } | ||
| 83 | + if (StrUtil.isBlank(condition.getIdentifier())) { | ||
| 84 | + log.debug("[evaluateDevicePropertyCondition][属性标识符为空]"); | ||
| 85 | + return false; | ||
| 86 | + } | ||
| 87 | + if (!IotSceneRuleMatcherHelper.isConditionOperatorAndParamValid(condition)) { | ||
| 88 | + log.debug("[evaluateDevicePropertyCondition][操作符或参数无效]"); | ||
| 89 | + return false; | ||
| 90 | + } | ||
| 91 | + | ||
| 92 | + // 2. 获取设备最新属性值 | ||
| 93 | + Map<String, IotDevicePropertyDO> properties = | ||
| 94 | + devicePropertyService.getLatestDeviceProperties(condition.getDeviceId()); | ||
| 95 | + if (properties == null || properties.isEmpty()) { | ||
| 96 | + log.debug("[evaluateDevicePropertyCondition][设备({}) 无属性数据]", condition.getDeviceId()); | ||
| 97 | + return false; | ||
| 98 | + } | ||
| 99 | + | ||
| 100 | + // 3. 获取指定属性 | ||
| 101 | + IotDevicePropertyDO property = properties.get(condition.getIdentifier()); | ||
| 102 | + if (property == null || property.getValue() == null) { | ||
| 103 | + log.debug("[evaluateDevicePropertyCondition][设备({}) 属性({}) 不存在或值为空]", | ||
| 104 | + condition.getDeviceId(), condition.getIdentifier()); | ||
| 105 | + return false; | ||
| 106 | + } | ||
| 107 | + | ||
| 108 | + // 4. 使用现有的条件评估逻辑进行比较 | ||
| 109 | + boolean matched = IotSceneRuleMatcherHelper.evaluateCondition( | ||
| 110 | + property.getValue(), condition.getOperator(), condition.getParam()); | ||
| 111 | + log.debug("[evaluateDevicePropertyCondition][设备({}) 属性({}) 值({}) 操作符({}) 参数({}) 匹配结果: {}]", | ||
| 112 | + condition.getDeviceId(), condition.getIdentifier(), property.getValue(), | ||
| 113 | + condition.getOperator(), condition.getParam(), matched); | ||
| 114 | + return matched; | ||
| 115 | + } | ||
| 116 | + | ||
| 117 | + /** | ||
| 118 | + * 评估设备状态条件 | ||
| 119 | + * | ||
| 120 | + * @param condition 条件配置 | ||
| 121 | + * @return 是否满足条件 | ||
| 122 | + */ | ||
| 123 | + private boolean evaluateDeviceStateCondition(IotSceneRuleDO.TriggerCondition condition) { | ||
| 124 | + // 1. 校验必要参数 | ||
| 125 | + if (condition.getDeviceId() == null) { | ||
| 126 | + log.debug("[evaluateDeviceStateCondition][设备ID为空]"); | ||
| 127 | + return false; | ||
| 128 | + } | ||
| 129 | + if (!IotSceneRuleMatcherHelper.isConditionOperatorAndParamValid(condition)) { | ||
| 130 | + log.debug("[evaluateDeviceStateCondition][操作符或参数无效]"); | ||
| 131 | + return false; | ||
| 132 | + } | ||
| 133 | + | ||
| 134 | + // 2. 获取设备信息 | ||
| 135 | + IotDeviceDO device = deviceService.getDevice(condition.getDeviceId()); | ||
| 136 | + if (device == null) { | ||
| 137 | + log.debug("[evaluateDeviceStateCondition][设备({}) 不存在]", condition.getDeviceId()); | ||
| 138 | + return false; | ||
| 139 | + } | ||
| 140 | + | ||
| 141 | + // 3. 获取设备状态 | ||
| 142 | + Integer state = device.getState(); | ||
| 143 | + if (state == null) { | ||
| 144 | + log.debug("[evaluateDeviceStateCondition][设备({}) 状态为空]", condition.getDeviceId()); | ||
| 145 | + return false; | ||
| 146 | + } | ||
| 147 | + | ||
| 148 | + // 4. 比较状态 | ||
| 149 | + boolean matched = IotSceneRuleMatcherHelper.evaluateCondition( | ||
| 150 | + state.toString(), condition.getOperator(), condition.getParam()); | ||
| 151 | + log.debug("[evaluateDeviceStateCondition][设备({}) 状态({}) 操作符({}) 参数({}) 匹配结果: {}]", | ||
| 152 | + condition.getDeviceId(), state, condition.getOperator(), condition.getParam(), matched); | ||
| 153 | + return matched; | ||
| 154 | + } | ||
| 155 | + | ||
| 156 | + /** | ||
| 157 | + * 评估当前时间条件 | ||
| 158 | + * | ||
| 159 | + * @param condition 条件配置 | ||
| 160 | + * @return 是否满足条件 | ||
| 161 | + */ | ||
| 162 | + private boolean evaluateCurrentTimeCondition(IotSceneRuleDO.TriggerCondition condition) { | ||
| 163 | + // 1. 校验必要参数 | ||
| 164 | + if (!IotSceneRuleMatcherHelper.isConditionOperatorAndParamValid(condition)) { | ||
| 165 | + log.debug("[evaluateCurrentTimeCondition][操作符或参数无效]"); | ||
| 166 | + return false; | ||
| 167 | + } | ||
| 168 | + | ||
| 169 | + // 2. 验证操作符是否为支持的时间操作符 | ||
| 170 | + IotSceneRuleConditionOperatorEnum operatorEnum = | ||
| 171 | + IotSceneRuleConditionOperatorEnum.operatorOf(condition.getOperator()); | ||
| 172 | + if (operatorEnum == null) { | ||
| 173 | + log.debug("[evaluateCurrentTimeCondition][无效的操作符: {}]", condition.getOperator()); | ||
| 174 | + return false; | ||
| 175 | + } | ||
| 176 | + | ||
| 177 | + if (IotSceneRuleTimeHelper.isTimeOperator(operatorEnum)) { | ||
| 178 | + log.debug("[evaluateCurrentTimeCondition][不支持的时间操作符: {}]", condition.getOperator()); | ||
| 179 | + return false; | ||
| 180 | + } | ||
| 181 | + | ||
| 182 | + // 3. 执行时间匹配 | ||
| 183 | + boolean matched = IotSceneRuleTimeHelper.executeTimeMatching(operatorEnum, condition.getParam()); | ||
| 184 | + log.debug("[evaluateCurrentTimeCondition][操作符({}) 参数({}) 匹配结果: {}]", | ||
| 185 | + condition.getOperator(), condition.getParam(), matched); | ||
| 186 | + return matched; | ||
| 187 | + } | ||
| 188 | + | ||
| 189 | +} |
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/tcp/IotTcpClientTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.service.rule.data.action.tcp; | ||
| 2 | + | ||
| 3 | +import cn.hutool.core.util.ReflectUtil; | ||
| 4 | +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkTcpConfig; | ||
| 5 | +import org.junit.jupiter.api.Test; | ||
| 6 | + | ||
| 7 | +import static org.junit.jupiter.api.Assertions.*; | ||
| 8 | + | ||
| 9 | +/** | ||
| 10 | + * {@link IotTcpClient} 的单元测试 | ||
| 11 | + * <p> | ||
| 12 | + * 测试 dataFormat 默认值行为 | ||
| 13 | + * Property 1: TCP 客户端 dataFormat 默认值行为 | ||
| 14 | + * Validates: Requirements 1.1, 1.2 | ||
| 15 | + * | ||
| 16 | + * @author HUIHUI | ||
| 17 | + */ | ||
| 18 | +class IotTcpClientTest { | ||
| 19 | + | ||
| 20 | + @Test | ||
| 21 | + public void testConstructor_dataFormatNull() { | ||
| 22 | + // 准备参数 | ||
| 23 | + String host = "localhost"; | ||
| 24 | + Integer port = 8080; | ||
| 25 | + | ||
| 26 | + // 调用 | ||
| 27 | + IotTcpClient client = new IotTcpClient(host, port, null, null, null, null); | ||
| 28 | + | ||
| 29 | + // 断言:dataFormat 为 null 时应使用默认值 | ||
| 30 | + assertEquals(IotDataSinkTcpConfig.DEFAULT_DATA_FORMAT, | ||
| 31 | + ReflectUtil.getFieldValue(client, "dataFormat")); | ||
| 32 | + } | ||
| 33 | + | ||
| 34 | + @Test | ||
| 35 | + public void testConstructor_dataFormatEmpty() { | ||
| 36 | + // 准备参数 | ||
| 37 | + String host = "localhost"; | ||
| 38 | + Integer port = 8080; | ||
| 39 | + | ||
| 40 | + // 调用 | ||
| 41 | + IotTcpClient client = new IotTcpClient(host, port, null, null, null, ""); | ||
| 42 | + | ||
| 43 | + // 断言:dataFormat 为空字符串时应使用默认值 | ||
| 44 | + assertEquals(IotDataSinkTcpConfig.DEFAULT_DATA_FORMAT, | ||
| 45 | + ReflectUtil.getFieldValue(client, "dataFormat")); | ||
| 46 | + } | ||
| 47 | + | ||
| 48 | + @Test | ||
| 49 | + public void testConstructor_dataFormatBlank() { | ||
| 50 | + // 准备参数 | ||
| 51 | + String host = "localhost"; | ||
| 52 | + Integer port = 8080; | ||
| 53 | + | ||
| 54 | + // 调用 | ||
| 55 | + IotTcpClient client = new IotTcpClient(host, port, null, null, null, " "); | ||
| 56 | + | ||
| 57 | + // 断言:dataFormat 为纯空白字符串时应使用默认值 | ||
| 58 | + assertEquals(IotDataSinkTcpConfig.DEFAULT_DATA_FORMAT, | ||
| 59 | + ReflectUtil.getFieldValue(client, "dataFormat")); | ||
| 60 | + } | ||
| 61 | + | ||
| 62 | + @Test | ||
| 63 | + public void testConstructor_dataFormatValid() { | ||
| 64 | + // 准备参数 | ||
| 65 | + String host = "localhost"; | ||
| 66 | + Integer port = 8080; | ||
| 67 | + String dataFormat = "BINARY"; | ||
| 68 | + | ||
| 69 | + // 调用 | ||
| 70 | + IotTcpClient client = new IotTcpClient(host, port, null, null, null, dataFormat); | ||
| 71 | + | ||
| 72 | + // 断言:dataFormat 为有效值时应保持原值 | ||
| 73 | + assertEquals(dataFormat, ReflectUtil.getFieldValue(client, "dataFormat")); | ||
| 74 | + } | ||
| 75 | + | ||
| 76 | + @Test | ||
| 77 | + public void testConstructor_defaultValues() { | ||
| 78 | + // 准备参数 | ||
| 79 | + String host = "localhost"; | ||
| 80 | + Integer port = 8080; | ||
| 81 | + | ||
| 82 | + // 调用 | ||
| 83 | + IotTcpClient client = new IotTcpClient(host, port, null, null, null, null); | ||
| 84 | + | ||
| 85 | + // 断言:验证所有默认值 | ||
| 86 | + assertEquals(host, ReflectUtil.getFieldValue(client, "host")); | ||
| 87 | + assertEquals(port, ReflectUtil.getFieldValue(client, "port")); | ||
| 88 | + assertEquals(IotDataSinkTcpConfig.DEFAULT_CONNECT_TIMEOUT_MS, | ||
| 89 | + ReflectUtil.getFieldValue(client, "connectTimeoutMs")); | ||
| 90 | + assertEquals(IotDataSinkTcpConfig.DEFAULT_READ_TIMEOUT_MS, | ||
| 91 | + ReflectUtil.getFieldValue(client, "readTimeoutMs")); | ||
| 92 | + assertEquals(IotDataSinkTcpConfig.DEFAULT_SSL, | ||
| 93 | + ReflectUtil.getFieldValue(client, "ssl")); | ||
| 94 | + assertEquals(IotDataSinkTcpConfig.DEFAULT_DATA_FORMAT, | ||
| 95 | + ReflectUtil.getFieldValue(client, "dataFormat")); | ||
| 96 | + } | ||
| 97 | + | ||
| 98 | + @Test | ||
| 99 | + public void testConstructor_customValues() { | ||
| 100 | + // 准备参数 | ||
| 101 | + String host = "192.168.1.100"; | ||
| 102 | + Integer port = 9090; | ||
| 103 | + Integer connectTimeoutMs = 3000; | ||
| 104 | + Integer readTimeoutMs = 8000; | ||
| 105 | + Boolean ssl = true; | ||
| 106 | + String dataFormat = "BINARY"; | ||
| 107 | + | ||
| 108 | + // 调用 | ||
| 109 | + IotTcpClient client = new IotTcpClient(host, port, connectTimeoutMs, readTimeoutMs, ssl, dataFormat); | ||
| 110 | + | ||
| 111 | + // 断言:验证自定义值 | ||
| 112 | + assertEquals(host, ReflectUtil.getFieldValue(client, "host")); | ||
| 113 | + assertEquals(port, ReflectUtil.getFieldValue(client, "port")); | ||
| 114 | + assertEquals(connectTimeoutMs, ReflectUtil.getFieldValue(client, "connectTimeoutMs")); | ||
| 115 | + assertEquals(readTimeoutMs, ReflectUtil.getFieldValue(client, "readTimeoutMs")); | ||
| 116 | + assertEquals(ssl, ReflectUtil.getFieldValue(client, "ssl")); | ||
| 117 | + assertEquals(dataFormat, ReflectUtil.getFieldValue(client, "dataFormat")); | ||
| 118 | + } | ||
| 119 | + | ||
| 120 | + @Test | ||
| 121 | + public void testIsConnected_initialState() { | ||
| 122 | + // 准备参数 | ||
| 123 | + String host = "localhost"; | ||
| 124 | + Integer port = 8080; | ||
| 125 | + | ||
| 126 | + // 调用 | ||
| 127 | + IotTcpClient client = new IotTcpClient(host, port, null, null, null, null); | ||
| 128 | + | ||
| 129 | + // 断言:初始状态应为未连接 | ||
| 130 | + assertFalse(client.isConnected()); | ||
| 131 | + } | ||
| 132 | + | ||
| 133 | + @Test | ||
| 134 | + public void testToString() { | ||
| 135 | + // 准备参数 | ||
| 136 | + String host = "localhost"; | ||
| 137 | + Integer port = 8080; | ||
| 138 | + | ||
| 139 | + // 调用 | ||
| 140 | + IotTcpClient client = new IotTcpClient(host, port, null, null, null, null); | ||
| 141 | + String result = client.toString(); | ||
| 142 | + | ||
| 143 | + // 断言 | ||
| 144 | + assertNotNull(result); | ||
| 145 | + assertTrue(result.contains("host='localhost'")); | ||
| 146 | + assertTrue(result.contains("port=8080")); | ||
| 147 | + assertTrue(result.contains("dataFormat='JSON'")); | ||
| 148 | + assertTrue(result.contains("connected=false")); | ||
| 149 | + } | ||
| 150 | + | ||
| 151 | +} |
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/websocket/IotWebSocketClientTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.service.rule.data.action.websocket; | ||
| 2 | + | ||
| 3 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | ||
| 4 | +import okhttp3.Response; | ||
| 5 | +import okhttp3.WebSocket; | ||
| 6 | +import okhttp3.WebSocketListener; | ||
| 7 | +import okhttp3.mockwebserver.MockResponse; | ||
| 8 | +import okhttp3.mockwebserver.MockWebServer; | ||
| 9 | +import org.jetbrains.annotations.NotNull; | ||
| 10 | +import org.jetbrains.annotations.Nullable; | ||
| 11 | +import org.junit.jupiter.api.AfterEach; | ||
| 12 | +import org.junit.jupiter.api.BeforeEach; | ||
| 13 | +import org.junit.jupiter.api.Test; | ||
| 14 | + | ||
| 15 | +import static org.junit.jupiter.api.Assertions.*; | ||
| 16 | + | ||
| 17 | +/** | ||
| 18 | + * {@link IotWebSocketClient} 的单元测试 | ||
| 19 | + * | ||
| 20 | + * @author HUIHUI | ||
| 21 | + */ | ||
| 22 | +class IotWebSocketClientTest { | ||
| 23 | + | ||
| 24 | + private MockWebServer mockWebServer; | ||
| 25 | + | ||
| 26 | + @BeforeEach | ||
| 27 | + public void setUp() throws Exception { | ||
| 28 | + mockWebServer = new MockWebServer(); | ||
| 29 | + mockWebServer.start(); | ||
| 30 | + } | ||
| 31 | + | ||
| 32 | + @AfterEach | ||
| 33 | + public void tearDown() throws Exception { | ||
| 34 | + if (mockWebServer != null) { | ||
| 35 | + mockWebServer.shutdown(); | ||
| 36 | + } | ||
| 37 | + } | ||
| 38 | + | ||
| 39 | + /** | ||
| 40 | + * 简单的 WebSocket 监听器,用于测试 | ||
| 41 | + */ | ||
| 42 | + private static class TestWebSocketListener extends WebSocketListener { | ||
| 43 | + @Override | ||
| 44 | + public void onOpen(@NotNull WebSocket webSocket, @NotNull Response response) { | ||
| 45 | + // 连接打开 | ||
| 46 | + } | ||
| 47 | + | ||
| 48 | + @Override | ||
| 49 | + public void onMessage(@NotNull WebSocket webSocket, @NotNull String text) { | ||
| 50 | + // 收到消息 | ||
| 51 | + } | ||
| 52 | + | ||
| 53 | + @Override | ||
| 54 | + public void onClosing(@NotNull WebSocket webSocket, int code, @NotNull String reason) { | ||
| 55 | + webSocket.close(code, reason); | ||
| 56 | + } | ||
| 57 | + | ||
| 58 | + @Override | ||
| 59 | + public void onFailure(@NotNull WebSocket webSocket, @NotNull Throwable t, @Nullable Response response) { | ||
| 60 | + // 连接失败 | ||
| 61 | + } | ||
| 62 | + } | ||
| 63 | + | ||
| 64 | + @Test | ||
| 65 | + public void testConstructor_defaultValues() { | ||
| 66 | + // 准备参数 | ||
| 67 | + String serverUrl = "ws://localhost:8080"; | ||
| 68 | + | ||
| 69 | + // 调用 | ||
| 70 | + IotWebSocketClient client = new IotWebSocketClient(serverUrl, null, null, null); | ||
| 71 | + | ||
| 72 | + // 断言:验证默认值被正确设置 | ||
| 73 | + assertNotNull(client); | ||
| 74 | + assertFalse(client.isConnected()); | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + @Test | ||
| 78 | + public void testConstructor_customValues() { | ||
| 79 | + // 准备参数 | ||
| 80 | + String serverUrl = "ws://localhost:8080"; | ||
| 81 | + Integer connectTimeoutMs = 3000; | ||
| 82 | + Integer sendTimeoutMs = 5000; | ||
| 83 | + String dataFormat = "TEXT"; | ||
| 84 | + | ||
| 85 | + // 调用 | ||
| 86 | + IotWebSocketClient client = new IotWebSocketClient(serverUrl, connectTimeoutMs, sendTimeoutMs, dataFormat); | ||
| 87 | + | ||
| 88 | + // 断言 | ||
| 89 | + assertNotNull(client); | ||
| 90 | + assertFalse(client.isConnected()); | ||
| 91 | + } | ||
| 92 | + | ||
| 93 | + @Test | ||
| 94 | + public void testConnect_success() throws Exception { | ||
| 95 | + // 准备参数:使用 MockWebServer 的 WebSocket 端点 | ||
| 96 | + String serverUrl = "ws://" + mockWebServer.getHostName() + ":" + mockWebServer.getPort(); | ||
| 97 | + IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON"); | ||
| 98 | + | ||
| 99 | + // mock:设置 MockWebServer 响应 WebSocket 升级请求 | ||
| 100 | + mockWebServer.enqueue(new MockResponse().withWebSocketUpgrade(new TestWebSocketListener())); | ||
| 101 | + | ||
| 102 | + // 调用 | ||
| 103 | + client.connect(); | ||
| 104 | + | ||
| 105 | + // 断言 | ||
| 106 | + assertTrue(client.isConnected()); | ||
| 107 | + | ||
| 108 | + // 清理 | ||
| 109 | + client.close(); | ||
| 110 | + } | ||
| 111 | + | ||
| 112 | + @Test | ||
| 113 | + public void testConnect_alreadyConnected() throws Exception { | ||
| 114 | + // 准备参数 | ||
| 115 | + String serverUrl = "ws://" + mockWebServer.getHostName() + ":" + mockWebServer.getPort(); | ||
| 116 | + IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON"); | ||
| 117 | + | ||
| 118 | + // mock | ||
| 119 | + mockWebServer.enqueue(new MockResponse().withWebSocketUpgrade(new TestWebSocketListener())); | ||
| 120 | + | ||
| 121 | + // 调用:第一次连接 | ||
| 122 | + client.connect(); | ||
| 123 | + assertTrue(client.isConnected()); | ||
| 124 | + | ||
| 125 | + // 调用:第二次连接(应该不会重复连接) | ||
| 126 | + client.connect(); | ||
| 127 | + assertTrue(client.isConnected()); | ||
| 128 | + | ||
| 129 | + // 清理 | ||
| 130 | + client.close(); | ||
| 131 | + } | ||
| 132 | + | ||
| 133 | + @Test | ||
| 134 | + public void testSendMessage_success() throws Exception { | ||
| 135 | + // 准备参数 | ||
| 136 | + String serverUrl = "ws://" + mockWebServer.getHostName() + ":" + mockWebServer.getPort(); | ||
| 137 | + IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON"); | ||
| 138 | + | ||
| 139 | + IotDeviceMessage message = IotDeviceMessage.builder() | ||
| 140 | + .deviceId(123L) | ||
| 141 | + .method("thing.property.report") | ||
| 142 | + .params("{\"temperature\": 25.5}") | ||
| 143 | + .build(); | ||
| 144 | + | ||
| 145 | + // mock | ||
| 146 | + mockWebServer.enqueue(new MockResponse().withWebSocketUpgrade(new TestWebSocketListener())); | ||
| 147 | + | ||
| 148 | + // 调用 | ||
| 149 | + client.connect(); | ||
| 150 | + client.sendMessage(message); | ||
| 151 | + | ||
| 152 | + // 断言:消息发送成功不抛异常 | ||
| 153 | + assertTrue(client.isConnected()); | ||
| 154 | + | ||
| 155 | + // 清理 | ||
| 156 | + client.close(); | ||
| 157 | + } | ||
| 158 | + | ||
| 159 | + @Test | ||
| 160 | + public void testSendMessage_notConnected() { | ||
| 161 | + // 准备参数 | ||
| 162 | + String serverUrl = "ws://localhost:8080"; | ||
| 163 | + IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON"); | ||
| 164 | + | ||
| 165 | + IotDeviceMessage message = IotDeviceMessage.builder() | ||
| 166 | + .deviceId(123L) | ||
| 167 | + .method("thing.property.report") | ||
| 168 | + .params("{\"temperature\": 25.5}") | ||
| 169 | + .build(); | ||
| 170 | + | ||
| 171 | + // 调用 & 断言:未连接时发送消息应抛出异常 | ||
| 172 | + assertThrows(IllegalStateException.class, () -> client.sendMessage(message)); | ||
| 173 | + } | ||
| 174 | + | ||
| 175 | + @Test | ||
| 176 | + public void testClose_success() throws Exception { | ||
| 177 | + // 准备参数 | ||
| 178 | + String serverUrl = "ws://" + mockWebServer.getHostName() + ":" + mockWebServer.getPort(); | ||
| 179 | + IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON"); | ||
| 180 | + | ||
| 181 | + // mock | ||
| 182 | + mockWebServer.enqueue(new MockResponse().withWebSocketUpgrade(new TestWebSocketListener())); | ||
| 183 | + | ||
| 184 | + // 调用 | ||
| 185 | + client.connect(); | ||
| 186 | + assertTrue(client.isConnected()); | ||
| 187 | + | ||
| 188 | + client.close(); | ||
| 189 | + | ||
| 190 | + // 断言 | ||
| 191 | + assertFalse(client.isConnected()); | ||
| 192 | + } | ||
| 193 | + | ||
| 194 | + @Test | ||
| 195 | + public void testClose_notConnected() { | ||
| 196 | + // 准备参数 | ||
| 197 | + String serverUrl = "ws://localhost:8080"; | ||
| 198 | + IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON"); | ||
| 199 | + | ||
| 200 | + // 调用:关闭未连接的客户端不应抛异常 | ||
| 201 | + assertDoesNotThrow(client::close); | ||
| 202 | + assertFalse(client.isConnected()); | ||
| 203 | + } | ||
| 204 | + | ||
| 205 | + @Test | ||
| 206 | + public void testIsConnected_initialState() { | ||
| 207 | + // 准备参数 | ||
| 208 | + String serverUrl = "ws://localhost:8080"; | ||
| 209 | + IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON"); | ||
| 210 | + | ||
| 211 | + // 断言:初始状态应为未连接 | ||
| 212 | + assertFalse(client.isConnected()); | ||
| 213 | + } | ||
| 214 | + | ||
| 215 | + @Test | ||
| 216 | + public void testToString() { | ||
| 217 | + // 准备参数 | ||
| 218 | + String serverUrl = "ws://localhost:8080"; | ||
| 219 | + IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON"); | ||
| 220 | + | ||
| 221 | + // 调用 | ||
| 222 | + String result = client.toString(); | ||
| 223 | + | ||
| 224 | + // 断言 | ||
| 225 | + assertNotNull(result); | ||
| 226 | + assertTrue(result.contains("serverUrl='ws://localhost:8080'")); | ||
| 227 | + assertTrue(result.contains("dataFormat='JSON'")); | ||
| 228 | + assertTrue(result.contains("connected=false")); | ||
| 229 | + } | ||
| 230 | + | ||
| 231 | + @Test | ||
| 232 | + public void testSendMessage_textFormat() throws Exception { | ||
| 233 | + // 准备参数 | ||
| 234 | + String serverUrl = "ws://" + mockWebServer.getHostName() + ":" + mockWebServer.getPort(); | ||
| 235 | + IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "TEXT"); | ||
| 236 | + | ||
| 237 | + IotDeviceMessage message = IotDeviceMessage.builder() | ||
| 238 | + .deviceId(123L) | ||
| 239 | + .method("thing.property.report") | ||
| 240 | + .params("{\"temperature\": 25.5}") | ||
| 241 | + .build(); | ||
| 242 | + | ||
| 243 | + // mock | ||
| 244 | + mockWebServer.enqueue(new MockResponse().withWebSocketUpgrade(new TestWebSocketListener())); | ||
| 245 | + | ||
| 246 | + // 调用 | ||
| 247 | + client.connect(); | ||
| 248 | + client.sendMessage(message); | ||
| 249 | + | ||
| 250 | + // 断言:消息发送成功不抛异常 | ||
| 251 | + assertTrue(client.isConnected()); | ||
| 252 | + | ||
| 253 | + // 清理 | ||
| 254 | + client.close(); | ||
| 255 | + } | ||
| 256 | + | ||
| 257 | +} |
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleTimerConditionIntegrationTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.service.rule.scene; | ||
| 2 | + | ||
| 3 | +import cn.hutool.core.collection.ListUtil; | ||
| 4 | +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; | ||
| 5 | +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; | ||
| 6 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceStateEnum; | ||
| 7 | +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; | ||
| 8 | +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDevicePropertyDO; | ||
| 9 | +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; | ||
| 10 | +import cn.iocoder.yudao.module.iot.dal.mysql.rule.IotSceneRuleMapper; | ||
| 11 | +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum; | ||
| 12 | +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionTypeEnum; | ||
| 13 | +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; | ||
| 14 | +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; | ||
| 15 | +import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; | ||
| 16 | +import cn.iocoder.yudao.module.iot.service.rule.scene.action.IotSceneRuleAction; | ||
| 17 | +import cn.iocoder.yudao.module.iot.service.rule.scene.timer.IotSceneRuleTimerHandler; | ||
| 18 | +import cn.iocoder.yudao.module.iot.service.rule.scene.timer.IotTimerConditionEvaluator; | ||
| 19 | +import org.junit.jupiter.api.BeforeEach; | ||
| 20 | +import org.junit.jupiter.api.DisplayName; | ||
| 21 | +import org.junit.jupiter.api.Nested; | ||
| 22 | +import org.junit.jupiter.api.Test; | ||
| 23 | +import org.mockito.InjectMocks; | ||
| 24 | +import org.mockito.Mock; | ||
| 25 | + | ||
| 26 | +import java.util.*; | ||
| 27 | + | ||
| 28 | +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; | ||
| 29 | +import static org.mockito.ArgumentMatchers.any; | ||
| 30 | +import static org.mockito.Mockito.*; | ||
| 31 | + | ||
| 32 | +/** | ||
| 33 | + * {@link IotSceneRuleServiceImpl} 定时触发器条件组集成测试 | ||
| 34 | + * <p> | ||
| 35 | + * 测试定时触发器的条件组评估功能: | ||
| 36 | + * - 空条件组直接执行动作 | ||
| 37 | + * - 条件组评估后决定是否执行动作 | ||
| 38 | + * - 条件组之间的 OR 逻辑 | ||
| 39 | + * - 条件组内的 AND 逻辑 | ||
| 40 | + * - 所有条件组不满足时跳过执行 | ||
| 41 | + * <p> | ||
| 42 | + * Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5 | ||
| 43 | + * | ||
| 44 | + * @author HUIHUI | ||
| 45 | + */ | ||
| 46 | +public class IotSceneRuleTimerConditionIntegrationTest extends BaseMockitoUnitTest { | ||
| 47 | + | ||
| 48 | + @InjectMocks | ||
| 49 | + private IotSceneRuleServiceImpl sceneRuleService; | ||
| 50 | + | ||
| 51 | + @Mock | ||
| 52 | + private IotSceneRuleMapper sceneRuleMapper; | ||
| 53 | + | ||
| 54 | + @Mock | ||
| 55 | + private IotDeviceService deviceService; | ||
| 56 | + | ||
| 57 | + @Mock | ||
| 58 | + private IotDevicePropertyService devicePropertyService; | ||
| 59 | + | ||
| 60 | + @Mock | ||
| 61 | + private List<IotSceneRuleAction> sceneRuleActions; | ||
| 62 | + | ||
| 63 | + @Mock | ||
| 64 | + private IotSceneRuleTimerHandler timerHandler; | ||
| 65 | + | ||
| 66 | + private IotTimerConditionEvaluator timerConditionEvaluator; | ||
| 67 | + | ||
| 68 | + // 测试常量 | ||
| 69 | + private static final Long SCENE_RULE_ID = 1L; | ||
| 70 | + private static final Long TENANT_ID = 1L; | ||
| 71 | + private static final Long DEVICE_ID = 100L; | ||
| 72 | + private static final String PROPERTY_IDENTIFIER = "temperature"; | ||
| 73 | + | ||
| 74 | + @BeforeEach | ||
| 75 | + void setUp() { | ||
| 76 | + // 创建并注入 timerConditionEvaluator 的依赖 | ||
| 77 | + timerConditionEvaluator = new IotTimerConditionEvaluator(); | ||
| 78 | + try { | ||
| 79 | + var devicePropertyServiceField = IotTimerConditionEvaluator.class.getDeclaredField("devicePropertyService"); | ||
| 80 | + devicePropertyServiceField.setAccessible(true); | ||
| 81 | + devicePropertyServiceField.set(timerConditionEvaluator, devicePropertyService); | ||
| 82 | + | ||
| 83 | + var deviceServiceField = IotTimerConditionEvaluator.class.getDeclaredField("deviceService"); | ||
| 84 | + deviceServiceField.setAccessible(true); | ||
| 85 | + deviceServiceField.set(timerConditionEvaluator, deviceService); | ||
| 86 | + | ||
| 87 | + var evaluatorField = IotSceneRuleServiceImpl.class.getDeclaredField("timerConditionEvaluator"); | ||
| 88 | + evaluatorField.setAccessible(true); | ||
| 89 | + evaluatorField.set(sceneRuleService, timerConditionEvaluator); | ||
| 90 | + } catch (Exception e) { | ||
| 91 | + throw new RuntimeException("Failed to inject dependencies", e); | ||
| 92 | + } | ||
| 93 | + } | ||
| 94 | + | ||
| 95 | + // ========== 辅助方法 ========== | ||
| 96 | + | ||
| 97 | + private IotSceneRuleDO createBaseSceneRule() { | ||
| 98 | + IotSceneRuleDO sceneRule = new IotSceneRuleDO(); | ||
| 99 | + sceneRule.setId(SCENE_RULE_ID); | ||
| 100 | + sceneRule.setTenantId(TENANT_ID); | ||
| 101 | + sceneRule.setName("测试定时触发器"); | ||
| 102 | + sceneRule.setStatus(CommonStatusEnum.ENABLE.getStatus()); | ||
| 103 | + sceneRule.setActions(Collections.emptyList()); | ||
| 104 | + return sceneRule; | ||
| 105 | + } | ||
| 106 | + | ||
| 107 | + private IotSceneRuleDO.Trigger createTimerTrigger(String cronExpression, | ||
| 108 | + List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups) { | ||
| 109 | + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); | ||
| 110 | + trigger.setType(IotSceneRuleTriggerTypeEnum.TIMER.getType()); | ||
| 111 | + trigger.setCronExpression(cronExpression); | ||
| 112 | + trigger.setConditionGroups(conditionGroups); | ||
| 113 | + return trigger; | ||
| 114 | + } | ||
| 115 | + | ||
| 116 | + private IotSceneRuleDO.TriggerCondition createDevicePropertyCondition(Long deviceId, String identifier, | ||
| 117 | + String operator, String param) { | ||
| 118 | + IotSceneRuleDO.TriggerCondition condition = new IotSceneRuleDO.TriggerCondition(); | ||
| 119 | + condition.setType(IotSceneRuleConditionTypeEnum.DEVICE_PROPERTY.getType()); | ||
| 120 | + condition.setDeviceId(deviceId); | ||
| 121 | + condition.setIdentifier(identifier); | ||
| 122 | + condition.setOperator(operator); | ||
| 123 | + condition.setParam(param); | ||
| 124 | + return condition; | ||
| 125 | + } | ||
| 126 | + | ||
| 127 | + private IotSceneRuleDO.TriggerCondition createDeviceStateCondition(Long deviceId, String operator, String param) { | ||
| 128 | + IotSceneRuleDO.TriggerCondition condition = new IotSceneRuleDO.TriggerCondition(); | ||
| 129 | + condition.setType(IotSceneRuleConditionTypeEnum.DEVICE_STATE.getType()); | ||
| 130 | + condition.setDeviceId(deviceId); | ||
| 131 | + condition.setOperator(operator); | ||
| 132 | + condition.setParam(param); | ||
| 133 | + return condition; | ||
| 134 | + } | ||
| 135 | + | ||
| 136 | + private void mockDeviceProperty(Long deviceId, String identifier, Object value) { | ||
| 137 | + Map<String, IotDevicePropertyDO> properties = new HashMap<>(); | ||
| 138 | + IotDevicePropertyDO property = new IotDevicePropertyDO(); | ||
| 139 | + property.setValue(value); | ||
| 140 | + properties.put(identifier, property); | ||
| 141 | + when(devicePropertyService.getLatestDeviceProperties(deviceId)).thenReturn(properties); | ||
| 142 | + } | ||
| 143 | + | ||
| 144 | + private void mockDeviceState(Long deviceId, Integer state) { | ||
| 145 | + IotDeviceDO device = new IotDeviceDO(); | ||
| 146 | + device.setId(deviceId); | ||
| 147 | + device.setState(state); | ||
| 148 | + when(deviceService.getDevice(deviceId)).thenReturn(device); | ||
| 149 | + } | ||
| 150 | + | ||
| 151 | + /** | ||
| 152 | + * 创建单条件的条件组列表 | ||
| 153 | + */ | ||
| 154 | + private List<List<IotSceneRuleDO.TriggerCondition>> createSingleConditionGroups( | ||
| 155 | + IotSceneRuleDO.TriggerCondition condition) { | ||
| 156 | + List<IotSceneRuleDO.TriggerCondition> group = new ArrayList<>(); | ||
| 157 | + group.add(condition); | ||
| 158 | + List<List<IotSceneRuleDO.TriggerCondition>> groups = new ArrayList<>(); | ||
| 159 | + groups.add(group); | ||
| 160 | + return groups; | ||
| 161 | + } | ||
| 162 | + | ||
| 163 | + /** | ||
| 164 | + * 创建两个单条件组的条件组列表 | ||
| 165 | + */ | ||
| 166 | + private List<List<IotSceneRuleDO.TriggerCondition>> createTwoSingleConditionGroups( | ||
| 167 | + IotSceneRuleDO.TriggerCondition cond1, IotSceneRuleDO.TriggerCondition cond2) { | ||
| 168 | + List<IotSceneRuleDO.TriggerCondition> group1 = new ArrayList<>(); | ||
| 169 | + group1.add(cond1); | ||
| 170 | + List<IotSceneRuleDO.TriggerCondition> group2 = new ArrayList<>(); | ||
| 171 | + group2.add(cond2); | ||
| 172 | + List<List<IotSceneRuleDO.TriggerCondition>> groups = new ArrayList<>(); | ||
| 173 | + groups.add(group1); | ||
| 174 | + groups.add(group2); | ||
| 175 | + return groups; | ||
| 176 | + } | ||
| 177 | + | ||
| 178 | + /** | ||
| 179 | + * 创建单个多条件组的条件组列表 | ||
| 180 | + */ | ||
| 181 | + private List<List<IotSceneRuleDO.TriggerCondition>> createSingleGroupWithMultipleConditions( | ||
| 182 | + IotSceneRuleDO.TriggerCondition... conditions) { | ||
| 183 | + List<IotSceneRuleDO.TriggerCondition> group = new ArrayList<>(Arrays.asList(conditions)); | ||
| 184 | + List<List<IotSceneRuleDO.TriggerCondition>> groups = new ArrayList<>(); | ||
| 185 | + groups.add(group); | ||
| 186 | + return groups; | ||
| 187 | + } | ||
| 188 | + | ||
| 189 | + // ========== 测试用例 ========== | ||
| 190 | + | ||
| 191 | + @Nested | ||
| 192 | + @DisplayName("空条件组测试 - Validates: Requirement 2.1") | ||
| 193 | + class EmptyConditionGroupsTest { | ||
| 194 | + | ||
| 195 | + @Test | ||
| 196 | + @DisplayName("定时触发器无条件组时,应直接执行动作") | ||
| 197 | + void testTimerTrigger_withNullConditionGroups_shouldExecuteActions() { | ||
| 198 | + // 准备数据 | ||
| 199 | + IotSceneRuleDO sceneRule = createBaseSceneRule(); | ||
| 200 | + IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", null); | ||
| 201 | + sceneRule.setTriggers(ListUtil.toList(trigger)); | ||
| 202 | + | ||
| 203 | + when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule); | ||
| 204 | + | ||
| 205 | + assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID)); | ||
| 206 | + | ||
| 207 | + verify(sceneRuleMapper, times(1)).selectById(SCENE_RULE_ID); | ||
| 208 | + verify(devicePropertyService, never()).getLatestDeviceProperties(any()); | ||
| 209 | + verify(deviceService, never()).getDevice(any()); | ||
| 210 | + } | ||
| 211 | + | ||
| 212 | + @Test | ||
| 213 | + @DisplayName("定时触发器条件组为空列表时,应直接执行动作") | ||
| 214 | + void testTimerTrigger_withEmptyConditionGroups_shouldExecuteActions() { | ||
| 215 | + IotSceneRuleDO sceneRule = createBaseSceneRule(); | ||
| 216 | + IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", Collections.emptyList()); | ||
| 217 | + sceneRule.setTriggers(ListUtil.toList(trigger)); | ||
| 218 | + | ||
| 219 | + when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule); | ||
| 220 | + | ||
| 221 | + assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID)); | ||
| 222 | + | ||
| 223 | + verify(sceneRuleMapper, times(1)).selectById(SCENE_RULE_ID); | ||
| 224 | + verify(devicePropertyService, never()).getLatestDeviceProperties(any()); | ||
| 225 | + } | ||
| 226 | + } | ||
| 227 | + | ||
| 228 | + @Nested | ||
| 229 | + @DisplayName("条件组 OR 逻辑测试 - Validates: Requirements 2.2, 2.3") | ||
| 230 | + class ConditionGroupOrLogicTest { | ||
| 231 | + | ||
| 232 | + @Test | ||
| 233 | + @DisplayName("多个条件组中第一个满足时,应执行动作") | ||
| 234 | + void testMultipleConditionGroups_firstGroupMatches_shouldExecuteActions() { | ||
| 235 | + IotSceneRuleDO.TriggerCondition condition1 = createDevicePropertyCondition( | ||
| 236 | + DEVICE_ID, PROPERTY_IDENTIFIER, IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "20"); | ||
| 237 | + IotSceneRuleDO.TriggerCondition condition2 = createDevicePropertyCondition( | ||
| 238 | + DEVICE_ID + 1, PROPERTY_IDENTIFIER, IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "50"); | ||
| 239 | + | ||
| 240 | + List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups = | ||
| 241 | + createTwoSingleConditionGroups(condition1, condition2); | ||
| 242 | + | ||
| 243 | + IotSceneRuleDO sceneRule = createBaseSceneRule(); | ||
| 244 | + IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups); | ||
| 245 | + sceneRule.setTriggers(ListUtil.toList(trigger)); | ||
| 246 | + | ||
| 247 | + mockDeviceProperty(DEVICE_ID, PROPERTY_IDENTIFIER, 30); | ||
| 248 | + mockDeviceProperty(DEVICE_ID + 1, PROPERTY_IDENTIFIER, 30); | ||
| 249 | + when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule); | ||
| 250 | + | ||
| 251 | + assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID)); | ||
| 252 | + | ||
| 253 | + verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID); | ||
| 254 | + } | ||
| 255 | + | ||
| 256 | + @Test | ||
| 257 | + @DisplayName("多个条件组中第二个满足时,应执行动作") | ||
| 258 | + void testMultipleConditionGroups_secondGroupMatches_shouldExecuteActions() { | ||
| 259 | + IotSceneRuleDO.TriggerCondition condition1 = createDevicePropertyCondition( | ||
| 260 | + DEVICE_ID, PROPERTY_IDENTIFIER, IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "50"); | ||
| 261 | + IotSceneRuleDO.TriggerCondition condition2 = createDevicePropertyCondition( | ||
| 262 | + DEVICE_ID + 1, PROPERTY_IDENTIFIER, IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "20"); | ||
| 263 | + | ||
| 264 | + List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups = | ||
| 265 | + createTwoSingleConditionGroups(condition1, condition2); | ||
| 266 | + | ||
| 267 | + IotSceneRuleDO sceneRule = createBaseSceneRule(); | ||
| 268 | + IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups); | ||
| 269 | + sceneRule.setTriggers(ListUtil.toList(trigger)); | ||
| 270 | + | ||
| 271 | + mockDeviceProperty(DEVICE_ID, PROPERTY_IDENTIFIER, 30); | ||
| 272 | + mockDeviceProperty(DEVICE_ID + 1, PROPERTY_IDENTIFIER, 30); | ||
| 273 | + when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule); | ||
| 274 | + | ||
| 275 | + assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID)); | ||
| 276 | + | ||
| 277 | + verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID); | ||
| 278 | + verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID + 1); | ||
| 279 | + } | ||
| 280 | + } | ||
| 281 | + | ||
| 282 | + @Nested | ||
| 283 | + @DisplayName("条件组内 AND 逻辑测试 - Validates: Requirement 2.4") | ||
| 284 | + class ConditionGroupAndLogicTest { | ||
| 285 | + | ||
| 286 | + @Test | ||
| 287 | + @DisplayName("条件组内所有条件都满足时,该组应匹配成功") | ||
| 288 | + void testSingleConditionGroup_allConditionsMatch_shouldPass() { | ||
| 289 | + IotSceneRuleDO.TriggerCondition condition1 = createDevicePropertyCondition( | ||
| 290 | + DEVICE_ID, "temperature", IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "20"); | ||
| 291 | + IotSceneRuleDO.TriggerCondition condition2 = createDevicePropertyCondition( | ||
| 292 | + DEVICE_ID, "humidity", IotSceneRuleConditionOperatorEnum.LESS_THAN.getOperator(), "80"); | ||
| 293 | + | ||
| 294 | + List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups = | ||
| 295 | + createSingleGroupWithMultipleConditions(condition1, condition2); | ||
| 296 | + | ||
| 297 | + IotSceneRuleDO sceneRule = createBaseSceneRule(); | ||
| 298 | + IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups); | ||
| 299 | + sceneRule.setTriggers(ListUtil.toList(trigger)); | ||
| 300 | + | ||
| 301 | + Map<String, IotDevicePropertyDO> properties = new HashMap<>(); | ||
| 302 | + IotDevicePropertyDO tempProperty = new IotDevicePropertyDO(); | ||
| 303 | + tempProperty.setValue(30); | ||
| 304 | + properties.put("temperature", tempProperty); | ||
| 305 | + IotDevicePropertyDO humidityProperty = new IotDevicePropertyDO(); | ||
| 306 | + humidityProperty.setValue(60); | ||
| 307 | + properties.put("humidity", humidityProperty); | ||
| 308 | + when(devicePropertyService.getLatestDeviceProperties(DEVICE_ID)).thenReturn(properties); | ||
| 309 | + when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule); | ||
| 310 | + | ||
| 311 | + assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID)); | ||
| 312 | + | ||
| 313 | + verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID); | ||
| 314 | + } | ||
| 315 | + | ||
| 316 | + @Test | ||
| 317 | + @DisplayName("条件组内有一个条件不满足时,该组应匹配失败") | ||
| 318 | + void testSingleConditionGroup_oneConditionFails_shouldFail() { | ||
| 319 | + IotSceneRuleDO.TriggerCondition condition1 = createDevicePropertyCondition( | ||
| 320 | + DEVICE_ID, "temperature", IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "20"); | ||
| 321 | + IotSceneRuleDO.TriggerCondition condition2 = createDevicePropertyCondition( | ||
| 322 | + DEVICE_ID, "humidity", IotSceneRuleConditionOperatorEnum.LESS_THAN.getOperator(), "50"); | ||
| 323 | + | ||
| 324 | + List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups = | ||
| 325 | + createSingleGroupWithMultipleConditions(condition1, condition2); | ||
| 326 | + | ||
| 327 | + IotSceneRuleDO sceneRule = createBaseSceneRule(); | ||
| 328 | + IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups); | ||
| 329 | + sceneRule.setTriggers(ListUtil.toList(trigger)); | ||
| 330 | + | ||
| 331 | + Map<String, IotDevicePropertyDO> properties = new HashMap<>(); | ||
| 332 | + IotDevicePropertyDO tempProperty = new IotDevicePropertyDO(); | ||
| 333 | + tempProperty.setValue(30); | ||
| 334 | + properties.put("temperature", tempProperty); | ||
| 335 | + IotDevicePropertyDO humidityProperty = new IotDevicePropertyDO(); | ||
| 336 | + humidityProperty.setValue(60); // 不满足 < 50 | ||
| 337 | + properties.put("humidity", humidityProperty); | ||
| 338 | + when(devicePropertyService.getLatestDeviceProperties(DEVICE_ID)).thenReturn(properties); | ||
| 339 | + when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule); | ||
| 340 | + | ||
| 341 | + assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID)); | ||
| 342 | + | ||
| 343 | + verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID); | ||
| 344 | + } | ||
| 345 | + } | ||
| 346 | + | ||
| 347 | + @Nested | ||
| 348 | + @DisplayName("所有条件组不满足测试 - Validates: Requirement 2.5") | ||
| 349 | + class AllConditionGroupsFailTest { | ||
| 350 | + | ||
| 351 | + @Test | ||
| 352 | + @DisplayName("所有条件组都不满足时,应跳过动作执行") | ||
| 353 | + void testAllConditionGroups_allFail_shouldSkipExecution() { | ||
| 354 | + IotSceneRuleDO.TriggerCondition condition1 = createDevicePropertyCondition( | ||
| 355 | + DEVICE_ID, PROPERTY_IDENTIFIER, IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "50"); | ||
| 356 | + IotSceneRuleDO.TriggerCondition condition2 = createDevicePropertyCondition( | ||
| 357 | + DEVICE_ID + 1, PROPERTY_IDENTIFIER, IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "50"); | ||
| 358 | + | ||
| 359 | + List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups = | ||
| 360 | + createTwoSingleConditionGroups(condition1, condition2); | ||
| 361 | + | ||
| 362 | + IotSceneRuleDO sceneRule = createBaseSceneRule(); | ||
| 363 | + IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups); | ||
| 364 | + sceneRule.setTriggers(ListUtil.toList(trigger)); | ||
| 365 | + | ||
| 366 | + mockDeviceProperty(DEVICE_ID, PROPERTY_IDENTIFIER, 30); | ||
| 367 | + mockDeviceProperty(DEVICE_ID + 1, PROPERTY_IDENTIFIER, 30); | ||
| 368 | + when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule); | ||
| 369 | + | ||
| 370 | + assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID)); | ||
| 371 | + | ||
| 372 | + verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID); | ||
| 373 | + verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID + 1); | ||
| 374 | + } | ||
| 375 | + } | ||
| 376 | + | ||
| 377 | + @Nested | ||
| 378 | + @DisplayName("设备状态条件测试 - Validates: Requirements 4.1, 4.2") | ||
| 379 | + class DeviceStateConditionTest { | ||
| 380 | + | ||
| 381 | + @Test | ||
| 382 | + @DisplayName("设备在线状态条件满足时,应匹配成功") | ||
| 383 | + void testDeviceStateCondition_online_shouldMatch() { | ||
| 384 | + IotSceneRuleDO.TriggerCondition condition = createDeviceStateCondition( | ||
| 385 | + DEVICE_ID, IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(), | ||
| 386 | + String.valueOf(IotDeviceStateEnum.ONLINE.getState())); | ||
| 387 | + | ||
| 388 | + List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups = createSingleConditionGroups(condition); | ||
| 389 | + | ||
| 390 | + IotSceneRuleDO sceneRule = createBaseSceneRule(); | ||
| 391 | + IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups); | ||
| 392 | + sceneRule.setTriggers(ListUtil.toList(trigger)); | ||
| 393 | + | ||
| 394 | + mockDeviceState(DEVICE_ID, IotDeviceStateEnum.ONLINE.getState()); | ||
| 395 | + when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule); | ||
| 396 | + | ||
| 397 | + assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID)); | ||
| 398 | + | ||
| 399 | + verify(deviceService, atLeastOnce()).getDevice(DEVICE_ID); | ||
| 400 | + } | ||
| 401 | + | ||
| 402 | + @Test | ||
| 403 | + @DisplayName("设备不存在时,条件应不匹配") | ||
| 404 | + void testDeviceStateCondition_deviceNotExists_shouldNotMatch() { | ||
| 405 | + IotSceneRuleDO.TriggerCondition condition = createDeviceStateCondition( | ||
| 406 | + DEVICE_ID, IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(), | ||
| 407 | + String.valueOf(IotDeviceStateEnum.ONLINE.getState())); | ||
| 408 | + | ||
| 409 | + List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups = createSingleConditionGroups(condition); | ||
| 410 | + | ||
| 411 | + IotSceneRuleDO sceneRule = createBaseSceneRule(); | ||
| 412 | + IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups); | ||
| 413 | + sceneRule.setTriggers(ListUtil.toList(trigger)); | ||
| 414 | + | ||
| 415 | + when(deviceService.getDevice(DEVICE_ID)).thenReturn(null); | ||
| 416 | + when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule); | ||
| 417 | + | ||
| 418 | + assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID)); | ||
| 419 | + | ||
| 420 | + verify(deviceService, atLeastOnce()).getDevice(DEVICE_ID); | ||
| 421 | + } | ||
| 422 | + } | ||
| 423 | + | ||
| 424 | + @Nested | ||
| 425 | + @DisplayName("设备属性条件测试 - Validates: Requirements 3.1, 3.2, 3.3") | ||
| 426 | + class DevicePropertyConditionTest { | ||
| 427 | + | ||
| 428 | + @Test | ||
| 429 | + @DisplayName("设备属性条件满足时,应匹配成功") | ||
| 430 | + void testDevicePropertyCondition_match_shouldPass() { | ||
| 431 | + IotSceneRuleDO.TriggerCondition condition = createDevicePropertyCondition( | ||
| 432 | + DEVICE_ID, PROPERTY_IDENTIFIER, IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "25"); | ||
| 433 | + | ||
| 434 | + List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups = createSingleConditionGroups(condition); | ||
| 435 | + | ||
| 436 | + IotSceneRuleDO sceneRule = createBaseSceneRule(); | ||
| 437 | + IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups); | ||
| 438 | + sceneRule.setTriggers(ListUtil.toList(trigger)); | ||
| 439 | + | ||
| 440 | + mockDeviceProperty(DEVICE_ID, PROPERTY_IDENTIFIER, 30); | ||
| 441 | + when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule); | ||
| 442 | + | ||
| 443 | + assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID)); | ||
| 444 | + | ||
| 445 | + verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID); | ||
| 446 | + } | ||
| 447 | + | ||
| 448 | + @Test | ||
| 449 | + @DisplayName("设备属性不存在时,条件应不匹配") | ||
| 450 | + void testDevicePropertyCondition_propertyNotExists_shouldNotMatch() { | ||
| 451 | + IotSceneRuleDO.TriggerCondition condition = createDevicePropertyCondition( | ||
| 452 | + DEVICE_ID, "nonexistent", IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "25"); | ||
| 453 | + | ||
| 454 | + List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups = createSingleConditionGroups(condition); | ||
| 455 | + | ||
| 456 | + IotSceneRuleDO sceneRule = createBaseSceneRule(); | ||
| 457 | + IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups); | ||
| 458 | + sceneRule.setTriggers(ListUtil.toList(trigger)); | ||
| 459 | + | ||
| 460 | + when(devicePropertyService.getLatestDeviceProperties(DEVICE_ID)).thenReturn(Collections.emptyMap()); | ||
| 461 | + when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule); | ||
| 462 | + | ||
| 463 | + assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID)); | ||
| 464 | + | ||
| 465 | + verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID); | ||
| 466 | + } | ||
| 467 | + | ||
| 468 | + @Test | ||
| 469 | + @DisplayName("设备属性等于条件测试") | ||
| 470 | + void testDevicePropertyCondition_equals_shouldMatch() { | ||
| 471 | + IotSceneRuleDO.TriggerCondition condition = createDevicePropertyCondition( | ||
| 472 | + DEVICE_ID, PROPERTY_IDENTIFIER, IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(), "30"); | ||
| 473 | + | ||
| 474 | + List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups = createSingleConditionGroups(condition); | ||
| 475 | + | ||
| 476 | + IotSceneRuleDO sceneRule = createBaseSceneRule(); | ||
| 477 | + IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups); | ||
| 478 | + sceneRule.setTriggers(ListUtil.toList(trigger)); | ||
| 479 | + | ||
| 480 | + mockDeviceProperty(DEVICE_ID, PROPERTY_IDENTIFIER, 30); | ||
| 481 | + when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule); | ||
| 482 | + | ||
| 483 | + assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID)); | ||
| 484 | + | ||
| 485 | + verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID); | ||
| 486 | + } | ||
| 487 | + } | ||
| 488 | + | ||
| 489 | + @Nested | ||
| 490 | + @DisplayName("场景规则状态测试") | ||
| 491 | + class SceneRuleStatusTest { | ||
| 492 | + | ||
| 493 | + @Test | ||
| 494 | + @DisplayName("场景规则不存在时,应直接返回") | ||
| 495 | + void testSceneRule_notExists_shouldReturn() { | ||
| 496 | + when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(null); | ||
| 497 | + | ||
| 498 | + assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID)); | ||
| 499 | + | ||
| 500 | + verify(devicePropertyService, never()).getLatestDeviceProperties(any()); | ||
| 501 | + } | ||
| 502 | + | ||
| 503 | + @Test | ||
| 504 | + @DisplayName("场景规则已禁用时,应直接返回") | ||
| 505 | + void testSceneRule_disabled_shouldReturn() { | ||
| 506 | + IotSceneRuleDO sceneRule = createBaseSceneRule(); | ||
| 507 | + sceneRule.setStatus(CommonStatusEnum.DISABLE.getStatus()); | ||
| 508 | + | ||
| 509 | + when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule); | ||
| 510 | + | ||
| 511 | + assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID)); | ||
| 512 | + | ||
| 513 | + verify(devicePropertyService, never()).getLatestDeviceProperties(any()); | ||
| 514 | + } | ||
| 515 | + | ||
| 516 | + @Test | ||
| 517 | + @DisplayName("场景规则无定时触发器时,应直接返回") | ||
| 518 | + void testSceneRule_noTimerTrigger_shouldReturn() { | ||
| 519 | + IotSceneRuleDO sceneRule = createBaseSceneRule(); | ||
| 520 | + IotSceneRuleDO.Trigger deviceTrigger = new IotSceneRuleDO.Trigger(); | ||
| 521 | + deviceTrigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_PROPERTY_POST.getType()); | ||
| 522 | + sceneRule.setTriggers(ListUtil.toList(deviceTrigger)); | ||
| 523 | + | ||
| 524 | + when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule); | ||
| 525 | + | ||
| 526 | + assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID)); | ||
| 527 | + | ||
| 528 | + verify(devicePropertyService, never()).getLatestDeviceProperties(any()); | ||
| 529 | + } | ||
| 530 | + } | ||
| 531 | + | ||
| 532 | + @Nested | ||
| 533 | + @DisplayName("复杂条件组合测试") | ||
| 534 | + class ComplexConditionCombinationTest { | ||
| 535 | + | ||
| 536 | + @Test | ||
| 537 | + @DisplayName("混合条件类型测试:设备属性 + 设备状态") | ||
| 538 | + void testMixedConditionTypes_propertyAndState() { | ||
| 539 | + IotSceneRuleDO.TriggerCondition propertyCondition = createDevicePropertyCondition( | ||
| 540 | + DEVICE_ID, PROPERTY_IDENTIFIER, IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "20"); | ||
| 541 | + IotSceneRuleDO.TriggerCondition stateCondition = createDeviceStateCondition( | ||
| 542 | + DEVICE_ID, IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(), | ||
| 543 | + String.valueOf(IotDeviceStateEnum.ONLINE.getState())); | ||
| 544 | + | ||
| 545 | + List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups = | ||
| 546 | + createSingleGroupWithMultipleConditions(propertyCondition, stateCondition); | ||
| 547 | + | ||
| 548 | + IotSceneRuleDO sceneRule = createBaseSceneRule(); | ||
| 549 | + IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups); | ||
| 550 | + sceneRule.setTriggers(ListUtil.toList(trigger)); | ||
| 551 | + | ||
| 552 | + mockDeviceProperty(DEVICE_ID, PROPERTY_IDENTIFIER, 30); | ||
| 553 | + mockDeviceState(DEVICE_ID, IotDeviceStateEnum.ONLINE.getState()); | ||
| 554 | + when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule); | ||
| 555 | + | ||
| 556 | + assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID)); | ||
| 557 | + | ||
| 558 | + verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID); | ||
| 559 | + verify(deviceService, atLeastOnce()).getDevice(DEVICE_ID); | ||
| 560 | + } | ||
| 561 | + | ||
| 562 | + @Test | ||
| 563 | + @DisplayName("多条件组 OR 逻辑 + 组内 AND 逻辑综合测试") | ||
| 564 | + void testComplexOrAndLogic() { | ||
| 565 | + // 条件组1:温度 > 30 AND 湿度 < 50(不满足) | ||
| 566 | + // 条件组2:温度 > 20 AND 设备在线(满足) | ||
| 567 | + IotSceneRuleDO.TriggerCondition group1Cond1 = createDevicePropertyCondition( | ||
| 568 | + DEVICE_ID, "temperature", IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "30"); | ||
| 569 | + IotSceneRuleDO.TriggerCondition group1Cond2 = createDevicePropertyCondition( | ||
| 570 | + DEVICE_ID, "humidity", IotSceneRuleConditionOperatorEnum.LESS_THAN.getOperator(), "50"); | ||
| 571 | + | ||
| 572 | + IotSceneRuleDO.TriggerCondition group2Cond1 = createDevicePropertyCondition( | ||
| 573 | + DEVICE_ID, "temperature", IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "20"); | ||
| 574 | + IotSceneRuleDO.TriggerCondition group2Cond2 = createDeviceStateCondition( | ||
| 575 | + DEVICE_ID, IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(), | ||
| 576 | + String.valueOf(IotDeviceStateEnum.ONLINE.getState())); | ||
| 577 | + | ||
| 578 | + // 创建两个条件组 | ||
| 579 | + List<IotSceneRuleDO.TriggerCondition> group1 = new ArrayList<>(); | ||
| 580 | + group1.add(group1Cond1); | ||
| 581 | + group1.add(group1Cond2); | ||
| 582 | + List<IotSceneRuleDO.TriggerCondition> group2 = new ArrayList<>(); | ||
| 583 | + group2.add(group2Cond1); | ||
| 584 | + group2.add(group2Cond2); | ||
| 585 | + List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups = new ArrayList<>(); | ||
| 586 | + conditionGroups.add(group1); | ||
| 587 | + conditionGroups.add(group2); | ||
| 588 | + | ||
| 589 | + IotSceneRuleDO sceneRule = createBaseSceneRule(); | ||
| 590 | + IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups); | ||
| 591 | + sceneRule.setTriggers(ListUtil.toList(trigger)); | ||
| 592 | + | ||
| 593 | + // Mock:温度 25,湿度 60,设备在线 | ||
| 594 | + Map<String, IotDevicePropertyDO> properties = new HashMap<>(); | ||
| 595 | + IotDevicePropertyDO tempProperty = new IotDevicePropertyDO(); | ||
| 596 | + tempProperty.setValue(25); | ||
| 597 | + properties.put("temperature", tempProperty); | ||
| 598 | + IotDevicePropertyDO humidityProperty = new IotDevicePropertyDO(); | ||
| 599 | + humidityProperty.setValue(60); | ||
| 600 | + properties.put("humidity", humidityProperty); | ||
| 601 | + when(devicePropertyService.getLatestDeviceProperties(DEVICE_ID)).thenReturn(properties); | ||
| 602 | + mockDeviceState(DEVICE_ID, IotDeviceStateEnum.ONLINE.getState()); | ||
| 603 | + when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule); | ||
| 604 | + | ||
| 605 | + assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID)); | ||
| 606 | + | ||
| 607 | + verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID); | ||
| 608 | + } | ||
| 609 | + } | ||
| 610 | + | ||
| 611 | +} |
yudao-module-iot/yudao-module-iot-biz/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; | @@ -7,7 +7,6 @@ import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; | ||
| 7 | import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; | 7 | import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; |
| 8 | import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotBaseConditionMatcherTest; | 8 | import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotBaseConditionMatcherTest; |
| 9 | import org.junit.jupiter.api.BeforeEach; | 9 | import org.junit.jupiter.api.BeforeEach; |
| 10 | -import org.junit.jupiter.api.Disabled; | ||
| 11 | import org.junit.jupiter.api.Test; | 10 | import org.junit.jupiter.api.Test; |
| 12 | 11 | ||
| 13 | import java.util.HashMap; | 12 | import java.util.HashMap; |
| @@ -23,7 +22,6 @@ import static org.junit.jupiter.api.Assertions.*; | @@ -23,7 +22,6 @@ import static org.junit.jupiter.api.Assertions.*; | ||
| 23 | * | 22 | * |
| 24 | * @author HUIHUI | 23 | * @author HUIHUI |
| 25 | */ | 24 | */ |
| 26 | -@Disabled // TODO @puhui999:单测有报错,先屏蔽 | ||
| 27 | public class IotDeviceServiceInvokeTriggerMatcherTest extends IotBaseConditionMatcherTest { | 25 | public class IotDeviceServiceInvokeTriggerMatcherTest extends IotBaseConditionMatcherTest { |
| 28 | 26 | ||
| 29 | private IotDeviceServiceInvokeTriggerMatcher matcher; | 27 | private IotDeviceServiceInvokeTriggerMatcher matcher; |
| @@ -378,6 +376,268 @@ public class IotDeviceServiceInvokeTriggerMatcherTest extends IotBaseConditionMa | @@ -378,6 +376,268 @@ public class IotDeviceServiceInvokeTriggerMatcherTest extends IotBaseConditionMa | ||
| 378 | assertFalse(result); | 376 | assertFalse(result); |
| 379 | } | 377 | } |
| 380 | 378 | ||
| 379 | + | ||
| 380 | + // ========== 参数条件匹配测试 ========== | ||
| 381 | + | ||
| 382 | + /** | ||
| 383 | + * 测试无参数条件时的匹配逻辑 - 只要标识符匹配就返回 true | ||
| 384 | + * **Property 4: 服务调用触发器参数匹配逻辑** | ||
| 385 | + * **Validates: Requirements 5.2** | ||
| 386 | + */ | ||
| 387 | + @Test | ||
| 388 | + public void testMatches_noParameterCondition_success() { | ||
| 389 | + // 准备参数 | ||
| 390 | + String serviceIdentifier = "testService"; | ||
| 391 | + Map<String, Object> serviceParams = MapUtil.builder(new HashMap<String, Object>()) | ||
| 392 | + .put("identifier", serviceIdentifier) | ||
| 393 | + .put("inputData", MapUtil.builder(new HashMap<String, Object>()) | ||
| 394 | + .put("level", 5) | ||
| 395 | + .build()) | ||
| 396 | + .build(); | ||
| 397 | + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); | ||
| 398 | + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); | ||
| 399 | + trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType()); | ||
| 400 | + trigger.setIdentifier(serviceIdentifier); | ||
| 401 | + trigger.setOperator(null); // 无参数条件 | ||
| 402 | + trigger.setValue(null); | ||
| 403 | + | ||
| 404 | + // 调用 | ||
| 405 | + boolean result = matcher.matches(message, trigger); | ||
| 406 | + | ||
| 407 | + // 断言 | ||
| 408 | + assertTrue(result); | ||
| 409 | + } | ||
| 410 | + | ||
| 411 | + /** | ||
| 412 | + * 测试有参数条件时的匹配逻辑 - 参数条件匹配成功 | ||
| 413 | + * **Property 4: 服务调用触发器参数匹配逻辑** | ||
| 414 | + * **Validates: Requirements 5.1** | ||
| 415 | + */ | ||
| 416 | + @Test | ||
| 417 | + public void testMatches_withParameterCondition_greaterThan_success() { | ||
| 418 | + // 准备参数 | ||
| 419 | + String serviceIdentifier = "level"; | ||
| 420 | + Map<String, Object> serviceParams = MapUtil.builder(new HashMap<String, Object>()) | ||
| 421 | + .put("identifier", serviceIdentifier) | ||
| 422 | + .put("inputData", MapUtil.builder(new HashMap<String, Object>()) | ||
| 423 | + .put("level", 5) | ||
| 424 | + .build()) | ||
| 425 | + .build(); | ||
| 426 | + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); | ||
| 427 | + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); | ||
| 428 | + trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType()); | ||
| 429 | + trigger.setIdentifier(serviceIdentifier); | ||
| 430 | + trigger.setOperator(">"); // 大于操作符 | ||
| 431 | + trigger.setValue("3"); | ||
| 432 | + | ||
| 433 | + // 调用 | ||
| 434 | + boolean result = matcher.matches(message, trigger); | ||
| 435 | + | ||
| 436 | + // 断言 | ||
| 437 | + assertTrue(result); | ||
| 438 | + } | ||
| 439 | + | ||
| 440 | + /** | ||
| 441 | + * 测试有参数条件时的匹配逻辑 - 参数条件匹配失败 | ||
| 442 | + * **Property 4: 服务调用触发器参数匹配逻辑** | ||
| 443 | + * **Validates: Requirements 5.1** | ||
| 444 | + */ | ||
| 445 | + @Test | ||
| 446 | + public void testMatches_withParameterCondition_greaterThan_failure() { | ||
| 447 | + // 准备参数 | ||
| 448 | + String serviceIdentifier = "level"; | ||
| 449 | + Map<String, Object> serviceParams = MapUtil.builder(new HashMap<String, Object>()) | ||
| 450 | + .put("identifier", serviceIdentifier) | ||
| 451 | + .put("inputData", MapUtil.builder(new HashMap<String, Object>()) | ||
| 452 | + .put("level", 2) | ||
| 453 | + .build()) | ||
| 454 | + .build(); | ||
| 455 | + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); | ||
| 456 | + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); | ||
| 457 | + trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType()); | ||
| 458 | + trigger.setIdentifier(serviceIdentifier); | ||
| 459 | + trigger.setOperator(">"); // 大于操作符 | ||
| 460 | + trigger.setValue("3"); | ||
| 461 | + | ||
| 462 | + // 调用 | ||
| 463 | + boolean result = matcher.matches(message, trigger); | ||
| 464 | + | ||
| 465 | + // 断言 | ||
| 466 | + assertFalse(result); | ||
| 467 | + } | ||
| 468 | + | ||
| 469 | + /** | ||
| 470 | + * 测试有参数条件时的匹配逻辑 - 等于操作符 | ||
| 471 | + * **Property 4: 服务调用触发器参数匹配逻辑** | ||
| 472 | + * **Validates: Requirements 5.1** | ||
| 473 | + */ | ||
| 474 | + @Test | ||
| 475 | + public void testMatches_withParameterCondition_equals_success() { | ||
| 476 | + // 准备参数 | ||
| 477 | + String serviceIdentifier = "mode"; | ||
| 478 | + Map<String, Object> serviceParams = MapUtil.builder(new HashMap<String, Object>()) | ||
| 479 | + .put("identifier", serviceIdentifier) | ||
| 480 | + .put("inputData", MapUtil.builder(new HashMap<String, Object>()) | ||
| 481 | + .put("mode", "auto") | ||
| 482 | + .build()) | ||
| 483 | + .build(); | ||
| 484 | + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); | ||
| 485 | + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); | ||
| 486 | + trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType()); | ||
| 487 | + trigger.setIdentifier(serviceIdentifier); | ||
| 488 | + trigger.setOperator("=="); // 等于操作符 | ||
| 489 | + trigger.setValue("auto"); | ||
| 490 | + | ||
| 491 | + // 调用 | ||
| 492 | + boolean result = matcher.matches(message, trigger); | ||
| 493 | + | ||
| 494 | + // 断言 | ||
| 495 | + assertTrue(result); | ||
| 496 | + } | ||
| 497 | + | ||
| 498 | + /** | ||
| 499 | + * 测试参数缺失时的处理 - 消息中缺少 inputData | ||
| 500 | + * **Property 4: 服务调用触发器参数匹配逻辑** | ||
| 501 | + * **Validates: Requirements 5.3** | ||
| 502 | + */ | ||
| 503 | + @Test | ||
| 504 | + public void testMatches_withParameterCondition_missingInputData() { | ||
| 505 | + // 准备参数 | ||
| 506 | + String serviceIdentifier = "testService"; | ||
| 507 | + Map<String, Object> serviceParams = MapUtil.builder(new HashMap<String, Object>()) | ||
| 508 | + .put("identifier", serviceIdentifier) | ||
| 509 | + // 缺少 inputData 字段 | ||
| 510 | + .build(); | ||
| 511 | + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); | ||
| 512 | + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); | ||
| 513 | + trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType()); | ||
| 514 | + trigger.setIdentifier(serviceIdentifier); | ||
| 515 | + trigger.setOperator(">"); // 配置了参数条件 | ||
| 516 | + trigger.setValue("3"); | ||
| 517 | + | ||
| 518 | + // 调用 | ||
| 519 | + boolean result = matcher.matches(message, trigger); | ||
| 520 | + | ||
| 521 | + // 断言 | ||
| 522 | + assertFalse(result); | ||
| 523 | + } | ||
| 524 | + | ||
| 525 | + /** | ||
| 526 | + * 测试参数缺失时的处理 - inputData 中缺少指定参数 | ||
| 527 | + * **Property 4: 服务调用触发器参数匹配逻辑** | ||
| 528 | + * **Validates: Requirements 5.3** | ||
| 529 | + */ | ||
| 530 | + @Test | ||
| 531 | + public void testMatches_withParameterCondition_missingParam() { | ||
| 532 | + // 准备参数 | ||
| 533 | + String serviceIdentifier = "level"; | ||
| 534 | + Map<String, Object> serviceParams = MapUtil.builder(new HashMap<String, Object>()) | ||
| 535 | + .put("identifier", serviceIdentifier) | ||
| 536 | + .put("inputData", MapUtil.builder(new HashMap<String, Object>()) | ||
| 537 | + .put("otherParam", 5) // 不是 level 参数 | ||
| 538 | + .build()) | ||
| 539 | + .build(); | ||
| 540 | + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); | ||
| 541 | + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); | ||
| 542 | + trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType()); | ||
| 543 | + trigger.setIdentifier(serviceIdentifier); | ||
| 544 | + trigger.setOperator(">"); // 配置了参数条件 | ||
| 545 | + trigger.setValue("3"); | ||
| 546 | + | ||
| 547 | + // 调用 | ||
| 548 | + boolean result = matcher.matches(message, trigger); | ||
| 549 | + | ||
| 550 | + // 断言 | ||
| 551 | + assertFalse(result); | ||
| 552 | + } | ||
| 553 | + | ||
| 554 | + /** | ||
| 555 | + * 测试只有 operator 没有 value 时不触发参数条件匹配 | ||
| 556 | + * **Property 4: 服务调用触发器参数匹配逻辑** | ||
| 557 | + * **Validates: Requirements 5.2** | ||
| 558 | + */ | ||
| 559 | + @Test | ||
| 560 | + public void testMatches_onlyOperator_noValue() { | ||
| 561 | + // 准备参数 | ||
| 562 | + String serviceIdentifier = "testService"; | ||
| 563 | + Map<String, Object> serviceParams = MapUtil.builder(new HashMap<String, Object>()) | ||
| 564 | + .put("identifier", serviceIdentifier) | ||
| 565 | + .put("inputData", MapUtil.builder(new HashMap<String, Object>()) | ||
| 566 | + .put("level", 5) | ||
| 567 | + .build()) | ||
| 568 | + .build(); | ||
| 569 | + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); | ||
| 570 | + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); | ||
| 571 | + trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType()); | ||
| 572 | + trigger.setIdentifier(serviceIdentifier); | ||
| 573 | + trigger.setOperator(">"); // 只有 operator | ||
| 574 | + trigger.setValue(null); // 没有 value | ||
| 575 | + | ||
| 576 | + // 调用 | ||
| 577 | + boolean result = matcher.matches(message, trigger); | ||
| 578 | + | ||
| 579 | + // 断言:只有 operator 没有 value 时,不触发参数条件匹配,标识符匹配即成功 | ||
| 580 | + assertTrue(result); | ||
| 581 | + } | ||
| 582 | + | ||
| 583 | + /** | ||
| 584 | + * 测试只有 value 没有 operator 时不触发参数条件匹配 | ||
| 585 | + * **Property 4: 服务调用触发器参数匹配逻辑** | ||
| 586 | + * **Validates: Requirements 5.2** | ||
| 587 | + */ | ||
| 588 | + @Test | ||
| 589 | + public void testMatches_onlyValue_noOperator() { | ||
| 590 | + // 准备参数 | ||
| 591 | + String serviceIdentifier = "testService"; | ||
| 592 | + Map<String, Object> serviceParams = MapUtil.builder(new HashMap<String, Object>()) | ||
| 593 | + .put("identifier", serviceIdentifier) | ||
| 594 | + .put("inputData", MapUtil.builder(new HashMap<String, Object>()) | ||
| 595 | + .put("level", 5) | ||
| 596 | + .build()) | ||
| 597 | + .build(); | ||
| 598 | + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); | ||
| 599 | + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); | ||
| 600 | + trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType()); | ||
| 601 | + trigger.setIdentifier(serviceIdentifier); | ||
| 602 | + trigger.setOperator(null); // 没有 operator | ||
| 603 | + trigger.setValue("3"); // 只有 value | ||
| 604 | + | ||
| 605 | + // 调用 | ||
| 606 | + boolean result = matcher.matches(message, trigger); | ||
| 607 | + | ||
| 608 | + // 断言:只有 value 没有 operator 时,不触发参数条件匹配,标识符匹配即成功 | ||
| 609 | + assertTrue(result); | ||
| 610 | + } | ||
| 611 | + | ||
| 612 | + /** | ||
| 613 | + * 测试使用 inputParams 字段(替代 inputData) | ||
| 614 | + * **Property 4: 服务调用触发器参数匹配逻辑** | ||
| 615 | + * **Validates: Requirements 5.1** | ||
| 616 | + */ | ||
| 617 | + @Test | ||
| 618 | + public void testMatches_withInputParams_success() { | ||
| 619 | + // 准备参数 | ||
| 620 | + String serviceIdentifier = "level"; | ||
| 621 | + Map<String, Object> serviceParams = MapUtil.builder(new HashMap<String, Object>()) | ||
| 622 | + .put("identifier", serviceIdentifier) | ||
| 623 | + .put("inputParams", MapUtil.builder(new HashMap<String, Object>()) // 使用 inputParams 而不是 inputData | ||
| 624 | + .put("level", 5) | ||
| 625 | + .build()) | ||
| 626 | + .build(); | ||
| 627 | + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); | ||
| 628 | + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); | ||
| 629 | + trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType()); | ||
| 630 | + trigger.setIdentifier(serviceIdentifier); | ||
| 631 | + trigger.setOperator(">"); // 大于操作符 | ||
| 632 | + trigger.setValue("3"); | ||
| 633 | + | ||
| 634 | + // 调用 | ||
| 635 | + boolean result = matcher.matches(message, trigger); | ||
| 636 | + | ||
| 637 | + // 断言 | ||
| 638 | + assertTrue(result); | ||
| 639 | + } | ||
| 640 | + | ||
| 381 | // ========== 辅助方法 ========== | 641 | // ========== 辅助方法 ========== |
| 382 | 642 | ||
| 383 | /** | 643 | /** |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtils.java
| @@ -100,6 +100,17 @@ public class IotDeviceMessageUtils { | @@ -100,6 +100,17 @@ public class IotDeviceMessageUtils { | ||
| 100 | } | 100 | } |
| 101 | 101 | ||
| 102 | /** | 102 | /** |
| 103 | + * 判断消息中是否不包含指定的标识符 | ||
| 104 | + * | ||
| 105 | + * @param message 消息 | ||
| 106 | + * @param identifier 要检查的标识符 | ||
| 107 | + * @return 是否不包含 | ||
| 108 | + */ | ||
| 109 | + public static boolean notContainsIdentifier(IotDeviceMessage message, String identifier) { | ||
| 110 | + return !containsIdentifier(message, identifier); | ||
| 111 | + } | ||
| 112 | + | ||
| 113 | + /** | ||
| 103 | * 将 params 解析为 Map | 114 | * 将 params 解析为 Map |
| 104 | * | 115 | * |
| 105 | * @param params 参数(可能是 Map 或 JSON 字符串) | 116 | * @param params 参数(可能是 Map 或 JSON 字符串) |
| @@ -196,6 +207,44 @@ public class IotDeviceMessageUtils { | @@ -196,6 +207,44 @@ public class IotDeviceMessageUtils { | ||
| 196 | return null; | 207 | return null; |
| 197 | } | 208 | } |
| 198 | 209 | ||
| 210 | + /** | ||
| 211 | + * 从服务调用消息中提取输入参数 | ||
| 212 | + * <p> | ||
| 213 | + * 服务调用消息的 params 结构通常为: | ||
| 214 | + * { | ||
| 215 | + * "identifier": "serviceIdentifier", | ||
| 216 | + * "inputData": { ... } 或 "inputParams": { ... } | ||
| 217 | + * } | ||
| 218 | + * | ||
| 219 | + * @param message 设备消息 | ||
| 220 | + * @return 输入参数 Map,如果未找到则返回 null | ||
| 221 | + */ | ||
| 222 | + @SuppressWarnings("unchecked") | ||
| 223 | + public static Map<String, Object> extractServiceInputParams(IotDeviceMessage message) { | ||
| 224 | + Object params = message.getParams(); | ||
| 225 | + if (params == null) { | ||
| 226 | + return null; | ||
| 227 | + } | ||
| 228 | + if (!(params instanceof Map)) { | ||
| 229 | + return null; | ||
| 230 | + } | ||
| 231 | + Map<String, Object> paramsMap = (Map<String, Object>) params; | ||
| 232 | + | ||
| 233 | + // 尝试从 inputData 字段获取 | ||
| 234 | + Object inputData = paramsMap.get("inputData"); | ||
| 235 | + if (inputData instanceof Map) { | ||
| 236 | + return (Map<String, Object>) inputData; | ||
| 237 | + } | ||
| 238 | + | ||
| 239 | + // 尝试从 inputParams 字段获取 | ||
| 240 | + Object inputParams = paramsMap.get("inputParams"); | ||
| 241 | + if (inputParams instanceof Map) { | ||
| 242 | + return (Map<String, Object>) inputParams; | ||
| 243 | + } | ||
| 244 | + | ||
| 245 | + return null; | ||
| 246 | + } | ||
| 247 | + | ||
| 199 | // ========== Topic 相关 ========== | 248 | // ========== Topic 相关 ========== |
| 200 | 249 | ||
| 201 | public static String buildMessageBusGatewayDeviceMessageTopic(String serverId) { | 250 | public static String buildMessageBusGatewayDeviceMessageTopic(String serverId) { |
yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtilsTest.java
| 1 | package cn.iocoder.yudao.module.iot.core.util; | 1 | package cn.iocoder.yudao.module.iot.core.util; |
| 2 | 2 | ||
| 3 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | ||
| 3 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | 4 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 4 | import org.junit.jupiter.api.Test; | 5 | import org.junit.jupiter.api.Test; |
| 5 | 6 | ||
| 6 | import java.util.HashMap; | 7 | import java.util.HashMap; |
| 7 | import java.util.Map; | 8 | import java.util.Map; |
| 8 | 9 | ||
| 9 | -import static org.junit.jupiter.api.Assertions.assertEquals; | ||
| 10 | -import static org.junit.jupiter.api.Assertions.assertNull; | 10 | +import static org.junit.jupiter.api.Assertions.*; |
| 11 | 11 | ||
| 12 | /** | 12 | /** |
| 13 | * {@link IotDeviceMessageUtils} 的单元测试 | 13 | * {@link IotDeviceMessageUtils} 的单元测试 |
| @@ -138,4 +138,72 @@ public class IotDeviceMessageUtilsTest { | @@ -138,4 +138,72 @@ public class IotDeviceMessageUtilsTest { | ||
| 138 | Object result = IotDeviceMessageUtils.extractPropertyValue(message, "temperature"); | 138 | Object result = IotDeviceMessageUtils.extractPropertyValue(message, "temperature"); |
| 139 | assertEquals(25.5, result); // 应该返回直接标识符的值 | 139 | assertEquals(25.5, result); // 应该返回直接标识符的值 |
| 140 | } | 140 | } |
| 141 | + | ||
| 142 | + // ========== notContainsIdentifier 测试 ========== | ||
| 143 | + | ||
| 144 | + /** | ||
| 145 | + * 测试 notContainsIdentifier 与 containsIdentifier 的互补性 | ||
| 146 | + * **Property 2: notContainsIdentifier 与 containsIdentifier 互补性** | ||
| 147 | + * **Validates: Requirements 4.1** | ||
| 148 | + */ | ||
| 149 | + @Test | ||
| 150 | + public void testNotContainsIdentifier_complementary_whenContains() { | ||
| 151 | + // 准备参数:消息包含指定标识符 | ||
| 152 | + IotDeviceMessage message = new IotDeviceMessage(); | ||
| 153 | + message.setMethod(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()); | ||
| 154 | + Map<String, Object> params = new HashMap<>(); | ||
| 155 | + params.put("temperature", 25); | ||
| 156 | + message.setParams(params); | ||
| 157 | + String identifier = "temperature"; | ||
| 158 | + | ||
| 159 | + // 调用 & 断言:验证互补性 | ||
| 160 | + boolean containsResult = IotDeviceMessageUtils.containsIdentifier(message, identifier); | ||
| 161 | + boolean notContainsResult = IotDeviceMessageUtils.notContainsIdentifier(message, identifier); | ||
| 162 | + assertTrue(containsResult); | ||
| 163 | + assertFalse(notContainsResult); | ||
| 164 | + assertEquals(!containsResult, notContainsResult); | ||
| 165 | + } | ||
| 166 | + | ||
| 167 | + /** | ||
| 168 | + * 测试 notContainsIdentifier 与 containsIdentifier 的互补性 | ||
| 169 | + * **Property 2: notContainsIdentifier 与 containsIdentifier 互补性** | ||
| 170 | + * **Validates: Requirements 4.1** | ||
| 171 | + */ | ||
| 172 | + @Test | ||
| 173 | + public void testNotContainsIdentifier_complementary_whenNotContains() { | ||
| 174 | + // 准备参数:消息不包含指定标识符 | ||
| 175 | + IotDeviceMessage message = new IotDeviceMessage(); | ||
| 176 | + message.setMethod(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()); | ||
| 177 | + Map<String, Object> params = new HashMap<>(); | ||
| 178 | + params.put("temperature", 25); | ||
| 179 | + message.setParams(params); | ||
| 180 | + String identifier = "humidity"; | ||
| 181 | + | ||
| 182 | + // 调用 & 断言:验证互补性 | ||
| 183 | + boolean containsResult = IotDeviceMessageUtils.containsIdentifier(message, identifier); | ||
| 184 | + boolean notContainsResult = IotDeviceMessageUtils.notContainsIdentifier(message, identifier); | ||
| 185 | + assertFalse(containsResult); | ||
| 186 | + assertTrue(notContainsResult); | ||
| 187 | + assertEquals(!containsResult, notContainsResult); | ||
| 188 | + } | ||
| 189 | + | ||
| 190 | + /** | ||
| 191 | + * 测试 notContainsIdentifier 与 containsIdentifier 的互补性 - 空参数场景 | ||
| 192 | + * **Property 2: notContainsIdentifier 与 containsIdentifier 互补性** | ||
| 193 | + * **Validates: Requirements 4.1** | ||
| 194 | + */ | ||
| 195 | + @Test | ||
| 196 | + public void testNotContainsIdentifier_complementary_nullParams() { | ||
| 197 | + // 准备参数:params 为 null | ||
| 198 | + IotDeviceMessage message = new IotDeviceMessage(); | ||
| 199 | + message.setParams(null); | ||
| 200 | + String identifier = "temperature"; | ||
| 201 | + | ||
| 202 | + // 调用 & 断言:验证互补性 | ||
| 203 | + boolean containsResult = IotDeviceMessageUtils.containsIdentifier(message, identifier); | ||
| 204 | + boolean notContainsResult = IotDeviceMessageUtils.notContainsIdentifier(message, identifier); | ||
| 205 | + assertFalse(containsResult); | ||
| 206 | + assertTrue(notContainsResult); | ||
| 207 | + assertEquals(!containsResult, notContainsResult); | ||
| 208 | + } | ||
| 141 | } | 209 | } |