微信小程序做消息推送,前前后后折腾了大半年,从最开始的模板消息一路踩到订阅消息,中间被各种文档坑得够呛。今天把这段经验完整整理出来,标题就叫"从模板消息到订阅消息的实战避坑指南"——这不仅仅是接口替换的问题,整个推送体系的思路都变了。如果你正准备给自己的小程序接入推送,或者正在被"用户授权了为什么还是发不出去"折磨,这篇文章应该能帮你少走不少弯路。
先说结论:消息推送这事,方案选型比代码实现重要得多。微信把模板消息砍掉、全面转向订阅消息,本质上是把"推送主动权"从开发者手里拿回来一部分交给用户。你可以在用户每次操作后请求一次订阅授权,攒够了授权额度再定向推送。这套机制看起来简单,但实际跑起来会发现授权时机、额度消耗、模板字段、调试环境这些环节处处是坑。
1. 先把规则吃透:模板消息为什么退场,订阅消息到底怎么运作
1.1 模板消息退场的真实原因:不是技术不行,是容易骚扰用户
老开发者应该都记得模板消息的流程:用户在小程序里完成一次操作后,弹窗征求用户同意,同意后开发者就能在后续任意时间点给用户推送模板消息,而且推送次数不受严格限制。我早期的项目就是靠这个做订单状态通知的,体验确实方便,用户只要授权一次,后面发货、签收、售后每个节点都能推。
但问题恰恰出在"授权一次,永久使用"上。很多小程序把模板消息当免费短信用,用户稍微有点互动就狂推营销内容,最终结果就是用户被骚扰到直接把小程序的通知权限关了。微信官方后来把模板消息逐步下线,新注册的小程序后台已经看不到模板消息入口,老接口也在 2020 年初彻底停用。本质原因就一句话:这种授权模式没法约束开发者,必须改成"一次授权、一次推送"的单次消耗模型,这就是订阅消息的雏形。
1.2 订阅消息的规则核心:授权一次,只能推一条
订阅消息最核心的规则就一句:用户的每次"允许"授权,只对应一次消息推送的额度。用户点了"允许",你拿到一条推送额度,用掉之后想再推,必须让用户再次授权。
这个设计意味着你不能像以前那样"先囤授权再慢慢推",而要把推送动作和用户的具体操作强绑定。比如用户下单成功后你弹订阅框,拿到额度后立刻发送"下单成功通知",这就是最标准的一次消耗闭环。如果你想在发货时再推一条,就要在用户下单时多次弹窗请求授权,或者等到发货前再找机会触发授权弹窗。
很多第一次做订阅消息的开发者会习惯性地问:用户每点一次允许我只能推一条,那我的业务有五个节点需要通知怎么办?答案只有一个:你的业务需要在不同的用户动作节点上,分别去拿对应的授权。比如下单节点拿"下单成功通知"的授权,发货节点拿"发货提醒"的授权,每一个节点独立授权、独立消耗。
1.3 一次性订阅和长期订阅,别再混为一谈了
订阅消息分为两种:一次性订阅消息和长期订阅消息。
一次性订阅消息是目前绝大多数小程序都在用的类型,任何主体都能申请,用户每次授权对应一条推送额度。
长期订阅消息则完全不同。它允许开发者一次授权后,在用户没有再次操作的情况下多次推送,但它对行业类目有严格限制,仅限医疗、政务、金融、教育等民生服务领域,个人主体和绝大多数普通企业主体根本没资格申请。我在后台翻过类目列表,普通电商、工具类目基本找不到长期订阅的入口。
所以对大部分开发者来说,只需要无脑关注一次性订阅消息就行。如果有人在技术社区跟你说"我这里可以开通长期订阅",基本可以断定是违规代开通或者营销骗局,微信对这种灰色操作的打击力度很大,不要碰。
2. 前端实战:授权链路从 openid 开始
2.1 openid 从哪来:code 换 openid 的基础流程不能错
订阅消息推送时,后端必须知道接收者的 openid,这个 openid 是每个用户在每个小程序下的唯一标识。获取 openid 的标准姿势就是 wx.login 拿 code,然后后端拿着 code 换 openid 和 session_key。
前端只有一小段代码:
// 前端:用户进入小程序时执行 wx.login({ success(res) { if (res.code) { // 把 code 传给后端 wx.request({ url: 'https://your-api.com/api/login', data: { code: res.code }, success(result) { // 后端返回 openid,前端可以存起来备用 console.log(result.data.openid) } }) } } })后端拿到 code 后调微信的 jscode2session 接口:
GET https://api.weixin.qq.com/sns/jscode2session?appid=APPID&secret=APPSECRET&js_code=CODE&grant_type=authorization_code注意两个坑:第一,这个接口用的是 appid 加 secret,不需要 access_token,很多人会下意识以为所有微信接口都要带 access_token,结果在这里先卡一道;第二,code 有效期只有 5 分钟,而且只能用一次,用完作废。我之前排过一个线上问题,前端并发请求把同一个 code 用了两次,第二次直接报 invalid code,排查了半天才发现是重复消费了。
换回来的结果是一个 JSON,包含 openid 和 session_key。openid 建议在后端直接和用户体系绑定,不要反复通过前端传来传去,避免被伪造。
2.2 授权弹窗的正确唤起姿势:别在 onLoad 里瞎弹
wx.requestSubscribeMessage 是前端拉起订阅授权弹窗的接口,但它的调用时机非常有讲究。最基础的规则是:必须在用户点击行为(tap)的同步回调里调用,不能在 onLoad、onShow 这些生命周期里直接弹,否则在某些基础库版本下会出现"接口调用成功但弹窗死活不出来"的情况,或者被微信静默降级处理。
更深一层的问题是频控。如果用户在一段时间内被你反复弹窗询问订阅,微信会直接限制你的弹窗唤起权限。我实测下来的体感是:同一用户同一模板短时间内弹两次以上,第二次弹窗出来的概率就明显下降;如果用户连续拒绝两三次,后面基本就弹不出来了。
所以正确做法是:不要在页面加载时就请求订阅,而要把订阅动作绑定到真实的业务操作节点上。比如下单成功、支付完成、报名成功这些用户主动完成动作的按钮回调里,顺势弹出订阅框。用户刚完成一个动作,心理预期里确实需要收到后续通知,这时候弹窗的接受率最高。
2.3 前端实操代码:一个干净的订阅按钮示例
下面这个示例是支付成功后引导用户订阅订单状态通知的标准写法。
// 支付成功回调里触发订阅 function handlePaySuccess(orderId) { // 先做业务请求,再拉起订阅 wx.requestSubscribeMessage({ tmplIds: ['TEMPLATE_ID_HERE'], // 在 mp 后台申请到的模板 ID success(res) { // 返回结果是一个对象,key 是模板 ID if (res['TEMPLATE_ID_HERE'] === 'accept') { // 用户点了允许,拿到一条推送额度 // 把授权结果上报后端,由后端记录授权库存 reportSubscribeAuth(orderId, 'TEMPLATE_ID_HERE') } else if (res['TEMPLATE_ID_HERE'] === 'reject') { // 用户拒绝,不要反复弹 console.log('用户拒绝了订阅') } else if (res['TEMPLATE_ID_HERE'] === 'ban') { // 被微信限制弹窗,需要引导用户去设置页手动开启 console.log('订阅被限制') } }, fail(err) { // 弹窗唤起失败,常见原因是调用时机不对或频控 console.error('订阅调用失败', err) } }) }重点说一下返回值的判断。wx.requestSubscribeMessage 的 success 回调里,返回值是一个以模板 ID 为 key 的对象,value 有三种情况:accept 表示允许、reject 表示拒绝、ban 表示被限制。很多人只判断了 success 就默认用户一定允许了,这是不严谨的。必须根据模板 ID 逐个取 value,再看是不是 accept。
另外 tmplIds 参数一次最多传 3 个模板 ID,这是官方限制。但我实际测试下来,一次弹 3 个模板的转化率会明显下降,用户看到三连弹窗往往直接全拒。我的建议是一个业务节点只弹一个最相关的模板,宁可多设计几个触发节点,也别在一个弹窗里塞多个模板。
3. 后端发送:access_token、模板字段与接口调试
3.1 access_token 的缓存策略:别每次都去换,会被限流
后端发送订阅消息前必须拿到 access_token,这是调用所有微信 cgi-bin 接口的通行证。access_token 的有效期是 7200 秒(两小时),每次获取都有频率限制:每日获取上限是 2000 次。如果用户量稍微上来一点,每次发送都现取 token,很容易把 2000 次配额打爆,接着就会报 45009(接口调用超过限额)。
我比较推荐的做法是内存缓存加过期时间,JVM 或 Node 进程里挂一个定时刷新任务,提前 5 分钟把 token 续上。伪代码如下:
let cachedToken = null let tokenExpireTime = 0 async function getAccessToken() { // 提前 5 分钟刷新,避免边缘过期 if (cachedToken && tokenExpireTime - 300 > Date.now()) { return cachedToken } const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${APPID}&secret=${APPSECRET}` const res = await axios.get(url) cachedToken = res.data.access_token tokenExpireTime = Date.now() + res.data.expires_in * 1000 return cachedToken }如果是多实例部署,建议把 token 放到 Redis 里,加锁更新,避免多个实例同时去刷新导致 token 互相覆盖。这一点在线上环境很重要,我见过测试环境单机跑着没事,一上生产多实例部署立刻出现 40001 的案例,原因就是各实例各自缓存了不同的 token,后一个获取的把前一个顶掉了。
3.2 订阅消息发送接口:Node.js 完整示例
发送订阅消息的接口是:
POST https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=ACCESS_TOKEN请求体是一个 JSON:
{ "touser": "OPENID", "template_id": "TEMPLATE_ID", "page": "pages/order/detail?id=123", "miniprogram_state": "formal", "lang": "zh_CN", "data": { "thing1": { "value": "您的订单已发货" }, "time2": { "value": "2024年6月30日 15:00" }, "character_string3": { "value": "SF1234567890" } } }对应 Node.js 后端代码:
const axios = require('axios') async function sendSubscribeMessage(openid, templateId, data, page) { const token = await getAccessToken() const url = `https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${token}` const body = { touser: openid, template_id: templateId, page: page || 'pages/index/index', miniprogram_state: 'formal', lang: 'zh_CN', data } const res = await axios.post(url, body) if (res.data.errcode === 0) { // 发送成功,同时消耗一条用户授权额度 return { success: true } } else { // 发送失败,根据 errcode 做不同处理 return { success: false, errcode: res.data.errcode, errmsg: res.data.errmsg } } }这里有一个很多新手会卡住的点:miniprogram_state 参数。它有三个可选值:developer(开发版)、trial(体验版)、formal(正式版)。如果你当前测试的小程序是体验版,但 miniprogram_state 传了 formal,接口可能返回成功,但用户手机上根本收不到这条订阅消息。反过来也一样,正式版环境用 developer 也收不到。
正确的测试姿势是:开发调试时用 developer 或 trial,发布上线后用 formal。我当时就是在体验版环境测试,忘了改这个参数,结果后端日志显示发送成功,手机一直收不到,排查了一个下午才发现是环境状态不匹配。
3.3 data 字段匹配:模板字段的类型和长度限制
订阅消息的 data 字段是最容易报 47003(参数格式错误)的地方。你在 mp 后台申请模板时,模板里每个字段都有固定的 key,比如 thing1、time2、number3、character_string4 这些。data 里的 key 必须和模板字段完全一致,少一个、多一个、key 名写错都会直接报错。
每种字段类型对 value 的格式和长度都有硬性限制:
- thing:20 个以内汉字,适合放物品名、订单备注等文本
- number:数字类型,适合放金额、数量
- time:时间格式,需要按指定格式传,一般是"2024年6月30日 15:00"这种样式
- character_string:20 个以内字符,适合放订单号、快递单号
- phrase:5 个以内汉字,适合放一句话状态
我踩过最深的坑是 thing 和 phrase 的长度限制。开发时测试数据短没事,一上线用户输入长一点就直接报 47003。有个比较稳妥的处理:后端在组装 data 之前,先对每个字段做长度截断或校验,超长的给用户提示,别让脏数据打到微信接口。
4. 从模板消息迁移到订阅消息:一份改造清单
4.1 模板 ID 的变化:从老接口到新模板 ID
模板消息时代,模板 ID 一般是一串很长的数字加字母混合的值;订阅消息的模板 ID 则是 T 字母开头的一串字符。你在 mp 后台的"订阅消息"模块里申请模板,审核通过后就能拿到 T 开头的模板 ID。
申请的路径是:登录微信公众平台 → 功能 → 订阅消息 → 公共模板库 → 选类目 → 选关键词 → 组合成自定义模板。关键词是从该行业类目下的固定词库里选的,不能自己随意造词。比如电商类目下会有"订单发货提醒""物流签收通知"这些关键词可用。
申请审核一般比较快,快的时候几小时就过了,慢的话一到两个工作日。我建议提前把业务需要的模板一次性申请好,别等上线了再补,审核期间业务会卡住。
从模板消息迁移到订阅消息时,你旧的后端逻辑里所有调用模板消息发送接口的地方都要换成订阅消息接口,模板 ID 全部替换,授权逻辑也要重写。相比代码改动,业务逻辑的调整更关键:模板消息是"一次授权,无限推送",订阅消息是"一次授权,一条推送",你的推送节点设计、授权触发时机全都要重新规划。
4.2 授权库存管理:把每条授权当成一种"资产"
订阅消息的授权额度是稀缺资源,不能随用随丢。我建议后端建一张表专门记录授权和消耗情况:
- 用户 openid
- 模板 ID
- 授权时间
- 是否已消耗
- 消耗时间
- 关联的业务单号
用户在哪个业务节点授权了哪个模板,额度是否已经用来发送过消息,都要能随时查出来。后面如果用户投诉"我没授权怎么给我推消息",你可以直接拉出这张表自证清白。
这里涉及一个关键的消费逻辑:当你调用发送接口返回 errcode 0 之后,微信默认消耗了用户的一次授权。如果返回的是 43101(用户拒绝),说明当前没有可用授权额度,这次发送不消耗任何额度。但要注意一种特殊情况:用户刚在前端点完"允许",你立刻在后端发消息,有一定概率仍然返回 43101,因为微信服务端对授权状态的写入存在轻微延迟。我的解决办法是:授权后不立即发送,而是把发送任务丢进延迟队列,等 5 到 10 秒再发,实测能把 43101 的概率降到很低。
4.3 提高送达率的三个手段:别只会调接口
订阅消息能不能真正到达用户手机,接口返回成功只是第一步,用户是否愿意点开、是否愿意继续授权才是关键。
第一,授权弹窗的时机要贴近用户真实需求。下单成功后问"要不要接收发货提醒",通过率很高;用户刚打开首页就弹"允许我们给你推送消息",基本是找拒。把订阅动作嵌到业务流程里,而不是做成独立环节。
第二,推送内容要一条是一条。订阅消息的本质是服务通知,不是营销短信。模板里能放的字数有限,你更应该确保每条消息对用户有实际价值。我见过一个电商项目,用户一注册就被弹订阅弹窗,通过率不到 10%;后来改成支付完成页弹"发货通知",通过率直接翻倍。
第三,要关注用户主动关闭通知的情况。用户在小程序右上角的"..."菜单里可以关闭整个小程序的服务通知开关,关闭后你发订阅消息,接口照样返回成功,但用户收不到。这不是技术能解决的,只能靠内容质量把用户"求回来"。
5. 高频报错排查与避坑实录
5.1 高频报错速查表
这里整理了我实际开发中遇到的几个高频错误码,建议大家收藏备查:
| 错误码 | 错误含义 | 排查思路 |
|---|---|---|
| 40001 | access_token 无效或过期 | 检查 token 缓存逻辑,是否多实例互相覆盖,重新获取 |
| 40003 | openid 不正确 | 确认 openid 是否来自同一小程序,前后端环境是否一致 |
| 40037 | 模板 ID 不正确 | 确认模板 ID 是否 T 开头,是否在后台申请通过 |
| 41030 | page 路径不正确 | page 必须以 pages/ 开头,且在 app.json 中注册 |
| 43101 | 用户拒绝接受消息 | 授权额度已消耗或用户拒绝了授权,检查授权库存 |
| 47003 | 参数格式错误 | 检查 data 字段 key、value 类型和长度限制 |
| 45009 | 接口调用超过限额 | access_token 是否做了缓存,是否触发微信频控 |
43101 是大家遇到最多的错误,但它的原因其实就那么几个:要么用户确实拒绝了弹窗,要么额度已经消耗完了,要么授权状态还没来得及同步。第一次遇到建议先等几秒重试一次,还不行就查授权库存。
5.2 审核合规红线:这些操作一碰就凉
订阅消息最大的合规红线是诱导授权。公众号后台审核时会重点检查你的页面有没有"订阅有礼""开启通知送优惠券"这类诱导话术。微信对诱导用户开启订阅的行为定性很明确,一旦发现,轻则模板被清退,重则封禁消息推送接口。
我亲眼见过一个项目把订阅弹窗和红包活动绑定,用户点"允许"才能领红包,上线第二天模板就被封了。微信的逻辑很简单:订阅授权必须是用户自愿的、出于真实需求的操作,不能跟利益挂钩。
另外,模板的使用场景要和用户动作保持一致。你在"订单发货"模板里推送广告内容,第一次可能没事,被用户举报后微信会倒查,到时候整个后台的订阅消息功能都可能被限制。推送内容务必和模板声明的场景强绑定。
5.3 我踩过的坑和一些实测心得
开发这大半年的小程序消息推送功能,我印象最深的是三个教训:
第一个是授权弹窗频控。有一版产品经理要求每个页面都要引导订阅,结果用户从一个页面跳到另一个页面,连续被弹了四次订阅框。第二天测试手机就再也弹不出订阅框了,微信对用户的保护机制直接把我们拉黑了。后来我们收敛成"每个用户生命周期最多弹三次订阅",只在最核心的业务节点触发。
第二个是 data 字段超长的问题。有个后台配置的功能,运营人员填了超过 20 个字的商品名,发送时一直报 47003,前端页面还看不到具体错误,排查了很久才发现是字段长度问题。后来我在后端加了一层参数校验,超过长度直接截断并打日志,问题再也没出现过。
第三个是授权库存的统计口径。刚开始我们只记录了用户授权成功的事件,忽略了发送失败和用户主动关闭开关的情况,导致运营看的数据和实际情况严重不符。后来把授权、消耗、失败、关闭四种事件全部埋点,才算把推送链路的完整数据串起来。
最后一个实用技巧:调试订阅消息时,建议在开发者工具里先把"模拟订阅消息"的功能用起来,这个功能可以让你不用真机弹窗就能测后端发送链路。但注意工具模拟和真机行为存在差异,比如授权弹窗的触发时机、频控限制这些,最终还是要以真机为准。我一般先用工具调通接口,再上真机验证完整链路,两边配合能省不少时间。