Selaa lähdekoodia

feat(push): 新增推送服务及实现微信订阅消息发送功能

mcbaiyun 3 viikkoa sitten
vanhempi
commit
4147ee001e

+ 4 - 0
src/main/java/work/baiyun/chronicdiseaseapp/mapper/PatientReminderMapper.java

@@ -10,4 +10,8 @@ import org.apache.ibatis.annotations.Select;
 public interface PatientReminderMapper extends BaseMapper<PatientReminder> {
     @Select("SELECT * FROM t_patient_reminder WHERE patient_user_id = #{patientUserId}")
     PatientReminder selectByPatientUserId(@Param("patientUserId") Long patientUserId);
+
+    // Debug helper: return raw map of column values to inspect driver result types
+    @Select("SELECT * FROM t_patient_reminder WHERE patient_user_id = #{patientUserId}")
+    java.util.Map<String, Object> selectByPatientUserIdMap(@Param("patientUserId") Long patientUserId);
 }

+ 30 - 0
src/main/java/work/baiyun/chronicdiseaseapp/service/PushService.java

@@ -0,0 +1,30 @@
+package work.baiyun.chronicdiseaseapp.service;
+
+import work.baiyun.chronicdiseaseapp.model.po.MessagePO;
+
+import java.util.Map;
+
+/**
+ * 推送服务接口
+ * 负责处理各种推送通知的发送
+ */
+public interface PushService {
+
+    /**
+     * 发送微信订阅消息
+     *
+     * @param userId 用户ID
+     * @param message 消息对象
+     * @return 发送结果
+     */
+    Map<String, Object> sendSubscribeMessage(Long userId, MessagePO message);
+
+    /**
+     * 发送弹窗通知(预留接口,后续可集成其他推送服务)
+     *
+     * @param userId 用户ID
+     * @param message 消息对象
+     * @return 发送结果
+     */
+    boolean sendPopupNotification(Long userId, MessagePO message);
+}

+ 6 - 9
src/main/java/work/baiyun/chronicdiseaseapp/service/impl/MessageServiceImpl.java

@@ -19,8 +19,8 @@ import work.baiyun.chronicdiseaseapp.model.vo.SendMessageRequest;
 import work.baiyun.chronicdiseaseapp.model.vo.SystemAnomalySendRequest;
 import work.baiyun.chronicdiseaseapp.model.vo.SystemDailySendRequest;
 import work.baiyun.chronicdiseaseapp.service.MessageService;
+import work.baiyun.chronicdiseaseapp.service.PushService;
 import work.baiyun.chronicdiseaseapp.service.UserBindingService;
-import work.baiyun.chronicdiseaseapp.enums.MessageType;
 import work.baiyun.chronicdiseaseapp.util.SecurityUtils;
 
 import java.time.LocalDateTime;
