Commit 19829c6c5ee2cf0708d5f5685347c27147558237

Authored by YunaiV
1 parent 0c943169

fix(iot): 完善设备状态事务与离线检测,改由业务端续期网关存活标记

- 保证设备状态与规则处理处于同一事务,避免重复及乱序消息触发错误状态
- 区分连接型和活跃型设备,完善网关离线后的子设备联动与离线巡检
- 通过 CommonApi 批量上报协议实例启动时间,由业务端 Redis 保存存活标记
- 补充状态处理、规则回滚及网关续期的单元测试
Showing 46 changed files with 2786 additions and 121 deletions
yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/JdbcUtils.java
... ... @@ -7,6 +7,7 @@ import cn.iocoder.yudao.framework.mybatis.core.enums.DbTypeEnum;
7 7 import com.baomidou.dynamic.datasource.DynamicRoutingDataSource;
8 8 import com.baomidou.mybatisplus.annotation.DbType;
9 9 import org.springframework.beans.factory.NoSuchBeanDefinitionException;
  10 +import org.springframework.dao.DataAccessException;
10 11 import org.springframework.jdbc.datasource.ConnectionHolder;
11 12 import org.springframework.transaction.support.TransactionSynchronizationManager;
12 13 import org.springframework.util.ConcurrentReferenceHashMap;
... ... @@ -209,6 +210,18 @@ public class JdbcUtils {
209 210 return ObjectUtils.equalsAny(dbType, DbType.SQL_SERVER, DbType.SQL_SERVER2005);
210 211 }
211 212  
212   -
  213 + /**
  214 + * 存在实际事务时,重新抛出数据访问异常,交给事务回滚
  215 + *
  216 + * 数据访问异常指 Spring 的 {@link DataAccessException},包括 JDBC、Redis 等数据访问的失败。
  217 + * 不在实际事务中,或者不是数据访问异常时,不做处理,由调用方按原逻辑处理(例如说记录日志)
  218 + *
  219 + * @param ex 异常
  220 + */
  221 + public static void throwIfDataAccessExceptionInTransaction(Throwable ex) {
  222 + if (ex instanceof DataAccessException && TransactionSynchronizationManager.isActualTransactionActive()) {
  223 + throw (DataAccessException) ex;
  224 + }
  225 + }
213 226  
214 227 }
... ...
yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/JdbcUtilsTest.java
... ... @@ -10,6 +10,7 @@ import org.junit.jupiter.api.BeforeEach;
10 10 import org.junit.jupiter.api.Test;
11 11 import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
12 12 import org.springframework.beans.factory.support.DefaultListableBeanFactory;
  13 +import org.springframework.dao.QueryTimeoutException;
13 14 import org.springframework.jdbc.datasource.ConnectionHolder;
14 15 import org.springframework.transaction.support.TransactionSynchronizationManager;
15 16  
... ... @@ -167,6 +168,26 @@ public class JdbcUtilsTest {
167 168 assertEquals(DbType.MYSQL, JdbcUtils.getDbType(broken.getDataSource()));
168 169 }
169 170  
  171 + @Test
  172 + public void testThrowIfDataAccessExceptionInTransaction() {
  173 + // 准备参数
  174 + QueryTimeoutException dataAccessException = new QueryTimeoutException("redis read timeout");
  175 + IllegalStateException otherException = new IllegalStateException("other failed");
  176 +
  177 + // 调用:非事务内,不抛出
  178 + assertDoesNotThrow(() -> JdbcUtils.throwIfDataAccessExceptionInTransaction(dataAccessException));
  179 + // 调用:事务内,只抛出数据访问异常
  180 + TransactionSynchronizationManager.setActualTransactionActive(true);
  181 + try {
  182 + assertSame(dataAccessException, assertThrows(QueryTimeoutException.class,
  183 + () -> JdbcUtils.throwIfDataAccessExceptionInTransaction(dataAccessException)));
  184 + assertDoesNotThrow(() -> JdbcUtils.throwIfDataAccessExceptionInTransaction(otherException));
  185 + assertDoesNotThrow(() -> JdbcUtils.throwIfDataAccessExceptionInTransaction(null));
  186 + } finally {
  187 + TransactionSynchronizationManager.setActualTransactionActive(false);
  188 + }
  189 + }
  190 +
170 191 private static void mockBeanFactory(String beanName, Object bean) {
171 192 DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
172 193 beanFactory.registerSingleton(beanName, bean);
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImpl.java
... ... @@ -19,9 +19,11 @@ import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO;
19 19 import cn.iocoder.yudao.module.iot.service.device.IotDeviceModbusConfigService;
20 20 import cn.iocoder.yudao.module.iot.service.device.IotDeviceModbusPointService;
21 21 import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
  22 +import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService;
22 23 import cn.iocoder.yudao.module.iot.service.product.IotProductService;
23 24 import jakarta.annotation.Resource;
24 25 import jakarta.annotation.security.PermitAll;
  26 +import lombok.extern.slf4j.Slf4j;
25 27 import org.springframework.context.annotation.Lazy;
26 28 import org.springframework.context.annotation.Primary;
27 29 import org.springframework.validation.annotation.Validated;
... ... @@ -45,6 +47,7 @@ import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.
45 47 */
46 48 @RestController
47 49 @Validated
  50 +@Slf4j
48 51 @Primary // 保证优先匹配,因为 yudao-iot-gateway 也有 IotDeviceCommonApi 的实现,并且也可能会被 biz 引入
49 52 public class IoTDeviceApiImpl implements IotDeviceCommonApi {
50 53  
... ... @@ -53,6 +56,8 @@ public class IoTDeviceApiImpl implements IotDeviceCommonApi {
53 56 @Resource
54 57 private IotProductService productService;
55 58 @Resource
  59 + private IotDevicePropertyService devicePropertyService;
  60 + @Resource
56 61 @Lazy // 延迟加载,解决循环依赖
57 62 private IotDeviceModbusConfigService modbusConfigService;
58 63 @Resource
... ... @@ -144,4 +149,23 @@ public class IoTDeviceApiImpl implements IotDeviceCommonApi {
144 149 return success(deviceService.registerSubDevices(reqDTO));
145 150 }
146 151  
  152 + @Override
  153 + @PostMapping(RpcConstants.RPC_API_PREFIX + "/iot/gateway-server/keep-alive")
  154 + @PermitAll // 无鉴权:前提是只有受信的网关能访问 RPC 接口
  155 + @TenantIgnore // 网关级的全局数据,不按租户隔离;如果以后在这里访问 DB,需要重新评估租户约束
  156 + public CommonResult<Boolean> keepAliveGatewayServers(@RequestBody IotGatewayServerAliveReqDTO reqDTO) {
  157 + // 参数校验失败时,不会执行到这里,所以非法的批次不会写入任何一项
  158 + int failCount = 0;
  159 + for (Map.Entry<String, Long> entry : reqDTO.getStartTimes().entrySet()) {
  160 + // 单项失败不影响其它项:失败明细只记录日志,整体结果返回 false
  161 + try {
  162 + devicePropertyService.updateGatewayServerStartTime(entry.getKey(), entry.getValue());
  163 + } catch (Exception e) {
  164 + failCount++;
  165 + log.error("[keepAliveGatewayServers][serverId({}) startTime({}) 续期存活标记失败]", entry.getKey(), entry.getValue(), e);
  166 + }
  167 + }
  168 + return success(failCount == 0);
  169 + }
  170 +
147 171 }
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/device/IotDeviceMapper.java
... ... @@ -6,6 +6,7 @@ import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
6 6 import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
7 7 import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
8 8 import cn.iocoder.yudao.module.iot.controller.admin.device.vo.device.IotDevicePageReqVO;
  9 +import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum;
9 10 import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
10 11 import cn.iocoder.yudao.module.iot.enums.product.IotProductDeviceTypeEnum;
11 12 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
... ... @@ -132,6 +133,74 @@ public interface IotDeviceMapper extends BaseMapperX&lt;IotDeviceDO&gt; {
132 133 .isNotNull(IotDeviceDO::getLongitude));
133 134 }
134 135  
  136 + // ========== 设备状态相关 ==========
  137 + // 说明:都是单条条件 UPDATE,依靠语句的原子性判断“是否生效”,不加锁;传入空实体,是为了让 update_time 自动填充
  138 +
  139 + /**
  140 + * 更新设备为在线:设备不在线,且事件时间不早于最后离线时间时生效。首次上线时,同一条语句写入激活时间
  141 + *
  142 + * @param id 设备编号
  143 + * @param time 事件时间
  144 + * @return 是否生效(1 生效,0 未生效)
  145 + */
  146 + default int updateStateOnline(Long id, LocalDateTime time) {
  147 + return update(new IotDeviceDO(), new LambdaUpdateWrapper<IotDeviceDO>()
  148 + .set(IotDeviceDO::getState, IotDeviceStateEnum.ONLINE.getState())
  149 + .set(IotDeviceDO::getOnlineTime, time)
  150 + .setSql("active_time = COALESCE(active_time, {0})", time)
  151 + .eq(IotDeviceDO::getId, id)
  152 + .and(w -> w.isNull(IotDeviceDO::getState).or().ne(IotDeviceDO::getState, IotDeviceStateEnum.ONLINE.getState()))
  153 + .and(w -> w.isNull(IotDeviceDO::getOfflineTime).or().le(IotDeviceDO::getOfflineTime, time)));
  154 + }
  155 +
  156 + /**
  157 + * 刷新在线设备的最后上线时间:只推进到更晚的事件时间,不改变状态
  158 + *
  159 + * @param id 设备编号
  160 + * @param time 事件时间
  161 + * @return 是否生效(1 生效,0 未生效)
  162 + */
  163 + default int updateOnlineTime(Long id, LocalDateTime time) {
  164 + return update(new IotDeviceDO(), new LambdaUpdateWrapper<IotDeviceDO>()
  165 + .set(IotDeviceDO::getOnlineTime, time)
  166 + .eq(IotDeviceDO::getId, id)
  167 + .eq(IotDeviceDO::getState, IotDeviceStateEnum.ONLINE.getState())
  168 + .and(w -> w.isNull(IotDeviceDO::getOnlineTime).or().lt(IotDeviceDO::getOnlineTime, time)));
  169 + }
  170 +
  171 + /**
  172 + * 更新设备为离线:设备在线,且事件时间不早于最后上线时间时生效
  173 + *
  174 + * @param id 设备编号
  175 + * @param time 事件时间
  176 + * @return 是否生效(1 生效,0 未生效)
  177 + */
  178 + default int updateStateOffline(Long id, LocalDateTime time) {
  179 + return update(new IotDeviceDO(), new LambdaUpdateWrapper<IotDeviceDO>()
  180 + .set(IotDeviceDO::getState, IotDeviceStateEnum.OFFLINE.getState())
  181 + .set(IotDeviceDO::getOfflineTime, time)
  182 + .eq(IotDeviceDO::getId, id)
  183 + .eq(IotDeviceDO::getState, IotDeviceStateEnum.ONLINE.getState())
  184 + .and(w -> w.isNull(IotDeviceDO::getOnlineTime).or().le(IotDeviceDO::getOnlineTime, time)));
  185 + }
  186 +
  187 + /**
  188 + * 推进不在线设备的最后离线时间(离线水位):只推进到更晚的事件时间,不改变状态
  189 + *
  190 + * 作用:挡住回滚重投造成的误判在线。例如:上线 t1 回滚 → 离线 t2 → 上线 t1 重投,因 t1 早于 t2 被拒绝
  191 + *
  192 + * @param id 设备编号
  193 + * @param time 事件时间
  194 + * @return 是否生效(1 生效,0 未生效)
  195 + */
  196 + default int updateOfflineTime(Long id, LocalDateTime time) {
  197 + return update(new IotDeviceDO(), new LambdaUpdateWrapper<IotDeviceDO>()
  198 + .set(IotDeviceDO::getOfflineTime, time)
  199 + .eq(IotDeviceDO::getId, id)
  200 + .and(w -> w.isNull(IotDeviceDO::getState).or().ne(IotDeviceDO::getState, IotDeviceStateEnum.ONLINE.getState()))
  201 + .and(w -> w.isNull(IotDeviceDO::getOfflineTime).or().lt(IotDeviceDO::getOfflineTime, time)));
  202 + }
  203 +
135 204 // ========== 网关-子设备绑定相关 ==========
136 205  
137 206 /**
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/redis/device/DeviceReportTimeRedisDAO.java
... ... @@ -4,13 +4,14 @@ import cn.hutool.core.date.LocalDateTimeUtil;
4 4 import cn.iocoder.yudao.module.iot.dal.redis.RedisKeyConstants;
5 5 import jakarta.annotation.Resource;
6 6 import org.springframework.data.redis.core.StringRedisTemplate;
  7 +import org.springframework.data.redis.core.ZSetOperations;
7 8 import org.springframework.stereotype.Repository;
8 9  
9 10 import java.time.LocalDateTime;
  11 +import java.util.LinkedHashMap;
  12 +import java.util.Map;
10 13 import java.util.Set;
11 14  
12   -import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
13   -
14 15 /**
15 16 * 设备的最后上报时间的 Redis DAO
16 17 *
... ... @@ -22,15 +23,37 @@ public class DeviceReportTimeRedisDAO {
22 23 @Resource
23 24 private StringRedisTemplate stringRedisTemplate;
24 25  
  26 + /**
  27 + * 更新设备的最后上报时间
  28 + *
  29 + * @param deviceId 设备编号
  30 + * @param reportTime 最后上报时间
  31 + */
25 32 public void update(Long deviceId, LocalDateTime reportTime) {
26 33 stringRedisTemplate.opsForZSet().add(RedisKeyConstants.DEVICE_REPORT_TIMES, String.valueOf(deviceId),
27 34 LocalDateTimeUtil.toEpochMilli(reportTime));
28 35 }
29 36  
30   - public Set<Long> range(LocalDateTime maxReportTime) {
31   - Set<String> values = stringRedisTemplate.opsForZSet().rangeByScore(RedisKeyConstants.DEVICE_REPORT_TIMES,
32   - 0, LocalDateTimeUtil.toEpochMilli(maxReportTime));
33   - return convertSet(values, Long::parseLong);
  37 + /**
  38 + * 获得最后上报时间不晚于指定时间的设备
  39 + *
  40 + * @param maxReportTime 最大的最后上报时间
  41 + * @return 设备编号与最后上报时间的映射
  42 + */
  43 + public Map<Long, LocalDateTime> rangeWithScores(LocalDateTime maxReportTime) {
  44 + Set<ZSetOperations.TypedTuple<String>> tuples = stringRedisTemplate.opsForZSet().rangeByScoreWithScores(
  45 + RedisKeyConstants.DEVICE_REPORT_TIMES, 0, LocalDateTimeUtil.toEpochMilli(maxReportTime));
  46 + Map<Long, LocalDateTime> result = new LinkedHashMap<>();
  47 + if (tuples == null) {
  48 + return result;
  49 + }
  50 + for (ZSetOperations.TypedTuple<String> tuple : tuples) {
  51 + if (tuple.getValue() == null || tuple.getScore() == null) {
  52 + continue;
  53 + }
  54 + result.put(Long.parseLong(tuple.getValue()), LocalDateTimeUtil.of(tuple.getScore().longValue()));
  55 + }
  56 + return result;
34 57 }
35 58  
36 59 }
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/redis/device/DeviceServerIdRedisDAO.java
1 1 package cn.iocoder.yudao.module.iot.dal.redis.device;
2 2  
  3 +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
3 4 import cn.iocoder.yudao.module.iot.dal.redis.RedisKeyConstants;
4 5 import jakarta.annotation.Resource;
5 6 import org.springframework.data.redis.core.StringRedisTemplate;
6 7 import org.springframework.stereotype.Repository;
7 8  
  9 +import java.time.Duration;
  10 +
8 11 /**
9 12 * 设备关联的网关 serverId 的 Redis DAO
10 13 *
... ... @@ -13,6 +16,11 @@ import org.springframework.stereotype.Repository;
13 16 @Repository
14 17 public class DeviceServerIdRedisDAO {
15 18  
  19 + /**
  20 + * 网关协议实例存活标记的过期时间:网关每 20 秒上报一次,可以容忍几轮上报失败、短时的 biz 重启
  21 + */
  22 + private static final Duration GATEWAY_SERVER_ALIVE_TIMEOUT = Duration.ofSeconds(120);
  23 +
16 24 @Resource
17 25 private StringRedisTemplate stringRedisTemplate;
18 26  
... ... @@ -27,4 +35,30 @@ public class DeviceServerIdRedisDAO {
27 35 return value != null ? (String) value : null;
28 36 }
29 37  
  38 + /**
  39 + * 获得网关协议实例的启动时间
  40 + *
  41 + * 只供离线检查任务使用:设备上线早于启动时间,说明网关重启过、设备未重连;不参与普通上下线的状态事务
  42 + *
  43 + * @param serverId 网关 serverId
  44 + * @return 启动时间(毫秒);标记不存在(网关已停止或宕机)时返回 null
  45 + */
  46 + public Long getGatewayServerStartTime(String serverId) {
  47 + String value = stringRedisTemplate.opsForValue().get(IotDeviceMessageUtils.buildGatewayServerAliveKey(serverId));
  48 + return value != null ? Long.valueOf(value) : null;
  49 + }
  50 +
  51 + /**
  52 + * 更新网关协议实例的启动时间,同时续期存活标记
  53 + *
  54 + * 直接覆盖,不比较新旧值:时钟回拨时,比较写入反而会拒绝新的启动时间
  55 + *
  56 + * @param serverId 网关 serverId
  57 + * @param startTime 网关上报的启动时间(毫秒),不是 biz 的接收时间
  58 + */
  59 + public void updateGatewayServerStartTime(String serverId, Long startTime) {
  60 + stringRedisTemplate.opsForValue().set(IotDeviceMessageUtils.buildGatewayServerAliveKey(serverId),
  61 + String.valueOf(startTime), GATEWAY_SERVER_ALIVE_TIMEOUT);
  62 + }
  63 +
30 64 }
31 65 \ No newline at end of file
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/job/device/IotDeviceOfflineCheckJob.java
1 1 package cn.iocoder.yudao.module.iot.job.device;
2 2  
3 3 import cn.hutool.core.collection.CollUtil;
  4 +import cn.hutool.core.date.LocalDateTimeUtil;
4 5 import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
5 6 import cn.iocoder.yudao.framework.quartz.core.handler.JobHandler;
6 7 import cn.iocoder.yudao.framework.tenant.core.job.TenantJob;
  8 +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
7 9 import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum;
8 10 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
9 11 import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
  12 +import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO;
  13 +import cn.iocoder.yudao.module.iot.enums.product.IotProductDeviceTypeEnum;
10 14 import cn.iocoder.yudao.module.iot.framework.iot.config.YudaoIotProperties;
11 15 import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
12 16 import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService;
13 17 import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService;
  18 +import cn.iocoder.yudao.module.iot.service.product.IotProductService;
14 19 import jakarta.annotation.Resource;
  20 +import lombok.extern.slf4j.Slf4j;
15 21 import org.springframework.stereotype.Component;
16 22  
17 23 import java.time.Duration;
18 24 import java.time.LocalDateTime;
  25 +import java.time.temporal.ChronoUnit;
19 26 import java.util.Collections;
  27 +import java.util.HashMap;
20 28 import java.util.List;
21   -import java.util.Set;
  29 +import java.util.Map;
22 30  
23 31 /**
24 32 * IoT 设备离线检查 Job
25 33 *
26 34 * 检测逻辑:设备最后一条 {@link IotDeviceMessage} 消息超过一定时间,则认为设备离线
  35 + * 1. 活跃型设备(HTTP、CoAP、UDP 直连设备、网关子设备):超时即离线
  36 + * 2. 连接型设备(MQTT、TCP 等长连接):在线状态由连接决定,超时后还要所在网关的协议实例已停止或重启过,才离线
27 37 *
28 38 * @see <a href="https://help.aliyun.com/zh/iot/support/faq-about-device-status#98f7056b2957y">阿里云 IoT —— 设备离线分析</a>
29 39 * @author 芋道源码
30 40 */
31 41 @Component
  42 +@Slf4j
