系列: SmartClaw × OpenClaw:企业级浏览器自动化实战(第⑤篇)
日期: 2026-05-04
标签: OpenClaw, 企业微信, Webhook, Spring Boot, AES 解密, 消息触发
适合谁看: Spring Boot 开发、企业微信集成、后端工程师

前言
OpenClaw 需要通过命令行或 API 触发任务,非技术人员无法使用。
场景: 门店员工需要通过微信发起设备报修,但她们不会用命令行,只会用微信。
SmartClaw 的做法: 接入企业微信 Webhook,员工发送"报修 空调不制冷 3楼302 张三",系统自动提取信息并创建工单,完成后回复"✅ 工单已创建"。
本文是系列第⑤篇,完整讲解 Spring Boot 如何接入企业微信 Webhook,实现"发消息即执行"的自动化流程。
一、OpenClaw 的交互局限
1.1 需要 CLI/API 触发
OpenClaw 的典型使用方式:
bash
# 命令行触发
openclaw run --prompt "登录系统,填写表单"
# 或者调用 API
curl -X POST http://localhost:3000/api/run \
-d '{"prompt": "登录系统,填写表单"}'
问题:
- 非技术人员无法使用
- 无法与现有工作流集成
- 移动端操作不便
1.2 对比表格
| 维度 | OpenClaw | SmartClaw + 企业微信 |
|---|---|---|
| 触发方式 | CLI/API | 微信消息 |
| 用户门槛 | 高(需懂技术) | 低(会发微信即可) |
| 移动支持 | ❌ | ✅ |
| 工作流集成 | ❌ | ✅ |
二、业务场景:门店报修自动派单
2.1 背景
某连锁零售集团有 50 个门店,每个门店每天需要:
- 将设备报修信息自动创建到工单系统
- 人工操作耗时 3 分钟/条
- 每天约 100 条,总耗时 5 小时
2.2 消息格式约定
前台通过企业微信发送:
报修 空调不制冷 3楼302 张三
2.3 执行流程
🧰 工单系统 ⚙️ SmartClaw Agent 🖥️ SmartClaw Server 🏢 企业微信服务器 👤 前台员工 🧰 工单系统 ⚙️ SmartClaw Agent 🖥️ SmartClaw Server 🏢 企业微信服务器 👤 前台员工 发送消息 "报修 空调不制冷 3楼302 张三" Webhook 回调 POST /api/wechat/work/callback AES 解密 + 规则匹配 提取变量 issue=空调不制冷, location=3楼302, reporter=张三 下发任务 templateId=store-repair-ticket Playwright 自动填单 返回结果 回传执行结果 调用 API 回复 收到回复 "✅ 工单已创建,流水号:c6efed0a"
三、核心技术实现
3.1 企业微信配置
步骤 1:创建应用
- 登录 企业微信管理后台
- 进入「应用管理」→「创建应用」
- 获取以下参数:
corp_id:企业 IDagent_id:应用 IDagent_secret:应用密钥
步骤 2:配置回调 URL
- 进入「应用管理」→ 选择应用 →「接收消息」
- 设置回调 URL:
https://your-domain.com/api/wechat/work/callback - 设置 Token(自定义,如
SmartClaw2026) - 设置 EncodingAESKey(随机生成 43 位字符)
步骤 3:配置 IP 白名单
将 SmartClaw Server 的公网 IP 加入白名单。
3.2 Spring Boot 配置
yaml
# application.yml
smartclaw:
wechat:
work:
enabled: true
corp-id: wwxxxxxxxxxxxxx
agent-id: 1000001
agent-secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
token: SmartClaw2026
encoding-aes-key: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
3.3 回调接收控制器
java
@RestController
@RequestMapping("/api/wechat/work")
@Slf4j
public class WechatWorkCallbackController {
@Autowired
private WxMsgDecryptService decryptService;
@Autowired
private WechatMessageDispatcher dispatcher;
/**
* 企业微信验证 URL 有效性(GET 请求)
*/
@GetMapping("/callback")
public String verify(
@RequestParam String msg_signature,
@RequestParam String timestamp,
@RequestParam String nonce,
@RequestParam String echostr) {
log.info("Verifying URL: timestamp={}, nonce={}", timestamp, nonce);
try {
// 验签 + 解密 echostr
String decryptedEchostr = decryptService.verifyUrl(
msg_signature, timestamp, nonce, echostr
);
log.info("URL verification successful");
return decryptedEchostr; // 必须原样返回
} catch (Exception e) {
log.error("URL verification failed", e);
return "fail";
}
}
/**
* 接收企业微信推送消息(POST 请求)
*/
@PostMapping("/callback")
public String callback(
@RequestBody String rawXml,
@RequestParam String msg_signature,
@RequestParam String timestamp,
@RequestParam String nonce) {
log.info("Received message: timestamp={}", timestamp);
try {
// 1. 验签 + 解密
WxWorkMessage message = decryptService.decryptMessage(
rawXml, msg_signature, timestamp, nonce
);
log.info("Decrypted message: from={}, content={}",
message.getFromUserName(), message.getContent());
// 2. 只处理文本消息
if ("text".equalsIgnoreCase(message.getMsgType())) {
// 3. 异步 dispatch,避免阻塞企业微信回调
dispatcher.dispatchAsync(message);
}
// 4. 必须返回 success,否则企业微信会重试
return "success";
} catch (Exception e) {
log.error("Callback processing failed", e);
// 即使失败也要返回 success,避免企业微信无限重试
return "success";
}
}
}
3.4 AES 解密服务
企业微信使用 AES-256-CBC 加密消息体,需要官方提供的加解密库。
引入依赖
xml
<!-- pom.xml -->
<dependency>
<groupId>com.github.binarywang</groupId>
<artifactId>weixin-java-cp</artifactId>
<version>4.5.0</version>
</dependency>
实现解密逻辑
java
@Service
@Slf4j
public class WxMsgDecryptService {
@Value("${smartclaw.wechat.work.token}")
private String token;
@Value("${smartclaw.wechat.work.encoding-aes-key}")
private String encodingAesKey;
@Value("${smartclaw.wechat.work.corp-id}")
private String corpId;
/**
* 验证 URL(GET 请求)
*/
public String verifyUrl(String msgSignature, String timestamp,
String nonce, String echostr) {
try {
WXBizMsgCrypt crypt = new WXBizMsgCrypt(
token, encodingAesKey, corpId
);
// 验签 + 解密
return crypt.VerifyURL(msgSignature, timestamp, nonce, echostr);
} catch (Exception e) {
log.error("URL verification failed", e);
throw new RuntimeException("URL verification failed", e);
}
}
/**
* 解密消息(POST 请求)
*/
public WxWorkMessage decryptMessage(String rawXml, String msgSignature,
String timestamp, String nonce) {
try {
WXBizMsgCrypt crypt = new WXBizMsgCrypt(
token, encodingAesKey, corpId
);
// 验签 + 解密
String decryptedXml = crypt.DecryptMsg(
msgSignature, timestamp, nonce, rawXml
);
log.debug("Decrypted XML: {}", decryptedXml);
// 解析 XML
return parseXml(decryptedXml);
} catch (Exception e) {
log.error("Message decryption failed", e);
throw new RuntimeException("Message decryption failed", e);
}
}
private WxWorkMessage parseXml(String xml) {
// 使用 JAXB 或 DOM 解析 XML
// 简化示例:
Document doc = DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(new InputSource(new StringReader(xml)));
WxWorkMessage message = new WxWorkMessage();
message.setFromUserName(getNodeText(doc, "FromUserName"));
message.setToUserName(getNodeText(doc, "ToUserName"));
message.setMsgType(getNodeText(doc, "MsgType"));
message.setContent(getNodeText(doc, "Content"));
message.setMsgId(getNodeText(doc, "MsgId"));
message.setCreateTime(Long.parseLong(getNodeText(doc, "CreateTime")));
return message;
}
private String getNodeText(Document doc, String tagName) {
NodeList nodes = doc.getElementsByTagName(tagName);
if (nodes.getLength() > 0) {
return nodes.item(0).getTextContent();
}
return null;
}
}
3.5 消息规则引擎
规则数据模型
sql
CREATE TABLE message_rule (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL COMMENT '规则名称',
source VARCHAR(20) NOT NULL COMMENT 'WECHAT_WORK',
pattern VARCHAR(500) NOT NULL COMMENT '正则表达式',
template_id VARCHAR(100) NOT NULL COMMENT '对应模板ID',
variable_names VARCHAR(200) COMMENT '变量名列表,JSON数组',
priority INT DEFAULT 0 COMMENT '优先级',
enabled TINYINT(1) DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 示例规则:匹配报修消息
INSERT INTO message_rule (name, source, pattern, template_id, variable_names, priority)
VALUES (
'门店报修派单',
'WECHAT_WORK',
'^报修\\s+(?<issue>[^\\s]{2,20})\\s+(?<location>[^\\s]{2,20})\\s+(?<reporter>[\\u4e00-\\u9fa5]{2,4})$',
'store-repair-ticket-v1',
'["issue","location","reporter"]',
100
);
规则匹配服务
java
@Service
@Slf4j
public class MessageRuleService {
@Autowired
private MessageRuleRepository ruleRepository;
@Autowired
private TaskDispatchService dispatchService;
/**
* 匹配消息并触发任务
*/
public Optional<DispatchResult> matchAndDispatch(WxWorkMessage message) {
String content = message.getContent().trim();
String sender = message.getFromUserName();
log.info("Matching message: content={}, sender={}", content, sender);
// 1. 从数据库加载启用的规则(按优先级排序)
List<MessageRule> rules = ruleRepository.findEnabledRulesOrderByPriorityDesc();
for (MessageRule rule : rules) {
// 2. 正则匹配
Matcher matcher = Pattern.compile(rule.getPattern()).matcher(content);
if (matcher.matches()) {
log.info("Rule matched: ruleName={}", rule.getName());
// 3. 提取命名捕获组作为变量
Map<String, String> variables = extractNamedGroups(matcher, rule);
// 4. 注入发送者信息
variables.put("_senderUserId", sender);
variables.put("_msgId", message.getMsgId());
// 5. 构建 dispatch 请求
DispatchRequest request = DispatchRequest.builder()
.templateId(rule.getTemplateId())
.variables(variables)
.idempotencyKey("wechat_work:" + message.getMsgId())
.source("WECHAT_WORK")
.requesterOpenId(sender)
.build();
// 6. 下发任务
DispatchResult result = dispatchService.dispatch(request);
log.info("Task dispatched: runId={}", result.getRunId());
return Optional.of(result);
}
}
log.warn("No rule matched for message: {}", content);
// 无匹配规则:发送默认回复
sendDefaultReply(sender, content);
return Optional.empty();
}
/**
* 提取正则命名捕获组
*/
private Map<String, String> extractNamedGroups(Matcher matcher, MessageRule rule) {
Map<String, String> vars = new HashMap<>();
// 解析 variable_names JSON 数组
List<String> varNames = parseJsonArray(rule.getVariableNames());
for (String groupName : varNames) {
try {
String value = matcher.group(groupName);
vars.put(groupName, value);
log.debug("Extracted variable: {}={}", groupName, value);
} catch (IllegalArgumentException e) {
log.warn("Group not found: {}", groupName);
}
}
return vars;
}
private List<String> parseJsonArray(String jsonArray) {
// 使用 Jackson 或 Gson 解析 JSON
try {
return objectMapper.readValue(
jsonArray,
new TypeReference<List<String>>() {}
);
} catch (Exception e) {
log.error("Failed to parse JSON array", e);
return Collections.emptyList();
}
}
}
3.6 消息调度器
java
@Service
@Slf4j
public class WechatMessageDispatcher {
@Autowired
private MessageRuleService ruleService;
@Autowired
private WechatWorkApiClient wechatApiClient;
/**
* 异步 dispatch(避免阻塞企业微信回调)
*/
@Async("wechatExecutor")
public void dispatchAsync(WxWorkMessage message) {
try {
Optional<DispatchResult> result = ruleService.matchAndDispatch(message);
if (result.isPresent()) {
log.info("Message processed successfully: msgId={}", message.getMsgId());
} else {
log.warn("Message not matched: msgId={}", message.getMsgId());
}
} catch (Exception e) {
log.error("Message dispatch failed: msgId={}", message.getMsgId(), e);
// 发送错误回复
wechatApiClient.sendText(
message.getFromUserName(),
"❌ 处理失败:" + e.getMessage()
);
}
}
}
3.7 回复消息 API 客户端
java
@Service
@Slf4j
public class WechatWorkApiClient {
private static final String TOKEN_URL =
"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={}&corpsecret={}";
private static final String SEND_URL =
"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={}";
@Value("${smartclaw.wechat.work.corp-id}")
private String corpId;
@Value("${smartclaw.wechat.work.agent-secret}")
private String agentSecret;
@Value("${smartclaw.wechat.work.agent-id}")
private String agentId;
@Autowired
private RestTemplate restTemplate;
// AccessToken 本地缓存(有效期 7200s)
private final LoadingCache<String, String> tokenCache = CacheBuilder.newBuilder()
.expireAfterWrite(7000, TimeUnit.SECONDS)
.build(CacheLoader.from(this::fetchAccessToken));
/**
* 发送文本消息
*/
public void sendText(String toUserId, String content) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("touser", toUserId);
body.put("msgtype", "text");
body.put("agentid", agentId);
body.put("text", Map.of("content", content));
String token = tokenCache.getUnchecked("token");
String url = SEND_URL.replace("{}", token);
try {
ResponseEntity<Map> response = restTemplate.postForEntity(
url, body, Map.class
);
Map responseBody = response.getBody();
Integer errcode = (Integer) responseBody.get("errcode");
if (errcode != 0) {
log.error("Failed to send message: errcode={}, errmsg={}",
errcode, responseBody.get("errmsg"));
} else {
log.info("Message sent successfully: toUserId={}", toUserId);
}
} catch (Exception e) {
log.error("Send message failed", e);
}
}
/**
* 发送 Markdown 消息(支持富文本)
*/
public void sendMarkdown(String toUserId, String markdownContent) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("touser", toUserId);
body.put("msgtype", "markdown");
body.put("agentid", agentId);
body.put("markdown", Map.of("content", markdownContent));
String token = tokenCache.getUnchecked("token");
String url = SEND_URL.replace("{}", token);
restTemplate.postForEntity(url, body, Map.class);
}
/**
* 获取 AccessToken
*/
private String fetchAccessToken(String key) {
String url = TOKEN_URL.replace("{}", corpId).replace("{}", agentSecret);
ResponseEntity<Map> response = restTemplate.getForEntity(url, Map.class);
Map responseBody = response.getBody();
String accessToken = (String) responseBody.get("access_token");
Integer expiresIn = (Integer) responseBody.get("expires_in");
log.info("Fetched access token, expires in: {}s", expiresIn);
return accessToken;
}
}
3.8 任务完成后回复通知
在 RunService 的 finishRun 方法中添加回调:
java
// RunService.java
@Autowired
private WechatWorkApiClient wechatWorkApiClient;
public void finishRun(String runId, RunFinishRequest request) {
// ... 更新任务状态
TaskRun run = taskRunRepository.findById(runId);
// 如果是微信触发的任务,发送回复
if ("WECHAT_WORK".equals(run.getSource())) {
notifyWechat(run);
}
}
private void notifyWechat(TaskRun run) {
String replyContent;
if ("SUCCESS".equals(run.getStatus())) {
replyContent = String.format(
"✅ **自动化任务完成**\n" +
"> 任务:%s\n" +
"> 流水号:%s\n" +
"> 耗时:%dms\n" +
"> 时间:%s",
run.getTemplateName(),
run.getRunId().substring(0, 8),
run.getDurationMs(),
LocalDateTime.now().format(DateTimeFormatter.ofPattern("MM-dd HH:mm:ss"))
);
} else {
replyContent = String.format(
"❌ **任务执行失败**\n" +
"> 任务:%s\n" +
"> 错误:%s\n" +
"> 流水号:%s",
run.getTemplateName(),
run.getFinalMessage(),
run.getRunId().substring(0, 8)
);
}
wechatWorkApiClient.sendMarkdown(
run.getRequesterOpenId(),
replyContent
);
}
四、完整执行流程演示
4.1 员工发送消息
报修 空调不制冷 3楼302 张三
4.2 企业微信回调
http
POST /api/wechat/work/callback?msg_signature=xxx×tamp=xxx&nonce=xxx
Content-Type: application/xml
<xml>
<ToUserName><![CDATA[ww...]]></ToUserName>
<FromUserName><![CDATA[ZhangSan]]></FromUserName>
<CreateTime>1714204800</CreateTime>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA[报修 空调不制冷 3楼302 张三]]></Content>
<MsgId>1234567890</MsgId>
<Encrypt><![CDATA[加密后的内容]]></Encrypt>
</xml>
4.3 规则匹配
java
// 正则匹配
Pattern: ^报修\s+(?<issue>[^\s]{2,20})\s+(?<location>[^\s]{2,20})\s+(?<reporter>[\u4e00-\u9fa5]{2,4})$
// 提取变量
issue = "空调不制冷"
location = "3楼302"
reporter = "张三"
4.4 下发任务
java
DispatchRequest request = DispatchRequest.builder()
.templateId("store-repair-ticket-v1")
.variables(Map.of(
"issue", "空调不制冷",
"location", "3楼302",
"reporter", "张三"
))
.idempotencyKey("wechat_work:1234567890")
.source("WECHAT_WORK")
.requesterOpenId("ZhangSan")
.build();
dispatchService.dispatch(request);
4.5 Agent 执行 DSL
yaml
steps:
- stepId: s1
action: navigate
params:
url: "https://support.example.com/ticket/new"
- stepId: s2
action: fill
params:
selector: "input[name='name']"
value: "${reporter}" # 张三
- stepId: s3
action: fill
params:
selector: "input[name='idcard']"
value: "${issue}" # 空调不制冷
- stepId: s4
action: fill
params:
selector: "input[name='room']"
value: "${location}" # 3楼302
- stepId: s5
action: clickRole
params:
role: "button"
name: "提交工单"
- stepId: s6
action: waitText
params:
text: "工单已创建"
timeoutMs: 8000
4.6 回复前台
✅ **自动化任务完成**
> 任务:门店报修派单
> 流水号:c6efed0a
> 耗时:4823ms
> 时间:04-27 14:32:11
五、性能优化建议
5.1 AccessToken 缓存
企业微信 AccessToken 有效期 7200 秒,不要每次请求都重新获取:
java
LoadingCache<String, String> tokenCache = CacheBuilder.newBuilder()
.expireAfterWrite(7000, TimeUnit.SECONDS) // 7000 秒过期
.build(CacheLoader.from(this::fetchAccessToken));
5.2 异步处理消息
企业微信要求 5 秒内响应,因此消息处理必须异步:
java
@Async("wechatExecutor")
public void dispatchAsync(WxWorkMessage message) {
// 异步处理,立即返回 success
}
线程池配置:
java
@Configuration
public class WechatExecutorConfig {
@Bean("wechatExecutor")
public Executor wechatExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("wechat-");
executor.initialize();
return executor;
}
}
5.3 幂等保护
通过消息 ID 保证同一条消息不会重复触发任务:
java
.idempotencyKey("wechat_work:" + message.getMsgId())
六、OpenClaw 做不到的事
6.1 自然语言交互
OpenClaw 需要手写 Prompt,而 SmartClaw 可以通过微信消息触发,用户门槛更低。
6.2 工作流集成
SmartClaw 可以与企业微信深度集成,实现:
- 消息触发任务
- 任务完成回复
- 群聊 @机器人触发
- 审批流程集成
6.3 移动端支持
企业微信支持 iOS/Android,用户可以随时随地触发自动化任务。
七、总结
OpenClaw 展示了 AI 操作浏览器的可能性,但在企业落地场景下,还需要解决交互入口的问题。
SmartClaw 通过接入企业微信 Webhook,实现了"发消息即执行"的自动化流程,将用户门槛降到最低。
相关资源
-
系列文章:
-
第④篇:Agent 调度与幂等设计
-
企业微信官方文档 :接收消息与事件
💬 互动交流
如果你在学习和使用过程中遇到问题,欢迎:
1. 在评论区留言讨论
2.如果觉得有帮助,点赞👍收藏📌关注➕,后续会持续分享SpringAI和AI工程的实战经验!
你的支持是我持续创作的最大动力!
