很多做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验证失败”。我按经验总结了一个排查顺序:
- 确认回调 URL 是公网可访问的 HTTPS,不是 HTTP,也不是
127.0.0.1或localhost; - 确认代码里收到的 GET 请求你是响应了明文
echostr,而不是返回了 JSON 或 XML 包了一层; - 确认
Token和EncodingAESKey跟后台配置一致,特别是EncodingAESKey必须是 43 位且以=结尾; - 确认验签算法用的是官方 SDK,不要自己实现 SHA-1 排序,排序用错是高频问题;
- 看日志:有没有收到 GET 请求?如果根本没收到,检查防火墙、安全组、反向代理是否拦截了 GET。
我遇到最诡异的一次是:回调地址本身没问题,但负载均衡层做了 WAF 过滤,把 GET 请求里的 query 参数给截掉了,导致echostr为空。后来直接在 Nginx 日志里看到参数缺失,才定位到是上层设备的问题。
5.2 消息重复推送的幂等处理
企微回调不保证消息只投递一次,也不保证顺序。同一消息重复到达、多条消息乱序到达,都是正常现象。
重复消息主要看<MsgId>字段。每条用户消息都有一个唯一MsgId,我建议在 Redis 里存最近 5 分钟的MsgId集合,重复MsgId直接丢弃。否则一个用户连续发两遍“查询订单”,机器人可能返回四次结果,如果消息内容是带副作用的(比如触发跑批任务),后果就是重复执行。
乱序问题麻烦一点。我的应对是:凡是带业务状态的消息,都要在服务端保留“乐观锁 + 版本号”,先到了旧消息不会被新消息覆盖,等新消息到了再执行最终状态校验。这一层和企微无关,纯业务设计,但没做之前确实出过脏数据。
5.3 限频错误码与重试策略
企微 API 对不同的接口有限频限制。常见错误码:
| 错误码 | 含义 | 处理建议 |
|---|---|---|
| 40001 | 无效的 secret 或 access_token | 检查 Token 缓存、重新获取 |
| 40014 | access_token 无效 | 多数是 Token 被覆盖或过期 |
| 42001 | access_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、参数、返回状态码。出问题时先翻这个日志,绝大多数接口问题都能一眼看出是鉴权、限频、还是参数结构异常。这个习惯帮我省了大量从头抓包的时间,也推荐你从第一天就养成。