feat(iot): Wave 5 Round 2 — B9/B14/B16 统一消费入口 + 告警分布式锁 + 通知集成

B9 IotRuleEngineMessageHandler(统一消费入口)
- 新消费者 v2 统一入口,@PostConstruct 注册到 IotMessageBus
- versionResolver.shouldUseV2 三态路由(V1/V2/HYBRID),绝不双跑
- device null 时 WARN + skip;RuleEngine 异常 try-catch 吞掉防重试风暴
- v1 三消费者(DataRule/SceneRule/CleanRule)增加前置 v2 bypass 判断
- 6 个单元测试(global-v1/v2/hybrid 白名单命中/未命中/device-null/引擎异常)

B14 告警缓存 + SET NX PX 分布式锁 + 有效性判断
- IotAlarmLockService:SET NX PX + Lua 原子解锁,锁冲突抛 ALARM_LOCK_CONFLICT
- IotAlarmCacheService:Redis Hash iot:alarm:state:{id},TTL 7天,cache miss 从DB重建
- AlarmStateValidator:isEffectiveTrigger/isEffectiveClear 时序校验,防旧消息重置已清除告警
- IotAlarmRecordServiceImpl:trigger/clear/ack/archive 全部在锁内,DB写后立即同步缓存
- 新增 ALARM_LOCK_CONFLICT 错误码;AlarmTriggerRequest 增加 timestamp 字段
- 17 个单元测试(锁 4 + 缓存 5 + 校验 9 + 集成 3)

B16 NotifyAction 4 通道集成 + 模板解析
- NotifyChannel SPI 接口 + Sms/Email/InApp/Webhook 四实现(@Component 注册)
- WebhookNotifyChannel:JDK 17 HttpClient 10s 超时 + SSRF 强制 HTTPS 校验
- NotifyDispatcher:独立 ForkJoinPool(8) 并行分发,30s 整体超时,部分失败不阻塞
- 模板变量统一走 TemplateResolver(评审 C5),缺失变量降级为空串
- NotifyAction 移除 stub,委托 NotifyDispatcher
- viewsh-module-system-api 依赖引入;13 个测试(Dispatcher 7 + Webhook SSRF 6)

测试:iot-rule 177/177 全绿;iot-server 251/251 全绿(含 Skipped 161 旧 v1 测试)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-04-24 11:00:38 +08:00
parent 8e7631987f
commit 171f201384
28 changed files with 2559 additions and 171 deletions

View File

@@ -28,6 +28,13 @@
<version>${revision}</version>
</dependency>
<!-- B16 通知集成SMS / 邮件 / 站内信 Feign API -->
<dependency>
<groupId>com.viewsh</groupId>
<artifactId>viewsh-module-system-api</artifactId>
<version>${revision}</version>
</dependency>
<!-- 【关键】Aviator 表达式引擎 -->
<dependency>
<groupId>com.googlecode.aviator</groupId>

View File

@@ -2,18 +2,17 @@ package com.viewsh.module.iot.rule.action;
import com.fasterxml.jackson.databind.JsonNode;
import com.viewsh.module.iot.rule.engine.RuleContext;
import com.viewsh.module.iot.rule.notify.NotifyDispatcher;
import com.viewsh.module.iot.rule.notify.model.NotifyResult;
import com.viewsh.module.iot.rule.result.ActionResult;
import com.viewsh.module.iot.rule.spi.ActionProvider;
import com.viewsh.module.iot.rule.template.TemplateResolver;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;
/**
* 通知发送 Actionnotify
@@ -22,37 +21,53 @@ import java.util.concurrent.Executors;
* <pre>{@code
* {
* "channels": ["sms", "email", "in_app", "webhook"],
* "receivers": { "userIds": [1001], "webhookUrl": "https://hook.example.com" },
* "receivers": {
* "adminUserIds": [1001],
* "memberUserIds": [],
* "webhookUrl": "https://hook.example.com"
* },
* "template": {
* "title": "设备 ${meta.deviceName} 告警",
* "body": "告警:${meta.alarmName},触发值 ${data.temperature}°C"
* }
* },
* "sms": {"templateCode": "ALARM_TRIGGER"},
* "mail": {"templateCode": "ALARM_MAIL"},
* "inApp": {"templateCode": "ALARM_INAPP"}
* }
* }</pre>
*
* <p>4 个通道并发触发,部分失败不阻塞其他通道,最终汇总结果
* 评审 C5title/body 统一走 {@link TemplateResolver} 解析
* 评审 B6@Async 慎用,保持同步线程池以保留 traceId 和 tenant 上下文
*
* <p>第一期 B16NotifyService未就绪各通道以 TODO stub 占位并记录日志;
* B16 就绪后替换 stub 即可。
* <p>委托 {@link NotifyDispatcher} 并行发送到各通道B16 通知集成)
* 部分失败不阻塞其他通道,最终汇总结果并记录
* 评审 C5title/body 统一走 {@link TemplateResolver} 解析(在 Dispatcher 内完成)
* 评审 B6独立 ForkJoinPool不占用 RuleEngine 主池。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class NotifyAction implements ActionProvider {
public static final String TYPE = "notify";
// TemplateResolver 保留(单元测试 ActionProviderTest 直接 new NotifyAction(templateResolver) 构造)
@SuppressWarnings("unused")
private final TemplateResolver templateResolver;
/** 通知并发线程池(复用,避免每次 Action 创建) */
private static final ExecutorService NOTIFY_POOL =
Executors.newFixedThreadPool(4, r -> {
Thread t = new Thread(r, "iot-notify-");
t.setDaemon(true);
return t;
});
/**
* NotifyDispatcher 可选注入B16 就绪后由 Spring 注入;
* 单元测试中如不提供则降级为老 stub 模式,保持 ActionProviderTest 兼容)。
*/
private final NotifyDispatcher notifyDispatcher;
/** 兼容旧构造ActionProviderTest 使用,不注入 Dispatcher */
public NotifyAction(TemplateResolver templateResolver) {
this.templateResolver = templateResolver;
this.notifyDispatcher = null;
}
@Autowired
public NotifyAction(TemplateResolver templateResolver, NotifyDispatcher notifyDispatcher) {
this.templateResolver = templateResolver;
this.notifyDispatcher = notifyDispatcher;
}
@Override
public String getType() {
@@ -67,36 +82,24 @@ public class NotifyAction implements ActionProvider {
return ActionResult.failure("notify: channels 未配置");
}
String title = resolveTemplate(config.path("template").path("title").asText(""), ctx);
String body = resolveTemplate(config.path("template").path("body").asText(""), ctx);
JsonNode receivers = config.path("receivers");
List<String> channels = new ArrayList<>();
for (JsonNode ch : channelsNode) {
channels.add(ch.asText());
if (notifyDispatcher == null) {
// 测试/降级路径Dispatcher 未注入时走简单成功返回stub
log.info("[NotifyAction] NotifyDispatcher 未注入stub 模式跳过通知 chain={}", ctx.getChainId());
return ActionResult.successMsg("notify: stub 模式NotifyDispatcher 未就绪)");
}
List<CompletableFuture<ChannelResult>> futures = channels.stream()
.map(channel -> CompletableFuture.supplyAsync(
() -> sendChannel(channel, title, body, receivers, ctx),
NOTIFY_POOL))
.toList();
List<NotifyResult> results = notifyDispatcher.dispatch(config, ctx);
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
List<String> failed = results.stream()
.filter(r -> !r.isSuccess())
.map(NotifyResult::toString)
.collect(Collectors.toList());
List<String> failedChannels = new ArrayList<>();
for (int i = 0; i < channels.size(); i++) {
ChannelResult cr = futures.get(i).join();
if (!cr.success()) {
failedChannels.add(channels.get(i) + ":" + cr.error());
}
}
if (failedChannels.isEmpty()) {
if (failed.isEmpty()) {
return ActionResult.successMsg("notify: 全部通道发送成功");
} else {
// 部分失败:仍返回 SUCCESSmessage 记录失败通道(评审 B6
String msg = "notify: 部分通道失败 " + failedChannels;
// 部分失败:仍返回 SUCCESSmessage 记录失败通道(评审 B6:部分失败不阻断规则链
String msg = "notify: 部分通道失败 " + failed;
log.warn("[NotifyAction] chain={} {}", ctx.getChainId(), msg);
return ActionResult.successMsg(msg);
}
@@ -106,56 +109,4 @@ public class NotifyAction implements ActionProvider {
return ActionResult.failure(e.getMessage());
}
}
private String resolveTemplate(String template, RuleContext ctx) {
if (template == null || template.isBlank()) return template;
try {
return String.valueOf(templateResolver.resolve(template, ctx));
} catch (Exception e) {
log.warn("[NotifyAction] 模板解析失败 template='{}': {}", template, e.getMessage());
return template;
}
}
/**
* 单通道发送(第一期 stubB16 NotifyService 就绪后替换)。
*/
private ChannelResult sendChannel(String channel, String title, String body,
JsonNode receivers, RuleContext ctx) {
try {
switch (channel) {
case "sms" -> {
// TODO B16: smsService.send(receivers.userIds, title, body)
log.info("[NotifyAction] [stub] sms chain={} title='{}' body='{}'",
ctx.getChainId(), title, body);
}
case "email" -> {
// TODO B16: emailService.send(receivers.userIds, title, body)
log.info("[NotifyAction] [stub] email chain={} title='{}' body='{}'",
ctx.getChainId(), title, body);
}
case "in_app" -> {
// TODO B16: inAppService.send(receivers.userIds, title, body)
log.info("[NotifyAction] [stub] in_app chain={} title='{}' body='{}'",
ctx.getChainId(), title, body);
}
case "webhook" -> {
String webhookUrl = receivers.path("webhookUrl").asText("");
// TODO B16: webhookService.post(webhookUrl, title, body)
log.info("[NotifyAction] [stub] webhook chain={} url='{}' title='{}' body='{}'",
ctx.getChainId(), webhookUrl, title, body);
}
default -> {
log.warn("[NotifyAction] 未知通道: {}", channel);
return new ChannelResult(false, "未知通道: " + channel);
}
}
return new ChannelResult(true, null);
} catch (Exception e) {
log.warn("[NotifyAction] channel={} 发送失败: {}", channel, e.getMessage());
return new ChannelResult(false, e.getMessage());
}
}
private record ChannelResult(boolean success, String error) {}
}

View File

@@ -0,0 +1,249 @@
package com.viewsh.module.iot.rule.notify;
import com.fasterxml.jackson.databind.JsonNode;
import com.viewsh.module.iot.rule.engine.RuleContext;
import com.viewsh.module.iot.rule.notify.channel.NotifyChannel;
import com.viewsh.module.iot.rule.notify.model.NotifyRequest;
import com.viewsh.module.iot.rule.notify.model.NotifyResult;
import com.viewsh.module.iot.rule.template.TemplateResolver;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* 通知分发器B16 通知集成)。
*
* <p>职责:
* <ol>
* <li>使用 {@link TemplateResolver} 统一解析 title/body 模板变量(评审 C5</li>
* <li>根据 config.channels 路由到对应的 {@link NotifyChannel} 实现</li>
* <li>独立 {@link ForkJoinPool}(不占用 RuleEngine 主池,评审 Known Pitfalls并行发送</li>
* <li>30s 整体超时(单通道超时由各通道自行控制)</li>
* <li>部分失败不阻塞其他通道(评审 B6</li>
* </ol>
*
* <p>config JSON 示例:
* <pre>{@code
* {
* "channels": ["sms", "email", "in_app", "webhook"],
* "receivers": {
* "adminUserIds": [1001, 1002],
* "memberUserIds": [2001],
* "webhookUrl": "https://hook.example.com/alert"
* },
* "template": {
* "title": "${alarm.severity} 告警:${alarm.name}",
* "body": "设备 ${meta.deviceName} 于 ${alarm.startTs} 触发,当前值 ${data.temperature}°C"
* },
* "sms": {"templateCode": "ALARM_TRIGGER"},
* "mail": {"templateCode": "ALARM_MAIL"},
* "inApp": {"templateCode": "ALARM_INAPP"}
* }
* }</pre>
*/
@Slf4j
@Service
public class NotifyDispatcher {
/** 整体超时(所有通道并行发送的等待上限,评审 Known Pitfalls */
static final int OVERALL_TIMEOUT_SECONDS = 30;
/**
* 独立 ForkJoinPool不占用 RuleEngine 或公共 ForkJoin 主池(评审 Known Pitfalls
* 并行度 = 84 通道 × 2留余量
*/
private final ForkJoinPool notifyExecutor = new ForkJoinPool(8);
private final List<NotifyChannel> channels;
private final TemplateResolver templateResolver;
/** 通道名称 → 实现 Map启动时构建O(1) 路由) */
private final Map<String, NotifyChannel> channelMap;
public NotifyDispatcher(List<NotifyChannel> channels, TemplateResolver templateResolver) {
this.channels = channels;
this.templateResolver = templateResolver;
this.channelMap = new HashMap<>();
for (NotifyChannel ch : channels) {
channelMap.put(ch.getName(), ch);
}
log.info("[NotifyDispatcher] 注册通道: {}", channelMap.keySet());
}
/**
* 并行分发通知。
*
* @param config 规则节点 config含 channels/receivers/template 等)
* @param ctx 规则执行上下文(供模板解析使用)
* @return 各通道发送结果列表(含成功与失败)
*/
public List<NotifyResult> dispatch(JsonNode config, RuleContext ctx) {
// 1. 目标通道列表
List<String> targetChannelNames = parseChannels(config);
if (targetChannelNames.isEmpty()) {
log.warn("[NotifyDispatcher] chain={} channels 未配置,跳过通知", ctx.getChainId());
return List.of();
}
// 2. 模板预解析(所有通道共用,评审 C5
String title = resolveTemplate(config.path("template").path("title").asText(""), ctx);
String body = resolveTemplate(config.path("template").path("body").asText(""), ctx);
// 3. 构建各通道请求
List<NotifyRequest> requests = targetChannelNames.stream()
.map(name -> buildRequest(name, title, body, config))
.collect(Collectors.toList());
// 4. 并行发送(独立 ForkJoinPool
List<CompletableFuture<NotifyResult>> futures = requests.stream()
.map(req -> CompletableFuture.supplyAsync(
() -> sendSafely(req),
notifyExecutor))
.collect(Collectors.toList());
// 5. 等待全部完成(整体超时 30s
List<NotifyResult> results = new ArrayList<>();
for (int i = 0; i < futures.size(); i++) {
CompletableFuture<NotifyResult> future = futures.get(i);
String channelName = targetChannelNames.get(i);
try {
results.add(future.get(OVERALL_TIMEOUT_SECONDS, TimeUnit.SECONDS));
} catch (Exception e) {
log.warn("[NotifyDispatcher] channel={} 超时或异常: {}", channelName, e.getMessage());
results.add(NotifyResult.failure(channelName, "timeout: " + e.getMessage()));
}
}
// 6. 汇总日志
long failCount = results.stream().filter(r -> !r.isSuccess()).count();
if (failCount == 0) {
log.info("[NotifyDispatcher] chain={} 全部通道发送成功 channels={}", ctx.getChainId(), targetChannelNames);
} else {
log.warn("[NotifyDispatcher] chain={} 部分通道失败 {}/{}: {}",
ctx.getChainId(), failCount, results.size(),
results.stream().filter(r -> !r.isSuccess()).map(NotifyResult::toString).collect(Collectors.joining(", ")));
}
return results;
}
// ====== Private methods ======
private List<String> parseChannels(JsonNode config) {
List<String> names = new ArrayList<>();
JsonNode chNode = config.path("channels");
if (chNode.isArray()) {
for (JsonNode n : chNode) {
String name = n.asText("").trim();
if (StringUtils.hasText(name)) {
names.add(name);
}
}
}
return names;
}
private String resolveTemplate(String template, RuleContext ctx) {
if (!StringUtils.hasText(template)) {
return template;
}
try {
Object result = templateResolver.resolve(template, ctx);
return result == null ? "" : String.valueOf(result);
} catch (Exception e) {
log.warn("[NotifyDispatcher] 模板解析失败 '{}': {}", template, e.getMessage());
return template; // 降级:返回原始模板字符串
}
}
private NotifyRequest buildRequest(String channelName, String title, String body, JsonNode config) {
JsonNode receivers = config.path("receivers");
NotifyRequest.NotifyRequestBuilder builder = NotifyRequest.builder()
.channel(channelName)
.title(title)
.body(body)
.adminUserIds(parseUserIds(receivers.path("adminUserIds")))
.memberUserIds(parseUserIds(receivers.path("memberUserIds")));
switch (channelName) {
case "sms" -> {
JsonNode smsConfig = config.path("sms");
builder.smsTemplateCode(smsConfig.path("templateCode").asText("ALARM_SMS"));
builder.smsTemplateParams(jsonToMap(smsConfig.path("templateParams")));
}
case "email" -> {
JsonNode mailConfig = config.path("mail");
builder.mailTemplateCode(mailConfig.path("templateCode").asText("ALARM_MAIL"));
builder.mailTemplateParams(jsonToMap(mailConfig.path("templateParams")));
}
case "in_app" -> {
JsonNode inAppConfig = config.path("inApp");
builder.inAppTemplateCode(inAppConfig.path("templateCode").asText("ALARM_INAPP"));
builder.inAppTemplateParams(jsonToMap(inAppConfig.path("templateParams")));
}
case "webhook" -> {
String webhookUrl = receivers.path("webhookUrl").asText("");
builder.webhookUrl(webhookUrl);
builder.webhookHeaders(jsonToStringMap(config.path("webhookHeaders")));
String webhookBody = config.path("webhookBody").asText(null);
builder.webhookBody(webhookBody);
}
default -> log.warn("[NotifyDispatcher] 未知通道: {}", channelName);
}
return builder.build();
}
private NotifyResult sendSafely(NotifyRequest req) {
String channelName = req.getChannel();
NotifyChannel channel = channelMap.get(channelName);
if (channel == null) {
log.warn("[NotifyDispatcher] 未找到通道实现: {}", channelName);
return NotifyResult.failure(channelName, "channel not found: " + channelName);
}
try {
return channel.send(req);
} catch (Exception e) {
log.error("[NotifyDispatcher] channel={} 异常: {}", channelName, e.getMessage(), e);
return NotifyResult.failure(channelName, e.getMessage());
}
}
private List<Long> parseUserIds(JsonNode node) {
List<Long> ids = new ArrayList<>();
if (node != null && node.isArray()) {
for (JsonNode n : node) {
ids.add(n.asLong());
}
}
return ids;
}
private Map<String, Object> jsonToMap(JsonNode node) {
if (node == null || node.isMissingNode()) {
return null;
}
Map<String, Object> map = new HashMap<>();
node.fields().forEachRemaining(e -> map.put(e.getKey(), e.getValue().asText()));
return map;
}
private Map<String, String> jsonToStringMap(JsonNode node) {
if (node == null || node.isMissingNode()) {
return null;
}
Map<String, String> map = new HashMap<>();
node.fields().forEachRemaining(e -> map.put(e.getKey(), e.getValue().asText()));
return map;
}
}

View File

@@ -0,0 +1,82 @@
package com.viewsh.module.iot.rule.notify.channel;
import com.viewsh.module.iot.rule.notify.model.NotifyRequest;
import com.viewsh.module.iot.rule.notify.model.NotifyResult;
import com.viewsh.module.system.api.mail.MailSendApi;
import com.viewsh.module.system.api.mail.dto.MailSendSingleToUserReqDTO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import java.util.HashMap;
import java.util.Map;
/**
* 邮件通知通道B16 通知集成)。
*
* <p>调用 {@link MailSendApi} Feign 发送邮件。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class EmailNotifyChannel implements NotifyChannel {
private final MailSendApi mailApi;
@Override
public String getName() {
return "email";
}
@Override
public NotifyResult send(NotifyRequest req) {
try {
if (CollectionUtils.isEmpty(req.getAdminUserIds())
&& CollectionUtils.isEmpty(req.getMemberUserIds())) {
log.warn("[EmailNotifyChannel] 未配置接收用户,跳过发送");
return NotifyResult.failure(getName(), "no receivers");
}
Map<String, Object> params = buildParams(req);
// Admin 用户
if (!CollectionUtils.isEmpty(req.getAdminUserIds())) {
for (Long userId : req.getAdminUserIds()) {
MailSendSingleToUserReqDTO dto = new MailSendSingleToUserReqDTO();
dto.setUserId(userId);
dto.setTemplateCode(req.getMailTemplateCode());
dto.setTemplateParams(params);
mailApi.sendSingleMailToAdmin(dto);
}
}
// Member 用户
if (!CollectionUtils.isEmpty(req.getMemberUserIds())) {
for (Long userId : req.getMemberUserIds()) {
MailSendSingleToUserReqDTO dto = new MailSendSingleToUserReqDTO();
dto.setUserId(userId);
dto.setTemplateCode(req.getMailTemplateCode());
dto.setTemplateParams(params);
mailApi.sendSingleMailToMember(dto);
}
}
log.debug("[EmailNotifyChannel] 发送成功 templateCode={}", req.getMailTemplateCode());
return NotifyResult.success(getName());
} catch (Exception e) {
log.error("[EmailNotifyChannel] 发送失败: {}", e.getMessage(), e);
return NotifyResult.failure(getName(), e.getMessage());
}
}
private Map<String, Object> buildParams(NotifyRequest req) {
Map<String, Object> params = new HashMap<>();
if (req.getMailTemplateParams() != null) {
params.putAll(req.getMailTemplateParams());
}
params.putIfAbsent("title", req.getTitle());
params.putIfAbsent("body", req.getBody());
return params;
}
}

View File

@@ -0,0 +1,82 @@
package com.viewsh.module.iot.rule.notify.channel;
import com.viewsh.module.iot.rule.notify.model.NotifyRequest;
import com.viewsh.module.iot.rule.notify.model.NotifyResult;
import com.viewsh.module.system.api.notify.NotifyMessageSendApi;
import com.viewsh.module.system.api.notify.dto.NotifySendSingleToUserReqDTO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import java.util.HashMap;
import java.util.Map;
/**
* 站内信通知通道B16 通知集成)。
*
* <p>调用 {@link NotifyMessageSendApi} Feign 发送站内信。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class InAppNotifyChannel implements NotifyChannel {
private final NotifyMessageSendApi notifyApi;
@Override
public String getName() {
return "in_app";
}
@Override
public NotifyResult send(NotifyRequest req) {
try {
if (CollectionUtils.isEmpty(req.getAdminUserIds())
&& CollectionUtils.isEmpty(req.getMemberUserIds())) {
log.warn("[InAppNotifyChannel] 未配置接收用户,跳过发送");
return NotifyResult.failure(getName(), "no receivers");
}
Map<String, Object> params = buildParams(req);
// Admin 用户
if (!CollectionUtils.isEmpty(req.getAdminUserIds())) {
for (Long userId : req.getAdminUserIds()) {
NotifySendSingleToUserReqDTO dto = new NotifySendSingleToUserReqDTO();
dto.setUserId(userId);
dto.setTemplateCode(req.getInAppTemplateCode());
dto.setTemplateParams(params);
notifyApi.sendSingleMessageToAdmin(dto);
}
}
// Member 用户
if (!CollectionUtils.isEmpty(req.getMemberUserIds())) {
for (Long userId : req.getMemberUserIds()) {
NotifySendSingleToUserReqDTO dto = new NotifySendSingleToUserReqDTO();
dto.setUserId(userId);
dto.setTemplateCode(req.getInAppTemplateCode());
dto.setTemplateParams(params);
notifyApi.sendSingleMessageToMember(dto);
}
}
log.debug("[InAppNotifyChannel] 发送成功 templateCode={}", req.getInAppTemplateCode());
return NotifyResult.success(getName());
} catch (Exception e) {
log.error("[InAppNotifyChannel] 发送失败: {}", e.getMessage(), e);
return NotifyResult.failure(getName(), e.getMessage());
}
}
private Map<String, Object> buildParams(NotifyRequest req) {
Map<String, Object> params = new HashMap<>();
if (req.getInAppTemplateParams() != null) {
params.putAll(req.getInAppTemplateParams());
}
params.putIfAbsent("title", req.getTitle());
params.putIfAbsent("body", req.getBody());
return params;
}
}

View File

@@ -0,0 +1,30 @@
package com.viewsh.module.iot.rule.notify.channel;
import com.viewsh.module.iot.rule.notify.model.NotifyRequest;
import com.viewsh.module.iot.rule.notify.model.NotifyResult;
/**
* 通知通道 SPI 接口B16 通知集成)。
*
* <p>每个通道实现对应一种推送方式sms / email / in_app / webhook
* 实现类通过 Spring {@code @Component} 注册,由 {@link com.viewsh.module.iot.rule.notify.NotifyDispatcher}
* 按通道名称路由。
*/
public interface NotifyChannel {
/**
* 通道名称。
*
* @return sms / email / in_app / webhook
*/
String getName();
/**
* 同步发送通知,捕获所有异常并以 {@link NotifyResult#failure} 返回;
* 不得向外抛出受检/非受检异常。
*
* @param req 通知请求title/body 已解析模板变量)
* @return 发送结果
*/
NotifyResult send(NotifyRequest req);
}

View File

@@ -0,0 +1,84 @@
package com.viewsh.module.iot.rule.notify.channel;
import com.viewsh.module.iot.rule.notify.model.NotifyRequest;
import com.viewsh.module.iot.rule.notify.model.NotifyResult;
import com.viewsh.module.system.api.sms.SmsSendApi;
import com.viewsh.module.system.api.sms.dto.send.SmsSendSingleToUserReqDTO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import java.util.HashMap;
import java.util.Map;
/**
* SMS 通知通道B16 通知集成)。
*
* <p>调用 {@link SmsSendApi} Feign 发送短信。
* 短信走模板({@code req.smsTemplateCode} 必填),不是自由文本(评审 Known Pitfalls
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SmsNotifyChannel implements NotifyChannel {
private final SmsSendApi smsApi;
@Override
public String getName() {
return "sms";
}
@Override
public NotifyResult send(NotifyRequest req) {
try {
if (CollectionUtils.isEmpty(req.getAdminUserIds())
&& CollectionUtils.isEmpty(req.getMemberUserIds())) {
log.warn("[SmsNotifyChannel] 未配置接收用户,跳过发送");
return NotifyResult.failure(getName(), "no receivers");
}
Map<String, Object> params = buildParams(req);
// Admin 用户
if (!CollectionUtils.isEmpty(req.getAdminUserIds())) {
for (Long userId : req.getAdminUserIds()) {
SmsSendSingleToUserReqDTO dto = new SmsSendSingleToUserReqDTO();
dto.setUserId(userId);
dto.setTemplateCode(req.getSmsTemplateCode());
dto.setTemplateParams(params);
smsApi.sendSingleSmsToAdmin(dto);
}
}
// Member 用户
if (!CollectionUtils.isEmpty(req.getMemberUserIds())) {
for (Long userId : req.getMemberUserIds()) {
SmsSendSingleToUserReqDTO dto = new SmsSendSingleToUserReqDTO();
dto.setUserId(userId);
dto.setTemplateCode(req.getSmsTemplateCode());
dto.setTemplateParams(params);
smsApi.sendSingleSmsToMember(dto);
}
}
log.debug("[SmsNotifyChannel] 发送成功 templateCode={}", req.getSmsTemplateCode());
return NotifyResult.success(getName());
} catch (Exception e) {
log.error("[SmsNotifyChannel] 发送失败: {}", e.getMessage(), e);
return NotifyResult.failure(getName(), e.getMessage());
}
}
private Map<String, Object> buildParams(NotifyRequest req) {
Map<String, Object> params = new HashMap<>();
if (req.getSmsTemplateParams() != null) {
params.putAll(req.getSmsTemplateParams());
}
// 将 title/body 也注入模板参数,方便模板直接引用
params.putIfAbsent("title", req.getTitle());
params.putIfAbsent("body", req.getBody());
return params;
}
}

View File

@@ -0,0 +1,140 @@
package com.viewsh.module.iot.rule.notify.channel;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.viewsh.module.iot.rule.notify.model.NotifyRequest;
import com.viewsh.module.iot.rule.notify.model.NotifyResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.Map;
/**
* Webhook 通知通道B16 通知集成)。
*
* <p>使用 JDK 11+ 内置 {@link HttpClient}10s 超时POST JSON 到目标 URL。
* 安全红线URL 必须是 HTTPS且可选配置允许主机白名单防 SSRF
*
* <p>配置项application.yml
* <pre>{@code
* iot:
* notify:
* webhook:
* allowed-hosts: # 可选;为空时仅校验 HTTPS
* - hook.example.com
* - api.company.com
* }</pre>
*/
@Slf4j
@Component
public class WebhookNotifyChannel implements NotifyChannel {
/** 单次 Webhook 请求超时10s评审 Known Pitfalls */
private static final int TIMEOUT_SECONDS = 10;
private final HttpClient httpClient;
private final ObjectMapper objectMapper;
/**
* 可信主机白名单(为空时仅要求 HTTPS不限制具体主机
* 通过 {@code iot.notify.webhook.allowed-hosts} 配置。
*/
@Value("${iot.notify.webhook.allowed-hosts:}")
private List<String> allowedHosts;
public WebhookNotifyChannel(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(TIMEOUT_SECONDS))
.build();
}
@Override
public String getName() {
return "webhook";
}
@Override
public NotifyResult send(NotifyRequest req) {
try {
String url = req.getWebhookUrl();
validateWebhookUrl(url);
String body = buildBody(req);
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(TIMEOUT_SECONDS))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body));
// 注入自定义请求头
if (req.getWebhookHeaders() != null) {
for (Map.Entry<String, String> entry : req.getWebhookHeaders().entrySet()) {
builder.header(entry.getKey(), entry.getValue());
}
}
HttpResponse<String> response = httpClient.send(
builder.build(),
HttpResponse.BodyHandlers.ofString());
int statusCode = response.statusCode();
if (statusCode >= 200 && statusCode < 300) {
log.debug("[WebhookNotifyChannel] 发送成功 url={} status={}", url, statusCode);
return NotifyResult.success(getName());
} else {
String msg = "HTTP " + statusCode + ": " + response.body();
log.warn("[WebhookNotifyChannel] 发送失败 url={} {}", url, msg);
return NotifyResult.failure(getName(), msg);
}
} catch (IllegalArgumentException e) {
// SSRF 校验失败,直接上抛(不包装为 failure
throw e;
} catch (Exception e) {
log.error("[WebhookNotifyChannel] 发送异常: {}", e.getMessage(), e);
return NotifyResult.failure(getName(), e.getMessage());
}
}
/**
* SSRF 防护URL 必须 HTTPS若配置了白名单则主机必须在名单内。
*
* @throws IllegalArgumentException 校验失败时
*/
void validateWebhookUrl(String url) {
if (!StringUtils.hasText(url)) {
throw new IllegalArgumentException("webhook url must not be blank");
}
if (!url.startsWith("https://")) {
throw new IllegalArgumentException("webhook url must be HTTPS, got: " + url);
}
if (allowedHosts != null && !allowedHosts.isEmpty()) {
URI uri = URI.create(url);
String host = uri.getHost();
boolean allowed = allowedHosts.stream().anyMatch(h -> h.equalsIgnoreCase(host));
if (!allowed) {
throw new IllegalArgumentException(
"webhook host '" + host + "' is not in the allowed-hosts whitelist");
}
}
}
private String buildBody(NotifyRequest req) throws Exception {
if (StringUtils.hasText(req.getWebhookBody())) {
return req.getWebhookBody();
}
// 默认 body包含 title + body
Map<String, String> payload = Map.of(
"title", req.getTitle() != null ? req.getTitle() : "",
"body", req.getBody() != null ? req.getBody() : "");
return objectMapper.writeValueAsString(payload);
}
}

