Commit 6c971631b03c1aeb065ba8a157b368aa1761d383

Authored by puhui999
2 parents 4901912e 4ad4fcf6

Merge remote-tracking branch 'origin/feature/iot' into feature/iot

Showing 15 changed files with 1067 additions and 82 deletions
yudao-dependencies/pom.xml
... ... @@ -76,6 +76,8 @@
76 76 <jimubi.version>2.3.0</jimubi.version>
77 77 <weixin-java.version>4.7.9-20251224.161447</weixin-java.version>
78 78 <alipay-sdk-java.version>4.40.607.ALL</alipay-sdk-java.version>
  79 + <!-- OkHttp -->
  80 + <okhttp.version>4.12.0</okhttp.version>
79 81 </properties>
80 82  
81 83 <dependencyManagement>
... ... @@ -653,6 +655,19 @@
653 655 <artifactId>org.eclipse.paho.client.mqttv3</artifactId>
654 656 <version>${mqtt.version}</version>
655 657 </dependency>
  658 +
  659 + <!-- OkHttp -->
  660 + <dependency>
  661 + <groupId>com.squareup.okhttp3</groupId>
  662 + <artifactId>okhttp</artifactId>
  663 + <version>${okhttp.version}</version>
  664 + </dependency>
  665 + <dependency>
  666 + <groupId>com.squareup.okhttp3</groupId>
  667 + <artifactId>mockwebserver</artifactId>
  668 + <version>${okhttp.version}</version>
  669 + <scope>test</scope>
  670 + </dependency>
656 671 </dependencies>
657 672 </dependencyManagement>
658 673  
... ...
yudao-module-iot/yudao-module-iot-biz/pom.xml
... ... @@ -73,6 +73,17 @@
73 73 <artifactId>yudao-spring-boot-starter-excel</artifactId>
74 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 88 <!-- TODO @芋艿:临时打开 -->
78 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 84 */
85 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/dal/redis/rule/IotWebSocketLockRedisDAO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.dal.redis.rule;
  2 +
  3 +import jakarta.annotation.Resource;
  4 +import org.redisson.api.RLock;
  5 +import org.redisson.api.RedissonClient;
  6 +import org.springframework.stereotype.Repository;
  7 +
  8 +import java.util.concurrent.TimeUnit;
  9 +
  10 +import static cn.iocoder.yudao.module.iot.dal.redis.RedisKeyConstants.WEBSOCKET_CONNECT_LOCK;
  11 +
  12 +/**
  13 + * IoT WebSocket 连接锁 Redis DAO
  14 + * <p>
  15 + * 用于保证 WebSocket 重连操作的线程安全,避免多线程同时重连导致的资源竞争
  16 + *
  17 + * @author HUIHUI
  18 + */
  19 +@Repository
  20 +public class IotWebSocketLockRedisDAO {
  21 +
  22 + /**
  23 + * 锁等待超时时间(毫秒)
  24 + */
  25 + private static final long LOCK_WAIT_TIME_MS = 5000;
  26 +
  27 + /**
  28 + * 锁持有超时时间(毫秒)
  29 + */
  30 + private static final long LOCK_LEASE_TIME_MS = 10000;
  31 +
  32 + @Resource
  33 + private RedissonClient redissonClient;
  34 +
  35 + /**
  36 + * 在分布式锁保护下执行操作
  37 + *
  38 + * @param serverUrl WebSocket 服务器地址
  39 + * @param runnable 需要执行的操作
  40 + * @throws Exception 如果获取锁超时或执行操作时发生异常
  41 + */
  42 + public void lock(String serverUrl, Runnable runnable) throws Exception {
  43 + String lockKey = formatKey(serverUrl);
  44 + RLock lock = redissonClient.getLock(lockKey);
  45 +
  46 + try {
  47 + // 尝试获取分布式锁
  48 + boolean acquired = lock.tryLock(LOCK_WAIT_TIME_MS, LOCK_LEASE_TIME_MS, TimeUnit.MILLISECONDS);
  49 + if (!acquired) {
  50 + throw new RuntimeException("获取 WebSocket 连接锁超时,服务器: " + serverUrl);
  51 + }
  52 +
  53 + // 执行操作
  54 + runnable.run();
  55 + } finally {
  56 + // 释放锁
  57 + if (lock.isHeldByCurrentThread()) {
  58 + lock.unlock();
  59 + }
  60 + }
  61 + }
  62 +
  63 + private static String formatKey(String serverUrl) {
  64 + return String.format(WEBSOCKET_CONNECT_LOCK, serverUrl);
  65 + }
  66 +
  67 +}
