|
|
@@ -1,16 +1,27 @@
|
|
|
package com.train.service;
|
|
|
|
|
|
+import com.alibaba.fastjson.JSON;
|
|
|
+import com.alibaba.fastjson.JSONObject;
|
|
|
import lombok.extern.slf4j.Slf4j;
|
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
|
+import org.springframework.http.HttpEntity;
|
|
|
+import org.springframework.http.HttpHeaders;
|
|
|
+import org.springframework.http.MediaType;
|
|
|
+import org.springframework.http.ResponseEntity;
|
|
|
import org.springframework.stereotype.Service;
|
|
|
+import org.springframework.web.client.RestTemplate;
|
|
|
+import org.springframework.web.client.RestClientException;
|
|
|
|
|
|
+import javax.annotation.Resource;
|
|
|
import java.util.Map;
|
|
|
|
|
|
/**
|
|
|
- * 订阅消息服务(骨架,二期/三期完善)。
|
|
|
+ * 订阅消息服务。
|
|
|
* <p>触发点(设计文档 §4.7):报名成功、课前 T-7/T-3/T-1、成果被退回、投票结果揭晓、T+ 跟进提醒。
|
|
|
- * test-mode:直接打日志模拟发送,不依赖微信 access_token 与模板;生产接入后需维护
|
|
|
- * train_message_template 配置(模板 ID、跳转路径)。
|
|
|
+ * <p>test-mode:直接打日志模拟发送,不依赖微信 access_token 与模板;
|
|
|
+ * 生产:调用微信 subscribeMessage.send(需 access_token,见 WechatService.getAccessToken),
|
|
|
+ * 模板 ID 通过 {@code wechat.message-template.<key>} 配置(默认随 messageKey 变化)。
|
|
|
+ * <p>一次性模板每人每周至多 1 条/模板——发送方需在业务侧保证不触频控。
|
|
|
*/
|
|
|
@Slf4j
|
|
|
@Service
|
|
|
@@ -19,23 +30,104 @@ public class SubscribeMessageService {
|
|
|
@Value("${wechat.test-mode}")
|
|
|
private boolean testMode;
|
|
|
|
|
|
+ /** 微信订阅消息接口地址 */
|
|
|
+ @Value("${wechat.subscribe-url:https://api.weixin.qq.com/cgi-bin/message/subscribe/send}")
|
|
|
+ private String subscribeUrl;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private WechatService wechatService;
|
|
|
+
|
|
|
+ private final RestTemplate restTemplate = new RestTemplate();
|
|
|
+
|
|
|
/**
|
|
|
* 发送微信订阅消息(一次性模板)。
|
|
|
*
|
|
|
- * @param openid 接收者 openid
|
|
|
- * @param templateId 消息模板 ID(train_message_template 配置)
|
|
|
+ * @param openid 接收者 openid(必填,空则直接返回 false)
|
|
|
+ * @param templateId 消息模板 ID(train_message_template 配置;空则回退 {@code wechat.message-template.<messageKey>} 默认值)
|
|
|
* @param page 点击跳转的小程序页面路径
|
|
|
* @param data 模板字段 {字段名: {value: xx}}
|
|
|
- * @return 是否发送成功
|
|
|
+ * @param messageKey 消息语义键(audit_log.action 等);用于按配置取模板 ID
|
|
|
+ * @return 是否发送成功(test-mode 恒 true,且仅打日志)
|
|
|
*/
|
|
|
- public boolean sendSubscribeMessage(String openid, String templateId, String page, Map<String, Object> data) {
|
|
|
+ public boolean sendSubscribeMessage(String openid, String templateId, String page, Map<String, Object> data, String messageKey) {
|
|
|
+ if (openid == null || openid.trim().isEmpty()) {
|
|
|
+ log.debug("订阅消息跳过:openid 为空,templateId={}", templateId);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
if (testMode) {
|
|
|
log.info("【测试模式】模拟订阅消息发送: openid={}, templateId={}, page={}, data={}",
|
|
|
openid, templateId, page, data);
|
|
|
return true;
|
|
|
}
|
|
|
- // TODO 生产接入:调微信 subscribeMessage.send(需 access_token,见 WechatService.getAccessToken)
|
|
|
- log.warn("订阅消息生产发送待接入: openid={}, templateId={}, page={}", openid, templateId, page);
|
|
|
- return false;
|
|
|
+ try {
|
|
|
+ // 模板 ID 优先级:显式传入 > 按 messageKey 配置 > 空字符串(返回 false 不误报)
|
|
|
+ String realTemplateId = templateId;
|
|
|
+ if (!org.springframework.util.StringUtils.hasText(realTemplateId)) {
|
|
|
+ realTemplateId = messageKey != null
|
|
|
+ ? templateIdByKey(messageKey)
|
|
|
+ : "";
|
|
|
+ }
|
|
|
+ if (!org.springframework.util.StringUtils.hasText(realTemplateId)) {
|
|
|
+ log.warn("订阅消息未配置模板 ID,跳过发送: messageKey={}", messageKey);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ String accessToken = wechatService.getAccessToken();
|
|
|
+ String url = subscribeUrl + "?access_token=" + accessToken;
|
|
|
+
|
|
|
+ JSONObject body = new JSONObject();
|
|
|
+ body.put("touser", openid);
|
|
|
+ body.put("template_id", realTemplateId);
|
|
|
+ body.put("page", page == null ? "pages/mine/index" : page);
|
|
|
+ JSONObject dataJson = new JSONObject();
|
|
|
+ if (data != null) {
|
|
|
+ for (Map.Entry<String, Object> entry : data.entrySet()) {
|
|
|
+ JSONObject item = new JSONObject();
|
|
|
+ item.put("value", entry.getValue() == null ? "" : String.valueOf(entry.getValue()));
|
|
|
+ dataJson.put(entry.getKey(), item);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ body.put("data", dataJson);
|
|
|
+
|
|
|
+ HttpHeaders headers = new HttpHeaders();
|
|
|
+ headers.setContentType(MediaType.APPLICATION_JSON);
|
|
|
+ ResponseEntity<String> response = restTemplate.postForEntity(
|
|
|
+ url, new HttpEntity<>(body.toJSONString(), headers), String.class);
|
|
|
+ JSONObject json = JSON.parseObject(response.getBody());
|
|
|
+ int errcode = json.getIntValue("errcode");
|
|
|
+ if (errcode != 0) {
|
|
|
+ log.warn("订阅消息发送失败 errcode={} errmsg={} (openid={}, templateId={})",
|
|
|
+ errcode, json.getString("errmsg"), openid, realTemplateId);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+ } catch (RestClientException e) {
|
|
|
+ log.error("订阅消息发送异常 (openid={})", openid, e);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从配置读取 messageKey 对应的模板 ID(缺省为空字符串)。
|
|
|
+ * 配置形如:wechat.message-template.followup-t1: <templateId>
|
|
|
+ */
|
|
|
+ @Value("${wechat.message-template}")
|
|
|
+ private Map<String, String> messageTemplates;
|
|
|
+
|
|
|
+ private String templateIdByKey(String messageKey) {
|
|
|
+ if (messageTemplates == null) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ String templateId = messageTemplates.get(messageKey);
|
|
|
+ return templateId == null ? "" : templateId;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 按 openid + messageKey 直接发送(模板 ID 从配置取,page 默认「我的」)。
|
|
|
+ *
|
|
|
+ * @return 是否发送成功
|
|
|
+ */
|
|
|
+ public boolean sendToUser(String openid, String messageKey, String page, Map<String, Object> data) {
|
|
|
+ return sendSubscribeMessage(openid, null, page, data, messageKey);
|
|
|
}
|
|
|
}
|