公众号 48 小时窗口怎么用满:客服消息接口的会话管理与被动回复实战

2026-09-28 01:19:13 0 次浏览
微信公众号公众号开发客服消息会话管理消息接口

适用读者:正在做微信公众号(尤其是服务号)后端开发的工程师;被 errcode=45011、950005 折磨过的人;需要给活动参与用户批量发提醒、又不想接订阅通知那套流程的开发者。要求对公众号服务器配置(URL 验证、XML 消息回调)有基本了解。

活动结束当晚,运营在群里丢了一句话:能不能给昨天参加打卡活动的 3000 多个用户发一条领奖提醒。我顺手就把用户 openid 列表拉出来,写了十几行代码循环调客服消息接口,前 200 个还算正常,然后开始批量刷 errcode=45011,中间还夹着一堆 950005。当晚查文档查到十二点多才彻底想明白:客服消息根本不是干这个用的,我把它当推送接口用了。这篇文章把那次踩坑涉及的全部机制、状态表设计和限速队列实现一次性讲清楚,省得你再走一遍。

先看现场:两种报错长什么样

先交代环境:服务号(已认证),后端 Python 3.10 + FastAPI,微信侧配置了服务器消息回调,Redis 6 做会话状态缓存。当晚的报错原文就两种:

公众号会话窗口与消息队列

{"errcode":45011,"errmsg":"api frequency code is limited hint: [lK0.aA0e-XXXXX]"}
{"errcode":950005,"errmsg":"user out of session scope hint: [qW3.bG0e-XXXXX]"}

45011 是接口调用频率限制,微信对客服消息接口的频控是按公众号维度算的,实测单凭一晚的表现估算,大概到每分钟 100 次左右就开始持续返回 45011,之后哪怕降到每分钟 30 次也还会被压几分钟才恢复。950005 则更隐蔽——目标用户跟公众号的会话窗口已经关闭了,这条消息从协议层就不允许发出去,跟频率没有任何关系。

当晚 3147 个 openid,发完统计下来:成功 1892 个,950005 报了 1124 个,45011 报了 131 个。窗口超时比例超过三分之一,这个数字直接决定了后面所有方案的设计方向。

机制剖析:48 小时会话窗口到底怎么算

窗口从哪来,到哪去

客服消息(Customer Service Message)的会话规则核心一句话:用户在 48 小时内与公众号发生过一次有效交互,公众号就获得一段发消息的许可窗口,且每个窗口内对同一下发通道有条数上限(5 条)。 这个规则在微信官方文档里写得比较分散,实际开发时容易只记住 48 小时,忘了条数上限和"哪些事件算有效交互"。

判定某次交互是否开启窗口,实测和文档交叉验证的结论如下表:

用户行为 是否开启 48h 窗口 备注
发送文字/图片/语音等消息 是 最标准的触发方式
点击公众号自定义菜单(click/view) 是 点击事件也会开窗,容易被忽略
关注/取消关注事件 是 取消关注再关注会重置窗口
扫带场景值的二维码(subscribe 事件) 是 与普通关注同
仅浏览图文、分享、点赞 否 不产生任何推送事件
被动回复本身 否 回复不消耗、也不延长窗口

48 小时从最后一次有效交互的时间点起算。用户 20:00 发了一条消息,窗口就是 20:00 到第三天 20:00。期间用户又点了一次菜单,窗口起点刷新到新的时间点。条数上限是按"一条用户消息对应最多 5 条下行"计的,5 条用完后即使窗口没过 48 小时,再调接口就是 950005。

mermaid 画一下窗口生命周期,对着图理解比看文字快:

flowchart LR
    A[用户发送消息/点击菜单/关注] --> B[会话窗口开启 48h 计时开始]
    B --> C{窗口内下行条数 < 5}
    C -- 是 --> D[客服消息接口可发送]
    D --> C
    C -- 否 --> E[条数用尽 返回 950005]
    B --> F[距最后交互超过 48h]
    F --> E
    A --> G[用户再次交互]
    G --> B

被动回复、客服消息、模板消息的边界

这三个通道混着用是新手最常见的错误,我当晚就是这么干的。三者的本质区别在于"谁有资格发起一次对话":

通道 触发前提 适用场景 典型误用
被动回复(XML 回包) 5 秒内响应微信服务器的消息/事件推送 对用户刚发的消息做应答 5 秒内不回导致用户收到"该公众号暂时无法提供服务"
客服消息 48h 窗口内 + 窗口剩余条数 > 0 对最近交互过的用户做即时跟进 拿来给活动名单批量推送,窗口外全军覆没
模板/订阅通知 用户订阅过对应模板 交易提醒、活动结果等可预期的通知 未订阅就调接口,报 43101

判断逻辑可以压缩成一行:即时应答走被动回复,窗口内跟进走客服消息,可预期的业务通知走模板或订阅通知,客服消息不能当推送用。 活动领奖提醒属于"结果类通知",正确路径本来是订阅通知,但活动上线时没做订阅引导,临时补救只能对窗口内还活着的用户发客服消息,窗口外的用户就只能等他们下次交互。

