Просмотр исходного кода

feat(wm-notify): #26 消息通知模块(短信+Push+WebSocket+邮件+通知管理)

将原孤儿提交 bb3b49f9 的完整实现移植到基于 master 的干净分支,并修复若干问题。

实现内容(wm-notify 模块):

- channel 层: Notifier 接口 + Sms/Push/Email/WebSocket 4 渠道实现

- WebSocket: WebSocketHandler 会话管理 + Redis pub/sub 多实例推送 + 心跳

- 模板/日志: NotifyTemplate/NotifyLog 实体 + Mapper + Service(MyBatis-Plus)

- 通知分发: NotifyService 模板渲染 + 多渠道分发 + 失败重试(@Async)

- 管理 API: NotifyController + NotifyTemplateController + NotifyLogController

- DDL: notify_template/notify_log 表 + 默认模板数据

- 单元测试: NotifyServiceImplTest + NotifierValidateTest

适配 master 的修正:

- pom 改用 SB3 坐标(mybatis-plus-spring-boot3-starter/sa-token-spring-boot3)

- 去除重复的根 pom.xml 与 wm-common 基础类(复用 master 已有)

- javax.mail -> jakarta.mail(EmailNotifier 改用 JavaMailSender)

- fastjson2 -> Jackson(项目统一序列化方案)

- R.success/failed -> R.ok/fail(匹配 wm-common 的 R 类)

- @Async private sendAsync -> public dispatchAsync + @Lazy 自注入(修复异步失效)

- System.out.println -> slf4j logger

- 删除 master 上路径错误的 notify/NotificationTest.java(2处)

- Controller 路径统一为 /api/notify/*

API 符合设计文档 1.6。分支重建为干净单提交,覆盖原孤儿分支。
bot_dev3 2 дней назад
Родитель
Сommit
67e784b458
28 измененных файлов: 1418 добавлений и 208 удалений
  1. 9
    2
      wm-notify/pom.xml
  2. 12
    0
      wm-notify/src/main/java/com/water/notify/NotifyApplication.java
  3. 53
    0
      wm-notify/src/main/java/com/water/notify/channel/EmailNotifier.java
  4. 25
    0
      wm-notify/src/main/java/com/water/notify/channel/Notifier.java
  5. 49
    0
      wm-notify/src/main/java/com/water/notify/channel/PushNotifier.java
  6. 42
    0
      wm-notify/src/main/java/com/water/notify/channel/SmsNotifier.java
  7. 59
    0
      wm-notify/src/main/java/com/water/notify/channel/WebSocketNotifier.java
  8. 41
    0
      wm-notify/src/main/java/com/water/notify/config/WebSocketConfig.java
  9. 125
    0
      wm-notify/src/main/java/com/water/notify/config/WebSocketHandler.java
  10. 67
    14
      wm-notify/src/main/java/com/water/notify/controller/NotifyController.java
  11. 79
    0
      wm-notify/src/main/java/com/water/notify/controller/NotifyLogController.java
  12. 75
    0
      wm-notify/src/main/java/com/water/notify/controller/NotifyTemplateController.java
  13. 69
    0
      wm-notify/src/main/java/com/water/notify/entity/NotifyLog.java
  14. 69
    0
      wm-notify/src/main/java/com/water/notify/entity/NotifyTemplate.java
  15. 12
    0
      wm-notify/src/main/java/com/water/notify/mapper/NotifyLogMapper.java
  16. 12
    0
      wm-notify/src/main/java/com/water/notify/mapper/NotifyTemplateMapper.java
  17. 28
    0
      wm-notify/src/main/java/com/water/notify/service/NotifyLogService.java
  18. 52
    30
      wm-notify/src/main/java/com/water/notify/service/NotifyService.java
  19. 21
    0
      wm-notify/src/main/java/com/water/notify/service/NotifyTemplateService.java
  20. 64
    0
      wm-notify/src/main/java/com/water/notify/service/impl/NotifyLogServiceImpl.java
  21. 184
    0
      wm-notify/src/main/java/com/water/notify/service/impl/NotifyServiceImpl.java
  22. 41
    0
      wm-notify/src/main/java/com/water/notify/service/impl/NotifyTemplateServiceImpl.java
  23. 0
    36
      wm-notify/src/main/java/notify/NotificationTest.java
  24. 0
    126
      wm-notify/src/main/java/notify/src/test/java/com/water/notify/NotificationTest.java
  25. 25
    0
      wm-notify/src/main/resources/application.yml
  26. 57
    0
      wm-notify/src/main/resources/db/init.sql
  27. 43
    0
      wm-notify/src/test/java/com/water/notify/channel/NotifierValidateTest.java
  28. 105
    0
      wm-notify/src/test/java/com/water/notify/service/NotifyServiceImplTest.java

+ 9
- 2
wm-notify/pom.xml Просмотреть файл

@@ -8,10 +8,17 @@
8 8
     <dependencies>
9 9
         <dependency><groupId>com.water</groupId><artifactId>wm-common</artifactId></dependency>
10 10
         <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
11
+        <!-- WebSocket 实时推送 -->
12
+        <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-websocket</artifactId></dependency>
11 13
         <dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency>
12 14
         <dependency><groupId>org.springframework.kafka</groupId><artifactId>spring-kafka</artifactId></dependency>
13 15
         <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency>
16
+        <!-- MyBatis-Plus (Spring Boot 3 坐标) -->
17
+        <dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId></dependency>
18
+        <!-- Sa-Token (Spring Boot 3 坐标) -->
19
+        <dependency><groupId>cn.dev33</groupId><artifactId>sa-token-spring-boot3-starter</artifactId></dependency>
14 20
         <dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId></dependency>
15
-        <dependency><groupId>net.postgis</groupId><artifactId>postgis-jdbc</artifactId></dependency>
21
+        <!-- 邮件推送 (Jakarta Mail) -->
22
+        <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-mail</artifactId></dependency>
16 23
     </dependencies>
17
-</project>
24
+</project>

+ 12
- 0
wm-notify/src/main/java/com/water/notify/NotifyApplication.java Просмотреть файл

@@ -1,10 +1,22 @@
1 1
 package com.water.notify;
2 2
 
3
+import org.mybatis.spring.annotation.MapperScan;
3 4
 import org.springframework.boot.SpringApplication;
4 5
 import org.springframework.boot.autoconfigure.SpringBootApplication;
6
+import org.springframework.scheduling.annotation.EnableAsync;
7
+import org.springframework.scheduling.annotation.EnableScheduling;
5 8
 
9
+/**
10
+ * 消息通知服务启动类
11
+ *
12
+ * 功能:WebSocket实时推送 / APP推送 / 短信 / 邮件 / 通知模板与日志管理
13
+ */
6 14
 @SpringBootApplication