... ...
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 43 config.getConnectTimeoutMs(),
44 44 config.getReadTimeoutMs(),
45 45 config.getSsl(),
46   - config.getSslCertPath(),
47 46 config.getDataFormat()
48 47 );
49 48 // 2.2 连接服务器
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotWebSocketDataRuleAction.java
... ... @@ -3,8 +3,10 @@ package cn.iocoder.yudao.module.iot.service.rule.data.action;
3 3 import cn.hutool.core.util.StrUtil;
4 4 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
5 5 import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkWebSocketConfig;
  6 +import cn.iocoder.yudao.module.iot.dal.redis.rule.IotWebSocketLockRedisDAO;
6 7 import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum;
7 8 import cn.iocoder.yudao.module.iot.service.rule.data.action.websocket.IotWebSocketClient;
  9 +import jakarta.annotation.Resource;
8 10 import lombok.extern.slf4j.Slf4j;
9 11 import org.springframework.stereotype.Component;
10 12  
... ... @@ -22,6 +24,9 @@ import org.springframework.stereotype.Component;
22 24 public class IotWebSocketDataRuleAction extends
23 25 IotDataRuleCacheableAction<IotDataSinkWebSocketConfig, IotWebSocketClient> {
24 26  
  27 + @Resource
  28 + private IotWebSocketLockRedisDAO webSocketLockRedisDAO;
  29 +
25 30 @Override
26 31 public Integer getType() {
27 32 return IotDataSinkTypeEnum.WEBSOCKET.getType();
... ... @@ -62,12 +67,11 @@ public class IotWebSocketDataRuleAction extends
62 67 protected void execute(IotDeviceMessage message, IotDataSinkWebSocketConfig config) throws Exception {
63 68 try {
64 69 // 1.1 获取或创建 WebSocket 客户端
65   - // TODO @puhui999:需要加锁,保证必须连接上;
66 70 IotWebSocketClient webSocketClient = getProducer(config);
67   - // 1.2 检查连接状态,如果断开则重新连接
  71 +
  72 + // 1.2 检查连接状态,如果断开则使用分布式锁保证重连的线程安全
68 73 if (!webSocketClient.isConnected()) {
69   - log.warn("[execute][WebSocket 连接已断开,尝试重新连接,服务器: {}]", config.getServerUrl());
70   - webSocketClient.connect();
  74 + reconnectWithLock(webSocketClient, config);
71 75 }
72 76  
73 77 // 2.1 发送消息
... ... @@ -82,4 +86,24 @@ public class IotWebSocketDataRuleAction extends
82 86 }
83 87 }
84 88  
  89 + /**
  90 + * 使用分布式锁进行重连
  91 + *
  92 + * @param webSocketClient WebSocket 客户端
  93 + * @param config 配置信息
  94 + */
  95 + private void reconnectWithLock(IotWebSocketClient webSocketClient, IotDataSinkWebSocketConfig config) throws Exception {
  96 + webSocketLockRedisDAO.lock(config.getServerUrl(), () -> {
  97 + // 双重检查:获取锁后再次检查连接状态,避免重复连接
  98 + if (!webSocketClient.isConnected()) {
  99 + log.warn("[reconnectWithLock][WebSocket 连接已断开,尝试重新连接,服务器: {}]", config.getServerUrl());
  100 + try {
  101 + webSocketClient.connect();
  102 + } catch (Exception e) {
  103 + throw new RuntimeException("WebSocket 重连失败,服务器: " + config.getServerUrl(), e);
  104 + }
  105 + }
  106 + });
  107 + }
  108 +
85 109 }
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/tcp/IotTcpClient.java
1 1 package cn.iocoder.yudao.module.iot.service.rule.data.action.tcp;
2 2  
  3 +import cn.hutool.core.util.ObjUtil;
3 4 import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
4 5 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
5 6 import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkTcpConfig;
... ... @@ -31,8 +32,6 @@ public class IotTcpClient {
31 32 private final Integer connectTimeoutMs;
32 33 private final Integer readTimeoutMs;
33 34 private final Boolean ssl;
34   - // TODO @puhui999:sslCertPath 是不是没在用?
35   - private final String sslCertPath;
36 35 private final String dataFormat;
37 36  
38 37 private Socket socket;
... ... @@ -41,15 +40,13 @@ public class IotTcpClient {
41 40 private final AtomicBoolean connected = new AtomicBoolean(false);
42 41  
43 42 public IotTcpClient(String host, Integer port, Integer connectTimeoutMs, Integer readTimeoutMs,
44   - Boolean ssl, String sslCertPath, String dataFormat) {
  43 + Boolean ssl, String dataFormat) {
45 44 this.host = host;
46 45 this.port = port;
47 46 this.connectTimeoutMs = connectTimeoutMs != null ? connectTimeoutMs : IotDataSinkTcpConfig.DEFAULT_CONNECT_TIMEOUT_MS;
48 47 this.readTimeoutMs = readTimeoutMs != null ? readTimeoutMs : IotDataSinkTcpConfig.DEFAULT_READ_TIMEOUT_MS;
49 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 4 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
5 5 import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkWebSocketConfig;
6 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 10 import java.util.concurrent.TimeUnit;
15 11 import java.util.concurrent.atomic.AtomicBoolean;
16 12  
... ... @@ -19,21 +15,23 @@ import java.util.concurrent.atomic.AtomicBoolean;
19 15 * <p>
20 16 * 负责与外部 WebSocket 服务器建立连接并发送设备消息
21 17 * 支持 ws:// 和 wss:// 协议,支持 JSON 和 TEXT 数据格式
22   - * 基于 Java 11+ 内置的 java.net.http.WebSocket 实现
  18 + * 基于 OkHttp WebSocket 实现,兼容 JDK 8+
  19 + * <p>
  20 + * 注意:该类的线程安全由调用方(IotWebSocketDataRuleAction)通过分布式锁保证
23 21 *
24 22 * @author HUIHUI
25 23 */
26 24 @Slf4j
27   -public class IotWebSocketClient implements WebSocket.Listener {
  25 +public class IotWebSocketClient {
28 26  
29 27 private final String serverUrl;
30 28 private final Integer connectTimeoutMs;
31 29 private final Integer sendTimeoutMs;
32 30 private final String dataFormat;
33 31  
34   - private WebSocket webSocket;
  32 + private OkHttpClient okHttpClient;
  33 + private volatile WebSocket webSocket;
35 34 private final AtomicBoolean connected = new AtomicBoolean(false);
36   - private final StringBuilder messageBuffer = new StringBuilder();
37 35  
38 36 public IotWebSocketClient(String serverUrl, Integer connectTimeoutMs, Integer sendTimeoutMs, String dataFormat) {
39 37 this.serverUrl = serverUrl;
... ... @@ -44,8 +42,9 @@ public class IotWebSocketClient implements WebSocket.Listener {
44 42  
45 43 /**
46 44 * 连接到 WebSocket 服务器
  45 + * <p>
  46 + * 注意:调用方需要通过分布式锁保证并发安全
47 47 */
48   - @SuppressWarnings("resource")
49 48 public void connect() throws Exception {
50 49 if (connected.get()) {
51 50 log.warn("[connect][WebSocket 客户端已经连接,无需重复连接]");
... ... @@ -53,17 +52,32 @@ public class IotWebSocketClient implements WebSocket.Listener {
53 52 }
54 53  
55 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 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 81 log.info("[connect][WebSocket 客户端连接成功,服务器地址: {}]", serverUrl);
68 82 } catch (Exception e) {
69 83 close();
... ... @@ -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 93 * @throws Exception 发送异常
110 94 */
111 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 98 throw new IllegalStateException("WebSocket 客户端未连接");
114 99 }
115 100  
... ... @@ -121,9 +106,11 @@ public class IotWebSocketClient implements WebSocket.Listener {
121 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 114 log.debug("[sendMessage][发送消息成功,设备 ID: {},消息长度: {}]",
128 115 message.getDeviceId(), messageData.length());
129 116 } catch (Exception e) {
... ... @@ -136,18 +123,17 @@ public class IotWebSocketClient implements WebSocket.Listener {
136 123 * 关闭连接
137 124 */
138 125 public void close() {
139   - if (!connected.get() && webSocket == null) {
140   - return;
141   - }
142   -
143 126 try {
144 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 138 connected.set(false);
153 139 log.info("[close][WebSocket 客户端连接已关闭,服务器地址: {}]", serverUrl);
... ... @@ -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/matcher/trigger/IotDevicePropertyPostTriggerMatcher.java
... ... @@ -38,8 +38,7 @@ public class IotDevicePropertyPostTriggerMatcher implements IotSceneRuleTriggerM
38 38  
39 39 // 1.3 检查消息中是否包含触发器指定的属性标识符
40 40 // 注意:属性上报可能同时上报多个属性,所以需要判断 trigger.getIdentifier() 是否在 message 的 params 中
41   - // TODO @puhui999:可以考虑 notXXX 方法,简化代码(尽量取反)
42   - if (!IotDeviceMessageUtils.containsIdentifier(message, trigger.getIdentifier())) {
  41 + if (IotDeviceMessageUtils.notContainsIdentifier(message, trigger.getIdentifier())) {
43 42 IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "消息中不包含属性: " +
44 43 trigger.getIdentifier());
45 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 1 package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.trigger;
2 2  
  3 +import cn.hutool.core.util.StrUtil;
3 4 import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
4 5 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
5 6 import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
... ... @@ -8,6 +9,8 @@ import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum;
8 9 import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherHelper;
9 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 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 54 IotSceneRuleMatcherHelper.logTriggerMatchSuccess(message, trigger);
49 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 100 @Override
53 101 public int getPriority() {
54 102 return 40; // 较低优先级
... ...
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/matcher/trigger/IotDeviceServiceInvokeTriggerMatcherTest.java
... ... @@ -7,7 +7,6 @@ import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO;
7 7 import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum;
8 8 import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotBaseConditionMatcherTest;
9 9 import org.junit.jupiter.api.BeforeEach;
10   -import org.junit.jupiter.api.Disabled;
11 10 import org.junit.jupiter.api.Test;
12 11  
13 12 import java.util.HashMap;
... ... @@ -23,7 +22,6 @@ import static org.junit.jupiter.api.Assertions.*;
23 22 *
24 23 * @author HUIHUI
25 24 */
26   -@Disabled // TODO @puhui999:单测有报错,先屏蔽
27 25 public class IotDeviceServiceInvokeTriggerMatcherTest extends IotBaseConditionMatcherTest {
28 26  
29 27 private IotDeviceServiceInvokeTriggerMatcher matcher;
... ... @@ -378,6 +376,268 @@ public class IotDeviceServiceInvokeTriggerMatcherTest extends IotBaseConditionMa
378 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 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 114 * 将 params 解析为 Map
104 115 *
105 116 * @param params 参数(可能是 Map 或 JSON 字符串)
... ... @@ -196,6 +207,44 @@ public class IotDeviceMessageUtils {
196 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 248 // ========== Topic 相关 ==========
200 249  
201 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 1 package cn.iocoder.yudao.module.iot.core.util;
2 2  
  3 +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
3 4 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
4 5 import org.junit.jupiter.api.Test;
5 6  
6 7 import java.util.HashMap;
7 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 13 * {@link IotDeviceMessageUtils} 的单元测试
... ... @@ -138,4 +138,72 @@ public class IotDeviceMessageUtilsTest {
138 138 Object result = IotDeviceMessageUtils.extractPropertyValue(message, "temperature");
139 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 }
... ...