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,6 +38,18 @@ | ||
| 38 | <artifactId>rocketmq-spring-boot-starter</artifactId> | 38 | <artifactId>rocketmq-spring-boot-starter</artifactId> |
| 39 | <optional>true</optional> | 39 | <optional>true</optional> |
| 40 | </dependency> | 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 | </dependencies> | 53 | </dependencies> |
| 42 | 54 | ||
| 43 | </project> | 55 | </project> |
| 44 | \ No newline at end of file | 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,6 +35,13 @@ public class RedisPendingMessageResendJob { | ||
| 35 | */ | 35 | */ |
| 36 | private static final int EXPIRE_TIME = 5 * 60; | 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 | private final List<AbstractRedisStreamMessageListener<?>> listeners; | 45 | private final List<AbstractRedisStreamMessageListener<?>> listeners; |
| 39 | private final RedisMQTemplate redisTemplate; | 46 | private final RedisMQTemplate redisTemplate; |
| 40 | private final RedissonClient redissonClient; | 47 | private final RedissonClient redissonClient; |
| @@ -74,8 +81,9 @@ public class RedisPendingMessageResendJob { | @@ -74,8 +81,9 @@ public class RedisPendingMessageResendJob { | ||
| 74 | Map<String, Long> pendingMessagesPerConsumer = pendingMessagesSummary.getPendingMessagesPerConsumer(); | 81 | Map<String, Long> pendingMessagesPerConsumer = pendingMessagesSummary.getPendingMessagesPerConsumer(); |
| 75 | pendingMessagesPerConsumer.forEach((consumerName, pendingMessageCount) -> { | 82 | pendingMessagesPerConsumer.forEach((consumerName, pendingMessageCount) -> { |
| 76 | log.info("[processPendingMessage][消费者({}) 消息数量({})]", consumerName, pendingMessageCount); | 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 | if (pendingMessages.isEmpty()) { | 87 | if (pendingMessages.isEmpty()) { |
| 80 | return; | 88 | return; |
| 81 | } | 89 | } |
| @@ -89,6 +97,11 @@ public class RedisPendingMessageResendJob { | @@ -89,6 +97,11 @@ public class RedisPendingMessageResendJob { | ||
| 89 | List<MapRecord<String, Object, Object>> records = ops.range(listener.getStreamKey(), | 97 | List<MapRecord<String, Object, Object>> records = ops.range(listener.getStreamKey(), |
| 90 | Range.of(Range.Bound.inclusive(pendingMessage.getIdAsString()), Range.Bound.inclusive(pendingMessage.getIdAsString()))); | 98 | Range.of(Range.Bound.inclusive(pendingMessage.getIdAsString()), Range.Bound.inclusive(pendingMessage.getIdAsString()))); |
| 91 | if (CollUtil.isEmpty(records)) { | 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 | return; | 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,7 +64,12 @@ public class IotDeviceMessageSubscriber implements IotMessageSubscriber<IotDevic | ||
| 64 | 64 | ||
| 65 | TenantUtils.execute(message.getTenantId(), () -> { | 65 | TenantUtils.execute(message.getTenantId(), () -> { |
| 66 | // 1.1 更新设备的最后时间 | 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 | devicePropertyService.updateDeviceReportTimeAsync(device.getId(), LocalDateTime.now()); | 73 | devicePropertyService.updateDeviceReportTimeAsync(device.getId(), LocalDateTime.now()); |
| 69 | // 1.2 更新设备的连接 server | 74 | // 1.2 更新设备的连接 server |
| 70 | devicePropertyService.updateDeviceServerIdAsync(device.getId(), message.getServerId()); | 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,6 +200,10 @@ public class IotSceneRuleServiceImpl implements IotSceneRuleService { | ||
| 200 | public void executeSceneRuleByDevice(IotDeviceMessage message) { | 200 | public void executeSceneRuleByDevice(IotDeviceMessage message) { |
| 201 | // 1.1 这里的 tenantId,通过设备获取; | 201 | // 1.1 这里的 tenantId,通过设备获取; |
| 202 | IotDeviceDO device = deviceService.getDeviceFromCache(message.getDeviceId()); | 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 | TenantUtils.execute(device.getTenantId(), () -> { | 207 | TenantUtils.execute(device.getTenantId(), () -> { |
| 204 | // 1.2 获得设备匹配的规则场景 | 208 | // 1.2 获得设备匹配的规则场景 |
| 205 | List<IotSceneRuleDO> sceneRules = getMatchedSceneRuleListByMessage(message); | 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,6 +3,8 @@ package cn.iocoder.yudao.module.iot.service.rule.scene; | ||
| 3 | import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; | 3 | import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; |
| 4 | import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; | 4 | import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; |
| 5 | import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.scene.IotSceneRuleSaveReqVO; | 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 | import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; | 8 | import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; |
| 7 | import cn.iocoder.yudao.module.iot.dal.mysql.rule.IotSceneRuleMapper; | 9 | import cn.iocoder.yudao.module.iot.dal.mysql.rule.IotSceneRuleMapper; |
| 8 | import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; | 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,6 +16,7 @@ import cn.iocoder.yudao.module.iot.service.rule.scene.timer.IotTimerConditionEva | ||
| 14 | import org.junit.jupiter.api.Test; | 16 | import org.junit.jupiter.api.Test; |
| 15 | import org.mockito.InjectMocks; | 17 | import org.mockito.InjectMocks; |
| 16 | import org.mockito.Mock; | 18 | import org.mockito.Mock; |
| 19 | +import org.springframework.dao.QueryTimeoutException; | ||
| 17 | 20 | ||
| 18 | import java.util.Collections; | 21 | import java.util.Collections; |
| 19 | import java.util.List; | 22 | import java.util.List; |
| @@ -22,6 +25,7 @@ import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId | @@ -22,6 +25,7 @@ import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId | ||
| 22 | import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; | 25 | import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; |
| 23 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; | 26 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; |
| 24 | import static org.junit.jupiter.api.Assertions.assertEquals; | 27 | import static org.junit.jupiter.api.Assertions.assertEquals; |
| 28 | +import static org.junit.jupiter.api.Assertions.assertThrows; | ||
| 25 | import static org.mockito.ArgumentMatchers.any; | 29 | import static org.mockito.ArgumentMatchers.any; |
| 26 | import static org.mockito.Mockito.*; | 30 | import static org.mockito.Mockito.*; |
| 27 | 31 | ||
| @@ -38,22 +42,16 @@ public class IotSceneRuleServiceSimpleTest extends BaseMockitoUnitTest { | @@ -38,22 +42,16 @@ public class IotSceneRuleServiceSimpleTest extends BaseMockitoUnitTest { | ||
| 38 | 42 | ||
| 39 | @Mock | 43 | @Mock |
| 40 | private IotSceneRuleMapper sceneRuleMapper; | 44 | private IotSceneRuleMapper sceneRuleMapper; |
| 41 | - | ||
| 42 | @Mock | 45 | @Mock |
| 43 | private List<IotSceneRuleAction> sceneRuleActions; | 46 | private List<IotSceneRuleAction> sceneRuleActions; |
| 44 | - | ||
| 45 | @Mock | 47 | @Mock |
| 46 | private IotSceneRuleTimerHandler timerHandler; | 48 | private IotSceneRuleTimerHandler timerHandler; |
| 47 | - | ||
| 48 | @Mock | 49 | @Mock |
| 49 | private IotTimerConditionEvaluator timerConditionEvaluator; | 50 | private IotTimerConditionEvaluator timerConditionEvaluator; |
| 50 | - | ||
| 51 | @Mock | 51 | @Mock |
| 52 | private IotSceneRuleMatcherManager sceneRuleMatcherManager; | 52 | private IotSceneRuleMatcherManager sceneRuleMatcherManager; |
| 53 | - | ||
| 54 | @Mock | 53 | @Mock |
| 55 | private IotProductService productService; | 54 | private IotProductService productService; |
| 56 | - | ||
| 57 | @Mock | 55 | @Mock |
| 58 | private IotDeviceService deviceService; | 56 | private IotDeviceService deviceService; |
| 59 | 57 | ||
| @@ -216,4 +214,37 @@ public class IotSceneRuleServiceSimpleTest extends BaseMockitoUnitTest { | @@ -216,4 +214,37 @@ public class IotSceneRuleServiceSimpleTest extends BaseMockitoUnitTest { | ||
| 216 | // 验证 | 214 | // 验证 |
| 217 | verify(sceneRuleMapper, times(1)).selectById(id); | 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 | } |