
前阵子接了个需求Java后端服务里要根据定时任务自动推送监控告警一部分发到微信、一部分发到QQ群而且告警要能带截图。网上搜了几天答案几乎全是个人号协议、定制hook、第三方机器人框架这类方案看着功能很全但实际上跑不了两天号就没了更别提发图片时各种报错。我后来把整套链路切到官方能力上微信走企业微信自建应用QQ走官方机器人渠道用Java把文本和图片消息都调通了。这篇文章把从选型、配置、核心代码到生产环境的坑完整过一遍适合做告警推送、运营通知、群消息自动化的Java后端也适合刚接触微信/QQ开放接口的同学。1. 不是所有“自动发消息”都叫正经方案技术选型决定成败1.1 我当年踩过的坑个人号协议为什么不能用先说结论但凡要长期稳定跑就别碰个人微信、个人QQ的协议自动化。市面上确实有一堆现成工具模拟登录微信/QQ然后发消息。它们的基本原理要么是Hook住PC客户端的消息发送函数要么是逆向分析移动端的私有协议再通过HTTP接口暴露给Java调用。这种方案有两个致命问题一是平台的风控策略一直在升级今天能发明天用户名就没了所有代码全部白写二是从合规角度看这明摆着违反平台用户协议涉及营销、批量发送时还有法律风险。所以这篇里我不会给任何非官方协议的操作方法。真正的正路是走平台提供的官方接口要么是企业微信、微信公众号要么是QQ开放平台的机器人能力。这些接口权限可能没个人号那么“随心所欲”但胜在稳定而且Java接入的复杂度完全可控。1.2 官方渠道怎么选四个选项的对比我梳理了一套对照表每次接新项目我都会先按这个表过一遍渠道官方程度接入成本稳定度适合场景企业微信自建应用官方API低后台创建应用即可拿凭据高给企业成员推送告警、报表、审批通知微信公众号模板消息官方API中需要认证服务号高给公众号粉丝推送服务通知QQ群Webhook机器人官方能力极低群管理里添加即可中给QQ群推送定时消息QQ官方机器人API官方开放平台中需要创建机器人高群/频道内交互、指令式通知我的经验是如果是自家服务给自家员工推告警企业微信自建应用是最省事的手机上装个企业微信就能收如果消息要进QQ群优先用群Webhook一个URL直接POST过去就完事如果要做成能被用户、能自动回复的机器人那再上QQ官方机器人API。1.3 我最终落地的组合拳我做监控系统时采用的方案是双通道并行所有紧急告警走企业微信应用消息直接推给值班人员和研发群日常的巡检汇总走QQ群机器人定时把趋势图和统计表格发到群里。图片这块企业微信用素材上传拿media_idQQ机器人直接传公网图片URL。这套组合的优点是每个环节都是官方接口出了问题能查文档、能看到错误码而不是像个人号方案那样整个链路是黑盒。下面分开讲微信侧和QQ侧的具体实现。2. 微信侧企业微信自建应用的配置与Java代码落地2.1 后台配置corpid、secret、agentid分别是什么用企业微信发消息先要搞明白三个核心参数我在第一次接入时被这三个术语绕了很久。corpid企业ID登录企业微信管理后台后在“我的企业 企业信息”里能看到是一串以ww开头的字符串。agentid自建应用的AgentId在“应用管理 应用 自建”里点进某个应用详情页就能看到是个纯数字。secret对应应用的密钥也在应用详情页里只显示一次点“查看”后会发送到企业微信客户端复制出来保存好它是调API的通行证。创建自建应用时关键一步是设置可见范围。默认情况下应用消息只能推给可见范围内的成员所以要把目标成员或部门加进去。不需要配置公网回调域名因为我们是主动调企业微信接口不是被动接收事件这个比公众号简单很多。2.2 用Java获取access_token并做好本地缓存企业微信接口的通用凭证是access_token获取接口是GET https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpidCORPIDcorpsecretSECRETtoken有效期官方默认是7200秒也就是2小时。这里有个新手最容易踩的坑只要服务重启或者多个实例同时请求频繁调用gettoken接口就会触发频率限制返回errcode: 45009。所以必须做缓存而且最好比官方过期时间提前一点刷新。我封装了一个简单的双检锁缓存版客户端用RestTemplate实现Component public class WeComTokenManager { private static final String TOKEN_URL https://qyapi.weixin.qq.com/cgi-bin/gettoken; private final RestTemplate restTemplate new RestTemplate(); private volatile String accessToken; private volatile long expireAt; // 单位毫秒 public String getToken(String corpId, String corpSecret) { long now System.currentTimeMillis(); if (accessToken ! null now expireAt) { return accessToken; } synchronized (this) { now System.currentTimeMillis(); if (accessToken ! null now expireAt) { return accessToken; } String url String.format(%s?corpid%scorpsecret%s, TOKEN_URL, corpId, corpSecret); MapString, Object resp restTemplate.getForObject(url, Map.class); if (resp ! null Integer.valueOf(0).equals(resp.get(errcode))) { accessToken (String) resp.get(access_token); // expires_in 单位是秒提前200秒刷新避免边界请求撞上过期 Number expiresIn (Number) resp.get(expires_in); expireAt now (expiresIn.longValue() - 200) * 1000; return accessToken; } throw new RuntimeException(获取企业微信access_token失败: resp); } } }这里有一个容易翻车的细节resp.get(errcode)在Jackson反序列化后可能是Integer所以用Integer.valueOf(0).equals(...)比较才安全。另外返回的expires_in可能是Integer要转成Number再取long值。2.3 发送文本消息的完整代码拿到token之后发送应用消息的接口是POST https://qyapi.weixin.qq.com/cgi-bin/message/send?access_tokenACCESS_TOKEN请求体长这样{ touser: zhangsan|lisi, msgtype: text, agentid: 1000002, text: { content: 告警CPU 使用率超过 90% } }touser支持多个成员用竖线|分隔也支持all表示全员但全员推送要谨慎容易造成骚扰。agentid必须是自建应用的ID不能随便填。我用RestTemplate发送public void sendText(String token, Integer agentId, String touser, String content) { String url https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token token; MapString, Object body new HashMap(); body.put(touser, touser); body.put(msgtype, text); body.put(agentid, agentId); MapString, String text new HashMap(); text.put(content, content); body.put(text, text); ResponseEntityMap resp restTemplate.postForEntity(url, body, Map.class); MapString, Object result resp.getBody(); // 返回 errcode0 代表发送成功 if (result null || !Integer.valueOf(0).equals(result.get(errcode))) { throw new RuntimeException(企业微信消息发送失败: result); } }发送成功后接口会返回msgid我在日志里一定会把msgid记下来后面排查“用户怎么没收到”全靠它。这里提醒一点如果成员手机上没有安装企业微信应用消息会以企业微信服务通知的形式推给TA但前提是这个成员已经在企业通讯录里。2.4 什么场景换公众号更合适企业微信适合内部员工但如果要面向C端用户推送就需要用微信公众号准确说是认证服务号的模板消息。公众号的access_token获取接口和企业微信不一样GET https://api.weixin.qq.com/cgi-bin/token?grant_typeclient_credentialappidAPPIDsecretSECRET发送模板消息的接口是POST https://api.weixin.qq.com/cgi-bin/message/template/send?access_tokenTOKEN请求体{ touser: OPENID, template_id: 模板ID, data: { first: {value: 系统告警}, keyword1: {value: CPU使用率90%}, keyword2: {value: 2024-01-01 12:00:00} } }这里最大前提是用户必须关注了你的服务号并且你要在用户关注事件里把openid存下来。模板消息适合发送订单状态、服务通知这类合规场景不能拿来发营销广告否则模板会被封禁。3. QQ侧群Webhook和官方机器人都能Java调通3.1 QQ群Webhook五分钟跑通最简单的推送如果想不写一堆鉴权逻辑直接把消息推到QQ群QQ群机器人Webhook是效率最高的方案。操作路径是在QQ群聊窗口的机器人入口添加一个群机器人创建之后会拿到一个Webhook地址。之后只要往这个地址POST一个JSON就能往群里发消息。关键就是要知道这个JSON的字段格式不同版本的群机器人可能略有差别但核心一般是content和image。public void sendToQQGroupWebhook(String webhookUrl, String content, String imageUrl) { MapString, Object body new HashMap(); body.put(content, content); if (imageUrl ! null !imageUrl.isEmpty()) { body.put(image, imageUrl); } HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntityMapString, Object request new HttpEntity(body, headers); ResponseEntityString resp restTemplate.postForEntity(webhookUrl, request, String.class); if (resp.getStatusCode().is2xxSuccessful()) { log.info(QQ群Webhook发送成功内容长度{}, content.length()); } else { log.error(QQ群Webhook发送失败{}, resp.getStatusCode()); } }这个方案有个安全细节Webhook地址就是钥匙谁拿到这个URL谁就能往群里发消息。所以一定不要在日志里打印完整URL更不要提交到公开仓库。后台如果支持设置IP白名单尽量把服务器公网IP填进去缩小暴露面。3.2 官方机器人APIAppID与Token认证下的消息发送如果要做更正经的场景比如群内有指令交互、需要管理消息上下文那就上QQ官方机器人开放平台。大致步骤是在开放平台注册开发者、创建机器人、拿到AppID和Token然后把机器人添加到自己的群或频道在开发者后台配置沙箱环境进行联调。官方机器人接口的鉴权方式很特别不是常见的Bearer Token而是需要在请求头里带Authorization: Bot {appId}.{token}发送消息的接口路径一般是POST https://api.sgroup.qq.com/channels/{channel_id}/messages请求体示例{ content: Java自动推送 | 每日日报, image: https://example.com/daily.png, msg_id: 要回复的消息ID主动推送时可以不带 }用Java调的话本质上是普通的HTTP POSTpublic void sendQQBotMessage(String appId, String token, String channelId, String content, String imageUrl) { String url https://api.sgroup.qq.com/channels/ channelId /messages; HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set(Authorization, Bot appId . token); MapString, Object body new HashMap(); body.put(content, content); if (imageUrl ! null !imageUrl.isEmpty()) { body.put(image, imageUrl); } HttpEntityMapString, Object request new HttpEntity(body, headers); ResponseEntityMap resp restTemplate.postForEntity(url, request, Map.class); log.info(QQ机器人发送结果status{}, body{}, resp.getStatusCode(), resp.getBody()); }有人可能会问channelId怎么拿在官方机器人事件里消息对象会携带channel_id字段如果是主动向某个群推送场景需要在开放平台通过一定方式获取群ID不同版本接口叫法不同以官方文档为准。刚接入时强烈建议先在沙箱频道里测通再切到正式群。3.3 事件驱动当有人机器人时用Java响应如果机器人需要接收群消息并自动回复就涉及事件驱动官方推荐用WebSocket长连接来接收消息事件。Java里用Java-WebSocket库实现起来很方便dependency groupIdorg.java-websocket/groupId artifactIdJava-WebSocket/artifactId version1.5.4/version /dependency连接WebSocket时在Header里带上认证信息收到消息后解析事件类型。以AT_MESSAGE_CREATE事件为例JSON里有频道ID、发送者ID、消息内容等字段。解析出这些字段后调用上一个小节的消息发送接口就能实现回复。整体链路就是群内有人机器人 - 平台推送事件到WebSocket - Java应用处理并调用API回复。这个模式下要特别注意心跳保活和断线重连。长连接偶尔断开是正常的关键是重连时不要疯狂循环建议用指数退避策略第一次等1秒第二次2秒第三次4秒最大间隔不超过60秒。4. 图片消息素材上传、media_id和URL的那点事4.1 企业微信上传素材multipart请求拿media_id微信体系发图片和发文本不一样必须先上传图片拿到media_id再引用这个media_id去发消息。企业微信上传临时素材的接口是POST https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_tokenACCESS_TOKENtypeimage这是一个multipart/form-data请求表单字段名必须是media文件名必须带扩展名否则会报invalid media。我用OkHttp实现上传代码更清晰public String uploadImage(String token, byte[] imageBytes, String fileName) throws IOException { String url https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token token typeimage; OkHttpClient client new OkHttpClient(); RequestBody fileBody RequestBody.create(imageBytes, MediaType.parse(image/png)); RequestBody requestBody new MultipartBody.Builder() .setType(MultipartBody.FORM) .addFormDataPart(media, fileName, fileBody) .build(); Request request new Request.Builder() .url(url) .post(requestBody) .build(); try (Response response client.newCall(request).execute()) { String respBody response.body().string(); // 解析JSON拿到media_id JsonObject json JsonParser.parseString(respBody).getAsJsonObject(); if (json.get(errcode) ! null json.get(errcode).getAsInt() ! 0) { throw new RuntimeException(上传图片失败 respBody); } return json.get(media_id).getAsString(); } }上传成功后返回的media_id官方建议用字符串存不要存成数字因为微信素材ID长度不固定。4.2 微信侧发图image消息体与3天有效期拿到media_id之后发送图片消息的请求体是{ touser: zhangsan, msgtype: image, agentid: 1000002, image: { media_id: MEDIA_ID } }Java实现时基本就是把文本消息的text字段换成image.media_id没有其他特别之处。这里有个非常关键的坑企业微信的临时素材media_id有效期是3天公众号临时素材同样只有3天有效期。可以把token和media_id一起做缓存但缓存过期时间不能超过3天过期后必须重新上传。我在实际项目里是把图片上传结果放Rediskey是图片内容哈希value是media_idTTL设置为2天这样既能复用图片又能避免素材失效。4.3 QQ侧图片URL消息与富媒体素材QQ侧的思路和微信完全不同它更偏向直接给图片URL。无论是群Webhook还是官方机器人API请求体里image字段填的就是一张图片的完整URL地址。这意味着图片必须公网可访问而且协议必须是HTTPS否则会发送失败。我在实际项目里遇到最多的报错就是图片URL用了http://或者地址是内网IP192.168.x.x平台那边根本访问不到。如果图片只存在服务器本地处理方案是先上传到对象存储拿到公网URL再调用QQ接口。这一步可以用云厂商的OSS SDK也可以自己实现的简单静态文件服务只要能生成可直接访问的图片链接就行。4.4 图片处理的通用细节扩展名、大小限制和压缩无论是微信还是QQ发图片消息都有一些通用限制我踩坑后总结了几条图片大小企业微信临时素材限制2MB以内公众号是10MBQQ机器人通常也不能太大。超过限制先压缩再传。文件扩展名上传素材时文件名一定要带.png或.jpg以扩展名判断文件类型省掉很多invalid media报错。GIF动图GIF在多数渠道要么被转成静态图要么直接失败。监控告警截图建议直接转成PNG/JPG。公网可达QQ侧图片必须公网HTTPS可达微信侧素材上传则可从服务器直传不需要公网回源。Java里压缩图片我用的是imageio自带能力简单场景够用要压缩到指定尺寸以内可以用Thumbnator库代码量少很多。核心思路是先读图片宽高等比缩放输出到ByteArrayOutputStream最后拿byte[]做上传。生产环境我一般把截图控制在1MB以内因为压缩后上传速度快发送成功率也高。5. 上线前必看的五个生产问题5.1 access_token的并发缓存单机双检锁和多实例Redis方案第二章的本地缓存方案适合单实例部署如果应用是多个实例每个实例各自缓存一份token在token刷新窗口期可能出现部分实例拿到旧token、部分实例拿到新token虽然微信接口容忍这种情况但遇到多实例瞬间一起过期还是会有并发穿透。多实例环境更稳的做法是分布式缓存。我用Redis实现过一版核心逻辑是public String getTokenWithRedis(String corpId, String corpSecret) { // 1. 先查Redis缓存 String token redis.get(wecom:token: corpId); if (token ! null) { return token; } // 2. 加分布式锁只让一个实例去调gettoken接口 boolean locked redis.setnx(wecom:token:lock: corpId, 1, 10, TimeUnit.SECONDS); if (!locked) { // 等锁或直接走双查缓存防止涌入 Thread.sleep(100); return redis.get(wecom:token: corpId); } try { String newToken doGetToken(corpId, corpSecret); redis.set(wecom:token: corpId, newToken, 7000, TimeUnit.SECONDS); return newToken; } finally { redis.delete(wecom:token:lock: corpId); } }这里的时效性设计是Redis缓存时间7000秒官方token是7200秒留200秒余量锁的自动过期时间10秒避免实例宕机导致死锁。这个思路对公众号的token同样适用只要把key前缀换掉就行。5.2 限频重试错误码与退避策略微信和QQ的接口都有频率限制直接的表现就是返回限频错误码。企业微信常见的有45009表示接口调用超过频率限制40014/42001表示token无效或过期。很多新手在40014出现时还在傻傻重试结果越试越错正确做法是先刷新token再重试。我封装重试时会做两层判断第一层判断错误类型只有可重试错误才重试第二层用指数退避第一次等1秒第二次2秒第三次4秒最多三次。如果三次都失败就不再浪费请求直接把告警上下文写到错误表由后续补偿任务处理。这套逻辑保证了大促或者故障时高并发下不会把消息接口打挂。5.3 把发送逻辑丢进线程池Web请求不能阻塞消息推送是IO型操作HTTP调用耗时一般在几十到几百毫秒如果放在业务请求线程里同步执行接口RT会很难看。更稳妥的做法是异步化把“组织消息”和“发送消息”解耦发送部分交给线程池。Spring Boot里最简单的方式是Async但要注意用自定义线程池不要用默认的SimpleAsyncTaskExecutor它每次都会新建线程。我项目里的配置是核心线程数10、最大20、队列容量500拒绝策略用CallerRunsPolicy保证任务不丢。Bean(pushExecutor) public Executor pushExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(10); executor.setMaxPoolSize(20); executor.setQueueCapacity(500); executor.setThreadNamePrefix(push-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; }还有一个必须注意的点异步方法内部一定要捕获所有异常并打日志。真实场景中线程池里抛出的异常如果没人接住会让你排查“消息没发出去”时完全无从下手。5.4 消息链路日志拿到msgid才能排查消息推送最大的麻烦是“接口显示成功用户就是没收到”。这时候能救命的只有详尽链路日志。我在推送模块里固定打印这几项渠道类型、接收人、消息类型、调用接口的返回码、返回码描述、msgid。建议日志格式统一成一行方便检索pushMdc|traceIdxxx|channelwecom|tozhangsan|typetext|errcode0|msgidxxxx一旦用户反馈没收到我直接根据traceId或接收人过滤日志看errcode是不是0msgid有没有返回。如果errcode是0但用户没收到那就是企业微信侧投递问题只能反馈官方。日志里一定不要把access_token、secret、webhook完整地址打出来这是压测时最容易泄露的凭据。5.5 合规红线哪些消息不能发技术能力到位之后更要清楚边界。微信侧应用消息不能频繁骚扰成员模板消息不能发广告、营销、诱导类内容否则模板会被封严重时整个应用接口被冻结。QQ侧机器人不能批量加群、刷屏、发送违法信息否则开放平台权限会被收回。还有一条特别重要不要拿任何官方接口去采集、存储用户个人隐私信息。我在设计系统时只保存必要字段openid、用户ID绝不保存聊天内容全文。如果业务涉及用户敏感信息必须做脱敏处理这既是平台规则也是基本的技术伦理。最后再分享两个小建议这套方案我在几个项目里复用过有两点经验值得说。第一小工具场景别一上来就上企业微信先用Server酱或者QQ群Webhook把逻辑跑通确认消息链路的业务价值之后再切换正式渠道能省很多配置成本。第二把token缓存、media_id缓存、限频重试这些通用逻辑抽成一个公共模块后续接公众号、接钉钉、接飞书都能复用一劳永逸。如果你还在个人号协议里反复折腾听我一句劝尽快转官方接口。封号损失的时间成本远超那点“能直接操作个人号”的便利。用官方API做出来的推送系统才是能睡个安稳觉的生产方案。