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