View File

@@ -0,0 +1,72 @@
package com.viewsh.module.iot.rule.notify.model;
import lombok.Builder;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* 通知请求B16 通知集成)。
*
* <p>包含已解析的模板内容 + 各通道所需参数。
* 模板变量由 NotifyDispatcher 在调用各通道前统一解析完成。
*/
@Data
@Builder
public class NotifyRequest {
/** 通道名称sms / email / in_app / webhook */
private String channel;
// ---------- 通用字段(所有通道共用) ----------
/** 通知标题(已解析模板变量) */
private String title;
/** 通知正文(已解析模板变量) */
private String body;
// ---------- 用户指向sms / email / in_app ----------
/** Admin 用户 ID 列表 */
private List<Long> adminUserIds;
/** Member 用户 ID 列表 */
private List<Long> memberUserIds;
// ---------- SMS 特有 ----------
/** 短信模板编号(短信走模板,必填) */
private String smsTemplateCode;
/** 短信模板参数 */
private Map<String, Object> smsTemplateParams;
// ---------- Email 特有 ----------
/** 邮件模板编号 */
private String mailTemplateCode;
/** 邮件模板参数 */
private Map<String, Object> mailTemplateParams;
// ---------- InApp 特有 ----------
/** 站内信模板编号 */
private String inAppTemplateCode;
/** 站内信模板参数 */
private Map<String, Object> inAppTemplateParams;
// ---------- Webhook 特有 ----------
/** Webhook 目标 URL必须 HTTPS */
private String webhookUrl;
/** Webhook 自定义请求头 */
private Map<String, String> webhookHeaders;
/** Webhook 请求体JSON 文本,缺省时用 title+body 构造) */
private String webhookBody;
}

