From 47b4431aa887dafae81a17d8b2efadbd94c98c66 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 16 Jan 2026 10:29:47 +0800 Subject: [PATCH 1/3] =?UTF-8?q?:art:=20#3848=20=E3=80=90=E4=BC=81=E4=B8=9A?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E3=80=91=E4=BF=AE=E5=A4=8D=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=AD=98=E6=A1=A3SDK=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=AF=BC=E8=87=B4=E7=9A=84JVM=E5=B4=A9?= =?UTF-8?q?=E6=BA=83=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CP_MSG_AUDIT_SDK_SAFE_USAGE.md | 295 ++++++++++++++++++ .../weixin/cp/api/WxCpMsgAuditService.java | 86 +++++ .../cp/api/impl/WxCpMsgAuditServiceImpl.java | 167 ++++++++++ .../weixin/cp/config/WxCpConfigStorage.java | 43 +++ .../cp/config/impl/WxCpDefaultConfigImpl.java | 69 ++++ .../cp/config/impl/WxCpRedisConfigImpl.java | 69 ++++ .../weixin/cp/api/WxCpMsgAuditTest.java | 78 +++++ 7 files changed, 807 insertions(+) create mode 100644 docs/CP_MSG_AUDIT_SDK_SAFE_USAGE.md diff --git a/docs/CP_MSG_AUDIT_SDK_SAFE_USAGE.md b/docs/CP_MSG_AUDIT_SDK_SAFE_USAGE.md new file mode 100644 index 0000000000..b3a3ea1d33 --- /dev/null +++ b/docs/CP_MSG_AUDIT_SDK_SAFE_USAGE.md @@ -0,0 +1,295 @@ +# 企业微信会话存档SDK安全使用指南 + +## 问题背景 + +在使用企业微信会话存档功能时,部分开发者遇到了JVM崩溃的问题。典型错误信息如下: + +``` +SIGSEGV (0xb) at pc=0x00007fcd50460d93 +Problematic frame: +C [libWeWorkFinanceSdk_Java.so+0x260d93] WeWorkFinanceSdk::TryRefresh(std::string const&, std::string const&, int)+0x23 +``` + +## 问题原因 + +旧版API设计存在以下问题: + +1. **SDK生命周期管理混乱** + - `getChatDatas()` 方法会返回SDK实例给调用方 + - 开发者需要手动调用 `Finance.DestroySdk()` 来销毁SDK + - 但SDK在框架内部有7200秒的缓存机制 + +2. **手动销毁导致缓存失效** + - 当开发者手动销毁SDK后,框架缓存中的SDK引用变为无效 + - 后续调用(如 `getMediaFile()`)仍然使用已销毁的SDK + - 底层C++库访问无效指针,导致SIGSEGV错误 + +3. **多线程并发问题** + - 在多线程环境下,一个线程销毁SDK后 + - 其他线程仍在使用该SDK,导致崩溃 + +## 解决方案 + +从 **4.8.0** 版本开始,WxJava提供了新的安全API,完全由框架管理SDK生命周期。 + +### 新API列表 + +| 旧API(已废弃) | 新API(推荐使用) | 说明 | +|----------------|------------------|------| +| `getChatDatas()` | `getChatRecords()` | 拉取聊天记录,不暴露SDK | +| `getDecryptData(sdk, ...)` | `getDecryptChatData(...)` | 解密聊天数据,无需传入SDK | +| `getChatPlainText(sdk, ...)` | `getChatRecordPlainText(...)` | 获取明文数据,无需传入SDK | +| `getMediaFile(sdk, ...)` | `downloadMediaFile(...)` | 下载媒体文件,无需传入SDK | + +### 使用示例 + +#### 错误用法(旧API,已废弃) + +```java +// ❌ 不推荐:容易导致JVM崩溃 +WxCpMsgAuditService msgAuditService = wxCpService.getMsgAuditService(); + +// 拉取聊天记录 +WxCpChatDatas chatDatas = msgAuditService.getChatDatas(seq, 1000L, null, null, 1000L); + +for (WxCpChatDatas.WxCpChatData chatData : chatDatas.getChatData()) { + // 解密数据 + WxCpChatModel model = msgAuditService.getDecryptData(chatDatas.getSdk(), chatData, 2); + + // 下载媒体文件 + if ("image".equals(model.getMsgType())) { + String sdkFileId = model.getImage().getSdkFileId(); + msgAuditService.getMediaFile(chatDatas.getSdk(), sdkFileId, null, null, 1000L, targetPath); + } +} + +// ❌ 危险操作:手动销毁SDK可能导致后续调用崩溃 +Finance.DestroySdk(chatDatas.getSdk()); +``` + +#### 正确用法(新API,推荐) + +```java +// ✅ 推荐:SDK生命周期由框架自动管理,安全可靠 +WxCpMsgAuditService msgAuditService = wxCpService.getMsgAuditService(); + +// 拉取聊天记录(不返回SDK) +List chatRecords = + msgAuditService.getChatRecords(seq, 1000L, null, null, 1000L); + +for (WxCpChatDatas.WxCpChatData chatData : chatRecords) { + // 解密数据(无需传入SDK) + WxCpChatModel model = msgAuditService.getDecryptChatData(chatData, 2); + + // 下载媒体文件(无需传入SDK) + if ("image".equals(model.getMsgType())) { + String sdkFileId = model.getImage().getSdkFileId(); + msgAuditService.downloadMediaFile(sdkFileId, null, null, 1000L, targetPath); + } +} + +// ✅ 无需手动销毁SDK,框架会自动管理 +``` + +### 完整示例:拉取并处理会话存档 + +```java +import me.chanjar.weixin.cp.api.WxCpService; +import me.chanjar.weixin.cp.api.WxCpMsgAuditService; +import me.chanjar.weixin.cp.bean.msgaudit.WxCpChatDatas; +import me.chanjar.weixin.cp.bean.msgaudit.WxCpChatModel; +import me.chanjar.weixin.cp.constant.WxCpConsts; + +import java.util.List; + +public class MsgAuditExample { + + private final WxCpService wxCpService; + + public void processMessages(long seq) throws Exception { + WxCpMsgAuditService msgAuditService = wxCpService.getMsgAuditService(); + + // 拉取聊天记录 + List chatRecords = + msgAuditService.getChatRecords(seq, 1000L, null, null, 1000L); + + for (WxCpChatDatas.WxCpChatData chatData : chatRecords) { + seq = chatData.getSeq(); + + // 获取明文数据 + String plainText = msgAuditService.getChatRecordPlainText(chatData, 2); + WxCpChatModel model = WxCpChatModel.fromJson(plainText); + + // 处理不同类型的消息 + switch (model.getMsgType()) { + case WxCpConsts.MsgAuditMediaType.TEXT: + processTextMessage(model); + break; + + case WxCpConsts.MsgAuditMediaType.IMAGE: + processImageMessage(model, msgAuditService); + break; + + case WxCpConsts.MsgAuditMediaType.FILE: + processFileMessage(model, msgAuditService); + break; + + default: + // 处理其他类型消息 + break; + } + } + } + + private void processTextMessage(WxCpChatModel model) { + String content = model.getText().getContent(); + System.out.println("文本消息:" + content); + } + + private void processImageMessage(WxCpChatModel model, WxCpMsgAuditService msgAuditService) + throws Exception { + String sdkFileId = model.getImage().getSdkFileId(); + String md5Sum = model.getImage().getMd5Sum(); + String targetPath = "/path/to/save/" + md5Sum + ".jpg"; + + // 下载图片(无需传入SDK) + msgAuditService.downloadMediaFile(sdkFileId, null, null, 1000L, targetPath); + System.out.println("图片已保存:" + targetPath); + } + + private void processFileMessage(WxCpChatModel model, WxCpMsgAuditService msgAuditService) + throws Exception { + String sdkFileId = model.getFile().getSdkFileId(); + String fileName = model.getFile().getFileName(); + String targetPath = "/path/to/save/" + fileName; + + // 下载文件(无需传入SDK) + msgAuditService.downloadMediaFile(sdkFileId, null, null, 1000L, targetPath); + System.out.println("文件已保存:" + targetPath); + } +} +``` + +### 使用Lambda处理媒体文件流 + +新API同样支持使用Lambda表达式处理媒体文件的数据流: + +```java +msgAuditService.downloadMediaFile(sdkFileId, null, null, 1000L, data -> { + try { + // 处理每个数据分片(大文件会分片传输) + // 例如:上传到云存储、写入数据库等 + uploadToCloud(data); + } catch (Exception e) { + e.printStackTrace(); + } +}); +``` + +## 技术实现原理 + +### 引用计数机制 + +新API在内部实现了SDK引用计数机制: + +1. **获取SDK时**:引用计数 +1 +2. **使用完成后**:引用计数 -1 +3. **计数归零时**:SDK被自动释放 + +```java +// 框架内部实现(简化版) +public void downloadMediaFile(String sdkFileId, ...) { + long sdk = initSdk(); // 获取或初始化SDK + configStorage.incrementMsgAuditSdkRefCount(sdk); // 引用计数 +1 + + try { + // 执行实际操作 + getMediaFile(sdk, sdkFileId, ...); + } finally { + // 确保引用计数一定会减少 + configStorage.decrementMsgAuditSdkRefCount(sdk); // 引用计数 -1 + } +} +``` + +### SDK缓存机制 + +SDK初始化后会缓存7200秒(企业微信官方文档规定),避免频繁初始化: + +- **首次调用**:初始化新的SDK +- **7200秒内**:复用缓存的SDK +- **超过7200秒**:重新初始化SDK + +新API的引用计数机制与缓存机制完美配合,确保SDK不会被提前销毁。 + +## 迁移指南 + +### 第一步:使用新API替换旧API + +查找代码中的旧API调用: + +```java +// 查找模式 +getChatDatas( +getDecryptData(.*sdk +getChatPlainText(.*sdk +getMediaFile(.*sdk +Finance.DestroySdk( +``` + +替换为对应的新API(参考前面的对照表)。 + +### 第二步:移除手动SDK管理代码 + +删除所有 `Finance.DestroySdk()` 调用,SDK生命周期由框架自动管理。 + +### 第三步:测试验证 + +1. 在测试环境验证新API功能正常 +2. 观察日志,确认没有SDK相关的错误 +3. 进行压力测试,验证多线程环境下的稳定性 + +## 常见问题 + +### Q1: 旧代码会立即停止工作吗? + +**A:** 不会。旧API被标记为 `@Deprecated`,但仍然可用,只是不推荐继续使用。建议尽快迁移到新API以避免潜在问题。 + +### Q2: 如何知道SDK是否被正确释放? + +**A:** 框架会自动管理SDK生命周期,开发者无需关心。如果需要调试,可以查看配置存储中的引用计数。 + +### Q3: 多线程环境下新API安全吗? + +**A:** 是的。新API使用了引用计数机制,配合 `synchronized` 关键字,确保多线程环境下的安全性。 + +### Q4: 性能会受影响吗? + +**A:** 不会。新API在实现上增加了引用计数的开销,但这是轻量级的操作(原子操作),对性能影响可以忽略不计。SDK缓存机制保持不变。 + +### Q5: 可以同时使用新旧API吗? + +**A:** 技术上可以,但强烈不推荐。混用可能导致SDK生命周期管理混乱,建议统一使用新API。 + +## 相关链接 + +- [企业微信会话存档官方文档](https://developer.work.weixin.qq.com/document/path/91360) +- [WxJava GitHub 仓库](https://github.com/binarywang/WxJava) +- [问题反馈](https://github.com/binarywang/WxJava/issues) + +## 版本要求 + +- **最低版本**: 4.8.0 +- **推荐版本**: 最新版本 + +## 反馈与支持 + +如果在使用过程中遇到问题,请: + +1. 查看本文档的常见问题部分 +2. 在 GitHub 上提交 Issue +3. 加入微信群获取社区支持 + +--- + +**最后更新时间**: 2026-01-14 diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpMsgAuditService.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpMsgAuditService.java index 221caf2e70..b754e32b7e 100644 --- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpMsgAuditService.java +++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpMsgAuditService.java @@ -28,9 +28,26 @@ public interface WxCpMsgAuditService { * @param timeout 超时时间,根据实际需要填写 * @return 返回是否调用成功 chat datas * @throws Exception the exception + * @deprecated 请使用 {@link #getChatRecords(long, long, String, String, long)} 代替, + * 该方法会将SDK暴露给调用方,容易导致SDK生命周期管理混乱,引发JVM崩溃 */ + @Deprecated WxCpChatDatas getChatDatas(long seq, @NonNull long limit, String proxy, String passwd, @NonNull long timeout) throws Exception; + /** + * 拉取聊天记录函数(推荐使用) + * 该方法不会将SDK暴露给调用方,SDK生命周期由框架自动管理,更加安全 + * + * @param seq 从指定的seq开始拉取消息,注意的是返回的消息从seq+1开始返回,seq为之前接口返回的最大seq值。首次使用请使用seq:0 + * @param limit 一次拉取的消息条数,最大值1000条,超过1000条会返回错误 + * @param proxy 使用代理的请求,需要传入代理的链接。如:socks5://10.0.0.1:8081 或者 http://10.0.0.1:8081,如果没有传null + * @param passwd 代理账号密码,需要传入代理的账号密码。如 user_name:passwd_123,如果没有传null + * @param timeout 超时时间,根据实际需要填写 + * @return 返回聊天记录列表,不包含SDK信息 + * @throws Exception the exception + */ + List getChatRecords(long seq, @NonNull long limit, String proxy, String passwd, @NonNull long timeout) throws Exception; + /** * 获取解密的聊天数据Model * @@ -39,10 +56,24 @@ public interface WxCpMsgAuditService { * @param pkcs1 使用什么方式进行解密,1代表使用PKCS1进行解密,2代表PKCS8进行解密 ... * @return 解密后的聊天数据 decrypt data * @throws Exception the exception + * @deprecated 请使用 {@link #getDecryptChatData(WxCpChatDatas.WxCpChatData, Integer)} 代替, + * 该方法需要传入SDK,容易导致SDK生命周期管理混乱,引发JVM崩溃 */ + @Deprecated WxCpChatModel getDecryptData(@NonNull long sdk, @NonNull WxCpChatDatas.WxCpChatData chatData, @NonNull Integer pkcs1) throws Exception; + /** + * 获取解密的聊天数据Model(推荐使用) + * 该方法不需要传入SDK,SDK由框架自动管理,更加安全 + * + * @param chatData 聊天数据 + * @param pkcs1 使用什么方式进行解密,1代表使用PKCS1进行解密,2代表PKCS8进行解密 ... + * @return 解密后的聊天数据 + * @throws Exception the exception + */ + WxCpChatModel getDecryptChatData(@NonNull WxCpChatDatas.WxCpChatData chatData, @NonNull Integer pkcs1) throws Exception; + /** * 获取解密的聊天数据明文 * @@ -51,9 +82,23 @@ WxCpChatModel getDecryptData(@NonNull long sdk, @NonNull WxCpChatDatas.WxCpChatD * @param pkcs1 使用什么方式进行解密,1代表使用PKCS1进行解密,2代表PKCS8进行解密 ... * @return 解密后的明文 chat plain text * @throws Exception the exception + * @deprecated 请使用 {@link #getChatRecordPlainText(WxCpChatDatas.WxCpChatData, Integer)} 代替, + * 该方法需要传入SDK,容易导致SDK生命周期管理混乱,引发JVM崩溃 */ + @Deprecated String getChatPlainText(@NonNull long sdk, @NonNull WxCpChatDatas.WxCpChatData chatData, @NonNull Integer pkcs1) throws Exception; + /** + * 获取解密的聊天数据明文(推荐使用) + * 该方法不需要传入SDK,SDK由框架自动管理,更加安全 + * + * @param chatData 聊天数据 + * @param pkcs1 使用什么方式进行解密,1代表使用PKCS1进行解密,2代表PKCS8进行解密 ... + * @return 解密后的明文 + * @throws Exception the exception + */ + String getChatRecordPlainText(@NonNull WxCpChatDatas.WxCpChatData chatData, @NonNull Integer pkcs1) throws Exception; + /** * 获取媒体文件 * 针对图片、文件等媒体数据,提供sdk接口拉取数据内容。 @@ -69,10 +114,32 @@ WxCpChatModel getDecryptData(@NonNull long sdk, @NonNull WxCpChatDatas.WxCpChatD * @param timeout 超时时间,分片数据需累加到文件存储。单次最大返回512K字节,如果文件比较大,自行设置长一点,比如timeout=10000 * @param targetFilePath 目标文件绝对路径+实际文件名,比如:/usr/local/file/20220114/474f866b39d10718810d55262af82662.gif * @throws WxErrorException the wx error exception + * @deprecated 请使用 {@link #downloadMediaFile(String, String, String, long, String)} 代替, + * 该方法需要传入SDK,容易导致SDK生命周期管理混乱,引发JVM崩溃 */ + @Deprecated void getMediaFile(@NonNull long sdk, @NonNull String sdkfileid, String proxy, String passwd, @NonNull long timeout, @NonNull String targetFilePath) throws WxErrorException; + /** + * 获取媒体文件(推荐使用) + * 该方法不需要传入SDK,SDK由框架自动管理,更加安全 + * 针对图片、文件等媒体数据,提供sdk接口拉取数据内容。 + *

