用户点过一次就再也不收到了:小程序订阅消息拒收与触达衰减的踩坑复盘
适用读者:正在做微信小程序(Mini Program)且已接入或准备接入订阅消息(subscribe message)的开发者,尤其是把「下单提醒 / 预约提醒 / 服务状态变更」当成核心触达手段的业务线。读过微信开放文档却仍在线上被 43101 打脸的同学,这篇里的坑位你大概率也会踩到。
客服群里的同一个问题,一天被问了七次:「我明明约了洗车,怎么一点提醒都没收到?」 后台日志里消息确实发出去过,返回的错误码是 43101,运营那边却以为「用户订阅过了就能一直发」。 等我们把发送日志和用户授权记录做了全量对齐,才发现触达率不是慢慢往下掉的,而是在几次产品迭代之后断崖式塌下去的。
触达率是怎么塌下去的
这条业务线是洗车预约,下单之后会在门店确认、服务开始、服务完成三个节点各推一条提醒。上线首周发送成功率 92% 上下,报表看着挺正常。

第二个月 71%,第三个月 43%,第四个月只剩 26%,同一时间段的订单量却是涨的。发得越多,触达越少——不是通道抖动,是额度被吃光了。
| 月份 | 待发消息数 | 发送成功数 | 触达率 | 43101 占比 | 当时的判断 |
|---|---|---|---|---|---|
| 第 1 月 | 8210 | 7543 | 91.9% | 3.1% | 正常,偶发拒收 |
| 第 2 月 | 15086 | 10711 | 71.0% | 22.4% | 怀疑 access_token 过期 |
| 第 3 月 | 21344 | 9178 | 43.0% | 51.7% | 怀疑模板被封 |
| 第 4 月 | 28701 | 7462 | 26.0% | 68.9% | 全量日志对齐后定位 |
复盘下来是三个原因叠在一起,而且互相掩盖:
- 一次性订阅(one-time subscription)被当成长期订阅用了,授权一次当成永久有效;
- 用户在弹窗里勾选「总是保持以上选择」并点了取消,之后这个模板再也不会弹窗;
- 模板参数超长返回 47003,我们却把「HTTP 调用成功」当成「下发成功」写进统计,报表是虚高的。
一次性订阅和长期订阅:申请边界比想象中窄
微信把订阅消息分成一次性订阅(one-time subscription)和长期订阅(long-term subscription),两者的申请门槛差得很远。
| 维度 | 一次性订阅 | 长期订阅 |
|---|---|---|
| 申请方式 | 后台「订阅消息」里直接选用公共模板 | 需在对应行业类目下申请,走人工审核 |
| 开放范围 | 全部小程序 | 仅公共服务类目,如政务民生、医疗、交通、金融、教育 |
| 额度模型 | 每授权一次,换一条下发额度 | 一次授权,可长期多次下发 |
| 典型场景 | 下单成功、预约提醒、支付完成 | 医院排队叫号、政务办理进度、快递状态 |
洗车预约属于商业服务,拿不到长期订阅,所以一次性订阅必须严格按「每次授权换一条额度」来建模。
原理剖析:配额模型为什么非要这么设计
这一节把三个最容易误解的机制拆开讲,线上事故基本都能追到对它们的理解偏差上。
一次性语义:授权一次,只换一条额度
订阅消息(subscribe message)这套机制的设计目标,是把「发不发、发几条」的控制权从开发者手里交回用户。上一代的模板消息(template message)只要有 formId 或支付凭证,开发者能在 7 天内自由下发,用户能做的反击只有彻底取关。
一次性订阅把粒度切得更细:用户在弹窗里勾选同意,微信侧的额度账本里这个模板加 1;服务端调用 subscribeMessage.send 下发成功后减 1;额度归零就必须回到前台再要一次授权。
推论很直接:额度是消耗品,不能用「订阅状态」这种布尔值表示。 我们第一版表结构就是 is_subscribed tinyint(1),用户授权三次之后还是 1,第四次发送直接吃到 43101。
长期订阅只是省掉了反复弹窗的成本,用户照样能在服务通知里关掉,账本依然要保留。
「总是保持以上选择」是一个永久开关
授权弹窗底部那个勾选框,官方叫「总是保持以上选择」(always keep the above choice)。用户勾上它并点了「取消」,微信会把这次选择记在用户维度,后续再调用 requestSubscribeMessage 时不再弹窗,直接返回 reject。
于是出现那个诡异现象:用户只在某一次点了取消,之后再也没见过授权弹窗,而开发者拿到的永远是拒绝。拒收发生在弹窗那一刻,不在发送那一刻,而且不可逆——除非用户自己在服务通知设置里重新打开,绝大部分人找不到入口。
客服工单里那句「点过一次就再也不收到了」,并不是错觉。
43101 是怎么一路传回来的
完整链路:小程序端拿到 accept → 上报服务端额度 +1 → 业务事件触发 → 服务端带上 openid 与 template_id 调 subscribeMessage.send → 微信侧校验「该用户对该模板是否还有额度」→ 额度为 0 或处于拒收态 → 返回 {errcode: 43101, errmsg: "user refuse to accept the msg"}。
这里有个反直觉的地方:43101 同样会出现在额度刚好为 0 的情况,而不只是用户主动拒收。 所以收到它不能只打日志,必须清零本地额度并置上拒收标记,否则账本会持续虚高,下次调度还会白发一次。
flowchart TD
A[用户点击预约按钮] --> B{调用 wx.requestSubscribeMessage}
B -->|在用户手势的同步调用栈内| C[弹出授权弹窗]
B -->|在 setTimeout 或 Promise 回调里调用| Z[报错 TAP gesture 校验失败]
C --> D{用户是否勾选总是保持以上选择}
D -->|勾选且点取消| E[(永久拒收态 reject_flag=1)]
D -->|未勾选| F{本次是否勾同意}
F -->|是| G[配额池 quota 加 1]
F -->|否| H[额度不变 下次仍可弹窗]
G --> I[业务事件触发]
I --> J{发送前检查配额}
J -->|quota 大于 0| K[调用 subscribeMessage.send]
J -->|quota 等于 0| P[进入降级触达]
E --> P
K --> L{返回结果}
L -->|errcode 0| M[quota 减 1 并写流水]
L -->|errcode 43101| N[quota 清零 置 reject_flag]
N --> P
L -->|errcode 47003| O[参数错误 回滚额度并告警]
调用时机:不是你想弹就能弹
wx.requestSubscribeMessage 的调用限制比多数人想的严格,我们踩过三条:
- 必须在用户点击(tap)手势的同步调用栈里发起,放在
onLoad、setTimeout、Promise 回调里都会失败; - 失败信息是
requestSubscribeMessage:fail can only be invoked by user TAP gesture,开发者工具里复现不出来,得真机测; - 一次最多传 3 个模板 ID(templateId),超了直接 fail,返回值是逐模板的状态映射,不是布尔值。
依赖版本上,接口要求基础库 2.8.2 及以上,我们把最低基础库锁到 2.15.0,低版本走降级不做授权引导。
// 依赖:微信基础库 2.8.2 及以上,低于该版本需 canIUse 判断后走降级
// 文件:pages/appoint/appoint.js —— 预约页的订阅授权封装
// 限制:单次调用最多传 3 个模板 ID,超出整个调用直接失败
const MAX_TMPL_PER_CALL = 3;
// 三个业务节点对应的模板 ID,在小程序后台「订阅消息」里申请后替换
const TMPL_IDS = [
'TMPL_APPOINT_CONFIRM', // 门店确认
'TMPL_SERVICE_START', // 服务开始
'TMPL_SERVICE_DONE' // 服务完成
];
Page({
// submitting 用于防连点,避免一次点击触发多次授权请求
data: { submitting: false },
// 必须由用户点击直接触发,不能放到 onLoad 或异步回调里
// 否则报 requestSubscribeMessage:fail can only be invoked by user TAP gesture
async onSubmit() {
// 防连点:一次点击只允许走一次授权与下单
if (this.data.submitting) return;
this.setData({ submitting: true });
// 先拉授权再下单,顺序反了会丢掉这次手势的授权结果
const granted = await this.askSubscribe();
// 无论授权结果如何都继续下单,订阅只影响提醒能不能发出去
const order = await wx.cloud.callFunction({
name: 'createOrder',
data: this.data.form
});
// 拿到 accept 的模板上报服务端记账,额度一律由服务端维护
if (granted.length > 0) {
await wx.cloud.callFunction({
name: 'grantQuota',
data: { tmplIds: granted }
});
}
// 收尾,把订单号回写给页面
this.setData({ submitting: false, orderId: order.result.orderId });
},
// 返回本次真正拿到 accept 的模板 ID 列表
askSubscribe() {
// 用 Promise 包一层,让调用方可以 await 授权结果
return new Promise(resolve => {
// 老版本基础库没有这个接口,直接返回空数组走降级触达
if (!wx.canIUse('requestSubscribeMessage')) return resolve([]);
// 截断到 3 个,防止模板数量变多之后整个调用失败
const tmplIds = TMPL_IDS.slice(0, MAX_TMPL_PER_CALL);
// tmplIds 传的是数组,返回里按模板 ID 逐个给状态
wx.requestSubscribeMessage({
tmplIds,
success: res => {
// res 形如 { errMsg: 'requestSubscribeMessage:ok', 'TMPL_X': 'accept' }
// 每个模板的状态可能是 accept reject ban filter 之一
const accepted = tmplIds.filter(id => res[id] === 'accept');
// ban 表示模板被封禁,filter 表示被系统过滤,都要上报
const abnormal = tmplIds.filter(id => res[id] === 'ban' || res[id] === 'filter');
if (abnormal.length) {
wx.reportAnalytics('tmpl_abnormal', { ids: abnormal.join(',') });
}
// 拿到 accept 不代表永久有效,只代表这次多了一条额度
resolve(accepted);
},
// 失败通常出现在非用户手势调用、或模板 ID 未在后台配置
fail: err => {
console.warn('subscribe fail', err.errMsg);
resolve([]);
}
});
});
}
});
配额池:把发送额度当成账本记
想明白额度是消耗品,数据结构就清楚了。我们拆成两张表:sub_quota 存余额与拒收标记,sub_quota_ledger 存每一笔变动,便于对账。
记账规则三条,每一条都是踩出来的:
- 授权成功就
quota = quota + 1,不要写成覆盖 1; - 发送成功才真正扣减,参数类错误要回滚;
- 43101 出现时把
quota置 0 并置reject_flag = 1,让调度层提前切降级通道。
# 依赖:Python 3.9+ / requests / SQLAlchemy 1.4+
# 文件:service/notify/quota.py —— 订阅消息配额池记账与下发
import time
import requests
from sqlalchemy import text
# 微信下发接口地址,access_token 走统一缓存,过期前 5 分钟刷新
SEND_URL = "https://api.weixin.qq.com/cgi-bin/message/subscribe/send"
# 收到这些错误码要清零额度:用户拒收或额度已耗尽
QUOTA_DEAD = {43101}
# 参数类错误不吃额度,需要回滚并告警
PARAM_ERROR = {47003, 40037}
# 其余错误码统一按可重试处理,交给队列重投
def grant(db, openid: str, tmpl_ids: list):
# 授权一次就给对应模板加一条额度,绝不做覆盖式写入
now = int(time.time())
# 逐模板入账,模板之间是互相独立的额度
for tmpl_id in tmpl_ids:
# 幂等键是 openid 加 tmpl_id,重复授权就是累加
db.execute(text("""
INSERT INTO sub_quota(openid, tmpl_id, quota, reject_flag, updated_at)
VALUES(:o, :t, 1, 0, :now)
ON DUPLICATE KEY UPDATE quota = quota + 1, updated_at = :now
"""), {"o": openid, "t": tmpl_id, "now": now})
# 流水单独写一条,事后对账全靠它
db.execute(text("""
INSERT INTO sub_quota_ledger(openid, tmpl_id, change_num, reason, created_at)
VALUES(:o, :t, 1, 'grant', :now)
"""), {"o": openid, "t": tmpl_id, "now": now})
# 提交事务,额度与流水要么同时成功要么同时回滚
db.commit()
def send_or_fallback(db, openid: str, tmpl_id: str, data: dict, token: str):
# 发送前先看额度和拒收标记,不要等微信拒绝再补救
# 这一步能把白发请求挡在调用之前,省掉短信以外的成本
row = db.execute(text("""
SELECT quota, reject_flag FROM sub_quota WHERE openid = :o AND tmpl_id = :t
"""), {"o": openid, "t": tmpl_id}).fetchone()
# 没有记录、额度为 0 或已标记拒收,直接走降级通道
if row is None or row.quota <= 0 or row.reject_flag == 1:
return fallback(openid, data)
# 组织下发参数,data 里的每个值都要按模板字段类型拼好
payload = {
# touser 是用户在本小程序下的 openid
"touser": openid,
"template_id": tmpl_id,
"page": "pages/appoint/detail",
"data": data
}
# 超时给 5 秒,微信侧抖动时宁可失败也不能拖住业务线程
resp = requests.post(SEND_URL, params={"access_token": token}, json=payload, timeout=5)
# 注意 HTTP 200 不代表下发成功,成败只看 body 里的 errcode
result = resp.json()
code = result.get("errcode", 0)
# 下发成功,扣掉一条额度并写流水
# 扣减带 quota > 0 条件,避免并发下扣成负数
if code == 0:
db.execute(text("""
UPDATE sub_quota SET quota = quota - 1, updated_at = :now
WHERE openid = :o AND tmpl_id = :t AND quota > 0
"""), {"o": openid, "t": tmpl_id, "now": int(time.time())})
db.commit()
return "sent"
# 43101 说明本地账本已经失真,清零并标记拒收
# 标记之后后续调度会直接走降级,不再白发订阅消息
if code in QUOTA_DEAD:
db.execute(text("""
UPDATE sub_quota SET quota = 0, reject_flag = 1, updated_at = :now
WHERE openid = :o AND tmpl_id = :t
"""), {"o": openid, "t": tmpl_id, "now": int(time.time())})
db.commit()
return fallback(openid, data)
# 参数类错误不吃额度,但要立刻告警,否则会长期静默失败
if code in PARAM_ERROR:
report_alarm(tmpl_id, code, data)
return "param_error"
# 其余错误码按可重试处理,交给队列重投
return "retry"
def fallback(openid: str, data: dict):
# 降级通道:短信、公众号模板消息、站内信依次尝试
# 具体顺序由下一节的决策流程决定
return "fallback"
参数超限是静默失败的重灾区
模板参数这块我们返工了两次,问题出在对字段类型限制理解得太粗。
| 字段类型 | 限制 | 我们踩过的坑 |
|---|---|---|
| thing | 20 个字符以内,可汉字数字字母组合 | 门店名超长,被截断后下发失败 |
| character_string | 32 位以内数字字母或符号 | 订单号带了中文前缀 |
| phrase | 5 个以内汉字 | 传了「已到店请移步接待区」共 9 字 |
| time | 24 小时制,可带年月日,时间段不超过 24 小时 | 直接传了 ISO8601,带 T 和 Z |
| amount | 币种符号加 10 位以内数字,结尾可带「元」 | 写成「¥ 128.00 元」,中间多了空格 |
| car_number | 8 位以内 | 车牌里带了空格 |
还有一条容易被忽略:thing 类型不接受纯数字,纯英文的 thing 参数在审核与下发环节容易被判为含义不明,建议中英混排或改用 character_string。
参数问题阴险在两点:47003 只给错误码不给字段级提示,排查靠人肉比对;我们早期把 HTTP 调用成功当成下发成功,那两个月的触达率报表是虚高的。
错误码与处置对照
| 错误码 | 含义 | 处置动作 |
|---|---|---|
| 0 | 下发成功 | 扣减额度,写流水 |
| 43101 | 用户拒收或额度已耗尽 | 额度清零,置 reject_flag,转降级 |
| 47003 | 模板参数不准确 | 不扣额度,告警并修参数 |
| 40037 | 模板 ID 不合法 | 检查后台配置与线上环境是否一致 |
| 42001 | access_token 超时 | 刷新凭证后重试 |
降级触达:拒收之后还剩几条路
额度这件事最终要接受一个现实:总有一部分用户永远收不到订阅消息。所以触达体系不能只有一条通道。
我们保留的四条兜底路径,按优先级排:
- 短信:需要手机号授权,有成本,到达率稳定,必须留退订方式;
- 公众号模板消息(template message):同主体公众号,用 UnionID 打通身份;
- 客服消息(customer service message):用户 48 小时内有交互即可下发,预约场景天然带咨询;
- 站内信与入口红点:成本为零,用户不打开就看不到,兜底用。
关键改进是把拒收检测前置:不等发送失败再降级,调度前先读 reject_flag 和 quota 直接分流。改造后短信成本没明显上升,原本白发的请求反而省掉了。
flowchart TD
A[产生一条预约提醒] --> B{读配额池 quota 与 reject_flag}
B -->|quota 大于 0 且未拒收| C[发订阅消息]
B -->|quota 为 0 或已拒收| D{是否绑定手机号}
C --> E{返回 errcode}
E -->|0| F[扣额度 流程结束]
E -->|43101| D
E -->|47003| G[修参数 不扣额度]
D -->|是| H[短信通道 带退订方式]
D -->|否| I{是否关注同主体公众号}
I -->|是| J[公众号模板消息 UnionID 映射]
I -->|否| K{48 小时内有客服交互}
K -->|是| L[客服消息下发]
K -->|否| M[站内信加入口红点]
改完之后回到数据
配额池上线并修掉参数问题之后,同口径数据又跑了一遍:43101 占比从 68.9% 降到 19.4%,整体触达率回到 61%。剩下的绝大多数是早已勾选「总是保持以上选择」的历史用户,只能靠降级通道覆盖。
回过头看,授权成功和下发成功之间隔着一个会消耗的额度,而额度会被用户的一次点击悄悄清零。 把它当账本而不是开关来建模,问题就解决了一半。
参考与延伸
- 小程序订阅消息能力介绍:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/subscribe-message.html
- 前端授权接口 wx.requestSubscribeMessage:https://developers.weixin.qq.com/miniprogram/dev/api/open-api/subscribe-message/wx.requestSubscribeMessage.html
- 服务端下发接口 subscribeMessage.send:https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/subscribe-message/subscribeMessage.send.html
关键词:微信小程序开发、订阅消息、requestSubscribeMessage、43101、触达率、消息模板、降级触达、公众号模板消息