调用微信公众号模板消息接口时,最让人头疼的报错之一就是errcode为43101,官方给出的解释是“用户未关注公众号”。这个错误看似简单,实际排查起来却常常让人摸不着头脑:明明数据库里存着这个用户的openid,明明昨天还发送成功,怎么今天就未关注了?本文结合实际开发中踩过的坑,把这类问题的排查思路完整梳理一遍。

一、先搞清楚43101错误的本质
模板消息的推送对象是“关注了公众号的用户”,这是微信的硬性规则。当接口返回43101时,说明微信服务器判定该openid对应的用户与当前公众号之间不存在关注关系。请求本身格式没问题,access_token也有效,只是目标用户不满足推送条件。
有一个容易被忽视的细节:同一个微信用户在不同公众号下的openid是完全不同的。如果你的系统同时接入了小程序和公众号,用户在小程序侧拿到的openid拿去公众号发模板消息,即使这个用户确实关注了公众号,也会报错。因为openid不通用,必须通过UnionID机制或者分别调用各端的登录接口来获取对应公众号的openid。
下面是一个典型的失败响应:
{
"errcode": 43101,
"errmsg": "user refuse to accept the msg hint: [xxxx] "
}注意errmsg里的"user refuse to accept the msg",有些开发者看到refuse以为是用户拒绝接收,其实取关、关闭公众号消息权限等多种情况都会归到43101这个码上,核心含义就是这条消息发不进去了。
二、四类常见原因逐一排查
第一类:用户已经取关,但业务系统没感知。这是最常见的原因。用户点了取消关注,微信会推送取消关注事件给公众号服务器,但如果你的事件回调处理有漏洞,比如没有更新用户表的关注状态字段,或者消息推送地址配置的域名挂了导致事件丢失,数据库里就会残留一批“僵尸粉丝”。给这些人发消息必然43101。
第二类:openid来源混乱,公众号和小程序搞混了。多端系统里 openid 的管理一定要严格隔离。建议在用户表里明确区分gzh_openid和xcx_openid两个字段,发送模板消息时只用前者。如果确实需要打通两端,应该在用户关注公众号时通过带参二维码或网页授权获取公众号openid并落库。
第三类:access_token对应的appid不对。调用接口时用的access_token如果是用小程序的appid和secret换来的,那么微信会以小程序的身份去查找这个openid,自然找不到关注关系。这类问题报错有时是43101,有时是40003(openid无效),都要检查token的来源。
第四类:测试号与正式号数据串了。测试公众号的模板ID、用户openid和正式环境完全不通用,把测试号的openid拿到正式号下发送,同样会报错。开发阶段建议在配置文件里显式区分环境,避免手滑切换。
三、建立发送前的校验与发送后的处理机制
与其每次发送失败后再补救,不如在架构层面做好两道防线。第一道防线是发送前校验,可以通过公众号的“获取用户信息”接口判断subscribe字段:
// 发送前先校验用户关注状态
async function checkSubscribed(openid, accessToken) {
const url = 'https://api.weixin.qq.com/cgi-bin/user/info'
+ '?access_token=' + accessToken
+ '&openid=' + openid + '&lang=zh_CN';
const res = await fetch(url).then(r => r.json());
// subscribe 为 1 表示已关注,0 表示未关注
return res.subscribe === 1;
}不过要注意,如果用户量很大,逐个调用这个接口会消耗不少时间,一般只对重点用户或者在发送失败后触发补偿校验时使用,也可以配合定时任务在低峰期批量刷新关注状态。
第二道防线是发送后的错误码处理。捕获到43101后,绝不能简单地把消息塞回重试队列,因为取关是持久状态,重试多少次都会失败,反而浪费接口调用配额。正确做法是标记该用户为“已取关”,并从后续的推送名单中剔除。示例逻辑如下:
if (result.errcode === 43101) {
// 用户已取关,标记状态并停止重试
await db.user.update(
{ openid: targetOpenid },
{ $set: { subscribed: false, unsubscribeTime: new Date() } }
);
logger.warn('用户已取关,移出推送名单: ' + targetOpenid);
} else if (result.errcode === 40001 || result.errcode === 42001) {
// token 失效,刷新后可重试一次
await refreshTokenAndRetry(message);
} else {
// 其他错误进入失败队列,记录待人工排查
await pushToFailedQueue(message, result.errcode);
}四、正确处理关注与取关事件,从源头杜绝脏数据
要彻底解决43101问题,关键是把关注事件的同步做扎实。公众号服务器配置的消息推送URL中,微信会推送event为subscribe和unsubscribe的事件,务必都处理。很多开发者只处理了subscribe,忽略unsubscribe,时间一长数据库里全是无效粉丝。
<xml> <ToUserName><![CDATA[gh_xxxx]]></ToUserName> <FromUserName><![CDATA[oXXXX_openid]]></FromUserName> <CreateTime>1700000000</CreateTime> <MsgType><![CDATA[event]]></MsgType> <Event><![CDATA[unsubscribe]]></Event> </xml>
收到unsubscribe事件后,除了更新数据库的subscribed字段,建议同时清理Redis里缓存的用户信息,避免缓存与库不一致。另外要考虑事件丢失的情况:服务器重启、网络抖动都可能让回调丢失,所以定期(比如每周)调用用户标签或拉取关注者列表接口做一次全量对账,是保证数据准确性的兜底手段。
最后提醒一点,微信官方已经逐步推进模板消息向订阅消息迁移,新接入的服务建议优先评估订阅消息方案。如果存量业务仍在使用模板消息,上述的排查思路和状态管理机制依然适用,只是在迁移时要注意订阅消息需要用户主动授权次数,发送逻辑上会有所不同。把关注状态管理、错误码处理、定期对账这三件事做扎实,43101就不再是让人加班的难题了。