3.3 订阅消息:小程序内通知
场景:免费的午餐,但有门槛
短信按条收钱,一天八十条一月就是小一百。林姐心疼钱不是主要原因,主要原因是客人越来越懒得看短信——很多中年客人把短信通知全关了,只留微信。微信小程序的订阅消息正好长在他们的习惯里:通知出现在微信首页的聊天列表,点开即看,还免费。
但订阅消息有个反直觉的规矩:客人不点授权,你一条都发不了。而且一次授权只换一次发送机会(一次性订阅),想长期发得客人在每次用完小程序后重新点。这个设计是微信保护用户体验的底线,也是我们架构设计里最有弹性的那根弦。
我们的数据也印证了客人确实更爱看微信:同样一条到店提醒,短信打开率约 20%,订阅消息打开率约 45%,差了一倍多。而成本差的是百分之百——订阅消息零成本。这笔账让林姐当场拍板:订阅消息能覆盖的客人绝不走短信。
方案:授权存在哪,发送怎么发
整体链路分三段:小程序端弹窗拿授权、后端记账、到点发送。授权动作由客人在小程序里主动触发,比如下单成功页自动弹出授权请求:
| 环节 | 位置 | 关键动作 |
|---|---|---|
| 授权 | 小程序前端 | requestSubscribeMessage 弹窗 |
| 记账 | 后端 | 表里加一条可用次数 |
| 发送 | 后端定时任务 | 满足条件时调用微信接口 |
| 回执 | 微信回调 | errcode 为 0 表示送达 |
记账表我们建了一张 t_sub_quota,每行是一次授权,字段包括 openid、模板编号、接收次数、状态。为什么单独建表不直接计数?因为同一客人可能在不同业务下授权同一模板多次,次数必须能逐笔追溯,退款或撤回时才能精确扣减。
CREATE TABLE t_sub_quota (
quota_id BIGINT PRIMARY KEY AUTO_INCREMENT,
openid VARCHAR(64) NOT NULL,
tpl_id VARCHAR(32) NOT NULL,
remain INT NOT NULL DEFAULT 1 COMMENT '剩余可发送次数',
KEY idx_openid_tpl (openid, tpl_id)
);
扣减动作必须在发送成功之后做,发送失败不扣次数,这是对客人授权资产的尊重。扣减用原子更新:remain 大于 0 时减一,RowsAffected 为零说明次数耗尽,自动降级到短信通道。
发送走的是微信服务端接口,参数是 openid 加 template_id 加 data。data 里每个键对应该模板在公众平台后台配置的字段,值的格式是固定的:
func sendSubscribeMsg(openid, tplID string, data map[string]SubValue) error {
token := getAccessToken() // 需缓存,两千次每日上限
body := map[string]interface{}{
"touser": openid,
"template_id": tplID,
"page": "pages/booking/detail?id=" + bookID,
"data": data,
}
return postJSON("https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token="+token, body)
}
getAccessToken 是整个链路里最需要小心的一步。微信对 access_token 的获取有每日次数限制,且多处获取会互相挤掉,所以必须全局缓存(我们存在 MySQL 里,有效期按官方提前五分钟刷新),单进程获取。
配置:模板申请与页面挂载
订阅消息模板在小程序后台的消息模板库里挑选或自己申请,审核一般一到三天。我们申请了三个:预约成功通知、到店提醒、排班变更。字段不能自定义全部,只能在模板库提供的字段组合里选,比如到店提醒模板带 store_name、time、service 三个字段。
授权弹窗挂在哪些页面也有讲究。挂早了客人一头雾水点拒绝,挂晚了错过最佳时机。我们最终只挂在两个节点:下单成功页和核销完成页,这两个时刻客人对门店的好感度最高,通过率从第一版首页弹窗的 22% 涨到 61%。
前端的授权调用极其简短,但背后的引导文案花了心思。弹窗是微信原生 UI,改不了样式,能改的只有触发前的页面提示:我们在下单成功页加了一行授权后我们将通过微信提醒您到店,让客人在弹窗出现前就知道点了同意意味着什么。
wx.requestSubscribeMessage({
tmplIds: ['tpl_booking_ok', 'tpl_remind'],
success(res) {
if (res.tpl_booking_ok === 'accept') {
reportGrant('tpl_booking_ok') // 通知后端记账
}
}
})
注意 tmplIds 一次最多三条,客人可以逐条勾选。我们把预约成功和到店提醒打包在一个弹窗里请求,一次点击拿两次发送权,授权效率翻倍。
| 挂载点 | 授权通过率 | 说明 |
|---|---|---|
| 首页 | 22% | 客人毫无预期 |
| 下单成功页 | 61% | 顺手一点 |
| 核销完成页 | 58% | 服务结束心情好 |
长期订阅的口子我们没走通。长期订阅模板仅对特定类目开放(如政务、医疗),美甲不在其中,所以所有模板都是一次性订阅。每发一条消息扣一次次数,次数用完再找机会引导授权。
这条限制反而逼出了一个好设计:因为次数稀缺,我们学会了对每条消息问一句这值得花客人的一次授权吗。短信时代是一个月八十块钱的公众号补贴,订阅消息时代是一次信任的预支,后者其实更贵。
踩坑:订阅消息现场的五个坑
第一个坑是用户拒绝授权不会报错,静默。requestSubscribeMessage 的失败只在 callback 里有,前端必须处理,我们在拒绝时埋了点统计拒绝率,并给拒绝的客人默认走短信通道,保证提醒不丢。
拒绝还有一个边界:客人在弹窗里勾了总是拒绝后,以后连弹窗都不再弹出。我们在后台记录了拒授权标记,这类客人进入页面时不再请求,避免骚扰;但短信兜底名单里永远保留他们。
第二个坑是次数可以累加但有效期不提示。客人今天授权三次、明天授权两次,账上五次,但没有任何界面告诉他还剩几次。我们在我的页面加了一个小角标显示剩余提醒次数,客人看得见才愿意继续授权。这个小角标上线后的两个月里,主动授权率回升了 12 个百分点。
第三个坑是 data 字段格式严格。值必须是对象,带 value 前缀,直接传字符串会报错格式不正确。这种错误在测试环境用接口调试工具测发送时一眼可见,但生产环境第一次真实发送时才暴露过一次。
data := map[string]SubValue{
"time1": {Value: "周六 18:00"},
"thing1": {Value: "法式美甲"},
}
养成习惯:data 构造统一走一个工厂函数,禁止业务代码手工拼 map,格式问题从此消失。
第四个坑是 access_token 缓存过期但本地不知道。token 有效期两小时,中间被另一台机器刷掉就会拿到失效 token,发送全部失败。解决办法是缓存按提前五分钟刷新,且所有发送走同一进程获取。我们的 token 缓存表除了存值还存了刷新时间戳,监控里有一条专门盯 token 刷新间隔,突然变短就意味着有人在别处违规获取。
第五个坑是发送频率别太密。一条预约就发三条订阅消息(预约成功、前一天提醒、当天提醒)看似合理,其实第三次往往次数不够。后来把提醒分配给短信,订阅消息只用在最关键的预约成功节点,有限的次数花在刀刃上。
还有一个隐藏要求:模板里的字段类型和内容长度有限制。thing 类型的值不能超过 20 个字,time 类型有固定格式。我们服务项目名最长的一个是日式光疗手护加手部肩颈按摩,22 个字,发送直接报错。最后把项目简称配一份短名,专门用于通知。
小结
订阅消息是日常提醒的主通道:免费、出现在聊天列表、打开率高。代价是授权管理的心智成本。把授权次数当成一种货币来运营——在客人好感度最高的时候收取、在最关键的节点花掉——这套思路让我们的订阅消息打开率稳定在 45% 左右,是短信打开率的两倍。
整个体系的终极形态是这样一条公式:每次授权买到一次到达客人的特权,而到达率最高的位置是微信聊天列表首页。我们把客人的授权次数看得比钱重,因为钱能再充,客人的耐心只有一次。下一节把短信和订阅消息编成一张兜底网。
延伸阅读
- 短信接入的完整链路,翻 短信接入:腾讯云 SMS
- 触达率的通道编排数据,见 触达策略:多通道兜底
- 小程序商城里的通知做法,翻 小程序商城实战
- 客人资产沉淀的下一步,见 会员体系:积分与复购
- 通道编排的完整策略,翻 触达策略:多通道兜底
