Commit f1368d9e792a2035720d3b38d94ed5a1b96d5ef1

Authored by haohao
1 parent 2737ffa1

reactor:【IoT 物联网】重构 MQTT 认证和事件处理逻辑,符合 EMQX 官方规范

yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotMqttUpstreamProtocol.java
... ... @@ -125,9 +125,8 @@ public class IotMqttUpstreamProtocol {
125 125  
126 126 // 创建认证处理器
127 127 IotMqttHttpAuthHandler authHandler = new IotMqttHttpAuthHandler(this);
128   - router.post(IotMqttTopicUtils.MQTT_AUTH_AUTHENTICATE_PATH).handler(authHandler::authenticate);
129   - router.post(IotMqttTopicUtils.MQTT_AUTH_CONNECTED_PATH).handler(authHandler::connected);
130   - router.post(IotMqttTopicUtils.MQTT_AUTH_DISCONNECTED_PATH).handler(authHandler::disconnected);
  128 + router.post(IotMqttTopicUtils.MQTT_AUTH_PATH).handler(authHandler::handleAuth);
  129 + router.post(IotMqttTopicUtils.MQTT_EVENT_PATH).handler(authHandler::handleEvent);
131 130  
132 131 // 启动 HTTP 服务器
133 132 int authPort = emqxProperties.getHttpAuthPort();
... ... @@ -338,8 +337,8 @@ public class IotMqttUpstreamProtocol {
338 337 int qos = emqxProperties.getMqttQos();
339 338 log.info("[subscribeToTopics][开始订阅主题, 共 {} 个, QoS: {}]", topicList.size(), qos);
340 339  
341   - int[] successCount = {0}; // 使用数组以便在 lambda 中修改
342   - int[] failCount = {0};
  340 + int[] successCount = { 0 }; // 使用数组以便在 lambda 中修改
  341 + int[] failCount = { 0 };
343 342  
344 343 for (String topic : topicList) {
345 344 mqttClient.subscribe(topic, qos, subscribeResult -> {
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/router/IotMqttHttpAuthHandler.java
... ... @@ -27,29 +27,34 @@ import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVIC
27 27 public class IotMqttHttpAuthHandler {
28 28  
29 29 /**
30   - * 认证成功状态码
  30 + * HTTP 成功状态码(EMQX 要求固定使用 200)
31 31 */
32 32 private static final int SUCCESS_STATUS_CODE = 200;
33 33  
34 34 /**
35   - * 参数错误状态码
  35 + * 认证允许结果
36 36 */
37   - private static final int BAD_REQUEST_STATUS_CODE = 400;
  37 + private static final String RESULT_ALLOW = "allow";
38 38  
39 39 /**
40   - * 认证失败状态码
  40 + * 认证拒绝结果
41 41 */
42   - private static final int UNAUTHORIZED_STATUS_CODE = 401;
  42 + private static final String RESULT_DENY = "deny";
43 43  
44 44 /**
45   - * 服务器错误状态码
  45 + * 认证忽略结果
46 46 */
47   - private static final int INTERNAL_ERROR_STATUS_CODE = 500;
  47 + private static final String RESULT_IGNORE = "ignore";
48 48  
49 49 /**
50   - * MQTT 协议实例,用于获取服务器ID
  50 + * EMQX 事件类型常量
51 51 */
  52 + private static final String EVENT_CLIENT_CONNECTED = "client.connected";
  53 + private static final String EVENT_CLIENT_DISCONNECTED = "client.disconnected";
  54 +
52 55 private final IotMqttUpstreamProtocol protocol;
  56 + private final IotDeviceMessageService deviceMessageService;
  57 + private final IotDeviceCommonApi deviceApi;
53 58  
54 59 /**
55 60 * 构造器
... ... @@ -58,12 +63,14 @@ public class IotMqttHttpAuthHandler {
58 63 */
59 64 public IotMqttHttpAuthHandler(IotMqttUpstreamProtocol protocol) {
60 65 this.protocol = protocol;
  66 + this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class);
  67 + this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class);
61 68 }
62 69  
63 70 /**
64 71 * EMQX 认证接口
65 72 */
66   - public void authenticate(RoutingContext context) {
  73 + public void handleAuth(RoutingContext context) {
67 74 try {
68 75 // 解析请求体
69 76 JsonObject body = parseRequestBody(context);
... ... @@ -71,35 +78,41 @@ public class IotMqttHttpAuthHandler {
71 78 return;
72 79 }
73 80  
74   - String clientid = body.getString("clientid");
  81 + String clientId = body.getString("clientid");
75 82 String username = body.getString("username");
76 83 String password = body.getString("password");
77 84  
78   - log.debug("[authenticate][EMQX 设备认证, clientId: {}, username: {}]", clientid, username);
  85 + log.debug("[handleAuth][设备认证请求: clientId={}, username={}]", clientId, username);
79 86  
80 87 // 参数校验
81   - if (!validateAuthParams(context, clientid, username, password)) {
  88 + if (StrUtil.hasEmpty(clientId, username, password)) {
  89 + log.info("[handleAuth][认证参数不完整: clientId={}, username={}]", clientId, username);
  90 + sendAuthResponse(context, RESULT_DENY, false, "认证参数不完整");
82 91 return;
83 92 }
84 93  
85 94 // 执行设备认证
86   - if (!performDeviceAuth(context, clientid, username, password)) {
87   - return;
  95 + boolean authResult = performDeviceAuth(clientId, username, password);
  96 + if (authResult) {
  97 + log.info("[handleAuth][设备认证成功: {}]", username);
  98 + sendAuthResponse(context, RESULT_ALLOW, false, null);
  99 + } else {
  100 + log.info("[handleAuth][设备认证失败: {}]", username);
  101 + sendAuthResponse(context, RESULT_DENY, false, DEVICE_AUTH_FAIL.getMsg());
88 102 }
89 103  
90   - log.debug("[authenticate][设备认证成功, clientId: {}, username: {}]", clientid, username);
91   - sendSuccessResponse(context, "认证成功");
92   -
93 104 } catch (Exception e) {
94   - log.error("[authenticate][设备认证异常, 详细信息: {}]", e.getMessage(), e);
95   - sendErrorResponse(context, INTERNAL_ERROR_STATUS_CODE, "认证服务异常");
  105 + log.error("[handleAuth][设备认证异常]", e);
  106 + sendAuthResponse(context, RESULT_IGNORE, false, "认证服务异常");
96 107 }
97 108 }
98 109  
99 110 /**
100   - * EMQX 客户端连接事件钩子
  111 + * EMQX 统一事件处理接口
  112 + * 根据 EMQX 官方 Webhook 设计,统一处理所有客户端事件
  113 + * 支持的事件类型:client.connected、client.disconnected 等
101 114 */
102   - public void connected(RoutingContext context) {
  115 + public void handleEvent(RoutingContext context) {
103 116 try {
104 117 // 解析请求体
105 118 JsonObject body = parseRequestBody(context);
... ... @@ -107,48 +120,52 @@ public class IotMqttHttpAuthHandler {
107 120 return;
108 121 }
109 122  
110   - String clientid = body.getString("clientid");
  123 + String event = body.getString("event");
111 124 String username = body.getString("username");
112   - Long timestamp = body.getLong("timestamp");
113 125  
114   - log.debug("[connected][设备连接, clientId: {}, username: {}, timestamp: {}]",
115   - clientid, username, timestamp);
  126 + log.debug("[handleEvent][收到事件: {} - {}]", event, username);
  127 +
  128 + // 根据事件类型进行分发处理
  129 + switch (event) {
  130 + case EVENT_CLIENT_CONNECTED:
  131 + handleClientConnected(body);
  132 + break;
  133 + case EVENT_CLIENT_DISCONNECTED:
  134 + handleClientDisconnected(body);
  135 + break;
  136 + default:
  137 + log.debug("[handleEvent][忽略事件: {}]", event);
  138 + break;
  139 + }
116 140  
117   - handleDeviceStateChange(username, true, "设备连接");
118   - sendSuccessResponse(context, "处理成功");
  141 + // EMQX Webhook 只需要200状态码,无需响应体
  142 + context.response().setStatusCode(SUCCESS_STATUS_CODE).end();
119 143  
120 144 } catch (Exception e) {
121   - log.error("[connected][处理设备连接事件失败, 详细信息: {}]", e.getMessage(), e);
122   - sendErrorResponse(context, INTERNAL_ERROR_STATUS_CODE, "处理失败");
  145 + log.error("[handleEvent][事件处理失败]", e);
  146 + // 即使处理失败,也返回200避免EMQX重试
  147 + context.response().setStatusCode(SUCCESS_STATUS_CODE).end();
123 148 }
124 149 }
125 150  
126 151 /**
127   - * EMQX 客户端断开连接事件钩子
  152 + * 处理客户端连接事件
128 153 */
129   - public void disconnected(RoutingContext context) {
130   - try {
131   - // 解析请求体
132   - JsonObject body = parseRequestBody(context);
133   - if (body == null) {
134   - return;
135   - }
136   -
137   - String clientid = body.getString("clientid");
138   - String username = body.getString("username");
139   - String reason = body.getString("reason");
140   - Long timestamp = body.getLong("timestamp");
141   -
142   - log.debug("[disconnected][设备断开连接, clientId: {}, username: {}, reason: {}, timestamp: {}]",
143   - clientid, username, reason, timestamp);
  154 + private void handleClientConnected(JsonObject body) {
  155 + String username = body.getString("username");
  156 + log.info("[handleClientConnected][设备上线: {}]", username);
  157 + handleDeviceStateChange(username, true);
  158 + }
144 159  
145   - handleDeviceStateChange(username, false, "设备断开连接,原因:" + reason);
146   - sendSuccessResponse(context, "处理成功");
  160 + /**
  161 + * 处理客户端断开连接事件
  162 + */
  163 + private void handleClientDisconnected(JsonObject body) {
  164 + String username = body.getString("username");
  165 + String reason = body.getString("reason");
147 166  
148   - } catch (Exception e) {
149   - log.error("[disconnected][处理设备断开连接事件失败, 详细信息: {}]", e.getMessage(), e);
150   - sendErrorResponse(context, INTERNAL_ERROR_STATUS_CODE, "处理失败");
151   - }
  167 + log.info("[handleClientDisconnected][设备下线: {} ({})]", username, reason);
  168 + handleDeviceStateChange(username, false);
152 169 }
153 170  
154 171 /**
... ... @@ -161,142 +178,113 @@ public class IotMqttHttpAuthHandler {
161 178 try {
162 179 JsonObject body = context.body().asJsonObject();
163 180 if (body == null) {
164   - log.warn("[parseRequestBody][请求体为空]");
165   - sendErrorResponse(context, BAD_REQUEST_STATUS_CODE, "请求体不能为空");
  181 + log.info("[parseRequestBody][请求体为空]");
  182 + sendAuthResponse(context, RESULT_IGNORE, false, "请求体不能为空");
166 183 return null;
167 184 }
168 185 return body;
169 186 } catch (Exception e) {
170 187 log.error("[parseRequestBody][解析请求体失败]", e);
171   - sendErrorResponse(context, BAD_REQUEST_STATUS_CODE, "请求体格式错误");
  188 + sendAuthResponse(context, RESULT_IGNORE, false, "请求体格式错误");
172 189 return null;
173 190 }
174 191 }
175 192  
176 193 /**
177   - * 验证认证参数
178   - *
179   - * @param context 路由上下文
180   - * @param clientid 客户端ID
181   - * @param username 用户名
182   - * @param password 密码
183   - * @return 验证是否通过
184   - */
185   - private boolean validateAuthParams(RoutingContext context, String clientid, String username, String password) {
186   - if (StrUtil.hasEmpty(clientid, username, password)) {
187   - log.warn("[validateAuthParams][认证参数不完整, clientId: {}, username: {}, password: {}]",
188   - clientid, username, StrUtil.isNotEmpty(password) ? "***" : "空");
189   - sendErrorResponse(context, BAD_REQUEST_STATUS_CODE, "认证参数不完整");
190   - return false;
191   - }
192   - return true;
193   - }
194   -
195   - /**
196 194 * 执行设备认证
197 195 *
198   - * @param context 路由上下文
199   - * @param clientid 客户端ID
  196 + * @param clientId 客户端ID
200 197 * @param username 用户名
201 198 * @param password 密码
202 199 * @return 认证是否成功
203 200 */
204   - private boolean performDeviceAuth(RoutingContext context, String clientid, String username, String password) {
  201 + private boolean performDeviceAuth(String clientId, String username, String password) {
205 202 try {
206   - IotDeviceCommonApi deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class);
207 203 CommonResult<Boolean> result = deviceApi.authDevice(new IotDeviceAuthReqDTO()
208   - .setClientId(clientid)
  204 + .setClientId(clientId)
209 205 .setUsername(username)
210 206 .setPassword(password));
211 207  
212 208 result.checkError();
213   - if (!BooleanUtil.isTrue(result.getData())) {
214   - log.warn("[performDeviceAuth][设备认证失败, clientId: {}, username: {}]", clientid, username);
215   - sendErrorResponse(context, UNAUTHORIZED_STATUS_CODE, DEVICE_AUTH_FAIL.getMsg());
216   - return false;
217   - }
218   - return true;
  209 + return BooleanUtil.isTrue(result.getData());
219 210 } catch (Exception e) {
220   - log.error("[performDeviceAuth][设备认证异常, clientId: {}, username: {}]", clientid, username, e);
221   - sendErrorResponse(context, INTERNAL_ERROR_STATUS_CODE, "认证服务异常");
222   - return false;
  211 + log.error("[performDeviceAuth][认证接口调用失败: {}]", username, e);
  212 + throw e;
223 213 }
224 214 }
225 215  
226 216 /**
227 217 * 处理设备状态变化
228 218 *
229   - * @param username 用户名
230   - * @param online 是否在线
231   - * @param actionDesc 操作描述
  219 + * @param username 用户名
  220 + * @param online 是否在线
232 221 */
233   - private void handleDeviceStateChange(String username, boolean online, String actionDesc) {
  222 + private void handleDeviceStateChange(String username, boolean online) {
234 223 if (StrUtil.isEmpty(username) || "undefined".equals(username)) {
235   - log.warn("[handleDeviceStateChange][用户名为空或'undefined', username: {}, action: {}]",
236   - username, actionDesc);
237 224 return;
238 225 }
239 226  
240 227 // 解析设备信息
241 228 IotDeviceAuthUtils.DeviceInfo deviceInfo = IotDeviceAuthUtils.parseUsername(username);
242 229 if (deviceInfo == null) {
243   - log.warn("[handleDeviceStateChange][无法从 username({}) 解析设备信息, action: {}]",
244   - username, actionDesc);
  230 + log.debug("[handleDeviceStateChange][跳过非设备连接: {}]", username);
245 231 return;
246 232 }
247 233  
248 234 try {
249   - // 从协议实例获取服务器 ID
250 235 String serverId = protocol.getServerId();
251 236 if (StrUtil.isEmpty(serverId)) {
252   - log.error("[handleDeviceStateChange][获取服务器ID失败, username: {}, action: {}]",
253   - username, actionDesc);
  237 + log.error("[handleDeviceStateChange][获取服务器ID失败]");
254 238 return;
255 239 }
256 240  
257 241 // 构建设备状态消息
258   - IotDeviceMessageService deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class);
259   - IotDeviceMessage message;
260   - if (online) {
261   - message = IotDeviceMessage.buildStateOnline();
262   - log.debug("[handleDeviceStateChange][发送设备上线消息, username: {}, serverId: {}]",
263   - username, serverId);
264   - } else {
265   - message = IotDeviceMessage.buildStateOffline();
266   - log.debug("[handleDeviceStateChange][发送设备下线消息, username: {}, serverId: {}]",
267   - username, serverId);
268   - }
  242 + IotDeviceMessage message = online ? IotDeviceMessage.buildStateOnline()
  243 + : IotDeviceMessage.buildStateOffline();
269 244  
270 245 // 发送消息到消息总线
271 246 deviceMessageService.sendDeviceMessage(message,
272 247 deviceInfo.getProductKey(), deviceInfo.getDeviceName(), serverId);
273 248  
274   - log.info("[handleDeviceStateChange][{}处理成功, productKey: {}, deviceName: {}, serverId: {}]",
275   - actionDesc, deviceInfo.getProductKey(), deviceInfo.getDeviceName(), serverId);
  249 + log.info("[handleDeviceStateChange][设备状态更新: {}/{} -> {}]",
  250 + deviceInfo.getProductKey(), deviceInfo.getDeviceName(),
  251 + online ? "在线" : "离线");
276 252  
277 253 } catch (Exception e) {
278   - log.error("[handleDeviceStateChange][发送设备状态消息失败, username: {}, online: {}, action: {}]",
279   - username, online, actionDesc, e);
  254 + log.error("[handleDeviceStateChange][发送设备状态消息失败: {}]", username, e);
280 255 }
281 256 }
282 257  
283 258 /**
284   - * 发送成功响应
  259 + * 发送 EMQX 认证响应
  260 + * 根据 EMQX 官方文档要求,必须返回 JSON 格式响应
  261 + *
  262 + * @param context 路由上下文
  263 + * @param result 认证结果:allow、deny、ignore
  264 + * @param isSuperuser 是否超级用户
  265 + * @param message 日志消息(仅用于日志记录,不返回给EMQX)
285 266 */
286   - private void sendSuccessResponse(RoutingContext context, String message) {
  267 + private void sendAuthResponse(RoutingContext context, String result, boolean isSuperuser, String message) {
  268 + // 构建符合 EMQX 官方规范的响应
  269 + JsonObject response = new JsonObject()
  270 + .put("result", result)
  271 + .put("is_superuser", isSuperuser);
  272 +
  273 + // 可以根据业务需求添加客户端属性
  274 + // response.put("client_attrs", new JsonObject().put("role", "device"));
  275 +
  276 + // 可以添加认证过期时间(可选)
  277 + // response.put("expire_at", System.currentTimeMillis() / 1000 + 3600);
  278 +
  279 + // 记录详细的响应日志(message仅用于日志,不返回给EMQX)
  280 + if (StrUtil.isNotEmpty(message)) {
  281 + log.debug("[sendAuthResponse][响应详情: result={}, message={}]", result, message);
  282 + }
  283 +
287 284 context.response()
288 285 .setStatusCode(SUCCESS_STATUS_CODE)
289   - .putHeader("Content-Type", "text/plain; charset=utf-8")
290   - .end(message);
  286 + .putHeader("Content-Type", "application/json; charset=utf-8")
  287 + .end(response.encode());
291 288 }
292 289  
293   - /**
294   - * 发送错误响应
295   - */
296   - private void sendErrorResponse(RoutingContext context, int statusCode, String message) {
297   - context.response()
298   - .setStatusCode(statusCode)
299   - .putHeader("Content-Type", "text/plain; charset=utf-8")
300   - .end(message);
301   - }
302 290 }
303 291 \ No newline at end of file
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/util/IotMqttTopicUtils.java
... ... @@ -21,22 +21,26 @@ public final class IotMqttTopicUtils {
21 21 */
22 22 private static final String SERVICE_TOPIC_PREFIX = "/thing/";
23 23  
24   - // ========== MQTT 认证路径常量 ==========
  24 + // ========== MQTT HTTP 接口路径常量 ==========
25 25  
26 26 /**
27   - * MQTT 认证路径
  27 + * MQTT 认证接口路径
  28 + * 对应 EMQX HTTP 认证插件的认证请求接口
28 29 */
29   - public static final String MQTT_AUTH_AUTHENTICATE_PATH = "/mqtt/auth/authenticate";
  30 + public static final String MQTT_AUTH_PATH = "/mqtt/auth";
30 31  
31 32 /**
32   - * MQTT 连接事件路径
  33 + * MQTT 统一事件处理接口路径
  34 + * 对应 EMQX Webhook 的统一事件处理接口,支持所有客户端事件
  35 + * 包括:client.connected、client.disconnected、message.publish 等
33 36 */
34   - public static final String MQTT_AUTH_CONNECTED_PATH = "/mqtt/auth/connected";
  37 + public static final String MQTT_EVENT_PATH = "/mqtt/event";
35 38  
36 39 /**
37   - * MQTT 断开事件路径
  40 + * MQTT 授权接口路径(预留)
  41 + * 对应 EMQX HTTP 授权插件的授权检查接口
38 42 */
39   - public static final String MQTT_AUTH_DISCONNECTED_PATH = "/mqtt/auth/disconnected";
  43 + public static final String MQTT_AUTHZ_PATH = "/mqtt/authz";
40 44  
41 45 // ========== 工具方法 ==========
42 46  
... ...