Commit 6ed0971489b4a30c2b6509f4f089e5f749a4dcfe
1 parent
8701cf75
fix(iot): 修复删除设备后消息反复重投及重投任务扫描无界
Showing
7 changed files
with
342 additions
and
9 deletions
yudao-framework/yudao-spring-boot-starter-mq/pom.xml
| ... | ... | @@ -38,6 +38,18 @@ |
| 38 | 38 | <artifactId>rocketmq-spring-boot-starter</artifactId> |
| 39 | 39 | <optional>true</optional> |
| 40 | 40 | </dependency> |
| 41 | + | |
| 42 | + <!-- Test 测试相关 --> | |
| 43 | + <dependency> | |
| 44 | + <groupId>org.mockito</groupId> | |
| 45 | + <artifactId>mockito-inline</artifactId> | |
| 46 | + <scope>test</scope> | |
| 47 | + </dependency> | |
| 48 | + <dependency> | |
| 49 | + <groupId>org.springframework.boot</groupId> | |
| 50 | + <artifactId>spring-boot-starter-test</artifactId> | |
| 51 | + <scope>test</scope> | |
| 52 | + </dependency> | |
| 41 | 53 | </dependencies> |
| 42 | 54 | |
| 43 | 55 | </project> |
| 44 | 56 | \ No newline at end of file | ... | ... |
yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/redis/core/job/RedisPendingMessageResendJob.java
| ... | ... | @@ -35,6 +35,13 @@ public class RedisPendingMessageResendJob { |
| 35 | 35 | */ |
| 36 | 36 | private static final int EXPIRE_TIME = 5 * 60; |
| 37 | 37 | |
| 38 | + /** | |
| 39 | + * 每个消费者每轮最多扫描的 pending 消息数量 | |
| 40 | + * | |
| 41 | + * 避免 pending 堆积时,单次 XPENDING 返回过多数据而长时间占用 Redis;未扫描到的消息,由后续轮次继续处理 | |
| 42 | + */ | |
| 43 | + private static final int PENDING_BATCH_SIZE = 1000; | |
| 44 | + | |
| 38 | 45 | private final List<AbstractRedisStreamMessageListener<?>> listeners; |
| 39 | 46 | private final RedisMQTemplate redisTemplate; |
| 40 | 47 | private final RedissonClient redissonClient; |
| ... | ... | @@ -74,8 +81,9 @@ public class RedisPendingMessageResendJob { |
| 74 | 81 | Map<String, Long> pendingMessagesPerConsumer = pendingMessagesSummary.getPendingMessagesPerConsumer(); |
| 75 | 82 | pendingMessagesPerConsumer.forEach((consumerName, pendingMessageCount) -> { |
| 76 | 83 | log.info("[processPendingMessage][消费者({}) 消息数量({})]", consumerName, pendingMessageCount); |
| 77 | - // 每个消费者的 pending消息的详情信息 | |
| 78 | - PendingMessages pendingMessages = ops.pending(listener.getStreamKey(), Consumer.from(listener.getGroup(), consumerName), Range.unbounded(), pendingMessageCount); | |
| 84 | + // 每个消费者的 pending消息的详情信息,分批扫描:https://github.com/YunaiV/ruoyi-vue-pro/issues/1271 | |
| 85 | + PendingMessages pendingMessages = ops.pending(listener.getStreamKey(), Consumer.from(listener.getGroup(), consumerName), | |
| 86 | + Range.unbounded(), Math.min(pendingMessageCount, PENDING_BATCH_SIZE)); | |
| 79 | 87 | if (pendingMessages.isEmpty()) { |
| 80 | 88 | return; |
| 81 | 89 | } |
| ... | ... | @@ -89,6 +97,11 @@ public class RedisPendingMessageResendJob { |
| 89 | 97 | List<MapRecord<String, Object, Object>> records = ops.range(listener.getStreamKey(), |
| 90 | 98 | Range.of(Range.Bound.inclusive(pendingMessage.getIdAsString()), Range.Bound.inclusive(pendingMessage.getIdAsString()))); |
| 91 | 99 | if (CollUtil.isEmpty(records)) { |
| 100 | + // 消息体已不存在(例如被 RedisStreamMessageCleanupJob 裁剪),无法重新投递,直接 ack 避免 pending 永久滞留 | |
| 101 | + redisTemplate.getRedisTemplate().opsForStream().acknowledge(listener.getStreamKey(), | |
| 102 | + listener.getGroup(), pendingMessage.getId()); | |
| 103 | + log.warn("[processPendingMessage][消息({}) 的消息体已不存在,无法重新投递,直接 ack]", | |
| 104 | + pendingMessage.getIdAsString()); | |
| 92 | 105 | return; |
| 93 | 106 | } |
| 94 | 107 | // 重新投递消息 | ... | ... |
yudao-framework/yudao-spring-boot-starter-mq/src/test/java/cn/iocoder/yudao/framework/mq/redis/core/job/RedisPendingMessageResendJobTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.framework.mq.redis.core.job; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.framework.mq.redis.core.RedisMQTemplate; | |
| 4 | +import cn.iocoder.yudao.framework.mq.redis.core.stream.AbstractRedisStreamMessage; | |
| 5 | +import cn.iocoder.yudao.framework.mq.redis.core.stream.AbstractRedisStreamMessageListener; | |
| 6 | +import org.junit.jupiter.api.BeforeEach; | |
| 7 | +import org.junit.jupiter.api.Test; | |
| 8 | +import org.junit.jupiter.api.extension.ExtendWith; | |
| 9 | +import org.mockito.ArgumentCaptor; | |
| 10 | +import org.mockito.Mock; | |
| 11 | +import org.mockito.junit.jupiter.MockitoExtension; | |
| 12 | +import org.redisson.api.RLock; | |
| 13 | +import org.redisson.api.RedissonClient; | |
| 14 | +import org.springframework.dao.QueryTimeoutException; | |
| 15 | +import org.springframework.data.domain.Range; | |
| 16 | +import org.springframework.data.redis.connection.stream.Record; | |
| 17 | +import org.springframework.data.redis.connection.stream.*; | |
| 18 | +import org.springframework.data.redis.core.RedisTemplate; | |
| 19 | +import org.springframework.data.redis.core.StreamOperations; | |
| 20 | + | |
| 21 | +import java.time.Duration; | |
| 22 | +import java.util.Collections; | |
| 23 | +import java.util.List; | |
| 24 | +import java.util.Map; | |
| 25 | + | |
| 26 | +import static java.util.Collections.singletonList; | |
| 27 | +import static java.util.Collections.singletonMap; | |
| 28 | +import static org.junit.jupiter.api.Assertions.assertEquals; | |
| 29 | +import static org.mockito.ArgumentMatchers.*; | |
| 30 | +import static org.mockito.Mockito.*; | |
| 31 | + | |
| 32 | +/** | |
| 33 | + * {@link RedisPendingMessageResendJob} 的单元测试类 | |
| 34 | + * | |
| 35 | + * @author 芋道源码 | |
| 36 | + */ | |
| 37 | +@ExtendWith(MockitoExtension.class) | |
| 38 | +public class RedisPendingMessageResendJobTest { | |
| 39 | + | |
| 40 | + private static final String STREAM_KEY = "test_stream"; | |
| 41 | + private static final String GROUP = "test_group"; | |
| 42 | + private static final String CONSUMER_NAME = "test_consumer"; | |
| 43 | + | |
| 44 | + private RedisPendingMessageResendJob resendJob; | |
| 45 | + | |
| 46 | + @Mock | |
| 47 | + private RedisMQTemplate redisMQTemplate; | |
| 48 | + @Mock | |
| 49 | + private RedisTemplate<String, Object> redisTemplate; | |
| 50 | + @Mock | |
| 51 | + private StreamOperations<String, Object, Object> streamOperations; | |
| 52 | + @Mock | |
| 53 | + private RedissonClient redissonClient; | |
| 54 | + @Mock | |
| 55 | + private RLock lock; | |
| 56 | + | |
| 57 | + @BeforeEach | |
| 58 | + @SuppressWarnings({"unchecked", "rawtypes"}) | |
| 59 | + public void setUp() { | |
| 60 | + AbstractRedisStreamMessageListener<?> listener = new AbstractRedisStreamMessageListener<>(STREAM_KEY, GROUP) { | |
| 61 | + | |
| 62 | + @Override | |
| 63 | + public void onMessage(AbstractRedisStreamMessage message) { | |
| 64 | + } | |
| 65 | + | |
| 66 | + }; | |
| 67 | + resendJob = new RedisPendingMessageResendJob(singletonList(listener), redisMQTemplate, redissonClient, | |
| 68 | + RedisPendingMessageResendJob.DEFAULT_RESEND_LOCK_KEY); | |
| 69 | + // mock 分布式锁 | |
| 70 | + when(redissonClient.getLock(eq(RedisPendingMessageResendJob.DEFAULT_RESEND_LOCK_KEY))).thenReturn(lock); | |
| 71 | + when(lock.tryLock()).thenReturn(true); | |
| 72 | + when(lock.isHeldByCurrentThread()).thenReturn(true); | |
| 73 | + // mock Redis Stream 操作 | |
| 74 | + when(redisMQTemplate.getRedisTemplate()).thenReturn((RedisTemplate) redisTemplate); | |
| 75 | + when(redisTemplate.opsForStream()).thenReturn((StreamOperations) streamOperations); | |
| 76 | + } | |
| 77 | + | |
| 78 | + @Test | |
| 79 | + @SuppressWarnings("unchecked") | |
| 80 | + public void testMessageResend_success() { | |
| 81 | + // mock 数据:一条超时消息,一条未超时消息 | |
| 82 | + RecordId expiredId = RecordId.of("1-0"); | |
| 83 | + RecordId freshId = RecordId.of("2-0"); | |
| 84 | + mockPending(2L, List.of(buildPendingMessage(expiredId, Duration.ofMinutes(6)), | |
| 85 | + buildPendingMessage(freshId, Duration.ofMinutes(1)))); | |
| 86 | + Map<Object, Object> body = singletonMap("payload", "{\"id\":1}"); | |
| 87 | + MapRecord<String, Object, Object> record = StreamRecords.newRecord().in(STREAM_KEY).withId(expiredId).ofMap(body); | |
| 88 | + when(streamOperations.range(eq(STREAM_KEY), eq(buildIdRange(expiredId)))).thenReturn(singletonList(record)); | |
| 89 | + | |
| 90 | + // 调用 | |
| 91 | + resendJob.messageResend(); | |
| 92 | + // 断言:超时消息重新投递完整消息体,并 ack 原 pending 消息 | |
| 93 | + ArgumentCaptor<Record<String, ?>> recordCaptor = ArgumentCaptor.forClass(Record.class); | |
| 94 | + verify(streamOperations).add(recordCaptor.capture()); | |
| 95 | + assertEquals(STREAM_KEY, recordCaptor.getValue().getStream()); | |
| 96 | + assertEquals(body, recordCaptor.getValue().getValue()); | |
| 97 | + verify(streamOperations).acknowledge(eq(GROUP), eq(record)); | |
| 98 | + // 断言:未超时消息不处理 | |
| 99 | + verify(streamOperations, never()).range(eq(STREAM_KEY), eq(buildIdRange(freshId))); | |
| 100 | + verify(lock).unlock(); | |
| 101 | + } | |
| 102 | + | |
| 103 | + @Test | |
| 104 | + public void testMessageResend_messageBodyNotExists() { | |
| 105 | + // mock 数据:消息体已被裁剪 | |
| 106 | + RecordId recordId = RecordId.of("1-0"); | |
| 107 | + mockPending(1L, singletonList(buildPendingMessage(recordId, Duration.ofMinutes(6)))); | |
| 108 | + when(streamOperations.range(eq(STREAM_KEY), eq(buildIdRange(recordId)))).thenReturn(Collections.emptyList()); | |
| 109 | + | |
| 110 | + // 调用 | |
| 111 | + resendJob.messageResend(); | |
| 112 | + // 断言:无法重新投递,直接 ack 清理 pending | |
| 113 | + verify(streamOperations).acknowledge(eq(STREAM_KEY), eq(GROUP), eq(recordId)); | |
| 114 | + verify(streamOperations, never()).add(any(Record.class)); | |
| 115 | + } | |
| 116 | + | |
| 117 | + @Test | |
| 118 | + public void testMessageResend_pendingBatchSize() { | |
| 119 | + // mock 数据:pending 数量超过单批上限 | |
| 120 | + mockPending(5000L, Collections.emptyList()); | |
| 121 | + | |
| 122 | + // 调用 | |
| 123 | + resendJob.messageResend(); | |
| 124 | + // 断言:单次 XPENDING 最多扫描 1000 条 | |
| 125 | + verify(streamOperations).pending(eq(STREAM_KEY), eq(Consumer.from(GROUP, CONSUMER_NAME)), | |
| 126 | + eq(Range.unbounded()), eq(1000L)); | |
| 127 | + } | |
| 128 | + | |
| 129 | + @Test | |
| 130 | + public void testMessageResend_pendingLessThanBatchSize() { | |
| 131 | + // mock 数据:pending 数量未超过单批上限 | |
| 132 | + mockPending(3L, Collections.emptyList()); | |
| 133 | + | |
| 134 | + // 调用 | |
| 135 | + resendJob.messageResend(); | |
| 136 | + // 断言:按实际数量扫描 | |
| 137 | + verify(streamOperations).pending(eq(STREAM_KEY), eq(Consumer.from(GROUP, CONSUMER_NAME)), | |
| 138 | + eq(Range.unbounded()), eq(3L)); | |
| 139 | + } | |
| 140 | + | |
| 141 | + @Test | |
| 142 | + public void testMessageResend_redisException() { | |
| 143 | + // mock 数据:读取消息体时 Redis 超时 | |
| 144 | + RecordId recordId = RecordId.of("1-0"); | |
| 145 | + mockPending(1L, singletonList(buildPendingMessage(recordId, Duration.ofMinutes(6)))); | |
| 146 | + when(streamOperations.range(eq(STREAM_KEY), eq(buildIdRange(recordId)))) | |
| 147 | + .thenThrow(new QueryTimeoutException("Redis 超时")); | |
| 148 | + | |
| 149 | + // 调用 | |
| 150 | + resendJob.messageResend(); | |
| 151 | + // 断言:保留 pending,不 ack 也不重新投递,等待下一轮重试 | |
| 152 | + verify(streamOperations, never()).acknowledge(anyString(), anyString(), any(RecordId[].class)); | |
| 153 | + verify(streamOperations, never()).acknowledge(anyString(), any(Record.class)); | |
| 154 | + verify(streamOperations, never()).add(any(Record.class)); | |
| 155 | + verify(lock).unlock(); | |
| 156 | + } | |
| 157 | + | |
| 158 | + // ========== 随机对象 ========== | |
| 159 | + | |
| 160 | + private void mockPending(Long pendingCount, List<PendingMessage> pendingMessages) { | |
| 161 | + when(streamOperations.pending(eq(STREAM_KEY), eq(GROUP))).thenReturn(new PendingMessagesSummary(GROUP, | |
| 162 | + pendingCount, Range.unbounded(), singletonMap(CONSUMER_NAME, pendingCount))); | |
| 163 | + when(streamOperations.pending(eq(STREAM_KEY), eq(Consumer.from(GROUP, CONSUMER_NAME)), eq(Range.unbounded()), | |
| 164 | + anyLong())).thenReturn(new PendingMessages(GROUP, pendingMessages)); | |
| 165 | + } | |
| 166 | + | |
| 167 | + private static PendingMessage buildPendingMessage(RecordId recordId, Duration elapsedTime) { | |
| 168 | + return new PendingMessage(recordId, Consumer.from(GROUP, CONSUMER_NAME), elapsedTime, 1L); | |
| 169 | + } | |
| 170 | + | |
| 171 | + private static Range<String> buildIdRange(RecordId recordId) { | |
| 172 | + return Range.of(Range.Bound.inclusive(recordId.getValue()), Range.Bound.inclusive(recordId.getValue())); | |
| 173 | + } | |
| 174 | + | |
| 175 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/device/IotDeviceMessageSubscriber.java
| ... | ... | @@ -64,7 +64,12 @@ public class IotDeviceMessageSubscriber implements IotMessageSubscriber<IotDevic |
| 64 | 64 | |
| 65 | 65 | TenantUtils.execute(message.getTenantId(), () -> { |
| 66 | 66 | // 1.1 更新设备的最后时间 |
| 67 | - IotDeviceDO device = deviceService.validateDeviceExistsFromCache(message.getDeviceId()); | |
| 67 | + // 设备已删除时(例如删除时消息仍在途),跳过处理,避免消息不断重投:https://github.com/YunaiV/ruoyi-vue-pro/issues/1271 | |
| 68 | + IotDeviceDO device = deviceService.getDeviceFromCache(message.getDeviceId()); | |
| 69 | + if (device == null) { | |
| 70 | + log.warn("[onMessage][message({}) 对应的设备不存在,忽略处理]", message); | |
| 71 | + return; | |
| 72 | + } | |
| 68 | 73 | devicePropertyService.updateDeviceReportTimeAsync(device.getId(), LocalDateTime.now()); |
| 69 | 74 | // 1.2 更新设备的连接 server |
| 70 | 75 | devicePropertyService.updateDeviceServerIdAsync(device.getId(), message.getServerId()); | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceImpl.java
| ... | ... | @@ -200,6 +200,10 @@ public class IotSceneRuleServiceImpl implements IotSceneRuleService { |
| 200 | 200 | public void executeSceneRuleByDevice(IotDeviceMessage message) { |
| 201 | 201 | // 1.1 这里的 tenantId,通过设备获取; |
| 202 | 202 | IotDeviceDO device = deviceService.getDeviceFromCache(message.getDeviceId()); |
| 203 | + if (device == null) { // 设备已删除时,跳过处理:https://github.com/YunaiV/ruoyi-vue-pro/issues/1271 | |
| 204 | + log.warn("[executeSceneRuleByDevice][设备({}) 不存在]", message.getDeviceId()); | |
| 205 | + return; | |
| 206 | + } | |
| 203 | 207 | TenantUtils.execute(device.getTenantId(), () -> { |
| 204 | 208 | // 1.2 获得设备匹配的规则场景 |
| 205 | 209 | List<IotSceneRuleDO> sceneRules = getMatchedSceneRuleListByMessage(message); | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/mq/consumer/device/IotDeviceMessageSubscriberTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.mq.consumer.device; | |
| 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.enums.device.IotDeviceStateEnum; | |
| 6 | +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; | |
| 7 | +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.service.device.IotDeviceService; | |
| 10 | +import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService; | |
| 11 | +import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; | |
| 12 | +import org.junit.jupiter.api.Test; | |
| 13 | +import org.mockito.InjectMocks; | |
| 14 | +import org.mockito.Mock; | |
| 15 | +import org.springframework.dao.QueryTimeoutException; | |
| 16 | + | |
| 17 | +import java.time.LocalDateTime; | |
| 18 | + | |
| 19 | +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; | |
| 20 | +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; | |
| 21 | +import static org.junit.jupiter.api.Assertions.assertThrows; | |
| 22 | +import static org.mockito.ArgumentMatchers.any; | |
| 23 | +import static org.mockito.ArgumentMatchers.eq; | |
| 24 | +import static org.mockito.Mockito.*; | |
| 25 | + | |
| 26 | +/** | |
| 27 | + * {@link IotDeviceMessageSubscriber} 的单元测试类 | |
| 28 | + * | |
| 29 | + * @author 芋道源码 | |
| 30 | + */ | |
| 31 | +public class IotDeviceMessageSubscriberTest extends BaseMockitoUnitTest { | |
| 32 | + | |
| 33 | + @InjectMocks | |
| 34 | + private IotDeviceMessageSubscriber deviceMessageSubscriber; | |
| 35 | + | |
| 36 | + @Mock | |
| 37 | + private IotDeviceService deviceService; | |
| 38 | + @Mock | |
| 39 | + private IotDevicePropertyService devicePropertyService; | |
| 40 | + @Mock | |
| 41 | + private IotDeviceMessageService deviceMessageService; | |
| 42 | + @Mock | |
| 43 | + private IotMessageBus messageBus; | |
| 44 | + | |
| 45 | + @Test | |
| 46 | + public void testOnMessage_success() { | |
| 47 | + // 准备参数 | |
| 48 | + IotDeviceMessage message = buildPropertyPostMessage(); | |
| 49 | + // mock 方法:设备在线 | |
| 50 | + IotDeviceDO device = new IotDeviceDO().setId(message.getDeviceId()) | |
| 51 | + .setState(IotDeviceStateEnum.ONLINE.getState()); | |
| 52 | + when(deviceService.getDeviceFromCache(eq(message.getDeviceId()))).thenReturn(device); | |
| 53 | + | |
| 54 | + // 调用 | |
| 55 | + deviceMessageSubscriber.onMessage(message); | |
| 56 | + // 断言 | |
| 57 | + verify(devicePropertyService).updateDeviceReportTimeAsync(eq(device.getId()), any(LocalDateTime.class)); | |
| 58 | + verify(devicePropertyService).updateDeviceServerIdAsync(eq(device.getId()), eq(message.getServerId())); | |
| 59 | + verify(deviceMessageService).handleUpstreamDeviceMessage(eq(message), eq(device)); | |
| 60 | + } | |
| 61 | + | |
| 62 | + @Test | |
| 63 | + public void testOnMessage_deviceNotExists() { | |
| 64 | + // 准备参数:设备已删除时,消息仍在途 | |
| 65 | + IotDeviceMessage message = buildPropertyPostMessage(); | |
| 66 | + // mock 方法 | |
| 67 | + when(deviceService.getDeviceFromCache(eq(message.getDeviceId()))).thenReturn(null); | |
| 68 | + | |
| 69 | + // 调用:正常返回,由消息总线 ack,不再重投 | |
| 70 | + assertDoesNotThrow(() -> deviceMessageSubscriber.onMessage(message)); | |
| 71 | + // 断言 | |
| 72 | + verifyNoInteractions(devicePropertyService, deviceMessageService); | |
| 73 | + } | |
| 74 | + | |
| 75 | + @Test | |
| 76 | + public void testOnMessage_queryException() { | |
| 77 | + // 准备参数 | |
| 78 | + IotDeviceMessage message = buildPropertyPostMessage(); | |
| 79 | + // mock 方法:查询设备时 Redis 超时 | |
| 80 | + when(deviceService.getDeviceFromCache(eq(message.getDeviceId()))) | |
| 81 | + .thenThrow(new QueryTimeoutException("Redis 超时")); | |
| 82 | + | |
| 83 | + // 调用,并断言异常:可恢复异常继续抛出,消息不 ack,由重投任务重试 | |
| 84 | + assertThrows(QueryTimeoutException.class, () -> deviceMessageSubscriber.onMessage(message)); | |
| 85 | + verifyNoInteractions(devicePropertyService, deviceMessageService); | |
| 86 | + } | |
| 87 | + | |
| 88 | + private static IotDeviceMessage buildPropertyPostMessage() { | |
| 89 | + return new IotDeviceMessage().setDeviceId(randomLongId()).setTenantId(randomLongId()) | |
| 90 | + .setServerId("test-server").setMethod(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()); | |
| 91 | + } | |
| 92 | + | |
| 93 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceSimpleTest.java
| ... | ... | @@ -3,6 +3,8 @@ package cn.iocoder.yudao.module.iot.service.rule.scene; |
| 3 | 3 | import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; |
| 4 | 4 | import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; |
| 5 | 5 | import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.scene.IotSceneRuleSaveReqVO; |
| 6 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | |
| 7 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 6 | 8 | import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; |
| 7 | 9 | import cn.iocoder.yudao.module.iot.dal.mysql.rule.IotSceneRuleMapper; |
| 8 | 10 | import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; |
| ... | ... | @@ -14,6 +16,7 @@ import cn.iocoder.yudao.module.iot.service.rule.scene.timer.IotTimerConditionEva |
| 14 | 16 | import org.junit.jupiter.api.Test; |
| 15 | 17 | import org.mockito.InjectMocks; |
| 16 | 18 | import org.mockito.Mock; |
| 19 | +import org.springframework.dao.QueryTimeoutException; | |
| 17 | 20 | |
| 18 | 21 | import java.util.Collections; |
| 19 | 22 | import java.util.List; |
| ... | ... | @@ -22,6 +25,7 @@ import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId |
| 22 | 25 | import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; |
| 23 | 26 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; |
| 24 | 27 | import static org.junit.jupiter.api.Assertions.assertEquals; |
| 28 | +import static org.junit.jupiter.api.Assertions.assertThrows; | |
| 25 | 29 | import static org.mockito.ArgumentMatchers.any; |
| 26 | 30 | import static org.mockito.Mockito.*; |
| 27 | 31 | |
| ... | ... | @@ -38,22 +42,16 @@ public class IotSceneRuleServiceSimpleTest extends BaseMockitoUnitTest { |
| 38 | 42 | |
| 39 | 43 | @Mock |
| 40 | 44 | private IotSceneRuleMapper sceneRuleMapper; |
| 41 | - | |
| 42 | 45 | @Mock |
| 43 | 46 | private List<IotSceneRuleAction> sceneRuleActions; |
| 44 | - | |
| 45 | 47 | @Mock |
| 46 | 48 | private IotSceneRuleTimerHandler timerHandler; |
| 47 | - | |
| 48 | 49 | @Mock |
| 49 | 50 | private IotTimerConditionEvaluator timerConditionEvaluator; |
| 50 | - | |
| 51 | 51 | @Mock |
| 52 | 52 | private IotSceneRuleMatcherManager sceneRuleMatcherManager; |
| 53 | - | |
| 54 | 53 | @Mock |
| 55 | 54 | private IotProductService productService; |
| 56 | - | |
| 57 | 55 | @Mock |
| 58 | 56 | private IotDeviceService deviceService; |
| 59 | 57 | |
| ... | ... | @@ -216,4 +214,37 @@ public class IotSceneRuleServiceSimpleTest extends BaseMockitoUnitTest { |
| 216 | 214 | // 验证 |
| 217 | 215 | verify(sceneRuleMapper, times(1)).selectById(id); |
| 218 | 216 | } |
| 217 | + | |
| 218 | + @Test | |
| 219 | + public void testExecuteSceneRuleByDevice_deviceNotExists() { | |
| 220 | + // 准备参数:设备已删除时,消息仍在途 | |
| 221 | + IotDeviceMessage message = new IotDeviceMessage().setDeviceId(randomLongId()) | |
| 222 | + .setMethod(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()); | |
| 223 | + | |
| 224 | + // Mock 行为 | |
| 225 | + when(deviceService.getDeviceFromCache(message.getDeviceId())).thenReturn(null); | |
| 226 | + | |
| 227 | + // 调用 - 设备不存在时跳过,不抛出异常 | |
| 228 | + assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByDevice(message)); | |
| 229 | + | |
| 230 | + // 验证:不再匹配、执行场景规则 | |
| 231 | + verify(deviceService, times(1)).getDeviceFromCache(message.getDeviceId()); | |
| 232 | + verifyNoInteractions(sceneRuleMapper, productService, sceneRuleMatcherManager, sceneRuleActions); | |
| 233 | + } | |
| 234 | + | |
| 235 | + @Test | |
| 236 | + public void testExecuteSceneRuleByDevice_queryException() { | |
| 237 | + // 准备参数 | |
| 238 | + IotDeviceMessage message = new IotDeviceMessage().setDeviceId(randomLongId()) | |
| 239 | + .setMethod(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()); | |
| 240 | + | |
| 241 | + // Mock 行为:查询设备时 Redis 超时 | |
| 242 | + when(deviceService.getDeviceFromCache(message.getDeviceId())) | |
| 243 | + .thenThrow(new QueryTimeoutException("Redis 超时")); | |
| 244 | + | |
| 245 | + // 调用,并断言异常:可恢复异常继续抛出,由消息重投重试 | |
| 246 | + assertThrows(QueryTimeoutException.class, () -> sceneRuleService.executeSceneRuleByDevice(message)); | |
| 247 | + verifyNoInteractions(sceneRuleMapper, productService, sceneRuleMatcherManager, sceneRuleActions); | |
| 248 | + } | |
| 249 | + | |
| 219 | 250 | } | ... | ... |