Commit 52b8e66466f009b8fef0fdd7ef8eea15c2037dac

Authored by YunaiV
1 parent b2fef46b

feat:【iot】modbus-tcp 协议接入 100%:完善注释、完善单测

yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/manager/IotTcpConnectionManager.java
1 1 package cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager;
2 2  
  3 +import io.vertx.core.buffer.Buffer;
3 4 import io.vertx.core.net.NetSocket;
4 5 import lombok.Data;
5 6 import lombok.extern.slf4j.Slf4j;
... ... @@ -119,7 +120,7 @@ public class IotTcpConnectionManager {
119 120 }
120 121  
121 122 try {
122   - socket.write(io.vertx.core.buffer.Buffer.buffer(data));
  123 + socket.write(Buffer.buffer(data));
123 124 log.debug("[sendToDevice][发送消息成功,设备 ID: {},数据长度: {} 字节]", deviceId, data.length);
124 125 return true;
125 126 } catch (Exception e) {
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotUdpUpstreamProtocol.java
1 1 package cn.iocoder.yudao.module.iot.gateway.protocol.udp;
2 2  
  3 +import cn.hutool.core.collection.CollUtil;
  4 +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO;
3 5 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
4 6 import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
5 7 import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties;
... ... @@ -15,6 +17,8 @@ import jakarta.annotation.PreDestroy;
15 17 import lombok.Getter;
16 18 import lombok.extern.slf4j.Slf4j;
17 19  
  20 +import java.util.List;
  21 +
18 22 /**
19 23 * IoT 网关 UDP 协议:接收设备上行消息
20 24 * <p>
... ... @@ -80,19 +84,18 @@ public class IotUdpUpstreamProtocol {
80 84  
81 85 // 4. 监听端口
82 86 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 {
  87 + if (result.failed()) {
94 88 log.error("[start][IoT 网关 UDP 协议启动失败]", result.cause());
  89 + return;
95 90 }
  91 + // 设置数据包处理器
  92 + udpSocket.handler(packet -> upstreamHandler.handle(packet, udpSocket));
  93 + log.info("[start][IoT 网关 UDP 协议启动成功,端口:{},接收缓冲区:{} 字节,发送缓冲区:{} 字节]",
  94 + udpProperties.getPort(), udpProperties.getReceiveBufferSize(),
  95 + udpProperties.getSendBufferSize());
  96 +
  97 + // 5. 启动会话清理定时器
  98 + startSessionCleanTimer();
96 99 });
97 100 }
98 101  
... ... @@ -123,16 +126,14 @@ public class IotUdpUpstreamProtocol {
123 126 cleanTimerId = vertx.setPeriodic(udpProperties.getSessionCleanIntervalMs(), id -> {
124 127 try {
125 128 // 1. 清理超时的设备地址映射,并获取离线设备列表
126   - // TODO @AI:兼容 jdk8,不要用 var;
127   - var offlineDevices = sessionManager.cleanExpiredMappings(udpProperties.getSessionTimeoutMs());
  129 + List<Long> offlineDeviceIds = sessionManager.cleanExpiredMappings(udpProperties.getSessionTimeoutMs());
128 130  
129 131 // 2. 为每个离线设备发送离线消息
130   - for (var offlineInfo : offlineDevices) {
131   - sendOfflineMessage(offlineInfo.getDeviceId());
  132 + for (Long deviceId : offlineDeviceIds) {
  133 + sendOfflineMessage(deviceId);
132 134 }
133   - // TODO @AI:CollUtil.isNotEmpty ;简化下 if 判断;
134   - if (!offlineDevices.isEmpty()) {
135   - log.info("[cleanExpiredMappings][本次清理 {} 个超时设备]", offlineDevices.size());
  135 + if (CollUtil.isNotEmpty(offlineDeviceIds)) {
  136 + log.info("[cleanExpiredMappings][本次清理 {} 个超时设备]", offlineDeviceIds.size());
136 137 }
137 138 } catch (Exception e) {
138 139 log.error("[cleanExpiredMappings][清理超时会话失败]", e);
... ... @@ -150,7 +151,7 @@ public class IotUdpUpstreamProtocol {
150 151 private void sendOfflineMessage(Long deviceId) {
151 152 try {
152 153 // 获取设备信息
153   - var device = deviceService.getDeviceFromCache(deviceId);
  154 + IotDeviceRespDTO device = deviceService.getDeviceFromCache(deviceId);
154 155 if (device == null) {
155 156 log.warn("[sendOfflineMessage][设备不存在,设备 ID: {}]", deviceId);
156 157 return;
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/manager/IotUdpSessionManager.java
... ... @@ -2,12 +2,14 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.udp.manager;
2 2  
3 3 import io.vertx.core.buffer.Buffer;
4 4 import io.vertx.core.datagram.DatagramSocket;
5   -import lombok.Data;
6 5 import lombok.extern.slf4j.Slf4j;
7 6 import org.springframework.stereotype.Component;
8 7  
9 8 import java.net.InetSocketAddress;
  9 +import java.time.LocalDateTime;
  10 +import java.util.ArrayList;
10 11 import java.util.Iterator;
  12 +import java.util.List;
11 13 import java.util.Map;
12 14 import java.util.concurrent.ConcurrentHashMap;
13 15  
... ... @@ -34,8 +36,7 @@ public class IotUdpSessionManager {
34 36 /**
35 37 * 设备地址 Key -> 最后活跃时间(用于清理)
36 38 */
37   - // TODO @AI:是不是尽量使用 LocalDateTime ?统一时间类型
38   - private final Map<String, Long> lastActiveTimeMap = new ConcurrentHashMap<>();
  39 + private final Map<String, LocalDateTime> lastActiveTimeMap = new ConcurrentHashMap<>();
39 40  
40 41 /**
41 42 * 设备地址 Key -> 设备 ID(反向映射,用于清理时同步)
... ... @@ -52,22 +53,11 @@ public class IotUdpSessionManager {
52 53 String addressKey = buildAddressKey(address);
53 54 // 更新设备地址映射
54 55 deviceAddressMap.put(deviceId, address);
55   - lastActiveTimeMap.put(addressKey, System.currentTimeMillis());
  56 + lastActiveTimeMap.put(addressKey, LocalDateTime.now());
56 57 addressDeviceMap.put(addressKey, deviceId);
57 58 log.debug("[updateDeviceAddress][更新设备地址,设备 ID: {},地址: {}]", deviceId, addressKey);
58 59 }
59 60  
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 61 /**
72 62 * 检查设备是否在线(即是否有地址映射)
73 63 *
... ... @@ -126,46 +116,35 @@ public class IotUdpSessionManager {
126 116 * @param timeoutMs 超时时间(毫秒)
127 117 * @return 清理的设备 ID 列表(用于发送离线消息)
128 118 */
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();
  119 + public List<Long> cleanExpiredMappings(long timeoutMs) {
  120 + List<Long> offlineDeviceIds = new ArrayList<>();
  121 + LocalDateTime now = LocalDateTime.now();
  122 + LocalDateTime expireTime = now.minusNanos(timeoutMs * 1_000_000);
  123 + Iterator<Map.Entry<String, LocalDateTime>> iterator = lastActiveTimeMap.entrySet().iterator();
134 124 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   - }
  125 + // 未过期,跳过
  126 + Map.Entry<String, LocalDateTime> entry = iterator.next();
  127 + if (entry.getValue().isAfter(expireTime)) {
  128 + continue;
  129 + }
  130 + // 过期处理:记录离线设备 ID
  131 + String addressKey = entry.getKey();
  132 + Long deviceId = addressDeviceMap.remove(addressKey);
  133 + if (deviceId == null) {
149 134 iterator.remove();
  135 + continue;
150 136 }
  137 + InetSocketAddress address = deviceAddressMap.remove(deviceId);
  138 + if (address == null) {
  139 + iterator.remove();
  140 + continue;
  141 + }
  142 + offlineDeviceIds.add(deviceId);
  143 + log.debug("[cleanExpiredMappings][清理超时设备,设备 ID: {},地址: {},最后活跃时间: {}]",
  144 + deviceId, addressKey, entry.getValue());
  145 + iterator.remove();
151 146 }
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   - }
  147 + return offlineDeviceIds;
169 148 }
170 149  
171 150 /**
... ... @@ -178,27 +157,4 @@ public class IotUdpSessionManager {
178 157 return address.getHostString() + ":" + address.getPort();
179 158 }
180 159  
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 160 }
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/package-info.java
1   -// TODO @AI:完善下注释,参考 mqtt 的 package.json
  1 +/**
  2 + * UDP 协议实现包
  3 + * <p>
  4 + * 提供基于 Vert.x DatagramSocket 的 IoT 设备连接和消息处理功能
  5 + */
2 6 package cn.iocoder.yudao.module.iot.gateway.protocol.udp;
3 7 \ 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/IotUdpUpstreamHandler.java
... ... @@ -26,11 +26,10 @@ import lombok.extern.slf4j.Slf4j;
26 26 import java.net.InetSocketAddress;
27 27 import java.util.Map;
28 28  
29   -// TODO @AI:注释里,不要出现 CoAP,避免理解成本过高;
30 29 /**
31 30 * UDP 上行消息处理器
32 31 * <p>
33   - * 采用 CoAP 风格的 Token 机制(无状态,每次请求携带 token):
  32 + * 采用无状态 Token 机制(每次请求携带 token):
34 33 * 1. 认证请求:设备发送 auth 消息,携带 clientId、username、password
35 34 * 2. 返回 Token:服务端验证后返回 JWT token
36 35 * 3. 后续请求:每次请求在 params 中携带 token
... ... @@ -45,6 +44,10 @@ public class IotUdpUpstreamHandler {
45 44 private static final String CODEC_TYPE_BINARY = IotTcpBinaryDeviceMessageCodec.TYPE;
46 45  
47 46 private static final String AUTH_METHOD = "auth";
  47 + /**
  48 + * Token 参数 Key
  49 + */
  50 + private static final String PARAM_KEY_TOKEN = "token";
