From 68437cd8308307c1b5a84475c64443dd0b69016a Mon Sep 17 00:00:00 2001 From: haohao <1036606149@qq.com> Date: Thu, 9 Oct 2025 22:27:30 +0800 Subject: [PATCH 01/17] =?UTF-8?q?feat=EF=BC=9A=E3=80=90IoT=20=E7=89=A9?= =?UTF-8?q?=E8=81=94=E7=BD=91=E3=80=91=E6=96=B0=E5=A2=9E=20MQTT=20WebSocke?= =?UTF-8?q?t=20=E5=8D=8F=E8=AE=AE=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- yudao-dependencies/pom.xml | 2 +- .../config/IotGatewayConfiguration.java | 42 + .../gateway/config/IotGatewayProperties.java | 101 ++ .../mqttws/IotMqttWsDownstreamSubscriber.java | 79 ++ .../mqttws/IotMqttWsUpstreamProtocol.java | 146 +++ .../manager/IotMqttWsConnectionManager.java | 257 +++++ .../gateway/protocol/mqttws/package-info.java | 15 + .../router/IotMqttWsDownstreamHandler.java | 221 +++++ .../router/IotMqttWsUpstreamHandler.java | 774 +++++++++++++++ .../src/main/resources/application.yaml | 18 +- .../resources/mqtt-websocket-test-client.html | 888 ++++++++++++++++++ 11 files changed, 2540 insertions(+), 3 deletions(-) create mode 100644 yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/IotMqttWsDownstreamSubscriber.java create mode 100644 yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/IotMqttWsUpstreamProtocol.java create mode 100644 yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/manager/IotMqttWsConnectionManager.java create mode 100644 yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/package-info.java create mode 100644 yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/router/IotMqttWsDownstreamHandler.java create mode 100644 yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/router/IotMqttWsUpstreamHandler.java create mode 100644 yudao-module-iot/yudao-module-iot-gateway/src/test/resources/mqtt-websocket-test-client.html diff --git a/yudao-dependencies/pom.xml b/yudao-dependencies/pom.xml index e08f834fb9..ada61302be 100644 --- a/yudao-dependencies/pom.xml +++ b/yudao-dependencies/pom.xml @@ -68,7 +68,7 @@ 4.2.4.Final 1.2.5 0.9.0 - 4.5.13 + 4.5.21 2.30.14 1.16.7 diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java index 4b9c3af32c..fab4c8cc85 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java @@ -10,6 +10,10 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttDownstreamSubscr import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttUpstreamProtocol; import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionManager; import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.router.IotMqttDownstreamHandler; +import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.IotMqttWsDownstreamSubscriber; +import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.IotMqttWsUpstreamProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.manager.IotMqttWsConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.router.IotMqttWsDownstreamHandler; import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpDownstreamSubscriber; import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpUpstreamProtocol; import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager.IotTcpConnectionManager; @@ -151,4 +155,42 @@ public class IotGatewayConfiguration { } + /** + * IoT 网关 MQTT WebSocket 协议配置类 + */ + @Configuration + @ConditionalOnProperty(prefix = "yudao.iot.gateway.protocol.mqtt-ws", name = "enabled", havingValue = "true") + @Slf4j + public static class MqttWsProtocolConfiguration { + + @Bean(destroyMethod = "close") + public Vertx mqttWsVertx() { + return Vertx.vertx(); + } + + @Bean + public IotMqttWsUpstreamProtocol iotMqttWsUpstreamProtocol(IotGatewayProperties gatewayProperties, + IotDeviceMessageService messageService, + IotMqttWsConnectionManager connectionManager, + Vertx mqttWsVertx) { + return new IotMqttWsUpstreamProtocol(gatewayProperties.getProtocol().getMqttWs(), + messageService, connectionManager, mqttWsVertx); + } + + @Bean + public IotMqttWsDownstreamHandler iotMqttWsDownstreamHandler(IotDeviceMessageService messageService, + IotDeviceService deviceService, + IotMqttWsConnectionManager connectionManager) { + return new IotMqttWsDownstreamHandler(messageService, deviceService, connectionManager); + } + + @Bean + public IotMqttWsDownstreamSubscriber iotMqttWsDownstreamSubscriber(IotMqttWsUpstreamProtocol mqttWsUpstreamProtocol, + IotMqttWsDownstreamHandler downstreamHandler, + IotMessageBus messageBus) { + return new IotMqttWsDownstreamSubscriber(mqttWsUpstreamProtocol, downstreamHandler, messageBus); + } + + } + } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayProperties.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayProperties.java index 2c2000fd1f..7655a3759e 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayProperties.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayProperties.java @@ -88,6 +88,11 @@ public class IotGatewayProperties { */ private MqttProperties mqtt; + /** + * MQTT WebSocket 组件配置 + */ + private MqttWsProperties mqttWs; + } @Data @@ -402,4 +407,100 @@ public class IotGatewayProperties { } + @Data + public static class MqttWsProperties { + + /** + * 是否开启 + */ + @NotNull(message = "是否开启不能为空") + private Boolean enabled; + + /** + * WebSocket 服务器端口(默认:8083) + */ + private Integer port = 8083; + + /** + * WebSocket 路径(默认:/mqtt) + */ + @NotEmpty(message = "WebSocket 路径不能为空") + private String path = "/mqtt"; + + /** + * 最大消息大小(字节) + */ + private Integer maxMessageSize = 8192; + + /** + * 连接超时时间(秒) + */ + private Integer connectTimeoutSeconds = 60; + + /** + * 保持连接超时时间(秒) + */ + private Integer keepAliveTimeoutSeconds = 300; + + /** + * 是否启用 SSL(wss://) + */ + private Boolean sslEnabled = false; + + /** + * SSL 配置 + */ + private SslOptions sslOptions = new SslOptions(); + + /** + * WebSocket 子协议(通常为 "mqtt" 或 "mqttv3.1") + */ + @NotEmpty(message = "WebSocket 子协议不能为空") + private String subProtocol = "mqtt"; + + /** + * 最大帧大小(字节) + */ + private Integer maxFrameSize = 65536; + + /** + * SSL 配置选项 + */ + @Data + public static class SslOptions { + + /** + * 密钥证书选项 + */ + private io.vertx.core.net.KeyCertOptions keyCertOptions; + + /** + * 信任选项 + */ + private io.vertx.core.net.TrustOptions trustOptions; + + /** + * SSL 证书路径 + */ + private String certPath; + + /** + * SSL 私钥路径 + */ + private String keyPath; + + /** + * 信任存储路径 + */ + private String trustStorePath; + + /** + * 信任存储密码 + */ + private String trustStorePassword; + + } + + } + } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/IotMqttWsDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/IotMqttWsDownstreamSubscriber.java new file mode 100644 index 0000000000..302824d6df --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/IotMqttWsDownstreamSubscriber.java @@ -0,0 +1,79 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.mqttws; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.router.IotMqttWsDownstreamHandler; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; + +/** + * IoT MQTT WebSocket 下行消息订阅器 + *

+ * 订阅消息总线的设备下行消息,并通过 WebSocket 发送到设备 + * + * @author 芋道源码 + */ +@Slf4j +public class IotMqttWsDownstreamSubscriber implements IotMessageSubscriber { + + private final IotMqttWsUpstreamProtocol upstreamProtocol; + private final IotMqttWsDownstreamHandler downstreamHandler; + private final IotMessageBus messageBus; + + public IotMqttWsDownstreamSubscriber(IotMqttWsUpstreamProtocol upstreamProtocol, + IotMqttWsDownstreamHandler downstreamHandler, + IotMessageBus messageBus) { + this.upstreamProtocol = upstreamProtocol; + this.downstreamHandler = downstreamHandler; + this.messageBus = messageBus; + } + + @PostConstruct + public void init() { + messageBus.register(this); + log.info("[init][MQTT WebSocket 下行消息订阅器已启动,topic: {}]", getTopic()); + } + + @Override + public String getTopic() { + return IotDeviceMessageUtils.buildMessageBusGatewayDeviceMessageTopic(upstreamProtocol.getServerId()); + } + + @Override + public String getGroup() { + // 保证点对点消费,需要保证独立的 Group,所以使用 Topic 作为 Group + return getTopic(); + } + + @Override + public void onMessage(IotDeviceMessage message) { + log.debug("[onMessage][收到下行消息,deviceId: {},method: {}]", + message.getDeviceId(), message.getMethod()); + try { + // 1. 校验 + String method = message.getMethod(); + if (StrUtil.isBlank(method)) { + log.warn("[onMessage][消息方法为空,deviceId: {}]", message.getDeviceId()); + return; + } + + // 2. 委托给下行处理器处理业务逻辑 + boolean success = downstreamHandler.handleDownstreamMessage(message); + if (success) { + log.debug("[onMessage][下行消息处理成功,deviceId: {},method: {}]", + message.getDeviceId(), message.getMethod()); + } else { + log.warn("[onMessage][下行消息处理失败,deviceId: {},method: {}]", + message.getDeviceId(), message.getMethod()); + } + } catch (Exception e) { + log.error("[onMessage][处理下行消息失败,deviceId: {},method: {}]", + message.getDeviceId(), message.getMethod(), e); + } + } + +} + diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/IotMqttWsUpstreamProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/IotMqttWsUpstreamProtocol.java new file mode 100644 index 0000000000..6944d47dad --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/IotMqttWsUpstreamProtocol.java @@ -0,0 +1,146 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.mqttws; + +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; +import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.manager.IotMqttWsConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.router.IotMqttWsUpstreamHandler; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpServer; +import io.vertx.core.http.HttpServerOptions; +import io.vertx.core.http.ServerWebSocket; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * IoT 网关 MQTT WebSocket 协议:接收设备上行消息 + *

+ * 基于 Vert.x 实现 MQTT over WebSocket 服务端,支持: + * - 标准 MQTT 3.1.1 协议 + * - WebSocket 协议升级 + * - SSL/TLS 加密(wss://) + * - 设备认证与连接管理 + * - QoS 0/1/2 消息质量保证 + * + * @author 芋道源码 + */ +@Slf4j +public class IotMqttWsUpstreamProtocol { + + private final IotGatewayProperties.MqttWsProperties mqttWsProperties; + + private final IotDeviceMessageService messageService; + + private final IotMqttWsConnectionManager connectionManager; + + private final Vertx vertx; + + @Getter + private final String serverId; + + private HttpServer httpServer; + + public IotMqttWsUpstreamProtocol(IotGatewayProperties.MqttWsProperties mqttWsProperties, + IotDeviceMessageService messageService, + IotMqttWsConnectionManager connectionManager, + Vertx vertx) { + this.mqttWsProperties = mqttWsProperties; + this.messageService = messageService; + this.connectionManager = connectionManager; + this.vertx = vertx; + this.serverId = IotDeviceMessageUtils.generateServerId(mqttWsProperties.getPort()); + } + + @PostConstruct + public void start() { + // 创建 HTTP 服务器选项 + HttpServerOptions options = new HttpServerOptions() + .setPort(mqttWsProperties.getPort()) + .setIdleTimeout(mqttWsProperties.getKeepAliveTimeoutSeconds()) + .setMaxWebSocketFrameSize(mqttWsProperties.getMaxFrameSize()) + .setMaxWebSocketMessageSize(mqttWsProperties.getMaxMessageSize()) + // 配置 WebSocket 子协议支持 + .addWebSocketSubProtocol(mqttWsProperties.getSubProtocol()); + + // 配置 SSL(如果启用) + if (Boolean.TRUE.equals(mqttWsProperties.getSslEnabled())) { + options.setSsl(true) + .setKeyCertOptions(mqttWsProperties.getSslOptions().getKeyCertOptions()) + .setTrustOptions(mqttWsProperties.getSslOptions().getTrustOptions()); + log.info("[start][MQTT WebSocket 已启用 SSL/TLS (wss://)]"); + } + + // 创建 HTTP 服务器 + httpServer = vertx.createHttpServer(options); + + // 设置 WebSocket 处理器 + httpServer.webSocketHandler(this::handleWebSocketConnection); + + // 启动服务器 + try { + httpServer.listen().result(); + log.info("[start][IoT 网关 MQTT WebSocket 协议启动成功,端口: {},路径: {},支持子协议: {}]", + mqttWsProperties.getPort(), mqttWsProperties.getPath(), + "mqtt, mqttv3.1, " + mqttWsProperties.getSubProtocol()); + } catch (Exception e) { + log.error("[start][IoT 网关 MQTT WebSocket 协议启动失败]", e); + throw e; + } + } + + @PreDestroy + public void stop() { + if (httpServer != null) { + try { + // 关闭所有连接 + connectionManager.closeAllConnections(); + + // 关闭服务器 + httpServer.close().result(); + log.info("[stop][IoT 网关 MQTT WebSocket 协议已停止]"); + } catch (Exception e) { + log.error("[stop][IoT 网关 MQTT WebSocket 协议停止失败]", e); + } + } + } + + /** + * 处理 WebSocket 连接请求 + * + * @param socket WebSocket 连接 + */ + private void handleWebSocketConnection(ServerWebSocket socket) { + String path = socket.path(); + String subProtocol = socket.subProtocol(); + + log.info("[handleWebSocketConnection][收到 WebSocket 连接请求,path: {},subProtocol: {},remoteAddress: {}]", + path, subProtocol, socket.remoteAddress()); + + // 验证路径 + if (!mqttWsProperties.getPath().equals(path)) { + log.warn("[handleWebSocketConnection][WebSocket 路径不匹配,拒绝连接,path: {},期望: {}]", + path, mqttWsProperties.getPath()); + socket.close(); + return; + } + + // 验证子协议 + // Vert.x 已经自动进行了子协议协商,这里只需要验证是否为 MQTT 相关协议 + if (subProtocol != null && !subProtocol.startsWith("mqtt")) { + log.warn("[handleWebSocketConnection][WebSocket 子协议不支持,拒绝连接,subProtocol: {}]", subProtocol); + socket.close(); + return; + } + + log.info("[handleWebSocketConnection][WebSocket 连接已接受,remoteAddress: {},subProtocol: {}]", + socket.remoteAddress(), subProtocol); + + // 创建处理器并处理连接 + IotMqttWsUpstreamHandler handler = new IotMqttWsUpstreamHandler( + this, messageService, connectionManager); + handler.handle(socket); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/manager/IotMqttWsConnectionManager.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/manager/IotMqttWsConnectionManager.java new file mode 100644 index 0000000000..2bf61bea83 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/manager/IotMqttWsConnectionManager.java @@ -0,0 +1,257 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.manager; + +import io.vertx.core.http.ServerWebSocket; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArraySet; + +/** + * IoT MQTT WebSocket 连接管理器 + * + * @author 芋道源码 + */ +@Slf4j +@Component +public class IotMqttWsConnectionManager { + + /** + * 存储设备连接 + * Key: 设备标识(deviceKey) + * Value: WebSocket 连接 + */ + private final Map connections = new ConcurrentHashMap<>(); + + /** + * 存储设备标识与 Socket ID 的映射 + * Key: 设备标识(deviceKey) + * Value: Socket ID(UUID) + */ + private final Map deviceKeyToSocketId = new ConcurrentHashMap<>(); + + /** + * 存储 Socket ID 与设备标识的映射 + * Key: Socket ID(UUID) + * Value: 设备标识(deviceKey) + */ + private final Map socketIdToDeviceKey = new ConcurrentHashMap<>(); + + /** + * 存储设备订阅的主题 + * Key: 设备标识(deviceKey) + * Value: 订阅的主题集合 + */ + private final Map> deviceSubscriptions = new ConcurrentHashMap<>(); + + /** + * 添加连接 + * + * @param deviceKey 设备标识 + * @param socket WebSocket 连接 + * @param socketId Socket ID(UUID) + */ + public void addConnection(String deviceKey, ServerWebSocket socket, String socketId) { + connections.put(deviceKey, socket); + deviceKeyToSocketId.put(deviceKey, socketId); + socketIdToDeviceKey.put(socketId, deviceKey); + log.info("[addConnection][设备连接已添加,deviceKey: {},socketId: {},当前连接数: {}]", + deviceKey, socketId, connections.size()); + } + + /** + * 移除连接 + * + * @param deviceKey 设备标识 + */ + public void removeConnection(String deviceKey) { + ServerWebSocket socket = connections.remove(deviceKey); + String socketId = deviceKeyToSocketId.remove(deviceKey); + if (socketId != null) { + socketIdToDeviceKey.remove(socketId); + } + if (socket != null) { + log.info("[removeConnection][设备连接已移除,deviceKey: {},socketId: {},当前连接数: {}]", + deviceKey, socketId, connections.size()); + } + } + + /** + * 根据 Socket ID 移除连接 + * + * @param socketId WebSocket 文本框架 ID + */ + public void removeConnectionBySocketId(String socketId) { + String deviceKey = socketIdToDeviceKey.remove(socketId); + if (deviceKey != null) { + connections.remove(deviceKey); + log.info("[removeConnectionBySocketId][设备连接已移除,socketId: {},deviceKey: {},当前连接数: {}]", + socketId, deviceKey, connections.size()); + } + } + + /** + * 获取连接 + * + * @param deviceKey 设备标识 + * @return WebSocket 连接 + */ + public ServerWebSocket getConnection(String deviceKey) { + return connections.get(deviceKey); + } + + /** + * 根据 Socket ID 获取设备标识 + * + * @param socketId WebSocket 文本框架 ID + * @return 设备标识 + */ + public String getDeviceKeyBySocketId(String socketId) { + return socketIdToDeviceKey.get(socketId); + } + + /** + * 检查设备是否在线 + * + * @param deviceKey 设备标识 + * @return 是否在线 + */ + public boolean isOnline(String deviceKey) { + return connections.containsKey(deviceKey); + } + + /** + * 获取当前连接数 + * + * @return 连接数 + */ + public int getConnectionCount() { + return connections.size(); + } + + /** + * 关闭所有连接 + */ + public void closeAllConnections() { + connections.forEach((deviceKey, socket) -> { + try { + socket.close(); + log.info("[closeAllConnections][关闭设备连接,deviceKey: {}]", deviceKey); + } catch (Exception e) { + log.error("[closeAllConnections][关闭设备连接失败,deviceKey: {}]", deviceKey, e); + } + }); + connections.clear(); + deviceKeyToSocketId.clear(); + socketIdToDeviceKey.clear(); + deviceSubscriptions.clear(); + log.info("[closeAllConnections][所有连接已关闭]"); + } + + // ==================== 订阅管理方法 ==================== + + /** + * 添加订阅 + * + * @param deviceKey 设备标识 + * @param topic 订阅主题 + */ + public void addSubscription(String deviceKey, String topic) { + deviceSubscriptions.computeIfAbsent(deviceKey, k -> new CopyOnWriteArraySet<>()).add(topic); + log.debug("[addSubscription][设备订阅主题,deviceKey: {},topic: {}]", deviceKey, topic); + } + + /** + * 移除订阅 + * + * @param deviceKey 设备标识 + * @param topic 订阅主题 + */ + public void removeSubscription(String deviceKey, String topic) { + Set topics = deviceSubscriptions.get(deviceKey); + if (topics != null) { + topics.remove(topic); + log.debug("[removeSubscription][设备取消订阅,deviceKey: {},topic: {}]", deviceKey, topic); + } + } + + /** + * 检查设备是否订阅了指定主题 + * 支持 MQTT 通配符匹配(+ 和 #) + * + * @param deviceKey 设备标识 + * @param topic 发布主题 + * @return 是否匹配 + */ + public boolean isSubscribed(String deviceKey, String topic) { + Set subscriptions = deviceSubscriptions.get(deviceKey); + if (subscriptions == null || subscriptions.isEmpty()) { + return false; + } + + // 检查是否有匹配的订阅 + for (String subscription : subscriptions) { + if (topicMatches(subscription, topic)) { + return true; + } + } + return false; + } + + /** + * 获取设备的所有订阅 + * + * @param deviceKey 设备标识 + * @return 订阅主题集合 + */ + public Set getSubscriptions(String deviceKey) { + return deviceSubscriptions.get(deviceKey); + } + + /** + * MQTT 主题匹配 + * 支持通配符: + * - +:匹配单层主题 + * - #:匹配多层主题(必须在末尾) + * + * @param subscription 订阅主题(可能包含通配符) + * @param topic 发布主题(不包含通配符) + * @return 是否匹配 + */ + private boolean topicMatches(String subscription, String topic) { + // 完全匹配 + if (subscription.equals(topic)) { + return true; + } + + // 不包含通配符 + if (!subscription.contains("+") && !subscription.contains("#")) { + return false; + } + + String[] subscriptionParts = subscription.split("/"); + String[] topicParts = topic.split("/"); + + int i = 0; + for (; i < subscriptionParts.length && i < topicParts.length; i++) { + String subPart = subscriptionParts[i]; + String topicPart = topicParts[i]; + + if (subPart.equals("#")) { + // # 匹配剩余所有层级,且必须在末尾 + return i == subscriptionParts.length - 1; + } + + if (!subPart.equals("+") && !subPart.equals(topicPart)) { + // 不是通配符且不匹配 + return false; + } + } + + // 检查是否都匹配完 + return i == subscriptionParts.length && i == topicParts.length; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/package-info.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/package-info.java new file mode 100644 index 0000000000..b9af4afe3a --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/package-info.java @@ -0,0 +1,15 @@ +/** + * IoT 网关 MQTT WebSocket 协议实现 + *

+ * 基于 Vert.x 实现 MQTT over WebSocket 服务端,支持: + * - 标准 MQTT 3.1.1 协议 + * - WebSocket 协议升级 + * - SSL/TLS 加密(wss://) + * - 设备认证与连接管理 + * - QoS 0/1/2 消息质量保证 + * - 双向消息通信(上行/下行) + * + * @author 芋道源码 + */ +package cn.iocoder.yudao.module.iot.gateway.protocol.mqttws; + diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/router/IotMqttWsDownstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/router/IotMqttWsDownstreamHandler.java new file mode 100644 index 0000000000..37148de7e5 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/router/IotMqttWsDownstreamHandler.java @@ -0,0 +1,221 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.router; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.manager.IotMqttWsConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.ServerWebSocket; +import lombok.extern.slf4j.Slf4j; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * IoT MQTT WebSocket 下行消息处理器 + *

+ * 处理从消息总线发送到设备的消息,包括: + * - 属性设置 + * - 服务调用 + * - 事件通知 + * + * @author 芋道源码 + */ +@Slf4j +public class IotMqttWsDownstreamHandler { + + private final IotDeviceMessageService deviceMessageService; + + private final IotDeviceService deviceService; + + private final IotMqttWsConnectionManager connectionManager; + + /** + * 消息 ID 生成器(用于发布消息) + */ + private final AtomicInteger messageIdGenerator = new AtomicInteger(1); + + public IotMqttWsDownstreamHandler(IotDeviceMessageService deviceMessageService, + IotDeviceService deviceService, + IotMqttWsConnectionManager connectionManager) { + this.deviceMessageService = deviceMessageService; + this.deviceService = deviceService; + this.connectionManager = connectionManager; + } + + /** + * 处理下行消息 + * + * @param message 设备消息 + * @return 是否处理成功 + */ + public boolean handleDownstreamMessage(IotDeviceMessage message) { + try { + // 1. 基础校验 + if (message == null || message.getDeviceId() == null) { + log.warn("[handleDownstreamMessage][消息或设备 ID 为空,忽略处理]"); + return false; + } + + // 2. 获取设备信息 + IotDeviceRespDTO deviceInfo = deviceService.getDeviceFromCache(message.getDeviceId()); + if (deviceInfo == null) { + log.warn("[handleDownstreamMessage][设备不存在,设备 ID:{}]", message.getDeviceId()); + return false; + } + + // 3. 构建设备标识 + String deviceKey = deviceInfo.getProductKey() + ":" + deviceInfo.getDeviceName(); + + // 4. 检查设备是否在线 + if (!connectionManager.isOnline(deviceKey)) { + log.warn("[handleDownstreamMessage][设备离线,无法发送消息,deviceKey: {}]", deviceKey); + return false; + } + + // 5. 构建主题 + String topic = buildDownstreamTopic(message, deviceInfo); + if (StrUtil.isBlank(topic)) { + log.warn("[handleDownstreamMessage][主题构建失败,设备 ID:{},方法:{}]", + message.getDeviceId(), message.getMethod()); + return false; + } + + // 6. 检查设备是否订阅了该主题 + if (!connectionManager.isSubscribed(deviceKey, topic)) { + log.warn("[handleDownstreamMessage][设备未订阅该主题,deviceKey: {},topic: {}]", deviceKey, topic); + return false; + } + + // 8. 编码消息 + byte[] payload = deviceMessageService.encodeDeviceMessage(message, + deviceInfo.getProductKey(), deviceInfo.getDeviceName()); + if (payload == null || payload.length == 0) { + log.warn("[handleDownstreamMessage][消息编码失败,设备 ID:{}]", message.getDeviceId()); + return false; + } + + // 9. 发送消息到设备 + return sendMessageToDevice(deviceKey, topic, payload, 1); + } catch (Exception e) { + if (message != null) { + log.error("[handleDownstreamMessage][处理下行消息异常,设备 ID:{},错误:{}]", + message.getDeviceId(), e.getMessage(), e); + } + return false; + } + } + + /** + * 构建下行消息主题 + * + * @param message 设备消息 + * @param deviceInfo 设备信息 + * @return 主题 + */ + private String buildDownstreamTopic(IotDeviceMessage message, IotDeviceRespDTO deviceInfo) { + String method = message.getMethod(); + if (StrUtil.isBlank(method)) { + return null; + } + + // 使用工具类构建主题,支持回复消息处理 + boolean isReply = IotDeviceMessageUtils.isReplyMessage(message); + return IotMqttTopicUtils.buildTopicByMethod(method, deviceInfo.getProductKey(), + deviceInfo.getDeviceName(), isReply); + } + + /** + * 发送消息到设备 + * + * @param deviceKey 设备标识(productKey:deviceName) + * @param topic 主题 + * @param payload 消息内容 + * @param qos QoS 级别 + * @return 是否发送成功 + */ + private boolean sendMessageToDevice(String deviceKey, String topic, byte[] payload, int qos) { + // 获取设备连接 + ServerWebSocket socket = connectionManager.getConnection(deviceKey); + if (socket == null) { + log.warn("[sendMessageToDevice][设备未连接,deviceKey: {}]", deviceKey); + return false; + } + + try { + int messageId = qos > 0 ? generateMessageId() : 0; + + // 手动编码MQTT PUBLISH消息 + io.netty.buffer.ByteBuf byteBuf = io.netty.buffer.Unpooled.buffer(); + + // 固定头:消息类型(PUBLISH=3) + DUP(0) + QoS + RETAIN + int fixedHeaderByte1 = 0x30 | (qos << 1); // PUBLISH类型 + byteBuf.writeByte(fixedHeaderByte1); + + // 计算剩余长度 + int topicLength = topic.getBytes().length; + int remainingLength = 2 + topicLength + (qos > 0 ? 2 : 0) + payload.length; + + // 写入剩余长度(简化版本,假设小于128字节) + if (remainingLength < 128) { + byteBuf.writeByte(remainingLength); + } else { + // 处理大于127的情况 + int x = remainingLength; + do { + int encodedByte = x % 128; + x = x / 128; + if (x > 0) { + encodedByte = encodedByte | 128; + } + byteBuf.writeByte(encodedByte); + } while (x > 0); + } + + // 可变头:主题名称 + byteBuf.writeShort(topicLength); + byteBuf.writeBytes(topic.getBytes()); + + // 可变头:消息ID(仅QoS>0时) + if (qos > 0) { + byteBuf.writeShort(messageId); + } + + // 有效载荷 + byteBuf.writeBytes(payload); + + // 发送 + byte[] bytes = new byte[byteBuf.readableBytes()]; + byteBuf.readBytes(bytes); + byteBuf.release(); + + socket.writeBinaryMessage(Buffer.buffer(bytes)); + + log.info("[sendMessageToDevice][消息已发送到设备,deviceKey: {},topic: {},qos: {},messageId: {}]", + deviceKey, topic, qos, messageId); + return true; + } catch (Exception e) { + log.error("[sendMessageToDevice][发送消息到设备失败,deviceKey: {},topic: {}]", deviceKey, topic, e); + return false; + } + } + + /** + * 生成消息 ID + * + * @return 消息 ID + */ + private int generateMessageId() { + int id = messageIdGenerator.getAndIncrement(); + // MQTT 消息 ID 范围是 1-65535 + if (id > 65535) { + messageIdGenerator.set(1); + return 1; + } + return id; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/router/IotMqttWsUpstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/router/IotMqttWsUpstreamHandler.java new file mode 100644 index 0000000000..d9688b7974 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/router/IotMqttWsUpstreamHandler.java @@ -0,0 +1,774 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.router; + +import cn.hutool.core.util.BooleanUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceGetReqDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils; +import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.IotMqttWsUpstreamProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.manager.IotMqttWsConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.handler.codec.DecoderException; +import io.netty.handler.codec.mqtt.*; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.ServerWebSocket; +import lombok.extern.slf4j.Slf4j; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * IoT MQTT WebSocket 上行消息处理器 + *

+ * 处理来自设备的 MQTT 消息,包括: + * - CONNECT:设备连接认证 + * - PUBLISH:设备发布消息 + * - SUBSCRIBE:设备订阅主题 + * - UNSUBSCRIBE:设备取消订阅 + * - PINGREQ:心跳请求 + * - DISCONNECT:设备断开连接 + * + * @author 芋道源码 + */ +@Slf4j +public class IotMqttWsUpstreamHandler { + + private final IotMqttWsUpstreamProtocol upstreamProtocol; + + private final IotDeviceCommonApi deviceApi; + + private final IotDeviceMessageService messageService; + + private final IotMqttWsConnectionManager connectionManager; + + /** + * 存储 WebSocket 连接到 Socket ID 的映射 + * Key: WebSocket 对象 + * Value: Socket ID(UUID) + */ + private final ConcurrentHashMap socketIdMap = new ConcurrentHashMap<>(); + + /** + * 存储 Socket ID 对应的设备信息 + * Key: Socket ID(UUID) + * Value: 设备信息 + */ + private final ConcurrentHashMap socketDeviceMap = new ConcurrentHashMap<>(); + + /** + * 存储设备的消息 ID 生成器(用于 QoS > 0 的消息) + */ + private final ConcurrentHashMap deviceMessageIdMap = new ConcurrentHashMap<>(); + + /** + * MQTT 解码通道(用于解析 WebSocket 中的 MQTT 二进制消息) + */ + private final ThreadLocal decoderChannelThreadLocal = ThreadLocal + .withInitial(() -> new EmbeddedChannel(new MqttDecoder())); + + /** + * MQTT 编码通道(用于编码 MQTT 响应消息) + */ + private final ThreadLocal encoderChannelThreadLocal = ThreadLocal + .withInitial(() -> new EmbeddedChannel(MqttEncoder.INSTANCE)); + + public IotMqttWsUpstreamHandler(IotMqttWsUpstreamProtocol upstreamProtocol, + IotDeviceMessageService messageService, + IotMqttWsConnectionManager connectionManager) { + this.upstreamProtocol = upstreamProtocol; + this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class); + this.messageService = messageService; + this.connectionManager = connectionManager; + } + + /** + * 处理 WebSocket 连接 + * + * @param socket WebSocket 连接 + */ + public void handle(ServerWebSocket socket) { + // 生成唯一的 Socket ID(因为 MQTT 使用二进制协议,textHandlerID() 会返回 null) + String socketId = IdUtil.simpleUUID(); + socketIdMap.put(socket, socketId); + + log.info("[handle][WebSocket 连接建立,socketId: {},remoteAddress: {}]", + socketId, socket.remoteAddress()); + + // 设置二进制数据处理器 + socket.binaryMessageHandler(buffer -> { + try { + handleMqttMessage(socket, buffer); + } catch (Exception e) { + log.error("[handle][处理 MQTT 消息异常,socketId: {}]", socketId, e); + socket.close(); + } + }); + + // 设置关闭处理器 + socket.closeHandler(v -> { + socketIdMap.remove(socket); + IotDeviceRespDTO device = socketDeviceMap.remove(socketId); + if (device != null) { + String deviceKey = device.getProductKey() + ":" + device.getDeviceName(); + connectionManager.removeConnection(deviceKey); + deviceMessageIdMap.remove(deviceKey); + // 发送设备离线消息 + sendOfflineMessage(device); + log.info("[handle][WebSocket 连接关闭,deviceKey: {},socketId: {}]", deviceKey, socketId); + } + }); + + // 设置异常处理器 + socket.exceptionHandler(e -> { + log.error("[handle][WebSocket 连接异常,socketId: {}]", socketId, e); + socketIdMap.remove(socket); + IotDeviceRespDTO device = socketDeviceMap.remove(socketId); + if (device != null) { + String deviceKey = device.getProductKey() + ":" + device.getDeviceName(); + connectionManager.removeConnection(deviceKey); + deviceMessageIdMap.remove(deviceKey); + } + socket.close(); + }); + } + + /** + * 处理 MQTT 消息 + * + * @param socket WebSocket 连接 + * @param buffer 消息缓冲区 + */ + private void handleMqttMessage(ServerWebSocket socket, Buffer buffer) { + String socketId = socketIdMap.get(socket); + ByteBuf byteBuf = Unpooled.wrappedBuffer(buffer.getBytes()); + + try { + // 使用 EmbeddedChannel 解码 MQTT 消息 + EmbeddedChannel decoderChannel = decoderChannelThreadLocal.get(); + decoderChannel.writeInbound(byteBuf.retain()); + + // 读取解码后的消息 + MqttMessage mqttMessage = decoderChannel.readInbound(); + if (mqttMessage == null) { + log.warn("[handleMqttMessage][MQTT 消息解码失败,socketId: {}]", socketId); + return; + } + + MqttMessageType messageType = mqttMessage.fixedHeader().messageType(); + log.debug("[handleMqttMessage][收到 MQTT 消息,类型: {},socketId: {}]", messageType, socketId); + + // 根据消息类型分发处理 + switch (messageType) { + case CONNECT: + handleConnect(socket, (MqttConnectMessage) mqttMessage); + break; + case PUBLISH: + handlePublish(socket, (MqttPublishMessage) mqttMessage); + break; + case PUBACK: + handlePubAck(socket, mqttMessage); + break; + case PUBREC: + handlePubRec(socket, mqttMessage); + break; + case PUBREL: + handlePubRel(socket, mqttMessage); + break; + case PUBCOMP: + handlePubComp(socket, mqttMessage); + break; + case SUBSCRIBE: + handleSubscribe(socket, (MqttSubscribeMessage) mqttMessage); + break; + case UNSUBSCRIBE: + handleUnsubscribe(socket, (MqttUnsubscribeMessage) mqttMessage); + break; + case PINGREQ: + handlePingReq(socket); + break; + case DISCONNECT: + handleDisconnect(socket); + break; + default: + log.warn("[handleMqttMessage][不支持的消息类型: {},socketId: {}]", messageType, socketId); + } + } catch (DecoderException e) { + log.error("[handleMqttMessage][MQTT 消息解码异常,socketId: {}]", socketId, e); + socket.close(); + } catch (Exception e) { + log.error("[handleMqttMessage][处理 MQTT 消息失败,socketId: {}]", socketId, e); + socket.close(); + } finally { + byteBuf.release(); + } + } + + /** + * 处理 CONNECT 消息(设备认证) + */ + private void handleConnect(ServerWebSocket socket, MqttConnectMessage message) { + String socketId = socketIdMap.get(socket); + try { + // 1. 解析 CONNECT 消息 + MqttConnectPayload payload = message.payload(); + String clientId = payload.clientIdentifier(); + String username = payload.userName(); + String password = payload.passwordInBytes() != null + ? new String(payload.passwordInBytes(), StandardCharsets.UTF_8) + : null; + + log.info("[handleConnect][收到 CONNECT 消息,clientId: {},username: {},socketId: {}]", + clientId, username, socketId); + + // 2. 设备认证 + IotDeviceRespDTO device = authenticateDevice(clientId, username, password); + if (device == null) { + log.warn("[handleConnect][设备认证失败,clientId: {},socketId: {}]", clientId, socketId); + sendConnAck(socket, MqttConnectReturnCode.CONNECTION_REFUSED_BAD_USER_NAME_OR_PASSWORD); + socket.close(); + return; + } + + // 3. 保存设备信息 + socketDeviceMap.put(socketId, device); + String deviceKey = device.getProductKey() + ":" + device.getDeviceName(); + connectionManager.addConnection(deviceKey, socket, socketId); + deviceMessageIdMap.put(deviceKey, new AtomicInteger(1)); + + log.info("[handleConnect][设备认证成功,deviceId: {},deviceKey: {},socketId: {}]", + device.getId(), deviceKey, socketId); + + // 4. 发送 CONNACK + sendConnAck(socket, MqttConnectReturnCode.CONNECTION_ACCEPTED); + + // 5. 发送设备上线消息 + sendOnlineMessage(device); + + } catch (Exception e) { + log.error("[handleConnect][处理 CONNECT 消息失败,socketId: {}]", socketId, e); + sendConnAck(socket, MqttConnectReturnCode.CONNECTION_REFUSED_SERVER_UNAVAILABLE); + socket.close(); + } + } + + /** + * 处理 PUBLISH 消息(设备发布消息) + */ + private void handlePublish(ServerWebSocket socket, MqttPublishMessage message) { + String socketId = socketIdMap.get(socket); + IotDeviceRespDTO device = socketDeviceMap.get(socketId); + + if (device == null) { + log.warn("[handlePublish][设备未认证,socketId: {}]", socketId); + socket.close(); + return; + } + + try { + // 1. 解析 PUBLISH 消息 + MqttFixedHeader fixedHeader = message.fixedHeader(); + MqttPublishVariableHeader variableHeader = message.variableHeader(); + ByteBuf payload = message.payload(); + + String topic = variableHeader.topicName(); + int messageId = variableHeader.packetId(); + MqttQoS qos = fixedHeader.qosLevel(); + + log.debug("[handlePublish][收到 PUBLISH 消息,topic: {},messageId: {},QoS: {},deviceId: {}]", + topic, messageId, qos, device.getId()); + + // 2. 读取 payload + byte[] payloadBytes = new byte[payload.readableBytes()]; + payload.readBytes(payloadBytes); + + // 3. 解码并发送消息 + IotDeviceMessage deviceMessage = messageService.decodeDeviceMessage(payloadBytes, + device.getProductKey(), device.getDeviceName()); + if (deviceMessage != null) { + deviceMessage.setServerId(upstreamProtocol.getServerId()); + messageService.sendDeviceMessage(deviceMessage, device.getProductKey(), + device.getDeviceName(), upstreamProtocol.getServerId()); + log.info("[handlePublish][设备消息已发送,method: {},deviceId: {}]", + deviceMessage.getMethod(), device.getId()); + } + + // 4. 根据 QoS 级别发送相应的确认消息 + if (qos == MqttQoS.AT_LEAST_ONCE) { + // QoS 1:发送 PUBACK + sendPubAck(socket, messageId); + } else if (qos == MqttQoS.EXACTLY_ONCE) { + // QoS 2:发送 PUBREC + sendPubRec(socket, messageId); + } + // QoS 0 无需确认 + + } catch (Exception e) { + log.error("[handlePublish][处理 PUBLISH 消息失败,deviceId: {}]", device.getId(), e); + } + } + + /** + * 处理 PUBACK 消息(QoS 1 确认) + */ + private void handlePubAck(ServerWebSocket socket, MqttMessage message) { + String socketId = socketIdMap.get(socket); + IotDeviceRespDTO device = socketDeviceMap.get(socketId); + + if (device == null) { + log.warn("[handlePubAck][设备未认证,socketId: {}]", socketId); + socket.close(); + return; + } + + int messageId = ((MqttMessageIdVariableHeader) message.variableHeader()).messageId(); + log.debug("[handlePubAck][收到 PUBACK,messageId: {},deviceId: {}]", messageId, device.getId()); + } + + /** + * 处理 PUBREC 消息(QoS 2 第一步确认) + */ + private void handlePubRec(ServerWebSocket socket, MqttMessage message) { + String socketId = socketIdMap.get(socket); + IotDeviceRespDTO device = socketDeviceMap.get(socketId); + + if (device == null) { + log.warn("[handlePubRec][设备未认证,socketId: {}]", socketId); + socket.close(); + return; + } + + int messageId = ((MqttMessageIdVariableHeader) message.variableHeader()).messageId(); + log.debug("[handlePubRec][收到 PUBREC,messageId: {},deviceId: {}]", messageId, device.getId()); + + // 发送 PUBREL + sendPubRel(socket, messageId); + } + + /** + * 处理 PUBREL 消息(QoS 2 第二步) + */ + private void handlePubRel(ServerWebSocket socket, MqttMessage message) { + String socketId = socketIdMap.get(socket); + IotDeviceRespDTO device = socketDeviceMap.get(socketId); + + if (device == null) { + log.warn("[handlePubRel][设备未认证,socketId: {}]", socketId); + socket.close(); + return; + } + + int messageId = ((MqttMessageIdVariableHeader) message.variableHeader()).messageId(); + log.debug("[handlePubRel][收到 PUBREL,messageId: {},deviceId: {}]", messageId, device.getId()); + + // 发送 PUBCOMP + sendPubComp(socket, messageId); + } + + /** + * 处理 PUBCOMP 消息(QoS 2 完成确认) + */ + private void handlePubComp(ServerWebSocket socket, MqttMessage message) { + String socketId = socketIdMap.get(socket); + IotDeviceRespDTO device = socketDeviceMap.get(socketId); + + if (device == null) { + log.warn("[handlePubComp][设备未认证,socketId: {}]", socketId); + socket.close(); + return; + } + + int messageId = ((MqttMessageIdVariableHeader) message.variableHeader()).messageId(); + log.debug("[handlePubComp][收到 PUBCOMP,messageId: {},deviceId: {}]", messageId, device.getId()); + } + + /** + * 处理 SUBSCRIBE 消息(设备订阅主题) + */ + private void handleSubscribe(ServerWebSocket socket, MqttSubscribeMessage message) { + String socketId = socketIdMap.get(socket); + IotDeviceRespDTO device = socketDeviceMap.get(socketId); + + if (device == null) { + log.warn("[handleSubscribe][设备未认证,socketId: {}]", socketId); + socket.close(); + return; + } + + try { + // 1. 解析 SUBSCRIBE 消息 + int messageId = message.variableHeader().messageId(); + MqttSubscribePayload payload = message.payload(); + String deviceKey = device.getProductKey() + ":" + device.getDeviceName(); + + log.info("[handleSubscribe][设备订阅请求,deviceKey: {},messageId: {},主题数量: {}]", + deviceKey, messageId, payload.topicSubscriptions().size()); + + // 2. 构建 QoS 列表并记录订阅信息 + int[] grantedQosList = new int[payload.topicSubscriptions().size()]; + for (int i = 0; i < payload.topicSubscriptions().size(); i++) { + MqttTopicSubscription subscription = payload.topicSubscriptions().get(i); + String topic = subscription.topicFilter(); + grantedQosList[i] = subscription.qualityOfService().value(); + + // 记录订阅信息到连接管理器 + connectionManager.addSubscription(deviceKey, topic); + + log.info("[handleSubscribe][订阅主题: {},QoS: {},deviceKey: {}]", + topic, subscription.qualityOfService(), deviceKey); + } + + // 3. 发送 SUBACK + sendSubAck(socket, messageId, grantedQosList); + + } catch (Exception e) { + log.error("[handleSubscribe][处理 SUBSCRIBE 消息失败,deviceId: {}]", device.getId(), e); + } + } + + /** + * 处理 UNSUBSCRIBE 消息(设备取消订阅) + */ + private void handleUnsubscribe(ServerWebSocket socket, MqttUnsubscribeMessage message) { + String socketId = socketIdMap.get(socket); + IotDeviceRespDTO device = socketDeviceMap.get(socketId); + + if (device == null) { + log.warn("[handleUnsubscribe][设备未认证,socketId: {}]", socketId); + socket.close(); + return; + } + + try { + // 1. 解析 UNSUBSCRIBE 消息 + int messageId = message.variableHeader().messageId(); + MqttUnsubscribePayload payload = message.payload(); + String deviceKey = device.getProductKey() + ":" + device.getDeviceName(); + + log.info("[handleUnsubscribe][设备取消订阅,deviceKey: {},messageId: {},主题数量: {}]", + deviceKey, messageId, payload.topics().size()); + + // 2. 移除订阅信息 + for (String topic : payload.topics()) { + connectionManager.removeSubscription(deviceKey, topic); + log.info("[handleUnsubscribe][取消订阅主题: {},deviceKey: {}]", topic, deviceKey); + } + + // 3. 发送 UNSUBACK + sendUnsubAck(socket, messageId); + + } catch (Exception e) { + log.error("[handleUnsubscribe][处理 UNSUBSCRIBE 消息失败,deviceId: {}]", device.getId(), e); + } + } + + /** + * 处理 PINGREQ 消息(心跳请求) + */ + private void handlePingReq(ServerWebSocket socket) { + String socketId = socketIdMap.get(socket); + IotDeviceRespDTO device = socketDeviceMap.get(socketId); + + if (device == null) { + log.warn("[handlePingReq][设备未认证,socketId: {}]", socketId); + socket.close(); + return; + } + + log.debug("[handlePingReq][收到心跳请求,deviceId: {}]", device.getId()); + + // 发送 PINGRESP + sendPingResp(socket); + } + + /** + * 处理 DISCONNECT 消息(设备断开连接) + */ + private void handleDisconnect(ServerWebSocket socket) { + String socketId = socketIdMap.get(socket); + IotDeviceRespDTO device = socketDeviceMap.remove(socketId); + + if (device != null) { + String deviceKey = device.getProductKey() + ":" + device.getDeviceName(); + connectionManager.removeConnection(deviceKey); + deviceMessageIdMap.remove(deviceKey); + sendOfflineMessage(device); + log.info("[handleDisconnect][设备主动断开连接,deviceKey: {}]", deviceKey); + } + + socket.close(); + } + + // ==================== 设备认证和状态相关方法 ==================== + + /** + * 设备认证 + */ + private IotDeviceRespDTO authenticateDevice(String clientId, String username, String password) { + try { + // 1. 参数校验 + if (StrUtil.hasEmpty(clientId, username, password)) { + log.warn("[authenticateDevice][认证参数不完整,clientId: {},username: {}]", clientId, username); + return null; + } + + // 2. 构建认证参数并调用 API + IotDeviceAuthReqDTO authParams = new IotDeviceAuthReqDTO() + .setClientId(clientId) + .setUsername(username) + .setPassword(password); + + CommonResult authResult = deviceApi.authDevice(authParams); + if (!authResult.isSuccess() || !BooleanUtil.isTrue(authResult.getData())) { + log.warn("[authenticateDevice][设备认证失败,clientId: {}]", clientId); + return null; + } + + // 3. 获取设备信息 + IotDeviceAuthUtils.DeviceInfo deviceInfo = IotDeviceAuthUtils.parseUsername(username); + if (deviceInfo == null) { + log.warn("[authenticateDevice][用户名格式不正确,username: {}]", username); + return null; + } + + IotDeviceGetReqDTO getReqDTO = new IotDeviceGetReqDTO() + .setProductKey(deviceInfo.getProductKey()) + .setDeviceName(deviceInfo.getDeviceName()); + + CommonResult deviceResult = deviceApi.getDevice(getReqDTO); + if (!deviceResult.isSuccess() || deviceResult.getData() == null) { + log.warn("[authenticateDevice][获取设备信息失败,username: {}]", username); + return null; + } + + return deviceResult.getData(); + } catch (Exception e) { + log.error("[authenticateDevice][设备认证异常,clientId: {}]", clientId, e); + return null; + } + } + + /** + * 发送设备上线消息 + */ + private void sendOnlineMessage(IotDeviceRespDTO device) { + try { + IotDeviceMessage onlineMessage = IotDeviceMessage.buildStateUpdateOnline(); + messageService.sendDeviceMessage(onlineMessage, device.getProductKey(), + device.getDeviceName(), upstreamProtocol.getServerId()); + log.info("[sendOnlineMessage][设备上线,deviceId: {}]", device.getId()); + } catch (Exception e) { + log.error("[sendOnlineMessage][发送设备上线消息失败,deviceId: {}]", device.getId(), e); + } + } + + /** + * 发送设备离线消息 + */ + private void sendOfflineMessage(IotDeviceRespDTO device) { + try { + IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline(); + messageService.sendDeviceMessage(offlineMessage, device.getProductKey(), + device.getDeviceName(), upstreamProtocol.getServerId()); + log.info("[sendOfflineMessage][设备离线,deviceId: {}]", device.getId()); + } catch (Exception e) { + log.error("[sendOfflineMessage][发送设备离线消息失败,deviceId: {}]", device.getId(), e); + } + } + + // ==================== 发送响应消息的辅助方法 ==================== + + /** + * 发送 CONNACK 消息 + */ + private void sendConnAck(ServerWebSocket socket, MqttConnectReturnCode returnCode) { + try { + // 构建 CONNACK 消息 + MqttFixedHeader fixedHeader = new MqttFixedHeader( + MqttMessageType.CONNACK, false, MqttQoS.AT_MOST_ONCE, false, 0); + MqttConnAckVariableHeader variableHeader = new MqttConnAckVariableHeader(returnCode, false); + MqttConnAckMessage connAckMessage = new MqttConnAckMessage(fixedHeader, variableHeader); + + // 编码并发送 + sendMqttMessage(socket, connAckMessage); + + log.debug("[sendConnAck][发送 CONNACK 消息,returnCode: {}]", returnCode); + } catch (Exception e) { + log.error("[sendConnAck][发送 CONNACK 消息失败]", e); + } + } + + /** + * 发送 PUBACK 消息(QoS 1 确认) + */ + private void sendPubAck(ServerWebSocket socket, int messageId) { + try { + // 构建 PUBACK 消息 + MqttFixedHeader fixedHeader = new MqttFixedHeader( + MqttMessageType.PUBACK, false, MqttQoS.AT_MOST_ONCE, false, 0); + MqttMessageIdVariableHeader variableHeader = MqttMessageIdVariableHeader.from(messageId); + MqttMessage pubAckMessage = new MqttMessage(fixedHeader, variableHeader); + + // 编码并发送 + sendMqttMessage(socket, pubAckMessage); + + log.debug("[sendPubAck][发送 PUBACK 消息,messageId: {}]", messageId); + } catch (Exception e) { + log.error("[sendPubAck][发送 PUBACK 消息失败,messageId: {}]", messageId, e); + } + } + + /** + * 发送 PUBREC 消息(QoS 2 第一步确认) + */ + private void sendPubRec(ServerWebSocket socket, int messageId) { + try { + // 构建 PUBREC 消息 + MqttFixedHeader fixedHeader = new MqttFixedHeader( + MqttMessageType.PUBREC, false, MqttQoS.AT_MOST_ONCE, false, 0); + MqttMessageIdVariableHeader variableHeader = MqttMessageIdVariableHeader.from(messageId); + MqttMessage pubRecMessage = new MqttMessage(fixedHeader, variableHeader); + + // 编码并发送 + sendMqttMessage(socket, pubRecMessage); + + log.debug("[sendPubRec][发送 PUBREC 消息,messageId: {}]", messageId); + } catch (Exception e) { + log.error("[sendPubRec][发送 PUBREC 消息失败,messageId: {}]", messageId, e); + } + } + + /** + * 发送 PUBREL 消息(QoS 2 第二步) + */ + private void sendPubRel(ServerWebSocket socket, int messageId) { + try { + // 构建 PUBREL 消息 + MqttFixedHeader fixedHeader = new MqttFixedHeader( + MqttMessageType.PUBREL, false, MqttQoS.AT_LEAST_ONCE, false, 0); + MqttMessageIdVariableHeader variableHeader = MqttMessageIdVariableHeader.from(messageId); + MqttMessage pubRelMessage = new MqttMessage(fixedHeader, variableHeader); + + // 编码并发送 + sendMqttMessage(socket, pubRelMessage); + + log.debug("[sendPubRel][发送 PUBREL 消息,messageId: {}]", messageId); + } catch (Exception e) { + log.error("[sendPubRel][发送 PUBREL 消息失败,messageId: {}]", messageId, e); + } + } + + /** + * 发送 PUBCOMP 消息(QoS 2 完成确认) + */ + private void sendPubComp(ServerWebSocket socket, int messageId) { + try { + // 构建 PUBCOMP 消息 + MqttFixedHeader fixedHeader = new MqttFixedHeader( + MqttMessageType.PUBCOMP, false, MqttQoS.AT_MOST_ONCE, false, 0); + MqttMessageIdVariableHeader variableHeader = MqttMessageIdVariableHeader.from(messageId); + MqttMessage pubCompMessage = new MqttMessage(fixedHeader, variableHeader); + + // 编码并发送 + sendMqttMessage(socket, pubCompMessage); + + log.debug("[sendPubComp][发送 PUBCOMP 消息,messageId: {}]", messageId); + } catch (Exception e) { + log.error("[sendPubComp][发送 PUBCOMP 消息失败,messageId: {}]", messageId, e); + } + } + + /** + * 发送 SUBACK 消息 + */ + private void sendSubAck(ServerWebSocket socket, int messageId, int[] grantedQosList) { + try { + // 构建 SUBACK 消息 + MqttFixedHeader fixedHeader = new MqttFixedHeader( + MqttMessageType.SUBACK, false, MqttQoS.AT_MOST_ONCE, false, 0); + MqttMessageIdVariableHeader variableHeader = MqttMessageIdVariableHeader.from(messageId); + MqttSubAckPayload payload = new MqttSubAckPayload(grantedQosList); + MqttSubAckMessage subAckMessage = new MqttSubAckMessage(fixedHeader, variableHeader, payload); + + // 编码并发送 + sendMqttMessage(socket, subAckMessage); + + log.debug("[sendSubAck][发送 SUBACK 消息,messageId: {},主题数量: {}]", messageId, grantedQosList.length); + } catch (Exception e) { + log.error("[sendSubAck][发送 SUBACK 消息失败,messageId: {}]", messageId, e); + } + } + + /** + * 发送 UNSUBACK 消息 + */ + private void sendUnsubAck(ServerWebSocket socket, int messageId) { + try { + // 构建 UNSUBACK 消息 + MqttFixedHeader fixedHeader = new MqttFixedHeader( + MqttMessageType.UNSUBACK, false, MqttQoS.AT_MOST_ONCE, false, 0); + MqttMessageIdVariableHeader variableHeader = MqttMessageIdVariableHeader.from(messageId); + MqttUnsubAckMessage unsubAckMessage = new MqttUnsubAckMessage(fixedHeader, variableHeader); + + // 编码并发送 + sendMqttMessage(socket, unsubAckMessage); + + log.debug("[sendUnsubAck][发送 UNSUBACK 消息,messageId: {}]", messageId); + } catch (Exception e) { + log.error("[sendUnsubAck][发送 UNSUBACK 消息失败,messageId: {}]", messageId, e); + } + } + + /** + * 发送 PINGRESP 消息 + */ + private void sendPingResp(ServerWebSocket socket) { + try { + // 构建 PINGRESP 消息 + MqttFixedHeader fixedHeader = new MqttFixedHeader( + MqttMessageType.PINGRESP, false, MqttQoS.AT_MOST_ONCE, false, 0); + MqttMessage pingRespMessage = new MqttMessage(fixedHeader); + + // 编码并发送 + sendMqttMessage(socket, pingRespMessage); + + log.debug("[sendPingResp][发送 PINGRESP 消息]"); + } catch (Exception e) { + log.error("[sendPingResp][发送 PINGRESP 消息失败]", e); + } + } + + /** + * 发送 MQTT 消息到 WebSocket + */ + private void sendMqttMessage(ServerWebSocket socket, MqttMessage mqttMessage) { + ByteBuf byteBuf = null; + try { + // 使用 EmbeddedChannel 编码 MQTT 消息 + EmbeddedChannel encoderChannel = encoderChannelThreadLocal.get(); + encoderChannel.writeOutbound(mqttMessage); + + // 读取编码后的 ByteBuf + byteBuf = encoderChannel.readOutbound(); + if (byteBuf != null) { + byte[] bytes = new byte[byteBuf.readableBytes()]; + byteBuf.readBytes(bytes); + socket.writeBinaryMessage(Buffer.buffer(bytes)); + } + } finally { + if (byteBuf != null) { + byteBuf.release(); + } + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/resources/application.yaml b/yudao-module-iot/yudao-module-iot-gateway/src/main/resources/application.yaml index b85e84c170..f633f1c60b 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/resources/application.yaml +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/resources/application.yaml @@ -48,7 +48,7 @@ yudao: # 针对引入的 HTTP 组件的配置 # ==================================== http: - enabled: true + enabled: false server-port: 8092 # ==================================== # 针对引入的 EMQX 组件的配置 @@ -99,11 +99,24 @@ yudao: # 针对引入的 MQTT 组件的配置 # ==================================== mqtt: - enabled: true + enabled: false port: 1883 max-message-size: 8192 connect-timeout-seconds: 60 ssl-enabled: false + # ==================================== + # 针对引入的 MQTT WebSocket 组件的配置 + # ==================================== + mqtt-ws: + enabled: true # 是否启用 MQTT WebSocket + port: 8083 # WebSocket 服务端口 + path: /mqtt # WebSocket 路径 + max-message-size: 8192 # 最大消息大小(字节) + max-frame-size: 65536 # 最大帧大小(字节) + connect-timeout-seconds: 60 # 连接超时时间(秒) + keep-alive-timeout-seconds: 300 # 保持连接超时时间(秒) + ssl-enabled: false # 是否启用 SSL(wss://) + sub-protocol: mqtt # WebSocket 子协议 --- #################### 日志相关配置 #################### @@ -123,6 +136,7 @@ logging: cn.iocoder.yudao.module.iot.gateway.protocol.emqx: DEBUG cn.iocoder.yudao.module.iot.gateway.protocol.http: DEBUG cn.iocoder.yudao.module.iot.gateway.protocol.mqtt: DEBUG + cn.iocoder.yudao.module.iot.gateway.protocol.mqttws: DEBUG # 根日志级别 root: INFO diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/resources/mqtt-websocket-test-client.html b/yudao-module-iot/yudao-module-iot-gateway/src/test/resources/mqtt-websocket-test-client.html new file mode 100644 index 0000000000..e0853ac6bf --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/resources/mqtt-websocket-test-client.html @@ -0,0 +1,888 @@ + + + + + + MQTT WebSocket 测试客户端 + + + +

+
+

🚀 MQTT WebSocket 测试客户端

+

RuoYi-Vue-Pro IoT 模块 - MQTT over WebSocket 在线测试工具

+
+ + +
+

📌 标准协议格式说明

+ +
+ +
+ +
+

📡 连接配置

+ +
+ ⚫ 未连接 +
+ +
+ + + WebSocket 地址,支持 ws:// 和 wss:// +
+ +
+ + + 格式:{productKey}.{deviceName} +
+ +
+ + + 格式:{deviceName}&{productKey} +
+ +
+ + + 设备的认证密钥(Device Secret) +
+ +
+ + + +
+ + +
+
+
0
+
发送消息数
+
+
+
0
+
接收消息数
+
+
+
0
+
错误次数
+
+
+
+ + +
+

📤 消息发布

+ +
+ + +
+ +
+ + + 标准格式:/sys/{productKey}/{deviceName}/thing/property/post +
+ +
+ + +
+ +
+ + + + Alink 协议格式:id(消息 ID)、version(协议版本)、method(方法)、params(参数) + +
+ +
+ + +
+ +

📥 主题订阅

+ +
+ + +
+ +
+ + + 标准格式:/sys/{productKey}/{deviceName}/thing/method 或使用通配符 + /sys/+/+/# +
+ +
+ + +
+ +
+ + +
+
+ + +
+

📝 日志输出

+
+
+
+
+ + + + + + + + + From ca014bdba5cbf4e3f428d9ee0a89866f81c6b832 Mon Sep 17 00:00:00 2001 From: haohao <1036606149@qq.com> Date: Sat, 6 Dec 2025 12:38:07 +0800 Subject: [PATCH 02/17] =?UTF-8?q?fix:=20=E3=80=90IoT=20=E7=89=A9=E8=81=94?= =?UTF-8?q?=E7=BD=91=E3=80=91=E4=BF=AE=E5=A4=8D=20IotDeviceServiceImpl=20?= =?UTF-8?q?=E4=B8=AD=E7=9A=84=E6=9B=B4=E6=96=B0=E6=94=AF=E6=8C=81=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E7=A1=AE=E4=BF=9D=E5=9C=A8=E4=B8=8D=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=9B=B4=E6=96=B0=E6=97=B6=E6=8A=9B=E5=87=BA=E5=BC=82?= =?UTF-8?q?=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yudao/module/iot/service/device/IotDeviceServiceImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceServiceImpl.java index 56f3818531..e8fe9c8098 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceServiceImpl.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceServiceImpl.java @@ -382,7 +382,7 @@ public class IotDeviceServiceImpl implements IotDeviceService { return; } // 2.2.2 如果存在,判断是否允许更新 - if (updateSupport) { + if (!updateSupport) { throw exception(DEVICE_KEY_EXISTS); } updateDevice(new IotDeviceSaveReqVO().setId(existDevice.getId()) From 6fdd91d01eb3ad619854be795a9f25a1d5e08f56 Mon Sep 17 00:00:00 2001 From: haohao <1036606149@qq.com> Date: Sun, 4 Jan 2026 12:21:54 +0800 Subject: [PATCH 03/17] =?UTF-8?q?feat:=20=E3=80=90IoT=20=E7=89=A9=E8=81=94?= =?UTF-8?q?=E7=BD=91=E3=80=91=E6=96=B0=E5=A2=9E=E7=BD=91=E5=85=B3=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=20ID=20=E5=AD=97=E6=AE=B5=E5=88=B0=20IotDevicePageReq?= =?UTF-8?q?VO=EF=BC=8C=E6=94=AF=E6=8C=81=E7=BD=91=E5=85=B3=E5=AD=90?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E7=9A=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/admin/device/vo/device/IotDevicePageReqVO.java | 3 +++ .../yudao/module/iot/dal/mysql/device/IotDeviceMapper.java | 1 + 2 files changed, 4 insertions(+) diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDevicePageReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDevicePageReqVO.java index f7d515df96..e527242fb3 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDevicePageReqVO.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDevicePageReqVO.java @@ -31,4 +31,7 @@ public class IotDevicePageReqVO extends PageParam { @Schema(description = "设备分组编号", example = "1024") private Long groupId; + @Schema(description = "网关设备 ID", example = "16380") + private Long gatewayId; + } \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/device/IotDeviceMapper.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/device/IotDeviceMapper.java index 606cf8f033..7423f943ce 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/device/IotDeviceMapper.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/device/IotDeviceMapper.java @@ -31,6 +31,7 @@ public interface IotDeviceMapper extends BaseMapperX { .eqIfPresent(IotDeviceDO::getDeviceType, reqVO.getDeviceType()) .likeIfPresent(IotDeviceDO::getNickname, reqVO.getNickname()) .eqIfPresent(IotDeviceDO::getState, reqVO.getStatus()) + .eqIfPresent(IotDeviceDO::getGatewayId, reqVO.getGatewayId()) .apply(ObjectUtil.isNotNull(reqVO.getGroupId()), "FIND_IN_SET(" + reqVO.getGroupId() + ",group_ids) > 0") .orderByDesc(IotDeviceDO::getId)); } From 50a88a9ce76af69bd4b05ceae449a02b91ed1d29 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Mon, 5 Jan 2026 20:26:16 +0800 Subject: [PATCH 04/17] =?UTF-8?q?review=EF=BC=9A=E3=80=90iot=E3=80=91mqtt?= =?UTF-8?q?=20websocket=20=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../manager/IotMqttWsConnectionManager.java | 10 +++++---- .../router/IotMqttWsDownstreamHandler.java | 10 ++++----- .../router/IotMqttWsUpstreamHandler.java | 21 ------------------- 3 files changed, 11 insertions(+), 30 deletions(-) diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/manager/IotMqttWsConnectionManager.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/manager/IotMqttWsConnectionManager.java index 2bf61bea83..fee3e359c8 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/manager/IotMqttWsConnectionManager.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/manager/IotMqttWsConnectionManager.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.manager; +import cn.hutool.core.collection.CollUtil; import io.vertx.core.http.ServerWebSocket; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -187,7 +188,7 @@ public class IotMqttWsConnectionManager { */ public boolean isSubscribed(String deviceKey, String topic) { Set subscriptions = deviceSubscriptions.get(deviceKey); - if (subscriptions == null || subscriptions.isEmpty()) { + if (CollUtil.isEmpty(subscriptions)) { return false; } @@ -210,6 +211,7 @@ public class IotMqttWsConnectionManager { return deviceSubscriptions.get(deviceKey); } + // TODO @haohao:这个方法,是不是也可以考虑抽到 IotMqttTopicUtils 里面去哈;感觉更简洁一点? /** * MQTT 主题匹配 * 支持通配符: @@ -227,25 +229,25 @@ public class IotMqttWsConnectionManager { } // 不包含通配符 + // TODO @haohao:这里要不要枚举下哈;+ # if (!subscription.contains("+") && !subscription.contains("#")) { return false; } String[] subscriptionParts = subscription.split("/"); String[] topicParts = topic.split("/"); - int i = 0; for (; i < subscriptionParts.length && i < topicParts.length; i++) { String subPart = subscriptionParts[i]; String topicPart = topicParts[i]; + // # 匹配剩余所有层级,且必须在末尾 if (subPart.equals("#")) { - // # 匹配剩余所有层级,且必须在末尾 return i == subscriptionParts.length - 1; } + // 不是通配符且不匹配 if (!subPart.equals("+") && !subPart.equals(topicPart)) { - // 不是通配符且不匹配 return false; } } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/router/IotMqttWsDownstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/router/IotMqttWsDownstreamHandler.java index 37148de7e5..3aeb6c5c48 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/router/IotMqttWsDownstreamHandler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/router/IotMqttWsDownstreamHandler.java @@ -148,7 +148,7 @@ public class IotMqttWsDownstreamHandler { try { int messageId = qos > 0 ? generateMessageId() : 0; - // 手动编码MQTT PUBLISH消息 + // 手动编码 MQTT PUBLISH 消息 io.netty.buffer.ByteBuf byteBuf = io.netty.buffer.Unpooled.buffer(); // 固定头:消息类型(PUBLISH=3) + DUP(0) + QoS + RETAIN @@ -159,11 +159,11 @@ public class IotMqttWsDownstreamHandler { int topicLength = topic.getBytes().length; int remainingLength = 2 + topicLength + (qos > 0 ? 2 : 0) + payload.length; - // 写入剩余长度(简化版本,假设小于128字节) + // 写入剩余长度(简化版本,假设小于 128 字节) if (remainingLength < 128) { byteBuf.writeByte(remainingLength); } else { - // 处理大于127的情况 + // 处理大于 127 的情况 int x = remainingLength; do { int encodedByte = x % 128; @@ -179,7 +179,7 @@ public class IotMqttWsDownstreamHandler { byteBuf.writeShort(topicLength); byteBuf.writeBytes(topic.getBytes()); - // 可变头:消息ID(仅QoS>0时) + // 可变头:消息 ID(仅 QoS > 0 时) if (qos > 0) { byteBuf.writeShort(messageId); } @@ -191,7 +191,6 @@ public class IotMqttWsDownstreamHandler { byte[] bytes = new byte[byteBuf.readableBytes()]; byteBuf.readBytes(bytes); byteBuf.release(); - socket.writeBinaryMessage(Buffer.buffer(bytes)); log.info("[sendMessageToDevice][消息已发送到设备,deviceKey: {},topic: {},qos: {},messageId: {}]", @@ -211,6 +210,7 @@ public class IotMqttWsDownstreamHandler { private int generateMessageId() { int id = messageIdGenerator.getAndIncrement(); // MQTT 消息 ID 范围是 1-65535 + // TODO @haohao:并发可能有问题; if (id > 65535) { messageIdGenerator.set(1); return 1; diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/router/IotMqttWsUpstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/router/IotMqttWsUpstreamHandler.java index d9688b7974..d11d109502 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/router/IotMqttWsUpstreamHandler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/router/IotMqttWsUpstreamHandler.java @@ -253,7 +253,6 @@ public class IotMqttWsUpstreamHandler { // 5. 发送设备上线消息 sendOnlineMessage(device); - } catch (Exception e) { log.error("[handleConnect][处理 CONNECT 消息失败,socketId: {}]", socketId, e); sendConnAck(socket, MqttConnectReturnCode.CONNECTION_REFUSED_SERVER_UNAVAILABLE); @@ -311,7 +310,6 @@ public class IotMqttWsUpstreamHandler { sendPubRec(socket, messageId); } // QoS 0 无需确认 - } catch (Exception e) { log.error("[handlePublish][处理 PUBLISH 消息失败,deviceId: {}]", device.getId(), e); } @@ -323,7 +321,6 @@ public class IotMqttWsUpstreamHandler { private void handlePubAck(ServerWebSocket socket, MqttMessage message) { String socketId = socketIdMap.get(socket); IotDeviceRespDTO device = socketDeviceMap.get(socketId); - if (device == null) { log.warn("[handlePubAck][设备未认证,socketId: {}]", socketId); socket.close(); @@ -340,7 +337,6 @@ public class IotMqttWsUpstreamHandler { private void handlePubRec(ServerWebSocket socket, MqttMessage message) { String socketId = socketIdMap.get(socket); IotDeviceRespDTO device = socketDeviceMap.get(socketId); - if (device == null) { log.warn("[handlePubRec][设备未认证,socketId: {}]", socketId); socket.close(); @@ -349,7 +345,6 @@ public class IotMqttWsUpstreamHandler { int messageId = ((MqttMessageIdVariableHeader) message.variableHeader()).messageId(); log.debug("[handlePubRec][收到 PUBREC,messageId: {},deviceId: {}]", messageId, device.getId()); - // 发送 PUBREL sendPubRel(socket, messageId); } @@ -369,7 +364,6 @@ public class IotMqttWsUpstreamHandler { int messageId = ((MqttMessageIdVariableHeader) message.variableHeader()).messageId(); log.debug("[handlePubRel][收到 PUBREL,messageId: {},deviceId: {}]", messageId, device.getId()); - // 发送 PUBCOMP sendPubComp(socket, messageId); } @@ -397,7 +391,6 @@ public class IotMqttWsUpstreamHandler { private void handleSubscribe(ServerWebSocket socket, MqttSubscribeMessage message) { String socketId = socketIdMap.get(socket); IotDeviceRespDTO device = socketDeviceMap.get(socketId); - if (device == null) { log.warn("[handleSubscribe][设备未认证,socketId: {}]", socketId); socket.close(); @@ -429,7 +422,6 @@ public class IotMqttWsUpstreamHandler { // 3. 发送 SUBACK sendSubAck(socket, messageId, grantedQosList); - } catch (Exception e) { log.error("[handleSubscribe][处理 SUBSCRIBE 消息失败,deviceId: {}]", device.getId(), e); } @@ -441,7 +433,6 @@ public class IotMqttWsUpstreamHandler { private void handleUnsubscribe(ServerWebSocket socket, MqttUnsubscribeMessage message) { String socketId = socketIdMap.get(socket); IotDeviceRespDTO device = socketDeviceMap.get(socketId); - if (device == null) { log.warn("[handleUnsubscribe][设备未认证,socketId: {}]", socketId); socket.close(); @@ -465,7 +456,6 @@ public class IotMqttWsUpstreamHandler { // 3. 发送 UNSUBACK sendUnsubAck(socket, messageId); - } catch (Exception e) { log.error("[handleUnsubscribe][处理 UNSUBSCRIBE 消息失败,deviceId: {}]", device.getId(), e); } @@ -477,7 +467,6 @@ public class IotMqttWsUpstreamHandler { private void handlePingReq(ServerWebSocket socket) { String socketId = socketIdMap.get(socket); IotDeviceRespDTO device = socketDeviceMap.get(socketId); - if (device == null) { log.warn("[handlePingReq][设备未认证,socketId: {}]", socketId); socket.close(); @@ -485,7 +474,6 @@ public class IotMqttWsUpstreamHandler { } log.debug("[handlePingReq][收到心跳请求,deviceId: {}]", device.getId()); - // 发送 PINGRESP sendPingResp(socket); } @@ -496,7 +484,6 @@ public class IotMqttWsUpstreamHandler { private void handleDisconnect(ServerWebSocket socket) { String socketId = socketIdMap.get(socket); IotDeviceRespDTO device = socketDeviceMap.remove(socketId); - if (device != null) { String deviceKey = device.getProductKey() + ":" + device.getDeviceName(); connectionManager.removeConnection(deviceKey); @@ -600,7 +587,6 @@ public class IotMqttWsUpstreamHandler { // 编码并发送 sendMqttMessage(socket, connAckMessage); - log.debug("[sendConnAck][发送 CONNACK 消息,returnCode: {}]", returnCode); } catch (Exception e) { log.error("[sendConnAck][发送 CONNACK 消息失败]", e); @@ -620,7 +606,6 @@ public class IotMqttWsUpstreamHandler { // 编码并发送 sendMqttMessage(socket, pubAckMessage); - log.debug("[sendPubAck][发送 PUBACK 消息,messageId: {}]", messageId); } catch (Exception e) { log.error("[sendPubAck][发送 PUBACK 消息失败,messageId: {}]", messageId, e); @@ -640,7 +625,6 @@ public class IotMqttWsUpstreamHandler { // 编码并发送 sendMqttMessage(socket, pubRecMessage); - log.debug("[sendPubRec][发送 PUBREC 消息,messageId: {}]", messageId); } catch (Exception e) { log.error("[sendPubRec][发送 PUBREC 消息失败,messageId: {}]", messageId, e); @@ -660,7 +644,6 @@ public class IotMqttWsUpstreamHandler { // 编码并发送 sendMqttMessage(socket, pubRelMessage); - log.debug("[sendPubRel][发送 PUBREL 消息,messageId: {}]", messageId); } catch (Exception e) { log.error("[sendPubRel][发送 PUBREL 消息失败,messageId: {}]", messageId, e); @@ -680,7 +663,6 @@ public class IotMqttWsUpstreamHandler { // 编码并发送 sendMqttMessage(socket, pubCompMessage); - log.debug("[sendPubComp][发送 PUBCOMP 消息,messageId: {}]", messageId); } catch (Exception e) { log.error("[sendPubComp][发送 PUBCOMP 消息失败,messageId: {}]", messageId, e); @@ -701,7 +683,6 @@ public class IotMqttWsUpstreamHandler { // 编码并发送 sendMqttMessage(socket, subAckMessage); - log.debug("[sendSubAck][发送 SUBACK 消息,messageId: {},主题数量: {}]", messageId, grantedQosList.length); } catch (Exception e) { log.error("[sendSubAck][发送 SUBACK 消息失败,messageId: {}]", messageId, e); @@ -721,7 +702,6 @@ public class IotMqttWsUpstreamHandler { // 编码并发送 sendMqttMessage(socket, unsubAckMessage); - log.debug("[sendUnsubAck][发送 UNSUBACK 消息,messageId: {}]", messageId); } catch (Exception e) { log.error("[sendUnsubAck][发送 UNSUBACK 消息失败,messageId: {}]", messageId, e); @@ -740,7 +720,6 @@ public class IotMqttWsUpstreamHandler { // 编码并发送 sendMqttMessage(socket, pingRespMessage); - log.debug("[sendPingResp][发送 PINGRESP 消息]"); } catch (Exception e) { log.error("[sendPingResp][发送 PINGRESP 消息失败]", e); From 7f038eb3201be2eec2d9e3eced022ac347323536 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Mon, 5 Jan 2026 21:08:44 +0800 Subject: [PATCH 05/17] =?UTF-8?q?fix=EF=BC=9A=E3=80=90iot=E3=80=91tdengine?= =?UTF-8?q?=20=E7=9A=84=20url=20=E5=A2=9E=E5=8A=A0=20varcharAsString=3Dtru?= =?UTF-8?q?e=20=E5=8F=82=E6=95=B0=EF=BC=8C=E8=A7=A3=E5=86=B3=20https://git?= =?UTF-8?q?hub.com/YunaiV/yudao-cloud/issues/282=20=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application-local.yaml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/yudao-server/src/main/resources/application-local.yaml b/yudao-server/src/main/resources/application-local.yaml index f9234cecfa..dc001c27a7 100644 --- a/yudao-server/src/main/resources/application-local.yaml +++ b/yudao-server/src/main/resources/application-local.yaml @@ -68,13 +68,13 @@ spring: url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true&nullCatalogMeansCurrent=true username: root password: 123456 -# tdengine: # IoT 数据库(需要 IoT 物联网再开启噢!) -# url: jdbc:TAOS-WS://127.0.0.1:6041/ruoyi_vue_pro -# driver-class-name: com.taosdata.jdbc.ws.WebSocketDriver -# username: root -# password: taosdata -# druid: -# validation-query: SELECT SERVER_STATUS() # TDengine 数据源的有效性检查 SQL + tdengine: # IoT 数据库(需要 IoT 物联网再开启噢!) + url: jdbc:TAOS-WS://127.0.0.1:6041/ruoyi_vue_pro?varcharAsString=true + driver-class-name: com.taosdata.jdbc.ws.WebSocketDriver + username: root + password: taosdata + druid: + validation-query: SELECT SERVER_STATUS() # TDengine 数据源的有效性检查 SQL # Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优 data: From dfd1f90ae057b696900ffc43f81dae6595c4f924 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Fri, 9 Jan 2026 19:38:32 +0800 Subject: [PATCH 06/17] =?UTF-8?q?fix=EF=BC=9A=E3=80=90bpm=E3=80=91getTaskT?= =?UTF-8?q?odoPage=20=E6=9C=AA=E5=9F=BA=E4=BA=8E=E7=A7=9F=E6=88=B7?= =?UTF-8?q?=E8=BF=87=E6=BB=A4=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yudao/module/bpm/service/task/BpmTaskServiceImpl.java | 1 + 1 file changed, 1 insertion(+) diff --git a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmTaskServiceImpl.java b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmTaskServiceImpl.java index 8bb9486594..89b7bb293d 100644 --- a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmTaskServiceImpl.java +++ b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmTaskServiceImpl.java @@ -116,6 +116,7 @@ public class BpmTaskServiceImpl implements BpmTaskService { .taskAssignee(String.valueOf(userId)) // 分配给自己 .active() .includeProcessVariables() + .taskTenantId(FlowableUtils.getTenantId()) .orderByTaskCreateTime().desc(); // 创建时间倒序 if (StrUtil.isNotBlank(pageVO.getName())) { taskQuery.taskNameLike("%" + pageVO.getName() + "%"); From 158576740dbb1d12ecead6c6853d51c857109984 Mon Sep 17 00:00:00 2001 From: puhui999 Date: Tue, 13 Jan 2026 15:29:29 +0800 Subject: [PATCH 07/17] =?UTF-8?q?feat=EF=BC=9A=E3=80=90iot=E3=80=91?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20WebSocket=20=E7=9A=84=20{@link=20IotDataRu?= =?UTF-8?q?leAction}=20=E5=AE=9E=E7=8E=B0=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../action/IotWebSocketDataRuleAction.java | 83 +++++++++ .../action/websocket/IotWebSocketClient.java | 175 ++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotWebSocketDataRuleAction.java create mode 100644 yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/websocket/IotWebSocketClient.java diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotWebSocketDataRuleAction.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotWebSocketDataRuleAction.java new file mode 100644 index 0000000000..5e2750980c --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotWebSocketDataRuleAction.java @@ -0,0 +1,83 @@ +package cn.iocoder.yudao.module.iot.service.rule.data.action; + +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkWebSocketConfig; +import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum; +import cn.iocoder.yudao.module.iot.service.rule.data.action.websocket.IotWebSocketClient; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * WebSocket 的 {@link IotDataRuleAction} 实现类 + *

+ * 负责将设备消息发送到外部 WebSocket 服务器 + * 支持 ws:// 和 wss:// 协议,支持 JSON 和 TEXT 数据格式 + * 使用连接池管理 WebSocket 连接,提高性能和资源利用率 + * + * @author HUIHUI + */ +@Component +@Slf4j +public class IotWebSocketDataRuleAction extends + IotDataRuleCacheableAction { + + @Override + public Integer getType() { + return IotDataSinkTypeEnum.WEBSOCKET.getType(); + } + + @Override + protected IotWebSocketClient initProducer(IotDataSinkWebSocketConfig config) throws Exception { + // 1.1 参数校验 + if (config.getServerUrl() == null || config.getServerUrl().trim().isEmpty()) { + throw new IllegalArgumentException("WebSocket 服务器地址不能为空"); + } + if (!config.getServerUrl().startsWith("ws://") && !config.getServerUrl().startsWith("wss://")) { + throw new IllegalArgumentException("WebSocket 服务器地址必须以 ws:// 或 wss:// 开头"); + } + + // 2.1 创建 WebSocket 客户端 + IotWebSocketClient webSocketClient = new IotWebSocketClient( + config.getServerUrl(), + config.getConnectTimeoutMs(), + config.getSendTimeoutMs(), + config.getDataFormat() + ); + // 2.2 连接服务器 + webSocketClient.connect(); + log.info("[initProducer][WebSocket 客户端创建并连接成功,服务器: {},数据格式: {}]", + config.getServerUrl(), config.getDataFormat()); + return webSocketClient; + } + + @Override + protected void closeProducer(IotWebSocketClient producer) throws Exception { + if (producer != null) { + producer.close(); + } + } + + @Override + protected void execute(IotDeviceMessage message, IotDataSinkWebSocketConfig config) throws Exception { + try { + // 1.1 获取或创建 WebSocket 客户端 + IotWebSocketClient webSocketClient = getProducer(config); + // 1.2 检查连接状态,如果断开则重新连接 + if (!webSocketClient.isConnected()) { + log.warn("[execute][WebSocket 连接已断开,尝试重新连接,服务器: {}]", config.getServerUrl()); + webSocketClient.connect(); + } + + // 2.1 发送消息 + webSocketClient.sendMessage(message); + // 2.2 记录发送成功日志 + log.info("[execute][message({}) config({}) 发送成功,WebSocket 服务器: {}]", + message, config, config.getServerUrl()); + } catch (Exception e) { + log.error("[execute][message({}) config({}) 发送失败,WebSocket 服务器: {}]", + message, config, config.getServerUrl(), e); + throw e; + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/websocket/IotWebSocketClient.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/websocket/IotWebSocketClient.java new file mode 100644 index 0000000000..15c3cd1ae3 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/websocket/IotWebSocketClient.java @@ -0,0 +1,175 @@ +package cn.iocoder.yudao.module.iot.service.rule.data.action.websocket; + +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import lombok.extern.slf4j.Slf4j; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * IoT WebSocket 客户端 + *

+ * 负责与外部 WebSocket 服务器建立连接并发送设备消息 + * 支持 ws:// 和 wss:// 协议,支持 JSON 和 TEXT 数据格式 + * 基于 Java 11+ 内置的 java.net.http.WebSocket 实现 + * + * @author HUIHUI + */ +@Slf4j +public class IotWebSocketClient implements WebSocket.Listener { + + private final String serverUrl; + private final Integer connectTimeoutMs; + private final Integer sendTimeoutMs; + private final String dataFormat; + + private WebSocket webSocket; + private final AtomicBoolean connected = new AtomicBoolean(false); + private final StringBuilder messageBuffer = new StringBuilder(); + + public IotWebSocketClient(String serverUrl, Integer connectTimeoutMs, Integer sendTimeoutMs, String dataFormat) { + this.serverUrl = serverUrl; + this.connectTimeoutMs = connectTimeoutMs != null ? connectTimeoutMs : 5000; + this.sendTimeoutMs = sendTimeoutMs != null ? sendTimeoutMs : 10000; + this.dataFormat = dataFormat != null ? dataFormat : "JSON"; + } + + /** + * 连接到 WebSocket 服务器 + */ + public void connect() throws Exception { + if (connected.get()) { + log.warn("[connect][WebSocket 客户端已经连接,无需重复连接]"); + return; + } + + try { + HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofMillis(connectTimeoutMs)) + .build(); + + CompletableFuture future = httpClient.newWebSocketBuilder() + .connectTimeout(Duration.ofMillis(connectTimeoutMs)) + .buildAsync(URI.create(serverUrl), this); + + // 等待连接完成 + webSocket = future.get(connectTimeoutMs, TimeUnit.MILLISECONDS); + connected.set(true); + log.info("[connect][WebSocket 客户端连接成功,服务器地址: {}]", serverUrl); + } catch (Exception e) { + close(); + log.error("[connect][WebSocket 客户端连接失败,服务器地址: {}]", serverUrl, e); + throw e; + } + } + + @Override + public void onOpen(WebSocket webSocket) { + log.debug("[onOpen][WebSocket 连接已打开]"); + webSocket.request(1); + } + + @Override + public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { + messageBuffer.append(data); + if (last) { + log.debug("[onText][收到 WebSocket 消息: {}]", messageBuffer); + messageBuffer.setLength(0); + } + webSocket.request(1); + return null; + } + + @Override + public CompletionStage onClose(WebSocket webSocket, int statusCode, String reason) { + connected.set(false); + log.info("[onClose][WebSocket 连接已关闭,状态码: {},原因: {}]", statusCode, reason); + return null; + } + + @Override + public void onError(WebSocket webSocket, Throwable error) { + connected.set(false); + log.error("[onError][WebSocket 发生错误]", error); + } + + /** + * 发送设备消息 + * + * @param message 设备消息 + * @throws Exception 发送异常 + */ + public void sendMessage(IotDeviceMessage message) throws Exception { + if (!connected.get() || webSocket == null) { + throw new IllegalStateException("WebSocket 客户端未连接"); + } + + try { + String messageData; + if ("JSON".equalsIgnoreCase(dataFormat)) { + messageData = JsonUtils.toJsonString(message); + } else { + messageData = message.toString(); + } + + // 发送消息并等待完成 + CompletableFuture future = webSocket.sendText(messageData, true); + future.get(sendTimeoutMs, TimeUnit.MILLISECONDS); + log.debug("[sendMessage][发送消息成功,设备 ID: {},消息长度: {}]", + message.getDeviceId(), messageData.length()); + } catch (Exception e) { + log.error("[sendMessage][发送消息失败,设备 ID: {}]", message.getDeviceId(), e); + throw e; + } + } + + /** + * 关闭连接 + */ + public void close() { + if (!connected.get() && webSocket == null) { + return; + } + + try { + if (webSocket != null) { + webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "客户端主动关闭") + .orTimeout(5, TimeUnit.SECONDS) + .exceptionally(e -> { + log.warn("[close][发送关闭帧失败]", e); + return null; + }); + } + connected.set(false); + log.info("[close][WebSocket 客户端连接已关闭,服务器地址: {}]", serverUrl); + } catch (Exception e) { + log.error("[close][关闭 WebSocket 客户端连接异常]", e); + } + } + + /** + * 检查连接状态 + * + * @return 是否已连接 + */ + public boolean isConnected() { + return connected.get() && webSocket != null; + } + + @Override + public String toString() { + return "IotWebSocketClient{" + + "serverUrl='" + serverUrl + '\'' + + ", dataFormat='" + dataFormat + '\'' + + ", connected=" + connected.get() + + '}'; + } + +} From 60817a6a5ba8aaa60ba63c280461dbd846cbe127 Mon Sep 17 00:00:00 2001 From: puhui999 Date: Tue, 13 Jan 2026 15:38:33 +0800 Subject: [PATCH 08/17] =?UTF-8?q?perf=EF=BC=9A=E3=80=90iot=E3=80=91IotData?= =?UTF-8?q?SinkTcpConfig=20=E5=B8=B8=E9=87=8F=E6=9E=9A=E4=B8=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rule/config/IotDataSinkTcpConfig.java | 43 ++++++++++++++++--- .../rule/data/action/tcp/IotTcpClient.java | 13 +++--- 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotDataSinkTcpConfig.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotDataSinkTcpConfig.java index 3d96f11ceb..513a987f2f 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotDataSinkTcpConfig.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotDataSinkTcpConfig.java @@ -10,6 +10,35 @@ import lombok.Data; @Data public class IotDataSinkTcpConfig extends IotAbstractDataSinkConfig { + /** + * 默认连接超时时间(毫秒) + */ + public static final int DEFAULT_CONNECT_TIMEOUT_MS = 5000; + /** + * 默认读取超时时间(毫秒) + */ + public static final int DEFAULT_READ_TIMEOUT_MS = 10000; + /** + * 默认是否启用 SSL + */ + public static final boolean DEFAULT_SSL = false; + /** + * 默认数据格式 + */ + public static final String DEFAULT_DATA_FORMAT = "JSON"; + /** + * 默认心跳间隔时间(毫秒) + */ + public static final long DEFAULT_HEARTBEAT_INTERVAL_MS = 30000L; + /** + * 默认重连间隔时间(毫秒) + */ + public static final long DEFAULT_RECONNECT_INTERVAL_MS = 5000L; + /** + * 默认最大重连次数 + */ + public static final int DEFAULT_MAX_RECONNECT_ATTEMPTS = 3; + /** * TCP 服务器地址 */ @@ -23,17 +52,17 @@ public class IotDataSinkTcpConfig extends IotAbstractDataSinkConfig { /** * 连接超时时间(毫秒) */ - private Integer connectTimeoutMs = 5000; + private Integer connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS; /** * 读取超时时间(毫秒) */ - private Integer readTimeoutMs = 10000; + private Integer readTimeoutMs = DEFAULT_READ_TIMEOUT_MS; /** * 是否启用 SSL */ - private Boolean ssl = false; + private Boolean ssl = DEFAULT_SSL; /** * SSL 证书路径(当 ssl=true 时需要) @@ -43,21 +72,21 @@ public class IotDataSinkTcpConfig extends IotAbstractDataSinkConfig { /** * 数据格式:JSON 或 BINARY */ - private String dataFormat = "JSON"; + private String dataFormat = DEFAULT_DATA_FORMAT; /** * 心跳间隔时间(毫秒),0 表示不启用心跳 */ - private Long heartbeatIntervalMs = 30000L; + private Long heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS; /** * 重连间隔时间(毫秒) */ - private Long reconnectIntervalMs = 5000L; + private Long reconnectIntervalMs = DEFAULT_RECONNECT_INTERVAL_MS; /** * 最大重连次数 */ - private Integer maxReconnectAttempts = 3; + private Integer maxReconnectAttempts = DEFAULT_MAX_RECONNECT_ATTEMPTS; } \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/tcp/IotTcpClient.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/tcp/IotTcpClient.java index 1618532a4a..b417dca5a2 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/tcp/IotTcpClient.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/tcp/IotTcpClient.java @@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.iot.service.rule.data.action.tcp; import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkTcpConfig; import lombok.extern.slf4j.Slf4j; import javax.net.ssl.SSLSocketFactory; @@ -38,16 +39,15 @@ public class IotTcpClient { private BufferedReader reader; private final AtomicBoolean connected = new AtomicBoolean(false); - // TODO @puhui999:default 值,IotDataSinkTcpConfig.java 枚举起来哈; public IotTcpClient(String host, Integer port, Integer connectTimeoutMs, Integer readTimeoutMs, Boolean ssl, String sslCertPath, String dataFormat) { this.host = host; this.port = port; - this.connectTimeoutMs = connectTimeoutMs != null ? connectTimeoutMs : 5000; - this.readTimeoutMs = readTimeoutMs != null ? readTimeoutMs : 10000; - this.ssl = ssl != null ? ssl : false; + this.connectTimeoutMs = connectTimeoutMs != null ? connectTimeoutMs : IotDataSinkTcpConfig.DEFAULT_CONNECT_TIMEOUT_MS; + this.readTimeoutMs = readTimeoutMs != null ? readTimeoutMs : IotDataSinkTcpConfig.DEFAULT_READ_TIMEOUT_MS; + this.ssl = ssl != null ? ssl : IotDataSinkTcpConfig.DEFAULT_SSL; this.sslCertPath = sslCertPath; - this.dataFormat = dataFormat != null ? dataFormat : "JSON"; + this.dataFormat = dataFormat != null ? dataFormat : IotDataSinkTcpConfig.DEFAULT_DATA_FORMAT; } /** @@ -99,9 +99,8 @@ public class IotTcpClient { } try { - // TODO @puhui999:枚举值 String messageData; - if ("JSON".equalsIgnoreCase(dataFormat)) { + if (IotDataSinkTcpConfig.DEFAULT_DATA_FORMAT.equalsIgnoreCase(dataFormat)) { // JSON 格式 messageData = JsonUtils.toJsonString(message); } else { From 9fbced1192a453d6557085b3959acead2ee9c71c Mon Sep 17 00:00:00 2001 From: puhui999 Date: Tue, 13 Jan 2026 15:42:06 +0800 Subject: [PATCH 09/17] =?UTF-8?q?perf=EF=BC=9A=E3=80=90iot=E3=80=91IotData?= =?UTF-8?q?SinkWebSocketConfig=20=E5=B8=B8=E9=87=8F=E6=9E=9A=E4=B8=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/IotDataSinkWebSocketConfig.java | 67 ++++++++++++++++--- .../iot/enums/rule/IotDataSinkTypeEnum.java | 4 +- .../data/action/IotTcpDataRuleAction.java | 5 -- .../action/websocket/IotWebSocketClient.java | 9 +-- 4 files changed, 63 insertions(+), 22 deletions(-) diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotDataSinkWebSocketConfig.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotDataSinkWebSocketConfig.java index f1b7e86d86..55514da7c8 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotDataSinkWebSocketConfig.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotDataSinkWebSocketConfig.java @@ -13,6 +13,51 @@ import lombok.Data; @Data public class IotDataSinkWebSocketConfig extends IotAbstractDataSinkConfig { + /** + * 默认连接超时时间(毫秒) + */ + public static final int DEFAULT_CONNECT_TIMEOUT_MS = 5000; + /** + * 默认发送超时时间(毫秒) + */ + public static final int DEFAULT_SEND_TIMEOUT_MS = 10000; + /** + * 默认心跳间隔时间(毫秒) + */ + public static final long DEFAULT_HEARTBEAT_INTERVAL_MS = 30000L; + /** + * 默认心跳消息内容 + */ + public static final String DEFAULT_HEARTBEAT_MESSAGE = "{\"type\":\"heartbeat\"}"; + /** + * 默认是否启用 SSL 证书验证 + */ + public static final boolean DEFAULT_VERIFY_SSL_CERT = true; + /** + * 默认数据格式 + */ + public static final String DEFAULT_DATA_FORMAT = "JSON"; + /** + * 默认重连间隔时间(毫秒) + */ + public static final long DEFAULT_RECONNECT_INTERVAL_MS = 5000L; + /** + * 默认最大重连次数 + */ + public static final int DEFAULT_MAX_RECONNECT_ATTEMPTS = 3; + /** + * 默认是否启用压缩 + */ + public static final boolean DEFAULT_ENABLE_COMPRESSION = false; + /** + * 默认消息发送重试次数 + */ + public static final int DEFAULT_SEND_RETRY_COUNT = 1; + /** + * 默认消息发送重试间隔(毫秒) + */ + public static final long DEFAULT_SEND_RETRY_INTERVAL_MS = 1000L; + /** * WebSocket 服务器地址 * 例如:ws://localhost:8080/ws 或 wss://example.com/ws @@ -22,22 +67,22 @@ public class IotDataSinkWebSocketConfig extends IotAbstractDataSinkConfig { /** * 连接超时时间(毫秒) */ - private Integer connectTimeoutMs = 5000; + private Integer connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS; /** * 发送超时时间(毫秒) */ - private Integer sendTimeoutMs = 10000; + private Integer sendTimeoutMs = DEFAULT_SEND_TIMEOUT_MS; /** * 心跳间隔时间(毫秒),0 表示不启用心跳 */ - private Long heartbeatIntervalMs = 30000L; + private Long heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS; /** * 心跳消息内容(JSON 格式) */ - private String heartbeatMessage = "{\"type\":\"heartbeat\"}"; + private String heartbeatMessage = DEFAULT_HEARTBEAT_MESSAGE; /** * 子协议列表(逗号分隔) @@ -52,36 +97,36 @@ public class IotDataSinkWebSocketConfig extends IotAbstractDataSinkConfig { /** * 是否启用 SSL 证书验证(仅对 wss:// 生效) */ - private Boolean verifySslCert = true; + private Boolean verifySslCert = DEFAULT_VERIFY_SSL_CERT; /** * 数据格式:JSON 或 TEXT */ - private String dataFormat = "JSON"; + private String dataFormat = DEFAULT_DATA_FORMAT; /** * 重连间隔时间(毫秒) */ - private Long reconnectIntervalMs = 5000L; + private Long reconnectIntervalMs = DEFAULT_RECONNECT_INTERVAL_MS; /** * 最大重连次数 */ - private Integer maxReconnectAttempts = 3; + private Integer maxReconnectAttempts = DEFAULT_MAX_RECONNECT_ATTEMPTS; /** * 是否启用压缩 */ - private Boolean enableCompression = false; + private Boolean enableCompression = DEFAULT_ENABLE_COMPRESSION; /** * 消息发送重试次数 */ - private Integer sendRetryCount = 1; + private Integer sendRetryCount = DEFAULT_SEND_RETRY_COUNT; /** * 消息发送重试间隔(毫秒) */ - private Long sendRetryIntervalMs = 1000L; + private Long sendRetryIntervalMs = DEFAULT_SEND_RETRY_INTERVAL_MS; } \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotDataSinkTypeEnum.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotDataSinkTypeEnum.java index 45a557db61..440fab5f53 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotDataSinkTypeEnum.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotDataSinkTypeEnum.java @@ -16,8 +16,8 @@ import java.util.Arrays; public enum IotDataSinkTypeEnum implements ArrayValuable { HTTP(1, "HTTP"), - TCP(2, "TCP"), // TODO @puhui999:待实现; - WEBSOCKET(3, "WebSocket"), // TODO @puhui999:待实现; + TCP(2, "TCP"), + WEBSOCKET(3, "WebSocket"), MQTT(10, "MQTT"), // TODO 待实现; diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotTcpDataRuleAction.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotTcpDataRuleAction.java index 4db6dc205a..53a3b71480 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotTcpDataRuleAction.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotTcpDataRuleAction.java @@ -7,8 +7,6 @@ import cn.iocoder.yudao.module.iot.service.rule.data.action.tcp.IotTcpClient; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import java.time.Duration; - /** * TCP 的 {@link IotDataRuleAction} 实现类 *

@@ -23,9 +21,6 @@ import java.time.Duration; public class IotTcpDataRuleAction extends IotDataRuleCacheableAction { - private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5); - private static final Duration SEND_TIMEOUT = Duration.ofSeconds(10); - @Override public Integer getType() { return IotDataSinkTypeEnum.TCP.getType(); diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/websocket/IotWebSocketClient.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/websocket/IotWebSocketClient.java index 15c3cd1ae3..bed197657f 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/websocket/IotWebSocketClient.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/websocket/IotWebSocketClient.java @@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.iot.service.rule.data.action.websocket; import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkWebSocketConfig; import lombok.extern.slf4j.Slf4j; import java.net.URI; @@ -36,9 +37,9 @@ public class IotWebSocketClient implements WebSocket.Listener { public IotWebSocketClient(String serverUrl, Integer connectTimeoutMs, Integer sendTimeoutMs, String dataFormat) { this.serverUrl = serverUrl; - this.connectTimeoutMs = connectTimeoutMs != null ? connectTimeoutMs : 5000; - this.sendTimeoutMs = sendTimeoutMs != null ? sendTimeoutMs : 10000; - this.dataFormat = dataFormat != null ? dataFormat : "JSON"; + this.connectTimeoutMs = connectTimeoutMs != null ? connectTimeoutMs : IotDataSinkWebSocketConfig.DEFAULT_CONNECT_TIMEOUT_MS; + this.sendTimeoutMs = sendTimeoutMs != null ? sendTimeoutMs : IotDataSinkWebSocketConfig.DEFAULT_SEND_TIMEOUT_MS; + this.dataFormat = dataFormat != null ? dataFormat : IotDataSinkWebSocketConfig.DEFAULT_DATA_FORMAT; } /** @@ -113,7 +114,7 @@ public class IotWebSocketClient implements WebSocket.Listener { try { String messageData; - if ("JSON".equalsIgnoreCase(dataFormat)) { + if (IotDataSinkWebSocketConfig.DEFAULT_DATA_FORMAT.equalsIgnoreCase(dataFormat)) { messageData = JsonUtils.toJsonString(message); } else { messageData = message.toString(); From 908f95875df88a0e5f4e41e55c0549d760d15d62 Mon Sep 17 00:00:00 2001 From: puhui999 Date: Tue, 13 Jan 2026 15:49:19 +0800 Subject: [PATCH 10/17] =?UTF-8?q?fix=EF=BC=9A=E3=80=90iot=E3=80=91saveDevi?= =?UTF-8?q?ceProperty=20=E4=B8=AD=EF=BC=8C=E7=B1=BB=E5=9E=8B=E8=A6=81?= =?UTF-8?q?=E8=BD=AC=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../device/property/IotDevicePropertyServiceImpl.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImpl.java index 8031c2a11a..4e1be3a0ca 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImpl.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImpl.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.iot.service.device.property; import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.convert.Convert; import cn.hutool.core.date.LocalDateTimeUtil; import cn.hutool.core.map.MapUtil; import cn.hutool.core.util.StrUtil; @@ -145,6 +146,12 @@ public class IotDevicePropertyServiceImpl implements IotDevicePropertyService { IotDataSpecsDataTypeEnum.STRUCT.getDataType(), IotDataSpecsDataTypeEnum.ARRAY.getDataType())) { // 特殊:STRUCT 和 ARRAY 类型,在 TDengine 里,有没对应数据类型,只能通过 JSON 来存储 properties.put((String) key, JsonUtils.toJsonString(value)); + } else if (IotDataSpecsDataTypeEnum.DOUBLE.getDataType().equals(thingModel.getProperty().getDataType())) { + properties.put((String) key, Convert.toDouble(value)); + } else if (IotDataSpecsDataTypeEnum.FLOAT.getDataType().equals(thingModel.getProperty().getDataType())) { + properties.put((String) key, Convert.toFloat(value)); + } else if (IotDataSpecsDataTypeEnum.BOOL.getDataType().equals(thingModel.getProperty().getDataType())) { + properties.put((String) key, Convert.toByte(value)); } else { properties.put((String) key, value); } From a7f655e1e7ba87bca575246fc543dd20247b0123 Mon Sep 17 00:00:00 2001 From: puhui999 Date: Tue, 13 Jan 2026 16:04:08 +0800 Subject: [PATCH 11/17] =?UTF-8?q?fix=EF=BC=9A=E3=80=90iot=E3=80=91?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BD=93=E5=A4=9A=E4=B8=AA=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=E5=90=8C=E6=97=B6=E5=90=AF=E7=94=A8=E6=97=B6=EF=BC=8C=E5=87=BA?= =?UTF-8?q?=E7=8E=B0=20Bean=20=E5=86=B2=E7=AA=81=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/IotGatewayConfiguration.java | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java index fab4c8cc85..3e573efdde 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java @@ -21,6 +21,7 @@ import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; import io.vertx.core.Vertx; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -59,20 +60,20 @@ public class IotGatewayConfiguration { @Slf4j public static class EmqxProtocolConfiguration { - @Bean(destroyMethod = "close") + @Bean(name = "emqxVertx", destroyMethod = "close") public Vertx emqxVertx() { return Vertx.vertx(); } @Bean public IotEmqxAuthEventProtocol iotEmqxAuthEventProtocol(IotGatewayProperties gatewayProperties, - Vertx emqxVertx) { + @Qualifier("emqxVertx") Vertx emqxVertx) { return new IotEmqxAuthEventProtocol(gatewayProperties.getProtocol().getEmqx(), emqxVertx); } @Bean public IotEmqxUpstreamProtocol iotEmqxUpstreamProtocol(IotGatewayProperties gatewayProperties, - Vertx emqxVertx) { + @Qualifier("emqxVertx") Vertx emqxVertx) { return new IotEmqxUpstreamProtocol(gatewayProperties.getProtocol().getEmqx(), emqxVertx); } @@ -91,7 +92,7 @@ public class IotGatewayConfiguration { @Slf4j public static class TcpProtocolConfiguration { - @Bean(destroyMethod = "close") + @Bean(name = "tcpVertx", destroyMethod = "close") public Vertx tcpVertx() { return Vertx.vertx(); } @@ -101,7 +102,7 @@ public class IotGatewayConfiguration { IotDeviceService deviceService, IotDeviceMessageService messageService, IotTcpConnectionManager connectionManager, - Vertx tcpVertx) { + @Qualifier("tcpVertx") Vertx tcpVertx) { return new IotTcpUpstreamProtocol(gatewayProperties.getProtocol().getTcp(), deviceService, messageService, connectionManager, tcpVertx); } @@ -126,7 +127,7 @@ public class IotGatewayConfiguration { @Slf4j public static class MqttProtocolConfiguration { - @Bean(destroyMethod = "close") + @Bean(name = "mqttVertx", destroyMethod = "close") public Vertx mqttVertx() { return Vertx.vertx(); } @@ -135,7 +136,7 @@ public class IotGatewayConfiguration { public IotMqttUpstreamProtocol iotMqttUpstreamProtocol(IotGatewayProperties gatewayProperties, IotDeviceMessageService messageService, IotMqttConnectionManager connectionManager, - Vertx mqttVertx) { + @Qualifier("mqttVertx") Vertx mqttVertx) { return new IotMqttUpstreamProtocol(gatewayProperties.getProtocol().getMqtt(), messageService, connectionManager, mqttVertx); } @@ -163,7 +164,7 @@ public class IotGatewayConfiguration { @Slf4j public static class MqttWsProtocolConfiguration { - @Bean(destroyMethod = "close") + @Bean(name = "mqttWsVertx", destroyMethod = "close") public Vertx mqttWsVertx() { return Vertx.vertx(); } @@ -172,7 +173,7 @@ public class IotGatewayConfiguration { public IotMqttWsUpstreamProtocol iotMqttWsUpstreamProtocol(IotGatewayProperties gatewayProperties, IotDeviceMessageService messageService, IotMqttWsConnectionManager connectionManager, - Vertx mqttWsVertx) { + @Qualifier("mqttWsVertx") Vertx mqttWsVertx) { return new IotMqttWsUpstreamProtocol(gatewayProperties.getProtocol().getMqttWs(), messageService, connectionManager, mqttWsVertx); } From a42202e7eb9990e62a0e4d24e4b76c68134cc2b6 Mon Sep 17 00:00:00 2001 From: puhui999 Date: Tue, 13 Jan 2026 16:31:43 +0800 Subject: [PATCH 12/17] =?UTF-8?q?fix=EF=BC=9A=E3=80=90iot=E3=80=91?= =?UTF-8?q?=E5=B1=9E=E6=80=A7=E4=B8=8A=E6=8A=A5=E5=8F=AF=E8=83=BD=E5=90=8C?= =?UTF-8?q?=E6=97=B6=E4=B8=8A=E6=8A=A5=E5=A4=9A=E4=B8=AA=E5=B1=9E=E6=80=A7?= =?UTF-8?q?=EF=BC=8C=E6=89=80=E4=BB=A5=E9=9C=80=E8=A6=81=E5=88=A4=E6=96=AD?= =?UTF-8?q?=20trigger.getIdentifier()=20=E6=98=AF=E5=90=A6=E5=9C=A8=20mess?= =?UTF-8?q?age=20=E7=9A=84=20params=20=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../IotDevicePropertyPostTriggerMatcher.java | 10 ++-- .../iot/core/util/IotDeviceMessageUtils.java | 50 +++++++++++++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDevicePropertyPostTriggerMatcher.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDevicePropertyPostTriggerMatcher.java index 27cb02a1a5..f5d461275b 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDevicePropertyPostTriggerMatcher.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDevicePropertyPostTriggerMatcher.java @@ -36,11 +36,11 @@ public class IotDevicePropertyPostTriggerMatcher implements IotSceneRuleTriggerM return false; } - // 1.3 检查标识符是否匹配 - String messageIdentifier = IotDeviceMessageUtils.getIdentifier(message); - if (!IotSceneRuleMatcherHelper.isIdentifierMatched(trigger.getIdentifier(), messageIdentifier)) { - IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "标识符不匹配,期望: " + - trigger.getIdentifier() + ", 实际: " + messageIdentifier); + // 1.3 检查消息中是否包含触发器指定的属性标识符 + // 注意:属性上报可能同时上报多个属性,所以需要判断 trigger.getIdentifier() 是否在 message 的 params 中 + if (!IotDeviceMessageUtils.containsIdentifier(message, trigger.getIdentifier())) { + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "消息中不包含属性: " + + trigger.getIdentifier()); return false; } diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtils.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtils.java index 65165425c8..5c1ac26005 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtils.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtils.java @@ -5,6 +5,7 @@ import cn.hutool.core.map.MapUtil; import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.system.SystemUtil; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; @@ -69,6 +70,55 @@ public class IotDeviceMessageUtils { return null; } + /** + * 判断消息中是否包含指定的标识符 + * + * 对于不同消息类型的处理: + * - EVENT_POST/SERVICE_INVOKE:检查 params.identifier 是否匹配 + * - STATE_UPDATE:检查 params.state 是否匹配 + * - PROPERTY_POST:检查 params 中是否包含该属性 key + * + * @param message 消息 + * @param identifier 要检查的标识符 + * @return 是否包含 + */ + public static boolean containsIdentifier(IotDeviceMessage message, String identifier) { + if (message.getParams() == null || StrUtil.isBlank(identifier)) { + return false; + } + // EVENT_POST / SERVICE_INVOKE / STATE_UPDATE:使用原有逻辑 + String messageIdentifier = getIdentifier(message); + if (messageIdentifier != null) { + return identifier.equals(messageIdentifier); + } + // PROPERTY_POST:检查 params 中是否包含该属性 key + if (StrUtil.equals(message.getMethod(), IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod())) { + Map params = parseParamsToMap(message.getParams()); + return params != null && params.containsKey(identifier); + } + return false; + } + + /** + * 将 params 解析为 Map + * + * @param params 参数(可能是 Map 或 JSON 字符串) + * @return Map,解析失败返回 null + */ + @SuppressWarnings("unchecked") + private static Map parseParamsToMap(Object params) { + if (params instanceof Map) { + return (Map) params; + } + if (params instanceof String) { + try { + return JsonUtils.parseObject((String) params, Map.class); + } catch (Exception ignored) { + } + } + return null; + } + /** * 从设备消息中提取指定标识符的属性值 * - 支持多种消息格式和属性值提取策略 From 9febc2b0b087bbd97f1e9dafdd7b0ad7f2f6c391 Mon Sep 17 00:00:00 2001 From: puhui999 Date: Tue, 13 Jan 2026 16:44:24 +0800 Subject: [PATCH 13/17] =?UTF-8?q?feat=EF=BC=9A=E3=80=90iot=E3=80=91execute?= =?UTF-8?q?SceneRuleAction=20=E6=9B=B4=E6=96=B0=E8=A7=84=E5=88=99=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=E7=9A=84=E6=9C=80=E5=90=8E=E8=A7=A6=E5=8F=91=E6=97=B6?= =?UTF-8?q?=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dal/dataobject/rule/IotSceneRuleDO.java | 6 ++++++ .../rule/scene/IotSceneRuleServiceImpl.java | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/IotSceneRuleDO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/IotSceneRuleDO.java index 94aa1eb5a3..ecf87db7a7 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/IotSceneRuleDO.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/IotSceneRuleDO.java @@ -21,6 +21,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import java.time.LocalDateTime; import java.util.List; /** @@ -56,6 +57,11 @@ public class IotSceneRuleDO extends TenantBaseDO { */ private Integer status; + /** + * 最后触发时间 + */ + private LocalDateTime lastTriggerTime; + /** * 场景定义配置 */ diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceImpl.java index 41052289a6..eb70b30480 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceImpl.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceImpl.java @@ -30,6 +30,7 @@ import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; +import java.time.LocalDateTime; import java.util.Collection; import java.util.List; @@ -392,9 +393,28 @@ public class IotSceneRuleServiceImpl implements IotSceneRuleService { } }); }); + + // 3. 更新最后触发时间 + updateLastTriggerTime(sceneRule.getId()); }); } + /** + * 更新规则场景的最后触发时间 + * + * @param id 规则场景编号 + */ + private void updateLastTriggerTime(Long id) { + try { + IotSceneRuleDO updateObj = new IotSceneRuleDO() + .setId(id) + .setLastTriggerTime(LocalDateTime.now()); + sceneRuleMapper.updateById(updateObj); + } catch (Exception e) { + log.error("[updateLastTriggerTime][规则场景编号({}) 更新最后触发时间异常]", id, e); + } + } + private IotSceneRuleServiceImpl getSelf() { return SpringUtil.getBean(IotSceneRuleServiceImpl.class); } From 5086e1225f55d18491b04b8f5c8638bde4c710c7 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Tue, 13 Jan 2026 23:04:28 +0800 Subject: [PATCH 14/17] =?UTF-8?q?review=EF=BC=9A=E3=80=90iot=E3=80=91?= =?UTF-8?q?=E2=80=9C=E4=BF=AE=E5=A4=8D=E4=BA=86=E4=B8=80=E4=BA=9B=20Iot=20?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=20TODO=20=E6=8F=90=E5=88=B0=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rule/data/action/IotWebSocketDataRuleAction.java | 8 +++++--- .../iot/service/rule/data/action/tcp/IotTcpClient.java | 2 ++ .../rule/data/action/websocket/IotWebSocketClient.java | 1 + .../iot/service/rule/scene/IotSceneRuleServiceImpl.java | 5 +---- .../trigger/IotDevicePropertyPostTriggerMatcher.java | 1 + 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotWebSocketDataRuleAction.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotWebSocketDataRuleAction.java index 5e2750980c..c0445df906 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotWebSocketDataRuleAction.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotWebSocketDataRuleAction.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.iot.service.rule.data.action; +import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkWebSocketConfig; import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum; @@ -28,11 +29,11 @@ public class IotWebSocketDataRuleAction extends @Override protected IotWebSocketClient initProducer(IotDataSinkWebSocketConfig config) throws Exception { - // 1.1 参数校验 - if (config.getServerUrl() == null || config.getServerUrl().trim().isEmpty()) { + // 1. 参数校验 + if (StrUtil.isBlank(config.getServerUrl())) { throw new IllegalArgumentException("WebSocket 服务器地址不能为空"); } - if (!config.getServerUrl().startsWith("ws://") && !config.getServerUrl().startsWith("wss://")) { + if (!StrUtil.startWithAny(config.getServerUrl(), "ws://", "wss://")) { throw new IllegalArgumentException("WebSocket 服务器地址必须以 ws:// 或 wss:// 开头"); } @@ -61,6 +62,7 @@ public class IotWebSocketDataRuleAction extends protected void execute(IotDeviceMessage message, IotDataSinkWebSocketConfig config) throws Exception { try { // 1.1 获取或创建 WebSocket 客户端 + // TODO @puhui999:需要加锁,保证必须连接上; IotWebSocketClient webSocketClient = getProducer(config); // 1.2 检查连接状态,如果断开则重新连接 if (!webSocketClient.isConnected()) { diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/tcp/IotTcpClient.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/tcp/IotTcpClient.java index b417dca5a2..15b57b5405 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/tcp/IotTcpClient.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/tcp/IotTcpClient.java @@ -31,6 +31,7 @@ public class IotTcpClient { private final Integer connectTimeoutMs; private final Integer readTimeoutMs; private final Boolean ssl; + // TODO @puhui999:sslCertPath 是不是没在用? private final String sslCertPath; private final String dataFormat; @@ -47,6 +48,7 @@ public class IotTcpClient { this.readTimeoutMs = readTimeoutMs != null ? readTimeoutMs : IotDataSinkTcpConfig.DEFAULT_READ_TIMEOUT_MS; this.ssl = ssl != null ? ssl : IotDataSinkTcpConfig.DEFAULT_SSL; this.sslCertPath = sslCertPath; + // TODO @puhui999:可以使用 StrUtil.defaultIfBlank 方法简化 this.dataFormat = dataFormat != null ? dataFormat : IotDataSinkTcpConfig.DEFAULT_DATA_FORMAT; } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/websocket/IotWebSocketClient.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/websocket/IotWebSocketClient.java index bed197657f..2f55d6ee74 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/websocket/IotWebSocketClient.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/websocket/IotWebSocketClient.java @@ -45,6 +45,7 @@ public class IotWebSocketClient implements WebSocket.Listener { /** * 连接到 WebSocket 服务器 */ + @SuppressWarnings("resource") public void connect() throws Exception { if (connected.get()) { log.warn("[connect][WebSocket 客户端已经连接,无需重复连接]"); diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceImpl.java index eb70b30480..f96bc9f450 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceImpl.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceImpl.java @@ -406,10 +406,7 @@ public class IotSceneRuleServiceImpl implements IotSceneRuleService { */ private void updateLastTriggerTime(Long id) { try { - IotSceneRuleDO updateObj = new IotSceneRuleDO() - .setId(id) - .setLastTriggerTime(LocalDateTime.now()); - sceneRuleMapper.updateById(updateObj); + sceneRuleMapper.updateById(new IotSceneRuleDO().setId(id).setLastTriggerTime(LocalDateTime.now())); } catch (Exception e) { log.error("[updateLastTriggerTime][规则场景编号({}) 更新最后触发时间异常]", id, e); } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDevicePropertyPostTriggerMatcher.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDevicePropertyPostTriggerMatcher.java index f5d461275b..d653c9c42e 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDevicePropertyPostTriggerMatcher.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDevicePropertyPostTriggerMatcher.java @@ -38,6 +38,7 @@ public class IotDevicePropertyPostTriggerMatcher implements IotSceneRuleTriggerM // 1.3 检查消息中是否包含触发器指定的属性标识符 // 注意:属性上报可能同时上报多个属性,所以需要判断 trigger.getIdentifier() 是否在 message 的 params 中 + // TODO @puhui999:可以考虑 notXXX 方法,简化代码(尽量取反) if (!IotDeviceMessageUtils.containsIdentifier(message, trigger.getIdentifier())) { IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "消息中不包含属性: " + trigger.getIdentifier()); From 832b36ac47182aa75fde0d14592fb6961e05346d Mon Sep 17 00:00:00 2001 From: haohao <1036606149@qq.com> Date: Sat, 17 Jan 2026 11:15:59 +0800 Subject: [PATCH 15/17] =?UTF-8?q?feat=EF=BC=9A=E3=80=90iot=E3=80=91?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=95=B0=E6=8D=AE=E6=B5=81=E8=BD=AC=E8=A7=84?= =?UTF-8?q?=E5=88=99=E5=92=8C=E7=9B=AE=E7=9A=84=E5=90=8D=E7=A7=B0=E5=94=AF?= =?UTF-8?q?=E4=B8=80=E6=80=A7=E6=A0=A1=E9=AA=8C=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../iot/dal/mysql/rule/IotDataRuleMapper.java | 4 +++ .../iot/dal/mysql/rule/IotDataSinkMapper.java | 4 +++ .../module/iot/enums/ErrorCodeConstants.java | 2 ++ .../rule/data/IotDataRuleServiceImpl.java | 28 +++++++++++++++++ .../rule/data/IotDataSinkServiceImpl.java | 30 +++++++++++++++++++ 5 files changed, 68 insertions(+) diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataRuleMapper.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataRuleMapper.java index 7c0c17d3bc..ce2eeb04bc 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataRuleMapper.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataRuleMapper.java @@ -35,4 +35,8 @@ public interface IotDataRuleMapper extends BaseMapperX { return selectList(IotDataRuleDO::getStatus, status); } + default IotDataRuleDO selectByName(String name) { + return selectOne(IotDataRuleDO::getName, name); + } + } \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataSinkMapper.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataSinkMapper.java index e65001db86..b13510ecc2 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataSinkMapper.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataSinkMapper.java @@ -29,4 +29,8 @@ public interface IotDataSinkMapper extends BaseMapperX { return selectList(IotDataSinkDO::getStatus, status); } + default IotDataSinkDO selectByName(String name) { + return selectOne(IotDataSinkDO::getName, name); + } + } \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java index d1cf60e206..025d61390e 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java @@ -65,10 +65,12 @@ public interface ErrorCodeConstants { // ========== IoT 数据流转规则 1-050-010-000 ========== ErrorCode DATA_RULE_NOT_EXISTS = new ErrorCode(1_050_010_000, "数据流转规则不存在"); + ErrorCode DATA_RULE_NAME_EXISTS = new ErrorCode(1_050_010_001, "数据流转规则名称已存在"); // ========== IoT 数据流转目的 1-050-011-000 ========== ErrorCode DATA_SINK_NOT_EXISTS = new ErrorCode(1_050_011_000, "数据桥梁不存在"); ErrorCode DATA_SINK_DELETE_FAIL_USED_BY_RULE = new ErrorCode(1_050_011_001, "数据流转目的正在被数据流转规则使用,无法删除"); + ErrorCode DATA_SINK_NAME_EXISTS = new ErrorCode(1_050_011_002, "数据流转目的名称已存在"); // ========== IoT 场景联动 1-050-012-000 ========== ErrorCode RULE_SCENE_NOT_EXISTS = new ErrorCode(1_050_012_000, "场景联动不存在"); diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImpl.java index 8eafcb681a..8fc27f47df 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImpl.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImpl.java @@ -32,6 +32,7 @@ import java.util.*; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DATA_RULE_NAME_EXISTS; import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DATA_RULE_NOT_EXISTS; /** @@ -62,6 +63,8 @@ public class IotDataRuleServiceImpl implements IotDataRuleService { @Override @CacheEvict(value = RedisKeyConstants.DATA_RULE_LIST, allEntries = true) public Long createDataRule(IotDataRuleSaveReqVO createReqVO) { + // 校验名称唯一 + validateDataRuleNameUnique(null, createReqVO.getName()); // 校验数据源配置和数据目的 validateDataRuleConfig(createReqVO); // 新增 @@ -75,6 +78,8 @@ public class IotDataRuleServiceImpl implements IotDataRuleService { public void updateDataRule(IotDataRuleSaveReqVO updateReqVO) { // 校验存在 validateDataRuleExists(updateReqVO.getId()); + // 校验名称唯一 + validateDataRuleNameUnique(updateReqVO.getId(), updateReqVO.getName()); // 校验数据源配置和数据目的 validateDataRuleConfig(updateReqVO); @@ -98,6 +103,29 @@ public class IotDataRuleServiceImpl implements IotDataRuleService { } } + /** + * 校验数据流转规则名称唯一性 + * + * @param id 数据流转规则编号(用于更新时排除自身) + * @param name 数据流转规则名称 + */ + private void validateDataRuleNameUnique(Long id, String name) { + if (StrUtil.isBlank(name)) { + return; + } + IotDataRuleDO dataRule = dataRuleMapper.selectByName(name); + if (dataRule == null) { + return; + } + // 如果 id 为空,说明不用比较是否为相同 id 的规则 + if (id == null) { + throw exception(DATA_RULE_NAME_EXISTS); + } + if (!dataRule.getId().equals(id)) { + throw exception(DATA_RULE_NAME_EXISTS); + } + } + /** * 校验数据流转规则配置 * diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataSinkServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataSinkServiceImpl.java index 9977afba22..09e11c8226 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataSinkServiceImpl.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataSinkServiceImpl.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.iot.service.rule.data; import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.sink.IotDataSinkPageReqVO; @@ -19,6 +20,7 @@ import java.util.List; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DATA_SINK_DELETE_FAIL_USED_BY_RULE; +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DATA_SINK_NAME_EXISTS; import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DATA_SINK_NOT_EXISTS; /** @@ -39,6 +41,9 @@ public class IotDataSinkServiceImpl implements IotDataSinkService { @Override public Long createDataSink(IotDataSinkSaveReqVO createReqVO) { + // 校验名称唯一 + validateDataSinkNameUnique(null, createReqVO.getName()); + // 新增 IotDataSinkDO dataBridge = BeanUtils.toBean(createReqVO, IotDataSinkDO.class); dataSinkMapper.insert(dataBridge); return dataBridge.getId(); @@ -48,6 +53,8 @@ public class IotDataSinkServiceImpl implements IotDataSinkService { public void updateDataSink(IotDataSinkSaveReqVO updateReqVO) { // 校验存在 validateDataBridgeExists(updateReqVO.getId()); + // 校验名称唯一 + validateDataSinkNameUnique(updateReqVO.getId(), updateReqVO.getName()); // 更新 IotDataSinkDO updateObj = BeanUtils.toBean(updateReqVO, IotDataSinkDO.class); dataSinkMapper.updateById(updateObj); @@ -71,6 +78,29 @@ public class IotDataSinkServiceImpl implements IotDataSinkService { } } + /** + * 校验数据流转目的名称唯一性 + * + * @param id 数据流转目的编号(用于更新时排除自身) + * @param name 数据流转目的名称 + */ + private void validateDataSinkNameUnique(Long id, String name) { + if (StrUtil.isBlank(name)) { + return; + } + IotDataSinkDO dataSink = dataSinkMapper.selectByName(name); + if (dataSink == null) { + return; + } + // 如果 id 为空,说明不用比较是否为相同 id 的目的 + if (id == null) { + throw exception(DATA_SINK_NAME_EXISTS); + } + if (!dataSink.getId().equals(id)) { + throw exception(DATA_SINK_NAME_EXISTS); + } + } + @Override public IotDataSinkDO getDataSink(Long id) { return dataSinkMapper.selectById(id); From 3027caa1d2dbf4290f5a611ab5eff3c699af602d Mon Sep 17 00:00:00 2001 From: haohao <1036606149@qq.com> Date: Sat, 17 Jan 2026 11:33:10 +0800 Subject: [PATCH 16/17] =?UTF-8?q?feat=EF=BC=9A=E3=80=90iot=E3=80=91?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=95=B0=E6=8D=AE=E7=9B=AE=E7=9A=84=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E5=AD=97=E6=AE=B5=E5=8F=8A=E5=85=B6=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E6=9D=A1=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/rule/vo/data/sink/IotDataSinkPageReqVO.java | 5 +++++ .../yudao/module/iot/dal/mysql/rule/IotDataSinkMapper.java | 1 + 2 files changed, 6 insertions(+) diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/sink/IotDataSinkPageReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/sink/IotDataSinkPageReqVO.java index 06bbecc894..8a8fcdef3d 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/sink/IotDataSinkPageReqVO.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/sink/IotDataSinkPageReqVO.java @@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.sink; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.common.pojo.PageParam; import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.springframework.format.annotation.DateTimeFormat; @@ -22,6 +23,10 @@ public class IotDataSinkPageReqVO extends PageParam { @InEnum(CommonStatusEnum.class) private Integer status; + @Schema(description = "数据目的类型", example = "1") + @InEnum(IotDataSinkTypeEnum.class) + private Integer type; + @Schema(description = "创建时间") @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) private LocalDateTime[] createTime; diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataSinkMapper.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataSinkMapper.java index b13510ecc2..57e2a84595 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataSinkMapper.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataSinkMapper.java @@ -21,6 +21,7 @@ public interface IotDataSinkMapper extends BaseMapperX { return selectPage(reqVO, new LambdaQueryWrapperX() .likeIfPresent(IotDataSinkDO::getName, reqVO.getName()) .eqIfPresent(IotDataSinkDO::getStatus, reqVO.getStatus()) + .eqIfPresent(IotDataSinkDO::getType, reqVO.getType()) .betweenIfPresent(IotDataSinkDO::getCreateTime, reqVO.getCreateTime()) .orderByDesc(IotDataSinkDO::getId)); } From 28a30d4b79ca6098dd3a87ad39c543b00156109f Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sun, 18 Jan 2026 18:26:31 +0800 Subject: [PATCH 17/17] =?UTF-8?q?feat=EF=BC=9A=E3=80=90iot=E3=80=91?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E5=85=B3=E9=97=AD=20mqttws=20=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=EF=BC=8C=3D=20=3D=20=E8=A6=81=E5=90=88=E5=B9=B6=20mas?= =?UTF-8?q?ter=20=E4=B8=80=E4=B8=8B=EF=BC=8C=E9=81=BF=E5=85=8D=E4=B8=8D?= =?UTF-8?q?=E4=B8=80=E5=AE=9A=E5=A4=A7=E5=AE=B6=E9=83=BD=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application.yaml | 4 ++-- .../src/main/resources/application-local.yaml | 15 ++++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/resources/application.yaml b/yudao-module-iot/yudao-module-iot-gateway/src/main/resources/application.yaml index f633f1c60b..d62e573fa4 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/resources/application.yaml +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/resources/application.yaml @@ -48,7 +48,7 @@ yudao: # 针对引入的 HTTP 组件的配置 # ==================================== http: - enabled: false + enabled: true server-port: 8092 # ==================================== # 针对引入的 EMQX 组件的配置 @@ -108,7 +108,7 @@ yudao: # 针对引入的 MQTT WebSocket 组件的配置 # ==================================== mqtt-ws: - enabled: true # 是否启用 MQTT WebSocket + enabled: false # 是否启用 MQTT WebSocket port: 8083 # WebSocket 服务端口 path: /mqtt # WebSocket 路径 max-message-size: 8192 # 最大消息大小(字节) diff --git a/yudao-server/src/main/resources/application-local.yaml b/yudao-server/src/main/resources/application-local.yaml index dc001c27a7..f4e9da8495 100644 --- a/yudao-server/src/main/resources/application-local.yaml +++ b/yudao-server/src/main/resources/application-local.yaml @@ -68,13 +68,14 @@ spring: url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true&nullCatalogMeansCurrent=true username: root password: 123456 - tdengine: # IoT 数据库(需要 IoT 物联网再开启噢!) - url: jdbc:TAOS-WS://127.0.0.1:6041/ruoyi_vue_pro?varcharAsString=true - driver-class-name: com.taosdata.jdbc.ws.WebSocketDriver - username: root - password: taosdata - druid: - validation-query: SELECT SERVER_STATUS() # TDengine 数据源的有效性检查 SQL +# tdengine: # IoT 数据库(需要 IoT 物联网再开启噢!) +# lazy: true # 开启懒加载,保证启动速度 +# url: jdbc:TAOS-WS://127.0.0.1:6041/ruoyi_vue_pro?varcharAsString=true +# driver-class-name: com.taosdata.jdbc.ws.WebSocketDriver +# username: root +# password: taosdata +# druid: +# validation-query: SELECT SERVER_STATUS() # TDengine 数据源的有效性检查 SQL # Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优 data: