企业微信推送文件:应用消息 vs 群机器人 Webhook
场景:Java 后端生成 PDF(简报、值班信息)后,自动推送到企业微信群。
两条通道都实测跑通过,本文记录原理、完整代码、踩坑点和选型结论。
一、先说结论
| 维度 | 应用消息(agentid) | 群机器人 Webhook |
|---|---|---|
| 凭据 | corpId + corpSecret + agentid | 一个 webhook key |
| 换 token | 需要,2 小时过期,要缓存 | 不需要 |
| 可信 IP 白名单 | 必须配,且要先设可信域名或回调 URL | 不需要 |
| 公网可达 | 配回调 URL 时需要 | 不需要 |
| 接收方 | 指定成员/部门/标签,可 @all | 固定为该机器人所在群 |
| 素材有效期 | media_id 2 天 | media_id 3 天 |
| 素材复用 | 同企业内通用 | 与 key 绑定,多个群要各上传一次 |
| 频率限制 | 应用维度较宽松 | 每分钟 20 条 |
| 客户侧部署成本 | 高(要配白名单、要外网入口) | 零配置 |
选型建议:只要"发到固定几个群"能满足需求,直接用 Webhook。
只有需要精确投递到个人或部门时,才值得付出应用消息那一整套配置代价。
二、通用原理
企业微信发文件都是两步式,不能像文本那样一次调用发完:
1. 上传文件 → 拿到 media_id(临时素材,有效期几天)
2. 发消息,msgtype=file,带上 media_id
文本和 markdown 是一步式,图片可以用 base64 一步发出去,
但文件(file)和语音(voice)必须先上传。这是网上很多"webhook 不支持文件"
说法的来源——其实支持,只是要多一步 upload_media。
三、方案 A:群机器人 Webhook(推荐)
3.1 两个接口
上传:POST https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media?key={KEY}&type=file
Content-Type: multipart/form-data
字段名必须是 media
返回:{"errcode":0,"media_id":"xxx","type":"file","created_at":"..."}
发送:POST https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key={KEY}
Content-Type: application/json
body: {"msgtype":"file","file":{"media_id":"xxx"}}
文件限制:5 字节 ~ 20MB。
3.2 完整实现
/** 从 webhook 的 send URL 中提取 key */
private String extractKey(String webhookUrl) {
if (StringUtils.isEmpty(webhookUrl)) return null;
int i = webhookUrl.indexOf("key=");
if (i < 0) return null;
String key = webhookUrl.substring(i + 4);
int amp = key.indexOf('&');
return amp > 0 ? key.substring(0, amp) : key;
}
/** 上传临时文件素材,返回 media_id(3天有效,仅该机器人可用,5字节~20MB) */
private String uploadMedia(String webhookUrl, File file) throws Exception {
String key = extractKey(webhookUrl);
if (key == null) throw new IllegalArgumentException("webhook地址中无key: " + webhookUrl);
String url = "https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media?key=" + key + "&type=file";
String boundary = "----" + java.util.UUID.randomUUID();
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
conn.setDoOutput(true);
conn.setDoInput(true);
conn.setConnectTimeout(30000);
conn.setReadTimeout(60000);
try (OutputStream os = conn.getOutputStream();
FileInputStream fis = new FileInputStream(file)) {
String header = "--" + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"media\"; filename=\""
+ file.getName() + "\"; filelength=" + file.length() + "\r\n"
+ "Content-Type: application/octet-stream\r\n\r\n";
os.write(header.getBytes("UTF-8")); // 关键:必须 UTF-8
byte[] buf = new byte[8192];
int len;
while ((len = fis.read(buf)) != -1) os.write(buf, 0, len);
os.write(("\r\n--" + boundary + "--\r\n").getBytes("UTF-8"));
os.flush();
}
int status = conn.getResponseCode();
String body;
try (InputStream is = (status == 200 ? conn.getInputStream() : conn.getErrorStream())) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] b = new byte[4096];
int n;
while ((n = is.read(b)) != -1) baos.write(b, 0, n);
body = new String(baos.toByteArray(), "UTF-8");
}
conn.disconnect();
log.info("webhook上传素材响应: {}", body);
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
if (json.get("errcode").getAsInt() != 0) {
throw new IOException("webhook上传素材失败: " + body); // 失败要抛,别静默
}
return json.get("media_id").getAsString();
}
/** 向单个机器人发送 file 消息,失败重试 */
public void sendFile(String webhookUrl, File file, int count) throws Exception {
String mediaId = uploadMedia(webhookUrl, file);
String json = "{\"msgtype\":\"file\",\"file\":{\"media_id\":\"" + mediaId + "\"}}";
try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
HttpPost httpPost = new HttpPost(webhookUrl);
httpPost.setHeader("Content-type", "application/json");
httpPost.setEntity(new StringEntity(json, "utf-8"));
String resp = EntityUtils.toString(httpClient.execute(httpPost).getEntity(), "utf-8");
log.info("webhook发送文件响应: {}", resp);
JsonObject obj = JsonParser.parseString(resp).getAsJsonObject();
if (obj.get("errcode").getAsInt() != 0 && count > 0) {
Thread.sleep(3000L);
sendFile(webhookUrl, file, count - 1);
}
}
}
3.3 对外入口(含文件就绪检查)
public void sendReportFile(File file) {
if (file == null) {
log.error("##### 发送文件失败,file为null");
return;
}
try {
// PDF 生成与推送并发,文件可能还没写完
boolean exists = retryWithSleep(file::exists, 20, 30000L);
if (!exists) {
log.error("##### 文件不存在: {}", file.getAbsolutePath());
return;
}
if (file.length() > 20 * 1024 * 1024) {
log.warn("##### 文件超过20MB,跳过: {}", file.getAbsolutePath());
return;
}
if (!verifyFileIntegrity(file)) {
log.warn("##### 文件md5两次不一致,可能仍在写入: {}", file.getName());
}
// media_id 与 key 绑定,每个群都要单独上传一次
sendFile(RebotUrl, file, sendTimes);
if (testSwitch) sendFile(RebotUrlTest, file, sendTimes);
if (checkSwitch) sendFile(RebotUrlCheck, file, sendTimes);
} catch (Throwable t) { // Throwable 而非 Exception:JNI 崩溃是 Error
log.error("##### webhook发送文件失败: {}", file.getAbsolutePath(), t);
}
}
配置只需三个 URL:
WebCom:
RebotUrl: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx
RebotUrlTest: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=yyy
RebotUrlCheck: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=zzz
四、方案 B:应用消息(agentid + access_token)
需要精确投递给个人/部门时才用。配置成本高得多。
4.1 三个接口
取 token:GET /cgi-bin/gettoken?corpid={CORPID}&corpsecret={SECRET}
返回 {"access_token":"xxx","expires_in":7200}
上传: POST /cgi-bin/media/upload?access_token={TOKEN}&type=file
发送: POST /cgi-bin/message/send?access_token={TOKEN}
body: {"touser":"@all","msgtype":"file","agentid":1000019,
"file":{"media_id":"xxx"},"safe":"0"}
4.2 token 必须缓存
access_token 有效期 7200 秒,且取 token 接口有频率限制。
每次发消息都去换会被限频,务必缓存并留出提前量:
private static volatile String cachedAccessToken = null;
private static volatile long cachedTokenExpireAt = 0L;
public synchronized String getValidAccessToken() {
long now = System.currentTimeMillis();
if (cachedAccessToken != null && now < cachedTokenExpireAt) {
return cachedAccessToken;
}
AccessToken token = getAccessToken();
cachedAccessToken = token.getAccess_token();
// 提前 5 分钟过期,避免边界失效
cachedTokenExpireAt = now + (token.getExpires_in() - 300) * 1000L;
return cachedAccessToken;
}
4.3 可信 IP 白名单——最大的坑
应用消息要求调用方 IP 在白名单里,否则报 errcode:60020 not allow to access from your ip。
而白名单的配置入口有个前置条件:
企业微信管理后台 → 应用管理 → 自建应用 → 企业可信IP
↓
提示"配置企业可信IP前,请先设置可信域名 或 设置接收消息服务器URL"
也就是说,为了配白名单,你得先有一个公网可达的回调地址并通过验证。
这对内网部署的项目非常不友好。
查出口 IP 的办法:直接调 gettoken,报错信息里的 from ip: x.x.x.x 就是。
注意家用宽带/办公网出口 IP 会变,变了就要重新加白名单。
4.4 回调 URL 验证(纯 JDK 实现)
企微会 GET 你的回调地址,带 msg_signature、timestamp、nonce、echostr
四个参数,要求你校验签名并解密 echostr 后原文返回。
不想引 weixin-java-cp 依赖的话,手写也就几十行:
@GetMapping("/callBack")
public String callBack(@RequestParam("msg_signature") String msgSignature,
@RequestParam("timestamp") String timestamp,
@RequestParam("nonce") String nonce,
@RequestParam("echostr") String echostr) {
try {
// 1. 签名校验:把 4 个值字典序排序后拼接,取 SHA1
String[] arr = new String[]{callbackToken, timestamp, nonce, echostr};
java.util.Arrays.sort(arr);
String joined = arr[0] + arr[1] + arr[2] + arr[3];
MessageDigest md = MessageDigest.getInstance("SHA-1");
String sha1Hex = bytesToHex(md.digest(joined.getBytes("UTF-8")));
if (!sha1Hex.equals(msgSignature)) {
log.error("签名校验失败: 期望={}, 实际={}", sha1Hex, msgSignature);
return "";
}
// 2. AES-256-CBC 解密。EncodingAESKey 是 43 位,补个 = 再 base64 解码
byte[] aesKey = Base64.getDecoder().decode(encodingAESKey + "=");
byte[] encrypted = Base64.getDecoder().decode(echostr);
Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
cipher.init(Cipher.DECRYPT_MODE,
new SecretKeySpec(aesKey, "AES"),
new IvParameterSpec(aesKey, 0, 16)); // IV = key 前 16 字节
byte[] decrypted = cipher.doFinal(encrypted);
// 3. 去 PKCS7 填充
int pad = decrypted[decrypted.length - 1] & 0xFF;
byte[] unpad = java.util.Arrays.copyOf(decrypted, decrypted.length - pad);
// 4. 结构:16字节随机 + 4字节长度(大端) + 明文 + corpid
int contentLen = ((unpad[16] & 0xFF) << 24) | ((unpad[17] & 0xFF) << 16)
| ((unpad[18] & 0xFF) << 8) | (unpad[19] & 0xFF);
return new String(java.util.Arrays.copyOfRange(unpad, 20, 20 + contentLen), "UTF-8");
} catch (Exception e) {
log.error("回调验证失败: {}", e.getMessage());
return "";
}
}
三个易错点:
- 签名是 4 个参数(token、timestamp、nonce、echostr)。
接收消息时是 3 个(不含 echostr),URL 验证时容易照抄错。 - EncodingAESKey 补
=。它是 43 位 base64,标准解码需要 44 位。 - IV 取 key 的前 16 字节,不是全零。
4.5 本地调试:内网穿透
开发机没有公网 IP,验证回调时需要临时隧道。无需注册的方案:
ssh -R 80:localhost:2023 serveo.net
# 输出 Forwarding HTTP traffic from https://xxxx.serveousercontent.com
# 备选
ssh -R 80:localhost:2023 nokey@localhost.run
URL 每次重连都会变,验证通过后企微那边就不再校验了,所以隧道只需开着完成一次验证。
要注意隧道服务器的出口 IP 会算作你的调用来源,白名单里填的是你本机的公网出口 IP,
两者别搞混。
五、踩坑记录
5.1 中文文件名变下划线
现象:群里收到的文件名是 _____20260825104814.pdf,中文全变下划线。
原因:multipart 请求头写入时用了平台默认编码(Windows 上是 GBK)。
os.write(header.getBytes()); // 错:用默认编码
os.write(header.getBytes("UTF-8")); // 对
这也是不推荐用 httpmime 的 MultipartEntityBuilder 的原因——
它对 filename 的编码处理不透明,手写 HttpURLConnection 能精确控制头部字节。
5.2 文件还没写完就上传
PDF 生成和推送如果是并发的(比如都丢进 CompletableFuture),
推送线程可能拿到一个正在写入的文件。两道防线:
// 1. 等文件出现
boolean exists = retryWithSleep(file::exists, 20, 30000L);
// 2. 间隔 1 秒算两次 MD5,一致才说明写入已停止
public boolean verifyFileIntegrity(File file) {
try {
String md5_1 = calculateMD5(file);
Thread.sleep(1000L);
String md5_2 = calculateMD5(file);
return md5_1 != null && md5_1.equals(md5_2);
} catch (Exception e) {
return false;
}
}
5.3 删除 @Value 字段导致启动失败
清理废弃代码时,如果一个 @Component 类里有 @Value("${a.b}"),
而某个 profile 的配置文件里 a.b 是注释状态,Spring 启动就会直接报
Could not resolve placeholder。
两个办法:
@Value("${WebCom.corpId:}") // 加默认值
// 或者干脆删掉整个类(如果确认没人引用)
另外要小心配置前缀相似但用途不同的情况,比如
WXWork.CorpId(通讯录接口用)和 WebCom.corpId(应用消息用)
是两套东西,清理时不能连坐。
六、常见错误码
| errcode | 含义 | 排查方向 |
|---|---|---|
| 40058 | invalid param 'key' | webhook key 写错或已失效 |
| 93000 | invalid webhook url | 同上,key 不存在 |
| 60020 | not allow to access from your ip | 出口 IP 不在可信 IP 白名单里 |
| 40014 | invalid access_token | token 过期或被其他实例刷新了 |
| 41001 | missing access_token | URL 拼接掉了 token 参数 |
| 45009 | api freq out of limit | 触发频率限制(webhook 每分钟 20 条) |
| 301002 | media_id 无效 | 素材过期,或跨 key 复用了 media_id |
错误响应里的 from ip: x.x.x.x 是白送的信息——查自己出口 IP 时直接用它,
比 curl ifconfig.me 更准(它是企微实际看到的来源 IP)。
七、可复用的检查清单
新项目接企业微信文件推送时,按这个顺序走:
- 确认需求:发到固定群 → 用 Webhook;要发给指定个人 → 才考虑应用消息
- 建群机器人,拿 webhook URL
- 实现
uploadMedia+sendFile,注意头部 UTF-8 - 加文件就绪检查(exists 轮询 + MD5 双算)
- 失败路径务必抛异常或打完整响应体
- 多环境配置分开,确认几个 key 真的不同
- 跑一次真实流程,日志里确认每个文件都有一对
errcode:0(上传 + 发送)
部署到客户环境时,Webhook 方案只需替换 jar,无需在客户侧做任何企微配置——
这是它相对应用消息最大的优势。
浙公网安备 33010602011771号