ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java企业微信机器人实战:企微API接入、回调与消息推送详解

Java企业微信机器人实战:企微API接入、回调与消息推送详解

很多做Java的人一提“微信机器人”就想到个人号协议、网页版模拟登录那套野路子。前几年还能凑合,现在个人号封号风险高、生态接口全面收紧,真正适合企业内部使用的方案,反而是企业微信(企微)官方API——也就是现在大家常说的企微 API。我这两年在好几个内部场景里做了基于企微 API 的 Java 机器人,处理过工单提醒、定时报表推送、群消息转发、甚至简单的对话式查询。这篇文章把我从零搭建的完整方案、核心代码、以及生产环境里真实踩过的坑全部写出来,给你一个可以直接抄作业的参考。

1. 为什么是企微 API:个人号方案已经不值得碰了

先说结论:如果你的机器人是给自己小范围用、对稳定性要求不高,个人号方案或许还能跑;但只要涉及团队协作、多用户并发、权限管理、消息审计,个人号那套东西迟早出事。企微 API 是官方开放接口,有完整的用户身份体系、应用隔离、回调机制和消息能力,不至于一封号全部白干。

1.1 你能用企微 API 做出什么样的机器人

我这里说的“机器人”不只是聊天室里复读关键词的玩具,而是能收发消息、能主动推送、能和内部系统打通的自动化入口。

具体能做到的事情包括:

  • 用户给应用发消息,机器人收到文本、图片、语音、位置、链接等消息,并自动回复;
  • 机器人主动向指定成员、部门或标签推送文本、Markdown、图片、文件、图文卡片等类型消息;
  • 结合回调事件(成员关注、进入应用、菜单点击等),实现用户行为触发的自动化;
  • 配合外部业务系统(工单系统、监控告警、绩效报表任务)作为统一消息出口。

我自己最常用的是两类:一类是“查询型”机器人,成员在群里发@机器人 查一下订单状态,后端去数据库查完把结果推回来;另一类是“推送型”机器人,定时任务每天把平台数据指标整理成文本或图片,推给指定负责人。这两类场景本质上都是“消息入口 + 业务逻辑处理 + 消息出口”三段式。

1.2 企微 API 和自己的应用、小程序有什么区别

不少新手会混淆:企业微信里能建自建应用,也能开发小程序,三者能力边界不太一样。

自建应用是企微 API 机器人的核心载体。它给你分配独立的 AgentId 和 Secret,用户在企业微信通讯录里可以看到这个应用。只要用户设置了“接收消息”或者被添加到可见范围,应用就可以通过会话能力跟用户互动。

小程序则是运行在企业微信内部的一套轻应用,功能入口是页面交互,消息推送能力反而要靠订阅消息模板。如果你要的是“机器人在聊天里被 @ 然后自动响应”这种体验,小程序不是主选项,自建应用消息接口才是。

1.3 开通前必须确认的三样东西

在写代码之前,先登录企业微信管理后台,确认下面三样东西都拿得到:

配置项说明哪里获取
CorpID企业ID,整个企业的唯一标识管理后台“我的企业”页
AgentId自建应用的AgentID应用管理里的应用详情
Secret应用密钥,调用API时的身份凭证应用管理里的“Secret”

另外要准备一个外网可访问的 HTTPS 地址,最好是域名形式。回调 URL 不能是本地地址,官方要求必须公网可达。开发阶段可以用内网穿透工具临时暴露,但生产环境一定要走正式域名和 HTTPS 证书,不然回调验证阶段就会被卡住。

注意:Secret 泄露等于把应用权限交给了别人。如果发现异常,第一时间在后台重置 Secret,同时检查调用记录里有没有陌生 IP 访问。

2. 项目骨架:依赖、配置文件与加密库的选择

企微 API 官方给出了 Java 版的加解密 SDK 示例代码,但其实只有零星几个类。我的建议是不要自己造轮子去实现 AES-CBC 加密,直接用官方 SDK 或者类库,把精力花在业务逻辑上。

2.1 Spring Boot 3 + 官方加密库的最小组合

我的项目以 Spring Boot 3 为基础,HTTP 客户端用 okhttp 3,JSON 处理用 Jackson(Spring Boot 自带)。企微回调的加密包从官方企业微信开放平台文档里下载的 JAR,里面主要是WXBizMsgCrypt这个类。

