Commit 853afd218479ee0630cb8c52a399ce4e133ee258

Authored by 芋道源码
Committed by Gitee
2 parents 17ec78f6 28a30d4b

!1501 IOT:同步最新代码

Merge pull request !1501 from 芋道源码/feature/iot
Showing 33 changed files with 3059 additions and 47 deletions
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmTaskServiceImpl.java
... ... @@ -116,6 +116,7 @@ public class BpmTaskServiceImpl implements BpmTaskService {
116 116 .taskAssignee(String.valueOf(userId)) // 分配给自己
117 117 .active()
118 118 .includeProcessVariables()
  119 + .taskTenantId(FlowableUtils.getTenantId())
119 120 .orderByTaskCreateTime().desc(); // 创建时间倒序
120 121 if (StrUtil.isNotBlank(pageVO.getName())) {
121 122 taskQuery.taskNameLike("%" + pageVO.getName() + "%");
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDevicePageReqVO.java
... ... @@ -31,4 +31,7 @@ public class IotDevicePageReqVO extends PageParam {
31 31 @Schema(description = "设备分组编号", example = "1024")
32 32 private Long groupId;
33 33  
  34 + @Schema(description = "网关设备 ID", example = "16380")
  35 + private Long gatewayId;
  36 +
34 37 }
35 38 \ No newline at end of file
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/sink/IotDataSinkPageReqVO.java
... ... @@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.sink;
3 3 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
4 4 import cn.iocoder.yudao.framework.common.pojo.PageParam;
5 5 import cn.iocoder.yudao.framework.common.validation.InEnum;
  6 +import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum;
6 7 import io.swagger.v3.oas.annotations.media.Schema;
7 8 import lombok.Data;
8 9 import org.springframework.format.annotation.DateTimeFormat;
... ... @@ -22,6 +23,10 @@ public class IotDataSinkPageReqVO extends PageParam {
22 23 @InEnum(CommonStatusEnum.class)
23 24 private Integer status;
24 25  
  26 + @Schema(description = "数据目的类型", example = "1")
  27 + @InEnum(IotDataSinkTypeEnum.class)
  28 + private Integer type;
  29 +
25 30 @Schema(description = "创建时间")
26 31 @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
27 32 private LocalDateTime[] createTime;
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/IotSceneRuleDO.java
... ... @@ -21,6 +21,7 @@ import lombok.Builder;
21 21 import lombok.Data;
22 22 import lombok.NoArgsConstructor;
23 23  
  24 +import java.time.LocalDateTime;
24 25 import java.util.List;
25 26  
26 27 /**
... ... @@ -57,6 +58,11 @@ public class IotSceneRuleDO extends TenantBaseDO {
57 58 private Integer status;
58 59  
59 60 /**
  61 + * 最后触发时间
  62 + */
  63 + private LocalDateTime lastTriggerTime;
  64 +
  65 + /**
60 66 * 场景定义配置
61 67 */
62 68 @TableField(typeHandler = JacksonTypeHandler.class)
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotDataSinkTcpConfig.java
... ... @@ -11,6 +11,35 @@ import lombok.Data;
11 11 public class IotDataSinkTcpConfig extends IotAbstractDataSinkConfig {
12 12  
13 13 /**
  14 + * 默认连接超时时间(毫秒)
  15 + */
  16 + public static final int DEFAULT_CONNECT_TIMEOUT_MS = 5000;
  17 + /**
  18 + * 默认读取超时时间(毫秒)
  19 + */
  20 + public static final int DEFAULT_READ_TIMEOUT_MS = 10000;
  21 + /**
  22 + * 默认是否启用 SSL
  23 + */
  24 + public static final boolean DEFAULT_SSL = false;
  25 + /**
  26 + * 默认数据格式
  27 + */
  28 + public static final String DEFAULT_DATA_FORMAT = "JSON";
  29 + /**
  30 + * 默认心跳间隔时间(毫秒)
  31 + */
  32 + public static final long DEFAULT_HEARTBEAT_INTERVAL_MS = 30000L;
  33 + /**
  34 + * 默认重连间隔时间(毫秒)
  35 + */
  36 + public static final long DEFAULT_RECONNECT_INTERVAL_MS = 5000L;
  37 + /**
  38 + * 默认最大重连次数
  39 + */
  40 + public static final int DEFAULT_MAX_RECONNECT_ATTEMPTS = 3;
  41 +
  42 + /**
14 43 * TCP 服务器地址
15 44 */
16 45 private String host;
... ... @@ -23,17 +52,17 @@ public class IotDataSinkTcpConfig extends IotAbstractDataSinkConfig {
23 52 /**
24 53 * 连接超时时间(毫秒)
25 54 */
26   - private Integer connectTimeoutMs = 5000;
  55 + private Integer connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS;
27 56  
28 57 /**
29 58 * 读取超时时间(毫秒)
30 59 */
31   - private Integer readTimeoutMs = 10000;
  60 + private Integer readTimeoutMs = DEFAULT_READ_TIMEOUT_MS;
32 61  
33 62 /**
34 63 * 是否启用 SSL
35 64 */
36   - private Boolean ssl = false;
  65 + private Boolean ssl = DEFAULT_SSL;
37 66  
38 67 /**
39 68 * SSL 证书路径(当 ssl=true 时需要)
... ... @@ -43,21 +72,21 @@ public class IotDataSinkTcpConfig extends IotAbstractDataSinkConfig {
43 72 /**
44 73 * 数据格式:JSON 或 BINARY
45 74 */
46   - private String dataFormat = "JSON";
  75 + private String dataFormat = DEFAULT_DATA_FORMAT;
47 76  
48 77 /**
49 78 * 心跳间隔时间(毫秒),0 表示不启用心跳
50 79 */
51   - private Long heartbeatIntervalMs = 30000L;
  80 + private Long heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS;
52 81  
53 82 /**
54 83 * 重连间隔时间(毫秒)
55 84 */
56   - private Long reconnectIntervalMs = 5000L;
  85 + private Long reconnectIntervalMs = DEFAULT_RECONNECT_INTERVAL_MS;
57 86  
58 87 /**
59 88 * 最大重连次数
60 89 */
61   - private Integer maxReconnectAttempts = 3;
  90 + private Integer maxReconnectAttempts = DEFAULT_MAX_RECONNECT_ATTEMPTS;
62 91  
63 92 }
64 93 \ No newline at end of file
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotDataSinkWebSocketConfig.java
... ... @@ -14,6 +14,51 @@ import lombok.Data;
14 14 public class IotDataSinkWebSocketConfig extends IotAbstractDataSinkConfig {
15 15  
16 16 /**
  17 + * 默认连接超时时间(毫秒)
  18 + */
  19 + public static final int DEFAULT_CONNECT_TIMEOUT_MS = 5000;
  20 + /**
  21 + * 默认发送超时时间(毫秒)
  22 + */
  23 + public static final int DEFAULT_SEND_TIMEOUT_MS = 10000;
  24 + /**
  25 + * 默认心跳间隔时间(毫秒)
  26 + */
  27 + public static final long DEFAULT_HEARTBEAT_INTERVAL_MS = 30000L;
  28 + /**
  29 + * 默认心跳消息内容
  30 + */
  31 + public static final String DEFAULT_HEARTBEAT_MESSAGE = "{\"type\":\"heartbeat\"}";
  32 + /**
  33 + * 默认是否启用 SSL 证书验证
  34 + */
  35 + public static final boolean DEFAULT_VERIFY_SSL_CERT = true;
  36 + /**
  37 + * 默认数据格式
  38 + */
  39 + public static final String DEFAULT_DATA_FORMAT = "JSON";
  40 + /**
  41 + * 默认重连间隔时间(毫秒)
  42 + */
  43 + public static final long DEFAULT_RECONNECT_INTERVAL_MS = 5000L;
  44 + /**
  45 + * 默认最大重连次数
  46 + */
  47 + public static final int DEFAULT_MAX_RECONNECT_ATTEMPTS = 3;
  48 + /**
  49 + * 默认是否启用压缩
  50 + */
  51 + public static final boolean DEFAULT_ENABLE_COMPRESSION = false;
  52 + /**
  53 + * 默认消息发送重试次数
  54 + */
  55 + public static final int DEFAULT_SEND_RETRY_COUNT = 1;
  56 + /**
  57 + * 默认消息发送重试间隔(毫秒)
  58 + */
  59 + public static final long DEFAULT_SEND_RETRY_INTERVAL_MS = 1000L;
  60 +
  61 + /**
17 62 * WebSocket 服务器地址
18 63 * 例如:ws://localhost:8080/ws 或 wss://example.com/ws
19 64 */
... ... @@ -22,22 +67,22 @@ public class IotDataSinkWebSocketConfig extends IotAbstractDataSinkConfig {
22 67 /**
23 68 * 连接超时时间(毫秒)
24 69 */
25   - private Integer connectTimeoutMs = 5000;
  70 + private Integer connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS;
26 71  
27 72 /**
28 73 * 发送超时时间(毫秒)
29 74 */
30   - private Integer sendTimeoutMs = 10000;
  75 + private Integer sendTimeoutMs = DEFAULT_SEND_TIMEOUT_MS;
31 76  
32 77 /**
33 78 * 心跳间隔时间(毫秒),0 表示不启用心跳
34 79 */
35   - private Long heartbeatIntervalMs = 30000L;
  80 + private Long heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS;
36 81  
37 82 /**
38 83 * 心跳消息内容(JSON 格式)
39 84 */
40   - private String heartbeatMessage = "{\"type\":\"heartbeat\"}";
  85 + private String heartbeatMessage = DEFAULT_HEARTBEAT_MESSAGE;
41 86  
42 87 /**
43 88 * 子协议列表(逗号分隔)
... ... @@ -52,36 +97,36 @@ public class IotDataSinkWebSocketConfig extends IotAbstractDataSinkConfig {
52 97 /**
53 98 * 是否启用 SSL 证书验证(仅对 wss:// 生效)
54 99 */
55   - private Boolean verifySslCert = true;
  100 + private Boolean verifySslCert = DEFAULT_VERIFY_SSL_CERT;
56 101  
57 102 /**
58 103 * 数据格式:JSON 或 TEXT
59 104 */
60   - private String dataFormat = "JSON";
  105 + private String dataFormat = DEFAULT_DATA_FORMAT;
61 106  
62 107 /**
63 108 * 重连间隔时间(毫秒)
64 109 */
65   - private Long reconnectIntervalMs = 5000L;
  110 + private Long reconnectIntervalMs = DEFAULT_RECONNECT_INTERVAL_MS;
66 111  
67 112 /**
68 113 * 最大重连次数
69 114 */
70   - private Integer maxReconnectAttempts = 3;
  115 + private Integer maxReconnectAttempts = DEFAULT_MAX_RECONNECT_ATTEMPTS;
71 116  
72 117 /**
73 118 * 是否启用压缩
74 119 */
75   - private Boolean enableCompression = false;
  120 + private Boolean enableCompression = DEFAULT_ENABLE_COMPRESSION;
76 121  
77 122 /**
78 123 * 消息发送重试次数
79 124 */
80   - private Integer sendRetryCount = 1;
  125 + private Integer sendRetryCount = DEFAULT_SEND_RETRY_COUNT;
81 126  
82 127 /**
83 128 * 消息发送重试间隔(毫秒)
84 129 */
85   - private Long sendRetryIntervalMs = 1000L;
  130 + private Long sendRetryIntervalMs = DEFAULT_SEND_RETRY_INTERVAL_MS;
86 131  
87 132 }
88 133 \ No newline at end of file
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/device/IotDeviceMapper.java
... ... @@ -31,6 +31,7 @@ public interface IotDeviceMapper extends BaseMapperX<IotDeviceDO> {
31 31 .eqIfPresent(IotDeviceDO::getDeviceType, reqVO.getDeviceType())
32 32 .likeIfPresent(IotDeviceDO::getNickname, reqVO.getNickname())
33 33 .eqIfPresent(IotDeviceDO::getState, reqVO.getStatus())
  34 + .eqIfPresent(IotDeviceDO::getGatewayId, reqVO.getGatewayId())
34 35 .apply(ObjectUtil.isNotNull(reqVO.getGroupId()), "FIND_IN_SET(" + reqVO.getGroupId() + ",group_ids) > 0")
35 36 .orderByDesc(IotDeviceDO::getId));
36 37 }
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataRuleMapper.java
... ... @@ -35,4 +35,8 @@ public interface IotDataRuleMapper extends BaseMapperX<IotDataRuleDO> {
35 35 return selectList(IotDataRuleDO::getStatus, status);
36 36 }
37 37  
  38 + default IotDataRuleDO selectByName(String name) {
  39 + return selectOne(IotDataRuleDO::getName, name);
  40 + }
  41 +
38 42 }
39 43 \ No newline at end of file
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataSinkMapper.java
... ... @@ -21,6 +21,7 @@ public interface IotDataSinkMapper extends BaseMapperX<IotDataSinkDO> {
21 21 return selectPage(reqVO, new LambdaQueryWrapperX<IotDataSinkDO>()
22 22 .likeIfPresent(IotDataSinkDO::getName, reqVO.getName())
23 23 .eqIfPresent(IotDataSinkDO::getStatus, reqVO.getStatus())
  24 + .eqIfPresent(IotDataSinkDO::getType, reqVO.getType())
24 25 .betweenIfPresent(IotDataSinkDO::getCreateTime, reqVO.getCreateTime())
25 26 .orderByDesc(IotDataSinkDO::getId));
26 27 }
... ... @@ -29,4 +30,8 @@ public interface IotDataSinkMapper extends BaseMapperX&lt;IotDataSinkDO&gt; {
29 30 return selectList(IotDataSinkDO::getStatus, status);
30 31 }
31 32  
  33 + default IotDataSinkDO selectByName(String name) {
  34 + return selectOne(IotDataSinkDO::getName, name);
  35 + }
  36 +
32 37 }
33 38 \ No newline at end of file
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java
... ... @@ -65,10 +65,12 @@ public interface ErrorCodeConstants {
65 65  
66 66 // ========== IoT 数据流转规则 1-050-010-000 ==========
67 67 ErrorCode DATA_RULE_NOT_EXISTS = new ErrorCode(1_050_010_000, "数据流转规则不存在");
  68 + ErrorCode DATA_RULE_NAME_EXISTS = new ErrorCode(1_050_010_001, "数据流转规则名称已存在");
68 69  
69 70 // ========== IoT 数据流转目的 1-050-011-000 ==========
70 71 ErrorCode DATA_SINK_NOT_EXISTS = new ErrorCode(1_050_011_000, "数据桥梁不存在");
71 72 ErrorCode DATA_SINK_DELETE_FAIL_USED_BY_RULE = new ErrorCode(1_050_011_001, "数据流转目的正在被数据流转规则使用,无法删除");
  73 + ErrorCode DATA_SINK_NAME_EXISTS = new ErrorCode(1_050_011_002, "数据流转目的名称已存在");
72 74  
73 75 // ========== IoT 场景联动 1-050-012-000 ==========
74 76 ErrorCode RULE_SCENE_NOT_EXISTS = new ErrorCode(1_050_012_000, "场景联动不存在");
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotDataSinkTypeEnum.java
... ... @@ -16,8 +16,8 @@ import java.util.Arrays;
16 16 public enum IotDataSinkTypeEnum implements ArrayValuable<Integer> {
17 17  
18 18 HTTP(1, "HTTP"),
19   - TCP(2, "TCP"), // TODO @puhui999:待实现;
20   - WEBSOCKET(3, "WebSocket"), // TODO @puhui999:待实现;
  19 + TCP(2, "TCP"),
  20 + WEBSOCKET(3, "WebSocket"),
21 21  
22 22 MQTT(10, "MQTT"), // TODO 待实现;
23 23  
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceServiceImpl.java
... ... @@ -382,7 +382,7 @@ public class IotDeviceServiceImpl implements IotDeviceService {
382 382 return;
383 383 }
384 384 // 2.2.2 如果存在,判断是否允许更新
385   - if (updateSupport) {
  385 + if (!updateSupport) {
386 386 throw exception(DEVICE_KEY_EXISTS);
387 387 }
388 388 updateDevice(new IotDeviceSaveReqVO().setId(existDevice.getId())
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImpl.java
1 1 package cn.iocoder.yudao.module.iot.service.device.property;
2 2  
3 3 import cn.hutool.core.collection.CollUtil;
  4 +import cn.hutool.core.convert.Convert;
4 5 import cn.hutool.core.date.LocalDateTimeUtil;
5 6 import cn.hutool.core.map.MapUtil;
6 7 import cn.hutool.core.util.StrUtil;
... ... @@ -145,6 +146,12 @@ public class IotDevicePropertyServiceImpl implements IotDevicePropertyService {
145 146 IotDataSpecsDataTypeEnum.STRUCT.getDataType(), IotDataSpecsDataTypeEnum.ARRAY.getDataType())) {
146 147 // 特殊:STRUCT 和 ARRAY 类型,在 TDengine 里,有没对应数据类型,只能通过 JSON 来存储
147 148 properties.put((String) key, JsonUtils.toJsonString(value));
  149 + } else if (IotDataSpecsDataTypeEnum.DOUBLE.getDataType().equals(thingModel.getProperty().getDataType())) {
  150 + properties.put((String) key, Convert.toDouble(value));
  151 + } else if (IotDataSpecsDataTypeEnum.FLOAT.getDataType().equals(thingModel.getProperty().getDataType())) {
  152 + properties.put((String) key, Convert.toFloat(value));
  153 + } else if (IotDataSpecsDataTypeEnum.BOOL.getDataType().equals(thingModel.getProperty().getDataType())) {
  154 + properties.put((String) key, Convert.toByte(value));
148 155 } else {
149 156 properties.put((String) key, value);
150 157 }
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImpl.java
... ... @@ -32,6 +32,7 @@ import java.util.*;
32 32  
33 33 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
34 34 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
  35 +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DATA_RULE_NAME_EXISTS;
35 36 import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DATA_RULE_NOT_EXISTS;
36 37  
37 38 /**
... ... @@ -62,6 +63,8 @@ public class IotDataRuleServiceImpl implements IotDataRuleService {
62 63 @Override
63 64 @CacheEvict(value = RedisKeyConstants.DATA_RULE_LIST, allEntries = true)
64 65 public Long createDataRule(IotDataRuleSaveReqVO createReqVO) {
  66 + // 校验名称唯一
  67 + validateDataRuleNameUnique(null, createReqVO.getName());
65 68 // 校验数据源配置和数据目的
66 69 validateDataRuleConfig(createReqVO);
67 70 // 新增
... ... @@ -75,6 +78,8 @@ public class IotDataRuleServiceImpl implements IotDataRuleService {
75 78 public void updateDataRule(IotDataRuleSaveReqVO updateReqVO) {
76 79 // 校验存在
77 80 validateDataRuleExists(updateReqVO.getId());
  81 + // 校验名称唯一
  82 + validateDataRuleNameUnique(updateReqVO.getId(), updateReqVO.getName());
78 83 // 校验数据源配置和数据目的
79 84 validateDataRuleConfig(updateReqVO);
80 85  
... ... @@ -99,6 +104,29 @@ public class IotDataRuleServiceImpl implements IotDataRuleService {
99 104 }
100 105  
101 106 /**
  107 + * 校验数据流转规则名称唯一性
  108 + *
  109 + * @param id 数据流转规则编号(用于更新时排除自身)
  110 + * @param name 数据流转规则名称
  111 + */
  112 + private void validateDataRuleNameUnique(Long id, String name) {
  113 + if (StrUtil.isBlank(name)) {
  114 + return;
  115 + }
  116 + IotDataRuleDO dataRule = dataRuleMapper.selectByName(name);
  117 + if (dataRule == null) {
  118 + return;
  119 + }
  120 + // 如果 id 为空,说明不用比较是否为相同 id 的规则
  121 + if (id == null) {
  122 + throw exception(DATA_RULE_NAME_EXISTS);
  123 + }
  124 + if (!dataRule.getId().equals(id)) {
  125 + throw exception(DATA_RULE_NAME_EXISTS);
  126 + }
  127 + }
  128 +
  129 + /**
102 130 * 校验数据流转规则配置
103 131 *
104 132 * @param reqVO 数据流转规则保存请求VO
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataSinkServiceImpl.java
1 1 package cn.iocoder.yudao.module.iot.service.rule.data;
2 2  
3 3 import cn.hutool.core.collection.CollUtil;
  4 +import cn.hutool.core.util.StrUtil;
4 5 import cn.iocoder.yudao.framework.common.pojo.PageResult;
5 6 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
6 7 import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.sink.IotDataSinkPageReqVO;
... ... @@ -19,6 +20,7 @@ import java.util.List;
19 20  
20 21 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
21 22 import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DATA_SINK_DELETE_FAIL_USED_BY_RULE;
  23 +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DATA_SINK_NAME_EXISTS;
22 24 import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DATA_SINK_NOT_EXISTS;
23 25  
24 26 /**
... ... @@ -39,6 +41,9 @@ public class IotDataSinkServiceImpl implements IotDataSinkService {
39 41  
40 42 @Override
41 43 public Long createDataSink(IotDataSinkSaveReqVO createReqVO) {
  44 + // 校验名称唯一
  45 + validateDataSinkNameUnique(null, createReqVO.getName());
  46 + // 新增
42 47 IotDataSinkDO dataBridge = BeanUtils.toBean(createReqVO, IotDataSinkDO.class);
43 48 dataSinkMapper.insert(dataBridge);
44 49 return dataBridge.getId();
... ... @@ -48,6 +53,8 @@ public class IotDataSinkServiceImpl implements IotDataSinkService {
48 53 public void updateDataSink(IotDataSinkSaveReqVO updateReqVO) {
49 54 // 校验存在
50 55 validateDataBridgeExists(updateReqVO.getId());
  56 + // 校验名称唯一
  57 + validateDataSinkNameUnique(updateReqVO.getId(), updateReqVO.getName());
51 58 // 更新
52 59 IotDataSinkDO updateObj = BeanUtils.toBean(updateReqVO, IotDataSinkDO.class);
53 60 dataSinkMapper.updateById(updateObj);
... ... @@ -71,6 +78,29 @@ public class IotDataSinkServiceImpl implements IotDataSinkService {
71 78 }
72 79 }
73 80  
  81 + /**
  82 + * 校验数据流转目的名称唯一性
  83 + *
  84 + * @param id 数据流转目的编号(用于更新时排除自身)
  85 + * @param name 数据流转目的名称
  86 + */
  87 + private void validateDataSinkNameUnique(Long id, String name) {
  88 + if (StrUtil.isBlank(name)) {
  89 + return;
  90 + }
  91 + IotDataSinkDO dataSink = dataSinkMapper.selectByName(name);
  92 + if (dataSink == null) {
  93 + return;
  94 + }
  95 + // 如果 id 为空,说明不用比较是否为相同 id 的目的
  96 + if (id == null) {
  97 + throw exception(DATA_SINK_NAME_EXISTS);
  98 + }
  99 + if (!dataSink.getId().equals(id)) {
  100 + throw exception(DATA_SINK_NAME_EXISTS);
  101 + }
  102 + }
  103 +
74 104 @Override
75 105 public IotDataSinkDO getDataSink(Long id) {
76 106 return dataSinkMapper.selectById(id);
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotTcpDataRuleAction.java
... ... @@ -7,8 +7,6 @@ import cn.iocoder.yudao.module.iot.service.rule.data.action.tcp.IotTcpClient;
7 7 import lombok.extern.slf4j.Slf4j;
8 8 import org.springframework.stereotype.Component;
9 9  
10   -import java.time.Duration;
11   -
12 10 /**
13 11 * TCP 的 {@link IotDataRuleAction} 实现类
14 12 * <p>
... ... @@ -23,9 +21,6 @@ import java.time.Duration;
23 21 public class IotTcpDataRuleAction extends
24 22 IotDataRuleCacheableAction<IotDataSinkTcpConfig, IotTcpClient> {
25 23  
26   - private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
27   - private static final Duration SEND_TIMEOUT = Duration.ofSeconds(10);
28   -
29 24 @Override
30 25 public Integer getType() {
31 26 return IotDataSinkTypeEnum.TCP.getType();
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotWebSocketDataRuleAction.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.service.rule.data.action;
  2 +
  3 +import cn.hutool.core.util.StrUtil;
  4 +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
  5 +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkWebSocketConfig;
  6 +import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum;
  7 +import cn.iocoder.yudao.module.iot.service.rule.data.action.websocket.IotWebSocketClient;
  8 +import lombok.extern.slf4j.Slf4j;
  9 +import org.springframework.stereotype.Component;
  10 +
  11 +/**
  12 + * WebSocket 的 {@link IotDataRuleAction} 实现类
  13 + * <p>
  14 + * 负责将设备消息发送到外部 WebSocket 服务器
  15 + * 支持 ws:// 和 wss:// 协议,支持 JSON 和 TEXT 数据格式
  16 + * 使用连接池管理 WebSocket 连接,提高性能和资源利用率
  17 + *
  18 + * @author HUIHUI
  19 + */
  20 +@Component
  21 +@Slf4j
  22 +public class IotWebSocketDataRuleAction extends
  23 + IotDataRuleCacheableAction<IotDataSinkWebSocketConfig, IotWebSocketClient> {
  24 +
  25 + @Override
  26 + public Integer getType() {
  27 + return IotDataSinkTypeEnum.WEBSOCKET.getType();
  28 + }
  29 +
  30 + @Override
  31 + protected IotWebSocketClient initProducer(IotDataSinkWebSocketConfig config) throws Exception {
  32 + // 1. 参数校验
  33 + if (StrUtil.isBlank(config.getServerUrl())) {
  34 + throw new IllegalArgumentException("WebSocket 服务器地址不能为空");
  35 + }
  36 + if (!StrUtil.startWithAny(config.getServerUrl(), "ws://", "wss://")) {
  37 + throw new IllegalArgumentException("WebSocket 服务器地址必须以 ws:// 或 wss:// 开头");
  38 + }
  39 +
  40 + // 2.1 创建 WebSocket 客户端
  41 + IotWebSocketClient webSocketClient = new IotWebSocketClient(
  42 + config.getServerUrl(),
  43 + config.getConnectTimeoutMs(),
  44 + config.getSendTimeoutMs(),
  45 + config.getDataFormat()
  46 + );
  47 + // 2.2 连接服务器
  48 + webSocketClient.connect();
  49 + log.info("[initProducer][WebSocket 客户端创建并连接成功,服务器: {},数据格式: {}]",
  50 + config.getServerUrl(), config.getDataFormat());
  51 + return webSocketClient;
  52 + }
  53 +
  54 + @Override
  55 + protected void closeProducer(IotWebSocketClient producer) throws Exception {
  56 + if (producer != null) {
  57 + producer.close();
  58 + }
  59 + }
  60 +
  61 + @Override
  62 + protected void execute(IotDeviceMessage message, IotDataSinkWebSocketConfig config) throws Exception {
  63 + try {
  64 + // 1.1 获取或创建 WebSocket 客户端
  65 + // TODO @puhui999:需要加锁,保证必须连接上;
  66 + IotWebSocketClient webSocketClient = getProducer(config);
  67 + // 1.2 检查连接状态,如果断开则重新连接
  68 + if (!webSocketClient.isConnected()) {
  69 + log.warn("[execute][WebSocket 连接已断开,尝试重新连接,服务器: {}]", config.getServerUrl());
  70 + webSocketClient.connect();
  71 + }
  72 +
  73 + // 2.1 发送消息
  74 + webSocketClient.sendMessage(message);
  75 + // 2.2 记录发送成功日志
  76 + log.info("[execute][message({}) config({}) 发送成功,WebSocket 服务器: {}]",
  77 + message, config, config.getServerUrl());
  78 + } catch (Exception e) {
  79 + log.error("[execute][message({}) config({}) 发送失败,WebSocket 服务器: {}]",
  80 + message, config, config.getServerUrl(), e);
  81 + throw e;
  82 + }
  83 + }
  84 +
  85 +}
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/tcp/IotTcpClient.java
... ... @@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.iot.service.rule.data.action.tcp;
2 2  
3 3 import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
4 4 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
  5 +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkTcpConfig;
5 6 import lombok.extern.slf4j.Slf4j;
6 7  
7 8 import javax.net.ssl.SSLSocketFactory;
... ... @@ -30,6 +31,7 @@ public class IotTcpClient {
30 31 private final Integer connectTimeoutMs;
31 32 private final Integer readTimeoutMs;
32 33 private final Boolean ssl;
  34 + // TODO @puhui999:sslCertPath 是不是没在用?
33 35 private final String sslCertPath;
34 36 private final String dataFormat;
35 37  
... ... @@ -38,16 +40,16 @@ public class IotTcpClient {
38 40 private BufferedReader reader;
39 41 private final AtomicBoolean connected = new AtomicBoolean(false);
40 42  
41   - // TODO @puhui999:default 值,IotDataSinkTcpConfig.java 枚举起来哈;
42 43 public IotTcpClient(String host, Integer port, Integer connectTimeoutMs, Integer readTimeoutMs,
43 44 Boolean ssl, String sslCertPath, String dataFormat) {
44 45 this.host = host;
45 46 this.port = port;
46   - this.connectTimeoutMs = connectTimeoutMs != null ? connectTimeoutMs : 5000;
47   - this.readTimeoutMs = readTimeoutMs != null ? readTimeoutMs : 10000;
48   - this.ssl = ssl != null ? ssl : false;
  47 + this.connectTimeoutMs = connectTimeoutMs != null ? connectTimeoutMs : IotDataSinkTcpConfig.DEFAULT_CONNECT_TIMEOUT_MS;
  48 + this.readTimeoutMs = readTimeoutMs != null ? readTimeoutMs : IotDataSinkTcpConfig.DEFAULT_READ_TIMEOUT_MS;
  49 + this.ssl = ssl != null ? ssl : IotDataSinkTcpConfig.DEFAULT_SSL;
49 50 this.sslCertPath = sslCertPath;
50   - this.dataFormat = dataFormat != null ? dataFormat : "JSON";
  51 + // TODO @puhui999:可以使用 StrUtil.defaultIfBlank 方法简化
  52 + this.dataFormat = dataFormat != null ? dataFormat : IotDataSinkTcpConfig.DEFAULT_DATA_FORMAT;
51 53 }
52 54  
53 55 /**
... ... @@ -99,9 +101,8 @@ public class IotTcpClient {
99 101 }
100 102  
101 103 try {
102   - // TODO @puhui999:枚举值
103 104 String messageData;
104   - if ("JSON".equalsIgnoreCase(dataFormat)) {
  105 + if (IotDataSinkTcpConfig.DEFAULT_DATA_FORMAT.equalsIgnoreCase(dataFormat)) {
105 106 // JSON 格式
106 107 messageData = JsonUtils.toJsonString(message);
107 108 } else {
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/websocket/IotWebSocketClient.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.service.rule.data.action.websocket;
  2 +
  3 +import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
  4 +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
  5 +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkWebSocketConfig;
  6 +import lombok.extern.slf4j.Slf4j;
  7 +
  8 +import java.net.URI;
  9 +import java.net.http.HttpClient;
  10 +import java.net.http.WebSocket;
  11 +import java.time.Duration;
  12 +import java.util.concurrent.CompletableFuture;
  13 +import java.util.concurrent.CompletionStage;
  14 +import java.util.concurrent.TimeUnit;
  15 +import java.util.concurrent.atomic.AtomicBoolean;
  16 +
  17 +/**
  18 + * IoT WebSocket 客户端
  19 + * <p>
  20 + * 负责与外部 WebSocket 服务器建立连接并发送设备消息
  21 + * 支持 ws:// 和 wss:// 协议,支持 JSON 和 TEXT 数据格式
  22 + * 基于 Java 11+ 内置的 java.net.http.WebSocket 实现
  23 + *
  24 + * @author HUIHUI
  25 + */
  26 +@Slf4j
  27 +public class IotWebSocketClient implements WebSocket.Listener {
  28 +
  29 + private final String serverUrl;
  30 + private final Integer connectTimeoutMs;
  31 + private final Integer sendTimeoutMs;
  32 + private final String dataFormat;
  33 +
  34 + private WebSocket webSocket;
  35 + private final AtomicBoolean connected = new AtomicBoolean(false);
  36 + private final StringBuilder messageBuffer = new StringBuilder();
  37 +
  38 + public IotWebSocketClient(String serverUrl, Integer connectTimeoutMs, Integer sendTimeoutMs, String dataFormat) {
  39 + this.serverUrl = serverUrl;
  40 + this.connectTimeoutMs = connectTimeoutMs != null ? connectTimeoutMs : IotDataSinkWebSocketConfig.DEFAULT_CONNECT_TIMEOUT_MS;
  41 + this.sendTimeoutMs = sendTimeoutMs != null ? sendTimeoutMs : IotDataSinkWebSocketConfig.DEFAULT_SEND_TIMEOUT_MS;
  42 + this.dataFormat = dataFormat != null ? dataFormat : IotDataSinkWebSocketConfig.DEFAULT_DATA_FORMAT;
  43 + }
  44 +
  45 + /**
  46 + * 连接到 WebSocket 服务器
  47 + */
  48 + @SuppressWarnings("resource")
  49 + public void connect() throws Exception {
  50 + if (connected.get()) {
  51 + log.warn("[connect][WebSocket 客户端已经连接,无需重复连接]");
  52 + return;
  53 + }
  54 +
  55 + try {
  56 + HttpClient httpClient = HttpClient.newBuilder()
  57 + .connectTimeout(Duration.ofMillis(connectTimeoutMs))
  58 + .build();
  59 +
  60 + CompletableFuture<WebSocket> future = httpClient.newWebSocketBuilder()
  61 + .connectTimeout(Duration.ofMillis(connectTimeoutMs))
  62 + .buildAsync(URI.create(serverUrl), this);
  63 +
  64 + // 等待连接完成
  65 + webSocket = future.get(connectTimeoutMs, TimeUnit.MILLISECONDS);
  66 + connected.set(true);
  67 + log.info("[connect][WebSocket 客户端连接成功,服务器地址: {}]", serverUrl);
  68 + } catch (Exception e) {
  69 + close();
  70 + log.error("[connect][WebSocket 客户端连接失败,服务器地址: {}]", serverUrl, e);
  71 + throw e;
  72 + }
  73 + }
  74 +
  75 + @Override
  76 + public void onOpen(WebSocket webSocket) {
  77 + log.debug("[onOpen][WebSocket 连接已打开]");
  78 + webSocket.request(1);
  79 + }
  80 +
  81 + @Override
  82 + public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
  83 + messageBuffer.append(data);
  84 + if (last) {
  85 + log.debug("[onText][收到 WebSocket 消息: {}]", messageBuffer);
  86 + messageBuffer.setLength(0);
  87 + }
  88 + webSocket.request(1);
  89 + return null;
  90 + }
  91 +
  92 + @Override
  93 + public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
  94 + connected.set(false);
  95 + log.info("[onClose][WebSocket 连接已关闭,状态码: {},原因: {}]", statusCode, reason);
  96 + return null;
  97 + }
  98 +
  99 + @Override
  100 + public void onError(WebSocket webSocket, Throwable error) {
  101 + connected.set(false);
  102 + log.error("[onError][WebSocket 发生错误]", error);
  103 + }
  104 +
  105 + /**
  106 + * 发送设备消息
  107 + *
  108 + * @param message 设备消息
  109 + * @throws Exception 发送异常
  110 + */
  111 + public void sendMessage(IotDeviceMessage message) throws Exception {
  112 + if (!connected.get() || webSocket == null) {
  113 + throw new IllegalStateException("WebSocket 客户端未连接");
  114 + }
  115 +
  116 + try {
  117 + String messageData;
  118 + if (IotDataSinkWebSocketConfig.DEFAULT_DATA_FORMAT.equalsIgnoreCase(dataFormat)) {
  119 + messageData = JsonUtils.toJsonString(message);
  120 + } else {
  121 + messageData = message.toString();
  122 + }
  123 +
  124 + // 发送消息并等待完成
  125 + CompletableFuture<WebSocket> future = webSocket.sendText(messageData, true);
  126 + future.get(sendTimeoutMs, TimeUnit.MILLISECONDS);
  127 + log.debug("[sendMessage][发送消息成功,设备 ID: {},消息长度: {}]",
  128 + message.getDeviceId(), messageData.length());
  129 + } catch (Exception e) {
  130 + log.error("[sendMessage][发送消息失败,设备 ID: {}]", message.getDeviceId(), e);
  131 + throw e;
  132 + }
  133 + }
  134 +
  135 + /**
  136 + * 关闭连接
  137 + */
  138 + public void close() {
  139 + if (!connected.get() && webSocket == null) {
  140 + return;
  141 + }
  142 +
  143 + try {
  144 + if (webSocket != null) {
  145 + webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "客户端主动关闭")
  146 + .orTimeout(5, TimeUnit.SECONDS)
  147 + .exceptionally(e -> {
  148 + log.warn("[close][发送关闭帧失败]", e);
  149 + return null;
  150 + });
  151 + }
  152 + connected.set(false);
  153 + log.info("[close][WebSocket 客户端连接已关闭,服务器地址: {}]", serverUrl);
  154 + } catch (Exception e) {
  155 + log.error("[close][关闭 WebSocket 客户端连接异常]", e);
  156 + }
  157 + }
  158 +
  159 + /**
  160 + * 检查连接状态
  161 + *
  162 + * @return 是否已连接
  163 + */
  164 + public boolean isConnected() {
  165 + return connected.get() && webSocket != null;
  166 + }
  167 +
  168 + @Override
  169 + public String toString() {
  170 + return "IotWebSocketClient{" +
  171 + "serverUrl='" + serverUrl + '\'' +
  172 + ", dataFormat='" + dataFormat + '\'' +
  173 + ", connected=" + connected.get() +
  174 + '}';
  175 + }
  176 +
  177 +}
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceImpl.java
... ... @@ -30,6 +30,7 @@ import org.springframework.cache.annotation.Cacheable;
30 30 import org.springframework.stereotype.Service;
31 31 import org.springframework.validation.annotation.Validated;
32 32  
  33 +import java.time.LocalDateTime;
33 34 import java.util.Collection;
34 35 import java.util.List;
35 36  
... ... @@ -392,9 +393,25 @@ public class IotSceneRuleServiceImpl implements IotSceneRuleService {
392 393 }
393 394 });
394 395 });
  396 +
  397 + // 3. 更新最后触发时间
  398 + updateLastTriggerTime(sceneRule.getId());
395 399 });
396 400 }
397 401  
  402 + /**
  403 + * 更新规则场景的最后触发时间
  404 + *
  405 + * @param id 规则场景编号
  406 + */
  407 + private void updateLastTriggerTime(Long id) {
  408 + try {
  409 + sceneRuleMapper.updateById(new IotSceneRuleDO().setId(id).setLastTriggerTime(LocalDateTime.now()));
  410 + } catch (Exception e) {
  411 + log.error("[updateLastTriggerTime][规则场景编号({}) 更新最后触发时间异常]", id, e);
  412 + }
  413 + }
  414 +