+ * 注意: + * 根据上面返回的文件类型,拼接好存放文件的绝对路径即可。此时绝对路径写入文件流,来达到获取媒体文件的目的。 + * 详情可以看官方文档,亦可阅读此接口源码。 + * + * @param sdkfileid 消息体内容中的sdkfileid信息 + * @param proxy 使用代理的请求,需要传入代理的链接。如:socks5://10.0.0.1:8081 或者 http://10.0.0.1:8081,如果没有传null + * @param passwd 代理账号密码,需要传入代理的账号密码。如 user_name:passwd_123,如果没有传null + * @param timeout 超时时间,分片数据需累加到文件存储。单次最大返回512K字节,如果文件比较大,自行设置长一点,比如timeout=10000 + * @param targetFilePath 目标文件绝对路径+实际文件名,比如:/usr/local/file/20220114/474f866b39d10718810d55262af82662.gif + * @throws WxErrorException the wx error exception + */ + void downloadMediaFile(@NonNull String sdkfileid, String proxy, String passwd, @NonNull long timeout, + @NonNull String targetFilePath) throws WxErrorException; + /** * 获取媒体文件 传入一个lambda,each所有的数据分片byte[],更加灵活 * 针对图片、文件等媒体数据,提供sdk接口拉取数据内容。 @@ -85,10 +152,29 @@ void getMediaFile(@NonNull long sdk, @NonNull String sdkfileid, String proxy, St * @param timeout 超时时间,分片数据需累加到文件存储。单次最大返回512K字节,如果文件比较大,自行设置长一点,比如timeout=10000 * @param action 传入一个lambda,each所有的数据分片 * @throws WxErrorException the wx error exception + * @deprecated 请使用 {@link #downloadMediaFile(String, String, String, long, Consumer)} 代替, + * 该方法需要传入SDK,容易导致SDK生命周期管理混乱,引发JVM崩溃 */ + @Deprecated void getMediaFile(@NonNull long sdk, @NonNull String sdkfileid, String proxy, String passwd, @NonNull long timeout, @NonNull Consumer action) throws WxErrorException; + /** + * 获取媒体文件 传入一个lambda,each所有的数据分片byte[],更加灵活(推荐使用) + * 该方法不需要传入SDK,SDK由框架自动管理,更加安全 + * 针对图片、文件等媒体数据,提供sdk接口拉取数据内容。 + * 详情可以看官方文档,亦可阅读此接口源码。 + * + * @param sdkfileid 消息体内容中的sdkfileid信息 + * @param proxy 使用代理的请求,需要传入代理的链接。如:socks5://10.0.0.1:8081 或者 http://10.0.0.1:8081,如果没有传null + * @param passwd 代理账号密码,需要传入代理的账号密码。如 user_name:passwd_123,如果没有传null + * @param timeout 超时时间,分片数据需累加到文件存储。单次最大返回512K字节,如果文件比较大,自行设置长一点,比如timeout=10000 + * @param action 传入一个lambda,each所有的数据分片 + * @throws WxErrorException the wx error exception + */ + void downloadMediaFile(@NonNull String sdkfileid, String proxy, String passwd, @NonNull long timeout, + @NonNull Consumer action) throws WxErrorException; + /** * 获取会话内容存档开启成员列表 * 企业可通过此接口,获取企业开启会话内容存档的成员列表 diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpMsgAuditServiceImpl.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpMsgAuditServiceImpl.java index cdf559ad7a..63dc7ac007 100644 --- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpMsgAuditServiceImpl.java +++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpMsgAuditServiceImpl.java @@ -20,6 +20,7 @@ import java.io.FileOutputStream; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.LinkedList; import java.util.List; import java.util.function.Consumer; @@ -137,6 +138,49 @@ private synchronized long initSdk() throws WxErrorException { return sdk; } + /** + * 获取SDK并增加引用计数(原子操作) + * 如果SDK未初始化或已过期,会自动初始化 + * + * @return sdk id + * @throws WxErrorException 初始化失败时抛出异常 + */ + private long acquireSdk() throws WxErrorException { + WxCpConfigStorage configStorage = cpService.getWxCpConfigStorage(); + + // 尝试获取现有的有效SDK并增加引用计数(原子操作) + long sdk = configStorage.acquireMsgAuditSdk(); + + if (sdk > 0) { + // 成功获取到有效的SDK + return sdk; + } + + // SDK未初始化或已过期,需要初始化 + // initSdk()方法已经是synchronized的,确保只有一个线程初始化 + sdk = this.initSdk(); + + // 初始化后增加引用计数 + int refCount = configStorage.incrementMsgAuditSdkRefCount(sdk); + if (refCount < 0) { + // SDK已经被替换,需要重新获取 + return acquireSdk(); + } + + return sdk; + } + + /** + * 释放SDK引用计数 + * + * @param sdk sdk id + */ + private void releaseSdk(long sdk) { + if (sdk > 0) { + cpService.getWxCpConfigStorage().releaseMsgAuditSdk(sdk); + } + } + @Override public WxCpChatModel getDecryptData(@NonNull long sdk, @NonNull WxCpChatDatas.WxCpChatData chatData, @NonNull Integer pkcs1) throws Exception { @@ -280,4 +324,127 @@ public WxCpAgreeInfo checkSingleAgree(@NonNull WxCpCheckAgreeRequest checkAgreeR return WxCpAgreeInfo.fromJson(responseContent); } + @Override + public List getChatRecords(long seq, @NonNull long limit, String proxy, String passwd, + @NonNull long timeout) throws Exception { + // 获取SDK并自动增加引用计数(原子操作) + long sdk = this.acquireSdk(); + + try { + long slice = Finance.NewSlice(); + long ret = Finance.GetChatData(sdk, seq, limit, proxy, passwd, timeout, slice); + if (ret != 0) { + Finance.FreeSlice(slice); + throw new WxErrorException("getchatdata err ret " + ret); + } + + // 拉取会话存档 + String content = Finance.GetContentFromSlice(slice); + Finance.FreeSlice(slice); + WxCpChatDatas chatDatas = WxCpChatDatas.fromJson(content); + if (chatDatas.getErrCode().intValue() != 0) { + throw new WxErrorException(chatDatas.toJson()); + } + + List chatDataList = chatDatas.getChatData(); + return chatDataList != null ? chatDataList : Collections.emptyList(); + } finally { + // 释放SDK引用计数(原子操作) + this.releaseSdk(sdk); + } + } + + @Override + public WxCpChatModel getDecryptChatData(@NonNull WxCpChatDatas.WxCpChatData chatData, + @NonNull Integer pkcs1) throws Exception { + // 获取SDK并自动增加引用计数(原子操作) + long sdk = this.acquireSdk(); + + try { + String plainText = this.decryptChatData(sdk, chatData, pkcs1); + return WxCpChatModel.fromJson(plainText); + } finally { + // 释放SDK引用计数(原子操作) + this.releaseSdk(sdk); + } + } + + @Override + public String getChatRecordPlainText(@NonNull WxCpChatDatas.WxCpChatData chatData, + @NonNull Integer pkcs1) throws Exception { + // 获取SDK并自动增加引用计数(原子操作) + long sdk = this.acquireSdk(); + + try { + return this.decryptChatData(sdk, chatData, pkcs1); + } finally { + // 释放SDK引用计数(原子操作) + this.releaseSdk(sdk); + } + } + + @Override + public void downloadMediaFile(@NonNull String sdkfileid, String proxy, String passwd, @NonNull long timeout, + @NonNull String targetFilePath) throws WxErrorException { + // 获取SDK并自动增加引用计数(原子操作) + long sdk; + try { + sdk = this.acquireSdk(); + } catch (Exception e) { + throw new WxErrorException(e); + } + + // 使用AtomicReference捕获Lambda中的异常,以便在执行完后抛出 + final java.util.concurrent.atomic.AtomicReference exceptionHolder = new java.util.concurrent.atomic.AtomicReference<>(); + + try { + File targetFile = new File(targetFilePath); + if (!targetFile.getParentFile().exists()) { + targetFile.getParentFile().mkdirs(); + } + this.getMediaFile(sdk, sdkfileid, proxy, passwd, timeout, i -> { + // 如果之前已经发生异常,不再继续处理 + if (exceptionHolder.get() != null) { + return; + } + try { + // 大于512k的文件会分片拉取,此处需要使用追加写,避免后面的分片覆盖之前的数据。 + FileOutputStream outputStream = new FileOutputStream(targetFile, true); + outputStream.write(i); + outputStream.close(); + } catch (Exception e) { + exceptionHolder.set(e); + } + }); + + // 检查是否发生异常,如果有则抛出 + Exception caughtException = exceptionHolder.get(); + if (caughtException != null) { + throw new WxErrorException(caughtException); + } + } finally { + // 释放SDK引用计数(原子操作) + this.releaseSdk(sdk); + } + } + + @Override + public void downloadMediaFile(@NonNull String sdkfileid, String proxy, String passwd, @NonNull long timeout, + @NonNull Consumer action) throws WxErrorException { + // 获取SDK并自动增加引用计数(原子操作) + long sdk; + try { + sdk = this.acquireSdk(); + } catch (Exception e) { + throw new WxErrorException(e); + } + + try { + this.getMediaFile(sdk, sdkfileid, proxy, passwd, timeout, action); + } finally { + // 释放SDK引用计数(原子操作) + this.releaseSdk(sdk); + } + } + } diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/WxCpConfigStorage.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/WxCpConfigStorage.java index 8b968e540c..fd96d76c30 100644 --- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/WxCpConfigStorage.java +++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/WxCpConfigStorage.java @@ -292,4 +292,47 @@ public interface WxCpConfigStorage { * 使会话存档SDK过期 */ void expireMsgAuditSdk(); + + /** + * 增加会话存档SDK的引用计数 + * 用于支持多线程安全的SDK生命周期管理 + * + * @param sdk sdk id + * @return 增加后的引用计数,如果SDK不匹配返回-1 + */ + int incrementMsgAuditSdkRefCount(long sdk); + + /** + * 减少会话存档SDK的引用计数 + * 当引用计数降为0时,自动销毁SDK以释放资源 + * + * @param sdk sdk id + * @return 减少后的引用计数,如果返回0表示SDK已被销毁,如果SDK不匹配返回-1 + */ + int decrementMsgAuditSdkRefCount(long sdk); + + /** + * 获取会话存档SDK的引用计数 + * + * @param sdk sdk id + * @return 当前引用计数,如果SDK不匹配返回-1 + */ + int getMsgAuditSdkRefCount(long sdk); + + /** + * 获取当前SDK并增加引用计数(原子操作) + * 如果SDK未初始化或已过期,返回0而不增加引用计数 + * 此方法用于在获取SDK后立即增加引用计数,避免并发问题 + * + * @return 当前有效的SDK id并已增加引用计数,如果SDK无效返回0 + */ + long acquireMsgAuditSdk(); + + /** + * 减少SDK引用计数并在必要时释放(原子操作) + * 此方法确保引用计数递减和SDK检查在同一个同步块内完成 + * + * @param sdk sdk id + */ + void releaseMsgAuditSdk(long sdk); } diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/impl/WxCpDefaultConfigImpl.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/impl/WxCpDefaultConfigImpl.java index 4bf13f24ea..f8047e846f 100644 --- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/impl/WxCpDefaultConfigImpl.java +++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/impl/WxCpDefaultConfigImpl.java @@ -1,5 +1,6 @@ package me.chanjar.weixin.cp.config.impl; +import com.tencent.wework.Finance; import me.chanjar.weixin.common.bean.WxAccessToken; import me.chanjar.weixin.common.util.http.apache.ApacheHttpClientBuilder; import me.chanjar.weixin.cp.config.WxCpConfigStorage; @@ -54,6 +55,10 @@ public class WxCpDefaultConfigImpl implements WxCpConfigStorage, Serializable { */ private volatile long msgAuditSdk; private volatile long msgAuditSdkExpiresTime; + /** + * 会话存档SDK引用计数,用于多线程安全的生命周期管理 + */ + private volatile int msgAuditSdkRefCount; private volatile String oauth2redirectUri; private volatile String httpProxyHost; private volatile int httpProxyPort; @@ -470,13 +475,77 @@ public boolean isMsgAuditSdkExpired() { @Override public synchronized void updateMsgAuditSdk(long sdk, int expiresInSeconds) { + // 如果有旧的SDK且不同于新的SDK,需要销毁旧的SDK + if (this.msgAuditSdk > 0 && this.msgAuditSdk != sdk) { + // 无论旧SDK是否仍有引用,都需要销毁它以避免资源泄漏 + // 如果有飞行中的请求使用旧SDK,这些请求可能会失败,但这比资源泄漏更安全 + Finance.DestroySdk(this.msgAuditSdk); + } this.msgAuditSdk = sdk; // 预留200秒的时间 this.msgAuditSdkExpiresTime = System.currentTimeMillis() + (expiresInSeconds - 200) * 1000L; + // 重置引用计数,因为这是一个全新的SDK + this.msgAuditSdkRefCount = 0; } @Override public void expireMsgAuditSdk() { this.msgAuditSdkExpiresTime = 0; } + + @Override + public synchronized int incrementMsgAuditSdkRefCount(long sdk) { + if (this.msgAuditSdk == sdk && sdk > 0) { + return ++this.msgAuditSdkRefCount; + } + return -1; // SDK不匹配,返回-1表示错误 + } + + @Override + public synchronized int decrementMsgAuditSdkRefCount(long sdk) { + if (this.msgAuditSdk == sdk && this.msgAuditSdkRefCount > 0) { + int newCount = --this.msgAuditSdkRefCount; + // 当引用计数降为0时,自动销毁SDK以释放资源 + // 再次检查SDK是否仍然是当前缓存的SDK(防止并发重新初始化) + if (newCount == 0 && this.msgAuditSdk == sdk) { + Finance.DestroySdk(sdk); + this.msgAuditSdk = 0; + this.msgAuditSdkExpiresTime = 0; + } + return newCount; + } + return -1; // SDK不匹配或引用计数已为0,返回-1表示错误 + } + + @Override + public synchronized int getMsgAuditSdkRefCount(long sdk) { + if (this.msgAuditSdk == sdk && sdk > 0) { + return this.msgAuditSdkRefCount; + } + return -1; // SDK不匹配,返回-1表示错误 + } + + @Override + public synchronized long acquireMsgAuditSdk() { + // 检查SDK是否有效(已初始化且未过期) + if (this.msgAuditSdk > 0 && !isMsgAuditSdkExpired()) { + this.msgAuditSdkRefCount++; + return this.msgAuditSdk; + } + return 0; // SDK未初始化或已过期 + } + + @Override + public synchronized void releaseMsgAuditSdk(long sdk) { + if (this.msgAuditSdk == sdk && this.msgAuditSdkRefCount > 0) { + int newCount = --this.msgAuditSdkRefCount; + // 当引用计数降为0时,自动销毁SDK以释放资源 + // 再次检查SDK是否仍然是当前缓存的SDK(防止并发重新初始化) + if (newCount == 0 && this.msgAuditSdk == sdk) { + Finance.DestroySdk(sdk); + this.msgAuditSdk = 0; + this.msgAuditSdkExpiresTime = 0; + } + } + } } diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/impl/WxCpRedisConfigImpl.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/impl/WxCpRedisConfigImpl.java index 49cd7c4559..48e2445506 100644 --- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/impl/WxCpRedisConfigImpl.java +++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/impl/WxCpRedisConfigImpl.java @@ -1,5 +1,6 @@ package me.chanjar.weixin.cp.config.impl; +import com.tencent.wework.Finance; import me.chanjar.weixin.common.bean.WxAccessToken; import me.chanjar.weixin.common.util.http.apache.ApacheHttpClientBuilder; import me.chanjar.weixin.cp.config.WxCpConfigStorage; @@ -55,6 +56,10 @@ public class WxCpRedisConfigImpl implements WxCpConfigStorage { */ private volatile long msgAuditSdk; private volatile long msgAuditSdkExpiresTime; + /** + * 会话存档SDK引用计数,用于多线程安全的生命周期管理 + */ + private volatile int msgAuditSdkRefCount; /** * Instantiates a new Wx cp redis config. @@ -488,13 +493,77 @@ public boolean isMsgAuditSdkExpired() { @Override public synchronized void updateMsgAuditSdk(long sdk, int expiresInSeconds) { + // 如果有旧的SDK且不同于新的SDK,需要销毁旧的SDK + if (this.msgAuditSdk > 0 && this.msgAuditSdk != sdk) { + // 无论旧SDK是否仍有引用,都需要销毁它以避免资源泄漏 + // 如果有飞行中的请求使用旧SDK,这些请求可能会失败,但这比资源泄漏更安全 + Finance.DestroySdk(this.msgAuditSdk); + } this.msgAuditSdk = sdk; // 预留200秒的时间 this.msgAuditSdkExpiresTime = System.currentTimeMillis() + (expiresInSeconds - 200) * 1000L; + // 重置引用计数,因为这是一个全新的SDK + this.msgAuditSdkRefCount = 0; } @Override public void expireMsgAuditSdk() { this.msgAuditSdkExpiresTime = 0; } + + @Override + public synchronized int incrementMsgAuditSdkRefCount(long sdk) { + if (this.msgAuditSdk == sdk && sdk > 0) { + return ++this.msgAuditSdkRefCount; + } + return -1; // SDK不匹配,返回-1表示错误 + } + + @Override + public synchronized int decrementMsgAuditSdkRefCount(long sdk) { + if (this.msgAuditSdk == sdk && this.msgAuditSdkRefCount > 0) { + int newCount = --this.msgAuditSdkRefCount; + // 当引用计数降为0时,自动销毁SDK以释放资源 + // 再次检查SDK是否仍然是当前缓存的SDK(防止并发重新初始化) + if (newCount == 0 && this.msgAuditSdk == sdk) { + Finance.DestroySdk(sdk); + this.msgAuditSdk = 0; + this.msgAuditSdkExpiresTime = 0; + } + return newCount; + } + return -1; // SDK不匹配或引用计数已为0,返回-1表示错误 + } + + @Override + public synchronized int getMsgAuditSdkRefCount(long sdk) { + if (this.msgAuditSdk == sdk && sdk > 0) { + return this.msgAuditSdkRefCount; + } + return -1; // SDK不匹配,返回-1表示错误 + } + + @Override + public synchronized long acquireMsgAuditSdk() { + // 检查SDK是否有效(已初始化且未过期) + if (this.msgAuditSdk > 0 && !isMsgAuditSdkExpired()) { + this.msgAuditSdkRefCount++; + return this.msgAuditSdk; + } + return 0; // SDK未初始化或已过期 + } + + @Override + public synchronized void releaseMsgAuditSdk(long sdk) { + if (this.msgAuditSdk == sdk && this.msgAuditSdkRefCount > 0) { + int newCount = --this.msgAuditSdkRefCount; + // 当引用计数降为0时,自动销毁SDK以释放资源 + // 再次检查SDK是否仍然是当前缓存的SDK(防止并发重新初始化) + if (newCount == 0 && this.msgAuditSdk == sdk) { + Finance.DestroySdk(sdk); + this.msgAuditSdk = 0; + this.msgAuditSdkExpiresTime = 0; + } + } + } } diff --git a/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/WxCpMsgAuditTest.java b/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/WxCpMsgAuditTest.java index ec7362ed5d..a1ea40f3fb 100644 --- a/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/WxCpMsgAuditTest.java +++ b/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/WxCpMsgAuditTest.java @@ -754,6 +754,84 @@ public void testGetMediaFile() throws Exception { Finance.DestroySdk(chatDatas.getSdk()); } + /** + * 测试新的安全API方法(推荐使用) + * 这些方法不需要手动管理SDK生命周期,更加安全 + */ + @Test + public void testNewSafeApi() throws Exception { + WxCpMsgAuditService msgAuditService = cpService.getMsgAuditService(); + + // 测试新的getChatRecords方法 - 不暴露SDK + List chatRecords = msgAuditService.getChatRecords(0L, 10L, null, null, 1000L); + log.info("获取到 {} 条聊天记录", chatRecords.size()); + + for (WxCpChatDatas.WxCpChatData chatData : chatRecords) { + // 测试新的getDecryptChatData方法 - 不需要传入SDK + WxCpChatModel decryptData = msgAuditService.getDecryptChatData(chatData, 2); + log.info("解密数据:{}", decryptData.toJson()); + + // 测试新的getChatRecordPlainText方法 - 不需要传入SDK + String plainText = msgAuditService.getChatRecordPlainText(chatData, 2); + log.info("明文数据:{}", plainText); + + // 如果是媒体消息,测试新的downloadMediaFile方法 + String msgType = decryptData.getMsgType(); + if ("image".equals(msgType) || "voice".equals(msgType) || "video".equals(msgType) || "file".equals(msgType)) { + String suffix = ""; + String md5Sum = ""; + String sdkFileId = ""; + + switch (msgType) { + case "image": + suffix = ".jpg"; + md5Sum = decryptData.getImage().getMd5Sum(); + sdkFileId = decryptData.getImage().getSdkFileId(); + break; + case "voice": + suffix = ".amr"; + md5Sum = decryptData.getVoice().getMd5Sum(); + sdkFileId = decryptData.getVoice().getSdkFileId(); + break; + case "video": + suffix = ".mp4"; + md5Sum = decryptData.getVideo().getMd5Sum(); + sdkFileId = decryptData.getVideo().getSdkFileId(); + break; + case "file": + md5Sum = decryptData.getFile().getMd5Sum(); + suffix = "." + decryptData.getFile().getFileExt(); + sdkFileId = decryptData.getFile().getSdkFileId(); + break; + default: + // 未知消息类型,跳过处理 + continue; + } + + // 测试新的downloadMediaFile方法 - 不需要传入SDK + String path = Thread.currentThread().getContextClassLoader().getResource("").getPath(); + String targetPath = path + "testfile-new/" + md5Sum + suffix; + File file = new File(targetPath); + + // 确保父目录存在 + if (!file.getParentFile().exists()) { + file.getParentFile().mkdirs(); + } + + // 删除已存在的文件 + if (file.exists()) { + file.delete(); + } + + // 使用新的API下载媒体文件 + msgAuditService.downloadMediaFile(sdkFileId, null, null, 1000L, targetPath); + log.info("媒体文件下载成功:{}", targetPath); + } + } + + // 注意:使用新API无需手动调用 Finance.DestroySdk(),SDK由框架自动管理 + } + // 测试Uint64类型 public static void main(String[] args){ /* From 12a9f83b98daaa1a4edb28525e0d5e25935ae846 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:33:04 +0800 Subject: [PATCH 2/3] =?UTF-8?q?:new:=20#3842=20=E3=80=90=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E6=94=AF=E4=BB=98=E3=80=91=E6=B7=BB=E5=8A=A0=20wx-java-pay-mul?= =?UTF-8?q?ti-spring-boot-starter=20=E6=A8=A1=E5=9D=97=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=A4=9A=E5=85=AC=E4=BC=97=E5=8F=B7=E5=85=B3=E8=81=94=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- spring-boot-starters/pom.xml | 1 + .../README.md | 316 ++++++++++++++++++ .../pom.xml | 53 +++ .../config/WxPayMultiAutoConfiguration.java | 38 +++ .../pay/properties/WxPayMultiProperties.java | 27 ++ .../pay/properties/WxPaySingleProperties.java | 124 +++++++ .../pay/service/WxPayMultiServices.java | 33 ++ .../pay/service/WxPayMultiServicesImpl.java | 92 +++++ .../main/resources/META-INF/spring.factories | 2 + ...ot.autoconfigure.AutoConfiguration.imports | 2 + .../wxjava/pay/WxPayMultiServicesTest.java | 104 ++++++ .../wxjava/pay/example/WxPayMultiExample.java | 249 ++++++++++++++ 12 files changed, 1041 insertions(+) create mode 100644 spring-boot-starters/wx-java-pay-multi-spring-boot-starter/README.md create mode 100644 spring-boot-starters/wx-java-pay-multi-spring-boot-starter/pom.xml create mode 100644 spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/config/WxPayMultiAutoConfiguration.java create mode 100644 spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/properties/WxPayMultiProperties.java create mode 100644 spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/properties/WxPaySingleProperties.java create mode 100644 spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/service/WxPayMultiServices.java create mode 100644 spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/service/WxPayMultiServicesImpl.java create mode 100644 spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/resources/META-INF/spring.factories create mode 100644 spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/test/java/com/binarywang/spring/starter/wxjava/pay/WxPayMultiServicesTest.java create mode 100644 spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/test/java/com/binarywang/spring/starter/wxjava/pay/example/WxPayMultiExample.java diff --git a/spring-boot-starters/pom.xml b/spring-boot-starters/pom.xml index e145e5fd66..8b000ff8c2 100644 --- a/spring-boot-starters/pom.xml +++ b/spring-boot-starters/pom.xml @@ -23,6 +23,7 @@ wx-java-mp-multi-spring-boot-starter wx-java-mp-spring-boot-starter wx-java-pay-spring-boot-starter + wx-java-pay-multi-spring-boot-starter wx-java-open-multi-spring-boot-starter wx-java-open-spring-boot-starter wx-java-qidian-spring-boot-starter diff --git a/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/README.md b/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/README.md new file mode 100644 index 0000000000..d8d41b7de8 --- /dev/null +++ b/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/README.md @@ -0,0 +1,316 @@ +# wx-java-pay-multi-spring-boot-starter + +## 快速开始 + +本starter支持微信支付多公众号关联配置,适用于以下场景: +- 一个服务商需要为多个公众号提供支付服务 +- 一个系统需要支持多个公众号的支付业务 +- 需要根据不同的appId动态切换支付配置 + +## 使用说明 + +### 1. 引入依赖 + +在项目的 `pom.xml` 中添加以下依赖: + +```xml + + com.github.binarywang + wx-java-pay-multi-spring-boot-starter + ${version} + +``` + +### 2. 添加配置 + +在 `application.yml` 或 `application.properties` 中配置多个公众号的支付信息。 + +#### 配置示例(application.yml) + +##### V2版本配置 +```yml +wx: + pay: + configs: + # 配置1 - 可以使用appId作为key + wx1234567890abcdef: + appId: wx1234567890abcdef + mchId: 1234567890 + mchKey: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + keyPath: classpath:cert/app1/apiclient_cert.p12 + notifyUrl: https://example.com/pay/notify + # 配置2 - 也可以使用自定义标识作为key + config2: + appId: wx9876543210fedcba + mchId: 9876543210 + mchKey: yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy + keyPath: classpath:cert/app2/apiclient_cert.p12 + notifyUrl: https://example.com/pay/notify +``` + +##### V3版本配置 +```yml +wx: + pay: + configs: + # 公众号1配置 + wx1234567890abcdef: + appId: wx1234567890abcdef + mchId: 1234567890 + apiV3Key: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + certSerialNo: 62C6CEAA360BCxxxxxxxxxxxxxxx + privateKeyPath: classpath:cert/app1/apiclient_key.pem + privateCertPath: classpath:cert/app1/apiclient_cert.pem + notifyUrl: https://example.com/pay/notify + # 公众号2配置 + wx9876543210fedcba: + appId: wx9876543210fedcba + mchId: 9876543210 + apiV3Key: yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy + certSerialNo: 73D7DFBB471CDxxxxxxxxxxxxxxx + privateKeyPath: classpath:cert/app2/apiclient_key.pem + privateCertPath: classpath:cert/app2/apiclient_cert.pem + notifyUrl: https://example.com/pay/notify +``` + +##### V3服务商版本配置 +```yml +wx: + pay: + configs: + # 服务商为公众号1提供服务 + config1: + appId: wxe97b2x9c2b3d # 服务商appId + mchId: 16486610 # 服务商商户号 + subAppId: wx118cexxe3c07679 # 子商户公众号appId + subMchId: 16496705 # 子商户号 + apiV3Key: Dc1DBwSc094jAKDGR5aqqb7PTHr + privateKeyPath: classpath:cert/apiclient_key.pem + privateCertPath: classpath:cert/apiclient_cert.pem + # 服务商为公众号2提供服务 + config2: + appId: wxe97b2x9c2b3d # 服务商appId(可以相同) + mchId: 16486610 # 服务商商户号(可以相同) + subAppId: wx228dexxf4d18890 # 子商户公众号appId(不同) + subMchId: 16496706 # 子商户号(不同) + apiV3Key: Dc1DBwSc094jAKDGR5aqqb7PTHr + privateKeyPath: classpath:cert/apiclient_key.pem + privateCertPath: classpath:cert/apiclient_cert.pem +``` + +#### 配置示例(application.properties) + +```properties +# 公众号1配置 +wx.pay.configs.wx1234567890abcdef.app-id=wx1234567890abcdef +wx.pay.configs.wx1234567890abcdef.mch-id=1234567890 +wx.pay.configs.wx1234567890abcdef.apiv3-key=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +wx.pay.configs.wx1234567890abcdef.cert-serial-no=62C6CEAA360BCxxxxxxxxxxxxxxx +wx.pay.configs.wx1234567890abcdef.private-key-path=classpath:cert/app1/apiclient_key.pem +wx.pay.configs.wx1234567890abcdef.private-cert-path=classpath:cert/app1/apiclient_cert.pem +wx.pay.configs.wx1234567890abcdef.notify-url=https://example.com/pay/notify + +# 公众号2配置 +wx.pay.configs.wx9876543210fedcba.app-id=wx9876543210fedcba +wx.pay.configs.wx9876543210fedcba.mch-id=9876543210 +wx.pay.configs.wx9876543210fedcba.apiv3-key=yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy +wx.pay.configs.wx9876543210fedcba.cert-serial-no=73D7DFBB471CDxxxxxxxxxxxxxxx +wx.pay.configs.wx9876543210fedcba.private-key-path=classpath:cert/app2/apiclient_key.pem +wx.pay.configs.wx9876543210fedcba.private-cert-path=classpath:cert/app2/apiclient_cert.pem +wx.pay.configs.wx9876543210fedcba.notify-url=https://example.com/pay/notify +``` + +### 3. 使用示例 + +自动注入的类型:`WxPayMultiServices` + +```java +import com.binarywang.spring.starter.wxjava.pay.service.WxPayMultiServices; +import com.github.binarywang.wxpay.bean.request.WxPayUnifiedOrderV3Request; +import com.github.binarywang.wxpay.bean.result.WxPayOrderQueryV3Result; +import com.github.binarywang.wxpay.bean.result.WxPayUnifiedOrderV3Result; +import com.github.binarywang.wxpay.bean.result.enums.TradeTypeEnum; +import com.github.binarywang.wxpay.service.WxPayService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class PayService { + @Autowired + private WxPayMultiServices wxPayMultiServices; + + /** + * 为不同的公众号创建支付订单 + * + * @param configKey 配置标识(即 wx.pay.configs.<configKey> 中的 key,可以是 appId 或自定义标识) + */ + public void createOrder(String configKey, String openId, Integer totalFee, String body) throws Exception { + // 根据配置标识获取对应的WxPayService + WxPayService wxPayService = wxPayMultiServices.getWxPayService(configKey); + + if (wxPayService == null) { + throw new IllegalArgumentException("未找到配置标识对应的微信支付配置: " + configKey); + } + + // 使用WxPayService进行支付操作 + WxPayUnifiedOrderV3Request request = new WxPayUnifiedOrderV3Request(); + request.setOutTradeNo(generateOutTradeNo()); + request.setDescription(body); + request.setAmount(new WxPayUnifiedOrderV3Request.Amount().setTotal(totalFee)); + request.setPayer(new WxPayUnifiedOrderV3Request.Payer().setOpenid(openId)); + request.setNotifyUrl(wxPayService.getConfig().getNotifyUrl()); + + // V3统一下单 + WxPayUnifiedOrderV3Result.JsapiResult result = + wxPayService.createOrderV3(TradeTypeEnum.JSAPI, request); + + // 返回给前端用于调起支付 + // ... + } + + /** + * 服务商模式示例 + */ + public void serviceProviderExample(String configKey) throws Exception { + // 使用配置标识获取WxPayService + WxPayService wxPayService = wxPayMultiServices.getWxPayService(configKey); + + if (wxPayService == null) { + throw new IllegalArgumentException("未找到配置: " + configKey); + } + + // 获取子商户的配置信息 + String subAppId = wxPayService.getConfig().getSubAppId(); + String subMchId = wxPayService.getConfig().getSubMchId(); + + // 进行支付操作 + // ... + } + + /** + * 查询订单示例 + * + * @param configKey 配置标识(即 wx.pay.configs.<configKey> 中的 key) + */ + public void queryOrder(String configKey, String outTradeNo) throws Exception { + WxPayService wxPayService = wxPayMultiServices.getWxPayService(configKey); + + if (wxPayService == null) { + throw new IllegalArgumentException("未找到配置标识对应的微信支付配置: " + configKey); + } + + // 查询订单 + WxPayOrderQueryV3Result result = wxPayService.queryOrderV3(null, outTradeNo); + // 处理查询结果 + // ... + } + + private String generateOutTradeNo() { + // 生成商户订单号 + return "ORDER_" + System.currentTimeMillis(); + } +} +``` + +### 4. 配置说明 + +#### 必填配置项 + +| 配置项 | 说明 | 示例 | +|--------|------|------| +| appId | 公众号或小程序的appId | wx1234567890abcdef | +| mchId | 商户号 | 1234567890 | + +#### V2版本配置项 + +| 配置项 | 说明 | 是否必填 | +|--------|------|----------| +| mchKey | 商户密钥 | 是(V2) | +| keyPath | p12证书文件路径 | 部分接口需要 | + +#### V3版本配置项 + +| 配置项 | 说明 | 是否必填 | +|--------|------|----------| +| apiV3Key | API V3密钥 | 是(V3) | +| certSerialNo | 证书序列号 | 是(V3) | +| privateKeyPath | apiclient_key.pem路径 | 是(V3) | +| privateCertPath | apiclient_cert.pem路径 | 是(V3) | + +#### 服务商模式配置项 + +| 配置项 | 说明 | 是否必填 | +|--------|------|----------| +| subAppId | 子商户公众号appId | 服务商模式必填 | +| subMchId | 子商户号 | 服务商模式必填 | + +#### 可选配置项 + +| 配置项 | 说明 | 默认值 | +|--------|------|--------| +| notifyUrl | 支付结果通知URL | 无 | +| refundNotifyUrl | 退款结果通知URL | 无 | +| serviceId | 微信支付分serviceId | 无 | +| payScoreNotifyUrl | 支付分回调地址 | 无 | +| payScorePermissionNotifyUrl | 支付分授权回调地址 | 无 | +| useSandboxEnv | 是否使用沙箱环境 | false | +| apiHostUrl | 自定义API主机地址 | https://api.mch.weixin.qq.com | +| strictlyNeedWechatPaySerial | 是否所有V3请求都添加序列号头 | false | +| fullPublicKeyModel | 是否完全使用公钥模式 | false | +| publicKeyId | 公钥ID | 无 | +| publicKeyPath | 公钥文件路径 | 无 | + +## 常见问题 + +### 1. 如何选择配置的key? + +配置的key(即 `wx.pay.configs.` 中的 `` 部分)可以自由选择: +- 可以使用appId作为key(如 `wx.pay.configs.wx1234567890abcdef`),这样调用 `getWxPayService("wx1234567890abcdef")` 时就像直接用 appId 获取服务 +- 可以使用自定义标识(如 `wx.pay.configs.config1`),调用时使用 `getWxPayService("config1")` + +**注意**:`getWxPayService(configKey)` 方法的参数是配置文件中定义的 key,而不是 appId。只有当你使用 appId 作为配置 key 时,才能直接传入 appId。 + +### 2. V2和V3配置可以混用吗? + +可以。不同的配置可以使用不同的版本,例如: +```yml +wx: + pay: + configs: + app1: # V2配置 + appId: wx111 + mchId: 111 + mchKey: xxx + app2: # V3配置 + appId: wx222 + mchId: 222 + apiV3Key: yyy + privateKeyPath: xxx +``` + +### 3. 证书文件如何放置? + +证书文件可以放在以下位置: +- `src/main/resources` 目录下,使用 `classpath:` 前缀 +- 服务器绝对路径,直接填写完整路径 +- 建议为不同配置使用不同的目录组织证书 + +### 4. 服务商模式如何配置? + +服务商模式需要同时配置服务商信息和子商户信息: +- `appId` 和 `mchId` 填写服务商的信息 +- `subAppId` 和 `subMchId` 填写子商户的信息 + +## 注意事项 + +1. **配置安全**:生产环境中的密钥、证书等敏感信息,建议使用配置中心或环境变量管理 +2. **证书管理**:不同公众号的证书文件要分开存放,避免混淆 +3. **懒加载**:WxPayService 实例采用懒加载策略,只有在首次调用时才会创建 +4. **线程安全**:WxPayMultiServices 的实现是线程安全的 +5. **配置更新**:如需动态更新配置,可调用 `removeWxPayService(configKey)` 方法移除缓存的实例 + +## 更多信息 + +- [WxJava 项目首页](https://github.com/Wechat-Group/WxJava) +- [微信支付官方文档](https://pay.weixin.qq.com/wiki/doc/api/) +- [微信支付V3接口文档](https://pay.weixin.qq.com/wiki/doc/apiv3/index.shtml) diff --git a/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/pom.xml new file mode 100644 index 0000000000..a5c0b842cb --- /dev/null +++ b/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/pom.xml @@ -0,0 +1,53 @@ + + + + wx-java-spring-boot-starters + com.github.binarywang + 4.8.0 + + 4.0.0 + + wx-java-pay-multi-spring-boot-starter + WxJava - Spring Boot Starter for Pay::支持多公众号关联配置 + 微信支付开发的 Spring Boot Starter::支持多公众号关联配置 + + + + com.github.binarywang + weixin-java-pay + ${project.version} + + + org.springframework.boot + spring-boot-starter-test + ${spring.boot.version} + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot.version} + + + org.apache.maven.plugins + maven-source-plugin + 2.2.1 + + + attach-sources + + jar-no-fork + + + + + + + + diff --git a/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/config/WxPayMultiAutoConfiguration.java b/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/config/WxPayMultiAutoConfiguration.java new file mode 100644 index 0000000000..08ddafbf9c --- /dev/null +++ b/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/config/WxPayMultiAutoConfiguration.java @@ -0,0 +1,38 @@ +package com.binarywang.spring.starter.wxjava.pay.config; + +import com.binarywang.spring.starter.wxjava.pay.properties.WxPayMultiProperties; +import com.binarywang.spring.starter.wxjava.pay.service.WxPayMultiServices; +import com.binarywang.spring.starter.wxjava.pay.service.WxPayMultiServicesImpl; +import com.github.binarywang.wxpay.service.WxPayService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 微信支付多公众号关联自动配置. + * + * @author Binary Wang + */ +@Slf4j +@Configuration +@EnableConfigurationProperties(WxPayMultiProperties.class) +@ConditionalOnClass(WxPayService.class) +@ConditionalOnProperty(prefix = WxPayMultiProperties.PREFIX, value = "enabled", matchIfMissing = true) +public class WxPayMultiAutoConfiguration { + + /** + * 构造微信支付多服务管理对象. + * + * @param wxPayMultiProperties 多配置属性 + * @return 微信支付多服务管理对象 + */ + @Bean + @ConditionalOnMissingBean(WxPayMultiServices.class) + public WxPayMultiServices wxPayMultiServices(WxPayMultiProperties wxPayMultiProperties) { + return new WxPayMultiServicesImpl(wxPayMultiProperties); + } +} diff --git a/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/properties/WxPayMultiProperties.java b/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/properties/WxPayMultiProperties.java new file mode 100644 index 0000000000..8d1180b0e4 --- /dev/null +++ b/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/properties/WxPayMultiProperties.java @@ -0,0 +1,27 @@ +package com.binarywang.spring.starter.wxjava.pay.properties; + +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; + +/** + * 微信支付多公众号关联配置属性类. + * + * @author Binary Wang + */ +@Data +@NoArgsConstructor +@ConfigurationProperties(WxPayMultiProperties.PREFIX) +public class WxPayMultiProperties implements Serializable { + private static final long serialVersionUID = -8015955705346835955L; + public static final String PREFIX = "wx.pay"; + + /** + * 多个公众号的配置信息,key 可以是 appId 或自定义的标识. + */ + private Map configs = new HashMap<>(); +} diff --git a/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/properties/WxPaySingleProperties.java b/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/properties/WxPaySingleProperties.java new file mode 100644 index 0000000000..a5cda55fb0 --- /dev/null +++ b/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/properties/WxPaySingleProperties.java @@ -0,0 +1,124 @@ +package com.binarywang.spring.starter.wxjava.pay.properties; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * 微信支付单个公众号配置属性类. + * + * @author Binary Wang + */ +@Data +@NoArgsConstructor +public class WxPaySingleProperties implements Serializable { + private static final long serialVersionUID = 3978986361098922525L; + + /** + * 设置微信公众号或者小程序等的appid. + */ + private String appId; + + /** + * 微信支付商户号. + */ + private String mchId; + + /** + * 微信支付商户密钥. + */ + private String mchKey; + + /** + * 服务商模式下的子商户公众账号ID,普通模式请不要配置. + */ + private String subAppId; + + /** + * 服务商模式下的子商户号,普通模式请不要配置. + */ + private String subMchId; + + /** + * apiclient_cert.p12文件的绝对路径,或者如果放在项目中,请以classpath:开头指定. + */ + private String keyPath; + + /** + * 微信支付分serviceId. + */ + private String serviceId; + + /** + * 证书序列号. + */ + private String certSerialNo; + + /** + * apiV3秘钥. + */ + private String apiv3Key; + + /** + * 微信支付异步回调地址,通知url必须为直接可访问的url,不能携带参数. + */ + private String notifyUrl; + + /** + * 退款结果异步回调地址,通知url必须为直接可访问的url,不能携带参数. + */ + private String refundNotifyUrl; + + /** + * 微信支付分回调地址. + */ + private String payScoreNotifyUrl; + + /** + * 微信支付分授权回调地址. + */ + private String payScorePermissionNotifyUrl; + + /** + * apiv3 商户apiclient_key.pem. + */ + private String privateKeyPath; + + /** + * apiv3 商户apiclient_cert.pem. + */ + private String privateCertPath; + + /** + * 公钥ID. + */ + private String publicKeyId; + + /** + * pub_key.pem证书文件的绝对路径或者以classpath:开头的类路径. + */ + private String publicKeyPath; + + /** + * 微信支付是否使用仿真测试环境. + * 默认不使用. + */ + private boolean useSandboxEnv = false; + + /** + * 自定义API主机地址,用于替换默认的 https://api.mch.weixin.qq.com. + * 例如:http://proxy.company.com:8080 + */ + private String apiHostUrl; + + /** + * 是否将全部v3接口的请求都添加Wechatpay-Serial请求头,默认不添加. + */ + private boolean strictlyNeedWechatPaySerial = false; + + /** + * 是否完全使用公钥模式(用以微信从平台证书到公钥的灰度切换),默认不使用. + */ + private boolean fullPublicKeyModel = false; +} diff --git a/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/service/WxPayMultiServices.java b/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/service/WxPayMultiServices.java new file mode 100644 index 0000000000..3e0b7a999f --- /dev/null +++ b/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/service/WxPayMultiServices.java @@ -0,0 +1,33 @@ +package com.binarywang.spring.starter.wxjava.pay.service; + +import com.github.binarywang.wxpay.service.WxPayService; + +/** + * 微信支付 {@link WxPayService} 所有实例存放类. + * + * @author Binary Wang + */ +public interface WxPayMultiServices { + /** + * 通过配置标识获取 WxPayService. + *

