Commit e2b995d448955285ee340be209e0dc2d1e7a1c75

Authored by YunaiV
1 parent e41d66ff

feat(iot):【协议改造】emqx 初步改造(40%):整体代码风格的优化

Showing 15 changed files with 1056 additions and 1314 deletions
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java
1 package cn.iocoder.yudao.module.iot.gateway.config; 1 package cn.iocoder.yudao.module.iot.gateway.config;
2 2
3 -import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;  
4 import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocolManager; 3 import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocolManager;
5 -import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxAuthEventProtocol;  
6 -import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.downstream.IotEmqxDownstreamSubscriber;  
7 -import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxUpstreamProtocol;  
8 import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializerManager; 4 import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializerManager;
9 -import io.vertx.core.Vertx;  
10 -import lombok.extern.slf4j.Slf4j;  
11 -import org.springframework.beans.factory.annotation.Qualifier;  
12 -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;  
13 import org.springframework.boot.context.properties.EnableConfigurationProperties; 5 import org.springframework.boot.context.properties.EnableConfigurationProperties;
14 import org.springframework.context.annotation.Bean; 6 import org.springframework.context.annotation.Bean;
15 import org.springframework.context.annotation.Configuration; 7 import org.springframework.context.annotation.Configuration;
16 8
17 -  
18 @Configuration 9 @Configuration
19 @EnableConfigurationProperties(IotGatewayProperties.class) 10 @EnableConfigurationProperties(IotGatewayProperties.class)
20 -@Slf4j  
21 public class IotGatewayConfiguration { 11 public class IotGatewayConfiguration {
22 12
23 @Bean 13 @Bean
@@ -30,36 +20,4 @@ public class IotGatewayConfiguration { @@ -30,36 +20,4 @@ public class IotGatewayConfiguration {
30 return new IotProtocolManager(gatewayProperties); 20 return new IotProtocolManager(gatewayProperties);
31 } 21 }
32 22
33 - /**  
34 - * IoT 网关 EMQX 协议配置类  
35 - */  
36 - @Configuration  
37 - @ConditionalOnProperty(prefix = "yudao.iot.gateway.protocol.emqx", name = "enabled", havingValue = "true")  
38 - @Slf4j  
39 - public static class EmqxProtocolConfiguration {  
40 -  
41 - @Bean(name = "emqxVertx", destroyMethod = "close")  
42 - public Vertx emqxVertx() {  
43 - return Vertx.vertx();  
44 - }  
45 -  
46 - @Bean  
47 - public IotEmqxAuthEventProtocol iotEmqxAuthEventProtocol(IotGatewayProperties gatewayProperties,  
48 - @Qualifier("emqxVertx") Vertx emqxVertx) {  
49 - return new IotEmqxAuthEventProtocol(gatewayProperties.getProtocol().getEmqx(), emqxVertx);  
50 - }  
51 -  
52 - @Bean  
53 - public IotEmqxUpstreamProtocol iotEmqxUpstreamProtocol(IotGatewayProperties gatewayProperties,  
54 - @Qualifier("emqxVertx") Vertx emqxVertx) {  
55 - return new IotEmqxUpstreamProtocol(gatewayProperties.getProtocol().getEmqx(), emqxVertx);  
56 - }  
57 -  
58 - @Bean  
59 - public IotEmqxDownstreamSubscriber iotEmqxDownstreamSubscriber(IotEmqxUpstreamProtocol mqttUpstreamProtocol,  
60 - IotMessageBus messageBus) {  
61 - return new IotEmqxDownstreamSubscriber(mqttUpstreamProtocol, messageBus);  
62 - }  
63 - }  
64 -  
65 } 23 }
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayProperties.java
@@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.iot.gateway.config; @@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.iot.gateway.config;
2 2
3 import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; 3 import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
4 import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapConfig; 4 import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapConfig;
  5 +import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxConfig;