398 415 private IotSceneRuleServiceImpl getSelf() {
399 416 return SpringUtil.getBean(IotSceneRuleServiceImpl.class);
400 417 }
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDevicePropertyPostTriggerMatcher.java
... ... @@ -36,11 +36,12 @@ public class IotDevicePropertyPostTriggerMatcher implements IotSceneRuleTriggerM
36 36 return false;
37 37 }
38 38  
39   - // 1.3 检查标识符是否匹配
40   - String messageIdentifier = IotDeviceMessageUtils.getIdentifier(message);
41   - if (!IotSceneRuleMatcherHelper.isIdentifierMatched(trigger.getIdentifier(), messageIdentifier)) {
42   - IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "标识符不匹配,期望: " +
43   - trigger.getIdentifier() + ", 实际: " + messageIdentifier);
  39 + // 1.3 检查消息中是否包含触发器指定的属性标识符
  40 + // 注意:属性上报可能同时上报多个属性,所以需要判断 trigger.getIdentifier() 是否在 message 的 params 中
  41 + // TODO @puhui999:可以考虑 notXXX 方法,简化代码(尽量取反)
  42 + if (!IotDeviceMessageUtils.containsIdentifier(message, trigger.getIdentifier())) {
  43 + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "消息中不包含属性: " +
  44 + trigger.getIdentifier());
