diff --git a/src/main/java/cn/van333/wxsend/business/controller/WeComCallbackController.java b/src/main/java/cn/van333/wxsend/business/controller/WeComCallbackController.java
new file mode 100644
index 0000000..e36f167
--- /dev/null
+++ b/src/main/java/cn/van333/wxsend/business/controller/WeComCallbackController.java
@@ -0,0 +1,91 @@
+package cn.van333.wxsend.business.controller;
+
+import cn.hutool.core.util.StrUtil;
+import cn.van333.wxsend.business.model.R;
+import cn.van333.wxsend.util.WeComCallbackCrypto;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.MediaType;
+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);
+
+ @Value("${qywx.app.corpId:}")
+ private String corpId;
+
+ @Value("${qywx.app.token:}")
+ private String token;
+
+ @Value("${qywx.app.encodingAESKey:}")
+ private String encodingAESKey;
+
+ @GetMapping(produces = MediaType.TEXT_PLAIN_VALUE)
+ public String verify(@RequestParam("msg_signature") String msgSignature,
+ @RequestParam("timestamp") String timestamp,
+ @RequestParam("nonce") String nonce,
+ @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;
+ }
+
+ @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 {
+ 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";
+ }
+
+ private static String readBody(HttpServletRequest request) throws IOException {
+ StringBuilder sb = new StringBuilder();
+ try (BufferedReader reader = request.getReader()) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ sb.append(line);
+ }
+ }
+ return sb.toString();
+ }
+
+ private static String parseEncrypt(String xml) {
+ // 简单提取
+ String start = "";
+ int i = xml.indexOf(start);
+ int j = xml.indexOf(end);
+ if (i >= 0 && j > i) {
+ return xml.substring(i + start.length(), j);
+ }
+ // 兼容无 CDATA 的情况
+ start = "";
+ end = "";
+ i = xml.indexOf(start);
+ j = xml.indexOf(end);
+ if (i >= 0 && j > i) {
+ return xml.substring(i + start.length(), j);
+ }
+ throw new RuntimeException("Encrypt not found");
+ }
+}
+
+
diff --git a/src/main/java/cn/van333/wxsend/util/WeComCallbackCrypto.java b/src/main/java/cn/van333/wxsend/util/WeComCallbackCrypto.java
new file mode 100644
index 0000000..fc7dd90
--- /dev/null
+++ b/src/main/java/cn/van333/wxsend/util/WeComCallbackCrypto.java
@@ -0,0 +1,164 @@
+package cn.van333.wxsend.util;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Comparator;
+
+/**
+ * 企业微信回调加解密工具(等价于官方 WXBizMsgCrypt 的精简实现)。
+ * 参考:https://developer.work.weixin.qq.com/document/path/90238
+ */
+public class WeComCallbackCrypto {
+
+ private static final String AES_ALGORITHM = "AES/CBC/NoPadding";
+
+ private final String token;
+ private final byte[] aesKey; // 32字节
+ private final String corpId;
+ private final byte[] iv;
+
+ public WeComCallbackCrypto(String token, String encodingAESKey, String corpId) {
+ this.token = token;
+ this.aesKey = Base64.getDecoder().decode(encodingAESKey + "=");
+ this.corpId = corpId;
+ this.iv = Arrays.copyOfRange(aesKey, 0, 16);
+ }
+
+ public String verifyURL(String msgSignature, String timestamp, String nonce, String echoStr) {
+ String sign = sha1(token, timestamp, nonce, echoStr);
+ if (!sign.equals(msgSignature)) {
+ throw new RuntimeException("signature not match");
+ }
+ String plain = decrypt(echoStr);
+ return plain;
+ }
+
+ public String decryptMsg(String msgSignature, String timestamp, String nonce, String encrypt) {
+ String sign = sha1(token, timestamp, nonce, encrypt);
+ if (!sign.equals(msgSignature)) {
+ throw new RuntimeException("signature not match");
+ }
+ return decrypt(encrypt);
+ }
+
+ public String encryptMsg(String reply, String timestamp, String nonce) {
+ String encrypt = encrypt(reply);
+ String sign = sha1(token, timestamp, nonce, encrypt);
+ // 返回 XML,与官方结构一致
+ return "" +
+ "" +
+ "" +
+ "" + timestamp + "" +
+ "" +
+ "";
+ }
+
+ private String decrypt(String base64CipherText) {
+ try {
+ byte[] cipherData = Base64.getDecoder().decode(base64CipherText);
+ Cipher cipher = Cipher.getInstance(AES_ALGORITHM);
+ SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES");
+ cipher.init(Cipher.DECRYPT_MODE, keySpec, new IvParameterSpec(iv));
+ byte[] plainPadded = cipher.doFinal(cipherData);
+ byte[] plain = PKCS7Encoder.decode(plainPadded);
+
+ // 结构:16字节随机 + 4字节网络序正文长度 + 正文 + corpId
+ byte[] networkOrder = Arrays.copyOfRange(plain, 16, 20);
+ int xmlLength = ByteBuffer.wrap(networkOrder).order(ByteOrder.BIG_ENDIAN).getInt();
+ byte[] xmlBytes = Arrays.copyOfRange(plain, 20, 20 + xmlLength);
+ String fromCorpId = new String(Arrays.copyOfRange(plain, 20 + xmlLength, plain.length), StandardCharsets.UTF_8);
+ if (!fromCorpId.equals(corpId)) {
+ throw new RuntimeException("corpId mismatch");
+ }
+ return new String(xmlBytes, StandardCharsets.UTF_8);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private String encrypt(String plainText) {
+ try {
+ byte[] random16 = randomBytes(16);
+ byte[] xmlBytes = plainText.getBytes(StandardCharsets.UTF_8);
+ ByteBuffer lenBuf = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(xmlBytes.length);
+ byte[] corpBytes = corpId.getBytes(StandardCharsets.UTF_8);
+
+ byte[] raw = concat(random16, lenBuf.array(), xmlBytes, corpBytes);
+ byte[] padded = PKCS7Encoder.encode(raw);
+
+ Cipher cipher = Cipher.getInstance(AES_ALGORITHM);
+ SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES");
+ cipher.init(Cipher.ENCRYPT_MODE, keySpec, new IvParameterSpec(iv));
+ byte[] cipherData = cipher.doFinal(padded);
+ return Base64.getEncoder().encodeToString(cipherData);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static String sha1(String token, String timestamp, String nonce, String encrypt) {
+ try {
+ String[] arr = new String[]{token, timestamp, nonce, encrypt};
+ Arrays.sort(arr, Comparator.naturalOrder());
+ String joined = String.join("", arr);
+ MessageDigest md = MessageDigest.getInstance("SHA-1");
+ byte[] digest = md.digest(joined.getBytes(StandardCharsets.UTF_8));
+ StringBuilder sb = new StringBuilder();
+ for (byte b : digest) {
+ String hex = Integer.toHexString(b & 0xff);
+ if (hex.length() < 2) sb.append('0');
+ sb.append(hex);
+ }
+ return sb.toString();
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static byte[] randomBytes(int n) {
+ byte[] b = new byte[n];
+ // 简易随机即可满足要求
+ new java.security.SecureRandom().nextBytes(b);
+ return b;
+ }
+
+ private static byte[] concat(byte[]... parts) {
+ int len = 0;
+ for (byte[] p : parts) len += p.length;
+ byte[] all = new byte[len];
+ int pos = 0;
+ for (byte[] p : parts) {
+ System.arraycopy(p, 0, all, pos, p.length);
+ pos += p.length;
+ }
+ return all;
+ }
+
+ private static class PKCS7Encoder {
+ private static final int BLOCK_SIZE = 32;
+
+ static byte[] encode(byte[] src) {
+ int amountToPad = BLOCK_SIZE - (src.length % BLOCK_SIZE);
+ if (amountToPad == 0) amountToPad = BLOCK_SIZE;
+ byte padChr = (byte) (amountToPad & 0xFF);
+ byte[] padding = new byte[amountToPad];
+ Arrays.fill(padding, padChr);
+ return concat(src, padding);
+ }
+
+ static byte[] decode(byte[] decrypted) {
+ int pad = decrypted[decrypted.length - 1] & 0xFF;
+ if (pad < 1 || pad > BLOCK_SIZE) pad = 0;
+ return Arrays.copyOfRange(decrypted, 0, decrypted.length - pad);
+ }
+ }
+}
+
+
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index ddabfc2..0b17772 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -72,3 +72,8 @@ qywx:
key: "ea1737ff-f906-426d-b39c-2cdace31c3af"
# 机器人安全设置中的加签secret(可选)。若不开启加签可留空
secret: ""
+ app:
+ corpId: ""
+ agentId: "1000002"
+ token: "7UV4cedJT5gx2kCQXmz7PH5"
+ encodingAESKey: "nHxBmJYFn9dAjwltyIdLi9YvxmHDGNRsX1MgBIPsog9"