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 1 package cn.iocoder.yudao.module.iot.gateway.config;
2 2  
3   -import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
4 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 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 5 import org.springframework.boot.context.properties.EnableConfigurationProperties;
14 6 import org.springframework.context.annotation.Bean;
15 7 import org.springframework.context.annotation.Configuration;
16 8  
17   -
18 9 @Configuration
19 10 @EnableConfigurationProperties(IotGatewayProperties.class)
20   -@Slf4j
21 11 public class IotGatewayConfiguration {
22 12  
23 13 @Bean
... ... @@ -30,36 +20,4 @@ public class IotGatewayConfiguration {
30 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 2  
3 3 import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
4 4 import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapConfig;
  5 +import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxConfig;
5 6 import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpConfig;
6 7 import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttConfig;
7 8 import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpConfig;
... ... @@ -32,11 +33,6 @@ public class IotGatewayProperties {
32 33 private TokenProperties token;
33 34  
34 35 /**
35   - * 协议配置(旧版,保持兼容)
36   - */
37   - private ProtocolProperties protocol;
38   -
39   - /**
40 36 * 协议实例列表
41 37 */
42 38 private List<ProtocolInstanceProperties> protocols;
... ... @@ -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 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 107 @NotNull(message = "服务端口不能为空")
288 108 private Integer port;
... ... @@ -292,7 +112,7 @@ public class IotGatewayProperties {
292 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 116 * 2. {@link IotProtocolTypeEnum#EMQX} 协议,目前支持根据产品(设备)配置的序列化类型来解析
297 117 */
298 118 private String serialize;
... ... @@ -304,13 +124,17 @@ public class IotGatewayProperties {
304 124 */
305 125 @Valid
306 126 private IotHttpConfig http;
  127 + /**
  128 + * WebSocket 协议配置
  129 + */
  130 + @Valid
  131 + private IotWebSocketConfig websocket;
307 132  
308 133 /**
309 134 * TCP 协议配置
310 135 */
311 136 @Valid
312 137 private IotTcpConfig tcp;
313   -
314 138 /**
315 139 * UDP 协议配置
316 140 */
... ... @@ -324,16 +148,15 @@ public class IotGatewayProperties {
324 148 private IotCoapConfig coap;
325 149  
326 150 /**
327   - * WebSocket 协议配置
  151 + * MQTT 协议配置
328 152 */
329 153 @Valid
330   - private IotWebSocketConfig websocket;
331   -
  154 + private IotMqttConfig mqtt;
332 155 /**
333   - * MQTT 协议配置
  156 + * EMQX 协议配置
334 157 */
335 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 5 import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
6 6 import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties;
7 7 import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapProtocol;
  8 +import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxProtocol;
8 9 import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpProtocol;
9 10 import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttProtocol;
10 11 import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpProtocol;
... ... @@ -109,6 +110,8 @@ public class IotProtocolManager implements SmartLifecycle {
109 110 return createWebSocketProtocol(config);
110 111 case MQTT:
111 112 return createMqttProtocol(config);
  113 + case EMQX:
  114 + return createEmqxProtocol(config);
112 115 default:
113 116 throw new IllegalArgumentException(String.format(
114 117 "[createProtocol][协议实例 %s 的协议类型 %s 暂不支持]", config.getId(), protocolType));
... ... @@ -175,4 +178,14 @@ public class IotProtocolManager implements SmartLifecycle {
175 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 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 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 5 import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO;
6 6 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
7 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 9 import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService;
10 10 import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
11 11 import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils;
... ... @@ -21,13 +21,13 @@ import lombok.extern.slf4j.Slf4j;
21 21 @Slf4j
22 22 public class IotEmqxDownstreamHandler {
23 23  
24   - private final IotEmqxUpstreamProtocol protocol;
  24 + private final IotEmqxProtocol protocol;
25 25  
26 26 private final IotDeviceService deviceService;
27 27  
28 28 private final IotDeviceMessageService deviceMessageService;
29 29  
30   - public IotEmqxDownstreamHandler(IotEmqxUpstreamProtocol protocol) {
  30 + public IotEmqxDownstreamHandler(IotEmqxProtocol protocol) {
31 31 this.protocol = protocol;
32 32 this.deviceService = SpringUtil.getBean(IotDeviceService.class);
33 33 this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class);
... ... @@ -74,4 +74,4 @@ public class IotEmqxDownstreamHandler {
74 74 return IotMqttTopicUtils.buildTopicByMethod(message.getMethod(), productKey, deviceName, isReply);
75 75 }
76 76  
77   -}
78 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 3 import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
4 4 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
5 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 7 import lombok.extern.slf4j.Slf4j;
10 8  
11 9 /**
... ... @@ -18,22 +16,11 @@ public class IotEmqxDownstreamSubscriber extends IotProtocolDownstreamSubscriber
18 16  
19 17 private final IotEmqxDownstreamHandler downstreamHandler;
20 18  
21   - public IotEmqxDownstreamSubscriber(IotEmqxUpstreamProtocol protocol, IotMessageBus messageBus) {
  19 + public IotEmqxDownstreamSubscriber(IotEmqxProtocol protocol, IotMessageBus messageBus) {
22 20 super(protocol, messageBus);
23 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 24 @Override
38 25 protected void handleMessage(IotDeviceMessage message) {
39 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 10 import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
11 11 import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
12 12 import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
  13 +import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils;
13 14 import io.vertx.core.json.JsonObject;
14 15 import io.vertx.ext.web.RoutingContext;
15 16 import lombok.extern.slf4j.Slf4j;
16 17  
  18 +import java.util.Locale;
  19 +
17 20 /**
18 21 * IoT 网关 EMQX 认证事件处理器
19 22 * <p>
20 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 28 * @author 芋道源码
25 29 */
... ... @@ -45,15 +49,17 @@ public class IotEmqxAuthEventHandler {
45 49 private static final String RESULT_IGNORE = "ignore";
46 50  
47 51 /**
48   - * EMQX 事件类型常量
  52 + * EMQX 事件类型常量 - 客户端连接
49 53 */
50 54 private static final String EVENT_CLIENT_CONNECTED = "client.connected";
  55 + /**
  56 + * EMQX 事件类型常量 - 客户端断开连接
  57 + */
51 58 private static final String EVENT_CLIENT_DISCONNECTED = "client.disconnected";
52 59  
53 60 private final String serverId;
54 61  
55 62 private final IotDeviceMessageService deviceMessageService;
56   -
57 63 private final IotDeviceCommonApi deviceApi;
58 64  
59 65 public IotEmqxAuthEventHandler(String serverId) {
... ... @@ -62,13 +68,16 @@ public class IotEmqxAuthEventHandler {
62 68 this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class);
63 69 }
64 70  
  71 + // ========== 认证处理 ==========
  72 +
65 73 /**
66 74 * EMQX 认证接口
67 75 */
68 76 public void handleAuth(RoutingContext context) {
  77 + JsonObject body = null;
69 78 try {
70 79 // 1. 参数校验
71   - JsonObject body = parseRequestBody(context);
  80 + body = parseRequestBody(context);
72 81 if (body == null) {
73 82 return;
74 83 }
... ... @@ -91,11 +100,179 @@ public class IotEmqxAuthEventHandler {
91 100 sendAuthResponse(context, RESULT_DENY);
92 101 }
93 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 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 277 * EMQX 统一事件处理接口:根据 EMQX 官方 Webhook 设计,统一处理所有客户端事件
101 278 * 支持的事件类型:client.connected、client.disconnected 等
... ... @@ -124,59 +301,16 @@ public class IotEmqxAuthEventHandler {
124 301 break;
125 302 }
126 303  
127   - // EMQX Webhook 只需要 200 状态码,无需响应体
  304 + // 3. EMQX Webhook 只需要 200 状态码,无需响应体
128 305 context.response().setStatusCode(SUCCESS_STATUS_CODE).end();
129 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 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 315 * <p>
182 316 * 事件接口解析失败时仅返回 200 状态码,无响应体(符合 EMQX Webhook 规范)
... ... @@ -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 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 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 3 import cn.hutool.core.util.StrUtil;
4 4 import cn.hutool.extra.spring.SpringUtil;
5 5 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
6   -import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxUpstreamProtocol;
7 6 import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
8 7 import io.vertx.mqtt.messages.MqttPublishMessage;
9 8 import lombok.extern.slf4j.Slf4j;
... ... @@ -20,16 +19,16 @@ public class IotEmqxUpstreamHandler {
20 19  
21 20 private final String serverId;
22 21  
23   - public IotEmqxUpstreamHandler(IotEmqxUpstreamProtocol protocol) {
  22 + public IotEmqxUpstreamHandler(String serverId) {
24 23 this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class);
25   - this.serverId = protocol.getServerId();
  24 + this.serverId = serverId;
26 25 }
27 26  
28 27 /**
29 28 * 处理 MQTT 发布消息
30 29 */
31 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 32 String topic = mqttMessage.topicName();
34 33 byte[] payload = mqttMessage.payload().getBytes();
35 34 try {
... ... @@ -57,4 +56,4 @@ public class IotEmqxUpstreamHandler {
57 56 }
58 57 }
59 58  
60   -}
61 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 38 */
39 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 91 || topic.equals(SYS_TOPIC_PREFIX + productKey + "/" + deviceName + "/#");
86 92 }
87 93  
88   -}
89 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 125 max-message-size: 8192 # 最大消息大小(字节)
126 126 connect-timeout-seconds: 60 # 连接超时时间(秒)
127 127 ssl-enabled: false # 是否启用 SSL
128   -
129   - # 协议配置(旧版,保持兼容)
130   - protocol:
131 128 # ====================================
132 129 # 针对引入的 EMQX 组件的配置
133 130 # ====================================
134   - emqx:
  131 + - id: emqx-1
135 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 187 cn.iocoder.yudao.module.iot.gateway.protocol.emqx: DEBUG
182 188 cn.iocoder.yudao.module.iot.gateway.protocol.http: DEBUG
183 189 cn.iocoder.yudao.module.iot.gateway.protocol.mqtt: DEBUG
184   - cn.iocoder.yudao.module.iot.gateway.protocol.mqttws: DEBUG
185 190 cn.iocoder.yudao.module.iot.gateway.protocol.coap: DEBUG
186 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;
... ...