Commit d2c000d64dedcb95e6bc15383db697eaf8abf1e6

Authored by YunaiV
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,6 +18,9 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager.IotTcpConnection
18 import cn.iocoder.yudao.module.iot.gateway.protocol.udp.IotUdpDownstreamSubscriber; 18 import cn.iocoder.yudao.module.iot.gateway.protocol.udp.IotUdpDownstreamSubscriber;
19 import cn.iocoder.yudao.module.iot.gateway.protocol.udp.IotUdpUpstreamProtocol; 19 import cn.iocoder.yudao.module.iot.gateway.protocol.udp.IotUdpUpstreamProtocol;
20 import cn.iocoder.yudao.module.iot.gateway.protocol.udp.manager.IotUdpSessionManager; 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 import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; 24 import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService;
22 import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; 25 import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
23 import io.vertx.core.Vertx; 26 import io.vertx.core.Vertx;
@@ -209,4 +212,37 @@ public class IotGatewayConfiguration { @@ -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,6 +103,11 @@ public class IotGatewayProperties {
103 */ 103 */
104 private CoapProperties coap; 104 private CoapProperties coap;
105 105
  106 + /**
  107 + * WebSocket 组件配置
  108 + */
  109 + private WebSocketProperties websocket;
  110 +
106 } 111 }
107 112
108 @Data 113 @Data
@@ -586,4 +591,56 @@ public class IotGatewayProperties { @@ -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,6 +123,17 @@ yudao:
123 max-message-size: 1024 # 最大消息大小(字节) 123 max-message-size: 1024 # 最大消息大小(字节)
124 ack-timeout: 2000 # ACK 超时时间(毫秒) 124 ack-timeout: 2000 # ACK 超时时间(毫秒)
125 max-retransmit: 4 # 最大重传次数 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,6 +155,7 @@ logging:
144 cn.iocoder.yudao.module.iot.gateway.protocol.mqtt: DEBUG 155 cn.iocoder.yudao.module.iot.gateway.protocol.mqtt: DEBUG
145 cn.iocoder.yudao.module.iot.gateway.protocol.mqttws: DEBUG 156 cn.iocoder.yudao.module.iot.gateway.protocol.mqttws: DEBUG
146 cn.iocoder.yudao.module.iot.gateway.protocol.coap: DEBUG 157 cn.iocoder.yudao.module.iot.gateway.protocol.coap: DEBUG
  158 + cn.iocoder.yudao.module.iot.gateway.protocol.websocket: DEBUG
147 # 根日志级别 159 # 根日志级别
148 root: INFO 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 +}