Commit 5754c9a21ddb0dcaa7dfc989d55a04d9026eb8b7

Authored by YunaiV
1 parent 96918b42

✨ feat(iot):实现场景联动告警触发的消息发送

- IotAlertTriggerSceneRuleAction 实现 sendAlertMessage:按 receiveTypes
  分别走短信 / 邮件 / 站内信通道,逐通道 try/catch 隔离失败
- IotAlertReceiveTypeEnum 新增 templateCode 字段与 of(Integer) 静态查找
- IotAlertRecordService.createAlertRecord 增加 IotDeviceDO 入参,由调用方
  在 execute 中一次性回源缓存,避免每个告警配置重复查询
- 新增 IotAlertTriggerSceneRuleActionTest 单元测试
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/alert/IotAlertReceiveTypeEnum.java
1 1 package cn.iocoder.yudao.module.iot.enums.alert;
2 2  
  3 +import cn.hutool.core.util.ArrayUtil;
3 4 import cn.iocoder.yudao.framework.common.core.ArrayValuable;
4 5 import lombok.Getter;
5 6 import lombok.RequiredArgsConstructor;
... ... @@ -15,12 +16,21 @@ import java.util.Arrays;
15 16 @Getter
16 17 public enum IotAlertReceiveTypeEnum implements ArrayValuable<Integer> {
17 18  
18   - SMS(1), // 短信
19   - MAIL(2), // 邮箱
20   - NOTIFY(3); // 站内信
  19 + SMS(1, "iot_alert_sms"), // 短信
  20 + MAIL(2, "iot_alert_mail"), // 邮箱
  21 + NOTIFY(3, "iot_alert_notify"); // 站内信
21 22 // TODO 待实现(欢迎 pull request):webhook 4
22 23  
  24 + /**
  25 + * 接收方式
  26 + */
23 27 private final Integer type;
  28 + /**
  29 + * 模板编号
  30 + *
  31 + * 关联 SmsTemplateDO / MailTemplateDO / NotifyTemplateDO 的 code 属性
  32 + */
  33 + private final String templateCode;
24 34  
25 35 public static final Integer[] ARRAYS = Arrays.stream(values()).map(IotAlertReceiveTypeEnum::getType).toArray(Integer[]::new);
26 36  
... ... @@ -29,4 +39,8 @@ public enum IotAlertReceiveTypeEnum implements ArrayValuable&lt;Integer&gt; {
29 39 return ARRAYS;
30 40 }
31 41  
  42 + public static IotAlertReceiveTypeEnum of(Integer type) {
  43 + return ArrayUtil.firstMatch(item -> item.getType().equals(type), values());
  44 + }
  45 +
32 46 }
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/alert/IotAlertRecordService.java
... ... @@ -5,6 +5,8 @@ import cn.iocoder.yudao.module.iot.controller.admin.alert.vo.recrod.IotAlertReco
5 5 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
6 6 import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertConfigDO;
7 7 import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertRecordDO;
  8 +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
  9 +import jakarta.annotation.Nullable;