@@ -40,9 +40,8 @@ public class MessageServiceImpl implements MessageService {
     @Autowired
     private UserBindingService userBindingService;
 
-    // TODO: 注入推送服务,如果有的话
-    // @Autowired
-    // private PushService pushService;
+    @Autowired
+    private PushService pushService;
 
     @Override
     public String sendMessage(SendMessageRequest request) {
@@ -64,8 +63,7 @@ public class MessageServiceImpl implements MessageService {
 
             // 推送通知
             if (message.getNotifySubscribe() == 1) {
-                // TODO: 调用推送服务
-                // pushService.sendSubscribeMessage(receiverId, message);
+                pushService.sendSubscribeMessage(receiverId, message);
             }
 
             logger.info("[MessageOperation] userId={}, action=send, messageId={}", currentUserId, message.getId());
@@ -86,8 +84,7 @@ public class MessageServiceImpl implements MessageService {
 
                     // 推送通知给家属
                     if (familyMessage.getNotifySubscribe() == 1) {
-                        // TODO: 调用推送服务
-                        // pushService.sendSubscribeMessage(familyId, familyMessage);
+                        pushService.sendSubscribeMessage(familyId, familyMessage);
                     }
 
                     logger.info("[MessageOperation] userId={}, action=send_to_family, patientId={}, familyId={}, messageId={}",
@@ -134,7 +131,7 @@ public class MessageServiceImpl implements MessageService {
         messageMapper.insert(message);
 
         // 推送
-        // TODO: pushService.sendSubscribeMessage(request.getPatientId(), message);
+        pushService.sendSubscribeMessage(request.getPatientId(), message);
 
         logger.info("[MessageOperation] action=system_anomaly_send, messageId={}", message.getId());
         return message.getId().toString();

+ 146 - 0
src/main/java/work/baiyun/chronicdiseaseapp/service/impl/PushServiceImpl.java

@@ -0,0 +1,146 @@
+package work.baiyun.chronicdiseaseapp.service.impl;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import work.baiyun.chronicdiseaseapp.config.WeChatProperties;
+import work.baiyun.chronicdiseaseapp.mapper.UserInfoMapper;
+import work.baiyun.chronicdiseaseapp.model.po.MessagePO;
+import work.baiyun.chronicdiseaseapp.model.po.UserInfo;
+import work.baiyun.chronicdiseaseapp.service.PushService;
+import work.baiyun.chronicdiseaseapp.util.WeChatAccessTokenManager;
+import work.baiyun.chronicdiseaseapp.util.WeChatSubscribeMessageSender;
+
+import java.time.format.DateTimeFormatter;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 推送服务实现类
+ */
+@Service
+public class PushServiceImpl implements PushService {
+
+    private static final Logger logger = LoggerFactory.getLogger(PushServiceImpl.class);
+
+    @Autowired
+    private WeChatProperties weChatProperties;
+
+    @Autowired
+    private UserInfoMapper userInfoMapper;
+
+    private WeChatAccessTokenManager accessTokenManager;
+    private WeChatSubscribeMessageSender subscribeMessageSender;
+
+    /**
+     * 懒加载初始化微信工具类
+     */
+    private void initWeChatTools() {
+        if (accessTokenManager == null) {
+            accessTokenManager = new WeChatAccessTokenManager(
+                weChatProperties.getAppid(),
+                weChatProperties.getSecret()
+            );
+        }
+        if (subscribeMessageSender == null) {
+            subscribeMessageSender = new WeChatSubscribeMessageSender(accessTokenManager);
+        }
+    }
+
+    @Override
+    public Map<String, Object> sendSubscribeMessage(Long userId, MessagePO message) {
+        try {
+            initWeChatTools();
+
+            // 获取用户信息
+            UserInfo user = userInfoMapper.selectById(userId);
+            if (user == null || user.getWxOpenid() == null || user.getWxOpenid().trim().isEmpty()) {
+                logger.warn("用户 {} 没有有效的 openid,跳过订阅消息发送", userId);
+                return Map.of("errcode", -1, "errmsg", "用户没有有效的openid");
+            }
+
+            // 构造订阅消息负载
+            Map<String, Object> payload = buildSubscribeMessagePayload(user.getWxOpenid(), message);
+
+            // 发送消息
+            Map<String, Object> result = subscribeMessageSender.send(payload);
+
+            if (result.containsKey("errcode") && result.get("errcode").equals(0)) {
+                logger.info("成功发送订阅消息给用户 {},消息ID: {}", userId, message.getId());
+            } else {
+                logger.error("发送订阅消息失败,用户: {},消息ID: {},错误: {}", userId, message.getId(), result);
+            }
+
+            return result;
+
+        } catch (Exception e) {
+            logger.error("发送订阅消息异常,用户: {},消息ID: {}", userId, message.getId(), e);
+            return Map.of("errcode", -1, "errmsg", e.getMessage());
+        }
+    }
+
+    @Override
+    public boolean sendPopupNotification(Long userId, MessagePO message) {
+        // 弹窗通知暂时通过前端实现,后续可集成其他推送服务
+        logger.info("弹窗通知暂不支持服务端推送,用户: {},消息ID: {}", userId, message.getId());
+        return true;
+    }
+
+    /**
+     * 构建订阅消息负载
+     */
+    private Map<String, Object> buildSubscribeMessagePayload(String openid, MessagePO message) {
+        Map<String, Object> payload = new HashMap<>();
+
+        // 基础信息
+        payload.put("touser", openid);
+        payload.put("template_id", "ACS7cwcbx0F0Y_YaB4GZr7rWP7BO2-7wQOtYsnUjmFI"); // 模板ID
+        payload.put("page", "pages/public/message-detail"); // 点击跳转页面
+        payload.put("miniprogram_state", "developer"); // 开发环境
+        payload.put("lang", "zh_CN");
+
+        // 消息数据
+        Map<String, Object> data = new HashMap<>();
+
+        // 根据消息类型设置不同的内容
+        String messageType = getMessageTypeText(message.getType().name());
+        String date = message.getCreateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
+        String content = message.getContent();
+
+        // 模板字段:phrase2(类型)、date3(日期)、thing4(内容)
+        data.put("phrase2", Map.of("value", messageType));
+        data.put("date3", Map.of("value", date));
+        data.put("thing4", Map.of("value", truncateContent(content, 20))); // thing4最多20个字符
+
+        payload.put("data", data);
+
+        return payload;
+    }
+
+    /**
+     * 获取消息类型文本
+     */
+    private String getMessageTypeText(String type) {
+        switch (type) {
+            case "DOCTOR":
+                return "医生消息";
+            case "SYSTEM_DAILY":
+                return "日常通知";
+            case "SYSTEM_ANOMALY":
+                return "异常提醒";
+            default:
+                return "消息通知";
+        }
+    }
+
+    /**
+     * 截断内容到指定长度
+     */
+    private String truncateContent(String content, int maxLength) {
+        if (content == null) {
+            return "";
+        }
+        return content.length() > maxLength ? content.substring(0, maxLength) : content;
+    }
+}

+ 75 - 0
src/main/java/work/baiyun/chronicdiseaseapp/util/WeChatAccessTokenManager.java

@@ -0,0 +1,75 @@
+package work.baiyun.chronicdiseaseapp.util;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.client.RestTemplate;
+
+/**
+ * 微信 Access Token 管理器
+ * 负责获取和管理微信 access_token
+ */
+public class WeChatAccessTokenManager {
+
+    private static final Logger logger = LoggerFactory.getLogger(WeChatAccessTokenManager.class);
+
+    private final String appid;
+    private final String secret;
+    private final RestTemplate restTemplate;
+    private final ObjectMapper objectMapper;
+
+    private String accessToken;
+    private long expireTime;
+
+    public WeChatAccessTokenManager(String appid, String secret) {
+        this.appid = appid;
+        this.secret = secret;
+        this.restTemplate = new RestTemplate();
+        this.objectMapper = new ObjectMapper();
+    }
+
+    /**
+     * 获取有效的 access_token
+     * @return access_token
+     */
+    public synchronized String getAccessToken() {
+        // 如果token不存在或即将过期,重新获取
+        if (accessToken == null || System.currentTimeMillis() >= expireTime - 60000) { // 提前1分钟刷新
+            refreshAccessToken();
+        }
+        return accessToken;
+    }
+
+    /**
+     * 刷新 access_token
+     */
+    private void refreshAccessToken() {
+        try {
+            String url = String.format(
+                "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s",
+                appid, secret
+            );
+
+            logger.info("正在获取微信 access_token...");
+            ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
+            response.getStatusCode().is2xxSuccessful();
+
+            JsonNode jsonNode = objectMapper.readTree(response.getBody());
+            if (jsonNode.has("access_token")) {
+                this.accessToken = jsonNode.get("access_token").asText();
+                int expiresIn = jsonNode.get("expires_in").asInt();
+                this.expireTime = System.currentTimeMillis() + (expiresIn * 1000L);
+                logger.info("成功获取微信 access_token,有效期{}秒", expiresIn);
+            } else {
+                String errorMsg = jsonNode.has("errmsg") ? jsonNode.get("errmsg").asText() : "Unknown error";
+                throw new RuntimeException("获取 access_token 失败: " + errorMsg);
+            }
+
+        } catch (Exception e) {
+            logger.error("获取微信 access_token 异常", e);
+            throw new RuntimeException("请求 access_token 异常: " + e.getMessage());
+        }
+    }
+}

+ 88 - 0
src/main/java/work/baiyun/chronicdiseaseapp/util/WeChatSubscribeMessageSender.java

@@ -0,0 +1,88 @@
+package work.baiyun.chronicdiseaseapp.util;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.Map;
+
+/**
+ * 微信订阅消息发送器
+ */
+public class WeChatSubscribeMessageSender {
+
+    private static final Logger logger = LoggerFactory.getLogger(WeChatSubscribeMessageSender.class);
+
+    private final WeChatAccessTokenManager accessTokenManager;
+    private final RestTemplate restTemplate;
+    private final ObjectMapper objectMapper;
+    private final String sendUrl = "https://api.weixin.qq.com/cgi-bin/message/subscribe/send";
+
+    public WeChatSubscribeMessageSender(WeChatAccessTokenManager accessTokenManager) {
+        this.accessTokenManager = accessTokenManager;
+        this.restTemplate = new RestTemplate();
+        this.objectMapper = new ObjectMapper();
+    }
+
+    /**
+     * 发送订阅消息
+     *
+     * @param payload 消息负载,必须包含 touser, template_id 等必要字段
+     * @return 微信接口返回的响应数据
+     */
+    public Map<String, Object> send(Map<String, Object> payload) {
+        if (payload == null || payload.isEmpty()) {
+            throw new IllegalArgumentException("payload 不能为空");
+        }
+
+        // 第一次尝试
+        String accessToken = accessTokenManager.getAccessToken();
+        Map<String, Object> result = doSend(payload, accessToken);
+
+        // 如果 token 失效,尝试刷新后重试
+        if (result.containsKey("errcode") &&
+            (result.get("errcode").equals(40001) ||
+             result.get("errcode").equals(40014) ||
+             result.get("errcode").equals(42001))) {
+            logger.info("检测到 access_token 可能失效,尝试刷新后重试");
+            accessToken = accessTokenManager.getAccessToken();
+            result = doSend(payload, accessToken);
+        }
+
+        return result;
+    }
+
+    /**
+     * 执行实际的发送请求
+     */
+    private Map<String, Object> doSend(Map<String, Object> payload, String accessToken) {
+        String url = sendUrl + "?access_token=" + accessToken;
+
+        logger.info("发送微信订阅消息: {}", url);
+        logger.debug("请求数据: {}", payload);
+
+        try {
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+
+            HttpEntity<Map<String, Object>> requestEntity = new HttpEntity<>(payload, headers);
+            ResponseEntity<String> response = restTemplate.postForEntity(url, requestEntity, String.class);
+
+            JsonNode jsonNode = objectMapper.readTree(response.getBody());
+            Map<String, Object> result = objectMapper.convertValue(jsonNode, Map.class);
+
+            logger.debug("响应数据: {}", result);
+            return result;
+
+        } catch (Exception e) {
+            logger.error("发送微信订阅消息请求异常", e);
+            return Map.of("errcode", -1, "errmsg", e.getMessage());
+        }
+    }
+}