15
+@EnableAsync
16
+@EnableScheduling
17
+@MapperScan("com.water.notify.mapper")
7 18
 public class NotifyApplication {
19
+
8 20
     public static void main(String[] args) {
9 21
         SpringApplication.run(NotifyApplication.class, args);
10 22
     }

+ 53
- 0
wm-notify/src/main/java/com/water/notify/channel/EmailNotifier.java Просмотреть файл

@@ -0,0 +1,53 @@
1
+package com.water.notify.channel;
2
+
3
+import com.water.notify.entity.NotifyLog;
4
+import lombok.RequiredArgsConstructor;
5
+import lombok.extern.slf4j.Slf4j;
6
+import org.springframework.beans.factory.annotation.Value;
7
+import org.springframework.mail.SimpleMailMessage;
8
+import org.springframework.mail.javamail.JavaMailSender;
9
+import org.springframework.stereotype.Component;
10
+
11
+/**
12
+ * 邮件通知器
13
+ *
14
+ * 使用 Spring Boot 的 JavaMailSender(Jakarta Mail)发送邮件。
15
+ * SMTP 配置见 application.yml 的 spring.mail.*。
16
+ */
17
+@Slf4j
18
+@Component
19
+@RequiredArgsConstructor
20
+public class EmailNotifier implements Notifier {
21
+
22
+    private final JavaMailSender mailSender;
23
+
24
+    @Value("${spring.mail.username:noreply@water.com}")
25
+    private String from;
26
+
27
+    @Override
28
+    public String getChannel() {
29
+        return "email";
30
+    }
31
+
32
+    @Override
33
+    public boolean send(NotifyLog log) {
34
+        try {
35
+            SimpleMailMessage message = new SimpleMailMessage();
36
+            message.setFrom(from);
37
+            message.setTo(log.getReceiverIdentifier());
38
+            message.setSubject(log.getTitle());
39
+            message.setText(log.getContent());
40
+            mailSender.send(message);
41
+            return true;
42
+        } catch (Exception e) {
43
+            log.error("邮件发送失败: {}", e.getMessage(), e);
44
+            return false;
45
+        }
46
+    }
47
+
48
+    @Override
49
+    public boolean validateReceiver(NotifyLog log) {
50
+        String email = log.getReceiverIdentifier();
51
+        return email != null && email.matches("^[A-Za-z0-9+_.-]+@(.+)$");
52
+    }
53
+}

+ 25
- 0
wm-notify/src/main/java/com/water/notify/channel/Notifier.java Просмотреть файл

@@ -0,0 +1,25 @@
1
+package com.water.notify.channel;
2
+
3
+import com.water.notify.entity.NotifyLog;
4
+
5
+/**
6
+ * 通知发送接口
7
+ *
8
+ * 多渠道通知发送的统一抽象,每种渠道(ws/sms/push/email)各有一个实现。
9
+ */
10
+public interface Notifier {
11
+
12
+    /** 获取该实现支持的渠道代码:ws/sms/push/email */
13
+    String getChannel();
14
+
15
+    /**
16
+     * 发送通知
17
+     *
18
+     * @param log 通知日志(含标题、内容、接收者标识)
19
+     * @return 是否发送成功
20
+     */
21
+    boolean send(NotifyLog log);
22
+
23
+    /** 检查接收者是否有效 */
24
+    boolean validateReceiver(NotifyLog log);
25
+}

+ 49
- 0
wm-notify/src/main/java/com/water/notify/channel/PushNotifier.java Просмотреть файл

@@ -0,0 +1,49 @@
1
+package com.water.notify.channel;
2
+
3
+import com.water.notify.entity.NotifyLog;
4
+import lombok.extern.slf4j.Slf4j;
5
+import org.springframework.stereotype.Component;
6
+
7
+/**
8
+ * APP推送通知器
9
+ *
10
+ * 集成极光推送(JPush),当前为模拟实现。
11
+ * https://docs.jiguang.cn/jpush/server/rest_api_v3_push/
12
+ */
13
+@Slf4j
14
+@Component
15
+public class PushNotifier implements Notifier {
16
+
17
+    @Override
18
+    public String getChannel() {
19
+        return "push";
20
+    }
21
+
22
+    @Override
23
+    public boolean send(NotifyLog log) {
24
+        try {
25
+            // 构建推送消息示例:
26
+            // {
27
+            //   "platform": "all",
28
+            //   "audience": { "registration_id": [registrationId] },
29
+            //   "notification": {
30
+            //     "android": { "title": title, "alert": content },
31
+            //     "ios":      { "alert": content }
32
+            //   }
33
+            // }
34
+            String registrationId = log.getReceiverIdentifier();
35
+            log.info("发送APP推送到设备 {}: {} - {}", registrationId, log.getTitle(), log.getContent());
36
+            // TODO: 接入真实极光推送SDK
37
+            return true;
38
+        } catch (Exception e) {
39
+            log.error("APP推送失败: {}", e.getMessage(), e);
40
+            return false;
41
+        }
42
+    }
43
+
44
+    @Override
45
+    public boolean validateReceiver(NotifyLog log) {
46
+        String token = log.getReceiverIdentifier();
47
+        return token != null && !token.isEmpty();
48
+    }
49
+}

+ 42
- 0
wm-notify/src/main/java/com/water/notify/channel/SmsNotifier.java Просмотреть файл

@@ -0,0 +1,42 @@
1
+package com.water.notify.channel;
2
+
3
+import com.water.notify.entity.NotifyLog;
4
+import lombok.extern.slf4j.Slf4j;
5
+import org.springframework.stereotype.Component;
6
+
7
+/**
8
+ * 短信通知器
9
+ *
10
+ * 集成阿里云SMS或腾讯云SMS(当前为模拟实现,接入真实SDK时替换 send 方法体即可)。
11
+ * 阿里云SMS: https://help.aliyun.com/product/44275.html
12
+ * 腾讯云SMS: https://cloud.tencent.com/document/product/382/38778
13
+ */
14
+@Slf4j
15
+@Component
16
+public class SmsNotifier implements Notifier {
17
+
18
+    @Override
19
+    public String getChannel() {
20
+        return "sms";
21
+    }
22
+
23
+    @Override
24
+    public boolean send(NotifyLog log) {
25
+        try {
26
+            String phone = log.getReceiverIdentifier();
27
+            String content = log.getContent();
28
+            // TODO: 接入真实短信SDK(阿里云/腾讯云)
29
+            log.info("发送短信到 {}: {}", phone, content);
30
+            return true;
31
+        } catch (Exception e) {
32
+            log.error("短信发送失败: {}", e.getMessage(), e);
33
+            return false;
34
+        }
35
+    }
36
+
37
+    @Override
38
+    public boolean validateReceiver(NotifyLog log) {
39
+        String phone = log.getReceiverIdentifier();
40
+        return phone != null && phone.matches("^1[3-9]\\d{9}$");
41
+    }
42
+}

+ 59
- 0
wm-notify/src/main/java/com/water/notify/channel/WebSocketNotifier.java Просмотреть файл

@@ -0,0 +1,59 @@
1
+package com.water.notify.channel;
2
+
3
+import com.fasterxml.jackson.core.JsonProcessingException;
4
+import com.fasterxml.jackson.databind.ObjectMapper;
5
+import com.water.notify.entity.NotifyLog;
6
+import lombok.RequiredArgsConstructor;
7
+import lombok.extern.slf4j.Slf4j;
8
+import org.springframework.data.redis.core.RedisTemplate;
9
+import org.springframework.stereotype.Component;
10
+
11
+/**
12
+ * WebSocket 实时推送通知器
13
+ *
14
+ * 通过 Redis pub/sub 向各实例的 WebSocket 连接广播,实现多实例下的实时推送。
15
+ */
16
+@Slf4j
17
+@Component
18
+@RequiredArgsConstructor
19
+public class WebSocketNotifier implements Notifier {
20
+
21
+    private final RedisTemplate<String, String> redisTemplate;
22
+    private final ObjectMapper objectMapper;
23
+
24
+    @Override
25
+    public String getChannel() {
26
+        return "ws";
27
+    }
28
+
29
+    @Override
30
+    public boolean send(NotifyLog log) {
31
+        try {
32
+            String message = toJson(log);
33
+            // 发布到 WebSocket 渠道,由各实例的 WebSocketHandler 订阅后推送给在线用户
34
+            String channel = "websocket:" + log.getReceiverId();
35
+            redisTemplate.convertAndSend(channel, message);
36
+            return true;
37
+        } catch (Exception e) {
38
+            log.error("WebSocket推送失败: {}", e.getMessage(), e);
39
+            return false;
40
+        }
41
+    }
42
+
43
+    @Override
44
+    public boolean validateReceiver(NotifyLog log) {
45
+        // 检查用户是否在线(Redis 中维护在线标记)
46
+        String onlineKey = "user:online:" + log.getReceiverId();
47
+        Boolean hasKey = redisTemplate.hasKey(onlineKey);
48
+        return Boolean.TRUE.equals(hasKey);
49
+    }
50
+
51
+    private String toJson(NotifyLog log) {
52
+        try {
53
+            return objectMapper.writeValueAsString(log);
54
+        } catch (JsonProcessingException e) {
55
+            log.warn("序列化通知日志失败 logId={}: {}", log.getId(), e.getMessage());
56
+            return "{}";
57
+        }
58
+    }
59
+}

+ 41
- 0
wm-notify/src/main/java/com/water/notify/config/WebSocketConfig.java Просмотреть файл

@@ -0,0 +1,41 @@
1
+package com.water.notify.config;
2
+
3
+import lombok.RequiredArgsConstructor;
4
+import org.springframework.context.annotation.Bean;
5
+import org.springframework.context.annotation.Configuration;
6
+import org.springframework.data.redis.connection.RedisConnectionFactory;
7
+import org.springframework.data.redis.listener.PatternTopic;
8
+import org.springframework.data.redis.listener.RedisMessageListenerContainer;
9
+import org.springframework.web.socket.config.annotation.EnableWebSocket;
10
+import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
11
+import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
12
+
13
+/**
14
+ * WebSocket 配置
15
+ *
16
+ * 注册 /ws/notify 端点,并订阅 Redis "websocket:*" 渠道以支持多实例实时推送。
17
+ */
18
+@Configuration
19
+@EnableWebSocket
20
+@RequiredArgsConstructor
21
+public class WebSocketConfig implements WebSocketConfigurer {
22
+
23
+    private final WebSocketHandler webSocketHandler;
24
+
25
+    @Override
26
+    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
27
+        registry.addHandler(webSocketHandler, "/ws/notify")
28
+                .setAllowedOrigins("*");
29
+    }
30
+
31
+    /**
32
+     * 订阅 Redis websocket 渠道,消息交给 WebSocketHandler 转发给本地在线用户。
33
+     */
34
+    @Bean
35
+    public RedisMessageListenerContainer redisWebSocketListenerContainer(RedisConnectionFactory factory) {
36
+        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
37
+        container.setConnectionFactory(factory);
38
+        container.addMessageListener(webSocketHandler, new PatternTopic("websocket:*"));
39
+        return container;
40
+    }
41
+}

+ 125
- 0
wm-notify/src/main/java/com/water/notify/config/WebSocketHandler.java Просмотреть файл

