package cn.iocoder.yudao.module.iot.gateway.util; import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; /** * IoT 网关 MQTT 主题工具类 *
* 用于统一管理 MQTT 协议中的主题常量,基于 Alink 协议规范 * * @author 芋道源码 */ public final class IotMqttTopicUtils { // ========== 静态常量 ========== /** * 系统主题前缀 */ private static final String SYS_TOPIC_PREFIX = "/sys/"; /** * 回复主题后缀 */ private static final String REPLY_TOPIC_SUFFIX = "_reply"; // ========== MQTT HTTP 接口路径常量 ========== /** * MQTT 认证接口路径 * 对应 EMQX HTTP 认证插件的认证请求接口 */ public static final String MQTT_AUTH_PATH = "/mqtt/auth"; /** * MQTT 统一事件处理接口路径 * 对应 EMQX Webhook 的统一事件处理接口,支持所有客户端事件 * 包括:client.connected、client.disconnected、message.publish 等 */ public static final String MQTT_EVENT_PATH = "/mqtt/event"; /** * MQTT ACL 接口路径 * 对应 EMQX HTTP ACL 插件的 ACL 请求接口 */ public static final String MQTT_ACL_PATH = "/mqtt/acl"; // ========== 消息方法标准化 ========== /** * 标准化设备回复消息的 method *
* MQTT 协议中,设备回复下行指令时,topic 和 method 会携带 _reply 后缀 * (如 thing.service.invoke_reply)。平台内部统一使用基础 method(如 thing.service.invoke), * 通过 {@link IotDeviceMessage#getCode()} 非空来识别回复消息。 *
* 此方法剥离 _reply 后缀,并确保 code 字段被设置。 * * @param message 设备消息 */ public static void normalizeReplyMethod(IotDeviceMessage message) { String method = message.getMethod(); if (!StrUtil.endWith(method, REPLY_TOPIC_SUFFIX)) { return; } // 1. 剥离 _reply 后缀 message.setMethod(method.substring(0, method.length() - REPLY_TOPIC_SUFFIX.length())); // 2. 确保 code 被设置,使 isReplyMessage() 能正确识别 if (message.getCode() == null) { message.setCode(GlobalErrorCodeConstants.SUCCESS.getCode()); } } // ========== 工具方法 ========== /** * 根据消息方法构建对应的主题 * * @param method 消息方法,例如 thing.property.post * @param productKey 产品 Key * @param deviceName 设备名称 * @param isReply 是否为回复消息 * @return 完整的主题路径 */ public static String buildTopicByMethod(String method, String productKey, String deviceName, boolean isReply) { if (StrUtil.isBlank(method)) { return null; } // 1. 将点分隔符转换为斜杠 String topicSuffix = method.replace('.', '/'); // 2. 对于回复消息,添加 _reply 后缀 if (isReply) { topicSuffix += REPLY_TOPIC_SUFFIX; } // 3. 构建完整主题 return SYS_TOPIC_PREFIX + productKey + "/" + deviceName + "/" + topicSuffix; } /** * 校验主题是否允许订阅 *
* 规则:主题必须以 /sys/{productKey}/{deviceName}/ 开头, * 或者是通配符形式 /sys/{productKey}/{deviceName}/# * * @param topic 订阅的主题 * @param productKey 产品 Key * @param deviceName 设备名称 * @return 是否允许订阅 */ public static boolean isTopicSubscribeAllowed(String topic, String productKey, String deviceName) { if (!StrUtil.isAllNotBlank(topic, productKey, deviceName)) { return false; } // 构建设备主题前缀 String deviceTopicPrefix = SYS_TOPIC_PREFIX + productKey + "/" + deviceName + "/"; // 主题必须以设备前缀开头,或者是设备前缀的通配符形式 return topic.startsWith(deviceTopicPrefix) || topic.equals(SYS_TOPIC_PREFIX + productKey + "/" + deviceName + "/#"); } /** * 校验主题是否允许发布 *
* 规则:主题必须以 /sys/{productKey}/{deviceName}/ 开头,且不允许包含通配符(+/#)。 * * @param topic 发布的主题 * @param productKey 产品 Key * @param deviceName 设备名称 * @return 是否允许发布 */ public static boolean isTopicPublishAllowed(String topic, String productKey, String deviceName) { if (!StrUtil.isAllNotBlank(topic, productKey, deviceName)) { return false; } // MQTT publish topic 不允许包含通配符,但这里做一次兜底校验 if (topic.contains("#") || topic.contains("+")) { return false; } String deviceTopicPrefix = SYS_TOPIC_PREFIX + productKey + "/" + deviceName + "/"; return topic.startsWith(deviceTopicPrefix); } }