Commit d2c000d64dedcb95e6bc15383db697eaf8abf1e6
1 parent
b87bc191
feat:【iot】websocket 协议:初始化
Showing
11 changed files
with
1954 additions
and
0 deletions
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java
| ... | ... | @@ -18,6 +18,9 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager.IotTcpConnection |
| 18 | 18 | import cn.iocoder.yudao.module.iot.gateway.protocol.udp.IotUdpDownstreamSubscriber; |
| 19 | 19 | import cn.iocoder.yudao.module.iot.gateway.protocol.udp.IotUdpUpstreamProtocol; |
| 20 | 20 | import cn.iocoder.yudao.module.iot.gateway.protocol.udp.manager.IotUdpSessionManager; |
| 21 | +import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.IotWebSocketDownstreamSubscriber; | |
| 22 | +import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.IotWebSocketUpstreamProtocol; | |
| 23 | +import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.manager.IotWebSocketConnectionManager; | |
| 21 | 24 | import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; |
| 22 | 25 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 23 | 26 | import io.vertx.core.Vertx; |
| ... | ... | @@ -209,4 +212,37 @@ public class IotGatewayConfiguration { |
| 209 | 212 | |
| 210 | 213 | } |
| 211 | 214 | |
| 215 | + /** | |
| 216 | + * IoT 网关 WebSocket 协议配置类 | |
| 217 | + */ | |
| 218 | + @Configuration | |
| 219 | + @ConditionalOnProperty(prefix = "yudao.iot.gateway.protocol.websocket", name = "enabled", havingValue = "true") | |
| 220 | + @Slf4j | |
| 221 | + public static class WebSocketProtocolConfiguration { | |
| 222 | + | |
| 223 | + @Bean(name = "websocketVertx", destroyMethod = "close") | |
| 224 | + public Vertx websocketVertx() { | |
| 225 | + return Vertx.vertx(); | |
| 226 | + } | |
| 227 | + | |
| 228 | + @Bean | |
| 229 | + public IotWebSocketUpstreamProtocol iotWebSocketUpstreamProtocol(IotGatewayProperties gatewayProperties, | |
| 230 | + IotDeviceService deviceService, | |
| 231 | + IotDeviceMessageService messageService, | |
| 232 | + IotWebSocketConnectionManager connectionManager, | |
| 233 | + @Qualifier("websocketVertx") Vertx websocketVertx) { | |
| 234 | + return new IotWebSocketUpstreamProtocol(gatewayProperties.getProtocol().getWebsocket(), | |
| 235 | + deviceService, messageService, connectionManager, websocketVertx); | |
| 236 | + } | |
| 237 | + | |
| 238 | + @Bean | |
| 239 | + public IotWebSocketDownstreamSubscriber iotWebSocketDownstreamSubscriber(IotWebSocketUpstreamProtocol protocolHandler, | |
| 240 | + IotDeviceMessageService messageService, | |
| 241 | + IotWebSocketConnectionManager connectionManager, | |
| 242 | + IotMessageBus messageBus) { | |
| 243 | + return new IotWebSocketDownstreamSubscriber(protocolHandler, messageService, connectionManager, messageBus); | |
| 244 | + } | |
| 245 | + | |
| 246 | + } | |
| 247 | + | |
| 212 | 248 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayProperties.java
| ... | ... | @@ -103,6 +103,11 @@ public class IotGatewayProperties { |
| 103 | 103 | */ |
| 104 | 104 | private CoapProperties coap; |
| 105 | 105 | |
| 106 | + /** | |
| 107 | + * WebSocket 组件配置 | |
| 108 | + */ | |
| 109 | + private WebSocketProperties websocket; | |
| 110 | + | |
| 106 | 111 | } |
| 107 | 112 | |
| 108 | 113 | @Data |
| ... | ... | @@ -586,4 +591,56 @@ public class IotGatewayProperties { |
| 586 | 591 | |
| 587 | 592 | } |
| 588 | 593 | |
| 594 | + @Data | |
| 595 | + public static class WebSocketProperties { | |
| 596 | + | |
| 597 | + /** | |
| 598 | + * 是否开启 | |
| 599 | + */ | |
| 600 | + @NotNull(message = "是否开启不能为空") | |
| 601 | + private Boolean enabled; | |
| 602 | + | |
| 603 | + /** | |
| 604 | + * 服务器端口(默认:8094) | |
| 605 | + */ | |
| 606 | + private Integer port = 8094; | |
| 607 | + | |
| 608 | + /** | |
| 609 | + * WebSocket 路径(默认:/ws) | |
| 610 | + */ | |
| 611 | + @NotEmpty(message = "WebSocket 路径不能为空") | |
| 612 | + private String path = "/ws"; | |
| 613 | + | |
| 614 | + /** | |
| 615 | + * 最大消息大小(字节,默认 64KB) | |
| 616 | + */ | |
| 617 | + private Integer maxMessageSize = 65536; | |
| 618 | + | |
| 619 | + /** | |
| 620 | + * 最大帧大小(字节,默认 64KB) | |
| 621 | + */ | |
| 622 | + private Integer maxFrameSize = 65536; | |
| 623 | + | |
| 624 | + /** | |
| 625 | + * 空闲超时时间(秒,默认 60) | |
| 626 | + */ | |
| 627 | + private Integer idleTimeoutSeconds = 60; | |
| 628 | + | |
| 629 | + /** | |
| 630 | + * 是否启用 SSL(wss://) | |
| 631 | + */ | |
| 632 | + private Boolean sslEnabled = false; | |
| 633 | + | |
| 634 | + /** | |
| 635 | + * SSL 证书路径 | |
| 636 | + */ | |
| 637 | + private String sslCertPath; | |
| 638 | + | |
| 639 | + /** | |
| 640 | + * SSL 私钥路径 | |
| 641 | + */ | |
| 642 | + private String sslKeyPath; | |
| 643 | + | |
| 644 | + } | |
| 645 | + | |
| 589 | 646 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotWebSocketDownstreamSubscriber.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.websocket; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; | |
| 4 | +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber; | |
| 5 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 6 | +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; | |
| 7 | +import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.manager.IotWebSocketConnectionManager; | |
| 8 | +import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.router.IotWebSocketDownstreamHandler; | |
| 9 | +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; | |
| 10 | +import jakarta.annotation.PostConstruct; | |
| 11 | +import lombok.RequiredArgsConstructor; | |
| 12 | +import lombok.extern.slf4j.Slf4j; | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * IoT 网关 WebSocket 下游订阅者:接收下行给设备的消息 | |
| 16 | + * | |
| 17 | + * @author 芋道源码 | |
| 18 | + */ | |
| 19 | +@Slf4j | |
| 20 | +@RequiredArgsConstructor | |
| 21 | +public class IotWebSocketDownstreamSubscriber implements IotMessageSubscriber<IotDeviceMessage> { | |
| 22 | + | |
| 23 | + private final IotWebSocketUpstreamProtocol protocol; | |
| 24 | + | |
| 25 | + private final IotDeviceMessageService messageService; | |
| 26 | + | |
| 27 | + private final IotWebSocketConnectionManager connectionManager; | |
| 28 | + | |
| 29 | + private final IotMessageBus messageBus; | |
| 30 | + | |
| 31 | + private IotWebSocketDownstreamHandler downstreamHandler; | |
| 32 | + | |
| 33 | + @PostConstruct | |
| 34 | + public void init() { | |
| 35 | + // 初始化下游处理器 | |
| 36 | + this.downstreamHandler = new IotWebSocketDownstreamHandler(messageService, connectionManager); | |
| 37 | + | |
| 38 | + messageBus.register(this); | |
| 39 | + log.info("[init][WebSocket 下游订阅者初始化完成,服务器 ID: {},Topic: {}]", | |
| 40 | + protocol.getServerId(), getTopic()); | |
| 41 | + } | |
| 42 | + | |
| 43 | + @Override | |
| 44 | + public String getTopic() { | |
| 45 | + return IotDeviceMessageUtils.buildMessageBusGatewayDeviceMessageTopic(protocol.getServerId()); | |
| 46 | + } | |
| 47 | + | |
| 48 | + @Override | |
| 49 | + public String getGroup() { | |
| 50 | + // 保证点对点消费,需要保证独立的 Group,所以使用 Topic 作为 Group | |
| 51 | + return getTopic(); | |
| 52 | + } | |
| 53 | + | |
| 54 | + @Override | |
| 55 | + public void onMessage(IotDeviceMessage message) { | |
| 56 | + try { | |
| 57 | + downstreamHandler.handle(message); | |
| 58 | + } catch (Exception e) { | |
| 59 | + log.error("[onMessage][处理下行消息失败,设备 ID: {},方法: {},消息 ID: {}]", | |
| 60 | + message.getDeviceId(), message.getMethod(), message.getId(), e); | |
| 61 | + } | |
| 62 | + } | |
| 63 | + | |
| 64 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotWebSocketUpstreamProtocol.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.websocket; | |
| 2 | + | |
| 3 | +import cn.hutool.core.util.ObjUtil; | |
| 4 | +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; | |
| 5 | +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; | |
| 6 | +import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.manager.IotWebSocketConnectionManager; | |
| 7 | +import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.router.IotWebSocketUpstreamHandler; | |
| 8 | +import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; | |
| 9 | +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; | |
| 10 | +import io.vertx.core.Vertx; | |
| 11 | +import io.vertx.core.http.HttpServer; | |
| 12 | +import io.vertx.core.http.HttpServerOptions; | |
| 13 | +import io.vertx.core.net.PemKeyCertOptions; | |
| 14 | +import jakarta.annotation.PostConstruct; | |
| 15 | +import jakarta.annotation.PreDestroy; | |
| 16 | +import lombok.Getter; | |
| 17 | +import lombok.extern.slf4j.Slf4j; | |
| 18 | + | |
| 19 | +/** | |
| 20 | + * IoT 网关 WebSocket 协议:接收设备上行消息 | |
| 21 | + * | |
| 22 | + * @author 芋道源码 | |
| 23 | + */ | |
| 24 | +@Slf4j | |
| 25 | +public class IotWebSocketUpstreamProtocol { | |
| 26 | + | |
| 27 | + private final IotGatewayProperties.WebSocketProperties wsProperties; | |
| 28 | + | |
| 29 | + private final IotDeviceService deviceService; | |
| 30 | + | |
| 31 | + private final IotDeviceMessageService messageService; | |
| 32 | + | |
| 33 | + private final IotWebSocketConnectionManager connectionManager; | |
| 34 | + | |
| 35 | + private final Vertx vertx; | |
| 36 | + | |
| 37 | + @Getter | |
| 38 | + private final String serverId; | |
| 39 | + | |
| 40 | + private HttpServer httpServer; | |
| 41 | + | |
| 42 | + public IotWebSocketUpstreamProtocol(IotGatewayProperties.WebSocketProperties wsProperties, | |
| 43 | + IotDeviceService deviceService, | |
| 44 | + IotDeviceMessageService messageService, | |
| 45 | + IotWebSocketConnectionManager connectionManager, | |
| 46 | + Vertx vertx) { | |
| 47 | + this.wsProperties = wsProperties; | |
| 48 | + this.deviceService = deviceService; | |
| 49 | + this.messageService = messageService; | |
| 50 | + this.connectionManager = connectionManager; | |
| 51 | + this.vertx = vertx; | |
| 52 | + this.serverId = IotDeviceMessageUtils.generateServerId(wsProperties.getPort()); | |
| 53 | + } | |
| 54 | + | |
| 55 | + @PostConstruct | |
| 56 | + public void start() { | |
| 57 | + // 1.1 创建服务器选项 | |
| 58 | + HttpServerOptions options = new HttpServerOptions() | |
| 59 | + .setPort(wsProperties.getPort()) | |
| 60 | + .setIdleTimeout(wsProperties.getIdleTimeoutSeconds()) | |
| 61 | + .setMaxWebSocketFrameSize(wsProperties.getMaxFrameSize()) | |
| 62 | + .setMaxWebSocketMessageSize(wsProperties.getMaxMessageSize()); | |
| 63 | + // 1.2 配置 SSL(如果启用) | |
| 64 | + if (Boolean.TRUE.equals(wsProperties.getSslEnabled())) { | |
| 65 | + PemKeyCertOptions pemKeyCertOptions = new PemKeyCertOptions() | |
| 66 | + .setKeyPath(wsProperties.getSslKeyPath()) | |
| 67 | + .setCertPath(wsProperties.getSslCertPath()); | |
| 68 | + options.setSsl(true).setKeyCertOptions(pemKeyCertOptions); | |
| 69 | + } | |
| 70 | + | |
| 71 | + // 2. 创建服务器并设置 WebSocket 处理器 | |
| 72 | + httpServer = vertx.createHttpServer(options); | |
| 73 | + httpServer.webSocketHandler(socket -> { | |
| 74 | + // 验证路径 | |
| 75 | + if (ObjUtil.notEqual(wsProperties.getPath(), socket.path())) { | |
| 76 | + log.warn("[webSocketHandler][WebSocket 路径不匹配,拒绝连接,路径: {},期望: {}]", | |
| 77 | + socket.path(), wsProperties.getPath()); | |
| 78 | + // TODO @AI:已经被废弃,看看换什么其他方法; | |
| 79 | + socket.reject(); | |
| 80 | + return; | |
| 81 | + } | |
| 82 | + | |
| 83 | + // 创建上行处理器 | |
| 84 | + IotWebSocketUpstreamHandler handler = new IotWebSocketUpstreamHandler( | |
| 85 | + this, messageService, deviceService, connectionManager); | |
| 86 | + handler.handle(socket); | |
| 87 | + }); | |
| 88 | + | |
| 89 | + // 3. 启动服务器 | |
| 90 | + try { | |
| 91 | + httpServer.listen().result(); | |
| 92 | + log.info("[start][IoT 网关 WebSocket 协议启动成功,端口:{},路径:{}]", | |
| 93 | + wsProperties.getPort(), wsProperties.getPath()); | |
| 94 | + } catch (Exception e) { | |
| 95 | + log.error("[start][IoT 网关 WebSocket 协议启动失败]", e); | |
| 96 | + throw e; | |
| 97 | + } | |
| 98 | + } | |
| 99 | + | |
| 100 | + @PreDestroy | |
| 101 | + public void stop() { | |
| 102 | + if (httpServer != null) { | |
| 103 | + try { | |
| 104 | + httpServer.close().result(); | |
| 105 | + log.info("[stop][IoT 网关 WebSocket 协议已停止]"); | |
| 106 | + } catch (Exception e) { | |
| 107 | + log.error("[stop][IoT 网关 WebSocket 协议停止失败]", e); | |
| 108 | + } | |
| 109 | + } | |
| 110 | + } | |
| 111 | + | |
| 112 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/manager/IotWebSocketConnectionManager.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.websocket.manager; | |
| 2 | + | |
| 3 | +import io.vertx.core.http.ServerWebSocket; | |
| 4 | +import lombok.Data; | |
| 5 | +import lombok.experimental.Accessors; | |
| 6 | +import lombok.extern.slf4j.Slf4j; | |
| 7 | +import org.springframework.stereotype.Component; | |
| 8 | + | |
| 9 | +import java.util.Map; | |
| 10 | +import java.util.concurrent.ConcurrentHashMap; | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * IoT 网关 WebSocket 连接管理器 | |
| 14 | + * <p> | |
| 15 | + * 统一管理 WebSocket 连接的认证状态、设备会话和消息发送功能: | |
| 16 | + * 1. 管理 WebSocket 连接的认证状态 | |
| 17 | + * 2. 管理设备会话和在线状态 | |
| 18 | + * 3. 管理消息发送到设备 | |
| 19 | + * | |
| 20 | + * @author 芋道源码 | |
| 21 | + */ | |
| 22 | +@Slf4j | |
| 23 | +@Component | |
| 24 | +public class IotWebSocketConnectionManager { | |
| 25 | + | |
| 26 | + /** | |
| 27 | + * 连接信息映射:ServerWebSocket -> 连接信息 | |
| 28 | + */ | |
| 29 | + private final Map<ServerWebSocket, ConnectionInfo> connectionMap = new ConcurrentHashMap<>(); | |
| 30 | + | |
| 31 | + /** | |
| 32 | + * 设备 ID -> ServerWebSocket 的映射 | |
| 33 | + */ | |
| 34 | + private final Map<Long, ServerWebSocket> deviceSocketMap = new ConcurrentHashMap<>(); | |
| 35 | + | |
| 36 | + /** | |
| 37 | + * 注册设备连接(包含认证信息) | |
| 38 | + * | |
| 39 | + * @param socket WebSocket 连接 | |
| 40 | + * @param deviceId 设备 ID | |
| 41 | + * @param connectionInfo 连接信息 | |
| 42 | + */ | |
| 43 | + public void registerConnection(ServerWebSocket socket, Long deviceId, ConnectionInfo connectionInfo) { | |
| 44 | + // 如果设备已有其他连接,先清理旧连接 | |
| 45 | + ServerWebSocket oldSocket = deviceSocketMap.get(deviceId); | |
| 46 | + if (oldSocket != null && oldSocket != socket) { | |
| 47 | + log.info("[registerConnection][设备已有其他连接,断开旧连接,设备 ID: {},旧连接: {}]", | |
| 48 | + deviceId, oldSocket.remoteAddress()); | |
| 49 | + oldSocket.close(); | |
| 50 | + // 清理旧连接的映射 | |
| 51 | + connectionMap.remove(oldSocket); | |
| 52 | + } | |
| 53 | + | |
| 54 | + connectionMap.put(socket, connectionInfo); | |
| 55 | + deviceSocketMap.put(deviceId, socket); | |
| 56 | + | |
| 57 | + log.info("[registerConnection][注册设备连接,设备 ID: {},连接: {},product key: {},device name: {}]", | |
| 58 | + deviceId, socket.remoteAddress(), connectionInfo.getProductKey(), connectionInfo.getDeviceName()); | |
| 59 | + } | |
| 60 | + | |
| 61 | + /** | |
| 62 | + * 注销设备连接 | |
| 63 | + * | |
| 64 | + * @param socket WebSocket 连接 | |
| 65 | + */ | |
| 66 | + public void unregisterConnection(ServerWebSocket socket) { | |
| 67 | + ConnectionInfo connectionInfo = connectionMap.remove(socket); | |
| 68 | + if (connectionInfo != null) { | |
| 69 | + Long deviceId = connectionInfo.getDeviceId(); | |
| 70 | + deviceSocketMap.remove(deviceId); | |
| 71 | + log.info("[unregisterConnection][注销设备连接,设备 ID: {},连接: {}]", | |
| 72 | + deviceId, socket.remoteAddress()); | |
| 73 | + } | |
| 74 | + } | |
| 75 | + | |
| 76 | + /** | |
| 77 | + * 获取连接信息 | |
| 78 | + */ | |
| 79 | + public ConnectionInfo getConnectionInfo(ServerWebSocket socket) { | |
| 80 | + return connectionMap.get(socket); | |
| 81 | + } | |
| 82 | + | |
| 83 | + /** | |
| 84 | + * 根据设备 ID 获取连接信息 | |
| 85 | + */ | |
| 86 | + public ConnectionInfo getConnectionInfoByDeviceId(Long deviceId) { | |
| 87 | + ServerWebSocket socket = deviceSocketMap.get(deviceId); | |
| 88 | + return socket != null ? connectionMap.get(socket) : null; | |
| 89 | + } | |
| 90 | + | |
| 91 | + /** | |
| 92 | + * 发送消息到设备(文本消息) | |
| 93 | + * | |
| 94 | + * @param deviceId 设备 ID | |
| 95 | + * @param message JSON 消息 | |
| 96 | + * @return 是否发送成功 | |
| 97 | + */ | |
| 98 | + public boolean sendToDevice(Long deviceId, String message) { | |
| 99 | + ServerWebSocket socket = deviceSocketMap.get(deviceId); | |
| 100 | + if (socket == null) { | |
| 101 | + log.warn("[sendToDevice][设备未连接,设备 ID: {}]", deviceId); | |
| 102 | + return false; | |
| 103 | + } | |
| 104 | + | |
| 105 | + try { | |
| 106 | + socket.writeTextMessage(message); | |
| 107 | + log.debug("[sendToDevice][发送消息成功,设备 ID: {},数据长度: {} 字节]", deviceId, message.length()); | |
| 108 | + return true; | |
| 109 | + } catch (Exception e) { | |
| 110 | + log.error("[sendToDevice][发送消息失败,设备 ID: {}]", deviceId, e); | |
| 111 | + // 发送失败时清理连接 | |
| 112 | + unregisterConnection(socket); | |
| 113 | + return false; | |
| 114 | + } | |
| 115 | + } | |
| 116 | + | |
| 117 | + /** | |
| 118 | + * 连接信息(包含认证信息) | |
| 119 | + */ | |
| 120 | + @Data | |
| 121 | + @Accessors(chain = true) | |
| 122 | + public static class ConnectionInfo { | |
| 123 | + | |
| 124 | + /** | |
| 125 | + * 设备 ID | |
| 126 | + */ | |
| 127 | + private Long deviceId; | |
| 128 | + /** | |
| 129 | + * 产品 Key | |
| 130 | + */ | |
| 131 | + private String productKey; | |
| 132 | + /** | |
| 133 | + * 设备名称 | |
| 134 | + */ | |
| 135 | + private String deviceName; | |
| 136 | + | |
| 137 | + /** | |
| 138 | + * 客户端 ID | |
| 139 | + */ | |
| 140 | + private String clientId; | |
| 141 | + | |
| 142 | + // TODO @AI:增加有个 codecType 字段;后续可以使用,参考 tcp、udp;然后下行的时候,也基于这个 codeType 去获取; | |
| 143 | + | |
| 144 | + } | |
| 145 | + | |
| 146 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/router/IotWebSocketDownstreamHandler.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.websocket.router; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 4 | +import cn.iocoder.yudao.module.iot.gateway.codec.websocket.IotWebSocketJsonDeviceMessageCodec; | |
| 5 | +import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.manager.IotWebSocketConnectionManager; | |
| 6 | +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; | |
| 7 | +import lombok.RequiredArgsConstructor; | |
| 8 | +import lombok.extern.slf4j.Slf4j; | |
| 9 | + | |
| 10 | +import java.nio.charset.StandardCharsets; | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * IoT 网关 WebSocket 下行消息处理器 | |
| 14 | + * | |
| 15 | + * @author 芋道源码 | |
| 16 | + */ | |
| 17 | +@Slf4j | |
| 18 | +@RequiredArgsConstructor | |
| 19 | +public class IotWebSocketDownstreamHandler { | |
| 20 | + | |
| 21 | + // TODO @芋艿:codeType 的处理; | |
| 22 | + private static final String CODEC_TYPE = IotWebSocketJsonDeviceMessageCodec.TYPE; | |
| 23 | + | |
| 24 | + private final IotDeviceMessageService deviceMessageService; | |
| 25 | + | |
| 26 | + private final IotWebSocketConnectionManager connectionManager; | |
| 27 | + | |
| 28 | + /** | |
| 29 | + * 处理下行消息 | |
| 30 | + */ | |
| 31 | + public void handle(IotDeviceMessage message) { | |
| 32 | + try { | |
| 33 | + log.info("[handle][处理下行消息,设备 ID: {},方法: {},消息 ID: {}]", | |
| 34 | + message.getDeviceId(), message.getMethod(), message.getId()); | |
| 35 | + | |
| 36 | + // 1. 获取连接信息 | |
| 37 | + IotWebSocketConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfoByDeviceId( | |
| 38 | + message.getDeviceId()); | |
| 39 | + if (connectionInfo == null) { | |
| 40 | + log.error("[handle][连接信息不存在,设备 ID: {}]", message.getDeviceId()); | |
| 41 | + return; | |
| 42 | + } | |
| 43 | + | |
| 44 | + // 2. 编码消息并发送到设备 | |
| 45 | + byte[] bytes = deviceMessageService.encodeDeviceMessage(message, CODEC_TYPE); | |
| 46 | + String jsonMessage = new String(bytes, StandardCharsets.UTF_8); | |
| 47 | + boolean success = connectionManager.sendToDevice(message.getDeviceId(), jsonMessage); | |
| 48 | + if (success) { | |
| 49 | + log.info("[handle][下行消息发送成功,设备 ID: {},方法: {},消息 ID: {},数据长度: {} 字节]", | |
| 50 | + message.getDeviceId(), message.getMethod(), message.getId(), bytes.length); | |
| 51 | + } else { | |
| 52 | + log.error("[handle][下行消息发送失败,设备 ID: {},方法: {},消息 ID: {}]", | |
| 53 | + message.getDeviceId(), message.getMethod(), message.getId()); | |
| 54 | + } | |
| 55 | + } catch (Exception e) { | |
| 56 | + log.error("[handle][处理下行消息失败,设备 ID: {},方法: {},消息内容: {}]", | |
| 57 | + message.getDeviceId(), message.getMethod(), message, e); | |
| 58 | + } | |
| 59 | + } | |
| 60 | + | |
| 61 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/router/IotWebSocketUpstreamHandler.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.websocket.router; | |
| 2 | + | |
| 3 | +import cn.hutool.core.map.MapUtil; | |
| 4 | +import cn.hutool.core.util.BooleanUtil; | |
| 5 | +import cn.hutool.core.util.IdUtil; | |
| 6 | +import cn.hutool.core.util.StrUtil; | |
| 7 | +import cn.hutool.extra.spring.SpringUtil; | |
| 8 | +import cn.iocoder.yudao.framework.common.pojo.CommonResult; | |
| 9 | +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; | |
| 10 | +import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; | |
| 11 | +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; | |
| 12 | +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; | |
| 13 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | |
| 14 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 15 | +import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; | |
| 16 | +import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO; | |
| 17 | +import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterRespDTO; | |
| 18 | +import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils; | |
| 19 | +import cn.iocoder.yudao.module.iot.gateway.codec.websocket.IotWebSocketJsonDeviceMessageCodec; | |
| 20 | +import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.IotWebSocketUpstreamProtocol; | |
| 21 | +import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.manager.IotWebSocketConnectionManager; | |
| 22 | +import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; | |
| 23 | +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; | |
| 24 | +import io.vertx.core.Handler; | |
| 25 | +import io.vertx.core.http.ServerWebSocket; | |
| 26 | +import lombok.extern.slf4j.Slf4j; | |
| 27 | + | |
| 28 | +import java.nio.charset.StandardCharsets; | |
| 29 | + | |
| 30 | +/** | |
| 31 | + * WebSocket 上行消息处理器 | |
| 32 | + * | |
| 33 | + * @author 芋道源码 | |
| 34 | + */ | |
| 35 | +@Slf4j | |
| 36 | +public class IotWebSocketUpstreamHandler implements Handler<ServerWebSocket> { | |
| 37 | + | |
| 38 | + // TODO @芋艿:codeType 的处理; | |
| 39 | + private static final String CODEC_TYPE = IotWebSocketJsonDeviceMessageCodec.TYPE; | |
| 40 | + | |
| 41 | + private static final String AUTH_METHOD = "auth"; | |
| 42 | + | |
| 43 | + private final IotDeviceMessageService deviceMessageService; | |
| 44 | + | |
| 45 | + private final IotDeviceService deviceService; | |
| 46 | + | |
| 47 | + private final IotWebSocketConnectionManager connectionManager; | |
| 48 | + | |
| 49 | + private final IotDeviceCommonApi deviceApi; | |
| 50 | + | |
| 51 | + private final String serverId; | |
| 52 | + | |
| 53 | + public IotWebSocketUpstreamHandler(IotWebSocketUpstreamProtocol protocol, | |
| 54 | + IotDeviceMessageService deviceMessageService, | |
| 55 | + IotDeviceService deviceService, | |
| 56 | + IotWebSocketConnectionManager connectionManager) { | |
| 57 | + this.deviceMessageService = deviceMessageService; | |
| 58 | + this.deviceService = deviceService; | |
| 59 | + this.connectionManager = connectionManager; | |
| 60 | + this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class); | |
| 61 | + this.serverId = protocol.getServerId(); | |
| 62 | + } | |
| 63 | + | |
| 64 | + @Override | |
| 65 | + public void handle(ServerWebSocket socket) { | |
| 66 | + // 1. 接受 WebSocket 连接 | |
| 67 | + String clientId = IdUtil.simpleUUID(); | |
| 68 | + log.debug("[handle][设备连接,客户端 ID: {},地址: {}]", clientId, socket.remoteAddress()); | |
| 69 | + // TODO @AI:这个方法已经废弃,看看有没其他替换的 | |
| 70 | + socket.accept(); | |
| 71 | + | |
| 72 | + // 2.1 设置异常和关闭处理器 | |
| 73 | + socket.exceptionHandler(ex -> { | |
| 74 | + log.warn("[handle][连接异常,客户端 ID: {},地址: {}]", clientId, socket.remoteAddress()); | |
| 75 | + cleanupConnection(socket); | |
| 76 | + }); | |
| 77 | + socket.closeHandler(v -> { | |
| 78 | + log.debug("[handle][连接关闭,客户端 ID: {},地址: {}]", clientId, socket.remoteAddress()); | |
| 79 | + cleanupConnection(socket); | |
| 80 | + }); | |
| 81 | + | |
| 82 | + // 2.2 设置文本消息处理器 | |
| 83 | + socket.textMessageHandler(message -> { | |
| 84 | + try { | |
| 85 | + processMessage(clientId, message, socket); | |
| 86 | + } catch (Exception e) { | |
| 87 | + log.error("[handle][消息解码失败,断开连接,客户端 ID: {},地址: {},错误: {}]", | |
| 88 | + clientId, socket.remoteAddress(), e.getMessage()); | |
| 89 | + cleanupConnection(socket); | |
| 90 | + socket.close(); | |
| 91 | + } | |
| 92 | + }); | |
| 93 | + } | |
| 94 | + | |
| 95 | + /** | |
| 96 | + * 处理消息 | |
| 97 | + * | |
| 98 | + * @param clientId 客户端 ID | |
| 99 | + * @param message 消息(JSON 字符串) | |
| 100 | + * @param socket WebSocket 连接 | |
| 101 | + * @throws Exception 消息解码失败时抛出异常 | |
| 102 | + */ | |
| 103 | + private void processMessage(String clientId, String message, ServerWebSocket socket) throws Exception { | |
| 104 | + // 1.1 基础检查 | |
| 105 | + if (StrUtil.isBlank(message)) { | |
| 106 | + return; | |
| 107 | + } | |
| 108 | + // 1.2 解码消息 | |
| 109 | + // TODO @AI:应该只有初始使用 CODEC_TYPE 解析,后续基于 | |
| 110 | + IotDeviceMessage deviceMessage; | |
| 111 | + try { | |
| 112 | + deviceMessage = deviceMessageService.decodeDeviceMessage( | |
| 113 | + message.getBytes(StandardCharsets.UTF_8), CODEC_TYPE); | |
| 114 | + if (deviceMessage == null) { | |
| 115 | + throw new Exception("解码后消息为空"); | |
| 116 | + } | |
| 117 | + } catch (Exception e) { | |
| 118 | + throw new Exception("消息解码失败: " + e.getMessage(), e); | |
| 119 | + } | |
| 120 | + | |
| 121 | + // 2. 根据消息类型路由处理 | |
| 122 | + try { | |
| 123 | + if (AUTH_METHOD.equals(deviceMessage.getMethod())) { | |
| 124 | + // 认证请求 | |
| 125 | + handleAuthenticationRequest(clientId, deviceMessage, socket); | |
| 126 | + } else if (IotDeviceMessageMethodEnum.DEVICE_REGISTER.getMethod().equals(deviceMessage.getMethod())) { | |
| 127 | + // 设备动态注册请求 | |
| 128 | + handleRegisterRequest(clientId, deviceMessage, socket); | |
| 129 | + } else { | |
| 130 | + // 业务消息 | |
| 131 | + handleBusinessRequest(clientId, deviceMessage, socket); | |
| 132 | + } | |
| 133 | + } catch (Exception e) { | |
| 134 | + log.error("[processMessage][处理消息失败,客户端 ID: {},消息方法: {}]", | |
| 135 | + clientId, deviceMessage.getMethod(), e); | |
| 136 | + // 发送错误响应,避免客户端一直等待 | |
| 137 | + try { | |
| 138 | + sendErrorResponse(socket, deviceMessage.getRequestId(), "消息处理失败"); | |
| 139 | + } catch (Exception responseEx) { | |
| 140 | + log.error("[processMessage][发送错误响应失败,客户端 ID: {}]", clientId, responseEx); | |
| 141 | + } | |
| 142 | + } | |
| 143 | + } | |
| 144 | + | |
| 145 | + /** | |
| 146 | + * 处理认证请求 | |
| 147 | + * | |
| 148 | + * @param clientId 客户端 ID | |
| 149 | + * @param message 消息信息 | |
| 150 | + * @param socket WebSocket 连接 | |
| 151 | + */ | |
| 152 | + private void handleAuthenticationRequest(String clientId, IotDeviceMessage message, ServerWebSocket socket) { | |
| 153 | + try { | |
| 154 | + // 1.1 解析认证参数 | |
| 155 | + IotDeviceAuthReqDTO authParams = parseAuthParams(message.getParams()); | |
| 156 | + if (authParams == null) { | |
| 157 | + log.warn("[handleAuthenticationRequest][认证参数解析失败,客户端 ID: {}]", clientId); | |
| 158 | + sendErrorResponse(socket, message.getRequestId(), "认证参数不完整"); | |
| 159 | + return; | |
| 160 | + } | |
| 161 | + // 1.2 执行认证 | |
| 162 | + if (!validateDeviceAuth(authParams)) { | |
| 163 | + log.warn("[handleAuthenticationRequest][认证失败,客户端 ID: {},username: {}]", | |
| 164 | + clientId, authParams.getUsername()); | |
| 165 | + sendErrorResponse(socket, message.getRequestId(), "认证失败"); | |
| 166 | + return; | |
| 167 | + } | |
| 168 | + | |
| 169 | + // 2.1 解析设备信息 | |
| 170 | + IotDeviceIdentity deviceInfo = IotDeviceAuthUtils.parseUsername(authParams.getUsername()); | |
| 171 | + if (deviceInfo == null) { | |
| 172 | + sendErrorResponse(socket, message.getRequestId(), "解析设备信息失败"); | |
| 173 | + return; | |
| 174 | + } | |
| 175 | + // 2.2 获取设备信息 | |
| 176 | + IotDeviceRespDTO device = deviceService.getDeviceFromCache(deviceInfo.getProductKey(), | |
| 177 | + deviceInfo.getDeviceName()); | |
| 178 | + if (device == null) { | |
| 179 | + sendErrorResponse(socket, message.getRequestId(), "设备不存在"); | |
| 180 | + return; | |
| 181 | + } | |
| 182 | + | |
| 183 | + // 3.1 注册连接 | |
| 184 | + registerConnection(socket, device, clientId); | |
| 185 | + // 3.2 发送上线消息 | |
| 186 | + sendOnlineMessage(device); | |
| 187 | + // 3.3 发送成功响应 | |
| 188 | + sendSuccessResponse(socket, message.getRequestId(), "认证成功"); | |
| 189 | + log.info("[handleAuthenticationRequest][认证成功,设备 ID: {},设备名: {}]", | |
| 190 | + device.getId(), device.getDeviceName()); | |
| 191 | + } catch (Exception e) { | |
| 192 | + log.error("[handleAuthenticationRequest][认证处理异常,客户端 ID: {}]", clientId, e); | |
| 193 | + sendErrorResponse(socket, message.getRequestId(), "认证处理异常"); | |
| 194 | + } | |
| 195 | + } | |
| 196 | + | |
| 197 | + /** | |
| 198 | + * 处理设备动态注册请求(一型一密,不需要认证) | |
| 199 | + * | |
| 200 | + * @param clientId 客户端 ID | |
| 201 | + * @param message 消息信息 | |
| 202 | + * @param socket WebSocket 连接 | |
| 203 | + * @see <a href="https://help.aliyun.com/zh/iot/user-guide/unique-certificate-per-product-verification">阿里云 - 一型一密</a> | |
| 204 | + */ | |
| 205 | + private void handleRegisterRequest(String clientId, IotDeviceMessage message, ServerWebSocket socket) { | |
| 206 | + try { | |
| 207 | + // 1. 解析注册参数 | |
| 208 | + IotDeviceRegisterReqDTO registerParams = parseRegisterParams(message.getParams()); | |
| 209 | + if (registerParams == null) { | |
| 210 | + log.warn("[handleRegisterRequest][注册参数解析失败,客户端 ID: {}]", clientId); | |
| 211 | + sendErrorResponse(socket, message.getRequestId(), "注册参数不完整"); | |
| 212 | + return; | |
| 213 | + } | |
| 214 | + | |
| 215 | + // 2. 调用动态注册 | |
| 216 | + CommonResult<IotDeviceRegisterRespDTO> result = deviceApi.registerDevice(registerParams); | |
| 217 | + if (result.isError()) { | |
| 218 | + log.warn("[handleRegisterRequest][注册失败,客户端 ID: {},错误: {}]", clientId, result.getMsg()); | |
| 219 | + sendErrorResponse(socket, message.getRequestId(), result.getMsg()); | |
| 220 | + return; | |
| 221 | + } | |
| 222 | + | |
| 223 | + // 3. 发送成功响应(包含 deviceSecret) | |
| 224 | + sendRegisterSuccessResponse(socket, message.getRequestId(), result.getData()); | |
| 225 | + log.info("[handleRegisterRequest][注册成功,客户端 ID: {},设备名: {}]", | |
| 226 | + clientId, registerParams.getDeviceName()); | |
| 227 | + } catch (Exception e) { | |
| 228 | + log.error("[handleRegisterRequest][注册处理异常,客户端 ID: {}]", clientId, e); | |
| 229 | + sendErrorResponse(socket, message.getRequestId(), "注册处理异常"); | |
| 230 | + } | |
| 231 | + } | |
| 232 | + | |
| 233 | + /** | |
| 234 | + * 处理业务请求 | |
| 235 | + * | |
| 236 | + * @param clientId 客户端 ID | |
| 237 | + * @param message 消息信息 | |
| 238 | + * @param socket WebSocket 连接 | |
| 239 | + */ | |
| 240 | + private void handleBusinessRequest(String clientId, IotDeviceMessage message, ServerWebSocket socket) { | |
| 241 | + try { | |
| 242 | + // 1. 获取认证信息并处理业务消息 | |
| 243 | + IotWebSocketConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket); | |
| 244 | + if (connectionInfo == null) { | |
| 245 | + log.warn("[handleBusinessRequest][连接未认证,拒绝处理业务消息,客户端 ID: {}]", clientId); | |
| 246 | + sendErrorResponse(socket, message.getRequestId(), "连接未认证"); | |
| 247 | + return; | |
| 248 | + } | |
| 249 | + | |
| 250 | + // 2. 发送消息到消息总线 | |
| 251 | + deviceMessageService.sendDeviceMessage(message, connectionInfo.getProductKey(), | |
| 252 | + connectionInfo.getDeviceName(), serverId); | |
| 253 | + log.info("[handleBusinessRequest][发送消息到消息总线,客户端 ID: {},消息: {}", | |
| 254 | + clientId, message.toString()); | |
| 255 | + } catch (Exception e) { | |
| 256 | + log.error("[handleBusinessRequest][业务请求处理异常,客户端 ID: {}]", clientId, e); | |
| 257 | + } | |
| 258 | + } | |
| 259 | + | |
| 260 | + /** | |
| 261 | + * 注册连接信息 | |
| 262 | + * | |
| 263 | + * @param socket WebSocket 连接 | |
| 264 | + * @param device 设备 | |
| 265 | + * @param clientId 客户端 ID | |
| 266 | + */ | |
| 267 | + private void registerConnection(ServerWebSocket socket, IotDeviceRespDTO device, String clientId) { | |
| 268 | + IotWebSocketConnectionManager.ConnectionInfo connectionInfo = new IotWebSocketConnectionManager.ConnectionInfo() | |
| 269 | + .setDeviceId(device.getId()) | |
| 270 | + .setProductKey(device.getProductKey()) | |
| 271 | + .setDeviceName(device.getDeviceName()) | |
| 272 | + .setClientId(clientId); | |
| 273 | + // 注册连接 | |
| 274 | + connectionManager.registerConnection(socket, device.getId(), connectionInfo); | |
| 275 | + } | |
| 276 | + | |
| 277 | + /** | |
| 278 | + * 发送设备上线消息 | |
| 279 | + * | |
| 280 | + * @param device 设备信息 | |
| 281 | + */ | |
| 282 | + private void sendOnlineMessage(IotDeviceRespDTO device) { | |
| 283 | + try { | |
| 284 | + IotDeviceMessage onlineMessage = IotDeviceMessage.buildStateUpdateOnline(); | |
| 285 | + deviceMessageService.sendDeviceMessage(onlineMessage, device.getProductKey(), | |
| 286 | + device.getDeviceName(), serverId); | |
| 287 | + } catch (Exception e) { | |
| 288 | + log.error("[sendOnlineMessage][发送上线消息失败,设备: {}]", device.getDeviceName(), e); | |
| 289 | + } | |
| 290 | + } | |
| 291 | + | |
| 292 | + /** | |
| 293 | + * 清理连接 | |
| 294 | + * | |
| 295 | + * @param socket WebSocket 连接 | |
| 296 | + */ | |
| 297 | + private void cleanupConnection(ServerWebSocket socket) { | |
| 298 | + try { | |
| 299 | + // 1. 发送离线消息(如果已认证) | |
| 300 | + IotWebSocketConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket); | |
| 301 | + if (connectionInfo != null) { | |
| 302 | + IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline(); | |
| 303 | + deviceMessageService.sendDeviceMessage(offlineMessage, connectionInfo.getProductKey(), | |
| 304 | + connectionInfo.getDeviceName(), serverId); | |
| 305 | + } | |
| 306 | + | |
| 307 | + // 2. 注销连接 | |
| 308 | + connectionManager.unregisterConnection(socket); | |
| 309 | + } catch (Exception e) { | |
| 310 | + log.error("[cleanupConnection][清理连接失败]", e); | |
| 311 | + } | |
| 312 | + } | |
| 313 | + | |
| 314 | + /** | |
| 315 | + * 发送响应消息 | |
| 316 | + * | |
| 317 | + * @param socket WebSocket 连接 | |
| 318 | + * @param success 是否成功 | |
| 319 | + * @param message 消息 | |
| 320 | + * @param requestId 请求 ID | |
| 321 | + */ | |
| 322 | + private void sendResponse(ServerWebSocket socket, boolean success, String message, String requestId) { | |
| 323 | + try { | |
| 324 | + Object responseData = MapUtil.builder() | |
| 325 | + .put("success", success) | |
| 326 | + .put("message", message) | |
| 327 | + .build(); | |
| 328 | + | |
| 329 | + int code = success ? 0 : 401; | |
| 330 | + IotDeviceMessage responseMessage = IotDeviceMessage.replyOf(requestId, AUTH_METHOD, responseData, | |
| 331 | + code, message); | |
| 332 | + | |
| 333 | + byte[] encodedData = deviceMessageService.encodeDeviceMessage(responseMessage, CODEC_TYPE); | |
| 334 | + socket.writeTextMessage(new String(encodedData, StandardCharsets.UTF_8)); | |
| 335 | + } catch (Exception e) { | |
| 336 | + log.error("[sendResponse][发送响应失败,requestId: {}]", requestId, e); | |
| 337 | + } | |
| 338 | + } | |
| 339 | + | |
| 340 | + /** | |
| 341 | + * 验证设备认证信息 | |
| 342 | + * | |
| 343 | + * @param authParams 认证参数 | |
| 344 | + * @return 是否认证成功 | |
| 345 | + */ | |
| 346 | + private boolean validateDeviceAuth(IotDeviceAuthReqDTO authParams) { | |
| 347 | + try { | |
| 348 | + CommonResult<Boolean> result = deviceApi.authDevice(new IotDeviceAuthReqDTO() | |
| 349 | + .setClientId(authParams.getClientId()).setUsername(authParams.getUsername()) | |
| 350 | + .setPassword(authParams.getPassword())); | |
| 351 | + result.checkError(); | |
| 352 | + return BooleanUtil.isTrue(result.getData()); | |
| 353 | + } catch (Exception e) { | |
| 354 | + log.error("[validateDeviceAuth][设备认证异常,username: {}]", authParams.getUsername(), e); | |
| 355 | + return false; | |
| 356 | + } | |
| 357 | + } | |
| 358 | + | |
| 359 | + /** | |
| 360 | + * 发送错误响应 | |
| 361 | + * | |
| 362 | + * @param socket WebSocket 连接 | |
| 363 | + * @param requestId 请求 ID | |
| 364 | + * @param errorMessage 错误消息 | |
| 365 | + */ | |
| 366 | + private void sendErrorResponse(ServerWebSocket socket, String requestId, String errorMessage) { | |
| 367 | + sendResponse(socket, false, errorMessage, requestId); | |
| 368 | + } | |
| 369 | + | |
| 370 | + /** | |
| 371 | + * 发送成功响应 | |
| 372 | + * | |
| 373 | + * @param socket WebSocket 连接 | |
| 374 | + * @param requestId 请求 ID | |
| 375 | + * @param message 消息 | |
| 376 | + */ | |
| 377 | + @SuppressWarnings("SameParameterValue") | |
| 378 | + private void sendSuccessResponse(ServerWebSocket socket, String requestId, String message) { | |
| 379 | + sendResponse(socket, true, message, requestId); | |
| 380 | + } | |
| 381 | + | |
| 382 | + /** | |
| 383 | + * 解析认证参数 | |
| 384 | + * | |
| 385 | + * @param params 参数对象(通常为 Map 类型) | |
| 386 | + * @return 认证参数 DTO,解析失败时返回 null | |
| 387 | + */ | |
| 388 | + @SuppressWarnings("unchecked") | |
| 389 | + private IotDeviceAuthReqDTO parseAuthParams(Object params) { | |
| 390 | + if (params == null) { | |
| 391 | + return null; | |
| 392 | + } | |
| 393 | + | |
| 394 | + try { | |
| 395 | + // 参数默认为 Map 类型,直接转换 | |
| 396 | + if (params instanceof java.util.Map) { | |
| 397 | + java.util.Map<String, Object> paramMap = (java.util.Map<String, Object>) params; | |
| 398 | + return new IotDeviceAuthReqDTO() | |
| 399 | + .setClientId(MapUtil.getStr(paramMap, "clientId")) | |
| 400 | + .setUsername(MapUtil.getStr(paramMap, "username")) | |
| 401 | + .setPassword(MapUtil.getStr(paramMap, "password")); | |
| 402 | + } | |
| 403 | + | |
| 404 | + // 如果已经是目标类型,直接返回 | |
| 405 | + if (params instanceof IotDeviceAuthReqDTO) { | |
| 406 | + return (IotDeviceAuthReqDTO) params; | |
| 407 | + } | |
| 408 | + | |
| 409 | + // 其他情况尝试 JSON 转换 | |
| 410 | + // TODO @芋艿:要不要优化下; | |
| 411 | + String jsonStr = JsonUtils.toJsonString(params); | |
| 412 | + return JsonUtils.convertObject(jsonStr, IotDeviceAuthReqDTO.class); | |
| 413 | + } catch (Exception e) { | |
| 414 | + log.error("[parseAuthParams][解析认证参数({})失败]", params, e); | |
| 415 | + return null; | |
| 416 | + } | |
| 417 | + } | |
| 418 | + | |
| 419 | + /** | |
| 420 | + * 解析注册参数 | |
| 421 | + * | |
| 422 | + * @param params 参数对象(通常为 Map 类型) | |
| 423 | + * @return 注册参数 DTO,解析失败时返回 null | |
| 424 | + */ | |
| 425 | + @SuppressWarnings("unchecked") | |
| 426 | + private IotDeviceRegisterReqDTO parseRegisterParams(Object params) { | |
| 427 | + if (params == null) { | |
| 428 | + return null; | |
| 429 | + } | |
| 430 | + | |
| 431 | + try { | |
| 432 | + // 参数默认为 Map 类型,直接转换 | |
| 433 | + if (params instanceof java.util.Map) { | |
| 434 | + java.util.Map<String, Object> paramMap = (java.util.Map<String, Object>) params; | |
| 435 | + String productKey = MapUtil.getStr(paramMap, "productKey"); | |
| 436 | + String deviceName = MapUtil.getStr(paramMap, "deviceName"); | |
| 437 | + String productSecret = MapUtil.getStr(paramMap, "productSecret"); | |
| 438 | + if (StrUtil.hasBlank(productKey, deviceName, productSecret)) { | |
| 439 | + return null; | |
| 440 | + } | |
| 441 | + return new IotDeviceRegisterReqDTO() | |
| 442 | + .setProductKey(productKey) | |
| 443 | + .setDeviceName(deviceName) | |
| 444 | + .setProductSecret(productSecret); | |
| 445 | + } | |
| 446 | + | |
| 447 | + // 如果已经是目标类型,直接返回 | |
| 448 | + if (params instanceof IotDeviceRegisterReqDTO) { | |
| 449 | + return (IotDeviceRegisterReqDTO) params; | |
| 450 | + } | |
| 451 | + | |
| 452 | + // 其他情况尝试 JSON 转换 | |
| 453 | + String jsonStr = JsonUtils.toJsonString(params); | |
| 454 | + return JsonUtils.parseObject(jsonStr, IotDeviceRegisterReqDTO.class); | |
| 455 | + } catch (Exception e) { | |
| 456 | + log.error("[parseRegisterParams][解析注册参数({})失败]", params, e); | |
| 457 | + return null; | |
| 458 | + } | |
| 459 | + } | |
| 460 | + | |
| 461 | + /** | |
| 462 | + * 发送注册成功响应(包含 deviceSecret) | |
| 463 | + * | |
| 464 | + * @param socket WebSocket 连接 | |
| 465 | + * @param requestId 请求 ID | |
| 466 | + * @param registerResp 注册响应 | |
| 467 | + */ | |
| 468 | + private void sendRegisterSuccessResponse(ServerWebSocket socket, String requestId, | |
| 469 | + IotDeviceRegisterRespDTO registerResp) { | |
| 470 | + try { | |
| 471 | + // 1. 构建响应消息(参考 HTTP 返回格式,直接返回 IotDeviceRegisterRespDTO) | |
| 472 | + IotDeviceMessage responseMessage = IotDeviceMessage.replyOf(requestId, | |
| 473 | + IotDeviceMessageMethodEnum.DEVICE_REGISTER.getMethod(), registerResp, 0, null); | |
| 474 | + // 2. 发送响应 | |
| 475 | + byte[] encodedData = deviceMessageService.encodeDeviceMessage(responseMessage, CODEC_TYPE); | |
| 476 | + socket.writeTextMessage(new String(encodedData, StandardCharsets.UTF_8)); | |
| 477 | + } catch (Exception e) { | |
| 478 | + log.error("[sendRegisterSuccessResponse][发送注册成功响应失败,requestId: {}]", requestId, e); | |
| 479 | + } | |
| 480 | + } | |
| 481 | + | |
| 482 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/resources/application.yaml
| ... | ... | @@ -123,6 +123,17 @@ yudao: |
| 123 | 123 | max-message-size: 1024 # 最大消息大小(字节) |
| 124 | 124 | ack-timeout: 2000 # ACK 超时时间(毫秒) |
| 125 | 125 | max-retransmit: 4 # 最大重传次数 |
| 126 | + # ==================================== | |
| 127 | + # 针对引入的 WebSocket 组件的配置 | |
| 128 | + # ==================================== | |
| 129 | + websocket: | |
| 130 | + enabled: false # 是否启用 WebSocket 协议 | |
| 131 | + port: 8094 # WebSocket 服务端口(默认 8094) | |
| 132 | + path: /ws # WebSocket 路径(默认 /ws) | |
| 133 | + max-message-size: 65536 # 最大消息大小(字节,默认 64KB) | |
| 134 | + max-frame-size: 65536 # 最大帧大小(字节,默认 64KB) | |
| 135 | + idle-timeout-seconds: 60 # 空闲超时时间(秒,默认 60) | |
| 136 | + ssl-enabled: false # 是否启用 SSL(wss://) | |
| 126 | 137 | |
| 127 | 138 | --- #################### 日志相关配置 #################### |
| 128 | 139 | |
| ... | ... | @@ -144,6 +155,7 @@ logging: |
| 144 | 155 | cn.iocoder.yudao.module.iot.gateway.protocol.mqtt: DEBUG |
| 145 | 156 | cn.iocoder.yudao.module.iot.gateway.protocol.mqttws: DEBUG |
| 146 | 157 | cn.iocoder.yudao.module.iot.gateway.protocol.coap: DEBUG |
| 158 | + cn.iocoder.yudao.module.iot.gateway.protocol.websocket: DEBUG | |
| 147 | 159 | # 根日志级别 |
| 148 | 160 | root: INFO |
| 149 | 161 | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotDirectDeviceWebSocketProtocolIntegrationTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.websocket; | |
| 2 | + | |
| 3 | +import cn.hutool.core.map.MapUtil; | |
| 4 | +import cn.hutool.core.util.IdUtil; | |
| 5 | +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; | |
| 6 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | |
| 7 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 8 | +import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO; | |
| 9 | +import cn.iocoder.yudao.module.iot.core.topic.event.IotDeviceEventPostReqDTO; | |
| 10 | +import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPostReqDTO; | |
| 11 | +import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils; | |
| 12 | +import cn.iocoder.yudao.module.iot.gateway.codec.IotDeviceMessageCodec; | |
| 13 | +import cn.iocoder.yudao.module.iot.gateway.codec.websocket.IotWebSocketJsonDeviceMessageCodec; | |
| 14 | +import io.vertx.core.Vertx; | |
| 15 | +import io.vertx.core.http.HttpClient; | |
| 16 | +import io.vertx.core.http.WebSocket; | |
| 17 | +import io.vertx.core.http.WebSocketConnectOptions; | |
| 18 | +import lombok.extern.slf4j.Slf4j; | |
| 19 | +import org.junit.jupiter.api.AfterAll; | |
| 20 | +import org.junit.jupiter.api.BeforeAll; | |
| 21 | +import org.junit.jupiter.api.Test; | |
| 22 | + | |
| 23 | +import java.nio.charset.StandardCharsets; | |
| 24 | +import java.util.concurrent.CountDownLatch; | |
| 25 | +import java.util.concurrent.TimeUnit; | |
| 26 | +import java.util.concurrent.atomic.AtomicReference; | |
| 27 | + | |
| 28 | +/** | |
| 29 | + * IoT 直连设备 WebSocket 协议集成测试(手动测试) | |
| 30 | + * | |
| 31 | + * <p>测试场景:直连设备(IotProductDeviceTypeEnum 的 DIRECT 类型)通过 WebSocket 协议直接连接平台 | |
| 32 | + * | |
| 33 | + * <p>使用步骤: | |
| 34 | + * <ol> | |
| 35 | + * <li>启动 yudao-module-iot-gateway 服务(WebSocket 端口 8094)</li> | |
| 36 | + * <li>运行以下测试方法: | |
| 37 | + * <ul> | |
| 38 | + * <li>{@link #testAuth()} - 设备认证</li> | |
| 39 | + * <li>{@link #testDeviceRegister()} - 设备动态注册(一型一密)</li> | |
| 40 | + * <li>{@link #testPropertyPost()} - 设备属性上报</li> | |
| 41 | + * <li>{@link #testEventPost()} - 设备事件上报</li> | |
| 42 | + * </ul> | |
| 43 | + * </li> | |
| 44 | + * </ol> | |
| 45 | + * | |
| 46 | + * <p>注意:WebSocket 协议是有状态的长连接,认证成功后同一连接上的后续请求无需再携带认证信息 | |
| 47 | + * | |
| 48 | + * @author 芋道源码 | |
| 49 | + */ | |
| 50 | +@Slf4j | |
| 51 | +public class IotDirectDeviceWebSocketProtocolIntegrationTest { | |
| 52 | + | |
| 53 | + private static final String SERVER_HOST = "127.0.0.1"; | |
| 54 | + private static final int SERVER_PORT = 8094; | |
| 55 | + private static final String WS_PATH = "/ws"; | |
| 56 | + private static final int TIMEOUT_SECONDS = 5; | |
| 57 | + | |
| 58 | + // 编解码器 | |
| 59 | + private static final IotDeviceMessageCodec CODEC = new IotWebSocketJsonDeviceMessageCodec(); | |
| 60 | + | |
| 61 | + // ===================== 直连设备信息(根据实际情况修改,从 iot_device 表查询) ===================== | |
| 62 | + private static final String PRODUCT_KEY = "4aymZgOTOOCrDKRT"; | |
| 63 | + private static final String DEVICE_NAME = "small"; | |
| 64 | + private static final String DEVICE_SECRET = "0baa4c2ecc104ae1a26b4070c218bdf3"; | |
| 65 | + | |
| 66 | + // Vert.x 实例 | |
| 67 | + private static Vertx vertx; | |
| 68 | + | |
| 69 | + @BeforeAll | |
| 70 | + public static void setUp() { | |
| 71 | + vertx = Vertx.vertx(); | |
| 72 | + } | |
| 73 | + | |
| 74 | + @AfterAll | |
| 75 | + public static void tearDown() { | |
| 76 | + if (vertx != null) { | |
| 77 | + vertx.close(); | |
| 78 | + } | |
| 79 | + } | |
| 80 | + | |
| 81 | + // ===================== 认证测试 ===================== | |
| 82 | + | |
| 83 | + /** | |
| 84 | + * 认证测试:获取设备 Token | |
| 85 | + */ | |
| 86 | + @Test | |
| 87 | + public void testAuth() throws Exception { | |
| 88 | + CountDownLatch latch = new CountDownLatch(1); | |
| 89 | + AtomicReference<String> responseRef = new AtomicReference<>(); | |
| 90 | + | |
| 91 | + // 1. 创建 WebSocket 连接 | |
| 92 | + HttpClient client = vertx.createHttpClient(); | |
| 93 | + WebSocketConnectOptions options = new WebSocketConnectOptions() | |
| 94 | + .setHost(SERVER_HOST) | |
| 95 | + .setPort(SERVER_PORT) | |
| 96 | + .setURI(WS_PATH); | |
| 97 | + | |
| 98 | + client.webSocket(options).onComplete(ar -> { | |
| 99 | + if (ar.succeeded()) { | |
| 100 | + WebSocket ws = ar.result(); | |
| 101 | + log.info("[testAuth][WebSocket 连接成功]"); | |
| 102 | + | |
| 103 | + // 设置消息处理器 | |
| 104 | + ws.textMessageHandler(message -> { | |
| 105 | + log.info("[testAuth][收到响应: {}]", message); | |
| 106 | + responseRef.set(message); | |
| 107 | + ws.close(); | |
| 108 | + latch.countDown(); | |
| 109 | + }); | |
| 110 | + | |
| 111 | + // 2. 构建认证消息 | |
| 112 | + IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET); | |
| 113 | + IotDeviceAuthReqDTO authReqDTO = new IotDeviceAuthReqDTO() | |
| 114 | + .setClientId(authInfo.getClientId()) | |
| 115 | + .setUsername(authInfo.getUsername()) | |
| 116 | + .setPassword(authInfo.getPassword()); | |
| 117 | + IotDeviceMessage request = IotDeviceMessage.of(IdUtil.fastSimpleUUID(), "auth", authReqDTO, null, null, null); | |
| 118 | + | |
| 119 | + // 3. 编码并发送 | |
| 120 | + byte[] payload = CODEC.encode(request); | |
| 121 | + String jsonMessage = new String(payload, StandardCharsets.UTF_8); | |
| 122 | + log.info("[testAuth][发送认证请求: {}]", jsonMessage); | |
| 123 | + ws.writeTextMessage(jsonMessage); | |
| 124 | + } else { | |
| 125 | + log.error("[testAuth][WebSocket 连接失败]", ar.cause()); | |
| 126 | + latch.countDown(); | |
| 127 | + } | |
| 128 | + }); | |
| 129 | + | |
| 130 | + // 4. 等待响应 | |
| 131 | + boolean completed = latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); | |
| 132 | + if (completed && responseRef.get() != null) { | |
| 133 | + IotDeviceMessage response = CODEC.decode(responseRef.get().getBytes(StandardCharsets.UTF_8)); | |
| 134 | + log.info("[testAuth][解码响应: {}]", response); | |
| 135 | + } else { | |
| 136 | + log.warn("[testAuth][测试超时或未收到响应]"); | |
| 137 | + } | |
| 138 | + } | |
| 139 | + | |
| 140 | + // ===================== 动态注册测试 ===================== | |
| 141 | + | |
| 142 | + /** | |
| 143 | + * 直连设备动态注册测试(一型一密) | |
| 144 | + */ | |
| 145 | + @Test | |
| 146 | + public void testDeviceRegister() throws Exception { | |
| 147 | + CountDownLatch latch = new CountDownLatch(1); | |
| 148 | + AtomicReference<String> responseRef = new AtomicReference<>(); | |
| 149 | + | |
| 150 | + HttpClient client = vertx.createHttpClient(); | |
| 151 | + WebSocketConnectOptions options = new WebSocketConnectOptions() | |
| 152 | + .setHost(SERVER_HOST) | |
| 153 | + .setPort(SERVER_PORT) | |
| 154 | + .setURI(WS_PATH); | |
| 155 | + | |
| 156 | + client.webSocket(options).onComplete(ar -> { | |
| 157 | + if (ar.succeeded()) { | |
| 158 | + WebSocket ws = ar.result(); | |
| 159 | + log.info("[testDeviceRegister][WebSocket 连接成功]"); | |
| 160 | + | |
| 161 | + ws.textMessageHandler(message -> { | |
| 162 | + log.info("[testDeviceRegister][收到响应: {}]", message); | |
| 163 | + responseRef.set(message); | |
| 164 | + ws.close(); | |
| 165 | + latch.countDown(); | |
| 166 | + }); | |
| 167 | + | |
| 168 | + // 构建注册消息 | |
| 169 | + IotDeviceRegisterReqDTO registerReqDTO = new IotDeviceRegisterReqDTO(); | |
| 170 | + registerReqDTO.setProductKey(PRODUCT_KEY); | |
| 171 | + registerReqDTO.setDeviceName("test-ws-" + System.currentTimeMillis()); | |
| 172 | + registerReqDTO.setProductSecret("test-product-secret"); | |
| 173 | + IotDeviceMessage request = IotDeviceMessage.of(IdUtil.fastSimpleUUID(), | |
| 174 | + IotDeviceMessageMethodEnum.DEVICE_REGISTER.getMethod(), registerReqDTO, null, null, null); | |
| 175 | + | |
| 176 | + byte[] payload = CODEC.encode(request); | |
| 177 | + String jsonMessage = new String(payload, StandardCharsets.UTF_8); | |
| 178 | + log.info("[testDeviceRegister][发送注册请求: {}]", jsonMessage); | |
| 179 | + ws.writeTextMessage(jsonMessage); | |
| 180 | + } else { | |
| 181 | + log.error("[testDeviceRegister][WebSocket 连接失败]", ar.cause()); | |
| 182 | + latch.countDown(); | |
| 183 | + } | |
| 184 | + }); | |
| 185 | + | |
| 186 | + boolean completed = latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); | |
| 187 | + if (completed && responseRef.get() != null) { | |
| 188 | + IotDeviceMessage response = CODEC.decode(responseRef.get().getBytes(StandardCharsets.UTF_8)); | |
| 189 | + log.info("[testDeviceRegister][解码响应: {}]", response); | |
| 190 | + } else { | |
| 191 | + log.warn("[testDeviceRegister][测试超时或未收到响应]"); | |
| 192 | + } | |
| 193 | + } | |
| 194 | + | |
| 195 | + // ===================== 直连设备属性上报测试 ===================== | |
| 196 | + | |
| 197 | + /** | |
| 198 | + * 属性上报测试 | |
| 199 | + */ | |
| 200 | + @Test | |
| 201 | + public void testPropertyPost() throws Exception { | |
| 202 | + CountDownLatch latch = new CountDownLatch(2); // 认证 + 属性上报 | |
| 203 | + AtomicReference<String> authResponseRef = new AtomicReference<>(); | |
| 204 | + AtomicReference<String> propertyResponseRef = new AtomicReference<>(); | |
| 205 | + | |
| 206 | + HttpClient client = vertx.createHttpClient(); | |
| 207 | + WebSocketConnectOptions options = new WebSocketConnectOptions() | |
| 208 | + .setHost(SERVER_HOST) | |
| 209 | + .setPort(SERVER_PORT) | |
| 210 | + .setURI(WS_PATH); | |
| 211 | + | |
| 212 | + client.webSocket(options).onComplete(ar -> { | |
| 213 | + if (ar.succeeded()) { | |
| 214 | + WebSocket ws = ar.result(); | |
| 215 | + log.info("[testPropertyPost][WebSocket 连接成功]"); | |
| 216 | + | |
| 217 | + final boolean[] authenticated = {false}; | |
| 218 | + | |
| 219 | + ws.textMessageHandler(message -> { | |
| 220 | + log.info("[testPropertyPost][收到响应: {}]", message); | |
| 221 | + if (!authenticated[0]) { | |
| 222 | + authResponseRef.set(message); | |
| 223 | + authenticated[0] = true; | |
| 224 | + latch.countDown(); | |
| 225 | + | |
| 226 | + // 认证成功后发送属性上报 | |
| 227 | + IotDeviceMessage propertyRequest = IotDeviceMessage.of( | |
| 228 | + IdUtil.fastSimpleUUID(), | |
| 229 | + IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), | |
| 230 | + IotDevicePropertyPostReqDTO.of(MapUtil.<String, Object>builder() | |
| 231 | + .put("width", 1) | |
| 232 | + .put("height", "2") | |
| 233 | + .build()), | |
| 234 | + null, null, null); | |
| 235 | + byte[] payload = CODEC.encode(propertyRequest); | |
| 236 | + String jsonMessage = new String(payload, StandardCharsets.UTF_8); | |
| 237 | + log.info("[testPropertyPost][发送属性上报请求: {}]", jsonMessage); | |
| 238 | + ws.writeTextMessage(jsonMessage); | |
| 239 | + } else { | |
| 240 | + propertyResponseRef.set(message); | |
| 241 | + ws.close(); | |
| 242 | + latch.countDown(); | |
| 243 | + } | |
| 244 | + }); | |
| 245 | + | |
| 246 | + // 先发送认证请求 | |
| 247 | + IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET); | |
| 248 | + IotDeviceAuthReqDTO authReqDTO = new IotDeviceAuthReqDTO() | |
| 249 | + .setClientId(authInfo.getClientId()) | |
| 250 | + .setUsername(authInfo.getUsername()) | |
| 251 | + .setPassword(authInfo.getPassword()); | |
| 252 | + IotDeviceMessage authRequest = IotDeviceMessage.of(IdUtil.fastSimpleUUID(), "auth", authReqDTO, null, null, null); | |
| 253 | + | |
| 254 | + byte[] payload = CODEC.encode(authRequest); | |
| 255 | + String jsonMessage = new String(payload, StandardCharsets.UTF_8); | |
| 256 | + log.info("[testPropertyPost][发送认证请求: {}]", jsonMessage); | |
| 257 | + ws.writeTextMessage(jsonMessage); | |
| 258 | + } else { | |
| 259 | + log.error("[testPropertyPost][WebSocket 连接失败]", ar.cause()); | |
| 260 | + latch.countDown(); | |
| 261 | + latch.countDown(); | |
| 262 | + } | |
| 263 | + }); | |
| 264 | + | |
| 265 | + boolean completed = latch.await(TIMEOUT_SECONDS * 2, TimeUnit.SECONDS); | |
| 266 | + if (completed) { | |
| 267 | + if (authResponseRef.get() != null) { | |
| 268 | + IotDeviceMessage authResponse = CODEC.decode(authResponseRef.get().getBytes(StandardCharsets.UTF_8)); | |
| 269 | + log.info("[testPropertyPost][认证响应: {}]", authResponse); | |
| 270 | + } | |
| 271 | + if (propertyResponseRef.get() != null) { | |
| 272 | + IotDeviceMessage propertyResponse = CODEC.decode(propertyResponseRef.get().getBytes(StandardCharsets.UTF_8)); | |
| 273 | + log.info("[testPropertyPost][属性上报响应: {}]", propertyResponse); | |
| 274 | + } | |
| 275 | + } else { | |
| 276 | + log.warn("[testPropertyPost][测试超时]"); | |
| 277 | + } | |
| 278 | + } | |
| 279 | + | |
| 280 | + // ===================== 直连设备事件上报测试 ===================== | |
| 281 | + | |
| 282 | + /** | |
| 283 | + * 事件上报测试 | |
| 284 | + */ | |
| 285 | + @Test | |
| 286 | + public void testEventPost() throws Exception { | |
| 287 | + CountDownLatch latch = new CountDownLatch(2); // 认证 + 事件上报 | |
| 288 | + AtomicReference<String> authResponseRef = new AtomicReference<>(); | |
| 289 | + AtomicReference<String> eventResponseRef = new AtomicReference<>(); | |
| 290 | + | |
| 291 | + HttpClient client = vertx.createHttpClient(); | |
| 292 | + WebSocketConnectOptions options = new WebSocketConnectOptions() | |
| 293 | + .setHost(SERVER_HOST) | |
| 294 | + .setPort(SERVER_PORT) | |
| 295 | + .setURI(WS_PATH); | |
| 296 | + | |
| 297 | + client.webSocket(options).onComplete(ar -> { | |
| 298 | + if (ar.succeeded()) { | |
| 299 | + WebSocket ws = ar.result(); | |
| 300 | + log.info("[testEventPost][WebSocket 连接成功]"); | |
| 301 | + | |
| 302 | + final boolean[] authenticated = {false}; | |
| 303 | + | |
| 304 | + ws.textMessageHandler(message -> { | |
| 305 | + log.info("[testEventPost][收到响应: {}]", message); | |
| 306 | + if (!authenticated[0]) { | |
| 307 | + authResponseRef.set(message); | |
| 308 | + authenticated[0] = true; | |
| 309 | + latch.countDown(); | |
| 310 | + | |
| 311 | + // 认证成功后发送事件上报 | |
| 312 | + IotDeviceMessage eventRequest = IotDeviceMessage.of( | |
| 313 | + IdUtil.fastSimpleUUID(), | |
| 314 | + IotDeviceMessageMethodEnum.EVENT_POST.getMethod(), | |
| 315 | + IotDeviceEventPostReqDTO.of( | |
| 316 | + "eat", | |
| 317 | + MapUtil.<String, Object>builder().put("rice", 3).build(), | |
| 318 | + System.currentTimeMillis()), | |
| 319 | + null, null, null); | |
| 320 | + byte[] payload = CODEC.encode(eventRequest); | |
| 321 | + String jsonMessage = new String(payload, StandardCharsets.UTF_8); | |
| 322 | + log.info("[testEventPost][发送事件上报请求: {}]", jsonMessage); | |
| 323 | + ws.writeTextMessage(jsonMessage); | |
| 324 | + } else { | |
| 325 | + eventResponseRef.set(message); | |
| 326 | + ws.close(); | |
| 327 | + latch.countDown(); | |
| 328 | + } | |
| 329 | + }); | |
| 330 | + | |
| 331 | + // 先发送认证请求 | |
| 332 | + IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET); | |
| 333 | + IotDeviceAuthReqDTO authReqDTO = new IotDeviceAuthReqDTO() | |
| 334 | + .setClientId(authInfo.getClientId()) | |
| 335 | + .setUsername(authInfo.getUsername()) | |
| 336 | + .setPassword(authInfo.getPassword()); | |
| 337 | + IotDeviceMessage authRequest = IotDeviceMessage.of(IdUtil.fastSimpleUUID(), "auth", authReqDTO, null, null, null); | |
| 338 | + | |
| 339 | + byte[] payload = CODEC.encode(authRequest); | |
| 340 | + String jsonMessage = new String(payload, StandardCharsets.UTF_8); | |
| 341 | + log.info("[testEventPost][发送认证请求: {}]", jsonMessage); | |
| 342 | + ws.writeTextMessage(jsonMessage); | |
| 343 | + } else { | |
| 344 | + log.error("[testEventPost][WebSocket 连接失败]", ar.cause()); | |
| 345 | + latch.countDown(); | |
| 346 | + latch.countDown(); | |
| 347 | + } | |
| 348 | + }); | |
| 349 | + | |
| 350 | + boolean completed = latch.await(TIMEOUT_SECONDS * 2, TimeUnit.SECONDS); | |
| 351 | + if (completed) { | |
| 352 | + if (authResponseRef.get() != null) { | |
| 353 | + IotDeviceMessage authResponse = CODEC.decode(authResponseRef.get().getBytes(StandardCharsets.UTF_8)); | |
| 354 | + log.info("[testEventPost][认证响应: {}]", authResponse); | |
| 355 | + } | |
| 356 | + if (eventResponseRef.get() != null) { | |
| 357 | + IotDeviceMessage eventResponse = CODEC.decode(eventResponseRef.get().getBytes(StandardCharsets.UTF_8)); | |
| 358 | + log.info("[testEventPost][事件上报响应: {}]", eventResponse); | |
| 359 | + } | |
| 360 | + } else { | |
| 361 | + log.warn("[testEventPost][测试超时]"); | |
| 362 | + } | |
| 363 | + } | |
| 364 | + | |
| 365 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotGatewayDeviceWebSocketProtocolIntegrationTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.websocket; | |
| 2 | + | |
| 3 | +import cn.hutool.core.map.MapUtil; | |
| 4 | +import cn.hutool.core.util.IdUtil; | |
| 5 | +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; | |
| 6 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | |
| 7 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 8 | +import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; | |
| 9 | +import cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterReqDTO; | |
| 10 | +import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPackPostReqDTO; | |
| 11 | +import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoAddReqDTO; | |
| 12 | +import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoDeleteReqDTO; | |
| 13 | +import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoGetReqDTO; | |
| 14 | +import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils; | |
| 15 | +import cn.iocoder.yudao.module.iot.gateway.codec.IotDeviceMessageCodec; | |
| 16 | +import cn.iocoder.yudao.module.iot.gateway.codec.websocket.IotWebSocketJsonDeviceMessageCodec; | |
| 17 | +import io.vertx.core.Vertx; | |
| 18 | +import io.vertx.core.http.HttpClient; | |
| 19 | +import io.vertx.core.http.WebSocket; | |
| 20 | +import io.vertx.core.http.WebSocketConnectOptions; | |
| 21 | +import lombok.extern.slf4j.Slf4j; | |
| 22 | +import org.junit.jupiter.api.AfterAll; | |
| 23 | +import org.junit.jupiter.api.BeforeAll; | |
| 24 | +import org.junit.jupiter.api.Test; | |
| 25 | + | |
| 26 | +import java.nio.charset.StandardCharsets; | |
| 27 | +import java.util.Collections; | |
| 28 | +import java.util.List; | |
| 29 | +import java.util.Map; | |
| 30 | +import java.util.concurrent.CountDownLatch; | |
| 31 | +import java.util.concurrent.TimeUnit; | |
| 32 | +import java.util.concurrent.atomic.AtomicReference; | |
| 33 | + | |
| 34 | +/** | |
| 35 | + * IoT 网关设备 WebSocket 协议集成测试(手动测试) | |
| 36 | + * | |
| 37 | + * <p>测试场景:网关设备(IotProductDeviceTypeEnum 的 GATEWAY 类型)通过 WebSocket 协议管理子设备拓扑关系 | |
| 38 | + * | |
| 39 | + * <p>使用步骤: | |
| 40 | + * <ol> | |
| 41 | + * <li>启动 yudao-module-iot-gateway 服务(WebSocket 端口 8094)</li> | |
| 42 | + * <li>运行以下测试方法: | |
| 43 | + * <ul> | |
| 44 | + * <li>{@link #testAuth()} - 网关设备认证</li> | |
| 45 | + * <li>{@link #testTopoAdd()} - 添加子设备拓扑关系</li> | |
| 46 | + * <li>{@link #testTopoDelete()} - 删除子设备拓扑关系</li> | |
| 47 | + * <li>{@link #testTopoGet()} - 获取子设备拓扑关系</li> | |
| 48 | + * <li>{@link #testSubDeviceRegister()} - 子设备动态注册</li> | |
| 49 | + * <li>{@link #testPropertyPackPost()} - 批量上报属性(网关 + 子设备)</li> | |
| 50 | + * </ul> | |
| 51 | + * </li> | |
| 52 | + * </ol> | |
| 53 | + * | |
| 54 | + * <p>注意:WebSocket 协议是有状态的长连接,认证成功后同一连接上的后续请求无需再携带认证信息 | |
| 55 | + * | |
| 56 | + * @author 芋道源码 | |
| 57 | + */ | |
| 58 | +@Slf4j | |
| 59 | +public class IotGatewayDeviceWebSocketProtocolIntegrationTest { | |
| 60 | + | |
| 61 | + private static final String SERVER_HOST = "127.0.0.1"; | |
| 62 | + private static final int SERVER_PORT = 8094; | |
| 63 | + private static final String WS_PATH = "/ws"; | |
| 64 | + private static final int TIMEOUT_SECONDS = 5; | |
| 65 | + | |
| 66 | + // 编解码器 | |
| 67 | + private static final IotDeviceMessageCodec CODEC = new IotWebSocketJsonDeviceMessageCodec(); | |
| 68 | + | |
| 69 | + // ===================== 网关设备信息(根据实际情况修改,从 iot_device 表查询网关设备) ===================== | |
| 70 | + private static final String GATEWAY_PRODUCT_KEY = "m6XcS1ZJ3TW8eC0v"; | |
| 71 | + private static final String GATEWAY_DEVICE_NAME = "sub-ddd"; | |
| 72 | + private static final String GATEWAY_DEVICE_SECRET = "b3d62c70f8a4495487ed1d35d61ac2b3"; | |
| 73 | + | |
| 74 | + // ===================== 子设备信息(根据实际情况修改,从 iot_device 表查询子设备) ===================== | |
| 75 | + private static final String SUB_DEVICE_PRODUCT_KEY = "jAufEMTF1W6wnPhn"; | |
| 76 | + private static final String SUB_DEVICE_NAME = "chazuo-it"; | |
| 77 | + private static final String SUB_DEVICE_SECRET = "d46ef9b28ab14238b9c00a3a668032af"; | |
| 78 | + | |
| 79 | + // Vert.x 实例 | |
| 80 | + private static Vertx vertx; | |
| 81 | + | |
| 82 | + @BeforeAll | |
| 83 | + public static void setUp() { | |
| 84 | + vertx = Vertx.vertx(); | |
| 85 | + } | |
| 86 | + | |
| 87 | + @AfterAll | |
| 88 | + public static void tearDown() { | |
| 89 | + if (vertx != null) { | |
| 90 | + vertx.close(); | |
| 91 | + } | |
| 92 | + } | |
| 93 | + | |
| 94 | + // ===================== 认证测试 ===================== | |
| 95 | + | |
| 96 | + /** | |
| 97 | + * 网关设备认证测试 | |
| 98 | + */ | |
| 99 | + @Test | |
| 100 | + public void testAuth() throws Exception { | |
| 101 | + CountDownLatch latch = new CountDownLatch(1); | |
| 102 | + AtomicReference<String> responseRef = new AtomicReference<>(); | |
| 103 | + | |
| 104 | + HttpClient client = vertx.createHttpClient(); | |
| 105 | + WebSocketConnectOptions options = new WebSocketConnectOptions() | |
| 106 | + .setHost(SERVER_HOST) | |
| 107 | + .setPort(SERVER_PORT) | |
| 108 | + .setURI(WS_PATH); | |
| 109 | + | |
| 110 | + client.webSocket(options).onComplete(ar -> { | |
| 111 | + if (ar.succeeded()) { | |
| 112 | + WebSocket ws = ar.result(); | |
| 113 | + log.info("[testAuth][WebSocket 连接成功]"); | |
| 114 | + | |
| 115 | + ws.textMessageHandler(message -> { | |
| 116 | + log.info("[testAuth][收到响应: {}]", message); | |
| 117 | + responseRef.set(message); | |
| 118 | + ws.close(); | |
| 119 | + latch.countDown(); | |
| 120 | + }); | |
| 121 | + | |
| 122 | + // 构建认证消息 | |
| 123 | + IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo( | |
| 124 | + GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME, GATEWAY_DEVICE_SECRET); | |
| 125 | + IotDeviceAuthReqDTO authReqDTO = new IotDeviceAuthReqDTO() | |
| 126 | + .setClientId(authInfo.getClientId()) | |
| 127 | + .setUsername(authInfo.getUsername()) | |
| 128 | + .setPassword(authInfo.getPassword()); | |
| 129 | + IotDeviceMessage request = IotDeviceMessage.of(IdUtil.fastSimpleUUID(), "auth", authReqDTO, null, null, null); | |
| 130 | + | |
| 131 | + byte[] payload = CODEC.encode(request); | |
| 132 | + String jsonMessage = new String(payload, StandardCharsets.UTF_8); | |
| 133 | + log.info("[testAuth][发送认证请求: {}]", jsonMessage); | |
| 134 | + ws.writeTextMessage(jsonMessage); | |
| 135 | + } else { | |
| 136 | + log.error("[testAuth][WebSocket 连接失败]", ar.cause()); | |
| 137 | + latch.countDown(); | |
| 138 | + } | |
| 139 | + }); | |
| 140 | + | |
| 141 | + boolean completed = latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); | |
| 142 | + if (completed && responseRef.get() != null) { | |
| 143 | + IotDeviceMessage response = CODEC.decode(responseRef.get().getBytes(StandardCharsets.UTF_8)); | |
| 144 | + log.info("[testAuth][解码响应: {}]", response); | |
| 145 | + } else { | |
| 146 | + log.warn("[testAuth][测试超时或未收到响应]"); | |
| 147 | + } | |
| 148 | + } | |
| 149 | + | |
| 150 | + // ===================== 拓扑管理测试 ===================== | |
| 151 | + | |
| 152 | + /** | |
| 153 | + * 添加子设备拓扑关系测试 | |
| 154 | + */ | |
| 155 | + @Test | |
| 156 | + public void testTopoAdd() throws Exception { | |
| 157 | + executeAuthenticatedRequest("testTopoAdd", ws -> { | |
| 158 | + // 构建子设备认证信息 | |
| 159 | + IotDeviceAuthReqDTO subAuthInfo = IotDeviceAuthUtils.getAuthInfo( | |
| 160 | + SUB_DEVICE_PRODUCT_KEY, SUB_DEVICE_NAME, SUB_DEVICE_SECRET); | |
| 161 | + IotDeviceAuthReqDTO subDeviceAuth = new IotDeviceAuthReqDTO() | |
| 162 | + .setClientId(subAuthInfo.getClientId()) | |
| 163 | + .setUsername(subAuthInfo.getUsername()) | |
| 164 | + .setPassword(subAuthInfo.getPassword()); | |
| 165 | + // 构建请求参数 | |
| 166 | + IotDeviceTopoAddReqDTO params = new IotDeviceTopoAddReqDTO(); | |
| 167 | + params.setSubDevices(Collections.singletonList(subDeviceAuth)); | |
| 168 | + return IotDeviceMessage.of( | |
| 169 | + IdUtil.fastSimpleUUID(), | |
| 170 | + IotDeviceMessageMethodEnum.TOPO_ADD.getMethod(), | |
| 171 | + params, | |
| 172 | + null, null, null); | |
| 173 | + }); | |
| 174 | + } | |
| 175 | + | |
| 176 | + /** | |
| 177 | + * 删除子设备拓扑关系测试 | |
| 178 | + */ | |
| 179 | + @Test | |
| 180 | + public void testTopoDelete() throws Exception { | |
| 181 | + executeAuthenticatedRequest("testTopoDelete", ws -> { | |
| 182 | + IotDeviceTopoDeleteReqDTO params = new IotDeviceTopoDeleteReqDTO(); | |
| 183 | + params.setSubDevices(Collections.singletonList( | |
| 184 | + new IotDeviceIdentity(SUB_DEVICE_PRODUCT_KEY, SUB_DEVICE_NAME))); | |
| 185 | + return IotDeviceMessage.of( | |
| 186 | + IdUtil.fastSimpleUUID(), | |
| 187 | + IotDeviceMessageMethodEnum.TOPO_DELETE.getMethod(), | |
| 188 | + params, | |
| 189 | + null, null, null); | |
| 190 | + }); | |
| 191 | + } | |
| 192 | + | |
| 193 | + /** | |
| 194 | + * 获取子设备拓扑关系测试 | |
| 195 | + */ | |
| 196 | + @Test | |
| 197 | + public void testTopoGet() throws Exception { | |
| 198 | + executeAuthenticatedRequest("testTopoGet", ws -> { | |
| 199 | + IotDeviceTopoGetReqDTO params = new IotDeviceTopoGetReqDTO(); | |
| 200 | + return IotDeviceMessage.of( | |
| 201 | + IdUtil.fastSimpleUUID(), | |
| 202 | + IotDeviceMessageMethodEnum.TOPO_GET.getMethod(), | |
| 203 | + params, | |
| 204 | + null, null, null); | |
| 205 | + }); | |
| 206 | + } | |
| 207 | + | |
| 208 | + // ===================== 子设备注册测试 ===================== | |
| 209 | + | |
| 210 | + /** | |
| 211 | + * 子设备动态注册测试 | |
| 212 | + */ | |
| 213 | + @Test | |
| 214 | + public void testSubDeviceRegister() throws Exception { | |
| 215 | + executeAuthenticatedRequest("testSubDeviceRegister", ws -> { | |
| 216 | + IotSubDeviceRegisterReqDTO subDevice = new IotSubDeviceRegisterReqDTO(); | |
| 217 | + subDevice.setProductKey(SUB_DEVICE_PRODUCT_KEY); | |
| 218 | + subDevice.setDeviceName("mougezishebei-ws"); | |
| 219 | + return IotDeviceMessage.of( | |
| 220 | + IdUtil.fastSimpleUUID(), | |
| 221 | + IotDeviceMessageMethodEnum.SUB_DEVICE_REGISTER.getMethod(), | |
| 222 | + Collections.singletonList(subDevice), | |
| 223 | + null, null, null); | |
| 224 | + }); | |
| 225 | + } | |
| 226 | + | |
| 227 | + // ===================== 批量上报测试 ===================== | |
| 228 | + | |
| 229 | + /** | |
| 230 | + * 批量上报属性测试(网关 + 子设备) | |
| 231 | + */ | |
| 232 | + @Test | |
| 233 | + public void testPropertyPackPost() throws Exception { | |
| 234 | + executeAuthenticatedRequest("testPropertyPackPost", ws -> { | |
| 235 | + // 构建【网关设备】自身属性 | |
| 236 | + Map<String, Object> gatewayProperties = MapUtil.<String, Object>builder() | |
| 237 | + .put("temperature", 25.5) | |
| 238 | + .build(); | |
| 239 | + // 构建【网关设备】自身事件 | |
| 240 | + IotDevicePropertyPackPostReqDTO.EventValue gatewayEvent = new IotDevicePropertyPackPostReqDTO.EventValue(); | |
| 241 | + gatewayEvent.setValue(MapUtil.builder().put("message", "gateway started").build()); | |
| 242 | + gatewayEvent.setTime(System.currentTimeMillis()); | |
| 243 | + Map<String, IotDevicePropertyPackPostReqDTO.EventValue> gatewayEvents = MapUtil.<String, IotDevicePropertyPackPostReqDTO.EventValue>builder() | |
| 244 | + .put("statusReport", gatewayEvent) | |
| 245 | + .build(); | |
| 246 | + // 构建【网关子设备】属性 | |
| 247 | + Map<String, Object> subDeviceProperties = MapUtil.<String, Object>builder() | |
| 248 | + .put("power", 100) | |
| 249 | + .build(); | |
| 250 | + // 构建【网关子设备】事件 | |
| 251 | + IotDevicePropertyPackPostReqDTO.EventValue subDeviceEvent = new IotDevicePropertyPackPostReqDTO.EventValue(); | |
| 252 | + subDeviceEvent.setValue(MapUtil.builder().put("errorCode", 0).build()); | |
| 253 | + subDeviceEvent.setTime(System.currentTimeMillis()); | |
| 254 | + Map<String, IotDevicePropertyPackPostReqDTO.EventValue> subDeviceEvents = MapUtil.<String, IotDevicePropertyPackPostReqDTO.EventValue>builder() | |
| 255 | + .put("healthCheck", subDeviceEvent) | |
| 256 | + .build(); | |
| 257 | + // 构建子设备数据 | |
| 258 | + IotDevicePropertyPackPostReqDTO.SubDeviceData subDeviceData = new IotDevicePropertyPackPostReqDTO.SubDeviceData(); | |
| 259 | + subDeviceData.setIdentity(new IotDeviceIdentity(SUB_DEVICE_PRODUCT_KEY, SUB_DEVICE_NAME)); | |
| 260 | + subDeviceData.setProperties(subDeviceProperties); | |
| 261 | + subDeviceData.setEvents(subDeviceEvents); | |
| 262 | + // 构建请求参数 | |
| 263 | + IotDevicePropertyPackPostReqDTO params = new IotDevicePropertyPackPostReqDTO(); | |
| 264 | + params.setProperties(gatewayProperties); | |
| 265 | + params.setEvents(gatewayEvents); | |
| 266 | + params.setSubDevices(List.of(subDeviceData)); | |
| 267 | + return IotDeviceMessage.of( | |
| 268 | + IdUtil.fastSimpleUUID(), | |
| 269 | + IotDeviceMessageMethodEnum.PROPERTY_PACK_POST.getMethod(), | |
| 270 | + params, | |
| 271 | + null, null, null); | |
| 272 | + }); | |
| 273 | + } | |
| 274 | + | |
| 275 | + // ===================== 辅助方法 ===================== | |
| 276 | + | |
| 277 | + /** | |
| 278 | + * 执行需要认证的请求 | |
| 279 | + * | |
| 280 | + * @param testName 测试名称 | |
| 281 | + * @param requestSupplier 请求消息提供者 | |
| 282 | + */ | |
| 283 | + private void executeAuthenticatedRequest(String testName, java.util.function.Function<WebSocket, IotDeviceMessage> requestSupplier) throws Exception { | |
| 284 | + CountDownLatch latch = new CountDownLatch(2); | |
| 285 | + AtomicReference<String> authResponseRef = new AtomicReference<>(); | |
| 286 | + AtomicReference<String> businessResponseRef = new AtomicReference<>(); | |
| 287 | + | |
| 288 | + HttpClient client = vertx.createHttpClient(); | |
| 289 | + WebSocketConnectOptions options = new WebSocketConnectOptions() | |
| 290 | + .setHost(SERVER_HOST) | |
| 291 | + .setPort(SERVER_PORT) | |
| 292 | + .setURI(WS_PATH); | |
| 293 | + | |
| 294 | + client.webSocket(options).onComplete(ar -> { | |
| 295 | + if (ar.succeeded()) { | |
| 296 | + WebSocket ws = ar.result(); | |
| 297 | + log.info("[{}][WebSocket 连接成功]", testName); | |
| 298 | + | |
| 299 | + final boolean[] authenticated = {false}; | |
| 300 | + | |
| 301 | + ws.textMessageHandler(message -> { | |
| 302 | + log.info("[{}][收到响应: {}]", testName, message); | |
| 303 | + if (!authenticated[0]) { | |
| 304 | + authResponseRef.set(message); | |
| 305 | + authenticated[0] = true; | |
| 306 | + latch.countDown(); | |
| 307 | + | |
| 308 | + // 认证成功后发送业务请求 | |
| 309 | + IotDeviceMessage businessRequest = requestSupplier.apply(ws); | |
| 310 | + byte[] payload = CODEC.encode(businessRequest); | |
| 311 | + String jsonMessage = new String(payload, StandardCharsets.UTF_8); | |
| 312 | + log.info("[{}][发送业务请求: {}]", testName, jsonMessage); | |
| 313 | + ws.writeTextMessage(jsonMessage); | |
| 314 | + } else { | |
| 315 | + businessResponseRef.set(message); | |
| 316 | + ws.close(); | |
| 317 | + latch.countDown(); | |
| 318 | + } | |
| 319 | + }); | |
| 320 | + | |
| 321 | + // 先发送认证请求 | |
| 322 | + IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo( | |
| 323 | + GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME, GATEWAY_DEVICE_SECRET); | |
| 324 | + IotDeviceAuthReqDTO authReqDTO = new IotDeviceAuthReqDTO() | |
| 325 | + .setClientId(authInfo.getClientId()) | |
| 326 | + .setUsername(authInfo.getUsername()) | |
| 327 | + .setPassword(authInfo.getPassword()); | |
| 328 | + IotDeviceMessage authRequest = IotDeviceMessage.of(IdUtil.fastSimpleUUID(), "auth", authReqDTO, null, null, null); | |
| 329 | + | |
| 330 | + byte[] payload = CODEC.encode(authRequest); | |
| 331 | + String jsonMessage = new String(payload, StandardCharsets.UTF_8); | |
| 332 | + log.info("[{}][发送认证请求: {}]", testName, jsonMessage); | |
| 333 | + ws.writeTextMessage(jsonMessage); | |
| 334 | + } else { | |
| 335 | + log.error("[{}][WebSocket 连接失败]", testName, ar.cause()); | |
| 336 | + latch.countDown(); | |
| 337 | + latch.countDown(); | |
| 338 | + } | |
| 339 | + }); | |
| 340 | + | |
| 341 | + boolean completed = latch.await(TIMEOUT_SECONDS * 2, TimeUnit.SECONDS); | |
| 342 | + if (completed) { | |
| 343 | + if (authResponseRef.get() != null) { | |
| 344 | + IotDeviceMessage authResponse = CODEC.decode(authResponseRef.get().getBytes(StandardCharsets.UTF_8)); | |
| 345 | + log.info("[{}][认证响应: {}]", testName, authResponse); | |
| 346 | + } | |
| 347 | + if (businessResponseRef.get() != null) { | |
| 348 | + IotDeviceMessage businessResponse = CODEC.decode(businessResponseRef.get().getBytes(StandardCharsets.UTF_8)); | |
| 349 | + log.info("[{}][业务响应: {}]", testName, businessResponse); | |
| 350 | + } | |
| 351 | + } else { | |
| 352 | + log.warn("[{}][测试超时]", testName); | |
| 353 | + } | |
| 354 | + } | |
| 355 | + | |
| 356 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotGatewaySubDeviceWebSocketProtocolIntegrationTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.websocket; | |
| 2 | + | |
| 3 | +import cn.hutool.core.map.MapUtil; | |
| 4 | +import cn.hutool.core.util.IdUtil; | |
| 5 | +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; | |
| 6 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | |
| 7 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 8 | +import cn.iocoder.yudao.module.iot.core.topic.event.IotDeviceEventPostReqDTO; | |
| 9 | +import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPostReqDTO; | |
| 10 | +import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils; | |
| 11 | +import cn.iocoder.yudao.module.iot.gateway.codec.IotDeviceMessageCodec; | |
| 12 | +import cn.iocoder.yudao.module.iot.gateway.codec.websocket.IotWebSocketJsonDeviceMessageCodec; | |
| 13 | +import io.vertx.core.Vertx; | |
| 14 | +import io.vertx.core.http.HttpClient; | |
| 15 | +import io.vertx.core.http.WebSocket; | |
| 16 | +import io.vertx.core.http.WebSocketConnectOptions; | |
| 17 | +import lombok.extern.slf4j.Slf4j; | |
| 18 | +import org.junit.jupiter.api.AfterAll; | |
| 19 | +import org.junit.jupiter.api.BeforeAll; | |
| 20 | +import org.junit.jupiter.api.Test; | |
| 21 | + | |
| 22 | +import java.nio.charset.StandardCharsets; | |
| 23 | +import java.util.concurrent.CountDownLatch; | |
| 24 | +import java.util.concurrent.TimeUnit; | |
| 25 | +import java.util.concurrent.atomic.AtomicReference; | |
| 26 | + | |
| 27 | +/** | |
| 28 | + * IoT 网关子设备 WebSocket 协议集成测试(手动测试) | |
| 29 | + * | |
| 30 | + * <p>测试场景:子设备(IotProductDeviceTypeEnum 的 SUB 类型)通过网关设备代理上报数据 | |
| 31 | + * | |
| 32 | + * <p><b>重要说明:子设备无法直接连接平台,所有请求均由网关设备(Gateway)代为转发。</b> | |
| 33 | + * | |
| 34 | + * <p>使用步骤: | |
| 35 | + * <ol> | |
| 36 | + * <li>启动 yudao-module-iot-gateway 服务(WebSocket 端口 8094)</li> | |
| 37 | + * <li>确保子设备已通过 {@link IotGatewayDeviceWebSocketProtocolIntegrationTest#testTopoAdd()} 绑定到网关</li> | |
| 38 | + * <li>运行以下测试方法: | |
| 39 | + * <ul> | |
| 40 | + * <li>{@link #testAuth()} - 子设备认证</li> | |
| 41 | + * <li>{@link #testPropertyPost()} - 子设备属性上报(由网关代理转发)</li> | |
| 42 | + * <li>{@link #testEventPost()} - 子设备事件上报(由网关代理转发)</li> | |
| 43 | + * </ul> | |
| 44 | + * </li> | |
| 45 | + * </ol> | |
| 46 | + * | |
| 47 | + * <p>注意:WebSocket 协议是有状态的长连接,认证成功后同一连接上的后续请求无需再携带认证信息 | |
| 48 | + * | |
| 49 | + * @author 芋道源码 | |
| 50 | + */ | |
| 51 | +@Slf4j | |
| 52 | +public class IotGatewaySubDeviceWebSocketProtocolIntegrationTest { | |
| 53 | + | |
| 54 | + private static final String SERVER_HOST = "127.0.0.1"; | |
| 55 | + private static final int SERVER_PORT = 8094; | |
| 56 | + private static final String WS_PATH = "/ws"; | |
| 57 | + private static final int TIMEOUT_SECONDS = 5; | |
| 58 | + | |
| 59 | + // 编解码器 | |
| 60 | + private static final IotDeviceMessageCodec CODEC = new IotWebSocketJsonDeviceMessageCodec(); | |
| 61 | + | |
| 62 | + // ===================== 网关子设备信息(根据实际情况修改,从 iot_device 表查询子设备) ===================== | |
| 63 | + private static final String PRODUCT_KEY = "jAufEMTF1W6wnPhn"; | |
| 64 | + private static final String DEVICE_NAME = "chazuo-it"; | |
| 65 | + private static final String DEVICE_SECRET = "d46ef9b28ab14238b9c00a3a668032af"; | |
| 66 | + | |
| 67 | + // Vert.x 实例 | |
| 68 | + private static Vertx vertx; | |
| 69 | + | |
| 70 | + @BeforeAll | |
| 71 | + public static void setUp() { | |
| 72 | + vertx = Vertx.vertx(); | |
| 73 | + } | |
| 74 | + | |
| 75 | + @AfterAll | |
| 76 | + public static void tearDown() { | |
| 77 | + if (vertx != null) { | |
| 78 | + vertx.close(); | |
| 79 | + } | |
| 80 | + } | |
| 81 | + | |
| 82 | + // ===================== 认证测试 ===================== | |
| 83 | + | |
| 84 | + /** | |
| 85 | + * 子设备认证测试 | |
| 86 | + */ | |
| 87 | + @Test | |
| 88 | + public void testAuth() throws Exception { | |
| 89 | + CountDownLatch latch = new CountDownLatch(1); | |
| 90 | + AtomicReference<String> responseRef = new AtomicReference<>(); | |
| 91 | + | |
| 92 | + HttpClient client = vertx.createHttpClient(); | |
| 93 | + WebSocketConnectOptions options = new WebSocketConnectOptions() | |
| 94 | + .setHost(SERVER_HOST) | |
| 95 | + .setPort(SERVER_PORT) | |
| 96 | + .setURI(WS_PATH); | |
| 97 | + | |
| 98 | + client.webSocket(options).onComplete(ar -> { | |
| 99 | + if (ar.succeeded()) { | |
| 100 | + WebSocket ws = ar.result(); | |
| 101 | + log.info("[testAuth][WebSocket 连接成功]"); | |
| 102 | + | |
| 103 | + ws.textMessageHandler(message -> { | |
| 104 | + log.info("[testAuth][收到响应: {}]", message); | |
| 105 | + responseRef.set(message); | |
| 106 | + ws.close(); | |
| 107 | + latch.countDown(); | |
| 108 | + }); | |
| 109 | + | |
| 110 | + // 构建认证消息 | |
| 111 | + IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET); | |
| 112 | + IotDeviceAuthReqDTO authReqDTO = new IotDeviceAuthReqDTO() | |
| 113 | + .setClientId(authInfo.getClientId()) | |
| 114 | + .setUsername(authInfo.getUsername()) | |
| 115 | + .setPassword(authInfo.getPassword()); | |
| 116 | + IotDeviceMessage request = IotDeviceMessage.of(IdUtil.fastSimpleUUID(), "auth", authReqDTO, null, null, null); | |
| 117 | + | |
| 118 | + byte[] payload = CODEC.encode(request); | |
| 119 | + String jsonMessage = new String(payload, StandardCharsets.UTF_8); | |
| 120 | + log.info("[testAuth][发送认证请求: {}]", jsonMessage); | |
| 121 | + ws.writeTextMessage(jsonMessage); | |
| 122 | + } else { | |
| 123 | + log.error("[testAuth][WebSocket 连接失败]", ar.cause()); | |
| 124 | + latch.countDown(); | |
| 125 | + } | |
| 126 | + }); | |
| 127 | + | |
| 128 | + boolean completed = latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); | |
| 129 | + if (completed && responseRef.get() != null) { | |
| 130 | + IotDeviceMessage response = CODEC.decode(responseRef.get().getBytes(StandardCharsets.UTF_8)); | |
| 131 | + log.info("[testAuth][解码响应: {}]", response); | |
| 132 | + } else { | |
| 133 | + log.warn("[testAuth][测试超时或未收到响应]"); | |
| 134 | + } | |
| 135 | + } | |
| 136 | + | |
| 137 | + // ===================== 子设备属性上报测试 ===================== | |
| 138 | + | |
| 139 | + /** | |
| 140 | + * 子设备属性上报测试 | |
| 141 | + */ | |
| 142 | + @Test | |
| 143 | + public void testPropertyPost() throws Exception { | |
| 144 | + executeAuthenticatedRequest("testPropertyPost", ws -> { | |
| 145 | + log.info("[testPropertyPost][子设备属性上报 - 请求实际由 Gateway 代为转发]"); | |
| 146 | + return IotDeviceMessage.of( | |
| 147 | + IdUtil.fastSimpleUUID(), | |
| 148 | + IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), | |
| 149 | + IotDevicePropertyPostReqDTO.of(MapUtil.<String, Object>builder() | |
| 150 | + .put("power", 100) | |
| 151 | + .put("status", "online") | |
| 152 | + .put("temperature", 36.5) | |
| 153 | + .build()), | |
| 154 | + null, null, null); | |
| 155 | + }); | |
| 156 | + } | |
| 157 | + | |
| 158 | + // ===================== 子设备事件上报测试 ===================== | |
| 159 | + | |
| 160 | + /** | |
| 161 | + * 子设备事件上报测试 | |
| 162 | + */ | |
| 163 | + @Test | |
| 164 | + public void testEventPost() throws Exception { | |
| 165 | + executeAuthenticatedRequest("testEventPost", ws -> { | |
| 166 | + log.info("[testEventPost][子设备事件上报 - 请求实际由 Gateway 代为转发]"); | |
| 167 | + return IotDeviceMessage.of( | |
| 168 | + IdUtil.fastSimpleUUID(), | |
| 169 | + IotDeviceMessageMethodEnum.EVENT_POST.getMethod(), | |
| 170 | + IotDeviceEventPostReqDTO.of( | |
| 171 | + "alarm", | |
| 172 | + MapUtil.<String, Object>builder() | |
| 173 | + .put("level", "warning") | |
| 174 | + .put("message", "temperature too high") | |
| 175 | + .put("threshold", 40) | |
| 176 | + .put("current", 42) | |
| 177 | + .build(), | |
| 178 | + System.currentTimeMillis()), | |
| 179 | + null, null, null); | |
| 180 | + }); | |
| 181 | + } | |
| 182 | + | |
| 183 | + // ===================== 辅助方法 ===================== | |
| 184 | + | |
| 185 | + /** | |
| 186 | + * 执行需要认证的请求 | |
| 187 | + * | |
| 188 | + * @param testName 测试名称 | |
| 189 | + * @param requestSupplier 请求消息提供者 | |
| 190 | + */ | |
| 191 | + private void executeAuthenticatedRequest(String testName, java.util.function.Function<WebSocket, IotDeviceMessage> requestSupplier) throws Exception { | |
| 192 | + CountDownLatch latch = new CountDownLatch(2); | |
| 193 | + AtomicReference<String> authResponseRef = new AtomicReference<>(); | |
| 194 | + AtomicReference<String> businessResponseRef = new AtomicReference<>(); | |
| 195 | + | |
| 196 | + HttpClient client = vertx.createHttpClient(); | |
| 197 | + WebSocketConnectOptions options = new WebSocketConnectOptions() | |
| 198 | + .setHost(SERVER_HOST) | |
| 199 | + .setPort(SERVER_PORT) | |
| 200 | + .setURI(WS_PATH); | |
| 201 | + | |
| 202 | + client.webSocket(options).onComplete(ar -> { | |
| 203 | + if (ar.succeeded()) { | |
| 204 | + WebSocket ws = ar.result(); | |
| 205 | + log.info("[{}][WebSocket 连接成功]", testName); | |
| 206 | + | |
| 207 | + final boolean[] authenticated = {false}; | |
| 208 | + | |
| 209 | + ws.textMessageHandler(message -> { | |
| 210 | + log.info("[{}][收到响应: {}]", testName, message); | |
| 211 | + if (!authenticated[0]) { | |
| 212 | + authResponseRef.set(message); | |
| 213 | + authenticated[0] = true; | |
| 214 | + latch.countDown(); | |
| 215 | + | |
| 216 | + // 认证成功后发送业务请求 | |
| 217 | + IotDeviceMessage businessRequest = requestSupplier.apply(ws); | |
| 218 | + byte[] payload = CODEC.encode(businessRequest); | |
| 219 | + String jsonMessage = new String(payload, StandardCharsets.UTF_8); | |
| 220 | + log.info("[{}][发送业务请求: {}]", testName, jsonMessage); | |
| 221 | + ws.writeTextMessage(jsonMessage); | |
| 222 | + } else { | |
| 223 | + businessResponseRef.set(message); | |
| 224 | + ws.close(); | |
| 225 | + latch.countDown(); | |
| 226 | + } | |
| 227 | + }); | |
| 228 | + | |
| 229 | + // 先发送认证请求 | |
| 230 | + IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET); | |
| 231 | + IotDeviceAuthReqDTO authReqDTO = new IotDeviceAuthReqDTO() | |
| 232 | + .setClientId(authInfo.getClientId()) | |
| 233 | + .setUsername(authInfo.getUsername()) | |
| 234 | + .setPassword(authInfo.getPassword()); | |
| 235 | + IotDeviceMessage authRequest = IotDeviceMessage.of(IdUtil.fastSimpleUUID(), "auth", authReqDTO, null, null, null); | |
| 236 | + | |
| 237 | + byte[] payload = CODEC.encode(authRequest); | |
| 238 | + String jsonMessage = new String(payload, StandardCharsets.UTF_8); | |
| 239 | + log.info("[{}][发送认证请求: {}]", testName, jsonMessage); | |
| 240 | + ws.writeTextMessage(jsonMessage); | |
| 241 | + } else { | |
| 242 | + log.error("[{}][WebSocket 连接失败]", testName, ar.cause()); | |
| 243 | + latch.countDown(); | |
| 244 | + latch.countDown(); | |
| 245 | + } | |
| 246 | + }); | |
| 247 | + | |
| 248 | + boolean completed = latch.await(TIMEOUT_SECONDS * 2, TimeUnit.SECONDS); | |
| 249 | + if (completed) { | |
| 250 | + if (authResponseRef.get() != null) { | |
| 251 | + IotDeviceMessage authResponse = CODEC.decode(authResponseRef.get().getBytes(StandardCharsets.UTF_8)); | |
| 252 | + log.info("[{}][认证响应: {}]", testName, authResponse); | |
| 253 | + } | |
| 254 | + if (businessResponseRef.get() != null) { | |
| 255 | + IotDeviceMessage businessResponse = CODEC.decode(businessResponseRef.get().getBytes(StandardCharsets.UTF_8)); | |
| 256 | + log.info("[{}][业务响应: {}]", testName, businessResponse); | |
| 257 | + } | |
| 258 | + } else { | |
| 259 | + log.warn("[{}][测试超时]", testName); | |
| 260 | + } | |
| 261 | + } | |
| 262 | + | |
| 263 | +} | ... | ... |