增加消息验证接口

Signed-off-by: 赵新乐 <zxl78585@126.com>
This commit is contained in:
zxl78585 committed 2026-05-18 17:49:58 +08:00
1 parent 8191a2dbc9
commit 5e2fc9348c
15 files changed
+333 -59

No files matched your search

+9
View File
@@ -70,6 +70,15 @@
<groupId>commons-codec</groupId> <groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId> <artifactId>commons-codec</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.dom4j</groupId>
<artifactId>dom4j</artifactId>
<version>2.1.4</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -0,0 +1,143 @@
package com.f8q8.qywechatarchive.common.utils;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import lombok.extern.slf4j.Slf4j;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.JavaType;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.SerializationFeature;
import tools.jackson.databind.json.JsonMapper;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.text.SimpleDateFormat;
import java.util.List;
import java.util.TimeZone;
@Slf4j
public class JsonTools {
private static final JsonMapper DEFAULT_MAPPER = buildDefaultMapper();
private static final JsonMapper PRETTY_MAPPER = buildPrettyMapper();
private static final XmlMapper XML_MAPPER = buildXmlMapper();
private JsonTools() {
}
public static JsonMapper getDefaultMapper() {
return DEFAULT_MAPPER;
}
public static JsonMapper getPrettyMapper() {
return PRETTY_MAPPER;
}
private static JsonMapper buildDefaultMapper() {
return JsonMapper.builder().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.defaultDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"))
.defaultTimeZone(TimeZone.getDefault()).build();
}
private static JsonMapper buildPrettyMapper() {
return JsonMapper.builder().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.defaultDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"))
.defaultTimeZone(TimeZone.getDefault()).enable(SerializationFeature.INDENT_OUTPUT).build();
}
private static XmlMapper buildXmlMapper() {
return XmlMapper.builder().build();
}
// 序列化
public static String toJson(Object Obj) {
try {
return DEFAULT_MAPPER.writeValueAsString(Obj);
} catch (Exception e) {
log.error(e.getMessage(), e);
throw new RuntimeException("json序列化失败", e);
}
}
public static String toPrettyJson(Object obj) {
try {
return PRETTY_MAPPER.writeValueAsString(obj);
} catch (Exception e) {
throw new RuntimeException("JSON序列化失败:", e);
}
}
public static void writeToStream(Object obj, OutputStream outputStream) {
DEFAULT_MAPPER.writeValue(outputStream, obj);
}
// 反序列化
public static <T> T fromJson(String json, Class<T> claszz) {
try {
return DEFAULT_MAPPER.readValue(json, claszz);
} catch (Exception e) {
log.error(e.getMessage(), e);
throw new RuntimeException("JSON反序列化失败:", e);
}
}
public static <T> T fromJson(String json, TypeReference<T> typeRef) {
try {
return DEFAULT_MAPPER.readValue(json, typeRef);
} catch (Exception e) {
throw new RuntimeException("JSON 反序列化失败", e);
}
}
// 泛型集合的辅助方法
public static <T> List<T> fromJsonToList(String json, Class<T> elementClass) {
try {
JavaType type = DEFAULT_MAPPER.getTypeFactory().constructCollectionType(List.class, elementClass);
return DEFAULT_MAPPER.readValue(json, type);
} catch (Exception e) {
throw new RuntimeException("JSON 反序列化为List失败", e);
}
}
public static <T> T fromStream(InputStream in, Class<T> clazz) throws IOException {
return DEFAULT_MAPPER.readValue(in, clazz);
}
public static <T> T fromStream(InputStream in, TypeReference<T> typeRef) throws IOException {
return DEFAULT_MAPPER.readValue(in, typeRef);
}
// ---------- JsonNode 操作 ----------
public static JsonNode toJsonNode(String json) {
try {
return DEFAULT_MAPPER.readTree(json);
} catch (Exception e) {
throw new RuntimeException("转换为 JsonNode 失败", e);
}
}
public static JsonNode valueToTree(Object obj) {
return DEFAULT_MAPPER.valueToTree(obj);
}
public static <T> T treeToValue(JsonNode node, Class<T> clazz) {
try {
return DEFAULT_MAPPER.treeToValue(node, clazz);
} catch (Exception e) {
throw new RuntimeException("JsonNode 转换对象失败", e);
}
}
public static JsonNode parseXml(String xmlText) {
try {
com.fasterxml.jackson.databind.JsonNode jsonNode = XML_MAPPER.readTree(xmlText);
return toJsonNode(jsonNode.toString());
} catch (Exception e) {
log.error(e.getMessage(), e);
throw new RuntimeException("XML转JSON失败:", e);
}
}
}
@@ -1,19 +1,35 @@
package com.f8q8.qywechatarchive.modules.api.action; package com.f8q8.qywechatarchive.modules.api.action;
import com.f8q8.qywechatarchive.core.utils.ResultUtils; import com.f8q8.qywechatarchive.common.utils.JsonTools;
import com.f8q8.qywechatarchive.modules.api.client.WeComSdkClient;
import com.f8q8.qywechatarchive.modules.api.service.WeComSyncService;
import com.f8q8.qywechatarchive.modules.api.utils.AesException;
import com.f8q8.qywechatarchive.modules.api.utils.WXBizJsonMsgCrypt; import com.f8q8.qywechatarchive.modules.api.utils.WXBizJsonMsgCrypt;
import com.f8q8.qywechatarchive.modules.api.vo.CallbackVO; import com.f8q8.qywechatarchive.modules.api.vo.CallbackVO;
import jakarta.annotation.Resource;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.util.StringUtils; import org.springframework.util.StreamUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import tools.jackson.databind.JsonNode;
import java.nio.charset.StandardCharsets;
@Slf4j @Slf4j
@RestController @RestController
@RequestMapping(value = "api/v1/callback") @RequestMapping(value = "api/v1/callback")
public class CallbackController { public class CallbackController {
@Resource
WeComSdkClient weComSdkClient;
@Resource
WeComSyncService weComSyncService;
@Value("${wecom.token}") @Value("${wecom.token}")
String token; String token;
@Value("${wecom.encodingAESKey}") @Value("${wecom.encodingAESKey}")
@@ -21,24 +37,62 @@ public class CallbackController {
@Value("${wecom.corp-id}") @Value("${wecom.corp-id}")
String corpId; String corpId;
@RequestMapping(value = "notify") private static WXBizJsonMsgCrypt wxcpt;
public Object notify(CallbackVO callbackVO) {
log.info("企业微信回调数据:{}", callbackVO); private WXBizJsonMsgCrypt getWxCpt() {
if (StringUtils.hasText(callbackVO.getEchostr())) { if (wxcpt == null) {
// 下面逻辑用于校验 try {
return verifyRequest(callbackVO); wxcpt = new WXBizJsonMsgCrypt(token, encodingAESKey, corpId);
} else { } catch (AesException e) {
// 下面逻辑用于消息解密 log.error(e.getMessage(), e);
return ResultUtils.success(); throw new RuntimeException(e);
}
} }
return wxcpt;
}
@GetMapping(value = "notify")
public Object verify(CallbackVO callbackVO) {
log.info("企业微信验证数据:{}", callbackVO);
return verifyRequest(callbackVO);
}
@PostMapping(value = "notify")
public Object notify(HttpServletRequest request) {
try {
// 1. 读取 URL 上的签名参数
String msgSignature = request.getParameter("msg_signature");
String timestamp = request.getParameter("timestamp");
String nonce = request.getParameter("nonce");
// 2. 读取请求体中的 XML
ServletInputStream inputStream = request.getInputStream();
String postData = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
// msg_signature=cb7349c730ce79a415d977e7de3fcee7da709597, timestamp=1779089871, nonce=1778976713, body=<xml><ToUserName><![CDATA[wwb42d275eae6910cc]]></ToUserName><Encrypt><![CDATA[GrVgKnXnMS6/7T/PVitnID+W/1Ak9GHV0BkPaznk21Hg+01yZmIdfKhUPIcWPFEXd2ccyMTeO0i/ug6RyDe88vnrt2w5WZpZ2/rJqYBh9eAi+WBbHn/0Th3MoIER8/dChnGDbLn9T1oV+rrEI8wqlZre8oaYiDdfGqhNkQZ7xesaVJZhqHFswXdjSu239fbtEojKX9YS22ss34O6ZK9VEcO3mjV2rI1pQETLf4d7auWsKz1Sm2zkodALOZrrdEk/bn5md1QxmHXRZ2gEc4Pl0UHUji2HrQRiiCFYQ9lW1kMJs1Ib3ElSwNcLEuSaZJV/bW8I+TqfLWy0nPvawhYw0BsVCs5LofsVYtpYSuWca7wo6b3Ug9nzduoiHWBbO0ni]]></Encrypt><AgentID><![CDATA[2000004]]></AgentID></xml>
log.info("收到回调消息: msg_signature={}, timestamp={}, nonce={}, body={}",
msgSignature, timestamp, nonce, postData);
String message = getWxCpt().DecryptMsg(msgSignature, timestamp, nonce, postData);
// 判断是否是消息通知,如果是,拉取数据
JsonNode jsonNode = JsonTools.parseXml(message);
if (jsonNode != null && jsonNode.has("Event") && "msgaudit_notify".equals(jsonNode.get("Event").asString())) {
// 是会话消息事件,拉取消息内容
int count = weComSyncService.syncMessages(corpId);
log.info("同步了{}条消息", count);
}
log.info(message);
} catch (Exception e) {
log.error(e.getMessage(), e);
}
return null;
} }
private String verifyRequest(CallbackVO callbackVO) { private String verifyRequest(CallbackVO callbackVO) {
String result = null; String result = null;
if (verifyParam(callbackVO)) { if (verifyParam(callbackVO)) {
try { try {
WXBizJsonMsgCrypt wxcpt = new WXBizJsonMsgCrypt(token, encodingAESKey, corpId); result = getWxCpt().VerifyURL(callbackVO.getMsg_signature(), String.valueOf(callbackVO.getTimestamp()),
result = wxcpt.VerifyURL(callbackVO.getMsg_signature(), String.valueOf(callbackVO.getTimestamp()),
callbackVO.getNonce(), callbackVO.getEchostr()); callbackVO.getNonce(), callbackVO.getEchostr());
} catch (Exception e) { } catch (Exception e) {
log.error(e.getMessage(), e); log.error(e.getMessage(), e);
@@ -15,46 +15,51 @@ package com.f8q8.qywechatarchive.modules.api.utils;
*/ */
import com.f8q8.qywechatarchive.common.utils.JsonTools;
import lombok.extern.slf4j.Slf4j;
import tools.jackson.databind.JsonNode;
/** /**
* JsonParse class * JsonParse class
* * <p>
* 提供提取消息格式中的密文及生成回复消息格式的接口. * 提供提取消息格式中的密文及生成回复消息格式的接口.
*/ */
@Slf4j
class JsonParse { class JsonParse {
/** /**
* 提取出 JSON 包中的加密消息 * 提取出 JSON 包中的加密消息
*
* @param jsontext 待提取的json字符串 * @param jsontext 待提取的json字符串
* @return 提取出的加密消息字符串 * @return 提取出的加密消息字符串
* @throws AesException * @throws AesException
*/ */
public static Object[] extract(String jsontext) throws AesException { public static Object[] extract(String jsonText) throws AesException {
Object[] result = new Object[3]; Object[] result = new Object[3];
/*
try {
JSONObject json = new JSONObject(jsontext);
String encrypt_msg = json.getString("encrypt");
String tousername = json.getString("tousername");
String agentid = json.getString("agentid");
result[0] = tousername; try {
result[1] = encrypt_msg; JsonNode json = JsonTools.toJsonNode(jsonText);
result[2] = agentid; String encrypt_msg = json.get("encrypt").asString();
return result; String tousername = json.get("tousername").asString();
} catch (Exception e) { String agentid = json.get("agentid").asString();
e.printStackTrace();
throw new AesException(AesException.ParseJsonError); result[0] = tousername;
} result[1] = encrypt_msg;
*/ result[2] = agentid;
return null; return result;
} catch (Exception e) {
log.error(e.getMessage(), e);
throw new AesException(AesException.ParseJsonError);
}
} }
/** /**
* 生成json消息 * 生成json消息
* @param encrypt 加密后的消息密文 *
* @param encrypt 加密后的消息密文
* @param signature 安全签名 * @param signature 安全签名
* @param timestamp 时间戳 * @param timestamp 时间戳
* @param nonce 随机字符串 * @param nonce 随机字符串
* @return 生成的json字符串 * @return 生成的json字符串
*/ */
public static String generate(String encrypt, String signature, String timestamp, String nonce) { public static String generate(String encrypt, String signature, String timestamp, String nonce) {
@@ -13,7 +13,9 @@
*/ */
package com.f8q8.qywechatarchive.modules.api.utils; package com.f8q8.qywechatarchive.modules.api.utils;
import com.f8q8.qywechatarchive.common.utils.JsonTools;
import org.apache.commons.codec.binary.Base64; import org.apache.commons.codec.binary.Base64;
import tools.jackson.databind.JsonNode;
import javax.crypto.Cipher; import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.IvParameterSpec;
@@ -46,10 +48,10 @@ public class WXBizJsonMsgCrypt {
/** /**
* 构造函数 * 构造函数
* @param token 企业微信后台,开发者设置的token
* @param encodingAesKey 企业微信后台,开发者设置的EncodingAESKey
* @param receiveid, 不同场景含义不同,详见文档
* *
* @param token 企业微信后台,开发者设置的token
* @param encodingAesKey 企业微信后台,开发者设置的EncodingAESKey
* @param receiveid, 不同场景含义不同,详见文档
* @throws AesException 执行失败,请查看该异常的错误码和具体的错误信息 * @throws AesException 执行失败,请查看该异常的错误码和具体的错误信息
*/ */
public WXBizJsonMsgCrypt(String token, String encodingAesKey, String receiveid) throws AesException { public WXBizJsonMsgCrypt(String token, String encodingAesKey, String receiveid) throws AesException {
@@ -201,10 +203,9 @@ public class WXBizJsonMsgCrypt {
* <li>将消息密文和安全签名打包成json格式</li> * <li>将消息密文和安全签名打包成json格式</li>
* </ol> * </ol>
* *
* @param replyMsg 企业微信待回复用户的消息,json格式的字符串 * @param replyMsg 企业微信待回复用户的消息,json格式的字符串
* @param timeStamp 时间戳,可以自己生成,也可以用URL参数的timestamp * @param timeStamp 时间戳,可以自己生成,也可以用URL参数的timestamp
* @param nonce 随机串,可以自己生成,也可以用URL参数的nonce * @param nonce 随机串,可以自己生成,也可以用URL参数的nonce
*
* @return 加密后的可以直接回复用户的密文,包括msg_signature, timestamp, nonce, encrypt的json格式的字符串 * @return 加密后的可以直接回复用户的密文,包括msg_signature, timestamp, nonce, encrypt的json格式的字符串
* @throws AesException 执行失败,请查看该异常的错误码和具体的错误信息 * @throws AesException 执行失败,请查看该异常的错误码和具体的错误信息
*/ */
@@ -234,10 +235,9 @@ public class WXBizJsonMsgCrypt {
* </ol> * </ol>
* *
* @param msgSignature 签名串,对应URL参数的msg_signature * @param msgSignature 签名串,对应URL参数的msg_signature
* @param timeStamp 时间戳,对应URL参数的timestamp * @param timeStamp 时间戳,对应URL参数的timestamp
* @param nonce 随机串,对应URL参数的nonce * @param nonce 随机串,对应URL参数的nonce
* @param postData 密文,对应POST请求的数据 * @param postData 密文,对应POST请求的数据
*
* @return 解密后的原文 * @return 解密后的原文
* @throws AesException 执行失败,请查看该异常的错误码和具体的错误信息 * @throws AesException 执行失败,请查看该异常的错误码和具体的错误信息
*/ */
@@ -246,10 +246,10 @@ public class WXBizJsonMsgCrypt {
// 密钥,公众账号的app secret // 密钥,公众账号的app secret
// 提取密文 // 提取密文
Object[] encrypt = JsonParse.extract(postData); JsonNode encrypt = JsonTools.parseXml(postData);
// 验证安全签名 // 验证安全签名
String signature = SHA1.getSHA1(token, timeStamp, nonce, encrypt[1].toString()); String signature = SHA1.getSHA1(token, timeStamp, nonce, encrypt.get("Encrypt").asString());
// 和URL中的签名比较是否相等 // 和URL中的签名比较是否相等
// System.out.println("第三方收到URL中的签名:" + msg_sign); // System.out.println("第三方收到URL中的签名:" + msg_sign);
@@ -257,19 +257,17 @@ public class WXBizJsonMsgCrypt {
if (!signature.equals(msgSignature)) { if (!signature.equals(msgSignature)) {
throw new AesException(AesException.ValidateSignatureError); throw new AesException(AesException.ValidateSignatureError);
} }
// 解密 // 解密
String result = decrypt(encrypt[1].toString()); return decrypt(encrypt.get("Encrypt").asString());
return result;
} }
/** /**
* 验证URL * 验证URL
* @param msgSignature 签名串,对应URL参数的msg_signature
* @param timeStamp 时间戳,对应URL参数的timestamp
* @param nonce 随机串,对应URL参数的nonce
* @param echoStr 随机串,对应URL参数的echostr
* *
* @param msgSignature 签名串,对应URL参数的msg_signature
* @param timeStamp 时间戳,对应URL参数的timestamp
* @param nonce 随机串,对应URL参数的nonce
* @param echoStr 随机串,对应URL参数的echostr
* @return 解密之后的echostr * @return 解密之后的echostr
* @throws AesException 执行失败,请查看该异常的错误码和具体的错误信息 * @throws AesException 执行失败,请查看该异常的错误码和具体的错误信息
*/ */
@@ -281,8 +279,7 @@ public class WXBizJsonMsgCrypt {
throw new AesException(AesException.ValidateSignatureError); throw new AesException(AesException.ValidateSignatureError);
} }
String result = decrypt(echoStr); return decrypt(echoStr);
return result;
} }
} }
@@ -0,0 +1,30 @@
package com.f8q8.qywechatarchive.modules.api.utils;
import lombok.extern.slf4j.Slf4j;
import org.dom4j.Document;
import org.dom4j.DocumentHelper;
import org.dom4j.Element;
import java.util.List;
@Slf4j
public class XmlTool {
public static Object[] extract(String xmlText) throws AesException {
Object[] result = null;
try {
Document document = DocumentHelper.parseText(xmlText);
Element root = document.getRootElement();
List<Element> elements = root.elements();
result = new Object[elements.size()];
for (int i = 0; i < elements.size(); i++) {
Element element = elements.get(i);
result[i] = element.attributeValue("name");
}
} catch (Exception e) {
log.error(e.getMessage(), e);
}
return result;
}
}
@@ -16,6 +16,9 @@ typedef struct MediaData {
*/ */
public class Finance { public class Finance {
private static final Object SDK_LOCK = new Object();
public native static long NewSdk(); public native static long NewSdk();
/** /**
+4 -4
View File
@@ -1,8 +1,8 @@
spring: spring:
datasource: datasource:
url: jdbc:mysql://frps.zxl78585.com:3306/qy_wechat_archive?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false url: jdbc:mysql://frps.zxl78585.com:3306/qy_wechat_archive?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: qyWechat username: wecom
password: qyWechat@2026Test password: wecom@2026Prod
hikari: hikari:
maximum-pool-size: 20 maximum-pool-size: 20
minimum-idle: 2 minimum-idle: 2
@@ -25,7 +25,7 @@ wecom:
# 同步间隔(秒) # 同步间隔(秒)
interval: 300 interval: 300
# 每次拉取条数 # 每次拉取条数
batch-size: 500 batch-size: 50
logging: logging:
file: file:
name: d:/logs/wecomArchive/wecomArchive.log name: d:/logs/wecomArchive/wecomArchive.log
+1 -1
View File
@@ -1,6 +1,6 @@
spring: spring:
datasource: datasource:
url: jdbc:mysql://127.0.0.1:3306/wecom?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false url: jdbc:mysql://127.0.0.1:3306/wecom?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: wecom username: wecom
password: wecom@2026Prod password: wecom@2026Prod
hikari: hikari:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,33 @@
package com.f8q8.qywechatarchive;
import com.f8q8.qywechatarchive.common.utils.JsonTools;
import lombok.extern.slf4j.Slf4j;
import org.dom4j.Document;
import org.dom4j.DocumentHelper;
import org.dom4j.Element;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import tools.jackson.databind.JsonNode;
@Slf4j
@SpringBootTest
public class XmlTest {
@Test
public void testXml() {
String xml = "<xml><ToUserName><![CDATA[wwb42d275eae6910cc]]></ToUserName><Encrypt><![CDATA[GrVgKnXnMS6/7T/PVitnID+W/1Ak9GHV0BkPaznk21Hg+01yZmIdfKhUPIcWPFEXd2ccyMTeO0i/ug6RyDe88vnrt2w5WZpZ2/rJqYBh9eAi+WBbHn/0Th3MoIER8/dChnGDbLn9T1oV+rrEI8wqlZre8oaYiDdfGqhNkQZ7xesaVJZhqHFswXdjSu239fbtEojKX9YS22ss34O6ZK9VEcO3mjV2rI1pQETLf4d7auWsKz1Sm2zkodALOZrrdEk/bn5md1QxmHXRZ2gEc4Pl0UHUji2HrQRiiCFYQ9lW1kMJs1Ib3ElSwNcLEuSaZJV/bW8I+TqfLWy0nPvawhYw0BsVCs5LofsVYtpYSuWca7wo6b3Ug9nzduoiHWBbO0ni]]></Encrypt><AgentID><![CDATA[2000004]]></AgentID></xml>";
try {
JsonNode json = JsonTools.parseXml(xml);
log.info(json.toPrettyString());
Document document = DocumentHelper.parseText(xml);
Element root = document.getRootElement();
for (Element element : root.elements()) {
log.info(element.getName());
log.info(element.getTextTrim());
}
} catch (Exception e) {
log.error(e.getMessage(), e);
}
}
}