Skip to content

Commit 09d72a0

Browse files
authored
Merge branch 'binarywang:develop' into develop
2 parents d69067c + 56fd7be commit 09d72a0

41 files changed

Lines changed: 1075 additions & 255 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/agents/my-agent.agent.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ description: 需要用中文,包括PR标题和分析总结过程
1212

1313
- 1、请使用中文输出思考过程和总结,包括PR标题,提交commit信息也要使用中文;
1414
- 2、生成代码时需要提供必要的单元测试代码;
15-
- 3、新增加的代码如果标记作者信息,请注意不要把作者名设为binarywang或者其他无关人员,要改为 GitHub Copilot。
15+
- 3、实现接口时请严格按照官方文档编写代码,严禁瞎编乱造、臆想并实现不存在的接口;
16+
- 4、新增加的代码如果标记作者信息,请注意不要把作者名设为binarywang或者其他无关人员,要改为 GitHub Copilot。

docs/CP_MSG_AUDIT_SDK_SAFE_USAGE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# 企业微信会话存档SDK安全使用指南
22

3+
## 说明
4+
该方案已废弃,请使用新版本:[CP_MSG_AUDIT_THREADLOCAL_LIFECYCLE_REFACTOR.md](CP_MSG_AUDIT_THREADLOCAL_LIFECYCLE_REFACTOR.md)
35
## 问题背景
46

57
在使用企业微信会话存档功能时,部分开发者遇到了JVM崩溃的问题。典型错误信息如下:
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
# 会话存档SDK生命周期重构方案
2+
3+
## Context
4+
5+
当前实现(4.8.x)通过"共享SDK + 引用计数 + 7200秒过期"来管理会话存档SDK生命周期。
6+
该方案存在以下核心问题:
7+
8+
1. **频繁初始化/销毁**:每次调用 `releaseSdk()` 后引用计数归零即销毁SDK。对于"拉取→解密→下载媒体"这类典型串行调用链,每步操作都会触发重新初始化。
9+
2. **7200秒过期规则无依据**:官方文档FAQ明确说"不需要每次new/init sdk,可以在多次拉取中复用同一个sdk",无任何7200秒过期说明。
10+
3. **线程安全问题**:企微技术人员建议"一个线程一个SDK实例",当前设计多线程共享同一SDK实例,存在并发安全隐患。
11+
12+
---
13+
14+
## 推荐方案:ThreadLocal SDK 模式
15+
16+
> **核心原则**:每个线程拥有独立SDK实例,懒初始化,生命周期与线程绑定。
17+
18+
### 设计要点
19+
20+
- 使用 `ThreadLocal<Long>` 为每个线程持有独立SDK
21+
- SDK在线程首次调用时初始化,后续所有操作复用(无需重复初始化)
22+
- 移除7200秒过期机制
23+
- 移除引用计数机制(每线程独占,无需计数)
24+
- 提供显式清理接口:`closeThreadLocalSdk()`(线程结束时调)、`closeAllSdks()`(应用关闭时调)
25+
26+
### 生命周期示意
27+
28+
```
29+
Thread A: init SDK_A → getChatRecords → getDecryptChatData → downloadMediaFile → [任务结束后调closeThreadLocalSdk]
30+
Thread B: init SDK_B → getChatRecords → getDecryptChatData → downloadMediaFile → ...
31+
Thread C: init SDK_C → ...
32+
```
33+
34+
---
35+
36+
## 涉及文件
37+
38+
| 文件 | 变更类型 |
39+
|------|--------|
40+
| `weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpMsgAuditServiceImpl.java` | 主要重构 |
41+
| `weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpMsgAuditService.java` | 新增接口方法 |
42+
| `weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/WxCpConfigStorage.java` | 废弃旧SDK管理方法 |
43+
| `weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/impl/WxCpDefaultConfigImpl.java` | 废弃旧字段/方法 |
44+
| `weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/WxCpMsgAuditTest.java` | 补充测试 |
45+
| `docs/CP_MSG_AUDIT_SDK_SAFE_USAGE.md` | 更新文档 |
46+
47+
---
48+
49+
## 详细变更
50+
51+
### 1. WxCpMsgAuditServiceImpl(主要变更)
52+
53+
**新增字段:**
54+
```java
55+
/** 每个线程持有独立SDK实例 */
56+
private final ThreadLocal<Long> threadLocalSdk = new ThreadLocal<>();
57+
58+
/** 跟踪所有已创建SDK,用于统一清理 */
59+
private final Set<Long> managedSdks = ConcurrentHashMap.newKeySet();
60+
```
61+
62+
**废弃字段/方法:**
63+
- 废弃常量 `SDK_EXPIRES_TIME = 7200`(无官方依据)
64+
- 废弃 `initSdk()`(由 `getOrInitThreadLocalSdk()` 替代)
65+
- 废弃 `acquireSdk()` / `releaseSdk()`(由ThreadLocal模式替代)
66+
67+
**新增核心方法:**
68+
69+
```java
70+
/**
71+
* 获取当前线程的SDK,不存在则创建。SDK在线程内跨调用复用,无需每次重新初始化。
72+
*/
73+
private long getOrInitThreadLocalSdk() throws WxErrorException {
74+
Long sdk = threadLocalSdk.get();
75+
if (sdk != null && sdk > 0) {
76+
return sdk;
77+
}
78+
long newSdk = createSdk();
79+
threadLocalSdk.set(newSdk);
80+
managedSdks.add(newSdk);
81+
log.info("线程 [{}] 初始化会话存档SDK成功,sdk={}", Thread.currentThread().getName(), newSdk);
82+
return newSdk;
83+
}
84+
85+
/**
86+
* 创建并初始化一个新SDK(私有,只在当前线程无SDK时调用)
87+
*/
88+
private long createSdk() throws WxErrorException {
89+
WxCpConfigStorage configStorage = cpService.getWxCpConfigStorage();
90+
// ... 与现有 initSdk() 内的库加载+Finance.NewSdk()+Finance.Init() 逻辑一致 ...
91+
// 注意:Finance.loadingLibraries() 是幂等的(System.load内部防重复),多线程调用安全
92+
}
93+
94+
/**
95+
* 关闭当前线程持有的SDK,释放本地资源。
96+
* 在线程任务结束时调用(如定时任务finally块,或线程池线程销毁时)。
97+
*/
98+
public void closeThreadLocalSdk() {
99+
Long sdk = threadLocalSdk.get();
100+
if (sdk != null && sdk > 0) {
101+
Finance.DestroySdk(sdk);
102+
managedSdks.remove(sdk);
103+
threadLocalSdk.remove();
104+
log.info("线程 [{}] 关闭会话存档SDK,sdk={}", Thread.currentThread().getName(), sdk);
105+
}
106+
}
107+
108+
/**
109+
* 关闭所有线程持有的SDK。应用关闭时调用(如Spring @PreDestroy / Shutdown Hook)。
110+
*/
111+
public void closeAllSdks() {
112+
managedSdks.forEach(sdk -> {
113+
Finance.DestroySdk(sdk);
114+
log.info("关闭会话存档SDK,sdk={}", sdk);
115+
});
116+
managedSdks.clear();
117+
threadLocalSdk.remove();
118+
}
119+
```
120+
121+
**更新新API方法(getChatRecords / getDecryptChatData / getChatRecordPlainText / downloadMediaFile):**
122+
- 调用 `getOrInitThreadLocalSdk()` 替代 `acquireSdk()`
123+
- 移除 try-finally 中的 `releaseSdk(sdk)` 调用(SDK不再每次释放)
124+
- 方法变得更简洁:直接使用sdk,无需包装计数
125+
126+
**保留旧API方法不变(getChatDatas / getDecryptData / getChatPlainText / getMediaFile):**
127+
- 保持 @Deprecated 标注
128+
- 内部调用改为 `getOrInitThreadLocalSdk()` 以保持一致性(旧方法也受益于ThreadLocal)
129+
- 移除对 `initSdk()` 的依赖
130+
131+
### 2. WxCpMsgAuditService(接口新增)
132+
133+
```java
134+
/**
135+
* 关闭当前线程持有的SDK,释放native资源。
136+
* Finance.DestroySdk() 不会随线程结束自动执行,无论线程池还是独立线程,
137+
* 均应在任务结束的finally块中调用本方法,防止native内存、连接等资源泄漏。
138+
*/
139+
void closeThreadLocalSdk();
140+
141+
/**
142+
* 关闭所有会话存档SDK实例。
143+
* 适用于应用关闭时(如Spring Bean销毁阶段)统一释放资源。
144+
*/
145+
void closeAllSdks();
146+
```
147+
148+
### 3. WxCpConfigStorage(废弃旧SDK管理API)
149+
150+
对以下方法标记 `@Deprecated`(保留实现,不做破坏性删除):
151+
- `getMsgAuditSdk()` / `updateMsgAuditSdk()` / `expireMsgAuditSdk()` / `isMsgAuditSdkExpired()`
152+
- `acquireMsgAuditSdk()` / `releaseMsgAuditSdk()`
153+
- `incrementMsgAuditSdkRefCount()` / `decrementMsgAuditSdkRefCount()` / `getMsgAuditSdkRefCount()`
154+
155+
### 4. WxCpDefaultConfigImpl(废弃旧字段)
156+
157+
-`msgAuditSdk``msgAuditSdkExpiresTime``msgAuditSdkRefCount` 字段标记 `@Deprecated`
158+
- 对应的 getter/setter/acquire/release 方法标记 `@Deprecated`
159+
- 保留实现,确保向后兼容
160+
161+
---
162+
163+
## 使用示例(更新文档)
164+
165+
```java
166+
// ✅ 典型用法(一次任务中串行调用,SDK在同线程内复用,无重复初始化)
167+
WxCpMsgAuditService msgAuditService = wxCpService.getMsgAuditService();
168+
169+
try {
170+
List<WxCpChatDatas.WxCpChatData> records = msgAuditService.getChatRecords(seq, 100L, null, null, 30L);
171+
for (WxCpChatDatas.WxCpChatData record : records) {
172+
WxCpChatModel model = msgAuditService.getDecryptChatData(record, 2);
173+
if ("image".equals(model.getMsgType())) {
174+
msgAuditService.downloadMediaFile(model.getImage().getSdkFileId(), null, null, 30L, "/tmp/img.jpg");
175+
}
176+
}
177+
} finally {
178+
// 无论线程池还是独立线程,均建议在 finally 中显式调用。
179+
// Finance.DestroySdk() 不会随线程结束自动执行,依赖 closeAllSdks() 兜底会造成
180+
// native 内存/连接资源的延迟泄漏,对定时任务等长期运行场景尤其有害。
181+
msgAuditService.closeThreadLocalSdk();
182+
}
183+
184+
// 应用关闭时(Spring @PreDestroy 或 Shutdown Hook)
185+
// msgAuditService.closeAllSdks();
186+
```
187+
188+
---
189+
190+
## 注意事项
191+
192+
1. **线程池场景下必须调用 `closeThreadLocalSdk()`**:线程池中线程会被复用,如不主动清理,下次任务仍会使用旧线程的SDK。对于计划任务/批处理,建议在 finally 块中调用。
193+
2. **独立线程同样建议显式关闭**`Finance.DestroySdk()` 是 native 调用,不会随线程结束自动执行,JVM GC 也不会触发它。依赖 `closeAllSdks()` 兜底意味着 native 内存、网络连接等资源在整个应用运行期间一直持有,对定时任务等高频场景会持续积累,建议统一在 finally 块中调用 `closeThreadLocalSdk()`
194+
3. **多企业(多CorpId)场景**`threadLocalSdk` 是实例字段(非static),不同 `WxCpMsgAuditServiceImpl` 实例(不同企业)的ThreadLocal独立,互不影响。
195+
4. **库加载幂等性**`Finance.loadingLibraries()` 底层调用 `System.load()`,JVM保证同一库不重复加载,多线程并发调用安全。
196+
197+
---
198+
199+
## 验证方式
200+
201+
1. **单元测试**:在 `WxCpMsgAuditTest` 中添加测试,验证同线程多次调用不触发重新初始化(可通过日志或mock Finance验证)
202+
2. **多线程压测**:多线程并发调用 `getChatRecords` + `getDecryptChatData`,观察无JVM崩溃
203+
3. **线程池复用测试**:使用固定线程池多次提交任务,验证 `closeThreadLocalSdk()` 后下次任务能正确重新初始化SDK
204+
4. **应用关闭测试**:调用 `closeAllSdks()`,验证所有线程的SDK被正确销毁

