本文详细介绍如何使用Java实现企业微信(企微)机器人Webhook消息推送功能,包括文本消息和文件(Excel)推送的完整实现方案。
📋 目录
一、背景介绍
1.1 什么是企业微信机器人
企业微信机器人是企业微信提供的一种消息推送机制,通过Webhook URL可以快速实现消息推送功能。它支持多种消息类型,包括:
- 文本消息:纯文本内容推送
- 文件消息:支持发送Excel、Word、PDF等文件
- 图片消息:发送图片文件
- Markdown消息:支持Markdown格式的富文本消息
- 卡片消息:交互式卡片消息
1.2 应用场景
- 📊 数据报表推送:定时推送Excel报表到企微群
- 🔔 系统通知:业务系统异常告警、任务完成通知
- 📈 数据分析:自动化数据处理后推送结果
- 💼 办公自动化:报销审批、流程提醒等
1.3 技术优势
- ✅ 简单易用:只需一个Webhook URL即可使用
- ✅ 无需认证:不需要复杂的OAuth认证流程
- ✅ 实时推送:消息即时到达,支持群聊和单聊
- ✅ 格式丰富:支持多种消息格式
二、技术方案
2.1 整体架构
┌─────────────┐
│ 业务系统 │
└──────┬──────┘
│
│ HTTP POST
▼
┌─────────────────────┐
│ HTTP工具类 │
│ (HttpUtil) │
└──────┬──────────────┘
│
├─── 文本消息 ───► POST JSON
│
└─── 文件消息 ───► POST Multipart
│
├── 步骤1: 上传文件获取media_id
│
└── 步骤2: 使用media_id发送消息
│
▼
┌─────────────────────┐
│ 企微Webhook API │
└─────────────────────┘
2.2 实现流程
文本消息推送流程
1. 构建JSON请求体
↓
2. POST请求到Webhook URL
↓
3. 解析响应判断成功/失败
文件消息推送流程
1. 上传文件到企微服务器
├── 提取Webhook URL中的key参数
├── 构建上传URL: /webhook/upload_media?key=xxx&type=file
└── POST multipart/form-data上传文件
↓
2. 获取media_id
↓
3. 使用media_id发送文件消息
├── 构建JSON请求体(包含media_id)
└── POST请求到Webhook URL
↓
4. 解析响应判断成功/失败
三、核心代码实现
3.1 HTTP工具类实现
首先,我们需要实现一个HTTP工具类,支持JSON POST和文件上传功能:
java
package io.javafxboot.util;
import lombok.extern.slf4j.Slf4j;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Map;
@Slf4j
public class HttpUtil {
private static final int DEFAULT_CONNECT_TIMEOUT = 10000;
private static final int DEFAULT_READ_TIMEOUT = 30000;
/**
* POST 请求(JSON)
*/
public static String postJson(String url, String jsonBody) {
try {
HttpURLConnection connection = createConnection(url, "POST", null);
connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
if (jsonBody != null && !jsonBody.isEmpty()) {
connection.setDoOutput(true);
try (OutputStream os = connection.getOutputStream()) {
os.write(jsonBody.getBytes(StandardCharsets.UTF_8));
}
}
return readResponse(connection);
} catch (Exception e) {
log.error("POST请求失败: {}", url, e);
return null;
}
}
/**
* POST 请求(multipart/form-data 文件上传)
*/
public static String postMultipart(String url, File file, String fieldName) {
if (file == null || !file.exists()) {
log.error("文件不存在: {}", file);
return null;
}
try {
// 生成boundary
String boundary = "----WebKitFormBoundary" + System.currentTimeMillis();
HttpURLConnection connection = createConnection(url, "POST", null);
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
connection.setDoOutput(true);
try (OutputStream os = connection.getOutputStream();
PrintWriter writer = new PrintWriter(
new OutputStreamWriter(os, StandardCharsets.UTF_8), true)) {
// 写入文件字段头
writer.append("--").append(boundary).append("\r\n");
writer.append("Content-Disposition: form-data; name=\"")
.append(fieldName)
.append("\"; filename=\"")
.append(file.getName())
.append("\"")
.append("\r\n");
writer.append("Content-Type: application/octet-stream").append("\r\n");
writer.append("\r\n");
writer.flush();
// 写入文件内容
try (FileInputStream fis = new FileInputStream(file)) {
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = fis.read(buffer)) != -1) {
os.write(buffer, 0, bytesRead);
}
os.flush();
}
// 写入结束边界
writer.append("\r\n");
writer.append("--").append(boundary).append("--").append("\r\n");
writer.flush();
}
return readResponse(connection);
} catch (Exception e) {
log.error("POST文件上传失败: {}", url, e);
return null;
}
}
/**
* 创建HTTP连接
*/
private static HttpURLConnection createConnection(String urlStr, String method,
Map<String, String> headers) throws Exception {
URL url = new URL(urlStr);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod(method);
connection.setConnectTimeout(DEFAULT_CONNECT_TIMEOUT);
connection.setReadTimeout(DEFAULT_READ_TIMEOUT);
connection.setRequestProperty("User-Agent", "Mozilla/5.0");
if (headers != null) {
for (Map.Entry<String, String> entry : headers.entrySet()) {
connection.setRequestProperty(entry.getKey(), entry.getValue());
}
}
return connection;
}
/**
* 读取响应内容
*/
private static String readResponse(HttpURLConnection connection) throws Exception {
int responseCode = connection.getResponseCode();
if (responseCode >= 200 && responseCode < 300) {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
StringBuilder response = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
response.append(line);
}
return response.toString();
}
} else {
// 尝试读取错误响应
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(connection.getErrorStream(), StandardCharsets.UTF_8))) {
StringBuilder errorResponse = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
errorResponse.append(line);
}
log.warn("HTTP请求失败,状态码: {}, 响应: {}", responseCode, errorResponse.toString());
}
return null;
}
}
}
3.2 企微推送服务接口
定义服务接口:
java
package io.javafxboot.service;
import java.util.function.Consumer;
public interface WeChatPushTestService {
/**
* 发送文本消息
*
* @param webhookUrl Webhook地址
* @param content 消息内容
* @param logCallback 日志回调
* @return 是否成功
*/
boolean sendTextMessage(String webhookUrl, String content, Consumer<String> logCallback);
/**
* 发送文件消息
*
* @param webhookUrl Webhook地址
* @param filePath 文件路径
* @param logCallback 日志回调
* @return 是否成功
*/
boolean sendFileMessage(String webhookUrl, String filePath, Consumer<String> logCallback);
}
3.3 企微推送服务实现
核心实现类:
java
package io.javafxboot.service.impl;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import io.javafxboot.service.WeChatPushTestService;
import io.javafxboot.util.HttpUtil;
import io.javafxboot.util.StringUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.io.File;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Consumer;
@Slf4j
@Service
public class WeChatPushTestServiceImpl implements WeChatPushTestService {
@Override
public boolean sendTextMessage(String webhookUrl, String content, Consumer<String> logCallback) {
// 参数校验
if (StringUtil.isBlank(webhookUrl)) {
logCallback.accept("✗ Webhook地址为空");
return false;
}
if (StringUtil.isBlank(content)) {
logCallback.accept("✗ 消息内容为空");
return false;
}
try {
logCallback.accept("开始发送文本消息...");
logCallback.accept("Webhook地址: " + webhookUrl);
logCallback.accept("消息内容: " + content);
// 构建企微机器人Webhook请求体(文本消息)
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("msgtype", "text");
Map<String, String> textContent = new HashMap<>();
textContent.put("content", content);
requestBody.put("text", textContent);
// 发送HTTP POST请求
String jsonBody = JSON.toJSONString(requestBody);
String response = HttpUtil.postJson(webhookUrl, jsonBody);
if (StringUtil.isBlank(response)) {
logCallback.accept("✗ 请求失败:响应为空");
return false;
}
// 解析响应
JSONObject responseJson = JSON.parseObject(response);
if (responseJson != null &&
responseJson.getInteger("errcode") != null &&
responseJson.getInteger("errcode") == 0) {
logCallback.accept("✓ 文本消息发送成功");
log.info("企微文本消息发送成功: {}", response);
return true;
} else {
String errMsg = responseJson != null ?
responseJson.getString("errmsg") : "未知错误";
logCallback.accept("✗ 文本消息发送失败: " + errMsg);
log.error("企微文本消息发送失败: {}", response);
return false;
}
} catch (Exception e) {
log.error("发送企微文本消息异常", e);
logCallback.accept("✗ 发送异常: " + e.getMessage());
return false;
}
}
@Override
public boolean sendFileMessage(String webhookUrl, String filePath, Consumer<String> logCallback) {
// 参数校验
if (StringUtil.isBlank(webhookUrl)) {
logCallback.accept("✗ Webhook地址为空");
return false;
}
if (StringUtil.isBlank(filePath)) {
logCallback.accept("✗ 文件路径为空");
return false;
}
File file = new File(filePath);
if (!file.exists() || !file.isFile()) {
logCallback.accept("✗ 文件不存在: " + filePath);
return false;
}
try {
logCallback.accept("开始发送文件消息...");
logCallback.accept("Webhook地址: " + webhookUrl);
logCallback.accept("文件路径: " + filePath);
logCallback.accept("文件大小: " + (file.length() / 1024) + " KB");
// 步骤1: 上传文件获取media_id
logCallback.accept("步骤1: 上传文件到企微服务器...");
// 从webhookUrl中提取key参数
String key = extractKeyFromWebhookUrl(webhookUrl);
if (StringUtil.isBlank(key)) {
logCallback.accept("✗ 无法从Webhook地址中提取key参数");
return false;
}
// 企微文件上传API
String uploadUrl = "https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media?key="
+ key + "&type=file";
logCallback.accept("上传URL: " + uploadUrl);
String uploadResponse = HttpUtil.postMultipart(uploadUrl, file, "media");
if (StringUtil.isBlank(uploadResponse)) {
logCallback.accept("✗ 文件上传失败:响应为空");
return false;
}
logCallback.accept("上传响应: " + uploadResponse);
// 解析上传响应
JSONObject uploadJson = JSON.parseObject(uploadResponse);
if (uploadJson == null ||
uploadJson.getInteger("errcode") == null ||
uploadJson.getInteger("errcode") != 0) {
String errMsg = uploadJson != null ?
uploadJson.getString("errmsg") : "未知错误";
logCallback.accept("✗ 文件上传失败: " + errMsg);
log.error("企微文件上传失败: {}", uploadResponse);
return false;
}
String mediaId = uploadJson.getString("media_id");
if (StringUtil.isBlank(mediaId)) {
logCallback.accept("✗ 文件上传成功但未获取到media_id");
return false;
}
logCallback.accept("✓ 文件上传成功,media_id: " + mediaId);
// 步骤2: 发送文件消息
logCallback.accept("步骤2: 发送文件消息...");
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("msgtype", "file");
Map<String, String> fileContent = new HashMap<>();
fileContent.put("media_id", mediaId);
requestBody.put("file", fileContent);
// 发送HTTP POST请求
String jsonBody = JSON.toJSONString(requestBody);
String response = HttpUtil.postJson(webhookUrl, jsonBody);
if (StringUtil.isBlank(response)) {
logCallback.accept("✗ 文件消息发送失败:响应为空");
return false;
}
// 解析响应
JSONObject responseJson = JSON.parseObject(response);
if (responseJson != null &&
responseJson.getInteger("errcode") != null &&
responseJson.getInteger("errcode") == 0) {
logCallback.accept("✓ 文件消息发送成功");
log.info("企微文件消息发送成功: {}", response);
return true;
} else {
String errMsg = responseJson != null ?
responseJson.getString("errmsg") : "未知错误";
logCallback.accept("✗ 文件消息发送失败: " + errMsg);
log.error("企微文件消息发送失败: {}", response);
return false;
}
} catch (Exception e) {
log.error("发送企微文件消息异常", e);
logCallback.accept("✗ 发送异常: " + e.getMessage());
return false;
}
}
/**
* 从Webhook URL中提取key参数
*
* 示例: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx
* 提取结果: xxx
*/
private String extractKeyFromWebhookUrl(String webhookUrl) {
if (StringUtil.isBlank(webhookUrl)) {
return null;
}
try {
int keyIndex = webhookUrl.indexOf("key=");
if (keyIndex == -1) {
return null;
}
String keyPart = webhookUrl.substring(keyIndex + 4);
int ampersandIndex = keyPart.indexOf("&");
if (ampersandIndex != -1) {
return keyPart.substring(0, ampersandIndex);
} else {
return keyPart;
}
} catch (Exception e) {
log.error("提取key参数失败", e);
return null;
}
}
}
3.4 关键实现要点
3.4.1 文本消息请求格式
json
{
"msgtype": "text",
"text": {
"content": "这是要发送的文本消息内容"
}
}
3.4.2 文件消息请求格式
第一步:上传文件获取media_id
POST https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media?key=xxx&type=file
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary...
------WebKitFormBoundary...
Content-Disposition: form-data; name="media"; filename="report.xlsx"
Content-Type: application/octet-stream
[文件二进制内容]
------WebKitFormBoundary...--
响应示例:
json
{
"errcode": 0,
"errmsg": "ok",
"type": "file",
"media_id": "2a8s6df9as8d6f6as8d6f7s8d6f1",
"created_at": "1609459200"
}
第二步:使用media_id发送文件消息
json
{
"msgtype": "file",
"file": {
"media_id": "2a8s6df9as8d6f6as8d6f7s8d6f1"
}
}
四、使用示例
4.1 发送文本消息
java
@Autowired
private WeChatPushTestService weChatPushTestService;
public void testSendTextMessage() {
String webhookUrl = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-webhook-key";
String content = "📊 数据报表已生成\n\n" +
"时间:2024-12-14 16:00:00\n" +
"状态:处理完成";
boolean success = weChatPushTestService.sendTextMessage(
webhookUrl,
content,
message -> System.out.println(message)
);
if (success) {
System.out.println("消息发送成功!");
} else {
System.out.println("消息发送失败!");
}
}
4.2 发送文件消息(Excel)
java
@Autowired
private WeChatPushTestService weChatPushTestService;
public void testSendFileMessage() {
String webhookUrl = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-webhook-key";
String filePath = "D:/reports/20241214.xlsx";
boolean success = weChatPushTestService.sendFileMessage(
webhookUrl,
filePath,
message -> System.out.println(message)
);
if (success) {
System.out.println("文件发送成功!");
} else {
System.out.println("文件发送失败!");
}
}
4.3 实际业务场景:报销数据处理后推送
java
@Service
public class ReimbursementProcessService {
@Autowired
private WeChatPushTestService weChatPushTestService;
public void processReimbursementAndNotify(String webhookUrl, String excelFilePath, String employeeName) {
// 1. 处理报销数据
// ... 业务逻辑 ...
// 2. 生成Excel报表
String reportPath = generateReport(excelFilePath, employeeName);
// 3. 发送文本通知
String textMessage = String.format(
"📊 记录处理完成\n\n" +
"员工:%s\n" +
"处理时间:%s\n" +
"结果文件:%s\n\n" +
"请查看附件。",
employeeName,
LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")),
new File(reportPath).getName()
);
weChatPushTestService.sendTextMessage(webhookUrl, textMessage, log::info);
// 4. 发送Excel文件
weChatPushTestService.sendFileMessage(webhookUrl, reportPath, log::info);
}
}
五、常见问题与解决方案
5.1 问题1:文件上传失败,返回errcode=40058
原因: 文件大小超过限制(企微机器人文件大小限制为20MB)
解决方案:
java
// 上传前检查文件大小
File file = new File(filePath);
long maxSize = 20 * 1024 * 1024; // 20MB
if (file.length() > maxSize) {
logCallback.accept("✗ 文件大小超过20MB限制");
return false;
}
5.2 问题2:media_id无效或已过期
原因: media_id有时效性,通常为3天
解决方案:
- 上传文件后立即使用media_id发送消息
- 不要缓存media_id,每次发送文件都重新上传
5.3 问题3:Webhook URL中的key参数提取失败
原因: URL格式不正确或key参数缺失
解决方案:
java
// 增强key提取逻辑,支持URL编码
private String extractKeyFromWebhookUrl(String webhookUrl) {
try {
URI uri = new URI(webhookUrl);
String query = uri.getQuery();
if (query != null) {
String[] params = query.split("&");
for (String param : params) {
String[] keyValue = param.split("=");
if (keyValue.length == 2 && "key".equals(keyValue[0])) {
return URLDecoder.decode(keyValue[1], "UTF-8");
}
}
}
} catch (Exception e) {
log.error("提取key参数失败", e);
}
return null;
}
5.4 问题4:网络超时
原因: 文件较大,上传时间过长
解决方案:
java
// 增加超时时间
private static final int FILE_UPLOAD_TIMEOUT = 60000; // 60秒
connection.setConnectTimeout(FILE_UPLOAD_TIMEOUT);
connection.setReadTimeout(FILE_UPLOAD_TIMEOUT);
5.5 问题5:中文文件名乱码
原因: multipart/form-data编码问题
解决方案:
java
// 使用URL编码处理文件名
String encodedFileName = URLEncoder.encode(file.getName(), "UTF-8");
writer.append("Content-Disposition: form-data; name=\"")
.append(fieldName)
.append("\"; filename=\"")
.append(encodedFileName)
.append("\"")
.append("\r\n");
六、总结
6.1 核心要点
- 文本消息推送:直接构建JSON请求体,POST到Webhook URL即可
- 文件消息推送 :需要两步操作
- 先上传文件获取
media_id - 再使用
media_id发送文件消息
- 先上传文件获取
- 错误处理 :通过
errcode判断成功/失败,errmsg获取错误信息 - 参数提取 :从Webhook URL中提取
key参数用于文件上传API
6.2 最佳实践
✅ 参数校验 :发送前校验Webhook URL和消息内容
✅ 异常处理 :完善的try-catch和日志记录
✅ 文件大小检查 :上传前检查文件大小限制
✅ 响应解析 :正确解析企微API响应判断成功/失败
✅ 日志记录 :记录详细的操作日志便于排查问题
✅ 安全配置:使用配置文件或环境变量存储Webhook Key,避免硬编码
6.2.1 配置管理建议
方式1:使用application.yml配置文件
yaml
wechat:
webhook:
url: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=${WECHAT_WEBHOOK_KEY:your-webhook-key}
方式2:使用环境变量
bash
# Linux/Mac
export WECHAT_WEBHOOK_KEY=your-webhook-key
# Windows
set WECHAT_WEBHOOK_KEY=your-webhook-key
方式3:使用配置类
java
@ConfigurationProperties(prefix = "wechat.webhook")
@Data
public class WeChatWebhookConfig {
private String url;
private String key;
}
⚠️ 重要提醒:
- 将包含真实Key的配置文件添加到
.gitignore - 使用
.gitignore排除敏感配置文件 - 在团队内部分享配置时使用加密方式
6.3 扩展功能
可以进一步扩展的功能:
- 📸 图片消息推送:支持发送图片文件
- 📝 Markdown消息:支持富文本格式消息
- 🎴 卡片消息:交互式卡片消息推送
- ⏰ 定时推送:结合定时任务实现定时推送
- 📊 批量推送:支持批量发送消息到多个群
6.4 参考资料
⚠️ 安全提示
重要:Webhook Key是敏感信息,请妥善保管!
- 🔒 不要将真实的Webhook Key提交到公开代码仓库
- 🔒 不要在公开文档、博客、论坛中泄露Webhook Key
- 🔒 建议使用配置文件或环境变量存储Webhook Key
- 🔒 如果Key泄露,请立即在企业微信管理后台重新生成新的Key
💡 写在最后
企业微信机器人Webhook是一个非常实用的消息推送方案,通过本文的详细介绍和代码示例,相信你已经掌握了文本消息和文件推送的实现方法。在实际项目中,可以根据业务需求进行扩展和优化。
如果本文对你有帮助,欢迎点赞、收藏、转发!如有问题,欢迎在评论区留言讨论。
标签 :Java 企业微信 Webhook 消息推送 文件上传 Excel推送 Spring Boot