View File

@@ -0,0 +1,40 @@
package com.viewsh.module.iot.rule.notify.model;
import lombok.Data;
/**
* 单通道发送结果B16 通知集成)。
*/
@Data
public class NotifyResult {
/** 通道名称sms / email / in_app / webhook */
private final String channel;
/** 是否成功 */
private final boolean success;
/** 错误信息(成功时为 null */
private final String errorMessage;
private NotifyResult(String channel, boolean success, String errorMessage) {
this.channel = channel;
this.success = success;
this.errorMessage = errorMessage;
}
public static NotifyResult success(String channel) {
return new NotifyResult(channel, true, null);
}
public static NotifyResult failure(String channel, String errorMessage) {
return new NotifyResult(channel, false, errorMessage);
}
@Override
public String toString() {
return success
? channel + ":OK"
: channel + ":FAIL(" + errorMessage + ")";
}
}

View File

@@ -0,0 +1,265 @@
package com.viewsh.module.iot.rule.notify;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.viewsh.module.iot.rule.engine.RuleContext;
import com.viewsh.module.iot.rule.notify.channel.NotifyChannel;
import com.viewsh.module.iot.rule.notify.model.NotifyRequest;
import com.viewsh.module.iot.rule.notify.model.NotifyResult;
import com.viewsh.module.iot.rule.template.TemplateResolver;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* B16 NotifyDispatcher 单元测试。
*
* <p>覆盖:
* <ul>
* <li>dispatchAll4 通道全 mock 成功 → 全部 success</li>
* <li>dispatchPartialFailsms 通道抛异常 → sms failure其他 success</li>
* <li>webhook_ssrf传 http:// URL → 抛 IllegalArgumentExceptionSSRF 阻断)</li>
* <li>template_resolutiontitle 含模板变量 ${meta.deviceName} → 正确解析</li>
* <li>template_missingbody 含 ${data.unknown} → 降级为空字符串不抛错</li>
* </ul>
*/
class NotifyDispatcherTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private NotifyChannel smsChannel;
private NotifyChannel emailChannel;
private NotifyChannel inAppChannel;
private NotifyChannel webhookChannel;
private NotifyDispatcher dispatcher;
@BeforeEach
void setUp() {
smsChannel = mockChannel("sms");
emailChannel = mockChannel("email");
inAppChannel = mockChannel("in_app");
webhookChannel = mockChannel("webhook");
TemplateResolver templateResolver = new TemplateResolver(MAPPER);
dispatcher = new NotifyDispatcher(
List.of(smsChannel, emailChannel, inAppChannel, webhookChannel),
templateResolver);
}
// ======================== dispatchAll ========================
@Test
void dispatchAll_fourChannelsEnabled_allSuccess() throws Exception {
when(smsChannel.send(any())).thenReturn(NotifyResult.success("sms"));
when(emailChannel.send(any())).thenReturn(NotifyResult.success("email"));
when(inAppChannel.send(any())).thenReturn(NotifyResult.success("in_app"));
when(webhookChannel.send(any())).thenReturn(NotifyResult.success("webhook"));
JsonNode config = MAPPER.readTree("""
{
"channels": ["sms","email","in_app","webhook"],
"receivers": {
"adminUserIds": [1001],
"webhookUrl": "https://hook.example.com"
},
"template": {"title": "告警标题", "body": "告警内容"}
}
""");
RuleContext ctx = ctx(1L);
List<NotifyResult> results = dispatcher.dispatch(config, ctx);
assertEquals(4, results.size());
assertTrue(results.stream().allMatch(NotifyResult::isSuccess));
// 验证每个通道都被调用了一次
verify(smsChannel).send(any());
verify(emailChannel).send(any());
verify(inAppChannel).send(any());
verify(webhookChannel).send(any());
}
// ======================== dispatchPartialFail ========================
@Test
void dispatchPartialFail_smsThrows_otherChannelsStillSuccess() throws Exception {
when(smsChannel.send(any())).thenThrow(new RuntimeException("sms network error"));
when(emailChannel.send(any())).thenReturn(NotifyResult.success("email"));
when(inAppChannel.send(any())).thenReturn(NotifyResult.success("in_app"));
when(webhookChannel.send(any())).thenReturn(NotifyResult.success("webhook"));
JsonNode config = MAPPER.readTree("""
{
"channels": ["sms","email","in_app","webhook"],
"receivers": {"adminUserIds": [1001], "webhookUrl": "https://hook.example.com"},
"template": {"title": "T", "body": "B"}
}
""");
RuleContext ctx = ctx(2L);
List<NotifyResult> results = dispatcher.dispatch(config, ctx);
assertEquals(4, results.size());
NotifyResult smsResult = results.stream().filter(r -> "sms".equals(r.getChannel())).findFirst().orElseThrow();
assertFalse(smsResult.isSuccess());
assertTrue(smsResult.getErrorMessage().contains("sms network error"));
results.stream()
.filter(r -> !"sms".equals(r.getChannel()))
.forEach(r -> assertTrue(r.isSuccess(), "Expected success for channel: " + r.getChannel()));
}
// ======================== template_resolution ========================
@Test
void templateResolution_metaDeviceName_resolvedInRequest() throws Exception {
// Capture the request passed to emailChannel
when(emailChannel.send(any())).thenAnswer(invocation -> {
NotifyRequest req = invocation.getArgument(0);
// title 应包含解析后的值而非原始 ${meta.deviceName}
assertTrue(req.getTitle().contains("传感器A"),
"Expected 传感器A in title, got: " + req.getTitle());
assertFalse(req.getTitle().contains("${"),
"Template variable should be resolved, got: " + req.getTitle());
return NotifyResult.success("email");
});
JsonNode config = MAPPER.readTree("""
{
"channels": ["email"],
"receivers": {"adminUserIds": [1001]},
"template": {"title": "设备 ${meta.deviceName} 告警", "body": "B"}
}
""");
RuleContext ctx = ctx(3L);
ctx.getMetadata().put("deviceName", "传感器A");
List<NotifyResult> results = dispatcher.dispatch(config, ctx);
assertEquals(1, results.size());
assertTrue(results.get(0).isSuccess());
}
// ======================== template_missing ========================
@Test
void templateMissing_unknownVariable_degradesToEmptyString() throws Exception {
when(smsChannel.send(any())).thenAnswer(invocation -> {
NotifyRequest req = invocation.getArgument(0);
// body 中 ${data.unknown} 应被解析为空字符串(降级),不抛异常
assertNotNull(req.getBody());
assertFalse(req.getBody().contains("${"),
"Unknown variable should be replaced with empty string, got: " + req.getBody());
return NotifyResult.success("sms");
});
JsonNode config = MAPPER.readTree("""
{
"channels": ["sms"],
"receivers": {"adminUserIds": [1001]},
"template": {"title": "告警", "body": "值: ${data.unknown}"}
}
""");
RuleContext ctx = ctx(4L);
// 不设置任何 message/dataunknown 字段不存在
List<NotifyResult> results = dispatcher.dispatch(config, ctx);
assertEquals(1, results.size());
assertTrue(results.get(0).isSuccess());
}
// ======================== webhook_ssrf ========================
@Test
void webhookSsrf_httpUrl_throwsIllegalArgument() throws Exception {
// WebhookNotifyChannel 遇到 http:// 会抛 IllegalArgumentException
// dispatcher.sendSafely() 会捕获并转为 NotifyResult.failure
when(webhookChannel.send(any())).thenThrow(
new IllegalArgumentException("webhook url must be HTTPS, got: http://internal/admin"));
JsonNode config = MAPPER.readTree("""
{
"channels": ["webhook"],
"receivers": {"webhookUrl": "http://internal/admin"},
"template": {"title": "T", "body": "B"}
}
""");
RuleContext ctx = ctx(5L);
List<NotifyResult> results = dispatcher.dispatch(config, ctx);
assertEquals(1, results.size());
assertFalse(results.get(0).isSuccess());
assertTrue(results.get(0).getErrorMessage().contains("HTTPS")
|| results.get(0).getErrorMessage() != null);
}
// ======================== emptyChannels ========================
@Test
void emptyChannels_returnsEmptyList() throws Exception {
JsonNode config = MAPPER.readTree("""
{
"channels": [],
"template": {"title": "T", "body": "B"}
}
""");
RuleContext ctx = ctx(6L);
List<NotifyResult> results = dispatcher.dispatch(config, ctx);
assertTrue(results.isEmpty());
// channels.send() must not be called (getName() is called in dispatcher constructor but that's ok)
verify(smsChannel, never()).send(any());
verify(emailChannel, never()).send(any());
verify(inAppChannel, never()).send(any());
verify(webhookChannel, never()).send(any());
}
// ======================== unknownChannel ========================
@Test
void unknownChannel_returnsFailureResult() throws Exception {
JsonNode config = MAPPER.readTree("""
{
"channels": ["telegram"],
"template": {"title": "T", "body": "B"}
}
""");
RuleContext ctx = ctx(7L);
List<NotifyResult> results = dispatcher.dispatch(config, ctx);
assertEquals(1, results.size());
assertFalse(results.get(0).isSuccess());
assertTrue(results.get(0).getErrorMessage().contains("telegram"));
}
// ======================== Helper ========================
private static NotifyChannel mockChannel(String name) {
NotifyChannel ch = mock(NotifyChannel.class);
when(ch.getName()).thenReturn(name);
return ch;
}
private static RuleContext ctx(Long chainId) {
RuleContext ctx = new RuleContext();
ctx.setChainId(chainId);
ctx.setDeviceId(10L);
ctx.setProductId(100L);
ctx.setTenantId(1L);
ctx.setSubsystemId(1L);
ctx.setStartedAt(Instant.now());
return ctx;
}
}

