Commit 8b4bee69f2d66e01e6d7abe614da22c5df810a45
1 parent
d70c6986
feat:【IoT 物联网】优化 MQTT 连接逻辑,更新 HTTP 认证端口,简化消息处理流程
Showing
6 changed files
with
178 additions
and
214 deletions
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayProperties.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.gateway.config; |
| 2 | 2 | |
| 3 | -import cn.hutool.core.util.StrUtil; | |
| 4 | 3 | import jakarta.validation.constraints.NotEmpty; |
| 5 | 4 | import jakarta.validation.constraints.NotNull; |
| 6 | 5 | import lombok.Data; |
| ... | ... | @@ -105,46 +104,53 @@ public class IotGatewayProperties { |
| 105 | 104 | @NotNull(message = "是否开启不能为空") |
| 106 | 105 | private Boolean enabled; |
| 107 | 106 | |
| 108 | - // TODO @haohao:是不是改成 httpPort?不只认证,目前看。 | |
| 109 | 107 | /** |
| 110 | - * HTTP 认证端口(默认:8090) | |
| 108 | + * HTTP 服务端口(默认:8090) | |
| 111 | 109 | */ |
| 112 | - private Integer httpAuthPort = 8090; | |
| 110 | + private Integer httpPort = 8090; | |
| 113 | 111 | |
| 114 | 112 | /** |
| 115 | 113 | * MQTT 服务器地址 |
| 116 | 114 | */ |
| 117 | 115 | @NotEmpty(message = "MQTT 服务器地址不能为空") |
| 118 | 116 | private String mqttHost; |
| 117 | + | |
| 119 | 118 | /** |
| 120 | 119 | * MQTT 服务器端口(默认:1883) |
| 121 | 120 | */ |
| 122 | 121 | @NotNull(message = "MQTT 服务器端口不能为空") |
| 123 | 122 | private Integer mqttPort = 1883; |
| 123 | + | |
| 124 | 124 | /** |
| 125 | 125 | * MQTT 用户名 |
| 126 | 126 | */ |
| 127 | 127 | @NotEmpty(message = "MQTT 用户名不能为空") |
| 128 | 128 | private String mqttUsername; |
| 129 | + | |
| 129 | 130 | /** |
| 130 | 131 | * MQTT 密码 |
| 131 | 132 | */ |
| 132 | 133 | @NotEmpty(message = "MQTT 密码不能为空") |
| 133 | 134 | private String mqttPassword; |
| 135 | + | |
| 134 | 136 | /** |
| 135 | 137 | * MQTT 客户端的 SSL 开关 |
| 136 | 138 | */ |
| 137 | 139 | @NotNull(message = "MQTT 是否开启 SSL 不能为空") |
| 138 | 140 | private Boolean mqttSsl = false; |
| 141 | + | |
| 139 | 142 | /** |
| 140 | 143 | * MQTT 客户端 ID(如果为空,系统将自动生成) |
| 141 | 144 | */ |
| 145 | + @NotEmpty(message = "MQTT 客户端 ID 不能为空") | |
| 142 | 146 | private String mqttClientId; |
| 147 | + | |
| 143 | 148 | /** |
| 144 | 149 | * MQTT 订阅的主题 |
| 145 | 150 | */ |
| 146 | 151 | @NotEmpty(message = "MQTT 主题不能为空") |
| 147 | 152 | private List<@NotEmpty(message = "MQTT 主题不能为空") String> mqttTopics; |
| 153 | + | |
| 148 | 154 | /** |
| 149 | 155 | * 默认 QoS 级别 |
| 150 | 156 | * <p> |
| ... | ... | @@ -158,24 +164,12 @@ public class IotGatewayProperties { |
| 158 | 164 | * 连接超时时间(秒) |
| 159 | 165 | */ |
| 160 | 166 | private Integer connectTimeoutSeconds = 10; |
| 167 | + | |
| 161 | 168 | /** |
| 162 | 169 | * 重连延迟时间(毫秒) |
| 163 | 170 | */ |
| 164 | 171 | private Long reconnectDelayMs = 5000L; |
| 165 | 172 | |
| 166 | - // TODO @haohao:貌似可以通过配置文件 + el 表达式;尽量还是配置文件; | |
| 167 | - /** | |
| 168 | - * 获取 MQTT 客户端 ID,如果未配置则自动生成 | |
| 169 | - * | |
| 170 | - * @return MQTT 客户端 ID | |
| 171 | - */ | |
| 172 | - public String getMqttClientId() { | |
| 173 | - if (StrUtil.isBlank(mqttClientId)) { | |
| 174 | - mqttClientId = "iot-gateway-mqtt-" + System.currentTimeMillis(); | |
| 175 | - } | |
| 176 | - return mqttClientId; | |
| 177 | - } | |
| 178 | - | |
| 179 | 173 | } |
| 180 | 174 | |
| 181 | 175 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotMqttUpstreamProtocol.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt; |
| 2 | 2 | |
| 3 | 3 | import cn.hutool.core.collection.CollUtil; |
| 4 | -import cn.hutool.core.util.StrUtil; | |
| 5 | 4 | import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; |
| 6 | 5 | import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; |
| 7 | 6 | import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.router.IotMqttHttpAuthHandler; |
| ... | ... | @@ -20,7 +19,10 @@ import jakarta.annotation.PreDestroy; |
| 20 | 19 | import lombok.Getter; |
| 21 | 20 | import lombok.extern.slf4j.Slf4j; |
| 22 | 21 | |
| 22 | +import java.util.HashMap; | |
| 23 | 23 | import java.util.List; |
| 24 | +import java.util.Map; | |
| 25 | +import java.util.concurrent.atomic.AtomicInteger; | |
| 24 | 26 | |
| 25 | 27 | /** |
| 26 | 28 | * IoT 网关 MQTT 协议:接收设备上行消息 |
| ... | ... | @@ -127,7 +129,7 @@ public class IotMqttUpstreamProtocol { |
| 127 | 129 | router.post(IotMqttTopicUtils.MQTT_EVENT_PATH).handler(authHandler::handleEvent); |
| 128 | 130 | |
| 129 | 131 | // 2. 启动 HTTP 服务器 |
| 130 | - int authPort = emqxProperties.getHttpAuthPort(); | |
| 132 | + int authPort = emqxProperties.getHttpPort(); | |
| 131 | 133 | try { |
| 132 | 134 | httpAuthServer = vertx.createHttpServer() |
| 133 | 135 | .requestHandler(router) |
| ... | ... | @@ -169,10 +171,13 @@ public class IotMqttUpstreamProtocol { |
| 169 | 171 | log.info("[startMqttClient][使用 MQTT 客户端 ID: {}]", emqxProperties.getMqttClientId()); |
| 170 | 172 | createMqttClient(); |
| 171 | 173 | |
| 172 | - // 3. 连接 MQTT Broker(异步连接,不会抛出异常) | |
| 173 | - connectMqtt(false); | |
| 174 | + // 3. 连接 MQTT Broker(同步等待首次连接结果) | |
| 175 | + boolean connected = connectMqttSync(); | |
| 176 | + if (!connected) { | |
| 177 | + throw new RuntimeException("首次连接 MQTT Broker 失败"); | |
| 178 | + } | |
| 174 | 179 | |
| 175 | - log.info("[startMqttClient][MQTT 客户端启动完成,正在异步连接中...]"); | |
| 180 | + log.info("[startMqttClient][MQTT 客户端启动完成]"); | |
| 176 | 181 | } catch (Exception e) { |
| 177 | 182 | log.error("[startMqttClient][MQTT 客户端启动失败]", e); |
| 178 | 183 | throw new RuntimeException("MQTT 客户端启动失败", e); |
| ... | ... | @@ -180,6 +185,48 @@ public class IotMqttUpstreamProtocol { |
| 180 | 185 | } |
| 181 | 186 | |
| 182 | 187 | /** |
| 188 | + * 同步连接 MQTT Broker | |
| 189 | + * | |
| 190 | + * @return 是否连接成功 | |
| 191 | + */ | |
| 192 | + private boolean connectMqttSync() { | |
| 193 | + String host = emqxProperties.getMqttHost(); | |
| 194 | + Integer port = emqxProperties.getMqttPort(); | |
| 195 | + log.info("[connectMqttSync][开始连接 MQTT Broker, host: {}, port: {}]", host, port); | |
| 196 | + | |
| 197 | + // 使用计数器实现同步等待 | |
| 198 | + java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1); | |
| 199 | + java.util.concurrent.atomic.AtomicBoolean success = new java.util.concurrent.atomic.AtomicBoolean(false); | |
| 200 | + | |
| 201 | + mqttClient.connect(port, host, connectResult -> { | |
| 202 | + if (connectResult.succeeded()) { | |
| 203 | + log.info("[connectMqttSync][MQTT 客户端连接成功, host: {}, port: {}]", host, port); | |
| 204 | + // 设置处理器 | |
| 205 | + setupMqttHandlers(); | |
| 206 | + // 订阅主题 | |
| 207 | + subscribeToTopics(); | |
| 208 | + success.set(true); | |
| 209 | + } else { | |
| 210 | + log.error("[connectMqttSync][连接 MQTT Broker 失败, host: {}, port: {}]", | |
| 211 | + host, port, connectResult.cause()); | |
| 212 | + // 首次连接失败,启动重连机制 | |
| 213 | + reconnectWithDelay(); | |
| 214 | + } | |
| 215 | + latch.countDown(); | |
| 216 | + }); | |
| 217 | + | |
| 218 | + try { | |
| 219 | + // 等待连接结果,最多等待10秒 | |
| 220 | + latch.await(10, java.util.concurrent.TimeUnit.SECONDS); | |
| 221 | + } catch (InterruptedException e) { | |
| 222 | + Thread.currentThread().interrupt(); | |
| 223 | + log.error("[connectMqttSync][等待连接结果被中断]", e); | |
| 224 | + } | |
| 225 | + | |
| 226 | + return success.get(); | |
| 227 | + } | |
| 228 | + | |
| 229 | + /** | |
| 183 | 230 | * 停止 MQTT 客户端 |
| 184 | 231 | */ |
| 185 | 232 | private void stopMqttClient() { |
| ... | ... | @@ -218,15 +265,6 @@ public class IotMqttUpstreamProtocol { |
| 218 | 265 | // 1. 参数校验 |
| 219 | 266 | String host = emqxProperties.getMqttHost(); |
| 220 | 267 | Integer port = emqxProperties.getMqttPort(); |
| 221 | - // TODO @haohao:这些参数校验,交给 validator; | |
| 222 | - if (StrUtil.isBlank(host)) { | |
| 223 | - log.error("[connectMqtt][MQTT Host 为空, 无法连接]"); | |
| 224 | - throw new IllegalArgumentException("MQTT Host 不能为空"); | |
| 225 | - } | |
| 226 | - if (port == null || port <= 0) { | |
| 227 | - log.error("[connectMqtt][MQTT Port({}) 无效]", port); | |
| 228 | - throw new IllegalArgumentException("MQTT Port 必须为正整数"); | |
| 229 | - } | |
| 230 | 268 | |
| 231 | 269 | if (isReconnect) { |
| 232 | 270 | log.info("[connectMqtt][开始重连 MQTT Broker, host: {}, port: {}]", host, port); |
| ... | ... | @@ -238,32 +276,28 @@ public class IotMqttUpstreamProtocol { |
| 238 | 276 | |
| 239 | 277 | // 2. 异步连接 |
| 240 | 278 | mqttClient.connect(port, host, connectResult -> { |
| 241 | - // TODO @haohao:if return,减少括号哈; | |
| 242 | - if (connectResult.succeeded()) { | |
| 243 | - if (isReconnect) { | |
| 244 | - log.info("[connectMqtt][MQTT 客户端重连成功, host: {}, port: {}]", host, port); | |
| 245 | - } else { | |
| 246 | - log.info("[connectMqtt][MQTT 客户端连接成功, host: {}, port: {}]", host, port); | |
| 247 | - } | |
| 248 | - | |
| 249 | - // 设置处理器 | |
| 250 | - setupMqttHandlers(); | |
| 251 | - // 订阅主题 | |
| 252 | - subscribeToTopics(); | |
| 253 | - } else { | |
| 279 | + if (!connectResult.succeeded()) { | |
| 254 | 280 | log.error("[connectMqtt][连接 MQTT Broker 失败, host: {}, port: {}, isReconnect: {}]", |
| 255 | 281 | host, port, isReconnect, connectResult.cause()); |
| 256 | 282 | |
| 257 | - // TODO @haohao:体感上,是不是首次必须连接成功?类似 mysql;首次要连接上,然后后续可以重连; | |
| 283 | + // 首次连接失败或重连失败时,尝试重连 | |
| 258 | 284 | if (!isReconnect) { |
| 259 | - // 首次连接失败时,也要尝试重连 | |
| 260 | 285 | log.warn("[connectMqtt][首次连接失败,将开始重连机制]"); |
| 261 | - reconnectWithDelay(); | |
| 262 | - } else { | |
| 263 | - // 重连失败时,继续尝试重连 | |
| 264 | - reconnectWithDelay(); | |
| 265 | 286 | } |
| 287 | + reconnectWithDelay(); | |
| 288 | + return; | |
| 289 | + } | |
| 290 | + | |
| 291 | + if (isReconnect) { | |
| 292 | + log.info("[connectMqtt][MQTT 客户端重连成功, host: {}, port: {}]", host, port); | |
| 293 | + } else { | |
| 294 | + log.info("[connectMqtt][MQTT 客户端连接成功, host: {}, port: {}]", host, port); | |
| 266 | 295 | } |
| 296 | + | |
| 297 | + // 设置处理器 | |
| 298 | + setupMqttHandlers(); | |
| 299 | + // 订阅主题 | |
| 300 | + subscribeToTopics(); | |
| 267 | 301 | }); |
| 268 | 302 | } |
| 269 | 303 | |
| ... | ... | @@ -283,12 +317,7 @@ public class IotMqttUpstreamProtocol { |
| 283 | 317 | * 设置 MQTT 处理器 |
| 284 | 318 | */ |
| 285 | 319 | private void setupMqttHandlers() { |
| 286 | - // TODO @haohao:mqttClient 一定非空; | |
| 287 | - if (mqttClient == null) { | |
| 288 | - log.warn("[setupMqttHandlers][MQTT 客户端为空,跳过处理器设置]"); | |
| 289 | - return; | |
| 290 | - } | |
| 291 | - | |
| 320 | + // 由于 mqttClient 在 createMqttClient() 方法中已初始化,此处无需检查 | |
| 292 | 321 | // 设置断开重连监听器 |
| 293 | 322 | mqttClient.closeHandler(closeEvent -> { |
| 294 | 323 | log.warn("[closeHandler][MQTT 连接已断开, 准备重连]"); |
| ... | ... | @@ -301,13 +330,9 @@ public class IotMqttUpstreamProtocol { |
| 301 | 330 | }); |
| 302 | 331 | |
| 303 | 332 | // 设置消息处理器 |
| 304 | - // TODO @haohao:upstreamHandler 一定非空; | |
| 305 | - if (upstreamHandler != null) { | |
| 306 | - mqttClient.publishHandler(upstreamHandler::handle); | |
| 307 | - log.debug("[setupMqttHandlers][MQTT 消息处理器设置完成]"); | |
| 308 | - } else { | |
| 309 | - log.warn("[setupMqttHandlers][上行消息处理器为空,跳过设置]"); | |
| 310 | - } | |
| 333 | + // upstreamHandler 在 startMqttClient() 方法中已初始化,此处无需检查 | |
| 334 | + mqttClient.publishHandler(upstreamHandler::handle); | |
| 335 | + log.debug("[setupMqttHandlers][MQTT 消息处理器设置完成]"); | |
| 311 | 336 | } |
| 312 | 337 | |
| 313 | 338 | /** |
| ... | ... | @@ -327,35 +352,39 @@ public class IotMqttUpstreamProtocol { |
| 327 | 352 | int qos = emqxProperties.getMqttQos(); |
| 328 | 353 | log.info("[subscribeToTopics][开始订阅主题, 共 {} 个, QoS: {}]", topicList.size(), qos); |
| 329 | 354 | |
| 330 | - // TODO @haohao:使用 atomicinteger 会更合适; | |
| 331 | - int[] successCount = { 0 }; // 使用数组以便在 lambda 中修改 | |
| 332 | - int[] failCount = { 0 }; | |
| 355 | + // 使用 AtomicInteger 替代数组,线程安全且更简洁 | |
| 356 | + AtomicInteger successCount = new AtomicInteger(0); | |
| 357 | + AtomicInteger failCount = new AtomicInteger(0); | |
| 333 | 358 | |
| 359 | + // 构建主题-QoS 映射,批量订阅 | |
| 360 | + Map<String, Integer> topicQosMap = new HashMap<>(); | |
| 334 | 361 | for (String topic : topicList) { |
| 335 | - // TODO @haohao:MqttClient subscribe(Map<String, Integer> topics, 是不是更简洁哈; | |
| 336 | - mqttClient.subscribe(topic, qos, subscribeResult -> { | |
| 337 | - if (subscribeResult.succeeded()) { | |
| 338 | - successCount[0]++; | |
| 339 | - log.debug("[subscribeToTopics][订阅主题成功, topic: {}, qos: {}]", topic, qos); | |
| 362 | + topicQosMap.put(topic, qos); | |
| 363 | + } | |
| 340 | 364 | |
| 341 | - // 当所有主题都处理完成时,记录汇总日志 | |
| 342 | - if (successCount[0] + failCount[0] == topicList.size()) { | |
| 343 | - log.info("[subscribeToTopics][主题订阅完成, 成功: {}, 失败: {}, 总计: {}]", | |
| 344 | - successCount[0], failCount[0], topicList.size()); | |
| 345 | - } | |
| 346 | - } else { | |
| 347 | - failCount[0]++; | |
| 348 | - log.error("[subscribeToTopics][订阅主题失败, topic: {}, qos: {}, 原因: {}]", | |
| 349 | - topic, qos, subscribeResult.cause().getMessage(), subscribeResult.cause()); | |
| 350 | - | |
| 351 | - // 当所有主题都处理完成时,记录汇总日志 | |
| 352 | - if (successCount[0] + failCount[0] == topicList.size()) { | |
| 353 | - log.info("[subscribeToTopics][主题订阅完成, 成功: {}, 失败: {}, 总计: {}]", | |
| 354 | - successCount[0], failCount[0], topicList.size()); | |
| 355 | - } | |
| 365 | + // 批量订阅所有主题 | |
| 366 | + mqttClient.subscribe(topicQosMap, subscribeResult -> { | |
| 367 | + if (subscribeResult.succeeded()) { | |
| 368 | + // 批量订阅成功,记录所有主题为成功 | |
| 369 | + int successful = successCount.addAndGet(topicList.size()); | |
| 370 | + log.info("[subscribeToTopics][批量订阅主题成功, 共 {} 个主题, QoS: {}]", successful, qos); | |
| 371 | + for (String topic : topicList) { | |
| 372 | + log.debug("[subscribeToTopics][订阅主题成功, topic: {}, qos: {}]", topic, qos); | |
| 356 | 373 | } |
| 357 | - }); | |
| 358 | - } | |
| 374 | + } else { | |
| 375 | + // 批量订阅失败,记录所有主题为失败 | |
| 376 | + int failed = failCount.addAndGet(topicList.size()); | |
| 377 | + log.error("[subscribeToTopics][批量订阅主题失败, 共 {} 个主题, 原因: {}]", | |
| 378 | + failed, subscribeResult.cause().getMessage(), subscribeResult.cause()); | |
| 379 | + for (String topic : topicList) { | |
| 380 | + log.error("[subscribeToTopics][订阅主题失败, topic: {}, qos: {}]", topic, qos); | |
| 381 | + } | |
| 382 | + } | |
| 383 | + | |
| 384 | + // 记录汇总日志 | |
| 385 | + log.info("[subscribeToTopics][主题订阅完成, 成功: {}, 失败: {}, 总计: {}]", | |
| 386 | + successCount.get(), failCount.get(), topicList.size()); | |
| 387 | + }); | |
| 359 | 388 | } |
| 360 | 389 | |
| 361 | 390 | /** | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/router/IotMqttDownstreamHandler.java
| ... | ... | @@ -2,10 +2,10 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.router; |
| 2 | 2 | |
| 3 | 3 | import cn.hutool.core.util.StrUtil; |
| 4 | 4 | import cn.hutool.extra.spring.SpringUtil; |
| 5 | -import cn.hutool.json.JSONObject; | |
| 6 | 5 | import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; |
| 7 | 6 | import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; |
| 8 | 7 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 8 | +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; | |
| 9 | 9 | import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttUpstreamProtocol; |
| 10 | 10 | import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; |
| 11 | 11 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| ... | ... | @@ -48,52 +48,49 @@ public class IotMqttDownstreamHandler { |
| 48 | 48 | } |
| 49 | 49 | |
| 50 | 50 | // 2.1 根据方法构建主题 |
| 51 | - String topic = buildTopicByMethod(message.getMethod(), deviceInfo.getProductKey(), deviceInfo.getDeviceName()); | |
| 51 | + String topic = buildTopicByMethod(message, deviceInfo.getProductKey(), deviceInfo.getDeviceName()); | |
| 52 | 52 | if (StrUtil.isBlank(topic)) { |
| 53 | 53 | log.warn("[handle][未知的消息方法: {}]", message.getMethod()); |
| 54 | 54 | return; |
| 55 | 55 | } |
| 56 | + | |
| 56 | 57 | // 2.2 构建载荷 |
| 57 | - // TODO @haohao:这里是不是 encode 就可以发拉?因为本身就 json 化了。 | |
| 58 | - JSONObject payload = buildDownstreamPayload(message); | |
| 58 | + byte[] payload = deviceMessageService.encodeDeviceMessage(message, deviceInfo.getProductKey(), deviceInfo.getDeviceName()); | |
| 59 | + | |
| 59 | 60 | // 2.3 发布消息 |
| 60 | - protocol.publishMessage(topic, payload.toString()); | |
| 61 | + protocol.publishMessage(topic, new String(payload)); | |
| 61 | 62 | } |
| 62 | 63 | |
| 63 | - // TODO @haohao:这个是不是也可以计算;IotDeviceMessageUtils 的 isReplyMessage;这样就直接生成了; | |
| 64 | 64 | /** |
| 65 | - * 根据方法构建主题 | |
| 65 | + * 根据消息方法和回复状态构建主题 | |
| 66 | 66 | * |
| 67 | - * @param method 消息方法 | |
| 67 | + * @param message 设备消息 | |
| 68 | 68 | * @param productKey 产品标识 |
| 69 | 69 | * @param deviceName 设备名称 |
| 70 | 70 | * @return 构建的主题,如果方法不支持返回 null |
| 71 | 71 | */ |
| 72 | - private String buildTopicByMethod(String method, String productKey, String deviceName) { | |
| 73 | - IotDeviceMessageMethodEnum methodEnum = IotDeviceMessageMethodEnum.of(method); | |
| 72 | + private String buildTopicByMethod(IotDeviceMessage message, String productKey, String deviceName) { | |
| 73 | + // 1. 解析消息方法 | |
| 74 | + IotDeviceMessageMethodEnum methodEnum = IotDeviceMessageMethodEnum.of(message.getMethod()); | |
| 74 | 75 | if (methodEnum == null) { |
| 76 | + log.warn("[buildTopicByMethod][未知的消息方法: {}]", message.getMethod()); | |
| 75 | 77 | return null; |
| 76 | 78 | } |
| 77 | - return switch (methodEnum) { | |
| 78 | - case PROPERTY_POST -> IotMqttTopicUtils.buildPropertyPostReplyTopic(productKey, deviceName); | |
| 79 | - case PROPERTY_SET -> IotMqttTopicUtils.buildPropertySetTopic(productKey, deviceName); | |
| 80 | - default -> null; | |
| 81 | - }; | |
| 82 | 79 | |
| 83 | - } | |
| 80 | + // 2. 判断是否回复消息 | |
| 81 | + boolean isReply = IotDeviceMessageUtils.isReplyMessage(message); | |
| 84 | 82 | |
| 85 | - /** | |
| 86 | - * 构建下行消息载荷 | |
| 87 | - * | |
| 88 | - * @param message 设备消息 | |
| 89 | - * @return JSON 载荷 | |
| 90 | - */ | |
| 91 | - private JSONObject buildDownstreamPayload(IotDeviceMessage message) { | |
| 92 | - // 使用 IotDeviceMessageService 进行消息编码 | |
| 93 | - IotDeviceRespDTO device = deviceService.getDeviceFromCache(message.getDeviceId()); | |
| 94 | - byte[] encodedBytes = deviceMessageService.encodeDeviceMessage(message, device.getProductKey(), | |
| 95 | - device.getDeviceName()); | |
| 96 | - return new JSONObject(new String(encodedBytes)); | |
| 83 | + // 3. 根据消息方法和回复状态,构建主题 | |
| 84 | + if (methodEnum == IotDeviceMessageMethodEnum.PROPERTY_POST && isReply) { | |
| 85 | + return IotMqttTopicUtils.buildPropertyPostReplyTopic(productKey, deviceName); | |
| 86 | + } | |
| 87 | + if (methodEnum == IotDeviceMessageMethodEnum.PROPERTY_SET && !isReply) { | |
| 88 | + return IotMqttTopicUtils.buildPropertySetTopic(productKey, deviceName); | |
| 89 | + } | |
| 90 | + | |
| 91 | + log.warn("[buildTopicByMethod][暂时不支持的下行消息: method={}, isReply={}]", | |
| 92 | + message.getMethod(), isReply); | |
| 93 | + return null; | |
| 97 | 94 | } |
| 98 | 95 | |
| 99 | 96 | } |
| 100 | 97 | \ No newline at end of file | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/router/IotMqttHttpAuthHandler.java
| ... | ... | @@ -14,8 +14,6 @@ import io.vertx.core.json.JsonObject; |
| 14 | 14 | import io.vertx.ext.web.RoutingContext; |
| 15 | 15 | import lombok.extern.slf4j.Slf4j; |
| 16 | 16 | |
| 17 | -import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_AUTH_FAIL; | |
| 18 | - | |
| 19 | 17 | /** |
| 20 | 18 | * IoT 网关 MQTT HTTP 认证处理器 |
| 21 | 19 | * <p> |
| ... | ... | @@ -67,7 +65,7 @@ public class IotMqttHttpAuthHandler { |
| 67 | 65 | */ |
| 68 | 66 | public void handleAuth(RoutingContext context) { |
| 69 | 67 | try { |
| 70 | - // 参数校验 | |
| 68 | + // 1. 参数校验 | |
| 71 | 69 | JsonObject body = parseRequestBody(context); |
| 72 | 70 | if (body == null) { |
| 73 | 71 | return; |
| ... | ... | @@ -78,23 +76,21 @@ public class IotMqttHttpAuthHandler { |
| 78 | 76 | log.debug("[handleAuth][设备认证请求: clientId={}, username={}]", clientId, username); |
| 79 | 77 | if (StrUtil.hasEmpty(clientId, username, password)) { |
| 80 | 78 | log.info("[handleAuth][认证参数不完整: clientId={}, username={}]", clientId, username); |
| 81 | - sendAuthResponse(context, RESULT_DENY, false, "认证参数不完整"); | |
| 79 | + sendAuthResponse(context, RESULT_DENY); | |
| 82 | 80 | return; |
| 83 | 81 | } |
| 84 | 82 | |
| 85 | - // 执行设备认证 | |
| 83 | + // 2. 执行认证 | |
| 86 | 84 | boolean authResult = performDeviceAuth(clientId, username, password); |
| 85 | + log.info("[handleAuth][设备认证结果: {} -> {}]", username, authResult); | |
| 87 | 86 | if (authResult) { |
| 88 | - // TODO @haohao:是不是两条 info,直接打认证结果:authResult | |
| 89 | - log.info("[handleAuth][设备认证成功: {}]", username); | |
| 90 | - sendAuthResponse(context, RESULT_ALLOW, false, null); | |
| 87 | + sendAuthResponse(context, RESULT_ALLOW); | |
| 91 | 88 | } else { |
| 92 | - log.info("[handleAuth][设备认证失败: {}]", username); | |
| 93 | - sendAuthResponse(context, RESULT_DENY, false, DEVICE_AUTH_FAIL.getMsg()); | |
| 89 | + sendAuthResponse(context, RESULT_DENY); | |
| 94 | 90 | } |
| 95 | 91 | } catch (Exception e) { |
| 96 | 92 | log.error("[handleAuth][设备认证异常]", e); |
| 97 | - sendAuthResponse(context, RESULT_IGNORE, false, "认证服务异常"); | |
| 93 | + sendAuthResponse(context, RESULT_IGNORE); | |
| 98 | 94 | } |
| 99 | 95 | } |
| 100 | 96 | |
| ... | ... | @@ -104,9 +100,10 @@ public class IotMqttHttpAuthHandler { |
| 104 | 100 | * 支持的事件类型:client.connected、client.disconnected 等 |
| 105 | 101 | */ |
| 106 | 102 | public void handleEvent(RoutingContext context) { |
| 103 | + JsonObject body = null; | |
| 107 | 104 | try { |
| 108 | - // 解析请求体 | |
| 109 | - JsonObject body = parseRequestBody(context); | |
| 105 | + // 1. 解析请求体 | |
| 106 | + body = parseRequestBody(context); | |
| 110 | 107 | if (body == null) { |
| 111 | 108 | return; |
| 112 | 109 | } |
| ... | ... | @@ -114,7 +111,7 @@ public class IotMqttHttpAuthHandler { |
| 114 | 111 | String username = body.getString("username"); |
| 115 | 112 | log.debug("[handleEvent][收到事件: {} - {}]", event, username); |
| 116 | 113 | |
| 117 | - // 根据事件类型进行分发处理 | |
| 114 | + // 2. 根据事件类型进行分发处理 | |
| 118 | 115 | switch (event) { |
| 119 | 116 | case EVENT_CLIENT_CONNECTED: |
| 120 | 117 | handleClientConnected(body); |
| ... | ... | @@ -123,15 +120,13 @@ public class IotMqttHttpAuthHandler { |
| 123 | 120 | handleClientDisconnected(body); |
| 124 | 121 | break; |
| 125 | 122 | default: |
| 126 | - log.debug("[handleEvent][忽略事件: {}]", event); | |
| 127 | 123 | break; |
| 128 | 124 | } |
| 129 | 125 | |
| 130 | 126 | // EMQX Webhook 只需要 200 状态码,无需响应体 |
| 131 | 127 | context.response().setStatusCode(SUCCESS_STATUS_CODE).end(); |
| 132 | 128 | } catch (Exception e) { |
| 133 | - // TODO @haohao:body 可以打印出来 | |
| 134 | - log.error("[handleEvent][事件处理失败]", e); | |
| 129 | + log.error("[handleEvent][事件处理失败][body={}]", body != null ? body.encode() : "null", e); | |
| 135 | 130 | // 即使处理失败,也返回 200 避免EMQX重试 |
| 136 | 131 | context.response().setStatusCode(SUCCESS_STATUS_CODE).end(); |
| 137 | 132 | } |
| ... | ... | @@ -163,18 +158,19 @@ public class IotMqttHttpAuthHandler { |
| 163 | 158 | * @return 请求体JSON对象,解析失败时返回null |
| 164 | 159 | */ |
| 165 | 160 | private JsonObject parseRequestBody(RoutingContext context) { |
| 161 | + String rawBody = null; | |
| 166 | 162 | try { |
| 163 | + rawBody = context.body().asString(); | |
| 167 | 164 | JsonObject body = context.body().asJsonObject(); |
| 168 | 165 | if (body == null) { |
| 169 | - log.info("[parseRequestBody][请求体为空]"); | |
| 170 | - sendAuthResponse(context, RESULT_IGNORE, false, "请求体不能为空"); | |
| 166 | + log.info("[parseRequestBody][请求体为空][rawBody={}]", rawBody); | |
| 167 | + sendAuthResponse(context, RESULT_IGNORE); | |
| 171 | 168 | return null; |
| 172 | 169 | } |
| 173 | 170 | return body; |
| 174 | 171 | } catch (Exception e) { |
| 175 | - // TODO @haohao:最好把 body 打印出来; | |
| 176 | - log.error("[parseRequestBody][解析请求体失败]", e); | |
| 177 | - sendAuthResponse(context, RESULT_IGNORE, false, "请求体格式错误"); | |
| 172 | + log.error("[parseRequestBody][解析请求体失败][rawBody={}]", rawBody, e); | |
| 173 | + sendAuthResponse(context, RESULT_IGNORE); | |
| 178 | 174 | return null; |
| 179 | 175 | } |
| 180 | 176 | } |
| ... | ... | @@ -203,13 +199,10 @@ public class IotMqttHttpAuthHandler { |
| 203 | 199 | * 处理设备状态变化 |
| 204 | 200 | * |
| 205 | 201 | * @param username 用户名 |
| 206 | - * @param online 是否在线 | |
| 202 | + * @param online 是否在线 true 在线 false 离线 | |
| 207 | 203 | */ |
| 208 | 204 | private void handleDeviceStateChange(String username, boolean online) { |
| 209 | - // 解析设备信息 | |
| 210 | - if (StrUtil.isEmpty(username) || "undefined".equals(username)) { | |
| 211 | - return; | |
| 212 | - } | |
| 205 | + // 1. 解析设备信息 | |
| 213 | 206 | IotDeviceAuthUtils.DeviceInfo deviceInfo = IotDeviceAuthUtils.parseUsername(username); |
| 214 | 207 | if (deviceInfo == null) { |
| 215 | 208 | log.debug("[handleDeviceStateChange][跳过非设备连接: {}]", username); |
| ... | ... | @@ -217,24 +210,13 @@ public class IotMqttHttpAuthHandler { |
| 217 | 210 | } |
| 218 | 211 | |
| 219 | 212 | try { |
| 220 | - // TODO @haohao:serverId 获取非空,可以忽略掉; | |
| 221 | - String serverId = protocol.getServerId(); | |
| 222 | - if (StrUtil.isEmpty(serverId)) { | |
| 223 | - log.error("[handleDeviceStateChange][获取服务器ID失败]"); | |
| 224 | - return; | |
| 225 | - } | |
| 226 | - | |
| 227 | - // 构建设备状态消息 | |
| 213 | + // 2. 构建设备状态消息 | |
| 228 | 214 | IotDeviceMessage message = online ? IotDeviceMessage.buildStateOnline() |
| 229 | 215 | : IotDeviceMessage.buildStateOffline(); |
| 230 | - // 发送消息到消息总线 | |
| 231 | - deviceMessageService.sendDeviceMessage(message, | |
| 232 | - deviceInfo.getProductKey(), deviceInfo.getDeviceName(), serverId); | |
| 233 | 216 | |
| 234 | - // TODO @haohao:online 不用翻译 | |
| 235 | - log.info("[handleDeviceStateChange][设备状态更新: {}/{} -> {}]", | |
| 236 | - deviceInfo.getProductKey(), deviceInfo.getDeviceName(), | |
| 237 | - online ? "在线" : "离线"); | |
| 217 | + // 3. 发送设备状态消息 | |
| 218 | + deviceMessageService.sendDeviceMessage(message, | |
| 219 | + deviceInfo.getProductKey(), deviceInfo.getDeviceName(), protocol.getServerId()); | |
| 238 | 220 | } catch (Exception e) { |
| 239 | 221 | log.error("[handleDeviceStateChange][发送设备状态消息失败: {}]", username, e); |
| 240 | 222 | } |
| ... | ... | @@ -244,16 +226,14 @@ public class IotMqttHttpAuthHandler { |
| 244 | 226 | * 发送 EMQX 认证响应 |
| 245 | 227 | * 根据 EMQX 官方文档要求,必须返回 JSON 格式响应 |
| 246 | 228 | * |
| 247 | - * @param context 路由上下文 | |
| 248 | - * @param result 认证结果:allow、deny、ignore | |
| 249 | - * @param isSuperuser 是否超级用户 | |
| 250 | - * @param message 日志消息(仅用于日志记录,不返回给EMQX) | |
| 229 | + * @param context 路由上下文 | |
| 230 | + * @param result 认证结果:allow、deny、ignore | |
| 251 | 231 | */ |
| 252 | - private void sendAuthResponse(RoutingContext context, String result, boolean isSuperuser, String message) { | |
| 232 | + private void sendAuthResponse(RoutingContext context, String result) { | |
| 253 | 233 | // 构建符合 EMQX 官方规范的响应 |
| 254 | 234 | JsonObject response = new JsonObject() |
| 255 | 235 | .put("result", result) |
| 256 | - .put("is_superuser", isSuperuser); | |
| 236 | + .put("is_superuser", false); | |
| 257 | 237 | |
| 258 | 238 | // 可以根据业务需求添加客户端属性 |
| 259 | 239 | // response.put("client_attrs", new JsonObject().put("role", "device")); |
| ... | ... | @@ -261,7 +241,6 @@ public class IotMqttHttpAuthHandler { |
| 261 | 241 | // 可以添加认证过期时间(可选) |
| 262 | 242 | // response.put("expire_at", System.currentTimeMillis() / 1000 + 3600); |
| 263 | 243 | |
| 264 | - // 记录详细的响应日志(message仅用于日志,不返回给EMQX) | |
| 265 | 244 | context.response() |
| 266 | 245 | .setStatusCode(SUCCESS_STATUS_CODE) |
| 267 | 246 | .putHeader("Content-Type", "application/json; charset=utf-8") | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/router/IotMqttUpstreamHandler.java
| ... | ... | @@ -7,9 +7,6 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttUpstreamProtocol |
| 7 | 7 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 8 | 8 | import io.vertx.mqtt.messages.MqttPublishMessage; |
| 9 | 9 | import lombok.extern.slf4j.Slf4j; |
| 10 | -import org.springframework.util.Assert; | |
| 11 | - | |
| 12 | -import java.util.Arrays; | |
| 13 | 10 | |
| 14 | 11 | /** |
| 15 | 12 | * IoT 网关 MQTT 上行消息处理器 |
| ... | ... | @@ -32,38 +29,24 @@ public class IotMqttUpstreamHandler { |
| 32 | 29 | * 处理 MQTT 发布消息 |
| 33 | 30 | */ |
| 34 | 31 | public void handle(MqttPublishMessage mqttMessage) { |
| 32 | + log.info("[handle][收到 MQTT 消息, topic: {}, payload: {}]", mqttMessage.topicName(), mqttMessage.payload()); | |
| 35 | 33 | String topic = mqttMessage.topicName(); |
| 36 | 34 | byte[] payload = mqttMessage.payload().getBytes(); |
| 37 | 35 | try { |
| 38 | - // 1. 前置校验 | |
| 39 | - if (StrUtil.isBlank(topic)) { | |
| 40 | - log.warn("[handle][主题为空, 忽略消息]"); | |
| 41 | - return; | |
| 42 | - } | |
| 43 | - | |
| 44 | - // 2.1 识别并验证消息类型 | |
| 45 | - String messageType = getMessageType(topic); | |
| 46 | - // TODO @haohao:可以使用 hutool 的,它的字符串拼接更简单; | |
| 47 | - Assert.notNull(messageType, String.format("未知的消息类型, topic(%s)", topic)); | |
| 48 | - // 2.2 解析主题,获取 productKey 和 deviceName | |
| 49 | - // TODO @haohao:体感 getMessageType 和下面,都 split;是不是一次就 ok 拉;1)split 掉;2)2、3 位置是 productKey、deviceName;3)4 开始还是 method | |
| 36 | + // 1. 解析主题,一次性获取所有信息 | |
| 50 | 37 | String[] topicParts = topic.split("/"); |
| 51 | - if (topicParts.length < 4) { | |
| 52 | - log.warn("[handle][topic({}) 格式不正确,无法解析 productKey 和 deviceName]", topic); | |
| 38 | + if (topicParts.length < 4 || StrUtil.hasBlank(topicParts[2], topicParts[3])) { | |
| 39 | + log.warn("[handle][topic({}) 格式不正确,无法解析有效的 productKey 和 deviceName]", topic); | |
| 53 | 40 | return; |
| 54 | 41 | } |
| 42 | + | |
| 55 | 43 | String productKey = topicParts[2]; |
| 56 | 44 | String deviceName = topicParts[3]; |
| 57 | - // TODO @haohao:是不是要判断,部分为空,就不行呀; | |
| 58 | - if (StrUtil.isAllBlank(productKey, deviceName)) { | |
| 59 | - log.warn("[handle][topic({}) 格式不正确,productKey 和 deviceName 部分为空]", topic); | |
| 60 | - return; | |
| 61 | - } | |
| 62 | 45 | |
| 63 | 46 | // 3. 解码消息 |
| 64 | 47 | IotDeviceMessage message = deviceMessageService.decodeDeviceMessage(payload, productKey, deviceName); |
| 65 | 48 | if (message == null) { |
| 66 | - log.warn("[handle][topic({}) payload({}) 消息解码失败", topic, new String(payload)); | |
| 49 | + log.warn("[handle][topic({}) payload({}) 消息解码失败]", topic, new String(payload)); | |
| 67 | 50 | return; |
| 68 | 51 | } |
| 69 | 52 | |
| ... | ... | @@ -74,22 +57,4 @@ public class IotMqttUpstreamHandler { |
| 74 | 57 | } |
| 75 | 58 | } |
| 76 | 59 | |
| 77 | - // TODO @haohao:是不是 getMethodFromTopic? | |
| 78 | - /** | |
| 79 | - * 从主题中,获得消息类型 | |
| 80 | - * | |
| 81 | - * @param topic 主题 | |
| 82 | - * @return 消息类型 | |
| 83 | - */ | |
| 84 | - private String getMessageType(String topic) { | |
| 85 | - String[] topicParts = topic.split("/"); | |
| 86 | - // 约定:topic 第 4 个部分开始为消息类型 | |
| 87 | - // 例如:/sys/{productKey}/{deviceName}/thing/property/post -> thing/property/post | |
| 88 | - if (topicParts.length > 4) { | |
| 89 | - // TODO @haohao:是不是 subString 前 3 个,性能更好; | |
| 90 | - return String.join("/", Arrays.copyOfRange(topicParts, 4, topicParts.length)); | |
| 91 | - } | |
| 92 | - return topicParts[topicParts.length - 1]; | |
| 93 | - } | |
| 94 | - | |
| 95 | 60 | } |
| 96 | 61 | \ No newline at end of file | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/resources/application-local.yaml
| ... | ... | @@ -32,13 +32,13 @@ yudao: |
| 32 | 32 | # ==================================== |
| 33 | 33 | emqx: |
| 34 | 34 | enabled: true |
| 35 | - http-auth-port: 8090 # MQTT HTTP 认证服务端口 | |
| 36 | - mqtt-host: 127.0.0.1 # MQTT Broker 地址 | |
| 37 | - mqtt-port: 1883 # MQTT Broker 端口 | |
| 38 | - mqtt-username: admin # MQTT 用户名 | |
| 39 | - mqtt-password: public # MQTT 密码 | |
| 40 | - mqtt-client-id: iot-gateway-mqtt # MQTT 客户端 ID | |
| 41 | - mqtt-ssl: false # 是否开启 SSL | |
| 35 | + http-port: 8090 # MQTT HTTP 服务端口 | |
| 36 | + mqtt-host: 127.0.0.1 # MQTT Broker 地址 | |
| 37 | + mqtt-port: 1883 # MQTT Broker 端口 | |
| 38 | + mqtt-username: admin # MQTT 用户名 | |
| 39 | + mqtt-password: public # MQTT 密码 | |
| 40 | + mqtt-client-id: iot-gateway-mqtt # MQTT 客户端 ID | |
| 41 | + mqtt-ssl: false # 是否开启 SSL | |
| 42 | 42 | mqtt-topics: |
| 43 | 43 | - "/sys/#" # 系统主题 |
| 44 | 44 | |
| ... | ... | @@ -55,4 +55,4 @@ logging: |
| 55 | 55 | cn.iocoder.yudao.module.iot.gateway.protocol.mqtt: DEBUG |
| 56 | 56 | cn.iocoder.yudao.module.iot.gateway.protocol.http: DEBUG |
| 57 | 57 | # MQTT 客户端日志 |
| 58 | - io.vertx.mqtt: DEBUG | |
| 59 | 58 | \ No newline at end of file |
| 59 | +# io.vertx.mqtt: DEBUG | |
| 60 | 60 | \ No newline at end of file | ... | ... |