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% 左右,是短信打开率的两倍。

整个体系的终极形态是这样一条公式:每次授权买到一次到达客人的特权,而到达率最高的位置是微信聊天列表首页。我们把客人的授权次数看得比钱重,因为钱能再充,客人的耐心只有一次。下一节把短信和订阅消息编成一张兜底网。

延伸阅读