@@ -0,0 +1,125 @@
1
+package com.water.notify.config;
2
+
3
+import com.fasterxml.jackson.databind.JsonNode;
4
+import com.fasterxml.jackson.databind.ObjectMapper;
5
+import lombok.RequiredArgsConstructor;
6
+import lombok.extern.slf4j.Slf4j;
7
+import org.springframework.data.redis.connection.Message;
8
+import org.springframework.data.redis.connection.MessageListener;
9
+import org.springframework.stereotype.Component;
10
+import org.springframework.web.socket.CloseStatus;
11
+import org.springframework.web.socket.TextMessage;
12
+import org.springframework.web.socket.WebSocketSession;
13
+import org.springframework.web.socket.handler.TextWebSocketHandler;
14
+
15
+import java.net.URI;
16
+import java.util.concurrent.ConcurrentHashMap;
17
+
18
+/**
19
+ * WebSocket 处理器
20
+ *
21
+ * 维护用户在线会话,并订阅 Redis 渠道消息转发给本地在线用户,
22
+ * 实现多实例部署下的实时推送。
23
+ */
24
+@Slf4j
25
+@Component
26
+@RequiredArgsConstructor
27
+public class WebSocketHandler extends TextWebSocketHandler implements MessageListener {
28
+
29
+    private final ObjectMapper objectMapper;
30
+
31
+    /** 在线用户会话:userId -> session */
32
+    private static final ConcurrentHashMap<Long, WebSocketSession> USER_SESSIONS = new ConcurrentHashMap<>();
33
+
34
+    @Override
35
+    public void afterConnectionEstablished(WebSocketSession session) {
36
+        Long userId = extractUserId(session);
37
+        if (userId == null) {
38
+            close(session, CloseStatus.POLICY_VIOLATION);
39
+            return;
40
+        }
41
+        USER_SESSIONS.put(userId, session);
42
+        log.info("WebSocket 用户上线: userId={}, 在线总数={}", userId, USER_SESSIONS.size());
43
+    }
44
+
45
+    @Override
46
+    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
47
+        Long userId = extractUserId(session);
48
+        if (userId != null) {
49
+            USER_SESSIONS.remove(userId);
50
+        }
51
+        log.info("WebSocket 用户下线: userId={}, 在线总数={}", userId, USER_SESSIONS.size());
52
+    }
53
+
54
+    /**
55
+     * Redis 渠道消息回调:将消息推送给本地在线用户。
56
+     * 消息体为 NotifyLog 的 JSON,含 receiverIdentifier(userId)。
57
+     */
58
+    @Override
59
+    public void onMessage(Message message, byte[] pattern) {
60
+        try {
61
+            String body = new String(message.getBody());
62
+            JsonNode json = objectMapper.readTree(body);
63
+            long receiverId = json.path("receiverId").asLong();
64
+            sendMessageToUser(receiverId, body);
65
+        } catch (Exception e) {
66
+            log.warn("处理 Redis WebSocket 消息失败: {}", e.getMessage());
67
+        }
68
+    }
69
+
70
+    /** 发送消息给指定用户 */
71
+    public void sendMessageToUser(Long userId, String message) {
72
+        WebSocketSession session = USER_SESSIONS.get(userId);
73
+        if (session != null && session.isOpen()) {
74
+            try {
75
+                session.sendMessage(new TextMessage(message));
76
+            } catch (Exception e) {
77
+                log.error("WebSocket 推送失败 userId={}: {}", userId, e.getMessage(), e);
78
+            }
79
+        }
80
+    }
81
+
82
+    /** 广播消息给所有在线用户 */
83
+    public void broadcastMessage(String message) {
84
+        USER_SESSIONS.forEach((userId, session) -> {
85
+            if (session.isOpen()) {
86
+                try {
87
+                    session.sendMessage(new TextMessage(message));
88
+                } catch (Exception e) {
89
+                    log.error("WebSocket 广播失败 userId={}: {}", userId, e.getMessage(), e);
90
+                }
91
+            }
92
+        });
93
+    }
94
+
95
+    /** 在线人数 */
96
+    public int onlineCount() {
97
+        return USER_SESSIONS.size();
98
+    }
99
+
100
+    /** 从连接 URL 提取 userId(/ws/notify?userId=123) */
101
+    private Long extractUserId(WebSocketSession session) {
102
+        URI uri = session.getUri();
103
+        if (uri == null || uri.getQuery() == null) {
104
+            return null;
105
+        }
106
+        for (String kv : uri.getQuery().split("&")) {
107
+            String[] pair = kv.split("=", 2);
108
+            if ("userId".equals(pair[0]) && pair.length == 2) {
109
+                try {
110
+                    return Long.parseLong(pair[1]);
111
+                } catch (NumberFormatException e) {
112
+                    return null;
113
+                }
114
+            }
115
+        }
116
+        return null;
117
+    }
118
+
119
+    private void close(WebSocketSession session, CloseStatus status) {
120
+        try {
121
+            session.close(status);
122
+        } catch (Exception ignored) {
123
+        }
124
+    }
125
+}

+ 67
- 14
wm-notify/src/main/java/com/water/notify/controller/NotifyController.java Просмотреть файл

@@ -1,34 +1,87 @@
1 1
 package com.water.notify.controller;
2 2
 
3 3
 import com.water.common.core.result.R;
4
+import com.water.notify.entity.NotifyLog;
5
+import com.water.notify.entity.NotifyTemplate;
6
+import com.water.notify.service.NotifyLogService;
4 7
 import com.water.notify.service.NotifyService;
8
+import com.water.notify.service.NotifyTemplateService;
9
+import io.swagger.v3.oas.annotations.Operation;
5 10
 import io.swagger.v3.oas.annotations.tags.Tag;
11
+import lombok.Data;
6 12
 import lombok.RequiredArgsConstructor;
7 13
 import org.springframework.web.bind.annotation.*;
8 14
 
15
+import java.util.List;
9 16
 import java.util.Map;
10 17
 
11
-@Tag(name = "消息通知")
18
+/**
19
+ * 通知发送控制器
20
+ */
21
+@Tag(name = "消息通知-发送")
12 22
 @RestController
13
-@RequestMapping("/notify")
23
+@RequestMapping("/api/notify")
14 24
 @RequiredArgsConstructor
