SheepDog1998

博客园 首页 新随笔 联系 订阅 管理

企业微信推送文件:应用消息 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_signaturetimestampnonceechostr
四个参数,要求你校验签名并解密 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 "";
    }
}

三个易错点:

  1. 签名是 4 个参数(token、timestamp、nonce、echostr)。
    接收消息时是 3 个(不含 echostr),URL 验证时容易照抄错。
  2. EncodingAESKey 补 =。它是 43 位 base64,标准解码需要 44 位。
  3. 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)。

七、可复用的检查清单

新项目接企业微信文件推送时,按这个顺序走:

  1. 确认需求:发到固定群 → 用 Webhook;要发给指定个人 → 才考虑应用消息
  2. 建群机器人,拿 webhook URL
  3. 实现 uploadMedia + sendFile,注意头部 UTF-8
  4. 加文件就绪检查(exists 轮询 + MD5 双算)
  5. 失败路径务必抛异常或打完整响应体
  6. 多环境配置分开,确认几个 key 真的不同
  7. 跑一次真实流程,日志里确认每个文件都有一对 errcode:0(上传 + 发送)

部署到客户环境时,Webhook 方案只需替换 jar,无需在客户侧做任何企微配置——
这是它相对应用消息最大的优势。

posted on 2026-08-25 15:44  SheepDog1998  阅读(6)  评论(0)    收藏  举报