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 cdf559ad7..63dc7ac00 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 8b968e540..fd96d76c3 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 4bf13f24e..f8047e846 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 49cd7c455..48e244550 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 ec7362ed5..a1ea40f3f 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/70] =?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 e145e5fd6..8b000ff8c 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 000000000..d8d41b7de
--- /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 000000000..a5c0b842c
--- /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 000000000..08ddafbf9
--- /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 000000000..8d1180b0e
--- /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 000000000..a5cda55fb
--- /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 000000000..3e0b7a999
--- /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 000000000..459fe3b6c
--- /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 000000000..d257d3727
--- /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 000000000..39e3342f4
--- /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 000000000..25a091da0
--- /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 000000000..48ae32d5b
--- /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/70] =?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 000000000..e4a7d0b9e
--- /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 dab89a014..2db2987d1 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 5347099a0..4b51c498d 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 000000000..010f15fc6
--- /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 000000000..c1c1460fe
--- /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/70] =?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 000000000..6581f6207
--- /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 000000000..6dd1d110c
--- /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 15e638f89..fba9d875e 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 6dae33d58..201aceb8b 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 000000000..40a01fb52
--- /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 1f431b645..46724c625 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 8b2fa58aa..9dd95f953 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 000000000..ca9123c57
--- /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/70] =?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 000000000..50954e70e
--- /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 000000000..628c75d5f
--- /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 b3f788815..581e3230b 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 3d8c83127..85f7ee23d 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 03bbc8c59..ce43aa3d0 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/70] =?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 1556fbc34..0e20fc8fa 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 ce43aa3d0..20bb33d7f 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/70] =?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 4ca7958ed..854fd6ba5 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 000000000..c2347300a
--- /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/70] =?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 7d55ff878..fe77fcaea 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 f722a248d..c7cc048db 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/70] =?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 dc7425fa6..37a6ca8de 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 8f240e915..08d29000e 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 2d5047981..d93d9beb7 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 e56d84670..e357f246a 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 2a7496e06..58b10039a 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 000000000..53afad70f
--- /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/70] =?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 d7d93322b..8a08484f0 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 d0ca564c2..d49beabc7 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 995ecbd53..4d7501026 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 b2ca35669..e52aadc71 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 17e24bfe2..6518a5599 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 7e6f2f816..50f6b22c7 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 932f9244c..bd69cd0ed 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 5ad8da85e..a52eab54c 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 7c02acdfe..c9329fd29 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 d72a5f7fc..00f7a3951 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 0f0527183..1d0a03fb7 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 7c1cb4e85..ab870301a 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 724bdf4ac..d46c9ca32 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 8b000ff8c..f37903c7e 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 b44f597d2..083072f60 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 95021e2d2..0d9e5e4e4 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 550a14d2a..6aa13ae81 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 81f68274c..a560bf8e0 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 f1cc1fba1..2d6c78009 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 8c8854067..32431c28c 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 bcc61b030..7e9ffbe30 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 6323ae4b6..0857fc4e1 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 38e484b45..5d0c2d026 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 1ad7a5e8e..3b01d26f4 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 9a25cd89d..0b9e203b8 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 a5c0b842c..c8b08a8a0 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 8b67ade1e..7bbfdbfbf 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 a0fc32943..f26d5dd88 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 3a220b288..272e07de5 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 28b3e2ed6..b994a98cb 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 2053177b1..33fd85d4b 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 9294b62d2..922d4f6b8 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 a3b1687b2..318d538a1 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 823e7fd1f..f0c590bab 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 6720cd2b3..c05fc5270 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 43851df85..7098d8d8a 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 7b06feb08..e40a096bf 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/70] =?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 080b831d1..69d5efbf3 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/70] =?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 000000000..615cbbff5
--- /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 0e35dbb68..f7f0aaaf3 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 7952513f5..ee77f5e97 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 5f5e52d2f..3387f37e3 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 c8dd069b4..845992e43 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/70] =?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 50954e70e..7b7eff023 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 20bb33d7f..a5421f5dc 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/70] =?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 92ec8a43e..848e37779 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/70] =?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 69d5efbf3..ab1d82352 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/70] =?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 9464144c1..fd5badb5d 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/70] =?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 f8f3275c4..a165c1c4c 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 000000000..db23c222f
--- /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/70] =?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 000000000..0b55a9c03
--- /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 08a093631..6475623d8 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 0b2324a5f..e87ff2334 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/70] =?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 0c8481288..2fd68fb45 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/70] =?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 000000000..fdfe536d1
--- /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 76012a281..3427d656e 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 bc18c9bc7..9c6932930 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 000000000..7c48b0bd5
--- /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 000000000..971e5958d
--- /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 000000000..07e286c2e
--- /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 000000000..e355d8cc6
--- /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