44 45 return false;
45 46 }
46 47  
... ...
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtils.java
... ... @@ -5,6 +5,7 @@ import cn.hutool.core.map.MapUtil;
5 5 import cn.hutool.core.util.IdUtil;
6 6 import cn.hutool.core.util.StrUtil;
7 7 import cn.hutool.system.SystemUtil;
  8 +import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
8 9 import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
9 10 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
10 11  
... ... @@ -70,6 +71,55 @@ public class IotDeviceMessageUtils {
70 71 }
71 72  
72 73 /**
  74 + * 判断消息中是否包含指定的标识符
  75 + *
  76 + * 对于不同消息类型的处理:
  77 + * - EVENT_POST/SERVICE_INVOKE:检查 params.identifier 是否匹配
  78 + * - STATE_UPDATE:检查 params.state 是否匹配
  79 + * - PROPERTY_POST:检查 params 中是否包含该属性 key
  80 + *
  81 + * @param message 消息
  82 + * @param identifier 要检查的标识符
  83 + * @return 是否包含
  84 + */
  85 + public static boolean containsIdentifier(IotDeviceMessage message, String identifier) {
  86 + if (message.getParams() == null || StrUtil.isBlank(identifier)) {
  87 + return false;
  88 + }
  89 + // EVENT_POST / SERVICE_INVOKE / STATE_UPDATE:使用原有逻辑
  90 + String messageIdentifier = getIdentifier(message);
  91 + if (messageIdentifier != null) {
  92 + return identifier.equals(messageIdentifier);
  93 + }
  94 + // PROPERTY_POST:检查 params 中是否包含该属性 key
  95 + if (StrUtil.equals(message.getMethod(), IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod())) {
  96 + Map<String, Object> params = parseParamsToMap(message.getParams());
  97 + return params != null && params.containsKey(identifier);
  98 + }
  99 + return false;
  100 + }
  101 +
  102 + /**
  103 + * 将 params 解析为 Map
  104 + *
  105 + * @param params 参数(可能是 Map 或 JSON 字符串)
  106 + * @return Map,解析失败返回 null
  107 + */
  108 + @SuppressWarnings("unchecked")
  109 + private static Map<String, Object> parseParamsToMap(Object params) {
  110 + if (params instanceof Map) {
  111 + return (Map<String, Object>) params;
  112 + }
  113 + if (params instanceof String) {
  114 + try {
  115 + return JsonUtils.parseObject((String) params, Map.class);
  116 + } catch (Exception ignored) {
  117 + }
  118 + }
  119 + return null;
  120 + }
  121 +
  122 + /**
73 123 * 从设备消息中提取指定标识符的属性值
74 124 * - 支持多种消息格式和属性值提取策略
75 125 * - 兼容现有的消息结构
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java
... ... @@ -10,6 +10,10 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttDownstreamSubscr
10 10 import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttUpstreamProtocol;
11 11 import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionManager;
12 12 import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.router.IotMqttDownstreamHandler;
  13 +import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.IotMqttWsDownstreamSubscriber;
  14 +import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.IotMqttWsUpstreamProtocol;
  15 +import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.manager.IotMqttWsConnectionManager;
  16 +import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.router.IotMqttWsDownstreamHandler;
13 17 import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpDownstreamSubscriber;
14 18 import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpUpstreamProtocol;
15 19 import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager.IotTcpConnectionManager;
... ... @@ -17,6 +21,7 @@ import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService;
17 21 import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
18 22 import io.vertx.core.Vertx;
19 23 import lombok.extern.slf4j.Slf4j;
  24 +import org.springframework.beans.factory.annotation.Qualifier;
20 25 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
21 26 import org.springframework.boot.context.properties.EnableConfigurationProperties;
22 27 import org.springframework.context.annotation.Bean;
... ... @@ -55,20 +60,20 @@ public class IotGatewayConfiguration {
55 60 @Slf4j
56 61 public static class EmqxProtocolConfiguration {
57 62  
58   - @Bean(destroyMethod = "close")
  63 + @Bean(name = "emqxVertx", destroyMethod = "close")
59 64 public Vertx emqxVertx() {
60 65 return Vertx.vertx();
61 66 }
62 67  
63 68 @Bean
64 69 public IotEmqxAuthEventProtocol iotEmqxAuthEventProtocol(IotGatewayProperties gatewayProperties,
65   - Vertx emqxVertx) {
  70 + @Qualifier("emqxVertx") Vertx emqxVertx) {
66 71 return new IotEmqxAuthEventProtocol(gatewayProperties.getProtocol().getEmqx(), emqxVertx);
67 72 }
68 73  
69 74 @Bean
70 75 public IotEmqxUpstreamProtocol iotEmqxUpstreamProtocol(IotGatewayProperties gatewayProperties,
71   - Vertx emqxVertx) {
  76 + @Qualifier("emqxVertx") Vertx emqxVertx) {
72 77 return new IotEmqxUpstreamProtocol(gatewayProperties.getProtocol().getEmqx(), emqxVertx);
73 78 }
74 79  
... ... @@ -87,7 +92,7 @@ public class IotGatewayConfiguration {
87 92 @Slf4j
88 93 public static class TcpProtocolConfiguration {
89 94  
90   - @Bean(destroyMethod = "close")
  95 + @Bean(name = "tcpVertx", destroyMethod = "close")
91 96 public Vertx tcpVertx() {
92 97 return Vertx.vertx();
93 98 }
... ... @@ -97,7 +102,7 @@ public class IotGatewayConfiguration {
97 102 IotDeviceService deviceService,
98 103 IotDeviceMessageService messageService,
99 104 IotTcpConnectionManager connectionManager,
100   - Vertx tcpVertx) {
  105 + @Qualifier("tcpVertx") Vertx tcpVertx) {
101 106 return new IotTcpUpstreamProtocol(gatewayProperties.getProtocol().getTcp(),
102 107 deviceService, messageService, connectionManager, tcpVertx);
103 108 }
... ... @@ -122,7 +127,7 @@ public class IotGatewayConfiguration {
122 127 @Slf4j
123 128 public static class MqttProtocolConfiguration {
124 129  
125   - @Bean(destroyMethod = "close")
  130 + @Bean(name = "mqttVertx", destroyMethod = "close")
126 131 public Vertx mqttVertx() {
127 132 return Vertx.vertx();
128 133 }
... ... @@ -131,7 +136,7 @@ public class IotGatewayConfiguration {
131 136 public IotMqttUpstreamProtocol iotMqttUpstreamProtocol(IotGatewayProperties gatewayProperties,
132 137 IotDeviceMessageService messageService,
133 138 IotMqttConnectionManager connectionManager,
134   - Vertx mqttVertx) {
  139 + @Qualifier("mqttVertx") Vertx mqttVertx) {
135 140 return new IotMqttUpstreamProtocol(gatewayProperties.getProtocol().getMqtt(), messageService,
136 141 connectionManager, mqttVertx);
137 142 }
... ... @@ -151,4 +156,42 @@ public class IotGatewayConfiguration {
151 156  
152 157 }
153 158  
  159 + /**
  160 + * IoT 网关 MQTT WebSocket 协议配置类
  161 + */
  162 + @Configuration
  163 + @ConditionalOnProperty(prefix = "yudao.iot.gateway.protocol.mqtt-ws", name = "enabled", havingValue = "true")
  164 + @Slf4j
  165 + public static class MqttWsProtocolConfiguration {
  166 +
  167 + @Bean(name = "mqttWsVertx", destroyMethod = "close")
  168 + public Vertx mqttWsVertx() {
  169 + return Vertx.vertx();
  170 + }
  171 +
  172 + @Bean
  173 + public IotMqttWsUpstreamProtocol iotMqttWsUpstreamProtocol(IotGatewayProperties gatewayProperties,
  174 + IotDeviceMessageService messageService,
  175 + IotMqttWsConnectionManager connectionManager,
  176 + @Qualifier("mqttWsVertx") Vertx mqttWsVertx) {
  177 + return new IotMqttWsUpstreamProtocol(gatewayProperties.getProtocol().getMqttWs(),
  178 + messageService, connectionManager, mqttWsVertx);
  179 + }
  180 +
  181 + @Bean
  182 + public IotMqttWsDownstreamHandler iotMqttWsDownstreamHandler(IotDeviceMessageService messageService,
  183 + IotDeviceService deviceService,
  184 + IotMqttWsConnectionManager connectionManager) {
  185 + return new IotMqttWsDownstreamHandler(messageService, deviceService, connectionManager);
  186 + }
  187 +
  188 + @Bean
  189 + public IotMqttWsDownstreamSubscriber iotMqttWsDownstreamSubscriber(IotMqttWsUpstreamProtocol mqttWsUpstreamProtocol,
  190 + IotMqttWsDownstreamHandler downstreamHandler,
  191 + IotMessageBus messageBus) {
  192 + return new IotMqttWsDownstreamSubscriber(mqttWsUpstreamProtocol, downstreamHandler, messageBus);
  193 + }
  194 +
  195 + }
  196 +
154 197 }
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayProperties.java
... ... @@ -88,6 +88,11 @@ public class IotGatewayProperties {
88 88 */
89 89 private MqttProperties mqtt;
90 90  
  91 + /**
  92 + * MQTT WebSocket 组件配置
  93 + */
  94 + private MqttWsProperties mqttWs;
  95 +
91 96 }
92 97  
93 98 @Data
... ... @@ -402,4 +407,100 @@ public class IotGatewayProperties {
402 407  
403 408 }
404 409  
  410 + @Data
  411 + public static class MqttWsProperties {
  412 +
  413 + /**
  414 + * 是否开启
  415 + */
  416 + @NotNull(message = "是否开启不能为空")
  417 + private Boolean enabled;
  418 +
  419 + /**
  420 + * WebSocket 服务器端口(默认:8083)
  421 + */
  422 + private Integer port = 8083;
  423 +
  424 + /**
  425 + * WebSocket 路径(默认:/mqtt)
  426 + */
  427 + @NotEmpty(message = "WebSocket 路径不能为空")
  428 + private String path = "/mqtt";
  429 +
  430 + /**
  431 + * 最大消息大小(字节)
  432 + */
  433 + private Integer maxMessageSize = 8192;
  434 +
  435 + /**
  436 + * 连接超时时间(秒)
  437 + */
  438 + private Integer connectTimeoutSeconds = 60;
  439 +
  440 + /**
  441 + * 保持连接超时时间(秒)
  442 + */
  443 + private Integer keepAliveTimeoutSeconds = 300;
  444 +
  445 + /**
  446 + * 是否启用 SSL(wss://)
  447 + */
  448 + private Boolean sslEnabled = false;
  449 +
  450 + /**
  451 + * SSL 配置
  452 + */
  453 + private SslOptions sslOptions = new SslOptions();
  454 +
  455 + /**
  456 + * WebSocket 子协议(通常为 "mqtt" 或 "mqttv3.1")
  457 + */
  458 + @NotEmpty(message = "WebSocket 子协议不能为空")
  459 + private String subProtocol = "mqtt";
  460 +
  461 + /**
  462 + * 最大帧大小(字节)
  463 + */
  464 + private Integer maxFrameSize = 65536;
  465 +
  466 + /**
  467 + * SSL 配置选项
  468 + */
  469 + @Data
  470 + public static class SslOptions {
  471 +
  472 + /**
  473 + * 密钥证书选项
  474 + */
  475 + private io.vertx.core.net.KeyCertOptions keyCertOptions;
  476 +
  477 + /**
  478 + * 信任选项
  479 + */
  480 + private io.vertx.core.net.TrustOptions trustOptions;
  481 +
  482 + /**
  483 + * SSL 证书路径
  484 + */
  485 + private String certPath;
  486 +
  487 + /**
  488 + * SSL 私钥路径
  489 + */
  490 + private String keyPath;
  491 +
  492 + /**
  493 + * 信任存储路径
  494 + */
  495 + private String trustStorePath;
  496 +
  497 + /**
  498 + * 信任存储密码
  499 + */
  500 + private String trustStorePassword;
  501 +
  502 + }
  503 +
  504 + }
  505 +
405 506 }
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/IotMqttWsDownstreamSubscriber.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.protocol.mqttws;
  2 +
  3 +import cn.hutool.core.util.StrUtil;
  4 +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
  5 +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber;
  6 +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
  7 +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
  8 +import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.router.IotMqttWsDownstreamHandler;
  9 +import jakarta.annotation.PostConstruct;
  10 +import lombok.extern.slf4j.Slf4j;
  11 +
  12 +/**
  13 + * IoT MQTT WebSocket 下行消息订阅器
  14 + * <p>
  15 + * 订阅消息总线的设备下行消息,并通过 WebSocket 发送到设备
  16 + *
  17 + * @author 芋道源码
  18 + */
  19 +@Slf4j
  20 +public class IotMqttWsDownstreamSubscriber implements IotMessageSubscriber<IotDeviceMessage> {
  21 +
  22 + private final IotMqttWsUpstreamProtocol upstreamProtocol;
  23 + private final IotMqttWsDownstreamHandler downstreamHandler;
  24 + private final IotMessageBus messageBus;
  25 +
  26 + public IotMqttWsDownstreamSubscriber(IotMqttWsUpstreamProtocol upstreamProtocol,
  27 + IotMqttWsDownstreamHandler downstreamHandler,
  28 + IotMessageBus messageBus) {
  29 + this.upstreamProtocol = upstreamProtocol;
  30 + this.downstreamHandler = downstreamHandler;
  31 + this.messageBus = messageBus;
  32 + }
  33 +
  34 + @PostConstruct
  35 + public void init() {
  36 + messageBus.register(this);
  37 + log.info("[init][MQTT WebSocket 下行消息订阅器已启动,topic: {}]", getTopic());
  38 + }
  39 +
  40 + @Override
  41 + public String getTopic() {
  42 + return IotDeviceMessageUtils.buildMessageBusGatewayDeviceMessageTopic(upstreamProtocol.getServerId());
  43 + }
  44 +
  45 + @Override
  46 + public String getGroup() {
  47 + // 保证点对点消费,需要保证独立的 Group,所以使用 Topic 作为 Group
  48 + return getTopic();
  49 + }
  50 +
  51 + @Override
  52 + public void onMessage(IotDeviceMessage message) {
  53 + log.debug("[onMessage][收到下行消息,deviceId: {},method: {}]",
  54 + message.getDeviceId(), message.getMethod());
  55 + try {
  56 + // 1. 校验
  57 + String method = message.getMethod();
  58 + if (StrUtil.isBlank(method)) {
  59 + log.warn("[onMessage][消息方法为空,deviceId: {}]", message.getDeviceId());
  60 + return;
  61 + }
  62 +
  63 + // 2. 委托给下行处理器处理业务逻辑
  64 + boolean success = downstreamHandler.handleDownstreamMessage(message);
  65 + if (success) {
  66 + log.debug("[onMessage][下行消息处理成功,deviceId: {},method: {}]",
  67 + message.getDeviceId(), message.getMethod());
  68 + } else {
  69 + log.warn("[onMessage][下行消息处理失败,deviceId: {},method: {}]",
  70 + message.getDeviceId(), message.getMethod());
  71 + }
  72 + } catch (Exception e) {
  73 + log.error("[onMessage][处理下行消息失败,deviceId: {},method: {}]",
  74 + message.getDeviceId(), message.getMethod(), e);
  75 + }
  76 + }
  77 +
  78 +}
  79 +
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/IotMqttWsUpstreamProtocol.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.protocol.mqttws;
  2 +
  3 +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
  4 +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties;
  5 +import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.manager.IotMqttWsConnectionManager;
  6 +import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.router.IotMqttWsUpstreamHandler;
  7 +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
  8 +import io.vertx.core.Vertx;
  9 +import io.vertx.core.http.HttpServer;
  10 +import io.vertx.core.http.HttpServerOptions;
  11 +import io.vertx.core.http.ServerWebSocket;
  12 +import jakarta.annotation.PostConstruct;
  13 +import jakarta.annotation.PreDestroy;
  14 +import lombok.Getter;
  15 +import lombok.extern.slf4j.Slf4j;
  16 +
  17 +/**
  18 + * IoT 网关 MQTT WebSocket 协议:接收设备上行消息
  19 + * <p>
  20 + * 基于 Vert.x 实现 MQTT over WebSocket 服务端,支持:
  21 + * - 标准 MQTT 3.1.1 协议
  22 + * - WebSocket 协议升级
  23 + * - SSL/TLS 加密(wss://)
  24 + * - 设备认证与连接管理
  25 + * - QoS 0/1/2 消息质量保证
  26 + *
  27 + * @author 芋道源码
  28 + */
  29 +@Slf4j
  30 +public class IotMqttWsUpstreamProtocol {
  31 +
  32 + private final IotGatewayProperties.MqttWsProperties mqttWsProperties;
  33 +
  34 + private final IotDeviceMessageService messageService;
  35 +
  36 + private final IotMqttWsConnectionManager connectionManager;
  37 +
  38 + private final Vertx vertx;
  39 +
  40 + @Getter
  41 + private final String serverId;
  42 +
  43 + private HttpServer httpServer;
  44 +
  45 + public IotMqttWsUpstreamProtocol(IotGatewayProperties.MqttWsProperties mqttWsProperties,
  46 + IotDeviceMessageService messageService,
  47 + IotMqttWsConnectionManager connectionManager,
  48 + Vertx vertx) {
  49 + this.mqttWsProperties = mqttWsProperties;
  50 + this.messageService = messageService;
  51 + this.connectionManager = connectionManager;
  52 + this.vertx = vertx;
  53 + this.serverId = IotDeviceMessageUtils.generateServerId(mqttWsProperties.getPort());
  54 + }
  55 +
  56 + @PostConstruct
  57 + public void start() {
  58 + // 创建 HTTP 服务器选项
  59 + HttpServerOptions options = new HttpServerOptions()
  60 + .setPort(mqttWsProperties.getPort())
  61 + .setIdleTimeout(mqttWsProperties.getKeepAliveTimeoutSeconds())
  62 + .setMaxWebSocketFrameSize(mqttWsProperties.getMaxFrameSize())
  63 + .setMaxWebSocketMessageSize(mqttWsProperties.getMaxMessageSize())
  64 + // 配置 WebSocket 子协议支持
  65 + .addWebSocketSubProtocol(mqttWsProperties.getSubProtocol());
  66 +
  67 + // 配置 SSL(如果启用)
  68 + if (Boolean.TRUE.equals(mqttWsProperties.getSslEnabled())) {
  69 + options.setSsl(true)
  70 + .setKeyCertOptions(mqttWsProperties.getSslOptions().getKeyCertOptions())
  71 + .setTrustOptions(mqttWsProperties.getSslOptions().getTrustOptions());
  72 + log.info("[start][MQTT WebSocket 已启用 SSL/TLS (wss://)]");
  73 + }
  74 +
  75 + // 创建 HTTP 服务器
  76 + httpServer = vertx.createHttpServer(options);
  77 +
  78 + // 设置 WebSocket 处理器
  79 + httpServer.webSocketHandler(this::handleWebSocketConnection);
  80 +
  81 + // 启动服务器
  82 + try {
  83 + httpServer.listen().result();
  84 + log.info("[start][IoT 网关 MQTT WebSocket 协议启动成功,端口: {},路径: {},支持子协议: {}]",
  85 + mqttWsProperties.getPort(), mqttWsProperties.getPath(),
  86 + "mqtt, mqttv3.1, " + mqttWsProperties.getSubProtocol());
  87 + } catch (Exception e) {
  88 + log.error("[start][IoT 网关 MQTT WebSocket 协议启动失败]", e);
  89 + throw e;
  90 + }
  91 + }
  92 +
  93 + @PreDestroy
  94 + public void stop() {
  95 + if (httpServer != null) {
  96 + try {
  97 + // 关闭所有连接
  98 + connectionManager.closeAllConnections();
  99 +
  100 + // 关闭服务器
  101 + httpServer.close().result();
  102 + log.info("[stop][IoT 网关 MQTT WebSocket 协议已停止]");
  103 + } catch (Exception e) {
  104 + log.error("[stop][IoT 网关 MQTT WebSocket 协议停止失败]", e);
  105 + }
  106 + }
  107 + }
  108 +
  109 + /**
  110 + * 处理 WebSocket 连接请求
  111 + *
  112 + * @param socket WebSocket 连接
  113 + */
  114 + private void handleWebSocketConnection(ServerWebSocket socket) {
  115 + String path = socket.path();
  116 + String subProtocol = socket.subProtocol();
  117 +
  118 + log.info("[handleWebSocketConnection][收到 WebSocket 连接请求,path: {},subProtocol: {},remoteAddress: {}]",
  119 + path, subProtocol, socket.remoteAddress());
  120 +
  121 + // 验证路径
  122 + if (!mqttWsProperties.getPath().equals(path)) {
  123 + log.warn("[handleWebSocketConnection][WebSocket 路径不匹配,拒绝连接,path: {},期望: {}]",
  124 + path, mqttWsProperties.getPath());
  125 + socket.close();
  126 + return;
  127 + }
  128 +
  129 + // 验证子协议
  130 + // Vert.x 已经自动进行了子协议协商,这里只需要验证是否为 MQTT 相关协议
  131 + if (subProtocol != null && !subProtocol.startsWith("mqtt")) {
  132 + log.warn("[handleWebSocketConnection][WebSocket 子协议不支持,拒绝连接,subProtocol: {}]", subProtocol);
  133 + socket.close();
  134 + return;
  135 + }
  136 +
  137 + log.info("[handleWebSocketConnection][WebSocket 连接已接受,remoteAddress: {},subProtocol: {}]",
  138 + socket.remoteAddress(), subProtocol);
  139 +
  140 + // 创建处理器并处理连接
  141 + IotMqttWsUpstreamHandler handler = new IotMqttWsUpstreamHandler(
  142 + this, messageService, connectionManager);
  143 + handler.handle(socket);
  144 + }
  145 +
  146 +}
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/manager/IotMqttWsConnectionManager.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.manager;
  2 +
  3 +import cn.hutool.core.collection.CollUtil;
  4 +import io.vertx.core.http.ServerWebSocket;
  5 +import lombok.extern.slf4j.Slf4j;
  6 +import org.springframework.stereotype.Component;
  7 +
  8 +import java.util.Map;
  9 +import java.util.Set;
  10 +import java.util.concurrent.ConcurrentHashMap;
  11 +import java.util.concurrent.CopyOnWriteArraySet;
  12 +
  13 +/**
  14 + * IoT MQTT WebSocket 连接管理器
  15 + *
  16 + * @author 芋道源码
  17 + */
  18 +@Slf4j
  19 +@Component
  20 +public class IotMqttWsConnectionManager {
  21 +
  22 + /**
  23 + * 存储设备连接
  24 + * Key: 设备标识(deviceKey)
  25 + * Value: WebSocket 连接
  26 + */
  27 + private final Map<String, ServerWebSocket> connections = new ConcurrentHashMap<>();
  28 +
  29 + /**
  30 + * 存储设备标识与 Socket ID 的映射
  31 + * Key: 设备标识(deviceKey)
  32 + * Value: Socket ID(UUID)
  33 + */
  34 + private final Map<String, String> deviceKeyToSocketId = new ConcurrentHashMap<>();
  35 +
  36 + /**
  37 + * 存储 Socket ID 与设备标识的映射
  38 + * Key: Socket ID(UUID)
  39 + * Value: 设备标识(deviceKey)
  40 + */
  41 + private final Map<String, String> socketIdToDeviceKey = new ConcurrentHashMap<>();
  42 +
  43 + /**
  44 + * 存储设备订阅的主题
  45 + * Key: 设备标识(deviceKey)
  46 + * Value: 订阅的主题集合
  47 + */
  48 + private final Map<String, Set<String>> deviceSubscriptions = new ConcurrentHashMap<>();
  49 +
  50 + /**
  51 + * 添加连接
  52 + *
  53 + * @param deviceKey 设备标识
  54 + * @param socket WebSocket 连接
  55 + * @param socketId Socket ID(UUID)
  56 + */
  57 + public void addConnection(String deviceKey, ServerWebSocket socket, String socketId) {
  58 + connections.put(deviceKey, socket);
  59 + deviceKeyToSocketId.put(deviceKey, socketId);
  60 + socketIdToDeviceKey.put(socketId, deviceKey);
  61 + log.info("[addConnection][设备连接已添加,deviceKey: {},socketId: {},当前连接数: {}]",
  62 + deviceKey, socketId, connections.size());
  63 + }
  64 +
  65 + /**
  66 + * 移除连接
  67 + *
  68 + * @param deviceKey 设备标识
  69 + */
  70 + public void removeConnection(String deviceKey) {
  71 + ServerWebSocket socket = connections.remove(deviceKey);
  72 + String socketId = deviceKeyToSocketId.remove(deviceKey);
  73 + if (socketId != null) {
  74 + socketIdToDeviceKey.remove(socketId);
  75 + }
  76 + if (socket != null) {
  77 + log.info("[removeConnection][设备连接已移除,deviceKey: {},socketId: {},当前连接数: {}]",
  78 + deviceKey, socketId, connections.size());
  79 + }
  80 + }
  81 +
  82 + /**
  83 + * 根据 Socket ID 移除连接
  84 + *
  85 + * @param socketId WebSocket 文本框架 ID
  86 + */
  87 + public void removeConnectionBySocketId(String socketId) {
  88 + String deviceKey = socketIdToDeviceKey.remove(socketId);
  89 + if (deviceKey != null) {
  90 + connections.remove(deviceKey);
  91 + log.info("[removeConnectionBySocketId][设备连接已移除,socketId: {},deviceKey: {},当前连接数: {}]",
  92 + socketId, deviceKey, connections.size());
  93 + }
  94 + }
  95 +
  96 + /**
  97 + * 获取连接
  98 + *
  99 + * @param deviceKey 设备标识
  100 + * @return WebSocket 连接
  101 + */
  102 + public ServerWebSocket getConnection(String deviceKey) {
  103 + return connections.get(deviceKey);
  104 + }
  105 +
  106 + /**
  107 + * 根据 Socket ID 获取设备标识
  108 + *
  109 + * @param socketId WebSocket 文本框架 ID
  110 + * @return 设备标识
  111 + */
  112 + public String getDeviceKeyBySocketId(String socketId) {
  113 + return socketIdToDeviceKey.get(socketId);
  114 + }
  115 +
  116 + /**
  117 + * 检查设备是否在线
  118 + *
  119 + * @param deviceKey 设备标识
  120 + * @return 是否在线
  121 + */
  122 + public boolean isOnline(String deviceKey) {
  123 + return connections.containsKey(deviceKey);
  124 + }
  125 +
  126 + /**
  127 + * 获取当前连接数
  128 + *
  129 + * @return 连接数
  130 + */
  131 + public int getConnectionCount() {
  132 + return connections.size();
  133 + }
  134 +
  135 + /**
  136 + * 关闭所有连接
  137 + */
  138 + public void closeAllConnections() {
  139 + connections.forEach((deviceKey, socket) -> {
  140 + try {
  141 + socket.close();
  142 + log.info("[closeAllConnections][关闭设备连接,deviceKey: {}]", deviceKey);
  143 + } catch (Exception e) {
  144 + log.error("[closeAllConnections][关闭设备连接失败,deviceKey: {}]", deviceKey, e);
  145 + }
  146 + });
  147 + connections.clear();
  148 + deviceKeyToSocketId.clear();
  149 + socketIdToDeviceKey.clear();
  150 + deviceSubscriptions.clear();
  151 + log.info("[closeAllConnections][所有连接已关闭]");
  152 + }
  153 +
  154 + // ==================== 订阅管理方法 ====================
  155 +
  156 + /**
  157 + * 添加订阅
  158 + *
  159 + * @param deviceKey 设备标识
  160 + * @param topic 订阅主题
  161 + */
  162 + public void addSubscription(String deviceKey, String topic) {
  163 + deviceSubscriptions.computeIfAbsent(deviceKey, k -> new CopyOnWriteArraySet<>()).add(topic);
  164 + log.debug("[addSubscription][设备订阅主题,deviceKey: {},topic: {}]", deviceKey, topic);
  165 + }
  166 +
  167 + /**
  168 + * 移除订阅
  169 + *
  170 + * @param deviceKey 设备标识
  171 + * @param topic 订阅主题
  172 + */
  173 + public void removeSubscription(String deviceKey, String topic) {
  174 + Set<String> topics = deviceSubscriptions.get(deviceKey);
  175 + if (topics != null) {
  176 + topics.remove(topic);
  177 + log.debug("[removeSubscription][设备取消订阅,deviceKey: {},topic: {}]", deviceKey, topic);
  178 + }
  179 + }
  180 +
  181 + /**
  182 + * 检查设备是否订阅了指定主题
  183 + * 支持 MQTT 通配符匹配(+ 和 #)
  184 + *
  185 + * @param deviceKey 设备标识
  186 + * @param topic 发布主题
  187 + * @return 是否匹配
  188 + */
  189 + public boolean isSubscribed(String deviceKey, String topic) {
  190 + Set<String> subscriptions = deviceSubscriptions.get(deviceKey);
  191 + if (CollUtil.isEmpty(subscriptions)) {
  192 + return false;
  193 + }
  194 +
  195 + // 检查是否有匹配的订阅
  196 + for (String subscription : subscriptions) {
  197 + if (topicMatches(subscription, topic)) {
  198 + return true;
  199 + }
  200 + }
  201 + return false;
  202 + }
  203 +
  204 + /**
  205 + * 获取设备的所有订阅
  206 + *
  207 + * @param deviceKey 设备标识
  208 + * @return 订阅主题集合
  209 + */
  210 + public Set<String> getSubscriptions(String deviceKey) {
  211 + return deviceSubscriptions.get(deviceKey);
  212 + }
  213 +
  214 + // TODO @haohao:这个方法,是不是也可以考虑抽到 IotMqttTopicUtils 里面去哈;感觉更简洁一点?
  215 + /**
  216 + * MQTT 主题匹配
  217 + * 支持通配符:
  218 + * - +:匹配单层主题
  219 + * - #:匹配多层主题(必须在末尾)
  220 + *
  221 + * @param subscription 订阅主题(可能包含通配符)
  222 + * @param topic 发布主题(不包含通配符)
  223 + * @return 是否匹配
  224 + */
  225 + private boolean topicMatches(String subscription, String topic) {
  226 + // 完全匹配
  227 + if (subscription.equals(topic)) {
  228 + return true;
  229 + }
  230 +
  231 + // 不包含通配符
  232 + // TODO @haohao:这里要不要枚举下哈;+ #
  233 + if (!subscription.contains("+") && !subscription.contains("#")) {
  234 + return false;
  235 + }
  236 +
  237 + String[] subscriptionParts = subscription.split("/");
  238 + String[] topicParts = topic.split("/");
  239 + int i = 0;
  240 + for (; i < subscriptionParts.length && i < topicParts.length; i++) {
  241 + String subPart = subscriptionParts[i];
  242 + String topicPart = topicParts[i];
  243 +
  244 + // # 匹配剩余所有层级,且必须在末尾
  245 + if (subPart.equals("#")) {
  246 + return i == subscriptionParts.length - 1;
  247 + }
  248 +
  249 + // 不是通配符且不匹配
  250 + if (!subPart.equals("+") && !subPart.equals(topicPart)) {
  251 + return false;
  252 + }
  253 + }
  254 +
  255 + // 检查是否都匹配完
  256 + return i == subscriptionParts.length && i == topicParts.length;
  257 + }
  258 +
  259 +}
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/package-info.java 0 → 100644
  1 +/**
  2 + * IoT 网关 MQTT WebSocket 协议实现
  3 + * <p>
  4 + * 基于 Vert.x 实现 MQTT over WebSocket 服务端,支持:
  5 + * - 标准 MQTT 3.1.1 协议
  6 + * - WebSocket 协议升级
  7 + * - SSL/TLS 加密(wss://)
  8 + * - 设备认证与连接管理
  9 + * - QoS 0/1/2 消息质量保证
  10 + * - 双向消息通信(上行/下行)
  11 + *
  12 + * @author 芋道源码
  13 + */
  14 +package cn.iocoder.yudao.module.iot.gateway.protocol.mqttws;
  15 +
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/router/IotMqttWsDownstreamHandler.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.router;
  2 +
  3 +import cn.hutool.core.util.StrUtil;
  4 +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO;
  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.mqttws.manager.IotMqttWsConnectionManager;
  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 cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils;
  11 +import io.vertx.core.buffer.Buffer;
  12 +import io.vertx.core.http.ServerWebSocket;
  13 +import lombok.extern.slf4j.Slf4j;
  14 +
  15 +import java.util.concurrent.atomic.AtomicInteger;
  16 +
  17 +/**
  18 + * IoT MQTT WebSocket 下行消息处理器
  19 + * <p>
  20 + * 处理从消息总线发送到设备的消息,包括:
  21 + * - 属性设置
  22 + * - 服务调用
  23 + * - 事件通知
  24 + *
  25 + * @author 芋道源码
  26 + */
  27 +@Slf4j
  28 +public class IotMqttWsDownstreamHandler {
  29 +
  30 + private final IotDeviceMessageService deviceMessageService;
  31 +
  32 + private final IotDeviceService deviceService;
  33 +
  34 + private final IotMqttWsConnectionManager connectionManager;
  35 +
  36 + /**
  37 + * 消息 ID 生成器(用于发布消息)
  38 + */
  39 + private final AtomicInteger messageIdGenerator = new AtomicInteger(1);
  40 +
  41 + public IotMqttWsDownstreamHandler(IotDeviceMessageService deviceMessageService,
  42 + IotDeviceService deviceService,
  43 + IotMqttWsConnectionManager connectionManager) {
  44 + this.deviceMessageService = deviceMessageService;
  45 + this.deviceService = deviceService;
  46 + this.connectionManager = connectionManager;
  47 + }
  48 +
  49 + /**
  50 + * 处理下行消息
  51 + *
  52 + * @param message 设备消息
  53 + * @return 是否处理成功
  54 + */
  55 + public boolean handleDownstreamMessage(IotDeviceMessage message) {
  56 + try {
  57 + // 1. 基础校验
  58 + if (message == null || message.getDeviceId() == null) {
  59 + log.warn("[handleDownstreamMessage][消息或设备 ID 为空,忽略处理]");
  60 + return false;
  61 + }
  62 +
  63 + // 2. 获取设备信息
  64 + IotDeviceRespDTO deviceInfo = deviceService.getDeviceFromCache(message.getDeviceId());
  65 + if (deviceInfo == null) {
  66 + log.warn("[handleDownstreamMessage][设备不存在,设备 ID:{}]", message.getDeviceId());
  67 + return false;
  68 + }
  69 +
  70 + // 3. 构建设备标识
  71 + String deviceKey = deviceInfo.getProductKey() + ":" + deviceInfo.getDeviceName();
  72 +
  73 + // 4. 检查设备是否在线
  74 + if (!connectionManager.isOnline(deviceKey)) {
  75 + log.warn("[handleDownstreamMessage][设备离线,无法发送消息,deviceKey: {}]", deviceKey);
  76 + return false;
  77 + }
  78 +
  79 + // 5. 构建主题
  80 + String topic = buildDownstreamTopic(message, deviceInfo);
  81 + if (StrUtil.isBlank(topic)) {
  82 + log.warn("[handleDownstreamMessage][主题构建失败,设备 ID:{},方法:{}]",
  83 + message.getDeviceId(), message.getMethod());
  84 + return false;
  85 + }
  86 +
  87 + // 6. 检查设备是否订阅了该主题
  88 + if (!connectionManager.isSubscribed(deviceKey, topic)) {
  89 + log.warn("[handleDownstreamMessage][设备未订阅该主题,deviceKey: {},topic: {}]", deviceKey, topic);
  90 + return false;
  91 + }
  92 +
  93 + // 8. 编码消息
  94 + byte[] payload = deviceMessageService.encodeDeviceMessage(message,
  95 + deviceInfo.getProductKey(), deviceInfo.getDeviceName());
  96 + if (payload == null || payload.length == 0) {
  97 + log.warn("[handleDownstreamMessage][消息编码失败,设备 ID:{}]", message.getDeviceId());
  98 + return false;
  99 + }
  100 +
  101 + // 9. 发送消息到设备
  102 + return sendMessageToDevice(deviceKey, topic, payload, 1);
  103 + } catch (Exception e) {
  104 + if (message != null) {
  105 + log.error("[handleDownstreamMessage][处理下行消息异常,设备 ID:{},错误:{}]",
  106 + message.getDeviceId(), e.getMessage(), e);
  107 + }
  108 + return false;
  109 + }
  110 + }
  111 +
  112 + /**
  113 + * 构建下行消息主题
  114 + *
  115 + * @param message 设备消息
  116 + * @param deviceInfo 设备信息
  117 + * @return 主题
  118 + */
  119 + private String buildDownstreamTopic(IotDeviceMessage message, IotDeviceRespDTO deviceInfo) {
  120 + String method = message.getMethod();
  121 + if (StrUtil.isBlank(method)) {
  122 + return null;
  123 + }
  124 +
  125 + // 使用工具类构建主题,支持回复消息处理
  126 + boolean isReply = IotDeviceMessageUtils.isReplyMessage(message);
  127 + return IotMqttTopicUtils.buildTopicByMethod(method, deviceInfo.getProductKey(),
  128 + deviceInfo.getDeviceName(), isReply);
  129 + }
  130 +
  131 + /**
  132 + * 发送消息到设备
  133 + *
  134 + * @param deviceKey 设备标识(productKey:deviceName)
  135 + * @param topic 主题
  136 + * @param payload 消息内容
  137 + * @param qos QoS 级别
  138 + * @return 是否发送成功
  139 + */
  140 + private boolean sendMessageToDevice(String deviceKey, String topic, byte[] payload, int qos) {
  141 + // 获取设备连接
  142 + ServerWebSocket socket = connectionManager.getConnection(deviceKey);
  143 + if (socket == null) {
  144 + log.warn("[sendMessageToDevice][设备未连接,deviceKey: {}]", deviceKey);
  145 + return false;
  146 + }
  147 +
  148 + try {
  149 + int messageId = qos > 0 ? generateMessageId() : 0;
  150 +
  151 + // 手动编码 MQTT PUBLISH 消息
  152 + io.netty.buffer.ByteBuf byteBuf = io.netty.buffer.Unpooled.buffer();
  153 +
  154 + // 固定头:消息类型(PUBLISH=3) + DUP(0) + QoS + RETAIN
  155 + int fixedHeaderByte1 = 0x30 | (qos << 1); // PUBLISH类型
  156 + byteBuf.writeByte(fixedHeaderByte1);
  157 +
  158 + // 计算剩余长度
  159 + int topicLength = topic.getBytes().length;
  160 + int remainingLength = 2 + topicLength + (qos > 0 ? 2 : 0) + payload.length;
  161 +
  162 + // 写入剩余长度(简化版本,假设小于 128 字节)
  163 + if (remainingLength < 128) {
  164 + byteBuf.writeByte(remainingLength);
  165 + } else {
  166 + // 处理大于 127 的情况
  167 + int x = remainingLength;
  168 + do {
  169 + int encodedByte = x % 128;
  170 + x = x / 128;
  171 + if (x > 0) {
  172 + encodedByte = encodedByte | 128;
  173 + }
  174 + byteBuf.writeByte(encodedByte);
  175 + } while (x > 0);
  176 + }
  177 +
  178 + // 可变头:主题名称
  179 + byteBuf.writeShort(topicLength);
  180 + byteBuf.writeBytes(topic.getBytes());
  181 +
  182 + // 可变头:消息 ID(仅 QoS > 0 时)
  183 + if (qos > 0) {
  184 + byteBuf.writeShort(messageId);
  185 + }
  186 +
  187 + // 有效载荷
  188 + byteBuf.writeBytes(payload);
  189 +
  190 + // 发送
  191 + byte[] bytes = new byte[byteBuf.readableBytes()];
  192 + byteBuf.readBytes(bytes);
  193 + byteBuf.release();
  194 + socket.writeBinaryMessage(Buffer.buffer(bytes));
  195 +
  196 + log.info("[sendMessageToDevice][消息已发送到设备,deviceKey: {},topic: {},qos: {},messageId: {}]",
  197 + deviceKey, topic, qos, messageId);
  198 + return true;
  199 + } catch (Exception e) {
  200 + log.error("[sendMessageToDevice][发送消息到设备失败,deviceKey: {},topic: {}]", deviceKey, topic, e);
  201 + return false;
  202 + }
  203 + }
  204 +
  205 + /**
  206 + * 生成消息 ID
  207 + *
  208 + * @return 消息 ID
  209 + */
  210 + private int generateMessageId() {
  211 + int id = messageIdGenerator.getAndIncrement();
  212 + // MQTT 消息 ID 范围是 1-65535
  213 + // TODO @haohao:并发可能有问题;
  214 + if (id > 65535) {
  215 + messageIdGenerator.set(1);
  216 + return 1;
  217 + }
  218 + return id;
  219 + }
  220 +
  221 +}
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/router/IotMqttWsUpstreamHandler.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.router;
  2 +
  3 +import cn.hutool.core.util.BooleanUtil;
  4 +import cn.hutool.core.util.IdUtil;
  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.module.iot.core.biz.IotDeviceCommonApi;
  9 +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
  10 +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceGetReqDTO;
  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.protocol.mqttws.IotMqttWsUpstreamProtocol;
  15 +import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.manager.IotMqttWsConnectionManager;
  16 +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
  17 +import io.netty.buffer.ByteBuf;
  18 +import io.netty.buffer.Unpooled;
  19 +import io.netty.channel.embedded.EmbeddedChannel;
  20 +import io.netty.handler.codec.DecoderException;
  21 +import io.netty.handler.codec.mqtt.*;
  22 +import io.vertx.core.buffer.Buffer;
  23 +import io.vertx.core.http.ServerWebSocket;
  24 +import lombok.extern.slf4j.Slf4j;
  25 +
  26 +import java.nio.charset.StandardCharsets;
  27 +import java.util.concurrent.ConcurrentHashMap;
  28 +import java.util.concurrent.atomic.AtomicInteger;
  29 +
  30 +/**
  31 + * IoT MQTT WebSocket 上行消息处理器
  32 + * <p>
  33 + * 处理来自设备的 MQTT 消息,包括:
  34 + * - CONNECT:设备连接认证
  35 + * - PUBLISH:设备发布消息
  36 + * - SUBSCRIBE:设备订阅主题
  37 + * - UNSUBSCRIBE:设备取消订阅
  38 + * - PINGREQ:心跳请求
  39 + * - DISCONNECT:设备断开连接
  40 + *
  41 + * @author 芋道源码
  42 + */
  43 +@Slf4j
  44 +public class IotMqttWsUpstreamHandler {
  45 +
  46 + private final IotMqttWsUpstreamProtocol upstreamProtocol;
  47 +
  48 + private final IotDeviceCommonApi deviceApi;
  49 +
  50 + private final IotDeviceMessageService messageService;
  51 +
  52 + private final IotMqttWsConnectionManager connectionManager;
  53 +
  54 + /**
  55 + * 存储 WebSocket 连接到 Socket ID 的映射
  56 + * Key: WebSocket 对象
  57 + * Value: Socket ID(UUID)
  58 + */
  59 + private final ConcurrentHashMap<ServerWebSocket, String> socketIdMap = new ConcurrentHashMap<>();
  60 +
  61 + /**
  62 + * 存储 Socket ID 对应的设备信息
  63 + * Key: Socket ID(UUID)
  64 + * Value: 设备信息
  65 + */
  66 + private final ConcurrentHashMap<String, IotDeviceRespDTO> socketDeviceMap = new ConcurrentHashMap<>();
  67 +
  68 + /**
  69 + * 存储设备的消息 ID 生成器(用于 QoS > 0 的消息)
  70 + */
  71 + private final ConcurrentHashMap<String, AtomicInteger> deviceMessageIdMap = new ConcurrentHashMap<>();
  72 +
  73 + /**
  74 + * MQTT 解码通道(用于解析 WebSocket 中的 MQTT 二进制消息)
  75 + */
  76 + private final ThreadLocal<EmbeddedChannel> decoderChannelThreadLocal = ThreadLocal
  77 + .withInitial(() -> new EmbeddedChannel(new MqttDecoder()));
  78 +
  79 + /**
  80 + * MQTT 编码通道(用于编码 MQTT 响应消息)
  81 + */
  82 + private final ThreadLocal<EmbeddedChannel> encoderChannelThreadLocal = ThreadLocal
  83 + .withInitial(() -> new EmbeddedChannel(MqttEncoder.INSTANCE));
  84 +
  85 + public IotMqttWsUpstreamHandler(IotMqttWsUpstreamProtocol upstreamProtocol,
  86 + IotDeviceMessageService messageService,
  87 + IotMqttWsConnectionManager connectionManager) {
  88 + this.upstreamProtocol = upstreamProtocol;
  89 + this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class);
  90 + this.messageService = messageService;
  91 + this.connectionManager = connectionManager;
  92 + }
  93 +
  94 + /**
  95 + * 处理 WebSocket 连接
  96 + *
  97 + * @param socket WebSocket 连接
  98 + */
  99 + public void handle(ServerWebSocket socket) {
  100 + // 生成唯一的 Socket ID(因为 MQTT 使用二进制协议,textHandlerID() 会返回 null)
  101 + String socketId = IdUtil.simpleUUID();
  102 + socketIdMap.put(socket, socketId);
  103 +
  104 + log.info("[handle][WebSocket 连接建立,socketId: {},remoteAddress: {}]",
  105 + socketId, socket.remoteAddress());
  106 +
  107 + // 设置二进制数据处理器
  108 + socket.binaryMessageHandler(buffer -> {
  109 + try {
  110 + handleMqttMessage(socket, buffer);
  111 + } catch (Exception e) {
  112 + log.error("[handle][处理 MQTT 消息异常,socketId: {}]", socketId, e);
  113 + socket.close();
  114 + }
  115 + });
  116 +
  117 + // 设置关闭处理器
  118 + socket.closeHandler(v -> {
  119 + socketIdMap.remove(socket);
  120 + IotDeviceRespDTO device = socketDeviceMap.remove(socketId);
  121 + if (device != null) {
  122 + String deviceKey = device.getProductKey() + ":" + device.getDeviceName();
  123 + connectionManager.removeConnection(deviceKey);
  124 + deviceMessageIdMap.remove(deviceKey);
  125 + // 发送设备离线消息
  126 + sendOfflineMessage(device);
  127 + log.info("[handle][WebSocket 连接关闭,deviceKey: {},socketId: {}]", deviceKey, socketId);
  128 + }
  129 + });
  130 +
  131 + // 设置异常处理器
  132 + socket.exceptionHandler(e -> {
  133 + log.error("[handle][WebSocket 连接异常,socketId: {}]", socketId, e);
  134 + socketIdMap.remove(socket);
  135 + IotDeviceRespDTO device = socketDeviceMap.remove(socketId);
  136 + if (device != null) {
  137 + String deviceKey = device.getProductKey() + ":" + device.getDeviceName();
  138 + connectionManager.removeConnection(deviceKey);
  139 + deviceMessageIdMap.remove(deviceKey);
  140 + }
  141 + socket.close();
  142 + });
  143 + }
  144 +
  145 + /**
  146 + * 处理 MQTT 消息
  147 + *
  148 + * @param socket WebSocket 连接
  149 + * @param buffer 消息缓冲区
  150 + */
  151 + private void handleMqttMessage(ServerWebSocket socket, Buffer buffer) {
  152 + String socketId = socketIdMap.get(socket);
  153 + ByteBuf byteBuf = Unpooled.wrappedBuffer(buffer.getBytes());
  154 +
  155 + try {
  156 + // 使用 EmbeddedChannel 解码 MQTT 消息
  157 + EmbeddedChannel decoderChannel = decoderChannelThreadLocal.get();
  158 + decoderChannel.writeInbound(byteBuf.retain());
  159 +
  160 + // 读取解码后的消息
  161 + MqttMessage mqttMessage = decoderChannel.readInbound();
  162 + if (mqttMessage == null) {
  163 + log.warn("[handleMqttMessage][MQTT 消息解码失败,socketId: {}]", socketId);
  164 + return;
  165 + }
  166 +
  167 + MqttMessageType messageType = mqttMessage.fixedHeader().messageType();
  168 + log.debug("[handleMqttMessage][收到 MQTT 消息,类型: {},socketId: {}]", messageType, socketId);
  169 +
  170 + // 根据消息类型分发处理
  171 + switch (messageType) {
  172 + case CONNECT:
  173 + handleConnect(socket, (MqttConnectMessage) mqttMessage);
  174 + break;
  175 + case PUBLISH:
  176 + handlePublish(socket, (MqttPublishMessage) mqttMessage);
  177 + break;
  178 + case PUBACK:
  179 + handlePubAck(socket, mqttMessage);
  180 + break;
  181 + case PUBREC:
  182 + handlePubRec(socket, mqttMessage);
  183 + break;
  184 + case PUBREL:
  185 + handlePubRel(socket, mqttMessage);
  186 + break;
  187 + case PUBCOMP:
  188 + handlePubComp(socket, mqttMessage);
  189 + break;
  190 + case SUBSCRIBE:
  191 + handleSubscribe(socket, (MqttSubscribeMessage) mqttMessage);
  192 + break;
  193 + case UNSUBSCRIBE:
  194 + handleUnsubscribe(socket, (MqttUnsubscribeMessage) mqttMessage);
  195 + break;
  196 + case PINGREQ:
  197 + handlePingReq(socket);
  198 + break;
  199 + case DISCONNECT:
  200 + handleDisconnect(socket);
  201 + break;
  202 + default:
  203 + log.warn("[handleMqttMessage][不支持的消息类型: {},socketId: {}]", messageType, socketId);
  204 + }
  205 + } catch (DecoderException e) {
  206 + log.error("[handleMqttMessage][MQTT 消息解码异常,socketId: {}]", socketId, e);
  207 + socket.close();
  208 + } catch (Exception e) {
  209 + log.error("[handleMqttMessage][处理 MQTT 消息失败,socketId: {}]", socketId, e);
  210 + socket.close();
  211 + } finally {
  212 + byteBuf.release();
  213 + }
  214 + }
  215 +
  216 + /**
  217 + * 处理 CONNECT 消息(设备认证)
  218 + */
  219 + private void handleConnect(ServerWebSocket socket, MqttConnectMessage message) {
  220 + String socketId = socketIdMap.get(socket);
  221 + try {
  222 + // 1. 解析 CONNECT 消息
  223 + MqttConnectPayload payload = message.payload();
  224 + String clientId = payload.clientIdentifier();
  225 + String username = payload.userName();
  226 + String password = payload.passwordInBytes() != null
  227 + ? new String(payload.passwordInBytes(), StandardCharsets.UTF_8)
  228 + : null;
  229 +
  230 + log.info("[handleConnect][收到 CONNECT 消息,clientId: {},username: {},socketId: {}]",
  231 + clientId, username, socketId);
  232 +
  233 + // 2. 设备认证
  234 + IotDeviceRespDTO device = authenticateDevice(clientId, username, password);
  235 + if (device == null) {
  236 + log.warn("[handleConnect][设备认证失败,clientId: {},socketId: {}]", clientId, socketId);
  237 + sendConnAck(socket, MqttConnectReturnCode.CONNECTION_REFUSED_BAD_USER_NAME_OR_PASSWORD);
  238 + socket.close();
  239 + return;
  240 + }
  241 +
  242 + // 3. 保存设备信息
  243 + socketDeviceMap.put(socketId, device);
  244 + String deviceKey = device.getProductKey() + ":" + device.getDeviceName();
  245 + connectionManager.addConnection(deviceKey, socket, socketId);
  246 + deviceMessageIdMap.put(deviceKey, new AtomicInteger(1));
  247 +
  248 + log.info("[handleConnect][设备认证成功,deviceId: {},deviceKey: {},socketId: {}]",
  249 + device.getId(), deviceKey, socketId);
  250 +
  251 + // 4. 发送 CONNACK
  252 + sendConnAck(socket, MqttConnectReturnCode.CONNECTION_ACCEPTED);
  253 +
  254 + // 5. 发送设备上线消息
  255 + sendOnlineMessage(device);
  256 + } catch (Exception e) {
  257 + log.error("[handleConnect][处理 CONNECT 消息失败,socketId: {}]", socketId, e);
  258 + sendConnAck(socket, MqttConnectReturnCode.CONNECTION_REFUSED_SERVER_UNAVAILABLE);
  259 + socket.close();
  260 + }
  261 + }
  262 +
  263 + /**
  264 + * 处理 PUBLISH 消息(设备发布消息)
  265 + */
  266 + private void handlePublish(ServerWebSocket socket, MqttPublishMessage message) {
  267 + String socketId = socketIdMap.get(socket);
  268 + IotDeviceRespDTO device = socketDeviceMap.get(socketId);
  269 +
  270 + if (device == null) {
  271 + log.warn("[handlePublish][设备未认证,socketId: {}]", socketId);
  272 + socket.close();
  273 + return;
  274 + }
  275 +
  276 + try {
  277 + // 1. 解析 PUBLISH 消息
  278 + MqttFixedHeader fixedHeader = message.fixedHeader();
  279 + MqttPublishVariableHeader variableHeader = message.variableHeader();
  280 + ByteBuf payload = message.payload();
  281 +
  282 + String topic = variableHeader.topicName();
  283 + int messageId = variableHeader.packetId();
  284 + MqttQoS qos = fixedHeader.qosLevel();
  285 +
  286 + log.debug("[handlePublish][收到 PUBLISH 消息,topic: {},messageId: {},QoS: {},deviceId: {}]",
  287 + topic, messageId, qos, device.getId());
  288 +
  289 + // 2. 读取 payload
  290 + byte[] payloadBytes = new byte[payload.readableBytes()];
  291 + payload.readBytes(payloadBytes);
  292 +
  293 + // 3. 解码并发送消息
  294 + IotDeviceMessage deviceMessage = messageService.decodeDeviceMessage(payloadBytes,
  295 + device.getProductKey(), device.getDeviceName());
  296 + if (deviceMessage != null) {
  297 + deviceMessage.setServerId(upstreamProtocol.getServerId());
  298 + messageService.sendDeviceMessage(deviceMessage, device.getProductKey(),
  299 + device.getDeviceName(), upstreamProtocol.getServerId());
  300 + log.info("[handlePublish][设备消息已发送,method: {},deviceId: {}]",
  301 + deviceMessage.getMethod(), device.getId());
  302 + }
  303 +
  304 + // 4. 根据 QoS 级别发送相应的确认消息
  305 + if (qos == MqttQoS.AT_LEAST_ONCE) {
  306 + // QoS 1:发送 PUBACK
  307 + sendPubAck(socket, messageId);
  308 + } else if (qos == MqttQoS.EXACTLY_ONCE) {
  309 + // QoS 2:发送 PUBREC
  310 + sendPubRec(socket, messageId);
  311 + }
  312 + // QoS 0 无需确认
  313 + } catch (Exception e) {
  314 + log.error("[handlePublish][处理 PUBLISH 消息失败,deviceId: {}]", device.getId(), e);
  315 + }
  316 + }
  317 +
  318 + /**
  319 + * 处理 PUBACK 消息(QoS 1 确认)
  320 + */
  321 + private void handlePubAck(ServerWebSocket socket, MqttMessage message) {
  322 + String socketId = socketIdMap.get(socket);
  323 + IotDeviceRespDTO device = socketDeviceMap.get(socketId);
  324 + if (device == null) {
  325 + log.warn("[handlePubAck][设备未认证,socketId: {}]", socketId);
  326 + socket.close();
  327 + return;
  328 + }
  329 +
  330 + int messageId = ((MqttMessageIdVariableHeader) message.variableHeader()).messageId();
  331 + log.debug("[handlePubAck][收到 PUBACK,messageId: {},deviceId: {}]", messageId, device.getId());
  332 + }
  333 +
  334 + /**
  335 + * 处理 PUBREC 消息(QoS 2 第一步确认)
  336 + */
  337 + private void handlePubRec(ServerWebSocket socket, MqttMessage message) {
  338 + String socketId = socketIdMap.get(socket);
  339 + IotDeviceRespDTO device = socketDeviceMap.get(socketId);
  340 + if (device == null) {
  341 + log.warn("[handlePubRec][设备未认证,socketId: {}]", socketId);
  342 + socket.close();
  343 + return;
  344 + }
  345 +
  346 + int messageId = ((MqttMessageIdVariableHeader) message.variableHeader()).messageId();
  347 + log.debug("[handlePubRec][收到 PUBREC,messageId: {},deviceId: {}]", messageId, device.getId());
  348 + // 发送 PUBREL
  349 + sendPubRel(socket, messageId);
  350 + }
  351 +
  352 + /**
  353 + * 处理 PUBREL 消息(QoS 2 第二步)
  354 + */
  355 + private void handlePubRel(ServerWebSocket socket, MqttMessage message) {
  356 + String socketId = socketIdMap.get(socket);
  357 + IotDeviceRespDTO device = socketDeviceMap.get(socketId);
  358 +
  359 + if (device == null) {
  360 + log.warn("[handlePubRel][设备未认证,socketId: {}]", socketId);
  361 + socket.close();
  362 + return;
  363 + }
  364 +
  365 + int messageId = ((MqttMessageIdVariableHeader) message.variableHeader()).messageId();
  366 + log.debug("[handlePubRel][收到 PUBREL,messageId: {},deviceId: {}]", messageId, device.getId());
  367 + // 发送 PUBCOMP
  368 + sendPubComp(socket, messageId);
  369 + }
  370 +
  371 + /**
  372 + * 处理 PUBCOMP 消息(QoS 2 完成确认)
  373 + */
  374 + private void handlePubComp(ServerWebSocket socket, MqttMessage message) {
  375 + String socketId = socketIdMap.get(socket);
  376 + IotDeviceRespDTO device = socketDeviceMap.get(socketId);
  377 +
  378 + if (device == null) {
  379 + log.warn("[handlePubComp][设备未认证,socketId: {}]", socketId);
  380 + socket.close();
  381 + return;
  382 + }
  383 +
  384 + int messageId = ((MqttMessageIdVariableHeader) message.variableHeader()).messageId();
  385 + log.debug("[handlePubComp][收到 PUBCOMP,messageId: {},deviceId: {}]", messageId, device.getId());
  386 + }
  387 +
  388 + /**
  389 + * 处理 SUBSCRIBE 消息(设备订阅主题)
  390 + */
  391 + private void handleSubscribe(ServerWebSocket socket, MqttSubscribeMessage message) {
  392 + String socketId = socketIdMap.get(socket);
  393 + IotDeviceRespDTO device = socketDeviceMap.get(socketId);
  394 + if (device == null) {
  395 + log.warn("[handleSubscribe][设备未认证,socketId: {}]", socketId);
  396 + socket.close();
  397 + return;
  398 + }
  399 +
  400 + try {
  401 + // 1. 解析 SUBSCRIBE 消息
  402 + int messageId = message.variableHeader().messageId();
  403 + MqttSubscribePayload payload = message.payload();
  404 + String deviceKey = device.getProductKey() + ":" + device.getDeviceName();
  405 +
  406 + log.info("[handleSubscribe][设备订阅请求,deviceKey: {},messageId: {},主题数量: {}]",
  407 + deviceKey, messageId, payload.topicSubscriptions().size());
  408 +
  409 + // 2. 构建 QoS 列表并记录订阅信息
  410 + int[] grantedQosList = new int[payload.topicSubscriptions().size()];
  411 + for (int i = 0; i < payload.topicSubscriptions().size(); i++) {
  412 + MqttTopicSubscription subscription = payload.topicSubscriptions().get(i);
  413 + String topic = subscription.topicFilter();
  414 + grantedQosList[i] = subscription.qualityOfService().value();
  415 +
  416 + // 记录订阅信息到连接管理器
  417 + connectionManager.addSubscription(deviceKey, topic);
  418 +
  419 + log.info("[handleSubscribe][订阅主题: {},QoS: {},deviceKey: {}]",
  420 + topic, subscription.qualityOfService(), deviceKey);
  421 + }
  422 +
  423 + // 3. 发送 SUBACK
  424 + sendSubAck(socket, messageId, grantedQosList);
  425 + } catch (Exception e) {
  426 + log.error("[handleSubscribe][处理 SUBSCRIBE 消息失败,deviceId: {}]", device.getId(), e);
  427 + }
  428 + }
  429 +
  430 + /**
  431 + * 处理 UNSUBSCRIBE 消息(设备取消订阅)
  432 + */
  433 + private void handleUnsubscribe(ServerWebSocket socket, MqttUnsubscribeMessage message) {
  434 + String socketId = socketIdMap.get(socket);
  435 + IotDeviceRespDTO device = socketDeviceMap.get(socketId);
  436 + if (device == null) {
  437 + log.warn("[handleUnsubscribe][设备未认证,socketId: {}]", socketId);
  438 + socket.close();
  439 + return;
  440 + }
  441 +
  442 + try {
  443 + // 1. 解析 UNSUBSCRIBE 消息
  444 + int messageId = message.variableHeader().messageId();
  445 + MqttUnsubscribePayload payload = message.payload();
  446 + String deviceKey = device.getProductKey() + ":" + device.getDeviceName();
  447 +
  448 + log.info("[handleUnsubscribe][设备取消订阅,deviceKey: {},messageId: {},主题数量: {}]",
  449 + deviceKey, messageId, payload.topics().size());
  450 +
  451 + // 2. 移除订阅信息
  452 + for (String topic : payload.topics()) {
  453 + connectionManager.removeSubscription(deviceKey, topic);
  454 + log.info("[handleUnsubscribe][取消订阅主题: {},deviceKey: {}]", topic, deviceKey);
  455 + }
  456 +
  457 + // 3. 发送 UNSUBACK
  458 + sendUnsubAck(socket, messageId);
  459 + } catch (Exception e) {
  460 + log.error("[handleUnsubscribe][处理 UNSUBSCRIBE 消息失败,deviceId: {}]", device.getId(), e);
  461 + }
  462 + }
  463 +
  464 + /**
  465 + * 处理 PINGREQ 消息(心跳请求)
  466 + */
  467 + private void handlePingReq(ServerWebSocket socket) {
  468 + String socketId = socketIdMap.get(socket);
  469 + IotDeviceRespDTO device = socketDeviceMap.get(socketId);
  470 + if (device == null) {
  471 + log.warn("[handlePingReq][设备未认证,socketId: {}]", socketId);
  472 + socket.close();
  473 + return;
  474 + }
  475 +
  476 + log.debug("[handlePingReq][收到心跳请求,deviceId: {}]", device.getId());
  477 + // 发送 PINGRESP
  478 + sendPingResp(socket);
  479 + }
  480 +
  481 + /**
  482 + * 处理 DISCONNECT 消息(设备断开连接)
  483 + */
  484 + private void handleDisconnect(ServerWebSocket socket) {
  485 + String socketId = socketIdMap.get(socket);
  486 + IotDeviceRespDTO device = socketDeviceMap.remove(socketId);
  487 + if (device != null) {
  488 + String deviceKey = device.getProductKey() + ":" + device.getDeviceName();
  489 + connectionManager.removeConnection(deviceKey);
  490 + deviceMessageIdMap.remove(deviceKey);
  491 + sendOfflineMessage(device);
  492 + log.info("[handleDisconnect][设备主动断开连接,deviceKey: {}]", deviceKey);
  493 + }
  494 +
  495 + socket.close();
  496 + }
  497 +
  498 + // ==================== 设备认证和状态相关方法 ====================
  499 +
  500 + /**
  501 + * 设备认证
  502 + */
  503 + private IotDeviceRespDTO authenticateDevice(String clientId, String username, String password) {
  504 + try {
  505 + // 1. 参数校验
  506 + if (StrUtil.hasEmpty(clientId, username, password)) {
  507 + log.warn("[authenticateDevice][认证参数不完整,clientId: {},username: {}]", clientId, username);
  508 + return null;
  509 + }
  510 +
  511 + // 2. 构建认证参数并调用 API
  512 + IotDeviceAuthReqDTO authParams = new IotDeviceAuthReqDTO()
  513 + .setClientId(clientId)
  514 + .setUsername(username)
  515 + .setPassword(password);
  516 +
  517 + CommonResult<Boolean> authResult = deviceApi.authDevice(authParams);
  518 + if (!authResult.isSuccess() || !BooleanUtil.isTrue(authResult.getData())) {
  519 + log.warn("[authenticateDevice][设备认证失败,clientId: {}]", clientId);
  520 + return null;
  521 + }
  522 +
  523 + // 3. 获取设备信息
  524 + IotDeviceAuthUtils.DeviceInfo deviceInfo = IotDeviceAuthUtils.parseUsername(username);
  525 + if (deviceInfo == null) {
  526 + log.warn("[authenticateDevice][用户名格式不正确,username: {}]", username);
  527 + return null;
  528 + }
  529 +
  530 + IotDeviceGetReqDTO getReqDTO = new IotDeviceGetReqDTO()
  531 + .setProductKey(deviceInfo.getProductKey())
  532 + .setDeviceName(deviceInfo.getDeviceName());
  533 +
  534 + CommonResult<IotDeviceRespDTO> deviceResult = deviceApi.getDevice(getReqDTO);
  535 + if (!deviceResult.isSuccess() || deviceResult.getData() == null) {
  536 + log.warn("[authenticateDevice][获取设备信息失败,username: {}]", username);
  537 + return null;
  538 + }
  539 +
  540 + return deviceResult.getData();
  541 + } catch (Exception e) {
  542 + log.error("[authenticateDevice][设备认证异常,clientId: {}]", clientId, e);
  543 + return null;
  544 + }
  545 + }
  546 +
  547 + /**
  548 + * 发送设备上线消息
  549 + */
  550 + private void sendOnlineMessage(IotDeviceRespDTO device) {
  551 + try {
  552 + IotDeviceMessage onlineMessage = IotDeviceMessage.buildStateUpdateOnline();
  553 + messageService.sendDeviceMessage(onlineMessage, device.getProductKey(),
  554 + device.getDeviceName(), upstreamProtocol.getServerId());
  555 + log.info("[sendOnlineMessage][设备上线,deviceId: {}]", device.getId());
  556 + } catch (Exception e) {
  557 + log.error("[sendOnlineMessage][发送设备上线消息失败,deviceId: {}]", device.getId(), e);
  558 + }
  559 + }
  560 +
  561 + /**
  562 + * 发送设备离线消息
  563 + */
  564 + private void sendOfflineMessage(IotDeviceRespDTO device) {
  565 + try {
  566 + IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline();
  567 + messageService.sendDeviceMessage(offlineMessage, device.getProductKey(),
  568 + device.getDeviceName(), upstreamProtocol.getServerId());
  569 + log.info("[sendOfflineMessage][设备离线,deviceId: {}]", device.getId());
  570 + } catch (Exception e) {
  571 + log.error("[sendOfflineMessage][发送设备离线消息失败,deviceId: {}]", device.getId(), e);
  572 + }
  573 + }
  574 +
  575 + // ==================== 发送响应消息的辅助方法 ====================
  576 +
  577 + /**
  578 + * 发送 CONNACK 消息
  579 + */
  580 + private void sendConnAck(ServerWebSocket socket, MqttConnectReturnCode returnCode) {
  581 + try {
  582 + // 构建 CONNACK 消息
  583 + MqttFixedHeader fixedHeader = new MqttFixedHeader(
  584 + MqttMessageType.CONNACK, false, MqttQoS.AT_MOST_ONCE, false, 0);
  585 + MqttConnAckVariableHeader variableHeader = new MqttConnAckVariableHeader(returnCode, false);
  586 + MqttConnAckMessage connAckMessage = new MqttConnAckMessage(fixedHeader, variableHeader);
  587 +
  588 + // 编码并发送
  589 + sendMqttMessage(socket, connAckMessage);
  590 + log.debug("[sendConnAck][发送 CONNACK 消息,returnCode: {}]", returnCode);
  591 + } catch (Exception e) {
  592 + log.error("[sendConnAck][发送 CONNACK 消息失败]", e);
  593 + }
  594 + }
  595 +
  596 + /**
  597 + * 发送 PUBACK 消息(QoS 1 确认)
  598 + */
  599 + private void sendPubAck(ServerWebSocket socket, int messageId) {
  600 + try {
  601 + // 构建 PUBACK 消息
  602 + MqttFixedHeader fixedHeader = new MqttFixedHeader(
  603 + MqttMessageType.PUBACK, false, MqttQoS.AT_MOST_ONCE, false, 0);
  604 + MqttMessageIdVariableHeader variableHeader = MqttMessageIdVariableHeader.from(messageId);
  605 + MqttMessage pubAckMessage = new MqttMessage(fixedHeader, variableHeader);
  606 +
  607 + // 编码并发送
  608 + sendMqttMessage(socket, pubAckMessage);
  609 + log.debug("[sendPubAck][发送 PUBACK 消息,messageId: {}]", messageId);
  610 + } catch (Exception e) {
  611 + log.error("[sendPubAck][发送 PUBACK 消息失败,messageId: {}]", messageId, e);
  612 + }
  613 + }
  614 +
  615 + /**
  616 + * 发送 PUBREC 消息(QoS 2 第一步确认)
  617 + */
  618 + private void sendPubRec(ServerWebSocket socket, int messageId) {
  619 + try {
  620 + // 构建 PUBREC 消息
  621 + MqttFixedHeader fixedHeader = new MqttFixedHeader(
  622 + MqttMessageType.PUBREC, false, MqttQoS.AT_MOST_ONCE, false, 0);
  623 + MqttMessageIdVariableHeader variableHeader = MqttMessageIdVariableHeader.from(messageId);
  624 + MqttMessage pubRecMessage = new MqttMessage(fixedHeader, variableHeader);
  625 +
  626 + // 编码并发送
  627 + sendMqttMessage(socket, pubRecMessage);
  628 + log.debug("[sendPubRec][发送 PUBREC 消息,messageId: {}]", messageId);
  629 + } catch (Exception e) {
  630 + log.error("[sendPubRec][发送 PUBREC 消息失败,messageId: {}]", messageId, e);
  631 + }
  632 + }
  633 +
  634 + /**
  635 + * 发送 PUBREL 消息(QoS 2 第二步)
  636 + */
  637 + private void sendPubRel(ServerWebSocket socket, int messageId) {
  638 + try {
  639 + // 构建 PUBREL 消息
  640 + MqttFixedHeader fixedHeader = new MqttFixedHeader(
  641 + MqttMessageType.PUBREL, false, MqttQoS.AT_LEAST_ONCE, false, 0);
  642 + MqttMessageIdVariableHeader variableHeader = MqttMessageIdVariableHeader.from(messageId);
  643 + MqttMessage pubRelMessage = new MqttMessage(fixedHeader, variableHeader);
  644 +
  645 + // 编码并发送
  646 + sendMqttMessage(socket, pubRelMessage);
  647 + log.debug("[sendPubRel][发送 PUBREL 消息,messageId: {}]", messageId);
  648 + } catch (Exception e) {
  649 + log.error("[sendPubRel][发送 PUBREL 消息失败,messageId: {}]", messageId, e);
  650 + }
  651 + }
  652 +
  653 + /**
  654 + * 发送 PUBCOMP 消息(QoS 2 完成确认)
  655 + */
  656 + private void sendPubComp(ServerWebSocket socket, int messageId) {
  657 + try {
  658 + // 构建 PUBCOMP 消息
  659 + MqttFixedHeader fixedHeader = new MqttFixedHeader(
  660 + MqttMessageType.PUBCOMP, false, MqttQoS.AT_MOST_ONCE, false, 0);
  661 + MqttMessageIdVariableHeader variableHeader = MqttMessageIdVariableHeader.from(messageId);
  662 + MqttMessage pubCompMessage = new MqttMessage(fixedHeader, variableHeader);
  663 +
  664 + // 编码并发送
  665 + sendMqttMessage(socket, pubCompMessage);
  666 + log.debug("[sendPubComp][发送 PUBCOMP 消息,messageId: {}]", messageId);
  667 + } catch (Exception e) {
  668 + log.error("[sendPubComp][发送 PUBCOMP 消息失败,messageId: {}]", messageId, e);
  669 + }
  670 + }
  671 +
  672 + /**
  673 + * 发送 SUBACK 消息
  674 + */
  675 + private void sendSubAck(ServerWebSocket socket, int messageId, int[] grantedQosList) {
  676 + try {
  677 + // 构建 SUBACK 消息
  678 + MqttFixedHeader fixedHeader = new MqttFixedHeader(
  679 + MqttMessageType.SUBACK, false, MqttQoS.AT_MOST_ONCE, false, 0);
  680 + MqttMessageIdVariableHeader variableHeader = MqttMessageIdVariableHeader.from(messageId);
  681 + MqttSubAckPayload payload = new MqttSubAckPayload(grantedQosList);
  682 + MqttSubAckMessage subAckMessage = new MqttSubAckMessage(fixedHeader, variableHeader, payload);
  683 +
  684 + // 编码并发送
  685 + sendMqttMessage(socket, subAckMessage);
  686 + log.debug("[sendSubAck][发送 SUBACK 消息,messageId: {},主题数量: {}]", messageId, grantedQosList.length);
  687 + } catch (Exception e) {
  688 + log.error("[sendSubAck][发送 SUBACK 消息失败,messageId: {}]", messageId, e);
  689 + }
  690 + }
  691 +
  692 + /**
  693 + * 发送 UNSUBACK 消息
  694 + */
  695 + private void sendUnsubAck(ServerWebSocket socket, int messageId) {
  696 + try {
  697 + // 构建 UNSUBACK 消息
  698 + MqttFixedHeader fixedHeader = new MqttFixedHeader(
  699 + MqttMessageType.UNSUBACK, false, MqttQoS.AT_MOST_ONCE, false, 0);
  700 + MqttMessageIdVariableHeader variableHeader = MqttMessageIdVariableHeader.from(messageId);
  701 + MqttUnsubAckMessage unsubAckMessage = new MqttUnsubAckMessage(fixedHeader, variableHeader);
  702 +
  703 + // 编码并发送
  704 + sendMqttMessage(socket, unsubAckMessage);
  705 + log.debug("[sendUnsubAck][发送 UNSUBACK 消息,messageId: {}]", messageId);
  706 + } catch (Exception e) {
  707 + log.error("[sendUnsubAck][发送 UNSUBACK 消息失败,messageId: {}]", messageId, e);
  708 + }
  709 + }
  710 +
  711 + /**
  712 + * 发送 PINGRESP 消息
  713 + */
  714 + private void sendPingResp(ServerWebSocket socket) {
  715 + try {
  716 + // 构建 PINGRESP 消息
  717 + MqttFixedHeader fixedHeader = new MqttFixedHeader(
  718 + MqttMessageType.PINGRESP, false, MqttQoS.AT_MOST_ONCE, false, 0);
  719 + MqttMessage pingRespMessage = new MqttMessage(fixedHeader);
  720 +
  721 + // 编码并发送
  722 + sendMqttMessage(socket, pingRespMessage);
  723 + log.debug("[sendPingResp][发送 PINGRESP 消息]");
  724 + } catch (Exception e) {
  725 + log.error("[sendPingResp][发送 PINGRESP 消息失败]", e);
  726 + }
  727 + }
  728 +
  729 + /**
  730 + * 发送 MQTT 消息到 WebSocket
  731 + */
  732 + private void sendMqttMessage(ServerWebSocket socket, MqttMessage mqttMessage) {
  733 + ByteBuf byteBuf = null;
  734 + try {
  735 + // 使用 EmbeddedChannel 编码 MQTT 消息
  736 + EmbeddedChannel encoderChannel = encoderChannelThreadLocal.get();
  737 + encoderChannel.writeOutbound(mqttMessage);
  738 +
  739 + // 读取编码后的 ByteBuf
  740 + byteBuf = encoderChannel.readOutbound();
  741 + if (byteBuf != null) {
  742 + byte[] bytes = new byte[byteBuf.readableBytes()];
  743 + byteBuf.readBytes(bytes);
  744 + socket.writeBinaryMessage(Buffer.buffer(bytes));
  745 + }
  746 + } finally {
  747 + if (byteBuf != null) {
  748 + byteBuf.release();
  749 + }
  750 + }
  751 + }
  752 +
  753 +}
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/resources/application.yaml
... ... @@ -99,11 +99,24 @@ yudao:
99 99 # 针对引入的 MQTT 组件的配置
100 100 # ====================================
101 101 mqtt:
102   - enabled: true
  102 + enabled: false
