Commit b2fef46b2cbb33d8912b4e89ebe7b5b0fcbe73f8
1 parent
3027caa1
feat:【iot】udp 协议接入 50%:初始化,基于 soft-frolicking-breeze.md 实现
Showing
11 changed files
with
1215 additions
and
1 deletions
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java
| @@ -17,6 +17,9 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.router.IotMqttWsDowns | @@ -17,6 +17,9 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.router.IotMqttWsDowns | ||
| 17 | import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpDownstreamSubscriber; | 17 | import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpDownstreamSubscriber; |
| 18 | import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpUpstreamProtocol; | 18 | import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpUpstreamProtocol; |
| 19 | import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager.IotTcpConnectionManager; | 19 | import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager.IotTcpConnectionManager; |
| 20 | +import cn.iocoder.yudao.module.iot.gateway.protocol.udp.IotUdpDownstreamSubscriber; | ||
| 21 | +import cn.iocoder.yudao.module.iot.gateway.protocol.udp.IotUdpUpstreamProtocol; | ||
| 22 | +import cn.iocoder.yudao.module.iot.gateway.protocol.udp.manager.IotUdpSessionManager; | ||
| 20 | import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; | 23 | import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; |
| 21 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; | 24 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 22 | import io.vertx.core.Vertx; | 25 | import io.vertx.core.Vertx; |
| @@ -194,4 +197,39 @@ public class IotGatewayConfiguration { | @@ -194,4 +197,39 @@ public class IotGatewayConfiguration { | ||
| 194 | 197 | ||
| 195 | } | 198 | } |
| 196 | 199 | ||
| 200 | + /** | ||
| 201 | + * IoT 网关 UDP 协议配置类 | ||
| 202 | + */ | ||
| 203 | + @Configuration | ||
| 204 | + @ConditionalOnProperty(prefix = "yudao.iot.gateway.protocol.udp", name = "enabled", havingValue = "true") | ||
| 205 | + @Slf4j | ||
| 206 | + public static class UdpProtocolConfiguration { | ||
| 207 | + | ||
| 208 | + @Bean(name = "udpVertx", destroyMethod = "close") | ||
| 209 | + public Vertx udpVertx() { | ||
| 210 | + return Vertx.vertx(); | ||
| 211 | + } | ||
| 212 | + | ||
| 213 | + @Bean | ||
| 214 | + public IotUdpUpstreamProtocol iotUdpUpstreamProtocol(IotGatewayProperties gatewayProperties, | ||
| 215 | + IotDeviceService deviceService, | ||
| 216 | + IotDeviceMessageService messageService, | ||
| 217 | + IotUdpSessionManager sessionManager, | ||
| 218 | + @Qualifier("udpVertx") Vertx udpVertx) { | ||
| 219 | + return new IotUdpUpstreamProtocol(gatewayProperties.getProtocol().getUdp(), | ||
| 220 | + deviceService, messageService, sessionManager, udpVertx); | ||
| 221 | + } | ||
| 222 | + | ||
| 223 | + @Bean | ||
| 224 | + public IotUdpDownstreamSubscriber iotUdpDownstreamSubscriber(IotUdpUpstreamProtocol protocolHandler, | ||
| 225 | + IotDeviceMessageService messageService, | ||
| 226 | + IotDeviceService deviceService, | ||
| 227 | + IotUdpSessionManager sessionManager, | ||
| 228 | + IotMessageBus messageBus) { | ||
| 229 | + return new IotUdpDownstreamSubscriber(protocolHandler, messageService, deviceService, sessionManager, | ||
| 230 | + messageBus); | ||
| 231 | + } | ||
| 232 | + | ||
| 233 | + } | ||
| 234 | + | ||
| 197 | } | 235 | } |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayProperties.java
| @@ -93,6 +93,11 @@ public class IotGatewayProperties { | @@ -93,6 +93,11 @@ public class IotGatewayProperties { | ||
| 93 | */ | 93 | */ |
| 94 | private MqttWsProperties mqttWs; | 94 | private MqttWsProperties mqttWs; |
| 95 | 95 | ||
| 96 | + /** | ||
| 97 | + * UDP 组件配置 | ||
| 98 | + */ | ||
| 99 | + private UdpProperties udp; | ||
| 100 | + | ||
| 96 | } | 101 | } |
| 97 | 102 | ||
| 98 | @Data | 103 | @Data |
| @@ -503,4 +508,42 @@ public class IotGatewayProperties { | @@ -503,4 +508,42 @@ public class IotGatewayProperties { | ||
| 503 | 508 | ||
| 504 | } | 509 | } |
| 505 | 510 | ||
| 511 | + @Data | ||
| 512 | + public static class UdpProperties { | ||
| 513 | + | ||
| 514 | + /** | ||
| 515 | + * 是否开启 | ||
| 516 | + */ | ||
| 517 | + @NotNull(message = "是否开启不能为空") | ||
| 518 | + private Boolean enabled; | ||
| 519 | + | ||
| 520 | + /** | ||
| 521 | + * 服务端口(默认 8092) | ||
| 522 | + */ | ||
| 523 | + private Integer port = 8092; | ||
| 524 | + | ||
| 525 | + /** | ||
| 526 | + * 接收缓冲区大小(默认 64KB) | ||
| 527 | + */ | ||
| 528 | + private Integer receiveBufferSize = 65536; | ||
| 529 | + | ||
| 530 | + /** | ||
| 531 | + * 发送缓冲区大小(默认 64KB) | ||
| 532 | + */ | ||
| 533 | + private Integer sendBufferSize = 65536; | ||
| 534 | + | ||
| 535 | + /** | ||
| 536 | + * 会话超时时间(毫秒,默认 60 秒) | ||
| 537 | + * <p> | ||
| 538 | + * 用于清理不活跃的设备地址映射 | ||
| 539 | + */ | ||
| 540 | + private Long sessionTimeoutMs = 60000L; | ||
| 541 | + | ||
| 542 | + /** | ||
| 543 | + * 会话清理间隔(毫秒,默认 30 秒) | ||
| 544 | + */ | ||
| 545 | + private Long sessionCleanIntervalMs = 30000L; | ||
| 546 | + | ||
| 547 | + } | ||
| 548 | + | ||
| 506 | } | 549 | } |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/router/IotTcpDownstreamHandler.java
| @@ -43,7 +43,7 @@ public class IotTcpDownstreamHandler { | @@ -43,7 +43,7 @@ public class IotTcpDownstreamHandler { | ||
| 43 | return; | 43 | return; |
| 44 | } | 44 | } |
| 45 | 45 | ||
| 46 | - // 2. 根据产品 Key 和设备名称编码消息并发送到设备 | 46 | + // 2. 根据产品 Key 和设备名称编码消息,并发送到设备 |
| 47 | byte[] bytes = deviceMessageService.encodeDeviceMessage(message, deviceInfo.getProductKey(), | 47 | byte[] bytes = deviceMessageService.encodeDeviceMessage(message, deviceInfo.getProductKey(), |
| 48 | deviceInfo.getDeviceName()); | 48 | deviceInfo.getDeviceName()); |
| 49 | boolean success = connectionManager.sendToDevice(message.getDeviceId(), bytes); | 49 | boolean success = connectionManager.sendToDevice(message.getDeviceId(), bytes); |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotUdpDownstreamSubscriber.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.udp; | ||
| 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.udp.manager.IotUdpSessionManager; | ||
| 8 | +import cn.iocoder.yudao.module.iot.gateway.protocol.udp.router.IotUdpDownstreamHandler; | ||
| 9 | +import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; | ||
| 10 | +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; | ||
| 11 | +import jakarta.annotation.PostConstruct; | ||
| 12 | +import lombok.RequiredArgsConstructor; | ||
| 13 | +import lombok.extern.slf4j.Slf4j; | ||
| 14 | + | ||
| 15 | +/** | ||
| 16 | + * IoT 网关 UDP 下游订阅者:接收下行给设备的消息 | ||
| 17 | + * | ||
| 18 | + * @author 芋道源码 | ||
| 19 | + */ | ||
| 20 | +@Slf4j | ||
| 21 | +@RequiredArgsConstructor | ||
| 22 | +public class IotUdpDownstreamSubscriber implements IotMessageSubscriber<IotDeviceMessage> { | ||
| 23 | + | ||
| 24 | + private final IotUdpUpstreamProtocol protocol; | ||
| 25 | + | ||
| 26 | + private final IotDeviceMessageService messageService; | ||
| 27 | + | ||
| 28 | + private final IotDeviceService deviceService; | ||
| 29 | + | ||
| 30 | + private final IotUdpSessionManager sessionManager; | ||
| 31 | + | ||
| 32 | + private final IotMessageBus messageBus; | ||
| 33 | + | ||
| 34 | + private IotUdpDownstreamHandler downstreamHandler; | ||
| 35 | + | ||
| 36 | + @PostConstruct | ||
| 37 | + public void init() { | ||
| 38 | + // 初始化下游处理器 | ||
| 39 | + this.downstreamHandler = new IotUdpDownstreamHandler(messageService, deviceService, sessionManager, protocol); | ||
| 40 | + | ||
| 41 | + messageBus.register(this); | ||
| 42 | + log.info("[init][UDP 下游订阅者初始化完成,服务器 ID: {},Topic: {}]", | ||
| 43 | + protocol.getServerId(), getTopic()); | ||
| 44 | + } | ||
| 45 | + | ||
| 46 | + @Override | ||
| 47 | + public String getTopic() { | ||
| 48 | + return IotDeviceMessageUtils.buildMessageBusGatewayDeviceMessageTopic(protocol.getServerId()); | ||
| 49 | + } | ||
| 50 | + | ||
| 51 | + @Override | ||
| 52 | + public String getGroup() { | ||
| 53 | + // 保证点对点消费,需要保证独立的 Group,所以使用 Topic 作为 Group | ||
| 54 | + return getTopic(); | ||
| 55 | + } | ||
| 56 | + | ||
| 57 | + @Override | ||
| 58 | + public void onMessage(IotDeviceMessage message) { | ||
| 59 | + try { | ||
| 60 | + downstreamHandler.handle(message); | ||
| 61 | + } catch (Exception e) { | ||
| 62 | + log.error("[onMessage][处理下行消息失败,设备 ID: {},方法: {},消息 ID: {}]", | ||
| 63 | + message.getDeviceId(), message.getMethod(), message.getId(), e); | ||
| 64 | + } | ||
| 65 | + } | ||
| 66 | + | ||
| 67 | +} |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotUdpUpstreamProtocol.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.udp; | ||
| 2 | + | ||
| 3 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | ||
| 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.udp.manager.IotUdpSessionManager; | ||
| 7 | +import cn.iocoder.yudao.module.iot.gateway.protocol.udp.router.IotUdpUpstreamHandler; | ||
| 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.datagram.DatagramSocket; | ||
| 12 | +import io.vertx.core.datagram.DatagramSocketOptions; | ||
| 13 | +import jakarta.annotation.PostConstruct; | ||
| 14 | +import jakarta.annotation.PreDestroy; | ||
| 15 | +import lombok.Getter; | ||
| 16 | +import lombok.extern.slf4j.Slf4j; | ||
| 17 | + | ||
| 18 | +/** | ||
| 19 | + * IoT 网关 UDP 协议:接收设备上行消息 | ||
| 20 | + * <p> | ||
| 21 | + * 采用 Vertx DatagramSocket 实现 UDP 服务器,主要功能: | ||
| 22 | + * 1. 监听 UDP 端口,接收设备消息 | ||
| 23 | + * 2. 定期清理不活跃的设备地址映射 | ||
| 24 | + * 3. 提供 UDP Socket 用于下行消息发送 | ||
| 25 | + * | ||
| 26 | + * @author 芋道源码 | ||
| 27 | + */ | ||
| 28 | +@Slf4j | ||
| 29 | +public class IotUdpUpstreamProtocol { | ||
| 30 | + | ||
| 31 | + private final IotGatewayProperties.UdpProperties udpProperties; | ||
| 32 | + | ||
| 33 | + private final IotDeviceService deviceService; | ||
| 34 | + | ||
| 35 | + private final IotDeviceMessageService messageService; | ||
| 36 | + | ||
| 37 | + private final IotUdpSessionManager sessionManager; | ||
| 38 | + | ||
| 39 | + private final Vertx vertx; | ||
| 40 | + | ||
| 41 | + @Getter | ||
| 42 | + private final String serverId; | ||
| 43 | + | ||
| 44 | + @Getter | ||
| 45 | + private DatagramSocket udpSocket; | ||
| 46 | + | ||
| 47 | + /** | ||
| 48 | + * 会话清理定时器 ID | ||
| 49 | + */ | ||
| 50 | + private Long cleanTimerId; | ||
| 51 | + | ||
| 52 | + private IotUdpUpstreamHandler upstreamHandler; | ||
| 53 | + | ||
| 54 | + public IotUdpUpstreamProtocol(IotGatewayProperties.UdpProperties udpProperties, | ||
| 55 | + IotDeviceService deviceService, | ||
| 56 | + IotDeviceMessageService messageService, | ||
| 57 | + IotUdpSessionManager sessionManager, | ||
| 58 | + Vertx vertx) { | ||
| 59 | + this.udpProperties = udpProperties; | ||
| 60 | + this.deviceService = deviceService; | ||
| 61 | + this.messageService = messageService; | ||
| 62 | + this.sessionManager = sessionManager; | ||
| 63 | + this.vertx = vertx; | ||
| 64 | + this.serverId = IotDeviceMessageUtils.generateServerId(udpProperties.getPort()); | ||
| 65 | + } | ||
| 66 | + | ||
| 67 | + @PostConstruct | ||
| 68 | + public void start() { | ||
| 69 | + // 1. 初始化上行消息处理器 | ||
| 70 | + this.upstreamHandler = new IotUdpUpstreamHandler(this, messageService, deviceService, sessionManager); | ||
| 71 | + | ||
| 72 | + // 2. 创建 UDP Socket 选项 | ||
| 73 | + DatagramSocketOptions options = new DatagramSocketOptions() | ||
| 74 | + .setReceiveBufferSize(udpProperties.getReceiveBufferSize()) | ||
| 75 | + .setSendBufferSize(udpProperties.getSendBufferSize()) | ||
| 76 | + .setReuseAddress(true); | ||
| 77 | + | ||
| 78 | + // 3. 创建 UDP Socket | ||
| 79 | + udpSocket = vertx.createDatagramSocket(options); | ||
| 80 | + | ||
| 81 | + // 4. 监听端口 | ||
| 82 | + udpSocket.listen(udpProperties.getPort(), "0.0.0.0", result -> { | ||
| 83 | + // TODO @AI:if return;简化下;成功才继续往下走; | ||
| 84 | + if (result.succeeded()) { | ||
| 85 | + // 设置数据包处理器 | ||
| 86 | + udpSocket.handler(packet -> upstreamHandler.handle(packet, udpSocket, this)); | ||
| 87 | + log.info("[start][IoT 网关 UDP 协议启动成功,端口:{},接收缓冲区:{} 字节,发送缓冲区:{} 字节]", | ||
| 88 | + udpProperties.getPort(), udpProperties.getReceiveBufferSize(), | ||
| 89 | + udpProperties.getSendBufferSize()); | ||
| 90 | + | ||
| 91 | + // 5. 启动会话清理定时器 | ||
| 92 | + startSessionCleanTimer(); | ||
| 93 | + } else { | ||
| 94 | + log.error("[start][IoT 网关 UDP 协议启动失败]", result.cause()); | ||
| 95 | + } | ||
| 96 | + }); | ||
| 97 | + } | ||
| 98 | + | ||
| 99 | + @PreDestroy | ||
| 100 | + public void stop() { | ||
| 101 | + // 1. 取消会话清理定时器 | ||
| 102 | + if (cleanTimerId != null) { | ||
| 103 | + vertx.cancelTimer(cleanTimerId); | ||
| 104 | + cleanTimerId = null; | ||
| 105 | + log.info("[stop][会话清理定时器已取消]"); | ||
| 106 | + } | ||
| 107 | + | ||
| 108 | + // 2. 关闭 UDP Socket | ||
| 109 | + if (udpSocket != null) { | ||
| 110 | + try { | ||
| 111 | + udpSocket.close().result(); | ||
| 112 | + log.info("[stop][IoT 网关 UDP 协议已停止]"); | ||
| 113 | + } catch (Exception e) { | ||
| 114 | + log.error("[stop][IoT 网关 UDP 协议停止失败]", e); | ||
| 115 | + } | ||
| 116 | + } | ||
| 117 | + } | ||
| 118 | + | ||
| 119 | + /** | ||
| 120 | + * 启动会话清理定时器 | ||
| 121 | + */ | ||
| 122 | + private void startSessionCleanTimer() { | ||
| 123 | + cleanTimerId = vertx.setPeriodic(udpProperties.getSessionCleanIntervalMs(), id -> { | ||
| 124 | + try { | ||
| 125 | + // 1. 清理超时的设备地址映射,并获取离线设备列表 | ||
| 126 | + // TODO @AI:兼容 jdk8,不要用 var; | ||
| 127 | + var offlineDevices = sessionManager.cleanExpiredMappings(udpProperties.getSessionTimeoutMs()); | ||
| 128 | + | ||
| 129 | + // 2. 为每个离线设备发送离线消息 | ||
| 130 | + for (var offlineInfo : offlineDevices) { | ||
| 131 | + sendOfflineMessage(offlineInfo.getDeviceId()); | ||
| 132 | + } | ||
| 133 | + // TODO @AI:CollUtil.isNotEmpty ;简化下 if 判断; | ||
| 134 | + if (!offlineDevices.isEmpty()) { | ||
| 135 | + log.info("[cleanExpiredMappings][本次清理 {} 个超时设备]", offlineDevices.size()); | ||
| 136 | + } | ||
| 137 | + } catch (Exception e) { | ||
| 138 | + log.error("[cleanExpiredMappings][清理超时会话失败]", e); | ||
| 139 | + } | ||
| 140 | + }); | ||
| 141 | + log.info("[startSessionCleanTimer][会话清理定时器启动,间隔:{} ms,超时:{} ms]", | ||
| 142 | + udpProperties.getSessionCleanIntervalMs(), udpProperties.getSessionTimeoutMs()); | ||
| 143 | + } | ||
| 144 | + | ||
| 145 | + /** | ||
| 146 | + * 发送设备离线消息 | ||
| 147 | + * | ||
| 148 | + * @param deviceId 设备 ID | ||
| 149 | + */ | ||
| 150 | + private void sendOfflineMessage(Long deviceId) { | ||
| 151 | + try { | ||
| 152 | + // 获取设备信息 | ||
| 153 | + var device = deviceService.getDeviceFromCache(deviceId); | ||
| 154 | + if (device == null) { | ||
| 155 | + log.warn("[sendOfflineMessage][设备不存在,设备 ID: {}]", deviceId); | ||
| 156 | + return; | ||
| 157 | + } | ||
| 158 | + | ||
| 159 | + // 发送离线消息 | ||
| 160 | + IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline(); | ||
| 161 | + messageService.sendDeviceMessage(offlineMessage, device.getProductKey(), | ||
| 162 | + device.getDeviceName(), serverId); | ||
| 163 | + log.info("[sendOfflineMessage][发送离线消息,设备 ID: {},设备名: {}]", | ||
| 164 | + deviceId, device.getDeviceName()); | ||
| 165 | + } catch (Exception e) { | ||
| 166 | + log.error("[sendOfflineMessage][发送离线消息失败,设备 ID: {}]", deviceId, e); | ||
| 167 | + } | ||
| 168 | + } | ||
| 169 | + | ||
| 170 | +} |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/manager/IotUdpSessionManager.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.udp.manager; | ||
| 2 | + | ||
| 3 | +import io.vertx.core.buffer.Buffer; | ||
| 4 | +import io.vertx.core.datagram.DatagramSocket; | ||
| 5 | +import lombok.Data; | ||
| 6 | +import lombok.extern.slf4j.Slf4j; | ||
| 7 | +import org.springframework.stereotype.Component; | ||
| 8 | + | ||
| 9 | +import java.net.InetSocketAddress; | ||
| 10 | +import java.util.Iterator; | ||
| 11 | +import java.util.Map; | ||
| 12 | +import java.util.concurrent.ConcurrentHashMap; | ||
| 13 | + | ||
| 14 | +/** | ||
| 15 | + * IoT 网关 UDP 会话管理器 | ||
| 16 | + * <p> | ||
| 17 | + * 采用无状态设计,SessionManager 主要用于: | ||
| 18 | + * 1. 管理设备地址映射(用于下行消息发送) | ||
| 19 | + * 2. 定期清理不活跃的设备地址映射 | ||
| 20 | + * <p> | ||
| 21 | + * 注意:UDP 是无连接协议,上行消息通过 token 验证身份,不依赖会话状态 | ||
| 22 | + * | ||
| 23 | + * @author 芋道源码 | ||
| 24 | + */ | ||
| 25 | +@Slf4j | ||
| 26 | +@Component | ||
| 27 | +public class IotUdpSessionManager { | ||
| 28 | + | ||
| 29 | + /** | ||
| 30 | + * 设备 ID -> 设备地址(用于下行消息发送) | ||
| 31 | + */ | ||
| 32 | + private final Map<Long, InetSocketAddress> deviceAddressMap = new ConcurrentHashMap<>(); | ||
| 33 | + | ||
| 34 | + /** | ||
| 35 | + * 设备地址 Key -> 最后活跃时间(用于清理) | ||
| 36 | + */ | ||
| 37 | + // TODO @AI:是不是尽量使用 LocalDateTime ?统一时间类型 | ||
| 38 | + private final Map<String, Long> lastActiveTimeMap = new ConcurrentHashMap<>(); | ||
| 39 | + | ||
| 40 | + /** | ||
| 41 | + * 设备地址 Key -> 设备 ID(反向映射,用于清理时同步) | ||
| 42 | + */ | ||
| 43 | + private final Map<String, Long> addressDeviceMap = new ConcurrentHashMap<>(); | ||
| 44 | + | ||
| 45 | + /** | ||
| 46 | + * 更新设备地址(每次收到上行消息时调用) | ||
| 47 | + * | ||
| 48 | + * @param deviceId 设备 ID | ||
| 49 | + * @param address 设备地址 | ||
| 50 | + */ | ||
| 51 | + public void updateDeviceAddress(Long deviceId, InetSocketAddress address) { | ||
| 52 | + String addressKey = buildAddressKey(address); | ||
| 53 | + // 更新设备地址映射 | ||
| 54 | + deviceAddressMap.put(deviceId, address); | ||
| 55 | + lastActiveTimeMap.put(addressKey, System.currentTimeMillis()); | ||
| 56 | + addressDeviceMap.put(addressKey, deviceId); | ||
| 57 | + log.debug("[updateDeviceAddress][更新设备地址,设备 ID: {},地址: {}]", deviceId, addressKey); | ||
| 58 | + } | ||
| 59 | + | ||
| 60 | + // TODO @AI:是不是用不到?用不掉就删除掉!简化 | ||
| 61 | + /** | ||
| 62 | + * 获取设备地址(下行消息发送时使用) | ||
| 63 | + * | ||
| 64 | + * @param deviceId 设备 ID | ||
| 65 | + * @return 设备地址,如果不存在返回 null | ||
| 66 | + */ | ||
| 67 | + public InetSocketAddress getDeviceAddress(Long deviceId) { | ||
| 68 | + return deviceAddressMap.get(deviceId); | ||
| 69 | + } | ||
| 70 | + | ||
| 71 | + /** | ||
| 72 | + * 检查设备是否在线(即是否有地址映射) | ||
| 73 | + * | ||
| 74 | + * @param deviceId 设备 ID | ||
| 75 | + * @return 是否在线 | ||
| 76 | + */ | ||
| 77 | + public boolean isDeviceOnline(Long deviceId) { | ||
| 78 | + return deviceAddressMap.containsKey(deviceId); | ||
| 79 | + } | ||
| 80 | + | ||
| 81 | + /** | ||
| 82 | + * 检查设备是否离线 | ||
| 83 | + * | ||
| 84 | + * @param deviceId 设备 ID | ||
| 85 | + * @return 是否离线 | ||
| 86 | + */ | ||
| 87 | + public boolean isDeviceOffline(Long deviceId) { | ||
| 88 | + return !isDeviceOnline(deviceId); | ||
| 89 | + } | ||
| 90 | + | ||
| 91 | + /** | ||
| 92 | + * 发送消息到设备 | ||
| 93 | + * | ||
| 94 | + * @param deviceId 设备 ID | ||
| 95 | + * @param data 数据 | ||
| 96 | + * @param socket UDP Socket | ||
| 97 | + * @return 是否发送成功 | ||
| 98 | + */ | ||
| 99 | + public boolean sendToDevice(Long deviceId, byte[] data, DatagramSocket socket) { | ||
| 100 | + InetSocketAddress address = deviceAddressMap.get(deviceId); | ||
| 101 | + if (address == null) { | ||
| 102 | + log.warn("[sendToDevice][设备地址不存在,设备 ID: {}]", deviceId); | ||
| 103 | + return false; | ||
| 104 | + } | ||
| 105 | + | ||
| 106 | + try { | ||
| 107 | + socket.send(Buffer.buffer(data), address.getPort(), address.getHostString(), result -> { | ||
| 108 | + if (result.succeeded()) { | ||
| 109 | + log.debug("[sendToDevice][发送消息成功,设备 ID: {},地址: {},数据长度: {} 字节]", | ||
| 110 | + deviceId, buildAddressKey(address), data.length); | ||
| 111 | + } else { | ||
| 112 | + log.error("[sendToDevice][发送消息失败,设备 ID: {},地址: {}]", | ||
| 113 | + deviceId, buildAddressKey(address), result.cause()); | ||
| 114 | + } | ||
| 115 | + }); | ||
| 116 | + return true; | ||
| 117 | + } catch (Exception e) { | ||
| 118 | + log.error("[sendToDevice][发送消息异常,设备 ID: {}]", deviceId, e); | ||
| 119 | + return false; | ||
| 120 | + } | ||
| 121 | + } | ||
| 122 | + | ||
| 123 | + /** | ||
| 124 | + * 定期清理不活跃的设备地址映射 | ||
| 125 | + * | ||
| 126 | + * @param timeoutMs 超时时间(毫秒) | ||
| 127 | + * @return 清理的设备 ID 列表(用于发送离线消息) | ||
| 128 | + */ | ||
| 129 | + // TODO @AI:目前暂时用不到 address 字段,是不是只返回 list of deviceId 就行?简化 | ||
| 130 | + public java.util.List<DeviceOfflineInfo> cleanExpiredMappings(long timeoutMs) { | ||
| 131 | + java.util.List<DeviceOfflineInfo> offlineDevices = new java.util.ArrayList<>(); | ||
| 132 | + long now = System.currentTimeMillis(); | ||
| 133 | + Iterator<Map.Entry<String, Long>> iterator = lastActiveTimeMap.entrySet().iterator(); | ||
| 134 | + while (iterator.hasNext()) { | ||
| 135 | + Map.Entry<String, Long> entry = iterator.next(); | ||
| 136 | + if (now - entry.getValue() > timeoutMs) { | ||
| 137 | + String addressKey = entry.getKey(); | ||
| 138 | + Long deviceId = addressDeviceMap.remove(addressKey); | ||
| 139 | + // TODO @AI:if continue,减少括号层级; | ||
| 140 | + if (deviceId != null) { | ||
| 141 | + InetSocketAddress address = deviceAddressMap.remove(deviceId); | ||
| 142 | + if (address != null) { | ||
| 143 | + // 获取设备信息用于发送离线消息 | ||
| 144 | + offlineDevices.add(new DeviceOfflineInfo(deviceId, addressKey)); | ||
| 145 | + log.info("[cleanExpiredMappings][清理超时设备,设备 ID: {},地址: {},最后活跃时间: {}ms 前]", | ||
| 146 | + deviceId, addressKey, now - entry.getValue()); | ||
| 147 | + } | ||
| 148 | + } | ||
| 149 | + iterator.remove(); | ||
| 150 | + } | ||
| 151 | + } | ||
| 152 | + return offlineDevices; | ||
| 153 | + } | ||
| 154 | + | ||
| 155 | + // TODO @AI:是不是用不到?用不掉就删除掉!简化 | ||
| 156 | + /** | ||
| 157 | + * 移除设备地址映射 | ||
| 158 | + * | ||
| 159 | + * @param deviceId 设备 ID | ||
| 160 | + */ | ||
| 161 | + public void removeDeviceAddress(Long deviceId) { | ||
| 162 | + InetSocketAddress address = deviceAddressMap.remove(deviceId); | ||
| 163 | + if (address != null) { | ||
| 164 | + String addressKey = buildAddressKey(address); | ||
| 165 | + lastActiveTimeMap.remove(addressKey); | ||
| 166 | + addressDeviceMap.remove(addressKey); | ||
| 167 | + log.debug("[removeDeviceAddress][移除设备地址,设备 ID: {},地址: {}]", deviceId, addressKey); | ||
| 168 | + } | ||
| 169 | + } | ||
| 170 | + | ||
| 171 | + /** | ||
| 172 | + * 构建地址 Key | ||
| 173 | + * | ||
| 174 | + * @param address 地址 | ||
| 175 | + * @return 地址 Key | ||
| 176 | + */ | ||
| 177 | + public String buildAddressKey(InetSocketAddress address) { | ||
| 178 | + return address.getHostString() + ":" + address.getPort(); | ||
| 179 | + } | ||
| 180 | + | ||
| 181 | + /** | ||
| 182 | + * 设备离线信息 | ||
| 183 | + */ | ||
| 184 | + @Data | ||
| 185 | + public static class DeviceOfflineInfo { | ||
| 186 | + | ||
| 187 | + /** | ||
| 188 | + * 设备 ID | ||
| 189 | + */ | ||
| 190 | + private final Long deviceId; | ||
| 191 | + | ||
| 192 | + /** | ||
| 193 | + * 设备地址 | ||
| 194 | + */ | ||
| 195 | + private final String address; | ||
| 196 | + | ||
| 197 | + public DeviceOfflineInfo(Long deviceId, String address) { | ||
| 198 | + this.deviceId = deviceId; | ||
| 199 | + this.address = address; | ||
| 200 | + } | ||
| 201 | + | ||
| 202 | + } | ||
| 203 | + | ||
| 204 | +} |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/package-info.java
0 → 100644
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/router/IotUdpDownstreamHandler.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.udp.router; | ||
| 2 | + | ||
| 3 | +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; | ||
| 4 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | ||
| 5 | +import cn.iocoder.yudao.module.iot.gateway.protocol.udp.IotUdpUpstreamProtocol; | ||
| 6 | +import cn.iocoder.yudao.module.iot.gateway.protocol.udp.manager.IotUdpSessionManager; | ||
| 7 | +import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; | ||
| 8 | +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; | ||
| 9 | +import io.vertx.core.datagram.DatagramSocket; | ||
| 10 | +import lombok.extern.slf4j.Slf4j; | ||
| 11 | + | ||
| 12 | +/** | ||
| 13 | + * IoT 网关 UDP 下行消息处理器 | ||
| 14 | + * | ||
| 15 | + * @author 芋道源码 | ||
| 16 | + */ | ||
| 17 | +@Slf4j | ||
| 18 | +public class IotUdpDownstreamHandler { | ||
| 19 | + | ||
| 20 | + private final IotDeviceMessageService deviceMessageService; | ||
| 21 | + | ||
| 22 | + private final IotDeviceService deviceService; | ||
| 23 | + | ||
| 24 | + private final IotUdpSessionManager sessionManager; | ||
| 25 | + | ||
| 26 | + private final IotUdpUpstreamProtocol protocol; | ||
| 27 | + | ||
| 28 | + public IotUdpDownstreamHandler(IotDeviceMessageService deviceMessageService, | ||
| 29 | + IotDeviceService deviceService, | ||
| 30 | + IotUdpSessionManager sessionManager, | ||
| 31 | + IotUdpUpstreamProtocol protocol) { | ||
| 32 | + this.deviceMessageService = deviceMessageService; | ||
| 33 | + this.deviceService = deviceService; | ||
| 34 | + this.sessionManager = sessionManager; | ||
| 35 | + this.protocol = protocol; | ||
| 36 | + } | ||
| 37 | + | ||
| 38 | + /** | ||
| 39 | + * 处理下行消息 | ||
| 40 | + * | ||
| 41 | + * @param message 下行消息 | ||
| 42 | + */ | ||
| 43 | + public void handle(IotDeviceMessage message) { | ||
| 44 | + try { | ||
| 45 | + log.info("[handle][处理下行消息,设备 ID: {},方法: {},消息 ID: {}]", | ||
| 46 | + message.getDeviceId(), message.getMethod(), message.getId()); | ||
| 47 | + | ||
| 48 | + // 1.1 获取设备信息 | ||
| 49 | + IotDeviceRespDTO deviceInfo = deviceService.getDeviceFromCache(message.getDeviceId()); | ||
| 50 | + if (deviceInfo == null) { | ||
| 51 | + log.error("[handle][设备不存在,设备 ID: {}]", message.getDeviceId()); | ||
| 52 | + return; | ||
| 53 | + } | ||
| 54 | + // 1.2 检查设备是否在线(即是否有地址映射) | ||
| 55 | + if (sessionManager.isDeviceOffline(message.getDeviceId())) { | ||
| 56 | + log.warn("[handle][设备不在线,设备 ID: {}]", message.getDeviceId()); | ||
| 57 | + return; | ||
| 58 | + } | ||
| 59 | + | ||
| 60 | + // 2. 根据产品 Key 和设备名称编码消息,并发送到设备 | ||
| 61 | + byte[] bytes = deviceMessageService.encodeDeviceMessage(message, deviceInfo.getProductKey(), | ||
| 62 | + deviceInfo.getDeviceName()); | ||
| 63 | + DatagramSocket socket = protocol.getUdpSocket(); | ||
| 64 | + if (socket == null) { | ||
| 65 | + log.error("[handle][UDP Socket 不可用,设备 ID: {}]", message.getDeviceId()); | ||
| 66 | + return; | ||
| 67 | + } | ||
| 68 | + boolean success = sessionManager.sendToDevice(message.getDeviceId(), bytes, socket); | ||
| 69 | + if (success) { | ||
| 70 | + log.info("[handle][下行消息发送成功,设备 ID: {},方法: {},消息 ID: {},数据长度: {} 字节]", | ||
| 71 | + message.getDeviceId(), message.getMethod(), message.getId(), bytes.length); | ||
| 72 | + } else { | ||
| 73 | + log.error("[handle][下行消息发送失败,设备 ID: {},方法: {},消息 ID: {}]", | ||
| 74 | + message.getDeviceId(), message.getMethod(), message.getId()); | ||
| 75 | + } | ||
| 76 | + } catch (Exception e) { | ||
| 77 | + log.error("[handle][处理下行消息失败,设备 ID: {},方法: {},消息内容: {}]", | ||
| 78 | + message.getDeviceId(), message.getMethod(), message, e); | ||
| 79 | + } | ||
| 80 | + } | ||
| 81 | + | ||
| 82 | +} |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/router/IotUdpUpstreamHandler.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.udp.router; | ||
| 2 | + | ||
| 3 | +import cn.hutool.core.map.MapUtil; | ||
| 4 | +import cn.hutool.core.util.BooleanUtil; | ||
| 5 | +import cn.hutool.core.util.StrUtil; | ||
| 6 | +import cn.hutool.extra.spring.SpringUtil; | ||
| 7 | +import cn.iocoder.yudao.framework.common.pojo.CommonResult; | ||
| 8 | +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; | ||
| 9 | +import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; | ||
| 10 | +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; | ||
| 11 | +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; | ||
| 12 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | ||
| 13 | +import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils; | ||
| 14 | +import cn.iocoder.yudao.module.iot.gateway.codec.tcp.IotTcpBinaryDeviceMessageCodec; | ||
| 15 | +import cn.iocoder.yudao.module.iot.gateway.codec.tcp.IotTcpJsonDeviceMessageCodec; | ||
| 16 | +import cn.iocoder.yudao.module.iot.gateway.protocol.udp.IotUdpUpstreamProtocol; | ||
| 17 | +import cn.iocoder.yudao.module.iot.gateway.protocol.udp.manager.IotUdpSessionManager; | ||
| 18 | +import cn.iocoder.yudao.module.iot.gateway.service.auth.IotDeviceTokenService; | ||
| 19 | +import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; | ||
| 20 | +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; | ||
| 21 | +import io.vertx.core.buffer.Buffer; | ||
| 22 | +import io.vertx.core.datagram.DatagramPacket; | ||
| 23 | +import io.vertx.core.datagram.DatagramSocket; | ||
| 24 | +import lombok.extern.slf4j.Slf4j; | ||
| 25 | + | ||
| 26 | +import java.net.InetSocketAddress; | ||
| 27 | +import java.util.Map; | ||
| 28 | + | ||
| 29 | +// TODO @AI:注释里,不要出现 CoAP,避免理解成本过高; | ||
| 30 | +/** | ||
| 31 | + * UDP 上行消息处理器 | ||
| 32 | + * <p> | ||
| 33 | + * 采用 CoAP 风格的 Token 机制(无状态,每次请求携带 token): | ||
| 34 | + * 1. 认证请求:设备发送 auth 消息,携带 clientId、username、password | ||
| 35 | + * 2. 返回 Token:服务端验证后返回 JWT token | ||
| 36 | + * 3. 后续请求:每次请求在 params 中携带 token | ||
| 37 | + * 4. 服务端验证:每次请求通过 IotDeviceTokenService.verifyToken() 验证 | ||
| 38 | + * | ||
| 39 | + * @author 芋道源码 | ||
| 40 | + */ | ||
| 41 | +@Slf4j | ||
| 42 | +public class IotUdpUpstreamHandler { | ||
| 43 | + | ||
| 44 | + private static final String CODEC_TYPE_JSON = IotTcpJsonDeviceMessageCodec.TYPE; | ||
| 45 | + private static final String CODEC_TYPE_BINARY = IotTcpBinaryDeviceMessageCodec.TYPE; | ||
| 46 | + | ||
| 47 | + private static final String AUTH_METHOD = "auth"; | ||
| 48 | + | ||
| 49 | + private final IotDeviceMessageService deviceMessageService; | ||
| 50 | + | ||
| 51 | + private final IotDeviceService deviceService; | ||
| 52 | + | ||
| 53 | + private final IotUdpSessionManager sessionManager; | ||
| 54 | + | ||
| 55 | + private final IotDeviceTokenService deviceTokenService; | ||
| 56 | + | ||
| 57 | + private final IotDeviceCommonApi deviceApi; | ||
| 58 | + | ||
| 59 | + private final String serverId; | ||
| 60 | + | ||
| 61 | + public IotUdpUpstreamHandler(IotUdpUpstreamProtocol protocol, | ||
| 62 | + IotDeviceMessageService deviceMessageService, | ||
| 63 | + IotDeviceService deviceService, | ||
| 64 | + IotUdpSessionManager sessionManager) { | ||
| 65 | + this.deviceMessageService = deviceMessageService; | ||
| 66 | + this.deviceService = deviceService; | ||
| 67 | + this.sessionManager = sessionManager; | ||
| 68 | + this.deviceTokenService = SpringUtil.getBean(IotDeviceTokenService.class); | ||
| 69 | + this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class); | ||
| 70 | + this.serverId = protocol.getServerId(); | ||
| 71 | + } | ||
| 72 | + | ||
| 73 | + // TODO @AI:protocol 这个参数如果用不到,就删除下; | ||
| 74 | + /** | ||
| 75 | + * 处理 UDP 数据包 | ||
| 76 | + * | ||
| 77 | + * @param packet 数据包 | ||
| 78 | + * @param socket UDP Socket | ||
| 79 | + * @param protocol UDP 协议 | ||
| 80 | + */ | ||
| 81 | + public void handle(DatagramPacket packet, DatagramSocket socket, IotUdpUpstreamProtocol protocol) { | ||
| 82 | + InetSocketAddress senderAddress = new InetSocketAddress(packet.sender().host(), packet.sender().port()); | ||
| 83 | + Buffer data = packet.data(); | ||
| 84 | + log.debug("[handle][收到 UDP 数据包,来源: {},数据长度: {} 字节]", | ||
| 85 | + sessionManager.buildAddressKey(senderAddress), data.length()); | ||
| 86 | + try { | ||
| 87 | + processMessage(data, senderAddress, socket); | ||
| 88 | + } catch (Exception e) { | ||
| 89 | + log.error("[handle][处理消息失败,来源: {},错误: {}]", | ||
| 90 | + sessionManager.buildAddressKey(senderAddress), e.getMessage(), e); | ||
| 91 | + // UDP 无连接,不需要断开连接,只记录错误 | ||
| 92 | + } | ||
| 93 | + } | ||
| 94 | + | ||
| 95 | + /** | ||
| 96 | + * 处理消息 | ||
| 97 | + * | ||
| 98 | + * @param buffer 消息 | ||
| 99 | + * @param senderAddress 发送者地址 | ||
| 100 | + * @param socket UDP Socket | ||
| 101 | + */ | ||
| 102 | + private void processMessage(Buffer buffer, InetSocketAddress senderAddress, DatagramSocket socket) { | ||
| 103 | + // 1. 基础检查 | ||
| 104 | + if (buffer == null || buffer.length() == 0) { | ||
| 105 | + return; | ||
| 106 | + } | ||
| 107 | + | ||
| 108 | + // 2. 获取消息格式类型 | ||
| 109 | + String codecType = getMessageCodecType(buffer); | ||
| 110 | + | ||
| 111 | + // 3. 解码消息 | ||
| 112 | + IotDeviceMessage message; | ||
| 113 | + try { | ||
| 114 | + message = deviceMessageService.decodeDeviceMessage(buffer.getBytes(), codecType); | ||
| 115 | + if (message == null) { | ||
| 116 | + log.warn("[processMessage][消息解码失败,来源: {}]", sessionManager.buildAddressKey(senderAddress)); | ||
| 117 | + sendErrorResponse(socket, senderAddress, null, "消息解码失败", codecType); | ||
| 118 | + return; | ||
| 119 | + } | ||
| 120 | + } catch (Exception e) { | ||
| 121 | + log.error("[processMessage][消息解码异常,来源: {}]", sessionManager.buildAddressKey(senderAddress), e); | ||
| 122 | + sendErrorResponse(socket, senderAddress, null, "消息解码失败: " + e.getMessage(), codecType); | ||
| 123 | + return; | ||
| 124 | + } | ||
| 125 | + | ||
| 126 | + // 4. 根据消息类型路由处理 | ||
| 127 | + try { | ||
| 128 | + if (AUTH_METHOD.equals(message.getMethod())) { | ||
| 129 | + // 认证请求 | ||
| 130 | + handleAuthenticationRequest(message, codecType, senderAddress, socket); | ||
| 131 | + } else { | ||
| 132 | + // 业务消息 | ||
| 133 | + handleBusinessRequest(message, codecType, senderAddress, socket); | ||
| 134 | + } | ||
| 135 | + } catch (Exception e) { | ||
| 136 | + log.error("[processMessage][处理消息失败,来源: {},消息方法: {}]", | ||
| 137 | + sessionManager.buildAddressKey(senderAddress), message.getMethod(), e); | ||
| 138 | + sendErrorResponse(socket, senderAddress, message.getRequestId(), "消息处理失败", codecType); | ||
| 139 | + } | ||
| 140 | + } | ||
| 141 | + | ||
| 142 | + /** | ||
| 143 | + * 处理认证请求 | ||
| 144 | + * | ||
| 145 | + * @param message 消息信息 | ||
| 146 | + * @param codecType 消息编解码类型 | ||
| 147 | + * @param senderAddress 发送者地址 | ||
| 148 | + * @param socket UDP Socket | ||
| 149 | + */ | ||
| 150 | + private void handleAuthenticationRequest(IotDeviceMessage message, String codecType, | ||
| 151 | + InetSocketAddress senderAddress, DatagramSocket socket) { | ||
| 152 | + String addressKey = sessionManager.buildAddressKey(senderAddress); | ||
| 153 | + try { | ||
| 154 | + // 1.1 解析认证参数 | ||
| 155 | + IotDeviceAuthReqDTO authParams = parseAuthParams(message.getParams()); | ||
| 156 | + if (authParams == null) { | ||
| 157 | + log.warn("[handleAuthenticationRequest][认证参数解析失败,来源: {}]", addressKey); | ||
| 158 | + sendErrorResponse(socket, senderAddress, message.getRequestId(), "认证参数不完整", codecType); | ||
| 159 | + return; | ||
| 160 | + } | ||
| 161 | + // 1.2 执行认证 | ||
| 162 | + if (!validateDeviceAuth(authParams)) { | ||
| 163 | + log.warn("[handleAuthenticationRequest][认证失败,来源: {},username: {}]", | ||
| 164 | + addressKey, authParams.getUsername()); | ||
| 165 | + sendErrorResponse(socket, senderAddress, message.getRequestId(), "认证失败", codecType); | ||
| 166 | + return; | ||
| 167 | + } | ||
| 168 | + | ||
| 169 | + // 2.1 解析设备信息 | ||
| 170 | + IotDeviceAuthUtils.DeviceInfo deviceInfo = IotDeviceAuthUtils.parseUsername(authParams.getUsername()); | ||
| 171 | + if (deviceInfo == null) { | ||
| 172 | + sendErrorResponse(socket, senderAddress, message.getRequestId(), "解析设备信息失败", codecType); | ||
| 173 | + return; | ||
| 174 | + } | ||
| 175 | + // 2.2 获取设备信息 | ||
| 176 | + IotDeviceRespDTO device = deviceService.getDeviceFromCache(deviceInfo.getProductKey(), | ||
| 177 | + deviceInfo.getDeviceName()); | ||
| 178 | + if (device == null) { | ||
| 179 | + sendErrorResponse(socket, senderAddress, message.getRequestId(), "设备不存在", codecType); | ||
| 180 | + return; | ||
| 181 | + } | ||
| 182 | + | ||
| 183 | + // 3.1 生成 JWT Token(CoAP 风格) | ||
| 184 | + String token = deviceTokenService.createToken(device.getProductKey(), device.getDeviceName()); | ||
| 185 | + | ||
| 186 | + // 3.2 更新设备地址映射(用于下行消息) | ||
| 187 | + sessionManager.updateDeviceAddress(device.getId(), senderAddress); | ||
| 188 | + | ||
| 189 | + // 3.3 发送上线消息 | ||
| 190 | + sendOnlineMessage(device); | ||
| 191 | + | ||
| 192 | + // 3.4 发送成功响应(包含 token) | ||
| 193 | + sendAuthSuccessResponse(socket, senderAddress, message.getRequestId(), token, codecType); | ||
| 194 | + log.info("[handleAuthenticationRequest][认证成功,设备 ID: {},设备名: {},来源: {}]", | ||
| 195 | + device.getId(), device.getDeviceName(), addressKey); | ||
| 196 | + } catch (Exception e) { | ||
| 197 | + log.error("[handleAuthenticationRequest][认证处理异常,来源: {}]", addressKey, e); | ||
| 198 | + sendErrorResponse(socket, senderAddress, message.getRequestId(), "认证处理异常", codecType); | ||
| 199 | + } | ||
| 200 | + } | ||
| 201 | + | ||
| 202 | + /** | ||
| 203 | + * 处理业务请求 | ||
| 204 | + * | ||
| 205 | + * @param message 消息信息 | ||
| 206 | + * @param codecType 消息编解码类型 | ||
| 207 | + * @param senderAddress 发送者地址 | ||
| 208 | + * @param socket UDP Socket | ||
| 209 | + */ | ||
| 210 | + @SuppressWarnings("unchecked") | ||
| 211 | + private void handleBusinessRequest(IotDeviceMessage message, String codecType, | ||
| 212 | + InetSocketAddress senderAddress, DatagramSocket socket) { | ||
| 213 | + String addressKey = sessionManager.buildAddressKey(senderAddress); | ||
| 214 | + try { | ||
| 215 | + // TODO @AI:token 需要枚举个 KEY;考虑到是通过 params 传递的话,需要获取到后,从 map 里移除掉,避免影响后续业务逻辑处理; | ||
| 216 | + // 1. 从消息中提取 token(CoAP 风格:消息体携带 token) | ||
| 217 | + String token = null; | ||
| 218 | + if (message.getParams() instanceof Map) { | ||
| 219 | + token = MapUtil.getStr((Map<String, Object>) message.getParams(), "token"); | ||
| 220 | + } | ||
| 221 | + | ||
| 222 | + if (StrUtil.isBlank(token)) { | ||
| 223 | + log.warn("[handleBusinessRequest][缺少 token,来源: {}]", addressKey); | ||
| 224 | + sendErrorResponse(socket, senderAddress, message.getRequestId(), "请先进行认证", codecType); | ||
| 225 | + return; | ||
| 226 | + } | ||
| 227 | + | ||
| 228 | + // 2. 验证 token,获取设备信息 | ||
| 229 | + IotDeviceAuthUtils.DeviceInfo deviceInfo = deviceTokenService.verifyToken(token); | ||
| 230 | + if (deviceInfo == null) { | ||
| 231 | + log.warn("[handleBusinessRequest][token 无效或已过期,来源: {}]", addressKey); | ||
| 232 | + sendErrorResponse(socket, senderAddress, message.getRequestId(), "token 无效或已过期", codecType); | ||
| 233 | + return; | ||
| 234 | + } | ||
| 235 | + | ||
| 236 | + // 3. 获取设备详细信息 | ||
| 237 | + IotDeviceRespDTO device = deviceService.getDeviceFromCache(deviceInfo.getProductKey(), | ||
| 238 | + deviceInfo.getDeviceName()); | ||
| 239 | + if (device == null) { | ||
| 240 | + log.warn("[handleBusinessRequest][设备不存在,来源: {},productKey: {},deviceName: {}]", | ||
| 241 | + addressKey, deviceInfo.getProductKey(), deviceInfo.getDeviceName()); | ||
| 242 | + sendErrorResponse(socket, senderAddress, message.getRequestId(), "设备不存在", codecType); | ||
| 243 | + return; | ||
| 244 | + } | ||
| 245 | + | ||
| 246 | + // 4. 更新设备地址映射(保持最新) | ||
| 247 | + sessionManager.updateDeviceAddress(device.getId(), senderAddress); | ||
| 248 | + | ||
| 249 | + // 5. 发送消息到消息总线 | ||
| 250 | + deviceMessageService.sendDeviceMessage(message, device.getProductKey(), | ||
| 251 | + device.getDeviceName(), serverId); | ||
| 252 | + | ||
| 253 | + // 6. 发送成功响应 | ||
| 254 | + sendSuccessResponse(socket, senderAddress, message.getRequestId(), "处理成功", codecType); | ||
| 255 | + log.debug("[handleBusinessRequest][业务消息处理成功,设备 ID: {},方法: {},来源: {}]", | ||
| 256 | + device.getId(), message.getMethod(), addressKey); | ||
| 257 | + } catch (Exception e) { | ||
| 258 | + log.error("[handleBusinessRequest][业务请求处理异常,来源: {}]", addressKey, e); | ||
| 259 | + sendErrorResponse(socket, senderAddress, message.getRequestId(), "处理失败", codecType); | ||
| 260 | + } | ||
| 261 | + } | ||
| 262 | + | ||
| 263 | + /** | ||
| 264 | + * 获取消息编解码类型 | ||
| 265 | + * | ||
| 266 | + * @param buffer 消息 | ||
| 267 | + * @return 消息编解码类型 | ||
| 268 | + */ | ||
| 269 | + private String getMessageCodecType(Buffer buffer) { | ||
| 270 | + // 检测消息格式类型 | ||
| 271 | + return IotTcpBinaryDeviceMessageCodec.isBinaryFormatQuick(buffer.getBytes()) ? CODEC_TYPE_BINARY | ||
| 272 | + : CODEC_TYPE_JSON; | ||
| 273 | + } | ||
| 274 | + | ||
| 275 | + /** | ||
| 276 | + * 发送设备上线消息 | ||
| 277 | + * | ||
| 278 | + * @param device 设备信息 | ||
| 279 | + */ | ||
| 280 | + private void sendOnlineMessage(IotDeviceRespDTO device) { | ||
| 281 | + try { | ||
| 282 | + IotDeviceMessage onlineMessage = IotDeviceMessage.buildStateUpdateOnline(); | ||
| 283 | + deviceMessageService.sendDeviceMessage(onlineMessage, device.getProductKey(), | ||
| 284 | + device.getDeviceName(), serverId); | ||
| 285 | + } catch (Exception e) { | ||
| 286 | + log.error("[sendOnlineMessage][发送上线消息失败,设备: {}]", device.getDeviceName(), e); | ||
| 287 | + } | ||
| 288 | + } | ||
| 289 | + | ||
| 290 | + /** | ||
| 291 | + * 验证设备认证信息 | ||
| 292 | + * | ||
| 293 | + * @param authParams 认证参数 | ||
| 294 | + * @return 是否认证成功 | ||
| 295 | + */ | ||
| 296 | + private boolean validateDeviceAuth(IotDeviceAuthReqDTO authParams) { | ||
| 297 | + try { | ||
| 298 | + CommonResult<Boolean> result = deviceApi.authDevice(new IotDeviceAuthReqDTO() | ||
| 299 | + .setClientId(authParams.getClientId()).setUsername(authParams.getUsername()) | ||
| 300 | + .setPassword(authParams.getPassword())); | ||
| 301 | + result.checkError(); | ||
| 302 | + return BooleanUtil.isTrue(result.getData()); | ||
| 303 | + } catch (Exception e) { | ||
| 304 | + log.error("[validateDeviceAuth][设备认证异常,username: {}]", authParams.getUsername(), e); | ||
| 305 | + return false; | ||
| 306 | + } | ||
| 307 | + } | ||
| 308 | + | ||
| 309 | + /** | ||
| 310 | + * 发送认证成功响应(包含 token) | ||
| 311 | + * | ||
| 312 | + * @param socket UDP Socket | ||
| 313 | + * @param address 目标地址 | ||
| 314 | + * @param requestId 请求 ID | ||
| 315 | + * @param token JWT Token | ||
| 316 | + * @param codecType 消息编解码类型 | ||
| 317 | + */ | ||
| 318 | + private void sendAuthSuccessResponse(DatagramSocket socket, InetSocketAddress address, | ||
| 319 | + String requestId, String token, String codecType) { | ||
| 320 | + try { | ||
| 321 | + Object responseData = MapUtil.builder() | ||
| 322 | + .put("success", true) | ||
| 323 | + .put("token", token) | ||
| 324 | + .put("message", "认证成功") | ||
| 325 | + .build(); | ||
| 326 | + | ||
| 327 | + IotDeviceMessage responseMessage = IotDeviceMessage.replyOf(requestId, AUTH_METHOD, responseData, 0, "认证成功"); | ||
| 328 | + byte[] encodedData = deviceMessageService.encodeDeviceMessage(responseMessage, codecType); | ||
| 329 | + socket.send(Buffer.buffer(encodedData), address.getPort(), address.getHostString(), result -> { | ||
| 330 | + if (result.failed()) { | ||
| 331 | + log.error("[sendAuthSuccessResponse][发送认证成功响应失败,地址: {}]", | ||
| 332 | + sessionManager.buildAddressKey(address), result.cause()); | ||
| 333 | + } | ||
| 334 | + }); | ||
| 335 | + } catch (Exception e) { | ||
| 336 | + log.error("[sendAuthSuccessResponse][发送认证成功响应异常,地址: {}]", | ||
| 337 | + sessionManager.buildAddressKey(address), e); | ||
| 338 | + } | ||
| 339 | + } | ||
| 340 | + | ||
| 341 | + /** | ||
| 342 | + * 发送成功响应 | ||
| 343 | + * | ||
| 344 | + * @param socket UDP Socket | ||
| 345 | + * @param address 目标地址 | ||
| 346 | + * @param requestId 请求 ID | ||
| 347 | + * @param message 消息 | ||
| 348 | + * @param codecType 消息编解码类型 | ||
| 349 | + */ | ||
| 350 | + @SuppressWarnings("SameParameterValue") | ||
| 351 | + private void sendSuccessResponse(DatagramSocket socket, InetSocketAddress address, | ||
| 352 | + String requestId, String message, String codecType) { | ||
| 353 | + sendResponse(socket, address, true, message, requestId, codecType); | ||
| 354 | + } | ||
| 355 | + | ||
| 356 | + /** | ||
| 357 | + * 发送错误响应 | ||
| 358 | + * | ||
| 359 | + * @param socket UDP Socket | ||
| 360 | + * @param address 目标地址 | ||
| 361 | + * @param requestId 请求 ID | ||
| 362 | + * @param errorMessage 错误消息 | ||
| 363 | + * @param codecType 消息编解码类型 | ||
| 364 | + */ | ||
| 365 | + private void sendErrorResponse(DatagramSocket socket, InetSocketAddress address, | ||
| 366 | + String requestId, String errorMessage, String codecType) { | ||
| 367 | + sendResponse(socket, address, false, errorMessage, requestId, codecType); | ||
| 368 | + } | ||
| 369 | + | ||
| 370 | + /** | ||
| 371 | + * 发送响应消息 | ||
| 372 | + * | ||
| 373 | + * @param socket UDP Socket | ||
| 374 | + * @param address 目标地址 | ||
| 375 | + * @param success 是否成功 | ||
| 376 | + * @param message 消息 | ||
| 377 | + * @param requestId 请求 ID | ||
| 378 | + * @param codecType 消息编解码类型 | ||
| 379 | + */ | ||
| 380 | + private void sendResponse(DatagramSocket socket, InetSocketAddress address, boolean success, | ||
| 381 | + String message, String requestId, String codecType) { | ||
| 382 | + try { | ||
| 383 | + Object responseData = MapUtil.builder() | ||
| 384 | + .put("success", success) | ||
| 385 | + .put("message", message) | ||
| 386 | + .build(); | ||
| 387 | + | ||
| 388 | + int code = success ? 0 : 401; | ||
| 389 | + IotDeviceMessage responseMessage = IotDeviceMessage.replyOf(requestId, "response", responseData, | ||
| 390 | + code, message); | ||
| 391 | + | ||
| 392 | + byte[] encodedData = deviceMessageService.encodeDeviceMessage(responseMessage, codecType); | ||
| 393 | + socket.send(Buffer.buffer(encodedData), address.getPort(), address.getHostString(), ar -> { | ||
| 394 | + if (ar.failed()) { | ||
| 395 | + log.error("[sendResponse][发送响应失败,地址: {}]", | ||
| 396 | + sessionManager.buildAddressKey(address), ar.cause()); | ||
| 397 | + } | ||
| 398 | + }); | ||
| 399 | + } catch (Exception e) { | ||
| 400 | + log.error("[sendResponse][发送响应异常,地址: {}]", | ||
| 401 | + sessionManager.buildAddressKey(address), e); | ||
| 402 | + } | ||
| 403 | + } | ||
| 404 | + | ||
| 405 | + /** | ||
| 406 | + * 解析认证参数 | ||
| 407 | + * | ||
| 408 | + * @param params 参数对象(通常为 Map 类型) | ||
| 409 | + * @return 认证参数 DTO,解析失败时返回 null | ||
| 410 | + */ | ||
| 411 | + @SuppressWarnings("unchecked") | ||
| 412 | + private IotDeviceAuthReqDTO parseAuthParams(Object params) { | ||
| 413 | + if (params == null) { | ||
| 414 | + return null; | ||
| 415 | + } | ||
| 416 | + | ||
| 417 | + try { | ||
| 418 | + // 参数默认为 Map 类型,直接转换 | ||
| 419 | + if (params instanceof Map) { | ||
| 420 | + Map<String, Object> paramMap = (Map<String, Object>) params; | ||
| 421 | + return new IotDeviceAuthReqDTO() | ||
| 422 | + .setClientId(MapUtil.getStr(paramMap, "clientId")) | ||
| 423 | + .setUsername(MapUtil.getStr(paramMap, "username")) | ||
| 424 | + .setPassword(MapUtil.getStr(paramMap, "password")); | ||
| 425 | + } | ||
| 426 | + | ||
| 427 | + // 如果已经是目标类型,直接返回 | ||
| 428 | + if (params instanceof IotDeviceAuthReqDTO) { | ||
| 429 | + return (IotDeviceAuthReqDTO) params; | ||
| 430 | + } | ||
| 431 | + | ||
| 432 | + // 其他情况尝试 JSON 转换 | ||
| 433 | + String jsonStr = JsonUtils.toJsonString(params); | ||
| 434 | + return JsonUtils.parseObject(jsonStr, IotDeviceAuthReqDTO.class); | ||
| 435 | + } catch (Exception e) { | ||
| 436 | + log.error("[parseAuthParams][解析认证参数({})失败]", params, e); | ||
| 437 | + return null; | ||
| 438 | + } | ||
| 439 | + } | ||
| 440 | + | ||
| 441 | +} |
yudao-module-iot/yudao-module-iot-gateway/src/main/resources/application.yaml
| @@ -96,6 +96,16 @@ yudao: | @@ -96,6 +96,16 @@ yudao: | ||
| 96 | ssl-cert-path: "classpath:certs/client.jks" | 96 | ssl-cert-path: "classpath:certs/client.jks" |
| 97 | ssl-key-path: "classpath:certs/client.jks" | 97 | ssl-key-path: "classpath:certs/client.jks" |
| 98 | # ==================================== | 98 | # ==================================== |
| 99 | + # 针对引入的 UDP 组件的配置 | ||
| 100 | + # ==================================== | ||
| 101 | + udp: | ||
| 102 | + enabled: false # 是否启用 UDP | ||
| 103 | + port: 8092 # UDP 服务端口 | ||
| 104 | + receive-buffer-size: 65536 # 接收缓冲区大小(字节,默认 64KB) | ||
| 105 | + send-buffer-size: 65536 # 发送缓冲区大小(字节,默认 64KB) | ||
| 106 | + session-timeout-ms: 60000 # 会话超时时间(毫秒,默认 60 秒) | ||
| 107 | + session-clean-interval-ms: 30000 # 会话清理间隔(毫秒,默认 30 秒) | ||
| 108 | + # ==================================== | ||
| 99 | # 针对引入的 MQTT 组件的配置 | 109 | # 针对引入的 MQTT 组件的配置 |
| 100 | # ==================================== | 110 | # ==================================== |
| 101 | mqtt: | 111 | mqtt: |
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotUdpProtocolIntegrationTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.udp; | ||
| 2 | + | ||
| 3 | +import cn.hutool.core.map.MapUtil; | ||
| 4 | +import cn.hutool.core.util.IdUtil; | ||
| 5 | +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; | ||
| 6 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | ||
| 7 | +import lombok.extern.slf4j.Slf4j; | ||
| 8 | +import org.junit.jupiter.api.Test; | ||
| 9 | + | ||
| 10 | +import java.net.DatagramPacket; | ||
| 11 | +import java.net.DatagramSocket; | ||
| 12 | +import java.net.InetAddress; | ||
| 13 | +import java.nio.charset.StandardCharsets; | ||
| 14 | + | ||
| 15 | +/** | ||
| 16 | + * IoT 网关 UDP 协议集成测试(手动测试) | ||
| 17 | + * | ||
| 18 | + * <p>使用步骤: | ||
| 19 | + * <ol> | ||
| 20 | + * <li>启动 yudao-module-iot-gateway 服务(UDP 端口 8092)</li> | ||
| 21 | + * <li>运行 {@link #testAuth()} 获取 token,将返回的 token 粘贴到 {@link #TOKEN} 常量</li> | ||
| 22 | + * <li>运行 {@link #testPropertyPost()} 测试属性上报,或运行 {@link #testEventPost()} 测试事件上报</li> | ||
| 23 | + * </ol> | ||
| 24 | + * | ||
| 25 | + * @author 芋道源码 | ||
| 26 | + */ | ||
| 27 | +@Slf4j | ||
| 28 | +public class IotUdpProtocolIntegrationTest { | ||
| 29 | + | ||
| 30 | + private static final String SERVER_HOST = "127.0.0.1"; | ||
| 31 | + private static final int SERVER_PORT = 8092; | ||
| 32 | + private static final int TIMEOUT_MS = 5000; | ||
| 33 | + | ||
| 34 | + // 设备信息(根据实际情况修改 PRODUCT_KEY、DEVICE_NAME、PASSWORD) | ||
| 35 | + private static final String PRODUCT_KEY = "4aymZgOTOOCrDKRT"; | ||
| 36 | + private static final String DEVICE_NAME = "small"; | ||
| 37 | + private static final String PASSWORD = "509e2b08f7598eb139d276388c600435913ba4c94cd0d50aebc5c0d1855bcb75"; | ||
| 38 | + | ||
| 39 | + private static final String CLIENT_ID = PRODUCT_KEY + "." + DEVICE_NAME; | ||
| 40 | + private static final String USERNAME = DEVICE_NAME + "&" + PRODUCT_KEY; | ||
| 41 | + | ||
| 42 | + /** | ||
| 43 | + * 设备 Token:从 {@link #testAuth()} 方法获取后,粘贴到这里 | ||
| 44 | + */ | ||
| 45 | + private static final String TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwcm9kdWN0S2V5IjoiNGF5bVpnT1RPT0NyREtSVCIsImV4cCI6MTc2OTMwNTA1NSwiZGV2aWNlTmFtZSI6InNtYWxsIn0.mf3MEATCn5bp6cXgULunZjs8d00RGUxj96JEz0hMS7k"; | ||
| 46 | + | ||
| 47 | + /** | ||
| 48 | + * 认证测试:获取设备 Token | ||
| 49 | + */ | ||
| 50 | + @Test | ||
| 51 | + public void testAuth() throws Exception { | ||
| 52 | + String payload = JsonUtils.toJsonString(MapUtil.builder() | ||
| 53 | + .put("id", IdUtil.fastSimpleUUID()) | ||
| 54 | + .put("method", "auth") | ||
| 55 | + .put("params", MapUtil.builder() | ||
| 56 | + .put("clientId", CLIENT_ID) | ||
| 57 | + .put("username", USERNAME) | ||
| 58 | + .put("password", PASSWORD) | ||
| 59 | + .build()) | ||
| 60 | + .build()); | ||
| 61 | + | ||
| 62 | + try (DatagramSocket socket = new DatagramSocket()) { | ||
| 63 | + socket.setSoTimeout(TIMEOUT_MS); | ||
| 64 | + | ||
| 65 | + log.info("[testAuth][请求体: {}]", payload); | ||
| 66 | + | ||
| 67 | + String response = sendAndReceive(socket, payload); | ||
| 68 | + | ||
| 69 | + log.info("[testAuth][响应体: {}]", response); | ||
| 70 | + log.info("[testAuth][请将返回的 token 复制到 TOKEN 常量中]"); | ||
| 71 | + } | ||
| 72 | + } | ||
| 73 | + | ||
| 74 | + /** | ||
| 75 | + * 属性上报测试 | ||
| 76 | + */ | ||
| 77 | + @Test | ||
| 78 | + public void testPropertyPost() throws Exception { | ||
| 79 | + String payload = JsonUtils.toJsonString(MapUtil.builder() | ||
| 80 | + .put("id", IdUtil.fastSimpleUUID()) | ||
| 81 | + .put("method", IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()) | ||
| 82 | + .put("version", "1.0") | ||
| 83 | + .put("params", MapUtil.builder() | ||
| 84 | + .put("token", TOKEN) | ||
| 85 | + .put("width", 1) | ||
| 86 | + .put("height", "2") | ||
| 87 | + .build()) | ||
| 88 | + .build()); | ||
| 89 | + | ||
| 90 | + try (DatagramSocket socket = new DatagramSocket()) { | ||
| 91 | + socket.setSoTimeout(TIMEOUT_MS); | ||
| 92 | + | ||
| 93 | + log.info("[testPropertyPost][请求体: {}]", payload); | ||
| 94 | + | ||
| 95 | + String response = sendAndReceive(socket, payload); | ||
| 96 | + | ||
| 97 | + log.info("[testPropertyPost][响应体: {}]", response); | ||
| 98 | + } | ||
| 99 | + } | ||
| 100 | + | ||
| 101 | + /** | ||
| 102 | + * 事件上报测试 | ||
| 103 | + */ | ||
| 104 | + @Test | ||
| 105 | + public void testEventPost() throws Exception { | ||
| 106 | + String payload = JsonUtils.toJsonString(MapUtil.builder() | ||
| 107 | + .put("id", IdUtil.fastSimpleUUID()) | ||
| 108 | + .put("method", IotDeviceMessageMethodEnum.EVENT_POST.getMethod()) | ||
| 109 | + .put("version", "1.0") | ||
| 110 | + .put("identifier", "eat") | ||
| 111 | + .put("params", MapUtil.builder() | ||
| 112 | + .put("token", TOKEN) | ||
| 113 | + .put("width", 1) | ||
| 114 | + .put("height", "2") | ||
| 115 | + .put("oneThree", "3") | ||
| 116 | + .build()) | ||
| 117 | + .build()); | ||
| 118 | + | ||
| 119 | + try (DatagramSocket socket = new DatagramSocket()) { | ||
| 120 | + socket.setSoTimeout(TIMEOUT_MS); | ||
| 121 | + | ||
| 122 | + log.info("[testEventPost][请求体: {}]", payload); | ||
| 123 | + | ||
| 124 | + String response = sendAndReceive(socket, payload); | ||
| 125 | + | ||
| 126 | + log.info("[testEventPost][响应体: {}]", response); | ||
| 127 | + } | ||
| 128 | + } | ||
| 129 | + | ||
| 130 | + /** | ||
| 131 | + * 发送 UDP 请求并接收响应 | ||
| 132 | + * | ||
| 133 | + * @param socket UDP Socket | ||
| 134 | + * @param payload 请求体 | ||
| 135 | + * @return 响应内容 | ||
| 136 | + */ | ||
| 137 | + private String sendAndReceive(DatagramSocket socket, String payload) throws Exception { | ||
| 138 | + byte[] sendData = payload.getBytes(StandardCharsets.UTF_8); | ||
| 139 | + InetAddress address = InetAddress.getByName(SERVER_HOST); | ||
| 140 | + | ||
| 141 | + // 发送请求 | ||
| 142 | + DatagramPacket sendPacket = new DatagramPacket(sendData, sendData.length, address, SERVER_PORT); | ||
| 143 | + socket.send(sendPacket); | ||
| 144 | + | ||
| 145 | + // 接收响应 | ||
| 146 | + byte[] receiveData = new byte[4096]; | ||
| 147 | + DatagramPacket receivePacket = new DatagramPacket(receiveData, receiveData.length); | ||
| 148 | + try { | ||
| 149 | + socket.receive(receivePacket); | ||
| 150 | + return new String(receivePacket.getData(), 0, receivePacket.getLength(), StandardCharsets.UTF_8); | ||
| 151 | + } catch (java.net.SocketTimeoutException e) { | ||
| 152 | + log.warn("[sendAndReceive][接收响应超时]"); | ||
| 153 | + return null; | ||
| 154 | + } | ||
| 155 | + } | ||
| 156 | + | ||
| 157 | +} |