会话状态表设计

想用满窗口,前提是知道每个用户的窗口状态。我们把状态落在 MySQL 一张表 + Redis 一层缓存上,结构如下:

CREATE TABLE wx_session (
  openid       VARCHAR(32)  NOT NULL,
  -- openid 是会话状态的主键,一个用户一行
  last_interact_at DATETIME NOT NULL COMMENT '最后一次有效交互时间',
  remain_count TINYINT      NOT NULL DEFAULT 5 COMMENT '窗口内剩余可发条数',
  -- 每发一条客服消息减 1,减到 0 就跳过该用户
  expire_at    DATETIME     NOT NULL COMMENT '窗口到期时间=last_interact_at+48h',
  updated_at   DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (openid),
  KEY idx_expire (expire_at, remain_count) COMMENT '批量发送只捞窗口未到期且有余量的',
  -- 冗余的唯一性约束兜底,防止并发回调重复插入
  UNIQUE KEY uk_openid_exp (openid, expire_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

几个设计决策说明一下:

  • 剩余条数用减法不用加法。 每成功调一次客服消息接口,remain_count 减 1,减到 0 就不再调接口,避免白白吃一次 950005 还消耗接口频控额度。
  • 索引按 expire_at 建,批量发送的查询条件永远是 expire_at > NOW() AND remain_count > 0,走这个联合索引扫出来的就是"现在还能发"的用户。
  • 交互事件(消息、菜单点击、关注)到达回调时,执行 INSERT ... ON DUPLICATE KEY UPDATE last_interact_at=..., remain_count=5, expire_at=...,一句话把窗口重置,不用查了再改。
  • Redis 层缓存 session:{openid} 的哈希(last_interact_at、remain_count),TTL 设 48 小时,高频读取不过数据库。缓存与 DB 以回调写入为准,发送失败不回滚计数——宁可少发一条,不能多发到报错。

事件回调入口的伪代码(FastAPI 处理 XML 那部分省略,只看状态更新):

def on_user_interact(openid: str, event_type: str) -> None:
    # 消息、菜单点击、关注事件统一走这个入口
    # 取消关注也要记录:窗口保留但发送会失败,可提前过滤
    now = datetime.now()

    # 窗口重置:每条有效交互给满 5 条额度
    # expire_at 固定加 48 小时,不做任何宽限
    # ON DUPLICATE KEY 一句话完成重置,免查询开销
    db.execute(
        """
        INSERT INTO wx_session (openid, last_interact_at, remain_count, expire_at)
        VALUES (%s, %s, 5, %s)
        ON DUPLICATE KEY UPDATE
            last_interact_at = VALUES(last_interact_at),
            remain_count = 5,
            expire_at = VALUES(expire_at)
        """,
        (openid, now, now + timedelta(hours=48)),
    )

    # 同步刷 Redis 缓存,TTL 与窗口等长
    # 后续批量发送只读缓存,不做穿透
    r.hset(f"session:{openid}", mapping={"remain": 5, "expire": (now + timedelta(hours=48)).timestamp()})
    r.expire(f"session:{openid}", 48 * 3600)

Access Token 集中管理

批量发送场景下 token 管理不当,会直接把 45011 提前触发——每个进程各自拿 token,get_access_token 接口本身也有每日调用上限(官方文档标注为 2000 次/天),而且新 token 获取会让旧 token 在 5 分钟后失效,多进程互相踢是经典事故。

我们的做法是单点刷新:一个独立的小服务(或定时任务)统一刷 token,写到 Redis wx:access_token,TTL 按返回的 expires_in 减 300 秒(提前 5 分钟刷新,留网络抖动余量)。业务侧只读 Redis,任何代码不允许直连微信拿 token:

# token 获取只允许这一个入口,业务代码一律走这里
# 独立成模块,任何散落的 requests 直连都算事故
def get_token() -> str:
    # 先读 Redis,命中率决定刷新接口的日调用量
    token = r.get("wx:access_token")
    # 命中直接返回,避免高频打刷新接口
    if token:
        return token.decode()

    # 未命中时加分布式锁,防止并发进程同时刷新
    # 互相踢旧 token 造成大面积 40001
    lock = r.set("wx:token_lock", 1, nx=True, ex=10)
    # 拿不到锁说明别的进程正在刷,稍等再读缓存
    if not lock:
        time.sleep(0.5)
        return get_token()

    # 拉新 token,grant_type 固定 client_credential
    resp = requests.get(
        "https://api.weixin.qq.com/cgi-bin/token",
        params={"grant_type": "client_credential",
                "appid": APPID, "secret": APP_SECRET},
        timeout=5,
    ).json()
    # 正常返回 expires_in=7200,减 300 秒提前过期
    r.setex("wx:access_token", resp["expires_in"] - 300, resp["access_token"])
    r.delete("wx:token_lock")
    return resp["access_token"]

补一句当晚的教训:临时脚本里曾经硬编码了一份自己刷的 token,跑了两小时后被主服务的刷新动作踢失效,中间穿插了一批 40001 和 45011 混合报错,排查了半小时才发现是两套 token 在打架。

批量发送的限速队列实现

完整方案是一个生产者-消费者队列:生产者按 expire_at 捞出可发用户,消费者以固定速率发送,遇到 45011 自动退避,遇到 950005 直接标记该用户不可发。

flowchart TB
    A[定时任务按 expire_at 捞可发用户] --> B[入队 Redis List]
    B --> C[消费者按速率出队]
    C --> D{调用客服消息接口}
    D -- 成功 --> E[remain_count 减 1]
    D -- 45011 --> F[指数退避 5s/10s/20s 重入队]
    D -- 950005 --> G[标记窗口失效 跳过]
    E --> H{队列还有任务}
    F --> D
    G --> H
    H -- 是 --> C
    H -- 否 --> I[结束 汇总成功率]

消费者核心代码如下,当晚实际生效的版本:

# 消费速率控制在频控线以下:30 条/分钟起跑
# 实测 100/分钟 必挂,50/分钟 边缘抖动
RATE_INTERVAL = 2.0

# 返回值统计三类结果:成功/窗口失效/撞频控
# 上层定时任务用它做汇总报表
def consume(queue_key: str) -> dict:
    stat = {"ok": 0, "stale": 0, "rate_limited": 0}
    # 退避初始 5 秒,撞频控后指数增长
    backoff = 5

    while True:
        # 阻塞出队,队列为空 5 秒后返回 None 收尾
        item = r.blpop(queue_key, timeout=5)
        if item is None:
            break
        # 入队格式固定为 openid|文案,竖线做分隔符
        openid, text = item[1].decode().split("|", 1)
        token = get_token()

        # 客服消息接口只有 text/image 等几种类型可选
        url = f"https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token={token}"
        payload = {"touser": openid, "msgtype": "text", "text": {"content": text}}
        resp = requests.post(url, json=payload, timeout=5).json()

        # 950005:窗口已关或条数用尽,这条用户标记后永久跳过
        if resp.get("errcode") == 950005:
            mark_session_dead(openid)
            stat["stale"] += 1
            continue

        # 45011:撞频控,指数退避后任务重新入队
        if resp.get("errcode") == 45011:
            stat["rate_limited"] += 1
            # 退避上限 60 秒,避免死循环式重试
            time.sleep(backoff)
            backoff = min(backoff * 2, 60)
            r.rpush(queue_key, f"{openid}|{text}")
            continue

        # 发送成功:剩余额度减 1,退避系数复位
        dec_remain(openid)
        stat["ok"] += 1
        backoff = 5
        # 速率间隔放在成功路径上,失败路径不计时
        time.sleep(RATE_INTERVAL)

    return stat

用这套队列重发当晚窗口外的 1124 个用户,分了两天跟随他们的自然交互逐批发出,最终送达 974 个,整体触达率从当晚的 60.1% 拉到 89.3%。两版方案的对比:

维度 当晚裸循环直发 限速队列 + 状态表
发送耗时 3147 个约 40 分钟 单批 2000 个约 70 分钟
950005 浪费请求 1124 次全浪费 0 次,先查窗口再发
45011 次数 131 次,中间熔断 个位数,退避内消化
触达率 60.1% 89.3%(含跨天跟发)
事后可审计 无记录 每条发送有状态可查

误区澄清

最后纠正几个我见过不止一次的执念:

  • "窗口 48 小时,所以活动后 48 小时内随便发"——错。条数上限 5 条和 48 小时是 AND 关系,且每条下发额度跟着"那一次用户交互"走,不是账号维度的大池子。
  • "菜单点击不算交互"——算。click 和 view 事件都会开窗,运营做活动引导时"点击菜单查看攻略"就是免费开窗动作,值得专门设计。
  • "被限频了歇一会就好,不用管队列"——45011 的恢复不是瞬时的,实测退避要按指数走,线性 5 秒重试会反复撞墙,还叠加接口频控的惩罚期。
  • "客服消息、模板消息能互相替代"——不能。窗口内即时跟进是客服消息的地盘,结果类、交易类通知应走订阅通知并提前做订阅引导。活动上线前把订阅动作埋进流程,比活动结束后抢救窗口省一个量级的力气。

这套状态表加队列的代码总量不到三百行,比事后人工补偿通知的成本低得多。你的场景里窗口利用率是多少,欢迎评论区交流。

参考与延伸

  • 微信公众平台·客服消息官方说明:https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Service_Center_messages.html
  • 微信公众平台·接收事件推送(关注、菜单点击等事件定义):https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Receiving_event_pushes.html
  • 微信公众平台·获取 access_token:https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html

微信公众号 · 客服消息 · 48小时窗口 · 会话管理 · Access Token · 限速队列

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