103 103 port: 1883
104 104 max-message-size: 8192
105 105 connect-timeout-seconds: 60
106 106 ssl-enabled: false
  107 + # ====================================
  108 + # 针对引入的 MQTT WebSocket 组件的配置
  109 + # ====================================
  110 + mqtt-ws:
  111 + enabled: false # 是否启用 MQTT WebSocket
  112 + port: 8083 # WebSocket 服务端口
  113 + path: /mqtt # WebSocket 路径
  114 + max-message-size: 8192 # 最大消息大小(字节)
  115 + max-frame-size: 65536 # 最大帧大小(字节)
  116 + connect-timeout-seconds: 60 # 连接超时时间(秒)
  117 + keep-alive-timeout-seconds: 300 # 保持连接超时时间(秒)
  118 + ssl-enabled: false # 是否启用 SSL(wss://)
  119 + sub-protocol: mqtt # WebSocket 子协议
107 120  
108 121 --- #################### 日志相关配置 ####################
109 122  
... ... @@ -123,6 +136,7 @@ logging:
123 136 cn.iocoder.yudao.module.iot.gateway.protocol.emqx: DEBUG
124 137 cn.iocoder.yudao.module.iot.gateway.protocol.http: DEBUG
125 138 cn.iocoder.yudao.module.iot.gateway.protocol.mqtt: DEBUG
  139 + cn.iocoder.yudao.module.iot.gateway.protocol.mqttws: DEBUG
