Bläddra i källkod

完善: 订阅消息真实推送(课前+课后定时任务)与海报小程序码接入,补uploads静态映射

liaoxg 1 vecka sedan
förälder
incheckning
a2046b3ff6

+ 16 - 0
train-backend/src/main/java/com/train/config/WebConfig.java

@@ -1,11 +1,14 @@
 package com.train.config;
 package com.train.config;
 
 
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.web.servlet.config.annotation.CorsRegistry;
 import org.springframework.web.servlet.config.annotation.CorsRegistry;
 import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
 import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
 import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
 import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
+import java.io.File;
 
 
 @Configuration
 @Configuration
 public class WebConfig implements WebMvcConfigurer {
 public class WebConfig implements WebMvcConfigurer {
@@ -13,6 +16,9 @@ public class WebConfig implements WebMvcConfigurer {
     @Resource
     @Resource
     private JwtInterceptor jwtInterceptor;
     private JwtInterceptor jwtInterceptor;
 
 
+    @Value("${upload.base-dir}")
+    private String baseDir;
+
     @Override
     @Override
     public void addInterceptors(InterceptorRegistry registry) {
     public void addInterceptors(InterceptorRegistry registry) {
         registry.addInterceptor(jwtInterceptor)
         registry.addInterceptor(jwtInterceptor)
@@ -28,4 +34,14 @@ public class WebConfig implements WebMvcConfigurer {
                 .allowCredentials(true)
                 .allowCredentials(true)
                 .maxAge(3600);
                 .maxAge(3600);
     }
     }
+
+    /**
+     * 静态资源映射:/uploads/** → 本地上传目录(上传文件、海报小程序码、资料附件等)。
+     * 走系统默认静态资源处理器(不经 JWT 拦截器,路径为 /uploads 而非 /api)。
+     */
+    @Override
+    public void addResourceHandlers(ResourceHandlerRegistry registry) {
+        String location = "file:" + new File(baseDir).getAbsolutePath() + File.separator;
+        registry.addResourceHandler("/uploads/**").addResourceLocations(location);
+    }
 }
 }

+ 73 - 3
train-backend/src/main/java/com/train/controller/InviteController.java

@@ -1,5 +1,6 @@
 package com.train.controller;
 package com.train.controller;
 
 
+import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.train.cfc.entity.CfcUser;
 import com.train.cfc.entity.CfcUser;
 import com.train.cfc.mapper.CfcUserMapper;
 import com.train.cfc.mapper.CfcUserMapper;
@@ -8,15 +9,25 @@ import com.train.entity.TrainInvite;
 import com.train.entity.TrainUser;
 import com.train.entity.TrainUser;
 import com.train.mapper.TrainInviteMapper;
 import com.train.mapper.TrainInviteMapper;
 import com.train.mapper.TrainUserMapper;
 import com.train.mapper.TrainUserMapper;
+import com.train.service.WechatService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import io.swagger.v3.oas.annotations.tags.Tag;
+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.util.StringUtils;
 import org.springframework.util.StringUtils;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestAttribute;
 import org.springframework.web.bind.annotation.RequestAttribute;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.client.RestTemplate;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
+import java.io.File;
+import java.io.FileOutputStream;
 import java.util.HashMap;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Map;
 import java.util.UUID;
 import java.util.UUID;
@@ -24,12 +35,13 @@ import java.util.UUID;
 /**
 /**
  * 裂变分享:专属海报/邀请码、我的转介绍统计。
  * 裂变分享:专属海报/邀请码、我的转介绍统计。
  * <p>邀请码复用 train_user.invite_code;未设置时按需生成(折校:可进校友校验证写入)。
  * <p>邀请码复用 train_user.invite_code;未设置时按需生成(折校:可进校友校验证写入)。
- * 海报 URL 为占位(test-mode 返回空,前端用邀请码本地渲染海报);
- * 生产环境接入微信 getwxacodeunlimit 生成小程序码后回填
+ * 海报小程序码:test-mode 返回空(前端用邀请码本地渲染海报);
+ * 生产环境调用微信 getwxacodeunlimit 生成带参小程序码,缓存到本地上传目录并回填 posterUrl
  * 同时返回 sharePath(小程序带参路径)/ shareText(分享文案),便于前端拼装分享卡。
  * 同时返回 sharePath(小程序带参路径)/ shareText(分享文案),便于前端拼装分享卡。
  */
  */
 @Tag(name = "裂变分享", description = "专属海报/邀请码、转介绍统计")
 @Tag(name = "裂变分享", description = "专属海报/邀请码、转介绍统计")
 @RestController
 @RestController
+@Slf4j
 @RequestMapping("/api/invite")
 @RequestMapping("/api/invite")
 public class InviteController {
 public class InviteController {
 
 
@@ -39,6 +51,19 @@ public class InviteController {
     private TrainInviteMapper trainInviteMapper;
     private TrainInviteMapper trainInviteMapper;
     @Resource
     @Resource
     private CfcUserMapper cfcUserMapper;
     private CfcUserMapper cfcUserMapper;
+    @Resource
+    private WechatService wechatService;
+
+    @Value("${wechat.test-mode}")
+    private boolean testMode;
+
+    @Value("${upload.base-dir}")
+    private String baseDir;
+
+    @Value("${wechat.qrcode-url:https://api.weixin.qq.com/wxa/getwxacodeunlimit}")
+    private String qrcodeUrl;
+
+    private final RestTemplate restTemplate = new RestTemplate();
 
 
     /**
     /**
      * 我的专属海报 + 邀请码 + 分享文案。
      * 我的专属海报 + 邀请码 + 分享文案。
@@ -75,12 +100,57 @@ public class InviteController {
                 sharePath;
                 sharePath;
         Map<String, Object> row = new HashMap<>();
         Map<String, Object> row = new HashMap<>();
         row.put("inviteCode", inviteCode);
         row.put("inviteCode", inviteCode);
-        row.put("posterUrl", ""); // 占位:test-mode 不生成海报图;生产接入 getwxacodeunlimit
+        row.put("posterUrl", generatePosterCode(inviteCode)); // test-mode 返回空串(前端本地渲染)
         row.put("sharePath", sharePath);
         row.put("sharePath", sharePath);
         row.put("shareText", shareText);
         row.put("shareText", shareText);
         return Result.success(row);
         return Result.success(row);
     }
     }
 
 
+    /**
+     * 生成带参小程序码(getwxacodeunlimit)。
+     * scene 传邀请码(最长 32 字符),page 固定报名列表页;
+     * 图片字节缓存到本地上传目录,返回可访问 URL。test-mode 或失败返回空串(前端降级本地海报)。
+     */
+    private String generatePosterCode(String inviteCode) {
+        if (testMode) {
+            return "";
+        }
+        try {
+            String accessToken = wechatService.getAccessToken();
+            JSONObject body = new JSONObject();
+            body.put("scene", inviteCode);
+            body.put("page", "pages/enroll/list");
+            body.put("check_path", false);
+            body.put("width", 430);
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+            ResponseEntity<byte[]> response = restTemplate.postForEntity(
+                    qrcodeUrl + "?access_token=" + accessToken,
+                    new HttpEntity<>(body.toJSONString(), headers),
+                    byte[].class);
+            byte[] bytes = response.getBody();
+            if (bytes == null || bytes.length == 0) {
+                log.warn("getwxacodeunlimit 返回空字节,inviteCode={}", inviteCode);
+                return "";
+            }
+            File dir = new File(baseDir);
+            if (!dir.exists() && !dir.mkdirs()) {
+                log.error("海报目录创建失败: {}", baseDir);
+                return "";
+            }
+            String filename = "invite_" + inviteCode + "_" + UUID.randomUUID().toString().substring(0, 8) + ".png";
+            File dest = new File(dir, filename);
+            try (FileOutputStream fos = new FileOutputStream(dest)) {
+                fos.write(bytes);
+            }
+            return "/uploads/" + filename;
+        } catch (Exception e) {
+            // 微信接口失败/网络异常:降级为前端本地海报,不阻塞分享
+            log.error("生成小程序码失败,降级本地海报 inviteCode={}", inviteCode, e);
+            return "";
+        }
+    }
+
     /**
     /**
      * 我的转介绍统计。
      * 我的转介绍统计。
      * @return {invited, paid, rewards}
      * @return {invited, paid, rewards}

+ 37 - 2
train-backend/src/main/java/com/train/service/FollowUpTask.java

@@ -3,20 +3,24 @@ package com.train.service;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.train.entity.TrainAuditLog;
 import com.train.entity.TrainAuditLog;
 import com.train.entity.TrainPlan;
 import com.train.entity.TrainPlan;
+import com.train.entity.TrainUser;
 import com.train.mapper.TrainAuditLogMapper;
 import com.train.mapper.TrainAuditLogMapper;
 import com.train.mapper.TrainPlanMapper;
 import com.train.mapper.TrainPlanMapper;
+import com.train.mapper.TrainUserMapper;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 import org.springframework.stereotype.Component;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.Map;
 import java.util.Date;
 import java.util.Date;
 import java.util.List;
 import java.util.List;
 
 
 /**
 /**
  * T+ 跟进定时任务:每日定时扫描已提交 7 天行动计划的学员,
  * T+ 跟进定时任务:每日定时扫描已提交 7 天行动计划的学员,
  * 相对计划提交日(T0)生成 T+1 / T+3 / T+7 提醒记录,落 audit_log(幂等)。
  * 相对计划提交日(T0)生成 T+1 / T+3 / T+7 提醒记录,落 audit_log(幂等)。
- * 骨架版本以落账为主,生产可在此处改为推送订阅消息(复用 SubscribeMessageService)。
+ * 落账成功后同步推送订阅消息(按 openid 发送;test-mode 打日志)。
  */
  */
 @Slf4j
 @Slf4j
 @Component
 @Component
@@ -26,9 +30,24 @@ public class FollowUpTask {
     private TrainPlanMapper trainPlanMapper;
     private TrainPlanMapper trainPlanMapper;
     @Resource
     @Resource
     private TrainAuditLogMapper trainAuditLogMapper;
     private TrainAuditLogMapper trainAuditLogMapper;
+    @Resource
+    private TrainUserMapper trainUserMapper;
+    @Resource
+    private SubscribeMessageService subscribeMessageService;
 
 
     private static final long DAY_MILLIS = 24L * 60 * 60 * 1000;
     private static final long DAY_MILLIS = 24L * 60 * 60 * 1000;
 
 
+    /** 各跟进里程碑的订阅消息 key(对应 wechat.message-template 配置)与跳转页 */
+    private static final Map<String, String[]> MILESTONE_MESSAGE = new HashMap<>();
+
+    static {
+        MILESTONE_MESSAGE.put("followup_t1", new String[]{"followup-t1", "pages/mine/index"});
+        MILESTONE_MESSAGE.put("followup_t3", new String[]{"followup-t3", "pages/mine/index"});
+        MILESTONE_MESSAGE.put("followup_t7", new String[]{"followup-t7", "pages/mine/index"});
+        MILESTONE_MESSAGE.put("followup_t21", new String[]{"followup-t21", "pages/mine/index"});
+        MILESTONE_MESSAGE.put("followup_t30", new String[]{"followup-t30", "pages/mine/index"});
+    }
+
     /** 每日 09:30 执行(课后跟进节奏:T+1 成果合集 / T+3 补齐 / T+7 回访) */
     /** 每日 09:30 执行(课后跟进节奏:T+1 成果合集 / T+3 补齐 / T+7 回访) */
     @Scheduled(cron = "0 30 9 * * ?")
     @Scheduled(cron = "0 30 9 * * ?")
     public void runFollowUp() {
     public void runFollowUp() {
@@ -59,7 +78,7 @@ public class FollowUpTask {
         log.info("FollowUpTask 完成一轮 T+ 跟进扫描,共 {} 条计划", plans.size());
         log.info("FollowUpTask 完成一轮 T+ 跟进扫描,共 {} 条计划", plans.size());
     }
     }
 
 
-    /** 幂等写入 audit_log:同 action + targetId 已存在则跳过 */
+    /** 幂等写入 audit_log:同 action + targetId 已存在则跳过;落账成功后推送订阅消息 */
     private void ensureLog(Long uid, String action, String detail) {
     private void ensureLog(Long uid, String action, String detail) {
         Long exists = trainAuditLogMapper.selectCount(
         Long exists = trainAuditLogMapper.selectCount(
                 new LambdaQueryWrapper<TrainAuditLog>()
                 new LambdaQueryWrapper<TrainAuditLog>()
@@ -76,5 +95,21 @@ public class FollowUpTask {
         entry.setDetail(detail);
         entry.setDetail(detail);
         entry.setTs(new Date());
         entry.setTs(new Date());
         trainAuditLogMapper.insert(entry);
         trainAuditLogMapper.insert(entry);
+        pushMessage(uid, action, detail);
+    }
+
+    /** 用 openid 推送订阅消息(无 openid/未配置模板时静默跳过) */
+    private void pushMessage(Long uid, String action, String detail) {
+        TrainUser user = trainUserMapper.selectById(uid);
+        if (user == null || user.getOpenid() == null || user.getOpenid().isEmpty()) {
+            return;
+        }
+        String[] msg = MILESTONE_MESSAGE.get(action);
+        if (msg == null) {
+            return;
+        }
+        Map<String, Object> data = new HashMap<>();
+        data.put("thing1", detail);
+        subscribeMessageService.sendToUser(user.getOpenid(), msg[0], msg[1], data);
     }
     }
 }
 }

+ 32 - 3
train-backend/src/main/java/com/train/service/PreClassReminderTask.java

@@ -12,13 +12,15 @@ import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 import org.springframework.stereotype.Component;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.Map;
 import java.util.Date;
 import java.util.Date;
 import java.util.List;
 import java.util.List;
 
 
 /**
 /**
  * 课前提醒定时任务(FR-PRE-02):扫描已设置 startAt 的 active 班次,
  * 课前提醒定时任务(FR-PRE-02):扫描已设置 startAt 的 active 班次,
- * 对已进班的学员在 T-7 / T-3 / T-1 触发提醒,落 audit_log(幂等)
- * test-mode:仅打日志;生产可在此处调用 SubscribeMessageService.sendToUser 推送订阅消息
+ * 对已进班的学员在 T-7 / T-3 / T-1 触发提醒,落 audit_log(幂等)
+ * 落账成功后同步推送订阅消息(按 openid 发送;test-mode 打日志)
  *
  *
  * 触发点判定:班次 startAt 为基准,相对天数落入窗口且当天未触发过则落账一次。
  * 触发点判定:班次 startAt 为基准,相对天数落入窗口且当天未触发过则落账一次。
  */
  */
@@ -32,9 +34,20 @@ public class PreClassReminderTask {
     private TrainUserMapper trainUserMapper;
     private TrainUserMapper trainUserMapper;
     @Resource
     @Resource
     private TrainAuditLogMapper trainAuditLogMapper;
     private TrainAuditLogMapper trainAuditLogMapper;
+    @Resource
+    private SubscribeMessageService subscribeMessageService;
 
 
     private static final long DAY_MILLIS = 24L * 60 * 60 * 1000;
     private static final long DAY_MILLIS = 24L * 60 * 60 * 1000;
 
 
+    /** 各课前提醒里程碑的订阅消息 key(对应 wechat.message-template 配置)与跳转页 */
+    private static final Map<String, String[]> MILESTONE_MESSAGE = new HashMap<>();
+
+    static {
+        MILESTONE_MESSAGE.put("preclass_t7", new String[]{"preclass-t7", "pages/mine/index"});
+        MILESTONE_MESSAGE.put("preclass_t3", new String[]{"preclass-t3", "pages/mine/index"});
+        MILESTONE_MESSAGE.put("preclass_t1", new String[]{"preclass-t1", "pages/mine/index"});
+    }
+
     /** 每日 10:00 执行(错开 FollowUpTask 的 09:30) */
     /** 每日 10:00 执行(错开 FollowUpTask 的 09:30) */
     @Scheduled(cron = "0 0 10 * * ?")
     @Scheduled(cron = "0 0 10 * * ?")
     public void runPreClassReminder() {
     public void runPreClassReminder() {
@@ -77,7 +90,7 @@ public class PreClassReminderTask {
         }
         }
     }
     }
 
 
-    /** 幂等:同 action + targetId 同日已存在则跳过;返回 true 表示本次落账 */
+    /** 幂等:同 action + targetId 同日已存在则跳过;返回 true 表示本次落账且已推送订阅消息 */
     private boolean ensureLog(Long uid, String action, String detail) {
     private boolean ensureLog(Long uid, String action, String detail) {
         Long exists = trainAuditLogMapper.selectCount(
         Long exists = trainAuditLogMapper.selectCount(
                 new LambdaQueryWrapper<TrainAuditLog>()
                 new LambdaQueryWrapper<TrainAuditLog>()
@@ -94,6 +107,22 @@ public class PreClassReminderTask {
         entry.setDetail(detail);
         entry.setDetail(detail);
         entry.setTs(new Date());
         entry.setTs(new Date());
         trainAuditLogMapper.insert(entry);
         trainAuditLogMapper.insert(entry);
+        pushMessage(uid, action, detail);
         return true;
         return true;
     }
     }
+
+    /** 用 openid 推送订阅消息(无 openid/未配置模板时静默跳过) */
+    private void pushMessage(Long uid, String action, String detail) {
+        TrainUser user = trainUserMapper.selectById(uid);
+        if (user == null || user.getOpenid() == null || user.getOpenid().isEmpty()) {
+            return;
+        }
+        String[] msg = MILESTONE_MESSAGE.get(action);
+        if (msg == null) {
+            return;
+        }
+        Map<String, Object> data = new HashMap<>();
+        data.put("thing1", detail);
+        subscribeMessageService.sendToUser(user.getOpenid(), msg[0], msg[1], data);
+    }
 }
 }

+ 102 - 10
train-backend/src/main/java/com/train/service/SubscribeMessageService.java

@@ -1,16 +1,27 @@
 package com.train.service;
 package com.train.service;
 
 
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
 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.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.client.RestClientException;
 
 
+import javax.annotation.Resource;
 import java.util.Map;
 import java.util.Map;
 
 
 /**
 /**
- * 订阅消息服务(骨架,二期/三期完善)
+ * 订阅消息服务。
  * <p>触发点(设计文档 §4.7):报名成功、课前 T-7/T-3/T-1、成果被退回、投票结果揭晓、T+ 跟进提醒。
  * <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
 @Slf4j
 @Service
 @Service
@@ -19,23 +30,104 @@ public class SubscribeMessageService {
     @Value("${wechat.test-mode}")
     @Value("${wechat.test-mode}")
     private boolean testMode;
     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 page       点击跳转的小程序页面路径
      * @param data       模板字段 {字段名: {value: xx}}
      * @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) {
         if (testMode) {
             log.info("【测试模式】模拟订阅消息发送: openid={}, templateId={}, page={}, data={}",
             log.info("【测试模式】模拟订阅消息发送: openid={}, templateId={}, page={}, data={}",
                     openid, templateId, page, data);
                     openid, templateId, page, data);
             return true;
             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);
     }
     }
 }
 }

+ 2 - 1
train-backend/src/main/java/com/train/service/WechatService.java

@@ -114,7 +114,8 @@ public class WechatService {
         }
         }
     }
     }
 
 
-    private String getAccessToken() {
+    /** 获取 access_token(带缓存;供其他服务复用,如订阅消息发送) */
+    public String getAccessToken() {
         if (!testMode && cachedAccessToken != null && System.currentTimeMillis() < tokenExpireAt) {
         if (!testMode && cachedAccessToken != null && System.currentTimeMillis() < tokenExpireAt) {
             return cachedAccessToken;
             return cachedAccessToken;
         }
         }

+ 13 - 0
train-backend/src/main/resources/application.yml

@@ -49,6 +49,19 @@ wechat:
   login-url: https://api.weixin.qq.com/sns/jscode2session
   login-url: https://api.weixin.qq.com/sns/jscode2session
   test-mode: true  # 测试模式:跳过微信 API 调用,使用模拟 openid
   test-mode: true  # 测试模式:跳过微信 API 调用,使用模拟 openid
   test-phone: "13800138000"  # 测试模式下 getPhoneNumber 的模拟手机号
   test-phone: "13800138000"  # 测试模式下 getPhoneNumber 的模拟手机号
+  subscribe-url: https://api.weixin.qq.com/cgi-bin/message/subscribe/send
+  qrcode-url: https://api.weixin.qq.com/wxa/getwxacodeunlimit
+  # 订阅消息模板 ID(生产填写:微信公众平台「订阅消息」申请后回填)
+  # key 与定时任务 action 对应:followup_t1..30 / preclass_t7..t1
+  message-template:
+    followup-t1: ""
+    followup-t3: ""
+    followup-t7: ""
+    followup-t21: ""
+    followup-t30: ""
+    preclass-t7: ""
+    preclass-t3: ""
+    preclass-t1: ""
 
 
 upload:
 upload:
   base-dir: ./uploads
   base-dir: ./uploads

+ 39 - 1
train-frontend/pages/share/index.vue

@@ -1,6 +1,11 @@
 <template>
 <template>
   <view class="share-page">
   <view class="share-page">
-    <view class="poster-card">
+    <!-- 生产:后端已生成带参小程序码海报,直接展示图片 -->
+    <view class="poster-card" v-if="posterUrl">
+      <image class="poster-image" :src="posterUrl" mode="widthFix"></image>
+    </view>
+    <!-- test-mode/降级:本地 canvas 渲染带邀请码海报 -->
+    <view class="poster-card" v-else>
       <canvas canvas-id="posterCanvas" id="posterCanvas" class="poster-canvas"></canvas>
       <canvas canvas-id="posterCanvas" id="posterCanvas" class="poster-canvas"></canvas>
     </view>
     </view>
 
 
@@ -149,6 +154,38 @@ export default {
       var self = this
       var self = this
       if (this.saving) return
       if (this.saving) return
       this.saving = true
       this.saving = true
+      // 后端已生成海报:保存远程图片到相册
+      if (this.posterUrl) {
+        uni.downloadFile({
+          url: this.posterUrl,
+          success: function(res) {
+            if (res.statusCode !== 200) {
+              self.saving = false
+              uni.showToast({ title: '海报下载失败', icon: 'none' })
+              return
+            }
+            uni.saveImageToPhotosAlbum({
+              filePath: res.tempFilePath,
+              success: function() {
+                uni.showToast({ title: '海报已保存到相册', icon: 'success' })
+              },
+              fail: function() {
+                self.saving = false
+                uni.showToast({ title: '保存失败,请检查相册权限', icon: 'none' })
+              },
+              complete: function() {
+                self.saving = false
+              }
+            })
+          },
+          fail: function() {
+            self.saving = false
+            uni.showToast({ title: '海报下载失败', icon: 'none' })
+          }
+        })
+        return
+      }
+      // 本地 canvas 渲染海报
       uni.canvasToTempFilePath({
       uni.canvasToTempFilePath({
         canvasId: 'posterCanvas',
         canvasId: 'posterCanvas',
         success: function(res) {
         success: function(res) {
@@ -195,6 +232,7 @@ export default {
 .share-page { min-height: 100vh; background: #F5F5F5; padding: 24rpx 32rpx; }
 .share-page { min-height: 100vh; background: #F5F5F5; padding: 24rpx 32rpx; }
 .poster-card { background: #FFF; border-radius: 16rpx; padding: 24rpx; margin-bottom: 24rpx; display: flex; justify-content: center; }
 .poster-card { background: #FFF; border-radius: 16rpx; padding: 24rpx; margin-bottom: 24rpx; display: flex; justify-content: center; }
 .poster-canvas { width: 300px; height: 450px; border-radius: 12rpx; }
 .poster-canvas { width: 300px; height: 450px; border-radius: 12rpx; }
+.poster-image { width: 100%; border-radius: 12rpx; }
 .invite-row { background: #FFF; border-radius: 16rpx; padding: 32rpx; margin-bottom: 24rpx; display: flex; align-items: center; }
 .invite-row { background: #FFF; border-radius: 16rpx; padding: 32rpx; margin-bottom: 24rpx; display: flex; align-items: center; }
 .share-text-row { padding: 20rpx 32rpx; }
 .share-text-row { padding: 20rpx 32rpx; }
 .share-text-preview { flex: 1; font-size: 24rpx; color: #F97316; margin-left: 24rpx; line-height: 1.5; }
 .share-text-preview { flex: 1; font-size: 24rpx; color: #F97316; margin-left: 24rpx; line-height: 1.5; }