Commit b2fef46b2cbb33d8912b4e89ebe7b5b0fcbe73f8

Authored by YunaiV
1 parent 3027caa1

feat:【iot】udp 协议接入 50%:初始化,基于 soft-frolicking-breeze.md 实现

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 17 import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpDownstreamSubscriber;
18 18 import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpUpstreamProtocol;
19 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 23 import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService;
21 24 import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
22 25 import io.vertx.core.Vertx;
... ... @@ -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 93 */
94 94 private MqttWsProperties mqttWs;
95 95  
  96 + /**
  97 + * UDP 组件配置
  98 + */
  99 + private UdpProperties udp;
  100 +
96 101 }
97 102  
98 103 @Data
... ... @@ -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 43 return;
44 44 }
45 45  
46   - // 2. 根据产品 Key 和设备名称编码消息并发送到设备
  46 + // 2. 根据产品 Key 和设备名称编码消息,并发送到设备
47 47 byte[] bytes = deviceMessageService.encodeDeviceMessage(message, deviceInfo.getProductKey(),
48 48 deviceInfo.getDeviceName());
49 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
  1 +// TODO @AI:完善下注释,参考 mqtt 的 package.json
  2 +package cn.iocoder.yudao.module.iot.gateway.protocol.udp;
0 3 \ No newline at end of file
... ...
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 96 ssl-cert-path: "classpath:certs/client.jks"
97 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 109 # 针对引入的 MQTT 组件的配置
100 110 # ====================================
101 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 +}
... ...