32 43 public class IotDeviceOfflineCheckJob implements JobHandler {
33 44  
34 45 @Resource
... ... @@ -40,6 +51,8 @@ public class IotDeviceOfflineCheckJob implements JobHandler {
40 51 private IotDevicePropertyService devicePropertyService;
41 52 @Resource
42 53 private IotDeviceMessageService deviceMessageService;
  54 + @Resource
  55 + private IotProductService productService;
43 56  
44 57 @Override
45 58 @TenantJob
... ... @@ -50,24 +63,85 @@ public class IotDeviceOfflineCheckJob implements JobHandler {
50 63 return JsonUtils.toJsonString(Collections.emptyList());
51 64 }
52 65 // 1.2 获取超时的设备集合
53   - Set<Long> timeoutDeviceIds = devicePropertyService.getDeviceIdListByReportTime(getTimeoutTime());
  66 + // DONE @AI:一次性,就不要抽方法了。(已内联到这里,去掉 getTimeout 方法)
  67 + Duration timeout = Duration.ofNanos((long) (iotProperties.getKeepAliveTime().toNanos() * iotProperties.getKeepAliveFactor()));
  68 + Map<Long, LocalDateTime> timeoutDevices = devicePropertyService.getDeviceReportTimeMapByReportTime(
  69 + LocalDateTime.now().minus(timeout));
54 70  
55 71 // 2. 下线设备
56 72 List<String[]> offlineDevices = CollUtil.newArrayList();
  73 + Map<Long, IotProductDO> productCache = new HashMap<>(); // 本轮内缓存,避免重复读取
  74 + Map<String, Long> startTimeCache = new HashMap<>();
57 75 for (IotDeviceDO device : devices) {
58   - if (!timeoutDeviceIds.contains(device.getId())) {
  76 + LocalDateTime reportTime = timeoutDevices.get(device.getId());
  77 + if (reportTime == null) {
  78 + continue;
  79 + }
  80 + // 连接型设备:连接还在(所在网关的协议实例没有停止或重启过),则不离线
  81 + if (isConnectionBased(device, productCache) && !isConnectionStale(device, productCache, startTimeCache)) {
59 82 continue;
60 83 }
61 84 offlineDevices.add(new String[]{device.getProductKey(), device.getDeviceName()});
62 85 // 为什么不直接更新状态呢?因为通过 IotDeviceMessage 可以经过一系列的处理,例如说记录日志等等
63   - deviceMessageService.sendDeviceMessage(IotDeviceMessage.buildStateOffline().setDeviceId(device.getId()));
  86 + // 事件时间 = 最后活跃时间 + 超时时间:期间如果设备重新上线,上线时间更晚,这条离线不会生效
  87 + deviceMessageService.sendDeviceMessage(IotDeviceMessage.buildStateOffline(), device, null, reportTime.plus(timeout));
64 88 }
65 89 return JsonUtils.toJsonString(offlineDevices);
66 90 }
67 91  
68   - private LocalDateTime getTimeoutTime() {
69   - return LocalDateTime.now().minus(Duration.ofNanos(
70   - (long) (iotProperties.getKeepAliveTime().toNanos() * iotProperties.getKeepAliveFactor())));
  92 + /**
  93 + * 是否为连接型设备
  94 + *
  95 + * 网关子设备、产品不存在、协议未知时,按活跃型处理
  96 + */
  97 + private boolean isConnectionBased(IotDeviceDO device, Map<Long, IotProductDO> productCache) {
  98 + if (IotProductDeviceTypeEnum.isGatewaySub(device.getDeviceType())) {
  99 + return false;
  100 + }
  101 + IotProtocolTypeEnum protocolType = getProtocolType(device, productCache);
  102 + return protocolType != null && protocolType.isConnectionBased();
  103 + }
  104 +
  105 + /**
  106 + * 连接型设备的连接是否已失效:所在网关的协议实例已停止(存活标记不存在),或者在设备上线后重启过
  107 + */
  108 + private boolean isConnectionStale(IotDeviceDO device, Map<Long, IotProductDO> productCache,
  109 + Map<String, Long> startTimeCache) {
  110 + // 1. EMQX 的连接由 EMQX 维护,不使用网关存活标记
  111 + if (getProtocolType(device, productCache) == IotProtocolTypeEnum.EMQX) {
  112 + return false;
  113 + }
  114 + // DONE @AI:方法内注释;(已按 1、2、3 步骤补充)
  115 + // 2. 获得设备所在网关协议实例的启动时间:同一轮内,相同 serverId 只读取一次
  116 + Long startTime;
  117 + try {
  118 + String serverId = devicePropertyService.getDeviceServerId(device.getId());
  119 + if (serverId == null) { // 没有记录所在的网关,无法确认连接还在
  120 + return true;
  121 + }
  122 + if (startTimeCache.containsKey(serverId)) {
  123 + startTime = startTimeCache.get(serverId);
  124 + } else {
  125 + startTime = devicePropertyService.getGatewayServerStartTime(serverId);
  126 + startTimeCache.put(serverId, startTime);
  127 + }
  128 + } catch (Exception e) {
  129 + // Redis 读取失败,不等于存活标记不存在:本轮跳过,不判离线
  130 + log.warn("[isConnectionStale][设备({}) 读取网关存活标记失败,本轮跳过]", device.getId(), e);
  131 + return false;
  132 + }
  133 + // 3. 存活标记不存在(网关已停止或宕机),或者设备上线早于启动时间(网关重启过、设备未重连),则连接已失效
  134 + if (startTime == null || device.getOnlineTime() == null) {
  135 + return true;
  136 + }
  137 + // 按秒比较:上线时间是秒级精度(见 IotDeviceServiceImpl#updateDeviceState),同一秒内上线的设备,视为启动后上线,不判离线
  138 + return device.getOnlineTime().truncatedTo(ChronoUnit.SECONDS)
  139 + .isBefore(LocalDateTimeUtil.of(startTime).truncatedTo(ChronoUnit.SECONDS));
  140 + }
  141 +
  142 + private IotProtocolTypeEnum getProtocolType(IotDeviceDO device, Map<Long, IotProductDO> productCache) {
  143 + IotProductDO product = productCache.computeIfAbsent(device.getProductId(), productService::getProductFromCache);
  144 + return product != null ? IotProtocolTypeEnum.of(product.getProtocolType()) : null;
71 145 }
72 146  
73 147 }
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/device/IotDeviceMessageSubscriber.java
... ... @@ -70,9 +70,15 @@ public class IotDeviceMessageSubscriber implements IotMessageSubscriber&lt;IotDevic
70 70 log.warn("[onMessage][message({}) 对应的设备不存在,忽略处理]", message);
71 71 return;
72 72 }
73   - devicePropertyService.updateDeviceReportTimeAsync(device.getId(), LocalDateTime.now());
74   - // 1.2 更新设备的连接 server
75   - devicePropertyService.updateDeviceServerIdAsync(device.getId(), message.getServerId());
  73 + // 特殊:离线消息不算活跃,也不改变连接的 server,避免旧连接的离线把路由改回旧节点
  74 + if (!IotDeviceMessageUtils.isStateOffline(message)) {
  75 + devicePropertyService.updateDeviceReportTimeAsync(device.getId(), LocalDateTime.now());
  76 + // 1.2 更新设备的连接 server:早于当前上线时间的旧消息,不改路由
  77 + if (device.getOnlineTime() == null || message.getReportTime() == null
  78 + || !message.getReportTime().isBefore(device.getOnlineTime())) {
  79 + devicePropertyService.updateDeviceServerIdAsync(device.getId(), message.getServerId());
  80 + }
  81 + }
76 82  
77 83 // 2. 未上线的设备,强制上线
78 84 forceDeviceOnline(message, device);
... ... @@ -94,8 +100,10 @@ public class IotDeviceMessageSubscriber implements IotMessageSubscriber&lt;IotDevic
94 100  
95 101 // 特殊:设备非在线时,主动标记设备为在线
96 102 // 为什么不直接更新状态呢?因为通过 IotDeviceMessage 可以经过一系列的处理,例如说记录日志、规则引擎等等
  103 + // 事件时间沿用源消息的上报时间:迟到的旧消息(早于离线)不会把设备重新拉上线
97 104 try {
98   - deviceMessageService.sendDeviceMessage(IotDeviceMessage.buildStateUpdateOnline().setDeviceId(device.getId()));
  105 + deviceMessageService.sendDeviceMessage(IotDeviceMessage.buildStateUpdateOnline(), device,
  106 + null, message.getReportTime());
99 107 } catch (Exception e) {
100 108 // 注意:即使执行失败,也不影响主流程
101 109 log.error("[forceDeviceOnline][message({}) device({}) 强制设备上线失败]", message, device, e);
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/rule/IotDataRuleMessageSubscriber.java
1 1 package cn.iocoder.yudao.module.iot.mq.consumer.rule;
2 2  
3 3 import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
  4 +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
4 5 import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
5 6 import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber;
6 7 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
... ... @@ -42,6 +43,10 @@ public class IotDataRuleMessageSubscriber implements IotMessageSubscriber&lt;IotDev
42 43  
43 44 @Override
44 45 public void onMessage(IotDeviceMessage message) {
  46 + // 状态消息:改由设备消费者在状态事务内触发,只有状态真的变化时才执行
  47 + if (IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod().equals(message.getMethod())) {
  48 + return;
  49 + }
45 50 TenantUtils.execute(message.getTenantId(), () -> dataRuleService.executeDataRule(message));
46 51 }
47 52  
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/rule/IotSceneRuleMessageSubscriber.java
1 1 package cn.iocoder.yudao.module.iot.mq.consumer.rule;
2 2  
  3 +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
3 4 import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
4 5 import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber;
5 6 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
... ... @@ -41,6 +42,10 @@ public class IotSceneRuleMessageSubscriber implements IotMessageSubscriber&lt;IotDe
41 42  
42 43 @Override
43 44 public void onMessage(IotDeviceMessage message) {
  45 + // 状态消息:改由设备消费者在状态事务内触发,只有状态真的变化时才执行
  46 + if (IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod().equals(message.getMethod())) {
  47 + return;
  48 + }
44 49 log.info("[onMessage][消息内容({})]", message);
45 50 sceneRuleService.executeSceneRuleByDevice(message);
46 51 }
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceService.java
... ... @@ -46,20 +46,27 @@ public interface IotDeviceService {
46 46 void updateDevice(@Valid IotDeviceSaveReqVO updateReqVO);
47 47  
48 48 /**
49   - * 更新设备状态
  49 + * 更新设备状态:按事件时间条件更新,迟到的旧消息、重复的状态不会覆盖较新的状态
50 50 *
51   - * @param device 设备信息
52   - * @param state 状态
  51 + * 状态未变化时:在线可按 refresh 推进最后上线时间;离线推进最后离线时间(离线水位)。二者都不算状态变化
  52 + *
  53 + * @param device 设备信息
  54 + * @param state 状态,只能是在线或离线
  55 + * @param time 事件时间
  56 + * @param refresh 状态未变化的在线,是否刷新最后上线时间
  57 + * @return 状态是否真的发生了变化
53 58 */
54   - void updateDeviceState(IotDeviceDO device, Integer state);
  59 + boolean updateDeviceState(IotDeviceDO device, Integer state, LocalDateTime time, boolean refresh);
55 60  
56 61 /**
57   - * 更新设备状态
  62 + * 处理网关下线:给它下面在线的子设备逐个发送离线消息
58 63 *
59   - * @param id 编号
60   - * @param state 状态
  64 + * 注意:需在网关的状态事务提交之后调用,不能在事务内调用
  65 + *
  66 + * @param gatewayDevice 网关设备
  67 + * @param offlineTime 网关离线的事件时间,作为子设备离线的事件时间
61 68 */
62   - void updateDeviceState(Long id, Integer state);
  69 + void handleGatewayOffline(IotDeviceDO gatewayDevice, LocalDateTime offlineTime);
63 70  
64 71 /**
65 72 * 更新设备分组
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceServiceImpl.java
... ... @@ -53,6 +53,7 @@ import org.springframework.validation.annotation.Validated;
53 53 import javax.annotation.Nullable;
54 54 import java.math.BigDecimal;
55 55 import java.time.LocalDateTime;
  56 +import java.time.temporal.ChronoUnit;
56 57 import java.util.*;
57 58  
58 59 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
... ... @@ -323,64 +324,57 @@ public class IotDeviceServiceImpl implements IotDeviceService {
323 324 }
324 325  
325 326 @Override
326   - public void updateDeviceState(IotDeviceDO device, Integer state) {
327   - // 1. 更新状态和时间
328   - IotDeviceDO updateObj = new IotDeviceDO().setId(device.getId()).setState(state);
329   - if (device.getOnlineTime() == null
330   - && Objects.equals(state, IotDeviceStateEnum.ONLINE.getState())) {
331   - updateObj.setActiveTime(LocalDateTime.now());
332   - }
333   - if (Objects.equals(state, IotDeviceStateEnum.ONLINE.getState())) {
334   - updateObj.setOnlineTime(LocalDateTime.now());
335   - } else if (Objects.equals(state, IotDeviceStateEnum.OFFLINE.getState())) {
336   - updateObj.setOfflineTime(LocalDateTime.now());
  327 + public boolean updateDeviceState(IotDeviceDO device, Integer state, LocalDateTime time, boolean refresh) {
  328 + // 1.0 事件时间按秒比较、存储:时间字段是秒级精度(例如说 MySQL DATETIME 会把毫秒四舍五入),
  329 + // 否则 .600 的离线存成下一秒后,同一秒内 .800 的重连会被误判为迟到。同一秒内的先后,按处理顺序决定
  330 + time = time.truncatedTo(ChronoUnit.SECONDS);
  331 + // 1.1 条件更新状态:只有真的从“不在线”变“在线”(或反之),才算状态变化
  332 + boolean online = Objects.equals(state, IotDeviceStateEnum.ONLINE.getState());
  333 + int changed = online ? deviceMapper.updateStateOnline(device.getId(), time)
  334 + : deviceMapper.updateStateOffline(device.getId(), time);
  335 + // 1.2 状态未变化:只推进时间水位,不算状态变化(不触发规则,不联动)
  336 + int refreshed = 0;
  337 + if (changed == 0) {
  338 + if (online) {
  339 + refreshed = refresh ? deviceMapper.updateOnlineTime(device.getId(), time) : 0;
  340 + } else {
  341 + refreshed = deviceMapper.updateOfflineTime(device.getId(), time);
  342 + }
337 343 }
338   - deviceMapper.updateById(updateObj);
339   -
340   - // 2. 清空对应缓存
341   - deleteDeviceCache(device);
342 344  
343   - // 3. 网关设备下线时,联动所有子设备下线
344   - if (Objects.equals(state, IotDeviceStateEnum.OFFLINE.getState())
345   - && IotProductDeviceTypeEnum.isGateway(device.getDeviceType())) {
346   - handleGatewayOffline(device);
  345 + // 2. 清空对应缓存(事务内调用时,提交后生效)
  346 + if (changed + refreshed > 0) {
  347 + deleteDeviceCache(device);
347 348 }
  349 + // 说明:网关下线联动子设备,不在这里执行;由状态消息的处理,在状态事务提交后调用 handleGatewayOffline
  350 + return changed > 0;
348 351 }
349 352  
350   - /**
351   - * 处理网关下线,联动所有子设备下线
352   - *
353   - * @param gatewayDevice 网关设备
354   - */
355   - private void handleGatewayOffline(IotDeviceDO gatewayDevice) {
  353 + @Override
  354 + public void handleGatewayOffline(IotDeviceDO gatewayDevice, LocalDateTime offlineTime) {
356 355 List<IotDeviceDO> subDevices = deviceMapper.selectListByGatewayId(gatewayDevice.getId());
357 356 if (CollUtil.isEmpty(subDevices)) {
358 357 return;
359 358 }
360 359 for (IotDeviceDO subDevice : subDevices) {
361   - if (Objects.equals(subDevice.getState(), IotDeviceStateEnum.ONLINE.getState())) {
362   - try {
363   - updateDeviceState(subDevice, IotDeviceStateEnum.OFFLINE.getState());
364   - log.info("[handleGatewayOffline][网关({}/{}) 下线,子设备({}/{}) 联动下线]",
365   - gatewayDevice.getProductKey(), gatewayDevice.getDeviceName(),
366   - subDevice.getProductKey(), subDevice.getDeviceName());
367   - } catch (Exception ex) {
368   - log.error("[handleGatewayOffline][子设备({}/{}) 下线失败]",
369   - subDevice.getProductKey(), subDevice.getDeviceName(), ex);
370   - }
  360 + if (IotDeviceStateEnum.isNotOnline(subDevice.getState())) {
  361 + continue;
  362 + }
  363 + // 为什么不直接更新状态呢?因为通过 IotDeviceMessage 可以经过一系列的处理,例如说记录日志、规则引擎等等
  364 + // 事件时间使用网关离线的时间:迟到的网关离线,不会把已经重连的子设备踢下线
  365 + try {
  366 + deviceMessageService.sendDeviceMessage(IotDeviceMessage.buildStateOffline(), subDevice, null, offlineTime);
  367 + log.info("[handleGatewayOffline][网关({}/{}) 下线,子设备({}/{}) 联动下线]",
  368 + gatewayDevice.getProductKey(), gatewayDevice.getDeviceName(),
  369 + subDevice.getProductKey(), subDevice.getDeviceName());
  370 + } catch (Exception ex) {
  371 + log.error("[handleGatewayOffline][子设备({}/{}) 下线失败]",
  372 + subDevice.getProductKey(), subDevice.getDeviceName(), ex);
371 373 }
372 374 }
373 375 }
374 376  
375 377 @Override
376   - public void updateDeviceState(Long id, Integer state) {
377   - // 校验存在
378   - IotDeviceDO device = validateDeviceExists(id);
379   - // 执行更新
380   - updateDeviceState(device, state);
381   - }
382   -
383   - @Override
384 378 public Long getDeviceCountByProductId(Long productId) {
385 379 return deviceMapper.selectCountByProductId(productId);
386 380 }
... ... @@ -759,9 +753,9 @@ public class IotDeviceServiceImpl implements IotDeviceService {
759 753 // 3. 清空对应缓存
760 754 deleteDeviceCache(subDevice);
761 755  
762   - // 4. 子设备下线
763   - if (Objects.equals(subDevice.getState(), IotDeviceStateEnum.ONLINE.getState())) {
764   - updateDeviceState(subDevice, IotDeviceStateEnum.OFFLINE.getState());
  756 + // 4. 子设备下线:发送离线消息,经过日志、规则引擎等处理;事件时间为解绑时刻
  757 + if (IotDeviceStateEnum.isOnline(subDevice.getState())) {
  758 + deviceMessageService.sendDeviceMessage(IotDeviceMessage.buildStateOffline(), subDevice, null, LocalDateTime.now());
765 759 }
766 760 }
767 761  
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageService.java
... ... @@ -40,6 +40,20 @@ public interface IotDeviceMessageService {
40 40 IotDeviceMessage sendDeviceMessage(IotDeviceMessage message, IotDeviceDO device, String serverId);
41 41  
42 42 /**
  43 + * 发送设备消息(指定事件时间)
  44 + *
  45 + * 用于 biz 派生的消息沿用源事件的时间,例如说:强制上线、网关下线联动子设备、离线检测。仅供内部调用,不对外暴露
  46 + *
  47 + * @param message 消息
  48 + * @param device 设备
  49 + * @param serverId 网关 serverId
  50 + * @param eventTime 事件时间,作为消息的 reportTime;为空时使用当前时间
  51 + * @return 设备消息
  52 + */
  53 + IotDeviceMessage sendDeviceMessage(IotDeviceMessage message, IotDeviceDO device, String serverId,
  54 + @Nullable LocalDateTime eventTime);
  55 +
  56 + /**
43 57 * 发送设备消息
44 58 *
45 59 * @param message 消息(“codec(编解码)字段” 部分字段)
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImpl.java
... ... @@ -3,8 +3,9 @@ package cn.iocoder.yudao.module.iot.service.device.message;
3 3 import cn.hutool.core.collection.CollUtil;
4 4 import cn.hutool.core.collection.ListUtil;
5 5 import cn.hutool.core.date.LocalDateTimeUtil;
6   -import cn.hutool.core.lang.Assert;
7 6 import cn.hutool.core.map.MapUtil;
  7 +import cn.hutool.core.util.NumberUtil;
  8 +import cn.hutool.core.util.ObjUtil;
8 9 import cn.hutool.core.util.StrUtil;
9 10 import cn.hutool.extra.spring.SpringUtil;
10 11 import cn.iocoder.yudao.framework.common.exception.ServiceException;
... ... @@ -17,6 +18,7 @@ import cn.iocoder.yudao.module.iot.controller.admin.statistics.vo.IotStatisticsD
17 18 import cn.iocoder.yudao.module.iot.controller.admin.statistics.vo.IotStatisticsDeviceMessageSummaryByDateRespVO;
18 19 import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
19 20 import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
  21 +import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum;
20 22 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
21 23 import cn.iocoder.yudao.module.iot.core.mq.producer.IotDeviceMessageProducer;
22 24 import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
... ... @@ -28,17 +30,23 @@ import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
28 30 import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO;
29 31 import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO;
30 32 import cn.iocoder.yudao.module.iot.dal.storage.IotDeviceMessageDAO;
  33 +import cn.iocoder.yudao.module.iot.enums.product.IotProductDeviceTypeEnum;
31 34 import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
32 35 import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService;
33 36 import cn.iocoder.yudao.module.iot.service.device.shadow.IotDeviceShadowService;
34 37 import cn.iocoder.yudao.module.iot.service.ota.IotOtaTaskRecordService;
35 38 import cn.iocoder.yudao.module.iot.service.product.IotProductService;
  39 +import cn.iocoder.yudao.module.iot.service.rule.data.IotDataRuleService;
  40 +import cn.iocoder.yudao.module.iot.service.rule.scene.IotSceneRuleService;
36 41 import com.google.common.base.Objects;
37 42 import jakarta.annotation.Resource;
38 43 import lombok.extern.slf4j.Slf4j;
39 44 import org.springframework.context.annotation.Lazy;
40 45 import org.springframework.scheduling.annotation.Async;
41 46 import org.springframework.stereotype.Service;
  47 +import org.springframework.transaction.annotation.Transactional;
  48 +import org.springframework.transaction.interceptor.TransactionAspectSupport;
  49 +import org.springframework.transaction.support.TransactionSynchronizationManager;
42 50 import org.springframework.validation.annotation.Validated;
43 51  
44 52 import java.sql.Timestamp;
... ... @@ -47,6 +55,7 @@ import java.util.List;
47 55 import java.util.Map;
48 56  
49 57 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  58 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
50 59 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
51 60 import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DEVICE_DOWNSTREAM_FAILED_SERVER_ID_NULL;
52 61 import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DEVICE_MESSAGE_RAW_METHOD_RESERVED;
... ... @@ -77,6 +86,12 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
77 86 @Resource
78 87 @Lazy // 延迟加载,避免循环依赖
79 88 private IotProductService productService;
  89 + @Resource
  90 + @Lazy // 延迟加载,避免循环依赖
  91 + private IotSceneRuleService sceneRuleService;
  92 + @Resource
  93 + @Lazy // 延迟加载,避免循环依赖
  94 + private IotDataRuleService dataRuleService;
80 95  
81 96 @Resource
82 97 private IotDeviceMessageDAO deviceMessageDAO;
... ... @@ -126,6 +141,12 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
126 141  
127 142 @Override
128 143 public IotDeviceMessage sendDeviceMessage(IotDeviceMessage message, IotDeviceDO device, String serverId) {
  144 + return sendDeviceMessage(message, device, serverId, null);
  145 + }
  146 +
  147 + @Override
  148 + public IotDeviceMessage sendDeviceMessage(IotDeviceMessage message, IotDeviceDO device, String serverId,
  149 + LocalDateTime eventTime) {
129 150 // 0. 透传:raw.post 只能由网关创建;raw.push 校验后改写为 raw,网关从 raw 取字节;其它消息不能携带 raw
130 151 byte[] rawPayload = validateRawMessage(message, device);
131 152 message.setRaw(rawPayload != null ? IotDeviceMessageUtils.encodeRaw(rawPayload) : null);
... ... @@ -133,7 +154,7 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
133 154 message.setParams(null);
134 155 }
135 156 // 1. 补充信息
136   - appendDeviceMessage(message, device);
  157 + appendDeviceMessage(message, device, eventTime);
137 158  
138 159 // 2.1 情况一:发送上行消息
139 160 boolean upstream = IotDeviceMessageUtils.isUpstreamMessage(message);
... ... @@ -203,11 +224,13 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
203 224 /**
204 225 * 补充消息的后端字段
205 226 *
206   - * @param message 消息
207   - * @param device 设备信息
  227 + * @param message 消息
  228 + * @param device 设备信息
  229 + * @param eventTime 事件时间;为空时使用当前时间
208 230 */
209   - private void appendDeviceMessage(IotDeviceMessage message, IotDeviceDO device) {
210   - message.setId(IotDeviceMessageUtils.generateMessageId()).setReportTime(LocalDateTime.now())
  231 + private void appendDeviceMessage(IotDeviceMessage message, IotDeviceDO device, LocalDateTime eventTime) {
  232 + message.setId(IotDeviceMessageUtils.generateMessageId())
  233 + .setReportTime(eventTime != null ? eventTime : LocalDateTime.now())
211 234 .setDeviceId(device.getId()).setTenantId(device.getTenantId());
212 235 // 特殊:如果设备没有指定 requestId,则使用 messageId
213 236 if (StrUtil.isEmpty(message.getRequestId())) {
... ... @@ -251,15 +274,10 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
251 274 }
252 275 }
253 276  
254   - // TODO @芋艿:可优化:未来逻辑复杂后,可以独立拆除 Processor 处理器
255 277 private Object handleUpstreamDeviceMessage0(IotDeviceMessage message, IotDeviceDO device) {
256 278 // 设备上下线
257 279 if (Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod())) {
258   - String stateStr = IotDeviceMessageUtils.getIdentifier(message);
259   - assert stateStr != null;
260   - Assert.notEmpty(stateStr, "设备状态不能为空");
261   - Integer state = Integer.valueOf(stateStr);
262   - deviceService.updateDeviceState(device, state);
  280 + processStateUpdateMessage(message, device);
263 281 return null;
264 282 }
265 283  
... ... @@ -307,6 +325,117 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
307 325 return null;
308 326 }
309 327  
  328 + // ========== 设备状态处理方法 ==========
  329 +
  330 + /**
  331 + * 处理设备状态消息
  332 + *
  333 + * 1. 校验状态值,忽略旧网关节点的离线
  334 + * 2. 在状态事务中:条件更新状态;状态真的变化时,执行场景联动、数据流转
  335 + * 3. 状态事务提交后:网关离线时,联动子设备离线
  336 + *
  337 + * @param message 消息
  338 + * @param device 设备
  339 + */
  340 + private void processStateUpdateMessage(IotDeviceMessage message, IotDeviceDO device) {
  341 + // 1.1 校验状态值:只接受在线、离线(设备自己上报的状态,同样在这里校验)
  342 + String stateStr = IotDeviceMessageUtils.getIdentifier(message);
  343 + Integer state = NumberUtil.isInteger(stateStr) ? Integer.valueOf(stateStr) : null;
  344 + if (ObjUtil.notEqual(state, IotDeviceStateEnum.ONLINE.getState())
  345 + && ObjUtil.notEqual(state, IotDeviceStateEnum.OFFLINE.getState())) {
  346 + throw invalidParamException("设备状态({}) 只能是在线或离线", stateStr);
  347 + }
  348 + // 1.2 旧网关节点的离线,不覆盖新节点上的连接
  349 + if (isStaleServerOffline(message, device, state)) {
  350 + return;
  351 + }
  352 + // 1.3 拒绝已有事务:REQUIRED 会加入外层事务,事务方法返回时并未提交,后续的子设备联动就失去了前提
  353 + // 说明:Local 总线在发布线程上同步消费,所以发布方不能在事务内发布状态消息
  354 + if (TransactionSynchronizationManager.isActualTransactionActive()) {
  355 + log.error("[processStateUpdateMessage][message({}) 状态消息不能在已有事务中处理]", message);
  356 + throw new IllegalStateException("状态消息不能在已有事务中处理");
  357 + }
  358 +
  359 + // 2. 状态事务:经过代理开启事务,返回时已经提交
  360 + LocalDateTime time = message.getReportTime() != null ? message.getReportTime() : LocalDateTime.now();
  361 + boolean changed = getSelf().handleStateUpdateMessage(message, device, state, time);
  362 +
  363 + // 3. 网关离线:提交后,再联动子设备离线。每个子设备走自己的消息和事务,不加入网关的状态事务
  364 + if (changed && Objects.equal(state, IotDeviceStateEnum.OFFLINE.getState())
  365 + && IotProductDeviceTypeEnum.isGateway(device.getDeviceType())) {
  366 + deviceService.handleGatewayOffline(device, time);
  367 + }
  368 + }
  369 +
  370 + /**
  371 + * 在状态事务中处理设备状态:条件更新状态;状态真的变化时,执行场景联动、数据流转
  372 + *
  373 + * 为什么规则也放在事务内?保证“已提交的有效状态变化,提交前已经尝试过规则”:
  374 + * 规则执行完成前失败时,状态一起回滚,异常抛给消息总线,重投后再次处理
  375 + *
  376 + * 注意:必须经过 Spring 代理调用,且调用方不在事务中;这样方法返回时,事务已经提交
  377 + *
  378 + * @param message 消息
  379 + * @param device 设备
  380 + * @param state 状态
  381 + * @param time 事件时间
  382 + * @return 状态是否真的发生了变化
  383 + */
  384 + @Transactional(rollbackFor = Exception.class)
  385 + public boolean handleStateUpdateMessage(IotDeviceMessage message, IotDeviceDO device, Integer state, LocalDateTime time) {
  386 + // 1. 条件更新状态:重复的、迟到的状态,直接返回,不触发规则
  387 + // 带 serverId 的上线来自网关的连接,状态未变化时也刷新最后上线时间
  388 + if (!deviceService.updateDeviceState(device, state, time, StrUtil.isNotEmpty(message.getServerId()))) {
  389 + return false;
  390 + }
  391 +
  392 + // 2. 执行场景联动、数据流转:两个规则订阅者跳过状态消息,统一在这里触发
  393 + sceneRuleService.executeSceneRuleByDevice(message);
  394 + dataRuleService.executeDataRule(message);
  395 +
  396 + // 3. 规则可能把事务标记为回滚:本地 rollback-only 时,提交会静默回滚、不抛异常,所以这里显式失败
  397 + if (TransactionAspectSupport.currentTransactionStatus().isRollbackOnly()) {
  398 + throw new IllegalStateException("状态事务已被标记回滚");
  399 + }
  400 + return true;
  401 + }
  402 +
  403 + /**
  404 + * 判断是否是旧网关节点的离线:设备已经连接到其它网关节点时,原节点上旧连接的离线不生效
  405 + *
  406 + * 只比较“离线且带 serverId”的网关连接事件:平台、子设备联动、离线检测发出的离线,serverId 为空,照常生效;
  407 + * EMQX 的 serverId 是接收 webhook 的节点,不代表连接归属,所以不比较
  408 + *
  409 + * @param message 消息
  410 + * @param device 设备
  411 + * @param state 状态
  412 + * @return 是否是旧网关节点的离线
  413 + */
  414 + private boolean isStaleServerOffline(IotDeviceMessage message, IotDeviceDO device, Integer state) {
  415 + if (ObjUtil.notEqual(state, IotDeviceStateEnum.OFFLINE.getState())
  416 + || StrUtil.isEmpty(message.getServerId()) || isEmqxDevice(device)) {
  417 + return false;
  418 + }
  419 + String currentServerId = devicePropertyService.getDeviceServerId(device.getId());
  420 + if (currentServerId == null || currentServerId.equals(message.getServerId())) {
  421 + return false;
  422 + }
  423 + log.info("[isStaleServerOffline][设备({}) 当前连接在网关({}),忽略网关({}) 的离线]",
  424 + device.getId(), currentServerId, message.getServerId());
  425 + return true;
  426 + }
  427 +
  428 + /**
  429 + * 是否是 EMQX 协议的设备:产品不存在时,按非 EMQX 处理
  430 + *
  431 + * @param device 设备
  432 + * @return 是否是 EMQX 协议的设备
  433 + */
  434 + private boolean isEmqxDevice(IotDeviceDO device) {
  435 + IotProductDO product = productService.getProductFromCache(device.getProductId());
  436 + return product != null && IotProtocolTypeEnum.of(product.getProtocolType()) == IotProtocolTypeEnum.EMQX;
  437 + }
  438 +
310 439 // ========== 批量上报处理方法 ==========
311 440  
312 441 /**
... ... @@ -327,7 +456,9 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
327 456 }
328 457  
329 458 // 2. 处理网关设备(自身)的数据
330   - sendDevicePackData(gatewayDevice, packMessage.getServerId(), params.getProperties(), params.getEvents());
  459 + // 特殊:拆分出的消息沿用 pack 的上报时间,避免迟到的 pack 变成“新”的消息(例如说,强制上线用的是它的时间)
  460 + LocalDateTime reportTime = packMessage.getReportTime() != null ? packMessage.getReportTime() : LocalDateTime.now();
  461 + sendDevicePackData(gatewayDevice, packMessage.getServerId(), params.getProperties(), params.getEvents(), reportTime);
331 462  
332 463 // 3. 处理子设备的数据
333 464 if (CollUtil.isEmpty(params.getSubDevices())) {
... ... @@ -342,7 +473,8 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
342 473 continue;
343 474 }
344 475 // 特殊:子设备不需要指定 serverId,因为子设备实际可能连接在不同的 gateway-server 上,导致 serverId 不同
345   - sendDevicePackData(subDevice, null, subDeviceData.getProperties(), subDeviceData.getEvents());
  476 + sendDevicePackData(subDevice, null, subDeviceData.getProperties(), subDeviceData.getEvents(),
  477 + reportTime);
346 478 } catch (Exception ex) {
347 479 log.error("[handlePackMessage][子设备({}/{}) 数据处理失败]", subDeviceData.getIdentity().getProductKey(),
348 480 subDeviceData.getIdentity().getDeviceName(), ex);
... ... @@ -357,16 +489,19 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
357 489 * @param serverId 服务标识
358 490 * @param properties 属性数据
359 491 * @param events 事件数据
  492 + * @param reportTime 上报时间,沿用 pack 消息的上报时间
360 493 */
361 494 private void sendDevicePackData(IotDeviceDO device, String serverId,
362 495 Map<String, Object> properties,
363   - Map<String, IotDevicePropertyPackPostReqDTO.EventValue> events) {
  496 + Map<String, IotDevicePropertyPackPostReqDTO.EventValue> events,
  497 + LocalDateTime reportTime) {
364 498 // 1. 发送属性消息
365 499 if (MapUtil.isNotEmpty(properties)) {
366 500 IotDeviceMessage propertyMsg = IotDeviceMessage.requestOf(
367 501 device.getId(), device.getTenantId(), serverId,
368 502 IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(),
369 503 IotDevicePropertyPostReqDTO.of(properties));
  504 + propertyMsg.setReportTime(reportTime);
370 505 deviceMessageProducer.sendDeviceMessage(propertyMsg);
371 506 }
372 507  
... ... @@ -382,6 +517,7 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
382 517 device.getId(), device.getTenantId(), serverId,
383 518 IotDeviceMessageMethodEnum.EVENT_POST.getMethod(),
384 519 IotDeviceEventPostReqDTO.of(eventId, eventValue.getValue(), eventValue.getTime()));
  520 + eventMsg.setReportTime(reportTime);
385 521 deviceMessageProducer.sendDeviceMessage(eventMsg);
386 522 }
387 523 }
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyService.java
... ... @@ -10,7 +10,6 @@ import jakarta.validation.Valid;
10 10 import java.time.LocalDateTime;
11 11 import java.util.List;
12 12 import java.util.Map;
13   -import java.util.Set;
14 13  
15 14 /**
16 15 * IoT 设备【属性】数据 Service 接口
... ... @@ -64,12 +63,12 @@ public interface IotDevicePropertyService {
64 63 // ========== 设备时间相关操作 ==========
65 64  
66 65 /**
67   - * 获得最后上报时间小于指定时间的设备编号集合
  66 + * 获得最后上报时间小于指定时间的设备
68 67 *
69 68 * @param maxReportTime 最大上报时间
70   - * @return 设备编号集合
  69 + * @return 设备编号与最后上报时间的映射
71 70 */
72   - Set<Long> getDeviceIdListByReportTime(LocalDateTime maxReportTime);
  71 + Map<Long, LocalDateTime> getDeviceReportTimeMapByReportTime(LocalDateTime maxReportTime);
73 72  
74 73 /**
75 74 * 更新设备上报时间
... ... @@ -95,4 +94,20 @@ public interface IotDevicePropertyService {
95 94 */
96 95 String getDeviceServerId(Long id);
97 96  
  97 + /**
  98 + * 获得网关协议实例的启动时间
  99 + *
  100 + * @param serverId 网关 serverId
  101 + * @return 启动时间(毫秒);网关已停止或宕机时返回 null
  102 + */
  103 + Long getGatewayServerStartTime(String serverId);
  104 +
  105 + /**
  106 + * 更新网关协议实例的启动时间,同时续期存活标记
  107 + *
  108 + * @param serverId 网关 serverId
  109 + * @param startTime 网关上报的启动时间(毫秒)
  110 + */
  111 + void updateGatewayServerStartTime(String serverId, Long startTime);
  112 +
98 113 }
99 114 \ No newline at end of file
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImpl.java
... ... @@ -189,8 +189,8 @@ public class IotDevicePropertyServiceImpl implements IotDevicePropertyService {
189 189 // ========== 设备时间相关操作 ==========
190 190  
191 191 @Override
192   - public Set<Long> getDeviceIdListByReportTime(LocalDateTime maxReportTime) {
193   - return deviceReportTimeRedisDAO.range(maxReportTime);
  192 + public Map<Long, LocalDateTime> getDeviceReportTimeMapByReportTime(LocalDateTime maxReportTime) {
  193 + return deviceReportTimeRedisDAO.rangeWithScores(maxReportTime);
194 194 }
195 195  
196 196 @Override
... ... @@ -212,6 +212,16 @@ public class IotDevicePropertyServiceImpl implements IotDevicePropertyService {
212 212 return deviceServerIdRedisDAO.get(id);
213 213 }
214 214  
  215 + @Override
  216 + public Long getGatewayServerStartTime(String serverId) {
  217 + return deviceServerIdRedisDAO.getGatewayServerStartTime(serverId);
  218 + }
  219 +
  220 + @Override
  221 + public void updateGatewayServerStartTime(String serverId, Long startTime) {
  222 + deviceServerIdRedisDAO.updateGatewayServerStartTime(serverId, startTime);
  223 + }
  224 +
215 225 // ========== 设备定位相关操作 ==========
216 226  
217 227 /**
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImpl.java
... ... @@ -8,6 +8,7 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult;
8 8 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
9 9 import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
10 10 import cn.iocoder.yudao.framework.common.util.spring.SpringUtils;
  11 +import cn.iocoder.yudao.framework.mybatis.core.util.JdbcUtils;
11 12 import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.rule.IotDataRulePageReqVO;
12 13 import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.rule.IotDataRuleSaveReqVO;
13 14 import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
... ... @@ -241,6 +242,8 @@ public class IotDataRuleServiceImpl implements IotDataRuleService {
241 242 Set<Long> processedSinkIds = new HashSet<>();
242 243 matchedRules.forEach(rule -> executeDataRule(message, rule, processedSinkIds));
243 244 } catch (Exception e) {
  245 + // 状态事务内,数据访问异常不吞掉,交给事务回滚(见 IotDeviceMessageServiceImpl#handleStateUpdateMessage)
  246 + JdbcUtils.throwIfDataAccessExceptionInTransaction(e);
244 247 log.error("[executeDataRule][消息({}) 执行数据流转规则异常]", message, e);
245 248 }
246 249 }
... ... @@ -298,6 +301,8 @@ public class IotDataRuleServiceImpl implements IotDataRuleService {
298 301 // 执行数据桥接操作
299 302 executeDataRuleAction(message, dataSink);
300 303 } catch (Exception e) {
  304 + // 状态事务内,数据访问异常不吞掉,交给事务回滚
  305 + JdbcUtils.throwIfDataAccessExceptionInTransaction(e);
301 306 log.error("[executeDataRule][规则({}) 数据目的({}) 执行异常]", rule.getId(), sinkId, e);
302 307 }
303 308 });
... ... @@ -322,6 +327,8 @@ public class IotDataRuleServiceImpl implements IotDataRuleService {
322 327 action.execute(message, dataSink);
323 328 log.info("[executeDataRuleAction][消息({}) 数据目的({}) 执行成功]", message.getId(), dataSink.getId());
324 329 } catch (Exception e) {
  330 + // 状态事务内,数据访问异常不吞掉,交给事务回滚;外部数据目的自身的异常,由各数据目的内部记录,不会到这里
  331 + JdbcUtils.throwIfDataAccessExceptionInTransaction(e);
325 332 log.error("[executeDataRuleAction][消息({}) 数据目的({}) 执行异常]", message.getId(), dataSink.getId(), e);
326 333 }
327 334 });
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotHttpDataSinkAction.java
... ... @@ -8,13 +8,16 @@ import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
8 8 import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotDataSinkDO;
9 9 import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkHttpConfig;
10 10 import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum;
  11 +import jakarta.annotation.PostConstruct;
11 12 import jakarta.annotation.Resource;
12 13 import lombok.extern.slf4j.Slf4j;
  14 +import org.springframework.boot.web.client.RestTemplateBuilder;
13 15 import org.springframework.http.*;
14 16 import org.springframework.stereotype.Component;
15 17 import org.springframework.web.client.RestTemplate;
16 18 import org.springframework.web.util.UriComponentsBuilder;
17 19  
  20 +import java.time.Duration;
18 21 import java.util.HashMap;
19 22 import java.util.Map;
20 23  
... ... @@ -29,9 +32,30 @@ import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.HEADER_
29 32 @Slf4j
30 33 public class IotHttpDataSinkAction implements IotDataRuleAction {
31 34  
  35 + /**
  36 + * 连接超时时间
  37 + *
  38 + * 设备状态消息会在事务内执行数据流转(持有设备行锁和数据库连接),所以不能使用无超时的共享 RestTemplate
  39 + */
  40 + static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3);
  41 + /**
  42 + * 读取超时时间
  43 + *
  44 + * 一般 Webhook 秒级即可响应;过长会拉长状态事务,过短容易误判慢接口,所以取 5 秒
  45 + */
  46 + static final Duration READ_TIMEOUT = Duration.ofSeconds(5);
  47 +
32 48 @Resource
  49 + private RestTemplateBuilder restTemplateBuilder;
  50 +
33 51 private RestTemplate restTemplate;
34 52  
  53 + @PostConstruct
  54 + public void init() {
  55 + // 基于共享的 RestTemplateBuilder 构建私有实例,不影响其它模块使用的 RestTemplate
  56 + restTemplate = restTemplateBuilder.connectTimeout(CONNECT_TIMEOUT).readTimeout(READ_TIMEOUT).build();
  57 + }
  58 +
35 59 @Override
36 60 public Integer getType() {
37 61 return IotDataSinkTypeEnum.HTTP.getType();
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceImpl.java
... ... @@ -7,6 +7,7 @@ import cn.hutool.extra.spring.SpringUtil;
7 7 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
8 8 import cn.iocoder.yudao.framework.common.pojo.PageResult;
9 9 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
  10 +import cn.iocoder.yudao.framework.mybatis.core.util.JdbcUtils;
10 11 import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
11 12 import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
12 13 import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.scene.IotSceneRulePageReqVO;
... ... @@ -386,6 +387,8 @@ public class IotSceneRuleServiceImpl implements IotSceneRuleService {
386 387 try {
387 388 return sceneRuleMatcherManager.isMatched(message, trigger) && isTriggerConditionGroupsMatched(message, trigger, sceneRule);
388 389 } catch (Exception e) {
  390 + // 状态事务内,数据访问异常不吞掉,交给事务回滚(见 IotDeviceMessageServiceImpl#handleStateUpdateMessage)
  391 + JdbcUtils.throwIfDataAccessExceptionInTransaction(e);
389 392 log.error("[matchSingleTrigger][触发器匹配异常] sceneRuleId: {}, triggerType: {}, message: {}",
390 393 sceneRule.getId(), trigger.getType(), message, e);
391 394 return false;
... ... @@ -445,6 +448,8 @@ public class IotSceneRuleServiceImpl implements IotSceneRuleService {
445 448 try {
446 449 return sceneRuleMatcherManager.isConditionMatched(message, condition);
447 450 } catch (Exception e) {
  451 + // 状态事务内,数据访问异常不吞掉,交给事务回滚
  452 + JdbcUtils.throwIfDataAccessExceptionInTransaction(e);
448 453 log.error("[isTriggerConditionMatched][规则场景编号({}) 的触发器({}) 条件匹配异常]",
449 454 sceneRule.getId(), trigger, e);
450 455 return false;
... ... @@ -475,6 +480,8 @@ public class IotSceneRuleServiceImpl implements IotSceneRuleService {
475 480 log.info("[executeSceneRuleAction][消息({}) 规则场景编号({}) 的执行动作({}) 成功]",
476 481 message, sceneRule.getId(), actionConfig);
477 482 } catch (Exception e) {
  483 + // 状态事务内,数据访问异常不吞掉,交给事务回滚
  484 + JdbcUtils.throwIfDataAccessExceptionInTransaction(e);
478 485 log.error("[executeSceneRuleAction][消息({}) 规则场景编号({}) 的执行动作({}) 执行异常]",
479 486 message, sceneRule.getId(), actionConfig, e);
480 487 }
... ... @@ -495,6 +502,8 @@ public class IotSceneRuleServiceImpl implements IotSceneRuleService {
495 502 try {
496 503 sceneRuleMapper.updateById(new IotSceneRuleDO().setId(id).setLastTriggerTime(LocalDateTime.now()));
497 504 } catch (Exception e) {
  505 + // 状态事务内,数据访问异常不吞掉,交给事务回滚(例如说,热点行的锁等待超时)
  506 + JdbcUtils.throwIfDataAccessExceptionInTransaction(e);
498 507 log.error("[updateLastTriggerTime][规则场景编号({}) 更新最后触发时间异常]", id, e);
499 508 }
500 509 }
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotAlertTriggerSceneRuleAction.java
... ... @@ -6,6 +6,7 @@ import cn.hutool.core.date.DatePattern;
6 6 import cn.hutool.core.date.LocalDateTimeUtil;
7 7 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
8 8 import cn.iocoder.yudao.framework.dict.core.DictFrameworkUtils;
  9 +import cn.iocoder.yudao.framework.mybatis.core.util.JdbcUtils;
9 10 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
10 11 import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertConfigDO;
11 12 import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
... ... @@ -115,6 +116,8 @@ public class IotAlertTriggerSceneRuleAction implements IotSceneRuleAction {
115 116 break;
116 117 }
117 118 } catch (Exception ex) {
  119 + // 状态事务内,数据访问异常不吞掉,交给事务回滚(例如说,单体部署下写短信、邮件、站内信日志失败)
  120 + JdbcUtils.throwIfDataAccessExceptionInTransaction(ex);
118 121 log.error("[sendAlertMessageToUser][用户({}) 模板参数({}) 发送 {} 告警失败]",
119 122 userId, templateParams, typeEnum, ex);
120 123 }
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotDevicePropertySetSceneRuleAction.java
... ... @@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.iot.service.rule.scene.action;
3 3 import cn.hutool.core.collection.CollUtil;
4 4 import cn.hutool.core.map.MapUtil;
5 5 import cn.hutool.core.util.StrUtil;
  6 +import cn.iocoder.yudao.framework.mybatis.core.util.JdbcUtils;
6 7 import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
7 8 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
8 9 import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
... ... @@ -111,6 +112,8 @@ public class IotDevicePropertySetSceneRuleAction implements IotSceneRuleAction {
111 112 log.info("[executePropertySetForDevice][规则场景({}) 动作配置({}) 设备({}) 属性设置消息({}) 发送成功]",
112 113 rule.getId(), actionConfig, device.getId(), result.getId());
113 114 } catch (Exception e) {
  115 + // 状态事务内,数据访问异常不吞掉,交给事务回滚(例如说,读取 serverId 的 Redis 故障)
  116 + JdbcUtils.throwIfDataAccessExceptionInTransaction(e);
114 117 log.error("[executePropertySetForDevice][规则场景({}) 动作配置({}) 设备({}) 属性设置消息发送失败]",
115 118 rule.getId(), actionConfig, device.getId(), e);
116 119 }
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotDeviceServiceInvokeSceneRuleAction.java
... ... @@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.iot.service.rule.scene.action;
3 3 import cn.hutool.core.collection.CollUtil;
4 4 import cn.hutool.core.map.MapUtil;
5 5 import cn.hutool.core.util.StrUtil;
  6 +import cn.iocoder.yudao.framework.mybatis.core.util.JdbcUtils;
6 7 import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
7 8 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
8 9 import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
... ... @@ -114,6 +115,8 @@ public class IotDeviceServiceInvokeSceneRuleAction implements IotSceneRuleAction
114 115 log.info("[executeServiceInvokeForDevice][规则场景({}) 动作配置({}) 设备({}) 服务调用消息({}) 发送成功]",
115 116 rule.getId(), actionConfig, device.getId(), result.getId());
116 117 } catch (Exception e) {
  118 + // 状态事务内,数据访问异常不吞掉,交给事务回滚(例如说,读取 serverId 的 Redis 故障)
  119 + JdbcUtils.throwIfDataAccessExceptionInTransaction(e);
117 120 log.error("[executeServiceInvokeForDevice][规则场景({}) 动作配置({}) 设备({}) 服务调用消息发送失败]",
118 121 rule.getId(), actionConfig, device.getId(), e);
119 122 }
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/IotSceneRuleMatcherManager.java
1 1 package cn.iocoder.yudao.module.iot.service.rule.scene.matcher;
2 2  
3 3 import cn.hutool.core.collection.CollUtil;
  4 +import cn.iocoder.yudao.framework.mybatis.core.util.JdbcUtils;
4 5 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
5 6 import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO;
6 7 import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionTypeEnum;
... ... @@ -114,6 +115,8 @@ public class IotSceneRuleMatcherManager {
114 115 try {
115 116 return matcher.matches(message, trigger);
116 117 } catch (Exception e) {
  118 + // 状态事务内,数据访问异常不吞掉,交给事务回滚(例如说,匹配时读取设备缓存的 Redis 故障)
  119 + JdbcUtils.throwIfDataAccessExceptionInTransaction(e);
117 120 log.error("[isMatched][触发器匹配异常] message: {}, trigger: {}", message, trigger, e);
118 121 return false;
119 122 }
... ... @@ -148,6 +151,8 @@ public class IotSceneRuleMatcherManager {
148 151 try {
149 152 return matcher.matches(message, condition);
150 153 } catch (Exception e) {
  154 + // 状态事务内,数据访问异常不吞掉,交给事务回滚
  155 + JdbcUtils.throwIfDataAccessExceptionInTransaction(e);
151 156 log.error("[isConditionMatched][message({}) condition({}) 条件匹配异常]", message, condition, e);
152 157 return false;
153 158 }
... ...
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImplTest.java
... ... @@ -6,11 +6,13 @@ import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
6 6 import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
7 7 import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceGetReqDTO;
8 8 import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO;
  9 +import cn.iocoder.yudao.module.iot.core.biz.dto.IotGatewayServerAliveReqDTO;
9 10 import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
10 11 import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO;
11 12 import cn.iocoder.yudao.module.iot.service.device.IotDeviceModbusConfigService;
12 13 import cn.iocoder.yudao.module.iot.service.device.IotDeviceModbusPointService;
13 14 import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
  15 +import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService;
14 16 import cn.iocoder.yudao.module.iot.service.product.IotProductService;
15 17 import jakarta.validation.Validation;
16 18 import org.junit.jupiter.api.BeforeEach;
... ... @@ -18,11 +20,22 @@ import org.junit.jupiter.api.Test;
18 20 import org.mockito.InjectMocks;
19 21 import org.mockito.Mock;
20 22 import org.springframework.aop.support.AopUtils;
  23 +import org.springframework.data.redis.RedisConnectionFailureException;
  24 +import org.springframework.http.MediaType;
  25 +import org.springframework.test.web.servlet.MockMvc;
  26 +import org.springframework.test.web.servlet.setup.MockMvcBuilders;
21 27 import org.springframework.validation.beanvalidation.MethodValidationPostProcessor;
22 28  
  29 +import java.util.LinkedHashMap;
  30 +import java.util.Map;
  31 +
23 32 import static org.junit.jupiter.api.Assertions.*;
  33 +import static org.mockito.ArgumentMatchers.any;
24 34 import static org.mockito.ArgumentMatchers.eq;
25 35 import static org.mockito.Mockito.*;
  36 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
  37 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
  38 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
26 39  
27 40 /**
28 41 * {@link IoTDeviceApiImpl} 的单元测试:经过真实的 Spring 方法校验代理(与 @Validated 的 Bean 在容器中一致)
... ... @@ -31,6 +44,8 @@ import static org.mockito.Mockito.*;
31 44 */
32 45 public class IoTDeviceApiImplTest extends BaseMockitoUnitTest {
33 46  
  47 + private static final String KEEP_ALIVE_URL = "/rpc-api/iot/gateway-server/keep-alive";
  48 +
34 49 @InjectMocks
35 50 private IoTDeviceApiImpl deviceApiImpl;
36 51  
... ... @@ -42,6 +57,8 @@ public class IoTDeviceApiImplTest extends BaseMockitoUnitTest {
42 57 private IotDeviceModbusConfigService modbusConfigService;
43 58 @Mock
44 59 private IotDeviceModbusPointService modbusPointService;
  60 + @Mock
  61 + private IotDevicePropertyService devicePropertyService;
45 62  
46 63 /**
47 64 * 方法校验代理:参数约束继承不合法时,代理上的任何方法调用都会抛 ConstraintDeclarationException(HV000151)
... ... @@ -113,4 +130,99 @@ public class IoTDeviceApiImplTest extends BaseMockitoUnitTest {
113 130 assertNull(result.getData().getScriptContent());
114 131 }
115 132  
  133 + @Test
  134 + public void testKeepAliveGatewayServers_success() {
  135 + // 准备参数
  136 + Map<String, Long> startTimes = new LinkedHashMap<>();
  137 + startTimes.put("server-1", 1000L);
  138 + startTimes.put("server-2", 2000L);
  139 + IotGatewayServerAliveReqDTO reqDTO = new IotGatewayServerAliveReqDTO().setStartTimes(startTimes);
  140 +
  141 + // 调用
  142 + CommonResult<Boolean> result = deviceApi.keepAliveGatewayServers(reqDTO);
  143 + // 断言:逐项写入网关上报的启动时间
  144 + assertTrue(result.isSuccess());
  145 + assertTrue(result.getData());
  146 + verify(devicePropertyService).updateGatewayServerStartTime(eq("server-1"), eq(1000L));
  147 + verify(devicePropertyService).updateGatewayServerStartTime(eq("server-2"), eq(2000L));
  148 + }
  149 +
  150 + @Test
  151 + public void testKeepAliveGatewayServers_partialFail() {
  152 + // 准备参数
  153 + Map<String, Long> startTimes = new LinkedHashMap<>();
  154 + startTimes.put("server-1", 1000L);
  155 + startTimes.put("server-2", 2000L);
  156 + startTimes.put("server-3", 3000L);
  157 + IotGatewayServerAliveReqDTO reqDTO = new IotGatewayServerAliveReqDTO().setStartTimes(startTimes);
  158 + // mock 方法:中间一项写入失败
  159 + doThrow(new RedisConnectionFailureException("Redis 不可用")).when(devicePropertyService)
  160 + .updateGatewayServerStartTime(eq("server-2"), eq(2000L));
  161 +
  162 + // 调用
  163 + CommonResult<Boolean> result = deviceApi.keepAliveGatewayServers(reqDTO);
  164 + // 断言:失败不影响后面的项,整体结果为 false
  165 + assertTrue(result.isSuccess());
  166 + assertFalse(result.getData());
  167 + verify(devicePropertyService).updateGatewayServerStartTime(eq("server-1"), eq(1000L));
  168 + verify(devicePropertyService).updateGatewayServerStartTime(eq("server-3"), eq(3000L));
  169 + }
  170 +
  171 + @Test
  172 + public void testKeepAliveGatewayServers_httpInvalid() throws Exception {
  173 + // 准备参数:非法的批次
  174 + Map<String, String> invalidBodies = new LinkedHashMap<>();
  175 + invalidBodies.put("startTimes 为 null", "{\"startTimes\":null}");
  176 + invalidBodies.put("startTimes 为空", "{\"startTimes\":{}}");
  177 + invalidBodies.put("serverId 为空白", "{\"startTimes\":{\" \":1000}}");
  178 + invalidBodies.put("启动时间为 null", "{\"startTimes\":{\"server-1\":null}}");
  179 + invalidBodies.put("启动时间为 0", "{\"startTimes\":{\"server-1\":0}}");
  180 + invalidBodies.put("启动时间为负数", "{\"startTimes\":{\"server-1\":-1}}");
  181 + invalidBodies.put("合法项与 null 混合", "{\"startTimes\":{\"server-1\":1000,\"server-2\":null}}");
  182 + MockMvc mockMvc = buildMockMvc();
  183 +
  184 + for (Map.Entry<String, String> entry : invalidBodies.entrySet()) {
  185 + // 调用
  186 + int httpStatus = mockMvc.perform(post(KEEP_ALIVE_URL).contentType(MediaType.APPLICATION_JSON)
  187 + .content(entry.getValue())).andReturn().getResponse().getStatus();
  188 + // 断言:被拒绝
  189 + assertEquals(400, httpStatus, entry.getKey());
  190 + }
  191 + // 断言:任何一项都没有写入
  192 + verifyNoInteractions(devicePropertyService);
  193 + }
  194 +
  195 + @Test
  196 + public void testKeepAliveGatewayServers_httpNoSizeLimit() throws Exception {
  197 + // 准备参数:101 个协议实例
  198 + StringBuilder body = new StringBuilder("{\"startTimes\":{");
  199 + for (int i = 0; i < 101; i++) {
  200 + body.append(i > 0 ? "," : "").append("\"server-").append(i).append("\":").append(1000 + i);
  201 + }
  202 + body.append("}}");
  203 + MockMvc mockMvc = buildMockMvc();
  204 +
  205 + // 调用
  206 + mockMvc.perform(post(KEEP_ALIVE_URL).contentType(MediaType.APPLICATION_JSON).content(body.toString()))
  207 + // 断言:不限制条数,全部写入
  208 + .andExpect(status().isOk())
  209 + .andExpect(jsonPath("$.code").value(0))
  210 + .andExpect(jsonPath("$.data").value(true));
  211 + verify(devicePropertyService, times(101)).updateGatewayServerStartTime(any(), any());
  212 + verify(devicePropertyService).updateGatewayServerStartTime(eq("server-100"), eq(1100L));
  213 + }
  214 +
  215 + /**
  216 + * 构建经过 HTTP 消息转换、参数校验的 MockMvc:控制器使用 CGLIB 方法校验代理,与容器中的 Bean 一致
  217 + */
  218 + private MockMvc buildMockMvc() {
  219 + MethodValidationPostProcessor processor = new MethodValidationPostProcessor();
  220 + processor.setProxyTargetClass(true);
  221 + processor.setValidator(Validation.buildDefaultValidatorFactory().getValidator());
  222 + processor.afterPropertiesSet();
  223 + Object controller = processor.postProcessAfterInitialization(deviceApiImpl, "ioTDeviceApiImpl");
  224 + assertTrue(AopUtils.isCglibProxy(controller));
  225 + return MockMvcBuilders.standaloneSetup(controller).build();
  226 + }
  227 +
116 228 }
... ...
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/job/device/IotDeviceOfflineCheckJobTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.job.device;
  2 +
  3 +import cn.hutool.core.date.LocalDateTimeUtil;
  4 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  5 +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
  6 +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
  7 +import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum;
  8 +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
  9 +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
  10 +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
  11 +import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO;
  12 +import cn.iocoder.yudao.module.iot.enums.product.IotProductDeviceTypeEnum;
  13 +import cn.iocoder.yudao.module.iot.framework.iot.config.YudaoIotProperties;
  14 +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
  15 +import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService;
  16 +import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService;
  17 +import cn.iocoder.yudao.module.iot.service.product.IotProductService;
  18 +import org.junit.jupiter.api.Test;
  19 +import org.mockito.InjectMocks;
  20 +import org.mockito.Mock;
  21 +import org.mockito.Spy;
  22 +import org.springframework.data.redis.RedisConnectionFailureException;
  23 +
  24 +import java.time.LocalDateTime;
  25 +import java.util.Arrays;
  26 +import java.util.HashMap;
  27 +import java.util.Map;
  28 +
  29 +import static org.mockito.ArgumentMatchers.any;
  30 +import static org.mockito.ArgumentMatchers.argThat;
  31 +import static org.mockito.ArgumentMatchers.eq;
  32 +import static org.mockito.ArgumentMatchers.isNull;
  33 +import static org.mockito.ArgumentMatchers.same;
  34 +import static org.mockito.Mockito.*;
  35 +
  36 +/**
  37 + * {@link IotDeviceOfflineCheckJob} 的单元测试
  38 + *
  39 + * @author 芋道源码
  40 + */
  41 +public class IotDeviceOfflineCheckJobTest extends BaseMockitoUnitTest {
  42 +
  43 + /**
  44 + * 最后上报时间:默认超时为 10 分钟 × 1.5 = 15 分钟
  45 + */
  46 + private static final LocalDateTime REPORT_TIME = LocalDateTime.of(2026, 10, 29, 10, 0, 0);
  47 +
  48 + @InjectMocks
  49 + private IotDeviceOfflineCheckJob job;
  50 +
  51 + @Spy
  52 + private YudaoIotProperties iotProperties = new YudaoIotProperties();
  53 + @Mock
  54 + private IotDeviceService deviceService;
  55 + @Mock
  56 + private IotDevicePropertyService devicePropertyService;
  57 + @Mock
  58 + private IotDeviceMessageService deviceMessageService;
  59 + @Mock
  60 + private IotProductService productService;
  61 +
  62 + @Test
  63 + public void testExecute_activityBased() {
  64 + // mock 数据:HTTP 直连设备、MQTT 网关的子设备已超时;另一个 HTTP 设备未超时
  65 + IotDeviceDO httpDevice = buildDevice(1L, 10L, IotProductDeviceTypeEnum.DIRECT, REPORT_TIME);
  66 + IotDeviceDO subDevice = buildDevice(2L, 20L, IotProductDeviceTypeEnum.GATEWAY_SUB, REPORT_TIME);
  67 + IotDeviceDO activeDevice = buildDevice(3L, 10L, IotProductDeviceTypeEnum.DIRECT, REPORT_TIME);
  68 + when(deviceService.getDeviceListByState(eq(IotDeviceStateEnum.ONLINE.getState())))
  69 + .thenReturn(Arrays.asList(httpDevice, subDevice, activeDevice));
  70 + mockTimeoutDevices(httpDevice, subDevice);
  71 + mockProduct(10L, IotProtocolTypeEnum.HTTP);
  72 +
  73 + // 调用
  74 + job.execute(null);
  75 + // 断言:超时即离线,事件时间 = 最后上报时间 + 超时时间;子设备不检查网关存活
  76 + verifyOffline(httpDevice);
  77 + verifyOffline(subDevice);
  78 + verify(deviceMessageService, never()).sendDeviceMessage(any(), same(activeDevice), any(), any());
  79 + verify(devicePropertyService, never()).getDeviceServerId(any());
  80 + }
  81 +
  82 + @Test
  83 + public void testExecute_connectionBased() {
  84 + // mock 数据:4 个已超时的 MQTT 设备,网关 server-1 在 REPORT_TIME - 1h 的 .100 启动
  85 + // device1:在 server-1 启动后上线,连接仍在 → 保持在线
  86 + // device2:在 server-1 启动前上线(之后网关重启过) → 离线
  87 + // device3:网关 server-2 的存活标记已过期 → 离线
  88 + // device4:与 server-1 启动在同一秒上线(实际 .200,按秒存储为 .000) → 保持在线
  89 + LocalDateTime startTime = REPORT_TIME.minusHours(1);
  90 + IotDeviceDO device1 = buildDevice(1L, 10L, IotProductDeviceTypeEnum.DIRECT, REPORT_TIME);
  91 + IotDeviceDO device2 = buildDevice(2L, 10L, IotProductDeviceTypeEnum.DIRECT, REPORT_TIME.minusHours(2));
  92 + IotDeviceDO device3 = buildDevice(3L, 10L, IotProductDeviceTypeEnum.DIRECT, REPORT_TIME);
  93 + IotDeviceDO device4 = buildDevice(4L, 10L, IotProductDeviceTypeEnum.DIRECT, startTime);
  94 + when(deviceService.getDeviceListByState(eq(IotDeviceStateEnum.ONLINE.getState())))
  95 + .thenReturn(Arrays.asList(device1, device2, device3, device4));
  96 + mockTimeoutDevices(device1, device2, device3, device4);
  97 + mockProduct(10L, IotProtocolTypeEnum.MQTT);
  98 + when(devicePropertyService.getDeviceServerId(eq(1L))).thenReturn("server-1");
  99 + when(devicePropertyService.getDeviceServerId(eq(2L))).thenReturn("server-1");
  100 + when(devicePropertyService.getDeviceServerId(eq(3L))).thenReturn("server-2");
  101 + when(devicePropertyService.getDeviceServerId(eq(4L))).thenReturn("server-1");
  102 + when(devicePropertyService.getGatewayServerStartTime(eq("server-1")))
  103 + .thenReturn(LocalDateTimeUtil.toEpochMilli(startTime) + 100);
  104 + when(devicePropertyService.getGatewayServerStartTime(eq("server-2"))).thenReturn(null);
  105 +
  106 + // 调用
  107 + job.execute(null);
  108 + // 断言
  109 + verify(deviceMessageService, never()).sendDeviceMessage(any(), same(device1), any(), any());
  110 + verify(deviceMessageService, never()).sendDeviceMessage(any(), same(device4), any(), any());
  111 + verifyOffline(device2);
  112 + verifyOffline(device3);
  113 + // 断言:同一个 serverId,本轮只读取一次
  114 + verify(devicePropertyService).getGatewayServerStartTime(eq("server-1"));
  115 + }
  116 +
  117 + @Test
  118 + public void testExecute_connectionBasedRedisFailed() {
  119 + // mock 数据:已超时的 TCP 设备
  120 + IotDeviceDO device = buildDevice(1L, 10L, IotProductDeviceTypeEnum.DIRECT, REPORT_TIME);
  121 + when(deviceService.getDeviceListByState(eq(IotDeviceStateEnum.ONLINE.getState())))
  122 + .thenReturn(Arrays.asList(device));
  123 + mockTimeoutDevices(device);
  124 + mockProduct(10L, IotProtocolTypeEnum.TCP);
  125 + when(devicePropertyService.getDeviceServerId(eq(1L))).thenReturn("server-1");
  126 + when(devicePropertyService.getGatewayServerStartTime(eq("server-1")))
  127 + .thenThrow(new RedisConnectionFailureException("Redis 不可用"));
  128 +
  129 + // 调用
  130 + job.execute(null);
  131 + // 断言:读取失败不等于标记不存在,本轮不判离线
  132 + verify(deviceMessageService, never()).sendDeviceMessage(any(), any(), any(), any());
  133 + }
  134 +
  135 + @Test
  136 + public void testExecute_emqx() {
  137 + // mock 数据:已超时的 EMQX 设备
  138 + IotDeviceDO device = buildDevice(1L, 10L, IotProductDeviceTypeEnum.DIRECT, REPORT_TIME);
  139 + when(deviceService.getDeviceListByState(eq(IotDeviceStateEnum.ONLINE.getState())))
  140 + .thenReturn(Arrays.asList(device));
  141 + mockTimeoutDevices(device);
  142 + mockProduct(10L, IotProtocolTypeEnum.EMQX);
  143 +
  144 + // 调用
  145 + job.execute(null);
  146 + // 断言:连接由 EMQX 维护,不使用存活标记,也不判离线
  147 + verify(deviceMessageService, never()).sendDeviceMessage(any(), any(), any(), any());
  148 + verify(devicePropertyService, never()).getGatewayServerStartTime(any());
  149 + }
  150 +
  151 + // ========== 辅助方法 ==========
  152 +
  153 + private void mockTimeoutDevices(IotDeviceDO... devices) {
  154 + Map<Long, LocalDateTime> timeoutDevices = new HashMap<>();
  155 + for (IotDeviceDO device : devices) {
  156 + timeoutDevices.put(device.getId(), REPORT_TIME);
  157 + }
  158 + when(devicePropertyService.getDeviceReportTimeMapByReportTime(any())).thenReturn(timeoutDevices);
  159 + }
  160 +
  161 + private void mockProduct(Long productId, IotProtocolTypeEnum protocolType) {
  162 + when(productService.getProductFromCache(eq(productId)))
  163 + .thenReturn(new IotProductDO().setId(productId).setProtocolType(protocolType.getType()));
  164 + }
  165 +
  166 + private void verifyOffline(IotDeviceDO device) {
  167 + verify(deviceMessageService).sendDeviceMessage(argThat((IotDeviceMessage m) ->
  168 + IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod().equals(m.getMethod())
  169 + && String.valueOf(IotDeviceStateEnum.OFFLINE.getState()).equals(IotDeviceMessageUtils.getIdentifier(m))),
  170 + same(device), isNull(), eq(REPORT_TIME.plusMinutes(15)));
  171 + }
  172 +
  173 + private static IotDeviceDO buildDevice(Long id, Long productId, IotProductDeviceTypeEnum deviceType,
  174 + LocalDateTime onlineTime) {
  175 + return IotDeviceDO.builder().id(id).productId(productId).deviceType(deviceType.getType())
  176 + .state(IotDeviceStateEnum.ONLINE.getState()).onlineTime(onlineTime).build();
  177 + }
  178 +
  179 +}
... ...
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/mq/consumer/device/IotDeviceMessageSubscriberTest.java
... ... @@ -21,6 +21,7 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
21 21 import static org.junit.jupiter.api.Assertions.assertThrows;
22 22 import static org.mockito.ArgumentMatchers.any;
23 23 import static org.mockito.ArgumentMatchers.eq;
  24 +import static org.mockito.ArgumentMatchers.isNull;
24 25 import static org.mockito.Mockito.*;
25 26  
26 27 /**
... ... @@ -90,7 +91,7 @@ public class IotDeviceMessageSubscriberTest extends BaseMockitoUnitTest {
90 91 // 准备参数:设备实际返回的回复(例如 Modbus 异常响应),按原逻辑作为设备活动
91 92 IotDeviceMessage message = new IotDeviceMessage().setDeviceId(randomLongId()).setTenantId(randomLongId())
92 93 .setServerId("test-server").setMethod(IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod())
93   - .setCode(1_051_003_111);
  94 + .setCode(1_051_003_111).setReportTime(LocalDateTime.of(2026, 10, 29, 10, 0, 0));
94 95 // mock 方法:设备离线
95 96 IotDeviceDO device = new IotDeviceDO().setId(message.getDeviceId())
96 97 .setState(IotDeviceStateEnum.OFFLINE.getState());
... ... @@ -98,11 +99,48 @@ public class IotDeviceMessageSubscriberTest extends BaseMockitoUnitTest {
98 99  
99 100 // 调用
100 101 deviceMessageSubscriber.onMessage(message);
101   - // 断言:更新上报时间、连接 server,并强制上线
  102 + // 断言:更新上报时间、连接 server,并强制上线(事件时间沿用源消息的上报时间)
102 103 verify(devicePropertyService).updateDeviceReportTimeAsync(eq(device.getId()), any(LocalDateTime.class));
103 104 verify(devicePropertyService).updateDeviceServerIdAsync(eq(device.getId()), eq(message.getServerId()));
104 105 verify(deviceMessageService).sendDeviceMessage(argThat((IotDeviceMessage m) ->
105   - IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod().equals(m.getMethod())));
  106 + IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod().equals(m.getMethod())),
  107 + same(device), isNull(), eq(message.getReportTime()));
  108 + verify(deviceMessageService).handleUpstreamDeviceMessage(eq(message), eq(device));
  109 + }
  110 +
  111 + @Test
  112 + public void testOnMessage_stateOffline() {
  113 + // 准备参数:网关连接断开的离线
  114 + IotDeviceMessage message = IotDeviceMessage.buildStateOffline().setDeviceId(randomLongId())
  115 + .setTenantId(randomLongId()).setServerId("old-server");
  116 + // mock 方法:设备在线
  117 + IotDeviceDO device = new IotDeviceDO().setId(message.getDeviceId())
  118 + .setState(IotDeviceStateEnum.ONLINE.getState());
  119 + when(deviceService.getDeviceFromCache(eq(message.getDeviceId()))).thenReturn(device);
  120 +
  121 + // 调用
  122 + deviceMessageSubscriber.onMessage(message);
  123 + // 断言:离线不算活跃,也不改变连接的 server
  124 + verify(devicePropertyService, never()).updateDeviceReportTimeAsync(any(), any());
  125 + verify(devicePropertyService, never()).updateDeviceServerIdAsync(any(), any());
  126 + verify(deviceMessageService).handleUpstreamDeviceMessage(eq(message), eq(device));
  127 + }
  128 +
  129 + @Test
  130 + public void testOnMessage_oldMessageKeepServerId() {
  131 + // 准备参数:早于当前上线时间的旧消息(例如说,旧连接上迟到的属性上报)
  132 + IotDeviceMessage message = buildPropertyPostMessage().setServerId("old-server")
  133 + .setReportTime(LocalDateTime.of(2026, 10, 29, 10, 0, 0));
  134 + // mock 方法:设备已经在新连接上线
  135 + IotDeviceDO device = new IotDeviceDO().setId(message.getDeviceId())
  136 + .setState(IotDeviceStateEnum.ONLINE.getState()).setOnlineTime(LocalDateTime.of(2026, 10, 29, 10, 1, 0));
  137 + when(deviceService.getDeviceFromCache(eq(message.getDeviceId()))).thenReturn(device);
  138 +
  139 + // 调用
  140 + deviceMessageSubscriber.onMessage(message);
  141 + // 断言:照常记录活跃,但不改路由
  142 + verify(devicePropertyService).updateDeviceReportTimeAsync(eq(device.getId()), any(LocalDateTime.class));
  143 + verify(devicePropertyService, never()).updateDeviceServerIdAsync(any(), any());
106 144 verify(deviceMessageService).handleUpstreamDeviceMessage(eq(message), eq(device));
107 145 }
108 146  
... ...
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/mq/consumer/rule/IotRuleMessageSubscriberTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.mq.consumer.rule;
  2 +
  3 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  4 +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
  5 +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
  6 +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
  7 +import cn.iocoder.yudao.module.iot.service.rule.data.IotDataRuleService;
  8 +import cn.iocoder.yudao.module.iot.service.rule.scene.IotSceneRuleService;
  9 +import org.junit.jupiter.api.Test;
  10 +import org.mockito.InjectMocks;
  11 +import org.mockito.Mock;
  12 +
  13 +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId;
  14 +import static org.mockito.ArgumentMatchers.same;
  15 +import static org.mockito.Mockito.verify;
  16 +import static org.mockito.Mockito.verifyNoInteractions;
  17 +
  18 +/**
  19 + * {@link IotSceneRuleMessageSubscriber}、{@link IotDataRuleMessageSubscriber} 的单元测试类
  20 + *
  21 + * 状态消息的规则,改由设备消费者在状态事务内触发;两个规则消费者如果不跳过,规则会重复执行
  22 + *
  23 + * @author 芋道源码
  24 + */
  25 +public class IotRuleMessageSubscriberTest extends BaseMockitoUnitTest {
  26 +
  27 + @InjectMocks
  28 + private IotSceneRuleMessageSubscriber sceneRuleMessageSubscriber;
  29 + @InjectMocks
  30 + private IotDataRuleMessageSubscriber dataRuleMessageSubscriber;
  31 +
  32 + @Mock
  33 + private IotSceneRuleService sceneRuleService;
  34 + @Mock
  35 + private IotDataRuleService dataRuleService;
  36 + @Mock
  37 + private IotMessageBus messageBus;
  38 +
  39 + @Test
  40 + public void testOnMessage_stateUpdateSkip() {
  41 + // 准备参数
  42 + IotDeviceMessage message = IotDeviceMessage.buildStateOffline().setDeviceId(randomLongId())
  43 + .setTenantId(randomLongId());
  44 +
  45 + // 调用
  46 + sceneRuleMessageSubscriber.onMessage(message);
  47 + dataRuleMessageSubscriber.onMessage(message);
  48 + // 断言:跳过,不执行规则
  49 + verifyNoInteractions(sceneRuleService, dataRuleService);
  50 + }
  51 +
  52 + @Test
  53 + public void testOnMessage_propertyPost() {
  54 + // 准备参数
  55 + IotDeviceMessage message = new IotDeviceMessage().setDeviceId(randomLongId()).setTenantId(randomLongId())
  56 + .setMethod(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod());
  57 +
  58 + // 调用
  59 + sceneRuleMessageSubscriber.onMessage(message);
  60 + dataRuleMessageSubscriber.onMessage(message);
  61 + // 断言:照常执行规则
  62 + verify(sceneRuleService).executeSceneRuleByDevice(same(message));
  63 + verify(dataRuleService).executeDataRule(same(message));
  64 + }
  65 +
  66 +}
... ...
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceServiceImplTest.java
1 1 package cn.iocoder.yudao.module.iot.service.device;
2 2  
3 3 import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
  4 +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
  5 +import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum;
  6 +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
  7 +import cn.iocoder.yudao.module.iot.core.mq.producer.IotDeviceMessageProducer;
  8 +import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
  9 +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
4 10 import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
  11 +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO;
5 12 import cn.iocoder.yudao.module.iot.dal.mysql.device.IotDeviceMapper;
  13 +import cn.iocoder.yudao.module.iot.dal.redis.RedisKeyConstants;
  14 +import cn.iocoder.yudao.module.iot.dal.storage.IotDeviceMessageDAO;
6 15 import cn.iocoder.yudao.module.iot.enums.product.IotProductDeviceTypeEnum;
7   -import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService;
  16 +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum;
  17 +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionTypeEnum;
  18 +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum;
  19 +import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageServiceImpl;
  20 +import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService;
8 21 import cn.iocoder.yudao.module.iot.service.device.shadow.IotDeviceShadowService;
  22 +import cn.iocoder.yudao.module.iot.service.ota.IotOtaTaskRecordService;
9 23 import cn.iocoder.yudao.module.iot.service.product.IotProductService;
  24 +import cn.iocoder.yudao.module.iot.service.rule.data.IotDataRuleService;
  25 +import cn.iocoder.yudao.module.iot.service.rule.scene.IotSceneRuleService;
  26 +import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherManager;
  27 +import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.condition.IotDeviceStateConditionMatcher;
  28 +import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.trigger.IotDeviceStateUpdateTriggerMatcher;
10 29 import jakarta.annotation.Resource;
  30 +import org.junit.jupiter.api.BeforeEach;
11 31 import org.junit.jupiter.api.Test;
  32 +import org.mockito.ArgumentCaptor;
  33 +import org.springframework.cache.Cache;
  34 +import org.springframework.cache.CacheManager;
  35 +import org.springframework.cache.annotation.EnableCaching;
  36 +import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
  37 +import org.springframework.cache.transaction.TransactionAwareCacheManagerProxy;
  38 +import org.springframework.context.annotation.Bean;
  39 +import org.springframework.context.annotation.Configuration;
12 40 import org.springframework.context.annotation.Import;
  41 +import org.springframework.dao.QueryTimeoutException;
13 42 import org.springframework.test.context.bean.override.mockito.MockitoBean;
  43 +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
  44 +import org.springframework.transaction.PlatformTransactionManager;
  45 +import org.springframework.transaction.interceptor.TransactionAspectSupport;
  46 +import org.springframework.transaction.support.TransactionSynchronization;
  47 +import org.springframework.transaction.support.TransactionSynchronizationManager;
  48 +import org.springframework.transaction.support.TransactionTemplate;
14 49  
  50 +import java.time.LocalDateTime;
  51 +import java.util.Collections;
  52 +import java.util.List;
  53 +
  54 +import static java.util.Collections.singletonList;
15 55 import static org.junit.jupiter.api.Assertions.*;
16   -import static org.mockito.Mockito.doThrow;
17   -import static org.mockito.Mockito.verify;
  56 +import static org.mockito.ArgumentMatchers.any;
  57 +import static org.mockito.ArgumentMatchers.argThat;
  58 +import static org.mockito.Mockito.*;
18 59  
19 60 /**
20 61 * {@link IotDeviceServiceImpl} 的单元测试
21 62 *
  63 + * 设备状态相关的用例,同时使用真实的 {@link IotDeviceMessageServiceImpl}:经过 Spring 代理开启事务,
  64 + * 在 H2 上验证条件更新、状态事务的提交与回滚、缓存清理时机、网关下线联动子设备;
  65 + * 规则匹配相关的用例,使用真实的 {@link IotSceneRuleMatcherManager} 与设备状态的触发器、条件匹配器
  66 + *
22 67 * @author 芋道源码
23 68 */
24   -@Import(IotDeviceServiceImpl.class)
  69 +@Import({IotDeviceServiceImpl.class, IotDeviceMessageServiceImpl.class, IotDeviceServiceImplTest.CacheConfiguration.class,
  70 + IotSceneRuleMatcherManager.class, IotDeviceStateUpdateTriggerMatcher.class, IotDeviceStateConditionMatcher.class})
25 71 public class IotDeviceServiceImplTest extends BaseDbUnitTest {
26 72  
  73 + /**
  74 + * 基准时间:H2 的时间精度有限,使用整秒
  75 + */
  76 + private static final LocalDateTime T0 = LocalDateTime.of(2026, 10, 29, 10, 0, 0);
  77 +
27 78 @Resource
28 79 private IotDeviceServiceImpl deviceService;
  80 + @Resource
  81 + private IotDeviceMessageServiceImpl deviceMessageService;
29 82  
30 83 @Resource
31 84 private IotDeviceMapper deviceMapper;
  85 + @Resource
  86 + private PlatformTransactionManager transactionManager;
  87 + @MockitoSpyBean
  88 + private CacheManager cacheManager;
  89 + @Resource
  90 + private IotSceneRuleMatcherManager sceneRuleMatcherManager;
32 91  
33 92 @MockitoBean
34 93 private IotProductService productService;
35 94 @MockitoBean
36 95 private IotDeviceGroupService deviceGroupService;
37 96 @MockitoBean
38   - private IotDeviceMessageService deviceMessageService;
39   - @MockitoBean
40 97 private IotDeviceShadowService deviceShadowService;
  98 + @MockitoBean
  99 + private IotDevicePropertyService devicePropertyService;
  100 + @MockitoBean
  101 + private IotOtaTaskRecordService otaTaskRecordService;
  102 + @MockitoBean
  103 + private IotSceneRuleService sceneRuleService;
  104 + @MockitoBean
  105 + private IotDataRuleService dataRuleService;
  106 + @MockitoBean
  107 + private IotDeviceMessageDAO deviceMessageDAO;
  108 + @MockitoBean
  109 + private IotDeviceMessageProducer deviceMessageProducer;
  110 +
  111 + /**
  112 + * 与线上一致:缓存的写入、清理,在事务提交后才生效
  113 + */
  114 + @Configuration
  115 + @EnableCaching
  116 + public static class CacheConfiguration {
  117 +
  118 + @Bean
  119 + public CacheManager cacheManager() {
  120 + return new TransactionAwareCacheManagerProxy(new ConcurrentMapCacheManager());
  121 + }
  122 +
  123 + }
  124 +
  125 + @BeforeEach
  126 + public void setUp() {
  127 + getDeviceCache().clear();
  128 + }
41 129  
42 130 @Test
43 131 public void testDeleteDevice_success() {
... ... @@ -71,6 +159,562 @@ public class IotDeviceServiceImplTest extends BaseDbUnitTest {
71 159 assertNotNull(deviceMapper.selectById(id));
72 160 }
73 161  
  162 + // ========== 设备状态:条件更新 ==========
  163 +
  164 + @Test
  165 + public void testUpdateDeviceState_firstOnline() {
  166 + // mock 数据:未激活
  167 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.INACTIVE.getState(), null, null);
  168 +
  169 + // 调用
  170 + boolean changed = deviceService.updateDeviceState(device, IotDeviceStateEnum.ONLINE.getState(), T0, false);
  171 + // 断言:上线,并且首次激活
  172 + assertTrue(changed);
  173 + IotDeviceDO dbDevice = deviceMapper.selectById(device.getId());
  174 + assertEquals(IotDeviceStateEnum.ONLINE.getState(), dbDevice.getState());
  175 + assertEquals(T0, dbDevice.getOnlineTime());
  176 + assertEquals(T0, dbDevice.getActiveTime());
  177 + }
  178 +
  179 + @Test
  180 + public void testUpdateDeviceState_reOnlineKeepActiveTime() {
  181 + // mock 数据:已激活、已离线
  182 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.OFFLINE.getState(), T0, T0.plusMinutes(1));
  183 +
  184 + // 调用
  185 + boolean changed = deviceService.updateDeviceState(device, IotDeviceStateEnum.ONLINE.getState(),
  186 + T0.plusMinutes(2), false);
  187 + // 断言:重新上线,激活时间不变
  188 + assertTrue(changed);
  189 + IotDeviceDO dbDevice = deviceMapper.selectById(device.getId());
  190 + assertEquals(IotDeviceStateEnum.ONLINE.getState(), dbDevice.getState());
  191 + assertEquals(T0.plusMinutes(2), dbDevice.getOnlineTime());
  192 + assertEquals(T0, dbDevice.getActiveTime());
  193 + }
  194 +
  195 + @Test
  196 + public void testUpdateDeviceState_duplicateOnline() {
  197 + // mock 数据:已在线
  198 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.ONLINE.getState(), T0, null);
  199 +
  200 + // 调用:网关连接的重复上线(refresh = true),推进上线时间,但不算状态变化
  201 + assertFalse(deviceService.updateDeviceState(device, IotDeviceStateEnum.ONLINE.getState(),
  202 + T0.plusMinutes(1), true));
  203 + assertEquals(T0.plusMinutes(1), deviceMapper.selectById(device.getId()).getOnlineTime());
  204 + // 调用:其它来源的重复上线(refresh = false),不推进上线时间
  205 + assertFalse(deviceService.updateDeviceState(device, IotDeviceStateEnum.ONLINE.getState(),
  206 + T0.plusMinutes(2), false));
  207 + assertEquals(T0.plusMinutes(1), deviceMapper.selectById(device.getId()).getOnlineTime());
  208 + // 调用:更早的上线,不回退上线时间
  209 + assertFalse(deviceService.updateDeviceState(device, IotDeviceStateEnum.ONLINE.getState(),
  210 + T0, true));
  211 + assertEquals(T0.plusMinutes(1), deviceMapper.selectById(device.getId()).getOnlineTime());
  212 + }
  213 +
  214 + @Test
  215 + public void testUpdateDeviceState_lateOnlineIgnored() {
  216 + // mock 数据:已离线,离线时间为 T0 + 10
  217 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.OFFLINE.getState(), T0, T0.plusMinutes(10));
  218 +
  219 + // 调用:迟到的上线(早于离线)
  220 + boolean changed = deviceService.updateDeviceState(device, IotDeviceStateEnum.ONLINE.getState(),
  221 + T0.plusMinutes(5), true);
  222 + // 断言:保持离线
  223 + assertFalse(changed);
  224 + IotDeviceDO dbDevice = deviceMapper.selectById(device.getId());
  225 + assertEquals(IotDeviceStateEnum.OFFLINE.getState(), dbDevice.getState());
  226 + assertEquals(T0, dbDevice.getOnlineTime());
  227 + }
  228 +
  229 + @Test
  230 + public void testUpdateDeviceState_lateOfflineIgnored() {
  231 + // mock 数据:已在线,上线时间为 T0 + 10
  232 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.ONLINE.getState(), T0.plusMinutes(10), null);
  233 +
  234 + // 调用:迟到的离线(早于上线)
  235 + boolean changed = deviceService.updateDeviceState(device, IotDeviceStateEnum.OFFLINE.getState(),
  236 + T0.plusMinutes(5), false);
  237 + // 断言:保持在线
  238 + assertFalse(changed);
  239 + IotDeviceDO dbDevice = deviceMapper.selectById(device.getId());
  240 + assertEquals(IotDeviceStateEnum.ONLINE.getState(), dbDevice.getState());
  241 + assertNull(dbDevice.getOfflineTime());
  242 + }
  243 +
  244 + @Test
  245 + public void testUpdateDeviceState_sameTimeOffline() {
  246 + // mock 数据:已在线,上线时间为 T0
  247 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.ONLINE.getState(), T0, null);
  248 +
  249 + // 调用:同一时间的离线
  250 + boolean changed = deviceService.updateDeviceState(device, IotDeviceStateEnum.OFFLINE.getState(), T0, false);
  251 + // 断言:离线生效
  252 + assertTrue(changed);
  253 + IotDeviceDO dbDevice = deviceMapper.selectById(device.getId());
  254 + assertEquals(IotDeviceStateEnum.OFFLINE.getState(), dbDevice.getState());
  255 + assertEquals(T0, dbDevice.getOfflineTime());
  256 + }
  257 +
  258 + @Test
  259 + public void testUpdateDeviceState_sameSecondReconnect() {
  260 + // mock 数据:已在线
  261 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.ONLINE.getState(), T0, null);
  262 +
  263 + // 调用:同一秒内断开(.600)又重连(.800)
  264 + assertTrue(deviceService.updateDeviceState(device, IotDeviceStateEnum.OFFLINE.getState(),
  265 + T0.plusNanos(600_000_000), false));
  266 + assertTrue(deviceService.updateDeviceState(device, IotDeviceStateEnum.ONLINE.getState(),
  267 + T0.plusNanos(800_000_000), true));
  268 + // 断言:重连生效;时间按秒存储,不会因为毫秒进位被误判为迟到
  269 + IotDeviceDO dbDevice = deviceMapper.selectById(device.getId());
  270 + assertEquals(IotDeviceStateEnum.ONLINE.getState(), dbDevice.getState());
  271 + assertEquals(T0, dbDevice.getOfflineTime());
  272 + assertEquals(T0, dbDevice.getOnlineTime());
  273 + }
  274 +
  275 + @Test
  276 + public void testUpdateDeviceState_duplicateOffline() {
  277 + // mock 数据:已离线,离线时间为 T0
  278 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.OFFLINE.getState(), null, T0);
  279 +
  280 + // 调用:更晚的离线,推进离线水位,但不算状态变化
  281 + assertFalse(deviceService.updateDeviceState(device, IotDeviceStateEnum.OFFLINE.getState(),
  282 + T0.plusMinutes(5), false));
  283 + assertEquals(T0.plusMinutes(5), deviceMapper.selectById(device.getId()).getOfflineTime());
  284 + // 调用:更早的离线,不回退离线水位
  285 + assertFalse(deviceService.updateDeviceState(device, IotDeviceStateEnum.OFFLINE.getState(),
  286 + T0, false));
  287 + assertEquals(T0.plusMinutes(5), deviceMapper.selectById(device.getId()).getOfflineTime());
  288 + }
  289 +
  290 + // ========== 设备状态:状态事务 ==========
  291 +
  292 + @Test
  293 + public void testHandleStateUpdate_commit() {
  294 + // mock 数据:已离线
  295 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.OFFLINE.getState(), null, null);
  296 + // 准备参数
  297 + IotDeviceMessage message = buildStateMessage(device, IotDeviceStateEnum.ONLINE.getState(), T0, "server-1");
  298 + // mock 方法:规则在状态事务内执行,此时已能读到新状态
  299 + doAnswer(invocation -> {
  300 + assertTrue(TransactionSynchronizationManager.isActualTransactionActive());
  301 + assertEquals(IotDeviceStateEnum.ONLINE.getState(), deviceMapper.selectById(device.getId()).getState());
  302 + return null;
  303 + }).when(sceneRuleService).executeSceneRuleByDevice(any());
  304 +
  305 + // 调用
  306 + deviceMessageService.handleUpstreamDeviceMessage(message, device);
  307 + // 断言:已提交
  308 + assertFalse(TransactionSynchronizationManager.isActualTransactionActive());
  309 + IotDeviceDO dbDevice = deviceMapper.selectById(device.getId());
  310 + assertEquals(IotDeviceStateEnum.ONLINE.getState(), dbDevice.getState());
  311 + assertEquals(T0, dbDevice.getOnlineTime());
  312 + verify(sceneRuleService).executeSceneRuleByDevice(same(message));
  313 + verify(dataRuleService).executeDataRule(same(message));
  314 + }
  315 +
  316 + @Test
  317 + public void testHandleStateUpdate_notChangedSkipRule() {
  318 + // mock 数据:已在线
  319 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.ONLINE.getState(), T0, null);
  320 + // 准备参数:重复的上线
  321 + IotDeviceMessage message = buildStateMessage(device, IotDeviceStateEnum.ONLINE.getState(),
  322 + T0.plusMinutes(1), "server-1");
  323 +
  324 + // 调用
  325 + deviceMessageService.handleUpstreamDeviceMessage(message, device);
  326 + // 断言:只推进上线时间,不触发规则
  327 + assertEquals(T0.plusMinutes(1), deviceMapper.selectById(device.getId()).getOnlineTime());
  328 + verify(sceneRuleService, never()).executeSceneRuleByDevice(any());
  329 + verify(dataRuleService, never()).executeDataRule(any());
  330 + }
  331 +
  332 + @Test
  333 + public void testHandleStateUpdate_ruleDataAccessExceptionRollback() {
  334 + // mock 数据:已离线
  335 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.OFFLINE.getState(), null, null);
  336 + // 准备参数
  337 + IotDeviceMessage message = buildStateMessage(device, IotDeviceStateEnum.ONLINE.getState(), T0, null);
  338 + // mock 方法:规则访问数据失败(例如说 Redis 超时),异常不被吞掉
  339 + QueryTimeoutException failure = new QueryTimeoutException("rule data access failed");
  340 + doThrow(failure).when(sceneRuleService).executeSceneRuleByDevice(any());
  341 +
  342 + // 调用,并断言异常:抛给消息总线
  343 + assertSame(failure, assertThrows(QueryTimeoutException.class,
  344 + () -> deviceMessageService.handleUpstreamDeviceMessage(message, device)));
  345 + // 断言:状态已回滚
  346 + IotDeviceDO dbDevice = deviceMapper.selectById(device.getId());
  347 + assertEquals(IotDeviceStateEnum.OFFLINE.getState(), dbDevice.getState());
  348 + assertNull(dbDevice.getOnlineTime());
  349 + verify(dataRuleService, never()).executeDataRule(any());
  350 + }
  351 +
  352 + @Test
  353 + public void testHandleStateUpdate_rollbackOnly() {
  354 + // mock 数据:已离线
  355 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.OFFLINE.getState(), null, null);
  356 + // 准备参数
  357 + IotDeviceMessage message = buildStateMessage(device, IotDeviceStateEnum.ONLINE.getState(), T0, null);
  358 + // mock 方法:规则内部的事务方法失败、被 catch 吞掉,状态事务被标记为 rollback-only
  359 + doAnswer(invocation -> {
  360 + try {
  361 + new TransactionTemplate(transactionManager).executeWithoutResult(status -> {
  362 + throw new IllegalArgumentException("inner failed");
  363 + });
  364 + } catch (IllegalArgumentException ignored) {
  365 + }
  366 + return null;
  367 + }).when(dataRuleService).executeDataRule(any());
  368 +
  369 + // 调用,并断言异常:显式失败,而不是静默回滚
  370 + IllegalStateException ex = assertThrows(IllegalStateException.class,
  371 + () -> deviceMessageService.handleUpstreamDeviceMessage(message, device));
  372 + assertEquals("状态事务已被标记回滚", ex.getMessage());
  373 + // 断言:状态已回滚
  374 + assertEquals(IotDeviceStateEnum.OFFLINE.getState(), deviceMapper.selectById(device.getId()).getState());
  375 + }
  376 +
  377 + @Test
  378 + public void testHandleStateUpdate_localRollbackOnly() {
  379 + // mock 数据:网关在线;子设备在线
  380 + IotDeviceDO gateway = insertGateway();
  381 + IotDeviceDO subDevice = insertSubDevice(gateway, IotDeviceStateEnum.ONLINE.getState(), T0);
  382 + // 准备参数
  383 + IotDeviceMessage message = buildStateMessage(gateway, IotDeviceStateEnum.OFFLINE.getState(),
  384 + T0.plusMinutes(10), "server-1");
  385 + // mock 方法:规则直接把当前事务标记为 rollback-only 后正常返回(只有本地标记)
  386 + // 说明:本地 rollback-only 时,commit 会静默回滚、不抛异常;如果不显式失败,调用方会误以为已经提交
  387 + doAnswer(invocation -> {
  388 + TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
  389 + return null;
  390 + }).when(dataRuleService).executeDataRule(any());
  391 +
  392 + // 调用,并断言异常:显式失败
  393 + IllegalStateException ex = assertThrows(IllegalStateException.class,
  394 + () -> deviceMessageService.handleUpstreamDeviceMessage(message, gateway));
  395 + assertEquals("状态事务已被标记回滚", ex.getMessage());
  396 + // 断言:网关状态已回滚,不联动子设备
  397 + assertEquals(IotDeviceStateEnum.ONLINE.getState(), deviceMapper.selectById(gateway.getId()).getState());
  398 + assertEquals(IotDeviceStateEnum.ONLINE.getState(), deviceMapper.selectById(subDevice.getId()).getState());
  399 + verify(deviceMessageProducer, never()).sendDeviceMessage(any());
  400 + }
  401 +
  402 + @Test
  403 + public void testHandleStateUpdate_rejectExistingTransaction() {
  404 + // mock 数据:已离线
  405 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.OFFLINE.getState(), null, null);
  406 + // 准备参数
  407 + IotDeviceMessage message = buildStateMessage(device, IotDeviceStateEnum.ONLINE.getState(), T0, null);
  408 +
  409 + // 调用,并断言异常:在已有事务中处理状态消息,直接拒绝
  410 + new TransactionTemplate(transactionManager).executeWithoutResult(status ->
  411 + assertThrows(IllegalStateException.class,
  412 + () -> deviceMessageService.handleUpstreamDeviceMessage(message, device)));
  413 + // 断言:没有更新状态,也没有触发规则
  414 + assertEquals(IotDeviceStateEnum.OFFLINE.getState(), deviceMapper.selectById(device.getId()).getState());
  415 + verify(sceneRuleService, never()).executeSceneRuleByDevice(any());
  416 + verify(dataRuleService, never()).executeDataRule(any());
  417 + }
  418 +
  419 + @Test
  420 + public void testHandleStateUpdate_evictCacheAfterCommit() {
  421 + // mock 数据:已离线,并且已经缓存
  422 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.OFFLINE.getState(), null, null);
  423 + getDeviceCache().put(device.getId(), device);
  424 + // 准备参数
  425 + IotDeviceMessage message = buildStateMessage(device, IotDeviceStateEnum.ONLINE.getState(), T0, null);
  426 + // mock 方法:事务内,缓存还没有清理
  427 + doAnswer(invocation -> {
  428 + assertNotNull(getDeviceCache().get(device.getId()));
  429 + return null;
  430 + }).when(sceneRuleService).executeSceneRuleByDevice(any());
  431 +
  432 + // 调用
  433 + deviceMessageService.handleUpstreamDeviceMessage(message, device);
  434 + // 断言:提交后,缓存已清理
  435 + verify(sceneRuleService).executeSceneRuleByDevice(any());
  436 + assertNull(getDeviceCache().get(device.getId()));
  437 + }
  438 +
  439 + @Test
  440 + public void testHandleStateUpdate_rollbackKeepCache() {
  441 + // mock 数据:已离线,并且已经缓存
  442 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.OFFLINE.getState(), null, null);
  443 + getDeviceCache().put(device.getId(), device);
  444 + // 准备参数
  445 + IotDeviceMessage message = buildStateMessage(device, IotDeviceStateEnum.ONLINE.getState(), T0, null);
  446 + // mock 方法:规则访问数据失败
  447 + doThrow(new QueryTimeoutException("rule data access failed")).when(dataRuleService).executeDataRule(any());
  448 +
  449 + // 调用
  450 + assertThrows(QueryTimeoutException.class, () -> deviceMessageService.handleUpstreamDeviceMessage(message, device));
  451 + // 断言:回滚后,缓存没有被清理(与数据库一致)
  452 + assertNotNull(getDeviceCache().get(device.getId()));
  453 + }
  454 +
  455 + // ========== 设备状态:网关下线联动子设备 ==========
  456 +
  457 + @Test
  458 + public void testHandleStateUpdate_gatewayOffline() {
  459 + // mock 数据:网关在线;子设备 1、2 在线,子设备 3 在网关离线之后重连,子设备 4 已离线
  460 + IotDeviceDO gateway = insertGateway();
  461 + IotDeviceDO subDevice1 = insertSubDevice(gateway, IotDeviceStateEnum.ONLINE.getState(), T0);
  462 + IotDeviceDO subDevice2 = insertSubDevice(gateway, IotDeviceStateEnum.ONLINE.getState(), T0);
  463 + IotDeviceDO subDevice3 = insertSubDevice(gateway, IotDeviceStateEnum.ONLINE.getState(), T0.plusMinutes(20));
  464 + IotDeviceDO subDevice4 = insertSubDevice(gateway, IotDeviceStateEnum.OFFLINE.getState(), T0);
  465 + // 准备参数:网关在 T0 + 10 离线
  466 + IotDeviceMessage message = buildStateMessage(gateway, IotDeviceStateEnum.OFFLINE.getState(),
  467 + T0.plusMinutes(10), "server-1");
  468 + // mock 方法:模拟 Local 总线,在发布线程上同步处理子设备消息
  469 + mockLocalMessageBus();
  470 +
  471 + // 调用
  472 + deviceMessageService.handleUpstreamDeviceMessage(message, gateway);
  473 + // 断言:网关离线
  474 + assertEquals(IotDeviceStateEnum.OFFLINE.getState(), deviceMapper.selectById(gateway.getId()).getState());
  475 + // 断言:在线的 3 个子设备,各发送一条离线消息,事件时间为网关离线的时间
  476 + ArgumentCaptor<IotDeviceMessage> captor = ArgumentCaptor.forClass(IotDeviceMessage.class);
  477 + verify(deviceMessageProducer, times(3)).sendDeviceMessage(captor.capture());
  478 + captor.getAllValues().forEach(subMessage -> {
  479 + assertEquals(IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod(), subMessage.getMethod());
  480 + assertEquals(String.valueOf(IotDeviceStateEnum.OFFLINE.getState()), IotDeviceMessageUtils.getIdentifier(subMessage));
  481 + assertEquals(T0.plusMinutes(10), subMessage.getReportTime());
  482 + assertNull(subMessage.getServerId());
  483 + });
  484 + // 断言:子设备 1、2 离线;子设备 3 的重连晚于网关离线,保持在线
  485 + assertEquals(IotDeviceStateEnum.OFFLINE.getState(), deviceMapper.selectById(subDevice1.getId()).getState());
  486 + assertEquals(IotDeviceStateEnum.OFFLINE.getState(), deviceMapper.selectById(subDevice2.getId()).getState());
  487 + assertEquals(IotDeviceStateEnum.ONLINE.getState(), deviceMapper.selectById(subDevice3.getId()).getState());
  488 + assertNull(deviceMapper.selectById(subDevice4.getId()).getOfflineTime());
  489 + // 断言:规则触发 3 次(网关、子设备 1、2)
  490 + verify(sceneRuleService, times(3)).executeSceneRuleByDevice(any());
  491 + }
  492 +
  493 + @Test
  494 + public void testHandleStateUpdate_gatewayOfflineSubDeviceFailed() {
  495 + // mock 数据:网关在线;子设备 1、2 在线
  496 + IotDeviceDO gateway = insertGateway();
  497 + IotDeviceDO subDevice1 = insertSubDevice(gateway, IotDeviceStateEnum.ONLINE.getState(), T0);
  498 + IotDeviceDO subDevice2 = insertSubDevice(gateway, IotDeviceStateEnum.ONLINE.getState(), T0);
  499 + // 准备参数
  500 + IotDeviceMessage message = buildStateMessage(gateway, IotDeviceStateEnum.OFFLINE.getState(),
  501 + T0.plusMinutes(10), "server-1");
  502 + // mock 方法:模拟 Local 总线;子设备 1 的规则访问数据失败
  503 + mockLocalMessageBus();
  504 + doThrow(new QueryTimeoutException("rule data access failed")).when(sceneRuleService)
  505 + .executeSceneRuleByDevice(argThat(m -> subDevice1.getId().equals(m.getDeviceId())));
  506 +
  507 + // 调用
  508 + deviceMessageService.handleUpstreamDeviceMessage(message, gateway);
  509 + // 断言:子设备 1 自己的事务回滚,不影响已提交的网关和其它子设备
  510 + assertEquals(IotDeviceStateEnum.OFFLINE.getState(), deviceMapper.selectById(gateway.getId()).getState());
  511 + assertEquals(IotDeviceStateEnum.ONLINE.getState(), deviceMapper.selectById(subDevice1.getId()).getState());
  512 + assertEquals(IotDeviceStateEnum.OFFLINE.getState(), deviceMapper.selectById(subDevice2.getId()).getState());
  513 + }
  514 +
  515 + @Test
  516 + public void testHandleStateUpdate_gatewayRollbackNoSubDeviceMessage() {
  517 + // mock 数据:网关在线;子设备在线
  518 + IotDeviceDO gateway = insertGateway();
  519 + IotDeviceDO subDevice = insertSubDevice(gateway, IotDeviceStateEnum.ONLINE.getState(), T0);
  520 + // 准备参数
  521 + IotDeviceMessage message = buildStateMessage(gateway, IotDeviceStateEnum.OFFLINE.getState(),
  522 + T0.plusMinutes(10), "server-1");
  523 + // mock 方法:网关的规则访问数据失败
  524 + doThrow(new QueryTimeoutException("rule data access failed")).when(sceneRuleService).executeSceneRuleByDevice(any());
  525 +
  526 + // 调用
  527 + assertThrows(QueryTimeoutException.class, () -> deviceMessageService.handleUpstreamDeviceMessage(message, gateway));
  528 + // 断言:网关状态回滚,不联动子设备
  529 + assertEquals(IotDeviceStateEnum.ONLINE.getState(), deviceMapper.selectById(gateway.getId()).getState());
  530 + assertEquals(IotDeviceStateEnum.ONLINE.getState(), deviceMapper.selectById(subDevice.getId()).getState());
  531 + verify(deviceMessageProducer, never()).sendDeviceMessage(any());
  532 + }
  533 +
  534 + @Test
  535 + public void testHandleStateUpdate_afterCommitFailedSkipSubDevices() {
  536 + // mock 数据:网关在线;子设备在线
  537 + IotDeviceDO gateway = insertGateway();
  538 + IotDeviceDO subDevice = insertSubDevice(gateway, IotDeviceStateEnum.ONLINE.getState(), T0);
  539 + // 准备参数
  540 + IotDeviceMessage message = buildStateMessage(gateway, IotDeviceStateEnum.OFFLINE.getState(),
  541 + T0.plusMinutes(10), "server-1");
  542 + // mock 方法:提交后的回调失败(例如说清理缓存失败)
  543 + doAnswer(invocation -> {
  544 + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
  545 +
  546 + @Override
  547 + public void afterCommit() {
  548 + throw new IllegalStateException("after commit failed");
  549 + }
  550 +
  551 + });
  552 + return null;
  553 + }).when(sceneRuleService).executeSceneRuleByDevice(any());
  554 +
  555 + // 调用,并断言异常
  556 + assertThrows(IllegalStateException.class, () -> deviceMessageService.handleUpstreamDeviceMessage(message, gateway));
  557 + // 断言:网关状态已提交,但没有联动子设备
  558 + assertEquals(IotDeviceStateEnum.OFFLINE.getState(), deviceMapper.selectById(gateway.getId()).getState());
  559 + verify(deviceMessageProducer, never()).sendDeviceMessage(any());
  560 +
  561 + // 调用:重投同一条消息
  562 + deviceMessageService.handleUpstreamDeviceMessage(message, deviceMapper.selectById(gateway.getId()));
  563 + // 断言:已知边界,状态不再变化,子设备仍不联动(由离线检测兜底)
  564 + verify(deviceMessageProducer, never()).sendDeviceMessage(any());
  565 + assertEquals(IotDeviceStateEnum.ONLINE.getState(), deviceMapper.selectById(subDevice.getId()).getState());
  566 + }
  567 +
  568 + // ========== 设备状态:规则匹配的数据访问异常 ==========
  569 +
  570 + @Test
  571 + public void testHandleStateUpdate_triggerMatchDataAccessExceptionRollback() {
  572 + // mock 数据:网关在线;子设备在线
  573 + IotDeviceDO gateway = insertGateway();
  574 + IotDeviceDO subDevice = insertSubDevice(gateway, IotDeviceStateEnum.ONLINE.getState(), T0);
  575 + // 准备参数:网关离线;按产品配置的状态触发器,匹配时读取网关的设备缓存
  576 + IotDeviceMessage message = buildStateMessage(gateway, IotDeviceStateEnum.OFFLINE.getState(),
  577 + T0.plusMinutes(10), "server-1");
  578 + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger();
  579 + trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_STATE_UPDATE.getType());
  580 + trigger.setProductId(gateway.getProductId());
  581 + trigger.setOperator(IotSceneRuleConditionOperatorEnum.EQUALS.getOperator());
  582 + trigger.setValue(String.valueOf(IotDeviceStateEnum.OFFLINE.getState()));
  583 + // mock 方法:场景联动执行真实的触发器匹配;读取网关的设备缓存失败
  584 + doAnswer(invocation -> sceneRuleMatcherManager.isMatched(invocation.getArgument(0), trigger))
  585 + .when(sceneRuleService).executeSceneRuleByDevice(any());
  586 + mockDeviceCacheGetFailed(gateway.getId());
  587 +
  588 + // 调用,并断言异常:状态事务内,匹配器的数据访问异常不吞掉
  589 + assertThrows(QueryTimeoutException.class, () -> deviceMessageService.handleUpstreamDeviceMessage(message, gateway));
  590 + // 断言:网关状态回滚,不联动子设备
  591 + assertEquals(IotDeviceStateEnum.ONLINE.getState(), deviceMapper.selectById(gateway.getId()).getState());
  592 + assertEquals(IotDeviceStateEnum.ONLINE.getState(), deviceMapper.selectById(subDevice.getId()).getState());
  593 + verify(deviceMessageProducer, never()).sendDeviceMessage(any());
  594 + // 调用:非事务内,与原逻辑一致,按不匹配处理
  595 + assertFalse(sceneRuleMatcherManager.isMatched(message, trigger));
  596 + }
  597 +
  598 + @Test
  599 + public void testHandleStateUpdate_conditionMatchDataAccessExceptionRollback() {
  600 + // mock 数据:网关在线;子设备在线
  601 + IotDeviceDO gateway = insertGateway();
  602 + IotDeviceDO subDevice = insertSubDevice(gateway, IotDeviceStateEnum.ONLINE.getState(), T0);
  603 + // 准备参数:网关离线;按产品配置的设备状态条件,匹配时读取网关的设备缓存
  604 + IotDeviceMessage message = buildStateMessage(gateway, IotDeviceStateEnum.OFFLINE.getState(),
  605 + T0.plusMinutes(10), "server-1");
  606 + IotSceneRuleDO.TriggerCondition condition = new IotSceneRuleDO.TriggerCondition();
  607 + condition.setType(IotSceneRuleConditionTypeEnum.DEVICE_STATE.getType());
  608 + condition.setProductId(gateway.getProductId());
  609 + condition.setOperator(IotSceneRuleConditionOperatorEnum.EQUALS.getOperator());
  610 + condition.setParam(String.valueOf(IotDeviceStateEnum.OFFLINE.getState()));
  611 + // mock 方法:场景联动执行真实的条件匹配;读取网关的设备缓存失败
  612 + doAnswer(invocation -> sceneRuleMatcherManager.isConditionMatched(invocation.getArgument(0), condition))
  613 + .when(sceneRuleService).executeSceneRuleByDevice(any());
  614 + mockDeviceCacheGetFailed(gateway.getId());
  615 +
  616 + // 调用,并断言异常:状态事务内,匹配器的数据访问异常不吞掉
  617 + assertThrows(QueryTimeoutException.class, () -> deviceMessageService.handleUpstreamDeviceMessage(message, gateway));
  618 + // 断言:网关状态回滚,不联动子设备
  619 + assertEquals(IotDeviceStateEnum.ONLINE.getState(), deviceMapper.selectById(gateway.getId()).getState());
  620 + assertEquals(IotDeviceStateEnum.ONLINE.getState(), deviceMapper.selectById(subDevice.getId()).getState());
  621 + verify(deviceMessageProducer, never()).sendDeviceMessage(any());
  622 + // 调用:非事务内,与原逻辑一致,按不匹配处理
  623 + assertFalse(sceneRuleMatcherManager.isConditionMatched(message, condition));
  624 + }
  625 +
  626 + @Test
  627 + public void testHandleTopoDeleteMessage_subDeviceOffline() {
  628 + // mock 数据:网关;在线的子设备
  629 + IotDeviceDO gateway = insertGateway();
  630 + IotDeviceDO subDevice = insertSubDevice(gateway, IotDeviceStateEnum.ONLINE.getState(), T0);
  631 + // 准备参数
  632 + IotDeviceMessage message = IotDeviceMessage.requestOf(IotDeviceMessageMethodEnum.TOPO_DELETE.getMethod(),
  633 + Collections.singletonMap("subDevices",
  634 + singletonList(new IotDeviceIdentity(subDevice.getProductKey(), subDevice.getDeviceName()))));
  635 +
  636 + // 调用
  637 + List<IotDeviceIdentity> result = deviceService.handleTopoDeleteMessage(message, gateway);
  638 + // 断言:解绑
  639 + assertEquals(1, result.size());
  640 + assertNull(deviceMapper.selectById(subDevice.getId()).getGatewayId());
  641 + // 断言:子设备离线走消息
  642 + verify(deviceMessageProducer).sendDeviceMessage(argThat(subMessage ->
  643 + subDevice.getId().equals(subMessage.getDeviceId())
  644 + && IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod().equals(subMessage.getMethod())
  645 + && String.valueOf(IotDeviceStateEnum.OFFLINE.getState()).equals(IotDeviceMessageUtils.getIdentifier(subMessage))
  646 + && subMessage.getReportTime() != null));
  647 + }
  648 +
  649 + // ========== 辅助方法 ==========
  650 +
  651 + /**
  652 + * 模拟 Local 总线:发布的消息,在发布线程上同步处理;订阅者的异常被吞掉(与 IotLocalMessageBus 一致)
  653 + */
  654 + private void mockLocalMessageBus() {
  655 + doAnswer(invocation -> {
  656 + IotDeviceMessage subMessage = invocation.getArgument(0);
  657 + // 断言:在网关的状态事务提交之后,才发送子设备消息
  658 + assertFalse(TransactionSynchronizationManager.isActualTransactionActive());
  659 + try {
  660 + deviceMessageService.handleUpstreamDeviceMessage(subMessage, deviceMapper.selectById(subMessage.getDeviceId()));
  661 + } catch (Exception ignored) {
  662 + }
  663 + return null;
  664 + }).when(deviceMessageProducer).sendDeviceMessage(any());
  665 + }
  666 +
  667 + private Cache getDeviceCache() {
  668 + return cacheManager.getCache(RedisKeyConstants.DEVICE);
  669 + }
  670 +
  671 + /**
  672 + * 模拟读取指定设备的缓存失败(例如说 Redis 超时):异常经 Spring 缓存抛出,与线上 Redis 缓存故障一致
  673 + */
  674 + private void mockDeviceCacheGetFailed(Long deviceId) {
  675 + Cache deviceCache = spy(getDeviceCache());
  676 + doThrow(new QueryTimeoutException("redis read timeout")).when(deviceCache).get(deviceId);
  677 + doReturn(deviceCache).when(cacheManager).getCache(RedisKeyConstants.DEVICE);
  678 + }
  679 +
  680 + private IotDeviceDO insertDevice(Integer state, LocalDateTime onlineTime, LocalDateTime offlineTime) {
  681 + IotDeviceDO device = buildDevice();
  682 + device.setState(state);
  683 + device.setOnlineTime(onlineTime);
  684 + device.setOfflineTime(offlineTime);
  685 + device.setActiveTime(onlineTime);
  686 + deviceMapper.insert(device);
  687 + return deviceMapper.selectById(device.getId());
  688 + }
  689 +
  690 + private IotDeviceDO insertGateway() {
  691 + IotDeviceDO gateway = IotDeviceDO.builder().deviceName("testGateway").productId(20L).productKey("testGatewayProduct")
  692 + .deviceType(IotProductDeviceTypeEnum.GATEWAY.getType())
  693 + .state(IotDeviceStateEnum.ONLINE.getState()).onlineTime(T0).activeTime(T0).build();
  694 + deviceMapper.insert(gateway);
  695 + return deviceMapper.selectById(gateway.getId());
  696 + }
  697 +
  698 + private IotDeviceDO insertSubDevice(IotDeviceDO gateway, Integer state, LocalDateTime onlineTime) {
  699 + IotDeviceDO subDevice = IotDeviceDO.builder().deviceName("testSubDevice" + System.nanoTime())
  700 + .productId(30L).productKey("testSubProduct")
  701 + .deviceType(IotProductDeviceTypeEnum.GATEWAY_SUB.getType()).gatewayId(gateway.getId())
  702 + .state(state).onlineTime(onlineTime).activeTime(onlineTime).build();
  703 + deviceMapper.insert(subDevice);
  704 + return deviceMapper.selectById(subDevice.getId());
  705 + }
  706 +
  707 + private static IotDeviceMessage buildStateMessage(IotDeviceDO device, Integer state,
  708 + LocalDateTime reportTime, String serverId) {
  709 + IotDeviceMessage message = IotDeviceStateEnum.ONLINE.getState().equals(state)
  710 + ? IotDeviceMessage.buildStateUpdateOnline() : IotDeviceMessage.buildStateOffline();
  711 + message.setId("msg-" + System.nanoTime());
  712 + message.setDeviceId(device.getId());
  713 + message.setReportTime(reportTime);
  714 + message.setServerId(serverId);
  715 + return message;
  716 + }
  717 +