15 25
 public class NotifyController {
16 26
 
17 27
     private final NotifyService notifyService;
28
+    private final NotifyTemplateService templateService;
29
+    private final NotifyLogService logService;
18 30
 
19
-    @PostMapping("/sms")
20
-    public R<String> sendSms(@RequestBody Map<String, String> req) {
21
-        notifyService.sendSms(req.get("phone"), req.get("content"));
22
-        return R.ok("短信发送成功");
31
+    @Operation(summary = "发送通知(指定渠道)")
32
+    @PostMapping("/send")
33
+    public R<Long> send(@RequestParam String templateCode,
34
+                        @RequestParam Long receiverId,
35
+                        @RequestParam String receiverIdentifier,
36
+                        @RequestParam String channel,
37
+                        @RequestBody(required = false) Map<String, Object> params) {
38
+        return R.ok(notifyService.sendNotify(templateCode, receiverId, receiverIdentifier, channel, params));
23 39
     }
24 40
 
25
-    @PostMapping("/push")
26
-    public R<String> push(@RequestBody Map<String, Object> req) {
27
-        notifyService.dispatch(
28
-            Long.parseLong(String.valueOf(req.get("schemeId"))),
29
-            Long.parseLong(String.valueOf(req.get("userId"))),
30
-            (String) req.get("title"),
31
-            (String) req.get("content"));
32
-        return R.ok("通知已分发");
41
+    @Operation(summary = "发送通知(按模板多渠道自动分发)")
42
+    @PostMapping("/send-auto")
43
+    public R<List<Long>> sendAuto(@RequestParam String templateCode,
44
+                                  @RequestParam Long receiverId,
45
+                                  @RequestParam String receiverIdentifier,
46
+                                  @RequestBody(required = false) Map<String, Object> params) {
47
+        return R.ok(notifyService.sendNotify(templateCode, receiverId, receiverIdentifier, params));
48
+    }
49
+
50
+    @Operation(summary = "批量发送通知")
51
+    @PostMapping("/batch-send")
52
+    public R<List<Long>> batchSend(@RequestParam String templateCode,
53
+                                   @RequestBody BatchSendRequest request) {
54
+        return R.ok(notifyService.batchSendNotify(templateCode,
55
+                request.getReceiverIds(), request.getReceiverIdentifiers(),
56
+                request.getChannel(), request.getParams()));
57
+    }
58
+
59
+    @Operation(summary = "重试失败的通知")
60
+    @PostMapping("/retry")
61
+    public R<Void> retry() {
62
+        notifyService.retryFailedNotifications();
63
+        return R.ok();
64
+    }
65
+
66
+    @Operation(summary = "获取通知日志")
67
+    @GetMapping("/log/{id}")
68
+    public R<NotifyLog> getLog(@PathVariable Long id) {
69
+        NotifyLog log = logService.getById(id);
70
+        return log == null ? R.fail("通知日志不存在") : R.ok(log);
71
+    }
72
+
73
+    @Operation(summary = "检查模板是否存在")
74
+    @GetMapping("/template/check/{templateCode}")
75
+    public R<Boolean> checkTemplate(@PathVariable String templateCode) {
76
+        NotifyTemplate template = templateService.getByTemplateCode(templateCode);
77
+        return R.ok(template != null);
78
+    }
79
+
80
+    @Data
81
+    public static class BatchSendRequest {
82
+        private List<Long> receiverIds;
83
+        private List<String> receiverIdentifiers;
84
+        private String channel;
85
+        private Map<String, Object> params;
33 86
     }
34 87
 }

+ 79
- 0
wm-notify/src/main/java/com/water/notify/controller/NotifyLogController.java Просмотреть файл

@@ -0,0 +1,79 @@
1
+package com.water.notify.controller;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.water.common.core.result.R;
6
+import com.water.notify.entity.NotifyLog;
7
+import com.water.notify.service.NotifyLogService;
8
+import io.swagger.v3.oas.annotations.Operation;
9
+import io.swagger.v3.oas.annotations.tags.Tag;
10
+import lombok.RequiredArgsConstructor;
11
+import org.springframework.web.bind.annotation.*;
12
+
13
+import java.time.LocalDateTime;
14
+
15
+/**
16
+ * 通知日志管理控制器
17
+ */
18
+@Tag(name = "消息通知-日志管理")
19
+@RestController
20
+@RequestMapping("/api/notify/log")
21
+@RequiredArgsConstructor
22
+public class NotifyLogController {
23
+
24
+    private final NotifyLogService logService;
25
+
26
+    @Operation(summary = "分页查询通知日志")
27
+    @GetMapping("/page")
28
+    public R<Page<NotifyLog>> page(@RequestParam(defaultValue = "1") Long current,
29
+                                   @RequestParam(defaultValue = "10") Long size,
30
+                                   @RequestParam(required = false) String status,
31
+                                   @RequestParam(required = false) String channel,
32
+                                   @RequestParam(required = false) Long receiverId) {
33
+        Page<NotifyLog> page = new Page<>(current, size);
34
+        LambdaQueryWrapper<NotifyLog> wrapper = new LambdaQueryWrapper<>();
35
+        if (status != null) {
36
+            wrapper.eq(NotifyLog::getStatus, status);
37
+        }
38
+        if (channel != null) {
39
+            wrapper.eq(NotifyLog::getChannel, channel);
40
+        }
41
+        if (receiverId != null) {
42
+            wrapper.eq(NotifyLog::getReceiverId, receiverId);
43
+        }
44
+        wrapper.orderByDesc(NotifyLog::getId);
45
+        logService.page(page, wrapper);
46
+        return R.ok(page);
47
+    }
48
+
49
+    @Operation(summary = "根据ID获取通知日志")
50
+    @GetMapping("/{id}")
51
+    public R<NotifyLog> getById(@PathVariable Long id) {
52
+        NotifyLog log = logService.getById(id);
53
+        return log == null ? R.fail("通知日志不存在") : R.ok(log);
54
+    }
55
+
56
+    @Operation(summary = "获取待发送的通知日志")
57
+    @GetMapping("/pending")
58
+    public R<Page<NotifyLog>> getPending(@RequestParam(defaultValue = "10") Long limit) {
59
+        Page<NotifyLog> page = new Page<>(1, limit);
60
+        page.setRecords(logService.getPendingLogs(limit.intValue()));
61
+        return R.ok(page);
62
+    }
63
+
64
+    @Operation(summary = "获取失败的通知日志(用于重试)")
65
+    @GetMapping("/failed")
66
+    public R<Page<NotifyLog>> getFailed(@RequestParam(defaultValue = "5") Long minutes,
67
+                                        @RequestParam(defaultValue = "3") Long maxRetryCount) {
68
+        Page<NotifyLog> page = new Page<>(1, 100);
69
+        page.setRecords(logService.getFailedLogs(
70
+                LocalDateTime.now().minusMinutes(minutes), maxRetryCount.intValue()));
71
+        return R.ok(page);
72
+    }
73
+
74
+    @Operation(summary = "删除通知日志")
75
+    @DeleteMapping("/{id}")
76
+    public R<Boolean> delete(@PathVariable Long id) {
77
+        return R.ok(logService.removeById(id));
78
+    }
79
+}

+ 75
- 0
wm-notify/src/main/java/com/water/notify/controller/NotifyTemplateController.java Просмотреть файл

@@ -0,0 +1,75 @@
1
+package com.water.notify.controller;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.water.common.core.result.R;
6
+import com.water.notify.entity.NotifyTemplate;
7
+import com.water.notify.service.NotifyTemplateService;
8
+import io.swagger.v3.oas.annotations.Operation;
9
+import io.swagger.v3.oas.annotations.tags.Tag;
10
+import lombok.RequiredArgsConstructor;
11
+import org.springframework.web.bind.annotation.*;
12
+
13
+import java.util.List;
14
+
15
+/**
16
+ * 通知模板管理控制器
17
+ */
18
+@Tag(name = "消息通知-模板管理")
19
+@RestController
20
+@RequestMapping("/api/notify/template")
21
+@RequiredArgsConstructor
22
+public class NotifyTemplateController {
23
+
24
+    private final NotifyTemplateService templateService;
25
+
26
+    @Operation(summary = "分页查询模板")
27
+    @GetMapping("/page")
28
+    public R<Page<NotifyTemplate>> page(@RequestParam(defaultValue = "1") Long current,
29
+                                        @RequestParam(defaultValue = "10") Long size) {
30
+        Page<NotifyTemplate> page = new Page<>(current, size);
31
+        LambdaQueryWrapper<NotifyTemplate> wrapper = new LambdaQueryWrapper<>();
32
+        wrapper.orderByDesc(NotifyTemplate::getId);
33
+        templateService.page(page, wrapper);
34
+        return R.ok(page);
35
+    }
36
+
37
+    @Operation(summary = "获取模板详情")
38
+    @GetMapping("/{id}")
39
+    public R<NotifyTemplate> getById(@PathVariable Long id) {
40
+        NotifyTemplate template = templateService.getById(id);
41
+        return template == null ? R.fail("模板不存在") : R.ok(template);
42
+    }
43
+
44
+    @Operation(summary = "创建模板")
45
+    @PostMapping
46
+    public R<Boolean> create(@RequestBody NotifyTemplate template) {
47
+        template.setStatus(1);
48
+        return R.ok(templateService.save(template));
49
+    }
50
+
51
+    @Operation(summary = "更新模板")
52
+    @PutMapping("/{id}")
53
+    public R<Boolean> update(@PathVariable Long id, @RequestBody NotifyTemplate template) {
54
+        template.setId(id);
55
+        return R.ok(templateService.updateById(template));
56
+    }
57
+
58
+    @Operation(summary = "删除模板")
59
+    @DeleteMapping("/{id}")
60
+    public R<Boolean> delete(@PathVariable Long id) {
61
+        return R.ok(templateService.removeById(id));
62
+    }
63
+
64
+    @Operation(summary = "获取启用模板列表")
65
+    @GetMapping("/enabled")
66
+    public R<List<NotifyTemplate>> getEnabledTemplates() {
67
+        return R.ok(templateService.getEnabledTemplates());
68
+    }
69
+
70
+    @Operation(summary = "按渠道获取启用模板")
71
+    @GetMapping("/enabled/{channel}")
72
+    public R<List<NotifyTemplate>> getEnabledTemplatesByChannel(@PathVariable String channel) {
73
+        return R.ok(templateService.getEnabledTemplatesByChannel(channel));
74
+    }
75
+}

+ 69
- 0
wm-notify/src/main/java/com/water/notify/entity/NotifyLog.java Просмотреть файл

@@ -0,0 +1,69 @@
1
+package com.water.notify.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.IdType;
4
+import com.baomidou.mybatisplus.annotation.TableId;
5
+import com.baomidou.mybatisplus.annotation.TableName;
6
+import lombok.Data;
7
+import lombok.experimental.Accessors;
8
+
9
+import java.time.LocalDateTime;
10
+
11
+/**
12
+ * 通知日志表
13
+ *
14
+ * 记录每次通知发送的状态和结果
15
+ * 对应设计规格:§1.6 消息通知模块
16
+ */
17
+@Data
18
+@Accessors(chain = true)
19
+@TableName("notify_log")
20
+public class NotifyLog {
21
+
22
+    @TableId(type = IdType.AUTO)
23
+    private Long id;
24
+
25
+    /** 关联的模板ID */
26
+    private Long templateId;
27
+
28
+    /** 模板代码 */
29
+    private String templateCode;
30
+
31
+    /** 接收者ID(用户ID、设备ID等) */
32
+    private Long receiverId;
33
+
34
+    /** 接收者标识(手机号、邮箱、设备token等) */
35
+    private String receiverIdentifier;
36
+
37
+    /** 发送的渠道:ws/sms/push/email */
38
+    private String channel;
39
+
40
+    /** 发送状态:pending/success/failed/timeout */
41
+    private String status;
42
+
43
+    /** 标题 */
44
+    private String title;
45
+
46
+    /** 内容 */
47
+    private String content;
48
+
49
+    /** 错误信息 */
50
+    private String errorMsg;
51
+
52
+    /** 重试次数 */
53
+    private Integer retryCount;
54
+
55
+    /** 最大重试次数 */
56
+    private Integer maxRetryCount;
57
+
58
+    /** 首次发送时间 */
59
+    private LocalDateTime firstSendAt;
60
+
61
+    /** 发送成功时间 */
62
+    private LocalDateTime successAt;
63
+
64
+    /** 发送失败时间 */
65
+    private LocalDateTime failedAt;
66
+
67
+    /** 通知配置JSON */
68
+    private String config;
69
+}

+ 69
- 0
wm-notify/src/main/java/com/water/notify/entity/NotifyTemplate.java Просмотреть файл

@@ -0,0 +1,69 @@
1
+package com.water.notify.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.IdType;
4
+import com.baomidou.mybatisplus.annotation.TableId;
5
+import com.baomidou.mybatisplus.annotation.TableName;
6
+import lombok.Data;
7
+import lombok.experimental.Accessors;
8
+
9
+import java.time.LocalDateTime;
10
+
11
+/**
12
+ * 通知模板表
13
+ *
14
+ * 对应设计规格:§1.6 消息通知模块
15
+ */
16
+@Data
17
+@Accessors(chain = true)
18
+@TableName("notify_template")
19
+public class NotifyTemplate {
20
+
21
+    @TableId(type = IdType.AUTO)
22
+    private Long id;
23
+
24
+    /** 模板代码,唯一标识 */
25
+    private String templateCode;
26
+
27
+    /** 模板名称 */
28
+    private String templateName;
29
+
30
+    /** 支持的渠道,逗号分隔:ws,sms,push,email */
31
+    private String channels;
32
+
33
+    /** 标题模板 */
34
+    private String titleTpl;
35
+
36
+    /** 内容模板(支持变量替换 ${var}) */
37
+    private String contentTpl;
38
+
39
+    /** 模板状态:1-启用 0-停用 */
40
+    private Integer status;
41
+
42
+    private LocalDateTime createdAt;
43
+
44
+    private LocalDateTime updatedAt;
45
+
46
+    /** 渠道枚举 */
47
+    public enum Channel {
48
+        WEBSOCKET("ws", "WebSocket实时推送"),
49
+        SMS("sms", "短信推送"),
50
+        PUSH("push", "APP推送"),
51
+        EMAIL("email", "邮件推送");
52
+
53
+        private final String code;
54
+        private final String description;
55
+
56
+        Channel(String code, String description) {
57
+            this.code = code;
58
+            this.description = description;
59
+        }
60
+
61
+        public String getCode() {
62
+            return code;
63
+        }
64
+
65
+        public String getDescription() {
66
+            return description;
67
+        }
68
+    }
69
+}

+ 12
- 0
wm-notify/src/main/java/com/water/notify/mapper/NotifyLogMapper.java Просмотреть файл

@@ -0,0 +1,12 @@
1
+package com.water.notify.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.notify.entity.NotifyLog;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+/**
8
+ * 通知日志 Mapper
9
+ */
10
+@Mapper
11
+public interface NotifyLogMapper extends BaseMapper<NotifyLog> {
12
+}

+ 12
- 0
wm-notify/src/main/java/com/water/notify/mapper/NotifyTemplateMapper.java Просмотреть файл

@@ -0,0 +1,12 @@
1
+package com.water.notify.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.notify.entity.NotifyTemplate;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+/**
8
+ * 通知模板 Mapper
9
+ */
10
+@Mapper
11
+public interface NotifyTemplateMapper extends BaseMapper<NotifyTemplate> {
12
+}

+ 28
- 0
wm-notify/src/main/java/com/water/notify/service/NotifyLogService.java Просмотреть файл

@@ -0,0 +1,28 @@
1
+package com.water.notify.service;
2
+
3
+import com.baomidou.mybatisplus.extension.service.IService;
4
+import com.water.notify.entity.NotifyLog;
5
+
6
+import java.time.LocalDateTime;
7
+import java.util.List;
8
+
9
+/**
10
+ * 通知日志服务
11
+ */
12
+public interface NotifyLogService extends IService<NotifyLog> {
13
+
14
+    /** 记录通知发送日志 */
15
+    void recordLog(NotifyLog log);
16
+
17
+    /** 更新日志状态为成功 */
18
+    void updateSuccess(Long logId, String response);
19
+
20
+    /** 更新日志状态为失败 */
21
+    void updateFailed(Long logId, String errorMsg);
22
+
23
+    /** 获取失败的通知日志(用于重试) */
24
+    List<NotifyLog> getFailedLogs(LocalDateTime beforeTime, int maxRetryCount);
25
+
26
+    /** 获取待发送的通知日志 */
27
+    List<NotifyLog> getPendingLogs(int limit);
28
+}

+ 52
- 30
wm-notify/src/main/java/com/water/notify/service/NotifyService.java Просмотреть файл

@@ -1,33 +1,55 @@
1 1
 package com.water.notify.service;
2 2
 
3
-import lombok.extern.slf4j.Slf4j;
4
-import org.springframework.stereotype.Service;
5
-
6
-@Slf4j
7
-@Service
8
-public class NotifyService {
9
-
10
-    /** 发送短信 */
11
-    public void sendSms(String phone, String content) {
12
-        log.info("Send SMS to {}: {}", phone, content);
13
-        // TODO: 集成阿里云/腾讯云短信 SDK
14
-    }
15
-
16
-    /** WebSocket 推送 */
17
-    public void pushWebSocket(Long userId, String message) {
18
-        log.info("Push WS to user {}: {}", userId, message);
19
-        // TODO: WebSocket session 管理
20
-    }
21
-
22
-    /** APP Push */
23
-    public void pushApp(Long userId, String title, String body) {
24
-        log.info("Push APP to user {}: {} - {}", userId, title, body);
25
-        // TODO: 极光推送
26
-    }
27
-
28
-    /** 按通知方案多渠道分发 */
29
-    public void dispatch(Long schemeId, Long userId, String title, String content) {
30
-        // TODO: 查询通知方案,按配置渠道分发
31
-        log.info("Notify dispatch: scheme={}, user={}, title={}", schemeId, userId, title);
32
-    }
3
+import com.water.notify.entity.NotifyLog;
4
+
5
+import java.util.List;
6
+import java.util.Map;
7
+
8
+/**
9
+ * 通知发送服务
10
+ *
11
+ * 统一的消息发送入口,支持多种渠道
12
+ */
13
+public interface NotifyService {
14
+
15
+    /**
16
+     * 发送通知(指定渠道)
17
+     *
18
+     * @param templateCode       模板代码
19
+     * @param receiverId         接收者ID
20
+     * @param receiverIdentifier 接收者标识
21
+     * @param channel            发送渠道
22
+     * @param params             模板参数
23
+     * @return 通知日志ID
24
+     */
25
+    Long sendNotify(String templateCode, Long receiverId, String receiverIdentifier,
26
+                    String channel, Map<String, Object> params);
27
+
28
+    /**
29
+     * 发送通知(按模板配置的多渠道自动分发)
30
+     *
31
+     * @return 各渠道产生的通知日志ID列表
32
+     */
33
+    List<Long> sendNotify(String templateCode, Long receiverId, String receiverIdentifier,
34
+                          Map<String, Object> params);
35
+
36
+    /**
37
+     * 批量发送通知
38
+     *
39
+     * @return 各接收者的通知日志ID列表
40
+     */
41
+    List<Long> batchSendNotify(String templateCode, List<Long> receiverIds,
42
+                               List<String> receiverIdentifiers, String channel,
43
+                               Map<String, Object> params);
44
+
45
+    /**
46
+     * 重新发送失败的通知(定时任务调用)
47
+     */
48
+    void retryFailedNotifications();
49
+
50
+    /**
51
+     * 异步分发通知到具体渠道(由 sendNotify 经由代理调用以使 @Async 生效)。
52
+     * 外部一般不直接调用。
53
+     */
54
+    void dispatchAsync(NotifyLog notifyLog);
33 55
 }

+ 21
- 0
wm-notify/src/main/java/com/water/notify/service/NotifyTemplateService.java Просмотреть файл

@@ -0,0 +1,21 @@
1
+package com.water.notify.service;
2
+
3
+import com.baomidou.mybatisplus.extension.service.IService;
4
+import com.water.notify.entity.NotifyTemplate;
5
+
6
+import java.util.List;
7
+
8
+/**
9
+ * 通知模板服务
10
+ */
11
+public interface NotifyTemplateService extends IService<NotifyTemplate> {
12
+
13
+    /** 根据模板代码获取启用的模板 */
14
+    NotifyTemplate getByTemplateCode(String templateCode);
15
+
16
+    /** 获取启用的模板列表 */
17
+    List<NotifyTemplate> getEnabledTemplates();
18
+
19
+    /** 根据渠道获取启用的模板 */
20
+    List<NotifyTemplate> getEnabledTemplatesByChannel(String channel);
21
+}

+ 64
- 0
wm-notify/src/main/java/com/water/notify/service/impl/NotifyLogServiceImpl.java Просмотреть файл

@@ -0,0 +1,64 @@
1
+package com.water.notify.service.impl;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
5
+import com.water.notify.entity.NotifyLog;
6
+import com.water.notify.mapper.NotifyLogMapper;
7
+import com.water.notify.service.NotifyLogService;
8
+import org.springframework.stereotype.Service;
9
+
10
+import java.time.LocalDateTime;
11
+import java.util.List;
12
+
13
+/**
14
+ * 通知日志服务实现
15
+ */
16
+@Service
17
+public class NotifyLogServiceImpl extends ServiceImpl<NotifyLogMapper, NotifyLog>
18
+        implements NotifyLogService {
19
+
20
+    @Override
21
+    public void recordLog(NotifyLog log) {
22
+        log.setFirstSendAt(LocalDateTime.now());
23
+        log.setStatus("pending");
24
+        log.setRetryCount(0);
25
+        this.save(log);
26
+    }
27
+
28
+    @Override
29
+    public void updateSuccess(Long logId, String response) {
30
+        NotifyLog log = new NotifyLog();
31
+        log.setId(logId);
32
+        log.setStatus("success");
33
+        log.setSuccessAt(LocalDateTime.now());
34
+        this.updateById(log);
35
+    }
36
+
37
+    @Override
38
+    public void updateFailed(Long logId, String errorMsg) {
39
+        NotifyLog log = new NotifyLog();
40
+        log.setId(logId);
41
+        log.setStatus("failed");
42
+        log.setErrorMsg(errorMsg);
43
+        log.setFailedAt(LocalDateTime.now());
44
+        this.updateById(log);
45
+    }
46
+
47
+    @Override
48
+    public List<NotifyLog> getFailedLogs(LocalDateTime beforeTime, int maxRetryCount) {
49
+        LambdaQueryWrapper<NotifyLog> wrapper = new LambdaQueryWrapper<>();
50
+        wrapper.eq(NotifyLog::getStatus, "failed")
51
+                .lt(NotifyLog::getFailedAt, beforeTime)
52
+                .lt(NotifyLog::getRetryCount, maxRetryCount);
53
+        return this.list(wrapper);
54
+    }
55
+
56
+    @Override
57
+    public List<NotifyLog> getPendingLogs(int limit) {
58
+        LambdaQueryWrapper<NotifyLog> wrapper = new LambdaQueryWrapper<>();
59
+        wrapper.eq(NotifyLog::getStatus, "pending")
60
+                .orderByAsc(NotifyLog::getFirstSendAt)
61
+                .last("LIMIT " + limit);
62
+        return this.list(wrapper);
63
+    }
64
+}

+ 184
- 0
wm-notify/src/main/java/com/water/notify/service/impl/NotifyServiceImpl.java Просмотреть файл

@@ -0,0 +1,184 @@
1
+package com.water.notify.service.impl;
2
+
3
+import com.fasterxml.jackson.core.JsonProcessingException;
4
+import com.fasterxml.jackson.databind.ObjectMapper;
5
+import com.water.notify.channel.Notifier;
6
+import com.water.notify.entity.NotifyLog;
7
+import com.water.notify.entity.NotifyTemplate;
8
+import com.water.notify.service.NotifyLogService;
9
+import com.water.notify.service.NotifyService;
10
+import com.water.notify.service.NotifyTemplateService;
11
+import lombok.extern.slf4j.Slf4j;
12
+import org.springframework.beans.factory.annotation.Autowired;
13
+import org.springframework.context.annotation.Lazy;
14
+import org.springframework.scheduling.annotation.Async;
15
+import org.springframework.stereotype.Service;
16
+
17
+import java.time.LocalDateTime;
18
+import java.util.ArrayList;
19
+import java.util.List;
20
+import java.util.Map;
21
+
22
+/**
23
+ * 通知服务实现
24
+ *
25
+ * 模板变量渲染 + 多渠道分发 + 失败重试。
26
+ */
27
+@Slf4j
28
+@Service
29
+public class NotifyServiceImpl implements NotifyService {
30
+
31
+    private final NotifyTemplateService templateService;
32
+    private final NotifyLogService logService;
33
+    private final List<Notifier> notifiers;
34
+    private final ObjectMapper objectMapper;
35
+
36
+    /**
37
+     * 自注入以使 {@link Async} 经由代理生效(同类内部直接调用不走代理,异步会失效)。
38
+     */
39
+    private final NotifyService self;
40
+
41
+    public NotifyServiceImpl(NotifyTemplateService templateService,
42
+                             NotifyLogService logService,
43
+                             List<Notifier> notifiers,
44
+                             ObjectMapper objectMapper,
45
+                             @Lazy @Autowired NotifyService self) {
46
+        this.templateService = templateService;
47
+        this.logService = logService;
48
+        this.notifiers = notifiers;
49
+        this.objectMapper = objectMapper;
50
+        this.self = self;
51
+    }
52
+
53
+    @Override
54
+    public Long sendNotify(String templateCode, Long receiverId, String receiverIdentifier,
55
+                           String channel, Map<String, Object> params) {
56
+        NotifyTemplate template = requireTemplate(templateCode);
57
+        if (!template.getChannels().contains(channel)) {
58
+            throw new IllegalArgumentException("模板不支持该渠道: " + channel);
59
+        }
60
+
61
+        String title = renderTemplate(template.getTitleTpl(), params);
62
+        String content = renderTemplate(template.getContentTpl(), params);
63
+
64
+        NotifyLog notifyLog = new NotifyLog()
65
+                .setTemplateId(template.getId())
66
+                .setTemplateCode(templateCode)
67
+                .setReceiverId(receiverId)
68
+                .setReceiverIdentifier(receiverIdentifier)
69
+                .setChannel(channel)
70
+                .setTitle(title)
71
+                .setContent(content)
72
+                .setConfig(toJson(params));
73
+
74
+        logService.recordLog(notifyLog);
75
+        // 经由代理调用,确保 @Async 生效
76
+        self.dispatchAsync(notifyLog);
77
+        return notifyLog.getId();
78
+    }
79
+
80
+    @Override
81
+    public List<Long> sendNotify(String templateCode, Long receiverId, String receiverIdentifier,
82
+                                 Map<String, Object> params) {
83
+        NotifyTemplate template = requireTemplate(templateCode);
84
+        List<Long> logIds = new ArrayList<>();
85
+        for (String channel : template.getChannels().split(",")) {
86
+            String ch = channel.trim();
87
+            if (ch.isEmpty()) {
88
+                continue;
89
+            }
90
+            try {
91
+                logIds.add(sendNotify(templateCode, receiverId, receiverIdentifier, ch, params));
92
+            } catch (Exception e) {
93
+                log.warn("多渠道分发失败 - 渠道{}: {}", ch, e.getMessage());
94
+            }
95
+        }
96
+        return logIds;
97
+    }
98
+
99
+    @Override
100
+    public List<Long> batchSendNotify(String templateCode, List<Long> receiverIds,
101
+                                      List<String> receiverIdentifiers, String channel,
102
+                                      Map<String, Object> params) {
103
+        List<Long> logIds = new ArrayList<>();
104
+        for (int i = 0; i < receiverIds.size(); i++) {
105
+            try {
106
+                logIds.add(sendNotify(templateCode, receiverIds.get(i), receiverIdentifiers.get(i), channel, params));
107
+            } catch (Exception e) {
108
+                log.warn("批量发送失败 - 用户{}: {}", receiverIds.get(i), e.getMessage());
109
+            }
110
+        }
111
+        return logIds;
112
+    }
113
+
114
+    @Override
115
+    public void retryFailedNotifications() {
116
+        LocalDateTime fiveMinutesAgo = LocalDateTime.now().minusMinutes(5);
117
+        List<NotifyLog> failedLogs = logService.getFailedLogs(fiveMinutesAgo, 3);
118
+        for (NotifyLog notifyLog : failedLogs) {
119
+            notifyLog.setRetryCount(notifyLog.getRetryCount() + 1);
120
+            self.dispatchAsync(notifyLog);
121
+        }
122
+    }
123
+
124
+    /**
125
+     * 异步分发通知到具体渠道。public + 经代理调用以使 {@link Async} 生效。
126
+     */
127
+    @Async
128
+    public void dispatchAsync(NotifyLog notifyLog) {
129
+        try {
130
+            Notifier notifier = notifiers.stream()
131
+                    .filter(n -> n.getChannel().equals(notifyLog.getChannel()))
132
+                    .findFirst()
133
+                    .orElseThrow(() -> new IllegalArgumentException("不支持的渠道: " + notifyLog.getChannel()));
134
+
135
+            if (!notifier.validateReceiver(notifyLog)) {
136
+                logService.updateFailed(notifyLog.getId(), "接收者无效");
137
+                return;
138
+            }
139
+            if (notifier.send(notifyLog)) {
140
+                logService.updateSuccess(notifyLog.getId(), "发送成功");
141
+            } else {
142
+                logService.updateFailed(notifyLog.getId(), "发送失败");
143
+            }
144
+        } catch (Exception e) {
145
+            log.error("通知发送异常 logId={}: {}", notifyLog.getId(), e.getMessage(), e);
146
+            logService.updateFailed(notifyLog.getId(), e.getMessage());
147
+        }
148
+    }
149
+
150
+    private NotifyTemplate requireTemplate(String templateCode) {
151
+        NotifyTemplate template = templateService.getByTemplateCode(templateCode);
152
+        if (template == null) {
153
+            throw new IllegalArgumentException("模板不存在: " + templateCode);
154
+        }
155
+        return template;
156
+    }
157
+
158
+    /** ${variable} -> value 简单替换 */
159
+    private String renderTemplate(String tpl, Map<String, Object> params) {
160
+        if (tpl == null || params == null) {
161
+            return tpl;
162
+        }
163
+        String result = tpl;
164
+        for (Map.Entry<String, Object> entry : params.entrySet()) {
165
+            String key = "${" + entry.getKey() + "}";
166
+            String value = entry.getValue() != null ? entry.getValue().toString() : "";
167
+            result = result.replace(key, value);
168
+        }
169
+        return result;
170
+    }
171
+
172
+    /** Map 序列化为 JSON(用于 config 字段记录),失败返回空串。 */
173
+    private String toJson(Map<String, Object> params) {
174
+        if (params == null) {
175
+            return null;
176
+        }
177
+        try {
178
+            return objectMapper.writeValueAsString(params);
179
+        } catch (JsonProcessingException e) {
180
+            log.warn("序列化通知参数失败: {}", e.getMessage());
181
+            return "";
182
+        }
183
+    }
184
+}

+ 41
- 0
wm-notify/src/main/java/com/water/notify/service/impl/NotifyTemplateServiceImpl.java Просмотреть файл

@@ -0,0 +1,41 @@
1
+package com.water.notify.service.impl;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
5
+import com.water.notify.entity.NotifyTemplate;
6
+import com.water.notify.mapper.NotifyTemplateMapper;
7
+import com.water.notify.service.NotifyTemplateService;
8
+import org.springframework.stereotype.Service;
9
+
10
+import java.util.List;
11
+
12
+/**
13
+ * 通知模板服务实现
14
+ */
15
+@Service
16
+public class NotifyTemplateServiceImpl extends ServiceImpl<NotifyTemplateMapper, NotifyTemplate>
17
+        implements NotifyTemplateService {
18
+
19
+    @Override
20
+    public NotifyTemplate getByTemplateCode(String templateCode) {
21
+        LambdaQueryWrapper<NotifyTemplate> wrapper = new LambdaQueryWrapper<>();
22
+        wrapper.eq(NotifyTemplate::getTemplateCode, templateCode)
23
+                .eq(NotifyTemplate::getStatus, 1);
24
+        return this.getOne(wrapper);
25
+    }
26
+
27
+    @Override
28
+    public List<NotifyTemplate> getEnabledTemplates() {
29
+        LambdaQueryWrapper<NotifyTemplate> wrapper = new LambdaQueryWrapper<>();
30
+        wrapper.eq(NotifyTemplate::getStatus, 1);
31
+        return this.list(wrapper);
32
+    }
33
+
34
+    @Override
35
+    public List<NotifyTemplate> getEnabledTemplatesByChannel(String channel) {
36
+        LambdaQueryWrapper<NotifyTemplate> wrapper = new LambdaQueryWrapper<>();
37
+        wrapper.eq(NotifyTemplate::getStatus, 1)
38
+                .like(NotifyTemplate::getChannels, channel);
39
+        return this.list(wrapper);
40
+    }
41
+}

+ 0
- 36
wm-notify/src/main/java/notify/NotificationTest.java Просмотреть файл

@@ -1,36 +0,0 @@
1
-package com.water.notify;
2
-
3
-import org.junit.jupiter.api.Test;
4
-import org.springframework.boot.test.context.SpringBootTest;
5
-import static org.junit.jupiter.api.Assertions.*;
6
-
7
-/**
8
- * 消息通知模块测试
9
- */
10
-@SpringBootTest
11
-public class NotificationTest {
12
-
13
-    @Test
14
-    public void testSmsNotification() {
15
-        // 测试短信通知
16
-        assertTrue(true, "短信通知测试通过");
17
-    }
18
-
19
-    @Test
20
-    public void testEmailNotification() {
21
-        // 测试邮件通知
22
-        assertTrue(true, "邮件通知测试通过");
23
-    }
24
-
25
-    @Test
26
-    public void testWechatNotification() {
27
-        // 测试微信通知
28
-        assertTrue(true, "微信通知测试通过");
29
-    }
30
-
31
-    @Test
32
-    public void testAppPushNotification() {
33
-        // 测试APP推送通知
34
-        assertTrue(true, "APP推送通知测试通过");
35
-    }
36
-}

+ 0
- 126
wm-notify/src/main/java/notify/src/test/java/com/water/notify/NotificationTest.java Просмотреть файл

@@ -1,126 +0,0 @@
1
-package com.water.notify;
2
-
3
-import org.junit.jupiter.api.Test;
4
-import org.junit.jupiter.api.BeforeEach;
5
-import static org.junit.jupiter.api.Assertions.*;
6
-import java.util.List;
7
-import java.util.Map;
8
-
9
-/**
10
- * 消息通知模块测试
11
- * 测试短信、邮件、站内信等通知功能
12
- */
13
-public class NotificationTest {
14
-    
15
-    private NotificationService notificationService;
16
-    
17
-    @BeforeEach
18
-    void setUp() {
19
-        notificationService = new NotificationService();
20
-    }
21
-    
22
-    @Test
23
-    void testSMSNotification() {
24
-        Map<String, Object> smsData = Map.of(
25
-            "phone", "13800138000",
26
-            "message", "您的水费账单已生成,请及时查看",
27
-            "template_id", "water_bill_template"
28
-        );
29
-        
30
-        boolean sent = notificationService.sendSMS(smsData);
31
-        assertTrue(sent, "短信发送应该成功");
32
-    }
33
-    
34
-    @Test
35
-    void testEmailNotification() {
36
-        Map<String, Object> emailData = Map.of(
37
-            "to", "user@example.com",
38
-            "subject", "水费账单通知",
39
-            "content", "尊敬的用户,您的6月份水费账单已生成...",
40
-            "template", "bill_email_template"
41
-        );
42
-        
43
-        boolean sent = notificationService.sendEmail(emailData);
44
-        assertTrue(sent, "邮件发送应该成功");
45
-    }
46
-    
47
-    @Test
48
-    void testInAppNotification() {
49
-        Map<String, Object> appData = Map.of(
50
-            "user_id", "user-001",
51
-            "title", "账单提醒",
52
-            "content": "您有新的水费账单待支付",
53
-            "type", "bill_reminder",
54
-            "priority", "high"
55
-        );
56
-        
57
-        boolean sent = notificationService.sendInAppNotification(appData);
58
-        assertTrue(sent, "应用内通知应该成功");
59
-    }
60
-    
61
-    @Test
62
-    void testNotificationTemplate() {
63
-        Map<String, Object> templateData = Map.of(
64
-            "template_name", "late_payment_template",
65
-            "template_content", "尊敬的用户,您的水费已逾期,请尽快缴纳...",
66
-            "variables", List.of("user_name", "amount", "due_date")
67
-        );
68
-        
69
-        boolean created = notificationService.createTemplate(templateData);
70
-        assertTrue(created, "通知模板创建应该成功");
71
-    }
72
-    
73
-    @Test
74
-    void testNotificationScheduling() {
75
-        Map<String, Object> scheduleData = Map.of(
76
-            "user_id", "user-002",
77
-            "notification_type", "sms",
78
-            "scheduled_time", "2026-06-17T09:00:00Z",
79
-            "message": "明日计划停水通知"
80
-        );
81
-        
82
-        String scheduleId = notificationService.scheduleNotification(scheduleData);
83
-        assertNotNull(scheduleId, "通知调度应该返回ID");
84
-        assertFalse(scheduleId.isEmpty(), "ID不应该为空");
85
-    }
86
-    
87
-    @Test
88
-    void testNotificationDeliveryStatus() {
89
-        String notificationId = "notif-001";
90
-        
91
-        String status = notificationService.getDeliveryStatus(notificationId);
92
-        assertNotNull(status, "通知状态不应该为null");
93
-        
94
-        // 测试状态枚举
95
-        assertTrue(status.equals("delivered") || 
96
-                   status.equals("failed") || 
97
-                   status.equals("pending"), 
98
-                   "状态应该是有效值之一");
99
-    }
100
-    
101
-    @Test
102
-    void testNotificationBatch() {
103
-        List<Map<String, Object>> batchData = List.of(
104
-            Map.of("user_id", "batch-001", "message", "批量通知1"),
105
-            Map.of("user_id", "batch-002", "message", "批量通知2"),
106
-            Map.of("user_id", "batch-003", "message", "批量通知3")
107
-        );
108
-        
109
-        int sentCount = notificationService.sendBatchNotifications(batchData);
110
-        assertEquals(3, sentCount, "批量通知应该发送3条");
111
-    }
112
-    
113
-    @Test
114
-    void testNotificationLog() {
115
-        Map<String, Object> logData = Map.of(
116
-            "notification_id", "log-001",
117
-            "user_id", "user-log",
118
-            "type", "sms",
119
-            "status", "delivered",
120
-            "timestamp", "2026-06-16T10:00:00Z"
121
-        );
122
-        
123
-        boolean logged = notificationService.logNotification(logData);
124
-        assertTrue(logged, "通知日志记录应该成功");
125
-    }
126
-}

+ 25
- 0
wm-notify/src/main/resources/application.yml Просмотреть файл

@@ -8,10 +8,35 @@ spring:
8 8
     url: jdbc:postgresql://${PG_HOST:127.0.0.1}:5432/water_management
9 9
     username: ${PG_USER:water}
10 10
     password: ${PG_PASS:water123}
11
+    driver-class-name: org.postgresql.Driver
12
+  data:
13
+    redis:
14
+      host: ${REDIS_HOST:127.0.0.1}
15
+      port: ${REDIS_PORT:6379}
16
+      database: 0
11 17
   cloud:
12 18
     nacos:
13 19
       discovery:
14 20
         server-addr: ${NACOS_HOST:127.0.0.1}:8848
21
+  # 邮件(EmailNotifier 使用)
22
+  mail:
23
+    host: ${MAIL_HOST:smtp.example.com}
24
+    port: ${MAIL_PORT:587}
25
+    username: ${MAIL_USER:noreply@water.com}
26
+    password: ${MAIL_PASS:password}
27
+    properties:
28
+      mail.smtp.auth: true
29
+      mail.smtp.starttls.enable: true
30
+  jackson:
31
+    date-format: yyyy-MM-dd HH:mm:ss
32
+    time-zone: Asia/Shanghai
15 33
 
16 34
 mybatis-plus:
17 35
   mapper-locations: classpath*:/mapper/**/*.xml
36
+  type-aliases-package: com.water.notify.entity
37
+  configuration:
38
+    map-underscore-to-camel-case: true
39
+
40
+logging:
41
+  level:
42
+    com.water.notify: debug

+ 57
- 0
wm-notify/src/main/resources/db/init.sql Просмотреть файл

@@ -0,0 +1,57 @@
1
+-- 消息通知模块数据库初始化脚本
2
+-- 对应设计规格:§1.6 消息通知模块
3
+
4
+-- 通知模板表
5
+CREATE TABLE IF NOT EXISTS notify_template (
6
+    id                  BIGSERIAL PRIMARY KEY,
7
+    template_code       VARCHAR(50) UNIQUE NOT NULL,
8
+    template_name       VARCHAR(100),
9
+    channels            VARCHAR(50) DEFAULT 'ws',     -- ws,sms,push,email 逗号分隔
10
+    title_tpl           VARCHAR(200),
11
+    content_tpl         TEXT NOT NULL,                -- 支持变量 ${device_sn} ${value}
12
+    status              SMALLINT DEFAULT 1,           -- 1-启用 0-停用
13
+    created_at          TIMESTAMPTZ DEFAULT NOW(),
14
+    updated_at          TIMESTAMPTZ DEFAULT NOW()
15
+);
16
+
17
+-- 通知日志表
18
+CREATE TABLE IF NOT EXISTS notify_log (
19
+    id                  BIGSERIAL PRIMARY KEY,
20
+    template_id         BIGINT,
21
+    template_code       VARCHAR(50),
22
+    receiver_id         BIGINT,
23
+    receiver_identifier VARCHAR(255),
24
+    channel             VARCHAR(20) NOT NULL,
25
+    status              VARCHAR(20) DEFAULT 'pending',  -- pending/success/failed/timeout
26
+    title               VARCHAR(500),
27
+    content             TEXT,
28
+    error_msg           TEXT,
29
+    retry_count         INT DEFAULT 0,
30
+    max_retry_count     INT DEFAULT 3,
31
+    first_send_at       TIMESTAMPTZ,
32
+    success_at          TIMESTAMPTZ,
33
+    failed_at           TIMESTAMPTZ,
34
+    config              TEXT,
35
+    created_at          TIMESTAMPTZ DEFAULT NOW(),
36
+    updated_at          TIMESTAMPTZ DEFAULT NOW()
37
+);
38
+
39
+CREATE INDEX IF NOT EXISTS idx_notify_template_code   ON notify_template(template_code);
40
+CREATE INDEX IF NOT EXISTS idx_notify_template_status ON notify_template(status);
41
+CREATE INDEX IF NOT EXISTS idx_notify_log_receiver    ON notify_log(receiver_id);
42
+CREATE INDEX IF NOT EXISTS idx_notify_log_channel     ON notify_log(channel);
43
+CREATE INDEX IF NOT EXISTS idx_notify_log_status      ON notify_log(status);
44
+CREATE INDEX IF NOT EXISTS idx_notify_log_created_at  ON notify_log(created_at);
45
+
46
+-- 默认通知模板
47
+INSERT INTO notify_template (template_code, template_name, channels, title_tpl, content_tpl, status) VALUES
48
+('device_alert', '设备报警通知', 'ws,push,sms', '设备报警: ${device_sn}',
49
+ '设备 ${device_sn} 发生 ${alert_level} 报警\n设备类型: ${device_type}\n报警内容: ${message}\n发生时间: ${alert_time}', 1),
50
+('maintenance_reminder', '维护提醒', 'ws,email', '设备维护提醒: ${device_sn}',
51
+ '设备 ${device_sn} 将在 ${maintenance_date} 进行维护,请提前做好准备。\n维护类型: ${maintenance_type}\n负责人: ${maintenance_person}', 1),
52
+('system_notice', '系统通知', 'ws,push,email', '系统通知', '${content}', 1),
53
+('bill_notification', '账单通知', 'ws,push,email', '账单通知: ${customer_name}',
54
+ '尊敬的${customer_name},您${bill_period}的账单已生成,金额${total_fee}元,请及时缴费。', 1),
55
+('inspection_reminder', '巡检提醒', 'ws,push', '巡检提醒: ${inspector_name}',
56
+ '${inspector_name},您今日有${task_count}个巡检任务,请及时完成。\n开始时间: ${plan_time}', 1)
57
+ON CONFLICT (template_code) DO NOTHING;

+ 43
- 0
wm-notify/src/test/java/com/water/notify/channel/NotifierValidateTest.java Просмотреть файл

@@ -0,0 +1,43 @@
1
+package com.water.notify.channel;
2
+
3
+import com.water.notify.entity.NotifyLog;
4
+import org.junit.jupiter.api.Test;
5
+
6
+import static org.junit.jupiter.api.Assertions.*;
7
+
8
+/**
9
+ * 各渠道 Notifier 接收者校验测试(纯逻辑,无 Spring 依赖)。
10
+ */
11
+class NotifierValidateTest {
12
+
13
+    private NotifyLog log(String identifier, Long receiverId) {
14
+        return new NotifyLog().setReceiverIdentifier(identifier).setReceiverId(receiverId);
15
+    }
16
+
17
+    @Test
18
+    void smsNotifier_validatesPhone() {
19
+        SmsNotifier sms = new SmsNotifier();
20
+        assertEquals("sms", sms.getChannel());
21
+        assertTrue(sms.validateReceiver(log("13800138000", 1L)));
22
+        assertFalse(sms.validateReceiver(log("12345", 1L)));
23
+        assertFalse(sms.validateReceiver(log(null, 1L)));
24
+    }
25
+
26
+    @Test
27
+    void pushNotifier_validatesNonEmptyToken() {
28
+        PushNotifier push = new PushNotifier();
29
+        assertEquals("push", push.getChannel());
30
+        assertTrue(push.validateReceiver(log("registration-id-xxx", 1L)));
31
+        assertFalse(push.validateReceiver(log("", 1L)));
32
+        assertFalse(push.validateReceiver(log(null, 1L)));
33
+    }
34
+
35
+    @Test
36
+    void emailNotifier_validatesEmailFormat() {
37
+        // EmailNotifier 依赖 JavaMailSender,只测 validateReceiver(不触发 send)
38
+        EmailNotifier email = new EmailNotifier(null);
39
+        assertEquals("email", email.getChannel());
40
+        assertTrue(email.validateReceiver(log("user@water.com", 1L)));
41
+        assertFalse(email.validateReceiver(log("not-an-email", 1L)));
42
+    }
43
+}

+ 105
- 0
wm-notify/src/test/java/com/water/notify/service/NotifyServiceImplTest.java Просмотреть файл

@@ -0,0 +1,105 @@
1
+package com.water.notify.service;
2
+
3
+import com.water.notify.channel.Notifier;
4
+import com.water.notify.entity.NotifyLog;
5
+import com.water.notify.entity.NotifyTemplate;
6
+import com.water.notify.service.impl.NotifyServiceImpl;
7
+import com.fasterxml.jackson.databind.ObjectMapper;
8
+import org.junit.jupiter.api.BeforeEach;
9
+import org.junit.jupiter.api.Test;
10
+import org.junit.jupiter.api.extension.ExtendWith;
11
+import org.mockito.Mock;
12
+import org.mockito.junit.jupiter.MockitoExtension;
13
+
14
+import java.util.List;
15
+import java.util.Map;
16
+
17
+import static org.junit.jupiter.api.Assertions.*;
18
+import static org.mockito.ArgumentMatchers.*;
19
+import static org.mockito.Mockito.*;
20
+
21
+/**
22
+ * NotifyServiceImpl 单元测试
23
+ *
24
+ * 覆盖:模板渲染、多渠道分发、模板不存在/渠道不支持异常、批量发送容错、失败重试。
25
+ * 手动构造被测对象(避免 Mockito @InjectMocks 对 List<Notifier> 集合参数注入的不确定性)。
26
+ */
27
+@ExtendWith(MockitoExtension.class)
28
+class NotifyServiceImplTest {
29
+
30
+    @Mock NotifyTemplateService templateService;
31
+    @Mock NotifyLogService logService;
32
+    @Mock Notifier wsNotifier;
33
+    @Mock NotifyService self;
34
+
35
+    private NotifyServiceImpl service;
36
+
37
+    @BeforeEach
38
+    void setUp() {
39
+        // notifiers 列表只含 ws 渠道的 stub;objectMapper 用真实实例(纯序列化,无副作用)
40
+        service = new NotifyServiceImpl(templateService, logService, List.of(wsNotifier),
41
+                new ObjectMapper(), self);
42
+    }
43
+
44
+    private NotifyTemplate template(String channels) {
45
+        NotifyTemplate t = new NotifyTemplate();
46
+        t.setId(1L);
47
+        t.setTemplateCode("device_alert");
48
+        t.setChannels(channels);
49
+        t.setTitleTpl("报警: ${device_sn}");
50
+        t.setContentTpl("设备 ${device_sn} 发生 ${level} 报警");
51
+        t.setStatus(1);
52
+        return t;
53
+    }
54
+
55
+    @Test
56
+    void sendNotify_singleChannel_recordsLogAndDispatches() {
57
+        when(templateService.getByTemplateCode("device_alert")).thenReturn(template("ws"));
58
+
59
+        Long id = service.sendNotify("device_alert", 100L, "100",
60
+                "ws", Map.of("device_sn", "FLOW_001", "level", "高"));
61
+
62
+        assertNotNull(id);
63
+        verify(logService).recordLog(any(NotifyLog.class));
64
+        verify(self).dispatchAsync(any(NotifyLog.class));
65
+    }
66
+
67
+    @Test
68
+    void sendNotify_unknownTemplate_throws() {
69
+        when(templateService.getByTemplateCode("missing")).thenReturn(null);
70
+        assertThrows(IllegalArgumentException.class,
71
+                () -> service.sendNotify("missing", 1L, "x", "ws", Map.of()));
72
+    }
73
+
74
+    @Test
75
+    void sendNotify_unsupportedChannel_throws() {
76
+        when(templateService.getByTemplateCode("device_alert")).thenReturn(template("ws"));
77
+        assertThrows(IllegalArgumentException.class,
78
+                () -> service.sendNotify("device_alert", 1L, "x", "sms", Map.of()));
79
+    }
80
+
81
+    @Test
82
+    void sendNotify_multiChannel_sendsAllConfiguredChannels() {
83
+        when(templateService.getByTemplateCode("device_alert")).thenReturn(template("ws,sms"));
84
+
85
+        List<Long> ids = service.sendNotify("device_alert", 100L, "100",
86
+                Map.of("device_sn", "F1", "level", "低"));
87
+
88
+        // ws,sms 两个渠道各产生一条日志(sms 渠道发送时 recordLog 仍会调用,
89
+        // dispatchAsync 由 self 代理接管,渠道不存在与否不影响日志记录)
90
+        assertEquals(2, ids.size());
91
+        verify(logService, times(2)).recordLog(any(NotifyLog.class));
92
+        verify(self, times(2)).dispatchAsync(any(NotifyLog.class));
93
+    }
94
+
95
+    @Test
96
+    void retryFailedNotifications_redispatchesFailedLogs() {
97
+        NotifyLog failed = new NotifyLog().setId(9L).setChannel("ws").setRetryCount(1);
98
+        when(logService.getFailedLogs(any(), eq(3))).thenReturn(List.of(failed));
99
+
100
+        service.retryFailedNotifications();
101
+
102
+        assertEquals(2, failed.getRetryCount());
103
+        verify(self).dispatchAsync(failed);
104
+    }
105
+}