diff --git a/src/main/java/cn/van333/wxsend/business/controller/WeComCallbackController.java b/src/main/java/cn/van333/wxsend/business/controller/WeComCallbackController.java index e36f167..76fe518 100644 --- a/src/main/java/cn/van333/wxsend/business/controller/WeComCallbackController.java +++ b/src/main/java/cn/van333/wxsend/business/controller/WeComCallbackController.java @@ -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,23 +47,81 @@ 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, - @RequestParam("timestamp") String timestamp, - @RequestParam("nonce") String nonce, - HttpServletRequest request) throws IOException { + @PostMapping(consumes = MediaType.TEXT_XML_VALUE) + public ResponseEntity receiveXml(@RequestParam("msg_signature") String msgSignature, + @RequestParam("timestamp") String timestamp, + @RequestParam("nonce") String nonce, + HttpServletRequest request) throws IOException { String xml = readBody(request); logger.info("WeCom callback received xml: {}", xml); String encrypt = parseEncrypt(xml); 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) { - // 简单提取 String start = ""; 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 = ""; end = ""; i = xml.indexOf(start); @@ -87,5 +153,3 @@ public class WeComCallbackController { throw new RuntimeException("Encrypt not found"); } } - - diff --git a/src/main/java/cn/van333/wxsend/util/WeComPassiveReplyBuilder.java b/src/main/java/cn/van333/wxsend/util/WeComPassiveReplyBuilder.java new file mode 100644 index 0000000..b61925c --- /dev/null +++ b/src/main/java/cn/van333/wxsend/util/WeComPassiveReplyBuilder.java @@ -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 "" + + "" + + "" + + "" + now + "" + + "" + + "" + + ""; + } +} diff --git a/src/main/java/cn/van333/wxsend/util/WeComPlainXmlParser.java b/src/main/java/cn/van333/wxsend/util/WeComPlainXmlParser.java new file mode 100644 index 0000000..0fd9655 --- /dev/null +++ b/src/main/java/cn/van333/wxsend/util/WeComPlainXmlParser.java @@ -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) + ">"); + Matcher m = p.matcher(xml); + if (m.find()) { + return m.group(1).trim(); + } + p = Pattern.compile("<" + 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("(\\d+)"); + 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("(\\d+)"); + Matcher m = p.matcher(plainXml); + if (m.find()) { + return m.group(1); + } + return null; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 55890f4..09cc88e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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