Commit 6ed0971489b4a30c2b6509f4f089e5f749a4dcfe

Authored by YunaiV
1 parent 8701cf75

fix(iot): 修复删除设备后消息反复重投及重投任务扫描无界

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&lt;IotDevic @@ -64,7 +64,12 @@ public class IotDeviceMessageSubscriber implements IotMessageSubscriber&lt;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 }