48 51  
49 52 private final IotDeviceMessageService deviceMessageService;
50 53  
... ... @@ -70,15 +73,13 @@ public class IotUdpUpstreamHandler {
70 73 this.serverId = protocol.getServerId();
71 74 }
72 75  
73   - // TODO @AI:protocol 这个参数如果用不到,就删除下;
74 76 /**
75 77 * 处理 UDP 数据包
76 78 *
77   - * @param packet 数据包
78   - * @param socket UDP Socket
79   - * @param protocol UDP 协议
  79 + * @param packet 数据包
  80 + * @param socket UDP Socket
80 81 */
81   - public void handle(DatagramPacket packet, DatagramSocket socket, IotUdpUpstreamProtocol protocol) {
  82 + public void handle(DatagramPacket packet, DatagramSocket socket) {
82 83 InetSocketAddress senderAddress = new InetSocketAddress(packet.sender().host(), packet.sender().port());
83 84 Buffer data = packet.data();
84 85 log.debug("[handle][收到 UDP 数据包,来源: {},数据长度: {} 字节]",
... ... @@ -180,7 +181,7 @@ public class IotUdpUpstreamHandler {
180 181 return;
181 182 }
182 183  
183   - // 3.1 生成 JWT Token(CoAP 风格)
  184 + // 3.1 生成 JWT Token(无状态)
184 185 String token = deviceTokenService.createToken(device.getProductKey(), device.getDeviceName());
185 186  
186 187 // 3.2 更新设备地址映射(用于下行消息)
... ... @@ -212,20 +213,18 @@ public class IotUdpUpstreamHandler {
212 213 InetSocketAddress senderAddress, DatagramSocket socket) {
213 214 String addressKey = sessionManager.buildAddressKey(senderAddress);
214 215 try {
215   - // TODO @AI:token 需要枚举个 KEY;考虑到是通过 params 传递的话,需要获取到后,从 map 里移除掉,避免影响后续业务逻辑处理;
216   - // 1. 从消息中提取 token(CoAP 风格:消息体携带 token)
  216 + // 1.1 从消息中提取 token(无状态:消息体携带 token)
217 217 String token = null;
218 218 if (message.getParams() instanceof Map) {
219   - token = MapUtil.getStr((Map<String, Object>) message.getParams(), "token");
  219 + Map<String, Object> paramsMap = (Map<String, Object>) message.getParams();
  220 + token = (String) paramsMap.remove(PARAM_KEY_TOKEN);
220 221 }
221   -
222 222 if (StrUtil.isBlank(token)) {
223 223 log.warn("[handleBusinessRequest][缺少 token,来源: {}]", addressKey);
224 224 sendErrorResponse(socket, senderAddress, message.getRequestId(), "请先进行认证", codecType);
225 225 return;
226 226 }
227   -
228   - // 2. 验证 token,获取设备信息
  227 + // 1.2 验证 token,获取设备信息
229 228 IotDeviceAuthUtils.DeviceInfo deviceInfo = deviceTokenService.verifyToken(token);
230 229 if (deviceInfo == null) {
231 230 log.warn("[handleBusinessRequest][token 无效或已过期,来源: {}]", addressKey);
... ... @@ -233,7 +232,7 @@ public class IotUdpUpstreamHandler {
233 232 return;
234 233 }
235 234  
236   - // 3. 获取设备详细信息
  235 + // 2. 获取设备详细信息
237 236 IotDeviceRespDTO device = deviceService.getDeviceFromCache(deviceInfo.getProductKey(),
238 237 deviceInfo.getDeviceName());
239 238 if (device == null) {
... ... @@ -243,14 +242,14 @@ public class IotUdpUpstreamHandler {
243 242 return;
244 243 }
245 244  
246   - // 4. 更新设备地址映射(保持最新)
  245 + // 3. 更新设备地址映射(保持最新)
247 246 sessionManager.updateDeviceAddress(device.getId(), senderAddress);
248 247  
249   - // 5. 发送消息到消息总线
  248 + // 4. 发送消息到消息总线
250 249 deviceMessageService.sendDeviceMessage(message, device.getProductKey(),
251 250 device.getDeviceName(), serverId);
252 251  
253   - // 6. 发送成功响应
  252 + // 5. 发送成功响应
254 253 sendSuccessResponse(socket, senderAddress, message.getRequestId(), "处理成功", codecType);
255 254 log.debug("[handleBusinessRequest][业务消息处理成功,设备 ID: {},方法: {},来源: {}]",
256 255 device.getId(), message.getMethod(), addressKey);
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotUdpProtocolIntegrationTest.java
... ... @@ -36,13 +36,14 @@ public class IotUdpProtocolIntegrationTest {
36 36 private static final String DEVICE_NAME = "small";
37 37 private static final String PASSWORD = "509e2b08f7598eb139d276388c600435913ba4c94cd0d50aebc5c0d1855bcb75";
38 38  
  39 + // TODO @芋艿:1、IotDeviceAuthUtils 调整下拼接;2、password 的生成;3、后续给 http 也整个单测;4、后续给 tcp 也整个单测;5、后续给 mqtt 也整个单测;6、后续给 emqp 也整个单测
39 40 private static final String CLIENT_ID = PRODUCT_KEY + "." + DEVICE_NAME;
40 41 private static final String USERNAME = DEVICE_NAME + "&" + PRODUCT_KEY;
41 42  
42 43 /**
43 44 * 设备 Token:从 {@link #testAuth()} 方法获取后,粘贴到这里
44 45 */
45   - private static final String TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwcm9kdWN0S2V5IjoiNGF5bVpnT1RPT0NyREtSVCIsImV4cCI6MTc2OTMwNTA1NSwiZGV2aWNlTmFtZSI6InNtYWxsIn0.mf3MEATCn5bp6cXgULunZjs8d00RGUxj96JEz0hMS7k";
  46 + private static final String TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwcm9kdWN0S2V5IjoiNGF5bVpnT1RPT0NyREtSVCIsImV4cCI6MTc2OTMxMTY0NiwiZGV2aWNlTmFtZSI6InNtYWxsIn0.re6LCaRfKiE9VQTP3w0Brh2ScVIgrvN3H96z_snndoM";
46 47  
47 48 /**
48 49 * 认证测试:获取设备 Token
... ... @@ -107,12 +108,14 @@ public class IotUdpProtocolIntegrationTest {
107 108 .put("id", IdUtil.fastSimpleUUID())
108 109 .put("method", IotDeviceMessageMethodEnum.EVENT_POST.getMethod())
109 110 .put("version", "1.0")
110   - .put("identifier", "eat")
111 111 .put("params", MapUtil.builder()
112   - .put("token", TOKEN)
113   - .put("width", 1)
114   - .put("height", "2")
115   - .put("oneThree", "3")
  112 + .put("identifier", "eat")
  113 + .put("value", MapUtil.builder()
  114 + .put("width", 1)
  115 + .put("height", "2")
  116 + .put("oneThree", "3")
  117 + .build())
  118 + .put("time", System.currentTimeMillis())
116 119 .build())
117 120 .build());
118 121  
... ...