Commit f3878c30560afc789236a26e72a44b0c199cc3e2

Authored by 芋道源码
Committed by Gitee
2 parents 4b67fc2d 7ec541e5

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