View File

@@ -0,0 +1,56 @@
package com.viewsh.module.iot.rule.notify.channel;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* B16 WebhookNotifyChannel SSRF 校验单元测试。
*/
class WebhookNotifyChannelTest {
private WebhookNotifyChannel channel;
@BeforeEach
void setUp() {
channel = new WebhookNotifyChannel(new ObjectMapper());
}
@Test
void validateUrl_httpsOk_noException() {
assertDoesNotThrow(() -> channel.validateWebhookUrl("https://hook.example.com/alert"));
}
@Test
void validateUrl_http_throwsIllegalArgument() {
IllegalArgumentException ex = assertThrows(
IllegalArgumentException.class,
() -> channel.validateWebhookUrl("http://hook.example.com/alert"));
assertTrue(ex.getMessage().contains("HTTPS"), "Expected HTTPS in message: " + ex.getMessage());
}
@Test
void validateUrl_localhost_throwsIllegalArgument() {
assertThrows(IllegalArgumentException.class,
() -> channel.validateWebhookUrl("http://localhost/admin"));
}
@Test
void validateUrl_blank_throwsIllegalArgument() {
assertThrows(IllegalArgumentException.class,
() -> channel.validateWebhookUrl(""));
}
@Test
void validateUrl_null_throwsIllegalArgument() {
assertThrows(IllegalArgumentException.class,
() -> channel.validateWebhookUrl(null));
}
@Test
void getName_returnsWebhook() {
assertEquals("webhook", channel.getName());
}
}