8 10 import jakarta.validation.constraints.NotNull;
9 11  
10 12 import java.util.Collection;
... ... @@ -58,8 +60,11 @@ public interface IotAlertRecordService {
58 60 * @param config 告警配置
59 61 * @param sceneRuleId 场景规则编号
60 62 * @param deviceMessage 设备消息,可为空
  63 + * @param device 设备信息,可为空;用于回填产品编号、设备编号
61 64 * @return 告警记录编号
62 65 */
63   - Long createAlertRecord(IotAlertConfigDO config, Long sceneRuleId, IotDeviceMessage deviceMessage);
  66 + @SuppressWarnings("UnusedReturnValue")
  67 + Long createAlertRecord(IotAlertConfigDO config, Long sceneRuleId,
  68 + @Nullable IotDeviceMessage deviceMessage, @Nullable IotDeviceDO device);
64 69  
65 70 }
66 71 \ No newline at end of file
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/alert/IotAlertRecordServiceImpl.java
... ... @@ -8,7 +8,6 @@ import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertConfigDO;
8 8 import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertRecordDO;
9 9 import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
10 10 import cn.iocoder.yudao.module.iot.dal.mysql.alert.IotAlertRecordMapper;
11   -import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
12 11 import jakarta.annotation.Resource;
13 12 import org.springframework.stereotype.Service;
14 13 import org.springframework.validation.annotation.Validated;
... ... @@ -28,9 +27,6 @@ public class IotAlertRecordServiceImpl implements IotAlertRecordService {
28 27 @Resource
29 28 private IotAlertRecordMapper alertRecordMapper;
30 29  
31   - @Resource
32   - private IotDeviceService deviceService;
33   -
34 30 @Override
35 31 public IotAlertRecordDO getAlertRecord(Long id) {
36 32 return alertRecordMapper.selectById(id);
... ... @@ -57,20 +53,18 @@ public class IotAlertRecordServiceImpl implements IotAlertRecordService {
57 53 }
58 54  
59 55 @Override
60   - public Long createAlertRecord(IotAlertConfigDO config, Long sceneRuleId, IotDeviceMessage message) {
  56 + public Long createAlertRecord(IotAlertConfigDO config, Long sceneRuleId,
  57 + IotDeviceMessage message, IotDeviceDO device) {
61 58 // 构建告警记录
62 59 IotAlertRecordDO.IotAlertRecordDOBuilder builder = IotAlertRecordDO.builder()
63 60 .configId(config.getId()).configName(config.getName()).configLevel(config.getLevel())
64 61 .sceneRuleId(sceneRuleId).processStatus(false);
65 62 if (message != null) {
66 63 builder.deviceMessage(message);
67   - // 填充设备信息
68   - IotDeviceDO device = deviceService.getDeviceFromCache(message.getDeviceId());
69   - if (device != null) {
70   - builder.productId(device.getProductId()).deviceId(device.getId());
71   - }
72 64 }
73   -
  65 + if (device != null) {
  66 + builder.productId(device.getProductId()).deviceId(device.getId());
  67 + }
74 68 // 插入记录
75 69 IotAlertRecordDO record = builder.build();
76 70 alertRecordMapper.insert(record);
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotAlertTriggerSceneRuleAction.java
1 1 package cn.iocoder.yudao.module.iot.service.rule.scene.action;
2 2  
3 3 import cn.hutool.core.collection.CollUtil;
  4 +import cn.hutool.core.date.DatePattern;
  5 +import cn.hutool.core.date.LocalDateTimeUtil;
4 6 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
  7 +import cn.iocoder.yudao.framework.dict.core.DictFrameworkUtils;
5 8 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
6 9 import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertConfigDO;
  10 +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
7 11 import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO;
  12 +import cn.iocoder.yudao.module.iot.enums.DictTypeConstants;
  13 +import cn.iocoder.yudao.module.iot.enums.alert.IotAlertReceiveTypeEnum;
8 14 import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleActionTypeEnum;
9 15 import cn.iocoder.yudao.module.iot.service.alert.IotAlertConfigService;
10 16 import cn.iocoder.yudao.module.iot.service.alert.IotAlertRecordService;
  17 +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
11 18 import cn.iocoder.yudao.module.system.api.mail.MailSendApi;
  19 +import cn.iocoder.yudao.module.system.api.mail.dto.MailSendSingleToUserReqDTO;
12 20 import cn.iocoder.yudao.module.system.api.notify.NotifyMessageSendApi;
  21 +import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDTO;
13 22 import cn.iocoder.yudao.module.system.api.sms.SmsSendApi;
  23 +import cn.iocoder.yudao.module.system.api.sms.dto.send.SmsSendSingleToUserReqDTO;
  24 +import jakarta.annotation.Nullable;
14 25 import jakarta.annotation.Resource;
  26 +import lombok.extern.slf4j.Slf4j;
15 27 import org.springframework.stereotype.Component;
16 28  
17   -import javax.annotation.Nullable;
  29 +import java.util.HashMap;
18 30 import java.util.List;
  31 +import java.util.Map;
19 32  
20 33 /**
21 34 * IoT 告警触发的 {@link IotSceneRuleAction} 实现类
... ... @@ -23,12 +36,15 @@ import java.util.List;
23 36 * @author 芋道源码
24 37 */
25 38 @Component
  39 +@Slf4j
26 40 public class IotAlertTriggerSceneRuleAction implements IotSceneRuleAction {
27 41  
28 42 @Resource
29 43 private IotAlertConfigService alertConfigService;
30 44 @Resource
31 45 private IotAlertRecordService alertRecordService;
  46 + @Resource
  47 + private IotDeviceService deviceService;
32 48  
33 49 @Resource
34 50 private SmsSendApi smsSendApi;
... ... @@ -45,19 +61,67 @@ public class IotAlertTriggerSceneRuleAction implements IotSceneRuleAction {
45 61 if (CollUtil.isEmpty(alertConfigs)) {
46 62 return;
47 63 }
  64 + // 获得设备信息
  65 + IotDeviceDO device = message != null ? deviceService.getDeviceFromCache(message.getDeviceId()) : null;
48 66 alertConfigs.forEach(alertConfig -> {
49   - // 记录告警记录,传递场景规则ID
50   - alertRecordService.createAlertRecord(alertConfig, rule.getId(), message);
  67 + // 创建告警记录
  68 + alertRecordService.createAlertRecord(alertConfig, rule.getId(), message, device);
51 69 // 发送告警消息
52   - sendAlertMessage(alertConfig, message);
  70 + sendAlertMessage(alertConfig, message, device);
53 71 });
54 72 }
55 73  
56   - private void sendAlertMessage(IotAlertConfigDO config, IotDeviceMessage deviceMessage) {
57   - // TODO @芋艿:等场景联动开发完,再实现
58   - // TODO @芋艿:短信
59   - // TODO @芋艿:邮箱
60   - // TODO @芋艿:站内信
  74 + private void sendAlertMessage(IotAlertConfigDO config,
  75 + @Nullable IotDeviceMessage deviceMessage,
  76 + @Nullable IotDeviceDO device) {
  77 + if (CollUtil.isEmpty(config.getReceiveUserIds()) || CollUtil.isEmpty(config.getReceiveTypes())) {
  78 + return;
  79 + }
  80 + Map<String, Object> templateParams = buildTemplateParams(config, deviceMessage, device);
  81 + config.getReceiveUserIds().forEach(userId ->
  82 + config.getReceiveTypes().forEach(receiveType -> sendAlertMessageToUser(userId, receiveType, templateParams)));
  83 + }
  84 +
  85 + /**
  86 + * 按指定接收方式,给单个用户发送告警消息
  87 + */
  88 + private void sendAlertMessageToUser(Long userId, Integer receiveType, Map<String, Object> templateParams) {
  89 + IotAlertReceiveTypeEnum typeEnum = IotAlertReceiveTypeEnum.of(receiveType);
  90 + if (typeEnum == null) {
  91 + return;
  92 + }
  93 + try {
  94 + switch (typeEnum) {
  95 + case SMS:
  96 + smsSendApi.sendSingleSmsToAdmin(new SmsSendSingleToUserReqDTO().setUserId(userId)
  97 + .setTemplateCode(typeEnum.getTemplateCode()).setTemplateParams(templateParams));
  98 + break;
  99 + case MAIL:
  100 + mailSendApi.sendSingleMailToAdmin(new MailSendSingleToUserReqDTO().setUserId(userId)
  101 + .setTemplateCode(typeEnum.getTemplateCode()).setTemplateParams(templateParams));
  102 + break;
  103 + case NOTIFY:
  104 + notifyMessageSendApi.sendSingleMessageToAdmin(new NotifySendSingleToUserReqDTO().setUserId(userId)
  105 + .setTemplateCode(typeEnum.getTemplateCode()).setTemplateParams(templateParams));
  106 + break;
  107 + }
  108 + } catch (Exception ex) {
  109 + log.error("[sendAlertMessageToUser][用户({}) 模板参数({}) 发送 {} 告警失败]",
  110 + userId, templateParams, typeEnum, ex);
  111 + }
  112 + }
  113 +
  114 + private Map<String, Object> buildTemplateParams(IotAlertConfigDO config,
  115 + @Nullable IotDeviceMessage deviceMessage,
  116 + @Nullable IotDeviceDO device) {
  117 + Map<String, Object> params = new HashMap<>();
  118 + params.put("configName", config.getName());
  119 + params.put("configDescription", config.getDescription());
  120 + params.put("configLevel", DictFrameworkUtils.parseDictDataLabel(DictTypeConstants.ALERT_LEVEL, config.getLevel()));
  121 + params.put("deviceName", device != null ? device.getDeviceName() : null);
  122 + params.put("reportTime", deviceMessage != null
  123 + ? LocalDateTimeUtil.format(deviceMessage.getReportTime(), DatePattern.NORM_DATETIME_PATTERN) : null);
  124 + return params;
61 125 }
62 126  
63 127 @Override
... ...
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotAlertTriggerSceneRuleActionTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.service.rule.scene.action;
  2 +
  3 +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
  4 +import cn.iocoder.yudao.framework.dict.core.DictFrameworkUtils;
  5 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  6 +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
  7 +import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertConfigDO;
  8 +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
  9 +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO;
  10 +import cn.iocoder.yudao.module.iot.enums.alert.IotAlertReceiveTypeEnum;
  11 +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleActionTypeEnum;
  12 +import cn.iocoder.yudao.module.iot.service.alert.IotAlertConfigService;
  13 +import cn.iocoder.yudao.module.iot.service.alert.IotAlertRecordService;
  14 +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
  15 +import cn.iocoder.yudao.module.system.api.mail.MailSendApi;
  16 +import cn.iocoder.yudao.module.system.api.mail.dto.MailSendSingleToUserReqDTO;
  17 +import cn.iocoder.yudao.module.system.api.notify.NotifyMessageSendApi;
  18 +import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDTO;
  19 +import cn.iocoder.yudao.module.system.api.sms.SmsSendApi;
  20 +import cn.iocoder.yudao.module.system.api.sms.dto.send.SmsSendSingleToUserReqDTO;
  21 +import org.junit.jupiter.api.Test;
  22 +import org.mockito.ArgumentCaptor;
  23 +import org.mockito.InjectMocks;
  24 +import org.mockito.Mock;
  25 +import org.mockito.MockedStatic;
  26 +
  27 +import java.time.LocalDateTime;
  28 +import java.util.Arrays;
  29 +import java.util.Collections;
  30 +
  31 +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId;
  32 +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
  33 +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString;
  34 +import static org.junit.jupiter.api.Assertions.assertEquals;
  35 +import static org.mockito.ArgumentMatchers.any;
  36 +import static org.mockito.ArgumentMatchers.anyLong;
  37 +import static org.mockito.ArgumentMatchers.eq;
  38 +import static org.mockito.Mockito.mockStatic;
  39 +import static org.mockito.Mockito.never;
  40 +import static org.mockito.Mockito.times;
  41 +import static org.mockito.Mockito.verify;
  42 +import static org.mockito.Mockito.when;
  43 +
  44 +/**
  45 + * {@link IotAlertTriggerSceneRuleAction} 的单元测试
  46 + *
  47 + * @author 芋道源码
  48 + */
  49 +public class IotAlertTriggerSceneRuleActionTest extends BaseMockitoUnitTest {
  50 +
  51 + @InjectMocks
  52 + private IotAlertTriggerSceneRuleAction action;
  53 +
  54 + @Mock
  55 + private IotAlertConfigService alertConfigService;
  56 + @Mock
  57 + private IotAlertRecordService alertRecordService;
  58 + @Mock
  59 + private IotDeviceService deviceService;
  60 +
  61 + @Mock
  62 + private SmsSendApi smsSendApi;
  63 + @Mock
  64 + private MailSendApi mailSendApi;
  65 + @Mock
  66 + private NotifyMessageSendApi notifyMessageSendApi;
  67 +
  68 + @Test
  69 + public void testGetType() {
  70 + // 调用并断言
  71 + assertEquals(IotSceneRuleActionTypeEnum.ALERT_TRIGGER, action.getType());
  72 + }
  73 +
  74 + @Test
  75 + public void testExecute_noAlertConfigs() throws Exception {
  76 + // 准备参数
  77 + IotDeviceMessage message = createDeviceMessage();
  78 + IotSceneRuleDO rule = randomPojo(IotSceneRuleDO.class);
  79 + IotSceneRuleDO.Action actionConfig = randomPojo(IotSceneRuleDO.Action.class);
  80 +
  81 + // mock 行为:返回空列表
  82 + when(alertConfigService.getAlertConfigListBySceneRuleIdAndStatus(rule.getId(), CommonStatusEnum.ENABLE.getStatus()))
  83 + .thenReturn(Collections.emptyList());
  84 +
  85 + // 调用
  86 + action.execute(message, rule, actionConfig);
  87 +
  88 + // 断言:不查设备、不创建记录、不发消息
  89 + verify(deviceService, never()).getDeviceFromCache(anyLong());
  90 + verify(alertRecordService, never()).createAlertRecord(any(), any(), any(), any());
  91 + verify(smsSendApi, never()).sendSingleSmsToAdmin(any());
  92 + verify(mailSendApi, never()).sendSingleMailToAdmin(any());
  93 + verify(notifyMessageSendApi, never()).sendSingleMessageToAdmin(any());
  94 + }
  95 +
  96 + @Test
  97 + public void testExecute_deviceTrigger_sendAllChannels() throws Exception {
  98 + // 准备参数
  99 + Long userId = randomLongId();
  100 + IotDeviceMessage message = createDeviceMessage();
  101 + IotSceneRuleDO rule = randomPojo(IotSceneRuleDO.class);
  102 + IotSceneRuleDO.Action actionConfig = randomPojo(IotSceneRuleDO.Action.class);
  103 + IotAlertConfigDO config = randomPojo(IotAlertConfigDO.class, c -> {
  104 + c.setReceiveUserIds(Collections.singletonList(userId));
  105 + c.setReceiveTypes(Arrays.asList(
  106 + IotAlertReceiveTypeEnum.SMS.getType(),
  107 + IotAlertReceiveTypeEnum.MAIL.getType(),
  108 + IotAlertReceiveTypeEnum.NOTIFY.getType()));
  109 + });
  110 + IotDeviceDO device = randomPojo(IotDeviceDO.class);
  111 +
  112 + // mock 行为
  113 + when(alertConfigService.getAlertConfigListBySceneRuleIdAndStatus(rule.getId(), CommonStatusEnum.ENABLE.getStatus()))
  114 + .thenReturn(Collections.singletonList(config));
  115 + when(deviceService.getDeviceFromCache(message.getDeviceId())).thenReturn(device);
  116 +
  117 + // 调用(mockStatic 需包住整个调用链;buildTemplateParams 内有 DictFrameworkUtils 静态调用)
  118 + try (MockedStatic<DictFrameworkUtils> dictMock = mockStatic(DictFrameworkUtils.class)) {
  119 + dictMock.when(() -> DictFrameworkUtils.parseDictDataLabel(any(), any(Integer.class)))
  120 + .thenReturn("WARN");
  121 + action.execute(message, rule, actionConfig);
  122 + }
  123 +
  124 + // 断言:设备只查一次
  125 + verify(deviceService, times(1)).getDeviceFromCache(message.getDeviceId());
  126 + // 断言:告警记录创建一次,参数透传
  127 + verify(alertRecordService, times(1))
  128 + .createAlertRecord(eq(config), eq(rule.getId()), eq(message), eq(device));
  129 + // 断言:三条通道各发一次,模板编号匹配
  130 + ArgumentCaptor<SmsSendSingleToUserReqDTO> smsCaptor = ArgumentCaptor.forClass(SmsSendSingleToUserReqDTO.class);
  131 + verify(smsSendApi, times(1)).sendSingleSmsToAdmin(smsCaptor.capture());
  132 + assertEquals(userId, smsCaptor.getValue().getUserId());
  133 + assertEquals(IotAlertReceiveTypeEnum.SMS.getTemplateCode(), smsCaptor.getValue().getTemplateCode());
  134 + ArgumentCaptor<MailSendSingleToUserReqDTO> mailCaptor = ArgumentCaptor.forClass(MailSendSingleToUserReqDTO.class);
  135 + verify(mailSendApi, times(1)).sendSingleMailToAdmin(mailCaptor.capture());
  136 + assertEquals(IotAlertReceiveTypeEnum.MAIL.getTemplateCode(), mailCaptor.getValue().getTemplateCode());
  137 + ArgumentCaptor<NotifySendSingleToUserReqDTO> notifyCaptor = ArgumentCaptor.forClass(NotifySendSingleToUserReqDTO.class);
  138 + verify(notifyMessageSendApi, times(1)).sendSingleMessageToAdmin(notifyCaptor.capture());
  139 + assertEquals(IotAlertReceiveTypeEnum.NOTIFY.getTemplateCode(), notifyCaptor.getValue().getTemplateCode());
  140 + }
  141 +
  142 + @Test
  143 + public void testExecute_timerTrigger_skipDeviceLookup() throws Exception {
  144 + // 准备参数:定时触发,message 为 null
  145 + Long userId = randomLongId();
  146 + IotSceneRuleDO rule = randomPojo(IotSceneRuleDO.class);
  147 + IotSceneRuleDO.Action actionConfig = randomPojo(IotSceneRuleDO.Action.class);
  148 + IotAlertConfigDO config = randomPojo(IotAlertConfigDO.class, c -> {
  149 + c.setReceiveUserIds(Collections.singletonList(userId));
  150 + c.setReceiveTypes(Collections.singletonList(IotAlertReceiveTypeEnum.NOTIFY.getType()));
  151 + });
  152 +
  153 + // mock 行为
  154 + when(alertConfigService.getAlertConfigListBySceneRuleIdAndStatus(rule.getId(), CommonStatusEnum.ENABLE.getStatus()))
  155 + .thenReturn(Collections.singletonList(config));
  156 +
  157 + // 调用
  158 + try (MockedStatic<DictFrameworkUtils> dictMock = mockStatic(DictFrameworkUtils.class)) {
  159 + dictMock.when(() -> DictFrameworkUtils.parseDictDataLabel(any(), any(Integer.class)))
  160 + .thenReturn("INFO");
  161 + action.execute(null, rule, actionConfig);
  162 + }
  163 +
  164 + // 断言:跳过设备查询;message 与 device 都用 null 创建告警记录
  165 + verify(deviceService, never()).getDeviceFromCache(anyLong());
  166 + verify(alertRecordService, times(1))
  167 + .createAlertRecord(eq(config), eq(rule.getId()), eq(null), eq(null));
  168 + verify(notifyMessageSendApi, times(1)).sendSingleMessageToAdmin(any(NotifySendSingleToUserReqDTO.class));
  169 + }
  170 +
  171 + @Test
  172 + public void testExecute_emptyReceiveUsers_skipSend() throws Exception {
  173 + // 准备参数:接收用户为空
  174 + IotDeviceMessage message = createDeviceMessage();
  175 + IotSceneRuleDO rule = randomPojo(IotSceneRuleDO.class);
  176 + IotSceneRuleDO.Action actionConfig = randomPojo(IotSceneRuleDO.Action.class);
  177 + IotAlertConfigDO config = randomPojo(IotAlertConfigDO.class, c -> {
  178 + c.setReceiveUserIds(Collections.emptyList());
  179 + c.setReceiveTypes(Collections.singletonList(IotAlertReceiveTypeEnum.SMS.getType()));
  180 + });
  181 + IotDeviceDO device = randomPojo(IotDeviceDO.class);
  182 +
  183 + // mock 行为
  184 + when(alertConfigService.getAlertConfigListBySceneRuleIdAndStatus(rule.getId(), CommonStatusEnum.ENABLE.getStatus()))
  185 + .thenReturn(Collections.singletonList(config));
  186 + when(deviceService.getDeviceFromCache(message.getDeviceId())).thenReturn(device);
  187 +
  188 + // 调用
  189 + action.execute(message, rule, actionConfig);
  190 +
  191 + // 断言:告警记录仍然创建,但不发送任何消息
  192 + verify(alertRecordService, times(1))
  193 + .createAlertRecord(eq(config), eq(rule.getId()), eq(message), eq(device));
  194 + verify(smsSendApi, never()).sendSingleSmsToAdmin(any());
  195 + }
  196 +
  197 + @Test
  198 + public void testExecute_unknownReceiveType_skipSend() throws Exception {
  199 + // 准备参数:接收类型为未知值
  200 + Long userId = randomLongId();
  201 + IotDeviceMessage message = createDeviceMessage();
  202 + IotSceneRuleDO rule = randomPojo(IotSceneRuleDO.class);
  203 + IotSceneRuleDO.Action actionConfig = randomPojo(IotSceneRuleDO.Action.class);
  204 + IotAlertConfigDO config = randomPojo(IotAlertConfigDO.class, c -> {
  205 + c.setReceiveUserIds(Collections.singletonList(userId));
  206 + c.setReceiveTypes(Collections.singletonList(99));
  207 + });
  208 + IotDeviceDO device = randomPojo(IotDeviceDO.class);
  209 +
  210 + // mock 行为
  211 + when(alertConfigService.getAlertConfigListBySceneRuleIdAndStatus(rule.getId(), CommonStatusEnum.ENABLE.getStatus()))
  212 + .thenReturn(Collections.singletonList(config));
  213 + when(deviceService.getDeviceFromCache(message.getDeviceId())).thenReturn(device);
  214 +
  215 + // 调用
  216 + try (MockedStatic<DictFrameworkUtils> dictMock = mockStatic(DictFrameworkUtils.class)) {
  217 + dictMock.when(() -> DictFrameworkUtils.parseDictDataLabel(any(), any(Integer.class)))
  218 + .thenReturn("WARN");
  219 + action.execute(message, rule, actionConfig);
  220 + }
  221 +
  222 + // 断言:未知类型不发送
  223 + verify(smsSendApi, never()).sendSingleSmsToAdmin(any());
  224 + verify(mailSendApi, never()).sendSingleMailToAdmin(any());
  225 + verify(notifyMessageSendApi, never()).sendSingleMessageToAdmin(any());
  226 + }
  227 +
  228 + @Test
  229 + public void testExecute_smsFailure_doesNotBlockOthers() throws Exception {
  230 + // 准备参数
  231 + Long userId = randomLongId();
  232 + IotDeviceMessage message = createDeviceMessage();
  233 + IotSceneRuleDO rule = randomPojo(IotSceneRuleDO.class);
  234 + IotSceneRuleDO.Action actionConfig = randomPojo(IotSceneRuleDO.Action.class);
  235 + IotAlertConfigDO config = randomPojo(IotAlertConfigDO.class, c -> {
  236 + c.setReceiveUserIds(Collections.singletonList(userId));
  237 + c.setReceiveTypes(Arrays.asList(
  238 + IotAlertReceiveTypeEnum.SMS.getType(),
  239 + IotAlertReceiveTypeEnum.MAIL.getType()));
  240 + });
  241 + IotDeviceDO device = randomPojo(IotDeviceDO.class);
  242 +
  243 + // mock 行为:sms 抛异常
  244 + when(alertConfigService.getAlertConfigListBySceneRuleIdAndStatus(rule.getId(), CommonStatusEnum.ENABLE.getStatus()))
  245 + .thenReturn(Collections.singletonList(config));
  246 + when(deviceService.getDeviceFromCache(message.getDeviceId())).thenReturn(device);
  247 + when(smsSendApi.sendSingleSmsToAdmin(any())).thenThrow(new RuntimeException("sms 渠道异常"));
  248 +
  249 + // 调用
  250 + try (MockedStatic<DictFrameworkUtils> dictMock = mockStatic(DictFrameworkUtils.class)) {
  251 + dictMock.when(() -> DictFrameworkUtils.parseDictDataLabel(any(), any(Integer.class)))
  252 + .thenReturn("ERROR");
  253 + action.execute(message, rule, actionConfig);
  254 + }
  255 +
  256 + // 断言:sms 抛错时邮件依旧发送
  257 + verify(smsSendApi, times(1)).sendSingleSmsToAdmin(any());
  258 + verify(mailSendApi, times(1)).sendSingleMailToAdmin(any());
  259 + }
  260 +
  261 + /**
  262 + * 创建带 reportTime 的设备消息
  263 + */
  264 + private IotDeviceMessage createDeviceMessage() {
  265 + IotDeviceMessage message = new IotDeviceMessage();
  266 + message.setId(randomString());
  267 + message.setDeviceId(randomLongId());
  268 + message.setReportTime(LocalDateTime.now());
  269 + return message;
  270 + }
  271 +
  272 +}
... ...