74 718 private static IotDeviceDO buildDevice() {
75 719 return IotDeviceDO.builder().deviceName("testDevice").productId(10L).productKey("testProduct")
76 720 .deviceType(IotProductDeviceTypeEnum.DIRECT.getType()).build();
... ...
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImplTest.java
... ... @@ -10,8 +10,11 @@ import cn.iocoder.yudao.module.iot.controller.admin.device.vo.message.IotDeviceM
10 10 import cn.iocoder.yudao.module.iot.controller.admin.statistics.vo.IotStatisticsDeviceMessageReqVO;
11 11 import cn.iocoder.yudao.module.iot.controller.admin.statistics.vo.IotStatisticsDeviceMessageSummaryByDateRespVO;
12 12 import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
  13 +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
  14 +import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum;
13 15 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
14 16 import cn.iocoder.yudao.module.iot.core.mq.producer.IotDeviceMessageProducer;
  17 +import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
15 18 import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
16 19 import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO;
17 20 import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO;
... ... @@ -34,10 +37,12 @@ import java.util.Collections;
34 37 import java.util.HashMap;
35 38 import java.util.List;
36 39  
  40 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
37 41 import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
38 42 import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.*;
39 43 import static org.junit.jupiter.api.Assertions.*;
40 44 import static org.mockito.ArgumentMatchers.any;
  45 +import static org.mockito.ArgumentMatchers.anyBoolean;
41 46 import static org.mockito.ArgumentMatchers.eq;
42 47 import static org.mockito.ArgumentMatchers.isNull;
43 48 import static org.mockito.ArgumentMatchers.same;
... ... @@ -443,6 +448,96 @@ public class IotDeviceMessageServiceImplTest extends BaseMockitoUnitTest {
443 448 verify(deviceMessageProducer, never()).sendDeviceMessageToGateway(any(), any());
444 449 }
445 450  
  451 + // ========== handleUpstreamDeviceMessage:设备状态 ==========
  452 +
  453 + @Test
  454 + public void testHandleUpstreamDeviceMessage_stateInvalid() {
  455 + // 准备参数:设备自报的非法状态
  456 + IotDeviceDO device = buildDevice();
  457 + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod());
  458 + message.setServerId("server-1").setParams(Collections.singletonMap("state", 5));
  459 +
  460 + try (MockedStatic<SpringUtil> springUtilMock = mockStatic(SpringUtil.class)) {
  461 + springUtilMock.when(() -> SpringUtil.getBean(IotDeviceMessageServiceImpl.class)).thenReturn(service);
  462 + // 调用:参数错误属于业务异常,记录日志后结束,不抛给消息总线
  463 + assertDoesNotThrow(() -> service.handleUpstreamDeviceMessage(message, device));
  464 + }
  465 + // 断言:不更新状态
  466 + verify(deviceService, never()).updateDeviceState(any(), any(), any(), anyBoolean());
  467 + }
  468 +
  469 + @Test
  470 + public void testHandleUpstreamDeviceMessage_staleServerOffline() {
  471 + // 准备参数:旧网关节点的离线
  472 + IotDeviceDO device = buildDevice();
  473 + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod());
  474 + message.setServerId("server-old").setParams(Collections.singletonMap("state", IotDeviceStateEnum.OFFLINE.getState()));
  475 + // mock 方法:设备已经连接到新的网关节点
  476 + when(productService.getProductFromCache(eq(3L)))
  477 + .thenReturn(new IotProductDO().setProtocolType(IotProtocolTypeEnum.MQTT.getType()));
  478 + when(devicePropertyService.getDeviceServerId(eq(2L))).thenReturn("server-new");
  479 +
  480 + try (MockedStatic<SpringUtil> springUtilMock = mockStatic(SpringUtil.class)) {
  481 + springUtilMock.when(() -> SpringUtil.getBean(IotDeviceMessageServiceImpl.class)).thenReturn(service);
  482 + // 调用
  483 + service.handleUpstreamDeviceMessage(message, device);
  484 + }
  485 + // 断言:忽略旧节点的离线
  486 + verify(deviceService, never()).updateDeviceState(any(), any(), any(), anyBoolean());
  487 + }
  488 +
  489 + @Test
  490 + public void testHandleUpstreamDeviceMessage_emqxOfflineNotCompareServerId() {
  491 + // 准备参数:EMQX 设备的离线(serverId 是接收 webhook 的节点)
  492 + IotDeviceDO device = buildDevice();
  493 + LocalDateTime reportTime = LocalDateTime.of(2026, 10, 29, 10, 0, 0);
  494 + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod());
  495 + message.setServerId("server-1").setReportTime(reportTime)
  496 + .setParams(Collections.singletonMap("state", IotDeviceStateEnum.OFFLINE.getState()));
  497 + // mock 方法
  498 + when(productService.getProductFromCache(eq(3L)))
  499 + .thenReturn(new IotProductDO().setProtocolType(IotProtocolTypeEnum.EMQX.getType()));
  500 +
  501 + try (MockedStatic<SpringUtil> springUtilMock = mockStatic(SpringUtil.class)) {
  502 + springUtilMock.when(() -> SpringUtil.getBean(IotDeviceMessageServiceImpl.class)).thenReturn(service);
  503 + // 调用
  504 + service.handleUpstreamDeviceMessage(message, device);
  505 + }
  506 + // 断言:不比较 serverId,按消息的事件时间更新状态
  507 + verify(devicePropertyService, never()).getDeviceServerId(any());
  508 + verify(deviceService).updateDeviceState(same(device), eq(IotDeviceStateEnum.OFFLINE.getState()),
  509 + eq(reportTime), eq(true));
  510 + }
  511 +
  512 + @Test
  513 + public void testHandleUpstreamDeviceMessage_packKeepReportTime() {
  514 + // 准备参数:网关的批量上报,包含一个子设备
  515 + IotDeviceDO gateway = buildDevice();
  516 + LocalDateTime reportTime = LocalDateTime.of(2026, 10, 29, 10, 0, 0);
  517 + HashMap<String, Object> subDevice = new HashMap<>();
  518 + subDevice.put("identity", new IotDeviceIdentity("sub-pk", "sub-dn"));
  519 + subDevice.put("properties", Collections.singletonMap("humidity", 60));
  520 + HashMap<String, Object> params = new HashMap<>();
  521 + params.put("properties", Collections.singletonMap("temperature", 25));
  522 + params.put("subDevices", Collections.singletonList(subDevice));
  523 + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.PROPERTY_PACK_POST.getMethod());
  524 + message.setReportTime(reportTime).setParams(params);
  525 + // mock 方法
  526 + when(deviceService.getDeviceFromCache(eq("sub-pk"), eq("sub-dn")))
  527 + .thenReturn(IotDeviceDO.builder().id(5L).productId(6L).build());
  528 +
  529 + try (MockedStatic<SpringUtil> springUtilMock = mockStatic(SpringUtil.class)) {
  530 + springUtilMock.when(() -> SpringUtil.getBean(IotDeviceMessageServiceImpl.class)).thenReturn(service);
  531 + // 调用
  532 + service.handleUpstreamDeviceMessage(message, gateway);
  533 + }
  534 + // 断言:拆分出的消息,沿用 pack 的上报时间
  535 + ArgumentCaptor<IotDeviceMessage> captor = ArgumentCaptor.forClass(IotDeviceMessage.class);
  536 + verify(deviceMessageProducer, times(2)).sendDeviceMessage(captor.capture());
  537 + assertEquals(Arrays.asList(2L, 5L), convertList(captor.getAllValues(), IotDeviceMessage::getDeviceId));
  538 + captor.getAllValues().forEach(subMessage -> assertEquals(reportTime, subMessage.getReportTime()));
  539 + }
  540 +