pom.xml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@
138138
<httpclient.version>4.5.13</httpclient.version>
139139
<httpclient5.version>5.5.2</httpclient5.version>
140140
<jetty.version>9.4.57.v20241219</jetty.version> <!-- 这个不能用10以上的版本,不支持jdk8-->
141+
<bouncycastle.version>1.84</bouncycastle.version>
141142
</properties>
142143
<dependencyManagement>
143144
<dependencies>
@@ -335,7 +336,12 @@
335336
<dependency>
336337
<groupId>org.bouncycastle</groupId>
337338
<artifactId>bcpkix-jdk18on</artifactId>
338-
<version>1.80</version>
339+
<version>${bouncycastle.version}</version>
340+
</dependency>
341+
<dependency>
342+
<groupId>org.bouncycastle</groupId>
343+
<artifactId>bcprov-jdk18on</artifactId>
344+
<version>${bouncycastle.version}</version>
339345
</dependency>
340346
</dependencies>
341347
</dependencyManagement>

solon-plugins/wx-java-pay-solon-plugin/src/main/java/com/binarywang/solon/wxjava/pay/config/WxPayAutoConfiguration.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ public WxPayService wxPayService() {
5555
payConfig.setPrivateKeyPath(StringUtils.trimToNull(this.properties.getPrivateKeyPath()));
5656
payConfig.setPrivateCertPath(StringUtils.trimToNull(this.properties.getPrivateCertPath()));
5757
payConfig.setCertSerialNo(StringUtils.trimToNull(this.properties.getCertSerialNo()));
58-
payConfig.setApiV3Key(StringUtils.trimToNull(this.properties.getApiv3Key()));
58+
payConfig.setApiV3Key(StringUtils.trimToNull(this.properties.getApiV3Key()));
5959
payConfig.setPublicKeyId(StringUtils.trimToNull(this.properties.getPublicKeyId()));
6060
payConfig.setPublicKeyPath(StringUtils.trimToNull(this.properties.getPublicKeyPath()));
6161
payConfig.setApiHostUrl(StringUtils.trimToNull(this.properties.getApiHostUrl()));

solon-plugins/wx-java-pay-solon-plugin/src/main/java/com/binarywang/solon/wxjava/pay/properties/WxPayProperties.java

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ public class WxPayProperties {
5959
/**
6060
* apiV3秘钥
6161
*/
62-
private String apiv3Key;
62+
private String apiV3Key;
6363

6464
/**
6565
* 微信支付分回调地址
@@ -114,13 +114,13 @@ public class WxPayProperties {
114114
private String apiHostUrl;
115115

116116
/**
117-
* 是否将全部v3接口的请求都添加Wechatpay-Serial请求头,默认不添加
117+
* 是否将全部v3接口的请求都添加Wechatpay-Serial请求头,默认添加
118118
*/
119-
private boolean strictlyNeedWechatPaySerial = false;
119+
private boolean strictlyNeedWechatPaySerial = true;
120120

121121
/**
122-
* 是否完全使用公钥模式(用以微信从平台证书到公钥的灰度切换),默认不使用
122+
* 是否完全使用公钥模式(用以微信从平台证书到公钥的灰度切换),默认使用
123123
*/
124-
private boolean fullPublicKeyModel = false;
124+
private boolean fullPublicKeyModel = true;
125125

126126
}

spring-boot-starters/wx-java-pay-multi-spring-boot-starter/README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ wx:
104104
# 公众号1配置
105105
wx.pay.configs.wx1234567890abcdef.app-id=wx1234567890abcdef
106106
wx.pay.configs.wx1234567890abcdef.mch-id=1234567890
107-
wx.pay.configs.wx1234567890abcdef.apiv3-key=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
107+
wx.pay.configs.wx1234567890abcdef.api-v3-key=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
108108
wx.pay.configs.wx1234567890abcdef.cert-serial-no=62C6CEAA360BCxxxxxxxxxxxxxxx
109109
wx.pay.configs.wx1234567890abcdef.private-key-path=classpath:cert/app1/apiclient_key.pem
110110
wx.pay.configs.wx1234567890abcdef.private-cert-path=classpath:cert/app1/apiclient_cert.pem
@@ -113,7 +113,7 @@ wx.pay.configs.wx1234567890abcdef.notify-url=https://example.com/pay/notify
113113
# 公众号2配置
114114
wx.pay.configs.wx9876543210fedcba.app-id=wx9876543210fedcba
115115
wx.pay.configs.wx9876543210fedcba.mch-id=9876543210
116-
wx.pay.configs.wx9876543210fedcba.apiv3-key=yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
116+
wx.pay.configs.wx9876543210fedcba.api-v3-key=yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
117117
wx.pay.configs.wx9876543210fedcba.cert-serial-no=73D7DFBB471CDxxxxxxxxxxxxxxx
118118
wx.pay.configs.wx9876543210fedcba.private-key-path=classpath:cert/app2/apiclient_key.pem
119119
wx.pay.configs.wx9876543210fedcba.private-cert-path=classpath:cert/app2/apiclient_cert.pem
@@ -255,8 +255,8 @@ public class PayService {
255255
| payScorePermissionNotifyUrl | 支付分授权回调地址 ||
256256
| useSandboxEnv | 是否使用沙箱环境 | false |
257257
| apiHostUrl | 自定义API主机地址 | https://api.mch.weixin.qq.com |
258-
| strictlyNeedWechatPaySerial | 是否所有V3请求都添加序列号头 | false |
259-
| fullPublicKeyModel | 是否完全使用公钥模式 | false |
258+
| strictlyNeedWechatPaySerial | 是否所有V3请求都添加序列号头 | true |
259+
| fullPublicKeyModel | 是否完全使用公钥模式 | true |
260260
| publicKeyId | 公钥ID ||
261261
| publicKeyPath | 公钥文件路径 ||
262262

@@ -312,5 +312,5 @@ wx:
312312
## 更多信息
313313

314314
- [WxJava 项目首页](https://github.com/Wechat-Group/WxJava)
315-
- [微信支付官方文档](https://pay.weixin.qq.com/wiki/doc/api/)
316-
- [微信支付V3接口文档](https://pay.weixin.qq.com/wiki/doc/apiv3/index.shtml)
315+
- [微信支付V2文档](https://pay.weixin.qq.com/doc/v2)
316+
- [微信支付V3接口文档](https://pay.weixin.qq.com/doc/v3/merchant/4012062524)

spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/properties/WxPaySingleProperties.java

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ public class WxPaySingleProperties implements Serializable {
5858
/**
5959
* apiV3秘钥.
6060
*/
61-
private String apiv3Key;
61+
private String apiV3Key;
6262

6363
/**
6464
* 微信支付异步回调地址,通知url必须为直接可访问的url,不能携带参数.
@@ -113,12 +113,12 @@ public class WxPaySingleProperties implements Serializable {
113113
private String apiHostUrl;
114114

115115
/**
116-
* 是否将全部v3接口的请求都添加Wechatpay-Serial请求头,默认不添加.
116+
* 是否将全部v3接口的请求都添加Wechatpay-Serial请求头,默认添加.
117117
*/
118-
private boolean strictlyNeedWechatPaySerial = false;
118+
private boolean strictlyNeedWechatPaySerial = true;
119119

120120
/**
121-
* 是否完全使用公钥模式(用以微信从平台证书到公钥的灰度切换),默认不使用.
121+
* 是否完全使用公钥模式(用以微信从平台证书到公钥的灰度切换),默认使用.
122122
*/
123-
private boolean fullPublicKeyModel = false;
123+
private boolean fullPublicKeyModel = true;
124124
}

spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/main/java/com/binarywang/spring/starter/wxjava/pay/service/WxPayMultiServicesImpl.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ private WxPayService buildWxPayService(WxPaySingleProperties properties) {
7979
payConfig.setPrivateKeyPath(StringUtils.trimToNull(properties.getPrivateKeyPath()));
8080
payConfig.setPrivateCertPath(StringUtils.trimToNull(properties.getPrivateCertPath()));
8181
payConfig.setCertSerialNo(StringUtils.trimToNull(properties.getCertSerialNo()));
82-
payConfig.setApiV3Key(StringUtils.trimToNull(properties.getApiv3Key()));
82+
payConfig.setApiV3Key(StringUtils.trimToNull(properties.getApiV3Key()));
8383
payConfig.setPublicKeyId(StringUtils.trimToNull(properties.getPublicKeyId()));
8484
payConfig.setPublicKeyPath(StringUtils.trimToNull(properties.getPublicKeyPath()));
8585
payConfig.setApiHostUrl(StringUtils.trimToNull(properties.getApiHostUrl()));

spring-boot-starters/wx-java-pay-multi-spring-boot-starter/src/test/java/com/binarywang/spring/starter/wxjava/pay/WxPayMultiServicesTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ public void testConfiguration() {
5757
assertNotNull(app2Config, "app2 configuration should exist");
5858
assertEquals("wx2222222222222222", app2Config.getAppId());
5959
assertEquals("2222222222", app2Config.getMchId());
60-
assertEquals("22222222222222222222222222222222", app2Config.getApiv3Key());
60+
assertEquals("22222222222222222222222222222222", app2Config.getApiV3Key());
6161
}
6262

6363
@Test

0 commit comments

Comments
 (0)