126 140 # 根日志级别
127 141 root: INFO
128 142  
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/resources/mqtt-websocket-test-client.html 0 → 100644
  1 +<!DOCTYPE html>
  2 +<html lang="zh-CN">
  3 +<head>
  4 + <meta charset="UTF-8">
  5 + <meta content="width=device-width, initial-scale=1.0" name="viewport">
  6 + <title>MQTT WebSocket 测试客户端</title>
  7 + <style>
  8 + * {
  9 + margin: 0;
  10 + padding: 0;
  11 + box-sizing: border-box;
  12 + }
  13 +
  14 + body {
  15 + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
  16 + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  17 + padding: 20px;
  18 + min-height: 100vh;
  19 + }
  20 +
  21 + .container {
  22 + max-width: 1200px;
  23 + margin: 0 auto;
  24 + background: white;
  25 + border-radius: 10px;
  26 + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
  27 + overflow: hidden;
  28 + }
  29 +
  30 + .header {
  31 + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  32 + color: white;
  33 + padding: 30px;
  34 + text-align: center;
  35 + }
  36 +
  37 + .header h1 {
  38 + font-size: 28px;
  39 + margin-bottom: 10px;
  40 + }
  41 +
  42 + .header p {
  43 + opacity: 0.9;
  44 + }
  45 +
  46 + .info-box {
  47 + background: #f8f9fa;
  48 + border-left: 4px solid #667eea;
  49 + padding: 15px;
  50 + margin: 20px;
  51 + border-radius: 5px;
  52 + }
  53 +
  54 + .info-box h3 {
  55 + color: #667eea;
  56 + margin-bottom: 10px;
  57 + font-size: 16px;
  58 + }
  59 +
  60 + .info-box ul {
  61 + margin-left: 20px;
  62 + color: #666;
  63 + font-size: 14px;
  64 + line-height: 1.6;
  65 + }
  66 +
  67 + .info-box code {
  68 + background: #e9ecef;
  69 + padding: 2px 6px;
  70 + border-radius: 3px;
  71 + font-family: 'Courier New', monospace;
  72 + color: #d63384;
  73 + }
  74 +
  75 + .content {
  76 + display: grid;
  77 + grid-template-columns: 1fr 1fr;
  78 + gap: 20px;
  79 + padding: 30px;
  80 + }
  81 +
  82 + .panel {
  83 + background: #f8f9fa;
  84 + border-radius: 8px;
  85 + padding: 20px;
  86 + }
  87 +
  88 + .panel h2 {
  89 + color: #667eea;
  90 + margin-bottom: 20px;
  91 + font-size: 20px;
  92 + border-bottom: 2px solid #667eea;
  93 + padding-bottom: 10px;
  94 + }
  95 +
  96 + .form-group {
  97 + margin-bottom: 15px;
  98 + }
  99 +
  100 + .form-group label {
  101 + display: block;
  102 + margin-bottom: 5px;
  103 + color: #333;
  104 + font-weight: 500;
  105 + }
  106 +
  107 + .form-group input,
  108 + .form-group select,
  109 + .form-group textarea {
  110 + width: 100%;
  111 + padding: 10px;
  112 + border: 1px solid #ddd;
  113 + border-radius: 4px;
  114 + font-size: 14px;
  115 + }
  116 +
  117 + .form-group input:focus,
  118 + .form-group select:focus,
  119 + .form-group textarea:focus {
  120 + outline: none;
  121 + border-color: #667eea;
  122 + }
  123 +
  124 + .form-group textarea {
  125 + resize: vertical;
  126 + min-height: 80px;
  127 + font-family: monospace;
  128 + }
  129 +
  130 + .btn {
  131 + padding: 10px 20px;
  132 + border: none;
  133 + border-radius: 4px;
  134 + cursor: pointer;
  135 + font-size: 14px;
  136 + font-weight: 500;
  137 + transition: all 0.3s;
  138 + }
  139 +
  140 + .btn-primary {
  141 + background: #667eea;
  142 + color: white;
  143 + }
  144 +
  145 + .btn-primary:hover {
  146 + background: #5568d3;
  147 + }
  148 +
  149 + .btn-success {
  150 + background: #28a745;
  151 + color: white;
  152 + }
  153 +
  154 + .btn-success:hover {
  155 + background: #218838;
  156 + }
  157 +
  158 + .btn-danger {
  159 + background: #dc3545;
  160 + color: white;
  161 + }
  162 +
  163 + .btn-danger:hover {
  164 + background: #c82333;
  165 + }
  166 +
  167 + .btn-warning {
  168 + background: #ffc107;
  169 + color: #333;
  170 + }
  171 +
  172 + .btn-warning:hover {
  173 + background: #e0a800;
  174 + }
  175 +
  176 + .btn-group {
  177 + display: flex;
  178 + gap: 10px;
  179 + margin-top: 15px;
  180 + }
  181 +
  182 + .status {
  183 + padding: 10px;
  184 + border-radius: 4px;
  185 + margin-bottom: 20px;
  186 + font-weight: 500;
  187 + }
  188 +
  189 + .status.disconnected {
  190 + background: #f8d7da;
  191 + color: #721c24;
  192 + }
  193 +
  194 + .status.connected {
  195 + background: #d4edda;
  196 + color: #155724;
  197 + }
  198 +
  199 + .status.connecting {
  200 + background: #fff3cd;
  201 + color: #856404;
  202 + }
  203 +
  204 + .log-area {
  205 + background: #1e1e1e;
  206 + color: #d4d4d4;
  207 + padding: 15px;
  208 + border-radius: 4px;
  209 + height: 400px;
  210 + overflow-y: auto;
  211 + font-family: 'Courier New', monospace;
  212 + font-size: 12px;
  213 + line-height: 1.6;
  214 + }
  215 +
  216 + .log-entry {
  217 + margin-bottom: 5px;
  218 + }
  219 +
  220 + .log-entry.info {
  221 + color: #4ec9b0;
  222 + }
  223 +
  224 + .log-entry.success {
  225 + color: #6a9955;
  226 + }
  227 +
  228 + .log-entry.error {
  229 + color: #f48771;
  230 + }
  231 +
  232 + .log-entry.warning {
  233 + color: #dcdcaa;
  234 + }
  235 +
  236 + .stats {
  237 + display: grid;
  238 + grid-template-columns: repeat(3, 1fr);
  239 + gap: 10px;
  240 + margin-top: 15px;
  241 + }
  242 +
  243 + .stat-item {
  244 + background: white;
  245 + padding: 15px;
  246 + border-radius: 4px;
  247 + text-align: center;
  248 + }
  249 +
  250 + .stat-item .value {
  251 + font-size: 24px;
  252 + font-weight: bold;
  253 + color: #667eea;
  254 + }
  255 +
  256 + .stat-item .label {
  257 + font-size: 12px;
  258 + color: #666;
  259 + margin-top: 5px;
  260 + }
  261 +
  262 + @media (max-width: 768px) {
  263 + .content {
  264 + grid-template-columns: 1fr;
  265 + }
  266 + }
  267 + </style>
  268 +</head>
  269 +<body>
  270 +<div class="container">
  271 + <div class="header">
  272 + <h1>🚀 MQTT WebSocket 测试客户端</h1>
  273 + <p>RuoYi-Vue-Pro IoT 模块 - MQTT over WebSocket 在线测试工具</p>
  274 + </div>
  275 +
  276 + <!-- 协议格式说明 -->
  277 + <div class="info-box">
  278 + <h3>📌 标准协议格式说明</h3>
  279 + <ul>
  280 + <li><strong>Topic 格式:</strong><code>/sys/{productKey}/{deviceName}/thing/property/post</code></li>
  281 + <li><strong>Client ID 格式:</strong><code>{productKey}.{deviceName}</code> 例如:<code>zOXKLvHjUqTo7ipD.ceshi001</code>
  282 + </li>
  283 + <li><strong>Username 格式:</strong><code>{deviceName}&{productKey}</code> 例如:<code>ceshi001&zOXKLvHjUqTo7ipD</code>
  284 + </li>
  285 + <li><strong>消息格式(Alink 协议):</strong>
  286 + <pre style="background: #e9ecef; padding: 10px; border-radius: 5px; margin-top: 5px; overflow-x: auto;">
  287 +{
  288 + "id": "消息 ID(唯一标识)",
  289 + "version": "1.0",
  290 + "method": "thing.property.post",
  291 + "params": {
  292 + "temperature": 25.5,
  293 + "humidity": 60
  294 + }
  295 +}</pre>
  296 + </li>
  297 + <li><strong>常用 Topic(下行 - 服务端推送):</strong>
  298 + <ul style="margin-top: 5px;">
  299 + <li>属性设置:<code>/sys/{pk}/{dn}/thing/property/set</code></li>
  300 + <li>服务调用:<code>/sys/{pk}/{dn}/thing/service/invoke</code></li>
  301 + <li>配置推送:<code>/sys/{pk}/{dn}/thing/config/push</code></li>
  302 + <li>OTA 升级:<code>/sys/{pk}/{dn}/thing/ota/upgrade</code></li>
  303 + </ul>
  304 + </li>
  305 + <li><strong>常用 Topic(上行 - 设备上报):</strong>
  306 + <ul style="margin-top: 5px;">
  307 + <li>状态更新:<code>/sys/{pk}/{dn}/thing/state/update</code></li>
  308 + <li>属性上报:<code>/sys/{pk}/{dn}/thing/property/post</code></li>
  309 + <li>事件上报:<code>/sys/{pk}/{dn}/thing/event/post</code></li>
  310 + <li>OTA 进度:<code>/sys/{pk}/{dn}/thing/ota/progress</code></li>
  311 + </ul>
  312 + </li>
  313 + </ul>
  314 + </div>
  315 +
  316 + <div class="content">
  317 + <!-- 连接配置面板 -->
  318 + <div class="panel">
  319 + <h2>📡 连接配置</h2>
  320 +
  321 + <div class="status disconnected" id="statusBar">
  322 + ⚫ 未连接
  323 + </div>
  324 +
  325 + <div class="form-group">
  326 + <label>服务器地址</label>
  327 + <input id="serverUrl" placeholder="ws://host:port/path" type="text" value="ws://localhost:8083/mqtt">
  328 + <small style="color: #666; font-size: 12px;">WebSocket 地址,支持 ws:// 和 wss://</small>
  329 + </div>
  330 +
  331 + <div class="form-group">
  332 + <label>Client ID</label>
  333 + <input id="clientId" placeholder="设备客户端 ID" type="text" value="fqTn4Afs982Nak4N.jiali001">
  334 + <small style="color: #666; font-size: 12px;">格式:{productKey}.{deviceName}</small>
  335 + </div>
  336 +
  337 + <div class="form-group">
  338 + <label>Username</label>
  339 + <input id="username" placeholder="用户名" type="text" value="jiali001&fqTn4Afs982Nak4N">
  340 + <small style="color: #666; font-size: 12px;">格式:{deviceName}&{productKey}</small>
  341 + </div>
  342 +
  343 + <div class="form-group">
  344 + <label>Password</label>
  345 + <input id="password" placeholder="设备密钥"
  346 + type="password" value="ae10188f93febbb6b37bd57f463b2a795ae2800fab8933aef75d3c6422873f28">
  347 + <small style="color: #666; font-size: 12px;">设备的认证密钥(Device Secret)</small>
  348 + </div>
  349 +
  350 + <div class="btn-group">
  351 + <button class="btn btn-success" id="connectBtn" onclick="connect()">🔌 连接</button>
  352 + <button class="btn btn-danger" disabled id="disconnectBtn" onclick="disconnect()">🔌 断开</button>
  353 + <button class="btn btn-warning" onclick="clearLogs()">🗑️ 清空日志</button>
  354 + </div>
  355 +
  356 + <!-- 统计信息 -->
  357 + <div class="stats">
  358 + <div class="stat-item">
  359 + <div class="value" id="sentCount">0</div>
  360 + <div class="label">发送消息数</div>
  361 + </div>
  362 + <div class="stat-item">
  363 + <div class="value" id="receivedCount">0</div>
  364 + <div class="label">接收消息数</div>
  365 + </div>
  366 + <div class="stat-item">
  367 + <div class="value" id="errorCount">0</div>
  368 + <div class="label">错误次数</div>
  369 + </div>
  370 + </div>
  371 + </div>
  372 +
  373 + <!-- 消息发布面板 -->
  374 + <div class="panel">
  375 + <h2>📤 消息发布</h2>
  376 +
  377 + <div class="form-group">
  378 + <label>快捷主题选择(上行消息 - 设备 → 服务端)</label>
  379 + <select id="quickPublishTopicSelect" onchange="selectQuickPublishTopic()"
  380 + style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 5px;">
  381 + <option value="">-- 选择上行消息类型 --</option>
  382 + <option value="thing.state.update">设备状态更新 (thing.state.update)</option>
  383 + <option value="thing.property.post">属性上报 (thing.property.post)</option>
  384 + <option value="thing.event.post">事件上报 (thing.event.post)</option>
  385 + <option value="thing.ota.progress">OTA 升级进度 (thing.ota.progress)</option>
  386 + </select>
  387 + </div>
  388 +
  389 + <div class="form-group">
  390 + <label>主题 (Topic)</label>
  391 + <input id="pubTopic" placeholder="消息主题,格式:/sys/{productKey}/{deviceName}/thing/property/post" type="text"
  392 + value="/sys/fqTn4Afs982Nak4N/jiali001/thing/property/post">
  393 + <small style="color: #666; font-size: 12px;">标准格式:/sys/{productKey}/{deviceName}/thing/property/post</small>
  394 + </div>
  395 +
  396 + <div class="form-group">
  397 + <label>QoS 级别</label>
  398 + <select id="pubQos">
  399 + <option value="0">0 - 最多一次</option>
  400 + <option selected value="1">1 - 至少一次</option>
  401 + <option value="2">2 - 刚好一次</option>
  402 + </select>
  403 + </div>
  404 +
  405 + <div class="form-group">
  406 + <label>消息内容 (JSON - Alink 协议格式)</label>
  407 + <textarea id="pubMessage" placeholder='Alink 协议格式消息'>{
  408 + "id": "123456789",
  409 + "version": "1.0",
  410 + "method": "thing.property.post",
  411 + "params": {
  412 + "temperature": 25.5,
  413 + "humidity": 60
  414 + }
  415 +}</textarea>
  416 + <small style="color: #666; font-size: 12px;">
  417 + Alink 协议格式:id(消息 ID)、version(协议版本)、method(方法)、params(参数)
  418 + </small>
  419 + </div>
  420 +
  421 + <div class="btn-group">
  422 + <button class="btn btn-primary" onclick="publish()">📤 发布消息</button>
  423 + <button class="btn btn-success" onclick="publishSampleData()">📊 发送样例数据</button>
  424 + </div>
  425 +
  426 + <h2 style="margin-top: 30px;">📥 主题订阅</h2>
  427 +
  428 + <div class="form-group">
  429 + <label>快捷主题选择(下行消息 - 服务端 → 设备)</label>
  430 + <select id="quickTopicSelect" onchange="selectQuickTopic()"
  431 + style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 5px;">
  432 + <option value="">-- 选择下行消息类型 --</option>
  433 + <optgroup label="📥 下行消息">
  434 + <option value="thing.property.set">属性设置 (thing.property.set)</option>
  435 + <option value="thing.service.invoke">服务调用 (thing.service.invoke)</option>
  436 + <option value="thing.config.push">配置推送 (thing.config.push)</option>
  437 + <option value="thing.ota.upgrade">OTA 固件推送 (thing.ota.upgrade)</option>
  438 + </optgroup>
  439 + <optgroup label="🔄 回复主题(上行消息的回复)">
  440 + <option value="thing.property.post_reply">属性上报回复 (thing.property.post_reply)</option>
  441 + <option value="thing.event.post_reply">事件上报回复 (thing.event.post_reply)</option>
  442 + </optgroup>
  443 + <optgroup label="🔧 通配符订阅">
  444 + <option value="wildcard_all">订阅所有主题 (/sys/+/+/#)</option>
  445 + <option value="wildcard_thing">订阅所有 thing 主题 (/sys/+/+/thing/#)</option>
  446 + <option value="wildcard_reply">订阅所有回复主题 (/sys/+/+/#_reply)</option>
  447 + </optgroup>
  448 + </select>
  449 + </div>
  450 +
  451 + <div class="form-group">
  452 + <label>订阅主题</label>
  453 + <input id="subTopic" placeholder="订阅主题,格式:/sys/{productKey}/{deviceName}/thing/property/set" type="text"
  454 + value="/sys/fqTn4Afs982Nak4N/jiali001/thing/property/set">
  455 + <small style="color: #666; font-size: 12px;">标准格式:/sys/{productKey}/{deviceName}/thing/method 或使用通配符
  456 + /sys/+/+/#</small>
  457 + </div>
  458 +
  459 + <div class="form-group">
  460 + <label>QoS 级别</label>
  461 + <select id="subQos">
  462 + <option value="0">0 - 最多一次</option>
  463 + <option selected value="1">1 - 至少一次</option>
  464 + <option value="2">2 - 刚好一次</option>
  465 + </select>
  466 + </div>
  467 +
  468 + <div class="btn-group">
  469 + <button class="btn btn-primary" onclick="subscribe()">📥 订阅</button>
  470 + <button class="btn btn-danger" onclick="unsubscribe()">❌ 取消订阅</button>
  471 + </div>
  472 + </div>
  473 +
  474 + <!-- 日志面板 -->
  475 + <div class="panel" style="grid-column: 1 / -1;">
  476 + <h2>📝 日志输出</h2>
  477 + <div class="log-area" id="logArea"></div>
  478 + </div>
  479 + </div>
  480 +</div>
  481 +
  482 +<!-- 使用 MQTT.js 库 -->
  483 +<script src="https://unpkg.com/mqtt/dist/mqtt.min.js"></script>
  484 +
  485 +<script>
  486 + let client = null;
  487 + let sentCount = 0;
  488 + let receivedCount = 0;
  489 + let errorCount = 0;
  490 +
  491 + // 添加日志
  492 + function addLog(message, type = 'info') {
  493 + const logArea = document.getElementById('logArea');
  494 + const timestamp = new Date().toLocaleTimeString();
  495 + const logEntry = document.createElement('div');
  496 + logEntry.className = `log-entry ${type}`;
  497 + logEntry.textContent = `[${timestamp}] ${message}`;
  498 + logArea.appendChild(logEntry);
  499 + logArea.scrollTop = logArea.scrollHeight;
  500 + }
  501 +
  502 + // 更新状态栏
  503 + function updateStatus(status, text) {
  504 + const statusBar = document.getElementById('statusBar');
  505 + statusBar.className = `status ${status}`;
  506 +
  507 + const icons = {
  508 + 'disconnected': '⚫',
  509 + 'connecting': '🟡',
  510 + 'connected': '🟢'
  511 + };
  512 +
  513 + statusBar.textContent = `${icons[status]} ${text}`;
  514 + }
  515 +
  516 + // 更新统计信息
  517 + function updateStats() {
  518 + document.getElementById('sentCount').textContent = sentCount;
  519 + document.getElementById('receivedCount').textContent = receivedCount;
  520 + document.getElementById('errorCount').textContent = errorCount;
  521 + }
  522 +
  523 + // 连接到服务器
  524 + function connect() {
  525 + const serverUrl = document.getElementById('serverUrl').value;
  526 + const clientId = document.getElementById('clientId').value;
  527 + const username = document.getElementById('username').value;
  528 + const password = document.getElementById('password').value;
  529 +
  530 + if (!serverUrl || !clientId) {
  531 + addLog('❌ 请填写服务器地址和 Client ID', 'error');
  532 + errorCount++;
  533 + updateStats();
  534 + return;
  535 + }
  536 +
  537 + updateStatus('connecting', '正在连接...');
  538 + addLog(`🔄 正在连接到 ${serverUrl}...`, 'info');
  539 +
  540 + const options = {
  541 + clientId: clientId,
  542 + username: username,
  543 + password: password,
  544 + clean: true,
  545 + reconnectPeriod: 5000,
  546 + connectTimeout: 30000,
  547 + };
  548 +
  549 + client = mqtt.connect(serverUrl, options);
  550 +
  551 + // 连接成功
  552 + client.on('connect', () => {
  553 + updateStatus('connected', '已连接');
  554 + addLog('✅ 连接成功!', 'success');
  555 + document.getElementById('connectBtn').disabled = true;
  556 + document.getElementById('disconnectBtn').disabled = false;
  557 + });
  558 +
  559 + // 接收消息
  560 + client.on('message', (topic, message) => {
  561 + receivedCount++;
  562 + updateStats();
  563 + addLog(`📥 收到消息 [${topic}]: ${message.toString()}`, 'success');
  564 + });
  565 +
  566 + // 连接错误
  567 + client.on('error', (error) => {
  568 + errorCount++;
  569 + updateStats();
  570 + addLog(`❌ 连接错误: ${error.message}`, 'error');
  571 + });
  572 +
  573 + // 断开连接
  574 + client.on('close', () => {
  575 + updateStatus('disconnected', '未连接');
  576 + addLog('🔌 连接已断开', 'warning');
  577 + document.getElementById('connectBtn').disabled = false;
  578 + document.getElementById('disconnectBtn').disabled = true;
  579 + });
  580 +
  581 + // 离线
  582 + client.on('offline', () => {
  583 + updateStatus('disconnected', '离线');
  584 + addLog('⚠️ 客户端离线', 'warning');
  585 + });
  586 +
  587 + // 重连
  588 + client.on('reconnect', () => {
  589 + updateStatus('connecting', '正在重连...');
  590 + addLog('🔄 正在重连...', 'info');
  591 + });
  592 + }
  593 +
  594 + // 断开连接
  595 + function disconnect() {
  596 + if (client) {
  597 + client.end();
  598 + addLog('👋 主动断开连接', 'info');
  599 + }
  600 + }
  601 +
  602 + // 发布消息
  603 + function publish() {
  604 + if (!client || !client.connected) {
  605 + addLog('❌ 请先连接到服务器', 'error');
  606 + errorCount++;
  607 + updateStats();
  608 + return;
  609 + }
  610 +
  611 + const topic = document.getElementById('pubTopic').value;
  612 + const qos = parseInt(document.getElementById('pubQos').value);
  613 + const message = document.getElementById('pubMessage').value;
  614 +
  615 + if (!topic || !message) {
  616 + addLog('❌ 请填写主题和消息内容', 'error');
  617 + errorCount++;
  618 + updateStats();
  619 + return;
  620 + }
  621 +
  622 + // 验证 JSON 格式
  623 + try {
  624 + JSON.parse(message);
  625 + } catch (e) {
  626 + addLog('⚠️ 消息不是有效的 JSON 格式,将作为纯文本发送', 'warning');
  627 + }
  628 +
  629 + client.publish(topic, message, {qos: qos}, (error) => {
  630 + if (error) {
  631 + errorCount++;
  632 + updateStats();
  633 + addLog(`❌ 发布失败: ${error.message}`, 'error');
  634 + } else {
  635 + sentCount++;
  636 + updateStats();
  637 + addLog(`📤 消息已发布 [${topic}] (QoS ${qos})`, 'success');
  638 + }
  639 + });
  640 + }
  641 +
  642 + // 发送样例数据
  643 + function publishSampleData() {
  644 + // 使用 Alink 协议格式的样例数据
  645 + const sampleData = {
  646 + id: Date.now().toString(),
  647 + version: "1.0",
  648 + method: "thing.property.post",
  649 + params: {
  650 + temperature: parseFloat((20 + Math.random() * 10).toFixed(2)),
  651 + humidity: parseFloat((50 + Math.random() * 20).toFixed(2)),
  652 + pressure: parseFloat((1000 + Math.random() * 50).toFixed(2))
  653 + }
  654 + };
  655 +
  656 + document.getElementById('pubMessage').value = JSON.stringify(sampleData, null, 2);
  657 + addLog('样例数据已生成(Alink 协议格式)', 'info');
  658 + publish();
  659 + }
  660 +
  661 + // 获取 productKey 和 deviceName
  662 + function getDeviceInfo() {
  663 + const clientId = document.getElementById('clientId').value;
  664 + const parts = clientId.split('.');
  665 +
  666 + if (parts.length !== 2) {
  667 + addLog('❌ Client ID 格式不正确(应为 {productKey}.{deviceName}),无法生成主题', 'error');
  668 + return null;
  669 + }
  670 +
  671 + return {
  672 + productKey: parts[0],
  673 + deviceName: parts[1]
  674 + };
  675 + }
  676 +
  677 + // 快捷主题选择(消息发布 - 上行消息)
  678 + function selectQuickPublishTopic() {
  679 + const select = document.getElementById('quickPublishTopicSelect');
  680 + const selectedValue = select.value;
  681 +
  682 + console.log('[selectQuickPublishTopic] 选择的值:', selectedValue);
  683 +
  684 + if (!selectedValue) {
  685 + return;
  686 + }
  687 +
  688 + const deviceInfo = getDeviceInfo();
  689 + if (!deviceInfo) {
  690 + return;
  691 + }
  692 +
  693 + console.log('[selectQuickPublishTopic] 设备信息:', deviceInfo);
  694 +
  695 + // 构建标准主题,将枚举中的点号替换为斜杠
  696 + // 例如:thing.property.post -> /sys/{pk}/{dn}/thing/property/post
  697 + const topic = `/sys/${deviceInfo.productKey}/${deviceInfo.deviceName}/${selectedValue.replace(/\./g, '/')}`;
  698 +
  699 + console.log('[selectQuickPublishTopic] 生成的主题:', topic);
  700 +
  701 + const pubTopicInput = document.getElementById('pubTopic');
  702 + pubTopicInput.value = topic;
  703 +
  704 + console.log('[selectQuickPublishTopic] 输入框的值已设置为:', pubTopicInput.value);
  705 +
  706 + addLog(`📋 已选择发布主题: ${topic}`, 'info');
  707 +
  708 + // 需要 reply 的消息类型(不在 REPLY_DISABLED 列表中)
  709 + const needsReply = [
  710 + 'thing.property.post',
  711 + 'thing.event.post'
  712 + ];
  713 +
  714 + // 如果需要 reply,自动订阅 reply 主题
  715 + if (needsReply.includes(selectedValue)) {
  716 + const replyTopic = `${topic}_reply`;
  717 +
  718 + if (client && client.connected) {
  719 + // 自动订阅 reply 主题
  720 + client.subscribe(replyTopic, {qos: 1}, (err) => {
  721 + if (!err) {
  722 + addLog(`✅ 已自动订阅回复主题: ${replyTopic}`, 'success');
  723 + } else {
  724 + addLog(`❌ 自动订阅回复主题失败: ${err.message}`, 'error');
  725 + }
  726 + });
  727 + } else {
  728 + addLog(`💡 提示: 该消息需要订阅回复主题 ${replyTopic}`, 'warning');
  729 + }
  730 + }
  731 +
  732 + // 重置下拉框到默认选项
  733 + select.selectedIndex = 0;
  734 + console.log('[selectQuickPublishTopic] 下拉框已重置');
  735 + }
  736 +
  737 + // 快捷主题选择(主题订阅 - 下行消息)
  738 + function selectQuickTopic() {
  739 + const select = document.getElementById('quickTopicSelect');
  740 + const selectedValue = select.value;
  741 +
  742 + console.log('[selectQuickTopic] 选择的值:', selectedValue);
  743 +
  744 + if (!selectedValue) {
  745 + return;
  746 + }
  747 +
  748 + const subTopicInput = document.getElementById('subTopic');
  749 +
  750 + // 处理通配符订阅
  751 + if (selectedValue === 'wildcard_all') {
  752 + subTopicInput.value = '/sys/+/+/#';
  753 + addLog('📋 已选择订阅主题: /sys/+/+/#(订阅所有主题)', 'info');
  754 + console.log('[selectQuickTopic] 输入框的值已设置为:', subTopicInput.value);
  755 + select.selectedIndex = 0;
  756 + console.log('[selectQuickTopic] 下拉框已重置');
  757 + return;
  758 + } else if (selectedValue === 'wildcard_thing') {
  759 + subTopicInput.value = '/sys/+/+/thing/#';
  760 + addLog('📋 已选择订阅主题: /sys/+/+/thing/#(订阅所有 thing 主题)', 'info');
  761 + console.log('[selectQuickTopic] 输入框的值已设置为:', subTopicInput.value);
  762 + select.selectedIndex = 0;
  763 + console.log('[selectQuickTopic] 下拉框已重置');
  764 + return;
  765 + } else if (selectedValue === 'wildcard_reply') {
  766 + const deviceInfo = getDeviceInfo();
  767 + if (!deviceInfo) {
  768 + select.selectedIndex = 0;
  769 + return;
  770 + }
  771 + subTopicInput.value = `/sys/${deviceInfo.productKey}/${deviceInfo.deviceName}/#_reply`;
  772 + addLog(`📋 已选择订阅主题: /sys/${deviceInfo.productKey}/${deviceInfo.deviceName}/#_reply(订阅所有回复主题)`, 'info');
  773 + console.log('[selectQuickTopic] 输入框的值已设置为:', subTopicInput.value);
  774 + select.selectedIndex = 0;
  775 + console.log('[selectQuickTopic] 下拉框已重置');
  776 + return;
  777 + }
  778 +
  779 + const deviceInfo = getDeviceInfo();
  780 + if (!deviceInfo) {
  781 + select.selectedIndex = 0;
  782 + return;
  783 + }
  784 +
  785 + console.log('[selectQuickTopic] 设备信息:', deviceInfo);
  786 +
  787 + // 构建标准主题,将枚举中的点号替换为斜杠
  788 + // 例如:thing.property.set -> /sys/{pk}/{dn}/thing/property/set
  789 + const topic = `/sys/${deviceInfo.productKey}/${deviceInfo.deviceName}/${selectedValue.replace(/\./g, '/')}`;
  790 +
  791 + console.log('[selectQuickTopic] 生成的主题:', topic);
  792 +
  793 + subTopicInput.value = topic;
  794 + addLog(`📋 已选择订阅主题: ${topic}`, 'info');
  795 +
  796 + console.log('[selectQuickTopic] 输入框的值已设置为:', subTopicInput.value);
  797 +
  798 + // 重置下拉框到默认选项
  799 + select.selectedIndex = 0;
  800 + console.log('[selectQuickTopic] 下拉框已重置');
  801 + }
  802 +
  803 + // 订阅主题
  804 + function subscribe() {
  805 + if (!client || !client.connected) {
  806 + addLog('❌ 请先连接到服务器', 'error');
  807 + errorCount++;
  808 + updateStats();
  809 + return;
  810 + }
  811 +
  812 + const topic = document.getElementById('subTopic').value;
  813 + const qos = parseInt(document.getElementById('subQos').value);
  814 +
  815 + if (!topic) {
  816 + addLog('❌ 请填写订阅主题', 'error');
  817 + errorCount++;
  818 + updateStats();
  819 + return;
  820 + }
  821 +
  822 + client.subscribe(topic, {qos: qos}, (error) => {
  823 + if (error) {
  824 + errorCount++;
  825 + updateStats();
  826 + addLog(`❌ 订阅失败: ${error.message}`, 'error');
  827 + } else {
  828 + addLog(`📥 已订阅主题 [${topic}] (QoS ${qos})`, 'success');
  829 + }
  830 + });
  831 + }
  832 +
  833 + // 取消订阅
  834 + function unsubscribe() {
  835 + if (!client || !client.connected) {
  836 + addLog('❌ 请先连接到服务器', 'error');
  837 + errorCount++;
  838 + updateStats();
  839 + return;
  840 + }
  841 +
  842 + const topic = document.getElementById('subTopic').value;
  843 +
  844 + if (!topic) {
  845 + addLog('❌ 请填写要取消的订阅主题', 'error');
  846 + errorCount++;
  847 + updateStats();
  848 + return;
  849 + }
  850 +
  851 + client.unsubscribe(topic, (error) => {
  852 + if (error) {
  853 + errorCount++;
  854 + updateStats();
  855 + addLog(`❌ 取消订阅失败: ${error.message}`, 'error');
  856 + } else {
  857 + addLog(`❌ 已取消订阅 [${topic}]`, 'info');
  858 + }
  859 + });
  860 + }
  861 +
  862 + // 清空日志
  863 + function clearLogs() {
  864 + document.getElementById('logArea').innerHTML = '';
  865 + sentCount = 0;
  866 + receivedCount = 0;
  867 + errorCount = 0;
  868 + updateStats();
  869 + addLog('🗑️ 日志已清空', 'info');
  870 + }
  871 +
  872 + // 页面加载完成
  873 + window.onload = function () {
  874 + addLog('👋 欢迎使用 MQTT WebSocket 测试客户端!', 'success');
  875 + addLog('📚 请配置连接参数后点击"连接"按钮', 'info');
  876 + };
  877 +
  878 + // 页面关闭前断开连接
  879 + window.onbeforeunload = function () {
  880 + if (client && client.connected) {
  881 + client.end();
  882 + }
  883 + };
  884 +</script>
  885 +</body>
  886 +</html>
  887 +
  888 +
... ...
yudao-server/src/main/resources/application-local.yaml
... ... @@ -69,7 +69,8 @@ spring:
69 69 username: root
70 70 password: 123456
71 71 # tdengine: # IoT 数据库(需要 IoT 物联网再开启噢!)
72   -# url: jdbc:TAOS-WS://127.0.0.1:6041/ruoyi_vue_pro
  72 +# lazy: true # 开启懒加载,保证启动速度
  73 +# url: jdbc:TAOS-WS://127.0.0.1:6041/ruoyi_vue_pro?varcharAsString=true
73 74 # driver-class-name: com.taosdata.jdbc.ws.WebSocketDriver
74 75 # username: root
75 76 # password: taosdata
... ...