1. 微信小程序消息订阅,先搞清楚它的三种订阅机制
很多人一上来就问"消息订阅怎么做",结果第一步就卡在概念上。微信小程序的消息订阅不是"给用户发私信"那么简单,它有一套自己的授权和下发机制。先花三分钟把机制弄清楚,比直接抄代码重要得多。
微信小程序消息订阅本质上是微信服务端代发通知的机制:用户在你的小程序里点击某个按钮,小程序弹出一个订阅授权框,用户点了"允许"之后,你的后端服务器才能调用微信的接口,向这个用户发送一条模板消息。这条消息会出现在用户的微信"服务通知"里,跟公众号消息是同一入口,用户不会错过。
目前微信提供三种订阅方式,区别非常大:
| 订阅类型 | 授权规则 | 发送限制 | 适用场景 |
|---|---|---|---|
| 一次性订阅 | 用户每次授权只能接收一条消息 | 授权一次,后台只能成功发送1次 | 下单成功通知、预约确认、审核结果 |
| 长期订阅 | 用户授权后可长期接收 | 无次数限制,但模板需要特殊申请 | 政务、医疗、教育等特定类目 |
| 永久订阅(已下线) | 已全面停止新申请 | 无限制 | 不再考虑 |
一次性订阅是最常见的。用户每次点"订阅"按钮,授权弹窗出现,他点了允许,你的后端就获得了一次发送机会。这次机会用完就没有了,用户下次还要接收,还得再点一次。
长期订阅看着美好,但门槛极高。个人开发者基本拿不到,只有政务、医疗、教育、交通等特定行业的小程序,在微信公众平台提交对应的资质材料,走人工审核流程,才可能申请到长期订阅模板。绝大多数做普通商业项目的开发者,实际能用到的就是一次性订阅。
这里有个常见的误解:以为用户授权之后就可以一直给他发消息。一次性订阅的"一次性"指的是发送机会只能用一次,不是订阅关系只存在一次。用户第一次点了允许,你发了一条,如果还想发第二条,用户必须再次点击订阅按钮并再次允许。这个机制天然地防止了小程序骚扰用户。
理解了这三种类型,后面的开发路径就清晰了。我下面整个教程都围绕一次性订阅展开,这是最主流、门槛最低、绝大多数中小程序项目都会用到的方案。
2. 完成后台配置:模板申请和开发信息准备
写代码之前,先要去微信公众平台把基础配置搞定。记住一句话:后台配置不完成,接口天上调不通。这一步看着简单,实际是很多人卡住的第一道坎。
2.1 申请订阅消息模板的完整路径
登录微信公众平台(mp.weixin.qq.com),进入小程序管理后台,左侧菜单找到"功能 -> 订阅消息"。首次进入会让你选择类目,这个类目必须跟你的小程序主体一致,选错会导致后续模板搜索不到。
进入订阅消息页面后,可以看到"公共模板库"和"我的模板"两个Tab。在公共模板库里搜索你需要的模板关键词,比如"订单"、"审核"、"预约",每个模板都有一个对应的ID,像这样:
模板ID:TZ9dW9PQo8B-Zzx1OrfpyG2TtGxxxxxxxx点"选用"之后,这个模板会进入"我的模板"列表。模板库里的模板内容都是微信官方定义好的,字段格式固定,你不能自己新增字段。比如一个订单通知模板,可能包含以下字段:
- 订单号
- 商品名称
- 下单时间
- 订单金额
- 备注
每个字段在模板里都有一个固定的key,这些key在发送消息的时候要用到。点开模板详情,能看到"字段名称"和对应的"字段key"。
还有一个必须记住的步骤:在模板详情页面有一个"关键词"列表,你必须选择合适的关键词组合。有的模板提供了10个关键词,你只需要选其中3个组成最终的模板内容。关键词的组合决定了用户看到的通知样式,也决定了后端要传哪些数据参数。
潜在风险:选关键词的时候一定要注意语义,有些关键词虽然跟业务沾边,但发送时你不一定有对应的数据。比如模板提供了"订单金额",但你的业务里订单金额存在分还是元的单位,这个在发送的时候要格外小心,后面会详细说。
2.2 获取AppID、AppSecret和IP白名单
消息订阅的后端接口调用需要用到小程序的AppID和AppSecret。AppID在小程序后台"开发 -> 开发管理 -> 开发设置"里可以看到。AppSecret点"重置"会重新生成,记住重置之后旧Secret立即失效,如果有多个环境共用,重置前一定要确认没有其他服务在用。
把这两个参数配置到你的后端服务配置文件中,比如Spring Boot的application.yml:
wechat: appid: wx1234567890abcdef secret: 1234567890abcdef1234567890abcdef还有一项容易被忽视的配置:IP白名单。同一个"开发设置"页面下面,有一个"服务器域名/IP白名单"的设置。微信要求:调用后端接口时,服务器的出口IP必须加到白名单里,否则调用接口会返回40164错误。
这里说的IP是你的后端服务器公网出口IP,不是本地开发机的IP。怎么查?在服务器上执行:
curl ifconfig.me如果后端服务搭在腾讯云或阿里云上,直接查内网对应公网IP也行。查到的IP填进白名单,通常10分钟内生效。本机联调的时候,可以把本地公网IP也加进去,方便本地调试,但上线后一定要把本地IP移除,只保留线上服务器的IP。
2.3 下发路径域名配置
订阅消息的url回调、图片等资源,如果page跳转需要携带参数,还有一个隐藏配置点——request合法域名。小程序的wx.request请求"默认"要校验域名,但在开发工具里可以勾选"不校验合法域名"先跑通,上线前必须配置到位。订阅消息本身经由微信服务器下发,不经过你的服务器,所以这一步相对简单,但后续联调如果发现前端请求自己被拦截,优先检查这里。
3. 后端第一关:稳定获取access_token并缓存
后端所有微信接口的调用,都绕不开access_token。access_token是调用微信全局接口的凭证,有效期7200秒(2小时),而且微信官方明确限制了获取频率:每天调用次数上限是2000次,同时接口本身也有稳定性要求,不建议频繁刷新。实际开发中最常犯的错误就是每次发消息都去重新获取access_token,一两天就把配额用光了,然后接口报45009(超出调用频率限制),一脸懵。
3.1 access_token获取接口
接口地址:
GET https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET返回:
{ "access_token": "ACCESS_TOKEN", "expires_in": 7200 }代码实现上,用Spring Boot + Redis来管理access_token是最常见的方案。Redis的setex命令天然适合做有效期控制:缓存2小时,过期自动删除。
@Service public class WxTokenService { @Autowired private StringRedisTemplate redisTemplate; @Value("${wechat.appid}") private String appid; @Value("${wechat.secret}") private String secret; private static final String TOKEN_KEY = "wx:access_token"; public String getAccessToken() { // 先从redis取,存在直接返回 String token = redisTemplate.opsForValue().get(TOKEN_KEY); if (StringUtils.hasText(token)) { return token; } // 不存在,调微信接口获取 String url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=" + appid + "&secret=" + secret; RestTemplate restTemplate = new RestTemplate(); String result = restTemplate.getForObject(url, String.class); JSONObject json = JSON.parseObject(result); String accessToken = json.getString("access_token"); Integer expiresIn = json.getInteger("expires_in"); // 缓存到redis,有效期设置比微信短一点,避免边界问题 redisTemplate.opsForValue().set(TOKEN_KEY, accessToken, expiresIn - 200, TimeUnit.SECONDS); return accessToken; } }注意我把缓存有效期设置成了expires_in - 200秒,也就是在微信token真正过期前200秒就主动换取新的。这样能避免一个经典问题:代码里拿到token,中间处理了半分钟业务,调用发送接口时token刚好过期,返回40001错误。既然微信的expires_in是7200秒,提前3分多钟刷新是完全合理的。
3.2 处理token失效自动重试
有一个情况你一定会遇到:token在Redis里还有效,但调微信接口时返回了40001(invalid credential,access_token无效或过期)。这时候不能直接返回错误给前端,应该主动清掉本地缓存,重新获取一次token,再重试业务请求。
public String sendSubscribeMessage(SendMessageRequest request) { String token = getAccessToken(); try { return doSend(token, request); } catch (WechatApiException e) { if ("40001".equals(e.getErrCode())) { // token失效,清除缓存重新获取 redisTemplate.delete(TOKEN_KEY); token = getAccessToken(); return doSend(token, request); } throw e; } }这个重试逻辑在并发场景下也能正常工作——多个请求同时发现token失效,都去删除缓存再获取,因为getAccessToken里Redis的检查再设置操作虽然存在并发窗口,但即便多调一次token接口也无伤大雅,最多浪费一次配额。
3.3 接口返回码速查表
后端接入微信接口,必须对返回码有概念。下面是订阅消息相关的重点返回码,建议收藏:
| 返回码 | 含义 | 处理建议 |
|---|---|---|
| 0 | 发送成功 | 正常入库记录 |
| 40003 | openid无效 | 检查touser是否正确,可能用户没关注/没登录 |
| 40037 | template_id不正确 | 检查模板ID是否从后台复制完整 |
| 43101 | 用户拒绝接收消息 | 用户没点授权或授权次数已用完,静默处理 |
| 47003 | 模板参数不准确 | 检查data里的key和value是否符合模板 |
| 45009 | 接口调用超过频率限制 | 检查access_token缓存逻辑,发送频率过高 |
| 41030 | page路径不正确 | 检查page参数,不要带.html后缀 |
| 40001 | access_token无效 | 按上面的重试逻辑处理 |
把这条规则定成后端开发规范:订阅消息发送接口对75000以下的业务错误码,都算作"预期内失败",不能影响主流程。比如用户授权次数用完了,后端返回43101,你只需要把这个状态记录一下,不能为了提高"成功率"而多次重复发送。
4. 后端发送消息:参数构造和完整代码实现
核心接口调用是标准的HTTP POST,通往:
POST https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=ACCESS_TOKEN请求体格式:
{ "touser": "OPENID", "template_id": "TEMPLATE_ID", "page": "pages/order/detail?id=123", "miniprogram_state": "formal", "lang": "zh_CN", "data": { "character_string1": { "value": "DD20240001" }, "thing2": { "value": "某某商品" }, "time3": { "value": "2024-01-15 14:30:00" }, "amount4": { "value": "99.50元" } } }参数说明:
- touser:接收者openid。注意是用户在小程序里的openid,不是公众号的openid,两者不通用。
- template_id:后台"我的模板"里的模板ID。
- page:用户点击消息后跳转的小程序页面路径,可以带参数。
- miniprogram_state:有
formal(正式版)、trial(开发版)、developer(体验版)三个值。后端如果填了developer,那么只有开发版的微信才能打开这条消息,正式版环境的一定要填formal,这是很多人上线后踩的坑:测试的时候能用,上线后用户点通知没反应,检查这里。 - lang:语言类型,默认zh_CN。
- data:模板字段,每个字段的类型在模板后台已经固定,比如
character_string表示字符串、thing表示事物名称、amount表示金额、time表示时间。传值的时候类型必须匹配,否则报47003错误。
data里有一个很隐蔽的规则:thing类型的字段,value长度不能超过20个字符。如果商品名称有30个字,截断后再传,不然接口会直接报错。这个坑我遇到过好几次,尤其是电商类小程序,商品全名动不动就超长。
下面是完整的Java后端实现,包含参数校验和错误处理:
@RestController @RequestMapping("/api/wx") public class SubscribeMessageController { @Autowired private WxSubscribeMessageService subscribeMessageService; /** * 发送订阅消息(业务系统内部调用或前端触发) */ @PostMapping("/subscribe/send") public Result sendSubscribe(@RequestBody SendSubscribeMessageDTO dto) { // 校验模板参数 if (!StringUtils.hasText(dto.getOpenId())) { return Result.error("openId不能为空"); } if (!StringUtils.hasText(dto.getTemplateId())) { return Result.error("templateId不能为空"); } boolean success = subscribeMessageService.send(dto); return success ? Result.success() : Result.error("发送失败,请检查参数或用户授权状态"); } }Service实现:
@Service public class WxSubscribeMessageServiceImpl implements WxSubscribeMessageService { @Autowired private WxTokenService wxTokenService; @Override public boolean send(SendSubscribeMessageDTO dto) { // 拼装请求参数 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"); JSONObject data = new JSONObject(); for (Map.Entry<String, String> entry : dto.getDataMap().entrySet()) { JSONObject fieldValue = new JSONObject(); // thing类型限制20字符 String value = entry.getValue(); if (value.length() > 20) { value = value.substring(0, 20); } fieldValue.put("value", value); data.put(entry.getKey(), fieldValue); } body.put("data", data); // 发送 String url = "https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=" + wxTokenService.getAccessToken(); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity<String> entity = new HttpEntity<>(body.toJSONString(), headers); RestTemplate restTemplate = new RestTemplate(); ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class); JSONObject result = JSON.parseObject(response.getBody()); int errCode = result.getIntValue("errcode"); if (errCode != 0) { // 记录错误日志 log.error("发送订阅消息失败,errCode={}, errMsg={}", errCode, result.getString("errmsg")); return false; } // 发送成功,可以在这里入库记录发送历史 return true; } }开发过程中强烈建议先把所有日志打全:openid、template_id、page、errcode、errmsg。订阅消息排错最依赖日志,因为用户侧看不到具体错误,只能靠服务端日志定位问题。
5. 前端接入:调起订阅弹窗与参数校验
后端就绪后,前端要做的事情其实不多,但每个细节都关乎用户体验。前端主要负责两件事:调起订阅授权弹窗和把openid传给后端。
5.1 wx.requestSubscribeMessage基础用法
在用户点击某个触发按钮时,调用wx.requestSubscribeMessage:
// pages/order/confirm.js Page({ data: { orderId: '' }, onLoad(options) { this.setData({ orderId: options.id }); }, // 用户点击"提交订单并订阅通知" handleSubmitOrder() { // 第一步:调起订阅授权 wx.requestSubscribeMessage({ tmplIds: ['TZ9dW9PQo8B-Zzx1OrfpyG2TtGxxxxxxxx'], success: (res) => { // 判断用户是否点击了允许 if (res['TZ9dW9PQo8B-Zzx1OrfpyG2TtGxxxxxxxx'] === 'accept') { // 用户允许了,继续下一步 this.submitOrder(); } else if (res['TZ9dW9PQo8B-Zzx1OrfpyG2TtGxxxxxxxx'] === 'reject') { // 用户拒绝了,也可以继续下单,只是没有通知 this.submitOrder(); } else { // 用户点击了取消,或者弹窗显示异常 wx.showToast({ title: '订阅失败,将无法收到通知', icon: 'none' }); this.submitOrder(); } }, fail: (err) => { console.error('requestSubscribeMessage fail', err); // 接口调用失败,常见原因是基础库版本过低 wx.showToast({ title: '当前微信版本不支持订阅消息', icon: 'none' }); this.submitOrder(); } }); }, submitOrder() { // 实际提交订单的请求 wx.request({ url: 'https://yourdomain.com/api/order/create', method: 'POST', data: { orderId: this.data.orderId }, success: (res) => { // 后端在保存订单后,通过openid发送订阅消息 } }); } });几个关键点:
首次弹窗时机要合适。wx.requestSubscribeMessage只能在用户点击行为(tap)的同步回调里调用,不能在异步请求的回调里调用,否则会弹窗失败。这是微信的限制,目的是防止小程序在非交互场景下随意打扰用户。
授权结果的状态值,除了accept和reject,还有一种情况是返回ban——表示用户之前在系统设置里关闭了订阅消息的授权。这种情况比较麻烦,需要引导用户去设置页重新打开。下面会细说。
一次可以传多个模板ID,最多3个。每个模板会单独弹出确认框让用户选择,一次授权多个模板是很常见的需求,比如同时订阅"发货通知"和"签收通知"。写法上tmplIds传数组即可:
wx.requestSubscribeMessage({ tmplIds: ['TEMPLATE_ID_1', 'TEMPLATE_ID_2', 'TEMPLATE_ID_3'], success: (res) => { // 分别判断每个模板的授权状态 } });注意:一次传多个模板,用户可以选择接受一部分、拒绝一部分,所以一定要逐个模板判断状态,不能只判断一个。
5.2 openid的获取与传递
订阅消息的发送方是后端,而后端需要的touser是用户的openid。所以前端还必须具备获取openid的能力。常用做法:用户登录时,小程序端把wx.login得到的code传给后端,后端用code换openid,并把openid返回给前端缓存。
// 登录 wx.login({ success: (res) => { const code = res.code; wx.request({ url: 'https://yourdomain.com/api/wx/login', method: 'POST', data: { code }, success: (res) => { const { openid, sessionKey } = res.data.data; wx.setStorageSync('openid', openid); } }); } });后端用code换openid的接口:
GET https://api.weixin.qq.com/sns/jscode2session?appid=APPID&secret=SECRET&js_code=CODE&grant_type=authorization_code这个流程属于登录模块的内容,消息订阅发送的时候直接用缓存好的openid即可。
5.3 订阅授权状态检查与引导打开
如果用户点了ban,说明他在微信的设置里关闭了"接收订阅消息",前端任何弹窗都不会再出现。这种情况下,需要引导用户手动打开开关。可以弹出自定义确认框:
wx.showModal({ title: '提示', content: '你已关闭消息接收,请在设置中重新开启后才能收到通知', confirmText: '去设置', success: (res) => { if (res.confirm) { wx.openSetting({ success: (settingRes) => { // 用户从设置页返回后,可以再次尝试请求订阅 if (settingRes.authSetting['scope.subscribeMessage']) { // 已打开,继续业务流程 } } }); } } });注意wx.openSetting只能打开"当前小程序"的设置页,无法打开微信总体的服务通知设置。如果用户在微信"我 -> 设置 -> 新消息通知"里关了服务通知,小程序端是引导不过去的,只能提示用户去微信设置里打开。
6. 端到端实战:用户下单到消息送达的完整链路
前端的弹窗授权和后端的接口发送都讲完了,但把它们串起来才是真正的核心。很多教程只教单个环节,结果读者联调时发现消息发不出去。下面用一个完整的"订单状态通知"案例,把整个链路走一遍。
6.1 业务场景设定
场景:用户在小程序里提交一个商品订单,后台审核通过后,给用户推送一条"审核结果通知"。整个时序如下:
- 用户点击"提交订单"按钮
- 前端调起订阅授权弹窗(模板:审核结果通知)
- 用户点击"允许"
- 前端将订单提交到后端
- 后端保存订单,拿到订单号
- 后端调用微信订阅消息接口,向用户openid发送审核结果通知
- 用户在微信"服务通知"里收到这条消息
- 用户点击消息,跳转小程序对应订单详情页
6.2 前端代码整合
完成授权和订单提交的整合:
// pages/order/confirm.js Page({ data: { orderId: '' }, onLoad(options) { this.setData({ orderId: options.id }); }, // 点击提交订单 onSubmitOrder() { const templateId = 'TZ9dW9PQo8B-Zzx1OrfpyG2TtGxxxxxxxx'; wx.requestSubscribeMessage({ tmplIds: [templateId], success: (res) => { const authResult = res[templateId]; // 不管用户是否允许订阅,订单都可以提交 // 但把授权结果传给后端,后端决定是否发送订阅消息 this.createOrder(authResult === 'accept'); }, fail: (err) => { // 弹窗调用失败,不阻塞下单 console.error('订阅授权失败', err); this.createOrder(false); } }); }, createOrder(canSubscribe) { wx.request({ url: 'https://yourdomain.com/api/order/create', method: 'POST', data: { orderId: this.data.orderId, canSubscribe: canSubscribe }, success: (res) => { if (res.data.code === 0) { wx.showToast({ title: '提交成功', icon: 'success' }); wx.navigateTo({ url: '/pages/order/list' }); } } }); } });这里有个产品设计上的小技巧:订阅授权按钮最好独立于主操作按钮。让用户先点击"订阅通知"按钮完成授权,再点击"提交订单"按钮下单。如果两个操作绑在同一个按钮上,用户面对一个弹窗一个确认框,容易懵,也会影响订单转化率。我实际运营中发现,订单确认页放两个按钮:"开启通知"和"提交订单",比"提交订单并开启通知"这种单一按钮的授权通过率高不少。
6.3 后端同步/异步发送策略
后端收到createOrder请求后,订单保存成功,就要发送订阅消息。这里有两种设计思路:
方案A:同步发送。在创建订单接口里直接调微信接口,用户提交订单请求后,一直等到微信返回再给用户响应。优点是逻辑简单;缺点是微信接口耗时不稳定,高峰期可能几百毫秒甚至一秒,用户的请求被拖慢。
方案B:异步发送。创建订单接口先返回"提交成功",订单保存后扔消息队列或者线程池去处理订阅消息发送。优点是响应快,用户体验好;缺点是需要额外的异步基础设施。
我个人的建议:上线初期用同步,跑顺了再改异步。同步代码少、好定位问题,等业务量上来了再引入MQ。
改造成异步也不复杂。如果项目里已经有RabbitMQ或者RocketMQ,把发送订阅消息封装成一个消息体投递到队列即可。如果没有MQ,用简单的线程池也可以:
@Component public class SubscribeMessageAsyncSender { private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(8); public void sendAsync(SendSubscribeMessageDTO dto) { EXECUTOR.submit(() -> { try { subscribeMessageService.send(dto); } catch (Exception e) { log.error("异步发送订阅消息失败", e); } }); } }注意线程池一定要用有名字的、可监控的配置,别用Executors.newCachedThreadPool,不然生产环境排查问题要抓瞎。
6.4 记录订阅消息发送历史
消息发送不应该是"发完不管"的。推荐建一张发送历史表,方便日后排查和统计:
CREATE TABLE wx_subscribe_message_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, openid VARCHAR(64) NOT NULL, template_id VARCHAR(64) NOT NULL, order_id VARCHAR(64), errcode INT, errmsg VARCHAR(255), send_status TINYINT COMMENT '0失败 1成功', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_openid (openid), INDEX idx_order_id (order_id) ) COMMENT='微信订阅消息发送日志';每次发送都记录,包括失败原因。这样如果用户投诉"没收到通知",查一下send_status=0且errcode=43101,你就能明确告诉用户是因为订阅授权过期了,而不是系统出了bug。
7. 高频踩坑实录:一次性订阅机制引发的四大坑
订阅消息的坑,90%集中在"一次性订阅"这个机制上。下面四个坑,是我在多个项目里都真实遇到过的,逐个说说解决方案。
7.1 用户只授权一次,后端却当天发了两条
场景:用户下单时订阅了"订单通知",结果订单状态从"待审核"变"已审核"发了一条,从"已审核"变"已发货"又发了一条。第二条明明调接口返回成功了,但用户就是收不到。
根因:一次性订阅的授权次数只能用一次。第一次发送成功后,该订阅授权已经消耗掉了,第二次虽然接口没有立即报错(实际返回43101或0,要看时序),但消息不会出现在用户的服务通知里。
解法:发送前先查询本次订阅机会是否还有效。微信没有开放的"查询订阅次数"接口,所以这个状态必须自维护。推荐做法:
- 用户授权成功时,前端把授权状态同步到后端,后端记录
subscribe_count = 1 - 后端每次发送订阅消息后,对应记录的
subscribe_count减1 - 减到0时,不再发送,或者提醒用户"订阅已过期,请重新开启"
更简单的方案:一个订单只发一条订阅消息。把多个通知合成一条模板消息,比如"您有新的订单动态:已审核、已发货",这样只需一次订阅就能承载整个订单生命周期。
7.2 用户授权了却没收到消息
这个场景非常普遍,而且原因多种多样。常见的排查顺序:
- 检查openid。用户在开发者工具里预览和真机上跑,openid可能不一样。体验版环境的openid跟正式版也不一样,要注意别混了。
- 检查miniprogram_state。如果你在正式环境发了
developer或trial的消息,用正式版微信打开是收不到的,服务通知里也不会展示。 - 检查模板ID前后端是否一致。有人改过后台模板,但前端代码里还是旧的模板ID,这也常见。
- 检查邀请体验成员。如果是开发版/体验版的测试,必须在微信后台"成员管理"里加入测试者,非成员收不到体验版消息。
7.3 用户上次拒绝过订阅,下次弹窗不出现
微信的规定:同一个模板在用户拒绝之后,短时间内再次调起wx.requestSubscribeMessage,弹窗会直接不显示。这种场景下你的success回调会执行,但返回的结果是reject,甚至可能直接是fail。这是微信防打扰的设计。
解法:不要为了刷授权率而频繁调起弹窗。产品上可以把订阅入口做成"可选项"——用户不订阅也能正常用,只是收不到通知。用户主动点击"开启通知"时才调起弹窗,比每次进入页面都自动弹窗效果好得多。
如果确实需要再次引导用户订阅,可以走wx.openSetting打开小程序设置页面,但小程序的设置页里没有单独的"订阅消息"开关(订阅消息的授权记录在微信底层,设置页不展示),所以wx.openSetting对恢复订阅弹窗没有直接帮助。真正有效的是:等待一段时间再尝试,微信的防打扰机制有冷却时间,实测一般要过几分钟到几十分钟不等。
7.4 消息模板字段长度超限
前面提到过thing类型限制20个字符,实际操作中还有更多限制:
| 字段类型 | 最大长度 | 说明 |
|---|---|---|
| thing | 20字符 | 名称、商品名等,超长截断 |
| character_string | 32字符 | 单号、编号等 |
| amount | 1个字符串位置 | 金额格式为xx.xx + 单位 |
| time | 具体时间 | 24小时制 |
| phone_number | 17字符 | 手机号 |
| number | 32字符 | 数字 |
| phrase | 5个汉字 | 短语,超长会报47003 |
特别注意phrase类型,只有5个汉字,比如"审核通过""订单取消"这种,超过5个字就报错。另外amount类型,传的时候要把"元"带上或者不带?实测两种写法都支持,但一定不能传类似"99.50元人民币"这种多余的内容。
另一个容易忽略的规则:模板字段的value不能包含换行符。有些业务场景想通过换行做排版,微信接口会直接拒绝。要做多行内容,可以在模板设计时用多个字段承载,而不是塞进一个字段里加"\n"。
8. 进阶实践:消息重试、降级策略与数据分析
订阅消息调试通了只是第一步,真正上线后要考虑稳定性、率控、体验几个维度的问题。这里分享几个进阶的实战思路。
8.1 发送结果回执与自动重试
微信的订阅消息发送接口是即时返回结果的,不像短信有异步回执。所以你要在发送失败的场景自行设计重试策略:
- 网络超时(超时无响应):可以重试,因为接口可能已经成功处理,也可能没处理。这种场景最好配合发送历史表的
幂等键做去重,避免用户收到两条重复消息。推荐在发送前生成一个消息唯一ID,微信接口的请求体虽然不支持幂等键,但你自己在日志和检索层面要有这个标识。 - 业务错误(如43101用户拒绝):不要重试。重试只会再次失败,浪费时间。应该把这个状态透传给业务方,提示用户重新订阅。
- 系统错误(如-1):微信接口偶发系统繁忙,这种可以延迟几秒重试一次,最多重试3次。
public boolean sendWithRetry(SendSubscribeMessageDTO dto, int maxRetry) { int attempt = 0; while (attempt < maxRetry) { try { return subscribeMessageService.send(dto); } catch (WechatApiException e) { if ("-1".equals(e.getErrCode())) { attempt++; Thread.sleep(1000 * attempt); continue; } throw e; } } return false; }8.2 万级用户场景下的发送频控
如果你的小程序日活有几十万,要特别留意消息发送的频率。微信对订阅消息的发送接口虽然没有明确的每日总量限制(只要access_token配额够,理论上可以一直发),但单用户被持续推送通知会引起投诉,严重时可能导致小程序被封禁消息能力。
建议做两件事:
- 用户级频控:单个用户一天最多收到3条订阅消息,超过就不再发。这需要后端维护一个维度为"openid+日期"的计数器。
- 全局限流:在网关层给
subscribe/send接口配一个QPS上限,比如100 QPS,防止异常情况打爆后端。
Redis计数器实现用户级频控,代码非常简洁:
public boolean checkFrequency(String openid) { String key = "wx:sub:count:" + openid + ":" + LocalDate.now(); Long count = redisTemplate.opsForValue().increment(key); if (count != null && count == 1) { redisTemplate.expire(key, 1, TimeUnit.DAYS); } return count != null && count <= 3; }8.3 用发送数据反向优化小程序
消息订阅的数据不光是技术数据,还是产品数据。发送成功率高不高、用户拒绝率高不高,直接反映你的产品设计和用户预期管理。
我自己在项目里会把发送日志做定时统计,关注三个指标:
- 授权点击率:弹窗弹出后用户点"允许"的比例。如果低于50%,说明弹窗时机不对或诱导文案不够清晰。
- 发送成功率:后端调微信接口成功的比例。如果低于95%,重点查access_token缓存、模板参数格式。
- 人均接收条数:每个用户平均收到的消息条数。超过3条就要警惕消息打扰。
这些指标用SQL就能跑出来,配合报表展示。用户授权点击率特别值得关注——它直接影响你后续所有通知类功能的效果。实测经验是:把"订阅通知"按钮做在表单提交按钮同一屏、文案直接告诉用户"订阅后可收到订单进度",能把点击率从30%拉到60%以上。
9. 全栈联调自查清单
最后给一份我自己每次联调订阅消息时都会过一遍的清单,照着检查能省下大量排错时间。
后台配置检查
- [ ] 小程序后台"功能 -> 订阅消息"里能看到已选用的模板
- [ ] 模板ID已复制到前端和后端配置
- [ ] 后端服务器出口IP已添加到IP白名单
- [ ] 正式环境request合法域名已配置
后端检查
- [ ] access_token有Redis缓存,不会每次请求都重新获取
- [ ] 发送接口参数完整(touser、template_id、data)
- [ ] data字段类型与后台模板一致
- [ ] thing类型内容不超过20字符
- [ ] miniprogram_state正式版填的是
formal - [ ] 日志记录了发送结果和错误码
前端检查
- [ ] 订阅弹窗在用户点击行为回调中同步调用
- [ ] 用户拒绝对业务流程无阻塞
- [ ] openid已从后端获取并持久化
- [ ] 多模板场景逐个判断授权状态
真机验证
- [ ] 开发者工具模拟器先行验证
- [ ] 真机上使用"体验版"或"开发版"验证
- [ ] 确认测试账号已在"成员管理"中添加
- [ ] 用户打开"服务通知"能收到消息
- [ ] 点击消息能正常跳转到小程序页面
这套流程走完,消息订阅功能基本就稳了。最后再提一句我在实际项目中的体会:消息订阅接入本身技术难度不大,真正拉开差距的是对"一次性订阅"机制的深刻理解,以及对用户授权时机的产品把控。把这两件事想明白了,订阅消息就是小程序里一个稳定可靠、成本极低的通知渠道。现在可以把这篇教程存下来,下次开发直接照着做,遇到问题回来对着清单排查,能省下不少时间。