446 541 private void assertRawPayloadInvalid(IotDeviceMessage message) {
447 542 ServiceException ex = assertThrows(ServiceException.class,
448 543 () -> service.sendDeviceMessage(message, buildDevice(), null));
... ...
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImplTest.java
1 1 package cn.iocoder.yudao.module.iot.service.rule.data;
2 2  
3 3 import cn.hutool.core.map.MapUtil;
  4 +import cn.hutool.extra.spring.SpringUtil;
4 5 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
5 6 import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
6 7 import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
... ... @@ -14,17 +15,30 @@ import cn.iocoder.yudao.module.iot.service.rule.data.action.IotDataRuleAction;
14 15 import cn.iocoder.yudao.module.iot.service.thingmodel.IotThingModelService;
15 16 import jakarta.annotation.Resource;
16 17 import org.junit.jupiter.api.Test;
  18 +import org.mockito.MockedStatic;
17 19 import org.springframework.context.annotation.Import;
  20 +import org.springframework.dao.QueryTimeoutException;
18 21 import org.springframework.test.context.bean.override.mockito.MockitoBean;
  22 +import org.springframework.transaction.PlatformTransactionManager;
  23 +import org.springframework.transaction.support.TransactionTemplate;
19 24  
  25 +import java.util.Arrays;
20 26 import java.util.Map;
21 27  
22 28 import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId;
23 29 import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
24 30 import static java.util.Collections.singletonList;
  31 +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
  32 +import static org.junit.jupiter.api.Assertions.assertThrows;
25 33 import static org.mockito.ArgumentMatchers.any;
  34 +import static org.mockito.ArgumentMatchers.anyLong;
  35 +import static org.mockito.ArgumentMatchers.anyString;
26 36 import static org.mockito.ArgumentMatchers.eq;
  37 +import static org.mockito.Mockito.doThrow;
  38 +import static org.mockito.Mockito.mock;
  39 +import static org.mockito.Mockito.mockStatic;
27 40 import static org.mockito.Mockito.never;
  41 +import static org.mockito.Mockito.times;
28 42 import static org.mockito.Mockito.verify;
29 43 import static org.mockito.Mockito.when;
30 44  
... ... @@ -41,6 +55,8 @@ class IotDataRuleServiceImplTest extends BaseDbUnitTest {
41 55  
42 56 @Resource
43 57 private IotDataRuleMapper dataRuleMapper;
  58 + @Resource
  59 + private PlatformTransactionManager transactionManager;
44 60  
45 61 @MockitoBean
46 62 private IotDataSinkService dataSinkService;
... ... @@ -191,6 +207,93 @@ class IotDataRuleServiceImplTest extends BaseDbUnitTest {
191 207 verify(dataRuleAction).execute(eq(message), eq(sink));
192 208 }
193 209  
  210 + // ========== executeDataRule:状态事务内的数据访问异常 ==========
  211 +
  212 + @Test
  213 + public void testExecuteDataRule_actionDataAccessException() {
  214 + // 准备参数
  215 + Long deviceId = randomLongId();
  216 + String identifier = "temperature";
  217 + IotDeviceMessage message = createPropertyPostMessage(deviceId,
  218 + MapUtil.<String, Object>builder().put(identifier, 25.5).build());
  219 + // mock 数据
  220 + Long sinkId = randomLongId();
  221 + insertEnabledRule(deviceId, IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), identifier, sinkId);
  222 + // mock 方法:数据目的访问数据失败
  223 + IotDataSinkDO sink = mockEnabledSink(sinkId);
  224 + doThrow(new QueryTimeoutException("data access failed")).when(dataRuleAction).execute(eq(message), eq(sink));
  225 +
  226 + // 调用,并断言异常:状态事务内,数据访问异常不吞掉,交给事务回滚
  227 + new TransactionTemplate(transactionManager).executeWithoutResult(status ->
  228 + assertThrows(QueryTimeoutException.class, () -> dataRuleService.executeDataRule(message)));
  229 + // 调用:非事务内,与原逻辑一致,记录日志后结束
  230 + assertDoesNotThrow(() -> dataRuleService.executeDataRule(message));
  231 + }
  232 +
  233 + @Test
  234 + public void testExecuteDataRule_sinkDataAccessException() {
  235 + // 准备参数
  236 + Long deviceId = randomLongId();
  237 + String identifier = "temperature";
  238 + IotDeviceMessage message = createPropertyPostMessage(deviceId,
  239 + MapUtil.<String, Object>builder().put(identifier, 25.5).build());
  240 + // mock 数据:一条规则,两个数据目的
  241 + Long sinkId1 = randomLongId();
  242 + Long sinkId2 = randomLongId();
  243 + insertEnabledRule(deviceId, IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), identifier, sinkId1, sinkId2);
  244 + // mock 方法:读取第一个数据目的失败(例如说 Redis 超时);第二个数据目的正常
  245 + when(dataSinkService.getDataSinkFromCache(sinkId1)).thenThrow(new QueryTimeoutException("sink query failed"));
  246 + IotDataSinkDO sink2 = mockEnabledSink(sinkId2);
  247 +
  248 + // 调用,并断言异常:状态事务内,不吞掉,后续数据目的不再执行
  249 + new TransactionTemplate(transactionManager).executeWithoutResult(status ->
  250 + assertThrows(QueryTimeoutException.class, () -> dataRuleService.executeDataRule(message)));
  251 + verify(dataRuleAction, never()).execute(any(), any());
  252 + // 调用:非事务内,与原逻辑一致,记录日志后继续执行第二个数据目的
  253 + assertDoesNotThrow(() -> dataRuleService.executeDataRule(message));
  254 + verify(dataRuleAction, times(1)).execute(eq(message), eq(sink2));
  255 + }
  256 +
  257 + @Test
  258 + public void testExecuteDataRule_matchDataAccessException() {
  259 + // 准备参数
  260 + IotDeviceMessage message = createPropertyPostMessage(randomLongId(),
  261 + MapUtil.<String, Object>builder().put("temperature", 25.5).build());
  262 + // mock 方法:匹配规则时访问数据失败(getSelf 的缓存方法)
  263 + IotDataRuleServiceImpl self = mock(IotDataRuleServiceImpl.class);
  264 + when(self.getDataRuleListByConditionFromCache(anyLong(), anyString(), any()))
  265 + .thenThrow(new QueryTimeoutException("rule query failed"));
  266 +
  267 + try (MockedStatic<SpringUtil> springUtilMock = mockStatic(SpringUtil.class)) {
  268 + springUtilMock.when(() -> SpringUtil.getBean(IotDataRuleServiceImpl.class)).thenReturn(self);
  269 + // 调用,并断言异常:状态事务内,不吞掉
  270 + new TransactionTemplate(transactionManager).executeWithoutResult(status ->
  271 + assertThrows(QueryTimeoutException.class, () -> dataRuleService.executeDataRule(message)));
  272 + // 调用:非事务内,与原逻辑一致,记录日志后结束
  273 + assertDoesNotThrow(() -> dataRuleService.executeDataRule(message));
  274 + }
  275 + verify(dataRuleAction, never()).execute(any(), any());
  276 + }
  277 +
  278 + @Test
  279 + public void testExecuteDataRule_otherExceptionInTransaction() {
  280 + // 准备参数
  281 + Long deviceId = randomLongId();
  282 + String identifier = "temperature";
  283 + IotDeviceMessage message = createPropertyPostMessage(deviceId,
  284 + MapUtil.<String, Object>builder().put(identifier, 25.5).build());
  285 + // mock 数据
  286 + Long sinkId = randomLongId();
  287 + insertEnabledRule(deviceId, IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), identifier, sinkId);
  288 + // mock 方法:数据目的的其它异常(例如说参数错误)
  289 + IotDataSinkDO sink = mockEnabledSink(sinkId);
  290 + doThrow(new IllegalArgumentException("config invalid")).when(dataRuleAction).execute(eq(message), eq(sink));
  291 +
  292 + // 调用:与原逻辑一致,记录日志后继续
  293 + new TransactionTemplate(transactionManager).executeWithoutResult(status ->
  294 + assertDoesNotThrow(() -> dataRuleService.executeDataRule(message)));
  295 + }
  296 +
