用户点过一次就再也不收到了:小程序订阅消息拒收与触达衰减的踩坑复盘

2026-09-21 01:35:47 1 次浏览
微信小程序订阅消息消息推送性能优化踩坑复盘

适用读者:正在做微信小程序(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)手势的同步调用栈里发起,放在 onLoadsetTimeout、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_flagquota 直接分流。改造后短信成本没明显上升,原本白发的请求反而省掉了。

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、触达率、消息模板、降级触达、公众号模板消息

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。