
微信小程序做消息推送前前后后折腾了大半年从最开始的模板消息一路踩到订阅消息中间被各种文档坑得够呛。今天把这段经验完整整理出来标题就叫从模板消息到订阅消息的实战避坑指南——这不仅仅是接口替换的问题整个推送体系的思路都变了。如果你正准备给自己的小程序接入推送或者正在被用户授权了为什么还是发不出去折磨这篇文章应该能帮你少走不少弯路。先说结论消息推送这事方案选型比代码实现重要得多。微信把模板消息砍掉、全面转向订阅消息本质上是把推送主动权从开发者手里拿回来一部分交给用户。你可以在用户每次操作后请求一次订阅授权攒够了授权额度再定向推送。这套机制看起来简单但实际跑起来会发现授权时机、额度消耗、模板字段、调试环境这些环节处处是坑。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?appidAPPIDsecretAPPSECRETjs_codeCODEgrant_typeauthorization_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_typeclient_credentialappid${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_tokenACCESS_TOKEN请求体是一个 JSON{ touser: OPENID, template_id: TEMPLATE_ID, page: pages/order/detail?id123, 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 的格式和长度都有硬性限制thing20 个以内汉字适合放物品名、订单备注等文本number数字类型适合放金额、数量time时间格式需要按指定格式传一般是2024年6月30日 15:00这种样式character_string20 个以内字符适合放订单号、快递单号phrase5 个以内汉字适合放一句话状态我踩过最深的坑是 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 高频报错速查表这里整理了我实际开发中遇到的几个高频错误码建议大家收藏备查错误码错误含义排查思路40001access_token 无效或过期检查 token 缓存逻辑是否多实例互相覆盖重新获取40003openid 不正确确认 openid 是否来自同一小程序前后端环境是否一致40037模板 ID 不正确确认模板 ID 是否 T 开头是否在后台申请通过41030page 路径不正确page 必须以 pages/ 开头且在 app.json 中注册43101用户拒绝接受消息授权额度已消耗或用户拒绝了授权检查授权库存47003参数格式错误检查 data 字段 key、value 类型和长度限制45009接口调用超过限额access_token 是否做了缓存是否触发微信频控43101 是大家遇到最多的错误但它的原因其实就那么几个要么用户确实拒绝了弹窗要么额度已经消耗完了要么授权状态还没来得及同步。第一次遇到建议先等几秒重试一次还不行就查授权库存。5.2 审核合规红线这些操作一碰就凉订阅消息最大的合规红线是诱导授权。公众号后台审核时会重点检查你的页面有没有订阅有礼开启通知送优惠券这类诱导话术。微信对诱导用户开启订阅的行为定性很明确一旦发现轻则模板被清退重则封禁消息推送接口。我亲眼见过一个项目把订阅弹窗和红包活动绑定用户点允许才能领红包上线第二天模板就被封了。微信的逻辑很简单订阅授权必须是用户自愿的、出于真实需求的操作不能跟利益挂钩。另外模板的使用场景要和用户动作保持一致。你在订单发货模板里推送广告内容第一次可能没事被用户举报后微信会倒查到时候整个后台的订阅消息功能都可能被限制。推送内容务必和模板声明的场景强绑定。5.3 我踩过的坑和一些实测心得开发这大半年的小程序消息推送功能我印象最深的是三个教训第一个是授权弹窗频控。有一版产品经理要求每个页面都要引导订阅结果用户从一个页面跳到另一个页面连续被弹了四次订阅框。第二天测试手机就再也弹不出订阅框了微信对用户的保护机制直接把我们拉黑了。后来我们收敛成每个用户生命周期最多弹三次订阅只在最核心的业务节点触发。第二个是 data 字段超长的问题。有个后台配置的功能运营人员填了超过 20 个字的商品名发送时一直报 47003前端页面还看不到具体错误排查了很久才发现是字段长度问题。后来我在后端加了一层参数校验超过长度直接截断并打日志问题再也没出现过。第三个是授权库存的统计口径。刚开始我们只记录了用户授权成功的事件忽略了发送失败和用户主动关闭开关的情况导致运营看的数据和实际情况严重不符。后来把授权、消耗、失败、关闭四种事件全部埋点才算把推送链路的完整数据串起来。最后一个实用技巧调试订阅消息时建议在开发者工具里先把模拟订阅消息的功能用起来这个功能可以让你不用真机弹窗就能测后端发送链路。但注意工具模拟和真机行为存在差异比如授权弹窗的触发时机、频控限制这些最终还是要以真机为准。我一般先用工具调通接口再上真机验证完整链路两边配合能省不少时间。