模板消息与订阅消息:区别、授权与下发
先说一个判决性的结论:你要下发的通知能不能发出去,八成不取决于接口怎么调,而取决于用户在什么时候点过哪个按钮。公众号侧的模板消息和小程序侧的订阅消息,本质都是「先拿到额度,再在额度内下发」,而额度来自用户的主动点击。很多人接口联调通过,上线第一天就发现发十条成功三条,转头怀疑 access_token 有并发问题——其实问题在产品设计阶段就埋下了。
先分清你要下发的到底是哪一种
两者经常被混为一谈,因为场景长得很像:都是「用户做了某件事,过一会儿你给他推一条结构化通知」。但载体不同,能力也不同。
- 公众号模板消息:走公众号会话或服务通知,用户必须关注,模板要在后台申请通过,字段是「关键字 + 值」。
- 小程序订阅消息:走小程序内的服务通知,不要求关注,模板从公共库里选,字段是 thing1、time2、amount3 这样的编号占位符。
- 两者都受授权约束,但形态不同:前者受发送频次与账号能力限制,后者受授权次数与模板类型限制。
选型先问一句:用户在哪里?只在公众号活动就走模板消息;核心场景在小程序、用户可能压根没关注号,就走订阅消息。两者可以并行,但字段命名规则完全不同,别指望一套模板兼容两边。
一次性订阅与长期订阅
小程序的授权分两种形态,差别直接决定你的产品能不能做「持续提醒」。
一次性订阅
用户点一次授权,你拿到一次下发额度,发完就消耗掉了,下次要提醒必须再让他点一次。同一模板下可以连续多次授权,额度会累积,但每次都对应一次真实点击。
长期订阅
用户授权一次,在较长时间内可以多次下发,但只对特定类目开放,普通工具类、电商类小程序通常申请不到。别一开始就把方案压在长期订阅上,先确认自己的类目有没有这个能力。
// 前端请求授权(一次性订阅)
wx.requestSubscribeMessage({
tmplIds: ['模板ID1', '模板ID2'],
success(res) {
// res['模板ID1'] === 'accept' 才算拿到额度
}
})
res 里的值只有三种:accept、reject、ban。accept 才是拿到额度,reject 是这次点了拒绝,ban 是被后台封禁。很多人只看到接口没报错就开始下发,结果自然是空跑。
授权必须由用户点击触发
这是整条链路上最硬的约束:requestSubscribeMessage 只能在用户点击的同步调用栈里发起。你在 setTimeout 里调、在接口回调里调、在页面 onLoad 里调,全部失败或被静默忽略。
正确做法是把授权和业务动作绑在一起:用户点「提交预约」,处理函数里先请求授权,拿到 accept 再提交表单。用户对我点了预约、所以要一条提醒这件事是有预期的,转化率远高于莫名弹一个授权框。反例是进页面就弹,用户不知道你要提醒什么,拒绝率极高,且拒绝后短期内很难再拉起来。另外弹窗每次最多带三个模板 ID,超出的会被忽略,要批量授权就分批,别在一次点击里连弹两次。
模板申请与字段命名规范
模板得先在后台申请或从公共库里选。申请时的关键词列表决定了你能下发什么,事后想加字段只能重新申请一个新模板。所以设计要一次想全,宁可多留一个字段。
字段命名
小程序的字段是「类型前缀 + 序号」,前缀决定了格式约束:thing 是短文本且有长度上限,time 是时间,amount 是金额(一般带币种单位),character_string 适合订单号,phrase 更短,number 是纯数字。超长度会直接导致下发失败或内容被截断,联调时用最长的真实样例压一遍。
公众号模板
公众号模板用 first、keyword1、keyword2、remark 这套结构,字段名固定,靠位置语义对齐。keyword 的顺序不能乱,用户在消息里看到的顺序就是你在请求体里提交的顺序。
下发接口的请求结构
两边都是 POST JSON,都要带 access_token,都不接受表单编码。
POST https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=ACCESS_TOKEN
{
"touser": "OPENID",
"template_id": "TEMPLATE_ID",
"url": "https://your.domain/detail?id=123",
"miniprogram": { "appid": "MINI_APPID", "pagepath": "pages/detail/index?id=123" },
"data": {
"first": { "value": "您的预约已确认" },
"keyword1": { "value": "18:30" },
"keyword2": { "value": "三楼会议室" },
"remark": { "value": "请提前十分钟到场" }
}
}
小程序侧结构类似,但 data 的键就是占位符名字,值同样包一层 value。跳转参数有两个坑:url 只能跳公众号内的网页;pagepath 不能以斜杠开头,页面还必须已发布,否则点进去是白屏。
access_token 的缓存与刷新
全局 access_token 有效期 7200 秒,一天有调用次数上限,必须缓存。但缓存本身有个更隐蔽的陷阱:并发刷新会让旧 token 失效。多个实例同时判断过期、同时刷新,微信侧只保留最新生成的那一个,先拿到的立刻作废,于是你手上出现一批看着新鲜实则已死的 token,接口开始随机报错。
- 集中刷新:所有实例共用一个带锁的入口,只有一个线程真正发起请求。
- 提前刷新:剩余有效期低于安全水位就开始刷新,不必等它真过期。
- 双活兜底:同时保留新旧两个值,旧值再留一小段时间,避免飞行中的请求失败。
- 失败重试一次:凭证类错误强制刷新并重试,但只重试一次,别把额度打爆。
素材、二维码等接口同样吃这个 token,所以刷新策略必须全局唯一。
跳转参数与常见失败原因
失败绝大部分不是网络问题,而是权限或额度问题。下面按发生频率排。
| 错误码 | 原因 | 处理 |
|---|---|---|
| 43004 | 用户未关注公众号 | 引导关注,或改用小程序订阅消息 |
| 43101 | 用户拒绝或未授权该模板 | 在业务动作里重新引导一次授权 |
| 40003 | openid 不正确 | 确认 openid 与当前 appid 属同一账号体系 |
| 40001 | 凭证失效,多为并发刷新导致 | 统一缓存入口,强制刷新后重试一次 |
| 47003 | 模板参数不合法,如超长或空值 | 按字段类型校验长度,空值也给占位文案 |
场景对应与迁移注意
| 场景 | 用哪种 | 理由 |
|---|---|---|
| 订单状态变更、发货提醒 | 小程序一次性订阅 | 下单天然是点击时机,额度可控 |
| 预约到店、课程开课 | 小程序一次性订阅 | 提交预约时授权,提醒一次即够 |
| 存量粉丝的活动通知 | 公众号模板消息 | 用户已在公众号内,触达路径最短 |
| 用户没关注号的小程序业务 | 小程序订阅消息 | 不依赖关注关系 |
| 周期性提醒、多次触达 | 先确认类目能否长期订阅 | 做不到就只能每次攒额度 |
迁移时先做一件事:把授权和下发解耦。授权发生在用户端,你只记录「某用户对某模板还剩几次额度」;下发发生在服务端,只消费额度。这样后端换哪种接口,前端都不用改。同时提前把字段长度上限做成校验规则,别等上线后被 47003 一条条打回来。





