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 21/94] =?UTF-8?q?:new:=20#3842=20=E3=80=90=E5=BE=AE?=
=?UTF-8?q?=E4=BF=A1=E6=94=AF=E4=BB=98=E3=80=91=E6=B7=BB=E5=8A=A0=20wx-jav?=
=?UTF-8?q?a-pay-multi-spring-boot-starter=20=E6=A8=A1=E5=9D=97=E6=94=AF?=
=?UTF-8?q?=E6=8C=81=E5=A4=9A=E5=85=AC=E4=BC=97=E5=8F=B7=E5=85=B3=E8=81=94?=
=?UTF-8?q?=E9=85=8D=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 22/94] =?UTF-8?q?:art:=20#3849=20=E3=80=90=E5=BE=AE?=
=?UTF-8?q?=E4=BF=A1=E6=94=AF=E4=BB=98=E3=80=91=E6=94=AF=E6=8C=81=E4=B8=80?=
=?UTF-8?q?=E4=B8=AA=E5=95=86=E6=88=B7=E5=8F=B7=E9=85=8D=E7=BD=AE=E5=A4=9A?=
=?UTF-8?q?=E4=B8=AA=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);
+ }
+}
From 373d9fa5f1de74700cb31e85b6a60a182aa839e1 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Mon, 19 Jan 2026 11:28:05 +0800
Subject: [PATCH 23/94] =?UTF-8?q?:art:=20#3840=20=E5=B0=8F=E7=A8=8B?=
=?UTF-8?q?=E5=BA=8F=E5=92=8C=E5=85=AC=E4=BC=97=E5=8F=B7=E7=9A=84=E5=A4=9A?=
=?UTF-8?q?=E7=A7=9F=E6=88=B7starter=E6=B7=BB=E5=8A=A0=E5=A4=9A=E7=A7=9F?=
=?UTF-8?q?=E6=88=B7=E5=85=B1=E4=BA=AB=E6=A8=A1=E5=BC=8F=E4=BB=A5=E4=BC=98?=
=?UTF-8?q?=E5=8C=96=E8=B5=84=E6=BA=90=E4=BD=BF=E7=94=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../MULTI_TENANT_MODE_IMPROVEMENT.md | 160 ++++++++++++++
.../MULTI_TENANT_MODE.md | 205 ++++++++++++++++++
.../services/AbstractWxMaConfiguration.java | 104 +++++++--
.../properties/WxMaMultiProperties.java | 24 ++
.../service/WxMaMultiServicesSharedImpl.java | 53 +++++
.../services/AbstractWxMpConfiguration.java | 110 ++++++++--
.../mp/properties/WxMpMultiProperties.java | 24 ++
.../service/WxMpMultiServicesSharedImpl.java | 53 +++++
8 files changed, 690 insertions(+), 43 deletions(-)
create mode 100644 spring-boot-starters/MULTI_TENANT_MODE_IMPROVEMENT.md
create mode 100644 spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/MULTI_TENANT_MODE.md
create mode 100644 spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/miniapp/service/WxMaMultiServicesSharedImpl.java
create mode 100644 spring-boot-starters/wx-java-mp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/mp/service/WxMpMultiServicesSharedImpl.java
diff --git a/spring-boot-starters/MULTI_TENANT_MODE_IMPROVEMENT.md b/spring-boot-starters/MULTI_TENANT_MODE_IMPROVEMENT.md
new file mode 100644
index 0000000000..6581f6207d
--- /dev/null
+++ b/spring-boot-starters/MULTI_TENANT_MODE_IMPROVEMENT.md
@@ -0,0 +1,160 @@
+# 多租户模式配置改进说明
+
+## 问题背景
+
+用户在 issue #3835 中提出了一个架构设计问题:
+
+> 基础 Wx 实现类中已经有 configMap 了,可以用 configMap 来存储不同的小程序配置。不同的配置,都是复用同一个 http 客户端。为什么在各个 spring-boot-starter 中又单独创建类来存储不同的配置?从 spring 的配置来看,http 客户端只有一个,不同小程序配置可以实现多租户,所以似乎没必要单独再建新类存放?重复创建,增加了 http 客户端的成本?直接使用 Wx 实现类中已经有 configMap 不是更好吗?
+
+## 解决方案
+
+从 4.8.0 版本开始,我们为多租户 Spring Boot Starter 提供了**两种实现模式**供用户选择:
+
+### 1. 隔离模式(ISOLATED,默认)
+
+**实现方式**:为每个租户创建独立的 WxService 实例,每个实例拥有独立的 HTTP 客户端。
+
+**优点**:
+- ✅ 线程安全,无需担心并发问题
+- ✅ 不依赖 ThreadLocal,适合异步/响应式编程
+- ✅ 租户间完全隔离,互不影响
+
+**缺点**:
+- ❌ 每个租户创建独立的 HTTP 客户端,资源占用较多
+- ❌ 适合租户数量不多的场景(建议 < 50 个租户)
+
+**代码实现**:`WxMaMultiServicesImpl`, `WxMpMultiServicesImpl` 等
+
+### 2. 共享模式(SHARED,新增)
+
+**实现方式**:使用单个 WxService 实例管理所有租户配置,通过 ThreadLocal 切换租户,所有租户共享同一个 HTTP 客户端。
+
+**优点**:
+- ✅ 共享 HTTP 客户端,大幅节省资源
+- ✅ 适合租户数量较多的场景(支持 100+ 租户)
+- ✅ 内存占用更小
+
+**缺点**:
+- ❌ 依赖 ThreadLocal 切换配置,在异步场景需要特别注意
+- ❌ 需要注意线程上下文传递
+
+**代码实现**:`WxMaMultiServicesSharedImpl`, `WxMpMultiServicesSharedImpl` 等
+
+## 使用方式
+
+### 配置示例
+
+```yaml
+wx:
+ ma: # 或 mp, cp, channel
+ apps:
+ tenant1:
+ app-id: wxd898fcb01713c555
+ app-secret: 47a2422a5d04a27e2b3ed1f1f0b0dbad
+ tenant2:
+ app-id: wx1234567890abcdef
+ app-secret: 1234567890abcdef1234567890abcdef
+
+ config-storage:
+ type: memory
+ http-client-type: http_client
+ # 多租户模式配置(新增)
+ multi-tenant-mode: shared # isolated(默认)或 shared
+```
+
+### 代码使用(两种模式代码完全相同)
+
+```java
+@RestController
+public class WxController {
+ @Autowired
+ private WxMaMultiServices wxMaMultiServices; // 或 WxMpMultiServices
+
+ @GetMapping("/api/{tenantId}")
+ public String handle(@PathVariable String tenantId) {
+ WxMaService wxService = wxMaMultiServices.getWxMaService(tenantId);
+ // 使用 wxService 调用微信 API
+ return wxService.getAccessToken();
+ }
+}
+```
+
+## 性能对比
+
+以 100 个租户为例:
+
+| 指标 | 隔离模式 | 共享模式 |
+|------|---------|---------|
+| HTTP 客户端数量 | 100 个 | 1 个 |
+| 内存占用(估算) | ~500MB | ~50MB |
+| 线程安全 | ✅ 完全安全 | ⚠️ 需注意异步场景 |
+| 性能 | 略高(无 ThreadLocal 切换) | 略低(有 ThreadLocal 切换) |
+| 适用场景 | 中小规模 | 大规模 |
+
+## 支持的模块
+
+目前已实现共享模式支持的模块:
+
+- ✅ **小程序(MiniApp)**:`wx-java-miniapp-multi-spring-boot-starter`
+- ✅ **公众号(MP)**:`wx-java-mp-multi-spring-boot-starter`
+
+后续版本将支持:
+- ⏳ 企业微信(CP)
+- ⏳ 视频号(Channel)
+- ⏳ 企业微信第三方应用(CP-TP)
+
+## 迁移指南
+
+### 从旧版本升级
+
+升级到 4.8.0+ 后:
+
+1. **默认行为不变**:如果不配置 `multi-tenant-mode`,将继续使用隔离模式(与旧版本行为一致)
+2. **向后兼容**:所有现有代码无需修改
+3. **可选升级**:如需节省资源,可配置 `multi-tenant-mode: shared` 启用共享模式
+
+### 选择建议
+
+**使用隔离模式(ISOLATED)的场景**:
+- 租户数量较少(< 50 个)
+- 使用异步编程、响应式编程
+- 对线程安全有严格要求
+- 对资源占用不敏感
+
+**使用共享模式(SHARED)的场景**:
+- 租户数量较多(> 50 个)
+- 同步编程场景
+- 对资源占用敏感
+- 可以接受 ThreadLocal 的约束
+
+## 注意事项
+
+### 共享模式下的异步编程
+
+如果使用共享模式,在异步编程时需要注意 ThreadLocal 的传递:
+
+```java
+// ❌ 错误:异步线程无法获取到正确的配置
+CompletableFuture.runAsync(() -> {
+ wxService.getUserService().getUserInfo(...); // 可能使用错误的租户配置
+});
+
+// ✅ 正确:在主线程获取必要信息,传递给异步线程
+String appId = wxService.getWxMaConfig().getAppid();
+CompletableFuture.runAsync(() -> {
+ log.info("AppId: {}", appId); // 使用已获取的配置信息
+});
+```
+
+## 详细文档
+
+- 小程序模块详细说明:[spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/MULTI_TENANT_MODE.md](spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/MULTI_TENANT_MODE.md)
+
+## 相关链接
+
+- Issue: [#3835](https://github.com/binarywang/WxJava/issues/3835)
+- Pull Request: [#3840](https://github.com/binarywang/WxJava/pull/3840)
+
+## 致谢
+
+感谢 issue 提出者对项目架构的深入思考和建议,这帮助我们提供了更灵活、更高效的多租户解决方案。
diff --git a/spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/MULTI_TENANT_MODE.md b/spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/MULTI_TENANT_MODE.md
new file mode 100644
index 0000000000..6dd1d110c3
--- /dev/null
+++ b/spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/MULTI_TENANT_MODE.md
@@ -0,0 +1,205 @@
+# 微信小程序多租户配置说明
+
+## 多租户模式对比
+
+从 4.8.0 版本开始,wx-java-miniapp-multi-spring-boot-starter 支持两种多租户实现模式:
+
+### 1. 隔离模式(ISOLATED,默认)
+
+每个租户创建独立的 `WxMaService` 实例,各自拥有独立的 HTTP 客户端。
+
+**优点:**
+- 线程安全,无需担心并发问题
+- 不依赖 ThreadLocal,适合异步/响应式编程
+- 租户间完全隔离,互不影响
+
+**缺点:**
+- 每个租户创建独立的 HTTP 客户端,资源占用较多
+- 适合租户数量不多的场景(建议 < 50 个租户)
+
+**适用场景:**
+- SaaS 应用,租户数量较少
+- 异步编程、响应式编程场景
+- 对线程安全有严格要求
+
+### 2. 共享模式(SHARED)
+
+使用单个 `WxMaService` 实例管理所有租户配置,所有租户共享同一个 HTTP 客户端。
+
+**优点:**
+- 共享 HTTP 客户端,大幅节省资源
+- 适合租户数量较多的场景(支持 100+ 租户)
+- 内存占用更小
+
+**缺点:**
+- 依赖 ThreadLocal 切换配置,在异步场景需要特别注意
+- 需要注意线程上下文传递
+
+**适用场景:**
+- 租户数量较多(> 50 个)
+- 同步编程场景
+- 对资源占用有严格要求
+
+## 配置方式
+
+### 使用隔离模式(默认)
+
+```yaml
+wx:
+ ma:
+ # 多租户配置
+ apps:
+ tenant1:
+ app-id: wxd898fcb01713c555
+ app-secret: 47a2422a5d04a27e2b3ed1f1f0b0dbad
+ token: aBcDeFg123456
+ aes-key: abcdefgh123456abcdefgh123456abc
+ tenant2:
+ app-id: wx1234567890abcdef
+ app-secret: 1234567890abcdef1234567890abcdef
+ token: token123
+ aes-key: aeskey123aeskey123aeskey123aes
+
+ # 配置存储(可选)
+ config-storage:
+ type: memory # memory, jedis, redisson, redis_template
+ http-client-type: http_client # http_client, ok_http, jodd_http
+ # multi-tenant-mode: isolated # 默认值,可以不配置
+```
+
+### 使用共享模式
+
+```yaml
+wx:
+ ma:
+ # 多租户配置
+ apps:
+ tenant1:
+ app-id: wxd898fcb01713c555
+ app-secret: 47a2422a5d04a27e2b3ed1f1f0b0dbad
+ tenant2:
+ app-id: wx1234567890abcdef
+ app-secret: 1234567890abcdef1234567890abcdef
+ # ... 可配置更多租户
+
+ # 配置存储
+ config-storage:
+ type: memory
+ http-client-type: http_client
+ multi-tenant-mode: shared # 启用共享模式
+```
+
+## 代码使用
+
+两种模式下的代码使用方式**完全相同**:
+
+```java
+@RestController
+@RequestMapping("/ma")
+public class MiniAppController {
+
+ @Autowired
+ private WxMaMultiServices wxMaMultiServices;
+
+ @GetMapping("/userInfo/{tenantId}")
+ public String getUserInfo(@PathVariable String tenantId, @RequestParam String code) {
+ // 获取指定租户的 WxMaService
+ WxMaService wxMaService = wxMaMultiServices.getWxMaService(tenantId);
+
+ try {
+ WxMaJscode2SessionResult session = wxMaService.jsCode2SessionInfo(code);
+ return "OpenId: " + session.getOpenid();
+ } catch (WxErrorException e) {
+ return "错误: " + e.getMessage();
+ }
+ }
+}
+```
+
+## 性能对比
+
+以 100 个租户为例:
+
+| 指标 | 隔离模式 | 共享模式 |
+|------|---------|---------|
+| HTTP 客户端数量 | 100 个 | 1 个 |
+| 内存占用(估算) | ~500MB | ~50MB |
+| 线程安全 | ✅ 完全安全 | ⚠️ 需注意异步场景 |
+| 性能 | 略高(无 ThreadLocal 切换) | 略低(有 ThreadLocal 切换) |
+| 适用场景 | 中小规模 | 大规模 |
+
+## 注意事项
+
+### 共享模式下的异步编程
+
+如果使用共享模式,在异步编程时需要注意 ThreadLocal 的传递:
+
+```java
+@Service
+public class MiniAppService {
+
+ @Autowired
+ private WxMaMultiServices wxMaMultiServices;
+
+ public void asyncOperation(String tenantId) {
+ WxMaService wxMaService = wxMaMultiServices.getWxMaService(tenantId);
+
+ // ❌ 错误:异步线程无法获取到正确的配置
+ CompletableFuture.runAsync(() -> {
+ // 这里 wxMaService.getWxMaConfig() 可能返回错误的配置
+ wxMaService.getUserService().getUserInfo(...);
+ });
+
+ // ✅ 正确:在主线程获取配置,传递给异步线程
+ WxMaConfig config = wxMaService.getWxMaConfig();
+ String appId = config.getAppid();
+ CompletableFuture.runAsync(() -> {
+ // 使用已获取的配置信息
+ log.info("AppId: {}", appId);
+ });
+ }
+}
+```
+
+### 动态添加/删除租户
+
+两种模式都支持运行时动态添加或删除租户配置。
+
+## 迁移指南
+
+如果您正在使用旧版本,升级到 4.8.0+ 后:
+
+1. **默认行为不变**:如果不配置 `multi-tenant-mode`,将继续使用隔离模式(与旧版本行为一致)
+2. **向后兼容**:所有现有代码无需修改
+3. **可选升级**:如需节省资源,可配置 `multi-tenant-mode: shared` 启用共享模式
+
+## 源码分析
+
+issue讨论地址:[#3835](https://github.com/binarywang/WxJava/issues/3835)
+
+### 为什么有两种设计?
+
+1. **基础实现类的 `configMap`**:
+ - 位置:`BaseWxMaServiceImpl`
+ - 特点:单个 Service 实例 + 多个配置 + ThreadLocal 切换
+ - 设计目的:支持在一个应用中管理多个小程序账号
+
+2. **Spring Boot Starter 的 `services` Map**:
+ - 位置:`WxMaMultiServicesImpl`
+ - 特点:多个 Service 实例 + 每个实例一个配置
+ - 设计目的:为 Spring Boot 提供更符合依赖注入风格的多租户支持
+
+### 新版本改进
+
+新版本通过配置项让用户自主选择实现方式:
+
+```
+用户 → WxMaMultiServices 接口
+ ↓
+ ┌────┴────┐
+ ↓ ↓
+隔离模式 共享模式
+(多Service) (单Service+configMap)
+```
+
+这样既保留了线程安全的优势(隔离模式),又提供了资源节省的选项(共享模式)。
diff --git a/spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/miniapp/configuration/services/AbstractWxMaConfiguration.java b/spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/miniapp/configuration/services/AbstractWxMaConfiguration.java
index 15e638f89e..fba9d875ee 100644
--- a/spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/miniapp/configuration/services/AbstractWxMaConfiguration.java
+++ b/spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/miniapp/configuration/services/AbstractWxMaConfiguration.java
@@ -4,6 +4,7 @@
import com.binarywang.spring.starter.wxjava.miniapp.properties.WxMaSingleProperties;
import com.binarywang.spring.starter.wxjava.miniapp.service.WxMaMultiServices;
import com.binarywang.spring.starter.wxjava.miniapp.service.WxMaMultiServicesImpl;
+import com.binarywang.spring.starter.wxjava.miniapp.service.WxMaMultiServicesSharedImpl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import cn.binarywang.wx.miniapp.api.WxMaService;
@@ -16,8 +17,10 @@
import org.apache.commons.lang3.StringUtils;
import java.util.Collection;
+import java.util.HashMap;
import java.util.Map;
import java.util.Set;
+import java.util.TreeMap;
import java.util.stream.Collectors;
/**
@@ -33,9 +36,10 @@ public abstract class AbstractWxMaConfiguration {
protected WxMaMultiServices wxMaMultiServices(WxMaMultiProperties wxMaMultiProperties) {
Map appsMap = wxMaMultiProperties.getApps();
if (appsMap == null || appsMap.isEmpty()) {
- log.warn("微信公众号应用参数未配置,通过 WxMaMultiServices#getWxMaService(\"tenantId\")获取实例将返回空");
+ log.warn("微信小程序应用参数未配置,通过 WxMaMultiServices#getWxMaService(\"tenantId\")获取实例将返回空");
return new WxMaMultiServicesImpl();
}
+
/**
* 校验 appId 是否唯一,避免使用 redis 缓存 token、ticket 时错乱。
*
@@ -49,12 +53,29 @@ protected WxMaMultiServices wxMaMultiServices(WxMaMultiProperties wxMaMultiPrope
.collect(Collectors.groupingBy(c -> c.getAppId() == null ? 0 : c.getAppId(), Collectors.counting()))
.entrySet().stream().anyMatch(e -> e.getValue() > 1);
if (multi) {
- throw new RuntimeException("请确保微信公众号配置 appId 的唯一性");
+ throw new RuntimeException("请确保微信小程序配置 appId 的唯一性");
}
}
- WxMaMultiServicesImpl services = new WxMaMultiServicesImpl();
+ // 根据配置选择多租户模式
+ WxMaMultiProperties.MultiTenantMode mode = wxMaMultiProperties.getConfigStorage().getMultiTenantMode();
+ if (mode == WxMaMultiProperties.MultiTenantMode.SHARED) {
+ return createSharedMultiServices(appsMap, wxMaMultiProperties);
+ } else {
+ return createIsolatedMultiServices(appsMap, wxMaMultiProperties);
+ }
+ }
+
+ /**
+ * 创建隔离模式的多租户服务(每个租户独立 WxMaService 实例)
+ */
+ private WxMaMultiServices createIsolatedMultiServices(
+ Map appsMap,
+ WxMaMultiProperties wxMaMultiProperties) {
+
+ WxMaMultiServicesImpl services = new WxMaMultiServicesImpl();
Set> entries = appsMap.entrySet();
+
for (Map.Entry entry : entries) {
String tenantId = entry.getKey();
WxMaSingleProperties wxMaSingleProperties = entry.getValue();
@@ -64,37 +85,63 @@ protected WxMaMultiServices wxMaMultiServices(WxMaMultiProperties wxMaMultiPrope
WxMaService wxMaService = this.wxMaService(storage, wxMaMultiProperties);
services.addWxMaService(tenantId, wxMaService);
}
+
+ log.info("微信小程序多租户服务初始化完成,使用隔离模式(ISOLATED),共配置 {} 个租户", appsMap.size());
return services;
}
/**
- * 配置 WxMaDefaultConfigImpl
- *
- * @param wxMaMultiProperties 参数
- * @return WxMaDefaultConfigImpl
+ * 创建共享模式的多租户服务(单个 WxMaService 实例管理多个配置)
*/
- protected abstract WxMaDefaultConfigImpl wxMaConfigStorage(WxMaMultiProperties wxMaMultiProperties);
-
- public WxMaService wxMaService(WxMaConfig wxMaConfig, WxMaMultiProperties wxMaMultiProperties) {
+ private WxMaMultiServices createSharedMultiServices(
+ Map appsMap,
+ WxMaMultiProperties wxMaMultiProperties) {
+
+ // 创建共享的 WxMaService 实例
WxMaMultiProperties.ConfigStorage storage = wxMaMultiProperties.getConfigStorage();
- WxMaMultiProperties.HttpClientType httpClientType = storage.getHttpClientType();
- WxMaService wxMaService;
+ WxMaService sharedService = createWxMaServiceByType(storage.getHttpClientType());
+ configureWxMaService(sharedService, storage);
+
+ // 准备所有租户的配置,使用 TreeMap 保证顺序一致性
+ Map configsMap = new HashMap<>();
+ String defaultTenantId = new TreeMap<>(appsMap).firstKey();
+
+ for (Map.Entry entry : appsMap.entrySet()) {
+ String tenantId = entry.getKey();
+ WxMaSingleProperties wxMaSingleProperties = entry.getValue();
+ WxMaDefaultConfigImpl config = this.wxMaConfigStorage(wxMaMultiProperties);
+ this.configApp(config, wxMaSingleProperties);
+ this.configHttp(config, storage);
+ configsMap.put(tenantId, config);
+ }
+
+ // 设置多配置到共享的 WxMaService
+ sharedService.setMultiConfigs(configsMap, defaultTenantId);
+
+ log.info("微信小程序多租户服务初始化完成,使用共享模式(SHARED),共配置 {} 个租户,共享一个 HTTP 客户端", appsMap.size());
+ return new WxMaMultiServicesSharedImpl(sharedService);
+ }
+
+ /**
+ * 根据类型创建 WxMaService 实例
+ */
+ private WxMaService createWxMaServiceByType(WxMaMultiProperties.HttpClientType httpClientType) {
switch (httpClientType) {
case OK_HTTP:
- wxMaService = new WxMaServiceOkHttpImpl();
- break;
+ return new WxMaServiceOkHttpImpl();
case JODD_HTTP:
- wxMaService = new WxMaServiceJoddHttpImpl();
- break;
+ return new WxMaServiceJoddHttpImpl();
case HTTP_CLIENT:
- wxMaService = new WxMaServiceHttpClientImpl();
- break;
+ return new WxMaServiceHttpClientImpl();
default:
- wxMaService = new WxMaServiceImpl();
- break;
+ return new WxMaServiceImpl();
}
+ }
- wxMaService.setWxMaConfig(wxMaConfig);
+ /**
+ * 配置 WxMaService 的通用参数
+ */
+ private void configureWxMaService(WxMaService wxMaService, WxMaMultiProperties.ConfigStorage storage) {
int maxRetryTimes = storage.getMaxRetryTimes();
if (maxRetryTimes < 0) {
maxRetryTimes = 0;
@@ -105,6 +152,21 @@ public WxMaService wxMaService(WxMaConfig wxMaConfig, WxMaMultiProperties wxMaMu
}
wxMaService.setRetrySleepMillis(retrySleepMillis);
wxMaService.setMaxRetryTimes(maxRetryTimes);
+ }
+
+ /**
+ * 配置 WxMaDefaultConfigImpl
+ *
+ * @param wxMaMultiProperties 参数
+ * @return WxMaDefaultConfigImpl
+ */
+ protected abstract WxMaDefaultConfigImpl wxMaConfigStorage(WxMaMultiProperties wxMaMultiProperties);
+
+ public WxMaService wxMaService(WxMaConfig wxMaConfig, WxMaMultiProperties wxMaMultiProperties) {
+ WxMaMultiProperties.ConfigStorage storage = wxMaMultiProperties.getConfigStorage();
+ WxMaService wxMaService = createWxMaServiceByType(storage.getHttpClientType());
+ wxMaService.setWxMaConfig(wxMaConfig);
+ configureWxMaService(wxMaService, storage);
return wxMaService;
}
diff --git a/spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/miniapp/properties/WxMaMultiProperties.java b/spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/miniapp/properties/WxMaMultiProperties.java
index 6dae33d584..201aceb8bf 100644
--- a/spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/miniapp/properties/WxMaMultiProperties.java
+++ b/spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/miniapp/properties/WxMaMultiProperties.java
@@ -116,6 +116,15 @@ public static class ConfigStorage implements Serializable {
*
*/
private int retrySleepMillis = 1000;
+
+ /**
+ * 多租户实现模式.
+ *
+ * - ISOLATED: 为每个租户创建独立的 WxMaService 实例(默认)
+ * - SHARED: 使用单个 WxMaService 实例管理所有租户配置,共享 HTTP 客户端
+ *
+ */
+ private MultiTenantMode multiTenantMode = MultiTenantMode.ISOLATED;
}
public enum StorageType {
@@ -151,4 +160,19 @@ public enum HttpClientType {
*/
JODD_HTTP
}
+
+ public enum MultiTenantMode {
+ /**
+ * 隔离模式:为每个租户创建独立的 WxMaService 实例.
+ * 优点:线程安全,不依赖 ThreadLocal
+ * 缺点:每个租户创建独立的 HTTP 客户端,资源占用较多
+ */
+ ISOLATED,
+ /**
+ * 共享模式:使用单个 WxMaService 实例管理所有租户配置.
+ * 优点:共享 HTTP 客户端,节省资源
+ * 缺点:依赖 ThreadLocal 切换配置,异步场景需注意
+ */
+ SHARED
+ }
}
diff --git a/spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/miniapp/service/WxMaMultiServicesSharedImpl.java b/spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/miniapp/service/WxMaMultiServicesSharedImpl.java
new file mode 100644
index 0000000000..40a01fb52e
--- /dev/null
+++ b/spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/miniapp/service/WxMaMultiServicesSharedImpl.java
@@ -0,0 +1,53 @@
+package com.binarywang.spring.starter.wxjava.miniapp.service;
+
+import cn.binarywang.wx.miniapp.api.WxMaService;
+import lombok.RequiredArgsConstructor;
+
+/**
+ * 微信小程序 {@link WxMaMultiServices} 共享式实现.
+ *
+ * 使用单个 WxMaService 实例管理多个租户配置,通过 switchover 切换租户。
+ * 相比 {@link WxMaMultiServicesImpl},此实现共享 HTTP 客户端,节省资源。
+ *
+ *
+ * 注意:由于使用 ThreadLocal 切换配置,在异步或多线程场景需要特别注意线程上下文切换。
+ *
+ *
+ * @author Binary Wang
+ * created on 2026/1/9
+ */
+@RequiredArgsConstructor
+public class WxMaMultiServicesSharedImpl implements WxMaMultiServices {
+ private final WxMaService sharedWxMaService;
+
+ @Override
+ public WxMaService getWxMaService(String tenantId) {
+ if (tenantId == null) {
+ return null;
+ }
+ // 使用 switchover 检查配置是否存在,保持与隔离模式 API 行为一致(不存在时返回 null)
+ if (!sharedWxMaService.switchover(tenantId)) {
+ return null;
+ }
+ return sharedWxMaService;
+ }
+
+ @Override
+ public void removeWxMaService(String tenantId) {
+ if (tenantId != null) {
+ sharedWxMaService.removeConfig(tenantId);
+ }
+ }
+
+ /**
+ * 添加租户配置到共享的 WxMaService 实例
+ *
+ * @param tenantId 租户 ID
+ * @param wxMaService 要添加配置的 WxMaService(仅使用其配置,不使用其实例)
+ */
+ public void addWxMaService(String tenantId, WxMaService wxMaService) {
+ if (tenantId != null && wxMaService != null) {
+ sharedWxMaService.addConfig(tenantId, wxMaService.getWxMaConfig());
+ }
+ }
+}
diff --git a/spring-boot-starters/wx-java-mp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/mp/configuration/services/AbstractWxMpConfiguration.java b/spring-boot-starters/wx-java-mp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/mp/configuration/services/AbstractWxMpConfiguration.java
index 1f431b645d..46724c625f 100644
--- a/spring-boot-starters/wx-java-mp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/mp/configuration/services/AbstractWxMpConfiguration.java
+++ b/spring-boot-starters/wx-java-mp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/mp/configuration/services/AbstractWxMpConfiguration.java
@@ -4,18 +4,25 @@
import com.binarywang.spring.starter.wxjava.mp.properties.WxMpSingleProperties;
import com.binarywang.spring.starter.wxjava.mp.service.WxMpMultiServices;
import com.binarywang.spring.starter.wxjava.mp.service.WxMpMultiServicesImpl;
+import com.binarywang.spring.starter.wxjava.mp.service.WxMpMultiServicesSharedImpl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.chanjar.weixin.mp.api.WxMpService;
-import me.chanjar.weixin.mp.api.impl.*;
+import me.chanjar.weixin.mp.api.impl.WxMpServiceHttpClientImpl;
+import me.chanjar.weixin.mp.api.impl.WxMpServiceHttpComponentsImpl;
+import me.chanjar.weixin.mp.api.impl.WxMpServiceImpl;
+import me.chanjar.weixin.mp.api.impl.WxMpServiceJoddHttpImpl;
+import me.chanjar.weixin.mp.api.impl.WxMpServiceOkHttpImpl;
import me.chanjar.weixin.mp.config.WxMpConfigStorage;
import me.chanjar.weixin.mp.config.WxMpHostConfig;
import me.chanjar.weixin.mp.config.impl.WxMpDefaultConfigImpl;
import org.apache.commons.lang3.StringUtils;
import java.util.Collection;
+import java.util.HashMap;
import java.util.Map;
import java.util.Set;
+import java.util.TreeMap;
import java.util.stream.Collectors;
/**
@@ -34,6 +41,7 @@ protected WxMpMultiServices wxMpMultiServices(WxMpMultiProperties wxMpMultiPrope
log.warn("微信公众号应用参数未配置,通过 WxMpMultiServices#getWxMpService(\"tenantId\")获取实例将返回空");
return new WxMpMultiServicesImpl();
}
+
/**
* 校验 appId 是否唯一,避免使用 redis 缓存 token、ticket 时错乱。
*
@@ -50,9 +58,26 @@ protected WxMpMultiServices wxMpMultiServices(WxMpMultiProperties wxMpMultiPrope
throw new RuntimeException("请确保微信公众号配置 appId 的唯一性");
}
}
- WxMpMultiServicesImpl services = new WxMpMultiServicesImpl();
+ // 根据配置选择多租户模式
+ WxMpMultiProperties.MultiTenantMode mode = wxMpMultiProperties.getConfigStorage().getMultiTenantMode();
+ if (mode == WxMpMultiProperties.MultiTenantMode.SHARED) {
+ return createSharedMultiServices(appsMap, wxMpMultiProperties);
+ } else {
+ return createIsolatedMultiServices(appsMap, wxMpMultiProperties);
+ }
+ }
+
+ /**
+ * 创建隔离模式的多租户服务(每个租户独立 WxMpService 实例)
+ */
+ private WxMpMultiServices createIsolatedMultiServices(
+ Map appsMap,
+ WxMpMultiProperties wxMpMultiProperties) {
+
+ WxMpMultiServicesImpl services = new WxMpMultiServicesImpl();
Set> entries = appsMap.entrySet();
+
for (Map.Entry entry : entries) {
String tenantId = entry.getKey();
WxMpSingleProperties wxMpSingleProperties = entry.getValue();
@@ -63,40 +88,66 @@ protected WxMpMultiServices wxMpMultiServices(WxMpMultiProperties wxMpMultiPrope
WxMpService wxMpService = this.wxMpService(storage, wxMpMultiProperties);
services.addWxMpService(tenantId, wxMpService);
}
+
+ log.info("微信公众号多租户服务初始化完成,使用隔离模式(ISOLATED),共配置 {} 个租户", appsMap.size());
return services;
}
/**
- * 配置 WxMpDefaultConfigImpl
- *
- * @param wxMpMultiProperties 参数
- * @return WxMpDefaultConfigImpl
+ * 创建共享模式的多租户服务(单个 WxMpService 实例管理多个配置)
*/
- protected abstract WxMpDefaultConfigImpl wxMpConfigStorage(WxMpMultiProperties wxMpMultiProperties);
-
- public WxMpService wxMpService(WxMpConfigStorage configStorage, WxMpMultiProperties wxMpMultiProperties) {
+ private WxMpMultiServices createSharedMultiServices(
+ Map appsMap,
+ WxMpMultiProperties wxMpMultiProperties) {
+
+ // 创建共享的 WxMpService 实例
WxMpMultiProperties.ConfigStorage storage = wxMpMultiProperties.getConfigStorage();
- WxMpMultiProperties.HttpClientType httpClientType = storage.getHttpClientType();
- WxMpService wxMpService;
+ WxMpService sharedService = createWxMpServiceByType(storage.getHttpClientType());
+ configureWxMpService(sharedService, storage);
+
+ // 准备所有租户的配置,使用 TreeMap 保证顺序一致性
+ Map configsMap = new HashMap<>();
+ String defaultTenantId = new TreeMap<>(appsMap).firstKey();
+
+ for (Map.Entry entry : appsMap.entrySet()) {
+ String tenantId = entry.getKey();
+ WxMpSingleProperties wxMpSingleProperties = entry.getValue();
+ WxMpDefaultConfigImpl config = this.wxMpConfigStorage(wxMpMultiProperties);
+ this.configApp(config, wxMpSingleProperties);
+ this.configHttp(config, storage);
+ this.configHost(config, wxMpMultiProperties.getHosts());
+ configsMap.put(tenantId, config);
+ }
+
+ // 设置多配置到共享的 WxMpService
+ sharedService.setMultiConfigStorages(configsMap, defaultTenantId);
+
+ log.info("微信公众号多租户服务初始化完成,使用共享模式(SHARED),共配置 {} 个租户,共享一个 HTTP 客户端", appsMap.size());
+ return new WxMpMultiServicesSharedImpl(sharedService);
+ }
+
+ /**
+ * 根据类型创建 WxMpService 实例
+ */
+ private WxMpService createWxMpServiceByType(WxMpMultiProperties.HttpClientType httpClientType) {
switch (httpClientType) {
case OK_HTTP:
- wxMpService = new WxMpServiceOkHttpImpl();
- break;
+ return new WxMpServiceOkHttpImpl();
case JODD_HTTP:
- wxMpService = new WxMpServiceJoddHttpImpl();
- break;
+ return new WxMpServiceJoddHttpImpl();
case HTTP_CLIENT:
- wxMpService = new WxMpServiceHttpClientImpl();
- break;
+ return new WxMpServiceHttpClientImpl();
case HTTP_COMPONENTS:
- wxMpService = new WxMpServiceHttpComponentsImpl();
- break;
+ return new WxMpServiceHttpComponentsImpl();
default:
- wxMpService = new WxMpServiceImpl();
- break;
+ return new WxMpServiceImpl();
}
+ }
- wxMpService.setWxMpConfigStorage(configStorage);
+ /**
+ * 配置 WxMpService 的通用参数
+ */
+ private void configureWxMpService(WxMpService wxMpService, WxMpMultiProperties.ConfigStorage storage) {
int maxRetryTimes = storage.getMaxRetryTimes();
if (maxRetryTimes < 0) {
maxRetryTimes = 0;
@@ -107,6 +158,21 @@ public WxMpService wxMpService(WxMpConfigStorage configStorage, WxMpMultiPropert
}
wxMpService.setRetrySleepMillis(retrySleepMillis);
wxMpService.setMaxRetryTimes(maxRetryTimes);
+ }
+
+ /**
+ * 配置 WxMpDefaultConfigImpl
+ *
+ * @param wxMpMultiProperties 参数
+ * @return WxMpDefaultConfigImpl
+ */
+ protected abstract WxMpDefaultConfigImpl wxMpConfigStorage(WxMpMultiProperties wxMpMultiProperties);
+
+ public WxMpService wxMpService(WxMpConfigStorage configStorage, WxMpMultiProperties wxMpMultiProperties) {
+ WxMpMultiProperties.ConfigStorage storage = wxMpMultiProperties.getConfigStorage();
+ WxMpService wxMpService = createWxMpServiceByType(storage.getHttpClientType());
+ wxMpService.setWxMpConfigStorage(configStorage);
+ configureWxMpService(wxMpService, storage);
return wxMpService;
}
diff --git a/spring-boot-starters/wx-java-mp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/mp/properties/WxMpMultiProperties.java b/spring-boot-starters/wx-java-mp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/mp/properties/WxMpMultiProperties.java
index 8b2fa58aa3..9dd95f9531 100644
--- a/spring-boot-starters/wx-java-mp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/mp/properties/WxMpMultiProperties.java
+++ b/spring-boot-starters/wx-java-mp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/mp/properties/WxMpMultiProperties.java
@@ -116,6 +116,15 @@ public static class ConfigStorage implements Serializable {
*
*/
private int retrySleepMillis = 1000;
+
+ /**
+ * 多租户实现模式.
+ *
+ * - ISOLATED: 为每个租户创建独立的 WxMpService 实例(默认)
+ * - SHARED: 使用单个 WxMpService 实例管理所有租户配置,共享 HTTP 客户端
+ *
+ */
+ private MultiTenantMode multiTenantMode = MultiTenantMode.ISOLATED;
}
public enum StorageType {
@@ -155,4 +164,19 @@ public enum HttpClientType {
*/
JODD_HTTP
}
+
+ public enum MultiTenantMode {
+ /**
+ * 隔离模式:为每个租户创建独立的 WxMpService 实例.
+ * 优点:线程安全,不依赖 ThreadLocal
+ * 缺点:每个租户创建独立的 HTTP 客户端,资源占用较多
+ */
+ ISOLATED,
+ /**
+ * 共享模式:使用单个 WxMpService 实例管理所有租户配置.
+ * 优点:共享 HTTP 客户端,节省资源
+ * 缺点:依赖 ThreadLocal 切换配置,异步场景需注意
+ */
+ SHARED
+ }
}
diff --git a/spring-boot-starters/wx-java-mp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/mp/service/WxMpMultiServicesSharedImpl.java b/spring-boot-starters/wx-java-mp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/mp/service/WxMpMultiServicesSharedImpl.java
new file mode 100644
index 0000000000..ca9123c572
--- /dev/null
+++ b/spring-boot-starters/wx-java-mp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/mp/service/WxMpMultiServicesSharedImpl.java
@@ -0,0 +1,53 @@
+package com.binarywang.spring.starter.wxjava.mp.service;
+
+import lombok.RequiredArgsConstructor;
+import me.chanjar.weixin.mp.api.WxMpService;
+
+/**
+ * 微信公众号 {@link WxMpMultiServices} 共享式实现.
+ *
+ * 使用单个 WxMpService 实例管理多个租户配置,通过 switchover 切换租户。
+ * 相比 {@link WxMpMultiServicesImpl},此实现共享 HTTP 客户端,节省资源。
+ *
+ *
+ * 注意:由于使用 ThreadLocal 切换配置,在异步或多线程场景需要特别注意线程上下文切换。
+ *
+ *
+ * @author Binary Wang
+ * created on 2026/1/9
+ */
+@RequiredArgsConstructor
+public class WxMpMultiServicesSharedImpl implements WxMpMultiServices {
+ private final WxMpService sharedWxMpService;
+
+ @Override
+ public WxMpService getWxMpService(String tenantId) {
+ if (tenantId == null) {
+ return null;
+ }
+ // 使用 switchover 检查配置是否存在,保持与隔离模式 API 行为一致(不存在时返回 null)
+ if (!sharedWxMpService.switchover(tenantId)) {
+ return null;
+ }
+ return sharedWxMpService;
+ }
+
+ @Override
+ public void removeWxMpService(String tenantId) {
+ if (tenantId != null) {
+ sharedWxMpService.removeConfigStorage(tenantId);
+ }
+ }
+
+ /**
+ * 添加租户配置到共享的 WxMpService 实例
+ *
+ * @param tenantId 租户 ID
+ * @param wxMpService 要添加配置的 WxMpService(仅使用其配置,不使用其实例)
+ */
+ public void addWxMpService(String tenantId, WxMpService wxMpService) {
+ if (tenantId != null && wxMpService != null) {
+ sharedWxMpService.addConfigStorage(tenantId, wxMpService.getWxMpConfigStorage());
+ }
+ }
+}
From e02f6d234c68fa323355416d44566d59c94c3120 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Tue, 20 Jan 2026 13:07:50 +0800
Subject: [PATCH 24/94] =?UTF-8?q?:new:=20#3862=20=E3=80=90=E5=BE=AE?=
=?UTF-8?q?=E4=BF=A1=E6=94=AF=E4=BB=98=E3=80=91=20=E5=A2=9E=E5=8A=A0V3?=
=?UTF-8?q?=E3=80=8C=E5=BE=AE=E5=B7=A5=E5=8D=A1=E3=80=8D=E7=9A=84=E6=89=B9?=
=?UTF-8?q?=E9=87=8F=E8=BD=AC=E8=B4=A6=E6=8E=A5=E5=8F=A3=E5=AE=9E=E7=8E=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../PayrollTransferBatchesRequest.java | 239 +++++++++++++++++
.../payroll/PayrollTransferBatchesResult.java | 241 ++++++++++++++++++
.../wxpay/service/PayrollService.java | 12 +
.../service/impl/PayrollServiceImpl.java | 23 ++
.../service/impl/PayrollServiceImplTest.java | 27 ++
5 files changed, 542 insertions(+)
create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/marketing/payroll/PayrollTransferBatchesRequest.java
create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/marketing/payroll/PayrollTransferBatchesResult.java
diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/marketing/payroll/PayrollTransferBatchesRequest.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/marketing/payroll/PayrollTransferBatchesRequest.java
new file mode 100644
index 0000000000..50954e70e5
--- /dev/null
+++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/marketing/payroll/PayrollTransferBatchesRequest.java
@@ -0,0 +1,239 @@
+package com.github.binarywang.wxpay.bean.marketing.payroll;
+
+import com.github.binarywang.wxpay.v3.SpecEncrypt;
+import com.google.gson.annotations.SerializedName;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ *
+ * 微工卡批量转账API请求参数
+ * 文档地址:https://pay.weixin.qq.com/wiki/doc/apiv3_partner/Offline/apis/chapter4_1_8.shtml
+ *
+ * 适用对象:服务商
+ * 请求URL:https://api.mch.weixin.qq.com/v3/payroll-card/transfer-batches
+ * 请求方式:POST
+ *
+ *
+ * @author binarywang
+ * created on 2025/01/19
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class PayrollTransferBatchesRequest implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ /**
+ *
+ * 字段名:应用ID
+ * 变量名:appid
+ * 是否必填:二选一
+ * 类型:string[1, 32]
+ * 描述:
+ * 服务商在微信申请公众号/小程序或移动应用成功后分配的账号ID
+ * 示例值:wxa1111111
+ *
+ */
+ @SerializedName(value = "appid")
+ private String appid;
+
+ /**
+ *
+ * 字段名:子商户应用ID
+ * 变量名:sub_appid
+ * 是否必填:二选一
+ * 类型:string[1, 32]
+ * 描述:
+ * 特约商户在微信申请公众号/小程序或移动应用成功后分配的账号ID
+ * 示例值:wxa1111111
+ *
+ */
+ @SerializedName(value = "sub_appid")
+ private String subAppid;
+
+ /**
+ *
+ * 字段名:子商户号
+ * 变量名:sub_mchid
+ * 是否必填:是
+ * 类型:string[1, 32]
+ * 描述:
+ * 微信服务商下特约商户的商户号,由微信支付生成并下发
+ * 示例值:1111111
+ *
+ */
+ @SerializedName(value = "sub_mchid")
+ private String subMchid;
+
+ /**
+ *
+ * 字段名:商家批次单号
+ * 变量名:out_batch_no
+ * 是否必填:是
+ * 类型:string[1, 32]
+ * 描述:
+ * 商户系统内部的商家批次单号,要求此参数只能由数字、大小写字母组成,在商户系统内部唯一
+ * 示例值:plfk2020042013
+ *
+ */
+ @SerializedName(value = "out_batch_no")
+ private String outBatchNo;
+
+ /**
+ *
+ * 字段名:批次名称
+ * 变量名:batch_name
+ * 是否必填:是
+ * 类型:string[1, 32]
+ * 描述:
+ * 该笔批量转账的名称
+ * 示例值:2019年1月深圳分部报销单
+ *
+ */
+ @SerializedName(value = "batch_name")
+ private String batchName;
+
+ /**
+ *
+ * 字段名:批次备注
+ * 变量名:batch_remark
+ * 是否必填:是
+ * 类型:string[1, 32]
+ * 描述:
+ * 转账说明,UTF8编码,最多允许32个字符
+ * 示例值:2019年1月深圳分部报销单
+ *
+ */
+ @SerializedName(value = "batch_remark")
+ private String batchRemark;
+
+ /**
+ *
+ * 字段名:转账总金额
+ * 变量名:total_amount
+ * 是否必填:是
+ * 类型:int64
+ * 描述:
+ * 转账金额单位为"分"。转账总金额必须与批次内所有明细转账金额之和保持一致,否则无法发起转账操作
+ * 示例值:4000000
+ *
+ */
+ @SerializedName(value = "total_amount")
+ private Long totalAmount;
+
+ /**
+ *
+ * 字段名:转账总笔数
+ * 变量名:total_num
+ * 是否必填:是
+ * 类型:int
+ * 描述:
+ * 一个转账批次单最多发起一千笔转账。转账总笔数必须与批次内所有明细之和保持一致,否则无法发起转账操作
+ * 示例值:200
+ *
+ */
+ @SerializedName(value = "total_num")
+ private Integer totalNum;
+
+ /**
+ *
+ * 字段名:转账明细列表
+ * 变量名:transfer_detail_list
+ * 是否必填:是
+ * 类型:array
+ * 描述:
+ * 发起批量转账的明细列表,最多一千笔
+ *
+ */
+ @SerializedName(value = "transfer_detail_list")
+ private List transferDetailList;
+
+ /**
+ * 转账明细
+ */
+ @Data
+ @Builder
+ @NoArgsConstructor
+ @AllArgsConstructor
+ public static class TransferDetail implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ /**
+ *
+ * 字段名:商家明细单号
+ * 变量名:out_detail_no
+ * 是否必填:是
+ * 类型:string[1, 32]
+ * 描述:
+ * 商户系统内部区分转账批次单下不同转账明细单的唯一标识
+ * 示例值:x23zy545Bd5436
+ *
+ */
+ @SerializedName(value = "out_detail_no")
+ private String outDetailNo;
+
+ /**
+ *
+ * 字段名:转账金额
+ * 变量名:transfer_amount
+ * 是否必填:是
+ * 类型:int64
+ * 描述:
+ * 转账金额单位为"分"
+ * 示例值:200000
+ *
+ */
+ @SerializedName(value = "transfer_amount")
+ private Long transferAmount;
+
+ /**
+ *
+ * 字段名:转账备注
+ * 变量名:transfer_remark
+ * 是否必填:是
+ * 类型:string[1, 32]
+ * 描述:
+ * 单条转账备注(微信用户会收到该备注),UTF8编码,最多允许32个字符
+ * 示例值:2020年4月报销
+ *
+ */
+ @SerializedName(value = "transfer_remark")
+ private String transferRemark;
+
+ /**
+ *
+ * 字段名:用户标识
+ * 变量名:openid
+ * 是否必填:是
+ * 类型:string[1, 64]
+ * 描述:
+ * 用户在商户对应appid下的唯一标识
+ * 示例值:o-MYE42l80oelYMDE34nYD456Xoy
+ *
+ */
+ @SerializedName(value = "openid")
+ private String openid;
+
+ /**
+ *
+ * 字段名:收款用户姓名
+ * 变量名:user_name
+ * 是否必填:否
+ * 类型:string[1, 1024]
+ * 描述:
+ * 收款用户真实姓名。该字段需进行加密处理,加密方法详见敏感信息加密说明
+ * 示例值:757b340b45ebef5467rter35gf464344v3542sdf4t6re4tb4f54ty45t4yyry45
+ *
+ */
+ @SpecEncrypt
+ @SerializedName(value = "user_name")
+ private String userName;
+ }
+}
diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/marketing/payroll/PayrollTransferBatchesResult.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/marketing/payroll/PayrollTransferBatchesResult.java
new file mode 100644
index 0000000000..628c75d5f7
--- /dev/null
+++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/marketing/payroll/PayrollTransferBatchesResult.java
@@ -0,0 +1,241 @@
+package com.github.binarywang.wxpay.bean.marketing.payroll;
+
+import com.google.gson.annotations.SerializedName;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serializable;
+
+/**
+ *
+ * 微工卡批量转账API返回结果
+ * 文档地址:https://pay.weixin.qq.com/wiki/doc/apiv3_partner/Offline/apis/chapter4_1_8.shtml
+ *
+ * 适用对象:服务商
+ * 请求URL:https://api.mch.weixin.qq.com/v3/payroll-card/transfer-batches
+ * 请求方式:POST
+ *
+ *
+ * @author binarywang
+ * created on 2025/01/19
+ */
+@Data
+@NoArgsConstructor
+public class PayrollTransferBatchesResult implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ /**
+ *
+ * 字段名:商家批次单号
+ * 变量名:out_batch_no
+ * 是否必填:是
+ * 类型:string[1, 32]
+ * 描述:
+ * 商户系统内部的商家批次单号
+ * 示例值:plfk2020042013
+ *
+ */
+ @SerializedName(value = "out_batch_no")
+ private String outBatchNo;
+
+ /**
+ *
+ * 字段名:微信批次单号
+ * 变量名:batch_id
+ * 是否必填:是
+ * 类型:string[1, 64]
+ * 描述:
+ * 微信批次单号,微信商家转账系统返回的唯一标识
+ * 示例值:1030000071100999991182020050700019480001
+ *
+ */
+ @SerializedName(value = "batch_id")
+ private String batchId;
+
+ /**
+ *
+ * 字段名:批次状态
+ * 变量名:batch_status
+ * 是否必填:是
+ * 类型:string[1, 32]
+ * 描述:
+ * ACCEPTED:已受理,批次已受理成功,若发起批量转账的30分钟后,转账批次单仍处于该状态,可能原因是商户账户余额不足等。商户可查询账户资金流水,若该笔转账批次单的扣款已经发生,则表示批次已经进入转账中,请再次查单确认
+ * PROCESSING:转账中,已开始处理批次内的转账明细单
+ * FINISHED:已完成,批次内的所有转账明细单都已处理完成
+ * CLOSED:已关闭,可查询具体的批次关闭原因确认
+ * 示例值:ACCEPTED
+ *
+ */
+ @SerializedName(value = "batch_status")
+ private String batchStatus;
+
+ /**
+ *
+ * 字段名:批次类型
+ * 变量名:batch_type
+ * 是否必填:是
+ * 类型:string[1, 32]
+ * 描述:
+ * 批次类型
+ * API:API方式发起
+ * WEB:WEB方式发起
+ * 示例值:API
+ *
+ */
+ @SerializedName(value = "batch_type")
+ private String batchType;
+
+ /**
+ *
+ * 字段名:批次名称
+ * 变量名:batch_name
+ * 是否必填:是
+ * 类型:string[1, 32]
+ * 描述:
+ * 该笔批量转账的名称
+ * 示例值:2019年1月深圳分部报销单
+ *
+ */
+ @SerializedName(value = "batch_name")
+ private String batchName;
+
+ /**
+ *
+ * 字段名:批次备注
+ * 变量名:batch_remark
+ * 是否必填:是
+ * 类型:string[1, 32]
+ * 描述:
+ * 转账说明,UTF8编码,最多允许32个字符
+ * 示例值:2019年1月深圳分部报销单
+ *
+ */
+ @SerializedName(value = "batch_remark")
+ private String batchRemark;
+
+ /**
+ *
+ * 字段名:批次关闭原因
+ * 变量名:close_reason
+ * 是否必填:否
+ * 类型:string[1, 32]
+ * 描述:
+ * 如果批次单状态为"CLOSED"(已关闭),则有关闭原因
+ * 示例值:OVERDUE_CLOSE
+ *
+ */
+ @SerializedName(value = "close_reason")
+ private String closeReason;
+
+ /**
+ *
+ * 字段名:转账总金额
+ * 变量名:total_amount
+ * 是否必填:是
+ * 类型:int64
+ * 描述:
+ * 转账金额单位为"分"
+ * 示例值:4000000
+ *
+ */
+ @SerializedName(value = "total_amount")
+ private Long totalAmount;
+
+ /**
+ *
+ * 字段名:转账总笔数
+ * 变量名:total_num
+ * 是否必填:是
+ * 类型:int
+ * 描述:
+ * 一个转账批次单最多发起一千笔转账
+ * 示例值:200
+ *
+ */
+ @SerializedName(value = "total_num")
+ private Integer totalNum;
+
+ /**
+ *
+ * 字段名:批次创建时间
+ * 变量名:create_time
+ * 是否必填:是
+ * 类型:string[1, 32]
+ * 描述:
+ * 批次受理成功时返回,遵循rfc3339标准格式,格式为YYYY-MM-DDTHH:mm:ss:sss+TIMEZONE
+ * 示例值:2015-05-20T13:29:35.120+08:00
+ *
+ */
+ @SerializedName(value = "create_time")
+ private String createTime;
+
+ /**
+ *
+ * 字段名:批次更新时间
+ * 变量名:update_time
+ * 是否必填:是
+ * 类型:string[1, 32]
+ * 描述:
+ * 批次最近一次状态变更的时间,遵循rfc3339标准格式,格式为YYYY-MM-DDTHH:mm:ss:sss+TIMEZONE
+ * 示例值:2015-05-20T13:29:35.120+08:00
+ *
+ */
+ @SerializedName(value = "update_time")
+ private String updateTime;
+
+ /**
+ *
+ * 字段名:转账成功金额
+ * 变量名:success_amount
+ * 是否必填:否
+ * 类型:int64
+ * 描述:
+ * 转账成功的金额,单位为"分"
+ * 示例值:3900000
+ *
+ */
+ @SerializedName(value = "success_amount")
+ private Long successAmount;
+
+ /**
+ *
+ * 字段名:转账成功笔数
+ * 变量名:success_num
+ * 是否必填:否
+ * 类型:int
+ * 描述:
+ * 转账成功的笔数
+ * 示例值:199
+ *
+ */
+ @SerializedName(value = "success_num")
+ private Integer successNum;
+
+ /**
+ *
+ * 字段名:转账失败金额
+ * 变量名:fail_amount
+ * 是否必填:否
+ * 类型:int64
+ * 描述:
+ * 转账失败的金额,单位为"分"
+ * 示例值:100000
+ *
+ */
+ @SerializedName(value = "fail_amount")
+ private Long failAmount;
+
+ /**
+ *
+ * 字段名:转账失败笔数
+ * 变量名:fail_num
+ * 是否必填:否
+ * 类型:int
+ * 描述:
+ * 转账失败的笔数
+ * 示例值:1
+ *
+ */
+ @SerializedName(value = "fail_num")
+ private Integer failNum;
+}
diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/PayrollService.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/PayrollService.java
index b3f788815c..581e3230b7 100644
--- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/PayrollService.java
+++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/PayrollService.java
@@ -101,4 +101,16 @@ public interface PayrollService {
*/
WxPayApplyBillV3Result merchantFundWithdrawBillType(String billType, String billDate, String tarType) throws WxPayException;
+ /**
+ * 微工卡批量转账API
+ * 适用对象:服务商
+ * 请求URL:https://api.mch.weixin.qq.com/v3/payroll-card/transfer-batches
+ * 请求方式:POST
+ *
+ * @param request 请求参数
+ * @return 返回数据
+ * @throws WxPayException the wx pay exception
+ */
+ PayrollTransferBatchesResult payrollCardTransferBatches(PayrollTransferBatchesRequest request) throws WxPayException;
+
}
diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/PayrollServiceImpl.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/PayrollServiceImpl.java
index 3d8c831271..85f7ee23dd 100644
--- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/PayrollServiceImpl.java
+++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/PayrollServiceImpl.java
@@ -193,4 +193,27 @@ public WxPayApplyBillV3Result merchantFundWithdrawBillType(String billType, Stri
return GSON.fromJson(response, WxPayApplyBillV3Result.class);
}
+ /**
+ * 微工卡批量转账API
+ * 适用对象:服务商
+ * 请求URL:https://api.mch.weixin.qq.com/v3/payroll-card/transfer-batches
+ * 请求方式:POST
+ *
+ * @param request 请求参数
+ * @return 返回数据
+ * @throws WxPayException the wx pay exception
+ */
+ @Override
+ public PayrollTransferBatchesResult payrollCardTransferBatches(PayrollTransferBatchesRequest request) throws WxPayException {
+ String url = String.format("%s/v3/payroll-card/transfer-batches", payService.getPayBaseUrl());
+ // 对敏感信息进行加密
+ if (request.getTransferDetailList() != null && !request.getTransferDetailList().isEmpty()) {
+ for (PayrollTransferBatchesRequest.TransferDetail detail : request.getTransferDetailList()) {
+ RsaCryptoUtil.encryptFields(detail, payService.getConfig().getVerifier().getValidCertificate());
+ }
+ }
+ String response = payService.postV3WithWechatpaySerial(url, GSON.toJson(request));
+ return GSON.fromJson(response, PayrollTransferBatchesResult.class);
+ }
+
}
diff --git a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/PayrollServiceImplTest.java b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/PayrollServiceImplTest.java
index 03bbc8c593..ce43aa3d04 100644
--- a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/PayrollServiceImplTest.java
+++ b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/PayrollServiceImplTest.java
@@ -14,6 +14,8 @@
import org.testng.annotations.Guice;
import org.testng.annotations.Test;
+import java.util.Collections;
+
/**
* 微工卡(服务商)
*
@@ -125,4 +127,29 @@ public void merchantFundWithdrawBillType() throws WxPayException {
log.info(result.toString());
}
+ @Test
+ public void payrollCardTransferBatches() throws WxPayException {
+ PayrollTransferBatchesRequest request = PayrollTransferBatchesRequest.builder()
+ .appid("wxa1111111")
+ .subMchid("1111111")
+ .subAppid("wxa1111111")
+ .outBatchNo("plfk2020042013" + System.currentTimeMillis())
+ .batchName("2019年1月深圳分部报销单")
+ .batchRemark("2019年1月深圳分部报销单")
+ .totalAmount(200000L)
+ .totalNum(1)
+ .transferDetailList(Collections.singletonList(
+ PayrollTransferBatchesRequest.TransferDetail.builder()
+ .outDetailNo("x23zy545Bd5436" + System.currentTimeMillis())
+ .transferAmount(200000L)
+ .transferRemark("2020年4月报销")
+ .openid("o-MYE42l80oelYMDE34nYD456Xoy")
+ .userName("张三")
+ .build()
+ ))
+ .build();
+ PayrollTransferBatchesResult result = wxPayService.getPayrollService().payrollCardTransferBatches(request);
+ log.info(result.toString());
+ }
+
}
From ae2aa43190b53ac69c3618b117b815949517d61f Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Tue, 20 Jan 2026 13:36:30 +0800
Subject: [PATCH 25/94] =?UTF-8?q?:art:=20#3861=20=E3=80=90=E5=BE=AE?=
=?UTF-8?q?=E4=BF=A1=E6=94=AF=E4=BB=98=E3=80=91=E5=BE=AE=E5=B7=A5=E5=8D=A1?=
=?UTF-8?q?=E6=A0=B8=E8=BA=AB=E9=A2=84=E4=B8=8B=E5=8D=95=E6=8E=A5=E5=8F=A3?=
=?UTF-8?q?=E7=9A=84=E8=AF=B7=E6=B1=82=E7=B1=BB=E6=B7=BB=E5=8A=A0=E7=BC=BA?=
=?UTF-8?q?=E5=A4=B1=E7=9A=84=E6=A0=B8=E8=BA=AB=E7=B1=BB=E5=9E=8B=E5=AD=97?=
=?UTF-8?q?=E6=AE=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../payroll/PreOrderWithAuthRequest.java | 18 ++++++++++++++++++
.../service/impl/PayrollServiceImplTest.java | 1 +
2 files changed, 19 insertions(+)
diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/marketing/payroll/PreOrderWithAuthRequest.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/marketing/payroll/PreOrderWithAuthRequest.java
index 1556fbc343..0e20fc8fa6 100644
--- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/marketing/payroll/PreOrderWithAuthRequest.java
+++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/marketing/payroll/PreOrderWithAuthRequest.java
@@ -166,4 +166,22 @@ public class PreOrderWithAuthRequest implements Serializable {
*/
@SerializedName(value = "employment_type")
private String employmentType;
+
+ /**
+ *
+ * 字段名:核身类型
+ * 变量名:authenticate_type
+ * 是否必填:否
+ * 类型:string[1,32]
+ * 描述:
+ * 核身类型,用于标识本次核身的业务类型;枚举值:
+ * NORMAL_AUTHENTICATE:普通核身
+ * LOGIN_AUTHENTICATE:登录核身
+ * INSURANCE_AUTHENTICATE:保险核身
+ * CONTRACT_AUTHENTICATE:合同核身
+ * 示例值:NORMAL_AUTHENTICATE
+ *
+ */
+ @SerializedName(value = "authenticate_type")
+ private String authenticateType;
}
diff --git a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/PayrollServiceImplTest.java b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/PayrollServiceImplTest.java
index ce43aa3d04..20bb33d7fd 100644
--- a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/PayrollServiceImplTest.java
+++ b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/PayrollServiceImplTest.java
@@ -114,6 +114,7 @@ public void payrollCardPreOrderWithAuth() throws WxPayException {
request.setIdCardNumber("7FzH5XksJG3a8HLLsaaUV6K54y1OnPMY5");
request.setProjectName("某项目");
request.setUserName("LP7bT4hQXUsOZCEvK2YrSiqFsnP0oRMfeoLN0vBg");
+ request.setAuthenticateType("NORMAL_AUTHENTICATE");
PreOrderWithAuthResult preOrderWithAuthResult = wxPayService.getPayrollService().payrollCardPreOrderWithAuth(request);
log.info(preOrderWithAuthResult.toString());
From 28a0d6e8eea226b15d4f1ea7354cca8244a03beb Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Tue, 20 Jan 2026 13:37:39 +0800
Subject: [PATCH 26/94] =?UTF-8?q?:art:=20#3860=20=E3=80=90=E5=BE=AE?=
=?UTF-8?q?=E4=BF=A1=E6=94=AF=E4=BB=98=E3=80=91=E8=BD=AC=E8=B4=A6=E5=88=B0?=
=?UTF-8?q?=E9=93=B6=E8=A1=8C=E5=8D=A1=E7=9A=84=E6=9F=A5=E8=AF=A2=E6=8E=A5?=
=?UTF-8?q?=E5=8F=A3=E5=A2=9E=E5=8A=A0=20bank=5Fname=20=E5=92=8C=20bank=5F?=
=?UTF-8?q?card=5Fnumber=5Ftail=20=E5=AD=97=E6=AE=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../transfer/BatchDetailsResult.java | 26 +++
.../transfer/BatchDetailsResultTest.java | 182 ++++++++++++++++++
2 files changed, 208 insertions(+)
create mode 100644 weixin-java-pay/src/test/java/com/github/binarywang/wxpay/bean/marketing/transfer/BatchDetailsResultTest.java
diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/marketing/transfer/BatchDetailsResult.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/marketing/transfer/BatchDetailsResult.java
index 4ca7958ed5..854fd6ba5a 100644
--- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/marketing/transfer/BatchDetailsResult.java
+++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/marketing/transfer/BatchDetailsResult.java
@@ -235,4 +235,30 @@ public String toString() {
*/
@SerializedName(value = "update_time")
private String updateTime;
+ /**
+ *
+ * 字段名:开户银行全称(含支行)
+ * 变量名:bank_name
+ * 是否必填:否
+ * 类型:string[1, 128]
+ * 描述:
+ * 转账到银行卡时返回,开户银行全称(含支行)
+ * 示例值:中国农业银行股份有限公司深圳分行
+ *
+ */
+ @SerializedName(value = "bank_name")
+ private String bankName;
+ /**
+ *
+ * 字段名:银行卡号后四位
+ * 变量名:bank_card_number_tail
+ * 是否必填:否
+ * 类型:string[4, 4]
+ * 描述:
+ * 转账到银行卡时返回,用于标识银行卡的后四位
+ * 示例值:1234
+ *
+ */
+ @SerializedName(value = "bank_card_number_tail")
+ private String bankCardNumberTail;
}
diff --git a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/bean/marketing/transfer/BatchDetailsResultTest.java b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/bean/marketing/transfer/BatchDetailsResultTest.java
new file mode 100644
index 0000000000..c2347300a6
--- /dev/null
+++ b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/bean/marketing/transfer/BatchDetailsResultTest.java
@@ -0,0 +1,182 @@
+package com.github.binarywang.wxpay.bean.marketing.transfer;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.*;
+
+/**
+ * 测试 BatchDetailsResult 的字段序列化和反序列化功能
+ *
+ * @author Binary Wang
+ */
+public class BatchDetailsResultTest {
+
+ private static final Gson GSON = new GsonBuilder().create();
+
+ @Test
+ public void testBankFieldsDeserialization() {
+ // 模拟微信API返回的JSON(包含银行相关字段)
+ String mockJson = "{\n" +
+ " \"sp_mchid\": \"1900001109\",\n" +
+ " \"out_batch_no\": \"plfk2020042013\",\n" +
+ " \"batch_id\": \"1030000071100999991182020050700019480001\",\n" +
+ " \"appid\": \"wxf636efh567hg4356\",\n" +
+ " \"out_detail_no\": \"x23zy545Bd5436\",\n" +
+ " \"detail_id\": \"1040000071100999991182020050700019500100\",\n" +
+ " \"detail_status\": \"SUCCESS\",\n" +
+ " \"transfer_amount\": 200000,\n" +
+ " \"transfer_remark\": \"2020年4月报销\",\n" +
+ " \"openid\": \"o-MYE42l80oelYMDE34nYD456Xoy\",\n" +
+ " \"username\": \"757b340b45ebef5467rter35gf464344v3542sdf4t6re4tb4f54ty45t4yyry45\",\n" +
+ " \"initiate_time\": \"2015-05-20T13:29:35.120+08:00\",\n" +
+ " \"update_time\": \"2015-05-20T13:29:35.120+08:00\",\n" +
+ " \"bank_name\": \"中国农业银行股份有限公司深圳分行\",\n" +
+ " \"bank_card_number_tail\": \"1234\"\n" +
+ "}";
+
+ // 反序列化JSON
+ BatchDetailsResult result = GSON.fromJson(mockJson, BatchDetailsResult.class);
+
+ // 验证基本字段正常解析
+ assertEquals(result.getSpMchid(), "1900001109");
+ assertEquals(result.getOutBatchNo(), "plfk2020042013");
+ assertEquals(result.getBatchId(), "1030000071100999991182020050700019480001");
+ assertEquals(result.getAppId(), "wxf636efh567hg4356");
+ assertEquals(result.getOutDetailNo(), "x23zy545Bd5436");
+ assertEquals(result.getDetailId(), "1040000071100999991182020050700019500100");
+ assertEquals(result.getDetailStatus(), "SUCCESS");
+ assertEquals(result.getTransferAmount(), Integer.valueOf(200000));
+ assertEquals(result.getTransferRemark(), "2020年4月报销");
+ assertEquals(result.getOpenid(), "o-MYE42l80oelYMDE34nYD456Xoy");
+ assertEquals(result.getUserName(), "757b340b45ebef5467rter35gf464344v3542sdf4t6re4tb4f54ty45t4yyry45");
+ assertEquals(result.getInitiateTime(), "2015-05-20T13:29:35.120+08:00");
+ assertEquals(result.getUpdateTime(), "2015-05-20T13:29:35.120+08:00");
+
+ // 验证新增的银行相关字段
+ assertEquals(result.getBankName(), "中国农业银行股份有限公司深圳分行");
+ assertEquals(result.getBankCardNumberTail(), "1234");
+ }
+
+ @Test
+ public void testBankFieldsWithNull() {
+ // 测试不包含银行字段的情况(转账到零钱)
+ String mockJsonWithoutBank = "{\n" +
+ " \"sp_mchid\": \"1900001109\",\n" +
+ " \"out_batch_no\": \"plfk2020042013\",\n" +
+ " \"batch_id\": \"1030000071100999991182020050700019480001\",\n" +
+ " \"out_detail_no\": \"x23zy545Bd5436\",\n" +
+ " \"detail_id\": \"1040000071100999991182020050700019500100\",\n" +
+ " \"detail_status\": \"SUCCESS\",\n" +
+ " \"transfer_amount\": 200000,\n" +
+ " \"transfer_remark\": \"2020年4月报销\",\n" +
+ " \"openid\": \"o-MYE42l80oelYMDE34nYD456Xoy\",\n" +
+ " \"username\": \"757b340b45ebef5467rter35gf464344v3542sdf4t6re4tb4f54ty45t4yyry45\",\n" +
+ " \"initiate_time\": \"2015-05-20T13:29:35.120+08:00\",\n" +
+ " \"update_time\": \"2015-05-20T13:29:35.120+08:00\"\n" +
+ "}";
+
+ BatchDetailsResult result = GSON.fromJson(mockJsonWithoutBank, BatchDetailsResult.class);
+
+ // 验证其他字段正常
+ assertEquals(result.getSpMchid(), "1900001109");
+ assertEquals(result.getDetailStatus(), "SUCCESS");
+
+ // 验证银行字段为null(转账到零钱场景下不返回这些字段)
+ assertNull(result.getBankName());
+ assertNull(result.getBankCardNumberTail());
+ }
+
+ @Test
+ public void testBankFieldsSerialization() {
+ // 测试序列化
+ BatchDetailsResult result = new BatchDetailsResult();
+ result.setSpMchid("1900001109");
+ result.setOutBatchNo("plfk2020042013");
+ result.setBatchId("1030000071100999991182020050700019480001");
+ result.setDetailStatus("SUCCESS");
+ result.setBankName("中国工商银行股份有限公司北京分行");
+ result.setBankCardNumberTail("5678");
+
+ String json = GSON.toJson(result);
+
+ // 验证JSON包含银行字段
+ assertTrue(json.contains("\"bank_name\":\"中国工商银行股份有限公司北京分行\""));
+ assertTrue(json.contains("\"bank_card_number_tail\":\"5678\""));
+ }
+
+ @Test
+ public void testToString() {
+ // 测试toString方法
+ BatchDetailsResult result = new BatchDetailsResult();
+ result.setSpMchid("1900001109");
+ result.setBankName("中国建设银行股份有限公司上海分行");
+ result.setBankCardNumberTail("9012");
+
+ String resultString = result.toString();
+
+ // 验证toString包含所有字段
+ assertNotNull(resultString);
+ assertTrue(resultString.contains("1900001109"));
+ assertTrue(resultString.contains("中国建设银行股份有限公司上海分行"));
+ assertTrue(resultString.contains("9012"));
+ }
+
+ @Test
+ public void testBankNameWithSpecialCharacters() {
+ // 测试银行名称包含特殊字符的情况
+ String mockJson = "{\n" +
+ " \"sp_mchid\": \"1900001109\",\n" +
+ " \"out_batch_no\": \"plfk2020042013\",\n" +
+ " \"batch_id\": \"1030000071100999991182020050700019480001\",\n" +
+ " \"out_detail_no\": \"x23zy545Bd5436\",\n" +
+ " \"detail_id\": \"1040000071100999991182020050700019500100\",\n" +
+ " \"detail_status\": \"SUCCESS\",\n" +
+ " \"transfer_amount\": 200000,\n" +
+ " \"transfer_remark\": \"2020年4月报销\",\n" +
+ " \"openid\": \"o-MYE42l80oelYMDE34nYD456Xoy\",\n" +
+ " \"username\": \"757b340b45ebef5467rter35gf464344v3542sdf4t6re4tb4f54ty45t4yyry45\",\n" +
+ " \"initiate_time\": \"2015-05-20T13:29:35.120+08:00\",\n" +
+ " \"update_time\": \"2015-05-20T13:29:35.120+08:00\",\n" +
+ " \"bank_name\": \"中国农业银行股份有限公司北京市朝阳区(支行)\",\n" +
+ " \"bank_card_number_tail\": \"0000\"\n" +
+ "}";
+
+ BatchDetailsResult result = GSON.fromJson(mockJson, BatchDetailsResult.class);
+
+ // 验证特殊字符正确解析
+ assertEquals(result.getBankName(), "中国农业银行股份有限公司北京市朝阳区(支行)");
+ assertEquals(result.getBankCardNumberTail(), "0000");
+ }
+
+ @Test
+ public void testFailedTransferWithoutBankFields() {
+ // 测试转账失败的情况
+ String mockJson = "{\n" +
+ " \"sp_mchid\": \"1900001109\",\n" +
+ " \"out_batch_no\": \"plfk2020042013\",\n" +
+ " \"batch_id\": \"1030000071100999991182020050700019480001\",\n" +
+ " \"out_detail_no\": \"x23zy545Bd5436\",\n" +
+ " \"detail_id\": \"1040000071100999991182020050700019500100\",\n" +
+ " \"detail_status\": \"FAIL\",\n" +
+ " \"transfer_amount\": 200000,\n" +
+ " \"transfer_remark\": \"2020年4月报销\",\n" +
+ " \"fail_reason\": \"ACCOUNT_FROZEN\",\n" +
+ " \"openid\": \"o-MYE42l80oelYMDE34nYD456Xoy\",\n" +
+ " \"username\": \"757b340b45ebef5467rter35gf464344v3542sdf4t6re4tb4f54ty45t4yyry45\",\n" +
+ " \"initiate_time\": \"2015-05-20T13:29:35.120+08:00\",\n" +
+ " \"update_time\": \"2015-05-20T13:29:35.120+08:00\"\n" +
+ "}";
+
+ BatchDetailsResult result = GSON.fromJson(mockJson, BatchDetailsResult.class);
+
+ // 验证失败状态
+ assertEquals(result.getDetailStatus(), "FAIL");
+ assertEquals(result.getFailReason(), "ACCOUNT_FROZEN");
+
+ // 失败的情况下银行字段应为null
+ assertNull(result.getBankName());
+ assertNull(result.getBankCardNumberTail());
+ }
+}
From a23429c144e38e50966a84857555bd95e7e5e17b Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Tue, 20 Jan 2026 13:39:41 +0800
Subject: [PATCH 27/94] =?UTF-8?q?:art:=20#3859=20=E3=80=90=E4=BC=81?=
=?UTF-8?q?=E4=B8=9A=E5=BE=AE=E4=BF=A1=E3=80=91=E5=AE=A1=E6=89=B9=E8=AF=A6?=
=?UTF-8?q?=E6=83=85=E6=8E=A5=E5=8F=A3=E5=A2=9E=E5=8A=A0=E6=80=BB=E8=B4=B9?=
=?UTF-8?q?=E7=94=A8=E9=87=91=E9=A2=9D=E5=AD=97=E6=AE=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../cp/bean/oa/WxCpApprovalDetailResult.java | 6 ++
.../cp/api/impl/WxCpOaServiceImplTest.java | 74 +++++++++++++++++++
2 files changed, 80 insertions(+)
diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/oa/WxCpApprovalDetailResult.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/oa/WxCpApprovalDetailResult.java
index 7d55ff878f..fe77fcaeac 100644
--- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/oa/WxCpApprovalDetailResult.java
+++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/oa/WxCpApprovalDetailResult.java
@@ -91,6 +91,12 @@ public static class WxCpApprovalDetail implements Serializable {
@SerializedName("comments")
private List comments;
+ /**
+ * 审批单据的总金额(单位:分),当审批单包含费用相关控件时返回
+ */
+ @SerializedName("sum_money")
+ private Long sumMoney;
+
}
}
diff --git a/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/impl/WxCpOaServiceImplTest.java b/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/impl/WxCpOaServiceImplTest.java
index f722a248d3..c7cc048db5 100644
--- a/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/impl/WxCpOaServiceImplTest.java
+++ b/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/impl/WxCpOaServiceImplTest.java
@@ -443,6 +443,80 @@ public void testGetApprovalDetail() throws WxErrorException {
System.out.println(gson.toJson(result));
}
+ /**
+ * Test sum_money field deserialization in approval detail.
+ * 测试审批详情中总费用金额字段的反序列化
+ */
+ @Test
+ public void testApprovalDetailSumMoney() {
+ // 测试包含总费用金额的审批详情JSON
+ String jsonWithSumMoney = "{\n" +
+ " \"errcode\": 0,\n" +
+ " \"errmsg\": \"ok\",\n" +
+ " \"info\": {\n" +
+ " \"sp_no\": \"202601140001\",\n" +
+ " \"sp_name\": \"报销申请\",\n" +
+ " \"sp_status\": 2,\n" +
+ " \"template_id\": \"test_template_id\",\n" +
+ " \"apply_time\": 1610000000,\n" +
+ " \"applyer\": {\n" +
+ " \"userid\": \"test_user\",\n" +
+ " \"partyid\": \"1\"\n" +
+ " },\n" +
+ " \"sp_record\": [],\n" +
+ " \"notifyer\": [],\n" +
+ " \"apply_data\": {\n" +
+ " \"contents\": []\n" +
+ " },\n" +
+ " \"comments\": [],\n" +
+ " \"sum_money\": 100000\n" +
+ " }\n" +
+ "}";
+
+ WxCpApprovalDetailResult result = WxCpGsonBuilder.create().fromJson(jsonWithSumMoney, WxCpApprovalDetailResult.class);
+ assertThat(result).isNotNull();
+ assertThat(result.getErrCode()).isEqualTo(0);
+ assertThat(result.getInfo()).isNotNull();
+ assertThat(result.getInfo().getSpNo()).isEqualTo("202601140001");
+ assertThat(result.getInfo().getSpName()).isEqualTo("报销申请");
+ assertThat(result.getInfo().getSumMoney()).isNotNull();
+ assertThat(result.getInfo().getSumMoney()).isEqualTo(100000L);
+
+ System.out.println("成功解析总费用金额字段 sum_money: " + result.getInfo().getSumMoney());
+
+ // 测试不包含 sum_money 字段的情况(向后兼容)
+ String jsonWithoutSumMoney = "{\n" +
+ " \"errcode\": 0,\n" +
+ " \"errmsg\": \"ok\",\n" +
+ " \"info\": {\n" +
+ " \"sp_no\": \"202601140002\",\n" +
+ " \"sp_name\": \"请假申请\",\n" +
+ " \"sp_status\": 1,\n" +
+ " \"template_id\": \"test_template_id\",\n" +
+ " \"apply_time\": 1610000000,\n" +
+ " \"applyer\": {\n" +
+ " \"userid\": \"test_user\",\n" +
+ " \"partyid\": \"1\"\n" +
+ " },\n" +
+ " \"sp_record\": [],\n" +
+ " \"notifyer\": [],\n" +
+ " \"apply_data\": {\n" +
+ " \"contents\": []\n" +
+ " },\n" +
+ " \"comments\": []\n" +
+ " }\n" +
+ "}";
+
+ WxCpApprovalDetailResult resultWithoutMoney = WxCpGsonBuilder.create().fromJson(jsonWithoutSumMoney, WxCpApprovalDetailResult.class);
+ assertThat(resultWithoutMoney).isNotNull();
+ assertThat(resultWithoutMoney.getInfo()).isNotNull();
+ assertThat(resultWithoutMoney.getInfo().getSpNo()).isEqualTo("202601140002");
+ assertThat(resultWithoutMoney.getInfo().getSumMoney()).isNull();
+
+ System.out.println("成功处理不包含 sum_money 字段的情况(向后兼容)");
+ System.out.println("完整测试通过!");
+ }
+
/**
* Test get template detail.
*
From 5e09c3e968232733a3ff4202d33b6e5d5f59da03 Mon Sep 17 00:00:00 2001
From: yclnycl
Date: Fri, 23 Jan 2026 16:55:19 +0800
Subject: [PATCH 28/94] =?UTF-8?q?:art:=20#3808=20=E3=80=90=E5=B0=8F?=
=?UTF-8?q?=E7=A8=8B=E5=BA=8F=E3=80=91=E4=BF=AE=E5=A4=8D=E7=94=A8=E5=B7=A5?=
=?UTF-8?q?=E5=85=B3=E7=B3=BB=E6=8E=A5=E5=8F=A3=E5=9C=B0=E5=9D=80=E4=BB=A5?=
=?UTF-8?q?=E5=8F=8A=E8=AF=B7=E6=B1=82=E5=AE=9E=E4=BD=93=E7=B1=BB=E5=AD=97?=
=?UTF-8?q?=E6=AE=B5=E9=94=99=E8=AF=AF=E7=9A=84=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../wx/miniapp/api/WxMaService.java | 3 +-
.../impl/WxMaEmployeeRelationServiceImpl.java | 1 +
.../employee/WxMaSendEmployeeMsgRequest.java | 66 ++++++++++++++---
.../employee/WxMaUnbindEmployeeRequest.java | 22 ++----
.../miniapp/constant/WxMaApiUrlConstants.java | 11 ++-
.../WxMaEmployeeRelationServiceImplTest.java | 73 +++++++++++++++++++
6 files changed, 145 insertions(+), 31 deletions(-)
create mode 100644 weixin-java-miniapp/src/test/java/cn/binarywang/wx/miniapp/api/impl/WxMaEmployeeRelationServiceImplTest.java
diff --git a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/WxMaService.java b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/WxMaService.java
index dc7425fa6e..37a6ca8de1 100644
--- a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/WxMaService.java
+++ b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/WxMaService.java
@@ -625,7 +625,8 @@ WxMaApiResponse execute(
/**
* 获取用工关系服务对象。
*
- * 文档:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/laboruse/intro.html
+ * 服务端api文档:https://developers.weixin.qq.com/miniprogram/dev/server/API/laboruse/
+ * 整体流程文档: https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/laboruse/intro.html
*
* @return 用工关系服务对象WxMaEmployeeRelationService
*/
diff --git a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/impl/WxMaEmployeeRelationServiceImpl.java b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/impl/WxMaEmployeeRelationServiceImpl.java
index 8f240e9151..08d29000ee 100644
--- a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/impl/WxMaEmployeeRelationServiceImpl.java
+++ b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/impl/WxMaEmployeeRelationServiceImpl.java
@@ -15,6 +15,7 @@
*
* @author Binary Wang
* created on 2025-12-19
+ * update on 2026-01-22 15:06:33
*/
@RequiredArgsConstructor
public class WxMaEmployeeRelationServiceImpl implements WxMaEmployeeRelationService {
diff --git a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/employee/WxMaSendEmployeeMsgRequest.java b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/employee/WxMaSendEmployeeMsgRequest.java
index 2d50479817..d93d9beb77 100644
--- a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/employee/WxMaSendEmployeeMsgRequest.java
+++ b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/employee/WxMaSendEmployeeMsgRequest.java
@@ -12,11 +12,12 @@
/**
* 小程序推送用工消息请求实体
*
- * 文档地址:推送用工消息
+ * 文档地址:推送用工消息
*
*
* @author Binary Wang
* created on 2025-12-19
+ * update on 2026-01-22 15:13:28
*/
@Data
@Builder(builderMethodName = "newBuilder")
@@ -27,33 +28,74 @@ public class WxMaSendEmployeeMsgRequest implements Serializable {
/**
*
- * 字段名:用户openid
+ * 字段名:模板id
* 是否必填:是
- * 描述:需要接收消息的用户openid
+ * 描述:需要在微信后台申请用工关系权限,通过后创建的模板审核通过后可以复制模板ID
*
*/
- @SerializedName("openid")
- private String openid;
+ @SerializedName("template_id")
+ private String templateId;
/**
*
- * 字段名:企业id
+ * 字段名:页面
* 是否必填:是
- * 描述:企业id,小程序管理员在微信开放平台配置
+ * 描述:用工消息通知跳转的page小程序链接(注意 小程序页面链接要是申请模板的小程序)
*
*/
- @SerializedName("corp_id")
- private String corpId;
+ @SerializedName("page")
+ private String page;
+
+ /**
+ *
+ * 字段名:被推送用户的openId
+ * 是否必填:是
+ * 描述:被推送用户的openId
+ *
+ */
+ @SerializedName("touser")
+ private String touser;
/**
*
* 字段名:消息内容
* 是否必填:是
- * 描述:推送的消息内容,文本格式,最长不超过200个字符
+ * 描述:需要根据小程序后台审核通过的模板id的字段类型序列化json传递
+ *
+ *
+ *
+ * 参考组装代码
+ *
+ *
+ * // 使用 HashMap 构建数据结构
+ * Map data1 = new HashMap<>();
+ * // 内层字段
+ * Map thing1 = new HashMap<>();
+ * Map thing2 = new HashMap<>();
+ * Map time1 = new HashMap<>();
+ * Map character_string1 = new HashMap<>();
+ * Map time2 = new HashMap<>();
+ * thing1.put("value", "高和蓝枫箱体测试");
+ * thing2.put("value", "门口全英测试");
+ * time1.put("value", "2026年11月23日 19:19");
+ * character_string1.put("value", "50kg");
+ * time2.put("value", "2026年11月23日 19:19");
+ *
+ * // 模板消息变量,有顺序要求
+ * Map dataContent = new LinkedHashMap<>();
+ * dataContent.put("thing1", thing1);
+ * dataContent.put("thing2", thing2);
+ * dataContent.put("time1", time1);
+ * dataContent.put("character_string1", character_string1);
+ * dataContent.put("time2", time2);
+ *
+ * data1.put("data", dataContent);
+ *
*
*/
- @SerializedName("msg")
- private String msg;
+
+ @SerializedName("data")
+ private String data;
public String toJson() {
return WxMaGsonBuilder.create().toJson(this);
diff --git a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/employee/WxMaUnbindEmployeeRequest.java b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/employee/WxMaUnbindEmployeeRequest.java
index e56d84670c..e357f246a5 100644
--- a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/employee/WxMaUnbindEmployeeRequest.java
+++ b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/employee/WxMaUnbindEmployeeRequest.java
@@ -8,15 +8,17 @@
import lombok.NoArgsConstructor;
import java.io.Serializable;
+import java.util.List;
/**
* 小程序解绑用工关系请求实体
*
- * 文档地址:解绑用工关系
+ * 文档地址:解绑用工关系
*
*
* @author Binary Wang
* created on 2025-12-19
+ * update on 2026-01-22 15:14:09
*/
@Data
@Builder(builderMethodName = "newBuilder")
@@ -27,23 +29,13 @@ public class WxMaUnbindEmployeeRequest implements Serializable {
/**
*
- * 字段名:用户openid
+ * 字段名:用户openid列表
* 是否必填:是
- * 描述:需要解绑的用户openid
+ * 描述:需要解绑的用户openid列表
*
*/
- @SerializedName("openid")
- private String openid;
-
- /**
- *
- * 字段名:企业id
- * 是否必填:是
- * 描述:企业id,小程序管理员在微信开放平台配置
- *
- */
- @SerializedName("corp_id")
- private String corpId;
+ @SerializedName("openid_list")
+ private List openidList;
public String toJson() {
return WxMaGsonBuilder.create().toJson(this);
diff --git a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/constant/WxMaApiUrlConstants.java b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/constant/WxMaApiUrlConstants.java
index 2a7496e06e..58b10039a3 100644
--- a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/constant/WxMaApiUrlConstants.java
+++ b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/constant/WxMaApiUrlConstants.java
@@ -1006,11 +1006,16 @@ public interface Complaint {
String UPLOAD_RESPONSE_IMAGE_URL = "https://api.weixin.qq.com/cgi-bin/miniapp/complaint/upload";
}
- /** 用工关系 */
+ /**
+ * 小程序用工关系接口
+ *
+ * 文档地址: https://developers.weixin.qq.com/miniprogram/dev/server/API/laboruse/
+ *
+ */
public interface Employee {
/** 解绑用工关系 */
- String UNBIND_EMPLOYEE_URL = "https://api.weixin.qq.com/wxa/unbinduserb2cauthinfo";
+ String UNBIND_EMPLOYEE_URL = "https://api.weixin.qq.com/wxa/business/unbinduserb2cauthinfo";
/** 推送用工消息 */
- String SEND_EMPLOYEE_MSG_URL = "https://api.weixin.qq.com/wxa/sendemployeerelationmsg";
+ String SEND_EMPLOYEE_MSG_URL = "https://api.weixin.qq.com/cgi-bin/message/wxopen/employeerelationmsg/send";
}
}
diff --git a/weixin-java-miniapp/src/test/java/cn/binarywang/wx/miniapp/api/impl/WxMaEmployeeRelationServiceImplTest.java b/weixin-java-miniapp/src/test/java/cn/binarywang/wx/miniapp/api/impl/WxMaEmployeeRelationServiceImplTest.java
new file mode 100644
index 0000000000..53afad70f3
--- /dev/null
+++ b/weixin-java-miniapp/src/test/java/cn/binarywang/wx/miniapp/api/impl/WxMaEmployeeRelationServiceImplTest.java
@@ -0,0 +1,73 @@
+package cn.binarywang.wx.miniapp.api.impl;
+
+import cn.binarywang.wx.miniapp.api.WxMaService;
+import cn.binarywang.wx.miniapp.bean.employee.WxMaSendEmployeeMsgRequest;
+import cn.binarywang.wx.miniapp.bean.employee.WxMaUnbindEmployeeRequest;
+import cn.binarywang.wx.miniapp.json.WxMaGsonBuilder;
+import cn.binarywang.wx.miniapp.test.ApiTestModule;
+import com.google.inject.Inject;
+import lombok.extern.slf4j.Slf4j;
+import me.chanjar.weixin.common.error.WxErrorException;
+import org.jetbrains.annotations.NotNull;
+import org.testng.annotations.Guice;
+import org.testng.annotations.Test;
+
+import java.util.*;
+
+@Slf4j
+@Test
+@Guice(modules = ApiTestModule.class)
+public class WxMaEmployeeRelationServiceImplTest {
+
+ @Inject
+ protected WxMaService wxService;
+
+ @Test
+ public void testSendEmployeeMsg() throws WxErrorException {
+ WxMaSendEmployeeMsgRequest wxMaSendEmployeeMsgRequest = new WxMaSendEmployeeMsgRequest();
+ wxMaSendEmployeeMsgRequest.setPage("/pages/index/index");
+ wxMaSendEmployeeMsgRequest.setTouser("o0uBr12b1zdgCk1qDoBivmSYb9GA");
+ wxMaSendEmployeeMsgRequest.setTemplateId("nmO-O4V33TOREVLAlumwPCsHssqkt7mea_cyWNE-IFmZqT9jh_LsERhzDOsOqa-3");
+
+ // 使用 HashMap 构建数据结构
+ Map data1 = new HashMap<>();
+ // 内层字段
+ Map dataContent = getStringObjectMap();
+
+ data1.put("data", dataContent);
+ wxMaSendEmployeeMsgRequest.setData(WxMaGsonBuilder.create().toJson(data1));
+ this.wxService.getEmployeeRelationService().sendEmployeeMsg(wxMaSendEmployeeMsgRequest);
+ }
+
+ @NotNull
+ private static Map getStringObjectMap() {
+ Map thing1 = new HashMap<>();
+ Map thing2 = new HashMap<>();
+ Map time1 = new HashMap<>();
+ Map character_string1 = new HashMap<>();
+ Map time2 = new HashMap<>();
+ thing1.put("value", "高和蓝枫箱体测试");
+ thing2.put("value", "门口全英测试");
+ time1.put("value", "2026年11月23日 19:19");
+ character_string1.put("value", "50kg");
+ time2.put("value", "2026年11月23日 19:19");
+
+ // 模板消息变量,有顺序要求
+ Map dataContent = new LinkedHashMap<>();
+ dataContent.put("thing1", thing1);
+ dataContent.put("thing2", thing2);
+ dataContent.put("time1", time1);
+ dataContent.put("character_string1", character_string1);
+ dataContent.put("time2", time2);
+ return dataContent;
+ }
+
+
+ @Test
+ public void testUnbinduserb2cauthinfo() throws WxErrorException {
+ WxMaUnbindEmployeeRequest wxMaUnbindEmployeeRequest = new WxMaUnbindEmployeeRequest();
+ wxMaUnbindEmployeeRequest.setOpenidList(List.of("o0uBr12b1zdgCk1qDoBivmSYb9GA"));
+ this.wxService.getEmployeeRelationService().unbindEmployee(wxMaUnbindEmployeeRequest);
+ }
+
+}
From 4d6f8a4bfa2784dbcaa8b7e63ba7ed0ce17e6339 Mon Sep 17 00:00:00 2001
From: Binary Wang
Date: Sun, 25 Jan 2026 21:20:16 +0800
Subject: [PATCH 29/94] =?UTF-8?q?:bookmark:=20=E5=8F=91=E5=B8=83=204.8.1.B?=
=?UTF-8?q?=20=E6=B5=8B=E8=AF=95=E7=89=88=E6=9C=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
pom.xml | 2 +-
solon-plugins/pom.xml | 2 +-
solon-plugins/wx-java-channel-multi-solon-plugin/pom.xml | 2 +-
solon-plugins/wx-java-channel-solon-plugin/pom.xml | 2 +-
solon-plugins/wx-java-cp-multi-solon-plugin/pom.xml | 2 +-
solon-plugins/wx-java-cp-solon-plugin/pom.xml | 2 +-
solon-plugins/wx-java-miniapp-multi-solon-plugin/pom.xml | 2 +-
solon-plugins/wx-java-miniapp-solon-plugin/pom.xml | 2 +-
solon-plugins/wx-java-mp-multi-solon-plugin/pom.xml | 2 +-
solon-plugins/wx-java-mp-solon-plugin/pom.xml | 2 +-
solon-plugins/wx-java-open-solon-plugin/pom.xml | 2 +-
solon-plugins/wx-java-pay-solon-plugin/pom.xml | 2 +-
solon-plugins/wx-java-qidian-solon-plugin/pom.xml | 2 +-
spring-boot-starters/pom.xml | 2 +-
.../wx-java-channel-multi-spring-boot-starter/pom.xml | 2 +-
.../wx-java-channel-spring-boot-starter/pom.xml | 2 +-
.../wx-java-cp-multi-spring-boot-starter/pom.xml | 2 +-
spring-boot-starters/wx-java-cp-spring-boot-starter/pom.xml | 2 +-
.../wx-java-cp-tp-multi-spring-boot-starter/pom.xml | 2 +-
.../wx-java-miniapp-multi-spring-boot-starter/pom.xml | 2 +-
.../wx-java-miniapp-spring-boot-starter/pom.xml | 2 +-
.../wx-java-mp-multi-spring-boot-starter/pom.xml | 2 +-
spring-boot-starters/wx-java-mp-spring-boot-starter/pom.xml | 2 +-
.../wx-java-open-multi-spring-boot-starter/pom.xml | 2 +-
spring-boot-starters/wx-java-open-spring-boot-starter/pom.xml | 2 +-
.../wx-java-pay-multi-spring-boot-starter/pom.xml | 2 +-
spring-boot-starters/wx-java-pay-spring-boot-starter/pom.xml | 2 +-
spring-boot-starters/wx-java-qidian-spring-boot-starter/pom.xml | 2 +-
weixin-graal/pom.xml | 2 +-
weixin-java-channel/pom.xml | 2 +-
weixin-java-common/pom.xml | 2 +-
weixin-java-cp/pom.xml | 2 +-
weixin-java-miniapp/pom.xml | 2 +-
weixin-java-mp/pom.xml | 2 +-
weixin-java-open/pom.xml | 2 +-
weixin-java-pay/pom.xml | 2 +-
weixin-java-qidian/pom.xml | 2 +-
37 files changed, 37 insertions(+), 37 deletions(-)
diff --git a/pom.xml b/pom.xml
index d7d93322b5..8a08484f0e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -3,7 +3,7 @@
4.0.0
com.github.binarywang
wx-java
- 4.8.0
+ 4.8.1.B
pom
WxJava - Weixin/Wechat Java SDK
微信开发Java SDK
diff --git a/solon-plugins/pom.xml b/solon-plugins/pom.xml
index d0ca564c24..d49beabc7c 100644
--- a/solon-plugins/pom.xml
+++ b/solon-plugins/pom.xml
@@ -6,7 +6,7 @@
com.github.binarywang
wx-java
- 4.8.0
+ 4.8.1.B
pom
wx-java-solon-plugins
diff --git a/solon-plugins/wx-java-channel-multi-solon-plugin/pom.xml b/solon-plugins/wx-java-channel-multi-solon-plugin/pom.xml
index 995ecbd532..4d75010262 100644
--- a/solon-plugins/wx-java-channel-multi-solon-plugin/pom.xml
+++ b/solon-plugins/wx-java-channel-multi-solon-plugin/pom.xml
@@ -5,7 +5,7 @@
wx-java-solon-plugins
com.github.binarywang
- 4.8.0
+ 4.8.1.B
4.0.0
diff --git a/solon-plugins/wx-java-channel-solon-plugin/pom.xml b/solon-plugins/wx-java-channel-solon-plugin/pom.xml
index b2ca356692..e52aadc71e 100644
--- a/solon-plugins/wx-java-channel-solon-plugin/pom.xml
+++ b/solon-plugins/wx-java-channel-solon-plugin/pom.xml
@@ -3,7 +3,7 @@
wx-java-solon-plugins
com.github.binarywang
- 4.8.0
+ 4.8.1.B
4.0.0
diff --git a/solon-plugins/wx-java-cp-multi-solon-plugin/pom.xml b/solon-plugins/wx-java-cp-multi-solon-plugin/pom.xml
index 17e24bfe2d..6518a55994 100644
--- a/solon-plugins/wx-java-cp-multi-solon-plugin/pom.xml
+++ b/solon-plugins/wx-java-cp-multi-solon-plugin/pom.xml
@@ -4,7 +4,7 @@
wx-java-solon-plugins
com.github.binarywang
- 4.8.0
+ 4.8.1.B
4.0.0
diff --git a/solon-plugins/wx-java-cp-solon-plugin/pom.xml b/solon-plugins/wx-java-cp-solon-plugin/pom.xml
index 7e6f2f8164..50f6b22c75 100644
--- a/solon-plugins/wx-java-cp-solon-plugin/pom.xml
+++ b/solon-plugins/wx-java-cp-solon-plugin/pom.xml
@@ -4,7 +4,7 @@
wx-java-solon-plugins
com.github.binarywang
- 4.8.0
+ 4.8.1.B
4.0.0
diff --git a/solon-plugins/wx-java-miniapp-multi-solon-plugin/pom.xml b/solon-plugins/wx-java-miniapp-multi-solon-plugin/pom.xml
index 932f9244ce..bd69cd0ed1 100644
--- a/solon-plugins/wx-java-miniapp-multi-solon-plugin/pom.xml
+++ b/solon-plugins/wx-java-miniapp-multi-solon-plugin/pom.xml
@@ -5,7 +5,7 @@
wx-java-solon-plugins
com.github.binarywang
- 4.8.0
+ 4.8.1.B
4.0.0
diff --git a/solon-plugins/wx-java-miniapp-solon-plugin/pom.xml b/solon-plugins/wx-java-miniapp-solon-plugin/pom.xml
index 5ad8da85e6..a52eab54c9 100644
--- a/solon-plugins/wx-java-miniapp-solon-plugin/pom.xml
+++ b/solon-plugins/wx-java-miniapp-solon-plugin/pom.xml
@@ -4,7 +4,7 @@
wx-java-solon-plugins
com.github.binarywang
- 4.8.0
+ 4.8.1.B
4.0.0
diff --git a/solon-plugins/wx-java-mp-multi-solon-plugin/pom.xml b/solon-plugins/wx-java-mp-multi-solon-plugin/pom.xml
index 7c02acdfef..c9329fd290 100644
--- a/solon-plugins/wx-java-mp-multi-solon-plugin/pom.xml
+++ b/solon-plugins/wx-java-mp-multi-solon-plugin/pom.xml
@@ -5,7 +5,7 @@
wx-java-solon-plugins
com.github.binarywang
- 4.8.0
+ 4.8.1.B
4.0.0
diff --git a/solon-plugins/wx-java-mp-solon-plugin/pom.xml b/solon-plugins/wx-java-mp-solon-plugin/pom.xml
index d72a5f7fc4..00f7a3951b 100644
--- a/solon-plugins/wx-java-mp-solon-plugin/pom.xml
+++ b/solon-plugins/wx-java-mp-solon-plugin/pom.xml
@@ -5,7 +5,7 @@
wx-java-solon-plugins
com.github.binarywang
- 4.8.0
+ 4.8.1.B
4.0.0
diff --git a/solon-plugins/wx-java-open-solon-plugin/pom.xml b/solon-plugins/wx-java-open-solon-plugin/pom.xml
index 0f0527183f..1d0a03fb73 100644
--- a/solon-plugins/wx-java-open-solon-plugin/pom.xml
+++ b/solon-plugins/wx-java-open-solon-plugin/pom.xml
@@ -5,7 +5,7 @@
wx-java-solon-plugins
com.github.binarywang
- 4.8.0
+ 4.8.1.B
4.0.0
diff --git a/solon-plugins/wx-java-pay-solon-plugin/pom.xml b/solon-plugins/wx-java-pay-solon-plugin/pom.xml
index 7c1cb4e850..ab870301ae 100644
--- a/solon-plugins/wx-java-pay-solon-plugin/pom.xml
+++ b/solon-plugins/wx-java-pay-solon-plugin/pom.xml
@@ -5,7 +5,7 @@
wx-java-solon-plugins
com.github.binarywang
- 4.8.0
+ 4.8.1.B
4.0.0
diff --git a/solon-plugins/wx-java-qidian-solon-plugin/pom.xml b/solon-plugins/wx-java-qidian-solon-plugin/pom.xml
index 724bdf4ac5..d46c9ca32c 100644
--- a/solon-plugins/wx-java-qidian-solon-plugin/pom.xml
+++ b/solon-plugins/wx-java-qidian-solon-plugin/pom.xml
@@ -3,7 +3,7 @@
wx-java-solon-plugins
com.github.binarywang
- 4.8.0
+ 4.8.1.B
4.0.0
diff --git a/spring-boot-starters/pom.xml b/spring-boot-starters/pom.xml
index 8b000ff8c2..f37903c7e6 100644
--- a/spring-boot-starters/pom.xml
+++ b/spring-boot-starters/pom.xml
@@ -6,7 +6,7 @@
com.github.binarywang
wx-java
- 4.8.0
+ 4.8.1.B
pom
wx-java-spring-boot-starters
diff --git a/spring-boot-starters/wx-java-channel-multi-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-channel-multi-spring-boot-starter/pom.xml
index b44f597d22..083072f604 100644
--- a/spring-boot-starters/wx-java-channel-multi-spring-boot-starter/pom.xml
+++ b/spring-boot-starters/wx-java-channel-multi-spring-boot-starter/pom.xml
@@ -5,7 +5,7 @@
wx-java-spring-boot-starters
com.github.binarywang
- 4.8.0
+ 4.8.1.B
4.0.0
diff --git a/spring-boot-starters/wx-java-channel-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-channel-spring-boot-starter/pom.xml
index 95021e2d22..0d9e5e4e47 100644
--- a/spring-boot-starters/wx-java-channel-spring-boot-starter/pom.xml
+++ b/spring-boot-starters/wx-java-channel-spring-boot-starter/pom.xml
@@ -3,7 +3,7 @@
wx-java-spring-boot-starters
com.github.binarywang
- 4.8.0
+ 4.8.1.B
4.0.0
diff --git a/spring-boot-starters/wx-java-cp-multi-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-cp-multi-spring-boot-starter/pom.xml
index 550a14d2ad..6aa13ae814 100644
--- a/spring-boot-starters/wx-java-cp-multi-spring-boot-starter/pom.xml
+++ b/spring-boot-starters/wx-java-cp-multi-spring-boot-starter/pom.xml
@@ -4,7 +4,7 @@
wx-java-spring-boot-starters
com.github.binarywang
- 4.8.0
+ 4.8.1.B
4.0.0
diff --git a/spring-boot-starters/wx-java-cp-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-cp-spring-boot-starter/pom.xml
index 81f68274c5..a560bf8e07 100644
--- a/spring-boot-starters/wx-java-cp-spring-boot-starter/pom.xml
+++ b/spring-boot-starters/wx-java-cp-spring-boot-starter/pom.xml
@@ -4,7 +4,7 @@
wx-java-spring-boot-starters
com.github.binarywang
- 4.8.0
+ 4.8.1.B
4.0.0
diff --git a/spring-boot-starters/wx-java-cp-tp-multi-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-cp-tp-multi-spring-boot-starter/pom.xml
index f1cc1fba13..2d6c78009d 100644
--- a/spring-boot-starters/wx-java-cp-tp-multi-spring-boot-starter/pom.xml
+++ b/spring-boot-starters/wx-java-cp-tp-multi-spring-boot-starter/pom.xml
@@ -4,7 +4,7 @@
wx-java-spring-boot-starters
com.github.binarywang
- 4.8.0
+ 4.8.1.B
4.0.0
diff --git a/spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/pom.xml
index 8c8854067f..32431c28c6 100644
--- a/spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/pom.xml
+++ b/spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/pom.xml
@@ -5,7 +5,7 @@
wx-java-spring-boot-starters
com.github.binarywang
- 4.8.0
+ 4.8.1.B
4.0.0
diff --git a/spring-boot-starters/wx-java-miniapp-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-miniapp-spring-boot-starter/pom.xml
index bcc61b0309..7e9ffbe308 100644
--- a/spring-boot-starters/wx-java-miniapp-spring-boot-starter/pom.xml
+++ b/spring-boot-starters/wx-java-miniapp-spring-boot-starter/pom.xml
@@ -4,7 +4,7 @@
wx-java-spring-boot-starters
com.github.binarywang
- 4.8.0
+ 4.8.1.B
4.0.0
diff --git a/spring-boot-starters/wx-java-mp-multi-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-mp-multi-spring-boot-starter/pom.xml
index 6323ae4b6a..0857fc4e1d 100644
--- a/spring-boot-starters/wx-java-mp-multi-spring-boot-starter/pom.xml
+++ b/spring-boot-starters/wx-java-mp-multi-spring-boot-starter/pom.xml
@@ -5,7 +5,7 @@
wx-java-spring-boot-starters
com.github.binarywang
- 4.8.0
+ 4.8.1.B
4.0.0
diff --git a/spring-boot-starters/wx-java-mp-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-mp-spring-boot-starter/pom.xml
index 38e484b450..5d0c2d0269 100644
--- a/spring-boot-starters/wx-java-mp-spring-boot-starter/pom.xml
+++ b/spring-boot-starters/wx-java-mp-spring-boot-starter/pom.xml
@@ -5,7 +5,7 @@
wx-java-spring-boot-starters
com.github.binarywang
- 4.8.0
+ 4.8.1.B
4.0.0
diff --git a/spring-boot-starters/wx-java-open-multi-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-open-multi-spring-boot-starter/pom.xml
index 1ad7a5e8e1..3b01d26f47 100644
--- a/spring-boot-starters/wx-java-open-multi-spring-boot-starter/pom.xml
+++ b/spring-boot-starters/wx-java-open-multi-spring-boot-starter/pom.xml
@@ -5,7 +5,7 @@
wx-java-spring-boot-starters
com.github.binarywang
- 4.8.0
+ 4.8.1.B
4.0.0
diff --git a/spring-boot-starters/wx-java-open-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-open-spring-boot-starter/pom.xml
index 9a25cd89d7..0b9e203b83 100644
--- a/spring-boot-starters/wx-java-open-spring-boot-starter/pom.xml
+++ b/spring-boot-starters/wx-java-open-spring-boot-starter/pom.xml
@@ -5,7 +5,7 @@
wx-java-spring-boot-starters
com.github.binarywang
- 4.8.0
+ 4.8.1.B
4.0.0
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
index a5c0b842cb..c8b08a8a0c 100644
--- 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
@@ -5,7 +5,7 @@
wx-java-spring-boot-starters
com.github.binarywang
- 4.8.0
+ 4.8.1.B
4.0.0
diff --git a/spring-boot-starters/wx-java-pay-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-pay-spring-boot-starter/pom.xml
index 8b67ade1ea..7bbfdbfbf1 100644
--- a/spring-boot-starters/wx-java-pay-spring-boot-starter/pom.xml
+++ b/spring-boot-starters/wx-java-pay-spring-boot-starter/pom.xml
@@ -5,7 +5,7 @@
wx-java-spring-boot-starters
com.github.binarywang
- 4.8.0
+ 4.8.1.B
4.0.0
diff --git a/spring-boot-starters/wx-java-qidian-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-qidian-spring-boot-starter/pom.xml
index a0fc329434..f26d5dd881 100644
--- a/spring-boot-starters/wx-java-qidian-spring-boot-starter/pom.xml
+++ b/spring-boot-starters/wx-java-qidian-spring-boot-starter/pom.xml
@@ -3,7 +3,7 @@
wx-java-spring-boot-starters
com.github.binarywang
- 4.8.0
+ 4.8.1.B
4.0.0
diff --git a/weixin-graal/pom.xml b/weixin-graal/pom.xml
index 3a220b2888..272e07de55 100644
--- a/weixin-graal/pom.xml
+++ b/weixin-graal/pom.xml
@@ -6,7 +6,7 @@
com.github.binarywang
wx-java
- 4.8.0
+ 4.8.1.B
weixin-graal
diff --git a/weixin-java-channel/pom.xml b/weixin-java-channel/pom.xml
index 28b3e2ed6c..b994a98cb5 100644
--- a/weixin-java-channel/pom.xml
+++ b/weixin-java-channel/pom.xml
@@ -6,7 +6,7 @@
com.github.binarywang
wx-java
- 4.8.0
+ 4.8.1.B
weixin-java-channel
diff --git a/weixin-java-common/pom.xml b/weixin-java-common/pom.xml
index 2053177b12..33fd85d4b3 100644
--- a/weixin-java-common/pom.xml
+++ b/weixin-java-common/pom.xml
@@ -6,7 +6,7 @@
com.github.binarywang
wx-java
- 4.8.0
+ 4.8.1.B
weixin-java-common
diff --git a/weixin-java-cp/pom.xml b/weixin-java-cp/pom.xml
index 9294b62d20..922d4f6b84 100644
--- a/weixin-java-cp/pom.xml
+++ b/weixin-java-cp/pom.xml
@@ -7,7 +7,7 @@
com.github.binarywang
wx-java
- 4.8.0
+ 4.8.1.B
weixin-java-cp
diff --git a/weixin-java-miniapp/pom.xml b/weixin-java-miniapp/pom.xml
index a3b1687b2e..318d538a14 100644
--- a/weixin-java-miniapp/pom.xml
+++ b/weixin-java-miniapp/pom.xml
@@ -7,7 +7,7 @@
com.github.binarywang
wx-java
- 4.8.0
+ 4.8.1.B
weixin-java-miniapp
diff --git a/weixin-java-mp/pom.xml b/weixin-java-mp/pom.xml
index 823e7fd1f8..f0c590bab5 100644
--- a/weixin-java-mp/pom.xml
+++ b/weixin-java-mp/pom.xml
@@ -7,7 +7,7 @@
com.github.binarywang
wx-java
- 4.8.0
+ 4.8.1.B
weixin-java-mp
diff --git a/weixin-java-open/pom.xml b/weixin-java-open/pom.xml
index 6720cd2b30..c05fc52705 100644
--- a/weixin-java-open/pom.xml
+++ b/weixin-java-open/pom.xml
@@ -7,7 +7,7 @@
com.github.binarywang
wx-java
- 4.8.0
+ 4.8.1.B
weixin-java-open
diff --git a/weixin-java-pay/pom.xml b/weixin-java-pay/pom.xml
index 43851df85d..7098d8d8aa 100644
--- a/weixin-java-pay/pom.xml
+++ b/weixin-java-pay/pom.xml
@@ -5,7 +5,7 @@
com.github.binarywang
wx-java
- 4.8.0
+ 4.8.1.B
4.0.0
diff --git a/weixin-java-qidian/pom.xml b/weixin-java-qidian/pom.xml
index 7b06feb08e..e40a096bf7 100644
--- a/weixin-java-qidian/pom.xml
+++ b/weixin-java-qidian/pom.xml
@@ -7,7 +7,7 @@
com.github.binarywang
wx-java
- 4.8.0
+ 4.8.1.B
weixin-java-qidian
From dec6792333776d9802df89aaea6b8a915530f433 Mon Sep 17 00:00:00 2001
From: Binary Wang
Date: Tue, 27 Jan 2026 11:24:37 +0800
Subject: [PATCH 30/94] =?UTF-8?q?:memo:=20=E8=B5=9E=E5=8A=A9=E5=95=86?=
=?UTF-8?q?=E6=8B=9B=E5=8B=9F=E4=B8=AD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 080b831d1e..69d5efbf34 100644
--- a/README.md
+++ b/README.md
@@ -47,10 +47,8 @@
-
-
-
-
+ |
+ 赞助商招募中
|
From 780c24bda0ca46084e504c49a4b22439937a6fe8 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Sat, 31 Jan 2026 00:53:44 +0800
Subject: [PATCH 31/94] =?UTF-8?q?:new:=20#3871=20=E3=80=90=E5=BE=AE?=
=?UTF-8?q?=E4=BF=A1=E6=94=AF=E4=BB=98=E3=80=91=E5=A2=9E=E5=8A=A0=E8=A7=86?=
=?UTF-8?q?=E9=A2=91=E4=B8=8A=E4=BC=A0=E6=8E=A5=E5=8F=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../wxpay/bean/media/VideoUploadResult.java | 29 +++++++++++++
.../wxpay/service/MerchantMediaService.java | 30 +++++++++++++
.../impl/MerchantMediaServiceImpl.java | 39 ++++++++++++++++-
.../wxpay/v3/WechatPayUploadHttpPost.java | 14 +++++--
.../impl/MerchantMediaServiceImplTest.java | 42 +++++++++++++++++++
5 files changed, 150 insertions(+), 4 deletions(-)
create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/media/VideoUploadResult.java
diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/media/VideoUploadResult.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/media/VideoUploadResult.java
new file mode 100644
index 0000000000..615cbbff5f
--- /dev/null
+++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/media/VideoUploadResult.java
@@ -0,0 +1,29 @@
+package com.github.binarywang.wxpay.bean.media;
+
+import com.google.gson.annotations.SerializedName;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import me.chanjar.weixin.common.util.json.WxGsonBuilder;
+
+/**
+ * 视频文件上传返回结果对象
+ *
+ * @author copilot
+ */
+@NoArgsConstructor
+@Data
+public class VideoUploadResult {
+
+ public static VideoUploadResult fromJson(String json) {
+ return WxGsonBuilder.create().fromJson(json, VideoUploadResult.class);
+ }
+
+ /**
+ * 媒体文件标识 Id
+ *
+ * 微信返回的媒体文件标识Id。
+ * 示例值:6uqyGjGrCf2GtyXP8bxrbuH9-aAoTjH-rKeSl3Lf4_So6kdkQu4w8BYVP3bzLtvR38lxt4PjtCDXsQpzqge_hQEovHzOhsLleGFQVRF-U_0
+ */
+ @SerializedName("media_id")
+ private String mediaId;
+}
diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/MerchantMediaService.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/MerchantMediaService.java
index 0e35dbb68b..f7f0aaaf3e 100644
--- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/MerchantMediaService.java
+++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/MerchantMediaService.java
@@ -1,6 +1,7 @@
package com.github.binarywang.wxpay.service;
import com.github.binarywang.wxpay.bean.media.ImageUploadResult;
+import com.github.binarywang.wxpay.bean.media.VideoUploadResult;
import com.github.binarywang.wxpay.exception.WxPayException;
import java.io.File;
@@ -42,5 +43,34 @@ public interface MerchantMediaService {
*/
ImageUploadResult imageUploadV3(InputStream inputStream, String fileName) throws WxPayException, IOException;
+ /**
+ *
+ * 通用接口-视频上传API
+ * 文档详见: https://pay.weixin.qq.com/wiki/doc/apiv3/wxpay/tool/chapter3_2.shtml
+ * 接口链接:https://api.mch.weixin.qq.com/v3/merchant/media/video_upload
+ *
+ *
+ * @param videoFile 需要上传的视频文件
+ * @return VideoUploadResult 微信返回的媒体文件标识Id。示例值:6uqyGjGrCf2GtyXP8bxrbuH9-aAoTjH-rKeSl3Lf4_So6kdkQu4w8BYVP3bzLtvR38lxt4PjtCDXsQpzqge_hQEovHzOhsLleGFQVRF-U_0
+ * @throws WxPayException the wx pay exception
+ * @throws IOException the io exception
+ */
+ VideoUploadResult videoUploadV3(File videoFile) throws WxPayException, IOException;
+
+ /**
+ *
+ * 通用接口-视频上传API
+ * 文档详见: https://pay.weixin.qq.com/wiki/doc/apiv3/wxpay/tool/chapter3_2.shtml
+ * 接口链接:https://api.mch.weixin.qq.com/v3/merchant/media/video_upload
+ * 注意:此方法会将整个视频流读入内存计算SHA256后再上传,大文件可能导致OOM,建议大文件使用File方式上传
+ *
+ *
+ * @param inputStream 需要上传的视频文件流
+ * @param fileName 需要上传的视频文件名
+ * @return VideoUploadResult 微信返回的媒体文件标识Id。示例值:6uqyGjGrCf2GtyXP8bxrbuH9-aAoTjH-rKeSl3Lf4_So6kdkQu4w8BYVP3bzLtvR38lxt4PjtCDXsQpzqge_hQEovHzOhsLleGFQVRF-U_0
+ * @throws WxPayException the wx pay exception
+ * @throws IOException the io exception
+ */
+ VideoUploadResult videoUploadV3(InputStream inputStream, String fileName) throws WxPayException, IOException;
}
diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/MerchantMediaServiceImpl.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/MerchantMediaServiceImpl.java
index 7952513f56..ee77f5e974 100644
--- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/MerchantMediaServiceImpl.java
+++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/MerchantMediaServiceImpl.java
@@ -1,6 +1,7 @@
package com.github.binarywang.wxpay.service.impl;
import com.github.binarywang.wxpay.bean.media.ImageUploadResult;
+import com.github.binarywang.wxpay.bean.media.VideoUploadResult;
import com.github.binarywang.wxpay.exception.WxPayException;
import com.github.binarywang.wxpay.service.MerchantMediaService;
import com.github.binarywang.wxpay.service.WxPayService;
@@ -40,7 +41,7 @@ public ImageUploadResult imageUploadV3(File imageFile) throws WxPayException,IOE
@Override
public ImageUploadResult imageUploadV3(InputStream inputStream, String fileName) throws WxPayException, IOException {
String url = String.format("%s/v3/merchant/media/upload", this.payService.getPayBaseUrl());
- try(ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
+ try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
byte[] buffer = new byte[2048];
int len;
while ((len = inputStream.read(buffer)) > -1) {
@@ -57,4 +58,40 @@ public ImageUploadResult imageUploadV3(InputStream inputStream, String fileName)
}
}
+ @Override
+ public VideoUploadResult videoUploadV3(File videoFile) throws WxPayException, IOException {
+ String url = String.format("%s/v3/merchant/media/video_upload", this.payService.getPayBaseUrl());
+
+ try (FileInputStream s1 = new FileInputStream(videoFile)) {
+ String sha256 = DigestUtils.sha256Hex(s1);
+ try (InputStream s2 = new FileInputStream(videoFile)) {
+ WechatPayUploadHttpPost request = new WechatPayUploadHttpPost.Builder(URI.create(url))
+ .withVideo(videoFile.getName(), sha256, s2)
+ .build();
+ String result = this.payService.postV3(url, request);
+ return VideoUploadResult.fromJson(result);
+ }
+ }
+ }
+
+ @Override
+ public VideoUploadResult videoUploadV3(InputStream inputStream, String fileName) throws WxPayException, IOException {
+ String url = String.format("%s/v3/merchant/media/video_upload", this.payService.getPayBaseUrl());
+ try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
+ byte[] buffer = new byte[2048];
+ int len;
+ while ((len = inputStream.read(buffer)) > -1) {
+ bos.write(buffer, 0, len);
+ }
+ bos.flush();
+ byte[] data = bos.toByteArray();
+ String sha256 = DigestUtils.sha256Hex(data);
+ WechatPayUploadHttpPost request = new WechatPayUploadHttpPost.Builder(URI.create(url))
+ .withVideo(fileName, sha256, new ByteArrayInputStream(data))
+ .build();
+ String result = this.payService.postV3(url, request);
+ return VideoUploadResult.fromJson(result);
+ }
+ }
+
}
diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/WechatPayUploadHttpPost.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/WechatPayUploadHttpPost.java
index 5f5e52d2ff..3387f37e3d 100644
--- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/WechatPayUploadHttpPost.java
+++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/WechatPayUploadHttpPost.java
@@ -35,7 +35,7 @@ public Builder(URI uri) {
this.uri = uri;
}
- public Builder withImage(String fileName, String fileSha256, InputStream inputStream) {
+ private Builder withMedia(String fileName, String fileSha256, InputStream inputStream) {
this.fileName = fileName;
this.fileSha256 = fileSha256;
this.fileInputStream = inputStream;
@@ -50,13 +50,21 @@ public Builder withImage(String fileName, String fileSha256, InputStream inputSt
return this;
}
+ public Builder withImage(String fileName, String fileSha256, InputStream inputStream) {
+ return withMedia(fileName, fileSha256, inputStream);
+ }
+
+ public Builder withVideo(String fileName, String fileSha256, InputStream inputStream) {
+ return withMedia(fileName, fileSha256, inputStream);
+ }
+
public WechatPayUploadHttpPost build() {
if (fileName == null || fileSha256 == null || fileInputStream == null) {
- throw new IllegalArgumentException("缺少待上传图片文件信息");
+ throw new IllegalArgumentException("缺少待上传文件信息");
}
if (uri == null) {
- throw new IllegalArgumentException("缺少上传图片接口URL");
+ throw new IllegalArgumentException("缺少上传文件接口URL");
}
String meta = String.format("{\"filename\":\"%s\",\"sha256\":\"%s\"}", fileName, fileSha256);
diff --git a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/MerchantMediaServiceImplTest.java b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/MerchantMediaServiceImplTest.java
index c8dd069b44..845992e43c 100644
--- a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/MerchantMediaServiceImplTest.java
+++ b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/MerchantMediaServiceImplTest.java
@@ -1,6 +1,7 @@
package com.github.binarywang.wxpay.service.impl;
import com.github.binarywang.wxpay.bean.media.ImageUploadResult;
+import com.github.binarywang.wxpay.bean.media.VideoUploadResult;
import com.github.binarywang.wxpay.exception.WxPayException;
import com.github.binarywang.wxpay.service.MerchantMediaService;
import com.github.binarywang.wxpay.service.WxPayService;
@@ -51,4 +52,45 @@ public void testImageUploadV3() throws WxPayException, IOException {
log.info("mediaId2:[{}]",mediaId2);
}
+
+ @Test
+ public void testVideoUploadV3() throws WxPayException, IOException {
+
+ MerchantMediaService merchantMediaService = new MerchantMediaServiceImpl(wxPayService);
+
+ String filePath = "你的视频文件的路径地址";
+// String filePath = "WxJava/test-video.mp4";
+
+ File file = new File(filePath);
+
+ VideoUploadResult videoUploadResult = merchantMediaService.videoUploadV3(file);
+ String mediaId = videoUploadResult.getMediaId();
+
+ log.info("视频上传成功,mediaId:[{}]", mediaId);
+
+ VideoUploadResult videoUploadResult2 = merchantMediaService.videoUploadV3(file);
+ String mediaId2 = videoUploadResult2.getMediaId();
+
+ log.info("视频上传成功2,mediaId2:[{}]", mediaId2);
+
+ }
+
+ @Test
+ public void testVideoUploadV3WithInputStream() throws WxPayException, IOException {
+
+ MerchantMediaService merchantMediaService = new MerchantMediaServiceImpl(wxPayService);
+
+ String filePath = "你的视频文件的路径地址";
+// String filePath = "WxJava/test-video.mp4";
+
+ File file = new File(filePath);
+
+ try (java.io.FileInputStream inputStream = new java.io.FileInputStream(file)) {
+ VideoUploadResult videoUploadResult = merchantMediaService.videoUploadV3(inputStream, file.getName());
+ String mediaId = videoUploadResult.getMediaId();
+
+ log.info("通过InputStream上传视频成功,mediaId:[{}]", mediaId);
+ }
+
+ }
}
From 3965823f0d5ac56159b746494d4abf7f77067f69 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Sat, 31 Jan 2026 22:54:53 +0800
Subject: [PATCH 32/94] =?UTF-8?q?:art:=20#3872=20=E3=80=90=E5=BE=AE?=
=?UTF-8?q?=E4=BF=A1=E6=94=AF=E4=BB=98=E3=80=91=E8=A1=A5=E5=85=85=E5=BE=AE?=
=?UTF-8?q?=E5=B7=A5=E5=8D=A1=E6=89=B9=E9=87=8F=E8=BD=AC=E8=B4=A6=20API=20?=
=?UTF-8?q?=E7=BC=BA=E5=A4=B1=E7=9A=84=E5=BF=85=E8=A6=81=E5=AD=97=E6=AE=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../PayrollTransferBatchesRequest.java | 70 +++++++++++++++++++
.../service/impl/PayrollServiceImplTest.java | 4 ++
2 files changed, 74 insertions(+)
diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/marketing/payroll/PayrollTransferBatchesRequest.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/marketing/payroll/PayrollTransferBatchesRequest.java
index 50954e70e5..7b7eff0233 100644
--- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/marketing/payroll/PayrollTransferBatchesRequest.java
+++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/marketing/payroll/PayrollTransferBatchesRequest.java
@@ -142,6 +142,61 @@ public class PayrollTransferBatchesRequest implements Serializable {
@SerializedName(value = "total_num")
private Integer totalNum;
+ /**
+ *
+ * 字段名:用工类型
+ * 变量名:employment_type
+ * 是否必填:是
+ * 类型:string[1, 32]
+ * 描述:
+ * 微工卡服务仅支持用于与商户有用工关系的用户,需明确用工类型;参考值:
+ * LONG_TERM_EMPLOYMENT:长期用工,
+ * SHORT_TERM_EMPLOYMENT:短期用工,
+ * COOPERATION_EMPLOYMENT:合作关系
+ * 示例值:LONG_TERM_EMPLOYMENT
+ *
+ */
+ @SerializedName(value = "employment_type")
+ private String employmentType;
+
+ /**
+ *
+ * 字段名:用工场景
+ * 变量名:employment_scene
+ * 是否必填:否
+ * 类型:string[1, 32]
+ * 描述:
+ * 用工场景,参考值:
+ * LOGISTICS:物流;
+ * MANUFACTURING:制造业;
+ * HOTEL:酒店;
+ * CATERING:餐饮业;
+ * EVENT:活动促销;
+ * RETAIL:零售;
+ * OTHERS:其他
+ * 示例值:LOGISTICS
+ *
+ */
+ @SerializedName(value = "employment_scene")
+ private String employmentScene;
+
+ /**
+ *
+ * 字段名:特约商户授权类型
+ * 变量名:authorization_type
+ * 是否必填:是
+ * 类型:string[1, 32]
+ * 描述:
+ * 特约商户授权类型:
+ * INFORMATION_AUTHORIZATION_TYPE:特约商户信息授权类型,
+ * FUND_AUTHORIZATION_TYPE:特约商户资金授权类型,
+ * INFORMATION_AND_FUND_AUTHORIZATION_TYPE:特约商户信息和资金授权类型
+ * 示例值:INFORMATION_AUTHORIZATION_TYPE
+ *
+ */
+ @SerializedName(value = "authorization_type")
+ private String authorizationType;
+
/**
*
* 字段名:转账明细列表
@@ -235,5 +290,20 @@ public static class TransferDetail implements Serializable {
@SpecEncrypt
@SerializedName(value = "user_name")
private String userName;
+
+ /**
+ *
+ * 字段名:收款用户身份证
+ * 变量名:user_id_card
+ * 是否必填:否
+ * 类型:string[1, 1024]
+ * 描述:
+ * 收款用户身份证号。该字段需进行加密处理,加密方法详见敏感信息加密说明
+ * 示例值:8609cb22e1774a50a930e414cc71eca06121bcd266335cda230d24a7886a8d9f
+ *
+ */
+ @SpecEncrypt
+ @SerializedName(value = "user_id_card")
+ private String userIdCard;
}
}
diff --git a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/PayrollServiceImplTest.java b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/PayrollServiceImplTest.java
index 20bb33d7fd..a5421f5dc9 100644
--- a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/PayrollServiceImplTest.java
+++ b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/PayrollServiceImplTest.java
@@ -139,6 +139,9 @@ public void payrollCardTransferBatches() throws WxPayException {
.batchRemark("2019年1月深圳分部报销单")
.totalAmount(200000L)
.totalNum(1)
+ .employmentType("LONG_TERM_EMPLOYMENT")
+ .employmentScene("LOGISTICS")
+ .authorizationType("INFORMATION_AUTHORIZATION_TYPE")
.transferDetailList(Collections.singletonList(
PayrollTransferBatchesRequest.TransferDetail.builder()
.outDetailNo("x23zy545Bd5436" + System.currentTimeMillis())
@@ -146,6 +149,7 @@ public void payrollCardTransferBatches() throws WxPayException {
.transferRemark("2020年4月报销")
.openid("o-MYE42l80oelYMDE34nYD456Xoy")
.userName("张三")
+ .userIdCard("8609cb22e1774a50a930e414cc71eca06121bcd266335cda230d24a7886a8d9f")
.build()
))
.build();
From b259206bcbb6a4293e4df16a47c9cde5b388efea Mon Sep 17 00:00:00 2001
From: cbxbj <56364140+cbxbj@users.noreply.github.com>
Date: Mon, 9 Feb 2026 14:19:02 +0800
Subject: [PATCH 33/94] =?UTF-8?q?:art:=20#3876=20=E3=80=90=E4=BC=81?=
=?UTF-8?q?=E4=B8=9A=E5=BE=AE=E4=BF=A1=E3=80=91=E5=AE=A1=E6=89=B9=E8=AF=A6?=
=?UTF-8?q?=E6=83=85=E6=8E=A5=E5=8F=A3=E8=BF=94=E5=9B=9E=E9=87=8C=E7=9A=84?=
=?UTF-8?q?=20ContentValue=20=E6=95=B0=E6=8D=AE=E6=A8=A1=E5=9E=8B=E6=B7=BB?=
=?UTF-8?q?=E5=8A=A0=E4=BA=86=E9=83=A8=E5=88=86=E5=AE=98=E6=96=B9=E6=96=B0?=
=?UTF-8?q?=E5=A2=9E=E5=AD=97=E6=AE=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: cbxbj
---
.../cp/bean/oa/applydata/ContentValue.java | 73 +++++++++++++++++++
1 file changed, 73 insertions(+)
diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/oa/applydata/ContentValue.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/oa/applydata/ContentValue.java
index 92ec8a43e8..848e377791 100644
--- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/oa/applydata/ContentValue.java
+++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/oa/applydata/ContentValue.java
@@ -58,6 +58,9 @@ public class ContentValue implements Serializable {
private Formula formula;
+ @SerializedName("bank_account")
+ private BankAccount bankAccount;
+
/**
* The type Date.
*/
@@ -68,6 +71,23 @@ public static class Date implements Serializable {
@SerializedName("s_timestamp")
private String timestamp;
+
+ @SerializedName("timezone_info")
+ private TimezoneInfo timezoneInfo;
+
+ /**
+ * The type TimezoneInfo.
+ */
+ @Data
+ public static class TimezoneInfo implements Serializable {
+ private static final long serialVersionUID = 164839205748392017L;
+
+ @SerializedName("zone_offset")
+ private String zoneOffset;
+
+ @SerializedName("zone_desc")
+ private String zoneDesc;
+ }
}
/**
@@ -228,6 +248,8 @@ public static class DataRange implements Serializable {
private Long end;
@SerializedName("new_duration")
private Long duration;
+ @SerializedName("timezone_info")
+ private Date.TimezoneInfo timezoneInfo;
}
/**
@@ -341,4 +363,55 @@ public static class Formula implements Serializable {
private String value;
}
+ /**
+ * The type BankAccount
+ */
+ @Data
+ public static class BankAccount implements Serializable {
+ private static final long serialVersionUID = 938475610283746192L;
+
+ @SerializedName("account_type")
+ private Long accountType;
+
+ @SerializedName("account_name")
+ private String accountName;
+
+ @SerializedName("account_number")
+ private String accountNumber;
+
+ private String remark;
+
+ private Bank bank;
+
+ /**
+ * The type Bank
+ */
+ @Data
+ public static class Bank implements Serializable {
+ private static final long serialVersionUID = 527384916203847561L;
+
+ @SerializedName("bank_alias")
+ private String bankAlias;
+
+ @SerializedName("bank_alias_code")
+ private String bankAliasCode;
+
+ private String province;
+
+ @SerializedName("province_code")
+ private Long provinceCode;
+
+ private String city;
+
+ @SerializedName("city_code")
+ private Long cityCode;
+
+ @SerializedName("bank_branch_name")
+ private String bankBranchName;
+
+ @SerializedName("bank_branch_id")
+ private String bankBranchId;
+ }
+ }
+
}
From 42405575f14d7def3ea070e60fe4779cf23fc395 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=A4=A7=E7=B2=BD=E5=AD=90?=
Date: Mon, 2 Feb 2026 03:13:07 +0000
Subject: [PATCH 34/94] =?UTF-8?q?:memo:=20=E6=9B=B4=E6=96=B0=E8=B5=9E?=
=?UTF-8?q?=E5=8A=A9=E5=95=86logo?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 69d5efbf34..ab1d823524 100644
--- a/README.md
+++ b/README.md
@@ -51,8 +51,8 @@
赞助商招募中
|
-
-
+
+
|
From b89ff6a5af2d482109f1136f12d385bfb63937ae Mon Sep 17 00:00:00 2001
From: wuKong
Date: Tue, 24 Feb 2026 07:55:00 +0000
Subject: [PATCH 35/94] =?UTF-8?q?:art:=20=E3=80=90=E5=BE=AE=E4=BF=A1?=
=?UTF-8?q?=E6=94=AF=E4=BB=98=E3=80=91=E8=A1=A5=E5=85=85=E5=AE=8C=E5=96=84?=
=?UTF-8?q?=E6=8A=95=E8=AF=89=E9=80=9A=E7=9F=A5=E7=BB=93=E6=9E=9C=E7=B1=BB?=
=?UTF-8?q?=E9=87=8C=E7=BC=BA=E5=B0=91=E7=9A=84=E5=AD=97=E6=AE=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../bean/notify/ComplaintNotifyResult.java | 108 ++++++++++++++++++
1 file changed, 108 insertions(+)
diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/notify/ComplaintNotifyResult.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/notify/ComplaintNotifyResult.java
index 9464144c1d..fd5badb5d7 100644
--- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/notify/ComplaintNotifyResult.java
+++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/notify/ComplaintNotifyResult.java
@@ -1,5 +1,6 @@
package com.github.binarywang.wxpay.bean.notify;
+import com.github.binarywang.wxpay.v3.SpecEncrypt;
import com.google.gson.annotations.SerializedName;
import lombok.Data;
import lombok.NoArgsConstructor;
@@ -69,6 +70,113 @@ public static class DecryptNotifyResult implements Serializable {
@SerializedName(value = "action_type")
private String actionType;
+ /**
+ *
+ * 字段名:商户订单号
+ * 是否必填:是
+ * 描述:
+ * 投诉单关联的商户订单号
+ *
+ */
+ @SerializedName("out_trade_no")
+ private String outTradeNo;
+
+ /**
+ *
+ * 字段名:投诉时间
+ * 是否必填:是
+ * 描述:投诉时间,遵循rfc3339标准格式,格式为yyyy-MM-DDTHH:mm:ss.sss+TIMEZONE,yyyy-MM-DD表示年月日,
+ * T出现在字符串中,表示time元素的开头,HH:mm:ss.sss表示时分秒毫秒,TIMEZONE表示时区(+08:00表示东八区时间,领先UTC 8小时,即北京时间)。
+ * 例如:2015-05-20T13:29:35.120+08:00表示北京时间2015年05月20日13点29分35秒
+ * 示例值:2015-05-20T13:29:35.120+08:00
+ *
+ */
+ @SerializedName("complaint_time")
+ private String complaintTime;
+
+ /**
+ *
+ * 字段名:订单金额
+ * 是否必填:是
+ * 描述:
+ * 订单金额,单位(分)
+ *
+ */
+ @SerializedName("amount")
+ private Integer amount;
+
+ /**
+ *
+ * 字段名:投诉人联系方式
+ * 是否必填:否
+ * 投诉人联系方式。该字段已做加密处理,具体解密方法详见敏感信息加密说明。
+ *
+ */
+ @SerializedName("payer_phone")
+ @SpecEncrypt
+ private String payerPhone;
+
+ /**
+ *
+ * 字段名:投诉详情
+ * 是否必填:是
+ * 投诉的具体描述
+ *
+ */
+ @SerializedName("complaint_detail")
+ private String complaintDetail;
+
+ /**
+ *
+ * 字段名:投诉单状态
+ * 是否必填:是
+ * 标识当前投诉单所处的处理阶段,具体状态如下所示:
+ * PENDING:待处理
+ * PROCESSING:处理中
+ * PROCESSED:已处理完成
+ *
+ */
+ @SerializedName("complaint_state")
+ private String complaintState;
+
+ /**
+ *
+ * 字段名:微信订单号
+ * 是否必填:是
+ * 描述:
+ * 投诉单关联的微信订单号
+ *
+ */
+ @SerializedName("transaction_id")
+ private String transactionId;
+
+ /**
+ *
+ * 字段名:商户处理状态
+ * 是否必填:是
+ * 描述:
+ * 触发本次投诉通知回调的具体动作类型,枚举如下:
+ * 常规通知:
+ * CREATE_COMPLAINT:用户提交投诉
+ * CONTINUE_COMPLAINT:用户继续投诉
+ * USER_RESPONSE:用户新留言
+ * RESPONSE_BY_PLATFORM:平台新留言
+ * SELLER_REFUND:商户发起全额退款
+ * MERCHANT_RESPONSE:商户新回复
+ * MERCHANT_CONFIRM_COMPLETE:商户反馈处理完成
+ * USER_APPLY_PLATFORM_SERVICE:用户申请平台协助
+ * USER_CANCEL_PLATFORM_SERVICE:用户取消平台协助
+ * PLATFORM_SERVICE_FINISHED:客服结束平台协助
+ *
+ * 申请退款单的附加通知:
+ * 以下通知会更新投诉单状态,建议收到后查询投诉单详情。
+ * MERCHANT_APPROVE_REFUND:商户同意退款
+ * MERCHANT_REJECT_REFUND:商户驳回退款
+ * REFUND_SUCCESS:退款到账
+ *
+ */
+ @SerializedName("complaint_handle_state")
+ private String complaintHandleState;
}
}
From 5ff4114b6c045b50925642b738f84761bb182500 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Sat, 28 Feb 2026 16:53:17 +0800
Subject: [PATCH 36/94] =?UTF-8?q?:art:=20#3886=20=E3=80=90=E4=BC=81?=
=?UTF-8?q?=E4=B8=9A=E5=BE=AE=E4=BF=A1=E3=80=91=E5=AE=A2=E6=9C=8D=E6=B6=88?=
=?UTF-8?q?=E6=81=AF=E6=96=B0=E5=A2=9E=E8=A7=86=E9=A2=91=E5=8F=B7=E6=B6=88?=
=?UTF-8?q?=E6=81=AF=E7=B1=BB=E5=9E=8B=EF=BC=88channels=EF=BC=89=E6=94=AF?=
=?UTF-8?q?=E6=8C=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../weixin/cp/bean/kf/WxCpKfMsgListResp.java | 1 +
.../cp/bean/kf/msg/WxCpKfChannelsMsg.java | 52 +++++++++++++++++++
2 files changed, 53 insertions(+)
create mode 100644 weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/kf/msg/WxCpKfChannelsMsg.java
diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/kf/WxCpKfMsgListResp.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/kf/WxCpKfMsgListResp.java
index f8f3275c46..a165c1c4cd 100644
--- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/kf/WxCpKfMsgListResp.java
+++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/kf/WxCpKfMsgListResp.java
@@ -67,6 +67,7 @@ public static class WxCpKfMsgItem {
private WxCpKfChannelsShopProductMsg channelsShopProduct;
@SerializedName("channels_shop_order")
private WxCpKfChannelsShopOrderMsg channelsShopOrder;
+ private WxCpKfChannelsMsg channels;
}
/**
diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/kf/msg/WxCpKfChannelsMsg.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/kf/msg/WxCpKfChannelsMsg.java
new file mode 100644
index 0000000000..db23c222f5
--- /dev/null
+++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/kf/msg/WxCpKfChannelsMsg.java
@@ -0,0 +1,52 @@
+package me.chanjar.weixin.cp.bean.kf.msg;
+
+import com.google.gson.annotations.SerializedName;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 视频号消息
+ *
+ * @author liuzhao created on 2024/2/25
+ */
+@NoArgsConstructor
+@Data
+public class WxCpKfChannelsMsg {
+
+ /**
+ * 视频号名称
+ */
+ @SerializedName("nickname")
+ private String nickname;
+
+ /**
+ * 视频/直播标题
+ */
+ @SerializedName("title")
+ private String title;
+
+ /**
+ * 视频/直播描述
+ */
+ @SerializedName("desc")
+ private String desc;
+
+ /**
+ * 封面图片url
+ */
+ @SerializedName("cover_url")
+ private String coverUrl;
+
+ /**
+ * 视频/直播链接
+ */
+ @SerializedName("url")
+ private String url;
+
+ /**
+ * 视频号账号名称
+ */
+ @SerializedName("find_username")
+ private String findUsername;
+
+}
From 3233384b17433de9c265cda32b1f6dcdbb449bf2 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Sat, 28 Feb 2026 16:55:43 +0800
Subject: [PATCH 37/94] =?UTF-8?q?:art:=20#3880=20=E3=80=90=E4=BC=81?=
=?UTF-8?q?=E4=B8=9A=E5=BE=AE=E4=BF=A1=E3=80=91=E4=BF=AE=E5=A4=8DMemChange?=
=?UTF-8?q?List=E7=BE=A4=E6=88=90=E5=91=98=E5=8F=98=E6=9B=B4ID=E8=A7=A3?=
=?UTF-8?q?=E6=9E=90=E4=B8=BA=E7=A9=BA=E5=AD=97=E7=AC=A6=E4=B8=B2=E7=9A=84?=
=?UTF-8?q?=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../util/xml/XStreamCDataListConverter.java | 54 +++++++++++++++++++
.../cp/bean/message/WxCpXmlMessage.java | 3 +-
.../cp/bean/message/WxCpXmlMessageTest.java | 48 +++++++++++++++++
3 files changed, 104 insertions(+), 1 deletion(-)
create mode 100644 weixin-java-common/src/main/java/me/chanjar/weixin/common/util/xml/XStreamCDataListConverter.java
diff --git a/weixin-java-common/src/main/java/me/chanjar/weixin/common/util/xml/XStreamCDataListConverter.java b/weixin-java-common/src/main/java/me/chanjar/weixin/common/util/xml/XStreamCDataListConverter.java
new file mode 100644
index 0000000000..0b55a9c037
--- /dev/null
+++ b/weixin-java-common/src/main/java/me/chanjar/weixin/common/util/xml/XStreamCDataListConverter.java
@@ -0,0 +1,54 @@
+package me.chanjar.weixin.common.util.xml;
+
+import com.thoughtworks.xstream.converters.Converter;
+import com.thoughtworks.xstream.converters.MarshallingContext;
+import com.thoughtworks.xstream.converters.UnmarshallingContext;
+import com.thoughtworks.xstream.io.HierarchicalStreamReader;
+import com.thoughtworks.xstream.io.HierarchicalStreamWriter;
+
+/**
+ * 兼容两种格式的字符串列表转换器:
+ *
+ * - 旧格式(4.8.0之前):<MemChangeList><![CDATA[id1,id2]]></MemChangeList>
+ * - 新格式(4.8.0起):<MemChangeList><Item><![CDATA[id1]]></Item></MemChangeList>
+ *
+ * 解析结果统一为逗号分隔的字符串。
+ */
+public class XStreamCDataListConverter implements Converter {
+
+ @Override
+ public void marshal(Object source, HierarchicalStreamWriter writer, MarshallingContext context) {
+ if (source != null) {
+ writer.setValue("");
+ }
+ }
+
+ @Override
+ public Object unmarshal(HierarchicalStreamReader reader, UnmarshallingContext context) {
+ if (reader.hasMoreChildren()) {
+ // 新格式:含有 - 子元素
+ StringBuilder sb = new StringBuilder();
+ while (reader.hasMoreChildren()) {
+ reader.moveDown();
+ String value = reader.getValue();
+ if (value != null && !value.isEmpty()) {
+ if (sb.length() > 0) {
+ sb.append(",");
+ }
+ sb.append(value);
+ }
+ reader.moveUp();
+ }
+ return sb.length() > 0 ? sb.toString() : null;
+ } else {
+ // 旧格式:直接 CDATA 文本
+ String value = reader.getValue();
+ return (value != null && !value.isEmpty()) ? value : null;
+ }
+ }
+
+ @Override
+ public boolean canConvert(Class type) {
+ return type == String.class;
+ }
+}
diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/message/WxCpXmlMessage.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/message/WxCpXmlMessage.java
index 08a0936317..6475623d80 100644
--- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/message/WxCpXmlMessage.java
+++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/message/WxCpXmlMessage.java
@@ -11,6 +11,7 @@
import me.chanjar.weixin.common.util.xml.IntegerArrayConverter;
import me.chanjar.weixin.common.util.xml.LongArrayConverter;
import me.chanjar.weixin.common.util.xml.XStreamCDataConverter;
+import me.chanjar.weixin.common.util.xml.XStreamCDataListConverter;
import me.chanjar.weixin.cp.config.WxCpConfigStorage;
import me.chanjar.weixin.cp.util.crypto.WxCpCryptUtil;
import me.chanjar.weixin.cp.util.json.WxCpGsonBuilder;
@@ -156,7 +157,7 @@ public class WxCpXmlMessage implements Serializable {
private String memChangeCnt;
@XStreamAlias("MemChangeList")
- @XStreamConverter(value = XStreamCDataConverter.class)
+ @XStreamConverter(value = XStreamCDataListConverter.class)
private String memChangeList;
@XStreamAlias("LastMemVer")
diff --git a/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/bean/message/WxCpXmlMessageTest.java b/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/bean/message/WxCpXmlMessageTest.java
index 0b2324a5f5..e87ff2334c 100644
--- a/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/bean/message/WxCpXmlMessageTest.java
+++ b/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/bean/message/WxCpXmlMessageTest.java
@@ -570,5 +570,53 @@ public void testExternalChatChangeEvent() {
assertEquals(wxMessage3.getUpdateDetail(), "change_name");
// 当XML中没有MemChangeList元素时,字段应该为null而不是空字符串
assertThat(wxMessage3.getMemChangeList()).isNull();
+
+ // 测试企业微信4.8.0新格式:MemChangeList使用
- 子元素(加群场景)
+ String xmlNewFormatAddMember = ""
+ + ""
+ + ""
+ + "9811170016713"
+ + ""
+ + ""
+ + ""
+ + ""
+ + ""
+ + "3"
+ + "1"
+ + "
"
+ + ""
+ + ""
+ + "";
+ WxCpXmlMessage wxMessage4 = WxCpXmlMessage.fromXml(xmlNewFormatAddMember);
+ assertEquals(wxMessage4.getEvent(), WxCpConsts.EventType.CHANGE_EXTERNAL_CHAT);
+ assertEquals(wxMessage4.getChangeType(), "update");
+ assertEquals(wxMessage4.getUpdateDetail(), "add_member");
+ assertEquals(wxMessage4.getJoinScene(), "3");
+ assertEquals(wxMessage4.getMemChangeCnt(), "1");
+ // 新格式:- 子元素中的成员ID应被正确解析
+ assertEquals(wxMessage4.getMemChangeList(), "wmxUBwDQAAO-Hn5_wFJz4wvo5TxLFibw");
+
+ // 测试企业微信4.8.0新格式:多个
- 子元素(多成员变更)
+ String xmlNewFormatMultiMember = ""
+ + ""
+ + ""
+ + "1403610513"
+ + ""
+ + ""
+ + ""
+ + ""
+ + ""
+ + "1"
+ + "2"
+ + ""
+ + "
"
+ + " "
+ + ""
+ + "";
+ WxCpXmlMessage wxMessage5 = WxCpXmlMessage.fromXml(xmlNewFormatMultiMember);
+ assertEquals(wxMessage5.getUpdateDetail(), "del_member");
+ assertEquals(wxMessage5.getMemChangeCnt(), "2");
+ // 多个- 元素应被解析为逗号分隔字符串
+ assertEquals(wxMessage5.getMemChangeList(), "wmEJiCwAAA9KG2qlSq6rKwASSgAAAA,wmEJiCwAAA9KG2qlSq6rKwBBBBBBB");
}
}
From 8ef5a33dd531ca3668f160cffb87c3c603030df0 Mon Sep 17 00:00:00 2001
From: Binary Wang
Date: Sat, 28 Feb 2026 16:59:40 +0800
Subject: [PATCH 38/94] =?UTF-8?q?=E6=9B=B4=E6=96=B0my-agent.agent.md?=
=?UTF-8?q?=EF=BC=8C=E5=A2=9E=E5=8A=A0=E4=BD=9C=E8=80=85=E4=BF=A1=E6=81=AF?=
=?UTF-8?q?=E8=AF=B4=E6=98=8E?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
新增关于作者信息的注意事项,确保作者名为GitHub Copilot。
---
.github/agents/my-agent.agent.md | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/.github/agents/my-agent.agent.md b/.github/agents/my-agent.agent.md
index 0c8481288a..2fd68fb457 100644
--- a/.github/agents/my-agent.agent.md
+++ b/.github/agents/my-agent.agent.md
@@ -10,5 +10,6 @@ description: 需要用中文,包括PR标题和分析总结过程
# My Agent
-1、请使用中文输出思考过程和总结,包括PR标题,提交commit信息也要使用中文;
-2、生成代码时需要提供必要的单元测试代码。
+- 1、请使用中文输出思考过程和总结,包括PR标题,提交commit信息也要使用中文;
+- 2、生成代码时需要提供必要的单元测试代码;
+- 3、新增加的代码如果标记作者信息,请注意不要把作者名设为binarywang或者其他无关人员,要改为 Github Copilot。
From 389f1785b63b9313f399edee02e6123ad518d59e Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Sat, 28 Feb 2026 17:01:23 +0800
Subject: [PATCH 39/94] =?UTF-8?q?:new:=20#3892=20=E3=80=90=E4=BC=81?=
=?UTF-8?q?=E4=B8=9A=E5=BE=AE=E4=BF=A1=E3=80=91=E5=AE=9E=E7=8E=B0=E4=BC=81?=
=?UTF-8?q?=E4=B8=9A=E5=BE=AE=E4=BF=A1=E4=BA=BA=E4=BA=8B=E5=8A=A9=E6=89=8B?=
=?UTF-8?q?=20API?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../chanjar/weixin/cp/api/WxCpHrService.java | 60 ++++++++++
.../me/chanjar/weixin/cp/api/WxCpService.java | 7 ++
.../cp/api/impl/BaseWxCpServiceImpl.java | 6 +
.../weixin/cp/api/impl/WxCpHrServiceImpl.java | 77 +++++++++++++
.../cp/bean/hr/WxCpHrEmployeeFieldData.java | 52 +++++++++
.../bean/hr/WxCpHrEmployeeFieldDataResp.java | 38 +++++++
.../cp/bean/hr/WxCpHrEmployeeFieldInfo.java | 103 ++++++++++++++++++
.../bean/hr/WxCpHrEmployeeFieldInfoResp.java | 38 +++++++
.../cp/bean/hr/WxCpHrEmployeeFieldValue.java | 94 ++++++++++++++++
.../weixin/cp/bean/hr/WxCpHrFieldType.java | 64 +++++++++++
.../weixin/cp/constant/WxCpApiPathConsts.java | 21 ++++
.../cp/api/impl/WxCpHrServiceImplTest.java | 100 +++++++++++++++++
12 files changed, 660 insertions(+)
create mode 100644 weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpHrService.java
create mode 100644 weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpHrServiceImpl.java
create mode 100644 weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/hr/WxCpHrEmployeeFieldData.java
create mode 100644 weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/hr/WxCpHrEmployeeFieldDataResp.java
create mode 100644 weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/hr/WxCpHrEmployeeFieldInfo.java
create mode 100644 weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/hr/WxCpHrEmployeeFieldInfoResp.java
create mode 100644 weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/hr/WxCpHrEmployeeFieldValue.java
create mode 100644 weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/hr/WxCpHrFieldType.java
create mode 100644 weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/impl/WxCpHrServiceImplTest.java
diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpHrService.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpHrService.java
new file mode 100644
index 0000000000..fdfe536d1e
--- /dev/null
+++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpHrService.java
@@ -0,0 +1,60 @@
+package me.chanjar.weixin.cp.api;
+
+import me.chanjar.weixin.common.error.WxErrorException;
+import me.chanjar.weixin.cp.bean.hr.WxCpHrEmployeeFieldData;
+import me.chanjar.weixin.cp.bean.hr.WxCpHrEmployeeFieldDataResp;
+import me.chanjar.weixin.cp.bean.hr.WxCpHrEmployeeFieldInfoResp;
+
+import java.util.List;
+
+/**
+ * 人事助手相关接口.
+ * 官方文档:https://developer.work.weixin.qq.com/document/path/99132
+ *
+ * @author leejoker created on 2024-01-01
+ */
+public interface WxCpHrService {
+
+ /**
+ * 获取员工档案字段信息.
+ *
+ * 请求方式:POST(HTTPS)
+ * 请求地址:https://qyapi.weixin.qq.com/cgi-bin/hr/employee/get_field_info?access_token=ACCESS_TOKEN
+ * 权限说明:
+ * 需要配置人事助手的secret,调用接口前需给对应成员赋予人事小助手应用的权限。
+ *
+ * @param fields 指定字段key列表,不填则返回全部字段
+ * @return 字段信息响应 wx cp hr employee field info resp
+ * @throws WxErrorException the wx error exception
+ */
+ WxCpHrEmployeeFieldInfoResp getFieldInfo(List fields) throws WxErrorException;
+
+ /**
+ * 获取员工档案数据.
+ *
+ * 请求方式:POST(HTTPS)
+ * 请求地址:https://qyapi.weixin.qq.com/cgi-bin/hr/employee/get_employee_field_info?access_token=ACCESS_TOKEN
+ * 权限说明:
+ * 需要配置人事助手的secret,调用接口前需给对应成员赋予人事小助手应用的权限。
+ *
+ * @param userids 员工userid列表,不超过20个
+ * @param fields 指定字段key列表,不填则返回全部字段
+ * @return 员工档案数据响应 wx cp hr employee field data resp
+ * @throws WxErrorException the wx error exception
+ */
+ WxCpHrEmployeeFieldDataResp getEmployeeFieldInfo(List userids, List fields) throws WxErrorException;
+
+ /**
+ * 更新员工档案数据.
+ *
+ * 请求方式:POST(HTTPS)
+ * 请求地址:https://qyapi.weixin.qq.com/cgi-bin/hr/employee/update_employee_field_info?access_token=ACCESS_TOKEN
+ * 权限说明:
+ * 需要配置人事助手的secret,调用接口前需给对应成员赋予人事小助手应用的权限。
+ *
+ * @param userid 员工userid
+ * @param fieldList 字段数据列表
+ * @throws WxErrorException the wx error exception
+ */
+ void updateEmployeeFieldInfo(String userid, List fieldList) throws WxErrorException;
+}
diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpService.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpService.java
index 76012a2812..3427d656ea 100644
--- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpService.java
+++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpService.java
@@ -594,4 +594,11 @@ public interface WxCpService extends WxService {
* @return 智能机器人服务 intelligent robot service
*/
WxCpIntelligentRobotService getIntelligentRobotService();
+
+ /**
+ * 获取人事助手服务
+ *
+ * @return 人事助手服务 hr service
+ */
+ WxCpHrService getHrService();
}
diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/BaseWxCpServiceImpl.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/BaseWxCpServiceImpl.java
index bc18c9bc7a..9c69329303 100644
--- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/BaseWxCpServiceImpl.java
+++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/BaseWxCpServiceImpl.java
@@ -75,6 +75,7 @@ public abstract class BaseWxCpServiceImpl implements WxCpService, RequestH
private final WxCpMeetingService meetingService = new WxCpMeetingServiceImpl(this);
private final WxCpCorpGroupService corpGroupService = new WxCpCorpGroupServiceImpl(this);
private final WxCpIntelligentRobotService intelligentRobotService = new WxCpIntelligentRobotServiceImpl(this);
+ private final WxCpHrService hrService = new WxCpHrServiceImpl(this);
/**
* 全局的是否正在刷新access token的锁.
@@ -708,4 +709,9 @@ public WxCpCorpGroupService getCorpGroupService() {
public WxCpIntelligentRobotService getIntelligentRobotService() {
return this.intelligentRobotService;
}
+
+ @Override
+ public WxCpHrService getHrService() {
+ return this.hrService;
+ }
}
diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpHrServiceImpl.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpHrServiceImpl.java
new file mode 100644
index 0000000000..7c48b0bd50
--- /dev/null
+++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpHrServiceImpl.java
@@ -0,0 +1,77 @@
+package me.chanjar.weixin.cp.api.impl;
+
+import com.google.gson.JsonObject;
+import lombok.RequiredArgsConstructor;
+import me.chanjar.weixin.common.error.WxErrorException;
+import me.chanjar.weixin.cp.api.WxCpHrService;
+import me.chanjar.weixin.cp.api.WxCpService;
+import me.chanjar.weixin.cp.bean.hr.WxCpHrEmployeeFieldData;
+import me.chanjar.weixin.cp.bean.hr.WxCpHrEmployeeFieldDataResp;
+import me.chanjar.weixin.cp.bean.hr.WxCpHrEmployeeFieldInfoResp;
+import me.chanjar.weixin.cp.util.json.WxCpGsonBuilder;
+
+import java.util.List;
+
+import static me.chanjar.weixin.cp.constant.WxCpApiPathConsts.Hr.*;
+
+/**
+ * 人事助手相关接口实现类.
+ * 官方文档:https://developer.work.weixin.qq.com/document/path/99132
+ *
+ * @author leejoker created on 2024-01-01
+ */
+@RequiredArgsConstructor
+public class WxCpHrServiceImpl implements WxCpHrService {
+
+ private final WxCpService cpService;
+
+ @Override
+ public WxCpHrEmployeeFieldInfoResp getFieldInfo(List fields) throws WxErrorException {
+ JsonObject jsonObject = new JsonObject();
+ if (fields != null && !fields.isEmpty()) {
+ jsonObject.add("fields", WxCpGsonBuilder.create().toJsonTree(fields));
+ }
+ String response = this.cpService.post(
+ this.cpService.getWxCpConfigStorage().getApiUrl(GET_FIELD_INFO),
+ jsonObject.toString()
+ );
+ return WxCpHrEmployeeFieldInfoResp.fromJson(response);
+ }
+
+ @Override
+ public WxCpHrEmployeeFieldDataResp getEmployeeFieldInfo(List userids, List fields) throws WxErrorException {
+ if (userids == null || userids.isEmpty()) {
+ throw new IllegalArgumentException("userids 不能为空");
+ }
+ if (userids.size() > 20) {
+ throw new IllegalArgumentException("userids 每次最多传入20个");
+ }
+ JsonObject jsonObject = new JsonObject();
+ jsonObject.add("userids", WxCpGsonBuilder.create().toJsonTree(userids));
+ if (fields != null && !fields.isEmpty()) {
+ jsonObject.add("fields", WxCpGsonBuilder.create().toJsonTree(fields));
+ }
+ String response = this.cpService.post(
+ this.cpService.getWxCpConfigStorage().getApiUrl(GET_EMPLOYEE_FIELD_INFO),
+ jsonObject.toString()
+ );
+ return WxCpHrEmployeeFieldDataResp.fromJson(response);
+ }
+
+ @Override
+ public void updateEmployeeFieldInfo(String userid, List fieldList) throws WxErrorException {
+ if (userid == null || userid.trim().isEmpty()) {
+ throw new IllegalArgumentException("userid 不能为空");
+ }
+ if (fieldList == null || fieldList.isEmpty()) {
+ throw new IllegalArgumentException("fieldList 不能为空");
+ }
+ JsonObject jsonObject = new JsonObject();
+ jsonObject.addProperty("userid", userid);
+ jsonObject.add("field_list", WxCpGsonBuilder.create().toJsonTree(fieldList));
+ this.cpService.post(
+ this.cpService.getWxCpConfigStorage().getApiUrl(UPDATE_EMPLOYEE_FIELD_INFO),
+ jsonObject.toString()
+ );
+ }
+}
diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/hr/WxCpHrEmployeeFieldData.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/hr/WxCpHrEmployeeFieldData.java
new file mode 100644
index 0000000000..971e5958d1
--- /dev/null
+++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/hr/WxCpHrEmployeeFieldData.java
@@ -0,0 +1,52 @@
+package me.chanjar.weixin.cp.bean.hr;
+
+import com.google.gson.annotations.SerializedName;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * 人事助手-员工档案数据(单个员工).
+ *
+ * @author leejoker created on 2024-01-01
+ */
+@Data
+@NoArgsConstructor
+public class WxCpHrEmployeeFieldData implements Serializable {
+ private static final long serialVersionUID = 4593693598671765396L;
+
+ /**
+ * 员工userid.
+ */
+ @SerializedName("userid")
+ private String userid;
+
+ /**
+ * 字段数据列表.
+ */
+ @SerializedName("field_list")
+ private List fieldList;
+
+ /**
+ * 字段数据项.
+ */
+ @Data
+ @NoArgsConstructor
+ public static class FieldItem implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 字段key.
+ */
+ @SerializedName("field_key")
+ private String fieldKey;
+
+ /**
+ * 字段值.
+ */
+ @SerializedName("field_value")
+ private WxCpHrEmployeeFieldValue fieldValue;
+ }
+}
diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/hr/WxCpHrEmployeeFieldDataResp.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/hr/WxCpHrEmployeeFieldDataResp.java
new file mode 100644
index 0000000000..07e286c2ef
--- /dev/null
+++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/hr/WxCpHrEmployeeFieldDataResp.java
@@ -0,0 +1,38 @@
+package me.chanjar.weixin.cp.bean.hr;
+
+import com.google.gson.annotations.SerializedName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import me.chanjar.weixin.cp.bean.WxCpBaseResp;
+import me.chanjar.weixin.cp.util.json.WxCpGsonBuilder;
+
+import java.util.List;
+
+/**
+ * 人事助手-获取员工档案数据响应.
+ *
+ * @author leejoker created on 2024-01-01
+ */
+@Data
+@NoArgsConstructor
+@EqualsAndHashCode(callSuper = true)
+public class WxCpHrEmployeeFieldDataResp extends WxCpBaseResp {
+ private static final long serialVersionUID = 6593693598671765396L;
+
+ /**
+ * 员工档案数据列表.
+ */
+ @SerializedName("employee_field_list")
+ private List employeeFieldList;
+
+ /**
+ * From json wx cp hr employee field data resp.
+ *
+ * @param json the json
+ * @return the wx cp hr employee field data resp
+ */
+ public static WxCpHrEmployeeFieldDataResp fromJson(String json) {
+ return WxCpGsonBuilder.create().fromJson(json, WxCpHrEmployeeFieldDataResp.class);
+ }
+}
diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/hr/WxCpHrEmployeeFieldInfo.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/hr/WxCpHrEmployeeFieldInfo.java
new file mode 100644
index 0000000000..e355d8cc6a
--- /dev/null
+++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/hr/WxCpHrEmployeeFieldInfo.java
@@ -0,0 +1,103 @@
+package me.chanjar.weixin.cp.bean.hr;
+
+import com.google.gson.annotations.SerializedName;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * 人事助手-员工档案字段信息.
+ *
+ * @author leejoker created on 2024-01-01
+ */
+@Data
+@NoArgsConstructor
+public class WxCpHrEmployeeFieldInfo implements Serializable {
+ private static final long serialVersionUID = 2593693598671765396L;
+
+ /**
+ * 字段key.
+ */
+ @SerializedName("field_key")
+ private String fieldKey;
+
+ /**
+ * 字段英文名称.
+ */
+ @SerializedName("field_en_name")
+ private String fieldEnName;
+
+ /**
+ * 字段中文名称.
+ */
+ @SerializedName("field_zh_name")
+ private String fieldZhName;
+
+ /**
+ * 字段类型.
+ * 具体取值参见 {@link WxCpHrFieldType}
+ */
+ @SerializedName("field_type")
+ private Integer fieldType;
+
+ /**
+ * 获取字段类型枚举.
+ *
+ * @return 字段类型枚举,未匹配时返回 null
+ */
+ public WxCpHrFieldType getFieldTypeEnum() {
+ return fieldType == null ? null : WxCpHrFieldType.fromCode(fieldType);
+ }
+
+ /**
+ * 是否系统字段.
+ * 0: 否
+ * 1: 是
+ */
+ @SerializedName("is_sys")
+ private Integer isSys;
+
+ /**
+ * 字段详情.
+ */
+ @SerializedName("field_detail")
+ private FieldDetail fieldDetail;
+
+ /**
+ * 字段详情.
+ */
+ @Data
+ @NoArgsConstructor
+ public static class FieldDetail implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 选项列表(单选/多选字段专用).
+ */
+ @SerializedName("option_list")
+ private List
+ */
+ @SerializedName("relate_limitations")
+ private List relateLimitations;
+
+ /**
+ * 商户被该原因管控的其他能力描述
+ *
+ * 在该原因下,若商户除了relate_limitations所罗列的被管控能力,还有其他被管控的能力时会返回(如有多项以英文逗号分隔)
+ *
+ */
+ @SerializedName("other_relate_limitations")
+ private String otherRelateLimitations;
+
+ /**
+ * 商户被该原因管控的解脱路径
+ *
+ * 在该原因下,若存在解脱路径时会返回
+ *
IRRECOVERABLE:不可恢复
+ * MODIFY_SUBJECT_INFORMATION:修改主体资料
+ * MODIFY_SETTLE_ACCOUNT_INFORMATION:修改结算银行账户
+ * VERIFY_INACTIVE_MERCHANT_IDENTITY:核实商户身份
+ * SUBMIT_OFFLINE_BUSINESS_SCENARIO_INFORMATION:提交线下经营场景信息
+ * SUBMIT_INFORMATION_FOR_APPEAL:提交相关信息申诉
+ * RESOLVE_TRANSACTION_DISPUTES:解决交易纠纷
+ * MODIFY_ADMINISTRATOR_INFORMATION:修改超级管理员
+ * CALL_CUSTOMER_SERVICE_AT_95017:拨打微信支付客服电话95017
+ * UPDATE_BUSINESS_SCENARIO_INFORMATION:更新经营场景信息
+ * SUBMIT_CDD_INFORMATION:填写尽调信息
+ * WAITING_FOR_PLATFORM_REVIEW:等待平台审核
+ * SUBMIT_UBO_INFORMATION:补充受益所有人信息
+ * SIGN_ANTI_FRAUD_PLEDGE_AND_VERIFY_FACE:签署反诈承诺书并刷脸核实身份
+ * CONTACT_APPROPRIATE_AUTHORITY_FOR_CONSULTATION:联系有权机关咨询
+ * MODIFY_ABBREVIATION_INFORMATION:修改商户简称
+ *
+ */
+ @SerializedName("recover_way")
+ private String recoverWay;
+
+ /**
+ * 商户被该原因管控的解脱路径参数
+ *
+ * 若解脱路径recover_way为“填写尽调信息”、“补充受益所有人信息”,需通过提交尽调来解脱,此处会返回“尽调单号”;若解脱路径recover_way
+ * 为“提交相关信息申诉”,需通过提交资料来解脱,此处会返回“商户管理记录单号”;若解脱路径recover_way为“联系有权机关咨询”,此处会返回有权机关信息
+ *
+ */
+ @SerializedName("recover_way_param")
+ private String recoverWayParam;
+
+ /**
+ * 商户被该原因管控的解脱帮助链接
+ *
+ * 在该原因下,若存在解脱帮助说明时会返回
+ *
+ */
+ @SerializedName("recover_help_url")
+ private String recoverHelpUrl;
+
+ /**
+ * 处置方式
+ *
+ * 管控处置方式类型,默认是立即管控
+ *
LIMIT_ACTION_TYPE_IMMEDIATE_CONTROL:立即管控
+ * LIMIT_ACTION_TYPE_DELAY_CONTROL:延迟管控
+ *
+ */
+ @SerializedName("limitation_action_type")
+ private String limitationActionType;
+
+ /**
+ * 预计管控开始时间
+ */
+ @SerializedName("limitation_start_date")
+ private String limitationStartDate;
+
+ /**
+ * 商户被该原因管控的时间
+ *
+ * 若商户被管控时会返回,延迟管控但是未到管控时间时不会返回
+ *
+ */
+ @SerializedName("limitation_date")
+ private String limitationDate;
+
+ }
+}
diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/notify/PartnerSubscribeNotifyResult.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/notify/PartnerSubscribeNotifyResult.java
new file mode 100644
index 0000000000..52917ddf17
--- /dev/null
+++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/notify/PartnerSubscribeNotifyResult.java
@@ -0,0 +1,66 @@
+package com.github.binarywang.wxpay.bean.notify;
+
+import com.google.gson.annotations.SerializedName;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serializable;
+
+/**
+ * 合作伙伴订阅通知 产品介绍
+ *
+ * 该类是订阅通知的通用结构,每个字段代表的含义和订阅类型有关。请依据文档自行判断使用。
+ *
+ *
+ * @author zhangyl
+ */
+@Data
+@NoArgsConstructor
+public class PartnerSubscribeNotifyResult implements Serializable,
+ WxPayBaseNotifyV3Result {
+ private static final long serialVersionUID = 1L;
+ /**
+ * 源数据
+ */
+ private OriginNotifyResponse rawData;
+ /**
+ * 解密后的数据
+ */
+ private DecryptNotifyResult result;
+
+ @Data
+ @NoArgsConstructor
+ public static class DecryptNotifyResult implements Serializable {
+ private static final long serialVersionUID = 1L;
+ /**
+ * 商户号
+ */
+ @SerializedName("merchant_code")
+ private String merchantCode;
+ /**
+ * 商户全称
+ */
+ @SerializedName("merchant_company_name")
+ private String merchantCompanyName;
+ /**
+ * 业务发生时间
+ */
+ @SerializedName("business_time")
+ private String businessTime;
+ /**
+ * 业务单据
+ */
+ @SerializedName("business_code")
+ private String businessCode;
+ /**
+ * 业务状态
+ */
+ @SerializedName("business_state")
+ private String businessState;
+ /**
+ * 备注
+ */
+ @SerializedName("remark")
+ private String remark;
+ }
+}
diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/MerchantLimitationService.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/MerchantLimitationService.java
new file mode 100644
index 0000000000..bc3753cce5
--- /dev/null
+++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/MerchantLimitationService.java
@@ -0,0 +1,28 @@
+package com.github.binarywang.wxpay.service;
+
+import com.github.binarywang.wxpay.bean.merchantlimitation.MerchantLimitationResult;
+import com.github.binarywang.wxpay.exception.WxPayException;
+
+/**
+ * 商户被管控能力及原因查询 接口
+ *
+ * 产品介绍
+ *
+ *
+ * @author zhangyl
+ */
+public interface MerchantLimitationService {
+
+ /**
+ * 查询子商户管控情况
+ *
+ * 接口文档
+ *
+ *
+ * @param subMchId 子商户号
+ * @return 子商户管控情况
+ * @throws WxPayException the wx pay exception
+ */
+ MerchantLimitationResult fetchLimitations(String subMchId) throws WxPayException;
+
+}
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 6a096c6338..a460d5f248 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
@@ -400,6 +400,13 @@ default WxPayService switchoverTo(String mchIdOrConfigKey) {
*/
void setEntPayService(EntPayService entPayService);
+ /**
+ * 获取商户被管控能力及原因查询接口
+ *
+ * @return MerchantLimitationService
+ */
+ MerchantLimitationService getMerchantLimitationService();
+
/**
*
* 查询订单.
@@ -1164,6 +1171,16 @@ WxPayRefundQueryResult refundQuery(String transactionId, String outTradeNo, Stri
*/
WxPayPartnerRefundNotifyV3Result parsePartnerRefundNotifyV3Result(String notifyData, SignatureHeader header) throws WxPayException;
+ /**
+ * 解析合作伙伴订阅通知
+ *
+ * @param notifyData 通知数据
+ * @param header 通知头部数据
+ * @return 合作伙伴订阅通知
+ * @throws WxPayException the wx pay exception
+ */
+ PartnerSubscribeNotifyResult parsePartnerSubscribeNotify(String notifyData, SignatureHeader header) throws WxPayException;
+
/**
* 解析扫码支付回调通知
* 详见https://pay.weixin.qq.com/wiki/doc/api/native.php?chapter=6_4
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 36987f637d..6868cb644f 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
@@ -143,6 +143,9 @@ public abstract class BaseWxPayServiceImpl implements WxPayService {
@Getter
private final MiPayService miPayService = new MiPayServiceImpl(this);
+ @Getter
+ private final MerchantLimitationService merchantLimitationService = new MerchantLimitationServiceImpl(this);
+
protected Map configMap = new ConcurrentHashMap<>();
@Override
@@ -631,6 +634,11 @@ public WxPayPartnerRefundNotifyV3Result parsePartnerRefundNotifyV3Result(String
return this.baseParseOrderNotifyV3Result(notifyData, header, WxPayPartnerRefundNotifyV3Result.class, WxPayPartnerRefundNotifyV3Result.DecryptNotifyResult.class);
}
+ @Override
+ public PartnerSubscribeNotifyResult parsePartnerSubscribeNotify(String notifyData, SignatureHeader header) throws WxPayException {
+ return this.baseParseOrderNotifyV3Result(notifyData, header, PartnerSubscribeNotifyResult.class, PartnerSubscribeNotifyResult.DecryptNotifyResult.class);
+ }
+
@Override
public WxScanPayNotifyResult parseScanPayNotifyResult(String xmlData, @Deprecated String signType) throws WxPayException {
try {
diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/MerchantLimitationServiceImpl.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/MerchantLimitationServiceImpl.java
new file mode 100644
index 0000000000..d946336e31
--- /dev/null
+++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/MerchantLimitationServiceImpl.java
@@ -0,0 +1,28 @@
+package com.github.binarywang.wxpay.service.impl;
+
+import com.github.binarywang.wxpay.bean.merchantlimitation.MerchantLimitationResult;
+import com.github.binarywang.wxpay.exception.WxPayException;
+import com.github.binarywang.wxpay.service.MerchantLimitationService;
+import com.github.binarywang.wxpay.service.WxPayService;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import lombok.RequiredArgsConstructor;
+
+/**
+ * 商户被管控能力及原因查询 接口实现
+ *
+ * @author zhangyl
+ */
+@RequiredArgsConstructor
+public class MerchantLimitationServiceImpl implements MerchantLimitationService {
+ private final WxPayService payService;
+ private static final Gson GSON = new GsonBuilder().create();
+
+ @Override
+ public MerchantLimitationResult fetchLimitations(String subMchId) throws WxPayException {
+ String url = String.format("%s/v3/mch-operation-manage/merchant-limitations/sub-mchid/%s",
+ this.payService.getPayBaseUrl(), subMchId);
+ String result = this.payService.getV3(url);
+ return GSON.fromJson(result, MerchantLimitationResult.class);
+ }
+}
From b545874f1a47b7cbeb42b2e8211addf4379646f7 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Mon, 11 May 2026 20:26:44 +0800
Subject: [PATCH 90/94] =?UTF-8?q?:new:=20#3952=E3=80=90=E5=B0=8F=E7=A8=8B?=
=?UTF-8?q?=E5=BA=8F=E3=80=91=E6=96=B0=E5=A2=9E=E6=9C=8D=E5=8A=A1=E5=8D=A1?=
=?UTF-8?q?=E7=89=87=E6=B6=88=E6=81=AF=EF=BC=88=E8=AE=A2=E9=98=85=E6=B6=88?=
=?UTF-8?q?=E6=81=AF=202.0=EF=BC=89=E6=9C=8D=E5=8A=A1=E7=AB=AF=E8=83=BD?=
=?UTF-8?q?=E5=8A=9B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../wx/miniapp/api/WxMaSubscribeService.java | 44 +++++++++
.../api/impl/WxMaSubscribeServiceImpl.java | 32 +++++++
.../bean/WxMaGetUserNotifyRequest.java | 66 +++++++++++++
.../miniapp/bean/WxMaGetUserNotifyResult.java | 60 ++++++++++++
.../bean/WxMaServiceNotifyExtRequest.java | 82 ++++++++++++++++
.../bean/WxMaServiceNotifyRequest.java | 93 +++++++++++++++++++
.../miniapp/constant/WxMaApiUrlConstants.java | 9 ++
.../impl/WxMaSubscribeServiceImplTest.java | 65 +++++++++++++
8 files changed, 451 insertions(+)
create mode 100644 weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/WxMaGetUserNotifyRequest.java
create mode 100644 weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/WxMaGetUserNotifyResult.java
create mode 100644 weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/WxMaServiceNotifyExtRequest.java
create mode 100644 weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/WxMaServiceNotifyRequest.java
diff --git a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/WxMaSubscribeService.java b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/WxMaSubscribeService.java
index e6b1ed16a2..1dbb9f64c9 100644
--- a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/WxMaSubscribeService.java
+++ b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/WxMaSubscribeService.java
@@ -1,5 +1,9 @@
package cn.binarywang.wx.miniapp.api;
+import cn.binarywang.wx.miniapp.bean.WxMaGetUserNotifyRequest;
+import cn.binarywang.wx.miniapp.bean.WxMaGetUserNotifyResult;
+import cn.binarywang.wx.miniapp.bean.WxMaServiceNotifyExtRequest;
+import cn.binarywang.wx.miniapp.bean.WxMaServiceNotifyRequest;
import cn.binarywang.wx.miniapp.bean.WxMaSubscribeMessage;
import me.chanjar.weixin.common.bean.subscribemsg.CategoryData;
import me.chanjar.weixin.common.bean.subscribemsg.PubTemplateKeyword;
@@ -113,4 +117,44 @@ public interface WxMaSubscribeService {
*/
void sendSubscribeMsg(WxMaSubscribeMessage subscribeMessage) throws WxErrorException;
+ /**
+ *
+ * 激活与更新服务卡片
+ *
+ * 详情请见: 激活与更新服务卡片
+ * 接口url格式: POST https://api.weixin.qq.com/wxa/setusernotify?access_token=ACCESS_TOKEN
+ *
+ *
+ * @param request 请求参数
+ * @throws WxErrorException .
+ */
+ void setUserNotify(WxMaServiceNotifyRequest request) throws WxErrorException;
+
+ /**
+ *
+ * 更新服务卡片扩展信息
+ *
+ * 详情请见: 更新服务卡片扩展信息
+ * 接口url格式: POST https://api.weixin.qq.com/wxa/setusernotifyext?access_token=ACCESS_TOKEN
+ *
+ *
+ * @param request 请求参数
+ * @throws WxErrorException .
+ */
+ void setUserNotifyExt(WxMaServiceNotifyExtRequest request) throws WxErrorException;
+
+ /**
+ *
+ * 查询服务卡片状态
+ *
+ * 详情请见: 查询服务卡片状态
+ * 接口url格式: POST https://api.weixin.qq.com/wxa/getusernotify?access_token=ACCESS_TOKEN
+ *
+ *
+ * @param request 请求参数
+ * @return 服务卡片状态
+ * @throws WxErrorException .
+ */
+ WxMaGetUserNotifyResult getUserNotify(WxMaGetUserNotifyRequest request) throws WxErrorException;
+
}
diff --git a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/impl/WxMaSubscribeServiceImpl.java b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/impl/WxMaSubscribeServiceImpl.java
index a7db154a68..edf4d5ba10 100644
--- a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/impl/WxMaSubscribeServiceImpl.java
+++ b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/impl/WxMaSubscribeServiceImpl.java
@@ -2,6 +2,10 @@
import cn.binarywang.wx.miniapp.api.WxMaService;
import cn.binarywang.wx.miniapp.api.WxMaSubscribeService;
+import cn.binarywang.wx.miniapp.bean.WxMaGetUserNotifyRequest;
+import cn.binarywang.wx.miniapp.bean.WxMaGetUserNotifyResult;
+import cn.binarywang.wx.miniapp.bean.WxMaServiceNotifyExtRequest;
+import cn.binarywang.wx.miniapp.bean.WxMaServiceNotifyRequest;
import cn.binarywang.wx.miniapp.bean.WxMaSubscribeMessage;
import me.chanjar.weixin.common.api.WxConsts;
import me.chanjar.weixin.common.bean.subscribemsg.CategoryData;
@@ -89,4 +93,32 @@ public void sendSubscribeMsg(WxMaSubscribeMessage subscribeMessage) throws WxErr
throw new WxErrorException(WxError.fromJson(responseContent, WxType.MiniApp));
}
}
+
+ @Override
+ public void setUserNotify(WxMaServiceNotifyRequest request) throws WxErrorException {
+ String responseContent = this.service.post(SERVICE_NOTIFY_SET_URL, request.toJson());
+ JsonObject jsonObject = GsonParser.parse(responseContent);
+ if (jsonObject.get(WxConsts.ERR_CODE).getAsInt() != 0) {
+ throw new WxErrorException(WxError.fromJson(responseContent, WxType.MiniApp));
+ }
+ }
+
+ @Override
+ public void setUserNotifyExt(WxMaServiceNotifyExtRequest request) throws WxErrorException {
+ String responseContent = this.service.post(SERVICE_NOTIFY_SET_EXT_URL, request.toJson());
+ JsonObject jsonObject = GsonParser.parse(responseContent);
+ if (jsonObject.get(WxConsts.ERR_CODE).getAsInt() != 0) {
+ throw new WxErrorException(WxError.fromJson(responseContent, WxType.MiniApp));
+ }
+ }
+
+ @Override
+ public WxMaGetUserNotifyResult getUserNotify(WxMaGetUserNotifyRequest request) throws WxErrorException {
+ String responseContent = this.service.post(SERVICE_NOTIFY_GET_URL, request.toJson());
+ JsonObject jsonObject = GsonParser.parse(responseContent);
+ if (jsonObject.get(WxConsts.ERR_CODE).getAsInt() != 0) {
+ throw new WxErrorException(WxError.fromJson(responseContent, WxType.MiniApp));
+ }
+ return WxMaGsonBuilder.create().fromJson(responseContent, WxMaGetUserNotifyResult.class);
+ }
}
diff --git a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/WxMaGetUserNotifyRequest.java b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/WxMaGetUserNotifyRequest.java
new file mode 100644
index 0000000000..abc7518e02
--- /dev/null
+++ b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/WxMaGetUserNotifyRequest.java
@@ -0,0 +1,66 @@
+package cn.binarywang.wx.miniapp.bean;
+
+import cn.binarywang.wx.miniapp.json.WxMaGsonBuilder;
+import com.google.gson.annotations.SerializedName;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serializable;
+
+/**
+ * 查询服务卡片状态请求.
+ *
+ * 接口文档:
+ *
+ * 查询服务卡片状态
+ *
+ * @author GitHub Copilot
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class WxMaGetUserNotifyRequest implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 用户身份标识符.
+ *
+ * 参数:openid
+ * 是否必填:是
+ *
+ */
+ @SerializedName("openid")
+ private String openid;
+
+ /**
+ * 动态更新令牌.
+ *
+ * 参数:notify_code
+ * 是否必填:是
+ *
+ */
+ @SerializedName("notify_code")
+ private String notifyCode;
+
+ /**
+ * 卡片ID.
+ *
+ * 参数:notify_type
+ * 是否必填:是
+ *
+ */
+ @SerializedName("notify_type")
+ private Integer notifyType;
+
+ /**
+ * 转为 JSON 字符串.
+ *
+ * @return JSON 字符串
+ */
+ public String toJson() {
+ return WxMaGsonBuilder.create().toJson(this);
+ }
+}
diff --git a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/WxMaGetUserNotifyResult.java b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/WxMaGetUserNotifyResult.java
new file mode 100644
index 0000000000..0090eb19b4
--- /dev/null
+++ b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/WxMaGetUserNotifyResult.java
@@ -0,0 +1,60 @@
+package cn.binarywang.wx.miniapp.bean;
+
+import com.google.gson.annotations.SerializedName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.io.Serializable;
+
+/**
+ * 查询服务卡片状态响应.
+ *
+ * 接口文档:
+ *
+ * 查询服务卡片状态
+ *
+ * @author GitHub Copilot
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class WxMaGetUserNotifyResult extends WxMaBaseResponse {
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 卡片状态信息.
+ */
+ @SerializedName("notify_info")
+ private NotifyInfo notifyInfo;
+
+ /**
+ * 卡片状态详情.
+ */
+ @Data
+ public static class NotifyInfo implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 卡片ID.
+ */
+ @SerializedName("notify_type")
+ private Integer notifyType;
+
+ /**
+ * 上次有效推送的卡片状态与状态相关字段,没推送过为空字符串.
+ */
+ @SerializedName("content_json")
+ private String contentJson;
+
+ /**
+ * code 状态:0 正常;1 有风险;2 异常;10 用户拒收本次code.
+ */
+ @SerializedName("code_state")
+ private Integer codeState;
+
+ /**
+ * code 过期时间,秒级时间戳.
+ */
+ @SerializedName("code_expire_time")
+ private Long codeExpireTime;
+ }
+}
diff --git a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/WxMaServiceNotifyExtRequest.java b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/WxMaServiceNotifyExtRequest.java
new file mode 100644
index 0000000000..56315ce95e
--- /dev/null
+++ b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/WxMaServiceNotifyExtRequest.java
@@ -0,0 +1,82 @@
+package cn.binarywang.wx.miniapp.bean;
+
+import cn.binarywang.wx.miniapp.json.WxMaGsonBuilder;
+import com.google.gson.annotations.SerializedName;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serializable;
+
+/**
+ * 更新服务卡片扩展信息请求.
+ *
+ *
接口文档:
+ *
+ * 更新服务卡片扩展信息
+ *
+ * @author GitHub Copilot
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class WxMaServiceNotifyExtRequest implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 用户身份标识符.
+ *
+ * 参数:openid
+ * 是否必填:是
+ * 描述:用户身份标识符。
+ * 当使用微信支付订单号作为 code 时,需要与实际支付用户一致;
+ * 当通过前端获取 code 时,需要与点击 button 的用户一致。
+ *
+ */
+ @SerializedName("openid")
+ private String openid;
+
+ /**
+ * 卡片ID.
+ *
+ * 参数:notify_type
+ * 是否必填:是
+ * 描述:卡片ID。
+ *
+ */
+ @SerializedName("notify_type")
+ private Integer notifyType;
+
+ /**
+ * 动态更新令牌.
+ *
+ * 参数:notify_code
+ * 是否必填:是
+ * 描述:动态更新令牌。
+ *
+ */
+ @SerializedName("notify_code")
+ private String notifyCode;
+
+ /**
+ * 扩展信息.
+ *
+ * 参数:ext_json
+ * 是否必填:是
+ * 描述:扩展信息,不同卡片的定义不同。
+ *
+ */
+ @SerializedName("ext_json")
+ private String extJson;
+
+ /**
+ * 转为 JSON 字符串.
+ *
+ * @return JSON 字符串
+ */
+ public String toJson() {
+ return WxMaGsonBuilder.create().toJson(this);
+ }
+}
diff --git a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/WxMaServiceNotifyRequest.java b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/WxMaServiceNotifyRequest.java
new file mode 100644
index 0000000000..e15e0782f9
--- /dev/null
+++ b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/WxMaServiceNotifyRequest.java
@@ -0,0 +1,93 @@
+package cn.binarywang.wx.miniapp.bean;
+
+import cn.binarywang.wx.miniapp.json.WxMaGsonBuilder;
+import com.google.gson.annotations.SerializedName;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serializable;
+
+/**
+ * 激活与更新服务卡片请求.
+ *
+ * 接口文档:
+ *
+ * 激活与更新服务卡片
+ *
+ * @author GitHub Copilot
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class WxMaServiceNotifyRequest implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 用户身份标识符.
+ *
+ * 参数:openid
+ * 是否必填:是
+ * 描述:用户身份标识符。
+ * 当使用微信支付订单号作为 code 时,需要与实际支付用户一致;
+ * 当通过前端获取 code 时,需要与点击 button 的用户一致。
+ *
+ */
+ @SerializedName("openid")
+ private String openid;
+
+ /**
+ * 卡片ID.
+ *
+ * 参数:notify_type
+ * 是否必填:是
+ * 描述:卡片ID。
+ *
+ */
+ @SerializedName("notify_type")
+ private Integer notifyType;
+
+ /**
+ * 动态更新令牌.
+ *
+ * 参数:notify_code
+ * 是否必填:是
+ * 描述:动态更新令牌。
+ *
+ */
+ @SerializedName("notify_code")
+ private String notifyCode;
+
+ /**
+ * 卡片状态与状态相关字段.
+ *
+ * 参数:content_json
+ * 是否必填:是
+ * 描述:卡片状态与状态相关字段,不同卡片的定义不同。
+ *
+ */
+ @SerializedName("content_json")
+ private String contentJson;
+
+ /**
+ * 微信支付订单号验证字段(可选).
+ *
+ * 参数:check_json
+ * 是否必填:否
+ * 描述:微信支付订单号验证字段。当将微信支付订单号作为 notify_code 时,在激活时需要传入。
+ *
+ */
+ @SerializedName("check_json")
+ private String checkJson;
+
+ /**
+ * 转为 JSON 字符串.
+ *
+ * @return JSON 字符串
+ */
+ public String toJson() {
+ return WxMaGsonBuilder.create().toJson(this);
+ }
+}
diff --git a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/constant/WxMaApiUrlConstants.java b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/constant/WxMaApiUrlConstants.java
index 86fa58ac6c..815d47c623 100644
--- a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/constant/WxMaApiUrlConstants.java
+++ b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/constant/WxMaApiUrlConstants.java
@@ -358,6 +358,15 @@ public interface Subscribe {
/** 发送订阅消息 */
String SUBSCRIBE_MSG_SEND_URL = "https://api.weixin.qq.com/cgi-bin/message/subscribe/send";
+
+ /** 激活与更新服务卡片 */
+ String SERVICE_NOTIFY_SET_URL = "https://api.weixin.qq.com/wxa/setusernotify";
+
+ /** 更新服务卡片扩展信息 */
+ String SERVICE_NOTIFY_SET_EXT_URL = "https://api.weixin.qq.com/wxa/setusernotifyext";
+
+ /** 查询服务卡片状态 */
+ String SERVICE_NOTIFY_GET_URL = "https://api.weixin.qq.com/wxa/getusernotify";
}
public interface User {
diff --git a/weixin-java-miniapp/src/test/java/cn/binarywang/wx/miniapp/api/impl/WxMaSubscribeServiceImplTest.java b/weixin-java-miniapp/src/test/java/cn/binarywang/wx/miniapp/api/impl/WxMaSubscribeServiceImplTest.java
index 10993e5651..c910d121d1 100644
--- a/weixin-java-miniapp/src/test/java/cn/binarywang/wx/miniapp/api/impl/WxMaSubscribeServiceImplTest.java
+++ b/weixin-java-miniapp/src/test/java/cn/binarywang/wx/miniapp/api/impl/WxMaSubscribeServiceImplTest.java
@@ -1,6 +1,11 @@
package cn.binarywang.wx.miniapp.api.impl;
import cn.binarywang.wx.miniapp.api.WxMaService;
+import cn.binarywang.wx.miniapp.api.WxMaSubscribeService;
+import cn.binarywang.wx.miniapp.bean.WxMaGetUserNotifyRequest;
+import cn.binarywang.wx.miniapp.bean.WxMaGetUserNotifyResult;
+import cn.binarywang.wx.miniapp.bean.WxMaServiceNotifyExtRequest;
+import cn.binarywang.wx.miniapp.bean.WxMaServiceNotifyRequest;
import cn.binarywang.wx.miniapp.bean.WxMaSubscribeMessage;
import me.chanjar.weixin.common.bean.subscribemsg.CategoryData;
import me.chanjar.weixin.common.bean.subscribemsg.PubTemplateKeyword;
@@ -10,12 +15,16 @@
import com.google.common.collect.Lists;
import com.google.inject.Inject;
import me.chanjar.weixin.common.error.WxErrorException;
+import org.testng.Assert;
import org.testng.annotations.Guice;
import org.testng.annotations.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
/**
* 测试类.
@@ -71,4 +80,60 @@ public void testSendSubscribeMsg() throws WxErrorException {
// TODO 待完善补充
this.wxService.getSubscribeService().sendSubscribeMsg(WxMaSubscribeMessage.builder().build());
}
+
+ @Test
+ public void testSetUserNotify() throws WxErrorException {
+ WxMaService service = mock(WxMaService.class);
+ when(service.post(anyString(), anyString())).thenReturn("{\"errcode\":0,\"errmsg\":\"ok\"}");
+
+ WxMaSubscribeService subscribeService = new WxMaSubscribeServiceImpl(service);
+ WxMaServiceNotifyRequest request = WxMaServiceNotifyRequest.builder()
+ .openid("test_openid")
+ .notifyType(1)
+ .notifyCode("test_notify_code")
+ .contentJson("{}")
+ .build();
+ subscribeService.setUserNotify(request);
+ }
+
+ @Test
+ public void testSetUserNotifyExt() throws WxErrorException {
+ WxMaService service = mock(WxMaService.class);
+ when(service.post(anyString(), anyString())).thenReturn("{\"errcode\":0,\"errmsg\":\"ok\"}");
+
+ WxMaSubscribeService subscribeService = new WxMaSubscribeServiceImpl(service);
+ WxMaServiceNotifyExtRequest request = WxMaServiceNotifyExtRequest.builder()
+ .openid("test_openid")
+ .notifyType(1)
+ .notifyCode("test_notify_code")
+ .extJson("{}")
+ .build();
+ subscribeService.setUserNotifyExt(request);
+ }
+
+ @Test
+ public void testGetUserNotify() throws WxErrorException {
+ WxMaService service = mock(WxMaService.class);
+ when(service.post(anyString(), anyString())).thenReturn(
+ "{\"errcode\":0,\"errmsg\":\"ok\","
+ + "\"notify_info\":{"
+ + "\"notify_type\":1,"
+ + "\"content_json\":\"{\\\"status\\\":1}\","
+ + "\"code_state\":0,"
+ + "\"code_expire_time\":1700000000"
+ + "}}");
+
+ WxMaSubscribeService subscribeService = new WxMaSubscribeServiceImpl(service);
+ WxMaGetUserNotifyRequest request = WxMaGetUserNotifyRequest.builder()
+ .openid("test_openid")
+ .notifyCode("test_notify_code")
+ .notifyType(1)
+ .build();
+ WxMaGetUserNotifyResult result = subscribeService.getUserNotify(request);
+ Assert.assertNotNull(result);
+ Assert.assertNotNull(result.getNotifyInfo());
+ Assert.assertEquals(result.getNotifyInfo().getNotifyType().intValue(), 1);
+ Assert.assertEquals(result.getNotifyInfo().getCodeState().intValue(), 0);
+ Assert.assertEquals(result.getNotifyInfo().getCodeExpireTime().longValue(), 1700000000L);
+ }
}
From bcb3110bd7adb76bc4f2c25ba9890b9473ccb5d6 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Mon, 11 May 2026 20:29:31 +0800
Subject: [PATCH 91/94] =?UTF-8?q?:new:=20#3969=20=E3=80=90=E5=B0=8F?=
=?UTF-8?q?=E7=A8=8B=E5=BA=8F=E3=80=91=E5=AE=9E=E7=8E=B0=E5=8A=A0=E5=AF=86?=
=?UTF-8?q?=E7=BD=91=E7=BB=9C=E9=80=9A=E9=81=93=E6=9C=8D=E5=8A=A1=E7=AB=AF?=
=?UTF-8?q?=E6=94=AF=E6=8C=81=EF=BC=8C=E5=B9=B6=E4=BF=AE=E5=A4=8D=20HMAC?=
=?UTF-8?q?=20=E7=AD=BE=E5=90=8D=E4=B8=8E=E9=94=99=E8=AF=AF=E5=A4=84?=
=?UTF-8?q?=E7=90=86=20Bug?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../api/impl/WxMaInternetServiceImpl.java | 5 +-
.../internet/WxMaInternetUserKeyInfo.java | 2 +-
.../wx/miniapp/util/crypt/WxMaCryptUtils.java | 99 +++++++++++++++++
.../util/crypt/WxMaCryptUtilsTest.java | 100 ++++++++++++++++++
4 files changed, 203 insertions(+), 3 deletions(-)
diff --git a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/impl/WxMaInternetServiceImpl.java b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/impl/WxMaInternetServiceImpl.java
index 7da44ddaba..91d11795f3 100644
--- a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/impl/WxMaInternetServiceImpl.java
+++ b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/impl/WxMaInternetServiceImpl.java
@@ -9,6 +9,7 @@
import me.chanjar.weixin.common.enums.WxType;
import me.chanjar.weixin.common.error.WxError;
import me.chanjar.weixin.common.error.WxErrorException;
+import org.apache.commons.codec.binary.Base64;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
@@ -26,7 +27,7 @@ public class WxMaInternetServiceImpl implements WxMaInternetService {
private String sha256(String data, String sessionKey) throws Exception {
Mac sha256_HMAC = Mac.getInstance("HmacSHA256");
- SecretKeySpec secret_key = new SecretKeySpec(sessionKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
+ SecretKeySpec secret_key = new SecretKeySpec(Base64.decodeBase64(sessionKey), "HmacSHA256");
sha256_HMAC.init(secret_key);
byte[] array = sha256_HMAC.doFinal(data.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
@@ -57,7 +58,7 @@ public WxMaInternetResponse getUserEncryptKey(String openid, String sessionKey)
private WxMaInternetResponse getWxMaInternetResponse(String url) throws WxErrorException {
String responseContent = this.wxMaService.post(url, "");
WxMaInternetResponse response = WxMaGsonBuilder.create().fromJson(responseContent, WxMaInternetResponse.class);
- if (response.getErrcode() == -1) {
+ if (response.getErrcode() != null && response.getErrcode() != 0) {
throw new WxErrorException(WxError.fromJson(responseContent, WxType.MiniApp));
}
return response;
diff --git a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/internet/WxMaInternetUserKeyInfo.java b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/internet/WxMaInternetUserKeyInfo.java
index 01bcfbce0b..305d8687e0 100644
--- a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/internet/WxMaInternetUserKeyInfo.java
+++ b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/bean/internet/WxMaInternetUserKeyInfo.java
@@ -44,7 +44,7 @@ public class WxMaInternetUserKeyInfo implements Serializable {
private Long expireIn;
/**
- * 加密iv
+ * 加密iv(Hex 编码,通常为 32 位十六进制字符,解码后为 16 字节,用于 AES-128-CBC)
*/
private String iv;
diff --git a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/util/crypt/WxMaCryptUtils.java b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/util/crypt/WxMaCryptUtils.java
index 2343634bfc..252297fdcd 100644
--- a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/util/crypt/WxMaCryptUtils.java
+++ b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/util/crypt/WxMaCryptUtils.java
@@ -84,4 +84,103 @@ public static String decryptAnotherWay(String sessionKey, String encryptedData,
}
}
+ /**
+ * 使用用户加密 key 对数据进行 AES-128-CBC 解密(用于小程序加密网络通道).
+ *
+ *
+ * 参考文档:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/user-encryptkey.html
+ * encryptKey 来自 getUserEncryptKey 接口返回的 encrypt_key 字段(Base64 编码,解码后须为 16 字节)
+ * hexIv 来自 getUserEncryptKey 接口返回的 iv 字段(Hex 编码,须为 32 位十六进制字符,解码后为 16 字节)
+ *
+ *
+ * @param encryptKey 用户加密 key(Base64 编码,解码后须为 16 字节)
+ * @param hexIv 加密 iv(Hex 编码,须为 32 位十六进制字符)
+ * @param encryptedData 加密数据(Base64 编码)
+ * @return 解密后的字符串
+ * @throws IllegalArgumentException 如果 encryptKey 解码后不为 16 字节,或 hexIv 格式非法/解码后不为 16 字节
+ */
+ public static String decryptWithEncryptKey(String encryptKey, String hexIv, String encryptedData) {
+ byte[] keyBytes = Base64.decodeBase64(encryptKey);
+ if (keyBytes.length != 16) {
+ throw new IllegalArgumentException(
+ "encryptKey 解码后必须为 16 字节(AES-128),实际为 " + keyBytes.length + " 字节");
+ }
+ byte[] ivBytes = hexToBytes(hexIv);
+ if (ivBytes.length != 16) {
+ throw new IllegalArgumentException(
+ "hexIv 解码后必须为 16 字节(AES-128-CBC),实际为 " + ivBytes.length + " 字节(需 32 位 Hex 字符串)");
+ }
+ byte[] dataBytes = Base64.decodeBase64(encryptedData);
+ try {
+ Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
+ cipher.init(Cipher.DECRYPT_MODE,
+ new SecretKeySpec(keyBytes, "AES"),
+ new IvParameterSpec(ivBytes));
+ return new String(cipher.doFinal(dataBytes), UTF_8);
+ } catch (Exception e) {
+ throw new WxRuntimeException("AES解密失败!", e);
+ }
+ }
+
+ /**
+ * 使用用户加密 key 对数据进行 AES-128-CBC 加密(用于小程序加密网络通道).
+ *
+ *
+ * 参考文档:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/user-encryptkey.html
+ * encryptKey 来自 getUserEncryptKey 接口返回的 encrypt_key 字段(Base64 编码,解码后须为 16 字节)
+ * hexIv 来自 getUserEncryptKey 接口返回的 iv 字段(Hex 编码,须为 32 位十六进制字符,解码后为 16 字节)
+ *
+ *
+ * @param encryptKey 用户加密 key(Base64 编码,解码后须为 16 字节)
+ * @param hexIv 加密 iv(Hex 编码,须为 32 位十六进制字符)
+ * @param data 待加密的明文字符串
+ * @return 加密后的数据(Base64 编码)
+ * @throws IllegalArgumentException 如果 encryptKey 解码后不为 16 字节,或 hexIv 格式非法/解码后不为 16 字节
+ */
+ public static String encryptWithEncryptKey(String encryptKey, String hexIv, String data) {
+ byte[] keyBytes = Base64.decodeBase64(encryptKey);
+ if (keyBytes.length != 16) {
+ throw new IllegalArgumentException(
+ "encryptKey 解码后必须为 16 字节(AES-128),实际为 " + keyBytes.length + " 字节");
+ }
+ byte[] ivBytes = hexToBytes(hexIv);
+ if (ivBytes.length != 16) {
+ throw new IllegalArgumentException(
+ "hexIv 解码后必须为 16 字节(AES-128-CBC),实际为 " + ivBytes.length + " 字节(需 32 位 Hex 字符串)");
+ }
+ try {
+ Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
+ cipher.init(Cipher.ENCRYPT_MODE,
+ new SecretKeySpec(keyBytes, "AES"),
+ new IvParameterSpec(ivBytes));
+ return Base64.encodeBase64String(cipher.doFinal(data.getBytes(UTF_8)));
+ } catch (Exception e) {
+ throw new WxRuntimeException("AES加密失败!", e);
+ }
+ }
+
+ /**
+ * 将 Hex 字符串转换为字节数组.
+ *
+ * @param hex Hex 字符串(长度必须为偶数,只包含 0-9 和 a-f/A-F 字符)
+ * @return 字节数组
+ * @throws IllegalArgumentException 如果输入不是合法的 Hex 字符串
+ */
+ private static byte[] hexToBytes(String hex) {
+ if (hex == null || hex.length() % 2 != 0) {
+ throw new IllegalArgumentException("无效的十六进制字符串格式:长度必须为偶数");
+ }
+ int len = hex.length();
+ byte[] data = new byte[len / 2];
+ for (int i = 0; i < len; i += 2) {
+ int high = Character.digit(hex.charAt(i), 16);
+ int low = Character.digit(hex.charAt(i + 1), 16);
+ if (high == -1 || low == -1) {
+ throw new IllegalArgumentException("无效的十六进制字符串格式:包含非法字符 '" + hex.charAt(high == -1 ? i : i + 1) + "'");
+ }
+ data[i / 2] = (byte) ((high << 4) + low);
+ }
+ return data;
+ }
+
}
diff --git a/weixin-java-miniapp/src/test/java/cn/binarywang/wx/miniapp/util/crypt/WxMaCryptUtilsTest.java b/weixin-java-miniapp/src/test/java/cn/binarywang/wx/miniapp/util/crypt/WxMaCryptUtilsTest.java
index 76b4e96743..742fa7d440 100644
--- a/weixin-java-miniapp/src/test/java/cn/binarywang/wx/miniapp/util/crypt/WxMaCryptUtilsTest.java
+++ b/weixin-java-miniapp/src/test/java/cn/binarywang/wx/miniapp/util/crypt/WxMaCryptUtilsTest.java
@@ -4,6 +4,7 @@
import org.testng.annotations.*;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
*
@@ -14,6 +15,11 @@
* @author Binary Wang
*/
public class WxMaCryptUtilsTest {
+ // 模拟来自 getUserEncryptKey 接口返回的 encrypt_key(Base64,解码后 16 字节)
+ // 和 iv(Hex,32 位十六进制字符,解码后 16 字节,AES-128-CBC 要求)
+ private static final String ENCRYPT_KEY = "VI6BpyrK9XH4i4AIGe86tg==";
+ private static final String HEX_IV = "6003f73ec441c3866003f73ec441c386";
+
@Test
public void testDecrypt() {
String sessionKey = "7MG7jbTToVVRWRXVA885rg==";
@@ -32,4 +38,98 @@ public void testDecryptAnotherWay() {
assertThat(WxMaCryptUtils.decrypt(sessionKey, encryptedData, ivStr))
.isEqualTo(WxMaCryptUtils.decryptAnotherWay(sessionKey, encryptedData, ivStr));
}
+
+ /**
+ * 测试使用用户加密 key(来自小程序加密网络通道)进行加密和解密的对称性.
+ * encrypt_key 为 Base64 编码的 16 字节 AES-128 密钥,iv 为 Hex 编码的 16 字节初始向量。
+ */
+ @Test
+ public void testEncryptAndDecryptWithEncryptKey() {
+ String plainText = "{\"userId\":\"12345\",\"amount\":100}";
+
+ String encrypted = WxMaCryptUtils.encryptWithEncryptKey(ENCRYPT_KEY, HEX_IV, plainText);
+ assertThat(encrypted).isNotNull().isNotEmpty();
+
+ String decrypted = WxMaCryptUtils.decryptWithEncryptKey(ENCRYPT_KEY, HEX_IV, encrypted);
+ assertThat(decrypted).isEqualTo(plainText);
+ }
+
+ /**
+ * 测试加密网络通道的加解密对称性(不同明文).
+ */
+ @Test
+ public void testEncryptDecryptSymmetryWithEncryptKey() {
+ String plainText = "hello miniprogram";
+
+ String encrypted = WxMaCryptUtils.encryptWithEncryptKey(ENCRYPT_KEY, HEX_IV, plainText);
+ String decrypted = WxMaCryptUtils.decryptWithEncryptKey(ENCRYPT_KEY, HEX_IV, encrypted);
+ assertThat(decrypted).isEqualTo(plainText);
+ }
+
+ /**
+ * 测试 hexIv 为奇数长度时,应抛出 IllegalArgumentException.
+ */
+ @Test
+ public void testEncryptWithEncryptKeyInvalidHexIvOddLength() {
+ assertThatThrownBy(() -> WxMaCryptUtils.encryptWithEncryptKey(ENCRYPT_KEY, "abc", "data"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("长度必须为偶数");
+ }
+
+ /**
+ * 测试 hexIv 包含非十六进制字符时,应抛出 IllegalArgumentException.
+ */
+ @Test
+ public void testEncryptWithEncryptKeyInvalidHexIvNonHexChar() {
+ // 32 位但含非法字符 'z'
+ assertThatThrownBy(() -> WxMaCryptUtils.encryptWithEncryptKey(
+ ENCRYPT_KEY, "6003f73ec441c3866003f73ec441z386", "data"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("非法字符");
+ }
+
+ /**
+ * 测试 hexIv 解码后不足 16 字节(如仅 16 位 hex = 8 字节)时,应抛出 IllegalArgumentException.
+ */
+ @Test
+ public void testEncryptWithEncryptKeyShortHexIv() {
+ // 16 位 hex = 8 字节,不满足 AES-CBC 要求的 16 字节
+ assertThatThrownBy(() -> WxMaCryptUtils.encryptWithEncryptKey(
+ ENCRYPT_KEY, "6003f73ec441c386", "data"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("hexIv 解码后必须为 16 字节");
+ }
+
+ /**
+ * 测试 encryptKey 解码后不足 16 字节时,应抛出 IllegalArgumentException.
+ */
+ @Test
+ public void testEncryptWithEncryptKeyShortKey() {
+ // Base64 编码的 8 字节 key(不符合 AES-128 要求)
+ String shortKey = java.util.Base64.getEncoder().encodeToString(new byte[8]);
+ assertThatThrownBy(() -> WxMaCryptUtils.encryptWithEncryptKey(shortKey, HEX_IV, "data"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("encryptKey 解码后必须为 16 字节");
+ }
+
+ /**
+ * 测试 decryptWithEncryptKey 使用非法 hexIv 时,应抛出 IllegalArgumentException.
+ */
+ @Test
+ public void testDecryptWithEncryptKeyInvalidHexIv() {
+ assertThatThrownBy(() -> WxMaCryptUtils.decryptWithEncryptKey(ENCRYPT_KEY, "abc", "data"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("长度必须为偶数");
+ }
+
+ /**
+ * 测试 decryptWithEncryptKey encryptKey 长度不合法时,应抛出 IllegalArgumentException.
+ */
+ @Test
+ public void testDecryptWithEncryptKeyShortKey() {
+ String shortKey = java.util.Base64.getEncoder().encodeToString(new byte[8]);
+ assertThatThrownBy(() -> WxMaCryptUtils.decryptWithEncryptKey(shortKey, HEX_IV, "data"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("encryptKey 解码后必须为 16 字节");
+ }
}
From 24703be5831a4404acb7f2d97ab3cea64f6accb4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=B0=B4=E4=BE=9D=E5=AF=92?=
Date: Mon, 11 May 2026 20:32:42 +0800
Subject: [PATCH 92/94] =?UTF-8?q?:art:=20=20#3968=E3=80=90=E5=BE=AE?=
=?UTF-8?q?=E4=BF=A1=E6=94=AF=E4=BB=98=E3=80=91=E4=BF=AE=E5=A4=8D=E5=BE=AE?=
=?UTF-8?q?=E4=BF=A1=E6=94=AF=E4=BB=98api-host-url=E9=85=8D=E7=BD=AE?=
=?UTF-8?q?=E5=8F=8D=E5=90=91=E4=BB=A3=E7=90=86=E8=B7=AF=E5=BE=84=E5=89=8D?=
=?UTF-8?q?=E7=BC=80=E6=97=B6=E4=BC=9A=E5=AF=BC=E8=87=B4v3=E7=AD=BE?=
=?UTF-8?q?=E5=90=8D=E5=BC=82=E5=B8=B8=E7=9A=84=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../wx-java-pay-solon-plugin/README.md | 2 +
.../pay/config/WxPayAutoConfiguration.java | 1 +
.../pay/properties/WxPayProperties.java | 6 +++
.../README.md | 1 +
.../pay/properties/WxPaySingleProperties.java | 6 +++
.../pay/service/WxPayMultiServicesImpl.java | 1 +
.../wxjava/pay/WxPayMultiServicesTest.java | 7 ++-
.../wx-java-pay-spring-boot-starter/README.md | 2 +
.../pay/config/WxPayAutoConfiguration.java | 1 +
.../pay/properties/WxPayProperties.java | 6 +++
.../wxpay/config/VerifierBuilder.java | 15 ++++++-
.../binarywang/wxpay/config/WxPayConfig.java | 44 +++++++++++++++++--
.../service/impl/BaseWxPayServiceImpl.java | 4 +-
.../wxpay/v3/WxPayV3HttpClientBuilder.java | 24 +++++++++-
.../wxpay/v3/auth/WxPayCredentials.java | 42 +++++++++++++++++-
.../wxpay/config/WxPayConfigTest.java | 11 +++++
16 files changed, 164 insertions(+), 9 deletions(-)
diff --git a/solon-plugins/wx-java-pay-solon-plugin/README.md b/solon-plugins/wx-java-pay-solon-plugin/README.md
index b0e212593b..8ff3416293 100644
--- a/solon-plugins/wx-java-pay-solon-plugin/README.md
+++ b/solon-plugins/wx-java-pay-solon-plugin/README.md
@@ -23,6 +23,8 @@ wx:
pay:
appId: xxxxxxxxxxx
mchId: 15xxxxxxxxx #商户id
+ apiHostUrl: http://10.0.0.1:3128 # 可选:代理主机
+ apiHostUrlPath: /api-weixin # 可选:代理入口前缀
apiV3Key: Dc1DBwSc094jACxxxxxxxxxxxxxxx #V3密钥
certSerialNo: 62C6CEAA360BCxxxxxxxxxxxxxxx
privateKeyPath: classpath:cert/apiclient_key.pem #apiclient_key.pem证书文件的绝对路径或者以classpath:开头的类路径
diff --git a/solon-plugins/wx-java-pay-solon-plugin/src/main/java/com/binarywang/solon/wxjava/pay/config/WxPayAutoConfiguration.java b/solon-plugins/wx-java-pay-solon-plugin/src/main/java/com/binarywang/solon/wxjava/pay/config/WxPayAutoConfiguration.java
index 4043c19e13..c311a099a2 100644
--- a/solon-plugins/wx-java-pay-solon-plugin/src/main/java/com/binarywang/solon/wxjava/pay/config/WxPayAutoConfiguration.java
+++ b/solon-plugins/wx-java-pay-solon-plugin/src/main/java/com/binarywang/solon/wxjava/pay/config/WxPayAutoConfiguration.java
@@ -59,6 +59,7 @@ public WxPayService wxPayService() {
payConfig.setPublicKeyId(StringUtils.trimToNull(this.properties.getPublicKeyId()));
payConfig.setPublicKeyPath(StringUtils.trimToNull(this.properties.getPublicKeyPath()));
payConfig.setApiHostUrl(StringUtils.trimToNull(this.properties.getApiHostUrl()));
+ payConfig.setApiHostUrlPath(StringUtils.trimToNull(this.properties.getApiHostUrlPath()));
payConfig.setStrictlyNeedWechatPaySerial(this.properties.isStrictlyNeedWechatPaySerial());
payConfig.setFullPublicKeyModel(this.properties.isFullPublicKeyModel());
diff --git a/solon-plugins/wx-java-pay-solon-plugin/src/main/java/com/binarywang/solon/wxjava/pay/properties/WxPayProperties.java b/solon-plugins/wx-java-pay-solon-plugin/src/main/java/com/binarywang/solon/wxjava/pay/properties/WxPayProperties.java
index df202a5b84..fe024f59f1 100644
--- a/solon-plugins/wx-java-pay-solon-plugin/src/main/java/com/binarywang/solon/wxjava/pay/properties/WxPayProperties.java
+++ b/solon-plugins/wx-java-pay-solon-plugin/src/main/java/com/binarywang/solon/wxjava/pay/properties/WxPayProperties.java
@@ -113,6 +113,12 @@ public class WxPayProperties {
*/
private String apiHostUrl;
+ /**
+ * 自定义API主机路径前缀(用于代理入口前缀)
+ * 例如:/api-weixin
+ */
+ private String apiHostUrlPath;
+
/**
* 是否将全部v3接口的请求都添加Wechatpay-Serial请求头,默认添加
*/
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
index 1af617aab8..1ae4ac6299 100644
--- 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
@@ -255,6 +255,7 @@ public class PayService {
| payScorePermissionNotifyUrl | 支付分授权回调地址 | 无 |
| useSandboxEnv | 是否使用沙箱环境 | false |
| apiHostUrl | 自定义API主机地址 | https://api.mch.weixin.qq.com |
+| apiHostUrlPath | 自定义API主机路径前缀(代理入口前缀) | 空 |
| strictlyNeedWechatPaySerial | 是否所有V3请求都添加序列号头 | true |
| fullPublicKeyModel | 是否完全使用公钥模式 | true |
| publicKeyId | 公钥ID | 无 |
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
index 93cdc9dd8f..ef936fc234 100644
--- 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
@@ -112,6 +112,12 @@ public class WxPaySingleProperties implements Serializable {
*/
private String apiHostUrl;
+ /**
+ * 自定义API主机路径前缀(用于代理入口前缀).
+ * 例如:/api-weixin
+ */
+ private String apiHostUrlPath;
+
/**
* 是否将全部v3接口的请求都添加Wechatpay-Serial请求头,默认添加.
*/
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
index 68f36ccb46..7cbcceabb4 100644
--- 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
@@ -83,6 +83,7 @@ private WxPayService buildWxPayService(WxPaySingleProperties properties) {
payConfig.setPublicKeyId(StringUtils.trimToNull(properties.getPublicKeyId()));
payConfig.setPublicKeyPath(StringUtils.trimToNull(properties.getPublicKeyPath()));
payConfig.setApiHostUrl(StringUtils.trimToNull(properties.getApiHostUrl()));
+ payConfig.setApiHostUrlPath(StringUtils.trimToNull(properties.getApiHostUrlPath()));
payConfig.setStrictlyNeedWechatPaySerial(properties.isStrictlyNeedWechatPaySerial());
payConfig.setFullPublicKeyModel(properties.isFullPublicKeyModel());
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
index a64822efd8..d60335ebed 100644
--- 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
@@ -26,6 +26,8 @@
"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.api-host-url=http://10.0.0.1:3128",
+ "wx.pay.configs.app2.api-host-url-path=/api-weixin",
"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",
@@ -57,7 +59,9 @@ public void testConfiguration() {
assertNotNull(app2Config, "app2 configuration should exist");
assertEquals("wx2222222222222222", app2Config.getAppId());
assertEquals("2222222222", app2Config.getMchId());
- assertEquals("22222222222222222222222222222222", app2Config.getApiV3Key());
+ assertEquals("http://10.0.0.1:3128", app2Config.getApiHostUrl());
+ assertEquals("/api-weixin", app2Config.getApiHostUrlPath());
+ assertEquals("22222222222222222222222222222222", app2Config.getApiv3Key());
}
@Test
@@ -71,6 +75,7 @@ public void testGetWxPayService() {
assertNotNull(app2Service, "Should get WxPayService for app2");
assertEquals("wx2222222222222222", app2Service.getConfig().getAppId());
assertEquals("2222222222", app2Service.getConfig().getMchId());
+ assertEquals("/api-weixin", app2Service.getConfig().getApiHostUrlPath());
// 测试相同key返回相同实例
WxPayService app1ServiceAgain = wxPayMultiServices.getWxPayService("app1");
diff --git a/spring-boot-starters/wx-java-pay-spring-boot-starter/README.md b/spring-boot-starters/wx-java-pay-spring-boot-starter/README.md
index d87a38fb9c..bed890d5e8 100644
--- a/spring-boot-starters/wx-java-pay-spring-boot-starter/README.md
+++ b/spring-boot-starters/wx-java-pay-spring-boot-starter/README.md
@@ -23,6 +23,8 @@ wx:
pay:
appId: xxxxxxxxxxx
mchId: 15xxxxxxxxx #商户id
+ apiHostUrl: http://10.0.0.1:3128 # 可选:代理主机
+ apiHostUrlPath: /api-weixin # 可选:代理入口前缀
apiV3Key: Dc1DBwSc094jACxxxxxxxxxxxxxxx #V3密钥
certSerialNo: 62C6CEAA360BCxxxxxxxxxxxxxxx
privateKeyPath: classpath:cert/apiclient_key.pem #apiclient_key.pem证书文件的绝对路径或者以classpath:开头的类路径
diff --git a/spring-boot-starters/wx-java-pay-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/config/WxPayAutoConfiguration.java b/spring-boot-starters/wx-java-pay-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/config/WxPayAutoConfiguration.java
index 0256aec990..7e748ba1a3 100644
--- a/spring-boot-starters/wx-java-pay-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/config/WxPayAutoConfiguration.java
+++ b/spring-boot-starters/wx-java-pay-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/config/WxPayAutoConfiguration.java
@@ -63,6 +63,7 @@ public WxPayService wxPayService() {
payConfig.setPublicKeyId(StringUtils.trimToNull(this.properties.getPublicKeyId()));
payConfig.setPublicKeyPath(StringUtils.trimToNull(this.properties.getPublicKeyPath()));
payConfig.setApiHostUrl(StringUtils.trimToNull(this.properties.getApiHostUrl()));
+ payConfig.setApiHostUrlPath(StringUtils.trimToNull(this.properties.getApiHostUrlPath()));
payConfig.setStrictlyNeedWechatPaySerial(this.properties.isStrictlyNeedWechatPaySerial());
payConfig.setFullPublicKeyModel(this.properties.isFullPublicKeyModel());
diff --git a/spring-boot-starters/wx-java-pay-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/properties/WxPayProperties.java b/spring-boot-starters/wx-java-pay-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/properties/WxPayProperties.java
index a7308bd6ea..49045c4ee0 100644
--- a/spring-boot-starters/wx-java-pay-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/properties/WxPayProperties.java
+++ b/spring-boot-starters/wx-java-pay-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/properties/WxPayProperties.java
@@ -111,6 +111,12 @@ public class WxPayProperties {
*/
private String apiHostUrl;
+ /**
+ * 自定义API主机路径前缀(用于代理入口前缀)
+ * 例如:/api-weixin
+ */
+ private String apiHostUrlPath;
+
/**
* 是否将全部v3接口的请求都添加Wechatpay-Serial请求头,默认添加
*/
diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/config/VerifierBuilder.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/config/VerifierBuilder.java
index b0d9276a32..4c8aafb8ee 100644
--- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/config/VerifierBuilder.java
+++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/config/VerifierBuilder.java
@@ -6,6 +6,8 @@
import lombok.NoArgsConstructor;
import org.apache.commons.lang3.StringUtils;
+import java.net.URI;
+import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.security.PrivateKey;
import java.security.PublicKey;
@@ -118,8 +120,19 @@ private static AutoUpdateCertificatesVerifier getCertificatesVerifier(
String certSerialNo, String mchId, String apiV3Key, PrivateKey merchantPrivateKey,
WxPayHttpProxy wxPayHttpProxy, int certAutoUpdateTime, String payBaseUrl
) {
+ String signUriStripPrefix = null;
+ if (StringUtils.isNotBlank(payBaseUrl)) {
+ try {
+ String rawPath = new URI(payBaseUrl).getRawPath();
+ if (StringUtils.isNotBlank(rawPath) && !"/".equals(rawPath)) {
+ signUriStripPrefix = rawPath;
+ }
+ } catch (URISyntaxException ignored) {
+ // ignore
+ }
+ }
return new AutoUpdateCertificatesVerifier(
- new WxPayCredentials(mchId, new PrivateKeySigner(certSerialNo, merchantPrivateKey)),
+ new WxPayCredentials(mchId, new PrivateKeySigner(certSerialNo, merchantPrivateKey), signUriStripPrefix),
apiV3Key.getBytes(StandardCharsets.UTF_8), certAutoUpdateTime,
payBaseUrl, wxPayHttpProxy);
}
diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/config/WxPayConfig.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/config/WxPayConfig.java
index bb9d6f7a12..1db2e06306 100644
--- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/config/WxPayConfig.java
+++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/config/WxPayConfig.java
@@ -64,6 +64,12 @@ public class WxPayConfig {
*/
private String apiHostUrl = DEFAULT_PAY_BASE_URL;
+ /**
+ * 微信支付接口请求地址路径前缀(用于网关代理前缀).
+ * 例如:/api-weixin
+ */
+ private String apiHostUrlPath;
+
/**
* http请求连接超时时间.
*/
@@ -285,11 +291,42 @@ public class WxPayConfig {
* @return 微信支付接口请求地址域名
*/
public String getApiHostUrl() {
- if (StringUtils.isEmpty(this.apiHostUrl)) {
+ String hostUrl = StringUtils.trimToNull(this.apiHostUrl);
+ if (hostUrl == null) {
return DEFAULT_PAY_BASE_URL;
}
+ if (hostUrl.endsWith("/")) {
+ hostUrl = hostUrl.substring(0, hostUrl.length() - 1);
+ }
+ return hostUrl;
+ }
+
+ /**
+ * 返回所设置的微信支付接口路径前缀.
+ *
+ * @return 路径前缀,不配置时为空字符串
+ */
+ public String getApiHostUrlPath() {
+ String pathPrefix = StringUtils.trimToNull(this.apiHostUrlPath);
+ if (pathPrefix == null || "/".equals(pathPrefix)) {
+ return "";
+ }
+ if (!pathPrefix.startsWith("/")) {
+ pathPrefix = "/" + pathPrefix;
+ }
+ if (pathPrefix.endsWith("/")) {
+ pathPrefix = pathPrefix.substring(0, pathPrefix.length() - 1);
+ }
+ return pathPrefix;
+ }
- return this.apiHostUrl;
+ /**
+ * 返回用于请求层拼接的基础地址:host + pathPrefix.
+ *
+ * @return 拼接后的基础地址
+ */
+ public String getApiHostWithPathPrefix() {
+ return this.getApiHostUrl() + this.getApiHostUrlPath();
}
@SneakyThrows
@@ -391,10 +428,11 @@ public CloseableHttpClient initApiV3HttpClient() throws WxPayException {
} else {
certificatesVerifier = VerifierBuilder.build(
this.getCertSerialNo(), this.getMchId(), this.getApiV3Key(), merchantPrivateKey, wxPayHttpProxy,
- this.getCertAutoUpdateTime(), this.getApiHostUrl(), this.getPublicKeyId(), publicKey);
+ this.getCertAutoUpdateTime(), this.getApiHostWithPathPrefix(), this.getPublicKeyId(), publicKey);
}
WxPayV3HttpClientBuilder wxPayV3HttpClientBuilder = WxPayV3HttpClientBuilder.create()
+ .withSignUriStripPrefix(this.getApiHostUrlPath())
.withMerchant(mchId, certSerialNo, merchantPrivateKey)
.withValidator(new WxPayValidator(certificatesVerifier));
// 当 apiHostUrl 配置为自定义代理地址时,将代理主机加入受信任列表,
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 6868cb644f..2574e969d7 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
@@ -366,9 +366,9 @@ public String getPayBaseUrl() {
if (StringUtils.isNotBlank(this.getConfig().getApiV3Key())) {
throw new WxRuntimeException("微信支付V3 目前不支持沙箱模式!");
}
- return this.getConfig().getApiHostUrl() + "/xdc/apiv2sandbox";
+ return this.getConfig().getApiHostWithPathPrefix() + "/xdc/apiv2sandbox";
}
- return this.getConfig().getApiHostUrl();
+ return this.getConfig().getApiHostWithPathPrefix();
}
@Override
diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/WxPayV3HttpClientBuilder.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/WxPayV3HttpClientBuilder.java
index 91baa16246..63a92b25ce 100644
--- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/WxPayV3HttpClientBuilder.java
+++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/WxPayV3HttpClientBuilder.java
@@ -15,6 +15,10 @@
public class WxPayV3HttpClientBuilder extends HttpClientBuilder {
private Credentials credentials;
private Validator validator;
+ /**
+ * 签名前从请求 URI Path 中移除的前缀(用于带路径前缀的代理场景)
+ */
+ private String signUriStripPrefix;
/**
* 额外受信任的主机列表,用于代理转发场景:对这些主机的请求也会携带微信支付 Authorization 头
*/
@@ -40,12 +44,30 @@ public static WxPayV3HttpClientBuilder create() {
public WxPayV3HttpClientBuilder withMerchant(String merchantId, String serialNo, PrivateKey privateKey) {
this.credentials =
- new WxPayCredentials(merchantId, new PrivateKeySigner(serialNo, privateKey));
+ new WxPayCredentials(merchantId, new PrivateKeySigner(serialNo, privateKey), this.signUriStripPrefix);
return this;
}
public WxPayV3HttpClientBuilder withCredentials(Credentials credentials) {
this.credentials = credentials;
+ if (this.credentials instanceof WxPayCredentials) {
+ ((WxPayCredentials) this.credentials).setSignUriStripPrefix(this.signUriStripPrefix);
+ }
+ return this;
+ }
+
+ /**
+ * 配置签名前需要移除的 URI Path 前缀.
+ * 例如设置为 "/api-weixin" 时,签名串中的 Path 会从 "/api-weixin/v3/..." 调整为 "/v3/..."。
+ *
+ * @param signUriStripPrefix 需要移除的前缀
+ * @return 当前 Builder 实例
+ */
+ public WxPayV3HttpClientBuilder withSignUriStripPrefix(String signUriStripPrefix) {
+ this.signUriStripPrefix = signUriStripPrefix;
+ if (this.credentials instanceof WxPayCredentials) {
+ ((WxPayCredentials) this.credentials).setSignUriStripPrefix(signUriStripPrefix);
+ }
return this;
}
diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/WxPayCredentials.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/WxPayCredentials.java
index 80eea8f686..4b78a26f73 100644
--- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/WxPayCredentials.java
+++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/WxPayCredentials.java
@@ -20,16 +20,42 @@ public class WxPayCredentials implements Credentials {
private static final SecureRandom RANDOM = new SecureRandom();
protected String merchantId;
protected Signer signer;
+ /**
+ * 签名前从 URI Path 中移除的前缀(用于带路径前缀的反向代理场景)
+ * 例如配置为 "/api-weixin" 时,"/api-weixin/v3/pay/..." 将参与签名为 "/v3/pay/..."
+ */
+ protected String signUriStripPrefix;
public WxPayCredentials(String merchantId, Signer signer) {
this.merchantId = merchantId;
this.signer = signer;
}
+ public WxPayCredentials(String merchantId, Signer signer, String signUriStripPrefix) {
+ this.merchantId = merchantId;
+ this.signer = signer;
+ this.setSignUriStripPrefix(signUriStripPrefix);
+ }
+
public String getMerchantId() {
return merchantId;
}
+ public void setSignUriStripPrefix(String signUriStripPrefix) {
+ if (signUriStripPrefix == null || signUriStripPrefix.trim().isEmpty()) {
+ this.signUriStripPrefix = null;
+ return;
+ }
+ String normalized = signUriStripPrefix.trim();
+ if (!normalized.startsWith("/")) {
+ normalized = "/" + normalized;
+ }
+ if (normalized.length() > 1 && normalized.endsWith("/")) {
+ normalized = normalized.substring(0, normalized.length() - 1);
+ }
+ this.signUriStripPrefix = normalized;
+ }
+
protected long generateTimestamp() {
return System.currentTimeMillis() / 1000;
}
@@ -70,7 +96,7 @@ public final String getToken(HttpRequestWrapper request) throws IOException {
protected final String buildMessage(String nonce, long timestamp, HttpRequestWrapper request)
throws IOException {
URI uri = request.getURI();
- String canonicalUrl = uri.getRawPath();
+ String canonicalUrl = stripPathPrefix(uri.getRawPath());
if (uri.getQuery() != null) {
canonicalUrl += "?" + uri.getRawQuery();
}
@@ -90,4 +116,18 @@ protected final String buildMessage(String nonce, long timestamp, HttpRequestWra
+ body + "\n";
}
+ private String stripPathPrefix(String rawPath) {
+ if (rawPath == null || rawPath.isEmpty() || signUriStripPrefix == null) {
+ return rawPath;
+ }
+ if (!rawPath.startsWith(signUriStripPrefix)) {
+ return rawPath;
+ }
+ String stripped = rawPath.substring(signUriStripPrefix.length());
+ if (stripped.isEmpty()) {
+ return "/";
+ }
+ return stripped.startsWith("/") ? stripped : "/" + stripped;
+ }
+
}
diff --git a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/config/WxPayConfigTest.java b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/config/WxPayConfigTest.java
index 46bc23aac2..0b5d1b7329 100644
--- a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/config/WxPayConfigTest.java
+++ b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/config/WxPayConfigTest.java
@@ -2,6 +2,8 @@
import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+
/**
*
* Created by BinaryWang on 2017/6/18.
@@ -38,6 +40,15 @@ public void testHashCode() {
payConfig.hashCode();
}
+ @Test
+ public void testApiHostUrlPath() {
+ payConfig.setApiHostUrl("http://10.0.0.1:3128/");
+ payConfig.setApiHostUrlPath("api-weixin/");
+ assertEquals(payConfig.getApiHostUrl(), "http://10.0.0.1:3128");
+ assertEquals(payConfig.getApiHostUrlPath(), "/api-weixin");
+ assertEquals(payConfig.getApiHostWithPathPrefix(), "http://10.0.0.1:3128/api-weixin");
+ }
+
@Test
public void testInitSSLContext_base64() throws Exception {
payConfig.setMchId("123");
From b79206dc8c43493c65a302f171d17a81366953b8 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Mon, 11 May 2026 20:33:43 +0800
Subject: [PATCH 93/94] =?UTF-8?q?:memo:=20=E6=98=8E=E7=A1=AE=E8=AF=B4?=
=?UTF-8?q?=E6=98=8E=20wx-java-cp-multi-spring-boot-starter=20=E4=B8=AD=20?=
=?UTF-8?q?corp-secret=20=E7=9A=84=E9=85=8D=E7=BD=AE=E6=96=B9=E5=BC=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../wx-java-cp-multi-solon-plugin/README.md | 85 ++++++++++--------
.../services/AbstractWxCpConfiguration.java | 12 ++-
.../properties/WxCpSingleProperties.java | 26 +++++-
.../README.md | 89 +++++++++++--------
.../services/AbstractWxCpConfiguration.java | 12 ++-
.../cp/properties/WxCpSingleProperties.java | 26 +++++-
6 files changed, 168 insertions(+), 82 deletions(-)
diff --git a/solon-plugins/wx-java-cp-multi-solon-plugin/README.md b/solon-plugins/wx-java-cp-multi-solon-plugin/README.md
index 97bcf0723f..8eb467f98f 100644
--- a/solon-plugins/wx-java-cp-multi-solon-plugin/README.md
+++ b/solon-plugins/wx-java-cp-multi-solon-plugin/README.md
@@ -6,6 +6,25 @@
- 未实现 WxCpTpService 初始化,需要的小伙伴可以参考多 WxCpService 配置的实现。
- 未实现 WxCpCgService 初始化,需要的小伙伴可以参考多 WxCpService 配置的实现。
+## 关于 corp-secret 的说明
+
+企业微信中不同功能模块对应不同的 `corp-secret`,每种 Secret 只对对应模块的接口具有调用权限:
+
+| Secret 类型 | 获取位置 | 可调用的接口 | 是否需要 agent-id |
+|---|---|---|---|
+| 自建应用 Secret | 应用管理 → 自建应用 → 选择应用 → 查看 Secret | 该应用有权限的接口 | **必填** |
+| 通讯录同步 Secret | 管理工具 → 通讯录同步 → 查看 Secret | 部门/成员增删改查等通讯录接口 | **不填** |
+| 客户联系 Secret | 客户联系 → API → Secret | 客户联系相关接口 | 不填 |
+
+> **常见问题**:
+> - 使用自建应用 Secret + agent-id 可以获取部门列表,但**无法更新部门**(因为写接口需要通讯录同步权限)
+> - 使用通讯录同步 Secret 可以同步部门,但**调用某些需要 agent-id 的应用接口会报错**
+
+如需同时使用多种权限范围,可在 `wx.cp.corps` 下配置多个条目,每个条目使用对应权限的 Secret,通过不同的 `tenantId` 区分后使用。
+
+> **注意**:
+> 当前插件实现会校验同一 `corp-id` 下的 `agent-id` **必须唯一**,并且 **只能有一个条目不填写 `agent-id`**。
+> 如果在同一 `corp-id` 下同时配置多个未填写 `agent-id` 的条目,会因 token/ticket 缓存 key 冲突而在启动时直接抛异常。
## 快速开始
1. 引入依赖
@@ -18,25 +37,21 @@
```
2. 添加配置(app.properties)
```properties
- # 应用 1 配置
- wx.cp.corps.tenantId1.corp-id = @corp-id
- wx.cp.corps.tenantId1.corp-secret = @corp-secret
+ # 自建应用 1 配置(使用自建应用 Secret,需填写 agent-id)
+ wx.cp.corps.app1.corp-id = @corp-id
+ wx.cp.corps.app1.corp-secret = @自建应用的Secret(在"应用管理-自建应用"中查看)
+ wx.cp.corps.app1.agent-id = @自建应用的AgentId
## 选填
- wx.cp.corps.tenantId1.agent-id = @agent-id
- wx.cp.corps.tenantId1.token = @token
- wx.cp.corps.tenantId1.aes-key = @aes-key
- wx.cp.corps.tenantId1.msg-audit-priKey = @msg-audit-priKey
- wx.cp.corps.tenantId1.msg-audit-lib-path = @msg-audit-lib-path
-
- # 应用 2 配置
- wx.cp.corps.tenantId2.corp-id = @corp-id
- wx.cp.corps.tenantId2.corp-secret = @corp-secret
- ## 选填
- wx.cp.corps.tenantId2.agent-id = @agent-id
- wx.cp.corps.tenantId2.token = @token
- wx.cp.corps.tenantId2.aes-key = @aes-key
- wx.cp.corps.tenantId2.msg-audit-priKey = @msg-audit-priKey
- wx.cp.corps.tenantId2.msg-audit-lib-path = @msg-audit-lib-path
+ wx.cp.corps.app1.token = @token
+ wx.cp.corps.app1.aes-key = @aes-key
+ wx.cp.corps.app1.msg-audit-priKey = @msg-audit-priKey
+ wx.cp.corps.app1.msg-audit-lib-path = @msg-audit-lib-path
+
+ # 通讯录同步配置(使用通讯录同步 Secret,不需要填写 agent-id)
+ # 此配置用于部门、成员的增删改查等通讯录管理操作
+ wx.cp.corps.contact.corp-id = @corp-id
+ wx.cp.corps.contact.corp-secret = @通讯录同步的Secret(在"管理工具-通讯录同步"中查看)
+ ## agent-id 不填,通讯录同步不需要 agentId
# 公共配置
## ConfigStorage 配置(选填)
@@ -59,8 +74,10 @@
```java
import com.binarywang.solon.wxjava.cp_multi.service.WxCpMultiServices;
+import me.chanjar.weixin.cp.api.WxCpDepartmentService;
import me.chanjar.weixin.cp.api.WxCpService;
import me.chanjar.weixin.cp.api.WxCpUserService;
+import me.chanjar.weixin.cp.bean.WxCpDepart;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
@@ -70,27 +87,21 @@ public class DemoService {
private WxCpMultiServices wxCpMultiServices;
public void test() {
- // 应用 1 的 WxCpService
- WxCpService wxCpService1 = wxCpMultiServices.getWxCpService("tenantId1");
- WxCpUserService userService1 = wxCpService1.getUserService();
- userService1.getUserId("xxx");
- // todo ...
-
- // 应用 2 的 WxCpService
- WxCpService wxCpService2 = wxCpMultiServices.getWxCpService("tenantId2");
- WxCpUserService userService2 = wxCpService2.getUserService();
- userService2.getUserId("xxx");
+ // 使用自建应用的 WxCpService(对应 corp-secret 为自建应用 Secret)
+ WxCpService appService = wxCpMultiServices.getWxCpService("app1");
+ WxCpUserService userService = appService.getUserService();
+ userService.getUserId("xxx");
// todo ...
- // 应用 3 的 WxCpService
- WxCpService wxCpService3 = wxCpMultiServices.getWxCpService("tenantId3");
- // 判断是否为空
- if (wxCpService3 == null) {
- // todo wxCpService3 为空,请先配置 tenantId3 企业微信应用参数
- return;
- }
- WxCpUserService userService3 = wxCpService3.getUserService();
- userService3.getUserId("xxx");
+ // 使用通讯录同步的 WxCpService(对应 corp-secret 为通讯录同步 Secret)
+ // 通讯录同步 Secret 具有部门/成员增删改查等权限
+ WxCpService contactService = wxCpMultiServices.getWxCpService("contact");
+ WxCpDepartmentService departmentService = contactService.getDepartmentService();
+ // 更新部门示例(WxCpDepart 包含 id、name、parentId 等字段)
+ WxCpDepart depart = new WxCpDepart();
+ depart.setId(100L);
+ depart.setName("新部门名称");
+ departmentService.update(depart);
// todo ...
}
}
diff --git a/solon-plugins/wx-java-cp-multi-solon-plugin/src/main/java/com/binarywang/solon/wxjava/cp_multi/configuration/services/AbstractWxCpConfiguration.java b/solon-plugins/wx-java-cp-multi-solon-plugin/src/main/java/com/binarywang/solon/wxjava/cp_multi/configuration/services/AbstractWxCpConfiguration.java
index ada4ac504c..25b4ab3747 100644
--- a/solon-plugins/wx-java-cp-multi-solon-plugin/src/main/java/com/binarywang/solon/wxjava/cp_multi/configuration/services/AbstractWxCpConfiguration.java
+++ b/solon-plugins/wx-java-cp-multi-solon-plugin/src/main/java/com/binarywang/solon/wxjava/cp_multi/configuration/services/AbstractWxCpConfiguration.java
@@ -15,6 +15,7 @@
import java.util.Collection;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@@ -37,6 +38,13 @@ protected WxCpMultiServices wxCpMultiServices(WxCpMultiProperties wxCpMultiPrope
/**
* 校验同一个企业下,agentId 是否唯一,避免使用 redis 缓存 token、ticket 时错乱。
*
+ * 同一企业(corpId 相同)下可配置多个条目以使用不同的权限 Secret,例如:
+ *
+ * - 自建应用条目:填写应用对应的 corpSecret 和 agentId
+ * - 通讯录同步条目:填写通讯录同步 Secret,agentId 可不填(null)
+ *
+ * 但同一 corpId 下不允许出现重复的 agentId(包括多个 null)。
+ *
* 查看 {@link me.chanjar.weixin.cp.config.impl.AbstractWxCpInRedisConfigImpl#setAgentId(Integer)}
*/
Collection corpList = corps.values();
@@ -49,8 +57,8 @@ protected WxCpMultiServices wxCpMultiServices(WxCpMultiProperties wxCpMultiPrope
String corpId = entry.getKey();
// 校验每个企业下,agentId 是否唯一
boolean multi = entry.getValue().stream()
- // 通讯录没有 agentId,如果不判断是否为空,这里会报 NPE 异常
- .collect(Collectors.groupingBy(c -> c.getAgentId() == null ? 0 : c.getAgentId(), Collectors.counting()))
+ // 通讯录没有 agentId,使用字符串转换避免 null 与 agentId=0 冲突
+ .collect(Collectors.groupingBy(c -> Objects.toString(c.getAgentId(), "null"), Collectors.counting()))
.entrySet().stream().anyMatch(e -> e.getValue() > 1);
if (multi) {
throw new RuntimeException("请确保企业微信配置唯一性[" + corpId + "]");
diff --git a/solon-plugins/wx-java-cp-multi-solon-plugin/src/main/java/com/binarywang/solon/wxjava/cp_multi/properties/WxCpSingleProperties.java b/solon-plugins/wx-java-cp-multi-solon-plugin/src/main/java/com/binarywang/solon/wxjava/cp_multi/properties/WxCpSingleProperties.java
index e761a09062..6f7f633c3f 100644
--- a/solon-plugins/wx-java-cp-multi-solon-plugin/src/main/java/com/binarywang/solon/wxjava/cp_multi/properties/WxCpSingleProperties.java
+++ b/solon-plugins/wx-java-cp-multi-solon-plugin/src/main/java/com/binarywang/solon/wxjava/cp_multi/properties/WxCpSingleProperties.java
@@ -8,6 +8,16 @@
/**
* 企业微信企业相关配置属性
*
+ * 企业微信中不同的 corpSecret 对应不同的权限范围,常见的有:
+ *
+ * - 自建应用 Secret:在"应用管理 - 自建应用"中查看,只能调用该应用有权限的接口
+ * - 通讯录同步 Secret:在"管理工具 - 通讯录同步"中查看,用于管理部门和成员(增删改查)
+ * - 客户联系 Secret:在"客户联系"中查看,用于客户联系相关接口
+ *
+ * 如需同时使用多种权限范围(例如:既要操作通讯录,又要调用自建应用接口),
+ * 可在 {@code wx.cp.corps} 下配置多个条目,每个条目使用对应权限的 {@code corpSecret},
+ * 其中通讯录同步的条目无需填写 {@code agentId}。
+ *
* @author yl
* created on 2023/10/16
*/
@@ -20,7 +30,16 @@ public class WxCpSingleProperties implements Serializable {
*/
private String corpId;
/**
- * 微信企业号 corpSecret
+ * 微信企业号 corpSecret(权限密钥)
+ *
+ * 企业微信针对不同的功能模块提供了不同的 Secret,每种 Secret 只对对应模块的接口有调用权限:
+ *
+ * - 自建应用 Secret:在"应用管理 - 自建应用"中找到对应应用,查看其 Secret,
+ * 使用时需同时配置对应的 {@code agentId}
+ * - 通讯录同步 Secret:在"管理工具 - 通讯录同步"中查看,
+ * 使用此 Secret 可管理部门、成员,无需配置 {@code agentId}
+ * - 其他 Secret(客户联系等):根据需要在企业微信后台查看对应 Secret
+ *
*/
private String corpSecret;
/**
@@ -28,7 +47,10 @@ public class WxCpSingleProperties implements Serializable {
*/
private String token;
/**
- * 微信企业号应用 ID
+ * 微信企业号应用 ID(AgentId)
+ *
+ * 使用自建应用 Secret 时,需要填写对应应用的 AgentId。
+ * 使用通讯录同步 Secret 时,无需填写此字段。
*/
private Integer agentId;
/**
diff --git a/spring-boot-starters/wx-java-cp-multi-spring-boot-starter/README.md b/spring-boot-starters/wx-java-cp-multi-spring-boot-starter/README.md
index e3ea7bf0f8..0f0b74695e 100644
--- a/spring-boot-starters/wx-java-cp-multi-spring-boot-starter/README.md
+++ b/spring-boot-starters/wx-java-cp-multi-spring-boot-starter/README.md
@@ -6,6 +6,29 @@
- 未实现 WxCpTpService 初始化,需要的小伙伴可以参考多 WxCpService 配置的实现。
- 未实现 WxCpCgService 初始化,需要的小伙伴可以参考多 WxCpService 配置的实现。
+## 关于 corp-secret 的说明
+
+企业微信中不同功能模块对应不同的 `corp-secret`,每种 Secret 只对对应模块的接口具有调用权限:
+
+| Secret 类型 | 获取位置 | 可调用的接口 | 是否需要 agent-id |
+|---|---|---|---|
+| 自建应用 Secret | 应用管理 → 自建应用 → 选择应用 → 查看 Secret | 该应用有权限的接口 | **必填** |
+| 通讯录同步 Secret | 管理工具 → 通讯录同步 → 查看 Secret | 部门/成员增删改查等通讯录接口 | **不填** |
+| 客户联系 Secret | 客户联系 → API → Secret | 客户联系相关接口 | 不填 |
+
+> **常见问题**:
+> - 使用自建应用 Secret + agent-id 可以获取部门列表,但**无法更新部门**(因为写接口需要通讯录同步权限)
+> - 使用通讯录同步 Secret 可以同步部门,但**调用某些需要 agent-id 的应用接口会报错**
+
+如需同时使用多种权限范围,可在 `wx.cp.corps` 下配置多个条目,每个条目使用对应权限的 Secret,通过不同的 `tenantId` 区分后使用。
+
+> **配置限制说明**:
+> - 当前 starter 实现会校验:同一 `corp-id` 下,`agent-id` **必须唯一**
+> - 同一 `corp-id` 下,**只能有一个条目不填 `agent-id`**
+> - 否则会因为 token/ticket 缓存 key 冲突而在启动时直接抛异常
+>
+> 因此,像"通讯录同步 Secret""客户联系 Secret"这类通常不填写 `agent-id` 的配置,**不能**在同一个 `corp-id` 下同时配置多个 `agent-id` 均为空的条目;如确有多个条目,请确保其中最多只有一个未填写 `agent-id`。
+
## 快速开始
1. 引入依赖
@@ -18,25 +41,21 @@
```
2. 添加配置(application.properties)
```properties
- # 应用 1 配置
- wx.cp.corps.tenantId1.corp-id = @corp-id
- wx.cp.corps.tenantId1.corp-secret = @corp-secret
- ## 选填
- wx.cp.corps.tenantId1.agent-id = @agent-id
- wx.cp.corps.tenantId1.token = @token
- wx.cp.corps.tenantId1.aes-key = @aes-key
- wx.cp.corps.tenantId1.msg-audit-priKey = @msg-audit-priKey
- wx.cp.corps.tenantId1.msg-audit-lib-path = @msg-audit-lib-path
-
- # 应用 2 配置
- wx.cp.corps.tenantId2.corp-id = @corp-id
- wx.cp.corps.tenantId2.corp-secret = @corp-secret
+ # 自建应用 1 配置(使用自建应用 Secret,需填写 agent-id)
+ wx.cp.corps.app1.corp-id = @corp-id
+ wx.cp.corps.app1.corp-secret = @自建应用的Secret(在"应用管理-自建应用"中查看)
+ wx.cp.corps.app1.agent-id = @自建应用的AgentId
## 选填
- wx.cp.corps.tenantId2.agent-id = @agent-id
- wx.cp.corps.tenantId2.token = @token
- wx.cp.corps.tenantId2.aes-key = @aes-key
- wx.cp.corps.tenantId2.msg-audit-priKey = @msg-audit-priKey
- wx.cp.corps.tenantId2.msg-audit-lib-path = @msg-audit-lib-path
+ wx.cp.corps.app1.token = @token
+ wx.cp.corps.app1.aes-key = @aes-key
+ wx.cp.corps.app1.msg-audit-priKey = @msg-audit-priKey
+ wx.cp.corps.app1.msg-audit-lib-path = @msg-audit-lib-path
+
+ # 通讯录同步配置(使用通讯录同步 Secret,不需要填写 agent-id)
+ # 此配置用于部门、成员的增删改查等通讯录管理操作
+ wx.cp.corps.contact.corp-id = @corp-id
+ wx.cp.corps.contact.corp-secret = @通讯录同步的Secret(在"管理工具-通讯录同步"中查看)
+ ## agent-id 不填,通讯录同步不需要 agentId
# 公共配置
## ConfigStorage 配置(选填)
@@ -59,8 +78,10 @@
```java
import com.binarywang.spring.starter.wxjava.cp.service.WxCpMultiServices;
+import me.chanjar.weixin.cp.api.WxCpDepartmentService;
import me.chanjar.weixin.cp.api.WxCpService;
import me.chanjar.weixin.cp.api.WxCpUserService;
+import me.chanjar.weixin.cp.bean.WxCpDepart;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -70,27 +91,21 @@ public class DemoService {
private WxCpMultiServices wxCpMultiServices;
public void test() {
- // 应用 1 的 WxCpService
- WxCpService wxCpService1 = wxCpMultiServices.getWxCpService("tenantId1");
- WxCpUserService userService1 = wxCpService1.getUserService();
- userService1.getUserId("xxx");
- // todo ...
-
- // 应用 2 的 WxCpService
- WxCpService wxCpService2 = wxCpMultiServices.getWxCpService("tenantId2");
- WxCpUserService userService2 = wxCpService2.getUserService();
- userService2.getUserId("xxx");
+ // 使用自建应用的 WxCpService(对应 corp-secret 为自建应用 Secret)
+ WxCpService appService = wxCpMultiServices.getWxCpService("app1");
+ WxCpUserService userService = appService.getUserService();
+ userService.getUserId("xxx");
// todo ...
- // 应用 3 的 WxCpService
- WxCpService wxCpService3 = wxCpMultiServices.getWxCpService("tenantId3");
- // 判断是否为空
- if (wxCpService3 == null) {
- // todo wxCpService3 为空,请先配置 tenantId3 企业微信应用参数
- return;
- }
- WxCpUserService userService3 = wxCpService3.getUserService();
- userService3.getUserId("xxx");
+ // 使用通讯录同步的 WxCpService(对应 corp-secret 为通讯录同步 Secret)
+ // 通讯录同步 Secret 具有部门/成员增删改查等权限
+ WxCpService contactService = wxCpMultiServices.getWxCpService("contact");
+ WxCpDepartmentService departmentService = contactService.getDepartmentService();
+ // 更新部门示例(WxCpDepart 包含 id、name、parentId 等字段)
+ WxCpDepart depart = new WxCpDepart();
+ depart.setId(100L);
+ depart.setName("新部门名称");
+ departmentService.update(depart);
// todo ...
}
}
diff --git a/spring-boot-starters/wx-java-cp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/cp/configuration/services/AbstractWxCpConfiguration.java b/spring-boot-starters/wx-java-cp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/cp/configuration/services/AbstractWxCpConfiguration.java
index 9b959222e0..a10bdf9bed 100644
--- a/spring-boot-starters/wx-java-cp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/cp/configuration/services/AbstractWxCpConfiguration.java
+++ b/spring-boot-starters/wx-java-cp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/cp/configuration/services/AbstractWxCpConfiguration.java
@@ -18,6 +18,7 @@
import java.util.Collection;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@@ -40,6 +41,13 @@ protected WxCpMultiServices wxCpMultiServices(WxCpMultiProperties wxCpMultiPrope
/**
* 校验同一个企业下,agentId 是否唯一,避免使用 redis 缓存 token、ticket 时错乱。
*
+ * 同一企业(corpId 相同)下可配置多个条目以使用不同的权限 Secret,例如:
+ *
+ * - 自建应用条目:填写应用对应的 corpSecret 和 agentId
+ * - 通讯录同步条目:填写通讯录同步 Secret,agentId 可不填(null)
+ *
+ * 但同一 corpId 下不允许出现重复的 agentId(包括多个 null)。
+ *
* 查看 {@link me.chanjar.weixin.cp.config.impl.AbstractWxCpInRedisConfigImpl#setAgentId(Integer)}
*/
Collection corpList = corps.values();
@@ -52,8 +60,8 @@ protected WxCpMultiServices wxCpMultiServices(WxCpMultiProperties wxCpMultiPrope
String corpId = entry.getKey();
// 校验每个企业下,agentId 是否唯一
boolean multi = entry.getValue().stream()
- // 通讯录没有 agentId,如果不判断是否为空,这里会报 NPE 异常
- .collect(Collectors.groupingBy(c -> c.getAgentId() == null ? 0 : c.getAgentId(), Collectors.counting()))
+ // 通讯录没有 agentId,使用字符串转换避免 null 与 agentId=0 冲突
+ .collect(Collectors.groupingBy(c -> Objects.toString(c.getAgentId(), "null"), Collectors.counting()))
.entrySet().stream().anyMatch(e -> e.getValue() > 1);
if (multi) {
throw new RuntimeException("请确保企业微信配置唯一性[" + corpId + "]");
diff --git a/spring-boot-starters/wx-java-cp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/cp/properties/WxCpSingleProperties.java b/spring-boot-starters/wx-java-cp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/cp/properties/WxCpSingleProperties.java
index 8ad7149fe6..fcfa654a15 100644
--- a/spring-boot-starters/wx-java-cp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/cp/properties/WxCpSingleProperties.java
+++ b/spring-boot-starters/wx-java-cp-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/cp/properties/WxCpSingleProperties.java
@@ -8,6 +8,16 @@
/**
* 企业微信企业相关配置属性
*
+ * 企业微信中不同的 corpSecret 对应不同的权限范围,常见的有:
+ *
+ * - 自建应用 Secret:在"应用管理 - 自建应用"中查看,只能调用该应用有权限的接口
+ * - 通讯录同步 Secret:在"管理工具 - 通讯录同步"中查看,用于管理部门和成员(增删改查)
+ * - 客户联系 Secret:在"客户联系"中查看,用于客户联系相关接口
+ *
+ * 如需同时使用多种权限范围(例如:既要操作通讯录,又要调用自建应用接口),
+ * 可在 {@code wx.cp.corps} 下配置多个条目,每个条目使用对应权限的 {@code corpSecret},
+ * 其中通讯录同步的条目无需填写 {@code agentId}。
+ *
* @author yl
* created on 2023/10/16
*/
@@ -20,7 +30,16 @@ public class WxCpSingleProperties implements Serializable {
*/
private String corpId;
/**
- * 微信企业号 corpSecret
+ * 微信企业号 corpSecret(权限密钥)
+ *
+ * 企业微信针对不同的功能模块提供了不同的 Secret,每种 Secret 只对对应模块的接口有调用权限:
+ *
+ * - 自建应用 Secret:在"应用管理 - 自建应用"中找到对应应用,查看其 Secret,
+ * 使用时需同时配置对应的 {@code agentId}
+ * - 通讯录同步 Secret:在"管理工具 - 通讯录同步"中查看,
+ * 使用此 Secret 可管理部门、成员,无需配置 {@code agentId}
+ * - 其他 Secret(客户联系等):根据需要在企业微信后台查看对应 Secret
+ *
*/
private String corpSecret;
/**
@@ -28,7 +47,10 @@ public class WxCpSingleProperties implements Serializable {
*/
private String token;
/**
- * 微信企业号应用 ID
+ * 微信企业号应用 ID(AgentId)
+ *
+ * 使用自建应用 Secret 时,需要填写对应应用的 AgentId。
+ * 使用通讯录同步 Secret 时,无需填写此字段。
*/
private Integer agentId;
/**
From 025430f4e657ba6d80f6a26d97a59aab39cae55c Mon Sep 17 00:00:00 2001
From: Binary Wang
Date: Mon, 11 May 2026 21:10:40 +0800
Subject: [PATCH 94/94] =?UTF-8?q?:bookmark:=20=E5=8F=91=E5=B8=83=204.8.3.B?=
=?UTF-8?q?=20=E6=B5=8B=E8=AF=95=E7=89=88=E6=9C=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
pom.xml | 2 +-
solon-plugins/pom.xml | 2 +-
solon-plugins/wx-java-channel-multi-solon-plugin/pom.xml | 2 +-
solon-plugins/wx-java-channel-solon-plugin/pom.xml | 2 +-
solon-plugins/wx-java-cp-multi-solon-plugin/pom.xml | 2 +-
solon-plugins/wx-java-cp-solon-plugin/pom.xml | 2 +-
solon-plugins/wx-java-miniapp-multi-solon-plugin/pom.xml | 2 +-
solon-plugins/wx-java-miniapp-solon-plugin/pom.xml | 2 +-
solon-plugins/wx-java-mp-multi-solon-plugin/pom.xml | 2 +-
solon-plugins/wx-java-mp-solon-plugin/pom.xml | 2 +-
solon-plugins/wx-java-open-solon-plugin/pom.xml | 2 +-
solon-plugins/wx-java-pay-solon-plugin/pom.xml | 2 +-
solon-plugins/wx-java-qidian-solon-plugin/pom.xml | 2 +-
spring-boot-starters/pom.xml | 2 +-
.../wx-java-channel-multi-spring-boot-starter/pom.xml | 2 +-
.../wx-java-channel-spring-boot-starter/pom.xml | 2 +-
.../wx-java-cp-multi-spring-boot-starter/pom.xml | 2 +-
spring-boot-starters/wx-java-cp-spring-boot-starter/pom.xml | 2 +-
.../wx-java-cp-tp-multi-spring-boot-starter/pom.xml | 2 +-
.../wx-java-miniapp-multi-spring-boot-starter/pom.xml | 2 +-
.../wx-java-miniapp-spring-boot-starter/pom.xml | 2 +-
.../wx-java-mp-multi-spring-boot-starter/pom.xml | 2 +-
spring-boot-starters/wx-java-mp-spring-boot-starter/pom.xml | 2 +-
.../wx-java-open-multi-spring-boot-starter/pom.xml | 2 +-
spring-boot-starters/wx-java-open-spring-boot-starter/pom.xml | 2 +-
.../wx-java-pay-multi-spring-boot-starter/pom.xml | 2 +-
.../spring/starter/wxjava/pay/WxPayMultiServicesTest.java | 2 +-
spring-boot-starters/wx-java-pay-spring-boot-starter/pom.xml | 2 +-
spring-boot-starters/wx-java-qidian-spring-boot-starter/pom.xml | 2 +-
weixin-graal/pom.xml | 2 +-
weixin-java-channel/pom.xml | 2 +-
weixin-java-common/pom.xml | 2 +-
weixin-java-cp/pom.xml | 2 +-
weixin-java-miniapp/pom.xml | 2 +-
weixin-java-mp/pom.xml | 2 +-
weixin-java-open/pom.xml | 2 +-
weixin-java-pay/pom.xml | 2 +-
weixin-java-qidian/pom.xml | 2 +-
wx-java-bom/pom.xml | 2 +-
39 files changed, 39 insertions(+), 39 deletions(-)
diff --git a/pom.xml b/pom.xml
index 3f4fc56610..905e582443 100644
--- a/pom.xml
+++ b/pom.xml
@@ -3,7 +3,7 @@
4.0.0
com.github.binarywang
wx-java
- 4.8.2.B
+ 4.8.3.B
pom
WxJava - Weixin/Wechat Java SDK
微信开发Java SDK
diff --git a/solon-plugins/pom.xml b/solon-plugins/pom.xml
index 9a375a60cc..87401a2c97 100644
--- a/solon-plugins/pom.xml
+++ b/solon-plugins/pom.xml
@@ -6,7 +6,7 @@
com.github.binarywang
wx-java
- 4.8.2.B
+ 4.8.3.B
pom
wx-java-solon-plugins
diff --git a/solon-plugins/wx-java-channel-multi-solon-plugin/pom.xml b/solon-plugins/wx-java-channel-multi-solon-plugin/pom.xml
index 908e3957ee..d99f9a67c1 100644
--- a/solon-plugins/wx-java-channel-multi-solon-plugin/pom.xml
+++ b/solon-plugins/wx-java-channel-multi-solon-plugin/pom.xml
@@ -5,7 +5,7 @@
wx-java-solon-plugins
com.github.binarywang
- 4.8.2.B
+ 4.8.3.B
4.0.0
diff --git a/solon-plugins/wx-java-channel-solon-plugin/pom.xml b/solon-plugins/wx-java-channel-solon-plugin/pom.xml
index 1e3f457cfe..a26072f8c4 100644
--- a/solon-plugins/wx-java-channel-solon-plugin/pom.xml
+++ b/solon-plugins/wx-java-channel-solon-plugin/pom.xml
@@ -3,7 +3,7 @@
wx-java-solon-plugins
com.github.binarywang
- 4.8.2.B
+ 4.8.3.B
4.0.0
diff --git a/solon-plugins/wx-java-cp-multi-solon-plugin/pom.xml b/solon-plugins/wx-java-cp-multi-solon-plugin/pom.xml
index c0d1dcc180..9ccd05578b 100644
--- a/solon-plugins/wx-java-cp-multi-solon-plugin/pom.xml
+++ b/solon-plugins/wx-java-cp-multi-solon-plugin/pom.xml
@@ -4,7 +4,7 @@
wx-java-solon-plugins
com.github.binarywang
- 4.8.2.B
+ 4.8.3.B
4.0.0
diff --git a/solon-plugins/wx-java-cp-solon-plugin/pom.xml b/solon-plugins/wx-java-cp-solon-plugin/pom.xml
index b8d2c43351..367d2a338c 100644
--- a/solon-plugins/wx-java-cp-solon-plugin/pom.xml
+++ b/solon-plugins/wx-java-cp-solon-plugin/pom.xml
@@ -4,7 +4,7 @@
wx-java-solon-plugins
com.github.binarywang
- 4.8.2.B
+ 4.8.3.B
4.0.0
diff --git a/solon-plugins/wx-java-miniapp-multi-solon-plugin/pom.xml b/solon-plugins/wx-java-miniapp-multi-solon-plugin/pom.xml
index 6ca319ad7f..9ea8b7caff 100644
--- a/solon-plugins/wx-java-miniapp-multi-solon-plugin/pom.xml
+++ b/solon-plugins/wx-java-miniapp-multi-solon-plugin/pom.xml
@@ -5,7 +5,7 @@
wx-java-solon-plugins
com.github.binarywang
- 4.8.2.B
+ 4.8.3.B
4.0.0
diff --git a/solon-plugins/wx-java-miniapp-solon-plugin/pom.xml b/solon-plugins/wx-java-miniapp-solon-plugin/pom.xml
index 28f80f00b1..0651e3b9b5 100644
--- a/solon-plugins/wx-java-miniapp-solon-plugin/pom.xml
+++ b/solon-plugins/wx-java-miniapp-solon-plugin/pom.xml
@@ -4,7 +4,7 @@
wx-java-solon-plugins
com.github.binarywang
- 4.8.2.B
+ 4.8.3.B
4.0.0
diff --git a/solon-plugins/wx-java-mp-multi-solon-plugin/pom.xml b/solon-plugins/wx-java-mp-multi-solon-plugin/pom.xml
index f78f9b5d59..4dc7eae667 100644
--- a/solon-plugins/wx-java-mp-multi-solon-plugin/pom.xml
+++ b/solon-plugins/wx-java-mp-multi-solon-plugin/pom.xml
@@ -5,7 +5,7 @@
wx-java-solon-plugins
com.github.binarywang
- 4.8.2.B
+ 4.8.3.B
4.0.0
diff --git a/solon-plugins/wx-java-mp-solon-plugin/pom.xml b/solon-plugins/wx-java-mp-solon-plugin/pom.xml
index 6ca5283c18..e0c79f79bf 100644
--- a/solon-plugins/wx-java-mp-solon-plugin/pom.xml
+++ b/solon-plugins/wx-java-mp-solon-plugin/pom.xml
@@ -5,7 +5,7 @@
wx-java-solon-plugins
com.github.binarywang
- 4.8.2.B
+ 4.8.3.B
4.0.0
diff --git a/solon-plugins/wx-java-open-solon-plugin/pom.xml b/solon-plugins/wx-java-open-solon-plugin/pom.xml
index dcd856dc26..4cd4b1ac56 100644
--- a/solon-plugins/wx-java-open-solon-plugin/pom.xml
+++ b/solon-plugins/wx-java-open-solon-plugin/pom.xml
@@ -5,7 +5,7 @@
wx-java-solon-plugins
com.github.binarywang
- 4.8.2.B
+ 4.8.3.B
4.0.0
diff --git a/solon-plugins/wx-java-pay-solon-plugin/pom.xml b/solon-plugins/wx-java-pay-solon-plugin/pom.xml
index 26e0b7faca..607c138fd3 100644
--- a/solon-plugins/wx-java-pay-solon-plugin/pom.xml
+++ b/solon-plugins/wx-java-pay-solon-plugin/pom.xml
@@ -5,7 +5,7 @@
wx-java-solon-plugins
com.github.binarywang
- 4.8.2.B
+ 4.8.3.B
4.0.0
diff --git a/solon-plugins/wx-java-qidian-solon-plugin/pom.xml b/solon-plugins/wx-java-qidian-solon-plugin/pom.xml
index cb0caaa1e4..f83c8a8066 100644
--- a/solon-plugins/wx-java-qidian-solon-plugin/pom.xml
+++ b/solon-plugins/wx-java-qidian-solon-plugin/pom.xml
@@ -3,7 +3,7 @@
wx-java-solon-plugins
com.github.binarywang
- 4.8.2.B
+ 4.8.3.B
4.0.0
diff --git a/spring-boot-starters/pom.xml b/spring-boot-starters/pom.xml
index ff2ce88236..07a1226e6f 100644
--- a/spring-boot-starters/pom.xml
+++ b/spring-boot-starters/pom.xml
@@ -6,7 +6,7 @@
com.github.binarywang
wx-java
- 4.8.2.B
+ 4.8.3.B
pom
wx-java-spring-boot-starters
diff --git a/spring-boot-starters/wx-java-channel-multi-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-channel-multi-spring-boot-starter/pom.xml
index de7a389532..c3c3441c9b 100644
--- a/spring-boot-starters/wx-java-channel-multi-spring-boot-starter/pom.xml
+++ b/spring-boot-starters/wx-java-channel-multi-spring-boot-starter/pom.xml
@@ -5,7 +5,7 @@
wx-java-spring-boot-starters
com.github.binarywang
- 4.8.2.B
+ 4.8.3.B
4.0.0
diff --git a/spring-boot-starters/wx-java-channel-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-channel-spring-boot-starter/pom.xml
index 9f22f79503..f74d3bfaae 100644
--- a/spring-boot-starters/wx-java-channel-spring-boot-starter/pom.xml
+++ b/spring-boot-starters/wx-java-channel-spring-boot-starter/pom.xml
@@ -3,7 +3,7 @@
wx-java-spring-boot-starters
com.github.binarywang
- 4.8.2.B
+ 4.8.3.B
4.0.0
diff --git a/spring-boot-starters/wx-java-cp-multi-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-cp-multi-spring-boot-starter/pom.xml
index 514a67b3ec..0cb592a7fc 100644
--- a/spring-boot-starters/wx-java-cp-multi-spring-boot-starter/pom.xml
+++ b/spring-boot-starters/wx-java-cp-multi-spring-boot-starter/pom.xml
@@ -4,7 +4,7 @@
wx-java-spring-boot-starters
com.github.binarywang
- 4.8.2.B
+ 4.8.3.B
4.0.0
diff --git a/spring-boot-starters/wx-java-cp-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-cp-spring-boot-starter/pom.xml
index df23601c73..881064d493 100644
--- a/spring-boot-starters/wx-java-cp-spring-boot-starter/pom.xml
+++ b/spring-boot-starters/wx-java-cp-spring-boot-starter/pom.xml
@@ -4,7 +4,7 @@
wx-java-spring-boot-starters
com.github.binarywang
- 4.8.2.B
+ 4.8.3.B
4.0.0
diff --git a/spring-boot-starters/wx-java-cp-tp-multi-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-cp-tp-multi-spring-boot-starter/pom.xml
index fa0b98aabf..b3bd632cad 100644
--- a/spring-boot-starters/wx-java-cp-tp-multi-spring-boot-starter/pom.xml
+++ b/spring-boot-starters/wx-java-cp-tp-multi-spring-boot-starter/pom.xml
@@ -4,7 +4,7 @@
wx-java-spring-boot-starters
com.github.binarywang
- 4.8.2.B
+ 4.8.3.B
4.0.0
diff --git a/spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/pom.xml
index 05f595ac26..744ba094a1 100644
--- a/spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/pom.xml
+++ b/spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/pom.xml
@@ -5,7 +5,7 @@
wx-java-spring-boot-starters
com.github.binarywang
- 4.8.2.B
+ 4.8.3.B
4.0.0
diff --git a/spring-boot-starters/wx-java-miniapp-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-miniapp-spring-boot-starter/pom.xml
index 25d5f66758..1088b711e7 100644
--- a/spring-boot-starters/wx-java-miniapp-spring-boot-starter/pom.xml
+++ b/spring-boot-starters/wx-java-miniapp-spring-boot-starter/pom.xml
@@ -4,7 +4,7 @@
wx-java-spring-boot-starters
com.github.binarywang
- 4.8.2.B
+ 4.8.3.B
4.0.0
diff --git a/spring-boot-starters/wx-java-mp-multi-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-mp-multi-spring-boot-starter/pom.xml
index 88b11099a3..de88f187a7 100644
--- a/spring-boot-starters/wx-java-mp-multi-spring-boot-starter/pom.xml
+++ b/spring-boot-starters/wx-java-mp-multi-spring-boot-starter/pom.xml
@@ -5,7 +5,7 @@
wx-java-spring-boot-starters
com.github.binarywang
- 4.8.2.B
+ 4.8.3.B
4.0.0
diff --git a/spring-boot-starters/wx-java-mp-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-mp-spring-boot-starter/pom.xml
index 9e95574bc2..672cf2e35c 100644
--- a/spring-boot-starters/wx-java-mp-spring-boot-starter/pom.xml
+++ b/spring-boot-starters/wx-java-mp-spring-boot-starter/pom.xml
@@ -5,7 +5,7 @@
wx-java-spring-boot-starters
com.github.binarywang
- 4.8.2.B
+ 4.8.3.B
4.0.0
diff --git a/spring-boot-starters/wx-java-open-multi-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-open-multi-spring-boot-starter/pom.xml
index c5cf07e799..dea66a5a35 100644
--- a/spring-boot-starters/wx-java-open-multi-spring-boot-starter/pom.xml
+++ b/spring-boot-starters/wx-java-open-multi-spring-boot-starter/pom.xml
@@ -5,7 +5,7 @@
wx-java-spring-boot-starters
com.github.binarywang
- 4.8.2.B
+ 4.8.3.B
4.0.0
diff --git a/spring-boot-starters/wx-java-open-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-open-spring-boot-starter/pom.xml
index 72c856f27c..22dbd864df 100644
--- a/spring-boot-starters/wx-java-open-spring-boot-starter/pom.xml
+++ b/spring-boot-starters/wx-java-open-spring-boot-starter/pom.xml
@@ -5,7 +5,7 @@
wx-java-spring-boot-starters
com.github.binarywang
- 4.8.2.B
+ 4.8.3.B
4.0.0
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
index 1964bcbbfe..c416b5ba40 100644
--- 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
@@ -5,7 +5,7 @@
wx-java-spring-boot-starters
com.github.binarywang
- 4.8.2.B
+ 4.8.3.B
4.0.0
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
index d60335ebed..87132fdcf3 100644
--- 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
@@ -61,7 +61,7 @@ public void testConfiguration() {
assertEquals("2222222222", app2Config.getMchId());
assertEquals("http://10.0.0.1:3128", app2Config.getApiHostUrl());
assertEquals("/api-weixin", app2Config.getApiHostUrlPath());
- assertEquals("22222222222222222222222222222222", app2Config.getApiv3Key());
+ assertEquals("22222222222222222222222222222222", app2Config.getApiV3Key());
}
@Test
diff --git a/spring-boot-starters/wx-java-pay-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-pay-spring-boot-starter/pom.xml
index ecdb925730..3c1313bc22 100644
--- a/spring-boot-starters/wx-java-pay-spring-boot-starter/pom.xml
+++ b/spring-boot-starters/wx-java-pay-spring-boot-starter/pom.xml
@@ -5,7 +5,7 @@
wx-java-spring-boot-starters
com.github.binarywang
- 4.8.2.B
+ 4.8.3.B
4.0.0
diff --git a/spring-boot-starters/wx-java-qidian-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-qidian-spring-boot-starter/pom.xml
index 7e314df780..d9b845adb1 100644
--- a/spring-boot-starters/wx-java-qidian-spring-boot-starter/pom.xml
+++ b/spring-boot-starters/wx-java-qidian-spring-boot-starter/pom.xml
@@ -3,7 +3,7 @@
wx-java-spring-boot-starters
com.github.binarywang
- 4.8.2.B
+ 4.8.3.B
4.0.0
diff --git a/weixin-graal/pom.xml b/weixin-graal/pom.xml
index a55cc19226..9c23e95add 100644
--- a/weixin-graal/pom.xml
+++ b/weixin-graal/pom.xml
@@ -6,7 +6,7 @@
com.github.binarywang
wx-java
- 4.8.2.B
+ 4.8.3.B
weixin-graal
diff --git a/weixin-java-channel/pom.xml b/weixin-java-channel/pom.xml
index 68edb075ce..0d332daa20 100644
--- a/weixin-java-channel/pom.xml
+++ b/weixin-java-channel/pom.xml
@@ -6,7 +6,7 @@
com.github.binarywang
wx-java
- 4.8.2.B
+ 4.8.3.B
weixin-java-channel
diff --git a/weixin-java-common/pom.xml b/weixin-java-common/pom.xml
index 98b7ddda62..ad124f8052 100644
--- a/weixin-java-common/pom.xml
+++ b/weixin-java-common/pom.xml
@@ -6,7 +6,7 @@
com.github.binarywang
wx-java
- 4.8.2.B
+ 4.8.3.B
weixin-java-common
diff --git a/weixin-java-cp/pom.xml b/weixin-java-cp/pom.xml
index a75b7b034e..d9d8694352 100644
--- a/weixin-java-cp/pom.xml
+++ b/weixin-java-cp/pom.xml
@@ -7,7 +7,7 @@
com.github.binarywang
wx-java
- 4.8.2.B
+ 4.8.3.B
weixin-java-cp
diff --git a/weixin-java-miniapp/pom.xml b/weixin-java-miniapp/pom.xml
index 2a1b5e4f1a..ca426c4e8b 100644
--- a/weixin-java-miniapp/pom.xml
+++ b/weixin-java-miniapp/pom.xml
@@ -7,7 +7,7 @@
com.github.binarywang
wx-java
- 4.8.2.B
+ 4.8.3.B
weixin-java-miniapp
diff --git a/weixin-java-mp/pom.xml b/weixin-java-mp/pom.xml
index 487728ea42..b21ac9bb26 100644
--- a/weixin-java-mp/pom.xml
+++ b/weixin-java-mp/pom.xml
@@ -7,7 +7,7 @@
com.github.binarywang
wx-java
- 4.8.2.B
+ 4.8.3.B
weixin-java-mp
diff --git a/weixin-java-open/pom.xml b/weixin-java-open/pom.xml
index 6fa96d8aea..f7701d1809 100644
--- a/weixin-java-open/pom.xml
+++ b/weixin-java-open/pom.xml
@@ -7,7 +7,7 @@
com.github.binarywang
wx-java
- 4.8.2.B
+ 4.8.3.B
weixin-java-open
diff --git a/weixin-java-pay/pom.xml b/weixin-java-pay/pom.xml
index c9037cdf7f..80893a55ab 100644
--- a/weixin-java-pay/pom.xml
+++ b/weixin-java-pay/pom.xml
@@ -5,7 +5,7 @@
com.github.binarywang
wx-java
- 4.8.2.B
+ 4.8.3.B
4.0.0
diff --git a/weixin-java-qidian/pom.xml b/weixin-java-qidian/pom.xml
index b98ca26e41..b7aa187817 100644
--- a/weixin-java-qidian/pom.xml
+++ b/weixin-java-qidian/pom.xml
@@ -7,7 +7,7 @@
com.github.binarywang
wx-java
- 4.8.2.B
+ 4.8.3.B
weixin-java-qidian
diff --git a/wx-java-bom/pom.xml b/wx-java-bom/pom.xml
index 793d4e09ea..e7f3920dc2 100644
--- a/wx-java-bom/pom.xml
+++ b/wx-java-bom/pom.xml
@@ -6,7 +6,7 @@
com.github.binarywang
wx-java
- 4.8.2.B
+ 4.8.3.B
wx-java-bom