WXBizMsgCrypt内部封装了三个方法,是整个回调链路的核心:

  • VerifyURL:处理回调 URL 验证请求,解密echostr;
  • DecryptMsg:解密用户发来的加密消息体;
  • EncryptMsg:加密被动回复的消息内容。

加解密算法的本质是 AES-256-CBC 加 PKCS7 填充,消息内容经过签名、Base64 编码后放在 XML 里。官方 SDK 把这些细节都隐藏了,但你必须知道底层是 AES,不然改了密钥格式或者字符集编码,就会遇到加密不匹配的问题。

2.2 回调 URL 的 GET 验证流程

配置回调 URL 并保存时,企微服务器会向你的地址发一次 GET 请求,带四个参数:msg_signature、timestamp、nonce、echostr。

服务器需要做的事很简单:校验签名,解密echostr,然后把明文原样返回。官方文档要求返回解密后的字符串,HTTP 状态 200,Content-Type 用text/plain。

核心代码如下:

@GetMapping("/wecom/callback") @ResponseBody public String verifyCallback(@RequestParam("msg_signature") String signature, @RequestParam("timestamp") String timestamp, @RequestParam("nonce") String nonce, @RequestParam("echostr") String echostr) throws Exception { WXBizMsgCrypt crypt = new WXBizMsgCrypt(token, encodingAesKey, corpId); return crypt.VerifyURL(signature, timestamp, nonce, echostr); }

这里最容易犯的错误是:签名校验用错了参数。msg_signature的生成顺序是token + timestamp + nonce + echostr做 SHA-1,再排序拼接。如果自己实现签名校验,排序和前一步的加密消息签名还不一样,网上抄代码很容易混淆。直接用WXBizMsgCrypt.VerifyURL可以避免这个坑。

2.3 POST 阶段的验签、解密与被动回复

用户发消息后,企微会向回调 URL 发 POST 请求,请求体是加密 XML,格式类似这样:

<xml> <ToUserName><![CDATA[corpid]]></ToUserName> <Encrypt><![CDATA[加密后的消息体]]></Encrypt> <AgentID><![CDATA[1000002]]></AgentID> </xml>

处理流程是:先解析 XML 取出<Encrypt>节点,再调用DecryptMsg得到明文 XML,然后根据MsgType走不同业务逻辑。如果需要被动回复,把回复内容拼成明文 XML,调用EncryptMsg加密后返回。

对应代码:

@PostMapping(value = "/wecom/callback", produces = "application/xml;charset=UTF-8") @ResponseBody public String callback(@RequestParam("msg_signature") String signature, @RequestParam("timestamp") String timestamp, @RequestParam("nonce") String nonce, @RequestBody String requestBody) throws Exception { WXBizMsgCrypt crypt = new WXBizMsgCrypt(token, encodingAesKey, corpId); String plainText = crypt.DecryptMsg(signature, timestamp, nonce, requestBody); // 解析明文XML Map<String, String> msg = WeComXmlParser.parse(plainText); String replyContent = handleMessage(msg); // 拼明文回复XML String replyXml = buildTextReplyXml(msg.get("FromUserName"), msg.get("ToUserName"), replyContent); return crypt.EncryptMsg(replyXml, timestamp, nonce); }

注意被动回复的时间窗口很有限:如果 5 秒内没返回,用户端会显示“服务异常”。所以耗时操作不能放在回调线程里做,我后面会详细说异步处理的设计。

2.4 本地调试消息加密的方法

回调里最烦人的一件事是:消息是加密的,本地打断点看到的全是密文,业务出问题很难定位。

我的做法有两种:

  • 在DecryptMsg之后、解析 XML 之前,把明文 XML 打日志,方便看消息内容;
  • 用企微管理后台的“同步助手”或者测试号发送指定类型消息,配合日志确认解析结果。

明文日志拿到后,整个消息处理链路就可以脱离签名加密单独调试了。生产环境的日志记得脱敏,不要把用户手机号、姓名等敏感字段全量打出来。

3. Access Token 管理:被“7200 秒”支配的细节

调用企微 API(发消息、传素材)之前,几乎都要先拿access_token。这个 Token 是整个 API 入口的通行证,但它的有效期是 7200 秒(2 小时),而且同一个应用全局只有一个有效 Token。很多人把 Token 每次都现取现用,接口频繁调用后很快撞上限频;还有人把 Token 存到单机内存,多实例负载均衡一上,马上就出现“Token 互相覆盖”的问题。

3.1 获取接口与返回字段

获取 Token 的接口很简单:

GET https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=ID&corpsecret=SECRET

正常返回:

{ "errcode": 0, "errmsg": "ok", "access_token": "xxxxxx", "expires_in": 7200 }

注意expires_in是个参考值,不是让你每次都去读这个字段动态决定过期时间。更稳的做法是:请求成功后,把当前时间加上 7200 秒记为过期时间点,并且提前 60 秒就判定过期,避免正好卡在临界时间点拿到一个刚失效的 Token。

3.2 定时刷新 vs 惰性刷新怎么选

Token 缓存策略有两种流派,我两种都试过。

定时刷新:用调度任务每 90 分钟刷新一次 Token,启动时立即刷新。好处是代码简单,Token 几乎永远是新鲜的;坏处是如果服务刚启动时网络不通,调度任务失败后没有补救机制,可能一整段时间内接口全挂。

惰性刷新:每次取 Token 前检查缓存,过期再去拉取。好处是容错性强,不会出现调度失败导致的长尾问题;坏处是要自己处理并发场景下的“同时拉起多个刷新请求”问题。

我在生产环境用的是“惰性刷新 + 分布式锁”的方案:

@Component public class AccessTokenService { private static final String TOKEN_URL = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=%s&corpsecret=%s"; private volatile String accessToken; private volatile long expireAt; public String getAccessToken() { if (accessToken != null && System.currentTimeMillis() < expireAt - 60_000) { return accessToken; } synchronized (this) { // 双重检查 if (accessToken != null && System.currentTimeMillis() < expireAt - 60_000) { return accessToken; } refreshToken(); } return accessToken; } private void refreshToken() { String url = String.format(TOKEN_URL, corpId, secret); // okhttp 同步请求,解析 JSON JsonNode json = doGet(url); if (json.get("errcode").asInt() != 0) { throw new RuntimeException("获取access_token失败: " + json); } this.accessToken = json.get("access_token").asText(); this.expireAt = System.currentTimeMillis() + json.get("expires_in").asLong() * 1000; } }

volatile + synchronized双重检查是单机场景最简单的并发保护,保证同一时刻只有一个请求去刷新 Token。

3.3 多实例部署时防止 Token 互相覆盖

但如果服务起了多个实例(比如 Kubernetes 里跑多个副本),单机锁就失效了——两个实例同时发现 Token 过期,各自去拉取,后请求的把先请求的覆盖掉,导致先请求的那个实例后续调用全部返回40014 invalid access_token。

多实例场景我有两种处理办法:

一种是用分布式锁(Redis + Redisson 或 Zookeeper)把刷新动作锁住,全局只有一个实例能拉 Token,拉完后其他实例直接读共享缓存。

另一种更简单实用:把 Token 存到 Redis,所有实例共用一个缓存。Key 用固定字符串,比如wecom:access_token:{agentId},带上过期时间;获取时先从 Redis 读,读不到再用分布式锁去刷新并写回 Redis。这样既保证了全局唯一,也避免了反复调用获取接口。

如果你没有 Redis,也可以退而求其次:把刷新周期设得足够短(比如每 5 分钟强制刷新一次),实例数控制在 2 个以内,短时间窗口内的覆盖概率不高。但这不是治本方案,接口一旦开始返回40014,排查起来非常痛苦。

4. 消息收发的完整闭环:从收到消息到主动推送

消息收发是机器人最核心的能力,也是文档最容易看晕的地方。这里我把整个链路拆成三段讲清楚。

4.1 接收消息:解析明文 XML

解密后的消息是 XML,不同消息类型的字段不同。文本消息长这样:

<xml> <ToUserName><![CDATA[企业ID]]></ToUserName> <FromUserName><![CDATA[成员UserID]]></FromUserName> <CreateTime>1348831860</CreateTime> <MsgType><![CDATA[text]]></MsgType> <Content><![CDATA[你好]]></Content> <MsgId>123456789</MsgId> <AgentID>1000002</AgentID> </xml>

图片、语音、视频消息会有<PicUrl>、<MediaId>、<Format>、<ThumbMediaId>等字段;位置消息有<Location_X>、<Location_Y>坐标;链接消息有<Title>、<Description>、<Url>。

我习惯写一个WeComXmlParser工具类,把<MsgType>外套一层 Map 解析,所有字段塞进Map<String, String>,后面业务逻辑直接按 key 取,不用为每类消息写一个 DTO。但要注意 XML 节点是嵌套的还是平铺的,企微回调消息结构基本是平铺节点,处理起来很简单。

4.2 被动回复消息:必须在 5 秒内返回

接上一步,如果消息触发的业务逻辑很快(比如查一个缓存,返回一句话),可以直接同步构造回复 XML 返回。回复的明文 XML 结构是:

<xml> <ToUserName><![CDATA[成员UserID]]></ToUserName> <FromUserName><![CDATA[企业ID]]></FromUserName> <CreateTime>1348831860</CreateTime> <MsgType><![CDATA[text]]></MsgType> <Content><![CDATA[这是回复内容]]></Content> </xml>

注意ToUserName和FromUserName跟收到的消息是相反的:收到消息时ToUserName是应用(企业ID)、FromUserName是成员;回复时要把这两个字段对调。

同步回复最大的风险是超时。如果你的查询逻辑超过 2~3 秒,回调线程阻塞在数据库或外部接口上,很容易超时。我的做法是:回调里先立刻返回一个空字符串(HTTP 200),表示“收到”,然后丢进线程池异步处理。异步完再用“主动推送接口”把结果发给用户。

但这样用户会感觉机器人“没反应”,体验不好。折中方案是:同步回复一句“正在查询,马上回来”,异步处理完再补充推正式结果。这个体验设计在团队内部用起来非常自然。

4.3 主动推送消息:发文本、Markdown、图文卡片

主动推送是机器人的主战场。接口是:

POST https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=ACCESS_TOKEN

文本消息的请求体:

{ "touser": "zhangsan|lisi", "msgtype": "text", "agentid": 1000002, "text": { "content": "您的工单已经处理完成" }, "safe": 0 }

touser可以是成员 UserID,多个用|分隔;也可以传toparty(部门ID)或totag(标签ID),但同一消息里只能用一种接收方式,不能touser和toparty同时出现。

Markdown 消息适合发格式丰富的告警和报表:

{ "msgtype": "markdown", "agentid": 1000002, "markdown": { "content": "## 今日订单统计\n\n> 总量:<font color=\"info\">1024</font>\n> 成功率:<font color=\"warning\">99.2%</font>\n\n[查看详情](https://example.com/report)" } }

这里要注意企微的 Markdown 不是完全标准的 GitHub Flavored Markdown,它支持标题、加粗、引用、链接、字体颜色,但表格支持很差。内容里不要混用复杂的 HTML 标签,会解析失败。

图文卡片(news)也很常用,但字段繁琐(title、description、url、picurl),我一般只在真正有跳转页面需求时才用,一般通知场景 Markdown 足够了。

4.4 图片和文件的发送链路

如果你想发图片或文件,不能直接用本地路径,必须先上传素材拿到media_id,再发“image”或“file”类型消息。

上传接口:

POST https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token=ACCESS_TOKEN&type=image

用 okhttp 构造 multipart 请求,media字段传文件流。返回:

{ "errcode": 0, "errmsg": "ok", "type": "image", "media_id": "MEDIA_ID", "created_at": 1678901234 }

然后发送:

{ "touser": "zhangsan", "msgtype": "image", "agentid": 1000002, "image": { "media_id": "MEDIA_ID" } }

media_id有效期为 3 天,所以高频推送内容(比如每日报表)建议每次推送前重新上传,不要长期缓存。临时素材接口的存储上限是 1GB,一般够用。

5. 生产环境踩坑清单:从验证失败到消息乱序

这一节是我最想写的。理论再多,不如把实际调试中一个接一个的坑列出来。

5.1 回调 URL 验证失败的第一步排查顺序

很多人配置回调 URL 时总在“保存”这一步失败,报错信息只有一句“回调URL验证失败”。我按经验总结了一个排查顺序:

  1. 确认回调 URL 是公网可访问的 HTTPS,不是 HTTP,也不是127.0.0.1或localhost;
  2. 确认代码里收到的 GET 请求你是响应了明文echostr,而不是返回了 JSON 或 XML 包了一层;
  3. 确认Token和EncodingAESKey跟后台配置一致,特别是EncodingAESKey必须是 43 位且以=结尾;
  4. 确认验签算法用的是官方 SDK,不要自己实现 SHA-1 排序,排序用错是高频问题;
  5. 看日志:有没有收到 GET 请求?如果根本没收到,检查防火墙、安全组、反向代理是否拦截了 GET。

我遇到最诡异的一次是:回调地址本身没问题,但负载均衡层做了 WAF 过滤,把 GET 请求里的 query 参数给截掉了,导致echostr为空。后来直接在 Nginx 日志里看到参数缺失,才定位到是上层设备的问题。

5.2 消息重复推送的幂等处理

企微回调不保证消息只投递一次,也不保证顺序。同一消息重复到达、多条消息乱序到达,都是正常现象。

重复消息主要看<MsgId>字段。每条用户消息都有一个唯一MsgId,我建议在 Redis 里存最近 5 分钟的MsgId集合,重复MsgId直接丢弃。否则一个用户连续发两遍“查询订单”,机器人可能返回四次结果,如果消息内容是带副作用的(比如触发跑批任务),后果就是重复执行。

乱序问题麻烦一点。我的应对是:凡是带业务状态的消息,都要在服务端保留“乐观锁 + 版本号”,先到了旧消息不会被新消息覆盖,等新消息到了再执行最终状态校验。这一层和企微无关,纯业务设计,但没做之前确实出过脏数据。

5.3 限频错误码与重试策略

企微 API 对不同的接口有限频限制。常见错误码:

错误码含义处理建议
40001无效的 secret 或 access_token检查 Token 缓存、重新获取
40014access_token 无效多数是 Token 被覆盖或过期
42001access_token 过期立即刷新 Token
45009接口调用超过频率限制指数退避重试
60020访问来源 IP 不在白名单检查可信 IP 配置

我在做告警推送时,峰值时段一分钟内要推几百条消息,经常撞上45009。后来做了两件事:第一是把所有推送放进一个队列,用固定速率(比如每秒 5 条)发送;第二是遇到45009或网络超时自动重试三次,退避间隔 1 秒、2 秒、4 秒。

注意重试不能无脑重放,尤其是订单状态流转这类场景。重试前先查业务表确认这条消息是否已经处理成功,避免重复执行。

5.4 IP 白名单与网络策略

企微 API 后台可以为应用配置“企业可信IP”,配置后只有白名单内的 IP 才能调用该应用的接口。这个设计双刃剑:加了更安全,但如果你用的是云函数、动态出口 IP、或者多区域的服务器,可能把合法调用挡掉。

踩过最惨的一次:在容器平台部署了新版本,POD 调度到另一个节点,新节点出口 IP 不在白名单,所有主动推送全部失败,错误码是60020。排查半天才发现是 IP 问题。

解决方案是把出口 IP 收敛:要么服务器固定弹性 IP,要么把需要调企微 API 的服务单独部署在一组专用节点上,只给这组节点开白名单。如果你有多个环境(测试、预发、生产),建议每个环境都配置独立的企微应用和独立白名单,避免互相污染。

6. 从“复读机”到“自动化助理”:我把这套能力做成了通用消息网关

项目做到后期,我意识到企微 API 机器人本身不复杂,复杂的是围绕“消息网关”衍生出来的各类工程问题。我现在把消息收发、Token 管理、重试、幂等、审计日志抽成了一个公共组件,业务系统只需要调用一个接口就能推送消息,不用关心企微细节。

一个典型的调用示例如下:

@Autowired private WeComMessageGateway messageGateway; public void pushOrderAlert(Order order) { messageGateway.sendMarkdown( "zhangsan", "订单异常提醒", "订单 **" + order.getOrderNo() + "** 状态异常,请及时处理。" ); }

底层统一做 Token 获取、素材上传、消息类型转换、限频控制。内部系统接入成本从“研读企微文档一遍”降到了“依赖一个 jar 包”。

如果你只是想做简单的机器人,按照第四节的链路搭一个 Spring Boot 服务完全可以覆盖需求。但如果你打算长期维护、接入多个业务系统,我强烈建议提前把消息能力和业务解耦,做成独立消息平台,否则后期接入方越多,维护越失控。

最后分享一个我调试总结出来的小习惯:把所有和企微 API 相关的 HTTP 请求都打进独立日志文件,包括 URL、参数、返回状态码。出问题时先翻这个日志,绝大多数接口问题都能一眼看出是鉴权、限频、还是参数结构异常。这个习惯帮我省了大量从头抓包的时间,也推荐你从第一天就养成。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进