
先说清楚这个项目是干什么的:用户在小程序里点一次允许订阅,后端Springboot在业务触发时(比如订单状态变更、活动开始前、预约时间快到),通过微信服务端接口给用户推送一条服务通知。小程序端弹订阅授权框,后端负责调微信接口发消息,两边配合完成一条完整的推送链路。这个需求的典型场景我举个具体的:我做过一个健康管理类小程序,用户在小程序里预约了明天上午10点的体检,这时候系统需要在预约成功时弹一次订阅授权,等第二天体检开始前半小时,自动给用户推送一条请按时到店体检的提醒。用户那边收到的就是微信服务通知里的一条消息,点击可以直接跳回小程序对应页面。整个流程涉及小程序端的授权交互、后端的用户身份识别、微信接口调用、以及推送后对用户订阅行为的记录和管理,环环相扣。这篇内容适合正在做微信小程序后端开发、或者刚接手订阅消息推送需求的同学。下面我把整个项目的设计思路、接口细节、代码实现和踩坑记录全部摊开来讲。1. 项目整体设计与核心思路1.1 为什么用订阅消息而不是站内信做消息通知时,很多人第一反应是:小程序里做个消息中心,用户打开小程序就能看到通知列表。但这里有个致命问题——用户不打开小程序,就永远看不到消息。对于体检前提醒、发货通知、活动开始提醒这类强时效性消息,站内信完全失效。短信和邮件也能做,但成本高、需要用户授权手机号或邮箱,而且短信通道的审核和签名备案在个人开发者场景下非常麻烦。微信订阅消息是官方能力,用户授权一次,小程序就能给用户推送一条服务通知,触达率远高于站内信,成本为零,是当前微信生态内性价比最高的通知方案。旧的模板消息接口在2020年已经下线,新项目必须用订阅消息接口。订阅消息和模板消息最核心的区别在于:模板消息是用户在小程序内有动作后7天内可下发,订阅消息则是必须先有用户明确的订阅动作,才能下发一次。这个先订阅、后推送的机制,决定了后端设计时必须围绕订阅关系来管理。1.2 整体架构与数据流向整个推送流程我拆成两条链路:订阅链路(用户主动操作):小程序端wx.login拿code → 后端用code换openid → 用户点击订阅提醒按钮 → 小程序调wx.requestSubscribeMessage弹出授权框 → 用户同意 → 前端把订阅结果和业务信息一起提交给后端 → 后端记录订阅关系。推送链路(后端触发):业务事件发生(如订单状态更新) → 后端组装消息内容 → 调微信订阅消息发送接口 → 微信服务器推送到用户微信服务通知 → 用户点击通知跳回小程序指定页面。这里有一个关键决策:为什么不能让小程序端直接调微信发送接口?因为发送接口需要access_token,而access_token的获取依赖appsecret,appsecret放在小程序前端等于裸奔,任何人反编译小程序都能拿到。另外发送动作往往发生在用户不在小程序内的时候(比如定时任务触发、订单超时自动提醒),只有后端能稳定地执行这类任务。1.3 技术选型说明服务端用Springboot 2.7.x,JDK 1.8,HTTP客户端用Spring的RestTemplate。有的团队用OkHttp或Hutool的HttpUtil,本质没有区别,选一个用熟了的就行。重点是access_token的缓存我用了Redis,如果是单体小项目直接用ConcurrentHashMap加定时刷新也能跑,但Redis方案在多实例部署下不会出现一个实例刷新了token、另一个实例还在用旧token的问题。小程序端原生语法,没有用uni-app或Taro这类跨端框架。订阅消息的核心API是wx.requestSubscribeMessage,跨端框架也封装了对应方法,底层行为一致,所以本文的JS代码改成任何框架都能用。2. 微信订阅消息机制深度拆解2.1 一次性订阅、长期订阅与设备订阅微信订阅消息分三种类型,搞清楚它们的区别是设计系统的前提:类型授权方式可推送次数适用场景一次性订阅消息用户每次主动授权授权一次推一条预约提醒、订单状态通知、活动通知长期订阅消息用户授权一次(需特别类目)长期有效,不限次数政务、医疗、交通等民生领域设备订阅消息用户连接设备时授权设备离线时推送智能硬件状态提醒个人开发者和小微企业能拿到的几乎只有一次性订阅消息。长期订阅消息的类目限制非常严格,比如医疗、公共交通、政务办事这类,普通电商、工具类小程序基本没戏。这意味着大多数项目必须接受一次订阅 一次推送这个硬约束。这个约束直接影响产品设计:你不能在用户第一次进入小程序时就弹订阅框,因为即使用户同意了,也只有一次推送机会,后面再想推就得让用户重新订阅。我见过不少项目在这里栽跟头——用户第一次授权后,后端推了一条欢迎语,等真正需要推送业务提醒时发现订阅次数用完了,接口直接报43101。2.2 模板ID与参数结构订阅消息的模板需要在小程序后台申请,路径是:登录微信公众平台 → 功能 → 订阅消息 → 选用模板。审核通过后会得到一个模板ID,格式类似zPc3JQjFkY3Vx9Gh6mhS8d_WKzNfLvEbT2uR4aB_yY。每个模板都有固定的字段,字段类型在申请模板时就能看到。常见的有:thing:短句,20个字以内,不能有标点符号(实测英文逗号、句号都不行)number:数字,可以带单位,单位最多5个字time:具体时间,格式yyyy-MM-dd HH:mm:ss或yyyy-MM-ddphrase:特定短语,类似下拉选择,如已完成、已取消amount:金额,支持带货币符号组装data参数时,Key必须和模板里的字段名称完全一致。比如模板里字段叫thing1,你传keyword1就会报47003参数错误。这个字段名是可以后台自己改的,但改了之后请求体里的Key也要同步改。注意thing类型的字符限制非常坑。经验值是先在前端做一遍校验,后端再校验一遍,因为模板字段超长或者包含非法字符,微信接口会直接拒绝整条消息。2.3 订阅授权的行为特征wx.requestSubscribeMessage一次可以传多个模板ID,最多3个,会同时弹出多个授权框。但用户同意多个模板后,每个模板仍然只算一次订阅机会。授权结果在success回调里是一个对象,Key是模板ID,Value是字符串:accept:用户同意reject:用户拒绝ban:表示用户拒绝了多次,之后不会弹窗,需要引导用户去设置页手动开启用户拒绝后不能马上再次弹窗,requestSubscribeMessage会直接走fail回调。实际项目里我一般是通过引导页 按钮的方式:第一次拒绝后不强制,等用户真正需要触发订阅时(比如提交预约表单的瞬间),再弹一次授权。还有一点容易被忽略:订阅授权框的弹出时机必须由用户主动点击触发,不能在小程序启动时自动调。微信对诱导订阅打击很严,如果用户在小程序里没有明确的订阅交互动作就弹框,有被投诉封禁的风险。3. Springboot后端核心实现3.1 工程结构与依赖项目是标准的Springboot分层结构:controller接收小程序请求,service处理业务逻辑,mapper操作数据库。额外加了一个wx包,专门放微信相关的工具类和服务。核心依赖只有三个:dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdcom.alibaba/groupId artifactIdfastjson/artifactId version2.0.x/version /dependency小程序配置放在application.yml里:wx: appid: 你的appid secret: 你的appsecret # 订阅消息模板ID template: order-notify: zPc3JQjFkY3Vx9Gh6mhS8d_WKzNfLvEbT2uR4aB_yY注意:appsecret是敏感凭证,生产环境务必放到配置中心或环境变量,不要提交到Git仓库。我习惯在配置文件里用${WX_APPSECRET}占位符,部署时从环境变量注入。3.2 access_token的获取与缓存access_token是调用微信所有接口的通行证,有效期7200秒(2小时)。获取接口:GET https://api.weixin.qq.com/cgi-bin/token?grant_typeclient_credentialappidAPPIDsecretAPPSECRET返回:{ access_token: 60_zL8Vx..., expires_in: 7200 }这里最大的坑在于:微信官方明确说明,access_token必须全局缓存,不能频繁刷新。每天获取次数上限2000次,如果每次都重新获取而不做缓存,高并发下很快会触发频率限制。我的缓存策略是:先从Redis取,取到直接用。取不到就调用微信接口获取,存到Redis,过期时间设为7000秒(提前200秒过期,防止Token刚好在调用瞬间失效)。获取时加JVM内置锁,防止多线程同时刷新导致2000次配额被迅速消耗。如果发送消息接口返回40001(access_token无效),主动删掉Redis里的缓存,下次请求时自动重新获取。具体实现:Service public class WxAccessTokenService { private static final String ACCESS_TOKEN_KEY wx:access_token; private static final String TOKEN_URL https://api.weixin.qq.com/cgi-bin/token; Autowired private RestTemplate restTemplate; Autowired private StringRedisTemplate redisTemplate; Value(${wx.appid}) private String appid; Value(${wx.secret}) private String secret; public String getAccessToken() { String token redisTemplate.opsForValue().get(ACCESS_TOKEN_KEY); if (StringUtils.hasText(token)) { return token; } synchronized (this) { // 双重检查,防止并发下重复刷新 token redisTemplate.opsForValue().get(ACCESS_TOKEN_KEY); if (StringUtils.hasText(token)) { return token; } String url TOKEN_URL ?grant_typeclient_credentialappid appid secret secret; String resp restTemplate.getForObject(url, String.class); JSONObject json JSON.parseObject(resp); if (json.containsKey(access_token)) { String accessToken json.getString(access_token); int expiresIn json.getInteger(expires_in); redisTemplate.opsForValue().set(ACCESS_TOKEN_KEY, accessToken, expiresIn - 200, TimeUnit.SECONDS); return accessToken; } else { throw new RuntimeException(获取access_token失败: resp); } } } public void clearToken() { redisTemplate.delete(ACCESS_TOKEN_KEY); } }3.3 用户登录与openid管理小程序端没有传统意义上的用户名密码,用户身份通过openid唯一标识。用户在小程序里调wx.login拿到一个临时code,把code传给后端,后端用code换openid:GET https://api.weixin.qq.com/sns/jscode2session?appidAPPIDsecretSECRETjs_codeCODEgrant_typeauthorization_code返回:{ openid: o6_bmjrPTlm6_2sgVt7hMZOPfL2M, session_key: tiihtNczf5v6AKRyjwEUhQ }openid是用户的唯一标识,推送订阅消息时touser字段就是填它。注意openid是跟随小程序appid的,同一个用户在你的A小程序和B小程序里,openid不同。所以获取openid时一定确认用的是当前小程序的appid和secret。登录接口:RestController RequestMapping(/api/wx) public class WxLoginController { Autowired private RestTemplate restTemplate; Value(${wx.appid}) private String appid; Value(${wx.secret}) private String secret; PostMapping(/login) public Result login(RequestBody LoginDTO dto) { String url https://api.weixin.qq.com/sns/jscode2session?appid appid secret secret js_code dto.getCode() grant_typeauthorization_code; String resp restTemplate.getForObject(url, String.class); JSONObject json JSON.parseObject(resp); String openid json.getString(openid); if (!StringUtils.hasText(openid)) { return Result.error(登录失败: resp); } // 查询或创建用户,这里省略mapper细节 User user userMapper.findByOpenid(openid); if (user null) { user new User(); user.setOpenid(openid); userMapper.insert(user); } // 返回openid给前端,实际项目建议返回自定义token return Result.success(openid); } }3.4 订阅记录的存储设计一次性订阅消息的一次授权一次推送特性,要求后端必须管理好每个用户的剩余订阅次数。我是这样设计的:用户表user多了两个字段:subscribable(当前可用订阅次数)和subscribed_at(最近授权时间)。每次用户在小程序端授权成功后,前端会调一个接口通知后端:PostMapping(/api/wx/subscribe/success) public Result subscribeSuccess(RequestBody SubscribeSuccessDTO dto) { // dto.templateId 用户授权的模板ID User user getCurrentUser(); user.setSubscribable(user.getSubscribable() 1); user.setSubscribedAt(new Date()); userMapper.updateById(user); return Result.success(); }这个订阅次数就是推送额度。推送成功后要减一次:public void afterPushSuccess(User user) { user.setSubscribable(Math.max(0, user.getSubscribable() - 1)); userMapper.updateById(user); }为什么这么设计?原因是在实际业务中,订阅授权和推送触发往往是异步的。比如用户周一订阅了预约提醒,真正去体检是周三,中间隔了几天,如果没有次数记录,很容易出现已经推送过但状态没更新导致重复推送的Bug。3.5 发送订阅消息的完整实现推送接口:POST https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_tokenACCESS_TOKEN请求体:{ touser: OPENID, template_id: TEMPLATE_ID, page: pages/detail/detail?id123, miniprogram_state: formal, lang: zh_CN, data: { thing1: { value: 预约成功 }, time2: { value: 2024-06-15 10:00 }, thing3: { value: 请提前10分钟到店 } } }参数说明:touser:接收者openidtemplate_id:模板IDpage:用户点击消息后跳转的小程序页面路径,可以带参数miniprogram_state:developer开发版、trial体验版、formal正式版。默认是formal,开发调试时注意切换lang:语言,zh_CN或en_USdata:模板字段数据,每个value是字符串,thing类型因为不能有标点,我会在服务端做一次格式化兜底核心Service实现:Service public class WxSubscribeMessageService { Autowired private RestTemplate restTemplate; Autowired private WxAccessTokenService accessTokenService; private static final String SEND_URL https://api.weixin.qq.com/cgi-bin/message/subscribe/send; /** * 发送订阅消息 */ public boolean sendSubscribeMessage(WxSubscribeMessageDTO dto) { String url SEND_URL ?access_token accessTokenService.getAccessToken(); JSONObject body new JSONObject(); body.put(touser, dto.getOpenId()); body.put(template_id, dto.getTemplateId()); body.put(page, dto.getPage()); body.put(miniprogram_state, dto.getMiniprogramState()); body.put(lang, zh_CN); body.put(data, buildData(dto.getData())); HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntityString request new HttpEntity(body.toJSONString(), headers); String resp restTemplate.postForObject(url, request, String.class); log.info(订阅消息推送响应: {}, resp); JSONObject json JSON.parseObject(resp); Integer errcode json.getInteger(errcode); if (errcode ! null errcode 0) { return true; } // token失效,清掉缓存重试一次 if (errcode ! null errcode 40001) { accessTokenService.clearToken(); } log.error(订阅消息推送失败, errcode{}, errmsg{}, errcode, json.getString(errmsg)); return false; } /** * 组装data参数,处理格式限制 */ private JSONObject buildData(MapString, String data) { JSONObject result new JSONObject(); data.forEach((key, value) - { JSONObject field new JSONObject(); field.put(value, formatValue(key, value)); result.put(key, field); }); return result; } private String formatValue(String fieldName, String value) { // thing类型限制20字、不含标点 if (fieldName.startsWith(thing)) { if (value null) { return ; } String cleaned value.replaceAll([\\p{P}\\p{S}], ); if (cleaned.length() 20) { cleaned cleaned.substring(0, 20); } return cleaned; } return value; } }data里面的字段名是动态的,每个模板不一样,所以用MapString, String来传参,业务侧按模板字段组装。我一般在使用前先写单元测试把模板里的字段名和格式确认一遍,避免上线后才发现参数错误。4. 小程序端核心实现4.1 登录获取openid小程序端没有cookie、没有session,获取用户身份的核心是wx.login。每次获取的code只能用一次,且有效期5分钟。wx.login({ success: async (res) { if (res.code) { const { data } await request({ url: /api/wx/login, method: POST, data: { code: res.code } }); // 正常应该换一个自定义token,这里直接存openid演示 wx.setStorageSync(openid, data.data); } else { console.error(登录失败, res); } } });这里有个体验优化的细节:wx.login的code过期时间很短,而且小程序热启动后code也可能已经变了。我一般在小程序app.js的onLaunch里调一次登录,把openid全局存起来;如果后面接口报登录态失效,就重新调wx.login刷新。4.2 发起订阅授权订阅授权必须放在业务动作上,比如用户点击提交预约按钮时,在提交逻辑里先调wx.requestSubscribeMessage,再调后端保存业务。不要让用户预先在一个单独的订阅设置页里订阅,那样转化率会低很多。async function subscribeNotify() { return new Promise((resolve, reject) { wx.requestSubscribeMessage({ tmplIds: [zPc3JQjFkY3Vx9Gh6mhS8d_WKzNfLvEbT2uR4aB_yY], success(res) { // res形如: {zPc3JQjFkY3Vx9Gh6mhS8d_WKzNfLvEbT2uR4aB_yY: accept} const status res[zPc3JQjFkY3Vx9Gh6mhS8d_WKzNfLvEbT2uR4aB_yY]; if (status accept) { resolve(true); } else if (status reject) { resolve(false); } else if (status ban) { // 被禁止弹窗,引导用户去设置页 wx.showModal({ title: 无法弹出订阅授权, content: 请在设置中开启订阅消息授权, confirmText: 去设置, success(modalRes) { if (modalRes.confirm) { wx.openSetting(); } } }); resolve(false); } }, fail(err) { // 系统层面失败,比如用户之前拒绝次数太多 console.error(订阅授权失败, err); resolve(false); } }); }); }请求后端保存业务时,把是否授权成功一起传过去:async function submitAppointment() { const subscribed await subscribeNotify(); await request({ url: /api/appointment/create, method: POST, data: { appointTime: formData.appointTime, doctorId: formData.doctorId, subscribed: subscribed } }); if (subscribed) { toast(预约成功,已开启提醒); } else { toast(预约成功,未开启提醒); } }一个重要的细节:wx.requestSubscribeMessage必须在用户点击事件的回调里同步调用,不能放在wx.request的返回回调里异步调用。微信内部会检查调用时机,如果在回调里异步弹窗,会直接报错。我在开发阶段就踩过这个坑,success回调里再发请求然后弹窗,怎么都弹不出来。4.3 接收通知后的页面跳转订阅消息推送到用户微信后,用户点击消息会打开小程序,并跳到发送接口里page字段指定的页面。这个页面路径必须在小程序app.json的pages数组里注册过,否则会提示页面不存在。跳转页面带参数时,参数会放在onLoad的options里:Page({ onLoad(options) { // options.id 就是page路径里的?id123 const id options.id; this.loadDetail(id); } });注意:如果page指向的是tabBar页面,路径不能带参数,只能跳默认首页。我记得当时做跳转到orderList这个tab页面时,怎么都拿不到参数,查了文档才发现tabBar页面的onLoad不会接参数,最后只能改造成跳到一个普通页面,再由普通页面redirectTo到tabBar页面。5. 联调避坑与常见问题速查5.1 高频错误码与排查思路订阅消息接口的错误码是排查问题的第一入口,我把实际开发中遇到最多的几个整理成了一张表:错误码含义排查方向0成功无需处理40001access_token无效或过期检查appid/secret是否匹配,清除token缓存重新获取40003openid不正确确认openid是否属于当前小程序的appid40037template_id不正确检查模板ID是否复制完整,是否属于当前小程序41030page路径不正确确认跳转页面已注册且路径拼写正确42001access_token超时重新获取token43101用户拒收用户未授权或订阅次数已用完,需引导重新订阅47001data格式错误请求体JSON格式错误,用JSON格式化工具检查47003参数格式不正确模板字段名不匹配或字段值格式/长度超限43101是最容易让人迷惑的错误。你代码明明写得没问题,接口返回用户拒收,但其实不是用户手动拒收了推送,而是订阅次数已经用完——用户当时确实授权过,但那条授权额度已经在某次推送中消耗了,现在再推就提示拒收。排查方法是查数据库里该用户的subscribable字段,如果为0,就说明是额度问题。5.2 实测踩坑记录第一个坑是access_token缓存穿透。最开始我把token存在内存里,用静态变量加定时任务刷新。上线后遇到一个诡异现象:偶尔某一次推送报40001,重启服务就好了。后来查日志发现,是定时任务和服务启动时同时刷新了token,A线程刷新了B线程的旧值被覆盖,导致真实token失效。后来改成Redis存储加getAndSet原子性操作才解决。如果项目只有单实例,用synchronized加双重检查就够了。第二个坑是模板字段的类型限制。我做过一个推送,模板里有一个thing类型的字段,我传了您的订单已发货,请注意查收!,结果一直报47003。排查半天发现thing类型不允许标点符号,感叹号、逗号、句号都被微信规则禁止。最后把所有标点替换成空格,您的订单已发货 请注意查收才通过。这个坑在申请模板时就要想清楚,字段类型能选phrase就用phrase,能选time就用time,thing的限制最多,能避则避。第三个坑是开发环境和正式环境的token混用。miniprogram_state字段可以指定消息在开发版、体验版还是正式版中跳转。开发调试时设成developer,消息只在开发版小程序里出现;上线前忘了改回formal,正式版用户就收不到消息。这个字段我都是放到配置中心的,按环境自动切换,避免手动改代码漏改。第四个坑是用户重复订阅导致订阅记录累加。用户每次都点允许订阅,后端的subscribable就1,理论上用户积攒了几次订阅额度,后面可以推送多条。但是微信有个隐藏限制:如果用户订阅的是同一个模板,额度是叠加的,但订阅记录的有效期只有3天(部分模板可能不同)。如果用户3天内没消耗完,额度就失效了。所以后端别把订阅记录存成永久有效,要加有效期判断。5.3 用户体验层面的几个建议订阅消息虽然是官方能力,但用户被骚扰多了会反感。我做了几期推送之后,总结了几个提升体验和订阅率的要点:订阅授权弹窗尽量放在用户完成核心交互动作之后,比如提交预约成功之前,不要一进小程序就弹。实测中,用户明确知道预约后需要提醒的场景下,订阅接受率能有70%以上;而在无关页面弹窗,接受率往往不到30%。建议在一次交互中同时申请多个模板,减少弹窗次数。比如用户预约成功后,同时订阅预约成功提醒和报告结果通知两个模板,用户点一次允许,就能获得两条推送额度。推送文案尽量精准,不要发无关的营销消息。微信团队对营销类订阅消息的投诉处理很严格,一旦被用户投诉太多,可能面临订阅消息能力被限制的风险。我做推送的原则是:** 只有用户明确需要知道的结果类变更才推送,活动推送、营销推送坚决不发**。写在最后的几句体会这个项目做完后,我最大的体会是:微信订阅消息推送本身不难,接口文档写得很清楚,真正的复杂度在于把订阅关系和业务状态在工程上管理好。一次订阅一次推送的限制,要求你认真设计订阅记录表、推送前后更新用户状态,否则上线之后就会遇到各种该推的没推、不该推的乱推的问题。最后再分享一个小技巧:订阅消息发送接口有一定概率因为网络抖动超时,但实际消息可能已经发出去了,所以在业务侧做发送失败自动重试时,一定要做幂等处理。我习惯在推送记录表里加一个message_id唯一索引,重试前先查一次是否已经推送成功,避免用户收到重复消息。这个机制用上之后,推送服务的稳定性高了不少,也是我在这个项目里收获最大的一点。