ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

微信小程序订阅消息推送实战:Spring Boot完整实现与避坑指南

微信小程序订阅消息推送实战:Spring Boot完整实现与避坑指南 订阅消息这个小功能前前后后做过好几个项目从最早踩模板消息的坑到后来迁移到订阅消息再到帮朋友排查线上推送不生效的问题确实积累了一些值得说的经验。尤其是用 Spring Boot 做后端服务时很多人光看官方文档会觉得流程挺简单无非就是前端授权、后端调接口但真到联调阶段各种边界情况会一个一个冒出来。这篇文章就围绕“微信小程序 Spring Boot 实现订阅消息推送”这个主题把完整实现链路、代码结构、常见报错和上线前的自查项都摊开来讲希望对正在做这块功能的开发者有点帮助。先说清楚本文适合谁看后端需要用 Java/Spring Boot 提供推送接口、小程序前端需要接入订阅消息授权、或者你已经在做但被各种报错码卡住的开发同学。如果你只是想做一次性通知比如预约成功、审核结果、活动开奖提醒这篇文章基本能把你要用的东西覆盖全。1. 订阅消息的本质是“先拿授权、再发消息”很多刚接触订阅消息的人容易把它理解成“像短信一样后端随时可以推给用户”。这个理解在业务设计层面就错了。微信订阅消息的核心规则是用户必须先主动订阅你的服务端才有资格给他推送而且是一次订阅对应一次推送机会。1.1 一次性授权模型对业务设计的影响先说“一次性订阅”。用户在小程序里点击某个授权的动作比如勾选“允许通知”系统会弹出一个订阅消息确认框用户点了“允许”你的服务端就获得了一次向这个用户发送指定模板消息的机会。注意是“一次”。你这次用掉了下次要再发用户还得再点一次授权。这个机制对业务设计的影响非常大。最典型的反例是很多人做了一个“签到提醒”功能希望每天早上八点给用户推送一条“该签到啦”。产品心里想的是用户签过一次就有持续通知但在现在一次性订阅的规则下这个流程是行不通的。用户每次授权只对应一条消息要推送周期性提醒必须想别的办法比如每次触发订阅时攒下多次机会或者通过服务号模板消息绕路但这些都不是订阅消息的原生能力。所以在项目启动阶段就要把业务场景和订阅次数对应起来。比如“预约成功通知”用户在提交预约表单时弹出授权提交成功后再触达一条这是完全匹配的。“订单发货提醒”也类似用户下单后点授权订单发货后再推送一次授权一次通知体验闭环是顺的。1.2 这套机制适合哪些业务不适合哪些业务从我的实际经验看订阅消息最适合的领域是结果通知类审核结果、预约结果、抽奖开奖、活动报名成功。状态变更类订单发货、快递签收、退款完成、服务进度更新。时间提醒类预约时间快到、会议开始前提醒、疫苗第二针提醒。不适合的包括日常营销推送、周期性运营触达、无授权前提的广播通知。这些场景微信管控很严也不是订阅消息设计来承载的事情。另外一个容易被忽略的点是“长期订阅消息”。按照微信目前的规则长期订阅只对特定行业类目开放比如政务、医疗、金融等普通电商、工具类小程序基本申请不到。所以中小团队在做方案设计时默认按一次性订阅来做就好不要一上来就规划长期订阅除非你的小程序类目确实符合条件并且已经拿到了对应权限。个人体会很多需求方说“我要给用户推送”第一反应是“能不能不授权也能推”。这个答案在订阅消息体系下是否定的越早让业务方理解授权模型后面返工越少。2. 整条链路先捋清楚从 User 点按钮到后端推送达成的完整数据流订阅消息不是“前端调一个 api后端直接发”这么简单中间牵扯到三个端微信客户端、小程序前端、你的 Spring Boot 后端。我把完整链路拆开你会发现每一步其实都有对应的关键数据在流转。2.1 前端授权时发生了什么用户在小程序里点击授权按钮小程序端调用wx.requestSubscribeMessage传入模板 ID。微信会弹出授权框让用户决定是否允许这个模板的推送。如果用户点了“允许”小程序端只拿到一个结果状态并不会直接把授权凭证给后端。真正的“授权结果”其实以另一种方式记录在微信服务器里微信知道“这个用户可以接收这个模板的一次推送”然后把这个权利和当前用户的 openid 绑定在一起。你的后端数据库里如果没有保存用户的 openid后面根本不知道该发给谁。这就延伸出另一个关键步骤小程序端必须把用户的身份标识同步给后端。常见做法是小程序调用wx.login拿到一个临时 code再把 code 传给后端后端拿 code 去微信接口换 openid。这个换 openid 的接口就是jscode2session接口。2.2 后端要保存哪些关键数据后端这里有一个非常容易漏掉的点光有 openid 不够还得把模板 ID 和用户订阅状态关联起来。如果用户订阅的是“订单发货通知”你的服务端至少要知道谁的 openid、订阅了哪个模板、订阅了之后这条机会是不是已经被消费掉了。比较务实的表结构设计可以这样字段说明id主键openid小程序用户的唯一标识template_id用户订阅的模板 IDsubscribe_time订阅时间status未使用、已使用、已过期business_id关联的业务单号比如订单号、预约号extra_data预留字段存一些页面跳转参数这张订阅关系表的价值在于你可以知道某次推送是否还有资格发送。比如运营后台要手动推一条变动通知先查一下这张表如果 status 不是未使用直接提示后端没有推送机会避免打到微信接口被拒。2.3 发送请求时微信侧又校验了什么后端调用subscribeMessage.send接口时微信侧会做三重校验第一access_token是否有效是否在有效期内。第二touser对应的 openid 是否存在是否关注/使用过这个小程序。第三当前用户和模板之间是否还有未被消费的订阅关系。只要有一项不满足接口就会返回对应的错误码。很多线上问题本质上就是这三重校验中某一步出了问题。下面第五章展开说常见报错时你会看到错误码基本都是围绕这三个维度的。注意一点推送接口只能后端调不能在小程序端直接调。原因一方面是安全性小程序端直接调用相当于把 access_token 暴露到了客户端另一方面微信的设计就是希望业务方通过服务端来控制推送逻辑。3. Spring Boot 服务端实现从零搭出可运行的推送模块下面进入正题直接说我推荐的项目结构和代码实现方式。下面这部分是全文的重点我在项目中就是按这个结构落地的如果你跟着敲基本能跑通。3.1 工程基础与 HTTP 客户端的选型项目基于 Spring Boot我这边用的是 Spring Boot 2.x 版本没有直接上 3.x。原因很现实很多老项目依赖的第三方库对 Jakarta 命名空间的适配还不完善如果你是新项目用 3.x 问题不大但要注意javax包名换成了jakarta同时确保 HTTP 客户端库版本兼容。HTTP 客户端我强烈建议直接用 Spring 自带的RestTemplate或者WebClient没必要额外引入 OkHttp。原因有两个一是 Spring Boot 工程里不用额外配置就能注入 Bean省事二是订阅消息推送的频率不高RestTemplate 的性能足够用代码也更好维护。Configuration public class RestTemplateConfig { Bean public RestTemplate restTemplate() { SimpleClientHttpRequestFactory factory new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(3000); factory.setReadTimeout(5000); return new RestTemplate(factory); } }超时时间建议设置短一点微信接口如果抽风你的业务线程不能一直被吊着。3 秒连接超时、5 秒读取超时是我这边比较常用的配置。3.2 获取并缓存 access_tokenaccess_token是调用微信接口的全局唯一凭证有效期为 7200 秒。一个很常见的错误是每次发消息都去请求一次access_token。微信对获取access_token的接口有频率限制短时间大量请求会被封禁 IP所以必须做本地缓存。网上很多教程直接给出一个“获取 access_token”的方法但没讲缓存策略。一个简单的实现思路是项目启动时先查内存缓存没有就用 appid 和 secret 请求微信接口拿到后放进本地 Map并设置过期时间提前 5 分钟刷新。Component public class WechatAccessTokenCache { private static final String ACCESS_TOKEN_URL https://api.weixin.qq.com/cgi-bin/token?grant_typeclient_credentialappid{appid}secret{secret}; private final RestTemplate restTemplate; private final MapString, String tokenMap new ConcurrentHashMap(); private volatile long expireTime 0L; public WechatAccessTokenCache(RestTemplate restTemplate) { this.restTemplate restTemplate; } public String getAccessToken(String appid, String secret) { if (System.currentTimeMillis() expireTime tokenMap.containsKey(appid)) { return tokenMap.get(appid); } synchronized (this) { if (System.currentTimeMillis() expireTime tokenMap.containsKey(appid)) { return tokenMap.get(appid); } MapString, Object params Map.of(appid, appid, secret, secret); WechatTokenResponse resp restTemplate.getForObject(ACCESS_TOKEN_URL, WechatTokenResponse.class, params); if (resp ! null resp.getAccessToken() ! null) { tokenMap.put(appid, resp.getAccessToken()); expireTime System.currentTimeMillis() (resp.getExpiresIn() - 300) * 1000L; } return resp null ? null : resp.getAccessToken(); } } }这里有两个细节值得注意加锁判断用synchronized双检锁避免多个线程同时过期后都去请求微信接口。过期时间减去 300 秒相当于在官方 7200 秒有效期的最后 5 分钟就重新获取防止刚好在过期瞬间请求导致调用失败。如果项目里已经用了 Redis完全可以把 token 放在 Redis 里加一个 7000 秒的过期时间多个服务实例共享同一个 token避免不同节点各自缓存导致触发频率限制。我这里为了减小依赖直接用 JVM 内存缓存单机部署够用。3.3 编写订阅消息发送客户端access_token拿到之后下面就是核心的发送方法。先定义模板消息推送的请求体。public class SubscribeMessageRequest { private String touser; private String templateId; private String page; private String miniprogramState; private MapString, TemplateValue data; }data里的字段不是随便定义的而是根据微信后台模板内容来的。比如我常用一个“审核结果通知”模板里面的字段是审核类型{{thing1.DATA}} 审核结果{{phrase2.DATA}} 审核时间{{time3.DATA}}对应到代码就是MapString, TemplateValue data new HashMap(); data.put(thing1, new TemplateValue(预约申请)); data.put(phrase2, new TemplateValue(通过)); data.put(time3, new TemplateValue(2024-06-27 10:30));请求体的组装方式是这样的MapString, Object body new HashMap(); body.put(touser, openid); body.put(template_id, templateId); body.put(page, pages/result/result); body.put(miniprogram_state, formal); body.put(data, dataToMap(data)); HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntityMapString, Object requestEntity new HttpEntity(body, headers); String url https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token accessToken; ResponseEntityWechatSendResult response restTemplate.postForEntity(url, requestEntity, WechatSendResult.class);miniprogram_state这个字段建议做成可配置的。开发者工具可以传developer体验版传trial线上才传formal。如果线上环境传了developer消息会发送失败很多新手在这里踩坑。我通常把它放到配置文件里不同环境自动适配。3.4 业务事件触发推送的代码组织有了客户端方法真正的业务代码不能直接把发送逻辑写在 Controller 里。我习惯的做法是把推送封装成一个MessagePushService业务层只关心“事件发生了需要通知用户”不关心微信接口细节。举个例子假如业务场景是“管理员审核通过用户的申请后推送审核结果给用户”。Service public class UserApplicationService { private final SubscribeMessageClient subscribeMessageClient; private final UserSubscribeRepository subscribeRepository; public void approveApplication(String applicationId) { // 1. 业务处理修改申请单状态 // 2. 查询申请对应的用户 openid // 3. 构造模板数据 SubscribeMessageRequest req new SubscribeMessageRequest(); req.setTouser(openid); req.setTemplateId(审核结果通知的模板ID); req.setData(buildData(applicationInfo)); // 4. 发送订阅消息先检查是否有可用的订阅机会 boolean hasSubscribe subscribeRepository.hasUnusedSubscribe(openid, templateId); if (hasSubscribe) { subscribeMessageClient.send(req); subscribeRepository.markUsed(openid, templateId, applicationId); } } }这样组织的好处是如果后续要对接其他通知渠道比如短信只需要在同一个业务方法里加一个渠道处理不会把业务代码改乱。而且通过subscribeRepository的标记还能保证同一个订阅机会不会被重复消费避免同一用户收到两条一样的推送。这里我再补充一个很多项目容易忽略的小细节模板消息的 data 字段有严格的类型和长度限制。thing 类型不超过 20 个汉字number 不超过 32 位数字time 必须是标准时间格式。如果数据超长微信会返回 47003 错误。所以最好在构造 data 的时候就做一次长度校验而不是等微信把错误抛回来再处理。4. 小程序端交互授权按钮、失败兜底与 openid 绑定后端说完了再回到小程序端。这一端的代码逻辑相对简单但交互细节决定了用户会不会顺利授权。4.1 触发授权的两种姿势小程序触发订阅消息授权有两种方式。第一种是用open-typesubscribe的 button 组件这种方式只能订阅不能携带自定义业务参数。第二种是调用wx.requestSubscribeMessageAPI可以代码控制授权时机页面跳转前拦截也可以随时触发。我大多数项目用的是第二种因为可以在同一个点击事件里既发请求又弹授权灵活性更高。Page({ handleSubscribe() { wx.requestSubscribeMessage({ tmplIds: [模板ID_A, 模板ID_B], success(res) { // res[模板ID_A] 可能是 accept、reject、ban 等 if (res[模板ID_A] accept) { console.log(用户同意订阅); // 这里可以继续提交业务表单 } }, fail(err) { console.error(授权调用失败, err); } }); } });tmplIds数组最多能传三个模板 ID一次性弹多个订阅框。实际体验中弹多个框用户容易反感而且转化率很低。我建议一个页面最多弹一个或两个且要放在用户能明显感知价值的场景里——比如“提交成功是否接收结果通知”而不是一进首页就弹。4.2 wx.login 换 openid 的小细节用户授权之前必须先把 openid 给后端。小程序端用wx.login拿到 code然后把 code 传给后端后端调用微信的jscode2session接口换取 openid。很多项目的实际问题是用户在授权和最终业务提交之间可能落后很久导致wx.login的 code 已经过期。注意wx.login的 code 是五分钟有效而且只能用一次。所以正确的姿势是在用户进入页面或者点击提交时先调wx.login拿新 code再拿 code 去做后续操作不要缓存 code 以后用。后端把 code 换 openid 的接口如下同样通过微信 API 实现public String code2Session(String code) { String url https://api.weixin.qq.com/sns/jscode2session?appid{appid}secret{secret}js_code{code}grant_typeauthorization_code; MapString, Object params Map.of(appid, appid, secret, secret, code, code); WechatSessionResponse resp restTemplate.getForObject(url, WechatSessionResponse.class, params); return resp null ? null : resp.getOpenid(); }这里要注意后端拿到 openid 后不应该直接暴露给前端原样存储。更稳妥的做法是在后端建立一个用户体系把 openid 跟业务用户 ID 关联起来。小程序端只传自己的用户 ID后端根据 ID 查 openid避免 openid 被恶意获取后在客户端随便传。4.3 弹窗被拒后的用户引导有一个交互细节我用了几次之后现在一定会做用户第一次点了“拒绝”之后下次再触发wx.requestSubscribeMessage微信不会再弹窗而是直接返回reject或者ban。此时用户的订阅入口相当于已经断了你只能引导用户去设置页重新打开“订阅消息”开关。实现引导的最简单方式是用微信的openSetting接口让用户手动打开接收开关。showSettingGuide() { wx.showModal({ title: 开启通知, content: 您已关闭消息通知建议进入设置开启以便接收审核结果通知, success(res) { if (res.confirm) { wx.openSetting({ success(settingRes) { // 用户回来后可检查订阅消息是否开启 } }); } } }); }这个引导不是万能的因为用户即使打开了设置里的开关也已经丢失了之前“一次性订阅”的机会得重新走一次授权流程。所以更重要的还是做好第一次弹窗的时机和文案引导从源头提高授权通过率。经验之谈订阅授权通过率跟弹窗时机强相关。我在一个项目里把授权弹窗从“进入页面即弹”改成“用户点击提交按钮后、提交请求前弹”通过率从不到 30% 提升到了 60% 以上。原因很简单用户已经决定要用这个功能了顺带授权通知心智负担最小。5. 实测中高频出现的报错和排查思路这一节写的是我在联调和线上排查过程中踩过次数最多的几个问题。每个问题后面我都会给出完整的排查链路而不只是直接甩一个错误码列表。5.1 43101 不等于用户拒绝43101应该是最常见的订阅消息报错中文含义是“用户拒绝接受消息如果是小程序-订阅消息则代表该用户拒绝了您的订阅消息”。很多人一看到 43101 就以为用户点了“拒绝”然后去骂前端弹窗怎么写的。实际上 43101 还有一个非常容易忽略的触发点用户之前确实授权过一次但那条消息已经被发送过了订阅机会消耗完毕再调用发送接口时微信返回 43101。也就是说这个错误码既代表用户从未授权也代表“次数用完”。排查链路应该是这样的第一步查后端订阅关系表确认这条 openid templateId 的记录 status 是不是已经是“已使用”。第二步如果 status 是“未使用”再看用户是否在小程序里主动取消过订阅消息开关。第三步如果都没有让前端重新触发一次wx.requestSubscribeMessage看返回是accept还是reject基本就能定位。我在项目中为了避免这种歧义特意在订阅关系表里加了status和consume_time两个字段后台一查就知道这条消息到底是根本没授权还是授权机会已经用掉了。5.2 47003 模板字段不匹配47003是“模板参数不匹配”这个报错在开发阶段特别折磨人因为微信返回的错误信息里会带一个参数名但很多人不知道参数名不对在哪。举例模板内容是审核类型{{thing1.DATA}} 审核结果{{phrase2.DATA}}但你的代码里 key 写了thing11或者pharse2微信就会返回 47003。排查办法很简单把模板 ID 在微信后台的模板详情里打开对照里面的 key 名字逐个检查。还有一种是字段长度超限。thing 类型最多 20 个汉字如果用户填的申请标题有 30 个字直接拼接进模板也会报 47003。解决方式是在构造 data 前统一做截断处理。private String truncate(String value, int maxLength) { if (value null) { return ; } if (value.length() maxLength) { return value; } return value.substring(0, maxLength); }这里要特别提醒中文的“字”在 Java 的 length 计算里是按字符算的一个中文算一个 length直接用substring截断不会出现半个中文的问题但要注意 emoji 这种代理对字符一个 emoji 占了两个 length。所以更稳妥的是用码点计数不过大多数模板内容纯中文场景下直接截断也够用。5.3 40003 openid 无效40003报错一般发生在两种场景一是 openid 拼错了比如多了个空格、大小写问题或者直接把 session_key 当成 openid 用了二是 openid 的确不对比如开发环境用测试号换到正式环境就没换 appid导致两个环境的 openid 体系不一样。我在项目里遇到过一种比较隐蔽的情况前端在小程序 A 里调wx.login拿到 code后端却用小程序 B 的 appid 去换 openid。微信不会报“appid 不匹配”这种明确错误而是返回一个看似正常的 openid但这个 openid 在小程序 B 的订阅消息体系里根本不存在发消息时就报 40003。排查方式是把后端的日志打开把接收 openid、发送时用的 appid、小程序的 AppID 三列拉出来对比基本一眼就能看出是否混用。这里也建议在 Spring Boot 配置里用不同 profile 区分开发/测试/生产环境的 appid 和 secret不要手改。5.4 开发工具里那些“莫名其妙”的连接问题这节想提一下开发过程中经常遇到的类似handshake failed due to invalid upgrade header: null的报错。这类报错通常在微信开发者工具里出现不一定跟订阅消息有关但在联调时会打断你的排查节奏。结合我自己的经验碰到这类 WebSocket 握手失败的问题优先检查三件事开发者工具是否开启了一个不稳定的网络代理导致 WebSocket 握手请求被拦截。本地项目是否同时占用了多个调试端口或者云托管环境与本地 localhost 混用。开发者工具版本是否过旧小程序的调试基础库版本和工具版本不匹配时也容易出现连接异常。如果确认不是代码逻辑问题最直接的办法是把基础库版本切到稳定版关掉系统代理清掉开发者工具缓存重启。这个处理思路对订阅消息调试的阻塞场景很管用。关于基础库版本订阅消息本身从基础库 2.8.2 开始支持调试时建议把调试器的“基础库版本”选到 2.10.0 以上这样 wx.requestSubscribeMessage 的实现更稳定错误码信息也更完整。6. 上线前如何自测和验收最后这部分是我每次上线订阅消息功能前都会过的检查清单用来确保不是“开发环境能跑、线上就挂”的状态。6.1 用开发者工具的订阅消息模拟功能微信开发者工具里“工具”菜单下有一个“订阅消息”调试面板可以选取模板、填字段、指定 openid 来模拟推送。我第一次用的时候觉得挺好用但后来发现它跟在真机上跑还是有差异的。主要体现在开发者工具默认不会真正弹授权框而是可以选择“接受”或“拒绝”来模拟用户操作。所以开发者工具的作用主要是验证后端接口是否能被正确调用。模板字段是否匹配。access_token 是否缓存成功。真正的授权流程和消息触达必须在真机上跑。6.2 真机与开发版的注意事项真机调试时小程序默认处于开发版状态后端发送消息时miniprogram_state要传developer或者trial否则体验版/开发版里面看不到推送。这里有一个很经典的坑后端代码在测试环境传formal前端真机体验版收不到消息后端还看不到报错因为接口返回明明成功了。实际上微信已经把消息发出来了只是开发版/体验版客户端不会展示formal状态的消息。所以我的做法是把小程序版本状态做成环境变量放到 Spring Boot 的application-{profile}.yml中wechat: miniprogram-state: trial # developer / trial / formal不同环境发布时只需要指定对应的 profile不需要改代码。测试阶段用 trial正式上线用 formal。6.3 消息文案和模板字段的运营细节订阅消息的文案审核比较严格上线前要过一遍微信后台的模板审核规则。比如模板里不能出现导流、营销类的句子不能出现外部联系方式变量内容尽量简短直白。我的经验是模板尽量只承载“事务性通知”的职能不要试图在里面做营销。还有一个小技巧订阅消息跳转的page字段一定要设置到用户能看到具体结果的页面。比如推送“审核结果通知”跳转页面就应该是申请详情页而不是首页。用户从通知里点进来如果能直接看到结果他对这个功能的好感度会高很多也间接影响下次是否愿意继续订阅。最后提一下关于“推送被用户忽略”的问题。订阅消息没有短信那么强的提醒效果用户可能只是看到红点不会点开。所以消息内容里的“结果”要尽量直接用文字描述清楚比如“您的申请已通过请点击查看”不要把用户的注意力引到其他无意义的信息上。这个内容后续要扩展的话可以在同一套 Spring Boot 工程里继续接小程序客服消息、服务号模板消息等渠道把通知中心做成一个可配置的模块。订阅消息只是其中一个实现但整体的授权模型、缓存策略、错误处理思路是相通的。如果你在落地过程中遇到什么奇怪的问题也欢迎按照上面的排查链路逐层定位大部分问题都能在日志和表数据里找到答案。
返回列表