194 297 // ========== 辅助方法 ==========
195 298  
196 299 private IotDeviceMessage createPropertyPostMessage(Long deviceId, Map<String, Object> params) {
... ... @@ -202,9 +305,9 @@ class IotDataRuleServiceImplTest extends BaseDbUnitTest {
202 305 }
203 306  
204 307 /**
205   - * 向 H2 中插入一条启用状态的数据流转规则,命中后会路由到 {@code sinkId}
  308 + * 向 H2 中插入一条启用状态的数据流转规则,命中后会路由到 {@code sinkIds}
206 309 */
207   - private void insertEnabledRule(Long deviceId, String method, String identifier, Long sinkId) {
  310 + private void insertEnabledRule(Long deviceId, String method, String identifier, Long... sinkIds) {
208 311 IotDataRuleDO.SourceConfig config = randomPojo(IotDataRuleDO.SourceConfig.class, o -> {
209 312 o.setDeviceId(deviceId);
210 313 o.setMethod(method);
... ... @@ -214,7 +317,7 @@ class IotDataRuleServiceImplTest extends BaseDbUnitTest {
214 317 o.setId(null);
215 318 o.setStatus(CommonStatusEnum.ENABLE.getStatus());
216 319 o.setSourceConfigs(singletonList(config));
217   - o.setSinkIds(singletonList(sinkId));
  320 + o.setSinkIds(Arrays.asList(sinkIds));
218 321 });
219 322 dataRuleMapper.insert(rule);
220 323 }
... ...
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotHttpDataSinkActionTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.service.rule.data.action;
  2 +
  3 +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
  4 +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotDataSinkDO;
  5 +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkHttpConfig;
  6 +import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum;
  7 +import org.junit.jupiter.api.Test;
  8 +import org.springframework.boot.web.client.RestTemplateBuilder;
  9 +import org.springframework.http.HttpMethod;
  10 +import org.springframework.test.util.ReflectionTestUtils;
  11 +
  12 +import java.net.InetAddress;
  13 +import java.net.ServerSocket;
  14 +
  15 +import static org.junit.jupiter.api.Assertions.assertTrue;
  16 +
  17 +/**
  18 + * {@link IotHttpDataSinkAction} 的单元测试
  19 + *
  20 + * @author 芋道源码
  21 + */
  22 +public class IotHttpDataSinkActionTest {
  23 +
  24 + @Test
  25 + public void testExecute_readTimeout() throws Exception {
  26 + // 准备参数:本地只建立连接、不响应的 server(不 accept,由 backlog 完成连接)
  27 + try (ServerSocket serverSocket = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) {
  28 + IotHttpDataSinkAction action = new IotHttpDataSinkAction();
  29 + ReflectionTestUtils.setField(action, "restTemplateBuilder", new RestTemplateBuilder());
  30 + action.init();
  31 + IotDataSinkHttpConfig config = new IotDataSinkHttpConfig()
  32 + .setUrl("http://127.0.0.1:" + serverSocket.getLocalPort() + "/hook")
  33 + .setMethod(HttpMethod.GET.name());
  34 + IotDataSinkDO dataSink = new IotDataSinkDO().setId(1L).setType(IotDataSinkTypeEnum.HTTP.getType())
  35 + .setConfig(config);
  36 + IotDeviceMessage message = new IotDeviceMessage().setId("msg-1").setDeviceId(2L).setTenantId(1L);
  37 +
  38 + // 调用:超时异常由 action 内部记录日志,不抛出
  39 + long start = System.currentTimeMillis();
  40 + action.execute(message, dataSink);
  41 + long cost = System.currentTimeMillis() - start;
  42 + // 断言:按读取超时返回,不会无限等待
  43 + long readTimeout = IotHttpDataSinkAction.READ_TIMEOUT.toMillis();
  44 + assertTrue(cost >= readTimeout - 500, "耗时过短:" + cost);
  45 + assertTrue(cost < readTimeout + IotHttpDataSinkAction.CONNECT_TIMEOUT.toMillis(), "耗时过长:" + cost);
  46 + }
  47 + }
  48 +
  49 +}
... ...
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceSimpleTest.java
1 1 package cn.iocoder.yudao.module.iot.service.rule.scene;
2 2  
  3 +import cn.hutool.core.util.ReflectUtil;
  4 +import cn.hutool.extra.spring.SpringUtil;
3 5 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
4 6 import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
5 7 import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.scene.IotSceneRuleSaveReqVO;
6 8 import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
7 9 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
  10 +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
  11 +import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO;
8 12 import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO;
  13 +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleActionTypeEnum;
  14 +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum;
9 15 import cn.iocoder.yudao.module.iot.dal.mysql.rule.IotSceneRuleMapper;
10 16 import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
11 17 import cn.iocoder.yudao.module.iot.service.product.IotProductService;
... ... @@ -14,9 +20,12 @@ import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatche
14 20 import cn.iocoder.yudao.module.iot.service.rule.scene.timer.IotSceneRuleTimerHandler;
15 21 import cn.iocoder.yudao.module.iot.service.rule.scene.timer.IotTimerConditionEvaluator;
16 22 import org.junit.jupiter.api.Test;
  23 +import org.junit.jupiter.api.function.Executable;
17 24 import org.mockito.InjectMocks;
18 25 import org.mockito.Mock;
  26 +import org.mockito.MockedStatic;
19 27 import org.springframework.dao.QueryTimeoutException;
  28 +import org.springframework.transaction.support.TransactionSynchronizationManager;
20 29  
21 30 import java.util.Collections;
22 31 import java.util.List;
... ... @@ -27,6 +36,7 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
27 36 import static org.junit.jupiter.api.Assertions.assertEquals;
28 37 import static org.junit.jupiter.api.Assertions.assertThrows;
29 38 import static org.mockito.ArgumentMatchers.any;
  39 +import static org.mockito.ArgumentMatchers.eq;
30 40 import static org.mockito.Mockito.*;
31 41  
32 42 /**
... ... @@ -54,6 +64,8 @@ public class IotSceneRuleServiceSimpleTest extends BaseMockitoUnitTest {
54 64 private IotProductService productService;
55 65 @Mock
56 66 private IotDeviceService deviceService;
  67 + @Mock
  68 + private IotSceneRuleAction sceneRuleAction;
57 69  
58 70 @Test
59 71 public void testCreateScene_rule_success() {
... ... @@ -247,4 +259,139 @@ public class IotSceneRuleServiceSimpleTest extends BaseMockitoUnitTest {
247 259 verifyNoInteractions(sceneRuleMapper, productService, sceneRuleMatcherManager, sceneRuleActions);
248 260 }
249 261  
  262 + // ========== executeSceneRuleByDevice:状态事务内的数据访问异常 ==========
  263 + // 说明:以下用例只标记“存在实际事务”,验证各 catch 的判断与原语义;不是真实的数据库事务,
  264 + // 真实的回滚由 IotDeviceServiceImplTest 覆盖
  265 +
  266 + @Test
  267 + public void testExecuteSceneRuleByDevice_triggerMatchDataAccessException() {
  268 + // 准备参数
  269 + IotDeviceMessage message = buildPropertyPostMessage();
  270 + IotSceneRuleDO sceneRule = buildSceneRule(null);
  271 + // mock 方法:触发器匹配时访问数据失败
  272 + when(sceneRuleMatcherManager.isMatched(eq(message), any())).thenThrow(new QueryTimeoutException("matcher failed"));
  273 +
  274 + try (MockedStatic<SpringUtil> ignored = mockMatchedSceneRule(message, sceneRule)) {
  275 + // 调用,并断言异常:状态事务内,不吞掉
  276 + assertThrows(QueryTimeoutException.class,
  277 + () -> executeInActualTransaction(() -> sceneRuleService.executeSceneRuleByDevice(message)));
  278 + // 调用:非事务内,与原逻辑一致,记录日志后视为不匹配
  279 + assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByDevice(message));
  280 + }
  281 + // 断言:没有执行动作
  282 + verifyNoInteractions(sceneRuleAction);
  283 + verify(sceneRuleMatcherManager, never()).isConditionMatched(any(), any());
  284 + }
  285 +
  286 + @Test
  287 + public void testExecuteSceneRuleByDevice_conditionMatchDataAccessException() {
  288 + // 准备参数:触发器带一个条件
  289 + IotDeviceMessage message = buildPropertyPostMessage();
  290 + IotSceneRuleDO sceneRule = buildSceneRule(Collections.singletonList(
  291 + Collections.singletonList(new IotSceneRuleDO.TriggerCondition())));
  292 + // mock 方法:触发器匹配;条件匹配时访问数据失败
  293 + when(sceneRuleMatcherManager.isMatched(eq(message), any())).thenReturn(true);
  294 + when(sceneRuleMatcherManager.isConditionMatched(eq(message), any())).thenThrow(new QueryTimeoutException("condition failed"));
  295 +
  296 + try (MockedStatic<SpringUtil> ignored = mockMatchedSceneRule(message, sceneRule)) {
  297 + // 调用,并断言异常:状态事务内,不吞掉
  298 + assertThrows(QueryTimeoutException.class,
  299 + () -> executeInActualTransaction(() -> sceneRuleService.executeSceneRuleByDevice(message)));
  300 + // 调用:非事务内,与原逻辑一致,视为条件不满足
  301 + assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByDevice(message));
  302 + }
  303 + // 断言:没有执行动作
  304 + verifyNoInteractions(sceneRuleAction);
  305 + }
  306 +
  307 + @Test
  308 + public void testExecuteSceneRuleByDevice_actionDataAccessException() throws Exception {
  309 + // 准备参数
  310 + IotDeviceMessage message = buildPropertyPostMessage();
  311 + IotSceneRuleDO sceneRule = buildSceneRule(null);
  312 + // mock 方法:触发器匹配;动作执行时访问数据失败
  313 + when(sceneRuleMatcherManager.isMatched(eq(message), any())).thenReturn(true);
  314 + mockSceneRuleAction();
  315 + doThrow(new QueryTimeoutException("action failed")).when(sceneRuleAction).execute(any(), any(), any());
  316 +
  317 + try (MockedStatic<SpringUtil> ignored = mockMatchedSceneRule(message, sceneRule)) {
  318 + // 调用,并断言异常:状态事务内,不吞掉,也不再更新最后触发时间
  319 + assertThrows(QueryTimeoutException.class,
  320 + () -> executeInActualTransaction(() -> sceneRuleService.executeSceneRuleByDevice(message)));
  321 + verify(sceneRuleMapper, never()).updateById(any(IotSceneRuleDO.class));
  322 + // 调用:非事务内,与原逻辑一致,记录日志后继续更新最后触发时间
  323 + assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByDevice(message));
  324 + }
  325 + verify(sceneRuleMapper, times(1)).updateById(any(IotSceneRuleDO.class));
  326 + }
  327 +
  328 + @Test
  329 + public void testExecuteSceneRuleByDevice_updateLastTriggerTimeDataAccessException() throws Exception {
  330 + // 准备参数
  331 + IotDeviceMessage message = buildPropertyPostMessage();
  332 + IotSceneRuleDO sceneRule = buildSceneRule(null);
  333 + // mock 方法:动作执行成功;更新最后触发时间失败(例如说热点行的锁等待超时)
  334 + when(sceneRuleMatcherManager.isMatched(eq(message), any())).thenReturn(true);
  335 + mockSceneRuleAction();
  336 + when(sceneRuleMapper.updateById(any(IotSceneRuleDO.class))).thenThrow(new QueryTimeoutException("update failed"));
  337 +
  338 + try (MockedStatic<SpringUtil> ignored = mockMatchedSceneRule(message, sceneRule)) {
  339 + // 调用,并断言异常:状态事务内,不吞掉
  340 + assertThrows(QueryTimeoutException.class,
  341 + () -> executeInActualTransaction(() -> sceneRuleService.executeSceneRuleByDevice(message)));
  342 + // 调用:非事务内,与原逻辑一致,记录日志后结束
  343 + assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByDevice(message));
  344 + }
  345 + verify(sceneRuleAction, times(2)).execute(same(message), same(sceneRule), any());
  346 + }
  347 +
  348 + // ========== 辅助方法 ==========
  349 +
  350 + /**
  351 + * 标记“存在实际事务”后执行:只用于验证 catch 的判断条件,不是真实的数据库事务
  352 + */
  353 + private static void executeInActualTransaction(Executable executable) throws Throwable {
  354 + TransactionSynchronizationManager.setActualTransactionActive(true);
  355 + try {
  356 + executable.execute();
  357 + } finally {
  358 + TransactionSynchronizationManager.setActualTransactionActive(false);
  359 + }
  360 + }
  361 +
  362 + /**
  363 + * mock 消息对应的设备、产品,以及匹配的规则场景(getSelf 的缓存方法)
  364 + */
  365 + private MockedStatic<SpringUtil> mockMatchedSceneRule(IotDeviceMessage message, IotSceneRuleDO sceneRule) {
  366 + IotDeviceDO device = IotDeviceDO.builder().id(message.getDeviceId()).productId(randomLongId()).build();
  367 + device.setTenantId(1L);
  368 + when(deviceService.getDeviceFromCache(message.getDeviceId())).thenReturn(device);
  369 + when(productService.getProductFromCache(device.getProductId()))
  370 + .thenReturn(IotProductDO.builder().id(device.getProductId()).build());
  371 + IotSceneRuleServiceImpl self = mock(IotSceneRuleServiceImpl.class);
  372 + when(self.getSceneRuleListByProductIdAndDeviceIdFromCache(device.getProductId(), device.getId()))
  373 + .thenReturn(Collections.singletonList(sceneRule));
  374 + MockedStatic<SpringUtil> springUtilMock = mockStatic(SpringUtil.class);
  375 + springUtilMock.when(() -> SpringUtil.getBean(IotSceneRuleServiceImpl.class)).thenReturn(self);
  376 + return springUtilMock;
  377 + }
  378 +
  379 + private void mockSceneRuleAction() {
  380 + when(sceneRuleAction.getType()).thenReturn(IotSceneRuleActionTypeEnum.ALERT_TRIGGER);
  381 + ReflectUtil.setFieldValue(sceneRuleService, "sceneRuleActions", Collections.singletonList(sceneRuleAction));
  382 + }
  383 +
  384 + private static IotDeviceMessage buildPropertyPostMessage() {
  385 + return new IotDeviceMessage().setDeviceId(randomLongId())
  386 + .setMethod(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod());
  387 + }
  388 +
  389 + private static IotSceneRuleDO buildSceneRule(List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups) {
  390 + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger()
  391 + .setType(IotSceneRuleTriggerTypeEnum.DEVICE_PROPERTY_POST.getType()).setConditionGroups(conditionGroups);
  392 + IotSceneRuleDO.Action action = new IotSceneRuleDO.Action().setType(IotSceneRuleActionTypeEnum.ALERT_TRIGGER.getType());
  393 + return new IotSceneRuleDO().setId(randomLongId()).setStatus(CommonStatusEnum.ENABLE.getStatus())
  394 + .setTriggers(Collections.singletonList(trigger)).setActions(Collections.singletonList(action));
  395 + }
  396 +
250 397 }
... ...
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotAlertTriggerSceneRuleActionTest.java
... ... @@ -19,10 +19,13 @@ import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDT
19 19 import cn.iocoder.yudao.module.system.api.sms.SmsSendApi;
20 20 import cn.iocoder.yudao.module.system.api.sms.dto.send.SmsSendSingleToUserReqDTO;
21 21 import org.junit.jupiter.api.Test;
  22 +import org.junit.jupiter.api.function.Executable;
22 23 import org.mockito.ArgumentCaptor;
23 24 import org.mockito.InjectMocks;
24 25 import org.mockito.Mock;
25 26 import org.mockito.MockedStatic;
  27 +import org.springframework.dao.QueryTimeoutException;
  28 +import org.springframework.transaction.support.TransactionSynchronizationManager;
26 29  
27 30 import java.time.LocalDateTime;
28 31 import java.util.Arrays;
... ... @@ -31,7 +34,9 @@ import java.util.Collections;
31 34 import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId;
32 35 import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
33 36 import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString;
  37 +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
34 38 import static org.junit.jupiter.api.Assertions.assertEquals;
  39 +import static org.junit.jupiter.api.Assertions.assertThrows;
35 40 import static org.mockito.ArgumentMatchers.any;
36 41 import static org.mockito.ArgumentMatchers.anyLong;
37 42 import static org.mockito.ArgumentMatchers.eq;
... ... @@ -261,6 +266,53 @@ public class IotAlertTriggerSceneRuleActionTest extends BaseMockitoUnitTest {
261 266 verify(mailSendApi, times(1)).sendSingleMailToAdmin(any());
262 267 }
263 268  
  269 + @Test
  270 + public void testExecute_smsDataAccessException() {
  271 + // 准备参数
  272 + Long userId = randomLongId();
  273 + IotDeviceMessage message = createDeviceMessage();
  274 + IotSceneRuleDO rule = randomPojo(IotSceneRuleDO.class);
  275 + IotSceneRuleDO.Action actionConfig = randomPojo(IotSceneRuleDO.Action.class);
  276 + IotAlertConfigDO config = randomPojo(IotAlertConfigDO.class, c -> {
  277 + c.setReceiveUserIds(Collections.singletonList(userId));
  278 + c.setReceiveTypes(Arrays.asList(
  279 + IotAlertReceiveTypeEnum.SMS.getType(),
  280 + IotAlertReceiveTypeEnum.MAIL.getType()));
  281 + });
  282 + IotDeviceDO device = randomPojo(IotDeviceDO.class);
  283 +
  284 + // mock 行为:sms 写发送日志失败(单体部署下,访问的是同一个数据库)
  285 + when(alertConfigService.getAlertConfigListBySceneRuleIdAndStatus(rule.getId(), CommonStatusEnum.ENABLE.getStatus()))
  286 + .thenReturn(Collections.singletonList(config));
  287 + when(deviceService.getDeviceFromCache(message.getDeviceId())).thenReturn(device);
  288 + when(smsSendApi.sendSingleSmsToAdmin(any())).thenThrow(new QueryTimeoutException("sms log failed"));
  289 +
  290 + try (MockedStatic<DictFrameworkUtils> dictMock = mockStatic(DictFrameworkUtils.class)) {
  291 + dictMock.when(() -> DictFrameworkUtils.parseDictDataLabel(any(), any(Integer.class)))
  292 + .thenReturn("ERROR");
  293 + // 调用,并断言异常:状态事务内,不吞掉,后续通道不再发送
  294 + // 说明:只标记“存在实际事务”,验证 catch 的判断;不是真实的数据库事务,真实的回滚由 IotDeviceServiceImplTest 覆盖
  295 + assertThrows(QueryTimeoutException.class,
  296 + () -> executeInActualTransaction(() -> action.execute(message, rule, actionConfig)));
  297 + verify(mailSendApi, never()).sendSingleMailToAdmin(any());
  298 + // 调用:非事务内,与原逻辑一致,记录日志后继续发送邮件
  299 + assertDoesNotThrow(() -> action.execute(message, rule, actionConfig));
  300 + }
  301 + verify(mailSendApi, times(1)).sendSingleMailToAdmin(any());
  302 + }
  303 +
  304 + /**
  305 + * 标记“存在实际事务”后执行:只用于验证 catch 的判断条件,不是真实的数据库事务
  306 + */
  307 + private static void executeInActualTransaction(Executable executable) throws Throwable {
  308 + TransactionSynchronizationManager.setActualTransactionActive(true);
  309 + try {
  310 + executable.execute();
  311 + } finally {
  312 + TransactionSynchronizationManager.setActualTransactionActive(false);
  313 + }
  314 + }
  315 +
264 316 /**
265 317 * 创建带 reportTime 的设备消息
266 318 */
... ...
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotDevicePropertySetSceneRuleActionTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.service.rule.scene.action;
  2 +
  3 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  4 +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
  5 +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
  6 +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
  7 +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO;
  8 +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
  9 +import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService;
  10 +import org.junit.jupiter.api.Test;
  11 +import org.junit.jupiter.api.function.Executable;
  12 +import org.mockito.InjectMocks;
  13 +import org.mockito.Mock;
  14 +import org.springframework.dao.QueryTimeoutException;
  15 +import org.springframework.transaction.support.TransactionSynchronizationManager;
  16 +
  17 +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId;
  18 +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
  19 +import static org.junit.jupiter.api.Assertions.assertThrows;
  20 +import static org.mockito.ArgumentMatchers.argThat;
  21 +import static org.mockito.ArgumentMatchers.same;
  22 +import static org.mockito.Mockito.*;
  23 +
  24 +/**
  25 + * {@link IotDevicePropertySetSceneRuleAction} 的单元测试
  26 + *
  27 + * 说明:用例只标记“存在实际事务”,验证 catch 的判断与原语义;不是真实的数据库事务,真实的回滚由 IotDeviceServiceImplTest 覆盖
  28 + *
  29 + * @author 芋道源码
  30 + */
  31 +public class IotDevicePropertySetSceneRuleActionTest extends BaseMockitoUnitTest {
  32 +
  33 + @InjectMocks
  34 + private IotDevicePropertySetSceneRuleAction action;
  35 +
  36 + @Mock
  37 + private IotDeviceService deviceService;
  38 + @Mock
  39 + private IotDeviceMessageService deviceMessageService;
  40 +
  41 + @Test
  42 + public void testExecute_sendDataAccessException() {
  43 + // 准备参数
  44 + IotDeviceDO device = IotDeviceDO.builder().id(randomLongId()).build();
  45 + IotSceneRuleDO rule = new IotSceneRuleDO().setId(randomLongId());
  46 + IotSceneRuleDO.Action actionConfig = buildActionConfig(device.getId());
  47 + // mock 方法:发送时读取 serverId 的 Redis 故障
  48 + when(deviceService.getDeviceFromCache(device.getId())).thenReturn(device);
  49 + when(deviceMessageService.sendDeviceMessage(argThat((IotDeviceMessage m) ->
  50 + IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod().equals(m.getMethod())), same(device)))
  51 + .thenThrow(new QueryTimeoutException("redis failed"));
  52 +
  53 + // 调用,并断言异常:状态事务内,不吞掉
  54 + assertThrows(QueryTimeoutException.class,
  55 + () -> executeInActualTransaction(() -> action.execute(null, rule, actionConfig)));
  56 + // 调用:非事务内,与原逻辑一致,记录日志后结束
  57 + assertDoesNotThrow(() -> action.execute(null, rule, actionConfig));
  58 + }
  59 +
  60 + // ========== 辅助方法 ==========
  61 +
  62 + /**
  63 + * 标记“存在实际事务”后执行:只用于验证 catch 的判断条件,不是真实的数据库事务
  64 + */
  65 + private static void executeInActualTransaction(Executable executable) throws Throwable {
  66 + TransactionSynchronizationManager.setActualTransactionActive(true);
  67 + try {
  68 + executable.execute();
  69 + } finally {
  70 + TransactionSynchronizationManager.setActualTransactionActive(false);
  71 + }
  72 + }
  73 +
  74 + private static IotSceneRuleDO.Action buildActionConfig(Long deviceId) {
  75 + return new IotSceneRuleDO.Action().setDeviceId(deviceId).setIdentifier("power").setParams("1");
  76 + }
  77 +
  78 +}
... ...
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotDeviceServiceInvokeSceneRuleActionTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.service.rule.scene.action;
  2 +
  3 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  4 +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
  5 +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
  6 +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
  7 +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO;
  8 +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
  9 +import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService;
  10 +import org.junit.jupiter.api.Test;
  11 +import org.junit.jupiter.api.function.Executable;
  12 +import org.mockito.InjectMocks;
  13 +import org.mockito.Mock;
  14 +import org.springframework.dao.QueryTimeoutException;
  15 +import org.springframework.transaction.support.TransactionSynchronizationManager;
  16 +
  17 +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId;
  18 +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
  19 +import static org.junit.jupiter.api.Assertions.assertThrows;
  20 +import static org.mockito.ArgumentMatchers.argThat;
  21 +import static org.mockito.ArgumentMatchers.same;
  22 +import static org.mockito.Mockito.*;
  23 +
  24 +/**
  25 + * {@link IotDeviceServiceInvokeSceneRuleAction} 的单元测试
  26 + *
  27 + * 说明:用例只标记“存在实际事务”,验证 catch 的判断与原语义;不是真实的数据库事务,真实的回滚由 IotDeviceServiceImplTest 覆盖
  28 + *
  29 + * @author 芋道源码
  30 + */
  31 +public class IotDeviceServiceInvokeSceneRuleActionTest extends BaseMockitoUnitTest {
  32 +
  33 + @InjectMocks
  34 + private IotDeviceServiceInvokeSceneRuleAction action;
  35 +
  36 + @Mock
  37 + private IotDeviceService deviceService;
  38 + @Mock
  39 + private IotDeviceMessageService deviceMessageService;
  40 +
  41 + @Test
  42 + public void testExecute_sendDataAccessException() {
  43 + // 准备参数
  44 + IotDeviceDO device = IotDeviceDO.builder().id(randomLongId()).build();
  45 + IotSceneRuleDO rule = new IotSceneRuleDO().setId(randomLongId());
  46 + IotSceneRuleDO.Action actionConfig = buildActionConfig(device.getId());
  47 + // mock 方法:发送时读取 serverId 的 Redis 故障
  48 + when(deviceService.getDeviceFromCache(device.getId())).thenReturn(device);
  49 + when(deviceMessageService.sendDeviceMessage(argThat((IotDeviceMessage m) ->
  50 + IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod().equals(m.getMethod())), same(device)))
  51 + .thenThrow(new QueryTimeoutException("redis failed"));
  52 +
  53 + // 调用,并断言异常:状态事务内,不吞掉
  54 + assertThrows(QueryTimeoutException.class,
  55 + () -> executeInActualTransaction(() -> action.execute(null, rule, actionConfig)));
  56 + // 调用:非事务内,与原逻辑一致,记录日志后结束
  57 + assertDoesNotThrow(() -> action.execute(null, rule, actionConfig));
  58 + }
  59 +
  60 + // ========== 辅助方法 ==========
  61 +
  62 + /**
  63 + * 标记“存在实际事务”后执行:只用于验证 catch 的判断条件,不是真实的数据库事务
  64 + */
  65 + private static void executeInActualTransaction(Executable executable) throws Throwable {
  66 + TransactionSynchronizationManager.setActualTransactionActive(true);
  67 + try {
  68 + executable.execute();
  69 + } finally {
  70 + TransactionSynchronizationManager.setActualTransactionActive(false);
  71 + }
  72 + }
  73 +
  74 + private static IotSceneRuleDO.Action buildActionConfig(Long deviceId) {
  75 + return new IotSceneRuleDO.Action().setDeviceId(deviceId).setIdentifier("reboot").setParams("{}");
  76 + }
  77 +
  78 +}
... ...
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/IotDeviceCommonApi.java
... ... @@ -5,6 +5,7 @@ import cn.iocoder.yudao.module.iot.core.biz.dto.*;
5 5 import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO;
6 6 import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterRespDTO;
7 7 import cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterRespDTO;
  8 +import jakarta.validation.Valid;
8 9  
9 10 import java.util.List;
10 11  
... ... @@ -55,4 +56,12 @@ public interface IotDeviceCommonApi {
55 56 */
56 57 CommonResult<List<IotModbusDeviceConfigRespDTO>> getModbusDeviceConfigList(IotModbusDeviceConfigListReqDTO listReqDTO);
57 58  
  59 + /**
  60 + * 续期网关协议实例的存活标记
  61 + *
  62 + * @param reqDTO 各协议实例的启动时间
  63 + * @return 是否全部续期成功;部分失败时为 false
  64 + */
  65 + CommonResult<Boolean> keepAliveGatewayServers(@Valid IotGatewayServerAliveReqDTO reqDTO);
  66 +
58 67 }
... ...
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotGatewayServerAliveReqDTO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.core.biz.dto;
  2 +
  3 +import jakarta.validation.constraints.NotBlank;
  4 +import jakarta.validation.constraints.NotEmpty;
  5 +import jakarta.validation.constraints.NotNull;
  6 +import jakarta.validation.constraints.Positive;
  7 +import lombok.Data;
  8 +
  9 +import java.util.Map;
  10 +
  11 +/**
  12 + * IoT 网关协议实例的存活上报 Request DTO
  13 + *
  14 + * @author 芋道源码
  15 + */
  16 +@Data
  17 +public class IotGatewayServerAliveReqDTO {
  18 +
  19 + /**
  20 + * 协议实例的启动时间(毫秒)
  21 + *
  22 + * key:协议实例的 serverId
  23 + * value:协议实例本次 start 前取的时间,续期期间不变
  24 + */
  25 + @NotEmpty(message = "启动时间不能为空")
  26 + private Map<@NotBlank(message = "serverId 不能为空") String,
  27 + @NotNull(message = "启动时间不能为空") @Positive(message = "启动时间必须大于 0") Long> startTimes;
  28 +
  29 +}
... ...
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotProtocolTypeEnum.java
... ... @@ -18,15 +18,15 @@ import java.util.Arrays;
18 18 @Getter
19 19 public enum IotProtocolTypeEnum implements ArrayValuable<String> {
20 20  
21   - TCP("tcp"),
22   - UDP("udp"),
23   - WEBSOCKET("websocket"),
24   - HTTP("http"),
25   - MQTT("mqtt"),
26   - EMQX("emqx"),
27   - COAP("coap"),
28   - MODBUS_TCP_CLIENT("modbus_tcp_client"),
29   - MODBUS_TCP_SERVER("modbus_tcp_server");
  21 + TCP("tcp", true),
  22 + UDP("udp", false),
  23 + WEBSOCKET("websocket", true),
  24 + HTTP("http", false),
  25 + MQTT("mqtt", true),
  26 + EMQX("emqx", true),
  27 + COAP("coap", false),
  28 + MODBUS_TCP_CLIENT("modbus_tcp_client", true),
  29 + MODBUS_TCP_SERVER("modbus_tcp_server", true);
30 30  
31 31 public static final String[] ARRAYS = Arrays.stream(values()).map(IotProtocolTypeEnum::getType).toArray(String[]::new);
32 32  
... ... @@ -34,6 +34,10 @@ public enum IotProtocolTypeEnum implements ArrayValuable&lt;String&gt; {
34 34 * 类型
35 35 */
36 36 private final String type;
  37 + /**
  38 + * 是否是连接型协议:在线状态由连接的建立和断开决定;其它(HTTP、CoAP、UDP)为活跃型,由离线任务按最后活跃时间判断
  39 + */
  40 + private final boolean connectionBased;
37 41  
38 42 @Override
39 43 public String[] array() {
... ...
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtils.java
... ... @@ -9,6 +9,7 @@ import cn.hutool.system.SystemUtil;
9 9 import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
10 10 import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
11 11 import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
  12 +import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum;
12 13 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
13 14  
14 15 import java.util.Base64;
... ... @@ -74,6 +75,17 @@ public class IotDeviceMessageUtils {
74 75 IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod());
75 76 }
76 77  
  78 + /**
  79 + * 是否是设备离线的状态消息:方法为 STATE_UPDATE,且状态为离线
  80 + *
  81 + * @param message 消息
  82 + * @return 是否
  83 + */
  84 + public static boolean isStateOffline(IotDeviceMessage message) {
  85 + return StrUtil.equals(message.getMethod(), IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod())
  86 + && StrUtil.equals(getIdentifier(message), String.valueOf(IotDeviceStateEnum.OFFLINE.getState()));
  87 + }
  88 +
77 89 // ========== 透传(raw)相关 ==========
78 90  
79 91 /**
... ... @@ -446,6 +458,18 @@ public class IotDeviceMessageUtils {
446 458  
447 459 // ========== Topic 相关 ==========
448 460  
  461 + /**
  462 + * 网关协议实例的存活标记,采用 STRING 结构:biz 接收网关上报(IotDeviceCommonApi#keepAliveGatewayServers)后写入,biz 离线任务读取
  463 + *
  464 + * KEY 格式:iot:gateway_server:{serverId}
  465 + * VALUE 数据类型:String 协议实例本次启动的时间(毫秒),由网关上报,不是 biz 的接收时间
  466 + * 过期:网关定期上报续期;网关宕机、停止后不再续期,自动过期
  467 + *
  468 + * 用途:只供离线检查任务(IotDeviceOfflineCheckJob)判断网关是否已停止或重启过,处理未重连的旧设备;
  469 + * 不参与普通上下线的状态事务
  470 + */
  471 + private static final String GATEWAY_SERVER_ALIVE_KEY = "iot:gateway_server:%s";
  472 +
449 473 public static String buildMessageBusGatewayDeviceMessageTopic(String serverId) {
450 474 return String.format(IotDeviceMessage.MESSAGE_BUS_GATEWAY_DEVICE_MESSAGE_TOPIC, serverId);
451 475 }
... ... @@ -462,4 +486,14 @@ public class IotDeviceMessageUtils {
462 486 return serverId.replaceAll("\\.", "_");
463 487 }
464 488  
  489 + /**
  490 + * 构建网关协议实例的存活标记 Key
  491 + *
  492 + * @param serverId 服务器编号
  493 + * @return Redis Key
  494 + */
  495 + public static String buildGatewayServerAliveKey(String serverId) {
  496 + return String.format(GATEWAY_SERVER_ALIVE_KEY, serverId);
  497 + }
  498 +
465 499 }
466 500 \ No newline at end of file
... ...
yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtilsTest.java
... ... @@ -2,10 +2,12 @@ package cn.iocoder.yudao.module.iot.core.util;
2 2  
3 3 import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
4 4 import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
  5 +import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum;
5 6 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
6 7 import cn.iocoder.yudao.module.iot.core.topic.event.IotDeviceEventPostReqDTO;
7 8 import org.junit.jupiter.api.Test;
8 9  
  10 +import java.util.Collections;
9 11 import java.util.HashMap;
10 12 import java.util.Map;
11 13 import java.util.Set;
... ... @@ -397,6 +399,20 @@ public class IotDeviceMessageUtilsTest {
397 399 }
398 400  
399 401 @Test
  402 + public void testIsStateOffline() {
  403 + // 离线的状态消息
  404 + assertTrue(IotDeviceMessageUtils.isStateOffline(IotDeviceMessage.buildStateOffline()));
  405 + // 在线的状态消息、没有状态值的状态消息,不是
  406 + assertFalse(IotDeviceMessageUtils.isStateOffline(IotDeviceMessage.buildStateUpdateOnline()));
  407 + assertFalse(IotDeviceMessageUtils.isStateOffline(
  408 + IotDeviceMessage.requestOf(IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod())));
  409 + // 其它方法的消息,即使带有离线的 state 参数,也不是
  410 + assertFalse(IotDeviceMessageUtils.isStateOffline(IotDeviceMessage.requestOf(
  411 + IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(),
  412 + Collections.singletonMap("state", IotDeviceStateEnum.OFFLINE.getState()))));
  413 + }
  414 +
  415 + @Test
400 416 public void testValidateRawPayload() {
401 417 // 通用协议:1~4096 字节
402 418 IotDeviceMessageUtils.validateRawPayload(IotProtocolTypeEnum.TCP, new byte[]{0x01});
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java
1 1 package cn.iocoder.yudao.module.iot.gateway.config;
2 2  
  3 +import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
3 4 import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocolManager;
4 5 import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializerManager;
5 6 import org.springframework.boot.context.properties.EnableConfigurationProperties;
... ... @@ -21,8 +22,9 @@ public class IotGatewayConfiguration {
21 22 }
22 23  
23 24 @Bean
24   - public IotProtocolManager iotProtocolManager(IotGatewayProperties gatewayProperties) {
25   - return new IotProtocolManager(gatewayProperties);
  25 + public IotProtocolManager iotProtocolManager(IotGatewayProperties gatewayProperties,
  26 + IotDeviceCommonApi deviceApi) {
  27 + return new IotProtocolManager(gatewayProperties, deviceApi);
26 28 }
27 29  
28 30 }
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocolManager.java
1 1 package cn.iocoder.yudao.module.iot.gateway.protocol;
2 2  
3 3 import cn.hutool.core.collection.CollUtil;
  4 +import cn.hutool.core.thread.NamedThreadFactory;
4 5 import cn.hutool.core.util.BooleanUtil;
  6 +import cn.iocoder.yudao.framework.common.pojo.CommonResult;
  7 +import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
  8 +import cn.iocoder.yudao.module.iot.core.biz.dto.IotGatewayServerAliveReqDTO;
5 9 import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
6 10 import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties;
7 11 import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapProtocol;
... ... @@ -17,8 +21,10 @@ import lombok.Getter;
17 21 import lombok.extern.slf4j.Slf4j;
18 22 import org.springframework.context.SmartLifecycle;
19 23  
20   -import java.util.ArrayList;
  24 +import java.util.HashMap;
21 25 import java.util.List;
  26 +import java.util.Map;
  27 +import java.util.concurrent.*;
22 28  
23 29 /**
24 30 * IoT 协议管理器:负责根据配置创建和管理协议实例
... ... @@ -28,18 +34,42 @@ import java.util.List;
28 34 @Slf4j
29 35 public class IotProtocolManager implements SmartLifecycle {
30 36  
  37 + /**
  38 + * 存活标记的续期间隔(秒)
  39 + *
  40 + * 存活标记由 biz 写入自己的 Redis,过期时间见 DeviceServerIdRedisDAO
  41 + */
  42 + static final long ALIVE_INTERVAL_SECONDS = 20;
  43 +
31 44 private final IotGatewayProperties gatewayProperties;
32 45  
  46 + private final IotDeviceCommonApi deviceApi;
  47 +
33 48 /**
34 49 * 协议实例列表
  50 + *
  51 + * 使用 CopyOnWriteArrayList 的原因:续期线程会并发遍历
  52 + */
  53 + private final List<IotProtocol> protocols = new CopyOnWriteArrayList<>();
  54 +
  55 + /**
  56 + * 协议实例的启动时间(毫秒)
  57 + *
  58 + * 每次 start 成功都记录:biz 的离线任务据此判断,设备上线之后,协议实例是否重启过
  59 + */
  60 + private final Map<IotProtocol, Long> startTimes = new ConcurrentHashMap<>();
  61 +
  62 + /**
  63 + * 存活标记的续期线程
35 64 */
36   - private final List<IotProtocol> protocols = new ArrayList<>();
  65 + private ScheduledExecutorService aliveExecutor;
37 66  
38 67 @Getter
39 68 private volatile boolean running = false;
40 69  
41   - public IotProtocolManager(IotGatewayProperties gatewayProperties) {
  70 + public IotProtocolManager(IotGatewayProperties gatewayProperties, IotDeviceCommonApi deviceApi) {
42 71 this.gatewayProperties = gatewayProperties;
  72 + this.deviceApi = deviceApi;
43 73 }
44 74  
45 75 @Override
... ... @@ -62,9 +92,15 @@ public class IotProtocolManager implements SmartLifecycle {
62 92 if (protocol == null) {
63 93 continue;
64 94 }
  95 + // 启动前取时间:保证启动后才连上的设备,上线时间都不早于它
  96 + long startTime = System.currentTimeMillis();
65 97 protocol.start();
  98 + startTimes.put(protocol, startTime);
66 99 protocols.add(protocol);
67 100 }
  101 + // 定时续期存活标记
  102 + aliveExecutor = Executors.newSingleThreadScheduledExecutor(new NamedThreadFactory("iot-gateway-alive-", true));
  103 + aliveExecutor.scheduleWithFixedDelay(this::writeAlive, 0, ALIVE_INTERVAL_SECONDS, TimeUnit.SECONDS);
68 104 running = true;
69 105 log.info("[start][协议管理器启动完成,共启动 {} 个协议实例]", protocols.size());
70 106 }
... ... @@ -74,6 +110,9 @@ public class IotProtocolManager implements SmartLifecycle {
74 110 if (!running) {
75 111 return;
76 112 }
  113 + // 先停止续期,再停止协议:停止后存活标记不再续期,过期后 biz 的离线任务即可判定
  114 + // 注意:已经发出的上报不会被撤销,biz 仍可能稍后写入,存活标记会相应晚一些过期
  115 + stopAliveExecutor();
77 116 for (IotProtocol protocol : protocols) {
78 117 try {
79 118 protocol.stop();
... ... @@ -82,10 +121,66 @@ public class IotProtocolManager implements SmartLifecycle {
82 121 }
83 122 }
84 123 protocols.clear();
  124 + startTimes.clear();
85 125 running = false;
86 126 log.info("[stop][协议管理器已停止]");
87 127 }
88 128  
  129 + // DONE @AI:方法注释;(已补充)
  130 + /**
  131 + * 停止存活标记的续期线程:最多等待 5 秒
  132 + *
  133 + * 只停止线程,不撤销已经发出的上报
  134 + */
  135 + private void stopAliveExecutor() {
  136 + if (aliveExecutor == null) {
  137 + return;
  138 + }
  139 + aliveExecutor.shutdownNow();
  140 + try {
  141 + if (!aliveExecutor.awaitTermination(5, TimeUnit.SECONDS)) {
  142 + log.warn("[stopAliveExecutor][存活标记续期线程未能在 5 秒内退出]");
  143 + }
  144 + } catch (InterruptedException e) {
  145 + Thread.currentThread().interrupt();
  146 + }
  147 + aliveExecutor = null;
  148 + }
  149 +
  150 + /**
  151 + * 续期各协议实例的存活标记:一轮一次批量上报给 biz,值为协议实例的启动时间
  152 + *
  153 + * EMQX 的连接由 EMQX 维护,不上报存活标记
  154 + */
  155 + void writeAlive() {
  156 + // 任何异常都不抛出:否则周期任务会永久停止。远程调用失败返回 error;同 JVM 直接调用时,可能直接抛出异常
  157 + try {
  158 + // 1. 收集运行中协议实例的启动时间:serverId -> startTime
  159 + Map<String, Long> serverStartTimes = new HashMap<>();
  160 + for (IotProtocol protocol : protocols) {
  161 + if (!protocol.isRunning() || protocol.getType() == IotProtocolTypeEnum.EMQX) {
  162 + continue;
  163 + }
  164 + Long startTime = this.startTimes.get(protocol);
  165 + if (startTime != null) {
  166 + serverStartTimes.put(protocol.getServerId(), startTime);
  167 + }
  168 + }
  169 + if (serverStartTimes.isEmpty()) {
  170 + return;
  171 + }
  172 +
  173 + // 2. 批量上报:部分失败时 data 为 false,下一轮继续续期
  174 + CommonResult<Boolean> result = deviceApi.keepAliveGatewayServers(
  175 + new IotGatewayServerAliveReqDTO().setStartTimes(serverStartTimes));
  176 + if (result == null || !result.isSuccess() || !Boolean.TRUE.equals(result.getData())) {
  177 + log.warn("[writeAlive][续期存活标记未全部成功,serverStartTimes({}) result({})]", serverStartTimes, result);
  178 + }
  179 + } catch (Throwable e) {
  180 + log.warn("[writeAlive][续期存活标记异常]", e);
  181 + }
  182 + }
  183 +
89 184 /**
90 185 * 创建协议实例
91 186 *
... ... @@ -93,7 +188,7 @@ public class IotProtocolManager implements SmartLifecycle {
93 188 * @return 协议实例
94 189 */
95 190 @SuppressWarnings({"EnhancedSwitchMigration"})
96   - private IotProtocol createProtocol(IotGatewayProperties.ProtocolProperties config) {
  191 + IotProtocol createProtocol(IotGatewayProperties.ProtocolProperties config) {
97 192 IotProtocolTypeEnum protocolType = IotProtocolTypeEnum.of(config.getProtocol());
98 193 if (protocolType == null) {
99 194 log.error("[createProtocol][协议实例 {} 的协议类型 {} 不存在]", config.getId(), config.getProtocol());
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/upstream/IotEmqxAuthEventHandler.java
... ... @@ -369,6 +369,12 @@ public class IotEmqxAuthEventHandler {
369 369 private void handleClientDisconnected(JsonObject body) {
370 370 String username = body.getString("username");
371 371 String reason = body.getString("reason");
  372 + // 同一 ClientID 的新连接接管(takenover)或丢弃(discarded)了旧会话:设备仍在线,忽略旧会话的断开
  373 + // 参考 https://docs.emqx.com/en/emqx/latest/data-integration/rule-sql-events-and-fields.html 中 client.disconnected 的 reason 说明
  374 + if (StrUtil.equalsAny(reason, "takenover", "discarded")) {
  375 + log.info("[handleClientDisconnected][设备({}) 旧会话被新连接接管({}),忽略]", username, reason);
  376 + return;
  377 + }
372 378 log.info("[handleClientDisconnected][设备下线: {} ({})]", username, reason);
373 379 handleDeviceStateChange(username, false);
374 380 }
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/remote/IotDeviceApiImpl.java
... ... @@ -6,6 +6,7 @@ import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
6 6 import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
7 7 import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceGetReqDTO;
8 8 import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO;
  9 +import cn.iocoder.yudao.module.iot.core.biz.dto.IotGatewayServerAliveReqDTO;
9 10 import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigListReqDTO;
10 11 import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO;
11 12 import cn.iocoder.yudao.module.iot.core.biz.dto.IotSubDeviceRegisterFullReqDTO;
... ... @@ -26,6 +27,7 @@ import org.springframework.http.ResponseEntity;
26 27 import org.springframework.stereotype.Service;
27 28 import org.springframework.web.client.RestTemplate;
28 29  
  30 +import java.time.Duration;
29 31 import java.util.List;
30 32  
31 33 import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR;
... ... @@ -39,10 +41,23 @@ import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeC
39 41 @Slf4j
40 42 public class IotDeviceApiImpl implements IotDeviceCommonApi {
41 43  
  44 + /**
  45 + * 存活上报的连接超时:不复用 RPC 配置的超时,避免一次上报等待过久,耽误后续的续期
  46 + */
  47 + private static final Duration KEEP_ALIVE_CONNECT_TIMEOUT = Duration.ofSeconds(2);
  48 + /**
  49 + * 存活上报的读取超时
  50 + */
  51 + private static final Duration KEEP_ALIVE_READ_TIMEOUT = Duration.ofSeconds(5);
  52 +
42 53 @Resource
43 54 private IotGatewayProperties gatewayProperties;
44 55  
45 56 private RestTemplate restTemplate;
  57 + /**
  58 + * 存活上报专用的 RestTemplate:超时更短;超时只是网关不再等待,不会取消 biz 已经接收的请求
  59 + */
  60 + private RestTemplate keepAliveRestTemplate;
46 61  
47 62 @PostConstruct
48 63 public void init() {
... ... @@ -52,6 +67,11 @@ public class IotDeviceApiImpl implements IotDeviceCommonApi {
52 67 .readTimeout(rpc.getReadTimeout())
53 68 .connectTimeout(rpc.getConnectTimeout())
54 69 .build();
  70 + keepAliveRestTemplate = new RestTemplateBuilder()
  71 + .rootUri(rpc.getUrl())
  72 + .readTimeout(KEEP_ALIVE_READ_TIMEOUT)
  73 + .connectTimeout(KEEP_ALIVE_CONNECT_TIMEOUT)
  74 + .build();
55 75 }
56 76  
57 77 @Override
... ... @@ -79,12 +99,23 @@ public class IotDeviceApiImpl implements IotDeviceCommonApi {
79 99 return doPost("/rpc-api/iot/device/register-sub", reqDTO, new ParameterizedTypeReference<>() { });
80 100 }
81 101  
  102 + @Override
  103 + public CommonResult<Boolean> keepAliveGatewayServers(IotGatewayServerAliveReqDTO reqDTO) {
  104 + return doPost(keepAliveRestTemplate, "/rpc-api/iot/gateway-server/keep-alive", reqDTO,
  105 + new ParameterizedTypeReference<>() { });
  106 + }
  107 +
82 108 private <T, R> CommonResult<R> doPost(String url, T body,
83 109 ParameterizedTypeReference<CommonResult<R>> responseType) {
  110 + return doPost(restTemplate, url, body, responseType);
  111 + }
  112 +
  113 + private <T, R> CommonResult<R> doPost(RestTemplate template, String url, T body,
  114 + ParameterizedTypeReference<CommonResult<R>> responseType) {
84 115 try {
85 116 // 请求
86 117 HttpEntity<T> requestEntity = new HttpEntity<>(body);
87   - ResponseEntity<CommonResult<R>> response = restTemplate.exchange(
  118 + ResponseEntity<CommonResult<R>> response = template.exchange(
88 119 url, HttpMethod.POST, requestEntity, responseType);
89 120 // 响应
90 121 CommonResult<R> result = response.getBody();
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocolManagerTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.protocol;
  2 +
  3 +import cn.iocoder.yudao.framework.common.pojo.CommonResult;
  4 +import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
  5 +import cn.iocoder.yudao.module.iot.core.biz.dto.IotGatewayServerAliveReqDTO;
  6 +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
  7 +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties;
  8 +import org.junit.jupiter.api.AfterEach;
  9 +import org.junit.jupiter.api.BeforeEach;
  10 +import org.junit.jupiter.api.Test;
  11 +import org.mockito.ArgumentCaptor;
  12 +
  13 +import java.util.Arrays;
  14 +import java.util.Collections;
  15 +import java.util.HashMap;
  16 +import java.util.HashSet;
  17 +import java.util.List;
  18 +import java.util.Map;
  19 +
  20 +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR;
  21 +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
  22 +import static org.junit.jupiter.api.Assertions.*;
  23 +import static org.mockito.ArgumentMatchers.any;
  24 +import static org.mockito.Mockito.*;
  25 +
  26 +/**
  27 + * {@link IotProtocolManager} 的单元测试:协议实例的存活上报
  28 + *
  29 + * @author 芋道源码
  30 + */
  31 +public class IotProtocolManagerTest {
  32 +
  33 + private static final String THREAD_NAME_PREFIX = "iot-gateway-alive-";
  34 +
  35 + private IotDeviceCommonApi deviceApi;
  36 +
  37 + private IotProtocol mqttProtocol;
  38 + private IotProtocol tcpProtocol;
  39 + private IotProtocol emqxProtocol;
  40 + private IotProtocolManager protocolManager;
  41 +
  42 + @BeforeEach
  43 + public void setUp() {
  44 + deviceApi = mock(IotDeviceCommonApi.class);
  45 + when(deviceApi.keepAliveGatewayServers(any())).thenReturn(success(true));
  46 + // 协议实例:MQTT、TCP 运行中;EMQX 不上报存活标记
  47 + mqttProtocol = mockProtocol("mqtt-1", "server-mqtt", IotProtocolTypeEnum.MQTT);
  48 + tcpProtocol = mockProtocol("tcp-1", "server-tcp", IotProtocolTypeEnum.TCP);
  49 + emqxProtocol = mockProtocol("emqx-1", "server-emqx", IotProtocolTypeEnum.EMQX);
  50 + Map<String, IotProtocol> protocols = new HashMap<>();
  51 + protocols.put("mqtt-1", mqttProtocol);
  52 + protocols.put("tcp-1", tcpProtocol);
  53 + protocols.put("emqx-1", emqxProtocol);
  54 + IotGatewayProperties gatewayProperties = new IotGatewayProperties();
  55 + gatewayProperties.setProtocols(Arrays.asList(buildConfig("mqtt-1"), buildConfig("tcp-1"), buildConfig("emqx-1")));
  56 + protocolManager = new IotProtocolManager(gatewayProperties, deviceApi) {
  57 +
  58 + @Override
  59 + IotProtocol createProtocol(IotGatewayProperties.ProtocolProperties config) {
  60 + return protocols.get(config.getId());
  61 + }
  62 +
  63 + };
  64 + }
  65 +
  66 + @AfterEach
  67 + public void tearDown() {
  68 + protocolManager.stop();
  69 + }
  70 +
  71 + @Test
  72 + public void testStart_writeAlive() {
  73 + // 调用
  74 + long before = System.currentTimeMillis();
  75 + protocolManager.start();
  76 + long after = System.currentTimeMillis();
  77 +
  78 + // 断言:启动后立即上报一次,一次批量包含全部运行中的非 EMQX 协议实例
  79 + Map<String, Long> startTimes = awaitFirstWriteAlive().getStartTimes();
  80 + assertEquals(new HashSet<>(Arrays.asList("server-mqtt", "server-tcp")), startTimes.keySet());
  81 + // 断言:值为协议实例的启动时间
  82 + assertTrue(startTimes.get("server-mqtt") >= before && startTimes.get("server-mqtt") <= after);
  83 + assertTrue(startTimes.get("server-tcp") >= before && startTimes.get("server-tcp") <= after);
  84 + }
  85 +
  86 + @Test
  87 + public void testWriteAlive_startTimeNotChange() {
  88 + // 准备参数
  89 + protocolManager.start();
  90 + Map<String, Long> firstStartTimes = awaitFirstWriteAlive().getStartTimes();
  91 +
  92 + // 调用:续期两轮
  93 + protocolManager.writeAlive();
  94 + protocolManager.writeAlive();
  95 + // 断言:每轮只调用一次,启动时间不随续期变化
  96 + ArgumentCaptor<IotGatewayServerAliveReqDTO> reqCaptor = ArgumentCaptor.forClass(IotGatewayServerAliveReqDTO.class);
  97 + verify(deviceApi, times(2)).keepAliveGatewayServers(reqCaptor.capture());
  98 + List<IotGatewayServerAliveReqDTO> reqDTOs = reqCaptor.getAllValues();
  99 + assertEquals(firstStartTimes, reqDTOs.get(0).getStartTimes());
  100 + assertEquals(firstStartTimes, reqDTOs.get(1).getStartTimes());
  101 + }
  102 +
  103 + @Test
  104 + public void testWriteAlive_skipNotRunning() {
  105 + // mock 方法:TCP 已停止
  106 + when(tcpProtocol.isRunning()).thenReturn(false);
  107 +
  108 + // 调用
  109 + protocolManager.start();
  110 + // 断言:只上报运行中的协议实例
  111 + assertEquals(Collections.singleton("server-mqtt"), awaitFirstWriteAlive().getStartTimes().keySet());
  112 + }
  113 +
  114 + @Test
  115 + public void testWriteAlive_empty() {
  116 + // mock 方法:MQTT、TCP 都已停止,只剩下 EMQX
  117 + when(mqttProtocol.isRunning()).thenReturn(false);
  118 + when(tcpProtocol.isRunning()).thenReturn(false);
  119 + protocolManager.start();
  120 +
  121 + // 调用
  122 + protocolManager.writeAlive();
  123 + // 断言:没有需要上报的协议实例,不调用
  124 + verify(deviceApi, never()).keepAliveGatewayServers(any());
  125 + }
  126 +
  127 + @Test
  128 + public void testWriteAlive_failNotThrow() {
  129 + // 准备参数
  130 + protocolManager.start();
  131 + awaitFirstWriteAlive();
  132 + // mock 方法:依次返回 error、部分失败、直接抛出异常(同 JVM 直接调用时),最后恢复成功
  133 + when(deviceApi.keepAliveGatewayServers(any())).thenReturn(CommonResult.error(INTERNAL_SERVER_ERROR))
  134 + .thenReturn(success(false)).thenThrow(new IllegalStateException("biz 不可用")).thenReturn(success(true));
  135 +
  136 + // 调用:异常不抛出(否则周期任务会永久停止),每轮都照常上报
  137 + for (int i = 0; i < 4; i++) {
  138 + assertDoesNotThrow(() -> protocolManager.writeAlive());
  139 + }
  140 + // 断言
  141 + verify(deviceApi, times(4)).keepAliveGatewayServers(any());
  142 + }
  143 +
  144 + @Test
  145 + public void testStopAndRestart() throws Exception {
  146 + // 调用:启动
  147 + protocolManager.start();
  148 + long firstStartTime = awaitFirstWriteAlive().getStartTimes().get("server-mqtt");
  149 +
  150 + // 调用:停止,重复停止
  151 + protocolManager.stop();
  152 + protocolManager.stop();
  153 + // 断言:协议只停止一次,续期线程已退出
  154 + verify(mqttProtocol).stop();
  155 + assertFalse(protocolManager.isRunning());
  156 + assertNoAliveThread();
  157 + // 断言:停止后不再上报
  158 + protocolManager.writeAlive();
  159 + verify(deviceApi, never()).keepAliveGatewayServers(any());
  160 +
  161 + // 调用:同 serverId 重新启动
  162 + Thread.sleep(5);
  163 + protocolManager.start();
  164 + // 断言:启动时间已更新
  165 + assertTrue(awaitFirstWriteAlive().getStartTimes().get("server-mqtt") > firstStartTime);
  166 + verify(mqttProtocol, times(2)).start();
  167 + }
  168 +
  169 + // ========== 辅助方法 ==========
  170 +
  171 + /**
  172 + * 等待启动后立即执行的那一轮上报,并清空调用记录;下一轮在 20 秒后,不会干扰后续断言
  173 + */
  174 + private IotGatewayServerAliveReqDTO awaitFirstWriteAlive() {
  175 + ArgumentCaptor<IotGatewayServerAliveReqDTO> reqCaptor = ArgumentCaptor.forClass(IotGatewayServerAliveReqDTO.class);
  176 + verify(deviceApi, timeout(2000)).keepAliveGatewayServers(reqCaptor.capture());
  177 + clearInvocations(deviceApi);
  178 + return reqCaptor.getValue();
  179 + }
  180 +
  181 + private static void assertNoAliveThread() throws InterruptedException {
  182 + // 线程池终止后,工作线程的退出稍有延迟
  183 + for (int i = 0; i < 100; i++) {
  184 + boolean alive = Thread.getAllStackTraces().keySet().stream()
  185 + .anyMatch(thread -> thread.getName().startsWith(THREAD_NAME_PREFIX) && thread.isAlive());
  186 + if (!alive) {
  187 + return;
  188 + }
  189 + Thread.sleep(10);
  190 + }
  191 + fail("续期线程没有退出");
  192 + }
  193 +
  194 + private static IotProtocol mockProtocol(String id, String serverId, IotProtocolTypeEnum type) {
  195 + IotProtocol protocol = mock(IotProtocol.class);
  196 + when(protocol.getId()).thenReturn(id);
  197 + when(protocol.getServerId()).thenReturn(serverId);
  198 + when(protocol.getType()).thenReturn(type);
  199 + when(protocol.isRunning()).thenReturn(true);
  200 + return protocol;
  201 + }
  202 +
  203 + private static IotGatewayProperties.ProtocolProperties buildConfig(String id) {
  204 + IotGatewayProperties.ProtocolProperties config = new IotGatewayProperties.ProtocolProperties();
  205 + config.setId(id);
  206 + return config;
  207 + }
  208 +
  209 +}
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/upstream/IotEmqxAuthEventHandlerTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.upstream;
  2 +
  3 +import cn.hutool.extra.spring.SpringUtil;
  4 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  5 +import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
  6 +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
  7 +import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum;
  8 +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
  9 +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
  10 +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
  11 +import io.vertx.core.json.JsonObject;
  12 +import io.vertx.ext.web.RoutingContext;
  13 +import org.junit.jupiter.api.AfterEach;
  14 +import org.junit.jupiter.api.BeforeEach;
  15 +import org.junit.jupiter.api.Test;
  16 +import org.mockito.Mock;
  17 +import org.mockito.MockedStatic;
  18 +
  19 +import static org.mockito.ArgumentMatchers.any;
  20 +import static org.mockito.ArgumentMatchers.argThat;
  21 +import static org.mockito.ArgumentMatchers.eq;
  22 +import static org.mockito.Mockito.*;
  23 +
  24 +/**
  25 + * {@link IotEmqxAuthEventHandler} 的单元测试
  26 + *
  27 + * @author 芋道源码
  28 + */
  29 +public class IotEmqxAuthEventHandlerTest extends BaseMockitoUnitTest {
  30 +
  31 + private static final String SERVER_ID = "emqx-server";
  32 +
  33 + @Mock
  34 + private IotDeviceMessageService deviceMessageService;
  35 + @Mock
  36 + private IotDeviceCommonApi deviceApi;
  37 +
  38 + private MockedStatic<SpringUtil> springUtilMockedStatic;
  39 + private IotEmqxAuthEventHandler handler;
  40 +
  41 + @BeforeEach
  42 + public void setUp() {
  43 + springUtilMockedStatic = mockStatic(SpringUtil.class);
  44 + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotDeviceMessageService.class)).thenReturn(deviceMessageService);
  45 + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotDeviceCommonApi.class)).thenReturn(deviceApi);
  46 + handler = new IotEmqxAuthEventHandler(SERVER_ID, null);
  47 + }
  48 +
  49 + @AfterEach
  50 + public void tearDown() {
  51 + springUtilMockedStatic.close();
  52 + }
  53 +
  54 + @Test
  55 + public void testHandleEvent_disconnectedTakenover() {
  56 + // 调用:同一 ClientID 的新连接接管、丢弃旧会话
  57 + handler.handleEvent(mockDisconnectedEvent("takenover"));
  58 + handler.handleEvent(mockDisconnectedEvent("discarded"));
  59 + // 断言:旧会话的断开被忽略,不发离线
  60 + verify(deviceMessageService, never()).sendDeviceMessage(any(), any(), any(), any());
  61 + }
  62 +
  63 + @Test
  64 + public void testHandleEvent_disconnectedNormal() {
  65 + // 调用:正常断开、心跳超时
  66 + handler.handleEvent(mockDisconnectedEvent("normal"));
  67 + handler.handleEvent(mockDisconnectedEvent("keepalive_timeout"));
  68 + // 断言:发送离线
  69 + verify(deviceMessageService, times(2)).sendDeviceMessage(argThat((IotDeviceMessage m) ->
  70 + IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod().equals(m.getMethod())
  71 + && String.valueOf(IotDeviceStateEnum.OFFLINE.getState()).equals(IotDeviceMessageUtils.getIdentifier(m))),
  72 + eq("pk"), eq("dn"), eq(SERVER_ID));
  73 + }
  74 +
  75 + private static RoutingContext mockDisconnectedEvent(String reason) {
  76 + RoutingContext context = mock(RoutingContext.class, RETURNS_DEEP_STUBS);
  77 + JsonObject body = new JsonObject().put("event", "client.disconnected")
  78 + .put("username", "dn&pk").put("reason", reason);
  79 + when(context.body().asJsonObject()).thenReturn(body);
  80 + return context;
  81 + }
  82 +
  83 +}
... ...