你可能跟我一样遇到过这种需求:业务方抛过来一句话,“咱们的客户群能不能每天让系统自动发条消息进去”,或者“外部群里的用户下单了,能不能让群里所有人都知道”。这类需求落到企业微信生态里,就是“企微API外部群主动推送消息”这个命题。很多人第一反应是拉个群机器人,配个webhook,然后 curl 一发,完事。但真实项目里,外部群、内部群、应用消息、群机器人这几条路线的权限边界、调用限制、消息形态完全不一样,如果一开始选错了通道,后面要么发不出去,要么被判定异常,要么消息直接变“已拦截”。
这篇文章我打算把“外部群主动推送”这件事从通道选型、ID获取、接口联调、高频推送避坑这几个层面完整拆开讲,把我自己在实际开发中验证过的方案和踩过的坑都写出来,给后端开发、运维和做企业微信集成的同学一份可以直接抄作业的参考。
1. 先搞清楚“主动推送”到底走哪条通道——webhook与应用消息API的取舍
1.1 群机器人webhook:最容易被选错通道
如果你搜“企业微信推送消息”,八成搜到的是“群机器人webhook”。这种方式的实现确实极其简单:在群里添加一个机器人,拿到一个 webhook 地址,之后只需要往这个地址 POST 一段 JSON,就能往群里发文本、markdown、图片消息。
POST https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your_key Content-Type: application/json { "msgtype": "text", "text": { "content": "各位好,系统提示:订单已发货。" } }问题在于,这个群里添加机器人这个动作,通常对面用户群的管理员不一定会配合。你说“我是我们公司的技术,要往你们客户的群里加个机器人”,这在外部合作群里是极其敏感的动作。外部群本身就是跨企业建立的,群里的人未必信任你,群主也未必愿意给你开这个权限。
所以群机器人这条路,我现在的判断非常明确:只适合用于“自己人拉的技术协作群”或“合作伙伴间已确认可折腾的群”,不太适合做面向外部客户群、用户群的主动运营系统。
1.2 应用消息API:真正的“主动推送”正路
企业微信的“应用消息”能力,是通过企业内部应用调用message/send接口,向指定成员或指定群聊发送消息。它的核心特点是:消息以应用的名义发出,接收方看到的发送方是一个“XX助手”或“XX系统”的应用卡片,可以按部门、按标签、按成员、按群聊维度发送。
这里的重点来了:外部群能不能通过应用消息API直接推?能,但有一个隐蔽的前提——这个外部群必须通过“客户联系”体系建立,也就是你的企业通过企业微信添加的“外部联系人”组成的群。
POST https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=ACCESS_TOKEN Content-Type: application/json { "touser": ["zhangsan"], "msgtype": "text", "text": { "content": "您的服务单已更新,请点击查看。" }, "agentid": 1000002 }单纯发文本消息给个人很简单,但如果要往“外部群”发消息,你并不需要像群机器人那样预先“进群”,而是要拿到这个外部群的chat_id,然后用appchat/send这个接口发送。这个chat_id的获取和权限,才是真正的门槛。
1.3 我的通道选择结论:优先走客户联系+应用消息
经过对比,我给自己定了一条相对稳妥的选型规则:
| 场景 | 推荐通道 | 理由 |
|---|---|---|
| 内部项目群、测试群 | 群机器人webhook | 快捷,无需过审,消息格式自由 |
| 外部客户群、用户群 | 应用消息 +appchat/send | 具备发送方身份,可追踪,可靠性高 |
| 大规模客户触达 | “客户联系”下的群发助手能力 | 依托客户联系体系,合规性好,但需要开通对应权限 |
一句话总结:webhook 是轻量武器的锦上添花,应用消息API才是正经推送系统应该走的路。
2. 外部群的 chat_id 是怎么拿到的——三条可行路径
这里要泼一盆冷水:企业微信不会把“我所有外部群的ID列表”像拉大便当一样直接端给你。官方接口里并没有“获取外部群列表”这个现成的接口。这是很多人第一次卡住的地方——文档翻半天,发现根本没有get_external_chat_list这种东西。
那实际项目里,怎么拿到外部群的chat_id?我自己实现并验证过的有三条路径。
2.1 路径一:通过客户联系“群聊数据”接口拿
如果你的企业开通了“客户联系”并具备权限,可以通过externalchat/get_group_chat_list分页拿到群基本信息。需要传一个status_filter(0表示存活的群),返回的列表里带chat_id。
// 伪代码示例 const res = await axios.post( `https://qyapi.weixin.qq.com/cgi-bin/externalcontact/groupchat/list?access_token=${token}`, { status_filter: 0, limit: 100, cursor: cursor } );这一步拿到的chat_id就是你在后续推送时的目标ID。但这个接口有几个前提:企业需要在“客户联系”功能里有使用记录,当前应用需要有“客户联系”权限,且调用者需要配置对应的“群聊数据”权限范围。缺一个,都会报60011之类的权限错误。
2.2 路径二:通过群成员关系反查
如果你知道自己企业里的某位同事在外部群里,可以通过“客户联系”里的“客户群成员”接口或者会话存档中的数据反查。但注意,这个路径做起来更绕,你需要先把“同事-群”的绑定关系在本地数据表里维护好,才能通过“同事A在哪些外部群”来命中chat_id列表。适合已经在维护客户关系管理系统的团队。
2.3 路径三:通过“加群活码”或“客户群二维码”事件回调拿到
当用户通过你的渠道码加群时,企业微信会推送change_external_chat事件到你的回调地址,事件内容里就会带上chat_id。如果你在群里有人进群、退群时都能拿到回调,那你可以长期维护一张“已知外部群”表,表里有群名、群主、创建时间、最近活跃时间等字段。这是我最推荐的方式,因为它天然建立了“群的动态维护”能力。
// 回调事件示例 { "ToUserName": "corpid", "FromUserName": "sys", "CreateTime": 1700000000, "MsgType": "event", "Event": "change_external_chat", "ChatId": "wrxxxxxxxxxxxxxxxxxx", "ChangeType": "add_member" }2.4 这几种拿 chat_id 方式的取舍
我的建议是:如果现在还拿不到chat_id,不要硬编码,不要试图用“猜”的方式去构造ID。先建立“回调埋点”或“群列表同步任务”,等系统跑几天,把外部群ID沉淀到本地表,再开始推送任务。磨刀不误砍柴工。
3. 主动推送的接口调用细节——消息体设计与发送边界
3.1 appchat/send 消息体的基本形态
appchat/send是应用消息里的“群聊发送”接口。它的请求参数里有一个chatid字段,对应你要推送的外部群ID。消息格式和普通的应用消息一致,支持文本、图片、语音、视频、文件、文本卡片、图文、markdown等。
我工程里最常用的是文本卡片消息,因为它既能承载“一段标题+一段摘要+一个跳转链接”,又能让群成员一眼看出是什么事。
{ "chatid": "wrxxxxxxxxxxxxxxxxxx", "msgtype": "textcard", "textcard": { "title": "发货通知", "description": "您购买的商品已发货,单号:SF123456", "url": "https://your-domain.com/order?id=123", "btntxt": "查看详情" }, "safe": 0 }3.2 外部群的发送边界:比你想象中更严格
这里有个我一开始踩过的坑:外部群通过appchat/send主动推送,并不是所有应用都能发的。必须是具备“客户联系”能力的应用,或者被授权了客户联系相关权限的应用。如果只是普通自建应用,拿着一个普通的agentid去发,大概率会返回错误码60011或45028,提示没有权限。
大概的错误码对照,我这边整理过一版:
| 错误码 | 含义 | 处理方向 |
|---|---|---|
| 60011 | 无权限访问接口 | 检查应用权限和授权范围 |
| 45028 | 发送频率受限 | 降频,错峰重试 |
| 40058 | 参数chatid不合法 | 检查chat_id来源 |
| 45009 | 接口调用次数超限 | 需要企业升级或降频 |
| 93000 | 机器人webhook地址非法 | 仅webhook场景 |
3.3 safe参数和消息存档的敏感性
在外部群里发消息,比内部群多一个很明显的敏感性:消息的可追踪性。如果你所在企业开通了会话存档功能,外部群消息属于重点存档范围。我建议在所有对外推送消息中,不要携带身份证号、手机号明文、地址等敏感信息。哪怕技术上能发,合规上也是在裸奔。
要做到“能发但不惹事”,我的经验是消息体尽量用脱敏数据:比如“订单尾号8848已发货”,而不是“您的手机号138xxxx的用户,发货至XX小区XX栋”。不要因小失大。
4. 高频推送的限频与重试策略——如何做“高效”而不是“恶性”
4.1 企业微信限频到底限的什么
做推送系统必然要面对限频。企业微信的接口限频设计大致有两层:
第一层是“接口调用次数”:每应用每分钟有固定的调用上限,大致在每分钟几十次到几百次的量级,具体与企业的认证等级和应用配置相关,不是一个恒定值。
第二层是“消息发送频率”:appchat/send有更隐蔽的约束,即短时间向同一个群发大量消息会触发频率限制,甚至可能导致群聊被限制发言。这一条官方文档写得非常隐晦,只有在真实高频压力下才会触到。
我自己的压测经验:一个外部群,10分钟内最多推送10条应用消息是比较安全的。超过这个频率,容易触发“发送失败,请稍后再试”。所以做推送系统时,单群要有节流器,不能业务上“每来一单就发一条”,要在服务端做聚合或限流。
4.2 重试保护机制:防抖加熔断
我的推送服务在发送环节加了三层保护,这里直接给出可落地的逻辑:
async function sendWithRetry(payload, maxRetry = 3) { for (let i = 0; i < maxRetry; i++) { try { const result = await sendAppChatMessage(payload); if (result.errcode === 0) { return result; } // 限频类错误码,等待时间递增 if ([45028, 45009].includes(result.errcode)) { await sleep(1000 * Math.pow(2, i)); continue; } // 其他错误码,直接返回不再重试 return result; } catch (e) { await sleep(500 * (i + 1)); } } // 记录失败消息,进本地消息队列表,等待人工或定时任务补偿 await saveToFailedQueue(payload); }这里面一个关键经验:不要把重试做成无限循环。连续失败的消息,让它落到失败表里,用定时任务每5分钟补偿一次,比在请求线程里死磕要优雅得多。
4.3 批量推送的正确姿势
如果你需要同时给几十个群推送同一条公告,不能一个群一个群串行发,也不能一次性开几百个并发。我实测过,比较理想的批量策略是:
- 将群列表分批,每批20个群。
- 每批之间间隔3~5秒。
- 单批内的20个请求做并发,但需控制在
Promise.all可容错范围内。 - 对失败项重新进入失败队列。
async function broadcastToGroups(chatIds, message) { const BATCH_SIZE = 20; for (let i = 0; i < chatIds.length; i += BATCH_SIZE) { const batch = chatIds.slice(i, i + BATCH_SIZE); await Promise.allSettled( batch.map(chatid => sendWithRetry({ chatid, ...message })) ); await sleep(3000); } }这样既不会把企业微信的限频打满,也能把整体推送时效控制在分钟级。
5. 外部群场景下几个极容易踩的边界问题
5.1 客户群被解散或群主变更后的消息状态
外部群有个特点:群不是企业方完全控制的。客户群的群主可能是客户企业的员工,一旦群主离职、被移出群、或者群被解散,你的系统还拿着这个chat_id去发消息,就会报40058或提示群不存在。
我现在的方案是:本地维护一张群状态表,推送失败且错误码是“群不存在”类时,自动将群标记为“失效”,并从定时任务中剔除。同时记录失效时间,方便运营排查是群解散了还是权限变了。
5.2 外部群成员的可见性问题
企业微信的API在“读取群成员信息”方面,对外部群有严格限制。通过externalcontact/groupchat/get拿群详情时,返回的member_list里有外部成员的unionid,但没有手机号、没有微信号,对客户的明感字段做了脱敏处理。所以别指望通过API直接拿到群成员的手机号再做标签匹配,这条路在合规上走不通,技术上也不现实。
做外部群推送时,消息内容中如果需要“@成员”,依赖的是群成员的userid或外部联系人的external_userid,这个ID不是正式账号。这块也要提前跟运营对齐预期。
5.3 “主动”到什么程度算骚扰
即使接口能力全开,业务上也要考虑“外部群体验”。我的建议是推送频率按群类型做差异化配置:
| 群类型 | 建议每天推送上限 | 建议时段 |
|---|---|---|
| 售后群 | 3条以内 | 9-12点,14-18点 |
| 会员活动群 | 5条以内 | 11-13点,19-21点 |
| 通知公告群 | 2条以内 | 10-11点 |
这个表不是官方给的,是我在运营配合下测出来的一个相对不惹人烦的节奏。外部群一旦被用户投诉或群成员退群,损失的远不止一次推送。
5.4 回调事件里的 chat_id 与发送 chat_id 的一致性
这里有个容易出问题的小点:事件回调里的ChatId是大小写敏感的唯一ID,我见过有人把它截断或拼错导致发送失败。强烈建议在入库时做成varchar(64)且用原始值,不要做任何转换。另外,同一企业不同应用的回调事件里的ChatId是一致的,但不同企业的ChatId不互通,也不要做跨企业群的幻想。
6. 附一份可以直接落地的推送服务设计参考
6.1 服务模块划分
我最终搭出来的推送服务,本质上只有三个模块:
- 群关系模块:负责同步外部群列表、维护群状态、处理入群退群事件。
- 消息队列模块:负责接收业务方推送请求、做频率控制、失败重试。
- 发送执行模块:负责调用企业微信接口,处理返回码,更新消息状态。
这三层之间用一张消息任务表串起来。
CREATE TABLE appchat_push_task ( id BIGINT AUTO_INCREMENT PRIMARY KEY, chat_id VARCHAR(64) NOT NULL, msg_type VARCHAR(16) NOT NULL, msg_content TEXT NOT NULL, status TINYINT DEFAULT 0 COMMENT '0待发送 1成功 2失败 3重试中', retry_count INT DEFAULT 0, last_error_code VARCHAR(32), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_chat_status (chat_id, status), INDEX idx_created (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;业务方调用你的推送服务时,你只需告诉它“消息已接收”,具体什么时候发、发失败怎么处理,都交给异步任务去管。这比在业务进程里同步发企微消息要稳得多,也不会因为企微限频而拖垮主业务流程。
6.2 获取 access_token 的缓存细节
再插一句老生常谈但容易翻车的事:access_token要全局缓存,有效期7200秒,但不要掐着秒去刷新。我见过很多服务因为在多实例部署时没有共享token,导致每个实例各自请求token,结果触发频率上限,报40014或42001。
最省事的方案是用 Redis 缓存:
const cached = await redis.get('qywx:access_token'); if (cached) return cached; // 重新拉取,并设置 7000 秒过期,留200秒余量 const res = await axios.get(`https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${corpid}&corpsecret=${secret}`); await redis.set('qywx:access_token', res.data.access_token, 'EX', 7000);6.3 消息内容的模板化
最后聊聊消息模板。外部群场景下,运营对文案的审核要求比内部群高得多。我在工程里维护了一张消息模板表,业务方推送时只能通过模板ID+参数来触发,不能直接塞原生JSON。这样既能保证消息风格统一,也能在参数中做好HTML转义,避免消息内容被构造成奇怪形式。
模板化之后还有一个好处:你能统一做关键词过滤。在发送前跑一遍敏感词库,命中就直接拦截并进行人工审核,避免因为一条消息的问题影响整个企业的企微信用。
7. 关于“高效”我的理解与收尾
“高效开发指南”这几个字,我的理解不是“尽量少写代码”,而是“代码写得少的同时,线上出问题的概率也低”。企微API外部群主动推送这件事,真正坑人的不是接口文档有多复杂,而是权限模型、限频策略和外部群的不可控性。我花了很长时间才把群里消息从“偶发失败”调到“持续稳定”,核心沉淀下来就是上面这些经验。
如果你正在做类似功能,我建议第一步不是写代码,而是先拿着文档确认三个问题:你的应用有没有客户联系权限?你的应用能不能回调外部群事件?你要推的内容有没有经过运营审核?这三个问题确认清楚了,再写接口,你大概率能少走一半弯路。
最后再分享一个小技巧:刚上线时,不要直接把业务流量切过来,先拿一两个真实外部群做灰度,每天定时推几条测试消息,持续跑一周,观察企微侧有没有异常反馈。确认稳了,再逐步扩群。这个过程虽然慢,但比一次性全量推然后被限频要踏实得多。