企业微信的群机器人大概是所有告警通道里性价比最高的一个:不用申请短信签名、不用配置邮件服务器、不用等审批,拉个群、点几下鼠标拿到一条 Webhook 地址,Java 里发一个 POST 请求,手机上就能收到消息。我在好几个项目里都用它做过构建通知、监控告警、定时任务结果推送,稳定跑了两三年。但真要用得舒服,光会发一条 POST 是远远不够的——消息长度限制、频率限制、失败重试、敏感信息脱敏、异步不阻塞主流程,这些才是踩坑的地方。这篇内容就围绕Java 调用企业微信机器人发送通知这件事,把整体设计、核心机制、代码落地、生产级改造和排查经验完整讲一遍。适合已经把基础语法写熟、正在做后端业务或者运维工具、需要给系统接一个轻量通知通道的开发者参考,也适合刚开始接触 HTTP 接口封装、想看看一个“小功能”怎么做到生产可用的人。
1. 整体方案设计与技术选型思路
1.1 先厘清一个容易混淆的概念:机器人到底是谁的机器人
很多刚接触这块的人第一反应是“给个人微信发消息”,然后在搜索框里翻半天,最后发现路子都不对。这里必须先把边界讲清楚:个人社交账号没有对外开放的机器人接口,能做消息推送的是企业微信的群机器人,它本质上是官方提供的一个 Webhook 通道,你把一段固定的 URL 拿到手,往上面 POST 一个 JSON,群里就会冒出一条消息。
这个认知非常关键,因为它直接决定了后面所有的技术选型。群机器人不是“账号”,不需要登录态,不需要扫码,不需要 Cookie,它就是一个纯粹的 HTTP 端点,和调用一个天气接口没有本质区别。这意味着:会话状态为零、鉴权信息全部塞在 URL 的 query 参数里、服务端无状态、任何能发 HTTP 请求的地方都能用。你在 Java 里写、在 Shell 里 curl、在 Python 里 requests,效果完全一样。
也正因为它是无状态端点,所以它具备了一个非常好的工程属性:可以在任何地方嵌入,成本极低。CI 流水线跑完发一条、定时任务失败发一条、订单异常发一条、服务器磁盘告警发一条,都不需要额外的中间件。这个特性是它比邮件、短信更讨喜的核心原因。
1.2 四种常见通知通道的横向对比
选通道这件事,不能只看“能不能发出去”,得看拿到手之后的维护成本。我把常见的四种方案拉平了对比,这张表是我自己在选型时反复用到的判断依据:
| 通道 | 接入成本 | 到达速度 | 内容表现力 | 长期维护成本 | 适用场景 |
|---|---|---|---|---|---|
| 企业微信群机器人 | 极低,几分钟 | 秒级 | 支持文本、Markdown、图文、图片、文件 | 极低,几乎零维护 | 内部告警、构建通知、运营日报 |
| 邮件 | 中,需要 SMTP 配置 | 分钟级,容易被判垃圾邮件 | 强,支持 HTML | 中,域名信誉、退信处理 | 正式报告、对外通知 |
| 短信 | 高,需要签名报备、计费 | 秒级 | 弱,纯文本 | 高,费用与通道质量 | 紧急告警、验证码 |
| 站内消息 | 中,需要自建表与推送 | 取决于实现 | 取决于前端 | 中,需要维护已读状态 | 面向终端用户的产品内通知 |
从表里能看出来,群机器人在“接入成本”和“长期维护成本”两列上是压倒性的优势。它唯一的短板是只能在企业内部群里扩散,无法触达外部用户。但这个短板在内部告警和研发协作场景里根本不是问题,反而成了优点——消息不会外泄,权限天然收敛在群成员范围内。
所以我给出的结论很直接:只要你的通知对象是内部同事、内部系统,优先上群机器人;只有当需要触达外部用户或者需要正式存档时,才考虑邮件或站内信。
1.3 为什么要在 Java 侧做一层抽象,而不是到处写 POST
小项目里最容易出现的一种写法是:在每个需要发通知的地方,复制一段 OkHttp 调用代码,改改 URL 和内容就完事。前三个类这么干没问题,等写到第十个类,问题就来了。
第一是重复代码带来的修改成本。某天产品要求所有通知统一加一个环境前缀,你得挨个文件去改;某天要换 Webhook 地址,你得全局搜索替换;某天要加一个失败重试,你得在十几个地方同时加。第二是错误处理不一致,有的地方吞异常,有的地方打印日志,有的地方直接把异常抛给主流程,导致一个通知失败把整个业务带崩。
这就是为什么我坚持在项目里做一层薄封装。抽象的粒度不需要很重,一个接口加一个实现类就够了,但接口的存在本身就是一种契约:调用方只关心“发什么”,不关心“怎么发”。后面要换通道、要加限流、要改成异步,全部在实现类里改,调用方一行不动。
我通常定义的接口长这样:
public interface Notifier { /** * 发送纯文本通知 * @param content 消息内容 * @return 是否发送成功 */ boolean sendText(String content); /** * 发送 Markdown 通知 */ boolean sendMarkdown(String content); }接口只有两个方法,看起来寒酸,但它把所有实现细节都挡在了背后。实现类里可以塞重试、塞限流、塞日志脱敏,调用方完全无感知。
1.4 分层设计:把“业务语义”和“通道细节”切开
再往上走一步,我会把通知能力拆成三层,这个分层是我踩过坑之后固化下来的:
- 通道层:只负责把一段字符串 POST 出去,处理 HTTP 状态码、解析返回体、做重试。这一层不知道“订单失败了”是什么意思,它只知道“发一段文本”。
- 模板层:负责把业务数据渲染成人类可读的消息。比如
buildOrderFailed(String orderNo, String reason),输出一段带加粗标题、订单号、失败原因、发生时间的 Markdown。 - 触发层:业务代码里调用模板层的方法,或者通过事件监听的方式异步触发。
这么拆的好处是:模板要改文案,动模板层;通道要换实现,动通道层;业务要加新场景,动触发层。三者互不干扰。很多团队把这三层揉在一起,导致改一句文案都要重新读一遍 HTTP 代码,效率极低。
这里还有一个隐藏收益:模板层天然适合做内容合规检查。所有要发出去的内容都从这里过,你可以在这里统一加脱敏逻辑,比如手机号中间四位打码、身份证号只留后四位、密钥字段整体替换成***。如果散落在各处直接拼字符串,脱敏就无从谈起。
2. 群机器人核心机制拆解:一条消息是怎么发出去的
2.1 Webhook 地址的构成与鉴权逻辑
拿到手的 Webhook 地址长这样:
https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx拆开看就两部分:路径固定是/cgi-bin/webhook/send,鉴权全靠 query 里的key参数。这个 key 是一串 UUID 格式的字符串,由群机器人生成,每个机器人一个,相当于这条通道的“钥匙”。
这里有几个必须说清楚的点。第一,key 就是密码,任何人拿到这个地址都能往你的群里发消息,所以绝对不能提交到公开的代码仓库,也不能写死在会打包进前端的配置里。第二,一个群可以添加多个机器人,每个机器人有独立的 key,这给了我们做环境隔离的机会:开发环境一个 key、测试环境一个 key、生产环境一个 key,消息不会串群。第三,机器人被移除后 key 立即失效,调用会返回错误码,这个在排查时是一个常见原因。
关于 key 的存放,我的做法是:本地开发放在application-local.yml并且加入.gitignore,测试和生产通过环境变量或者配置中心注入,代码里永远只写${WECOM_WEBHOOK_KEY}这种占位符。这条规矩看起来小题大做,但凡是团队里出过一次 key 泄露到公开仓库的事故,都会理解它的必要性。
2.2 消息体结构:msgtype 决定一切
请求体是一个 JSON,最外层有两个关键字段:msgtype和与它同名的子对象。这种设计在接口里叫“标签联合体”,理解了这个模式,后面所有消息类型都是同一个套路。
纯文本的最简结构:
{ "msgtype": "text", "text": { "content": "构建完成,耗时 42 秒", "mentioned_list": ["@all"], "mentioned_mobile_list": ["13800000000"] } }msgtype的值决定了服务端去解析哪个子对象。如果msgtype是text,服务端就只看text字段,其他字段一律忽略。一旦msgtype和子对象名字对不上,比如写了markdown却没给markdown对象,就会拿到参数错误。
mentioned_list里放的是群成员的账号 ID,@all表示所有人;mentioned_mobile_list放手机号。这两个字段只在text类型下有效,Markdown 类型不支持 @ 提醒——这是个高频踩坑点,后面会细说。
2.3 五种消息类型的取舍与容量限制
官方支持的类型包括text、markdown、image、news、file,实际项目里用得最多的是前两种。我把它们的特性整理成表:
| 类型 | 内容上限 | 支持 @ | 支持颜色/加粗 | 典型用途 |
|---|---|---|---|---|
| text | 2048 字节 | 是 | 否 | 短提示、简单状态同步 |
| markdown | 4096 字节 | 否 | 是,支持标题、加粗、引用、链接、字体颜色 | 告警详情、构建报告、日报 |
| image | 图片 base64 不超过 2MB | 否 | - | 图表截图、监控曲线 |
| news | 最多 8 条图文 | 否 | - | 多条资讯聚合 |
| file | 需先上传拿 media_id | 否 | - | 日志文件、报表附件 |
这张表里有两个数字要特别留意:2048 字节和 4096 字节。注意单位是字节不是字符,中文在 UTF-8 下一个字占 3 个字节,也就是说 text 类型实际只能放六百多个汉字,一段稍长的堆栈信息就直接超了。超长不会报错,而是会被截断,消息照发,但后半段静悄悄消失,这个现象非常迷惑人,我在第一次遇到时排查了很久,以为是编码问题。
所以我的建议是:任何可能超过 500 字的内容,一律走 markdown;如果连 4096 字节都放不下(比如完整异常堆栈),就截断后附一句“完整日志请查看附件或日志平台”,或者把日志文件上传后用file类型发出去。
2.4 频率限制:每分钟 20 条是硬约束
每个机器人每分钟最多发送 20 条消息。这个限制不是建议,是硬性的,超过之后接口会返回频率超限的错误码,消息直接丢弃。
20 条听起来不少,但在真实场景里很容易撞线。我遇到过一次:某个批量任务处理了 30 个订单,每个订单失败都单独发一条通知,结果前 20 条发出去,后面 10 条全丢,而且因为没做失败处理,业务方完全没感知。这种“看起来在正常工作、实际上悄悄丢消息”的故障是最难排查的。
正确的做法是在客户端主动限流,而不是等服务端拒绝。思路有两种:单机用令牌桶,把速率控制在每分钟 20 个以内并留一点余量;多实例部署时要么改用集中式限流,要么在业务侧做聚合——把同一批次的 N 条通知合并成一条 Markdown 列表,这往往比限流更优雅。
我个人的偏好是聚合优先。通知的目的是让人看见并行动,三十条刷屏消息只会让人把群消息免打扰,一条汇总消息反而更容易被认真读。
3. Java 落地实现:从零到第一条通知
3.1 HTTP 客户端选型:别为了一个小功能引入重型依赖
发一个 POST 请求,可选方案很多,我把实际用过的几个列出来对比:
| 方案 | 依赖体积 | 上手难度 | 连接池 | 适用场景 |
|---|---|---|---|---|
| JDK 11+ HttpClient | 零依赖 | 低 | 内置 | 新项目首选,无第三方依赖 |
| OkHttp | 小 | 低 | 内置,配置灵活 | 已有 OkHttp 的项目,需要精细控制超时 |
| Apache HttpClient | 中 | 中 | 内置 | 老项目、需要复杂代理配置 |
| Spring RestTemplate | 跟随 Spring | 低 | 需配工厂 | Spring 项目,但已被标记为维护模式 |
| Spring WebClient | 跟随 WebFlux | 中 | 内置 | 响应式栈,需要非阻塞 |
如果项目跑在 JDK 11 以上,我一般直接用内置的java.net.http.HttpClient,零依赖、API 清爽、支持异步。如果项目已经用了 OkHttp(比如集成了其他 SDK 顺带带进来),那就用 OkHttp,没必要为了“统一”再去引一个。
这里有个细节值得说:HttpClient 实例要复用,不要每次发消息都 new 一个。每次新建意味着每次都要重新建 TCP 连接、重新做 TLS 握手,在告警风暴的时候会额外增加几百毫秒延迟,还可能因为本地端口耗尽而失败。JDK 的HttpClient本身是线程安全的,声明成static final或者交给 Spring 管理单例即可。
3.2 最小可用版本:三十行代码跑通链路
先把最短路径跑通,这一步的意义在于验证 key 有效、网络可达、格式正确,排除掉大部分环境问题。
import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; public class WecomRobot { private static final HttpClient CLIENT = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build(); private final String webhookUrl; public WecomRobot(String webhookUrl) { this.webhookUrl = webhookUrl; } public String sendText(String content) throws Exception { String body = """ { "msgtype": "text", "text": { "content": %s } } """.formatted(toJsonString(content)); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(webhookUrl)) .timeout(Duration.ofSeconds(10)) .header("Content-Type", "application/json; charset=utf-8") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); return response.body(); } // 简易转义,生产环境请用 Jackson private String toJsonString(String raw) { return "\"" + raw.replace("\\", "\\\\") .replace("\"", "\\\"") .replace("\n", "\\n") + "\""; } }这段代码能跑,但它故意留了一个坑:手写 JSON 转义。我在真实项目里见过有人这么干,然后在消息里带了一个制表符或者中文引号,整个 JSON 就废了。所以下一节必须换成正经的序列化库。
调用方式:
WecomRobot robot = new WecomRobot(System.getenv("WECOM_WEBHOOK_URL")); System.out.println(robot.sendText("服务启动完成,环境:test"));返回值是一个 JSON,成功时长这样:
{"errcode":0,"errmsg":"ok"}注意:HTTP 状态码 200 不代表发送成功。接口在业务失败时依然返回 200,真正的结果藏在errcode里。这是所有新手都会踩的第一个坑,后面排查章节会详细展开。
3.3 用 Jackson 构建消息体,把转义交给库
换成 Jackson 之后,代码结构建议改成“实体类 + ObjectMapper”,这样字段名、嵌套关系都是强类型的,改起来不容易出错。
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.ObjectMapper; public class TextMessage { @JsonProperty("msgtype") private final String msgType = "text"; @JsonProperty("text") private final TextBody text; public TextMessage(String content) { this.text = new TextBody(content); } public static class TextBody { @JsonProperty("content") private final String content; TextBody(String content) { this.content = content; } } }序列化就一行:objectMapper.writeValueAsString(new TextMessage("内容"))。Jackson 会自动处理引号、换行、中文、emoji 的转义,不需要你操心。
有一个配置项值得注意:不要开启WRITE_DATES_AS_TIMESTAMPS之外的花哨特性,也不要配置NON_NULL之外的字段过滤,保持默认最稳。曾经有同事全局配置了SerializationFeature.INDENT_OUTPUT,结果每条消息体都带上换行和缩进,白白多出几百字节,在接近 2048 字节上限的场景里直接超长。
Markdown 消息的实体几乎一样,只是把msgtype换成markdown,子对象名也换成markdown:
public class MarkdownMessage { private final String msgtype = "markdown"; private final MarkdownBody markdown; public MarkdownMessage(String content) { this.markdown = new MarkdownBody(content); } public static class MarkdownBody { private final String content; MarkdownBody(String content) { this.content = content; } public String getContent() { return content; } } public String getMsgtype() { return msgtype; } public MarkdownBody getMarkdown() { return markdown; } }3.4 封装成可复用的组件:把返回值解析、日志、异常都吃进去
把上面这些东西收拢到一个类里,才是能在项目里长期用的形态。核心要点有三个:返回值必须解析errcode、异常必须被捕获不能让通知失败影响主流程、日志里必须对 key 做脱敏。
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class WecomNotifier implements Notifier { private static final Logger log = LoggerFactory.getLogger(WecomNotifier.class); private static final ObjectMapper MAPPER = new ObjectMapper(); private final String webhookUrl; private final HttpClient client; public WecomNotifier(String webhookUrl) { this.webhookUrl = webhookUrl; this.client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(3)) .build(); } @Override public boolean sendText(String content) { return doSend(new TextMessage(content), "text"); } @Override public boolean sendMarkdown(String content) { return doSend(new MarkdownMessage(content), "markdown"); } private boolean doSend(Object message, String type) { long start = System.currentTimeMillis(); try { String body = MAPPER.writeValueAsString(message); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(webhookUrl)) .timeout(Duration.ofSeconds(5)) .header("Content-Type", "application/json; charset=utf-8") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() != 200) { log.warn("企业微信通知 HTTP 异常, status={}, cost={}ms", response.statusCode(), System.currentTimeMillis() - start); return false; } JsonNode node = MAPPER.readTree(response.body()); int errcode = node.path("errcode").asInt(-1); if (errcode != 0) { log.warn("企业微信通知业务失败, errcode={}, errmsg={}", errcode, node.path("errmsg").asText()); return false; } log.debug("企业微信通知发送成功, type={}, cost={}ms", type, System.currentTimeMillis() - start); return true; } catch (Exception e) { // 通知失败绝不向上抛,避免拖垮主业务 log.error("企业微信通知发送异常, url={}", maskUrl(webhookUrl), e); return false; } } private String maskUrl(String url) { int idx = url.indexOf("key="); if (idx < 0) return url; return url.substring(0, idx) + "key=***"; } }这段代码里有两个决定值得解释。
第一个是吞掉所有异常只返回布尔值。理由是通知属于旁路能力,它的失败不应该影响主业务。想象一下订单支付成功的逻辑里嵌了一个通知调用,结果因为网络抖动抛了异常,事务回滚,用户钱扣了单没生成——这个事故的代价远大于“少发一条消息”。所以我的原则是:通知层永远向外返回结果,不向上抛异常,失败由调用方决定是否补救。
第二个是日志里对 URL 做脱敏。maskUrl把 key 替换成***,这样即使日志被采集到日志平台,也不会泄露凭据。这个处理成本极低,但一旦遗漏,凭据就会以明文形式长期躺在日志系统里。
3.5 和 Spring 整合:配置外置与条件装配
如果项目是 Spring Boot,把这个组件交给容器管理,配置文件里只放占位符,是最省心的做法。
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration @ConfigurationProperties(prefix = "wecom") public class WecomConfig { private String webhookUrl; private boolean enabled = true; @Bean public Notifier notifier() { if (!enabled || webhookUrl == null || webhookUrl.isBlank()) { return new NoopNotifier(); // 空实现,什么都不做 } return new WecomNotifier(webhookUrl); } public String getWebhookUrl() { return webhookUrl; } public void setWebhookUrl(String webhookUrl) { this.webhookUrl = webhookUrl; } public boolean isEnabled() { return enabled; } public void setEnabled(boolean enabled) { this.enabled = enabled; } }配置文件:
wecom: enabled: true webhook-url: ${WECOM_WEBHOOK_URL:}这里引入了NoopNotifier——一个所有方法都返回 true 但不做任何事的空实现。它的价值在于本地开发和单元测试:开发者在本地不需要真实 key 也能跑通业务流程,测试环境不会因为缺少配置而启动失败。这种“可关闭的旁路能力”是很实用的设计习惯,我在每个外部依赖上都这么做。
3.6 用 Markdown 把消息做得好看一点:模板设计
纯文本能表达信息,但不好读。Markdown 类型支持标题、加粗、引用块、链接和字体颜色,用它做告警卡片效果会好很多。
public class MarkdownTemplates { public static String buildAlert(String title, String level, String detail, String happenTime) { String color = "严重".equals(level) ? "warning" : "info"; return """ ## %s > 级别:<font color="%s">%s</font> > 时间:%s > 详情:%s """.formatted(title, color, level, happenTime, detail); } }渲染出来是一个带二级标题的卡片,级别那一行会显示成彩色。企业微信的 Markdown 支持<font color="info|comment|warning">三种颜色,info是绿色、comment是灰色、warning是橙红色,用这三个值做严重程度分级刚好够用。
模板设计上我有几个固定习惯。一是第一行永远放结论,比如“订单同步任务失败”,让人扫一眼就知道要不要细看。二是关键数字加粗,比如失败 3 条 / 共 100 条,数字是决策依据。三是控制在一屏之内,超过一屏的消息大概率没人看完。四是附上跳转链接,日志平台、监控面板、工单系统的链接都放上,让人看完能立刻行动。
4. 生产级改造:让通知在真实环境里靠谱
4.1 失败重试:区分“值得重试”和“重试也没用”
不是所有失败都该重试。网络超时、连接被重置、5xx 状态码,这些是瞬时故障,重试有意义;而errcode表示 key 无效、消息格式错误、消息超长,这些是确定性错误,重试一百次结果一样,只会白白消耗频率配额。
所以重试逻辑必须先做错误分类:
public boolean sendWithRetry(String content, int maxAttempts) { long backoff = 200L; for (int i = 1; i <= maxAttempts; i++) { SendResult result = doSendAndClassify(content); if (result.isSuccess()) { return true; } if (!result.isRetryable()) { log.warn("不可重试错误,直接放弃, reason={}", result.getReason()); return false; } if (i < maxAttempts) { try { Thread.sleep(backoff); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } backoff *= 2; // 指数退避:200ms、400ms、800ms } } return false; }退避策略我一般用 3 次、200ms 起步、每次翻倍,总耗时约 1.4 秒。这个量级对于告警场景足够,再长就会让调用线程阻塞太久。这里必须提醒:重试要加抖动,如果多个实例同时失败同时重试,会在同一毫秒打到服务端,容易触发限流。加个ThreadLocalRandom.current().nextLong(100)的随机偏移就能缓解。
还有一点容易被忽略:重试不能造成重复消息。如果第一次请求其实已经成功了,只是响应在网络里丢了,重试就会让群里出现两条一样的消息。对于告警类消息,重复比丢失的伤害小,可以接受;但对于“审批结果通知”这类消息,就需要业务侧做去重,比如在消息里带一个唯一业务 ID,或者在本地记录已发送的 ID 集合。
4.2 主动限流:别等服务端打回你
前面说过限制是每分钟 20 条。单机场景最简单的实现是令牌桶:
import com.google.common.util.concurrent.RateLimiter; private final RateLimiter limiter = RateLimiter.create(0.3); // 每秒 0.3 个,即每分钟 18 个 public boolean sendSafely(String content) { if (!limiter.tryAcquire(1, TimeUnit.SECONDS)) { log.warn("触发本地限流,消息被丢弃或转入队列"); return false; } return doSend(new TextMessage(content), "text"); }0.3 的速率意味着每分钟 18 条,故意留了两条余量,防止时钟漂移或者与其他实例共享 key 时超额。Guava 的RateLimiter是单机版,多实例部署时每个实例各自限流,总量还是会超。这种情况要么换成基于 Redis 的分布式限流,要么给不同实例分配不同的 key。
比限流更值得推荐的是消息聚合。我做过一个失败任务的告警,原来是每失败一条发一条,后来改成用一个ConcurrentLinkedQueue收集,每 10 秒或者攒够 20 条就 flush 一次,拼成一条带列表的 Markdown。改完之后群里的消息量下降了一个数量级,可读性反而更好,因为能看到“这一批总共失败了多少条”。
4.3 异步化:别让通知阻塞业务线程
同步发通知的耗时包括建连、TLS 握手、等待响应,正常在几十毫秒到几百毫秒,网络不好时可能到秒级。如果这段耗时发生在 Tomcat 的工作线程里,高并发下线程池很快会被占满。
所以生产环境我基本都用异步:一个专用的小线程池,有界队列,拒绝策略是丢弃并计数。
@Configuration public class NotifyExecutorConfig { @Bean("notifyExecutor") public ThreadPoolExecutor notifyExecutor() { return new ThreadPoolExecutor( 2, 4, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(500), r -> { Thread t = new Thread(r, "wecom-notify"); t.setDaemon(true); return t; }, new ThreadPoolExecutor.DiscardPolicy() // 队列满了就丢,不阻塞业务 ); } }参数是这么定的:核心线程 2 个,因为通知是 IO 密集且量不大,两个线程足够;最大 4 个,应对突发;队列 500,按每条消息平均 1KB 估算,最多占 500KB 内存,可以接受;拒绝策略用DiscardPolicy,因为通知是可丢弃的,宁可丢消息也不能让业务线程因为队列满而阻塞。
注意这里用DiscardPolicy有个副作用:丢弃是静默的,你完全不知道丢了多少。所以我在实际项目里会重写RejectedExecutionHandler,丢的时候打一条 warn 日志并给一个计数器加一,方便后续观察。
4.4 敏感信息处理:消息会长期留在群里
这一点特别容易被忽视。群里发的消息是可以被搜索、被截图、被转发、被长期保存的,一旦里面带了用户手机号、身份证、银行卡、密钥,就等于把这些数据扩散到了无法收回的地方。
我给自己定的三条规则:
- 该打码的一律打码。手机号保留前三位后四位,中间用
****;身份证只留前六后四;邮箱只留@前面的首字母。 - 绝不发完整的请求体或响应体。业务日志里可能包含 token、cookie、完整报文,直接丢进群是事故。要发就发经过白名单过滤的字段。
- 异常堆栈只发首行和异常类名,完整堆栈留在日志平台,消息里给个链接。
这段脱敏逻辑最好收在模板层,因为那是所有消息的必经之路。我一般会写一个MaskUtils工具类,模板里凡是涉及用户数据的地方都过一遍,形成肌肉记忆。
4.5 可观测性:知道发了多少、失败多少、慢了没
通知虽然是旁路,但也需要被观测。最少要有三个指标:发送总数、失败数、耗时分布。这些数据用 Micrometer 打点到 Prometheus,配一个简单的看板,就能在通知通道出问题时第一时间发现。
private final Counter sendTotal = Counter.builder("wecom.notify.total") .tag("type", "text").register(registry); private final Counter sendFail = Counter.builder("wecom.notify.fail") .tag("reason", "errcode").register(registry);除了指标,我还会在每天固定时间跑一次“探活”:给一个专门的测试群发一条无害的消息,确保通道是通的。这个做法的价值在于主动发现而不是被动等待——很多通知通道的故障是因为 key 被误删、机器人被移出群、群被解散,这些变更不会通知任何人,只有发不出去的时候才知道。
5. 常见问题排查与避坑实录
5.1 错误码速查表
拿到errcode不等于 0 时,先查这张表,能覆盖 90% 的情况:
| errcode | 含义 | 排查方向 | 是否可重试 |
|---|---|---|---|
| 0 | 成功 | - | - |
| 93000 | 无效的 webhook key | key 拼写错误、机器人被移除、key 已失效 | 否 |
| 40058 | 参数不合法 | JSON 结构错误、msgtype 与子对象不匹配 | 否 |
| 40008 | 消息类型不支持 | msgtype 拼写错误,注意大小写 | 否 |
| 45009 | 接口调用超过限制 | 触发每分钟 20 条限制 | 是,等待后重试 |
| 44004 | 内容为空 | content 为空字符串或全是空白字符 | 否 |
| 40014 | 不合法的 access_token | 混淆了群机器人与应用消息接口 | 否 |
这张表里最值得一提的是最后一行。网上大量示例代码是给“企业微信应用消息”写的,需要先获取 access_token,再调/cgi-bin/message/send。那套接口和群机器人是两条完全不同的路径,把两者混用就会出现各种看不懂的错误。判断依据很简单:群机器人的 URL 里带key=,应用消息的 URL 里带access_token=。
5.2 高频问题与解决思路
问题一:消息发出去但内容被截断了。十有八九是超过 2048 字节上限。中文按 3 字节算,一个 800 字的异常堆栈轻松突破。解决办法是改 Markdown,或者先截断再发送,并在末尾注明“已截断”。
问题二:Markdown 消息里 @ 人不生效。这不是 bug,Markdown 类型本身就不支持mentioned_list。如果必须 @ 人,就把提醒单独发一条 text 类型的短消息,或者干脆在 Markdown 里把名字加粗,靠人眼识别。
问题三:本地能发,服务器发不出去。先看网络出口,确认服务器能访问对应的域名;再看 DNS 解析;最后看是否有出站白名单限制。这类问题在容器环境里尤其常见,因为容器的出网策略往往和宿主机不同。
问题四:间歇性失败,日志里有 timeout。多半是HttpClient每次新建导致的连接开销,或者超时设置太短。我一般把连接超时设 3 秒、请求超时设 5 秒,并且复用客户端实例。
问题五:同一时间收到大量重复消息。检查是不是重试逻辑没做幂等,或者多个实例同时在跑定时任务却没有分布式锁。后者是典型的多实例重复执行问题,需要靠调度框架的分布式锁解决,光靠改通知代码没用。
问题六:消息里全是乱码。请求头没有指定charset=utf-8,或者用BodyPublishers.ofString时用了错误的编码。JDK 的ofString默认是 UTF-8,但如果显式传了别的字符集就会出问题。
5.3 上线前的自检清单
每次把通知功能接进一个新系统,我都会过一遍这几条:
- key 是否通过环境变量或配置中心注入,代码里是否有硬编码残留
- 日志中的 Webhook 地址是否做了脱敏
- 是否解析了返回体的
errcode,而不是只看 HTTP 状态码 - 是否有本地限流,速率是否低于每分钟 20
- 通知失败是否会抛出异常影响主业务
- 是否对用户敏感数据做了打码
- 是否有至少一个针对发送失败的可观测指标
- Markdown 内容长度是否做过检查,超长时是否有降级方案
5.4 几个值得记下来的实操心得
第一,先用 curl 验证,再写代码。拿到 key 之后,直接在终端里发一条curl,能立刻区分是网络问题还是代码问题。这一步能省掉大量在 IDE 里瞎猜的时间。
curl -X POST 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=你的key' \ -H 'Content-Type: application/json' \ -d '{"msgtype":"text","text":{"content":"测试消息"}}'第二,准备一个专门的测试群。开发阶段的调试消息不要发到正式的告警群,否则很容易在半夜触发全员通知,影响很不好。测试群的 key 单独配置,并且在配置文件里明确标注。
第三,给消息加来源标识。多个系统共用一个群的时候,消息前面带上[订单服务]、[构建流水线]这样的前缀,能让人一眼分清是谁发的。这个小细节在多系统环境下极其有用。
第四,估算消息长度要在字节层面做,不是字符层面。我写过一个简单的估算方法:中文按 3 个字节、英文和数字按 1 个字节、换行按 1 个字节累加,超过阈值就截断。粗糙但够用,比上线后发现被截断要好。
第五,别忘了关闭开关。前面提到的wecom.enabled配置项,在演练、压测、故障处理时能一键关掉通知,避免雪崩时被消息洪水淹没。这个开关的价值在平静时期看不出来,在故障时期非常关键。
第六,群机器人不是监控系统的替代品。它只是“最后一公里”的触达手段,真正的问题定位依赖日志、指标、链路追踪。把通知当作唯一的信息来源,排查时就会陷入“只知道失败了,不知道为什么”的困境。
说到底,Java 调企业微信群机器人这件事本身技术含量不高,一个 POST 请求就能通。真正拉开差距的是那些工程细节:返回值怎么判、错误怎么分类、限流放在哪一层、失败怎么兜底、凭据怎么保护、消息怎么让人愿意看。这些细节在文档里通常不会写,只有在真实项目里被坑过之后才会养成习惯。我个人现在给任何系统接通知通道,基本都是照着上面这套骨架搭一遍,改动很少,跑得也稳。