diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/JdbcUtils.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/JdbcUtils.java index 39dbe0e..92efdb1 100644 --- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/JdbcUtils.java +++ b/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; import com.baomidou.dynamic.datasource.DynamicRoutingDataSource; import com.baomidou.mybatisplus.annotation.DbType; import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.dao.DataAccessException; import org.springframework.jdbc.datasource.ConnectionHolder; import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.util.ConcurrentReferenceHashMap; @@ -209,6 +210,18 @@ public class JdbcUtils { return ObjectUtils.equalsAny(dbType, DbType.SQL_SERVER, DbType.SQL_SERVER2005); } - + /** + * 存在实际事务时,重新抛出数据访问异常,交给事务回滚 + * + * 数据访问异常指 Spring 的 {@link DataAccessException},包括 JDBC、Redis 等数据访问的失败。 + * 不在实际事务中,或者不是数据访问异常时,不做处理,由调用方按原逻辑处理(例如说记录日志) + * + * @param ex 异常 + */ + public static void throwIfDataAccessExceptionInTransaction(Throwable ex) { + if (ex instanceof DataAccessException && TransactionSynchronizationManager.isActualTransactionActive()) { + throw (DataAccessException) ex; + } + } } diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/JdbcUtilsTest.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/JdbcUtilsTest.java index 8f25f55..7c5abb2 100644 --- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/JdbcUtilsTest.java +++ b/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; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.dao.QueryTimeoutException; import org.springframework.jdbc.datasource.ConnectionHolder; import org.springframework.transaction.support.TransactionSynchronizationManager; @@ -167,6 +168,26 @@ public class JdbcUtilsTest { assertEquals(DbType.MYSQL, JdbcUtils.getDbType(broken.getDataSource())); } + @Test + public void testThrowIfDataAccessExceptionInTransaction() { + // 准备参数 + QueryTimeoutException dataAccessException = new QueryTimeoutException("redis read timeout"); + IllegalStateException otherException = new IllegalStateException("other failed"); + + // 调用:非事务内,不抛出 + assertDoesNotThrow(() -> JdbcUtils.throwIfDataAccessExceptionInTransaction(dataAccessException)); + // 调用:事务内,只抛出数据访问异常 + TransactionSynchronizationManager.setActualTransactionActive(true); + try { + assertSame(dataAccessException, assertThrows(QueryTimeoutException.class, + () -> JdbcUtils.throwIfDataAccessExceptionInTransaction(dataAccessException))); + assertDoesNotThrow(() -> JdbcUtils.throwIfDataAccessExceptionInTransaction(otherException)); + assertDoesNotThrow(() -> JdbcUtils.throwIfDataAccessExceptionInTransaction(null)); + } finally { + TransactionSynchronizationManager.setActualTransactionActive(false); + } + } + private static void mockBeanFactory(String beanName, Object bean) { DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); beanFactory.registerSingleton(beanName, bean); diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImpl.java index a5d4c13..cad6092 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImpl.java +++ b/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; import cn.iocoder.yudao.module.iot.service.device.IotDeviceModbusConfigService; import cn.iocoder.yudao.module.iot.service.device.IotDeviceModbusPointService; import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; import cn.iocoder.yudao.module.iot.service.product.IotProductService; import jakarta.annotation.Resource; import jakarta.annotation.security.PermitAll; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Primary; import org.springframework.validation.annotation.Validated; @@ -45,6 +47,7 @@ import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils. */ @RestController @Validated +@Slf4j @Primary // 保证优先匹配,因为 yudao-iot-gateway 也有 IotDeviceCommonApi 的实现,并且也可能会被 biz 引入 public class IoTDeviceApiImpl implements IotDeviceCommonApi { @@ -53,6 +56,8 @@ public class IoTDeviceApiImpl implements IotDeviceCommonApi { @Resource private IotProductService productService; @Resource + private IotDevicePropertyService devicePropertyService; + @Resource @Lazy // 延迟加载,解决循环依赖 private IotDeviceModbusConfigService modbusConfigService; @Resource @@ -144,4 +149,23 @@ public class IoTDeviceApiImpl implements IotDeviceCommonApi { return success(deviceService.registerSubDevices(reqDTO)); } + @Override + @PostMapping(RpcConstants.RPC_API_PREFIX + "/iot/gateway-server/keep-alive") + @PermitAll // 无鉴权:前提是只有受信的网关能访问 RPC 接口 + @TenantIgnore // 网关级的全局数据,不按租户隔离;如果以后在这里访问 DB,需要重新评估租户约束 + public CommonResult keepAliveGatewayServers(@RequestBody IotGatewayServerAliveReqDTO reqDTO) { + // 参数校验失败时,不会执行到这里,所以非法的批次不会写入任何一项 + int failCount = 0; + for (Map.Entry entry : reqDTO.getStartTimes().entrySet()) { + // 单项失败不影响其它项:失败明细只记录日志,整体结果返回 false + try { + devicePropertyService.updateGatewayServerStartTime(entry.getKey(), entry.getValue()); + } catch (Exception e) { + failCount++; + log.error("[keepAliveGatewayServers][serverId({}) startTime({}) 续期存活标记失败]", entry.getKey(), entry.getValue(), e); + } + } + return success(failCount == 0); + } + } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/device/IotDeviceMapper.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/device/IotDeviceMapper.java index 84c2d99..c0b608b 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/device/IotDeviceMapper.java +++ b/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; import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils; import cn.iocoder.yudao.module.iot.controller.admin.device.vo.device.IotDevicePageReqVO; +import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; import cn.iocoder.yudao.module.iot.enums.product.IotProductDeviceTypeEnum; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; @@ -132,6 +133,74 @@ public interface IotDeviceMapper extends BaseMapperX { .isNotNull(IotDeviceDO::getLongitude)); } + // ========== 设备状态相关 ========== + // 说明:都是单条条件 UPDATE,依靠语句的原子性判断“是否生效”,不加锁;传入空实体,是为了让 update_time 自动填充 + + /** + * 更新设备为在线:设备不在线,且事件时间不早于最后离线时间时生效。首次上线时,同一条语句写入激活时间 + * + * @param id 设备编号 + * @param time 事件时间 + * @return 是否生效(1 生效,0 未生效) + */ + default int updateStateOnline(Long id, LocalDateTime time) { + return update(new IotDeviceDO(), new LambdaUpdateWrapper() + .set(IotDeviceDO::getState, IotDeviceStateEnum.ONLINE.getState()) + .set(IotDeviceDO::getOnlineTime, time) + .setSql("active_time = COALESCE(active_time, {0})", time) + .eq(IotDeviceDO::getId, id) + .and(w -> w.isNull(IotDeviceDO::getState).or().ne(IotDeviceDO::getState, IotDeviceStateEnum.ONLINE.getState())) + .and(w -> w.isNull(IotDeviceDO::getOfflineTime).or().le(IotDeviceDO::getOfflineTime, time))); + } + + /** + * 刷新在线设备的最后上线时间:只推进到更晚的事件时间,不改变状态 + * + * @param id 设备编号 + * @param time 事件时间 + * @return 是否生效(1 生效,0 未生效) + */ + default int updateOnlineTime(Long id, LocalDateTime time) { + return update(new IotDeviceDO(), new LambdaUpdateWrapper() + .set(IotDeviceDO::getOnlineTime, time) + .eq(IotDeviceDO::getId, id) + .eq(IotDeviceDO::getState, IotDeviceStateEnum.ONLINE.getState()) + .and(w -> w.isNull(IotDeviceDO::getOnlineTime).or().lt(IotDeviceDO::getOnlineTime, time))); + } + + /** + * 更新设备为离线:设备在线,且事件时间不早于最后上线时间时生效 + * + * @param id 设备编号 + * @param time 事件时间 + * @return 是否生效(1 生效,0 未生效) + */ + default int updateStateOffline(Long id, LocalDateTime time) { + return update(new IotDeviceDO(), new LambdaUpdateWrapper() + .set(IotDeviceDO::getState, IotDeviceStateEnum.OFFLINE.getState()) + .set(IotDeviceDO::getOfflineTime, time) + .eq(IotDeviceDO::getId, id) + .eq(IotDeviceDO::getState, IotDeviceStateEnum.ONLINE.getState()) + .and(w -> w.isNull(IotDeviceDO::getOnlineTime).or().le(IotDeviceDO::getOnlineTime, time))); + } + + /** + * 推进不在线设备的最后离线时间(离线水位):只推进到更晚的事件时间,不改变状态 + * + * 作用:挡住回滚重投造成的误判在线。例如:上线 t1 回滚 → 离线 t2 → 上线 t1 重投,因 t1 早于 t2 被拒绝 + * + * @param id 设备编号 + * @param time 事件时间 + * @return 是否生效(1 生效,0 未生效) + */ + default int updateOfflineTime(Long id, LocalDateTime time) { + return update(new IotDeviceDO(), new LambdaUpdateWrapper() + .set(IotDeviceDO::getOfflineTime, time) + .eq(IotDeviceDO::getId, id) + .and(w -> w.isNull(IotDeviceDO::getState).or().ne(IotDeviceDO::getState, IotDeviceStateEnum.ONLINE.getState())) + .and(w -> w.isNull(IotDeviceDO::getOfflineTime).or().lt(IotDeviceDO::getOfflineTime, time))); + } + // ========== 网关-子设备绑定相关 ========== /** diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/redis/device/DeviceReportTimeRedisDAO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/redis/device/DeviceReportTimeRedisDAO.java index 0b28855..4a82d94 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/redis/device/DeviceReportTimeRedisDAO.java +++ b/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; import cn.iocoder.yudao.module.iot.dal.redis.RedisKeyConstants; import jakarta.annotation.Resource; import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; import org.springframework.stereotype.Repository; import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.Set; -import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; - /** * 设备的最后上报时间的 Redis DAO * @@ -22,15 +23,37 @@ public class DeviceReportTimeRedisDAO { @Resource private StringRedisTemplate stringRedisTemplate; + /** + * 更新设备的最后上报时间 + * + * @param deviceId 设备编号 + * @param reportTime 最后上报时间 + */ public void update(Long deviceId, LocalDateTime reportTime) { stringRedisTemplate.opsForZSet().add(RedisKeyConstants.DEVICE_REPORT_TIMES, String.valueOf(deviceId), LocalDateTimeUtil.toEpochMilli(reportTime)); } - public Set range(LocalDateTime maxReportTime) { - Set values = stringRedisTemplate.opsForZSet().rangeByScore(RedisKeyConstants.DEVICE_REPORT_TIMES, - 0, LocalDateTimeUtil.toEpochMilli(maxReportTime)); - return convertSet(values, Long::parseLong); + /** + * 获得最后上报时间不晚于指定时间的设备 + * + * @param maxReportTime 最大的最后上报时间 + * @return 设备编号与最后上报时间的映射 + */ + public Map rangeWithScores(LocalDateTime maxReportTime) { + Set> tuples = stringRedisTemplate.opsForZSet().rangeByScoreWithScores( + RedisKeyConstants.DEVICE_REPORT_TIMES, 0, LocalDateTimeUtil.toEpochMilli(maxReportTime)); + Map result = new LinkedHashMap<>(); + if (tuples == null) { + return result; + } + for (ZSetOperations.TypedTuple tuple : tuples) { + if (tuple.getValue() == null || tuple.getScore() == null) { + continue; + } + result.put(Long.parseLong(tuple.getValue()), LocalDateTimeUtil.of(tuple.getScore().longValue())); + } + return result; } } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/redis/device/DeviceServerIdRedisDAO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/redis/device/DeviceServerIdRedisDAO.java index cef78f3..5feca86 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/redis/device/DeviceServerIdRedisDAO.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/redis/device/DeviceServerIdRedisDAO.java @@ -1,10 +1,13 @@ package cn.iocoder.yudao.module.iot.dal.redis.device; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; import cn.iocoder.yudao.module.iot.dal.redis.RedisKeyConstants; import jakarta.annotation.Resource; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Repository; +import java.time.Duration; + /** * 设备关联的网关 serverId 的 Redis DAO * @@ -13,6 +16,11 @@ import org.springframework.stereotype.Repository; @Repository public class DeviceServerIdRedisDAO { + /** + * 网关协议实例存活标记的过期时间:网关每 20 秒上报一次,可以容忍几轮上报失败、短时的 biz 重启 + */ + private static final Duration GATEWAY_SERVER_ALIVE_TIMEOUT = Duration.ofSeconds(120); + @Resource private StringRedisTemplate stringRedisTemplate; @@ -27,4 +35,30 @@ public class DeviceServerIdRedisDAO { return value != null ? (String) value : null; } + /** + * 获得网关协议实例的启动时间 + * + * 只供离线检查任务使用:设备上线早于启动时间,说明网关重启过、设备未重连;不参与普通上下线的状态事务 + * + * @param serverId 网关 serverId + * @return 启动时间(毫秒);标记不存在(网关已停止或宕机)时返回 null + */ + public Long getGatewayServerStartTime(String serverId) { + String value = stringRedisTemplate.opsForValue().get(IotDeviceMessageUtils.buildGatewayServerAliveKey(serverId)); + return value != null ? Long.valueOf(value) : null; + } + + /** + * 更新网关协议实例的启动时间,同时续期存活标记 + * + * 直接覆盖,不比较新旧值:时钟回拨时,比较写入反而会拒绝新的启动时间 + * + * @param serverId 网关 serverId + * @param startTime 网关上报的启动时间(毫秒),不是 biz 的接收时间 + */ + public void updateGatewayServerStartTime(String serverId, Long startTime) { + stringRedisTemplate.opsForValue().set(IotDeviceMessageUtils.buildGatewayServerAliveKey(serverId), + String.valueOf(startTime), GATEWAY_SERVER_ALIVE_TIMEOUT); + } + } \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/job/device/IotDeviceOfflineCheckJob.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/job/device/IotDeviceOfflineCheckJob.java index 789b2f2..5740dd7 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/job/device/IotDeviceOfflineCheckJob.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/job/device/IotDeviceOfflineCheckJob.java @@ -1,34 +1,45 @@ package cn.iocoder.yudao.module.iot.job.device; import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.date.LocalDateTimeUtil; import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import cn.iocoder.yudao.framework.quartz.core.handler.JobHandler; import cn.iocoder.yudao.framework.tenant.core.job.TenantJob; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; +import cn.iocoder.yudao.module.iot.enums.product.IotProductDeviceTypeEnum; import cn.iocoder.yudao.module.iot.framework.iot.config.YudaoIotProperties; import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService; import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; +import cn.iocoder.yudao.module.iot.service.product.IotProductService; import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import java.time.Duration; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import java.util.Collections; +import java.util.HashMap; import java.util.List; -import java.util.Set; +import java.util.Map; /** * IoT 设备离线检查 Job * * 检测逻辑:设备最后一条 {@link IotDeviceMessage} 消息超过一定时间,则认为设备离线 + * 1. 活跃型设备(HTTP、CoAP、UDP 直连设备、网关子设备):超时即离线 + * 2. 连接型设备(MQTT、TCP 等长连接):在线状态由连接决定,超时后还要所在网关的协议实例已停止或重启过,才离线 * * @see 阿里云 IoT —— 设备离线分析 * @author 芋道源码 */ @Component +@Slf4j public class IotDeviceOfflineCheckJob implements JobHandler { @Resource @@ -40,6 +51,8 @@ public class IotDeviceOfflineCheckJob implements JobHandler { private IotDevicePropertyService devicePropertyService; @Resource private IotDeviceMessageService deviceMessageService; + @Resource + private IotProductService productService; @Override @TenantJob @@ -50,24 +63,85 @@ public class IotDeviceOfflineCheckJob implements JobHandler { return JsonUtils.toJsonString(Collections.emptyList()); } // 1.2 获取超时的设备集合 - Set timeoutDeviceIds = devicePropertyService.getDeviceIdListByReportTime(getTimeoutTime()); + // DONE @AI:一次性,就不要抽方法了。(已内联到这里,去掉 getTimeout 方法) + Duration timeout = Duration.ofNanos((long) (iotProperties.getKeepAliveTime().toNanos() * iotProperties.getKeepAliveFactor())); + Map timeoutDevices = devicePropertyService.getDeviceReportTimeMapByReportTime( + LocalDateTime.now().minus(timeout)); // 2. 下线设备 List offlineDevices = CollUtil.newArrayList(); + Map productCache = new HashMap<>(); // 本轮内缓存,避免重复读取 + Map startTimeCache = new HashMap<>(); for (IotDeviceDO device : devices) { - if (!timeoutDeviceIds.contains(device.getId())) { + LocalDateTime reportTime = timeoutDevices.get(device.getId()); + if (reportTime == null) { + continue; + } + // 连接型设备:连接还在(所在网关的协议实例没有停止或重启过),则不离线 + if (isConnectionBased(device, productCache) && !isConnectionStale(device, productCache, startTimeCache)) { continue; } offlineDevices.add(new String[]{device.getProductKey(), device.getDeviceName()}); // 为什么不直接更新状态呢?因为通过 IotDeviceMessage 可以经过一系列的处理,例如说记录日志等等 - deviceMessageService.sendDeviceMessage(IotDeviceMessage.buildStateOffline().setDeviceId(device.getId())); + // 事件时间 = 最后活跃时间 + 超时时间:期间如果设备重新上线,上线时间更晚,这条离线不会生效 + deviceMessageService.sendDeviceMessage(IotDeviceMessage.buildStateOffline(), device, null, reportTime.plus(timeout)); } return JsonUtils.toJsonString(offlineDevices); } - private LocalDateTime getTimeoutTime() { - return LocalDateTime.now().minus(Duration.ofNanos( - (long) (iotProperties.getKeepAliveTime().toNanos() * iotProperties.getKeepAliveFactor()))); + /** + * 是否为连接型设备 + * + * 网关子设备、产品不存在、协议未知时,按活跃型处理 + */ + private boolean isConnectionBased(IotDeviceDO device, Map productCache) { + if (IotProductDeviceTypeEnum.isGatewaySub(device.getDeviceType())) { + return false; + } + IotProtocolTypeEnum protocolType = getProtocolType(device, productCache); + return protocolType != null && protocolType.isConnectionBased(); + } + + /** + * 连接型设备的连接是否已失效:所在网关的协议实例已停止(存活标记不存在),或者在设备上线后重启过 + */ + private boolean isConnectionStale(IotDeviceDO device, Map productCache, + Map startTimeCache) { + // 1. EMQX 的连接由 EMQX 维护,不使用网关存活标记 + if (getProtocolType(device, productCache) == IotProtocolTypeEnum.EMQX) { + return false; + } + // DONE @AI:方法内注释;(已按 1、2、3 步骤补充) + // 2. 获得设备所在网关协议实例的启动时间:同一轮内,相同 serverId 只读取一次 + Long startTime; + try { + String serverId = devicePropertyService.getDeviceServerId(device.getId()); + if (serverId == null) { // 没有记录所在的网关,无法确认连接还在 + return true; + } + if (startTimeCache.containsKey(serverId)) { + startTime = startTimeCache.get(serverId); + } else { + startTime = devicePropertyService.getGatewayServerStartTime(serverId); + startTimeCache.put(serverId, startTime); + } + } catch (Exception e) { + // Redis 读取失败,不等于存活标记不存在:本轮跳过,不判离线 + log.warn("[isConnectionStale][设备({}) 读取网关存活标记失败,本轮跳过]", device.getId(), e); + return false; + } + // 3. 存活标记不存在(网关已停止或宕机),或者设备上线早于启动时间(网关重启过、设备未重连),则连接已失效 + if (startTime == null || device.getOnlineTime() == null) { + return true; + } + // 按秒比较:上线时间是秒级精度(见 IotDeviceServiceImpl#updateDeviceState),同一秒内上线的设备,视为启动后上线,不判离线 + return device.getOnlineTime().truncatedTo(ChronoUnit.SECONDS) + .isBefore(LocalDateTimeUtil.of(startTime).truncatedTo(ChronoUnit.SECONDS)); + } + + private IotProtocolTypeEnum getProtocolType(IotDeviceDO device, Map productCache) { + IotProductDO product = productCache.computeIfAbsent(device.getProductId(), productService::getProductFromCache); + return product != null ? IotProtocolTypeEnum.of(product.getProtocolType()) : null; } } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/device/IotDeviceMessageSubscriber.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/device/IotDeviceMessageSubscriber.java index 48a7da3..131406f 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/device/IotDeviceMessageSubscriber.java +++ b/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 dataRuleService.executeDataRule(message)); } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/rule/IotSceneRuleMessageSubscriber.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/rule/IotSceneRuleMessageSubscriber.java index de74beb..c91adc5 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/rule/IotSceneRuleMessageSubscriber.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/rule/IotSceneRuleMessageSubscriber.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.iot.mq.consumer.rule; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; @@ -41,6 +42,10 @@ public class IotSceneRuleMessageSubscriber implements IotMessageSubscriber 0) { + deleteDeviceCache(device); } + // 说明:网关下线联动子设备,不在这里执行;由状态消息的处理,在状态事务提交后调用 handleGatewayOffline + return changed > 0; } - /** - * 处理网关下线,联动所有子设备下线 - * - * @param gatewayDevice 网关设备 - */ - private void handleGatewayOffline(IotDeviceDO gatewayDevice) { + @Override + public void handleGatewayOffline(IotDeviceDO gatewayDevice, LocalDateTime offlineTime) { List subDevices = deviceMapper.selectListByGatewayId(gatewayDevice.getId()); if (CollUtil.isEmpty(subDevices)) { return; } for (IotDeviceDO subDevice : subDevices) { - if (Objects.equals(subDevice.getState(), IotDeviceStateEnum.ONLINE.getState())) { - try { - updateDeviceState(subDevice, IotDeviceStateEnum.OFFLINE.getState()); - log.info("[handleGatewayOffline][网关({}/{}) 下线,子设备({}/{}) 联动下线]", - gatewayDevice.getProductKey(), gatewayDevice.getDeviceName(), - subDevice.getProductKey(), subDevice.getDeviceName()); - } catch (Exception ex) { - log.error("[handleGatewayOffline][子设备({}/{}) 下线失败]", - subDevice.getProductKey(), subDevice.getDeviceName(), ex); - } + if (IotDeviceStateEnum.isNotOnline(subDevice.getState())) { + continue; + } + // 为什么不直接更新状态呢?因为通过 IotDeviceMessage 可以经过一系列的处理,例如说记录日志、规则引擎等等 + // 事件时间使用网关离线的时间:迟到的网关离线,不会把已经重连的子设备踢下线 + try { + deviceMessageService.sendDeviceMessage(IotDeviceMessage.buildStateOffline(), subDevice, null, offlineTime); + log.info("[handleGatewayOffline][网关({}/{}) 下线,子设备({}/{}) 联动下线]", + gatewayDevice.getProductKey(), gatewayDevice.getDeviceName(), + subDevice.getProductKey(), subDevice.getDeviceName()); + } catch (Exception ex) { + log.error("[handleGatewayOffline][子设备({}/{}) 下线失败]", + subDevice.getProductKey(), subDevice.getDeviceName(), ex); } } } @Override - public void updateDeviceState(Long id, Integer state) { - // 校验存在 - IotDeviceDO device = validateDeviceExists(id); - // 执行更新 - updateDeviceState(device, state); - } - - @Override public Long getDeviceCountByProductId(Long productId) { return deviceMapper.selectCountByProductId(productId); } @@ -759,9 +753,9 @@ public class IotDeviceServiceImpl implements IotDeviceService { // 3. 清空对应缓存 deleteDeviceCache(subDevice); - // 4. 子设备下线 - if (Objects.equals(subDevice.getState(), IotDeviceStateEnum.ONLINE.getState())) { - updateDeviceState(subDevice, IotDeviceStateEnum.OFFLINE.getState()); + // 4. 子设备下线:发送离线消息,经过日志、规则引擎等处理;事件时间为解绑时刻 + if (IotDeviceStateEnum.isOnline(subDevice.getState())) { + deviceMessageService.sendDeviceMessage(IotDeviceMessage.buildStateOffline(), subDevice, null, LocalDateTime.now()); } } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageService.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageService.java index da093e7..29e29e4 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageService.java +++ b/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 { IotDeviceMessage sendDeviceMessage(IotDeviceMessage message, IotDeviceDO device, String serverId); /** + * 发送设备消息(指定事件时间) + * + * 用于 biz 派生的消息沿用源事件的时间,例如说:强制上线、网关下线联动子设备、离线检测。仅供内部调用,不对外暴露 + * + * @param message 消息 + * @param device 设备 + * @param serverId 网关 serverId + * @param eventTime 事件时间,作为消息的 reportTime;为空时使用当前时间 + * @return 设备消息 + */ + IotDeviceMessage sendDeviceMessage(IotDeviceMessage message, IotDeviceDO device, String serverId, + @Nullable LocalDateTime eventTime); + + /** * 发送设备消息 * * @param message 消息(“codec(编解码)字段” 部分字段) diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImpl.java index fb217d5..a3ed77e 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImpl.java +++ b/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; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.collection.ListUtil; import cn.hutool.core.date.LocalDateTimeUtil; -import cn.hutool.core.lang.Assert; import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.NumberUtil; +import cn.hutool.core.util.ObjUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.extra.spring.SpringUtil; import cn.iocoder.yudao.framework.common.exception.ServiceException; @@ -17,6 +18,7 @@ import cn.iocoder.yudao.module.iot.controller.admin.statistics.vo.IotStatisticsD import cn.iocoder.yudao.module.iot.controller.admin.statistics.vo.IotStatisticsDeviceMessageSummaryByDateRespVO; import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; +import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.core.mq.producer.IotDeviceMessageProducer; import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; @@ -28,17 +30,23 @@ import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO; import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; import cn.iocoder.yudao.module.iot.dal.storage.IotDeviceMessageDAO; +import cn.iocoder.yudao.module.iot.enums.product.IotProductDeviceTypeEnum; import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; import cn.iocoder.yudao.module.iot.service.device.shadow.IotDeviceShadowService; import cn.iocoder.yudao.module.iot.service.ota.IotOtaTaskRecordService; import cn.iocoder.yudao.module.iot.service.product.IotProductService; +import cn.iocoder.yudao.module.iot.service.rule.data.IotDataRuleService; +import cn.iocoder.yudao.module.iot.service.rule.scene.IotSceneRuleService; import com.google.common.base.Objects; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Lazy; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.interceptor.TransactionAspectSupport; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.validation.annotation.Validated; import java.sql.Timestamp; @@ -47,6 +55,7 @@ import java.util.List; import java.util.Map; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DEVICE_DOWNSTREAM_FAILED_SERVER_ID_NULL; import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DEVICE_MESSAGE_RAW_METHOD_RESERVED; @@ -77,6 +86,12 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { @Resource @Lazy // 延迟加载,避免循环依赖 private IotProductService productService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private IotSceneRuleService sceneRuleService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private IotDataRuleService dataRuleService; @Resource private IotDeviceMessageDAO deviceMessageDAO; @@ -126,6 +141,12 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { @Override public IotDeviceMessage sendDeviceMessage(IotDeviceMessage message, IotDeviceDO device, String serverId) { + return sendDeviceMessage(message, device, serverId, null); + } + + @Override + public IotDeviceMessage sendDeviceMessage(IotDeviceMessage message, IotDeviceDO device, String serverId, + LocalDateTime eventTime) { // 0. 透传:raw.post 只能由网关创建;raw.push 校验后改写为 raw,网关从 raw 取字节;其它消息不能携带 raw byte[] rawPayload = validateRawMessage(message, device); message.setRaw(rawPayload != null ? IotDeviceMessageUtils.encodeRaw(rawPayload) : null); @@ -133,7 +154,7 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { message.setParams(null); } // 1. 补充信息 - appendDeviceMessage(message, device); + appendDeviceMessage(message, device, eventTime); // 2.1 情况一:发送上行消息 boolean upstream = IotDeviceMessageUtils.isUpstreamMessage(message); @@ -203,11 +224,13 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { /** * 补充消息的后端字段 * - * @param message 消息 - * @param device 设备信息 + * @param message 消息 + * @param device 设备信息 + * @param eventTime 事件时间;为空时使用当前时间 */ - private void appendDeviceMessage(IotDeviceMessage message, IotDeviceDO device) { - message.setId(IotDeviceMessageUtils.generateMessageId()).setReportTime(LocalDateTime.now()) + private void appendDeviceMessage(IotDeviceMessage message, IotDeviceDO device, LocalDateTime eventTime) { + message.setId(IotDeviceMessageUtils.generateMessageId()) + .setReportTime(eventTime != null ? eventTime : LocalDateTime.now()) .setDeviceId(device.getId()).setTenantId(device.getTenantId()); // 特殊:如果设备没有指定 requestId,则使用 messageId if (StrUtil.isEmpty(message.getRequestId())) { @@ -251,15 +274,10 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { } } - // TODO @芋艿:可优化:未来逻辑复杂后,可以独立拆除 Processor 处理器 private Object handleUpstreamDeviceMessage0(IotDeviceMessage message, IotDeviceDO device) { // 设备上下线 if (Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod())) { - String stateStr = IotDeviceMessageUtils.getIdentifier(message); - assert stateStr != null; - Assert.notEmpty(stateStr, "设备状态不能为空"); - Integer state = Integer.valueOf(stateStr); - deviceService.updateDeviceState(device, state); + processStateUpdateMessage(message, device); return null; } @@ -307,6 +325,117 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { return null; } + // ========== 设备状态处理方法 ========== + + /** + * 处理设备状态消息 + * + * 1. 校验状态值,忽略旧网关节点的离线 + * 2. 在状态事务中:条件更新状态;状态真的变化时,执行场景联动、数据流转 + * 3. 状态事务提交后:网关离线时,联动子设备离线 + * + * @param message 消息 + * @param device 设备 + */ + private void processStateUpdateMessage(IotDeviceMessage message, IotDeviceDO device) { + // 1.1 校验状态值:只接受在线、离线(设备自己上报的状态,同样在这里校验) + String stateStr = IotDeviceMessageUtils.getIdentifier(message); + Integer state = NumberUtil.isInteger(stateStr) ? Integer.valueOf(stateStr) : null; + if (ObjUtil.notEqual(state, IotDeviceStateEnum.ONLINE.getState()) + && ObjUtil.notEqual(state, IotDeviceStateEnum.OFFLINE.getState())) { + throw invalidParamException("设备状态({}) 只能是在线或离线", stateStr); + } + // 1.2 旧网关节点的离线,不覆盖新节点上的连接 + if (isStaleServerOffline(message, device, state)) { + return; + } + // 1.3 拒绝已有事务:REQUIRED 会加入外层事务,事务方法返回时并未提交,后续的子设备联动就失去了前提 + // 说明:Local 总线在发布线程上同步消费,所以发布方不能在事务内发布状态消息 + if (TransactionSynchronizationManager.isActualTransactionActive()) { + log.error("[processStateUpdateMessage][message({}) 状态消息不能在已有事务中处理]", message); + throw new IllegalStateException("状态消息不能在已有事务中处理"); + } + + // 2. 状态事务:经过代理开启事务,返回时已经提交 + LocalDateTime time = message.getReportTime() != null ? message.getReportTime() : LocalDateTime.now(); + boolean changed = getSelf().handleStateUpdateMessage(message, device, state, time); + + // 3. 网关离线:提交后,再联动子设备离线。每个子设备走自己的消息和事务,不加入网关的状态事务 + if (changed && Objects.equal(state, IotDeviceStateEnum.OFFLINE.getState()) + && IotProductDeviceTypeEnum.isGateway(device.getDeviceType())) { + deviceService.handleGatewayOffline(device, time); + } + } + + /** + * 在状态事务中处理设备状态:条件更新状态;状态真的变化时,执行场景联动、数据流转 + * + * 为什么规则也放在事务内?保证“已提交的有效状态变化,提交前已经尝试过规则”: + * 规则执行完成前失败时,状态一起回滚,异常抛给消息总线,重投后再次处理 + * + * 注意:必须经过 Spring 代理调用,且调用方不在事务中;这样方法返回时,事务已经提交 + * + * @param message 消息 + * @param device 设备 + * @param state 状态 + * @param time 事件时间 + * @return 状态是否真的发生了变化 + */ + @Transactional(rollbackFor = Exception.class) + public boolean handleStateUpdateMessage(IotDeviceMessage message, IotDeviceDO device, Integer state, LocalDateTime time) { + // 1. 条件更新状态:重复的、迟到的状态,直接返回,不触发规则 + // 带 serverId 的上线来自网关的连接,状态未变化时也刷新最后上线时间 + if (!deviceService.updateDeviceState(device, state, time, StrUtil.isNotEmpty(message.getServerId()))) { + return false; + } + + // 2. 执行场景联动、数据流转:两个规则订阅者跳过状态消息,统一在这里触发 + sceneRuleService.executeSceneRuleByDevice(message); + dataRuleService.executeDataRule(message); + + // 3. 规则可能把事务标记为回滚:本地 rollback-only 时,提交会静默回滚、不抛异常,所以这里显式失败 + if (TransactionAspectSupport.currentTransactionStatus().isRollbackOnly()) { + throw new IllegalStateException("状态事务已被标记回滚"); + } + return true; + } + + /** + * 判断是否是旧网关节点的离线:设备已经连接到其它网关节点时,原节点上旧连接的离线不生效 + * + * 只比较“离线且带 serverId”的网关连接事件:平台、子设备联动、离线检测发出的离线,serverId 为空,照常生效; + * EMQX 的 serverId 是接收 webhook 的节点,不代表连接归属,所以不比较 + * + * @param message 消息 + * @param device 设备 + * @param state 状态 + * @return 是否是旧网关节点的离线 + */ + private boolean isStaleServerOffline(IotDeviceMessage message, IotDeviceDO device, Integer state) { + if (ObjUtil.notEqual(state, IotDeviceStateEnum.OFFLINE.getState()) + || StrUtil.isEmpty(message.getServerId()) || isEmqxDevice(device)) { + return false; + } + String currentServerId = devicePropertyService.getDeviceServerId(device.getId()); + if (currentServerId == null || currentServerId.equals(message.getServerId())) { + return false; + } + log.info("[isStaleServerOffline][设备({}) 当前连接在网关({}),忽略网关({}) 的离线]", + device.getId(), currentServerId, message.getServerId()); + return true; + } + + /** + * 是否是 EMQX 协议的设备:产品不存在时,按非 EMQX 处理 + * + * @param device 设备 + * @return 是否是 EMQX 协议的设备 + */ + private boolean isEmqxDevice(IotDeviceDO device) { + IotProductDO product = productService.getProductFromCache(device.getProductId()); + return product != null && IotProtocolTypeEnum.of(product.getProtocolType()) == IotProtocolTypeEnum.EMQX; + } + // ========== 批量上报处理方法 ========== /** @@ -327,7 +456,9 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { } // 2. 处理网关设备(自身)的数据 - sendDevicePackData(gatewayDevice, packMessage.getServerId(), params.getProperties(), params.getEvents()); + // 特殊:拆分出的消息沿用 pack 的上报时间,避免迟到的 pack 变成“新”的消息(例如说,强制上线用的是它的时间) + LocalDateTime reportTime = packMessage.getReportTime() != null ? packMessage.getReportTime() : LocalDateTime.now(); + sendDevicePackData(gatewayDevice, packMessage.getServerId(), params.getProperties(), params.getEvents(), reportTime); // 3. 处理子设备的数据 if (CollUtil.isEmpty(params.getSubDevices())) { @@ -342,7 +473,8 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { continue; } // 特殊:子设备不需要指定 serverId,因为子设备实际可能连接在不同的 gateway-server 上,导致 serverId 不同 - sendDevicePackData(subDevice, null, subDeviceData.getProperties(), subDeviceData.getEvents()); + sendDevicePackData(subDevice, null, subDeviceData.getProperties(), subDeviceData.getEvents(), + reportTime); } catch (Exception ex) { log.error("[handlePackMessage][子设备({}/{}) 数据处理失败]", subDeviceData.getIdentity().getProductKey(), subDeviceData.getIdentity().getDeviceName(), ex); @@ -357,16 +489,19 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { * @param serverId 服务标识 * @param properties 属性数据 * @param events 事件数据 + * @param reportTime 上报时间,沿用 pack 消息的上报时间 */ private void sendDevicePackData(IotDeviceDO device, String serverId, Map properties, - Map events) { + Map events, + LocalDateTime reportTime) { // 1. 发送属性消息 if (MapUtil.isNotEmpty(properties)) { IotDeviceMessage propertyMsg = IotDeviceMessage.requestOf( device.getId(), device.getTenantId(), serverId, IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), IotDevicePropertyPostReqDTO.of(properties)); + propertyMsg.setReportTime(reportTime); deviceMessageProducer.sendDeviceMessage(propertyMsg); } @@ -382,6 +517,7 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { device.getId(), device.getTenantId(), serverId, IotDeviceMessageMethodEnum.EVENT_POST.getMethod(), IotDeviceEventPostReqDTO.of(eventId, eventValue.getValue(), eventValue.getTime())); + eventMsg.setReportTime(reportTime); deviceMessageProducer.sendDeviceMessage(eventMsg); } } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyService.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyService.java index 0db791c..470f829 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyService.java +++ b/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; import java.time.LocalDateTime; import java.util.List; import java.util.Map; -import java.util.Set; /** * IoT 设备【属性】数据 Service 接口 @@ -64,12 +63,12 @@ public interface IotDevicePropertyService { // ========== 设备时间相关操作 ========== /** - * 获得最后上报时间小于指定时间的设备编号集合 + * 获得最后上报时间小于指定时间的设备 * * @param maxReportTime 最大上报时间 - * @return 设备编号集合 + * @return 设备编号与最后上报时间的映射 */ - Set getDeviceIdListByReportTime(LocalDateTime maxReportTime); + Map getDeviceReportTimeMapByReportTime(LocalDateTime maxReportTime); /** * 更新设备上报时间 @@ -95,4 +94,20 @@ public interface IotDevicePropertyService { */ String getDeviceServerId(Long id); + /** + * 获得网关协议实例的启动时间 + * + * @param serverId 网关 serverId + * @return 启动时间(毫秒);网关已停止或宕机时返回 null + */ + Long getGatewayServerStartTime(String serverId); + + /** + * 更新网关协议实例的启动时间,同时续期存活标记 + * + * @param serverId 网关 serverId + * @param startTime 网关上报的启动时间(毫秒) + */ + void updateGatewayServerStartTime(String serverId, Long startTime); + } \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImpl.java index efefb6f..043a198 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImpl.java +++ b/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 { // ========== 设备时间相关操作 ========== @Override - public Set getDeviceIdListByReportTime(LocalDateTime maxReportTime) { - return deviceReportTimeRedisDAO.range(maxReportTime); + public Map getDeviceReportTimeMapByReportTime(LocalDateTime maxReportTime) { + return deviceReportTimeRedisDAO.rangeWithScores(maxReportTime); } @Override @@ -212,6 +212,16 @@ public class IotDevicePropertyServiceImpl implements IotDevicePropertyService { return deviceServerIdRedisDAO.get(id); } + @Override + public Long getGatewayServerStartTime(String serverId) { + return deviceServerIdRedisDAO.getGatewayServerStartTime(serverId); + } + + @Override + public void updateGatewayServerStartTime(String serverId, Long startTime) { + deviceServerIdRedisDAO.updateGatewayServerStartTime(serverId, startTime); + } + // ========== 设备定位相关操作 ========== /** diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImpl.java index ccde54e..90573ff 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImpl.java +++ b/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; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; import cn.iocoder.yudao.framework.common.util.spring.SpringUtils; +import cn.iocoder.yudao.framework.mybatis.core.util.JdbcUtils; import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.rule.IotDataRulePageReqVO; import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.rule.IotDataRuleSaveReqVO; import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; @@ -241,6 +242,8 @@ public class IotDataRuleServiceImpl implements IotDataRuleService { Set processedSinkIds = new HashSet<>(); matchedRules.forEach(rule -> executeDataRule(message, rule, processedSinkIds)); } catch (Exception e) { + // 状态事务内,数据访问异常不吞掉,交给事务回滚(见 IotDeviceMessageServiceImpl#handleStateUpdateMessage) + JdbcUtils.throwIfDataAccessExceptionInTransaction(e); log.error("[executeDataRule][消息({}) 执行数据流转规则异常]", message, e); } } @@ -298,6 +301,8 @@ public class IotDataRuleServiceImpl implements IotDataRuleService { // 执行数据桥接操作 executeDataRuleAction(message, dataSink); } catch (Exception e) { + // 状态事务内,数据访问异常不吞掉,交给事务回滚 + JdbcUtils.throwIfDataAccessExceptionInTransaction(e); log.error("[executeDataRule][规则({}) 数据目的({}) 执行异常]", rule.getId(), sinkId, e); } }); @@ -322,6 +327,8 @@ public class IotDataRuleServiceImpl implements IotDataRuleService { action.execute(message, dataSink); log.info("[executeDataRuleAction][消息({}) 数据目的({}) 执行成功]", message.getId(), dataSink.getId()); } catch (Exception e) { + // 状态事务内,数据访问异常不吞掉,交给事务回滚;外部数据目的自身的异常,由各数据目的内部记录,不会到这里 + JdbcUtils.throwIfDataAccessExceptionInTransaction(e); log.error("[executeDataRuleAction][消息({}) 数据目的({}) 执行异常]", message.getId(), dataSink.getId(), e); } }); diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotHttpDataSinkAction.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotHttpDataSinkAction.java index 3f4b8eb..8e6b993 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotHttpDataSinkAction.java +++ b/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; import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotDataSinkDO; import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkHttpConfig; import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum; +import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.http.*; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; +import java.time.Duration; import java.util.HashMap; import java.util.Map; @@ -29,9 +32,30 @@ import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.HEADER_ @Slf4j public class IotHttpDataSinkAction implements IotDataRuleAction { + /** + * 连接超时时间 + * + * 设备状态消息会在事务内执行数据流转(持有设备行锁和数据库连接),所以不能使用无超时的共享 RestTemplate + */ + static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3); + /** + * 读取超时时间 + * + * 一般 Webhook 秒级即可响应;过长会拉长状态事务,过短容易误判慢接口,所以取 5 秒 + */ + static final Duration READ_TIMEOUT = Duration.ofSeconds(5); + @Resource + private RestTemplateBuilder restTemplateBuilder; + private RestTemplate restTemplate; + @PostConstruct + public void init() { + // 基于共享的 RestTemplateBuilder 构建私有实例,不影响其它模块使用的 RestTemplate + restTemplate = restTemplateBuilder.connectTimeout(CONNECT_TIMEOUT).readTimeout(READ_TIMEOUT).build(); + } + @Override public Integer getType() { return IotDataSinkTypeEnum.HTTP.getType(); diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceImpl.java index d91d3ed..07a5b18 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceImpl.java +++ b/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; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.mybatis.core.util.JdbcUtils; import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore; import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils; import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.scene.IotSceneRulePageReqVO; @@ -386,6 +387,8 @@ public class IotSceneRuleServiceImpl implements IotSceneRuleService { try { return sceneRuleMatcherManager.isMatched(message, trigger) && isTriggerConditionGroupsMatched(message, trigger, sceneRule); } catch (Exception e) { + // 状态事务内,数据访问异常不吞掉,交给事务回滚(见 IotDeviceMessageServiceImpl#handleStateUpdateMessage) + JdbcUtils.throwIfDataAccessExceptionInTransaction(e); log.error("[matchSingleTrigger][触发器匹配异常] sceneRuleId: {}, triggerType: {}, message: {}", sceneRule.getId(), trigger.getType(), message, e); return false; @@ -445,6 +448,8 @@ public class IotSceneRuleServiceImpl implements IotSceneRuleService { try { return sceneRuleMatcherManager.isConditionMatched(message, condition); } catch (Exception e) { + // 状态事务内,数据访问异常不吞掉,交给事务回滚 + JdbcUtils.throwIfDataAccessExceptionInTransaction(e); log.error("[isTriggerConditionMatched][规则场景编号({}) 的触发器({}) 条件匹配异常]", sceneRule.getId(), trigger, e); return false; @@ -475,6 +480,8 @@ public class IotSceneRuleServiceImpl implements IotSceneRuleService { log.info("[executeSceneRuleAction][消息({}) 规则场景编号({}) 的执行动作({}) 成功]", message, sceneRule.getId(), actionConfig); } catch (Exception e) { + // 状态事务内,数据访问异常不吞掉,交给事务回滚 + JdbcUtils.throwIfDataAccessExceptionInTransaction(e); log.error("[executeSceneRuleAction][消息({}) 规则场景编号({}) 的执行动作({}) 执行异常]", message, sceneRule.getId(), actionConfig, e); } @@ -495,6 +502,8 @@ public class IotSceneRuleServiceImpl implements IotSceneRuleService { try { sceneRuleMapper.updateById(new IotSceneRuleDO().setId(id).setLastTriggerTime(LocalDateTime.now())); } catch (Exception e) { + // 状态事务内,数据访问异常不吞掉,交给事务回滚(例如说,热点行的锁等待超时) + JdbcUtils.throwIfDataAccessExceptionInTransaction(e); log.error("[updateLastTriggerTime][规则场景编号({}) 更新最后触发时间异常]", id, e); } } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotAlertTriggerSceneRuleAction.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotAlertTriggerSceneRuleAction.java index 937b8f6..102e3b6 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotAlertTriggerSceneRuleAction.java +++ b/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; import cn.hutool.core.date.LocalDateTimeUtil; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.dict.core.DictFrameworkUtils; +import cn.iocoder.yudao.framework.mybatis.core.util.JdbcUtils; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertConfigDO; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; @@ -115,6 +116,8 @@ public class IotAlertTriggerSceneRuleAction implements IotSceneRuleAction { break; } } catch (Exception ex) { + // 状态事务内,数据访问异常不吞掉,交给事务回滚(例如说,单体部署下写短信、邮件、站内信日志失败) + JdbcUtils.throwIfDataAccessExceptionInTransaction(ex); log.error("[sendAlertMessageToUser][用户({}) 模板参数({}) 发送 {} 告警失败]", userId, templateParams, typeEnum, ex); } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotDevicePropertySetSceneRuleAction.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotDevicePropertySetSceneRuleAction.java index 746e18d..208a30c 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotDevicePropertySetSceneRuleAction.java +++ b/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; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.map.MapUtil; import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.mybatis.core.util.JdbcUtils; import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; @@ -111,6 +112,8 @@ public class IotDevicePropertySetSceneRuleAction implements IotSceneRuleAction { log.info("[executePropertySetForDevice][规则场景({}) 动作配置({}) 设备({}) 属性设置消息({}) 发送成功]", rule.getId(), actionConfig, device.getId(), result.getId()); } catch (Exception e) { + // 状态事务内,数据访问异常不吞掉,交给事务回滚(例如说,读取 serverId 的 Redis 故障) + JdbcUtils.throwIfDataAccessExceptionInTransaction(e); log.error("[executePropertySetForDevice][规则场景({}) 动作配置({}) 设备({}) 属性设置消息发送失败]", rule.getId(), actionConfig, device.getId(), e); } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotDeviceServiceInvokeSceneRuleAction.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotDeviceServiceInvokeSceneRuleAction.java index 6469906..ba934fa 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotDeviceServiceInvokeSceneRuleAction.java +++ b/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; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.map.MapUtil; import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.mybatis.core.util.JdbcUtils; import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; @@ -114,6 +115,8 @@ public class IotDeviceServiceInvokeSceneRuleAction implements IotSceneRuleAction log.info("[executeServiceInvokeForDevice][规则场景({}) 动作配置({}) 设备({}) 服务调用消息({}) 发送成功]", rule.getId(), actionConfig, device.getId(), result.getId()); } catch (Exception e) { + // 状态事务内,数据访问异常不吞掉,交给事务回滚(例如说,读取 serverId 的 Redis 故障) + JdbcUtils.throwIfDataAccessExceptionInTransaction(e); log.error("[executeServiceInvokeForDevice][规则场景({}) 动作配置({}) 设备({}) 服务调用消息发送失败]", rule.getId(), actionConfig, device.getId(), e); } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/IotSceneRuleMatcherManager.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/IotSceneRuleMatcherManager.java index ddca893..fef2a5b 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/IotSceneRuleMatcherManager.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/IotSceneRuleMatcherManager.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.iot.service.rule.scene.matcher; import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.mybatis.core.util.JdbcUtils; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionTypeEnum; @@ -114,6 +115,8 @@ public class IotSceneRuleMatcherManager { try { return matcher.matches(message, trigger); } catch (Exception e) { + // 状态事务内,数据访问异常不吞掉,交给事务回滚(例如说,匹配时读取设备缓存的 Redis 故障) + JdbcUtils.throwIfDataAccessExceptionInTransaction(e); log.error("[isMatched][触发器匹配异常] message: {}, trigger: {}", message, trigger, e); return false; } @@ -148,6 +151,8 @@ public class IotSceneRuleMatcherManager { try { return matcher.matches(message, condition); } catch (Exception e) { + // 状态事务内,数据访问异常不吞掉,交给事务回滚 + JdbcUtils.throwIfDataAccessExceptionInTransaction(e); log.error("[isConditionMatched][message({}) condition({}) 条件匹配异常]", message, condition, e); return false; } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImplTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImplTest.java index f1cfb02..8a0e3b3 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImplTest.java +++ b/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; import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceGetReqDTO; import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotGatewayServerAliveReqDTO; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; import cn.iocoder.yudao.module.iot.service.device.IotDeviceModbusConfigService; import cn.iocoder.yudao.module.iot.service.device.IotDeviceModbusPointService; import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; import cn.iocoder.yudao.module.iot.service.product.IotProductService; import jakarta.validation.Validation; import org.junit.jupiter.api.BeforeEach; @@ -18,11 +20,22 @@ import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; import org.springframework.aop.support.AopUtils; +import org.springframework.data.redis.RedisConnectionFailureException; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.validation.beanvalidation.MethodValidationPostProcessor; +import java.util.LinkedHashMap; +import java.util.Map; + import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** * {@link IoTDeviceApiImpl} 的单元测试:经过真实的 Spring 方法校验代理(与 @Validated 的 Bean 在容器中一致) @@ -31,6 +44,8 @@ import static org.mockito.Mockito.*; */ public class IoTDeviceApiImplTest extends BaseMockitoUnitTest { + private static final String KEEP_ALIVE_URL = "/rpc-api/iot/gateway-server/keep-alive"; + @InjectMocks private IoTDeviceApiImpl deviceApiImpl; @@ -42,6 +57,8 @@ public class IoTDeviceApiImplTest extends BaseMockitoUnitTest { private IotDeviceModbusConfigService modbusConfigService; @Mock private IotDeviceModbusPointService modbusPointService; + @Mock + private IotDevicePropertyService devicePropertyService; /** * 方法校验代理:参数约束继承不合法时,代理上的任何方法调用都会抛 ConstraintDeclarationException(HV000151) @@ -113,4 +130,99 @@ public class IoTDeviceApiImplTest extends BaseMockitoUnitTest { assertNull(result.getData().getScriptContent()); } + @Test + public void testKeepAliveGatewayServers_success() { + // 准备参数 + Map startTimes = new LinkedHashMap<>(); + startTimes.put("server-1", 1000L); + startTimes.put("server-2", 2000L); + IotGatewayServerAliveReqDTO reqDTO = new IotGatewayServerAliveReqDTO().setStartTimes(startTimes); + + // 调用 + CommonResult result = deviceApi.keepAliveGatewayServers(reqDTO); + // 断言:逐项写入网关上报的启动时间 + assertTrue(result.isSuccess()); + assertTrue(result.getData()); + verify(devicePropertyService).updateGatewayServerStartTime(eq("server-1"), eq(1000L)); + verify(devicePropertyService).updateGatewayServerStartTime(eq("server-2"), eq(2000L)); + } + + @Test + public void testKeepAliveGatewayServers_partialFail() { + // 准备参数 + Map startTimes = new LinkedHashMap<>(); + startTimes.put("server-1", 1000L); + startTimes.put("server-2", 2000L); + startTimes.put("server-3", 3000L); + IotGatewayServerAliveReqDTO reqDTO = new IotGatewayServerAliveReqDTO().setStartTimes(startTimes); + // mock 方法:中间一项写入失败 + doThrow(new RedisConnectionFailureException("Redis 不可用")).when(devicePropertyService) + .updateGatewayServerStartTime(eq("server-2"), eq(2000L)); + + // 调用 + CommonResult result = deviceApi.keepAliveGatewayServers(reqDTO); + // 断言:失败不影响后面的项,整体结果为 false + assertTrue(result.isSuccess()); + assertFalse(result.getData()); + verify(devicePropertyService).updateGatewayServerStartTime(eq("server-1"), eq(1000L)); + verify(devicePropertyService).updateGatewayServerStartTime(eq("server-3"), eq(3000L)); + } + + @Test + public void testKeepAliveGatewayServers_httpInvalid() throws Exception { + // 准备参数:非法的批次 + Map invalidBodies = new LinkedHashMap<>(); + invalidBodies.put("startTimes 为 null", "{\"startTimes\":null}"); + invalidBodies.put("startTimes 为空", "{\"startTimes\":{}}"); + invalidBodies.put("serverId 为空白", "{\"startTimes\":{\" \":1000}}"); + invalidBodies.put("启动时间为 null", "{\"startTimes\":{\"server-1\":null}}"); + invalidBodies.put("启动时间为 0", "{\"startTimes\":{\"server-1\":0}}"); + invalidBodies.put("启动时间为负数", "{\"startTimes\":{\"server-1\":-1}}"); + invalidBodies.put("合法项与 null 混合", "{\"startTimes\":{\"server-1\":1000,\"server-2\":null}}"); + MockMvc mockMvc = buildMockMvc(); + + for (Map.Entry entry : invalidBodies.entrySet()) { + // 调用 + int httpStatus = mockMvc.perform(post(KEEP_ALIVE_URL).contentType(MediaType.APPLICATION_JSON) + .content(entry.getValue())).andReturn().getResponse().getStatus(); + // 断言:被拒绝 + assertEquals(400, httpStatus, entry.getKey()); + } + // 断言:任何一项都没有写入 + verifyNoInteractions(devicePropertyService); + } + + @Test + public void testKeepAliveGatewayServers_httpNoSizeLimit() throws Exception { + // 准备参数:101 个协议实例 + StringBuilder body = new StringBuilder("{\"startTimes\":{"); + for (int i = 0; i < 101; i++) { + body.append(i > 0 ? "," : "").append("\"server-").append(i).append("\":").append(1000 + i); + } + body.append("}}"); + MockMvc mockMvc = buildMockMvc(); + + // 调用 + mockMvc.perform(post(KEEP_ALIVE_URL).contentType(MediaType.APPLICATION_JSON).content(body.toString())) + // 断言:不限制条数,全部写入 + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").value(true)); + verify(devicePropertyService, times(101)).updateGatewayServerStartTime(any(), any()); + verify(devicePropertyService).updateGatewayServerStartTime(eq("server-100"), eq(1100L)); + } + + /** + * 构建经过 HTTP 消息转换、参数校验的 MockMvc:控制器使用 CGLIB 方法校验代理,与容器中的 Bean 一致 + */ + private MockMvc buildMockMvc() { + MethodValidationPostProcessor processor = new MethodValidationPostProcessor(); + processor.setProxyTargetClass(true); + processor.setValidator(Validation.buildDefaultValidatorFactory().getValidator()); + processor.afterPropertiesSet(); + Object controller = processor.postProcessAfterInitialization(deviceApiImpl, "ioTDeviceApiImpl"); + assertTrue(AopUtils.isCglibProxy(controller)); + return MockMvcBuilders.standaloneSetup(controller).build(); + } + } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/job/device/IotDeviceOfflineCheckJobTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/job/device/IotDeviceOfflineCheckJobTest.java new file mode 100644 index 0000000..c52977a --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/job/device/IotDeviceOfflineCheckJobTest.java @@ -0,0 +1,179 @@ +package cn.iocoder.yudao.module.iot.job.device; + +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; +import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; +import cn.iocoder.yudao.module.iot.enums.product.IotProductDeviceTypeEnum; +import cn.iocoder.yudao.module.iot.framework.iot.config.YudaoIotProperties; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService; +import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; +import cn.iocoder.yudao.module.iot.service.product.IotProductService; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.springframework.data.redis.RedisConnectionFailureException; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.*; + +/** + * {@link IotDeviceOfflineCheckJob} 的单元测试 + * + * @author 芋道源码 + */ +public class IotDeviceOfflineCheckJobTest extends BaseMockitoUnitTest { + + /** + * 最后上报时间:默认超时为 10 分钟 × 1.5 = 15 分钟 + */ + private static final LocalDateTime REPORT_TIME = LocalDateTime.of(2026, 10, 29, 10, 0, 0); + + @InjectMocks + private IotDeviceOfflineCheckJob job; + + @Spy + private YudaoIotProperties iotProperties = new YudaoIotProperties(); + @Mock + private IotDeviceService deviceService; + @Mock + private IotDevicePropertyService devicePropertyService; + @Mock + private IotDeviceMessageService deviceMessageService; + @Mock + private IotProductService productService; + + @Test + public void testExecute_activityBased() { + // mock 数据:HTTP 直连设备、MQTT 网关的子设备已超时;另一个 HTTP 设备未超时 + IotDeviceDO httpDevice = buildDevice(1L, 10L, IotProductDeviceTypeEnum.DIRECT, REPORT_TIME); + IotDeviceDO subDevice = buildDevice(2L, 20L, IotProductDeviceTypeEnum.GATEWAY_SUB, REPORT_TIME); + IotDeviceDO activeDevice = buildDevice(3L, 10L, IotProductDeviceTypeEnum.DIRECT, REPORT_TIME); + when(deviceService.getDeviceListByState(eq(IotDeviceStateEnum.ONLINE.getState()))) + .thenReturn(Arrays.asList(httpDevice, subDevice, activeDevice)); + mockTimeoutDevices(httpDevice, subDevice); + mockProduct(10L, IotProtocolTypeEnum.HTTP); + + // 调用 + job.execute(null); + // 断言:超时即离线,事件时间 = 最后上报时间 + 超时时间;子设备不检查网关存活 + verifyOffline(httpDevice); + verifyOffline(subDevice); + verify(deviceMessageService, never()).sendDeviceMessage(any(), same(activeDevice), any(), any()); + verify(devicePropertyService, never()).getDeviceServerId(any()); + } + + @Test + public void testExecute_connectionBased() { + // mock 数据:4 个已超时的 MQTT 设备,网关 server-1 在 REPORT_TIME - 1h 的 .100 启动 + // device1:在 server-1 启动后上线,连接仍在 → 保持在线 + // device2:在 server-1 启动前上线(之后网关重启过) → 离线 + // device3:网关 server-2 的存活标记已过期 → 离线 + // device4:与 server-1 启动在同一秒上线(实际 .200,按秒存储为 .000) → 保持在线 + LocalDateTime startTime = REPORT_TIME.minusHours(1); + IotDeviceDO device1 = buildDevice(1L, 10L, IotProductDeviceTypeEnum.DIRECT, REPORT_TIME); + IotDeviceDO device2 = buildDevice(2L, 10L, IotProductDeviceTypeEnum.DIRECT, REPORT_TIME.minusHours(2)); + IotDeviceDO device3 = buildDevice(3L, 10L, IotProductDeviceTypeEnum.DIRECT, REPORT_TIME); + IotDeviceDO device4 = buildDevice(4L, 10L, IotProductDeviceTypeEnum.DIRECT, startTime); + when(deviceService.getDeviceListByState(eq(IotDeviceStateEnum.ONLINE.getState()))) + .thenReturn(Arrays.asList(device1, device2, device3, device4)); + mockTimeoutDevices(device1, device2, device3, device4); + mockProduct(10L, IotProtocolTypeEnum.MQTT); + when(devicePropertyService.getDeviceServerId(eq(1L))).thenReturn("server-1"); + when(devicePropertyService.getDeviceServerId(eq(2L))).thenReturn("server-1"); + when(devicePropertyService.getDeviceServerId(eq(3L))).thenReturn("server-2"); + when(devicePropertyService.getDeviceServerId(eq(4L))).thenReturn("server-1"); + when(devicePropertyService.getGatewayServerStartTime(eq("server-1"))) + .thenReturn(LocalDateTimeUtil.toEpochMilli(startTime) + 100); + when(devicePropertyService.getGatewayServerStartTime(eq("server-2"))).thenReturn(null); + + // 调用 + job.execute(null); + // 断言 + verify(deviceMessageService, never()).sendDeviceMessage(any(), same(device1), any(), any()); + verify(deviceMessageService, never()).sendDeviceMessage(any(), same(device4), any(), any()); + verifyOffline(device2); + verifyOffline(device3); + // 断言:同一个 serverId,本轮只读取一次 + verify(devicePropertyService).getGatewayServerStartTime(eq("server-1")); + } + + @Test + public void testExecute_connectionBasedRedisFailed() { + // mock 数据:已超时的 TCP 设备 + IotDeviceDO device = buildDevice(1L, 10L, IotProductDeviceTypeEnum.DIRECT, REPORT_TIME); + when(deviceService.getDeviceListByState(eq(IotDeviceStateEnum.ONLINE.getState()))) + .thenReturn(Arrays.asList(device)); + mockTimeoutDevices(device); + mockProduct(10L, IotProtocolTypeEnum.TCP); + when(devicePropertyService.getDeviceServerId(eq(1L))).thenReturn("server-1"); + when(devicePropertyService.getGatewayServerStartTime(eq("server-1"))) + .thenThrow(new RedisConnectionFailureException("Redis 不可用")); + + // 调用 + job.execute(null); + // 断言:读取失败不等于标记不存在,本轮不判离线 + verify(deviceMessageService, never()).sendDeviceMessage(any(), any(), any(), any()); + } + + @Test + public void testExecute_emqx() { + // mock 数据:已超时的 EMQX 设备 + IotDeviceDO device = buildDevice(1L, 10L, IotProductDeviceTypeEnum.DIRECT, REPORT_TIME); + when(deviceService.getDeviceListByState(eq(IotDeviceStateEnum.ONLINE.getState()))) + .thenReturn(Arrays.asList(device)); + mockTimeoutDevices(device); + mockProduct(10L, IotProtocolTypeEnum.EMQX); + + // 调用 + job.execute(null); + // 断言:连接由 EMQX 维护,不使用存活标记,也不判离线 + verify(deviceMessageService, never()).sendDeviceMessage(any(), any(), any(), any()); + verify(devicePropertyService, never()).getGatewayServerStartTime(any()); + } + + // ========== 辅助方法 ========== + + private void mockTimeoutDevices(IotDeviceDO... devices) { + Map timeoutDevices = new HashMap<>(); + for (IotDeviceDO device : devices) { + timeoutDevices.put(device.getId(), REPORT_TIME); + } + when(devicePropertyService.getDeviceReportTimeMapByReportTime(any())).thenReturn(timeoutDevices); + } + + private void mockProduct(Long productId, IotProtocolTypeEnum protocolType) { + when(productService.getProductFromCache(eq(productId))) + .thenReturn(new IotProductDO().setId(productId).setProtocolType(protocolType.getType())); + } + + private void verifyOffline(IotDeviceDO device) { + verify(deviceMessageService).sendDeviceMessage(argThat((IotDeviceMessage m) -> + IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod().equals(m.getMethod()) + && String.valueOf(IotDeviceStateEnum.OFFLINE.getState()).equals(IotDeviceMessageUtils.getIdentifier(m))), + same(device), isNull(), eq(REPORT_TIME.plusMinutes(15))); + } + + private static IotDeviceDO buildDevice(Long id, Long productId, IotProductDeviceTypeEnum deviceType, + LocalDateTime onlineTime) { + return IotDeviceDO.builder().id(id).productId(productId).deviceType(deviceType.getType()) + .state(IotDeviceStateEnum.ONLINE.getState()).onlineTime(onlineTime).build(); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/mq/consumer/device/IotDeviceMessageSubscriberTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/mq/consumer/device/IotDeviceMessageSubscriberTest.java index 92f74e0..76340da 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/mq/consumer/device/IotDeviceMessageSubscriberTest.java +++ b/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; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.*; /** @@ -90,7 +91,7 @@ public class IotDeviceMessageSubscriberTest extends BaseMockitoUnitTest { // 准备参数:设备实际返回的回复(例如 Modbus 异常响应),按原逻辑作为设备活动 IotDeviceMessage message = new IotDeviceMessage().setDeviceId(randomLongId()).setTenantId(randomLongId()) .setServerId("test-server").setMethod(IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod()) - .setCode(1_051_003_111); + .setCode(1_051_003_111).setReportTime(LocalDateTime.of(2026, 10, 29, 10, 0, 0)); // mock 方法:设备离线 IotDeviceDO device = new IotDeviceDO().setId(message.getDeviceId()) .setState(IotDeviceStateEnum.OFFLINE.getState()); @@ -98,11 +99,48 @@ public class IotDeviceMessageSubscriberTest extends BaseMockitoUnitTest { // 调用 deviceMessageSubscriber.onMessage(message); - // 断言:更新上报时间、连接 server,并强制上线 + // 断言:更新上报时间、连接 server,并强制上线(事件时间沿用源消息的上报时间) verify(devicePropertyService).updateDeviceReportTimeAsync(eq(device.getId()), any(LocalDateTime.class)); verify(devicePropertyService).updateDeviceServerIdAsync(eq(device.getId()), eq(message.getServerId())); verify(deviceMessageService).sendDeviceMessage(argThat((IotDeviceMessage m) -> - IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod().equals(m.getMethod()))); + IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod().equals(m.getMethod())), + same(device), isNull(), eq(message.getReportTime())); + verify(deviceMessageService).handleUpstreamDeviceMessage(eq(message), eq(device)); + } + + @Test + public void testOnMessage_stateOffline() { + // 准备参数:网关连接断开的离线 + IotDeviceMessage message = IotDeviceMessage.buildStateOffline().setDeviceId(randomLongId()) + .setTenantId(randomLongId()).setServerId("old-server"); + // mock 方法:设备在线 + IotDeviceDO device = new IotDeviceDO().setId(message.getDeviceId()) + .setState(IotDeviceStateEnum.ONLINE.getState()); + when(deviceService.getDeviceFromCache(eq(message.getDeviceId()))).thenReturn(device); + + // 调用 + deviceMessageSubscriber.onMessage(message); + // 断言:离线不算活跃,也不改变连接的 server + verify(devicePropertyService, never()).updateDeviceReportTimeAsync(any(), any()); + verify(devicePropertyService, never()).updateDeviceServerIdAsync(any(), any()); + verify(deviceMessageService).handleUpstreamDeviceMessage(eq(message), eq(device)); + } + + @Test + public void testOnMessage_oldMessageKeepServerId() { + // 准备参数:早于当前上线时间的旧消息(例如说,旧连接上迟到的属性上报) + IotDeviceMessage message = buildPropertyPostMessage().setServerId("old-server") + .setReportTime(LocalDateTime.of(2026, 10, 29, 10, 0, 0)); + // mock 方法:设备已经在新连接上线 + IotDeviceDO device = new IotDeviceDO().setId(message.getDeviceId()) + .setState(IotDeviceStateEnum.ONLINE.getState()).setOnlineTime(LocalDateTime.of(2026, 10, 29, 10, 1, 0)); + when(deviceService.getDeviceFromCache(eq(message.getDeviceId()))).thenReturn(device); + + // 调用 + deviceMessageSubscriber.onMessage(message); + // 断言:照常记录活跃,但不改路由 + verify(devicePropertyService).updateDeviceReportTimeAsync(eq(device.getId()), any(LocalDateTime.class)); + verify(devicePropertyService, never()).updateDeviceServerIdAsync(any(), any()); verify(deviceMessageService).handleUpstreamDeviceMessage(eq(message), eq(device)); } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/mq/consumer/rule/IotRuleMessageSubscriberTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/mq/consumer/rule/IotRuleMessageSubscriberTest.java new file mode 100644 index 0000000..98a930a --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/mq/consumer/rule/IotRuleMessageSubscriberTest.java @@ -0,0 +1,66 @@ +package cn.iocoder.yudao.module.iot.mq.consumer.rule; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.service.rule.data.IotDataRuleService; +import cn.iocoder.yudao.module.iot.service.rule.scene.IotSceneRuleService; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +/** + * {@link IotSceneRuleMessageSubscriber}、{@link IotDataRuleMessageSubscriber} 的单元测试类 + * + * 状态消息的规则,改由设备消费者在状态事务内触发;两个规则消费者如果不跳过,规则会重复执行 + * + * @author 芋道源码 + */ +public class IotRuleMessageSubscriberTest extends BaseMockitoUnitTest { + + @InjectMocks + private IotSceneRuleMessageSubscriber sceneRuleMessageSubscriber; + @InjectMocks + private IotDataRuleMessageSubscriber dataRuleMessageSubscriber; + + @Mock + private IotSceneRuleService sceneRuleService; + @Mock + private IotDataRuleService dataRuleService; + @Mock + private IotMessageBus messageBus; + + @Test + public void testOnMessage_stateUpdateSkip() { + // 准备参数 + IotDeviceMessage message = IotDeviceMessage.buildStateOffline().setDeviceId(randomLongId()) + .setTenantId(randomLongId()); + + // 调用 + sceneRuleMessageSubscriber.onMessage(message); + dataRuleMessageSubscriber.onMessage(message); + // 断言:跳过,不执行规则 + verifyNoInteractions(sceneRuleService, dataRuleService); + } + + @Test + public void testOnMessage_propertyPost() { + // 准备参数 + IotDeviceMessage message = new IotDeviceMessage().setDeviceId(randomLongId()).setTenantId(randomLongId()) + .setMethod(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()); + + // 调用 + sceneRuleMessageSubscriber.onMessage(message); + dataRuleMessageSubscriber.onMessage(message); + // 断言:照常执行规则 + verify(sceneRuleService).executeSceneRuleByDevice(same(message)); + verify(dataRuleService).executeDataRule(same(message)); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceServiceImplTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceServiceImplTest.java index ab217a8..03dd652 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceServiceImplTest.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceServiceImplTest.java @@ -1,43 +1,131 @@ package cn.iocoder.yudao.module.iot.service.device; import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.mq.producer.IotDeviceMessageProducer; +import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; import cn.iocoder.yudao.module.iot.dal.mysql.device.IotDeviceMapper; +import cn.iocoder.yudao.module.iot.dal.redis.RedisKeyConstants; +import cn.iocoder.yudao.module.iot.dal.storage.IotDeviceMessageDAO; import cn.iocoder.yudao.module.iot.enums.product.IotProductDeviceTypeEnum; -import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionTypeEnum; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; +import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageServiceImpl; +import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; import cn.iocoder.yudao.module.iot.service.device.shadow.IotDeviceShadowService; +import cn.iocoder.yudao.module.iot.service.ota.IotOtaTaskRecordService; import cn.iocoder.yudao.module.iot.service.product.IotProductService; +import cn.iocoder.yudao.module.iot.service.rule.data.IotDataRuleService; +import cn.iocoder.yudao.module.iot.service.rule.scene.IotSceneRuleService; +import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherManager; +import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.condition.IotDeviceStateConditionMatcher; +import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.trigger.IotDeviceStateUpdateTriggerMatcher; import jakarta.annotation.Resource; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.cache.transaction.TransactionAwareCacheManagerProxy; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; +import org.springframework.dao.QueryTimeoutException; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.interceptor.TransactionAspectSupport; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +import static java.util.Collections.singletonList; import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.verify; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.*; /** * {@link IotDeviceServiceImpl} 的单元测试 * + * 设备状态相关的用例,同时使用真实的 {@link IotDeviceMessageServiceImpl}:经过 Spring 代理开启事务, + * 在 H2 上验证条件更新、状态事务的提交与回滚、缓存清理时机、网关下线联动子设备; + * 规则匹配相关的用例,使用真实的 {@link IotSceneRuleMatcherManager} 与设备状态的触发器、条件匹配器 + * * @author 芋道源码 */ -@Import(IotDeviceServiceImpl.class) +@Import({IotDeviceServiceImpl.class, IotDeviceMessageServiceImpl.class, IotDeviceServiceImplTest.CacheConfiguration.class, + IotSceneRuleMatcherManager.class, IotDeviceStateUpdateTriggerMatcher.class, IotDeviceStateConditionMatcher.class}) public class IotDeviceServiceImplTest extends BaseDbUnitTest { + /** + * 基准时间:H2 的时间精度有限,使用整秒 + */ + private static final LocalDateTime T0 = LocalDateTime.of(2026, 10, 29, 10, 0, 0); + @Resource private IotDeviceServiceImpl deviceService; + @Resource + private IotDeviceMessageServiceImpl deviceMessageService; @Resource private IotDeviceMapper deviceMapper; + @Resource + private PlatformTransactionManager transactionManager; + @MockitoSpyBean + private CacheManager cacheManager; + @Resource + private IotSceneRuleMatcherManager sceneRuleMatcherManager; @MockitoBean private IotProductService productService; @MockitoBean private IotDeviceGroupService deviceGroupService; @MockitoBean - private IotDeviceMessageService deviceMessageService; - @MockitoBean private IotDeviceShadowService deviceShadowService; + @MockitoBean + private IotDevicePropertyService devicePropertyService; + @MockitoBean + private IotOtaTaskRecordService otaTaskRecordService; + @MockitoBean + private IotSceneRuleService sceneRuleService; + @MockitoBean + private IotDataRuleService dataRuleService; + @MockitoBean + private IotDeviceMessageDAO deviceMessageDAO; + @MockitoBean + private IotDeviceMessageProducer deviceMessageProducer; + + /** + * 与线上一致:缓存的写入、清理,在事务提交后才生效 + */ + @Configuration + @EnableCaching + public static class CacheConfiguration { + + @Bean + public CacheManager cacheManager() { + return new TransactionAwareCacheManagerProxy(new ConcurrentMapCacheManager()); + } + + } + + @BeforeEach + public void setUp() { + getDeviceCache().clear(); + } @Test public void testDeleteDevice_success() { @@ -71,6 +159,562 @@ public class IotDeviceServiceImplTest extends BaseDbUnitTest { assertNotNull(deviceMapper.selectById(id)); } + // ========== 设备状态:条件更新 ========== + + @Test + public void testUpdateDeviceState_firstOnline() { + // mock 数据:未激活 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.INACTIVE.getState(), null, null); + + // 调用 + boolean changed = deviceService.updateDeviceState(device, IotDeviceStateEnum.ONLINE.getState(), T0, false); + // 断言:上线,并且首次激活 + assertTrue(changed); + IotDeviceDO dbDevice = deviceMapper.selectById(device.getId()); + assertEquals(IotDeviceStateEnum.ONLINE.getState(), dbDevice.getState()); + assertEquals(T0, dbDevice.getOnlineTime()); + assertEquals(T0, dbDevice.getActiveTime()); + } + + @Test + public void testUpdateDeviceState_reOnlineKeepActiveTime() { + // mock 数据:已激活、已离线 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.OFFLINE.getState(), T0, T0.plusMinutes(1)); + + // 调用 + boolean changed = deviceService.updateDeviceState(device, IotDeviceStateEnum.ONLINE.getState(), + T0.plusMinutes(2), false); + // 断言:重新上线,激活时间不变 + assertTrue(changed); + IotDeviceDO dbDevice = deviceMapper.selectById(device.getId()); + assertEquals(IotDeviceStateEnum.ONLINE.getState(), dbDevice.getState()); + assertEquals(T0.plusMinutes(2), dbDevice.getOnlineTime()); + assertEquals(T0, dbDevice.getActiveTime()); + } + + @Test + public void testUpdateDeviceState_duplicateOnline() { + // mock 数据:已在线 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.ONLINE.getState(), T0, null); + + // 调用:网关连接的重复上线(refresh = true),推进上线时间,但不算状态变化 + assertFalse(deviceService.updateDeviceState(device, IotDeviceStateEnum.ONLINE.getState(), + T0.plusMinutes(1), true)); + assertEquals(T0.plusMinutes(1), deviceMapper.selectById(device.getId()).getOnlineTime()); + // 调用:其它来源的重复上线(refresh = false),不推进上线时间 + assertFalse(deviceService.updateDeviceState(device, IotDeviceStateEnum.ONLINE.getState(), + T0.plusMinutes(2), false)); + assertEquals(T0.plusMinutes(1), deviceMapper.selectById(device.getId()).getOnlineTime()); + // 调用:更早的上线,不回退上线时间 + assertFalse(deviceService.updateDeviceState(device, IotDeviceStateEnum.ONLINE.getState(), + T0, true)); + assertEquals(T0.plusMinutes(1), deviceMapper.selectById(device.getId()).getOnlineTime()); + } + + @Test + public void testUpdateDeviceState_lateOnlineIgnored() { + // mock 数据:已离线,离线时间为 T0 + 10 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.OFFLINE.getState(), T0, T0.plusMinutes(10)); + + // 调用:迟到的上线(早于离线) + boolean changed = deviceService.updateDeviceState(device, IotDeviceStateEnum.ONLINE.getState(), + T0.plusMinutes(5), true); + // 断言:保持离线 + assertFalse(changed); + IotDeviceDO dbDevice = deviceMapper.selectById(device.getId()); + assertEquals(IotDeviceStateEnum.OFFLINE.getState(), dbDevice.getState()); + assertEquals(T0, dbDevice.getOnlineTime()); + } + + @Test + public void testUpdateDeviceState_lateOfflineIgnored() { + // mock 数据:已在线,上线时间为 T0 + 10 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.ONLINE.getState(), T0.plusMinutes(10), null); + + // 调用:迟到的离线(早于上线) + boolean changed = deviceService.updateDeviceState(device, IotDeviceStateEnum.OFFLINE.getState(), + T0.plusMinutes(5), false); + // 断言:保持在线 + assertFalse(changed); + IotDeviceDO dbDevice = deviceMapper.selectById(device.getId()); + assertEquals(IotDeviceStateEnum.ONLINE.getState(), dbDevice.getState()); + assertNull(dbDevice.getOfflineTime()); + } + + @Test + public void testUpdateDeviceState_sameTimeOffline() { + // mock 数据:已在线,上线时间为 T0 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.ONLINE.getState(), T0, null); + + // 调用:同一时间的离线 + boolean changed = deviceService.updateDeviceState(device, IotDeviceStateEnum.OFFLINE.getState(), T0, false); + // 断言:离线生效 + assertTrue(changed); + IotDeviceDO dbDevice = deviceMapper.selectById(device.getId()); + assertEquals(IotDeviceStateEnum.OFFLINE.getState(), dbDevice.getState()); + assertEquals(T0, dbDevice.getOfflineTime()); + } + + @Test + public void testUpdateDeviceState_sameSecondReconnect() { + // mock 数据:已在线 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.ONLINE.getState(), T0, null); + + // 调用:同一秒内断开(.600)又重连(.800) + assertTrue(deviceService.updateDeviceState(device, IotDeviceStateEnum.OFFLINE.getState(), + T0.plusNanos(600_000_000), false)); + assertTrue(deviceService.updateDeviceState(device, IotDeviceStateEnum.ONLINE.getState(), + T0.plusNanos(800_000_000), true)); + // 断言:重连生效;时间按秒存储,不会因为毫秒进位被误判为迟到 + IotDeviceDO dbDevice = deviceMapper.selectById(device.getId()); + assertEquals(IotDeviceStateEnum.ONLINE.getState(), dbDevice.getState()); + assertEquals(T0, dbDevice.getOfflineTime()); + assertEquals(T0, dbDevice.getOnlineTime()); + } + + @Test + public void testUpdateDeviceState_duplicateOffline() { + // mock 数据:已离线,离线时间为 T0 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.OFFLINE.getState(), null, T0); + + // 调用:更晚的离线,推进离线水位,但不算状态变化 + assertFalse(deviceService.updateDeviceState(device, IotDeviceStateEnum.OFFLINE.getState(), + T0.plusMinutes(5), false)); + assertEquals(T0.plusMinutes(5), deviceMapper.selectById(device.getId()).getOfflineTime()); + // 调用:更早的离线,不回退离线水位 + assertFalse(deviceService.updateDeviceState(device, IotDeviceStateEnum.OFFLINE.getState(), + T0, false)); + assertEquals(T0.plusMinutes(5), deviceMapper.selectById(device.getId()).getOfflineTime()); + } + + // ========== 设备状态:状态事务 ========== + + @Test + public void testHandleStateUpdate_commit() { + // mock 数据:已离线 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.OFFLINE.getState(), null, null); + // 准备参数 + IotDeviceMessage message = buildStateMessage(device, IotDeviceStateEnum.ONLINE.getState(), T0, "server-1"); + // mock 方法:规则在状态事务内执行,此时已能读到新状态 + doAnswer(invocation -> { + assertTrue(TransactionSynchronizationManager.isActualTransactionActive()); + assertEquals(IotDeviceStateEnum.ONLINE.getState(), deviceMapper.selectById(device.getId()).getState()); + return null; + }).when(sceneRuleService).executeSceneRuleByDevice(any()); + + // 调用 + deviceMessageService.handleUpstreamDeviceMessage(message, device); + // 断言:已提交 + assertFalse(TransactionSynchronizationManager.isActualTransactionActive()); + IotDeviceDO dbDevice = deviceMapper.selectById(device.getId()); + assertEquals(IotDeviceStateEnum.ONLINE.getState(), dbDevice.getState()); + assertEquals(T0, dbDevice.getOnlineTime()); + verify(sceneRuleService).executeSceneRuleByDevice(same(message)); + verify(dataRuleService).executeDataRule(same(message)); + } + + @Test + public void testHandleStateUpdate_notChangedSkipRule() { + // mock 数据:已在线 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.ONLINE.getState(), T0, null); + // 准备参数:重复的上线 + IotDeviceMessage message = buildStateMessage(device, IotDeviceStateEnum.ONLINE.getState(), + T0.plusMinutes(1), "server-1"); + + // 调用 + deviceMessageService.handleUpstreamDeviceMessage(message, device); + // 断言:只推进上线时间,不触发规则 + assertEquals(T0.plusMinutes(1), deviceMapper.selectById(device.getId()).getOnlineTime()); + verify(sceneRuleService, never()).executeSceneRuleByDevice(any()); + verify(dataRuleService, never()).executeDataRule(any()); + } + + @Test + public void testHandleStateUpdate_ruleDataAccessExceptionRollback() { + // mock 数据:已离线 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.OFFLINE.getState(), null, null); + // 准备参数 + IotDeviceMessage message = buildStateMessage(device, IotDeviceStateEnum.ONLINE.getState(), T0, null); + // mock 方法:规则访问数据失败(例如说 Redis 超时),异常不被吞掉 + QueryTimeoutException failure = new QueryTimeoutException("rule data access failed"); + doThrow(failure).when(sceneRuleService).executeSceneRuleByDevice(any()); + + // 调用,并断言异常:抛给消息总线 + assertSame(failure, assertThrows(QueryTimeoutException.class, + () -> deviceMessageService.handleUpstreamDeviceMessage(message, device))); + // 断言:状态已回滚 + IotDeviceDO dbDevice = deviceMapper.selectById(device.getId()); + assertEquals(IotDeviceStateEnum.OFFLINE.getState(), dbDevice.getState()); + assertNull(dbDevice.getOnlineTime()); + verify(dataRuleService, never()).executeDataRule(any()); + } + + @Test + public void testHandleStateUpdate_rollbackOnly() { + // mock 数据:已离线 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.OFFLINE.getState(), null, null); + // 准备参数 + IotDeviceMessage message = buildStateMessage(device, IotDeviceStateEnum.ONLINE.getState(), T0, null); + // mock 方法:规则内部的事务方法失败、被 catch 吞掉,状态事务被标记为 rollback-only + doAnswer(invocation -> { + try { + new TransactionTemplate(transactionManager).executeWithoutResult(status -> { + throw new IllegalArgumentException("inner failed"); + }); + } catch (IllegalArgumentException ignored) { + } + return null; + }).when(dataRuleService).executeDataRule(any()); + + // 调用,并断言异常:显式失败,而不是静默回滚 + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> deviceMessageService.handleUpstreamDeviceMessage(message, device)); + assertEquals("状态事务已被标记回滚", ex.getMessage()); + // 断言:状态已回滚 + assertEquals(IotDeviceStateEnum.OFFLINE.getState(), deviceMapper.selectById(device.getId()).getState()); + } + + @Test + public void testHandleStateUpdate_localRollbackOnly() { + // mock 数据:网关在线;子设备在线 + IotDeviceDO gateway = insertGateway(); + IotDeviceDO subDevice = insertSubDevice(gateway, IotDeviceStateEnum.ONLINE.getState(), T0); + // 准备参数 + IotDeviceMessage message = buildStateMessage(gateway, IotDeviceStateEnum.OFFLINE.getState(), + T0.plusMinutes(10), "server-1"); + // mock 方法:规则直接把当前事务标记为 rollback-only 后正常返回(只有本地标记) + // 说明:本地 rollback-only 时,commit 会静默回滚、不抛异常;如果不显式失败,调用方会误以为已经提交 + doAnswer(invocation -> { + TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); + return null; + }).when(dataRuleService).executeDataRule(any()); + + // 调用,并断言异常:显式失败 + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> deviceMessageService.handleUpstreamDeviceMessage(message, gateway)); + assertEquals("状态事务已被标记回滚", ex.getMessage()); + // 断言:网关状态已回滚,不联动子设备 + assertEquals(IotDeviceStateEnum.ONLINE.getState(), deviceMapper.selectById(gateway.getId()).getState()); + assertEquals(IotDeviceStateEnum.ONLINE.getState(), deviceMapper.selectById(subDevice.getId()).getState()); + verify(deviceMessageProducer, never()).sendDeviceMessage(any()); + } + + @Test + public void testHandleStateUpdate_rejectExistingTransaction() { + // mock 数据:已离线 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.OFFLINE.getState(), null, null); + // 准备参数 + IotDeviceMessage message = buildStateMessage(device, IotDeviceStateEnum.ONLINE.getState(), T0, null); + + // 调用,并断言异常:在已有事务中处理状态消息,直接拒绝 + new TransactionTemplate(transactionManager).executeWithoutResult(status -> + assertThrows(IllegalStateException.class, + () -> deviceMessageService.handleUpstreamDeviceMessage(message, device))); + // 断言:没有更新状态,也没有触发规则 + assertEquals(IotDeviceStateEnum.OFFLINE.getState(), deviceMapper.selectById(device.getId()).getState()); + verify(sceneRuleService, never()).executeSceneRuleByDevice(any()); + verify(dataRuleService, never()).executeDataRule(any()); + } + + @Test + public void testHandleStateUpdate_evictCacheAfterCommit() { + // mock 数据:已离线,并且已经缓存 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.OFFLINE.getState(), null, null); + getDeviceCache().put(device.getId(), device); + // 准备参数 + IotDeviceMessage message = buildStateMessage(device, IotDeviceStateEnum.ONLINE.getState(), T0, null); + // mock 方法:事务内,缓存还没有清理 + doAnswer(invocation -> { + assertNotNull(getDeviceCache().get(device.getId())); + return null; + }).when(sceneRuleService).executeSceneRuleByDevice(any()); + + // 调用 + deviceMessageService.handleUpstreamDeviceMessage(message, device); + // 断言:提交后,缓存已清理 + verify(sceneRuleService).executeSceneRuleByDevice(any()); + assertNull(getDeviceCache().get(device.getId())); + } + + @Test + public void testHandleStateUpdate_rollbackKeepCache() { + // mock 数据:已离线,并且已经缓存 + IotDeviceDO device = insertDevice(IotDeviceStateEnum.OFFLINE.getState(), null, null); + getDeviceCache().put(device.getId(), device); + // 准备参数 + IotDeviceMessage message = buildStateMessage(device, IotDeviceStateEnum.ONLINE.getState(), T0, null); + // mock 方法:规则访问数据失败 + doThrow(new QueryTimeoutException("rule data access failed")).when(dataRuleService).executeDataRule(any()); + + // 调用 + assertThrows(QueryTimeoutException.class, () -> deviceMessageService.handleUpstreamDeviceMessage(message, device)); + // 断言:回滚后,缓存没有被清理(与数据库一致) + assertNotNull(getDeviceCache().get(device.getId())); + } + + // ========== 设备状态:网关下线联动子设备 ========== + + @Test + public void testHandleStateUpdate_gatewayOffline() { + // mock 数据:网关在线;子设备 1、2 在线,子设备 3 在网关离线之后重连,子设备 4 已离线 + IotDeviceDO gateway = insertGateway(); + IotDeviceDO subDevice1 = insertSubDevice(gateway, IotDeviceStateEnum.ONLINE.getState(), T0); + IotDeviceDO subDevice2 = insertSubDevice(gateway, IotDeviceStateEnum.ONLINE.getState(), T0); + IotDeviceDO subDevice3 = insertSubDevice(gateway, IotDeviceStateEnum.ONLINE.getState(), T0.plusMinutes(20)); + IotDeviceDO subDevice4 = insertSubDevice(gateway, IotDeviceStateEnum.OFFLINE.getState(), T0); + // 准备参数:网关在 T0 + 10 离线 + IotDeviceMessage message = buildStateMessage(gateway, IotDeviceStateEnum.OFFLINE.getState(), + T0.plusMinutes(10), "server-1"); + // mock 方法:模拟 Local 总线,在发布线程上同步处理子设备消息 + mockLocalMessageBus(); + + // 调用 + deviceMessageService.handleUpstreamDeviceMessage(message, gateway); + // 断言:网关离线 + assertEquals(IotDeviceStateEnum.OFFLINE.getState(), deviceMapper.selectById(gateway.getId()).getState()); + // 断言:在线的 3 个子设备,各发送一条离线消息,事件时间为网关离线的时间 + ArgumentCaptor captor = ArgumentCaptor.forClass(IotDeviceMessage.class); + verify(deviceMessageProducer, times(3)).sendDeviceMessage(captor.capture()); + captor.getAllValues().forEach(subMessage -> { + assertEquals(IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod(), subMessage.getMethod()); + assertEquals(String.valueOf(IotDeviceStateEnum.OFFLINE.getState()), IotDeviceMessageUtils.getIdentifier(subMessage)); + assertEquals(T0.plusMinutes(10), subMessage.getReportTime()); + assertNull(subMessage.getServerId()); + }); + // 断言:子设备 1、2 离线;子设备 3 的重连晚于网关离线,保持在线 + assertEquals(IotDeviceStateEnum.OFFLINE.getState(), deviceMapper.selectById(subDevice1.getId()).getState()); + assertEquals(IotDeviceStateEnum.OFFLINE.getState(), deviceMapper.selectById(subDevice2.getId()).getState()); + assertEquals(IotDeviceStateEnum.ONLINE.getState(), deviceMapper.selectById(subDevice3.getId()).getState()); + assertNull(deviceMapper.selectById(subDevice4.getId()).getOfflineTime()); + // 断言:规则触发 3 次(网关、子设备 1、2) + verify(sceneRuleService, times(3)).executeSceneRuleByDevice(any()); + } + + @Test + public void testHandleStateUpdate_gatewayOfflineSubDeviceFailed() { + // mock 数据:网关在线;子设备 1、2 在线 + IotDeviceDO gateway = insertGateway(); + IotDeviceDO subDevice1 = insertSubDevice(gateway, IotDeviceStateEnum.ONLINE.getState(), T0); + IotDeviceDO subDevice2 = insertSubDevice(gateway, IotDeviceStateEnum.ONLINE.getState(), T0); + // 准备参数 + IotDeviceMessage message = buildStateMessage(gateway, IotDeviceStateEnum.OFFLINE.getState(), + T0.plusMinutes(10), "server-1"); + // mock 方法:模拟 Local 总线;子设备 1 的规则访问数据失败 + mockLocalMessageBus(); + doThrow(new QueryTimeoutException("rule data access failed")).when(sceneRuleService) + .executeSceneRuleByDevice(argThat(m -> subDevice1.getId().equals(m.getDeviceId()))); + + // 调用 + deviceMessageService.handleUpstreamDeviceMessage(message, gateway); + // 断言:子设备 1 自己的事务回滚,不影响已提交的网关和其它子设备 + assertEquals(IotDeviceStateEnum.OFFLINE.getState(), deviceMapper.selectById(gateway.getId()).getState()); + assertEquals(IotDeviceStateEnum.ONLINE.getState(), deviceMapper.selectById(subDevice1.getId()).getState()); + assertEquals(IotDeviceStateEnum.OFFLINE.getState(), deviceMapper.selectById(subDevice2.getId()).getState()); + } + + @Test + public void testHandleStateUpdate_gatewayRollbackNoSubDeviceMessage() { + // mock 数据:网关在线;子设备在线 + IotDeviceDO gateway = insertGateway(); + IotDeviceDO subDevice = insertSubDevice(gateway, IotDeviceStateEnum.ONLINE.getState(), T0); + // 准备参数 + IotDeviceMessage message = buildStateMessage(gateway, IotDeviceStateEnum.OFFLINE.getState(), + T0.plusMinutes(10), "server-1"); + // mock 方法:网关的规则访问数据失败 + doThrow(new QueryTimeoutException("rule data access failed")).when(sceneRuleService).executeSceneRuleByDevice(any()); + + // 调用 + assertThrows(QueryTimeoutException.class, () -> deviceMessageService.handleUpstreamDeviceMessage(message, gateway)); + // 断言:网关状态回滚,不联动子设备 + assertEquals(IotDeviceStateEnum.ONLINE.getState(), deviceMapper.selectById(gateway.getId()).getState()); + assertEquals(IotDeviceStateEnum.ONLINE.getState(), deviceMapper.selectById(subDevice.getId()).getState()); + verify(deviceMessageProducer, never()).sendDeviceMessage(any()); + } + + @Test + public void testHandleStateUpdate_afterCommitFailedSkipSubDevices() { + // mock 数据:网关在线;子设备在线 + IotDeviceDO gateway = insertGateway(); + IotDeviceDO subDevice = insertSubDevice(gateway, IotDeviceStateEnum.ONLINE.getState(), T0); + // 准备参数 + IotDeviceMessage message = buildStateMessage(gateway, IotDeviceStateEnum.OFFLINE.getState(), + T0.plusMinutes(10), "server-1"); + // mock 方法:提交后的回调失败(例如说清理缓存失败) + doAnswer(invocation -> { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + + @Override + public void afterCommit() { + throw new IllegalStateException("after commit failed"); + } + + }); + return null; + }).when(sceneRuleService).executeSceneRuleByDevice(any()); + + // 调用,并断言异常 + assertThrows(IllegalStateException.class, () -> deviceMessageService.handleUpstreamDeviceMessage(message, gateway)); + // 断言:网关状态已提交,但没有联动子设备 + assertEquals(IotDeviceStateEnum.OFFLINE.getState(), deviceMapper.selectById(gateway.getId()).getState()); + verify(deviceMessageProducer, never()).sendDeviceMessage(any()); + + // 调用:重投同一条消息 + deviceMessageService.handleUpstreamDeviceMessage(message, deviceMapper.selectById(gateway.getId())); + // 断言:已知边界,状态不再变化,子设备仍不联动(由离线检测兜底) + verify(deviceMessageProducer, never()).sendDeviceMessage(any()); + assertEquals(IotDeviceStateEnum.ONLINE.getState(), deviceMapper.selectById(subDevice.getId()).getState()); + } + + // ========== 设备状态:规则匹配的数据访问异常 ========== + + @Test + public void testHandleStateUpdate_triggerMatchDataAccessExceptionRollback() { + // mock 数据:网关在线;子设备在线 + IotDeviceDO gateway = insertGateway(); + IotDeviceDO subDevice = insertSubDevice(gateway, IotDeviceStateEnum.ONLINE.getState(), T0); + // 准备参数:网关离线;按产品配置的状态触发器,匹配时读取网关的设备缓存 + IotDeviceMessage message = buildStateMessage(gateway, IotDeviceStateEnum.OFFLINE.getState(), + T0.plusMinutes(10), "server-1"); + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); + trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_STATE_UPDATE.getType()); + trigger.setProductId(gateway.getProductId()); + trigger.setOperator(IotSceneRuleConditionOperatorEnum.EQUALS.getOperator()); + trigger.setValue(String.valueOf(IotDeviceStateEnum.OFFLINE.getState())); + // mock 方法:场景联动执行真实的触发器匹配;读取网关的设备缓存失败 + doAnswer(invocation -> sceneRuleMatcherManager.isMatched(invocation.getArgument(0), trigger)) + .when(sceneRuleService).executeSceneRuleByDevice(any()); + mockDeviceCacheGetFailed(gateway.getId()); + + // 调用,并断言异常:状态事务内,匹配器的数据访问异常不吞掉 + assertThrows(QueryTimeoutException.class, () -> deviceMessageService.handleUpstreamDeviceMessage(message, gateway)); + // 断言:网关状态回滚,不联动子设备 + assertEquals(IotDeviceStateEnum.ONLINE.getState(), deviceMapper.selectById(gateway.getId()).getState()); + assertEquals(IotDeviceStateEnum.ONLINE.getState(), deviceMapper.selectById(subDevice.getId()).getState()); + verify(deviceMessageProducer, never()).sendDeviceMessage(any()); + // 调用:非事务内,与原逻辑一致,按不匹配处理 + assertFalse(sceneRuleMatcherManager.isMatched(message, trigger)); + } + + @Test + public void testHandleStateUpdate_conditionMatchDataAccessExceptionRollback() { + // mock 数据:网关在线;子设备在线 + IotDeviceDO gateway = insertGateway(); + IotDeviceDO subDevice = insertSubDevice(gateway, IotDeviceStateEnum.ONLINE.getState(), T0); + // 准备参数:网关离线;按产品配置的设备状态条件,匹配时读取网关的设备缓存 + IotDeviceMessage message = buildStateMessage(gateway, IotDeviceStateEnum.OFFLINE.getState(), + T0.plusMinutes(10), "server-1"); + IotSceneRuleDO.TriggerCondition condition = new IotSceneRuleDO.TriggerCondition(); + condition.setType(IotSceneRuleConditionTypeEnum.DEVICE_STATE.getType()); + condition.setProductId(gateway.getProductId()); + condition.setOperator(IotSceneRuleConditionOperatorEnum.EQUALS.getOperator()); + condition.setParam(String.valueOf(IotDeviceStateEnum.OFFLINE.getState())); + // mock 方法:场景联动执行真实的条件匹配;读取网关的设备缓存失败 + doAnswer(invocation -> sceneRuleMatcherManager.isConditionMatched(invocation.getArgument(0), condition)) + .when(sceneRuleService).executeSceneRuleByDevice(any()); + mockDeviceCacheGetFailed(gateway.getId()); + + // 调用,并断言异常:状态事务内,匹配器的数据访问异常不吞掉 + assertThrows(QueryTimeoutException.class, () -> deviceMessageService.handleUpstreamDeviceMessage(message, gateway)); + // 断言:网关状态回滚,不联动子设备 + assertEquals(IotDeviceStateEnum.ONLINE.getState(), deviceMapper.selectById(gateway.getId()).getState()); + assertEquals(IotDeviceStateEnum.ONLINE.getState(), deviceMapper.selectById(subDevice.getId()).getState()); + verify(deviceMessageProducer, never()).sendDeviceMessage(any()); + // 调用:非事务内,与原逻辑一致,按不匹配处理 + assertFalse(sceneRuleMatcherManager.isConditionMatched(message, condition)); + } + + @Test + public void testHandleTopoDeleteMessage_subDeviceOffline() { + // mock 数据:网关;在线的子设备 + IotDeviceDO gateway = insertGateway(); + IotDeviceDO subDevice = insertSubDevice(gateway, IotDeviceStateEnum.ONLINE.getState(), T0); + // 准备参数 + IotDeviceMessage message = IotDeviceMessage.requestOf(IotDeviceMessageMethodEnum.TOPO_DELETE.getMethod(), + Collections.singletonMap("subDevices", + singletonList(new IotDeviceIdentity(subDevice.getProductKey(), subDevice.getDeviceName())))); + + // 调用 + List result = deviceService.handleTopoDeleteMessage(message, gateway); + // 断言:解绑 + assertEquals(1, result.size()); + assertNull(deviceMapper.selectById(subDevice.getId()).getGatewayId()); + // 断言:子设备离线走消息 + verify(deviceMessageProducer).sendDeviceMessage(argThat(subMessage -> + subDevice.getId().equals(subMessage.getDeviceId()) + && IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod().equals(subMessage.getMethod()) + && String.valueOf(IotDeviceStateEnum.OFFLINE.getState()).equals(IotDeviceMessageUtils.getIdentifier(subMessage)) + && subMessage.getReportTime() != null)); + } + + // ========== 辅助方法 ========== + + /** + * 模拟 Local 总线:发布的消息,在发布线程上同步处理;订阅者的异常被吞掉(与 IotLocalMessageBus 一致) + */ + private void mockLocalMessageBus() { + doAnswer(invocation -> { + IotDeviceMessage subMessage = invocation.getArgument(0); + // 断言:在网关的状态事务提交之后,才发送子设备消息 + assertFalse(TransactionSynchronizationManager.isActualTransactionActive()); + try { + deviceMessageService.handleUpstreamDeviceMessage(subMessage, deviceMapper.selectById(subMessage.getDeviceId())); + } catch (Exception ignored) { + } + return null; + }).when(deviceMessageProducer).sendDeviceMessage(any()); + } + + private Cache getDeviceCache() { + return cacheManager.getCache(RedisKeyConstants.DEVICE); + } + + /** + * 模拟读取指定设备的缓存失败(例如说 Redis 超时):异常经 Spring 缓存抛出,与线上 Redis 缓存故障一致 + */ + private void mockDeviceCacheGetFailed(Long deviceId) { + Cache deviceCache = spy(getDeviceCache()); + doThrow(new QueryTimeoutException("redis read timeout")).when(deviceCache).get(deviceId); + doReturn(deviceCache).when(cacheManager).getCache(RedisKeyConstants.DEVICE); + } + + private IotDeviceDO insertDevice(Integer state, LocalDateTime onlineTime, LocalDateTime offlineTime) { + IotDeviceDO device = buildDevice(); + device.setState(state); + device.setOnlineTime(onlineTime); + device.setOfflineTime(offlineTime); + device.setActiveTime(onlineTime); + deviceMapper.insert(device); + return deviceMapper.selectById(device.getId()); + } + + private IotDeviceDO insertGateway() { + IotDeviceDO gateway = IotDeviceDO.builder().deviceName("testGateway").productId(20L).productKey("testGatewayProduct") + .deviceType(IotProductDeviceTypeEnum.GATEWAY.getType()) + .state(IotDeviceStateEnum.ONLINE.getState()).onlineTime(T0).activeTime(T0).build(); + deviceMapper.insert(gateway); + return deviceMapper.selectById(gateway.getId()); + } + + private IotDeviceDO insertSubDevice(IotDeviceDO gateway, Integer state, LocalDateTime onlineTime) { + IotDeviceDO subDevice = IotDeviceDO.builder().deviceName("testSubDevice" + System.nanoTime()) + .productId(30L).productKey("testSubProduct") + .deviceType(IotProductDeviceTypeEnum.GATEWAY_SUB.getType()).gatewayId(gateway.getId()) + .state(state).onlineTime(onlineTime).activeTime(onlineTime).build(); + deviceMapper.insert(subDevice); + return deviceMapper.selectById(subDevice.getId()); + } + + private static IotDeviceMessage buildStateMessage(IotDeviceDO device, Integer state, + LocalDateTime reportTime, String serverId) { + IotDeviceMessage message = IotDeviceStateEnum.ONLINE.getState().equals(state) + ? IotDeviceMessage.buildStateUpdateOnline() : IotDeviceMessage.buildStateOffline(); + message.setId("msg-" + System.nanoTime()); + message.setDeviceId(device.getId()); + message.setReportTime(reportTime); + message.setServerId(serverId); + return message; + } + private static IotDeviceDO buildDevice() { return IotDeviceDO.builder().deviceName("testDevice").productId(10L).productKey("testProduct") .deviceType(IotProductDeviceTypeEnum.DIRECT.getType()).build(); diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImplTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImplTest.java index 449e923..fe89e06 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImplTest.java +++ b/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 import cn.iocoder.yudao.module.iot.controller.admin.statistics.vo.IotStatisticsDeviceMessageReqVO; import cn.iocoder.yudao.module.iot.controller.admin.statistics.vo.IotStatisticsDeviceMessageSummaryByDateRespVO; import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; +import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.core.mq.producer.IotDeviceMessageProducer; +import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO; import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; @@ -34,10 +37,12 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.same; @@ -443,6 +448,96 @@ public class IotDeviceMessageServiceImplTest extends BaseMockitoUnitTest { verify(deviceMessageProducer, never()).sendDeviceMessageToGateway(any(), any()); } + // ========== handleUpstreamDeviceMessage:设备状态 ========== + + @Test + public void testHandleUpstreamDeviceMessage_stateInvalid() { + // 准备参数:设备自报的非法状态 + IotDeviceDO device = buildDevice(); + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod()); + message.setServerId("server-1").setParams(Collections.singletonMap("state", 5)); + + try (MockedStatic springUtilMock = mockStatic(SpringUtil.class)) { + springUtilMock.when(() -> SpringUtil.getBean(IotDeviceMessageServiceImpl.class)).thenReturn(service); + // 调用:参数错误属于业务异常,记录日志后结束,不抛给消息总线 + assertDoesNotThrow(() -> service.handleUpstreamDeviceMessage(message, device)); + } + // 断言:不更新状态 + verify(deviceService, never()).updateDeviceState(any(), any(), any(), anyBoolean()); + } + + @Test + public void testHandleUpstreamDeviceMessage_staleServerOffline() { + // 准备参数:旧网关节点的离线 + IotDeviceDO device = buildDevice(); + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod()); + message.setServerId("server-old").setParams(Collections.singletonMap("state", IotDeviceStateEnum.OFFLINE.getState())); + // mock 方法:设备已经连接到新的网关节点 + when(productService.getProductFromCache(eq(3L))) + .thenReturn(new IotProductDO().setProtocolType(IotProtocolTypeEnum.MQTT.getType())); + when(devicePropertyService.getDeviceServerId(eq(2L))).thenReturn("server-new"); + + try (MockedStatic springUtilMock = mockStatic(SpringUtil.class)) { + springUtilMock.when(() -> SpringUtil.getBean(IotDeviceMessageServiceImpl.class)).thenReturn(service); + // 调用 + service.handleUpstreamDeviceMessage(message, device); + } + // 断言:忽略旧节点的离线 + verify(deviceService, never()).updateDeviceState(any(), any(), any(), anyBoolean()); + } + + @Test + public void testHandleUpstreamDeviceMessage_emqxOfflineNotCompareServerId() { + // 准备参数:EMQX 设备的离线(serverId 是接收 webhook 的节点) + IotDeviceDO device = buildDevice(); + LocalDateTime reportTime = LocalDateTime.of(2026, 10, 29, 10, 0, 0); + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod()); + message.setServerId("server-1").setReportTime(reportTime) + .setParams(Collections.singletonMap("state", IotDeviceStateEnum.OFFLINE.getState())); + // mock 方法 + when(productService.getProductFromCache(eq(3L))) + .thenReturn(new IotProductDO().setProtocolType(IotProtocolTypeEnum.EMQX.getType())); + + try (MockedStatic springUtilMock = mockStatic(SpringUtil.class)) { + springUtilMock.when(() -> SpringUtil.getBean(IotDeviceMessageServiceImpl.class)).thenReturn(service); + // 调用 + service.handleUpstreamDeviceMessage(message, device); + } + // 断言:不比较 serverId,按消息的事件时间更新状态 + verify(devicePropertyService, never()).getDeviceServerId(any()); + verify(deviceService).updateDeviceState(same(device), eq(IotDeviceStateEnum.OFFLINE.getState()), + eq(reportTime), eq(true)); + } + + @Test + public void testHandleUpstreamDeviceMessage_packKeepReportTime() { + // 准备参数:网关的批量上报,包含一个子设备 + IotDeviceDO gateway = buildDevice(); + LocalDateTime reportTime = LocalDateTime.of(2026, 10, 29, 10, 0, 0); + HashMap subDevice = new HashMap<>(); + subDevice.put("identity", new IotDeviceIdentity("sub-pk", "sub-dn")); + subDevice.put("properties", Collections.singletonMap("humidity", 60)); + HashMap params = new HashMap<>(); + params.put("properties", Collections.singletonMap("temperature", 25)); + params.put("subDevices", Collections.singletonList(subDevice)); + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.PROPERTY_PACK_POST.getMethod()); + message.setReportTime(reportTime).setParams(params); + // mock 方法 + when(deviceService.getDeviceFromCache(eq("sub-pk"), eq("sub-dn"))) + .thenReturn(IotDeviceDO.builder().id(5L).productId(6L).build()); + + try (MockedStatic springUtilMock = mockStatic(SpringUtil.class)) { + springUtilMock.when(() -> SpringUtil.getBean(IotDeviceMessageServiceImpl.class)).thenReturn(service); + // 调用 + service.handleUpstreamDeviceMessage(message, gateway); + } + // 断言:拆分出的消息,沿用 pack 的上报时间 + ArgumentCaptor captor = ArgumentCaptor.forClass(IotDeviceMessage.class); + verify(deviceMessageProducer, times(2)).sendDeviceMessage(captor.capture()); + assertEquals(Arrays.asList(2L, 5L), convertList(captor.getAllValues(), IotDeviceMessage::getDeviceId)); + captor.getAllValues().forEach(subMessage -> assertEquals(reportTime, subMessage.getReportTime())); + } + private void assertRawPayloadInvalid(IotDeviceMessage message) { ServiceException ex = assertThrows(ServiceException.class, () -> service.sendDeviceMessage(message, buildDevice(), null)); diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImplTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImplTest.java index 3bdc8c3..9aa0682 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImplTest.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImplTest.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.iot.service.rule.data; import cn.hutool.core.map.MapUtil; +import cn.hutool.extra.spring.SpringUtil; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; @@ -14,17 +15,30 @@ import cn.iocoder.yudao.module.iot.service.rule.data.action.IotDataRuleAction; import cn.iocoder.yudao.module.iot.service.thingmodel.IotThingModelService; import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; import org.springframework.context.annotation.Import; +import org.springframework.dao.QueryTimeoutException; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import java.util.Arrays; import java.util.Map; import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; import static java.util.Collections.singletonList; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -41,6 +55,8 @@ class IotDataRuleServiceImplTest extends BaseDbUnitTest { @Resource private IotDataRuleMapper dataRuleMapper; + @Resource + private PlatformTransactionManager transactionManager; @MockitoBean private IotDataSinkService dataSinkService; @@ -191,6 +207,93 @@ class IotDataRuleServiceImplTest extends BaseDbUnitTest { verify(dataRuleAction).execute(eq(message), eq(sink)); } + // ========== executeDataRule:状态事务内的数据访问异常 ========== + + @Test + public void testExecuteDataRule_actionDataAccessException() { + // 准备参数 + Long deviceId = randomLongId(); + String identifier = "temperature"; + IotDeviceMessage message = createPropertyPostMessage(deviceId, + MapUtil.builder().put(identifier, 25.5).build()); + // mock 数据 + Long sinkId = randomLongId(); + insertEnabledRule(deviceId, IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), identifier, sinkId); + // mock 方法:数据目的访问数据失败 + IotDataSinkDO sink = mockEnabledSink(sinkId); + doThrow(new QueryTimeoutException("data access failed")).when(dataRuleAction).execute(eq(message), eq(sink)); + + // 调用,并断言异常:状态事务内,数据访问异常不吞掉,交给事务回滚 + new TransactionTemplate(transactionManager).executeWithoutResult(status -> + assertThrows(QueryTimeoutException.class, () -> dataRuleService.executeDataRule(message))); + // 调用:非事务内,与原逻辑一致,记录日志后结束 + assertDoesNotThrow(() -> dataRuleService.executeDataRule(message)); + } + + @Test + public void testExecuteDataRule_sinkDataAccessException() { + // 准备参数 + Long deviceId = randomLongId(); + String identifier = "temperature"; + IotDeviceMessage message = createPropertyPostMessage(deviceId, + MapUtil.builder().put(identifier, 25.5).build()); + // mock 数据:一条规则,两个数据目的 + Long sinkId1 = randomLongId(); + Long sinkId2 = randomLongId(); + insertEnabledRule(deviceId, IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), identifier, sinkId1, sinkId2); + // mock 方法:读取第一个数据目的失败(例如说 Redis 超时);第二个数据目的正常 + when(dataSinkService.getDataSinkFromCache(sinkId1)).thenThrow(new QueryTimeoutException("sink query failed")); + IotDataSinkDO sink2 = mockEnabledSink(sinkId2); + + // 调用,并断言异常:状态事务内,不吞掉,后续数据目的不再执行 + new TransactionTemplate(transactionManager).executeWithoutResult(status -> + assertThrows(QueryTimeoutException.class, () -> dataRuleService.executeDataRule(message))); + verify(dataRuleAction, never()).execute(any(), any()); + // 调用:非事务内,与原逻辑一致,记录日志后继续执行第二个数据目的 + assertDoesNotThrow(() -> dataRuleService.executeDataRule(message)); + verify(dataRuleAction, times(1)).execute(eq(message), eq(sink2)); + } + + @Test + public void testExecuteDataRule_matchDataAccessException() { + // 准备参数 + IotDeviceMessage message = createPropertyPostMessage(randomLongId(), + MapUtil.builder().put("temperature", 25.5).build()); + // mock 方法:匹配规则时访问数据失败(getSelf 的缓存方法) + IotDataRuleServiceImpl self = mock(IotDataRuleServiceImpl.class); + when(self.getDataRuleListByConditionFromCache(anyLong(), anyString(), any())) + .thenThrow(new QueryTimeoutException("rule query failed")); + + try (MockedStatic springUtilMock = mockStatic(SpringUtil.class)) { + springUtilMock.when(() -> SpringUtil.getBean(IotDataRuleServiceImpl.class)).thenReturn(self); + // 调用,并断言异常:状态事务内,不吞掉 + new TransactionTemplate(transactionManager).executeWithoutResult(status -> + assertThrows(QueryTimeoutException.class, () -> dataRuleService.executeDataRule(message))); + // 调用:非事务内,与原逻辑一致,记录日志后结束 + assertDoesNotThrow(() -> dataRuleService.executeDataRule(message)); + } + verify(dataRuleAction, never()).execute(any(), any()); + } + + @Test + public void testExecuteDataRule_otherExceptionInTransaction() { + // 准备参数 + Long deviceId = randomLongId(); + String identifier = "temperature"; + IotDeviceMessage message = createPropertyPostMessage(deviceId, + MapUtil.builder().put(identifier, 25.5).build()); + // mock 数据 + Long sinkId = randomLongId(); + insertEnabledRule(deviceId, IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), identifier, sinkId); + // mock 方法:数据目的的其它异常(例如说参数错误) + IotDataSinkDO sink = mockEnabledSink(sinkId); + doThrow(new IllegalArgumentException("config invalid")).when(dataRuleAction).execute(eq(message), eq(sink)); + + // 调用:与原逻辑一致,记录日志后继续 + new TransactionTemplate(transactionManager).executeWithoutResult(status -> + assertDoesNotThrow(() -> dataRuleService.executeDataRule(message))); + } + // ========== 辅助方法 ========== private IotDeviceMessage createPropertyPostMessage(Long deviceId, Map params) { @@ -202,9 +305,9 @@ class IotDataRuleServiceImplTest extends BaseDbUnitTest { } /** - * 向 H2 中插入一条启用状态的数据流转规则,命中后会路由到 {@code sinkId} + * 向 H2 中插入一条启用状态的数据流转规则,命中后会路由到 {@code sinkIds} */ - private void insertEnabledRule(Long deviceId, String method, String identifier, Long sinkId) { + private void insertEnabledRule(Long deviceId, String method, String identifier, Long... sinkIds) { IotDataRuleDO.SourceConfig config = randomPojo(IotDataRuleDO.SourceConfig.class, o -> { o.setDeviceId(deviceId); o.setMethod(method); @@ -214,7 +317,7 @@ class IotDataRuleServiceImplTest extends BaseDbUnitTest { o.setId(null); o.setStatus(CommonStatusEnum.ENABLE.getStatus()); o.setSourceConfigs(singletonList(config)); - o.setSinkIds(singletonList(sinkId)); + o.setSinkIds(Arrays.asList(sinkIds)); }); dataRuleMapper.insert(rule); } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotHttpDataSinkActionTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotHttpDataSinkActionTest.java new file mode 100644 index 0000000..d493a4b --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotHttpDataSinkActionTest.java @@ -0,0 +1,49 @@ +package cn.iocoder.yudao.module.iot.service.rule.data.action; + +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotDataSinkDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkHttpConfig; +import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum; +import org.junit.jupiter.api.Test; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.HttpMethod; +import org.springframework.test.util.ReflectionTestUtils; + +import java.net.InetAddress; +import java.net.ServerSocket; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@link IotHttpDataSinkAction} 的单元测试 + * + * @author 芋道源码 + */ +public class IotHttpDataSinkActionTest { + + @Test + public void testExecute_readTimeout() throws Exception { + // 准备参数:本地只建立连接、不响应的 server(不 accept,由 backlog 完成连接) + try (ServerSocket serverSocket = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { + IotHttpDataSinkAction action = new IotHttpDataSinkAction(); + ReflectionTestUtils.setField(action, "restTemplateBuilder", new RestTemplateBuilder()); + action.init(); + IotDataSinkHttpConfig config = new IotDataSinkHttpConfig() + .setUrl("http://127.0.0.1:" + serverSocket.getLocalPort() + "/hook") + .setMethod(HttpMethod.GET.name()); + IotDataSinkDO dataSink = new IotDataSinkDO().setId(1L).setType(IotDataSinkTypeEnum.HTTP.getType()) + .setConfig(config); + IotDeviceMessage message = new IotDeviceMessage().setId("msg-1").setDeviceId(2L).setTenantId(1L); + + // 调用:超时异常由 action 内部记录日志,不抛出 + long start = System.currentTimeMillis(); + action.execute(message, dataSink); + long cost = System.currentTimeMillis() - start; + // 断言:按读取超时返回,不会无限等待 + long readTimeout = IotHttpDataSinkAction.READ_TIMEOUT.toMillis(); + assertTrue(cost >= readTimeout - 500, "耗时过短:" + cost); + assertTrue(cost < readTimeout + IotHttpDataSinkAction.CONNECT_TIMEOUT.toMillis(), "耗时过长:" + cost); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceSimpleTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceSimpleTest.java index 36496b2..a6f4317 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceSimpleTest.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceSimpleTest.java @@ -1,11 +1,17 @@ package cn.iocoder.yudao.module.iot.service.rule.scene; +import cn.hutool.core.util.ReflectUtil; +import cn.hutool.extra.spring.SpringUtil; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.scene.IotSceneRuleSaveReqVO; import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleActionTypeEnum; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; import cn.iocoder.yudao.module.iot.dal.mysql.rule.IotSceneRuleMapper; import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; import cn.iocoder.yudao.module.iot.service.product.IotProductService; @@ -14,9 +20,12 @@ import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatche import cn.iocoder.yudao.module.iot.service.rule.scene.timer.IotSceneRuleTimerHandler; import cn.iocoder.yudao.module.iot.service.rule.scene.timer.IotTimerConditionEvaluator; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.MockedStatic; import org.springframework.dao.QueryTimeoutException; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.util.Collections; import java.util.List; @@ -27,6 +36,7 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; /** @@ -54,6 +64,8 @@ public class IotSceneRuleServiceSimpleTest extends BaseMockitoUnitTest { private IotProductService productService; @Mock private IotDeviceService deviceService; + @Mock + private IotSceneRuleAction sceneRuleAction; @Test public void testCreateScene_rule_success() { @@ -247,4 +259,139 @@ public class IotSceneRuleServiceSimpleTest extends BaseMockitoUnitTest { verifyNoInteractions(sceneRuleMapper, productService, sceneRuleMatcherManager, sceneRuleActions); } + // ========== executeSceneRuleByDevice:状态事务内的数据访问异常 ========== + // 说明:以下用例只标记“存在实际事务”,验证各 catch 的判断与原语义;不是真实的数据库事务, + // 真实的回滚由 IotDeviceServiceImplTest 覆盖 + + @Test + public void testExecuteSceneRuleByDevice_triggerMatchDataAccessException() { + // 准备参数 + IotDeviceMessage message = buildPropertyPostMessage(); + IotSceneRuleDO sceneRule = buildSceneRule(null); + // mock 方法:触发器匹配时访问数据失败 + when(sceneRuleMatcherManager.isMatched(eq(message), any())).thenThrow(new QueryTimeoutException("matcher failed")); + + try (MockedStatic ignored = mockMatchedSceneRule(message, sceneRule)) { + // 调用,并断言异常:状态事务内,不吞掉 + assertThrows(QueryTimeoutException.class, + () -> executeInActualTransaction(() -> sceneRuleService.executeSceneRuleByDevice(message))); + // 调用:非事务内,与原逻辑一致,记录日志后视为不匹配 + assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByDevice(message)); + } + // 断言:没有执行动作 + verifyNoInteractions(sceneRuleAction); + verify(sceneRuleMatcherManager, never()).isConditionMatched(any(), any()); + } + + @Test + public void testExecuteSceneRuleByDevice_conditionMatchDataAccessException() { + // 准备参数:触发器带一个条件 + IotDeviceMessage message = buildPropertyPostMessage(); + IotSceneRuleDO sceneRule = buildSceneRule(Collections.singletonList( + Collections.singletonList(new IotSceneRuleDO.TriggerCondition()))); + // mock 方法:触发器匹配;条件匹配时访问数据失败 + when(sceneRuleMatcherManager.isMatched(eq(message), any())).thenReturn(true); + when(sceneRuleMatcherManager.isConditionMatched(eq(message), any())).thenThrow(new QueryTimeoutException("condition failed")); + + try (MockedStatic ignored = mockMatchedSceneRule(message, sceneRule)) { + // 调用,并断言异常:状态事务内,不吞掉 + assertThrows(QueryTimeoutException.class, + () -> executeInActualTransaction(() -> sceneRuleService.executeSceneRuleByDevice(message))); + // 调用:非事务内,与原逻辑一致,视为条件不满足 + assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByDevice(message)); + } + // 断言:没有执行动作 + verifyNoInteractions(sceneRuleAction); + } + + @Test + public void testExecuteSceneRuleByDevice_actionDataAccessException() throws Exception { + // 准备参数 + IotDeviceMessage message = buildPropertyPostMessage(); + IotSceneRuleDO sceneRule = buildSceneRule(null); + // mock 方法:触发器匹配;动作执行时访问数据失败 + when(sceneRuleMatcherManager.isMatched(eq(message), any())).thenReturn(true); + mockSceneRuleAction(); + doThrow(new QueryTimeoutException("action failed")).when(sceneRuleAction).execute(any(), any(), any()); + + try (MockedStatic ignored = mockMatchedSceneRule(message, sceneRule)) { + // 调用,并断言异常:状态事务内,不吞掉,也不再更新最后触发时间 + assertThrows(QueryTimeoutException.class, + () -> executeInActualTransaction(() -> sceneRuleService.executeSceneRuleByDevice(message))); + verify(sceneRuleMapper, never()).updateById(any(IotSceneRuleDO.class)); + // 调用:非事务内,与原逻辑一致,记录日志后继续更新最后触发时间 + assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByDevice(message)); + } + verify(sceneRuleMapper, times(1)).updateById(any(IotSceneRuleDO.class)); + } + + @Test + public void testExecuteSceneRuleByDevice_updateLastTriggerTimeDataAccessException() throws Exception { + // 准备参数 + IotDeviceMessage message = buildPropertyPostMessage(); + IotSceneRuleDO sceneRule = buildSceneRule(null); + // mock 方法:动作执行成功;更新最后触发时间失败(例如说热点行的锁等待超时) + when(sceneRuleMatcherManager.isMatched(eq(message), any())).thenReturn(true); + mockSceneRuleAction(); + when(sceneRuleMapper.updateById(any(IotSceneRuleDO.class))).thenThrow(new QueryTimeoutException("update failed")); + + try (MockedStatic ignored = mockMatchedSceneRule(message, sceneRule)) { + // 调用,并断言异常:状态事务内,不吞掉 + assertThrows(QueryTimeoutException.class, + () -> executeInActualTransaction(() -> sceneRuleService.executeSceneRuleByDevice(message))); + // 调用:非事务内,与原逻辑一致,记录日志后结束 + assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByDevice(message)); + } + verify(sceneRuleAction, times(2)).execute(same(message), same(sceneRule), any()); + } + + // ========== 辅助方法 ========== + + /** + * 标记“存在实际事务”后执行:只用于验证 catch 的判断条件,不是真实的数据库事务 + */ + private static void executeInActualTransaction(Executable executable) throws Throwable { + TransactionSynchronizationManager.setActualTransactionActive(true); + try { + executable.execute(); + } finally { + TransactionSynchronizationManager.setActualTransactionActive(false); + } + } + + /** + * mock 消息对应的设备、产品,以及匹配的规则场景(getSelf 的缓存方法) + */ + private MockedStatic mockMatchedSceneRule(IotDeviceMessage message, IotSceneRuleDO sceneRule) { + IotDeviceDO device = IotDeviceDO.builder().id(message.getDeviceId()).productId(randomLongId()).build(); + device.setTenantId(1L); + when(deviceService.getDeviceFromCache(message.getDeviceId())).thenReturn(device); + when(productService.getProductFromCache(device.getProductId())) + .thenReturn(IotProductDO.builder().id(device.getProductId()).build()); + IotSceneRuleServiceImpl self = mock(IotSceneRuleServiceImpl.class); + when(self.getSceneRuleListByProductIdAndDeviceIdFromCache(device.getProductId(), device.getId())) + .thenReturn(Collections.singletonList(sceneRule)); + MockedStatic springUtilMock = mockStatic(SpringUtil.class); + springUtilMock.when(() -> SpringUtil.getBean(IotSceneRuleServiceImpl.class)).thenReturn(self); + return springUtilMock; + } + + private void mockSceneRuleAction() { + when(sceneRuleAction.getType()).thenReturn(IotSceneRuleActionTypeEnum.ALERT_TRIGGER); + ReflectUtil.setFieldValue(sceneRuleService, "sceneRuleActions", Collections.singletonList(sceneRuleAction)); + } + + private static IotDeviceMessage buildPropertyPostMessage() { + return new IotDeviceMessage().setDeviceId(randomLongId()) + .setMethod(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()); + } + + private static IotSceneRuleDO buildSceneRule(List> conditionGroups) { + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger() + .setType(IotSceneRuleTriggerTypeEnum.DEVICE_PROPERTY_POST.getType()).setConditionGroups(conditionGroups); + IotSceneRuleDO.Action action = new IotSceneRuleDO.Action().setType(IotSceneRuleActionTypeEnum.ALERT_TRIGGER.getType()); + return new IotSceneRuleDO().setId(randomLongId()).setStatus(CommonStatusEnum.ENABLE.getStatus()) + .setTriggers(Collections.singletonList(trigger)).setActions(Collections.singletonList(action)); + } + } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotAlertTriggerSceneRuleActionTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotAlertTriggerSceneRuleActionTest.java index 5b2a4cb..b0eaf32 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotAlertTriggerSceneRuleActionTest.java +++ b/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 import cn.iocoder.yudao.module.system.api.sms.SmsSendApi; import cn.iocoder.yudao.module.system.api.sms.dto.send.SmsSendSingleToUserReqDTO; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockedStatic; +import org.springframework.dao.QueryTimeoutException; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.time.LocalDateTime; import java.util.Arrays; @@ -31,7 +34,9 @@ import java.util.Collections; import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; @@ -261,6 +266,53 @@ public class IotAlertTriggerSceneRuleActionTest extends BaseMockitoUnitTest { verify(mailSendApi, times(1)).sendSingleMailToAdmin(any()); } + @Test + public void testExecute_smsDataAccessException() { + // 准备参数 + Long userId = randomLongId(); + IotDeviceMessage message = createDeviceMessage(); + IotSceneRuleDO rule = randomPojo(IotSceneRuleDO.class); + IotSceneRuleDO.Action actionConfig = randomPojo(IotSceneRuleDO.Action.class); + IotAlertConfigDO config = randomPojo(IotAlertConfigDO.class, c -> { + c.setReceiveUserIds(Collections.singletonList(userId)); + c.setReceiveTypes(Arrays.asList( + IotAlertReceiveTypeEnum.SMS.getType(), + IotAlertReceiveTypeEnum.MAIL.getType())); + }); + IotDeviceDO device = randomPojo(IotDeviceDO.class); + + // mock 行为:sms 写发送日志失败(单体部署下,访问的是同一个数据库) + when(alertConfigService.getAlertConfigListBySceneRuleIdAndStatus(rule.getId(), CommonStatusEnum.ENABLE.getStatus())) + .thenReturn(Collections.singletonList(config)); + when(deviceService.getDeviceFromCache(message.getDeviceId())).thenReturn(device); + when(smsSendApi.sendSingleSmsToAdmin(any())).thenThrow(new QueryTimeoutException("sms log failed")); + + try (MockedStatic dictMock = mockStatic(DictFrameworkUtils.class)) { + dictMock.when(() -> DictFrameworkUtils.parseDictDataLabel(any(), any(Integer.class))) + .thenReturn("ERROR"); + // 调用,并断言异常:状态事务内,不吞掉,后续通道不再发送 + // 说明:只标记“存在实际事务”,验证 catch 的判断;不是真实的数据库事务,真实的回滚由 IotDeviceServiceImplTest 覆盖 + assertThrows(QueryTimeoutException.class, + () -> executeInActualTransaction(() -> action.execute(message, rule, actionConfig))); + verify(mailSendApi, never()).sendSingleMailToAdmin(any()); + // 调用:非事务内,与原逻辑一致,记录日志后继续发送邮件 + assertDoesNotThrow(() -> action.execute(message, rule, actionConfig)); + } + verify(mailSendApi, times(1)).sendSingleMailToAdmin(any()); + } + + /** + * 标记“存在实际事务”后执行:只用于验证 catch 的判断条件,不是真实的数据库事务 + */ + private static void executeInActualTransaction(Executable executable) throws Throwable { + TransactionSynchronizationManager.setActualTransactionActive(true); + try { + executable.execute(); + } finally { + TransactionSynchronizationManager.setActualTransactionActive(false); + } + } + /** * 创建带 reportTime 的设备消息 */ diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotDevicePropertySetSceneRuleActionTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotDevicePropertySetSceneRuleActionTest.java new file mode 100644 index 0000000..342271b --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotDevicePropertySetSceneRuleActionTest.java @@ -0,0 +1,78 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene.action; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.dao.QueryTimeoutException; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.*; + +/** + * {@link IotDevicePropertySetSceneRuleAction} 的单元测试 + * + * 说明:用例只标记“存在实际事务”,验证 catch 的判断与原语义;不是真实的数据库事务,真实的回滚由 IotDeviceServiceImplTest 覆盖 + * + * @author 芋道源码 + */ +public class IotDevicePropertySetSceneRuleActionTest extends BaseMockitoUnitTest { + + @InjectMocks + private IotDevicePropertySetSceneRuleAction action; + + @Mock + private IotDeviceService deviceService; + @Mock + private IotDeviceMessageService deviceMessageService; + + @Test + public void testExecute_sendDataAccessException() { + // 准备参数 + IotDeviceDO device = IotDeviceDO.builder().id(randomLongId()).build(); + IotSceneRuleDO rule = new IotSceneRuleDO().setId(randomLongId()); + IotSceneRuleDO.Action actionConfig = buildActionConfig(device.getId()); + // mock 方法:发送时读取 serverId 的 Redis 故障 + when(deviceService.getDeviceFromCache(device.getId())).thenReturn(device); + when(deviceMessageService.sendDeviceMessage(argThat((IotDeviceMessage m) -> + IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod().equals(m.getMethod())), same(device))) + .thenThrow(new QueryTimeoutException("redis failed")); + + // 调用,并断言异常:状态事务内,不吞掉 + assertThrows(QueryTimeoutException.class, + () -> executeInActualTransaction(() -> action.execute(null, rule, actionConfig))); + // 调用:非事务内,与原逻辑一致,记录日志后结束 + assertDoesNotThrow(() -> action.execute(null, rule, actionConfig)); + } + + // ========== 辅助方法 ========== + + /** + * 标记“存在实际事务”后执行:只用于验证 catch 的判断条件,不是真实的数据库事务 + */ + private static void executeInActualTransaction(Executable executable) throws Throwable { + TransactionSynchronizationManager.setActualTransactionActive(true); + try { + executable.execute(); + } finally { + TransactionSynchronizationManager.setActualTransactionActive(false); + } + } + + private static IotSceneRuleDO.Action buildActionConfig(Long deviceId) { + return new IotSceneRuleDO.Action().setDeviceId(deviceId).setIdentifier("power").setParams("1"); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotDeviceServiceInvokeSceneRuleActionTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotDeviceServiceInvokeSceneRuleActionTest.java new file mode 100644 index 0000000..20f1b7d --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotDeviceServiceInvokeSceneRuleActionTest.java @@ -0,0 +1,78 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene.action; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.dao.QueryTimeoutException; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.*; + +/** + * {@link IotDeviceServiceInvokeSceneRuleAction} 的单元测试 + * + * 说明:用例只标记“存在实际事务”,验证 catch 的判断与原语义;不是真实的数据库事务,真实的回滚由 IotDeviceServiceImplTest 覆盖 + * + * @author 芋道源码 + */ +public class IotDeviceServiceInvokeSceneRuleActionTest extends BaseMockitoUnitTest { + + @InjectMocks + private IotDeviceServiceInvokeSceneRuleAction action; + + @Mock + private IotDeviceService deviceService; + @Mock + private IotDeviceMessageService deviceMessageService; + + @Test + public void testExecute_sendDataAccessException() { + // 准备参数 + IotDeviceDO device = IotDeviceDO.builder().id(randomLongId()).build(); + IotSceneRuleDO rule = new IotSceneRuleDO().setId(randomLongId()); + IotSceneRuleDO.Action actionConfig = buildActionConfig(device.getId()); + // mock 方法:发送时读取 serverId 的 Redis 故障 + when(deviceService.getDeviceFromCache(device.getId())).thenReturn(device); + when(deviceMessageService.sendDeviceMessage(argThat((IotDeviceMessage m) -> + IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod().equals(m.getMethod())), same(device))) + .thenThrow(new QueryTimeoutException("redis failed")); + + // 调用,并断言异常:状态事务内,不吞掉 + assertThrows(QueryTimeoutException.class, + () -> executeInActualTransaction(() -> action.execute(null, rule, actionConfig))); + // 调用:非事务内,与原逻辑一致,记录日志后结束 + assertDoesNotThrow(() -> action.execute(null, rule, actionConfig)); + } + + // ========== 辅助方法 ========== + + /** + * 标记“存在实际事务”后执行:只用于验证 catch 的判断条件,不是真实的数据库事务 + */ + private static void executeInActualTransaction(Executable executable) throws Throwable { + TransactionSynchronizationManager.setActualTransactionActive(true); + try { + executable.execute(); + } finally { + TransactionSynchronizationManager.setActualTransactionActive(false); + } + } + + private static IotSceneRuleDO.Action buildActionConfig(Long deviceId) { + return new IotSceneRuleDO.Action().setDeviceId(deviceId).setIdentifier("reboot").setParams("{}"); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/IotDeviceCommonApi.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/IotDeviceCommonApi.java index c0b3f9d..9d95bf0 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/IotDeviceCommonApi.java +++ b/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.*; import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO; import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterRespDTO; import cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterRespDTO; +import jakarta.validation.Valid; import java.util.List; @@ -55,4 +56,12 @@ public interface IotDeviceCommonApi { */ CommonResult> getModbusDeviceConfigList(IotModbusDeviceConfigListReqDTO listReqDTO); + /** + * 续期网关协议实例的存活标记 + * + * @param reqDTO 各协议实例的启动时间 + * @return 是否全部续期成功;部分失败时为 false + */ + CommonResult keepAliveGatewayServers(@Valid IotGatewayServerAliveReqDTO reqDTO); + } diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotGatewayServerAliveReqDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotGatewayServerAliveReqDTO.java new file mode 100644 index 0000000..63a1f6b --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotGatewayServerAliveReqDTO.java @@ -0,0 +1,29 @@ +package cn.iocoder.yudao.module.iot.core.biz.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.Data; + +import java.util.Map; + +/** + * IoT 网关协议实例的存活上报 Request DTO + * + * @author 芋道源码 + */ +@Data +public class IotGatewayServerAliveReqDTO { + + /** + * 协议实例的启动时间(毫秒) + * + * key:协议实例的 serverId + * value:协议实例本次 start 前取的时间,续期期间不变 + */ + @NotEmpty(message = "启动时间不能为空") + private Map<@NotBlank(message = "serverId 不能为空") String, + @NotNull(message = "启动时间不能为空") @Positive(message = "启动时间必须大于 0") Long> startTimes; + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotProtocolTypeEnum.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotProtocolTypeEnum.java index f9c6936..19d3525 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotProtocolTypeEnum.java +++ b/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; @Getter public enum IotProtocolTypeEnum implements ArrayValuable { - TCP("tcp"), - UDP("udp"), - WEBSOCKET("websocket"), - HTTP("http"), - MQTT("mqtt"), - EMQX("emqx"), - COAP("coap"), - MODBUS_TCP_CLIENT("modbus_tcp_client"), - MODBUS_TCP_SERVER("modbus_tcp_server"); + TCP("tcp", true), + UDP("udp", false), + WEBSOCKET("websocket", true), + HTTP("http", false), + MQTT("mqtt", true), + EMQX("emqx", true), + COAP("coap", false), + MODBUS_TCP_CLIENT("modbus_tcp_client", true), + MODBUS_TCP_SERVER("modbus_tcp_server", true); public static final String[] ARRAYS = Arrays.stream(values()).map(IotProtocolTypeEnum::getType).toArray(String[]::new); @@ -34,6 +34,10 @@ public enum IotProtocolTypeEnum implements ArrayValuable { * 类型 */ private final String type; + /** + * 是否是连接型协议:在线状态由连接的建立和断开决定;其它(HTTP、CoAP、UDP)为活跃型,由离线任务按最后活跃时间判断 + */ + private final boolean connectionBased; @Override public String[] array() { diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtils.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtils.java index 10de46a..507da7f 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtils.java +++ b/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; import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; +import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import java.util.Base64; @@ -74,6 +75,17 @@ public class IotDeviceMessageUtils { IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod()); } + /** + * 是否是设备离线的状态消息:方法为 STATE_UPDATE,且状态为离线 + * + * @param message 消息 + * @return 是否 + */ + public static boolean isStateOffline(IotDeviceMessage message) { + return StrUtil.equals(message.getMethod(), IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod()) + && StrUtil.equals(getIdentifier(message), String.valueOf(IotDeviceStateEnum.OFFLINE.getState())); + } + // ========== 透传(raw)相关 ========== /** @@ -446,6 +458,18 @@ public class IotDeviceMessageUtils { // ========== Topic 相关 ========== + /** + * 网关协议实例的存活标记,采用 STRING 结构:biz 接收网关上报(IotDeviceCommonApi#keepAliveGatewayServers)后写入,biz 离线任务读取 + * + * KEY 格式:iot:gateway_server:{serverId} + * VALUE 数据类型:String 协议实例本次启动的时间(毫秒),由网关上报,不是 biz 的接收时间 + * 过期:网关定期上报续期;网关宕机、停止后不再续期,自动过期 + * + * 用途:只供离线检查任务(IotDeviceOfflineCheckJob)判断网关是否已停止或重启过,处理未重连的旧设备; + * 不参与普通上下线的状态事务 + */ + private static final String GATEWAY_SERVER_ALIVE_KEY = "iot:gateway_server:%s"; + public static String buildMessageBusGatewayDeviceMessageTopic(String serverId) { return String.format(IotDeviceMessage.MESSAGE_BUS_GATEWAY_DEVICE_MESSAGE_TOPIC, serverId); } @@ -462,4 +486,14 @@ public class IotDeviceMessageUtils { return serverId.replaceAll("\\.", "_"); } + /** + * 构建网关协议实例的存活标记 Key + * + * @param serverId 服务器编号 + * @return Redis Key + */ + public static String buildGatewayServerAliveKey(String serverId) { + return String.format(GATEWAY_SERVER_ALIVE_KEY, serverId); + } + } \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtilsTest.java b/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtilsTest.java index 3764851..8a11556 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtilsTest.java +++ b/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; import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; +import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.core.topic.event.IotDeviceEventPostReqDTO; import org.junit.jupiter.api.Test; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Set; @@ -397,6 +399,20 @@ public class IotDeviceMessageUtilsTest { } @Test + public void testIsStateOffline() { + // 离线的状态消息 + assertTrue(IotDeviceMessageUtils.isStateOffline(IotDeviceMessage.buildStateOffline())); + // 在线的状态消息、没有状态值的状态消息,不是 + assertFalse(IotDeviceMessageUtils.isStateOffline(IotDeviceMessage.buildStateUpdateOnline())); + assertFalse(IotDeviceMessageUtils.isStateOffline( + IotDeviceMessage.requestOf(IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod()))); + // 其它方法的消息,即使带有离线的 state 参数,也不是 + assertFalse(IotDeviceMessageUtils.isStateOffline(IotDeviceMessage.requestOf( + IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), + Collections.singletonMap("state", IotDeviceStateEnum.OFFLINE.getState())))); + } + + @Test public void testValidateRawPayload() { // 通用协议:1~4096 字节 IotDeviceMessageUtils.validateRawPayload(IotProtocolTypeEnum.TCP, new byte[]{0x01}); diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java index 5c2fd86..c1456ce 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.iot.gateway.config; +import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocolManager; import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializerManager; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -21,8 +22,9 @@ public class IotGatewayConfiguration { } @Bean - public IotProtocolManager iotProtocolManager(IotGatewayProperties gatewayProperties) { - return new IotProtocolManager(gatewayProperties); + public IotProtocolManager iotProtocolManager(IotGatewayProperties gatewayProperties, + IotDeviceCommonApi deviceApi) { + return new IotProtocolManager(gatewayProperties, deviceApi); } } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocolManager.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocolManager.java index 310533d..8269a3a 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocolManager.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocolManager.java @@ -1,7 +1,11 @@ package cn.iocoder.yudao.module.iot.gateway.protocol; import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.thread.NamedThreadFactory; import cn.hutool.core.util.BooleanUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotGatewayServerAliveReqDTO; import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapProtocol; @@ -17,8 +21,10 @@ import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.context.SmartLifecycle; -import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.concurrent.*; /** * IoT 协议管理器:负责根据配置创建和管理协议实例 @@ -28,18 +34,42 @@ import java.util.List; @Slf4j public class IotProtocolManager implements SmartLifecycle { + /** + * 存活标记的续期间隔(秒) + * + * 存活标记由 biz 写入自己的 Redis,过期时间见 DeviceServerIdRedisDAO + */ + static final long ALIVE_INTERVAL_SECONDS = 20; + private final IotGatewayProperties gatewayProperties; + private final IotDeviceCommonApi deviceApi; + /** * 协议实例列表 + * + * 使用 CopyOnWriteArrayList 的原因:续期线程会并发遍历 + */ + private final List protocols = new CopyOnWriteArrayList<>(); + + /** + * 协议实例的启动时间(毫秒) + * + * 每次 start 成功都记录:biz 的离线任务据此判断,设备上线之后,协议实例是否重启过 + */ + private final Map startTimes = new ConcurrentHashMap<>(); + + /** + * 存活标记的续期线程 */ - private final List protocols = new ArrayList<>(); + private ScheduledExecutorService aliveExecutor; @Getter private volatile boolean running = false; - public IotProtocolManager(IotGatewayProperties gatewayProperties) { + public IotProtocolManager(IotGatewayProperties gatewayProperties, IotDeviceCommonApi deviceApi) { this.gatewayProperties = gatewayProperties; + this.deviceApi = deviceApi; } @Override @@ -62,9 +92,15 @@ public class IotProtocolManager implements SmartLifecycle { if (protocol == null) { continue; } + // 启动前取时间:保证启动后才连上的设备,上线时间都不早于它 + long startTime = System.currentTimeMillis(); protocol.start(); + startTimes.put(protocol, startTime); protocols.add(protocol); } + // 定时续期存活标记 + aliveExecutor = Executors.newSingleThreadScheduledExecutor(new NamedThreadFactory("iot-gateway-alive-", true)); + aliveExecutor.scheduleWithFixedDelay(this::writeAlive, 0, ALIVE_INTERVAL_SECONDS, TimeUnit.SECONDS); running = true; log.info("[start][协议管理器启动完成,共启动 {} 个协议实例]", protocols.size()); } @@ -74,6 +110,9 @@ public class IotProtocolManager implements SmartLifecycle { if (!running) { return; } + // 先停止续期,再停止协议:停止后存活标记不再续期,过期后 biz 的离线任务即可判定 + // 注意:已经发出的上报不会被撤销,biz 仍可能稍后写入,存活标记会相应晚一些过期 + stopAliveExecutor(); for (IotProtocol protocol : protocols) { try { protocol.stop(); @@ -82,10 +121,66 @@ public class IotProtocolManager implements SmartLifecycle { } } protocols.clear(); + startTimes.clear(); running = false; log.info("[stop][协议管理器已停止]"); } + // DONE @AI:方法注释;(已补充) + /** + * 停止存活标记的续期线程:最多等待 5 秒 + * + * 只停止线程,不撤销已经发出的上报 + */ + private void stopAliveExecutor() { + if (aliveExecutor == null) { + return; + } + aliveExecutor.shutdownNow(); + try { + if (!aliveExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + log.warn("[stopAliveExecutor][存活标记续期线程未能在 5 秒内退出]"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + aliveExecutor = null; + } + + /** + * 续期各协议实例的存活标记:一轮一次批量上报给 biz,值为协议实例的启动时间 + * + * EMQX 的连接由 EMQX 维护,不上报存活标记 + */ + void writeAlive() { + // 任何异常都不抛出:否则周期任务会永久停止。远程调用失败返回 error;同 JVM 直接调用时,可能直接抛出异常 + try { + // 1. 收集运行中协议实例的启动时间:serverId -> startTime + Map serverStartTimes = new HashMap<>(); + for (IotProtocol protocol : protocols) { + if (!protocol.isRunning() || protocol.getType() == IotProtocolTypeEnum.EMQX) { + continue; + } + Long startTime = this.startTimes.get(protocol); + if (startTime != null) { + serverStartTimes.put(protocol.getServerId(), startTime); + } + } + if (serverStartTimes.isEmpty()) { + return; + } + + // 2. 批量上报:部分失败时 data 为 false,下一轮继续续期 + CommonResult result = deviceApi.keepAliveGatewayServers( + new IotGatewayServerAliveReqDTO().setStartTimes(serverStartTimes)); + if (result == null || !result.isSuccess() || !Boolean.TRUE.equals(result.getData())) { + log.warn("[writeAlive][续期存活标记未全部成功,serverStartTimes({}) result({})]", serverStartTimes, result); + } + } catch (Throwable e) { + log.warn("[writeAlive][续期存活标记异常]", e); + } + } + /** * 创建协议实例 * @@ -93,7 +188,7 @@ public class IotProtocolManager implements SmartLifecycle { * @return 协议实例 */ @SuppressWarnings({"EnhancedSwitchMigration"}) - private IotProtocol createProtocol(IotGatewayProperties.ProtocolProperties config) { + IotProtocol createProtocol(IotGatewayProperties.ProtocolProperties config) { IotProtocolTypeEnum protocolType = IotProtocolTypeEnum.of(config.getProtocol()); if (protocolType == null) { log.error("[createProtocol][协议实例 {} 的协议类型 {} 不存在]", config.getId(), config.getProtocol()); diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/upstream/IotEmqxAuthEventHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/upstream/IotEmqxAuthEventHandler.java index 1863a5a..255fd86 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/upstream/IotEmqxAuthEventHandler.java +++ b/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 { private void handleClientDisconnected(JsonObject body) { String username = body.getString("username"); String reason = body.getString("reason"); + // 同一 ClientID 的新连接接管(takenover)或丢弃(discarded)了旧会话:设备仍在线,忽略旧会话的断开 + // 参考 https://docs.emqx.com/en/emqx/latest/data-integration/rule-sql-events-and-fields.html 中 client.disconnected 的 reason 说明 + if (StrUtil.equalsAny(reason, "takenover", "discarded")) { + log.info("[handleClientDisconnected][设备({}) 旧会话被新连接接管({}),忽略]", username, reason); + return; + } log.info("[handleClientDisconnected][设备下线: {} ({})]", username, reason); handleDeviceStateChange(username, false); } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/remote/IotDeviceApiImpl.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/remote/IotDeviceApiImpl.java index eed9bbb..db820d2 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/remote/IotDeviceApiImpl.java +++ b/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; import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceGetReqDTO; import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotGatewayServerAliveReqDTO; import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigListReqDTO; import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; import cn.iocoder.yudao.module.iot.core.biz.dto.IotSubDeviceRegisterFullReqDTO; @@ -26,6 +27,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; +import java.time.Duration; import java.util.List; 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 @Slf4j public class IotDeviceApiImpl implements IotDeviceCommonApi { + /** + * 存活上报的连接超时:不复用 RPC 配置的超时,避免一次上报等待过久,耽误后续的续期 + */ + private static final Duration KEEP_ALIVE_CONNECT_TIMEOUT = Duration.ofSeconds(2); + /** + * 存活上报的读取超时 + */ + private static final Duration KEEP_ALIVE_READ_TIMEOUT = Duration.ofSeconds(5); + @Resource private IotGatewayProperties gatewayProperties; private RestTemplate restTemplate; + /** + * 存活上报专用的 RestTemplate:超时更短;超时只是网关不再等待,不会取消 biz 已经接收的请求 + */ + private RestTemplate keepAliveRestTemplate; @PostConstruct public void init() { @@ -52,6 +67,11 @@ public class IotDeviceApiImpl implements IotDeviceCommonApi { .readTimeout(rpc.getReadTimeout()) .connectTimeout(rpc.getConnectTimeout()) .build(); + keepAliveRestTemplate = new RestTemplateBuilder() + .rootUri(rpc.getUrl()) + .readTimeout(KEEP_ALIVE_READ_TIMEOUT) + .connectTimeout(KEEP_ALIVE_CONNECT_TIMEOUT) + .build(); } @Override @@ -79,12 +99,23 @@ public class IotDeviceApiImpl implements IotDeviceCommonApi { return doPost("/rpc-api/iot/device/register-sub", reqDTO, new ParameterizedTypeReference<>() { }); } + @Override + public CommonResult keepAliveGatewayServers(IotGatewayServerAliveReqDTO reqDTO) { + return doPost(keepAliveRestTemplate, "/rpc-api/iot/gateway-server/keep-alive", reqDTO, + new ParameterizedTypeReference<>() { }); + } + private CommonResult doPost(String url, T body, ParameterizedTypeReference> responseType) { + return doPost(restTemplate, url, body, responseType); + } + + private CommonResult doPost(RestTemplate template, String url, T body, + ParameterizedTypeReference> responseType) { try { // 请求 HttpEntity requestEntity = new HttpEntity<>(body); - ResponseEntity> response = restTemplate.exchange( + ResponseEntity> response = template.exchange( url, HttpMethod.POST, requestEntity, responseType); // 响应 CommonResult result = response.getBody(); diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocolManagerTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocolManagerTest.java new file mode 100644 index 0000000..602300c --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocolManagerTest.java @@ -0,0 +1,209 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotGatewayServerAliveReqDTO; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR; +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * {@link IotProtocolManager} 的单元测试:协议实例的存活上报 + * + * @author 芋道源码 + */ +public class IotProtocolManagerTest { + + private static final String THREAD_NAME_PREFIX = "iot-gateway-alive-"; + + private IotDeviceCommonApi deviceApi; + + private IotProtocol mqttProtocol; + private IotProtocol tcpProtocol; + private IotProtocol emqxProtocol; + private IotProtocolManager protocolManager; + + @BeforeEach + public void setUp() { + deviceApi = mock(IotDeviceCommonApi.class); + when(deviceApi.keepAliveGatewayServers(any())).thenReturn(success(true)); + // 协议实例:MQTT、TCP 运行中;EMQX 不上报存活标记 + mqttProtocol = mockProtocol("mqtt-1", "server-mqtt", IotProtocolTypeEnum.MQTT); + tcpProtocol = mockProtocol("tcp-1", "server-tcp", IotProtocolTypeEnum.TCP); + emqxProtocol = mockProtocol("emqx-1", "server-emqx", IotProtocolTypeEnum.EMQX); + Map protocols = new HashMap<>(); + protocols.put("mqtt-1", mqttProtocol); + protocols.put("tcp-1", tcpProtocol); + protocols.put("emqx-1", emqxProtocol); + IotGatewayProperties gatewayProperties = new IotGatewayProperties(); + gatewayProperties.setProtocols(Arrays.asList(buildConfig("mqtt-1"), buildConfig("tcp-1"), buildConfig("emqx-1"))); + protocolManager = new IotProtocolManager(gatewayProperties, deviceApi) { + + @Override + IotProtocol createProtocol(IotGatewayProperties.ProtocolProperties config) { + return protocols.get(config.getId()); + } + + }; + } + + @AfterEach + public void tearDown() { + protocolManager.stop(); + } + + @Test + public void testStart_writeAlive() { + // 调用 + long before = System.currentTimeMillis(); + protocolManager.start(); + long after = System.currentTimeMillis(); + + // 断言:启动后立即上报一次,一次批量包含全部运行中的非 EMQX 协议实例 + Map startTimes = awaitFirstWriteAlive().getStartTimes(); + assertEquals(new HashSet<>(Arrays.asList("server-mqtt", "server-tcp")), startTimes.keySet()); + // 断言:值为协议实例的启动时间 + assertTrue(startTimes.get("server-mqtt") >= before && startTimes.get("server-mqtt") <= after); + assertTrue(startTimes.get("server-tcp") >= before && startTimes.get("server-tcp") <= after); + } + + @Test + public void testWriteAlive_startTimeNotChange() { + // 准备参数 + protocolManager.start(); + Map firstStartTimes = awaitFirstWriteAlive().getStartTimes(); + + // 调用:续期两轮 + protocolManager.writeAlive(); + protocolManager.writeAlive(); + // 断言:每轮只调用一次,启动时间不随续期变化 + ArgumentCaptor reqCaptor = ArgumentCaptor.forClass(IotGatewayServerAliveReqDTO.class); + verify(deviceApi, times(2)).keepAliveGatewayServers(reqCaptor.capture()); + List reqDTOs = reqCaptor.getAllValues(); + assertEquals(firstStartTimes, reqDTOs.get(0).getStartTimes()); + assertEquals(firstStartTimes, reqDTOs.get(1).getStartTimes()); + } + + @Test + public void testWriteAlive_skipNotRunning() { + // mock 方法:TCP 已停止 + when(tcpProtocol.isRunning()).thenReturn(false); + + // 调用 + protocolManager.start(); + // 断言:只上报运行中的协议实例 + assertEquals(Collections.singleton("server-mqtt"), awaitFirstWriteAlive().getStartTimes().keySet()); + } + + @Test + public void testWriteAlive_empty() { + // mock 方法:MQTT、TCP 都已停止,只剩下 EMQX + when(mqttProtocol.isRunning()).thenReturn(false); + when(tcpProtocol.isRunning()).thenReturn(false); + protocolManager.start(); + + // 调用 + protocolManager.writeAlive(); + // 断言:没有需要上报的协议实例,不调用 + verify(deviceApi, never()).keepAliveGatewayServers(any()); + } + + @Test + public void testWriteAlive_failNotThrow() { + // 准备参数 + protocolManager.start(); + awaitFirstWriteAlive(); + // mock 方法:依次返回 error、部分失败、直接抛出异常(同 JVM 直接调用时),最后恢复成功 + when(deviceApi.keepAliveGatewayServers(any())).thenReturn(CommonResult.error(INTERNAL_SERVER_ERROR)) + .thenReturn(success(false)).thenThrow(new IllegalStateException("biz 不可用")).thenReturn(success(true)); + + // 调用:异常不抛出(否则周期任务会永久停止),每轮都照常上报 + for (int i = 0; i < 4; i++) { + assertDoesNotThrow(() -> protocolManager.writeAlive()); + } + // 断言 + verify(deviceApi, times(4)).keepAliveGatewayServers(any()); + } + + @Test + public void testStopAndRestart() throws Exception { + // 调用:启动 + protocolManager.start(); + long firstStartTime = awaitFirstWriteAlive().getStartTimes().get("server-mqtt"); + + // 调用:停止,重复停止 + protocolManager.stop(); + protocolManager.stop(); + // 断言:协议只停止一次,续期线程已退出 + verify(mqttProtocol).stop(); + assertFalse(protocolManager.isRunning()); + assertNoAliveThread(); + // 断言:停止后不再上报 + protocolManager.writeAlive(); + verify(deviceApi, never()).keepAliveGatewayServers(any()); + + // 调用:同 serverId 重新启动 + Thread.sleep(5); + protocolManager.start(); + // 断言:启动时间已更新 + assertTrue(awaitFirstWriteAlive().getStartTimes().get("server-mqtt") > firstStartTime); + verify(mqttProtocol, times(2)).start(); + } + + // ========== 辅助方法 ========== + + /** + * 等待启动后立即执行的那一轮上报,并清空调用记录;下一轮在 20 秒后,不会干扰后续断言 + */ + private IotGatewayServerAliveReqDTO awaitFirstWriteAlive() { + ArgumentCaptor reqCaptor = ArgumentCaptor.forClass(IotGatewayServerAliveReqDTO.class); + verify(deviceApi, timeout(2000)).keepAliveGatewayServers(reqCaptor.capture()); + clearInvocations(deviceApi); + return reqCaptor.getValue(); + } + + private static void assertNoAliveThread() throws InterruptedException { + // 线程池终止后,工作线程的退出稍有延迟 + for (int i = 0; i < 100; i++) { + boolean alive = Thread.getAllStackTraces().keySet().stream() + .anyMatch(thread -> thread.getName().startsWith(THREAD_NAME_PREFIX) && thread.isAlive()); + if (!alive) { + return; + } + Thread.sleep(10); + } + fail("续期线程没有退出"); + } + + private static IotProtocol mockProtocol(String id, String serverId, IotProtocolTypeEnum type) { + IotProtocol protocol = mock(IotProtocol.class); + when(protocol.getId()).thenReturn(id); + when(protocol.getServerId()).thenReturn(serverId); + when(protocol.getType()).thenReturn(type); + when(protocol.isRunning()).thenReturn(true); + return protocol; + } + + private static IotGatewayProperties.ProtocolProperties buildConfig(String id) { + IotGatewayProperties.ProtocolProperties config = new IotGatewayProperties.ProtocolProperties(); + config.setId(id); + return config; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/upstream/IotEmqxAuthEventHandlerTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/upstream/IotEmqxAuthEventHandlerTest.java new file mode 100644 index 0000000..411403f --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/upstream/IotEmqxAuthEventHandlerTest.java @@ -0,0 +1,83 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.upstream; + +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.RoutingContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockedStatic; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * {@link IotEmqxAuthEventHandler} 的单元测试 + * + * @author 芋道源码 + */ +public class IotEmqxAuthEventHandlerTest extends BaseMockitoUnitTest { + + private static final String SERVER_ID = "emqx-server"; + + @Mock + private IotDeviceMessageService deviceMessageService; + @Mock + private IotDeviceCommonApi deviceApi; + + private MockedStatic springUtilMockedStatic; + private IotEmqxAuthEventHandler handler; + + @BeforeEach + public void setUp() { + springUtilMockedStatic = mockStatic(SpringUtil.class); + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotDeviceMessageService.class)).thenReturn(deviceMessageService); + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotDeviceCommonApi.class)).thenReturn(deviceApi); + handler = new IotEmqxAuthEventHandler(SERVER_ID, null); + } + + @AfterEach + public void tearDown() { + springUtilMockedStatic.close(); + } + + @Test + public void testHandleEvent_disconnectedTakenover() { + // 调用:同一 ClientID 的新连接接管、丢弃旧会话 + handler.handleEvent(mockDisconnectedEvent("takenover")); + handler.handleEvent(mockDisconnectedEvent("discarded")); + // 断言:旧会话的断开被忽略,不发离线 + verify(deviceMessageService, never()).sendDeviceMessage(any(), any(), any(), any()); + } + + @Test + public void testHandleEvent_disconnectedNormal() { + // 调用:正常断开、心跳超时 + handler.handleEvent(mockDisconnectedEvent("normal")); + handler.handleEvent(mockDisconnectedEvent("keepalive_timeout")); + // 断言:发送离线 + verify(deviceMessageService, times(2)).sendDeviceMessage(argThat((IotDeviceMessage m) -> + IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod().equals(m.getMethod()) + && String.valueOf(IotDeviceStateEnum.OFFLINE.getState()).equals(IotDeviceMessageUtils.getIdentifier(m))), + eq("pk"), eq("dn"), eq(SERVER_ID)); + } + + private static RoutingContext mockDisconnectedEvent(String reason) { + RoutingContext context = mock(RoutingContext.class, RETURNS_DEEP_STUBS); + JsonObject body = new JsonObject().put("event", "client.disconnected") + .put("username", "dn&pk").put("reason", reason); + when(context.body().asJsonObject()).thenReturn(body); + return context; + } + +}