+ * 注意:configKey 是配置文件中定义的 key(如 wx.pay.configs.<configKey>.xxx), + * 而不是 appId。如果使用 appId 作为配置 key,则可以直接传入 appId。 + *

+ * + * @param configKey 配置标识(配置文件中 wx.pay.configs 下的 key) + * @return WxPayService + */ + WxPayService getWxPayService(String configKey); + + /** + * 根据配置标识,从列表中移除一个 WxPayService 实例. + *

+ * 注意:configKey 是配置文件中定义的 key(如 wx.pay.configs.<configKey>.xxx), + * 而不是 appId。如果使用 appId 作为配置 key,则可以直接传入 appId。 + *

+ * + * @param configKey 配置标识(配置文件中 wx.pay.configs 下的 key) + */ + void removeWxPayService(String configKey); +} diff --git a/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/service/WxPayMultiServicesImpl.java b/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/service/WxPayMultiServicesImpl.java new file mode 100644 index 0000000000..459fe3b6c0 --- /dev/null +++ b/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/service/WxPayMultiServicesImpl.java @@ -0,0 +1,92 @@ +package com.binarywang.spring.starter.wxjava.pay.service; + +import com.binarywang.spring.starter.wxjava.pay.properties.WxPayMultiProperties; +import com.binarywang.spring.starter.wxjava.pay.properties.WxPaySingleProperties; +import com.github.binarywang.wxpay.config.WxPayConfig; +import com.github.binarywang.wxpay.service.WxPayService; +import com.github.binarywang.wxpay.service.impl.WxPayServiceImpl; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 微信支付多服务管理实现类. + * + * @author Binary Wang + */ +@Slf4j +public class WxPayMultiServicesImpl implements WxPayMultiServices { + private final Map services = new ConcurrentHashMap<>(); + private final WxPayMultiProperties wxPayMultiProperties; + + public WxPayMultiServicesImpl(WxPayMultiProperties wxPayMultiProperties) { + this.wxPayMultiProperties = wxPayMultiProperties; + } + + @Override + public WxPayService getWxPayService(String configKey) { + if (StringUtils.isBlank(configKey)) { + log.warn("配置标识为空,无法获取WxPayService"); + return null; + } + + // 使用 computeIfAbsent 实现线程安全的懒加载,避免使用 synchronized(this) 带来的性能问题 + return services.computeIfAbsent(configKey, key -> { + WxPaySingleProperties properties = wxPayMultiProperties.getConfigs().get(key); + if (properties == null) { + log.warn("未找到配置标识为[{}]的微信支付配置", key); + return null; + } + return this.buildWxPayService(properties); + }); + } + + @Override + public void removeWxPayService(String configKey) { + if (StringUtils.isBlank(configKey)) { + log.warn("配置标识为空,无法移除WxPayService"); + return; + } + services.remove(configKey); + } + + /** + * 根据配置构建 WxPayService. + * + * @param properties 单个配置属性 + * @return WxPayService + */ + private WxPayService buildWxPayService(WxPaySingleProperties properties) { + WxPayServiceImpl wxPayService = new WxPayServiceImpl(); + WxPayConfig payConfig = new WxPayConfig(); + + payConfig.setAppId(StringUtils.trimToNull(properties.getAppId())); + payConfig.setMchId(StringUtils.trimToNull(properties.getMchId())); + payConfig.setMchKey(StringUtils.trimToNull(properties.getMchKey())); + payConfig.setSubAppId(StringUtils.trimToNull(properties.getSubAppId())); + payConfig.setSubMchId(StringUtils.trimToNull(properties.getSubMchId())); + payConfig.setKeyPath(StringUtils.trimToNull(properties.getKeyPath())); + payConfig.setUseSandboxEnv(properties.isUseSandboxEnv()); + payConfig.setNotifyUrl(StringUtils.trimToNull(properties.getNotifyUrl())); + payConfig.setRefundNotifyUrl(StringUtils.trimToNull(properties.getRefundNotifyUrl())); + + // 以下是apiv3以及支付分相关 + payConfig.setServiceId(StringUtils.trimToNull(properties.getServiceId())); + payConfig.setPayScoreNotifyUrl(StringUtils.trimToNull(properties.getPayScoreNotifyUrl())); + payConfig.setPayScorePermissionNotifyUrl(StringUtils.trimToNull(properties.getPayScorePermissionNotifyUrl())); + payConfig.setPrivateKeyPath(StringUtils.trimToNull(properties.getPrivateKeyPath())); + payConfig.setPrivateCertPath(StringUtils.trimToNull(properties.getPrivateCertPath())); + payConfig.setCertSerialNo(StringUtils.trimToNull(properties.getCertSerialNo())); + payConfig.setApiV3Key(StringUtils.trimToNull(properties.getApiv3Key())); + payConfig.setPublicKeyId(StringUtils.trimToNull(properties.getPublicKeyId())); + payConfig.setPublicKeyPath(StringUtils.trimToNull(properties.getPublicKeyPath())); + payConfig.setApiHostUrl(StringUtils.trimToNull(properties.getApiHostUrl())); + payConfig.setStrictlyNeedWechatPaySerial(properties.isStrictlyNeedWechatPaySerial()); + payConfig.setFullPublicKeyModel(properties.isFullPublicKeyModel()); + + wxPayService.setConfig(payConfig); + return wxPayService; + } +} diff --git a/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/resources/META-INF/spring.factories b/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000000..d257d37276 --- /dev/null +++ b/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +com.binarywang.spring.starter.wxjava.pay.config.WxPayMultiAutoConfiguration diff --git a/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000000..39e3342f4a --- /dev/null +++ b/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +com.binarywang.spring.starter.wxjava.pay.config.WxPayMultiAutoConfiguration + diff --git a/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/test/java/com/binarywang/spring/starter/wxjava/pay/WxPayMultiServicesTest.java b/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/test/java/com/binarywang/spring/starter/wxjava/pay/WxPayMultiServicesTest.java new file mode 100644 index 0000000000..25a091da02 --- /dev/null +++ b/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/test/java/com/binarywang/spring/starter/wxjava/pay/WxPayMultiServicesTest.java @@ -0,0 +1,104 @@ +package com.binarywang.spring.starter.wxjava.pay; + +import com.binarywang.spring.starter.wxjava.pay.config.WxPayMultiAutoConfiguration; +import com.binarywang.spring.starter.wxjava.pay.properties.WxPayMultiProperties; +import com.binarywang.spring.starter.wxjava.pay.properties.WxPaySingleProperties; +import com.binarywang.spring.starter.wxjava.pay.service.WxPayMultiServices; +import com.github.binarywang.wxpay.service.WxPayService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 微信支付多公众号关联配置测试. + * + * @author Binary Wang + */ +@SpringBootTest(classes = {WxPayMultiAutoConfiguration.class, WxPayMultiServicesTest.TestApplication.class}) +@TestPropertySource(properties = { + "wx.pay.configs.app1.app-id=wx1111111111111111", + "wx.pay.configs.app1.mch-id=1111111111", + "wx.pay.configs.app1.mch-key=11111111111111111111111111111111", + "wx.pay.configs.app1.notify-url=https://example.com/pay/notify", + "wx.pay.configs.app2.app-id=wx2222222222222222", + "wx.pay.configs.app2.mch-id=2222222222", + "wx.pay.configs.app2.apiv3-key=22222222222222222222222222222222", + "wx.pay.configs.app2.cert-serial-no=2222222222222222", + "wx.pay.configs.app2.private-key-path=classpath:cert/apiclient_key.pem", + "wx.pay.configs.app2.private-cert-path=classpath:cert/apiclient_cert.pem" +}) +public class WxPayMultiServicesTest { + + @Autowired + private WxPayMultiServices wxPayMultiServices; + + @Autowired + private WxPayMultiProperties wxPayMultiProperties; + + @Test + public void testConfiguration() { + assertNotNull(wxPayMultiServices, "WxPayMultiServices should be autowired"); + assertNotNull(wxPayMultiProperties, "WxPayMultiProperties should be autowired"); + + // 验证配置正确加载 + assertEquals(2, wxPayMultiProperties.getConfigs().size(), "Should have 2 configurations"); + + WxPaySingleProperties app1Config = wxPayMultiProperties.getConfigs().get("app1"); + assertNotNull(app1Config, "app1 configuration should exist"); + assertEquals("wx1111111111111111", app1Config.getAppId()); + assertEquals("1111111111", app1Config.getMchId()); + assertEquals("11111111111111111111111111111111", app1Config.getMchKey()); + + WxPaySingleProperties app2Config = wxPayMultiProperties.getConfigs().get("app2"); + assertNotNull(app2Config, "app2 configuration should exist"); + assertEquals("wx2222222222222222", app2Config.getAppId()); + assertEquals("2222222222", app2Config.getMchId()); + assertEquals("22222222222222222222222222222222", app2Config.getApiv3Key()); + } + + @Test + public void testGetWxPayService() { + WxPayService app1Service = wxPayMultiServices.getWxPayService("app1"); + assertNotNull(app1Service, "Should get WxPayService for app1"); + assertEquals("wx1111111111111111", app1Service.getConfig().getAppId()); + assertEquals("1111111111", app1Service.getConfig().getMchId()); + + WxPayService app2Service = wxPayMultiServices.getWxPayService("app2"); + assertNotNull(app2Service, "Should get WxPayService for app2"); + assertEquals("wx2222222222222222", app2Service.getConfig().getAppId()); + assertEquals("2222222222", app2Service.getConfig().getMchId()); + + // 测试相同key返回相同实例 + WxPayService app1ServiceAgain = wxPayMultiServices.getWxPayService("app1"); + assertSame(app1Service, app1ServiceAgain, "Should return the same instance for the same key"); + } + + @Test + public void testGetWxPayServiceWithInvalidKey() { + WxPayService service = wxPayMultiServices.getWxPayService("nonexistent"); + assertNull(service, "Should return null for non-existent key"); + } + + @Test + public void testRemoveWxPayService() { + // 首先获取一个服务实例 + WxPayService app1Service = wxPayMultiServices.getWxPayService("app1"); + assertNotNull(app1Service, "Should get WxPayService for app1"); + + // 移除服务 + wxPayMultiServices.removeWxPayService("app1"); + + // 再次获取时应该创建新实例 + WxPayService app1ServiceNew = wxPayMultiServices.getWxPayService("app1"); + assertNotNull(app1ServiceNew, "Should get new WxPayService for app1"); + assertNotSame(app1Service, app1ServiceNew, "Should return a new instance after removal"); + } + + @SpringBootApplication + static class TestApplication { + } +} diff --git a/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/test/java/com/binarywang/spring/starter/wxjava/pay/example/WxPayMultiExample.java b/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/test/java/com/binarywang/spring/starter/wxjava/pay/example/WxPayMultiExample.java new file mode 100644 index 0000000000..48ae32d5b4 --- /dev/null +++ b/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/test/java/com/binarywang/spring/starter/wxjava/pay/example/WxPayMultiExample.java @@ -0,0 +1,249 @@ +package com.binarywang.spring.starter.wxjava.pay.example; + +import com.binarywang.spring.starter.wxjava.pay.service.WxPayMultiServices; +import com.github.binarywang.wxpay.bean.request.WxPayRefundV3Request; +import com.github.binarywang.wxpay.bean.request.WxPayUnifiedOrderV3Request; +import com.github.binarywang.wxpay.bean.result.WxPayOrderQueryV3Result; +import com.github.binarywang.wxpay.bean.result.WxPayRefundV3Result; +import com.github.binarywang.wxpay.bean.result.WxPayUnifiedOrderV3Result; +import com.github.binarywang.wxpay.bean.result.enums.TradeTypeEnum; +import com.github.binarywang.wxpay.service.WxPayService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +/** + * 微信支付多公众号关联使用示例. + *

+ * 本示例展示了如何使用 wx-java-pay-multi-spring-boot-starter 来管理多个公众号的支付配置。 + *

+ * + * @author Binary Wang + */ +@Slf4j +@Service +public class WxPayMultiExample { + + @Autowired + private WxPayMultiServices wxPayMultiServices; + + /** + * 示例1:根据appId创建支付订单. + *

+ * 适用场景:系统需要支持多个公众号,根据用户所在的公众号动态选择支付配置 + *

+ * + * @param appId 公众号appId + * @param openId 用户的openId + * @param totalFee 支付金额(分) + * @param body 商品描述 + * @return JSAPI支付参数 + */ + public WxPayUnifiedOrderV3Result.JsapiResult createJsapiOrder(String appId, String openId, + Integer totalFee, String body) { + try { + // 根据appId获取对应的WxPayService + WxPayService wxPayService = wxPayMultiServices.getWxPayService(appId); + + if (wxPayService == null) { + log.error("未找到appId对应的微信支付配置: {}", appId); + throw new IllegalArgumentException("未找到appId对应的微信支付配置"); + } + + // 构建支付请求 + WxPayUnifiedOrderV3Request request = new WxPayUnifiedOrderV3Request(); + request.setOutTradeNo(generateOutTradeNo()); + request.setDescription(body); + request.setAmount(new WxPayUnifiedOrderV3Request.Amount().setTotal(totalFee)); + request.setPayer(new WxPayUnifiedOrderV3Request.Payer().setOpenid(openId)); + request.setNotifyUrl(wxPayService.getConfig().getNotifyUrl()); + + // 调用微信支付API创建订单 + WxPayUnifiedOrderV3Result.JsapiResult result = + wxPayService.createOrderV3(TradeTypeEnum.JSAPI, request); + + log.info("创建JSAPI支付订单成功,appId: {}, outTradeNo: {}", appId, request.getOutTradeNo()); + return result; + + } catch (Exception e) { + log.error("创建JSAPI支付订单失败,appId: {}", appId, e); + throw new RuntimeException("创建支付订单失败", e); + } + } + + /** + * 示例2:服务商模式 - 为不同子商户创建订单. + *

+ * 适用场景:服务商为多个子商户提供支付服务 + *

+ * + * @param configKey 配置标识(在配置文件中定义) + * @param subOpenId 子商户用户的openId + * @param totalFee 支付金额(分) + * @param body 商品描述 + * @return JSAPI支付参数 + */ + public WxPayUnifiedOrderV3Result.JsapiResult createPartnerOrder(String configKey, String subOpenId, + Integer totalFee, String body) { + try { + // 根据配置标识获取WxPayService + WxPayService wxPayService = wxPayMultiServices.getWxPayService(configKey); + + if (wxPayService == null) { + log.error("未找到配置: {}", configKey); + throw new IllegalArgumentException("未找到配置"); + } + + // 获取子商户信息 + String subAppId = wxPayService.getConfig().getSubAppId(); + String subMchId = wxPayService.getConfig().getSubMchId(); + log.info("使用服务商模式,子商户appId: {}, 子商户号: {}", subAppId, subMchId); + + // 构建支付请求 + WxPayUnifiedOrderV3Request request = new WxPayUnifiedOrderV3Request(); + request.setOutTradeNo(generateOutTradeNo()); + request.setDescription(body); + request.setAmount(new WxPayUnifiedOrderV3Request.Amount().setTotal(totalFee)); + request.setPayer(new WxPayUnifiedOrderV3Request.Payer().setOpenid(subOpenId)); + request.setNotifyUrl(wxPayService.getConfig().getNotifyUrl()); + + // 调用微信支付API创建订单 + WxPayUnifiedOrderV3Result.JsapiResult result = + wxPayService.createOrderV3(TradeTypeEnum.JSAPI, request); + + log.info("创建服务商支付订单成功,配置: {}, outTradeNo: {}", configKey, request.getOutTradeNo()); + return result; + + } catch (Exception e) { + log.error("创建服务商支付订单失败,配置: {}", configKey, e); + throw new RuntimeException("创建支付订单失败", e); + } + } + + /** + * 示例3:查询订单状态. + *

+ * 适用场景:查询不同公众号的订单支付状态 + *

+ * + * @param appId 公众号appId + * @param outTradeNo 商户订单号 + * @return 订单状态 + */ + public String queryOrderStatus(String appId, String outTradeNo) { + try { + WxPayService wxPayService = wxPayMultiServices.getWxPayService(appId); + + if (wxPayService == null) { + log.error("未找到appId对应的微信支付配置: {}", appId); + throw new IllegalArgumentException("未找到appId对应的微信支付配置"); + } + + // 查询订单 + WxPayOrderQueryV3Result result = wxPayService.queryOrderV3(null, outTradeNo); + String tradeState = result.getTradeState(); + + log.info("查询订单状态成功,appId: {}, outTradeNo: {}, 状态: {}", appId, outTradeNo, tradeState); + return tradeState; + + } catch (Exception e) { + log.error("查询订单状态失败,appId: {}, outTradeNo: {}", appId, outTradeNo, e); + throw new RuntimeException("查询订单失败", e); + } + } + + /** + * 示例4:申请退款. + *

+ * 适用场景:为不同公众号的订单申请退款 + *

+ * + * @param appId 公众号appId + * @param outTradeNo 商户订单号 + * @param refundFee 退款金额(分) + * @param totalFee 订单总金额(分) + * @param reason 退款原因 + * @return 退款单号 + */ + public String refund(String appId, String outTradeNo, Integer refundFee, + Integer totalFee, String reason) { + try { + WxPayService wxPayService = wxPayMultiServices.getWxPayService(appId); + + if (wxPayService == null) { + log.error("未找到appId对应的微信支付配置: {}", appId); + throw new IllegalArgumentException("未找到appId对应的微信支付配置"); + } + + // 构建退款请求 + com.github.binarywang.wxpay.bean.request.WxPayRefundV3Request request = + new com.github.binarywang.wxpay.bean.request.WxPayRefundV3Request(); + request.setOutTradeNo(outTradeNo); + request.setOutRefundNo(generateRefundNo()); + request.setReason(reason); + request.setNotifyUrl(wxPayService.getConfig().getRefundNotifyUrl()); + + com.github.binarywang.wxpay.bean.request.WxPayRefundV3Request.Amount amount = + new com.github.binarywang.wxpay.bean.request.WxPayRefundV3Request.Amount(); + amount.setRefund(refundFee); + amount.setTotal(totalFee); + amount.setCurrency("CNY"); + request.setAmount(amount); + + // 调用微信支付API申请退款 + WxPayRefundV3Result result = wxPayService.refundV3(request); + + log.info("申请退款成功,appId: {}, outTradeNo: {}, outRefundNo: {}", + appId, outTradeNo, request.getOutRefundNo()); + return request.getOutRefundNo(); + + } catch (Exception e) { + log.error("申请退款失败,appId: {}, outTradeNo: {}", appId, outTradeNo, e); + throw new RuntimeException("申请退款失败", e); + } + } + + /** + * 示例5:动态管理配置. + *

+ * 适用场景:需要在运行时更新配置(如证书更新后需要重新加载) + *

+ * + * @param configKey 配置标识 + */ + public void reloadConfig(String configKey) { + try { + // 移除缓存的WxPayService实例 + wxPayMultiServices.removeWxPayService(configKey); + log.info("移除配置成功,下次获取时将重新创建: {}", configKey); + + // 下次调用 getWxPayService 时会重新创建实例 + WxPayService wxPayService = wxPayMultiServices.getWxPayService(configKey); + if (wxPayService != null) { + log.info("重新加载配置成功: {}", configKey); + } + + } catch (Exception e) { + log.error("重新加载配置失败: {}", configKey, e); + throw new RuntimeException("重新加载配置失败", e); + } + } + + /** + * 生成商户订单号. + * + * @return 商户订单号 + */ + private String generateOutTradeNo() { + return "ORDER_" + System.currentTimeMillis(); + } + + /** + * 生成商户退款单号. + * + * @return 商户退款单号 + */ + private String generateRefundNo() { + return "REFUND_" + System.currentTimeMillis(); + } +} From 12db287ae02224ba55bc5ac02b37115d78dc8370 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 16 Jan 2026 17:28:51 +0800 Subject: [PATCH 3/3] =?UTF-8?q?:art:=20#3849=20=E3=80=90=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E6=94=AF=E4=BB=98=E3=80=91=E6=94=AF=E6=8C=81=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E5=95=86=E6=88=B7=E5=8F=B7=E9=85=8D=E7=BD=AE=E5=A4=9A=E4=B8=AA?= =?UTF-8?q?=E5=B0=8F=E7=A8=8B=E5=BA=8FappId?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- weixin-java-pay/MULTI_APPID_USAGE.md | 200 +++++++++++ .../wxpay/service/WxPayService.java | 25 ++ .../service/impl/BaseWxPayServiceImpl.java | 54 +++ .../impl/MultiAppIdSwitchoverManualTest.java | 127 +++++++ .../impl/MultiAppIdSwitchoverTest.java | 310 ++++++++++++++++++ 5 files changed, 716 insertions(+) create mode 100644 weixin-java-pay/MULTI_APPID_USAGE.md create mode 100644 weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/MultiAppIdSwitchoverManualTest.java create mode 100644 weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/MultiAppIdSwitchoverTest.java diff --git a/weixin-java-pay/MULTI_APPID_USAGE.md b/weixin-java-pay/MULTI_APPID_USAGE.md new file mode 100644 index 0000000000..e4a7d0b9eb --- /dev/null +++ b/weixin-java-pay/MULTI_APPID_USAGE.md @@ -0,0 +1,200 @@ +# 支持一个商户号对应多个 appId 的使用说明 + +## 背景 + +在实际业务中,经常会遇到一个微信支付商户号需要绑定多个小程序的场景。例如: +- 一个商家有多个小程序(主店、分店、活动小程序等) +- 所有小程序共用同一个支付商户号 +- 支付配置(商户号、密钥、证书等)完全相同,只有 appId 不同 + +## 解决方案 + +WxJava 支持在配置多个相同商户号、不同 appId 的情况下,**可以仅通过商户号进行配置切换**,无需每次都指定 appId。 + +## 使用方式 + +### 1. 配置多个 appId + +```java +WxPayService payService = new WxPayServiceImpl(); + +String mchId = "1234567890"; // 商户号 + +// 配置小程序1 +WxPayConfig config1 = new WxPayConfig(); +config1.setMchId(mchId); +config1.setAppId("wx1111111111111111"); // 小程序1的appId +config1.setMchKey("your_mch_key"); +config1.setApiV3Key("your_api_v3_key"); +// ... 其他配置 + +// 配置小程序2 +WxPayConfig config2 = new WxPayConfig(); +config2.setMchId(mchId); +config2.setAppId("wx2222222222222222"); // 小程序2的appId +config2.setMchKey("your_mch_key"); +config2.setApiV3Key("your_api_v3_key"); +// ... 其他配置 + +// 配置小程序3 +WxPayConfig config3 = new WxPayConfig(); +config3.setMchId(mchId); +config3.setAppId("wx3333333333333333"); // 小程序3的appId +config3.setMchKey("your_mch_key"); +config3.setApiV3Key("your_api_v3_key"); +// ... 其他配置 + +// 添加到配置映射 +Map configMap = new HashMap<>(); +configMap.put(mchId + "_" + config1.getAppId(), config1); +configMap.put(mchId + "_" + config2.getAppId(), config2); +configMap.put(mchId + "_" + config3.getAppId(), config3); + +payService.setMultiConfig(configMap); +``` + +### 2. 切换配置的方式 + +#### 方式一:精确切换(原有方式,向后兼容) + +```java +// 切换到小程序1的配置 +payService.switchover("1234567890", "wx1111111111111111"); + +// 切换到小程序2的配置 +payService.switchover("1234567890", "wx2222222222222222"); +``` + +#### 方式二:仅使用商户号切换(新功能) + +```java +// 仅使用商户号切换,会自动匹配该商户号的某个配置 +// 适用于不关心具体使用哪个 appId 的场景 +boolean success = payService.switchover("1234567890"); +``` + +**注意**:当使用仅商户号切换时,会按照以下逻辑查找配置: +1. 先尝试精确匹配商户号(针对只配置商户号、没有 appId 的情况) +2. 如果未找到,则尝试前缀匹配(查找以 `商户号_` 开头的配置) +3. 如果有多个匹配项,将返回其中任意一个匹配项,具体选择结果不保证稳定或可预测,如需确定性行为请使用精确匹配方式(同时指定商户号和 appId) + +#### 方式三:链式调用 + +```java +// 精确切换,支持链式调用 +WxPayUnifiedOrderResult result = payService + .switchoverTo("1234567890", "wx1111111111111111") + .unifiedOrder(request); + +// 仅商户号切换,支持链式调用 +WxPayUnifiedOrderResult result = payService + .switchoverTo("1234567890") + .unifiedOrder(request); +``` + +### 3. 动态添加配置 + +```java +// 运行时动态添加新的 appId 配置 +WxPayConfig newConfig = new WxPayConfig(); +newConfig.setMchId("1234567890"); +newConfig.setAppId("wx4444444444444444"); +// ... 其他配置 + +payService.addConfig("1234567890", "wx4444444444444444", newConfig); + +// 切换到新添加的配置 +payService.switchover("1234567890", "wx4444444444444444"); +``` + +### 4. 移除配置 + +```java +// 移除特定的 appId 配置 +payService.removeConfig("1234567890", "wx1111111111111111"); +``` + +## 实际应用场景 + +### 场景1:根据用户来源切换 appId + +```java +// 在支付前,根据订单来源切换到对应小程序的配置 +String orderSource = order.getSource(); // 例如: "miniapp1", "miniapp2" +String appId = getAppIdBySource(orderSource); + +// 精确切换到特定小程序 +payService.switchover(mchId, appId); + +// 创建订单 +WxPayUnifiedOrderRequest request = new WxPayUnifiedOrderRequest(); +// ... 设置订单参数 +WxPayUnifiedOrderResult result = payService.unifiedOrder(request); +``` + +### 场景2:处理支付回调 + +```java +@PostMapping("/pay/notify") +public String handlePayNotify(@RequestBody String xmlData) { + try { + // 解析回调通知 + WxPayOrderNotifyResult notifyResult = payService.parseOrderNotifyResult(xmlData); + + // 注意:parseOrderNotifyResult 方法内部会自动调用 + // switchover(notifyResult.getMchId(), notifyResult.getAppid()) + // 切换到正确的配置进行签名验证 + + // 处理业务逻辑 + processOrder(notifyResult); + + return WxPayNotifyResponse.success("成功"); + } catch (WxPayException e) { + log.error("支付回调处理失败", e); + return WxPayNotifyResponse.fail("失败"); + } +} +``` + +### 场景3:不关心具体 appId 的场景 + +```java +// 某些场景下,只要是该商户号的配置即可,不关心具体是哪个 appId +// 例如:查询订单、退款等操作 + +// 仅使用商户号切换 +payService.switchover(mchId); + +// 查询订单 +WxPayOrderQueryResult queryResult = payService.queryOrder(null, outTradeNo); + +// 申请退款 +WxPayRefundRequest refundRequest = new WxPayRefundRequest(); +// ... 设置退款参数 +WxPayRefundResult refundResult = payService.refund(refundRequest); +``` + +## 注意事项 + +1. **向后兼容**:所有原有的使用方式继续有效,不需要修改现有代码。 + +2. **配置隔离**:每个 `mchId + appId` 组合都是独立的配置,修改一个配置不会影响其他配置。 + +3. **线程安全**:配置切换使用 `WxPayConfigHolder`(基于 `ThreadLocal`),是线程安全的。 + +4. **自动切换**:在处理支付回调时,SDK 会自动根据回调中的 `mchId` 和 `appId` 切换到正确的配置。 + +5. **推荐实践**: + - 如果知道具体的 appId,建议使用精确切换方式,避免歧义 + - 如果使用仅商户号切换,确保该商户号下至少有一个可用的配置 + +## 相关 API + +| 方法 | 参数 | 返回值 | 说明 | +|-----|------|--------|------| +| `switchover(String mchId, String appId)` | 商户号, appId | boolean | 精确切换到指定配置 | +| `switchover(String mchId)` | 商户号 | boolean | 仅使用商户号切换 | +| `switchoverTo(String mchId, String appId)` | 商户号, appId | WxPayService | 精确切换,支持链式调用 | +| `switchoverTo(String mchId)` | 商户号 | WxPayService | 仅商户号切换,支持链式调用 | +| `addConfig(String mchId, String appId, WxPayConfig)` | 商户号, appId, 配置 | void | 动态添加配置 | +| `removeConfig(String mchId, String appId)` | 商户号, appId | void | 移除指定配置 | diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/WxPayService.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/WxPayService.java index dab89a0142..2db2987d12 100644 --- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/WxPayService.java +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/WxPayService.java @@ -78,6 +78,18 @@ public interface WxPayService { */ boolean switchover(String mchId, String appId); + /** + * 仅根据商户号进行切换. + * 适用于一个商户号对应多个appId的场景,切换时会匹配符合该商户号的配置. + * 注意:由于HashMap迭代顺序不确定,当存在多个匹配项时返回的配置是不可预测的,建议使用精确匹配方式. + * + * @param mchId 商户标识 + * @return 切换是否成功,如果找不到匹配的配置则返回false + */ + default boolean switchover(String mchId) { + return false; + } + /** * 进行相应的商户切换. * @@ -87,6 +99,19 @@ public interface WxPayService { */ WxPayService switchoverTo(String mchId, String appId); + /** + * 仅根据商户号进行切换. + * 适用于一个商户号对应多个appId的场景,切换时会匹配符合该商户号的配置. + * 注意:由于HashMap迭代顺序不确定,当存在多个匹配项时返回的配置是不可预测的,建议使用精确匹配方式. + * + * @param mchId 商户标识 + * @return 切换成功,则返回当前对象,方便链式调用 + * @throws me.chanjar.weixin.common.error.WxRuntimeException 如果找不到匹配的配置 + */ + default WxPayService switchoverTo(String mchId) { + throw new me.chanjar.weixin.common.error.WxRuntimeException("子类需要实现此方法"); + } + /** * 发送post请求,得到响应字节数组. * diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/BaseWxPayServiceImpl.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/BaseWxPayServiceImpl.java index 5347099a0b..4b51c498d2 100644 --- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/BaseWxPayServiceImpl.java +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/BaseWxPayServiceImpl.java @@ -212,6 +212,34 @@ public boolean switchover(String mchId, String appId) { return false; } + @Override + public boolean switchover(String mchId) { + // 参数校验 + if (StringUtils.isBlank(mchId)) { + log.error("商户号mchId不能为空"); + return false; + } + + // 先尝试精确匹配(针对只有mchId没有appId的配置) + if (this.configMap.containsKey(mchId)) { + WxPayConfigHolder.set(mchId); + return true; + } + + // 尝试前缀匹配(查找以 mchId_ 开头的配置) + String prefix = mchId + "_"; + for (String key : this.configMap.keySet()) { + if (key.startsWith(prefix)) { + WxPayConfigHolder.set(key); + log.debug("根据mchId=【{}】找到配置key=【{}】", mchId, key); + return true; + } + } + + log.error("无法找到对应mchId=【{}】的商户号配置信息,请核实!", mchId); + return false; + } + @Override public WxPayService switchoverTo(String mchId, String appId) { String configKey = this.getConfigKey(mchId, appId); @@ -222,6 +250,32 @@ public WxPayService switchoverTo(String mchId, String appId) { throw new WxRuntimeException(String.format("无法找到对应mchId=【%s】,appId=【%s】的商户号配置信息,请核实!", mchId, appId)); } + @Override + public WxPayService switchoverTo(String mchId) { + // 参数校验 + if (StringUtils.isBlank(mchId)) { + throw new WxRuntimeException("商户号mchId不能为空"); + } + + // 先尝试精确匹配(针对只有mchId没有appId的配置) + if (this.configMap.containsKey(mchId)) { + WxPayConfigHolder.set(mchId); + return this; + } + + // 尝试前缀匹配(查找以 mchId_ 开头的配置) + String prefix = mchId + "_"; + for (String key : this.configMap.keySet()) { + if (key.startsWith(prefix)) { + WxPayConfigHolder.set(key); + log.debug("根据mchId=【{}】找到配置key=【{}】", mchId, key); + return this; + } + } + + throw new WxRuntimeException(String.format("无法找到对应mchId=【%s】的商户号配置信息,请核实!", mchId)); + } + public String getConfigKey(String mchId, String appId) { return mchId + "_" + appId; } diff --git a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/MultiAppIdSwitchoverManualTest.java b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/MultiAppIdSwitchoverManualTest.java new file mode 100644 index 0000000000..010f15fc69 --- /dev/null +++ b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/MultiAppIdSwitchoverManualTest.java @@ -0,0 +1,127 @@ +package com.github.binarywang.wxpay.service.impl; + +import com.github.binarywang.wxpay.config.WxPayConfig; +import com.github.binarywang.wxpay.service.WxPayService; + +import java.util.HashMap; +import java.util.Map; + +/** + * 手动验证多appId切换功能 + */ +public class MultiAppIdSwitchoverManualTest { + + public static void main(String[] args) { + WxPayService payService = new WxPayServiceImpl(); + + String testMchId = "1234567890"; + String testAppId1 = "wx1111111111111111"; + String testAppId2 = "wx2222222222222222"; + String testAppId3 = "wx3333333333333333"; + + // 配置同一个商户号,三个不同的appId + WxPayConfig config1 = new WxPayConfig(); + config1.setMchId(testMchId); + config1.setAppId(testAppId1); + config1.setMchKey("test_key_1"); + + WxPayConfig config2 = new WxPayConfig(); + config2.setMchId(testMchId); + config2.setAppId(testAppId2); + config2.setMchKey("test_key_2"); + + WxPayConfig config3 = new WxPayConfig(); + config3.setMchId(testMchId); + config3.setAppId(testAppId3); + config3.setMchKey("test_key_3"); + + Map configMap = new HashMap<>(); + configMap.put(testMchId + "_" + testAppId1, config1); + configMap.put(testMchId + "_" + testAppId2, config2); + configMap.put(testMchId + "_" + testAppId3, config3); + + payService.setMultiConfig(configMap); + + // 测试1: 使用 mchId + appId 精确切换 + System.out.println("=== 测试1: 使用 mchId + appId 精确切换 ==="); + boolean success = payService.switchover(testMchId, testAppId1); + System.out.println("切换结果: " + success); + System.out.println("当前配置 - MchId: " + payService.getConfig().getMchId() + ", AppId: " + payService.getConfig().getAppId() + ", MchKey: " + payService.getConfig().getMchKey()); + verify(success, "切换应该成功"); + verify(testAppId1.equals(payService.getConfig().getAppId()), "AppId应该是 " + testAppId1); + System.out.println("✓ 测试1通过\n"); + + // 测试2: 仅使用 mchId 切换 + System.out.println("=== 测试2: 仅使用 mchId 切换 ==="); + success = payService.switchover(testMchId); + System.out.println("切换结果: " + success); + System.out.println("当前配置 - MchId: " + payService.getConfig().getMchId() + ", AppId: " + payService.getConfig().getAppId() + ", MchKey: " + payService.getConfig().getMchKey()); + verify(success, "仅使用mchId切换应该成功"); + verify(testMchId.equals(payService.getConfig().getMchId()), "MchId应该是 " + testMchId); + System.out.println("✓ 测试2通过\n"); + + // 测试3: 使用 switchoverTo 链式调用(精确匹配) + System.out.println("=== 测试3: 使用 switchoverTo 链式调用(精确匹配) ==="); + WxPayService result = payService.switchoverTo(testMchId, testAppId2); + System.out.println("返回对象: " + (result == payService ? "同一实例" : "不同实例")); + System.out.println("当前配置 - MchId: " + payService.getConfig().getMchId() + ", AppId: " + payService.getConfig().getAppId() + ", MchKey: " + payService.getConfig().getMchKey()); + verify(result == payService, "应该返回同一实例"); + verify(testAppId2.equals(payService.getConfig().getAppId()), "AppId应该是 " + testAppId2); + System.out.println("✓ 测试3通过\n"); + + // 测试4: 使用 switchoverTo 链式调用(仅mchId) + System.out.println("=== 测试4: 使用 switchoverTo 链式调用(仅mchId) ==="); + result = payService.switchoverTo(testMchId); + System.out.println("返回对象: " + (result == payService ? "同一实例" : "不同实例")); + System.out.println("当前配置 - MchId: " + payService.getConfig().getMchId() + ", AppId: " + payService.getConfig().getAppId() + ", MchKey: " + payService.getConfig().getMchKey()); + verify(result == payService, "应该返回同一实例"); + verify(testMchId.equals(payService.getConfig().getMchId()), "MchId应该是 " + testMchId); + System.out.println("✓ 测试4通过\n"); + + // 测试5: 切换到不存在的商户号 + System.out.println("=== 测试5: 切换到不存在的商户号 ==="); + success = payService.switchover("nonexistent_mch_id"); + System.out.println("切换结果: " + success); + verify(!success, "切换到不存在的商户号应该失败"); + System.out.println("✓ 测试5通过\n"); + + // 测试6: 切换到不存在的 appId + System.out.println("=== 测试6: 切换到不存在的 appId ==="); + success = payService.switchover(testMchId, "wx9999999999999999"); + System.out.println("切换结果: " + success); + verify(!success, "切换到不存在的appId应该失败"); + System.out.println("✓ 测试6通过\n"); + + // 测试7: 添加新配置后切换 + System.out.println("=== 测试7: 添加新配置后切换 ==="); + String newAppId = "wx4444444444444444"; + WxPayConfig newConfig = new WxPayConfig(); + newConfig.setMchId(testMchId); + newConfig.setAppId(newAppId); + newConfig.setMchKey("test_key_4"); + payService.addConfig(testMchId, newAppId, newConfig); + + success = payService.switchover(testMchId, newAppId); + System.out.println("切换结果: " + success); + System.out.println("当前配置 - MchId: " + payService.getConfig().getMchId() + ", AppId: " + payService.getConfig().getAppId() + ", MchKey: " + payService.getConfig().getMchKey()); + verify(success, "切换到新添加的配置应该成功"); + verify(newAppId.equals(payService.getConfig().getAppId()), "AppId应该是 " + newAppId); + System.out.println("✓ 测试7通过\n"); + + System.out.println("=================="); + System.out.println("所有测试通过! ✓"); + System.out.println("=================="); + } + + /** + * 验证条件是否为真,如果为假则抛出异常 + * + * @param condition 待验证的条件 + * @param message 验证失败时的错误信息 + */ + private static void verify(boolean condition, String message) { + if (!condition) { + throw new RuntimeException("验证失败: " + message); + } + } +} diff --git a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/MultiAppIdSwitchoverTest.java b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/MultiAppIdSwitchoverTest.java new file mode 100644 index 0000000000..c1c1460fec --- /dev/null +++ b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/MultiAppIdSwitchoverTest.java @@ -0,0 +1,310 @@ +package com.github.binarywang.wxpay.service.impl; + +import com.github.binarywang.wxpay.config.WxPayConfig; +import com.github.binarywang.wxpay.service.WxPayService; +import me.chanjar.weixin.common.error.WxRuntimeException; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.testng.Assert.*; + +/** + * 测试一个商户号配置多个appId的场景 + * + * @author Binary Wang + */ +public class MultiAppIdSwitchoverTest { + + private WxPayService payService; + private final String testMchId = "1234567890"; + private final String testAppId1 = "wx1111111111111111"; + private final String testAppId2 = "wx2222222222222222"; + private final String testAppId3 = "wx3333333333333333"; + + @BeforeMethod + public void setup() { + payService = new WxPayServiceImpl(); + + // 配置同一个商户号,三个不同的appId + WxPayConfig config1 = new WxPayConfig(); + config1.setMchId(testMchId); + config1.setAppId(testAppId1); + config1.setMchKey("test_key_1"); + + WxPayConfig config2 = new WxPayConfig(); + config2.setMchId(testMchId); + config2.setAppId(testAppId2); + config2.setMchKey("test_key_2"); + + WxPayConfig config3 = new WxPayConfig(); + config3.setMchId(testMchId); + config3.setAppId(testAppId3); + config3.setMchKey("test_key_3"); + + Map configMap = new HashMap<>(); + configMap.put(testMchId + "_" + testAppId1, config1); + configMap.put(testMchId + "_" + testAppId2, config2); + configMap.put(testMchId + "_" + testAppId3, config3); + + payService.setMultiConfig(configMap); + } + + /** + * 测试使用 mchId + appId 精确切换(原有功能,确保向后兼容) + */ + @Test + public void testSwitchoverWithMchIdAndAppId() { + // 切换到第一个配置 + boolean success = payService.switchover(testMchId, testAppId1); + assertTrue(success); + assertEquals(payService.getConfig().getAppId(), testAppId1); + assertEquals(payService.getConfig().getMchKey(), "test_key_1"); + + // 切换到第二个配置 + success = payService.switchover(testMchId, testAppId2); + assertTrue(success); + assertEquals(payService.getConfig().getAppId(), testAppId2); + assertEquals(payService.getConfig().getMchKey(), "test_key_2"); + + // 切换到第三个配置 + success = payService.switchover(testMchId, testAppId3); + assertTrue(success); + assertEquals(payService.getConfig().getAppId(), testAppId3); + assertEquals(payService.getConfig().getMchKey(), "test_key_3"); + } + + /** + * 测试仅使用 mchId 切换(新功能) + * 应该能够成功切换到该商户号的某个配置 + */ + @Test + public void testSwitchoverWithMchIdOnly() { + // 仅使用商户号切换,应该能够成功切换到该商户号的某个配置 + boolean success = payService.switchover(testMchId); + assertTrue(success, "应该能够通过mchId切换配置"); + + // 验证配置确实是该商户号的配置之一 + WxPayConfig currentConfig = payService.getConfig(); + assertNotNull(currentConfig); + assertEquals(currentConfig.getMchId(), testMchId); + + // appId应该是三个中的一个 + String currentAppId = currentConfig.getAppId(); + assertTrue( + testAppId1.equals(currentAppId) || testAppId2.equals(currentAppId) || testAppId3.equals(currentAppId), + "当前appId应该是配置的appId之一" + ); + } + + /** + * 测试 switchoverTo 方法(带链式调用,使用 mchId + appId) + */ + @Test + public void testSwitchoverToWithMchIdAndAppId() { + WxPayService result = payService.switchoverTo(testMchId, testAppId2); + assertNotNull(result); + assertEquals(result, payService, "switchoverTo应该返回当前服务实例,支持链式调用"); + assertEquals(payService.getConfig().getAppId(), testAppId2); + } + + /** + * 测试 switchoverTo 方法(带链式调用,仅使用 mchId) + */ + @Test + public void testSwitchoverToWithMchIdOnly() { + WxPayService result = payService.switchoverTo(testMchId); + assertNotNull(result); + assertEquals(result, payService, "switchoverTo应该返回当前服务实例,支持链式调用"); + assertEquals(payService.getConfig().getMchId(), testMchId); + } + + /** + * 测试切换到不存在的商户号 + */ + @Test + public void testSwitchoverToNonexistentMchId() { + boolean success = payService.switchover("nonexistent_mch_id"); + assertFalse(success, "切换到不存在的商户号应该失败"); + } + + /** + * 测试 switchoverTo 切换到不存在的商户号(应该抛出异常) + */ + @Test(expectedExceptions = WxRuntimeException.class) + public void testSwitchoverToNonexistentMchIdThrowsException() { + payService.switchoverTo("nonexistent_mch_id"); + } + + /** + * 测试切换到不存在的 mchId + appId 组合 + */ + @Test + public void testSwitchoverToNonexistentAppId() { + boolean success = payService.switchover(testMchId, "wx9999999999999999"); + assertFalse(success, "切换到不存在的appId应该失败"); + } + + /** + * 测试添加配置后能够正常切换 + */ + @Test + public void testAddConfigAndSwitchover() { + String newAppId = "wx4444444444444444"; + + // 动态添加一个新的配置 + WxPayConfig newConfig = new WxPayConfig(); + newConfig.setMchId(testMchId); + newConfig.setAppId(newAppId); + newConfig.setMchKey("test_key_4"); + + payService.addConfig(testMchId, newAppId, newConfig); + + // 切换到新添加的配置 + boolean success = payService.switchover(testMchId, newAppId); + assertTrue(success); + assertEquals(payService.getConfig().getAppId(), newAppId); + assertEquals(payService.getConfig().getMchKey(), "test_key_4"); + + // 使用仅mchId切换也应该能够找到配置 + success = payService.switchover(testMchId); + assertTrue(success); + assertEquals(payService.getConfig().getMchId(), testMchId); + } + + /** + * 测试移除配置后切换 + */ + @Test + public void testRemoveConfigAndSwitchover() { + // 移除一个配置 + payService.removeConfig(testMchId, testAppId1); + + // 切换到已移除的配置应该失败 + boolean success = payService.switchover(testMchId, testAppId1); + assertFalse(success); + + // 但仍然能够切换到其他配置 + success = payService.switchover(testMchId, testAppId2); + assertTrue(success); + + // 使用仅mchId切换应该仍然有效(因为还有其他appId的配置) + success = payService.switchover(testMchId); + assertTrue(success); + } + + /** + * 测试单个配置的场景(确保向后兼容) + */ + @Test + public void testSingleConfig() { + WxPayService singlePayService = new WxPayServiceImpl(); + WxPayConfig singleConfig = new WxPayConfig(); + singleConfig.setMchId("single_mch_id"); + singleConfig.setAppId("single_app_id"); + singleConfig.setMchKey("single_key"); + + singlePayService.setConfig(singleConfig); + + // 直接获取配置应该成功 + assertEquals(singlePayService.getConfig().getMchId(), "single_mch_id"); + assertEquals(singlePayService.getConfig().getAppId(), "single_app_id"); + + // 使用精确匹配切换 + boolean success = singlePayService.switchover("single_mch_id", "single_app_id"); + assertTrue(success); + + // 使用仅mchId切换 + success = singlePayService.switchover("single_mch_id"); + assertTrue(success); + } + + /** + * 测试空参数或null参数的处理 + */ + @Test + public void testSwitchoverWithNullOrEmptyMchId() { + // 测试 null 参数 + boolean success = payService.switchover(null); + assertFalse(success, "使用null作为mchId应该返回false"); + + // 测试空字符串 + success = payService.switchover(""); + assertFalse(success, "使用空字符串作为mchId应该返回false"); + + // 测试空白字符串 + success = payService.switchover(" "); + assertFalse(success, "使用空白字符串作为mchId应该返回false"); + } + + /** + * 测试 switchoverTo 方法对空参数或null参数的处理 + */ + @Test(expectedExceptions = WxRuntimeException.class) + public void testSwitchoverToWithNullMchId() { + payService.switchoverTo((String) null); + } + + @Test(expectedExceptions = WxRuntimeException.class) + public void testSwitchoverToWithEmptyMchId() { + payService.switchoverTo(""); + } + + @Test(expectedExceptions = WxRuntimeException.class) + public void testSwitchoverToWithBlankMchId() { + payService.switchoverTo(" "); + } + + /** + * 测试商户号存在包含关系的场景 + * 例如同时配置 "123" 和 "1234",验证前缀匹配不会错误匹配 + */ + @Test + public void testSwitchoverWithOverlappingMchIds() { + WxPayService testService = new WxPayServiceImpl(); + + // 配置两个有包含关系的商户号 + String mchId1 = "123"; + String mchId2 = "1234"; + String appId1 = "wx_app_123"; + String appId2 = "wx_app_1234"; + + WxPayConfig config1 = new WxPayConfig(); + config1.setMchId(mchId1); + config1.setAppId(appId1); + config1.setMchKey("key_123"); + + WxPayConfig config2 = new WxPayConfig(); + config2.setMchId(mchId2); + config2.setAppId(appId2); + config2.setMchKey("key_1234"); + + Map configMap = new HashMap<>(); + configMap.put(mchId1 + "_" + appId1, config1); + configMap.put(mchId2 + "_" + appId2, config2); + testService.setMultiConfig(configMap); + + // 切换到 "123",应该只匹配 "123_wx_app_123" + boolean success = testService.switchover(mchId1); + assertTrue(success); + assertEquals(testService.getConfig().getMchId(), mchId1); + assertEquals(testService.getConfig().getAppId(), appId1); + + // 切换到 "1234",应该只匹配 "1234_wx_app_1234" + success = testService.switchover(mchId2); + assertTrue(success); + assertEquals(testService.getConfig().getMchId(), mchId2); + assertEquals(testService.getConfig().getAppId(), appId2); + + // 精确切换验证 + success = testService.switchover(mchId1, appId1); + assertTrue(success); + assertEquals(testService.getConfig().getAppId(), appId1); + + success = testService.switchover(mchId2, appId2); + assertTrue(success); + assertEquals(testService.getConfig().getAppId(), appId2); + } +}