5 import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpConfig; 6 import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpConfig;
6 import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttConfig; 7 import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttConfig;
7 import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpConfig; 8 import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpConfig;
@@ -32,11 +33,6 @@ public class IotGatewayProperties { @@ -32,11 +33,6 @@ public class IotGatewayProperties {
32 private TokenProperties token; 33 private TokenProperties token;
33 34
34 /** 35 /**
35 - * 协议配置(旧版,保持兼容)  
36 - */  
37 - private ProtocolProperties protocol;  
38 -  
39 - /**  
40 * 协议实例列表 36 * 协议实例列表
41 */ 37 */
42 private List<ProtocolInstanceProperties> protocols; 38 private List<ProtocolInstanceProperties> protocols;
@@ -78,186 +74,6 @@ public class IotGatewayProperties { @@ -78,186 +74,6 @@ public class IotGatewayProperties {
78 74
79 } 75 }
80 76
81 - @Data  
82 - public static class ProtocolProperties {  
83 -  
84 - /**  
85 - * EMQX 组件配置  
86 - */  
87 - private EmqxProperties emqx;  
88 -  
89 - }  
90 -  
91 - @Data  
92 - public static class EmqxProperties {  
93 -  
94 - /**  
95 - * 是否开启  
96 - */  
97 - @NotNull(message = "是否开启不能为空")  
98 - private Boolean enabled;  
99 -  
100 - /**  
101 - * HTTP 服务端口(默认:8090)  
102 - */  
103 - private Integer httpPort = 8090;  
104 -  
105 - /**  
106 - * MQTT 服务器地址  
107 - */  
108 - @NotEmpty(message = "MQTT 服务器地址不能为空")  
109 - private String mqttHost;  
110 -  
111 - /**  
112 - * MQTT 服务器端口(默认:1883)  
113 - */  
114 - @NotNull(message = "MQTT 服务器端口不能为空")  
115 - private Integer mqttPort = 1883;  
116 -  
117 - /**  
118 - * MQTT 用户名  
119 - */  
120 - @NotEmpty(message = "MQTT 用户名不能为空")  
121 - private String mqttUsername;  
122 -  
123 - /**  
124 - * MQTT 密码  
125 - */  
126 - @NotEmpty(message = "MQTT 密码不能为空")  
127 - private String mqttPassword;  
128 -  
129 - /**  
130 - * MQTT 客户端的 SSL 开关  
131 - */  
132 - @NotNull(message = "MQTT 是否开启 SSL 不能为空")  
133 - private Boolean mqttSsl = false;  
134 -  
135 - /**  
136 - * MQTT 客户端 ID(如果为空,系统将自动生成)  
137 - */  
138 - @NotEmpty(message = "MQTT 客户端 ID 不能为空")  
139 - private String mqttClientId;  
140 -  
141 - /**  
142 - * MQTT 订阅的主题  
143 - */  
144 - @NotEmpty(message = "MQTT 主题不能为空")  
145 - private List<@NotEmpty(message = "MQTT 主题不能为空") String> mqttTopics;  
146 -  
147 - /**  
148 - * 默认 QoS 级别  
149 - * <p>  
150 - * 0 - 最多一次  
151 - * 1 - 至少一次  
152 - * 2 - 刚好一次  
153 - */  
154 - private Integer mqttQos = 1;  
155 -  
156 - /**  
157 - * 连接超时时间(秒)  
158 - */  
159 - private Integer connectTimeoutSeconds = 10;  
160 -  
161 - /**  
162 - * 重连延迟时间(毫秒)  
163 - */  
164 - private Long reconnectDelayMs = 5000L;  
165 -  
166 - /**  
167 - * 是否启用 Clean Session (清理会话)  
168 - * true: 每次连接都是新会话,Broker 不保留离线消息和订阅关系。  
169 - * 对于网关这类“永远在线”且会主动重新订阅的应用,建议为 true。  
170 - */  
171 - private Boolean cleanSession = true;  
172 -  
173 - /**  
174 - * 心跳间隔(秒)  
175 - * 用于保持连接活性,及时发现网络中断。  
176 - */  
177 - private Integer keepAliveIntervalSeconds = 60;  
178 -  
179 - /**  
180 - * 最大未确认消息队列大小  
181 - * 限制已发送但未收到 Broker 确认的 QoS 1/2 消息数量,用于流量控制。  
182 - */  
183 - private Integer maxInflightQueue = 10000;  
184 -  
185 - /**  
186 - * 是否信任所有 SSL 证书  
187 - * 警告:此配置会绕过证书验证,仅建议在开发和测试环境中使用!  
188 - * 在生产环境中,应设置为 false,并配置正确的信任库。  
189 - */  
190 - private Boolean trustAll = false;  
191 -  
192 - /**  
193 - * 遗嘱消息配置 (用于网关异常下线时通知其他系统)  
194 - */  
195 - private final Will will = new Will();  
196 -  
197 - /**  
198 - * 高级 SSL/TLS 配置 (用于生产环境)  
199 - */  
200 - private final Ssl sslOptions = new Ssl();  
201 -  
202 - /**  
203 - * 遗嘱消息 (Last Will and Testament)  
204 - */  
205 - @Data  
206 - public static class Will {  
207 -  
208 - /**  
209 - * 是否启用遗嘱消息  
210 - */  
211 - private boolean enabled = false;  
212 - /**  
213 - * 遗嘱消息主题  
214 - */  
215 - private String topic;  
216 - /**  
217 - * 遗嘱消息内容  
218 - */  
219 - private String payload;  
220 - /**  
221 - * 遗嘱消息 QoS 等级  
222 - */  
223 - private Integer qos = 1;  
224 - /**  
225 - * 遗嘱消息是否作为保留消息发布  
226 - */  
227 - private boolean retain = true;  
228 -  
229 - }  
230 -  
231 - /**  
232 - * 高级 SSL/TLS 配置  
233 - */  
234 - @Data  
235 - public static class Ssl {  
236 -  
237 - /**  
238 - * 密钥库(KeyStore)路径,例如:classpath:certs/client.jks  
239 - * 包含客户端自己的证书和私钥,用于向服务端证明身份(双向认证)。  
240 - */  
241 - private String keyStorePath;  
242 - /**  
243 - * 密钥库密码  
244 - */  
245 - private String keyStorePassword;  
246 - /**  
247 - * 信任库(TrustStore)路径,例如:classpath:certs/trust.jks  
248 - * 包含服务端信任的 CA 证书,用于验证服务端的身份,防止中间人攻击。  
249 - */  
250 - private String trustStorePath;  
251 - /**  
252 - * 信任库密码  
253 - */  
254 - private String trustStorePassword;  
255 -  
256 - }  
257 -  
258 - }  
259 -  
260 - // NOTE:暂未统一为 ProtocolProperties,待协议改造完成再调整  
261 /** 77 /**
262 * 协议实例配置 78 * 协议实例配置
263 */ 79 */
@@ -283,6 +99,10 @@ public class IotGatewayProperties { @@ -283,6 +99,10 @@ public class IotGatewayProperties {
283 private String protocol; 99 private String protocol;
284 /** 100 /**
285 * 服务端口 101 * 服务端口
  102 + * <p>
  103 + * 不同协议含义不同:
  104 + * 1. TCP/UDP/HTTP/WebSocket/MQTT/CoAP:对应网关自身监听的服务端口
  105 + * 2. EMQX:对应网关提供给 EMQX 回调的 HTTP Hook 端口(/mqtt/auth、/mqtt/acl、/mqtt/event)
286 */ 106 */
287 @NotNull(message = "服务端口不能为空") 107 @NotNull(message = "服务端口不能为空")
288 private Integer port; 108 private Integer port;
@@ -292,7 +112,7 @@ public class IotGatewayProperties { @@ -292,7 +112,7 @@ public class IotGatewayProperties {
292 * @see cn.iocoder.yudao.module.iot.core.enums.IotSerializeTypeEnum 112 * @see cn.iocoder.yudao.module.iot.core.enums.IotSerializeTypeEnum
293 * 113 *
294 * 为什么是可选的呢? 114 * 为什么是可选的呢?
295 - * 1. {@link IotProtocolTypeEnum#HTTP}、${@link IotProtocolTypeEnum#COAP} 协议,目前强制是 JSON 格式 115 + * 1. {@link IotProtocolTypeEnum#HTTP}、{@link IotProtocolTypeEnum#COAP} 协议,目前强制是 JSON 格式
296 * 2. {@link IotProtocolTypeEnum#EMQX} 协议,目前支持根据产品(设备)配置的序列化类型来解析 116 * 2. {@link IotProtocolTypeEnum#EMQX} 协议,目前支持根据产品(设备)配置的序列化类型来解析
297 */ 117 */
298 private String serialize; 118 private String serialize;
@@ -304,13 +124,17 @@ public class IotGatewayProperties { @@ -304,13 +124,17 @@ public class IotGatewayProperties {
304 */ 124 */
305 @Valid 125 @Valid
306 private IotHttpConfig http; 126 private IotHttpConfig http;
  127 + /**
  128 + * WebSocket 协议配置
  129 + */
  130 + @Valid
  131 + private IotWebSocketConfig websocket;
307 132
308 /** 133 /**
309 * TCP 协议配置 134 * TCP 协议配置
310 */ 135 */
311 @Valid 136 @Valid
312 private IotTcpConfig tcp; 137 private IotTcpConfig tcp;
313 -  
314 /** 138 /**
315 * UDP 协议配置 139 * UDP 协议配置
316 */ 140 */
@@ -324,16 +148,15 @@ public class IotGatewayProperties { @@ -324,16 +148,15 @@ public class IotGatewayProperties {
324 private IotCoapConfig coap; 148 private IotCoapConfig coap;
325 149
326 /** 150 /**
327 - * WebSocket 协议配置 151 + * MQTT 协议配置
328 */ 152 */
329 @Valid 153 @Valid
330 - private IotWebSocketConfig websocket;  
331 - 154 + private IotMqttConfig mqtt;
332 /** 155 /**
333 - * MQTT 协议配置 156 + * EMQX 协议配置
334 */ 157 */
335 @Valid 158 @Valid
336 - private IotMqttConfig mqtt; 159 + private IotEmqxConfig emqx;
337 160
338 } 161 }
339 162
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocolManager.java
@@ -5,6 +5,7 @@ import cn.hutool.core.util.BooleanUtil; @@ -5,6 +5,7 @@ import cn.hutool.core.util.BooleanUtil;
5 import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; 5 import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
6 import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; 6 import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties;
7 import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapProtocol; 7 import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapProtocol;
  8 +import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxProtocol;
8 import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpProtocol; 9 import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpProtocol;
9 import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttProtocol; 10 import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttProtocol;
10 import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpProtocol; 11 import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpProtocol;
@@ -109,6 +110,8 @@ public class IotProtocolManager implements SmartLifecycle { @@ -109,6 +110,8 @@ public class IotProtocolManager implements SmartLifecycle {
109 return createWebSocketProtocol(config); 110 return createWebSocketProtocol(config);
110 case MQTT: 111 case MQTT:
111 return createMqttProtocol(config); 112 return createMqttProtocol(config);
  113 + case EMQX:
  114 + return createEmqxProtocol(config);
112 default: 115 default:
113 throw new IllegalArgumentException(String.format( 116 throw new IllegalArgumentException(String.format(
114 "[createProtocol][协议实例 %s 的协议类型 %s 暂不支持]", config.getId(), protocolType)); 117 "[createProtocol][协议实例 %s 的协议类型 %s 暂不支持]", config.getId(), protocolType));
@@ -175,4 +178,14 @@ public class IotProtocolManager implements SmartLifecycle { @@ -175,4 +178,14 @@ public class IotProtocolManager implements SmartLifecycle {
175 return new IotMqttProtocol(config); 178 return new IotMqttProtocol(config);
176 } 179 }
177 180
  181 + /**
  182 + * 创建 EMQX 协议实例
  183 + *
  184 + * @param config 协议实例配置
  185 + * @return EMQX 协议实例
  186 + */
  187 + private IotEmqxProtocol createEmqxProtocol(IotGatewayProperties.ProtocolInstanceProperties config) {
  188 + return new IotEmqxProtocol(config);
  189 + }
  190 +
178 } 191 }
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxAuthEventProtocol.java deleted
1 -package cn.iocoder.yudao.module.iot.gateway.protocol.emqx;  
2 -  
3 -import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;  
4 -import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties;  
5 -import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.upstream.IotEmqxAuthEventHandler;  
6 -import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils;  
7 -import io.vertx.core.Vertx;  
8 -import io.vertx.core.http.HttpServer;  
9 -import io.vertx.ext.web.Router;  
10 -import io.vertx.ext.web.handler.BodyHandler;  
11 -import jakarta.annotation.PostConstruct;  
12 -import jakarta.annotation.PreDestroy;  
13 -import lombok.extern.slf4j.Slf4j;  
14 -  
15 -/**  
16 - * IoT 网关 EMQX 认证事件协议服务  
17 - * <p>  
18 - * 为 EMQX 提供 HTTP 接口服务,包括:  
19 - * 1. 设备认证接口 - 对应 EMQX HTTP 认证插件  
20 - * 2. 设备事件处理接口 - 对应 EMQX Webhook 事件通知  
21 - *  
22 - * @author 芋道源码  
23 - */  
24 -@Slf4j  
25 -public class IotEmqxAuthEventProtocol {  
26 -  
27 - private final IotGatewayProperties.EmqxProperties emqxProperties;  
28 -  
29 - private final String serverId;  
30 -  
31 - private final Vertx vertx;  
32 -  
33 - private HttpServer httpServer;  
34 -  
35 - public IotEmqxAuthEventProtocol(IotGatewayProperties.EmqxProperties emqxProperties,  
36 - Vertx vertx) {  
37 - this.emqxProperties = emqxProperties;  
38 - this.vertx = vertx;  
39 - this.serverId = IotDeviceMessageUtils.generateServerId(emqxProperties.getMqttPort());  
40 - }  
41 -  
42 - @PostConstruct  
43 - public void start() {  
44 - try {  
45 - startHttpServer();  
46 - log.info("[start][IoT 网关 EMQX 认证事件协议服务启动成功, 端口: {}]", emqxProperties.getHttpPort());  
47 - } catch (Exception e) {  
48 - log.error("[start][IoT 网关 EMQX 认证事件协议服务启动失败]", e);  
49 - throw e;  
50 - }  
51 - }  
52 -  
53 - @PreDestroy  
54 - public void stop() {  
55 - stopHttpServer();  
56 - log.info("[stop][IoT 网关 EMQX 认证事件协议服务已停止]");  
57 - }  
58 -  
59 - /**  
60 - * 启动 HTTP 服务器  
61 - */  
62 - private void startHttpServer() {  
63 - int port = emqxProperties.getHttpPort();  
64 -  
65 - // 1. 创建路由  
66 - Router router = Router.router(vertx);  
67 - router.route().handler(BodyHandler.create());  
68 -  
69 - // 2. 创建处理器,传入 serverId  
70 - IotEmqxAuthEventHandler handler = new IotEmqxAuthEventHandler(serverId);  
71 - router.post(IotMqttTopicUtils.MQTT_AUTH_PATH).handler(handler::handleAuth);  
72 - router.post(IotMqttTopicUtils.MQTT_EVENT_PATH).handler(handler::handleEvent);  
73 - // TODO @haohao:/mqtt/acl 需要处理么?  
74 - // TODO @芋艿:已在 EMQX 处理,如果是“设备直连”模式需要处理  
75 -  
76 - // 3. 启动 HTTP 服务器  
77 - try {  
78 - httpServer = vertx.createHttpServer()  
79 - .requestHandler(router)  
80 - .listen(port)  
81 - .result();  
82 - } catch (Exception e) {  
83 - log.error("[startHttpServer][HTTP 服务器启动失败, 端口: {}]", port, e);  
84 - throw e;  
85 - }  
86 - }  
87 -  
88 - /**  
89 - * 停止 HTTP 服务器  
90 - */  
91 - private void stopHttpServer() {  
92 - if (httpServer == null) {  
93 - return;  
94 - }  
95 -  
96 - try {  
97 - httpServer.close().result();  
98 - log.info("[stopHttpServer][HTTP 服务器已停止]");  
99 - } catch (Exception e) {  
100 - log.error("[stopHttpServer][HTTP 服务器停止失败]", e);  
101 - }  
102 - }  
103 -  
104 -}  
105 \ No newline at end of file 0 \ No newline at end of file
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxConfig.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.protocol.emqx;
  2 +
  3 +import jakarta.validation.Valid;
  4 +import jakarta.validation.constraints.Max;
  5 +import jakarta.validation.constraints.Min;
  6 +import jakarta.validation.constraints.NotEmpty;
  7 +import jakarta.validation.constraints.NotNull;
  8 +import lombok.Data;
  9 +
  10 +import java.util.List;
  11 +
  12 +/**
  13 + * IoT EMQX 协议配置
  14 + *
  15 + * @author 芋道源码
  16 + */
  17 +@Data
  18 +public class IotEmqxConfig {
  19 +
  20 + // ========== MQTT Client 配置(连接 EMQX Broker) ==========
  21 +
  22 + /**
  23 + * MQTT 服务器地址
  24 + */
  25 + @NotEmpty(message = "MQTT 服务器地址不能为空")
  26 + private String mqttHost;
  27 +
  28 + /**
  29 + * MQTT 服务器端口(默认:1883)
  30 + */
  31 + @NotNull(message = "MQTT 服务器端口不能为空")
  32 + private Integer mqttPort = 1883;
  33 +
  34 + /**
  35 + * MQTT 用户名
  36 + */
  37 + @NotEmpty(message = "MQTT 用户名不能为空")
  38 + private String mqttUsername;
  39 +
  40 + /**
  41 + * MQTT 密码
  42 + */
  43 + @NotEmpty(message = "MQTT 密码不能为空")
  44 + private String mqttPassword;
  45 +
  46 + /**
  47 + * MQTT 客户端的 SSL 开关
  48 + */
  49 + @NotNull(message = "MQTT 是否开启 SSL 不能为空")
  50 + private Boolean mqttSsl = false;
  51 +
  52 + /**
  53 + * MQTT 客户端 ID
  54 + */
  55 + @NotEmpty(message = "MQTT 客户端 ID 不能为空")
  56 + private String mqttClientId;
  57 +
  58 + /**
  59 + * MQTT 订阅的主题
  60 + */
  61 + @NotEmpty(message = "MQTT 主题不能为空")
  62 + private List<@NotEmpty(message = "MQTT 主题不能为空") String> mqttTopics;
  63 +
  64 + /**
  65 + * 默认 QoS 级别
  66 + * <p>
  67 + * 0 - 最多一次
  68 + * 1 - 至少一次
  69 + * 2 - 刚好一次
  70 + */
  71 + @NotNull(message = "MQTT QoS 不能为空")
  72 + @Min(value = 0, message = "MQTT QoS 不能小于 0")
  73 + @Max(value = 2, message = "MQTT QoS 不能大于 2")
  74 + private Integer mqttQos = 1;
  75 +
  76 + /**
  77 + * 连接超时时间(秒)
  78 + */
  79 + @NotNull(message = "连接超时时间不能为空")
  80 + @Min(value = 1, message = "连接超时时间不能小于 1 秒")
  81 + private Integer connectTimeoutSeconds = 10;
  82 +
  83 + /**
  84 + * 重连延迟时间(毫秒)
  85 + */
  86 + @NotNull(message = "重连延迟时间不能为空")
  87 + @Min(value = 0, message = "重连延迟时间不能小于 0 毫秒")
  88 + private Long reconnectDelayMs = 5000L;
  89 +
  90 + /**
  91 + * 是否启用 Clean Session (清理会话)
  92 + * true: 每次连接都是新会话,Broker 不保留离线消息和订阅关系。
  93 + * 对于网关这类“永远在线”且会主动重新订阅的应用,建议为 true。
  94 + */
  95 + @NotNull(message = "是否启用 Clean Session 不能为空")
  96 + private Boolean cleanSession = true;
  97 +
  98 + /**
  99 + * 心跳间隔(秒)
  100 + * 用于保持连接活性,及时发现网络中断。
  101 + */
  102 + @NotNull(message = "心跳间隔不能为空")
  103 + @Min(value = 1, message = "心跳间隔不能小于 1 秒")
  104 + private Integer keepAliveIntervalSeconds = 60;
  105 +
  106 + /**
  107 + * 最大未确认消息队列大小
  108 + * 限制已发送但未收到 Broker 确认的 QoS 1/2 消息数量,用于流量控制。
  109 + */
  110 + @NotNull(message = "最大未确认消息队列大小不能为空")
  111 + @Min(value = 1, message = "最大未确认消息队列大小不能小于 1")
  112 + private Integer maxInflightQueue = 10000;
  113 +
  114 + /**
  115 + * 是否信任所有 SSL 证书
  116 + * 警告:此配置会绕过证书验证,仅建议在开发和测试环境中使用!
  117 + * 在生产环境中,应设置为 false,并配置正确的信任库。
  118 + */
  119 + @NotNull(message = "是否信任所有 SSL 证书不能为空")
  120 + private Boolean trustAll = false;
  121 +
  122 + // ========== MQTT Will / SSL 高级配置 ==========
  123 +
  124 + /**
  125 + * 遗嘱消息配置 (用于网关异常下线时通知其他系统)
  126 + */
  127 + @Valid
  128 + private Will will = new Will();
  129 +
  130 + /**
  131 + * 高级 SSL/TLS 配置 (用于生产环境)
  132 + */
  133 + @Valid
  134 + private Ssl sslOptions = new Ssl();
  135 +
  136 + // ========== HTTP Hook 配置(网关提供给 EMQX 调用) ==========
  137 +
  138 + /**
  139 + * HTTP Hook 服务配置(用于 /mqtt/auth、/mqtt/event)
  140 + */
  141 + @Valid
  142 + private Http http = new Http();
  143 +
  144 + /**
  145 + * 遗嘱消息 (Last Will and Testament)
  146 + */
  147 + @Data
  148 + public static class Will {
  149 +
  150 + /**
  151 + * 是否启用遗嘱消息
  152 + */
  153 + private boolean enabled = false;
  154 + /**
  155 + * 遗嘱消息主题
  156 + */
  157 + private String topic;
  158 + /**
  159 + * 遗嘱消息内容
  160 + */
  161 + private String payload;
  162 + /**
  163 + * 遗嘱消息 QoS 等级
  164 + */
  165 + @Min(value = 0, message = "遗嘱消息 QoS 不能小于 0")
  166 + @Max(value = 2, message = "遗嘱消息 QoS 不能大于 2")
  167 + private Integer qos = 1;
  168 + /**
  169 + * 遗嘱消息是否作为保留消息发布
  170 + */
  171 + private boolean retain = true;
  172 +
  173 + }
  174 +
  175 + /**
  176 + * 高级 SSL/TLS 配置
  177 + */
  178 + @Data
  179 + public static class Ssl {
  180 +
  181 + /**
  182 + * 密钥库(KeyStore)路径,例如:classpath:certs/client.jks
  183 + * 包含客户端自己的证书和私钥,用于向服务端证明身份(双向认证)。
  184 + */
  185 + private String keyStorePath;
  186 + /**
  187 + * 密钥库密码
  188 + */
  189 + private String keyStorePassword;
  190 + /**
  191 + * 信任库(TrustStore)路径,例如:classpath:certs/trust.jks
  192 + * 包含服务端信任的 CA 证书,用于验证服务端的身份,防止中间人攻击。
  193 + */
  194 + private String trustStorePath;
  195 + /**
  196 + * 信任库密码
  197 + */
  198 + private String trustStorePassword;
  199 +
  200 + }
  201 +
  202 + /**
  203 + * HTTP Hook 服务 SSL 配置
  204 + */
  205 + @Data
  206 + public static class Http {
  207 +
  208 + /**
  209 + * 是否启用 SSL
  210 + */
  211 + private Boolean sslEnabled = false;
  212 +
  213 + /**
  214 + * SSL 证书路径
  215 + */
  216 + private String sslCertPath;
  217 +
  218 + /**
  219 + * SSL 私钥路径
  220 + */
  221 + private String sslKeyPath;
  222 +
  223 + }
  224 +
  225 +}
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxProtocol.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.protocol.emqx;
  2 +
  3 +import cn.hutool.core.collection.CollUtil;
  4 +import cn.hutool.core.lang.Assert;
  5 +import cn.hutool.core.util.StrUtil;
  6 +import cn.hutool.extra.spring.SpringUtil;
  7 +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
  8 +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
  9 +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
  10 +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties.ProtocolInstanceProperties;
  11 +import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol;
  12 +import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.downstream.IotEmqxDownstreamSubscriber;
  13 +import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.upstream.IotEmqxAuthEventHandler;
  14 +import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.upstream.IotEmqxUpstreamHandler;
  15 +import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils;
  16 +import io.netty.handler.codec.mqtt.MqttQoS;
  17 +import io.vertx.core.Vertx;
  18 +import io.vertx.core.buffer.Buffer;
  19 +import io.vertx.core.http.HttpServer;
  20 +import io.vertx.core.http.HttpServerOptions;
  21 +import io.vertx.core.net.JksOptions;
  22 +import io.vertx.core.net.PemKeyCertOptions;
  23 +import io.vertx.ext.web.Router;
  24 +import io.vertx.ext.web.handler.BodyHandler;
  25 +import io.vertx.mqtt.MqttClient;
  26 +import io.vertx.mqtt.MqttClientOptions;
  27 +import lombok.Getter;
  28 +import lombok.extern.slf4j.Slf4j;
  29 +
  30 +import java.util.List;
  31 +import java.util.Map;
  32 +import java.util.concurrent.TimeUnit;
  33 +
  34 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
  35 +
  36 +/**
  37 + * IoT 网关 EMQX 协议实现:
  38 + * <p>
  39 + * 1. 提供 HTTP Hook 服务(/mqtt/auth、/mqtt/acl、/mqtt/event)给 EMQX 调用
  40 + * 2. 通过 MQTT Client 订阅设备上行消息,并发布下行消息到 Broker
  41 + *
  42 + * @author 芋道源码
  43 + */
  44 +@Slf4j
  45 +public class IotEmqxProtocol implements IotProtocol {
  46 +
  47 + /**
  48 + * 协议配置
  49 + */
  50 + private final ProtocolInstanceProperties properties;
  51 + /**
  52 + * EMQX 配置
  53 + */
  54 + private final IotEmqxConfig emqxConfig;
  55 + /**
  56 + * 服务器 ID
  57 + */
  58 + @Getter
  59 + private final String serverId;
  60 +
  61 + /**
  62 + * 运行状态
  63 + */
  64 + @Getter
  65 + private volatile boolean running = false;
  66 +
  67 + /**
  68 + * Vert.x 实例
  69 + */
  70 + private Vertx vertx;
  71 + /**
  72 + * HTTP Hook 服务器
  73 + */
  74 + private HttpServer httpServer;
  75 +
  76 + /**
  77 + * MQTT Client
  78 + */
  79 + private volatile MqttClient mqttClient;
  80 + /**
  81 + * MQTT 重连定时器 ID
  82 + */
  83 + private volatile Long reconnectTimerId;
  84 +
  85 + /**
  86 + * 上行消息处理器
  87 + */
  88 + private final IotEmqxUpstreamHandler upstreamHandler;
  89 +
  90 + /**
  91 + * 下行消息订阅者
  92 + */
  93 + private final IotEmqxDownstreamSubscriber downstreamSubscriber;
  94 +
  95 + public IotEmqxProtocol(ProtocolInstanceProperties properties) {
  96 + Assert.notNull(properties, "协议实例配置不能为空");
  97 + Assert.notNull(properties.getEmqx(), "EMQX 协议配置(emqx)不能为空");
  98 + this.properties = properties;
  99 + this.emqxConfig = properties.getEmqx();
  100 + Assert.notNull(emqxConfig.getConnectTimeoutSeconds(),
  101 + "MQTT 连接超时时间(emqx.connect-timeout-seconds)不能为空");
  102 + this.serverId = IotDeviceMessageUtils.generateServerId(properties.getPort());
  103 + this.upstreamHandler = new IotEmqxUpstreamHandler(serverId);
  104 +
  105 + // 初始化下行消息订阅者
  106 + IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class);
  107 + this.downstreamSubscriber = new IotEmqxDownstreamSubscriber(this, messageBus);
  108 + }
  109 +
  110 + @Override
  111 + public String getId() {
  112 + return properties.getId();
  113 + }
  114 +
  115 + @Override
  116 + public IotProtocolTypeEnum getType() {
  117 + return IotProtocolTypeEnum.EMQX;
  118 + }
  119 +
  120 + @Override
  121 + public void start() {
  122 + if (running) {
  123 + log.warn("[start][IoT EMQX 协议 {} 已经在运行中]", getId());
  124 + return;
  125 + }
  126 +
  127 + // 1.1 创建 Vertx 实例
  128 + this.vertx = Vertx.vertx();
  129 +
  130 + try {
  131 + // 1.2 启动 HTTP Hook 服务
  132 + startHttpServer();
  133 +
  134 + // 1.3 启动 MQTT Client
  135 + startMqttClient();
  136 + running = true;
  137 + log.info("[start][IoT EMQX 协议 {} 启动成功,hookPort:{},serverId:{}]",
  138 + getId(), properties.getPort(), serverId);
  139 +
  140 + // 2. 启动下行消息订阅者
  141 + this.downstreamSubscriber.start();
  142 + } catch (Exception e) {
  143 + log.error("[start][IoT EMQX 协议 {} 启动失败]", getId(), e);
  144 + // 启动失败时,关闭资源
  145 + stop0();
  146 + throw e;
  147 + }
  148 + }
  149 +
  150 + @Override
  151 + public void stop() {
  152 + if (!running) {
  153 + return;
  154 + }
  155 + stop0();
  156 + }
  157 +
  158 + private void stop0() {
  159 + // 1. 停止下行消息订阅者
  160 + try {
  161 + downstreamSubscriber.stop();
  162 + log.info("[stop][IoT EMQX 协议 {} 下行消息订阅者已停止]", getId());
  163 + } catch (Exception e) {
  164 + log.error("[stop][IoT EMQX 协议 {} 下行消息订阅者停止失败]", getId(), e);
  165 + }
  166 +
  167 + // 2.1 先置为 false:避免 closeHandler 触发重连
  168 + running = false;
  169 + stopMqttClientReconnectChecker();
  170 + // 2.2 停止 MQTT Client
  171 + stopMqttClient();
  172 +
  173 + // 2.3 停止 HTTP Hook 服务
  174 + stopHttpServer();
  175 +
  176 + // 2.4 关闭 Vertx
  177 + if (vertx != null) {
  178 + try {
  179 + vertx.close().result();
  180 + log.info("[stop][IoT EMQX 协议 {} Vertx 已关闭]", getId());
  181 + } catch (Exception e) {
  182 + log.error("[stop][IoT EMQX 协议 {} Vertx 关闭失败]", getId(), e);
  183 + }
  184 + vertx = null;
  185 + }
  186 +
  187 + log.info("[stop][IoT EMQX 协议 {} 已停止]", getId());
  188 + }
  189 +
  190 + // ======================================= HTTP Hook Server =======================================
  191 +
  192 + /**
  193 + * 启动 HTTP Hook 服务(/mqtt/auth、/mqtt/acl、/mqtt/event)
  194 + */
  195 + private void startHttpServer() {
  196 + // 1. 创建路由
  197 + Router router = Router.router(vertx);
  198 + router.route().handler(BodyHandler.create());
  199 +
  200 + // 2. 创建处理器
  201 + IotEmqxAuthEventHandler handler = new IotEmqxAuthEventHandler(serverId);
  202 + router.post(IotMqttTopicUtils.MQTT_AUTH_PATH).handler(handler::handleAuth);
  203 + router.post(IotMqttTopicUtils.MQTT_ACL_PATH).handler(handler::handleAcl);
  204 + router.post(IotMqttTopicUtils.MQTT_EVENT_PATH).handler(handler::handleEvent);
  205 +
  206 + // 3. 启动 HTTP Server(支持 HTTPS)
  207 + IotEmqxConfig.Http httpConfig = emqxConfig.getHttp();
  208 + HttpServerOptions options = new HttpServerOptions().setPort(properties.getPort());
  209 + if (httpConfig != null && Boolean.TRUE.equals(httpConfig.getSslEnabled())) {
  210 + Assert.notBlank(httpConfig.getSslCertPath(), "EMQX HTTP SSL 证书路径(emqx.http.ssl-cert-path)不能为空");
  211 + Assert.notBlank(httpConfig.getSslKeyPath(), "EMQX HTTP SSL 私钥路径(emqx.http.ssl-key-path)不能为空");
  212 + PemKeyCertOptions pemKeyCertOptions = new PemKeyCertOptions()
  213 + .setKeyPath(httpConfig.getSslKeyPath())
  214 + .setCertPath(httpConfig.getSslCertPath());
  215 + options.setSsl(true).setKeyCertOptions(pemKeyCertOptions);
  216 + }
  217 + try {
  218 + httpServer = vertx.createHttpServer(options)
  219 + .requestHandler(router)
  220 + .listen()
  221 + .result();
  222 + log.info("[startHttpServer][IoT EMQX 协议 {} HTTP Hook 服务启动成功, port: {}, ssl: {}]",
  223 + getId(), properties.getPort(), httpConfig != null && Boolean.TRUE.equals(httpConfig.getSslEnabled()));
  224 + } catch (Exception e) {
  225 + log.error("[startHttpServer][IoT EMQX 协议 {} HTTP Hook 服务启动失败, port: {}]", getId(), properties.getPort(), e);
  226 + throw e;
  227 + }
  228 + }
  229 +
  230 + private void stopHttpServer() {
  231 + if (httpServer == null) {
  232 + return;
  233 + }
  234 + try {
  235 + httpServer.close().result();
  236 + log.info("[stopHttpServer][IoT EMQX 协议 {} HTTP Hook 服务已停止]", getId());
  237 + } catch (Exception e) {
  238 + log.error("[stopHttpServer][IoT EMQX 协议 {} HTTP Hook 服务停止失败]", getId(), e);
  239 + } finally {
  240 + httpServer = null;
  241 + }
  242 + }
  243 +
  244 + // ======================================= MQTT Client ======================================
  245 +
  246 + private void startMqttClient() {
  247 + // 1.1 创建 MQTT Client
  248 + MqttClient client = createMqttClient();
  249 + this.mqttClient = client;
  250 + // 1.2 连接 MQTT Broker
  251 + if (!connectMqttClient(client)) {
  252 + throw new RuntimeException("MQTT Client 启动失败: 连接 Broker 失败");
  253 + }
  254 +
  255 + // 2. 启动定时重连检查
  256 + startMqttClientReconnectChecker();
  257 + }
  258 +
  259 + private void stopMqttClient() {
  260 + MqttClient client = this.mqttClient;
  261 + if (client == null || !client.isConnected()) {
  262 + return;
  263 + }
  264 + this.mqttClient = null;
  265 +
  266 + // 1. 批量取消订阅
  267 + List<String> topicList = emqxConfig.getMqttTopics();
  268 + if (CollUtil.isNotEmpty(topicList)) {
  269 + try {
  270 + client.unsubscribe(topicList).toCompletionStage().toCompletableFuture()
  271 + .get(5, TimeUnit.SECONDS);
  272 + } catch (Exception e) {
  273 + log.warn("[stopMqttClient][IoT EMQX 协议 {} 取消订阅异常]", getId(), e);
  274 + }
  275 + }
  276 +
  277 + // 2. 断开 MQTT 连接
  278 + try {
  279 + client.disconnect().toCompletionStage().toCompletableFuture()
  280 + .get(5, TimeUnit.SECONDS);
  281 + } catch (Exception e) {
  282 + log.warn("[stopMqttClient][IoT EMQX 协议 {} 断开连接异常]", getId(), e);
  283 + }
  284 + }
  285 +
  286 + // ======================================= MQTT 基础方法 ======================================
  287 +
  288 + /**
  289 + * 创建 MQTT 客户端
  290 + *
  291 + * @return 新创建的 MqttClient
  292 + */
  293 + private MqttClient createMqttClient() {
  294 + // 1.1 基础配置
  295 + MqttClientOptions options = new MqttClientOptions()
  296 + .setClientId(emqxConfig.getMqttClientId())
  297 + .setUsername(emqxConfig.getMqttUsername())
  298 + .setPassword(emqxConfig.getMqttPassword())
  299 + .setSsl(emqxConfig.getMqttSsl())
  300 + .setCleanSession(emqxConfig.getCleanSession())
  301 + .setKeepAliveInterval(emqxConfig.getKeepAliveIntervalSeconds())
  302 + .setMaxInflightQueue(emqxConfig.getMaxInflightQueue());
  303 + options.setConnectTimeout(emqxConfig.getConnectTimeoutSeconds() * 1000); // Vert.x 需要毫秒
  304 + options.setTrustAll(emqxConfig.getTrustAll());
  305 + // 1.2 配置遗嘱消息
  306 + IotEmqxConfig.Will will = emqxConfig.getWill();
  307 + if (will.isEnabled()) {
  308 + Assert.notBlank(will.getTopic(), "遗嘱消息主题(emqx.will.topic)不能为空");
  309 + Assert.notNull(will.getPayload(), "遗嘱消息内容(emqx.will.payload)不能为空");
  310 + options.setWillFlag(true)
  311 + .setWillTopic(will.getTopic())
  312 + .setWillMessageBytes(Buffer.buffer(will.getPayload()))
  313 + .setWillQoS(will.getQos())
  314 + .setWillRetain(will.isRetain());
  315 + }
  316 + // 1.3 配置高级 SSL/TLS(仅在启用 SSL 且不信任所有证书时生效)
  317 + if (Boolean.TRUE.equals(emqxConfig.getMqttSsl()) && !Boolean.TRUE.equals(emqxConfig.getTrustAll())) {
  318 + IotEmqxConfig.Ssl sslOptions = emqxConfig.getSslOptions();
  319 + if (StrUtil.isNotBlank(sslOptions.getTrustStorePath())) {
  320 + options.setTrustStoreOptions(new JksOptions()
  321 + .setPath(sslOptions.getTrustStorePath())
  322 + .setPassword(sslOptions.getTrustStorePassword()));
  323 + }
  324 + if (StrUtil.isNotBlank(sslOptions.getKeyStorePath())) {
  325 + options.setKeyStoreOptions(new JksOptions()
  326 + .setPath(sslOptions.getKeyStorePath())
  327 + .setPassword(sslOptions.getKeyStorePassword()));
  328 + }
  329 + }
  330 +
  331 + // 2. 创建客户端
  332 + return MqttClient.create(vertx, options);
  333 + }
  334 +
  335 + /**
  336 + * 连接 MQTT Broker(同步等待)
  337 + *
  338 + * @param client MQTT 客户端
  339 + * @return 连接成功返回 true,失败返回 false
  340 + */
  341 + @SuppressWarnings("BooleanMethodIsAlwaysInverted")
  342 + private synchronized boolean connectMqttClient(MqttClient client) {
  343 + String host = emqxConfig.getMqttHost();
  344 + int port = emqxConfig.getMqttPort();
  345 + int timeoutSeconds = emqxConfig.getConnectTimeoutSeconds();
  346 + try {
  347 + // 1. 连接 Broker
  348 + client.connect(port, host).toCompletionStage().toCompletableFuture()
  349 + .get(timeoutSeconds, TimeUnit.SECONDS);
  350 + log.info("[connectMqttClient][IoT EMQX 协议 {} 连接成功, host: {}, port: {}]",
  351 + getId(), host, port);
  352 +
  353 + // 2. 设置处理器
  354 + setupMqttClientHandlers(client);
  355 + subscribeMqttClientTopics(client);
  356 + return true;
  357 + } catch (Exception e) {
  358 + log.error("[connectMqttClient][IoT EMQX 协议 {} 连接发生异常]", getId(), e);
  359 + return false;
  360 + }
  361 + }
  362 +
  363 + /**
  364 + * 关闭 MQTT 客户端
  365 + */
  366 + private void closeMqttClient() {
  367 + MqttClient oldClient = this.mqttClient;
  368 + if (oldClient == null || !oldClient.isConnected()) {
  369 + return;
  370 + }
  371 + this.mqttClient = null;
  372 + try {
  373 + oldClient.disconnect().toCompletionStage().toCompletableFuture()
  374 + .get(5, TimeUnit.SECONDS);
  375 + } catch (Exception ignored) {
  376 + }
  377 + }
  378 +
  379 + // ======================================= MQTT 重连机制 ======================================
  380 +
  381 + /**
  382 + * 启动 MQTT Client 周期性重连检查器
  383 + */
  384 + private void startMqttClientReconnectChecker() {
  385 + long interval = emqxConfig.getReconnectDelayMs();
  386 + this.reconnectTimerId = vertx.setPeriodic(interval, timerId -> {
  387 + if (!running) {
  388 + return;
  389 + }
  390 + if (mqttClient != null && mqttClient.isConnected()) {
  391 + return;
  392 + }
  393 + log.info("[startMqttClientReconnectChecker][IoT EMQX 协议 {} 检测到断开,尝试重连]", getId());
  394 + tryReconnectMqttClient();
  395 + });
  396 + }
  397 +
  398 + /**
  399 + * 停止 MQTT Client 重连检查器
  400 + */
  401 + private void stopMqttClientReconnectChecker() {
  402 + if (reconnectTimerId != null && vertx != null) {
  403 + try {
  404 + vertx.cancelTimer(reconnectTimerId);
  405 + } catch (Exception ignored) {
  406 + }
  407 + reconnectTimerId = null;
  408 + }
  409 + }
  410 +
  411 + /**
  412 + * 尝试重连 MQTT Client
  413 + */
  414 + private synchronized void tryReconnectMqttClient() {
  415 + // 1. 前置检查
  416 + if (!running) {
  417 + return;
  418 + }
  419 + if (mqttClient != null && mqttClient.isConnected()) {
  420 + return;
  421 + }
  422 +
  423 + log.info("[tryReconnectMqttClient][IoT EMQX 协议 {} 开始重连]", getId());
  424 + try {
  425 + // 2. 关闭旧客户端
  426 + closeMqttClient();
  427 +
  428 + // 3.1 创建新客户端
  429 + MqttClient client = createMqttClient();
  430 + this.mqttClient = client;
  431 + // 3.2 连接(失败只打印日志,等下次定时)
  432 + if (!connectMqttClient(client)) {
  433 + log.warn("[tryReconnectMqttClient][IoT EMQX 协议 {} 重连失败,等待下次重试]", getId());
  434 + }
  435 + } catch (Exception e) {
  436 + log.error("[tryReconnectMqttClient][IoT EMQX 协议 {} 重连异常]", getId(), e);
  437 + }
  438 + }
  439 +
  440 + // ======================================= MQTT Handler ======================================
  441 +
  442 + /**
  443 + * 设置 MQTT Client 事件处理器
  444 + */
  445 + private void setupMqttClientHandlers(MqttClient client) {
  446 + // 1. 断开重连监听
  447 + client.closeHandler(closeEvent -> {
  448 + if (!running) {
  449 + return;
  450 + }
  451 + log.warn("[setupMqttClientHandlers][IoT EMQX 协议 {} 连接断开,立即尝试重连]", getId());
  452 + vertx.runOnContext(v -> tryReconnectMqttClient());
  453 + });
  454 +
  455 + // 2. 异常处理
  456 + client.exceptionHandler(exception ->
  457 + log.error("[setupMqttClientHandlers][IoT EMQX 协议 {} MQTT Client 异常]", getId(), exception));
  458 +
  459 + // 3. 上行消息处理
  460 + client.publishHandler(upstreamHandler::handle);
  461 + }
  462 +
  463 + /**
  464 + * 订阅 MQTT Client 主题(同步等待)
  465 + */
  466 + private void subscribeMqttClientTopics(MqttClient client) {
  467 + List<String> topicList = emqxConfig.getMqttTopics();
  468 + if (!client.isConnected()) {
  469 + log.warn("[subscribeMqttClientTopics][IoT EMQX 协议 {} MQTT Client 未连接, 跳过订阅]", getId());
  470 + return;
  471 + }
  472 + if (CollUtil.isEmpty(topicList)) {
  473 + log.warn("[subscribeMqttClientTopics][IoT EMQX 协议 {} 未配置订阅主题, 跳过订阅]", getId());
  474 + return;
  475 + }
  476 + // 执行订阅
  477 + Map<String, Integer> topics = convertMap(emqxConfig.getMqttTopics(), topic -> topic,
  478 + topic -> emqxConfig.getMqttQos());
  479 + try {
  480 + client.subscribe(topics).toCompletionStage().toCompletableFuture()
  481 + .get(10, TimeUnit.SECONDS);
  482 + log.info("[subscribeMqttClientTopics][IoT EMQX 协议 {} 订阅成功, 共 {} 个主题]", getId(), topicList.size());
  483 + } catch (Exception e) {
  484 + log.error("[subscribeMqttClientTopics][IoT EMQX 协议 {} 订阅失败]", getId(), e);
  485 + }
  486 + }
  487 +
  488 + /**
  489 + * 发布消息到 MQTT Broker
  490 + *
  491 + * @param topic 主题
  492 + * @param payload 消息内容
  493 + */
  494 + public void publishMessage(String topic, byte[] payload) {
  495 + if (mqttClient == null || !mqttClient.isConnected()) {
  496 + log.warn("[publishMessage][IoT EMQX 协议 {} MQTT Client 未连接, 无法发布消息]", getId());
  497 + return;
  498 + }
  499 + MqttQoS qos = MqttQoS.valueOf(emqxConfig.getMqttQos());
  500 + mqttClient.publish(topic, Buffer.buffer(payload), qos, false, false);
  501 + }
  502 +
  503 +}
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxUpstreamProtocol.java deleted
1 -package cn.iocoder.yudao.module.iot.gateway.protocol.emqx;  
2 -  
3 -import cn.hutool.core.lang.Assert;  
4 -import cn.hutool.core.util.StrUtil;  
5 -import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;  
6 -import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;  
7 -import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties;  
8 -import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol;  
9 -import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.upstream.IotEmqxUpstreamHandler;  
10 -import io.netty.handler.codec.mqtt.MqttQoS;  
11 -import io.vertx.core.Vertx;  
12 -import io.vertx.core.buffer.Buffer;  
13 -import io.vertx.core.net.JksOptions;  
14 -import io.vertx.mqtt.MqttClient;  
15 -import io.vertx.mqtt.MqttClientOptions;  
16 -import jakarta.annotation.PostConstruct;  
17 -import jakarta.annotation.PreDestroy;  
18 -import lombok.Getter;  
19 -import lombok.extern.slf4j.Slf4j;  
20 -  
21 -import java.util.HashMap;  
22 -import java.util.List;  
23 -import java.util.Map;  
24 -import java.util.concurrent.CountDownLatch;  
25 -import java.util.concurrent.atomic.AtomicBoolean;  
26 -  
27 -/**  
28 - * IoT 网关 EMQX 协议:接收设备上行消息  
29 - *  
30 - * @author 芋道源码  
31 - */  
32 -@Slf4j  
33 -public class IotEmqxUpstreamProtocol implements IotProtocol {  
34 -  
35 - private static final String ID = "emqx";  
36 -  
37 - private final IotGatewayProperties.EmqxProperties emqxProperties;  
38 -  
39 - private volatile boolean running = false;  
40 -  
41 - private final Vertx vertx;  
42 -  
43 - @Getter  
44 - private final String serverId;  
45 -  
46 - private MqttClient mqttClient;  
47 -  
48 - private IotEmqxUpstreamHandler upstreamHandler;  
49 -  
50 - public IotEmqxUpstreamProtocol(IotGatewayProperties.EmqxProperties emqxProperties,  
51 - Vertx vertx) {  
52 - this.emqxProperties = emqxProperties;  
53 - this.serverId = IotDeviceMessageUtils.generateServerId(emqxProperties.getMqttPort());  
54 - this.vertx = vertx;  
55 - }  
56 -  
57 - @Override  
58 - public String getId() {  
59 - return ID;  
60 - }  
61 -  
62 - @Override  
63 - public IotProtocolTypeEnum getType() {  
64 - return IotProtocolTypeEnum.EMQX;  
65 - }  
66 -  
67 - @Override  
68 - @PostConstruct  
69 - public void start() {  
70 - if (running) {  
71 - return;  
72 - }  
73 -  
74 - try {  
75 - // 1. 启动 MQTT 客户端  
76 - startMqttClient();  
77 -  
78 - // 2. 标记服务为运行状态  
79 - running = true;  
80 - log.info("[start][IoT 网关 EMQX 协议启动成功]");  
81 - } catch (Exception e) {  
82 - log.error("[start][IoT 网关 EMQX 协议服务启动失败,应用将关闭]", e);  
83 - stop();  
84 -  
85 - // 异步关闭应用  
86 - Thread shutdownThread = new Thread(() -> {  
87 - try {  
88 - // 确保日志输出完成,使用更优雅的方式  
89 - log.error("[start][由于 MQTT 连接失败,正在关闭应用]");  
90 - // 等待日志输出完成  
91 - Thread.sleep(1000);  
92 - } catch (InterruptedException ie) {  
93 - Thread.currentThread().interrupt();  
94 - log.warn("[start][应用关闭被中断]");  
95 - }  
96 - System.exit(1);  
97 - });  
98 - shutdownThread.setDaemon(true);  
99 - shutdownThread.setName("emergency-shutdown");  
100 - shutdownThread.start();  
101 -  
102 - throw e;  
103 - }  
104 - }  
105 -  
106 - @Override  
107 - @PreDestroy  
108 - public void stop() {  
109 - if (!running) {  
110 - return;  
111 - }  
112 -  
113 - // 1. 停止 MQTT 客户端  
114 - stopMqttClient();  
115 -  
116 - // 2. 标记服务为停止状态  
117 - running = false;  
118 - log.info("[stop][IoT 网关 MQTT 协议服务已停止]");  
119 - }  
120 -  
121 - @Override  
122 - public boolean isRunning() {  
123 - return running;  
124 - }  
125 -  
126 - /**  
127 - * 启动 MQTT 客户端  
128 - */  
129 - private void startMqttClient() {  
130 - try {  
131 - // 1. 初始化消息处理器  
132 - this.upstreamHandler = new IotEmqxUpstreamHandler(this);  
133 -  
134 - // 2. 创建 MQTT 客户端  
135 - createMqttClient();  
136 -  
137 - // 3. 同步连接 MQTT Broker  
138 - connectMqttSync();  
139 - } catch (Exception e) {  
140 - log.error("[startMqttClient][MQTT 客户端启动失败]", e);  
141 - throw new RuntimeException("MQTT 客户端启动失败: " + e.getMessage(), e);  
142 - }  
143 - }  
144 -  
145 - /**  
146 - * 同步连接 MQTT Broker  
147 - */  
148 - private void connectMqttSync() {  
149 - String host = emqxProperties.getMqttHost();  
150 - int port = emqxProperties.getMqttPort();  
151 - // 1. 连接 MQTT Broker  
152 - CountDownLatch latch = new CountDownLatch(1);  
153 - AtomicBoolean success = new AtomicBoolean(false);  
154 - mqttClient.connect(port, host, connectResult -> {  
155 - if (connectResult.succeeded()) {  
156 - log.info("[connectMqttSync][MQTT 客户端连接成功, host: {}, port: {}]", host, port);  
157 - setupMqttHandlers();  
158 - subscribeToTopics();  
159 - success.set(true);  
160 - } else {  
161 - log.error("[connectMqttSync][连接 MQTT Broker 失败, host: {}, port: {}]",  
162 - host, port, connectResult.cause());  
163 - }  
164 - latch.countDown();  
165 - });  
166 -  
167 - // 2. 等待连接结果  
168 - try {  
169 - // 应用层超时控制:防止启动过程无限阻塞,与MQTT客户端的网络超时是不同层次的控制  
170 - boolean awaitResult = latch.await(10, java.util.concurrent.TimeUnit.SECONDS);  
171 - if (!awaitResult) {  
172 - log.error("[connectMqttSync][等待连接结果超时]");  
173 - throw new RuntimeException("连接 MQTT Broker 超时");  
174 - }  
175 - if (!success.get()) {  
176 - throw new RuntimeException(String.format("首次连接 MQTT Broker 失败,地址: %s, 端口: %d", host, port));  
177 - }  
178 - } catch (InterruptedException e) {  
179 - Thread.currentThread().interrupt();  
180 - log.error("[connectMqttSync][等待连接结果被中断]", e);  
181 - throw new RuntimeException("连接 MQTT Broker 被中断", e);  
182 - }  
183 - }  
184 -  
185 - /**  
186 - * 异步连接 MQTT Broker  
187 - */  
188 - private void connectMqttAsync() {  
189 - String host = emqxProperties.getMqttHost();  
190 - int port = emqxProperties.getMqttPort();  
191 - mqttClient.connect(port, host, connectResult -> {  
192 - if (connectResult.succeeded()) {  
193 - log.info("[connectMqttAsync][MQTT 客户端重连成功]");  
194 - setupMqttHandlers();  
195 - subscribeToTopics();  
196 - } else {  
197 - log.error("[connectMqttAsync][连接 MQTT Broker 失败, host: {}, port: {}]",  
198 - host, port, connectResult.cause());  
199 - log.warn("[connectMqttAsync][重连失败,将再次尝试]");  
200 - reconnectWithDelay();  
201 - }  
202 - });  
203 - }  
204 -  
205 - /**  
206 - * 延迟重连  
207 - */  
208 - private void reconnectWithDelay() {  
209 - if (!running) {  
210 - return;  
211 - }  
212 - if (mqttClient != null && mqttClient.isConnected()) {  
213 - return;  
214 - }  
215 -  
216 - long delay = emqxProperties.getReconnectDelayMs();  
217 - log.info("[reconnectWithDelay][将在 {} 毫秒后尝试重连 MQTT Broker]", delay);  
218 - vertx.setTimer(delay, timerId -> {  
219 - if (!running) {  
220 - return;  
221 - }  
222 - if (mqttClient != null && mqttClient.isConnected()) {  
223 - return;  
224 - }  
225 -  
226 - log.info("[reconnectWithDelay][开始重连 MQTT Broker]");  
227 - try {  
228 - createMqttClient();  
229 - connectMqttAsync();  
230 - } catch (Exception e) {  
231 - log.error("[reconnectWithDelay][重连过程中发生异常]", e);  
232 - vertx.setTimer(delay, t -> reconnectWithDelay());  
233 - }  
234 - });  
235 - }  
236 -  
237 - /**  
238 - * 停止 MQTT 客户端  
239 - */  
240 - private void stopMqttClient() {  
241 - if (mqttClient == null) {  
242 - return;  
243 - }  
244 - try {  
245 - if (mqttClient.isConnected()) {  
246 - // 1. 取消订阅所有主题  
247 - List<String> topicList = emqxProperties.getMqttTopics();  
248 - for (String topic : topicList) {  
249 - try {  
250 - mqttClient.unsubscribe(topic);  
251 - } catch (Exception e) {  
252 - log.warn("[stopMqttClient][取消订阅主题({})异常]", topic, e);  
253 - }  
254 - }  
255 -  
256 - // 2. 断开 MQTT 客户端连接  
257 - try {  
258 - CountDownLatch disconnectLatch = new CountDownLatch(1);  
259 - mqttClient.disconnect(ar -> disconnectLatch.countDown());  
260 - if (!disconnectLatch.await(5, java.util.concurrent.TimeUnit.SECONDS)) {  
261 - log.warn("[stopMqttClient][断开 MQTT 连接超时]");  
262 - }  
263 - } catch (Exception e) {  
264 - log.warn("[stopMqttClient][关闭 MQTT 客户端异常]", e);  
265 - }  
266 - }  
267 - } catch (Exception e) {  
268 - log.warn("[stopMqttClient][停止 MQTT 客户端过程中发生异常]", e);  
269 - } finally {  
270 - mqttClient = null;  
271 - }  
272 - }  
273 -  
274 - /**  
275 - * 创建 MQTT 客户端  
276 - */  
277 - private void createMqttClient() {  
278 - // 1.1 创建基础配置  
279 - MqttClientOptions options = (MqttClientOptions) new MqttClientOptions()  
280 - .setClientId(emqxProperties.getMqttClientId())  
281 - .setUsername(emqxProperties.getMqttUsername())  
282 - .setPassword(emqxProperties.getMqttPassword())  
283 - .setSsl(emqxProperties.getMqttSsl())  
284 - .setCleanSession(emqxProperties.getCleanSession())  
285 - .setKeepAliveInterval(emqxProperties.getKeepAliveIntervalSeconds())  
286 - .setMaxInflightQueue(emqxProperties.getMaxInflightQueue())  
287 - .setConnectTimeout(emqxProperties.getConnectTimeoutSeconds() * 1000) // Vert.x 需要毫秒  
288 - .setTrustAll(emqxProperties.getTrustAll());  
289 - // 1.2 配置遗嘱消息  
290 - IotGatewayProperties.EmqxProperties.Will will = emqxProperties.getWill();  
291 - if (will.isEnabled()) {  
292 - Assert.notBlank(will.getTopic(), "遗嘱消息主题(will.topic)不能为空");  
293 - Assert.notNull(will.getPayload(), "遗嘱消息内容(will.payload)不能为空");  
294 - options.setWillFlag(true)  
295 - .setWillTopic(will.getTopic())  
296 - .setWillMessageBytes(Buffer.buffer(will.getPayload()))  
297 - .setWillQoS(will.getQos())  
298 - .setWillRetain(will.isRetain());  
299 - }  
300 - // 1.3 配置高级 SSL/TLS (仅在启用 SSL 且不信任所有证书时生效)  
301 - if (Boolean.TRUE.equals(emqxProperties.getMqttSsl()) && !Boolean.TRUE.equals(emqxProperties.getTrustAll())) {  
302 - IotGatewayProperties.EmqxProperties.Ssl sslOptions = emqxProperties.getSslOptions();  
303 - if (StrUtil.isNotBlank(sslOptions.getTrustStorePath())) {  
304 - options.setTrustStoreOptions(new JksOptions()  
305 - .setPath(sslOptions.getTrustStorePath())  
306 - .setPassword(sslOptions.getTrustStorePassword()));  
307 - }  
308 - if (StrUtil.isNotBlank(sslOptions.getKeyStorePath())) {  
309 - options.setKeyStoreOptions(new JksOptions()  
310 - .setPath(sslOptions.getKeyStorePath())  
311 - .setPassword(sslOptions.getKeyStorePassword()));  
312 - }  
313 - }  
314 - // 1.4 安全警告日志  
315 - if (Boolean.TRUE.equals(emqxProperties.getTrustAll())) {  
316 - log.warn("[createMqttClient][安全警告:当前配置信任所有 SSL 证书(trustAll=true),这在生产环境中存在严重安全风险!]");  
317 - }  
318 -  
319 - // 2. 创建客户端实例  
320 - this.mqttClient = MqttClient.create(vertx, options);  
321 - }  
322 -  
323 - /**  
324 - * 设置 MQTT 处理器  
325 - */  
326 - private void setupMqttHandlers() {  
327 - // 1. 设置断开重连监听器  
328 - mqttClient.closeHandler(closeEvent -> {  
329 - if (!running) {  
330 - return;  
331 - }  
332 - log.warn("[closeHandler][MQTT 连接已断开, 准备重连]");  
333 - reconnectWithDelay();  
334 - });  
335 -  
336 - // 2. 设置异常处理器  
337 - mqttClient.exceptionHandler(exception ->  
338 - log.error("[exceptionHandler][MQTT 客户端异常]", exception));  
339 -  
340 - // 3. 设置消息处理器  
341 - mqttClient.publishHandler(upstreamHandler::handle);  
342 - }  
343 -  
344 - /**  
345 - * 订阅设备上行消息主题  
346 - */  
347 - private void subscribeToTopics() {  
348 - // 1. 校验 MQTT 客户端是否连接  
349 - List<String> topicList = emqxProperties.getMqttTopics();  
350 - if (mqttClient == null || !mqttClient.isConnected()) {  
351 - log.warn("[subscribeToTopics][MQTT 客户端未连接, 跳过订阅]");  
352 - return;  
353 - }  
354 -  
355 - // 2. 批量订阅所有主题  
356 - Map<String, Integer> topics = new HashMap<>();  
357 - int qos = emqxProperties.getMqttQos();  
358 - for (String topic : topicList) {  
359 - topics.put(topic, qos);  
360 - }  
361 - mqttClient.subscribe(topics, subscribeResult -> {  
362 - if (subscribeResult.succeeded()) {  
363 - log.info("[subscribeToTopics][订阅主题成功, 共 {} 个主题]", topicList.size());  
364 - } else {  
365 - log.error("[subscribeToTopics][订阅主题失败, 共 {} 个主题, 原因: {}]",  
366 - topicList.size(), subscribeResult.cause().getMessage(), subscribeResult.cause());  
367 - }  
368 - });  
369 - }  
370 -  
371 - /**  
372 - * 发布消息到 MQTT Broker  
373 - *  
374 - * @param topic 主题  
375 - * @param payload 消息内容  
376 - */  
377 - public void publishMessage(String topic, byte[] payload) {  
378 - if (mqttClient == null || !mqttClient.isConnected()) {  
379 - log.warn("[publishMessage][MQTT 客户端未连接, 无法发布消息]");  
380 - return;  
381 - }  
382 - MqttQoS qos = MqttQoS.valueOf(emqxProperties.getMqttQos());  
383 - mqttClient.publish(topic, Buffer.buffer(payload), qos, false, false);  
384 - }  
385 -  
386 -}  
387 \ No newline at end of file 0 \ No newline at end of file
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/downstream/IotEmqxDownstreamHandler.java
@@ -5,7 +5,7 @@ import cn.hutool.extra.spring.SpringUtil; @@ -5,7 +5,7 @@ import cn.hutool.extra.spring.SpringUtil;
5 import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; 5 import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO;
6 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; 6 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
7 import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; 7 import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
8 -import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxUpstreamProtocol; 8 +import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxProtocol;
9 import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; 9 import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService;
10 import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; 10 import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
11 import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; 11 import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils;
@@ -21,13 +21,13 @@ import lombok.extern.slf4j.Slf4j; @@ -21,13 +21,13 @@ import lombok.extern.slf4j.Slf4j;
21 @Slf4j 21 @Slf4j
22 public class IotEmqxDownstreamHandler { 22 public class IotEmqxDownstreamHandler {
23 23
24 - private final IotEmqxUpstreamProtocol protocol; 24 + private final IotEmqxProtocol protocol;
25 25
26 private final IotDeviceService deviceService; 26 private final IotDeviceService deviceService;
27 27
28 private final IotDeviceMessageService deviceMessageService; 28 private final IotDeviceMessageService deviceMessageService;
29 29
30 - public IotEmqxDownstreamHandler(IotEmqxUpstreamProtocol protocol) { 30 + public IotEmqxDownstreamHandler(IotEmqxProtocol protocol) {
31 this.protocol = protocol; 31 this.protocol = protocol;
32 this.deviceService = SpringUtil.getBean(IotDeviceService.class); 32 this.deviceService = SpringUtil.getBean(IotDeviceService.class);
33 this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); 33 this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class);
@@ -74,4 +74,4 @@ public class IotEmqxDownstreamHandler { @@ -74,4 +74,4 @@ public class IotEmqxDownstreamHandler {
74 return IotMqttTopicUtils.buildTopicByMethod(message.getMethod(), productKey, deviceName, isReply); 74 return IotMqttTopicUtils.buildTopicByMethod(message.getMethod(), productKey, deviceName, isReply);
75 } 75 }
76 76
77 -}  
78 \ No newline at end of file 77 \ No newline at end of file
  78 +}
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/downstream/IotEmqxDownstreamSubscriber.java
@@ -3,9 +3,7 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.downstream; @@ -3,9 +3,7 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.downstream;
3 import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; 3 import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
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.gateway.protocol.IotProtocolDownstreamSubscriber; 5 import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocolDownstreamSubscriber;
6 -import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxUpstreamProtocol;  
7 -import jakarta.annotation.PostConstruct;  
8 -import jakarta.annotation.PreDestroy; 6 +import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxProtocol;
9 import lombok.extern.slf4j.Slf4j; 7 import lombok.extern.slf4j.Slf4j;
10 8
11 /** 9 /**
@@ -18,22 +16,11 @@ public class IotEmqxDownstreamSubscriber extends IotProtocolDownstreamSubscriber @@ -18,22 +16,11 @@ public class IotEmqxDownstreamSubscriber extends IotProtocolDownstreamSubscriber
18 16
19 private final IotEmqxDownstreamHandler downstreamHandler; 17 private final IotEmqxDownstreamHandler downstreamHandler;
20 18
21 - public IotEmqxDownstreamSubscriber(IotEmqxUpstreamProtocol protocol, IotMessageBus messageBus) { 19 + public IotEmqxDownstreamSubscriber(IotEmqxProtocol protocol, IotMessageBus messageBus) {
22 super(protocol, messageBus); 20 super(protocol, messageBus);
23 this.downstreamHandler = new IotEmqxDownstreamHandler(protocol); 21 this.downstreamHandler = new IotEmqxDownstreamHandler(protocol);
24 } 22 }
25 23
26 - @PostConstruct  
27 - public void startSubscriber() {  
28 - // EMQX 模式下,由 Spring 管理 Bean 生命周期;需要显式启动订阅者,才能从消息总线消费下行消息并发布到 Broker  
29 - start();  
30 - }  
31 -  
32 - @PreDestroy  
33 - public void stopSubscriber() {  
34 - stop();  
35 - }  
36 -  
37 @Override 24 @Override
38 protected void handleMessage(IotDeviceMessage message) { 25 protected void handleMessage(IotDeviceMessage message) {
39 downstreamHandler.handle(message); 26 downstreamHandler.handle(message);
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/upstream/IotEmqxAuthEventHandler.java
@@ -10,16 +10,20 @@ import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; @@ -10,16 +10,20 @@ import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
10 import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; 10 import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
11 import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils; 11 import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
12 import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; 12 import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
  13 +import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils;
13 import io.vertx.core.json.JsonObject; 14 import io.vertx.core.json.JsonObject;
14 import io.vertx.ext.web.RoutingContext; 15 import io.vertx.ext.web.RoutingContext;
15 import lombok.extern.slf4j.Slf4j; 16 import lombok.extern.slf4j.Slf4j;
16 17
  18 +import java.util.Locale;
  19 +
17 /** 20 /**
18 * IoT 网关 EMQX 认证事件处理器 21 * IoT 网关 EMQX 认证事件处理器
19 * <p> 22 * <p>
20 * 为 EMQX 提供 HTTP 接口服务,包括: 23 * 为 EMQX 提供 HTTP 接口服务,包括:
21 - * 1. 设备认证接口 - 对应 EMQX HTTP 认证插件  
22 - * 2. 设备事件处理接口 - 对应 EMQX Webhook 事件通知 24 + * 1. 设备认证接口 - 对应 EMQX HTTP 认证插件 {@link #handleAuth(RoutingContext)}
  25 + * 2. 设备事件处理接口 - 对应 EMQX Webhook 事件通知 {@link #handleEvent(RoutingContext)}
  26 + * 3. 设备 ACL 权限接口 - 对应 EMQX HTTP ACL 插件 {@link #handleAcl(RoutingContext)}
23 * 27 *
24 * @author 芋道源码 28 * @author 芋道源码
25 */ 29 */
@@ -45,15 +49,17 @@ public class IotEmqxAuthEventHandler { @@ -45,15 +49,17 @@ public class IotEmqxAuthEventHandler {
45 private static final String RESULT_IGNORE = "ignore"; 49 private static final String RESULT_IGNORE = "ignore";
46 50
47 /** 51 /**
48 - * EMQX 事件类型常量 52 + * EMQX 事件类型常量 - 客户端连接
49 */ 53 */
50 private static final String EVENT_CLIENT_CONNECTED = "client.connected"; 54 private static final String EVENT_CLIENT_CONNECTED = "client.connected";
  55 + /**
  56 + * EMQX 事件类型常量 - 客户端断开连接
  57 + */
51 private static final String EVENT_CLIENT_DISCONNECTED = "client.disconnected"; 58 private static final String EVENT_CLIENT_DISCONNECTED = "client.disconnected";
52 59
53 private final String serverId; 60 private final String serverId;
54 61
55 private final IotDeviceMessageService deviceMessageService; 62 private final IotDeviceMessageService deviceMessageService;
56 -  
57 private final IotDeviceCommonApi deviceApi; 63 private final IotDeviceCommonApi deviceApi;
58 64
59 public IotEmqxAuthEventHandler(String serverId) { 65 public IotEmqxAuthEventHandler(String serverId) {
@@ -62,13 +68,16 @@ public class IotEmqxAuthEventHandler { @@ -62,13 +68,16 @@ public class IotEmqxAuthEventHandler {
62 this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class); 68 this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class);
63 } 69 }
64 70
  71 + // ========== 认证处理 ==========
  72 +
65 /** 73 /**
66 * EMQX 认证接口 74 * EMQX 认证接口
67 */ 75 */
68 public void handleAuth(RoutingContext context) { 76 public void handleAuth(RoutingContext context) {
  77 + JsonObject body = null;
69 try { 78 try {
70 // 1. 参数校验 79 // 1. 参数校验
71 - JsonObject body = parseRequestBody(context); 80 + body = parseRequestBody(context);
72 if (body == null) { 81 if (body == null) {
73 return; 82 return;
74 } 83 }
@@ -91,11 +100,179 @@ public class IotEmqxAuthEventHandler { @@ -91,11 +100,179 @@ public class IotEmqxAuthEventHandler {
91 sendAuthResponse(context, RESULT_DENY); 100 sendAuthResponse(context, RESULT_DENY);
92 } 101 }
93 } catch (Exception e) { 102 } catch (Exception e) {
94 - log.error("[handleAuth][设备认证异常]", e); 103 + log.error("[handleAuth][设备认证异常][body={}]", body, e);
  104 + sendAuthResponse(context, RESULT_IGNORE);
  105 + }
  106 + }
  107 +
  108 + /**
  109 + * 解析认证接口请求体
  110 + * <p>
  111 + * 认证接口解析失败时返回 JSON 格式响应(包含 result 字段)
  112 + *
  113 + * @param context 路由上下文
  114 + * @return 请求体JSON对象,解析失败时返回null
  115 + */
  116 + private JsonObject parseRequestBody(RoutingContext context) {
  117 + try {
  118 + JsonObject body = context.body().asJsonObject();
  119 + if (body == null) {
  120 + log.info("[parseRequestBody][请求体为空]");
  121 + sendAuthResponse(context, RESULT_IGNORE);
  122 + return null;
  123 + }
  124 + return body;
  125 + } catch (Exception e) {
  126 + log.error("[parseRequestBody][body({}) 解析请求体失败]", context.body().asString(), e);
95 sendAuthResponse(context, RESULT_IGNORE); 127 sendAuthResponse(context, RESULT_IGNORE);
  128 + return null;
  129 + }
  130 + }
  131 +
  132 + /**
  133 + * 执行设备认证
  134 + *
  135 + * @param clientId 客户端ID
  136 + * @param username 用户名
  137 + * @param password 密码
  138 + * @return 认证是否成功
  139 + */
  140 + private boolean handleDeviceAuth(String clientId, String username, String password) {
  141 + try {
  142 + CommonResult<Boolean> result = deviceApi.authDevice(new IotDeviceAuthReqDTO()
  143 + .setClientId(clientId).setUsername(username).setPassword(password));
  144 + result.checkError();
  145 + return BooleanUtil.isTrue(result.getData());
  146 + } catch (Exception e) {
  147 + log.error("[handleDeviceAuth][设备({}) 认证接口调用失败]", username, e);
  148 + throw e;
  149 + }
  150 + }
  151 +
  152 + /**
  153 + * 发送 EMQX 认证响应
  154 + * 根据 EMQX 官方文档要求,必须返回 JSON 格式响应
  155 + *
  156 + * @param context 路由上下文
  157 + * @param result 认证结果:allow、deny、ignore
  158 + */
  159 + private void sendAuthResponse(RoutingContext context, String result) {
  160 + // 构建符合 EMQX 官方规范的响应
  161 + JsonObject response = new JsonObject()
  162 + .put("result", result)
  163 + .put("is_superuser", false);
  164 + // 可以根据业务需求添加客户端属性
  165 + // response.put("client_attrs", new JsonObject().put("role", "device"));
  166 + // 可以添加认证过期时间(可选)
  167 + // response.put("expire_at", System.currentTimeMillis() / 1000 + 3600);
  168 +
  169 + // 回复响应
  170 + context.response()
  171 + .setStatusCode(SUCCESS_STATUS_CODE)
  172 + .putHeader("Content-Type", "application/json; charset=utf-8")
  173 + .end(response.encode());
  174 + }
  175 +
  176 + // ========== ACL 处理 ==========
  177 +
  178 + /**
  179 + * EMQX ACL 接口
  180 + * <p>
  181 + * 用于 EMQX 的 HTTP ACL 插件校验设备的 publish/subscribe 权限。
  182 + * 若请求参数无法识别,则返回 ignore 交给 EMQX 自身 ACL 规则处理。
  183 + */
  184 + public void handleAcl(RoutingContext context) {
  185 + JsonObject body = null;
  186 + try {
  187 + // 1.1 解析请求体
  188 + body = parseRequestBody(context);
  189 + if (body == null) {
  190 + return;
  191 + }
  192 + String username = body.getString("username");
  193 + String topic = body.getString("topic");
  194 + if (StrUtil.hasBlank(username, topic)) {
  195 + log.info("[handleAcl][ACL 参数不完整: username={}, topic={}]", username, topic);
  196 + sendAuthResponse(context, RESULT_IGNORE);
  197 + return;
  198 + }
  199 + // 1.2 解析设备身份
  200 + IotDeviceIdentity deviceInfo = IotDeviceAuthUtils.parseUsername(username);
  201 + if (deviceInfo == null) {
  202 + sendAuthResponse(context, RESULT_IGNORE);
  203 + return;
  204 + }
  205 + // 1.3 解析 ACL 动作(兼容多种 EMQX 版本/插件字段)
  206 + Boolean subscribe = parseAclSubscribeFlag(body);
  207 + if (subscribe == null) {
  208 + sendAuthResponse(context, RESULT_IGNORE);
  209 + return;
  210 + }
  211 +
  212 + // 2. 执行 ACL 校验
  213 + boolean allowed = subscribe
  214 + ? IotMqttTopicUtils.isTopicSubscribeAllowed(topic, deviceInfo.getProductKey(), deviceInfo.getDeviceName())
  215 + : IotMqttTopicUtils.isTopicPublishAllowed(topic, deviceInfo.getProductKey(), deviceInfo.getDeviceName());
  216 + sendAuthResponse(context, allowed ? RESULT_ALLOW : RESULT_DENY);
  217 + } catch (Exception e) {
  218 + log.error("[handleAcl][ACL 处理失败][body={}]", body, e);
  219 + sendAuthResponse(context, RESULT_IGNORE);
  220 + }
  221 + }
  222 +
  223 + /**
  224 + * 解析 ACL 动作类型:订阅/发布
  225 + *
  226 + * @param body ACL 请求体
  227 + * @return true 订阅;false 发布;null 不识别
  228 + */
  229 + private static Boolean parseAclSubscribeFlag(JsonObject body) {
  230 + // 1. action 字段(常见为 publish/subscribe)
  231 + String action = body.getString("action");
  232 + if (StrUtil.isNotBlank(action)) {
  233 + String lower = action.toLowerCase(Locale.ROOT);
  234 + if (lower.contains("sub")) {
  235 + return true;
  236 + }
  237 + if (lower.contains("pub")) {
  238 + return false;
  239 + }
  240 + }
  241 +
  242 + // 2. access 字段:可能是数字或字符串
  243 + Integer access = body.getInteger("access");
  244 + if (access != null) {
  245 + if (access == 1) {
  246 + return true;
  247 + }
  248 + if (access == 2) {
  249 + return false;
  250 + }
  251 + }
  252 + String accessText = body.getString("access");
  253 + if (StrUtil.isNotBlank(accessText)) {
  254 + String lower = accessText.toLowerCase(Locale.ROOT);
  255 + if (lower.contains("sub")) {
  256 + return true;
  257 + }
  258 + if (lower.contains("pub")) {
  259 + return false;
  260 + }
  261 + if (StrUtil.isNumeric(accessText)) {
  262 + int value = Integer.parseInt(accessText);
  263 + if (value == 1) {
  264 + return true;
  265 + }
  266 + if (value == 2) {
  267 + return false;
  268 + }
  269 + }
96 } 270 }
  271 + return null;
97 } 272 }
98 273
  274 + // ========== 事件处理 ==========
  275 +
99 /** 276 /**
100 * EMQX 统一事件处理接口:根据 EMQX 官方 Webhook 设计,统一处理所有客户端事件 277 * EMQX 统一事件处理接口:根据 EMQX 官方 Webhook 设计,统一处理所有客户端事件
101 * 支持的事件类型:client.connected、client.disconnected 等 278 * 支持的事件类型:client.connected、client.disconnected 等
@@ -124,59 +301,16 @@ public class IotEmqxAuthEventHandler { @@ -124,59 +301,16 @@ public class IotEmqxAuthEventHandler {
124 break; 301 break;
125 } 302 }
126 303
127 - // EMQX Webhook 只需要 200 状态码,无需响应体 304 + // 3. EMQX Webhook 只需要 200 状态码,无需响应体
128 context.response().setStatusCode(SUCCESS_STATUS_CODE).end(); 305 context.response().setStatusCode(SUCCESS_STATUS_CODE).end();
129 } catch (Exception e) { 306 } catch (Exception e) {
130 - log.error("[handleEvent][事件处理失败][body={}]", body != null ? body.encode() : "null", e);  
131 - // 即使处理失败,也返回 200 避免EMQX重试 307 + log.error("[handleEvent][事件处理失败][body={}]", body, e);
  308 + // 即使处理失败,也返回 200 避免 EMQX 重试
132 context.response().setStatusCode(SUCCESS_STATUS_CODE).end(); 309 context.response().setStatusCode(SUCCESS_STATUS_CODE).end();
133 } 310 }
134 } 311 }
135 312
136 /** 313 /**
137 - * 处理客户端连接事件  
138 - */  
139 - private void handleClientConnected(JsonObject body) {  
140 - String username = body.getString("username");  
141 - log.info("[handleClientConnected][设备上线: {}]", username);  
142 - handleDeviceStateChange(username, true);  
143 - }  
144 -  
145 - /**  
146 - * 处理客户端断开连接事件  
147 - */  
148 - private void handleClientDisconnected(JsonObject body) {  
149 - String username = body.getString("username");  
150 - String reason = body.getString("reason");  
151 - log.info("[handleClientDisconnected][设备下线: {} ({})]", username, reason);  
152 - handleDeviceStateChange(username, false);  
153 - }  
154 -  
155 - /**  
156 - * 解析认证接口请求体  
157 - * <p>  
158 - * 认证接口解析失败时返回 JSON 格式响应(包含 result 字段)  
159 - *  
160 - * @param context 路由上下文  
161 - * @return 请求体JSON对象,解析失败时返回null  
162 - */  
163 - private JsonObject parseRequestBody(RoutingContext context) {  
164 - try {  
165 - JsonObject body = context.body().asJsonObject();  
166 - if (body == null) {  
167 - log.info("[parseRequestBody][请求体为空]");  
168 - sendAuthResponse(context, RESULT_IGNORE);  
169 - return null;  
170 - }  
171 - return body;  
172 - } catch (Exception e) {  
173 - log.error("[parseRequestBody][body({}) 解析请求体失败]", context.body().asString(), e);  
174 - sendAuthResponse(context, RESULT_IGNORE);  
175 - return null;  
176 - }  
177 - }  
178 -  
179 - /**  
180 * 解析事件接口请求体 314 * 解析事件接口请求体
181 * <p> 315 * <p>
182 * 事件接口解析失败时仅返回 200 状态码,无响应体(符合 EMQX Webhook 规范) 316 * 事件接口解析失败时仅返回 200 状态码,无响应体(符合 EMQX Webhook 规范)
@@ -201,23 +335,22 @@ public class IotEmqxAuthEventHandler { @@ -201,23 +335,22 @@ public class IotEmqxAuthEventHandler {
201 } 335 }
202 336
203 /** 337 /**
204 - * 执行设备认证  
205 - *  
206 - * @param clientId 客户端ID  
207 - * @param username 用户名  
208 - * @param password 密码  
209 - * @return 认证是否成功 338 + * 处理客户端连接事件
210 */ 339 */
211 - private boolean handleDeviceAuth(String clientId, String username, String password) {  
212 - try {  
213 - CommonResult<Boolean> result = deviceApi.authDevice(new IotDeviceAuthReqDTO()  
214 - .setClientId(clientId).setUsername(username).setPassword(password));  
215 - result.checkError();  
216 - return BooleanUtil.isTrue(result.getData());  
217 - } catch (Exception e) {  
218 - log.error("[handleDeviceAuth][设备({}) 认证接口调用失败]", username, e);  
219 - throw e;  
220 - } 340 + private void handleClientConnected(JsonObject body) {
  341 + String username = body.getString("username");
  342 + log.info("[handleClientConnected][设备上线: {}]", username);
  343 + handleDeviceStateChange(username, true);
  344 + }
  345 +
  346 + /**
  347 + * 处理客户端断开连接事件
  348 + */
  349 + private void handleClientDisconnected(JsonObject body) {
  350 + String username = body.getString("username");
  351 + String reason = body.getString("reason");
  352 + log.info("[handleClientDisconnected][设备下线: {} ({})]", username, reason);
  353 + handleDeviceStateChange(username, false);
221 } 354 }
222 355
223 /** 356 /**
@@ -247,29 +380,4 @@ public class IotEmqxAuthEventHandler { @@ -247,29 +380,4 @@ public class IotEmqxAuthEventHandler {
247 } 380 }
248 } 381 }
249 382
250 - /**  
251 - * 发送 EMQX 认证响应  
252 - * 根据 EMQX 官方文档要求,必须返回 JSON 格式响应  
253 - *  
254 - * @param context 路由上下文  
255 - * @param result 认证结果:allow、deny、ignore  
256 - */  
257 - private void sendAuthResponse(RoutingContext context, String result) {  
258 - // 构建符合 EMQX 官方规范的响应  
259 - JsonObject response = new JsonObject()  
260 - .put("result", result)  
261 - .put("is_superuser", false);  
262 -  
263 - // 可以根据业务需求添加客户端属性  
264 - // response.put("client_attrs", new JsonObject().put("role", "device"));  
265 -  
266 - // 可以添加认证过期时间(可选)  
267 - // response.put("expire_at", System.currentTimeMillis() / 1000 + 3600);  
268 -  
269 - context.response()  
270 - .setStatusCode(SUCCESS_STATUS_CODE)  
271 - .putHeader("Content-Type", "application/json; charset=utf-8")  
272 - .end(response.encode());  
273 - }  
274 -  
275 -}  
276 \ No newline at end of file 383 \ No newline at end of file
  384 +}
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/upstream/IotEmqxUpstreamHandler.java
@@ -3,7 +3,6 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.upstream; @@ -3,7 +3,6 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.upstream;
3 import cn.hutool.core.util.StrUtil; 3 import cn.hutool.core.util.StrUtil;
4 import cn.hutool.extra.spring.SpringUtil; 4 import cn.hutool.extra.spring.SpringUtil;
5 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; 5 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
6 -import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxUpstreamProtocol;  
7 import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; 6 import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
8 import io.vertx.mqtt.messages.MqttPublishMessage; 7 import io.vertx.mqtt.messages.MqttPublishMessage;
9 import lombok.extern.slf4j.Slf4j; 8 import lombok.extern.slf4j.Slf4j;
@@ -20,16 +19,16 @@ public class IotEmqxUpstreamHandler { @@ -20,16 +19,16 @@ public class IotEmqxUpstreamHandler {
20 19
21 private final String serverId; 20 private final String serverId;
22 21
23 - public IotEmqxUpstreamHandler(IotEmqxUpstreamProtocol protocol) { 22 + public IotEmqxUpstreamHandler(String serverId) {
24 this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); 23 this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class);
25 - this.serverId = protocol.getServerId(); 24 + this.serverId = serverId;
26 } 25 }
27 26
28 /** 27 /**
29 * 处理 MQTT 发布消息 28 * 处理 MQTT 发布消息
30 */ 29 */
31 public void handle(MqttPublishMessage mqttMessage) { 30 public void handle(MqttPublishMessage mqttMessage) {
32 - log.info("[handle][收到 MQTT 消息, topic: {}, payload: {}]", mqttMessage.topicName(), mqttMessage.payload()); 31 + log.debug("[handle][收到 MQTT 消息, topic: {}, payload: {}]", mqttMessage.topicName(), mqttMessage.payload());
33 String topic = mqttMessage.topicName(); 32 String topic = mqttMessage.topicName();
34 byte[] payload = mqttMessage.payload().getBytes(); 33 byte[] payload = mqttMessage.payload().getBytes();
35 try { 34 try {
@@ -57,4 +56,4 @@ public class IotEmqxUpstreamHandler { @@ -57,4 +56,4 @@ public class IotEmqxUpstreamHandler {
57 } 56 }
58 } 57 }
59 58
60 -}  
61 \ No newline at end of file 59 \ No newline at end of file
  60 +}
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/util/IotMqttTopicUtils.java
@@ -38,6 +38,12 @@ public final class IotMqttTopicUtils { @@ -38,6 +38,12 @@ public final class IotMqttTopicUtils {
38 */ 38 */
39 public static final String MQTT_EVENT_PATH = "/mqtt/event"; 39 public static final String MQTT_EVENT_PATH = "/mqtt/event";
40 40
  41 + /**
  42 + * MQTT ACL 接口路径
  43 + * 对应 EMQX HTTP ACL 插件的 ACL 请求接口
  44 + */
  45 + public static final String MQTT_ACL_PATH = "/mqtt/acl";
  46 +
41 // ========== 工具方法 ========== 47 // ========== 工具方法 ==========
42 48
43 /** 49 /**
@@ -85,4 +91,28 @@ public final class IotMqttTopicUtils { @@ -85,4 +91,28 @@ public final class IotMqttTopicUtils {
85 || topic.equals(SYS_TOPIC_PREFIX + productKey + "/" + deviceName + "/#"); 91 || topic.equals(SYS_TOPIC_PREFIX + productKey + "/" + deviceName + "/#");
86 } 92 }
87 93
88 -}  
89 \ No newline at end of file 94 \ No newline at end of file
  95 + /**
  96 + * 校验主题是否允许发布
  97 + * <p>
  98 + * 规则:主题必须以 /sys/{productKey}/{deviceName}/ 开头,且不允许包含通配符(+/#)。
  99 + *
  100 + * @param topic 发布的主题
  101 + * @param productKey 产品 Key
  102 + * @param deviceName 设备名称
  103 + * @return 是否允许发布
  104 + */
  105 + // TODO DONE @AI:这个逻辑,是不是 mqtt 协议,也要使用???答:是通用工具方法,MQTT 协议可按需调用;
  106 + // TODO @AI:那你改下 mqtt,也调用!!!
  107 + public static boolean isTopicPublishAllowed(String topic, String productKey, String deviceName) {
  108 + if (!StrUtil.isAllNotBlank(topic, productKey, deviceName)) {
  109 + return false;
  110 + }
  111 + // MQTT publish topic 不允许包含通配符,但这里做一次兜底校验
  112 + if (topic.contains("#") || topic.contains("+")) {
  113 + return false;
  114 + }
  115 + String deviceTopicPrefix = SYS_TOPIC_PREFIX + productKey + "/" + deviceName + "/";
  116 + return topic.startsWith(deviceTopicPrefix);
  117 + }
  118 +
  119 +}
yudao-module-iot/yudao-module-iot-gateway/src/main/resources/application.yaml
@@ -125,43 +125,49 @@ yudao: @@ -125,43 +125,49 @@ yudao:
125 max-message-size: 8192 # 最大消息大小(字节) 125 max-message-size: 8192 # 最大消息大小(字节)
126 connect-timeout-seconds: 60 # 连接超时时间(秒) 126 connect-timeout-seconds: 60 # 连接超时时间(秒)
127 ssl-enabled: false # 是否启用 SSL 127 ssl-enabled: false # 是否启用 SSL
128 -  
129 - # 协议配置(旧版,保持兼容)  
130 - protocol:  
131 # ==================================== 128 # ====================================
132 # 针对引入的 EMQX 组件的配置 129 # 针对引入的 EMQX 组件的配置
133 # ==================================== 130 # ====================================
134 - emqx: 131 + - id: emqx-1
135 enabled: true 132 enabled: true
136 - http-port: 8090 # MQTT HTTP 服务端口  
137 - mqtt-host: 127.0.0.1 # MQTT Broker 地址  
138 - mqtt-port: 1883 # MQTT Broker 端口  
139 - mqtt-username: admin # MQTT 用户名  
140 - mqtt-password: public # MQTT 密码  
141 - mqtt-client-id: iot-gateway-mqtt # MQTT 客户端 ID  
142 - mqtt-ssl: false # 是否开启 SSL  
143 - mqtt-topics:  
144 - - "/sys/#" # 系统主题  
145 - clean-session: true # 是否启用 Clean Session (默认: true)  
146 - keep-alive-interval-seconds: 60 # 心跳间隔,单位秒 (默认: 60)  
147 - max-inflight-queue: 10000 # 最大飞行消息队列,单位:条  
148 - connect-timeout-seconds: 10 # 连接超时,单位:秒  
149 - # 是否信任所有 SSL 证书 (默认: false)。警告:生产环境必须为 false!  
150 - # 仅在开发环境或内网测试时,如果使用了自签名证书,可以临时设置为 true  
151 - trust-all: true # 在 dev 环境可以设为 true  
152 - # 遗嘱消息配置 (用于网关异常下线时通知其他系统)  
153 - will:  
154 - enabled: true # 生产环境强烈建议开启  
155 - topic: "gateway/status/${yudao.iot.gateway.emqx.mqtt-client-id}" # 遗嘱消息主题  
156 - payload: "offline" # 遗嘱消息负载  
157 - qos: 1 # 遗嘱消息 QoS  
158 - retain: true # 遗嘱消息是否保留  
159 - # 高级 SSL/TLS 配置 (当 trust-all: false 且 mqtt-ssl: true 时生效)  
160 - ssl-options:  
161 - key-store-path: "classpath:certs/client.jks" # 客户端证书库路径  
162 - key-store-password: "your-keystore-password" # 客户端证书库密码  
163 - trust-store-path: "classpath:certs/trust.jks" # 信任的 CA 证书库路径  
164 - trust-store-password: "your-truststore-password" # 信任的 CA 证书库密码 133 + protocol: emqx
  134 + port: 8090 # EMQX HTTP Hook 端口(/mqtt/auth、/mqtt/event)
  135 + emqx:
  136 + mqtt-host: 127.0.0.1 # MQTT Broker 地址
  137 + mqtt-port: 1883 # MQTT Broker 端口
  138 + mqtt-username: admin # MQTT 用户名
  139 + mqtt-password: public # MQTT 密码
  140 + mqtt-client-id: iot-gateway-mqtt # MQTT 客户端 ID
  141 + mqtt-ssl: false # 是否开启 SSL
  142 + mqtt-topics:
  143 + - "/sys/#" # 系统主题
  144 + mqtt-qos: 1 # 默认 QoS
  145 + clean-session: true # 是否启用 Clean Session (默认: true)
  146 + keep-alive-interval-seconds: 60 # 心跳间隔,单位秒 (默认: 60)
  147 + max-inflight-queue: 10000 # 最大飞行消息队列,单位:条
  148 + connect-timeout-seconds: 10 # 连接超时,单位:秒
  149 + reconnect-delay-ms: 5000 # 重连延迟,单位:毫秒
  150 + # 是否信任所有 SSL 证书 (默认: false)。警告:生产环境必须为 false!
  151 + # 仅在开发环境或内网测试时,如果使用了自签名证书,可以临时设置为 true
  152 + trust-all: true # 在 dev 环境可以设为 true
  153 + # EMQX HTTP Hook 回调网关的 HTTPS 配置(可选)
  154 + http:
  155 + ssl-enabled: false
  156 + # ssl-cert-path: "path/to/server.crt"
  157 + # ssl-key-path: "path/to/server.key"
  158 + # 遗嘱消息配置 (用于网关异常下线时通知其他系统)
  159 + will:
  160 + enabled: true # 生产环境强烈建议开启
  161 + topic: "gateway/status/iot-gateway-mqtt" # 遗嘱消息主题
  162 + payload: "offline" # 遗嘱消息负载
  163 + qos: 1 # 遗嘱消息 QoS
  164 + retain: true # 遗嘱消息是否保留
  165 + # 高级 SSL/TLS 配置 (当 trust-all: false 且 mqtt-ssl: true 时生效)
  166 + ssl-options:
  167 + key-store-path: "classpath:certs/client.jks" # 客户端证书库路径
  168 + key-store-password: "your-keystore-password" # 客户端证书库密码
  169 + trust-store-path: "classpath:certs/trust.jks" # 信任的 CA 证书库路径
  170 + trust-store-password: "your-truststore-password" # 信任的 CA 证书库密码
165 171
166 --- #################### 日志相关配置 #################### 172 --- #################### 日志相关配置 ####################
167 173
@@ -181,7 +187,6 @@ logging: @@ -181,7 +187,6 @@ logging:
181 cn.iocoder.yudao.module.iot.gateway.protocol.emqx: DEBUG 187 cn.iocoder.yudao.module.iot.gateway.protocol.emqx: DEBUG
182 cn.iocoder.yudao.module.iot.gateway.protocol.http: DEBUG 188 cn.iocoder.yudao.module.iot.gateway.protocol.http: DEBUG
183 cn.iocoder.yudao.module.iot.gateway.protocol.mqtt: DEBUG 189 cn.iocoder.yudao.module.iot.gateway.protocol.mqtt: DEBUG
184 - cn.iocoder.yudao.module.iot.gateway.protocol.mqttws: DEBUG  
185 cn.iocoder.yudao.module.iot.gateway.protocol.coap: DEBUG 190 cn.iocoder.yudao.module.iot.gateway.protocol.coap: DEBUG
186 cn.iocoder.yudao.module.iot.gateway.protocol.websocket: DEBUG 191 cn.iocoder.yudao.module.iot.gateway.protocol.websocket: DEBUG
187 # 根日志级别 192 # 根日志级别
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotDirectDeviceEmqxProtocolIntegrationTest.java deleted
1 -package cn.iocoder.yudao.module.iot.gateway.protocol.emqx;  
2 -  
3 -import cn.hutool.core.map.MapUtil;  
4 -import cn.hutool.http.HttpResponse;  
5 -import cn.hutool.http.HttpUtil;  
6 -import cn.iocoder.yudao.framework.common.util.json.JsonUtils;  
7 -import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;  
8 -import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;  
9 -import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;  
10 -import cn.iocoder.yudao.module.iot.core.topic.event.IotDeviceEventPostReqDTO;  
11 -import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPostReqDTO;  
12 -import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;  
13 -import cn.iocoder.yudao.module.iot.gateway.codec.IotDeviceMessageCodec;  
14 -import cn.iocoder.yudao.module.iot.gateway.codec.alink.IotAlinkDeviceMessageCodec;  
15 -import io.netty.handler.codec.mqtt.MqttQoS;  
16 -import io.vertx.core.Vertx;  
17 -import io.vertx.core.buffer.Buffer;  
18 -import io.vertx.mqtt.MqttClient;  
19 -import io.vertx.mqtt.MqttClientOptions;  
20 -import lombok.extern.slf4j.Slf4j;  
21 -import org.junit.jupiter.api.AfterAll;  
22 -import org.junit.jupiter.api.BeforeAll;  
23 -import org.junit.jupiter.api.Disabled;  
24 -import org.junit.jupiter.api.Test;  
25 -  
26 -import java.util.concurrent.TimeUnit;  
27 -  
28 -/**  
29 - * IoT 直连设备 EMQX 协议集成测试(手动测试)  
30 - *  
31 - * <p>测试场景:直连设备(IotProductDeviceTypeEnum 的 DIRECT 类型)通过 EMQX Broker 连接平台  
32 - *  
33 - * <p>EMQX 协议架构:  
34 - * <pre>  
35 - * +--------+ MQTT +-------------+ HTTP Hook +---------+  
36 - * | 设备 | ----------------> | EMQX Broker | --------------------> | 网关 |  
37 - * +--------+ +-------------+ +---------+  
38 - * | | |  
39 - * | 1. 连接认证 | 2. 调用 /mqtt/auth |  
40 - * | 3. 发布消息 | 4. 调用 /mqtt/event (上线/下线) |  
41 - * | | 5. 网关订阅 EMQX 消息 |  
42 - * | | |  
43 - * </pre>  
44 - *  
45 - * <p>测试分类:  
46 - * <ul>  
47 - * <li>第一部分:模拟设备连接 EMQX Broker,发送 MQTT 消息</li>  
48 - * <li>第二部分:模拟 EMQX Server 调用网关 HTTP Hook 接口(认证、事件)</li>  
49 - * </ul>  
50 - *  
51 - * <p>使用步骤:  
52 - * <ol>  
53 - * <li>启动 EMQX Broker(MQTT 端口 1883)</li>  
54 - * <li>启动 yudao-module-iot-gateway 服务(HTTP 端口 18083)</li>  
55 - * <li>配置 EMQX HTTP 认证插件指向网关的 /mqtt/auth 接口</li>  
56 - * <li>配置 EMQX Webhook 插件指向网关的 /mqtt/event 接口</li>  
57 - * <li>运行测试方法</li>  
58 - * </ol>  
59 - *  
60 - * @author 芋道源码  
61 - */  
62 -@Slf4j  
63 -@Disabled  
64 -@SuppressWarnings("HttpUrlsUsage")  
65 -public class IotDirectDeviceEmqxProtocolIntegrationTest {  
66 -  
67 - private static final String SERVER_HOST = "127.0.0.1";  
68 - /**  
69 - * EMQX 认证事件 HTTP 接口端口(网关提供给 EMQX Server 调用)  
70 - */  
71 - private static final int HTTP_PORT = 18083;  
72 - /**  
73 - * EMQX Broker MQTT 端口(设备连接 EMQX)  
74 - */  
75 - private static final int MQTT_PORT = 1883;  
76 - private static final int TIMEOUT_SECONDS = 10;  
77 -  
78 - private static Vertx vertx;  
79 -  
80 - // ===================== 编解码器(EMQX 使用 Alink 协议) =====================  
81 -  
82 - private static final IotDeviceMessageCodec CODEC = new IotAlinkDeviceMessageCodec();  
83 -  
84 - // ===================== 直连设备信息(根据实际情况修改,从 iot_device 表查询) =====================  
85 -  
86 - private static final String PRODUCT_KEY = "4aymZgOTOOCrDKRT";  
87 - private static final String DEVICE_NAME = "small";  
88 - private static final String DEVICE_SECRET = "0baa4c2ecc104ae1a26b4070c218bdf3";  
89 -  
90 - @BeforeAll  
91 - public static void setUp() {  
92 - vertx = Vertx.vertx();  
93 - }  
94 -  
95 - @AfterAll  
96 - public static void tearDown() {  
97 - if (vertx != null) {  
98 - vertx.close();  
99 - }  
100 - }  
101 -  
102 - // ==================================================================================  
103 - // 第一部分:模拟设备连接 EMQX Broker  
104 - // ==================================================================================  
105 -  
106 - /**  
107 - * 设备连接测试:模拟设备连接 EMQX Broker  
108 - * <p>  
109 - * 当设备连接 EMQX 时,EMQX 会自动调用网关的 /mqtt/auth 接口进行认证  
110 - */  
111 - @Test  
112 - public void testDeviceConnect() throws Exception {  
113 - // 1. 构建认证信息  
114 - IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET);  
115 - log.info("[testDeviceConnect][认证信息: clientId={}, username={}, password={}]",  
116 - authInfo.getClientId(), authInfo.getUsername(), authInfo.getPassword());  
117 -  
118 - // 2. 创建客户端并连接 EMQX Broker  
119 - MqttClient client = createClient(authInfo);  
120 - try {  
121 - client.connect(MQTT_PORT, SERVER_HOST)  
122 - .toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);  
123 - log.info("[testDeviceConnect][连接 EMQX Broker 成功,客户端 ID: {}]", client.clientId());  
124 - log.info("[testDeviceConnect][EMQX 会自动调用网关的 /mqtt/auth 接口进行认证]");  
125 - log.info("[testDeviceConnect][EMQX 会自动调用网关的 /mqtt/event 接口通知设备上线]");  
126 - } finally {  
127 - disconnect(client);  
128 - log.info("[testDeviceConnect][EMQX 会自动调用网关的 /mqtt/event 接口通知设备下线]");  
129 - }  
130 - }  
131 -  
132 - /**  
133 - * 属性上报测试:设备通过 EMQX Broker 发布属性消息  
134 - * <p>  
135 - * 消息流程:设备 -> EMQX Broker -> 网关(订阅 EMQX 消息)  
136 - */  
137 - @Test  
138 - public void testPropertyPost() throws Exception {  
139 - // 1. 连接 EMQX Broker  
140 - MqttClient client = connectToEmqx();  
141 - log.info("[testPropertyPost][连接 EMQX Broker 成功]");  
142 -  
143 - try {  
144 - // 2.1 构建属性上报消息  
145 - IotDeviceMessage request = IotDeviceMessage.requestOf(  
146 - IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(),  
147 - IotDevicePropertyPostReqDTO.of(MapUtil.<String, Object>builder()  
148 - .put("width", 1)  
149 - .put("height", "2")  
150 - .build()));  
151 -  
152 - // 2.2 发布消息到 EMQX Broker  
153 - String topic = String.format("/sys/%s/%s/thing/property/post", PRODUCT_KEY, DEVICE_NAME);  
154 - publish(client, topic, request);  
155 - log.info("[testPropertyPost][属性上报消息已发送到 EMQX Broker]");  
156 - log.info("[testPropertyPost][网关会通过订阅 EMQX 接收此消息]");  
157 -  
158 - // 2.3 等待消息处理  
159 - Thread.sleep(2000);  
160 - log.info("[testPropertyPost][请检查网关日志确认消息是否被正确处理]");  
161 - } finally {  
162 - disconnect(client);  
163 - }  
164 - }  
165 -  
166 - /**  
167 - * 事件上报测试:设备通过 EMQX Broker 发布事件消息  
168 - * <p>  
169 - * 消息流程:设备 -> EMQX Broker -> 网关(订阅 EMQX 消息)  
170 - */  
171 - @Test  
172 - public void testEventPost() throws Exception {  
173 - // 1. 连接 EMQX Broker  
174 - MqttClient client = connectToEmqx();  
175 - log.info("[testEventPost][连接 EMQX Broker 成功]");  
176 -  
177 - try {  
178 - // 2.1 构建事件上报消息  
179 - IotDeviceMessage request = IotDeviceMessage.requestOf(  
180 - IotDeviceMessageMethodEnum.EVENT_POST.getMethod(),  
181 - IotDeviceEventPostReqDTO.of(  
182 - "eat",  
183 - MapUtil.<String, Object>builder().put("rice", 3).build(),  
184 - System.currentTimeMillis()));  
185 -  
186 - // 2.2 发布消息到 EMQX Broker  
187 - String topic = String.format("/sys/%s/%s/thing/event/post", PRODUCT_KEY, DEVICE_NAME);  
188 - publish(client, topic, request);  
189 - log.info("[testEventPost][事件上报消息已发送到 EMQX Broker]");  
190 - log.info("[testEventPost][网关会通过订阅 EMQX 接收此消息]");  
191 -  
192 - // 2.3 等待消息处理  
193 - Thread.sleep(2000);  
194 - log.info("[testEventPost][请检查网关日志确认消息是否被正确处理]");  
195 - } finally {  
196 - disconnect(client);  
197 - }  
198 - }  
199 -  
200 - /**  
201 - * 订阅下行消息测试:设备订阅服务端下发的消息  
202 - * <p>  
203 - * 消息流程:网关 -> EMQX Broker -> 设备  
204 - */  
205 - @Test  
206 - public void testSubscribe() throws Exception {  
207 - // 1. 连接 EMQX Broker  
208 - MqttClient client = connectToEmqx();  
209 - log.info("[testSubscribe][连接 EMQX Broker 成功]");  
210 -  
211 - try {  
212 - // 2. 设置消息处理器  
213 - client.publishHandler(message -> log.info("[testSubscribe][收到下行消息: topic={}, payload={}]",  
214 - message.topicName(), message.payload().toString()));  
215 -  
216 - // 3. 订阅下行主题  
217 - String topic = String.format("/sys/%s/%s/thing/service/#", PRODUCT_KEY, DEVICE_NAME);  
218 - log.info("[testSubscribe][订阅主题: {}]", topic);  
219 - subscribe(client, topic);  
220 - log.info("[testSubscribe][订阅成功,等待下行消息... (30秒后自动断开)]");  
221 - log.info("[testSubscribe][网关下发的消息会通过 EMQX Broker 转发给设备]");  
222 -  
223 - // 4. 保持连接 30 秒等待消息  
224 - Thread.sleep(30000);  
225 - } finally {  
226 - disconnect(client);  
227 - }  
228 - }  
229 -  
230 - // ==================================================================================  
231 - // 第二部分:模拟 EMQX Server 调用网关 HTTP Hook 接口  
232 - // 说明:这些接口是 EMQX Server 自动调用的,这里只是用于单独测试接口功能  
233 - // ==================================================================================  
234 -  
235 - /**  
236 - * 认证接口测试:模拟 EMQX Server 调用 /mqtt/auth 接口  
237 - * <p>  
238 - * 注意:正常情况下此接口由 EMQX HTTP 认证插件自动调用,这里只是测试接口本身  
239 - */  
240 - @Test  
241 - public void testEmqxAuthHook() {  
242 - // 1.1 构建请求  
243 - String url = String.format("http://%s:%d/mqtt/auth", SERVER_HOST, HTTP_PORT);  
244 - IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET);  
245 - // 1.2 EMQX 认证请求格式  
246 - String payload = JsonUtils.toJsonString(MapUtil.builder()  
247 - .put("clientid", authInfo.getClientId())  
248 - .put("username", authInfo.getUsername())  
249 - .put("password", authInfo.getPassword())  
250 - .build());  
251 - // 1.3 输出请求  
252 - log.info("[testEmqxAuthHook][模拟 EMQX Server 调用认证接口]");  
253 - log.info("[testEmqxAuthHook][请求 URL: {}]", url);  
254 - log.info("[testEmqxAuthHook][请求体: {}]", payload);  
255 -  
256 - // 2.1 发送请求  
257 - try (HttpResponse httpResponse = HttpUtil.createPost(url)  
258 - .header("Content-Type", "application/json")  
259 - .body(payload)  
260 - .execute()) {  
261 - // 2.2 输出结果  
262 - log.info("[testEmqxAuthHook][响应状态码: {}]", httpResponse.getStatus());  
263 - log.info("[testEmqxAuthHook][响应体: {}]", httpResponse.body());  
264 - log.info("[testEmqxAuthHook][认证结果: result=allow 表示认证成功, result=deny 表示认证失败]");  
265 - }  
266 - }  
267 -  
268 - /**  
269 - * 认证失败测试:模拟 EMQX Server 调用 /mqtt/auth 接口(错误密码)  
270 - */  
271 - @Test  
272 - public void testEmqxAuthHookFailed() {  
273 - // 1.1 构建请求  
274 - String url = String.format("http://%s:%d/mqtt/auth", SERVER_HOST, HTTP_PORT);  
275 - // 1.2 使用错误的密码  
276 - String payload = JsonUtils.toJsonString(MapUtil.builder()  
277 - .put("clientid", PRODUCT_KEY + "." + DEVICE_NAME)  
278 - .put("username", DEVICE_NAME + "&" + PRODUCT_KEY)  
279 - .put("password", "wrong_password")  
280 - .build());  
281 - // 1.3 输出请求  
282 - log.info("[testEmqxAuthHookFailed][模拟 EMQX Server 调用认证接口(错误密码)]");  
283 - log.info("[testEmqxAuthHookFailed][请求 URL: {}]", url);  
284 - log.info("[testEmqxAuthHookFailed][请求体: {}]", payload);  
285 -  
286 - // 2.1 发送请求  
287 - try (HttpResponse httpResponse = HttpUtil.createPost(url)  
288 - .header("Content-Type", "application/json")  
289 - .body(payload)  
290 - .execute()) {  
291 - // 2.2 输出结果  
292 - log.info("[testEmqxAuthHookFailed][响应状态码: {}]", httpResponse.getStatus());  
293 - log.info("[testEmqxAuthHookFailed][响应体: {}]", httpResponse.body());  
294 - log.info("[testEmqxAuthHookFailed][预期结果: result=deny]");  
295 - }  
296 - }  
297 -  
298 - /**  
299 - * 设备上线事件测试:模拟 EMQX Server Webhook 调用 /mqtt/event 接口  
300 - * <p>  
301 - * 注意:正常情况下此接口由 EMQX Webhook 插件自动调用,这里只是测试接口本身  
302 - */  
303 - @Test  
304 - public void testEmqxClientConnectedHook() {  
305 - // 1.1 构建请求  
306 - String url = String.format("http://%s:%d/mqtt/event", SERVER_HOST, HTTP_PORT);  
307 - IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET);  
308 - // 1.2 EMQX Webhook client.connected 事件格式  
309 - String payload = JsonUtils.toJsonString(MapUtil.builder()  
310 - .put("event", "client.connected")  
311 - .put("clientid", authInfo.getClientId())  
312 - .put("username", authInfo.getUsername())  
313 - .put("peername", "127.0.0.1:12345")  
314 - .put("connected_at", System.currentTimeMillis())  
315 - .build());  
316 - // 1.3 输出请求  
317 - log.info("[testEmqxClientConnectedHook][模拟 EMQX Server Webhook 调用设备上线事件]");  
318 - log.info("[testEmqxClientConnectedHook][请求 URL: {}]", url);  
319 - log.info("[testEmqxClientConnectedHook][请求体: {}]", payload);  
320 -  
321 - // 2.1 发送请求  
322 - try (HttpResponse httpResponse = HttpUtil.createPost(url)  
323 - .header("Content-Type", "application/json")  
324 - .body(payload)  
325 - .execute()) {  
326 - // 2.2 输出结果  
327 - log.info("[testEmqxClientConnectedHook][响应状态码: {}]", httpResponse.getStatus());  
328 - log.info("[testEmqxClientConnectedHook][响应体: {}]", httpResponse.body());  
329 - log.info("[testEmqxClientConnectedHook][预期结果: 状态码 200,设备状态更新为在线]");  
330 - }  
331 - }  
332 -  
333 - /**  
334 - * 设备下线事件测试:模拟 EMQX Server Webhook 调用 /mqtt/event 接口  
335 - * <p>  
336 - * 注意:正常情况下此接口由 EMQX Webhook 插件自动调用,这里只是测试接口本身  
337 - */  
338 - @Test  
339 - public void testEmqxClientDisconnectedHook() {  
340 - // 1.1 构建请求  
341 - String url = String.format("http://%s:%d/mqtt/event", SERVER_HOST, HTTP_PORT);  
342 - IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET);  
343 - // 1.2 EMQX Webhook client.disconnected 事件格式  
344 - String payload = JsonUtils.toJsonString(MapUtil.builder()  
345 - .put("event", "client.disconnected")  
346 - .put("clientid", authInfo.getClientId())  
347 - .put("username", authInfo.getUsername())  
348 - .put("reason", "normal")  
349 - .put("disconnected_at", System.currentTimeMillis())  
350 - .build());  
351 - // 1.3 输出请求  
352 - log.info("[testEmqxClientDisconnectedHook][模拟 EMQX Server Webhook 调用设备下线事件]");  
353 - log.info("[testEmqxClientDisconnectedHook][请求 URL: {}]", url);  
354 - log.info("[testEmqxClientDisconnectedHook][请求体: {}]", payload);  
355 -  
356 - // 2.1 发送请求  
357 - try (HttpResponse httpResponse = HttpUtil.createPost(url)  
358 - .header("Content-Type", "application/json")  
359 - .body(payload)  
360 - .execute()) {  
361 - // 2.2 输出结果  
362 - log.info("[testEmqxClientDisconnectedHook][响应状态码: {}]", httpResponse.getStatus());  
363 - log.info("[testEmqxClientDisconnectedHook][响应体: {}]", httpResponse.body());  
364 - log.info("[testEmqxClientDisconnectedHook][预期结果: 状态码 200,设备状态更新为离线]");  
365 - }  
366 - }  
367 -  
368 - // ===================== 辅助方法 =====================  
369 -  
370 - /**  
371 - * 创建 MQTT 客户端  
372 - *  
373 - * @param authInfo 认证信息  
374 - * @return MQTT 客户端  
375 - */  
376 - private MqttClient createClient(IotDeviceAuthReqDTO authInfo) {  
377 - MqttClientOptions options = new MqttClientOptions()  
378 - .setClientId(authInfo.getClientId())  
379 - .setUsername(authInfo.getUsername())  
380 - .setPassword(authInfo.getPassword())  
381 - .setCleanSession(true)  
382 - .setKeepAliveInterval(60);  
383 - return MqttClient.create(vertx, options);  
384 - }  
385 -  
386 - /**  
387 - * 连接 EMQX Broker 并认证设备  
388 - *  
389 - * @return 已认证的 MQTT 客户端  
390 - */  
391 - private MqttClient connectToEmqx() throws Exception {  
392 - IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET);  
393 - MqttClient client = createClient(authInfo);  
394 - client.connect(MQTT_PORT, SERVER_HOST)  
395 - .toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);  
396 - return client;  
397 - }  
398 -  
399 - /**  
400 - * 订阅主题  
401 - *  
402 - * @param client MQTT 客户端  
403 - * @param topic 主题  
404 - */  
405 - private void subscribe(MqttClient client, String topic) throws Exception {  
406 - client.subscribe(topic, MqttQoS.AT_LEAST_ONCE.value())  
407 - .toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);  
408 - log.info("[subscribe][订阅主题成功: {}]", topic);  
409 - }  
410 -  
411 - /**  
412 - * 发布消息  
413 - *  
414 - * @param client MQTT 客户端  
415 - * @param topic 发布主题  
416 - * @param request 请求消息  
417 - */  
418 - private void publish(MqttClient client, String topic, IotDeviceMessage request) throws Exception {  
419 - byte[] payload = CODEC.encode(request);  
420 - log.info("[publish][发送消息: topic={}, payload={}]", topic, new String(payload));  
421 - client.publish(topic, Buffer.buffer(payload), MqttQoS.AT_LEAST_ONCE, false, false)  
422 - .toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);  
423 - log.info("[publish][消息发布成功]");  
424 - }  
425 -  
426 - /**  
427 - * 断开连接  
428 - *  
429 - * @param client MQTT 客户端  
430 - */  
431 - private void disconnect(MqttClient client) throws Exception {  
432 - client.disconnect()  
433 - .toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);  
434 - log.info("[disconnect][断开连接成功]");  
435 - }  
436 -  
437 -}  
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/package-info.java 0 → 100644
  1 +/**
  2 + * IoT 网关 EMQX 协议集成测试包
  3 + *
  4 + * <p>
  5 + * 测试类直接使用 mqtt 包下的单测即可,因为设备都是通过 MQTT 协议连接 EMQX Broker。
  6 + *
  7 + * @see cn.iocoder.yudao.module.iot.gateway.protocol.mqtt
  8 + *
  9 + * <h2>架构</h2>
  10 + * <pre>
  11 + * +--------+ MQTT +-------------+ HTTP Hook +---------+
  12 + * | 设备 | --------------> | EMQX Broker | ----------------> | 网关 |
  13 + * +--------+ +-------------+ +---------+
  14 + * </pre>
  15 + *
  16 + * @author 芋道源码
  17 + */
  18 +package cn.iocoder.yudao.module.iot.gateway.protocol.emqx;