This commit is contained in:
van
2026-04-01 01:57:22 +08:00
parent aaa1325afc
commit 6b8987b4db
4 changed files with 178 additions and 16 deletions

View File

@@ -1,25 +1,29 @@
package cn.van333.wxsend.business.controller;
import cn.hutool.core.util.StrUtil;
import cn.van333.wxsend.business.model.R;
import cn.hutool.http.HttpRequest;
import cn.van333.wxsend.util.WeComCallbackCrypto;
import cn.van333.wxsend.util.WeComPassiveReplyBuilder;
import cn.van333.wxsend.util.WeComPlainXmlParser;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.io.BufferedReader;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/wecom/callback")
public class WeComCallbackController {
private static final Logger logger = LoggerFactory.getLogger(WeComCallbackController.class);
private static final int HTTP_OK = 200;
@Value("${qywx.app.corpId:}")
private String corpId;
@@ -30,6 +34,12 @@ public class WeComCallbackController {
@Value("${qywx.app.encodingAESKey:}")
private String encodingAESKey;
@Value("${jarvis.wecom.bridge-url:}")
private String jarvisBridgeUrl;
@Value("${jarvis.wecom.shared-secret:}")
private String jarvisSharedSecret;
@GetMapping(produces = MediaType.TEXT_PLAIN_VALUE)
public String verify(@RequestParam("msg_signature") String msgSignature,
@RequestParam("timestamp") String timestamp,
@@ -37,12 +47,11 @@ public class WeComCallbackController {
@RequestParam("echostr") String echostr) {
logger.info("WeCom callback verify: ts={}, nonce={}", timestamp, nonce);
WeComCallbackCrypto crypto = new WeComCallbackCrypto(token, encodingAESKey, corpId);
String plain = crypto.verifyURL(msgSignature, timestamp, nonce, echostr);
return plain;
return crypto.verifyURL(msgSignature, timestamp, nonce, echostr);
}
@PostMapping(consumes = MediaType.TEXT_XML_VALUE, produces = MediaType.TEXT_PLAIN_VALUE)
public String receiveXml(@RequestParam("msg_signature") String msgSignature,
@PostMapping(consumes = MediaType.TEXT_XML_VALUE)
public ResponseEntity<String> receiveXml(@RequestParam("msg_signature") String msgSignature,
@RequestParam("timestamp") String timestamp,
@RequestParam("nonce") String nonce,
HttpServletRequest request) throws IOException {
@@ -52,8 +61,67 @@ public class WeComCallbackController {
WeComCallbackCrypto crypto = new WeComCallbackCrypto(token, encodingAESKey, corpId);
String plainXml = crypto.decryptMsg(msgSignature, timestamp, nonce, encrypt);
logger.info("WeCom callback plain xml: {}", plainXml);
// TODO: 在此解析 plainXml 的 MsgType/Event/Content 等,进行业务处理
return "success";
String msgType = WeComPlainXmlParser.msgType(plainXml);
if (!"text".equalsIgnoreCase(StrUtil.emptyToDefault(msgType, ""))) {
return ResponseEntity.ok().contentType(MediaType.TEXT_PLAIN).body("success");
}
String from = WeComPlainXmlParser.fromUserName(plainXml);
String content = WeComPlainXmlParser.content(plainXml);
String toCorp = WeComPlainXmlParser.toUserName(plainXml);
String agentId = WeComPlainXmlParser.agentId(plainXml);
String msgId = WeComPlainXmlParser.msgId(plainXml);
String reply = callJarvisBridge(from, content, toCorp, agentId, msgId);
if (StrUtil.isBlank(reply)) {
return ResponseEntity.ok().contentType(MediaType.TEXT_PLAIN).body("success");
}
String inner = WeComPassiveReplyBuilder.text(from, toCorp, reply);
String encryptedOuter = crypto.encryptMsg(inner, timestamp, nonce);
return ResponseEntity.ok()
.contentType(MediaType.TEXT_XML)
.body(encryptedOuter);
}
private String callJarvisBridge(String fromUserName, String content, String toUserName,
String agentId, String msgId) {
if (StrUtil.isBlank(jarvisBridgeUrl) || StrUtil.isBlank(jarvisSharedSecret)) {
logger.warn("未配置 jarvis.wecom.bridge-url 或 shared-secret跳过转发");
return null;
}
JSONObject body = new JSONObject();
body.put("fromUserName", fromUserName);
body.put("content", content != null ? content : "");
body.put("toUserName", toUserName);
body.put("agentId", agentId);
body.put("msgId", msgId);
try {
String resp = HttpRequest.post(jarvisBridgeUrl.trim())
.header("Content-Type", "application/json;charset=UTF-8")
.header("X-Jarvis-WeCom-Secret", jarvisSharedSecret)
.body(body.toJSONString())
.timeout(120_000)
.execute()
.body();
JSONObject root = JSON.parseObject(resp);
if (root == null) {
return null;
}
if (root.getIntValue("code") != HTTP_OK) {
logger.warn("Jarvis 桥接返回非200: {}", resp);
return null;
}
JSONObject data = root.getJSONObject("data");
if (data == null) {
return null;
}
return data.getString("reply");
} catch (Exception e) {
logger.error("调用 Jarvis 企微桥接失败", e);
return null;
}
}
private static String readBody(HttpServletRequest request) throws IOException {
@@ -68,7 +136,6 @@ public class WeComCallbackController {
}
private static String parseEncrypt(String xml) {
// 简单提取 <Encrypt><![CDATA[...]]></Encrypt>
String start = "<Encrypt><![CDATA[";
String end = "]]></Encrypt>";
int i = xml.indexOf(start);
@@ -76,7 +143,6 @@ public class WeComCallbackController {
if (i >= 0 && j > i) {
return xml.substring(i + start.length(), j);
}
// 兼容无 CDATA 的情况
start = "<Encrypt>";
end = "</Encrypt>";
i = xml.indexOf(start);
@@ -87,5 +153,3 @@ public class WeComCallbackController {
throw new RuntimeException("Encrypt not found");
}
}

View File

@@ -0,0 +1,21 @@
package cn.van333.wxsend.util;
/**
* 企微被动回复明文 XML将被 {@link WeComCallbackCrypto#encryptMsg} 再包一层)
*/
public final class WeComPassiveReplyBuilder {
private WeComPassiveReplyBuilder() {
}
public static String text(String toUser, String fromCorpId, String content) {
long now = System.currentTimeMillis() / 1000;
return "<xml>"
+ "<ToUserName><![CDATA[" + toUser + "]]></ToUserName>"
+ "<FromUserName><![CDATA[" + fromCorpId + "]]></FromUserName>"
+ "<CreateTime>" + now + "</CreateTime>"
+ "<MsgType><![CDATA[text]]></MsgType>"
+ "<Content><![CDATA[" + content + "]]></Content>"
+ "</xml>";
}
}

View File

@@ -0,0 +1,72 @@
package cn.van333.wxsend.util;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 企微解密后的明文 XML 简单解析(文本消息)
*/
public final class WeComPlainXmlParser {
private WeComPlainXmlParser() {
}
public static String cdataOrText(String xml, String tag) {
if (xml == null) {
return null;
}
Pattern p = Pattern.compile("<" + Pattern.quote(tag) + "><!\\[CDATA\\[([\\s\\S]*?)\\]\\]></" + Pattern.quote(tag) + ">");
Matcher m = p.matcher(xml);
if (m.find()) {
return m.group(1).trim();
}
p = Pattern.compile("<" + Pattern.quote(tag) + ">([^<]*)</" + Pattern.quote(tag) + ">");
m = p.matcher(xml);
if (m.find()) {
return m.group(1).trim();
}
return null;
}
public static String msgType(String plainXml) {
return cdataOrText(plainXml, "MsgType");
}
public static String content(String plainXml) {
return cdataOrText(plainXml, "Content");
}
public static String fromUserName(String plainXml) {
return cdataOrText(plainXml, "FromUserName");
}
public static String toUserName(String plainXml) {
return cdataOrText(plainXml, "ToUserName");
}
public static String msgId(String plainXml) {
String v = cdataOrText(plainXml, "MsgId");
if (v != null) {
return v;
}
Pattern p = Pattern.compile("<MsgId>(\\d+)</MsgId>");
Matcher m = p.matcher(plainXml);
if (m.find()) {
return m.group(1);
}
return null;
}
public static String agentId(String plainXml) {
String v = cdataOrText(plainXml, "AgentID");
if (v != null) {
return v;
}
Pattern p = Pattern.compile("<AgentID>(\\d+)</AgentID>");
Matcher m = p.matcher(plainXml);
if (m.find()) {
return m.group(1);
}
return null;
}
}

View File

@@ -66,6 +66,11 @@ logging:
cn.van333: debug
org.springframework: warn
jarvis:
wecom:
bridge-url: https://jarvis.van333.cn/jarvis-api/jarvis/wecom/inbound
shared-secret: jarvis_wecom_bridge_change_me
qywx:
webhook:
# 默认 webhook key可在请求体中显式传入key覆盖https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=ea1737ff-f906-426d-b39c-2cdace31c3af