2026-06-24 f4bd1f3c89d906131495a0aca5aaf82966378510
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
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 主题工具类
 * <p>
 * 用于统一管理 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
     * <p>
     * MQTT 协议中,设备回复下行指令时,topic 和 method 会携带 _reply 后缀
     * (如 thing.service.invoke_reply)。平台内部统一使用基础 method(如 thing.service.invoke),
     * 通过 {@link IotDeviceMessage#getCode()} 非空来识别回复消息。
     * <p>
     * 此方法剥离 _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;
    }
 
    /**
     * 校验主题是否允许订阅
     * <p>
     * 规则:主题必须以 /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 + "/#");
    }
 
    /**
     * 校验主题是否允许发布
     * <p>
     * 规则:主题必须以 /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);
    }
 
}