1
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>";
|
||||
}
|
||||
}
|
||||
72
src/main/java/cn/van333/wxsend/util/WeComPlainXmlParser.java
Normal file
72
src/main/java/cn/van333/wxsend/util/WeComPlainXmlParser.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user