Skip to content

Commit fab9862

Browse files
authored
🎨 #3934【企业微信】修复会话存档 SDK 每次 API 调用后被销毁并重新初始化的问题
1 parent 45d529c commit fab9862

7 files changed

Lines changed: 260 additions & 24 deletions

File tree

docs/CP_MSG_AUDIT_SDK_SAFE_USAGE.md

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -196,33 +196,36 @@ msgAuditService.downloadMediaFile(sdkFileId, null, null, 1000L, data -> {
196196

197197
1. **获取SDK时**:引用计数 +1
198198
2. **使用完成后**:引用计数 -1
199-
3. **计数归零时**:SDK被自动释放
199+
3. **计数归零且SDK已过期时**:SDK被销毁并清理缓存
200+
4. **计数归零但SDK未过期时**:保留缓存,供后续调用直接复用
201+
202+
> **注意**:引用计数归零并不等同于立即销毁SDK。只有在 SDK 已超过有效期的情况下,框架才会调用 `Finance.DestroySdk()` 释放资源。这一机制避免了每次 API 调用后的频繁初始化/销毁循环。
200203
201204
```java
202205
// 框架内部实现(简化版)
203206
public void downloadMediaFile(String sdkFileId, ...) {
204-
long sdk = initSdk(); // 获取或初始化SDK
207+
long sdk = initSdk(); // 获取或初始化SDK(有效期内直接复用缓存)
205208
configStorage.incrementMsgAuditSdkRefCount(sdk); // 引用计数 +1
206-
209+
207210
try {
208211
// 执行实际操作
209212
getMediaFile(sdk, sdkFileId, ...);
210213
} finally {
211-
// 确保引用计数一定会减少
214+
// 确保引用计数一定会减少;仅在归零且过期时销毁
212215
configStorage.decrementMsgAuditSdkRefCount(sdk); // 引用计数 -1
213216
}
214217
}
215218
```
216219

217220
### SDK缓存机制
218221

219-
SDK初始化后会缓存7200秒(企业微信官方文档规定),避免频繁初始化:
222+
SDK初始化后会缓存7200秒,避免频繁初始化:
220223

221224
- **首次调用**:初始化新的SDK
222-
- **7200秒内**:复用缓存的SDK
223-
- **超过7200秒**重新初始化SDK
225+
- **7200秒内**:复用缓存的SDK(即使引用计数曾归零也不重新初始化)
226+
- **超过7200秒**下次 `acquireMsgAuditSdk()` 返回0,触发重新初始化,旧SDK在重新初始化时被销毁
224227

225-
新API的引用计数机制与缓存机制完美配合,确保SDK不会被提前销毁。
228+
新API的引用计数机制与缓存机制完美配合,确保SDK不会被提前销毁,也不会永久残留
226229

227230
## 迁移指南
228231

docs/CP_MSG_AUDIT_THREADLOCAL_LIFECYCLE_REFACTOR.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
当前实现(4.8.x)通过"共享SDK + 引用计数 + 7200秒过期"来管理会话存档SDK生命周期。
66
该方案存在以下核心问题:
77

8-
1. **频繁初始化/销毁**:每次调用 `releaseSdk()` 后引用计数归零即销毁SDK。对于"拉取→解密→下载媒体"这类典型串行调用链,每步操作都会触发重新初始化。
8+
1. ~~**频繁初始化/销毁**:每次调用 `releaseSdk()` 后引用计数归零即销毁SDK。对于"拉取→解密→下载媒体"这类典型串行调用链,每步操作都会触发重新初始化~~ ✅ 已在 4.8.3.B+ 修复:引用计数归零时,仅在 SDK 已过期的情况下才销毁,有效期内继续复用缓存
99
2. **7200秒过期规则无依据**:官方文档FAQ明确说"不需要每次new/init sdk,可以在多次拉取中复用同一个sdk",无任何7200秒过期说明。
1010
3. **线程安全问题**:企微技术人员建议"一个线程一个SDK实例",当前设计多线程共享同一SDK实例,存在并发安全隐患。
1111

weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/WxCpConfigStorage.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -392,10 +392,11 @@ public interface WxCpConfigStorage {
392392

393393
/**
394394
* 减少会话存档SDK的引用计数
395-
* 当引用计数降为0时,自动销毁SDK以释放资源
395+
* 当引用计数降为0且SDK已过期时,才自动销毁SDK以释放资源
396+
* 如果SDK尚未过期,保留SDK缓存以供后续调用复用
396397
*
397398
* @param sdk sdk id
398-
* @return 减少后的引用计数,如果返回0表示SDK已被销毁,如果SDK不匹配返回-1
399+
* @return 减少后的引用计数;SDK不匹配或引用计数已为0时返回-1
399400
* @deprecated 引用计数机制已废弃,由 ThreadLocal 模式替代。
400401
*/
401402
@Deprecated
@@ -424,7 +425,8 @@ public interface WxCpConfigStorage {
424425

425426
/**
426427
* 减少SDK引用计数并在必要时释放(原子操作)
427-
* 此方法确保引用计数递减和SDK检查在同一个同步块内完成
428+
* 当引用计数降为0且SDK已过期时,才销毁SDK以释放资源
429+
* 如果SDK尚未过期,保留SDK缓存以供后续调用复用,避免频繁初始化和销毁
428430
*
429431
* @param sdk sdk id
430432
* @deprecated 引用计数机制已废弃,由 ThreadLocal 模式替代。

weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/impl/WxCpDefaultConfigImpl.java

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -609,9 +609,9 @@ public synchronized int incrementMsgAuditSdkRefCount(long sdk) {
609609
public synchronized int decrementMsgAuditSdkRefCount(long sdk) {
610610
if (this.msgAuditSdk == sdk && this.msgAuditSdkRefCount > 0) {
611611
int newCount = --this.msgAuditSdkRefCount;
612-
// 当引用计数降为0时,自动销毁SDK以释放资源
613-
// 再次检查SDK是否仍然是当前缓存的SDK(防止并发重新初始化)
614-
if (newCount == 0 && this.msgAuditSdk == sdk) {
612+
// 当引用计数降为0且SDK已过期时,才销毁SDK以释放资源
613+
// 如果SDK尚未过期,保留SDK缓存以供后续调用复用,避免频繁初始化和销毁
614+
if (newCount == 0 && this.msgAuditSdk == sdk && isMsgAuditSdkExpired()) {
615615
Finance.DestroySdk(sdk);
616616
this.msgAuditSdk = 0;
617617
this.msgAuditSdkExpiresTime = 0;
@@ -646,9 +646,9 @@ public synchronized long acquireMsgAuditSdk() {
646646
public synchronized void releaseMsgAuditSdk(long sdk) {
647647
if (this.msgAuditSdk == sdk && this.msgAuditSdkRefCount > 0) {
648648
int newCount = --this.msgAuditSdkRefCount;
649-
// 当引用计数降为0时,自动销毁SDK以释放资源
650-
// 再次检查SDK是否仍然是当前缓存的SDK(防止并发重新初始化)
651-
if (newCount == 0 && this.msgAuditSdk == sdk) {
649+
// 当引用计数降为0且SDK已过期时,才销毁SDK以释放资源
650+
// 如果SDK尚未过期,保留SDK缓存以供后续调用复用,避免频繁初始化和销毁
651+
if (newCount == 0 && this.msgAuditSdk == sdk && isMsgAuditSdkExpired()) {
652652
Finance.DestroySdk(sdk);
653653
this.msgAuditSdk = 0;
654654
this.msgAuditSdkExpiresTime = 0;

weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/impl/WxCpRedisConfigImpl.java

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -589,9 +589,9 @@ public synchronized int incrementMsgAuditSdkRefCount(long sdk) {
589589
public synchronized int decrementMsgAuditSdkRefCount(long sdk) {
590590
if (this.msgAuditSdk == sdk && this.msgAuditSdkRefCount > 0) {
591591
int newCount = --this.msgAuditSdkRefCount;
592-
// 当引用计数降为0时,自动销毁SDK以释放资源
593-
// 再次检查SDK是否仍然是当前缓存的SDK(防止并发重新初始化)
594-
if (newCount == 0 && this.msgAuditSdk == sdk) {
592+
// 当引用计数降为0且SDK已过期时,才销毁SDK以释放资源
593+
// 如果SDK尚未过期,保留SDK缓存以供后续调用复用,避免频繁初始化和销毁
594+
if (newCount == 0 && this.msgAuditSdk == sdk && isMsgAuditSdkExpired()) {
595595
Finance.DestroySdk(sdk);
596596
this.msgAuditSdk = 0;
597597
this.msgAuditSdkExpiresTime = 0;
@@ -623,9 +623,9 @@ public synchronized long acquireMsgAuditSdk() {
623623
public synchronized void releaseMsgAuditSdk(long sdk) {
624624
if (this.msgAuditSdk == sdk && this.msgAuditSdkRefCount > 0) {
625625
int newCount = --this.msgAuditSdkRefCount;
626-
// 当引用计数降为0时,自动销毁SDK以释放资源
627-
// 再次检查SDK是否仍然是当前缓存的SDK(防止并发重新初始化)
628-
if (newCount == 0 && this.msgAuditSdk == sdk) {
626+
// 当引用计数降为0且SDK已过期时,才销毁SDK以释放资源
627+
// 如果SDK尚未过期,保留SDK缓存以供后续调用复用,避免频繁初始化和销毁
628+
if (newCount == 0 && this.msgAuditSdk == sdk && isMsgAuditSdkExpired()) {
629629
Finance.DestroySdk(sdk);
630630
this.msgAuditSdk = 0;
631631
this.msgAuditSdkExpiresTime = 0;
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
package me.chanjar.weixin.cp.config.impl;
2+
3+
import org.testng.Assert;
4+
import org.testng.annotations.BeforeMethod;
5+
import org.testng.annotations.Test;
6+
7+
import java.lang.reflect.Field;
8+
9+
/**
10+
* 测试 WxCpDefaultConfigImpl 中会话存档 SDK 引用计数的正确性
11+
* 验证修复:SDK 在引用计数降为 0 但尚未过期时,不应被销毁
12+
*
13+
* @author GitHub Copilot
14+
*/
15+
public class WxCpDefaultConfigImplMsgAuditSdkTest {
16+
17+
/**
18+
* 用于测试的未过期时间偏移量(毫秒),模拟 SDK 有效状态
19+
*/
20+
private static final long VALID_EXPIRATION_TIME_OFFSET = 7_000_000L;
21+
22+
private WxCpDefaultConfigImpl config;
23+
24+
@BeforeMethod
25+
public void setUp() {
26+
config = new WxCpDefaultConfigImpl();
27+
}
28+
29+
/**
30+
* 通过反射设置内部字段
31+
*/
32+
private void setField(String fieldName, Object value) throws Exception {
33+
Field field = WxCpDefaultConfigImpl.class.getDeclaredField(fieldName);
34+
field.setAccessible(true);
35+
field.set(config, value);
36+
}
37+
38+
/**
39+
* 通过反射获取内部字段值
40+
*/
41+
private Object getField(String fieldName) throws Exception {
42+
Field field = WxCpDefaultConfigImpl.class.getDeclaredField(fieldName);
43+
field.setAccessible(true);
44+
return field.get(config);
45+
}
46+
47+
/**
48+
* 验证 acquireMsgAuditSdk 在 SDK 有效时能正确返回 SDK 并增加引用计数
49+
*/
50+
@Test
51+
public void testAcquireMsgAuditSdkWhenSdkValid() throws Exception {
52+
long fakeSdk = 12345L;
53+
// 设置一个有效的(未过期的)SDK
54+
setField("msgAuditSdk", fakeSdk);
55+
setField("msgAuditSdkExpiresTime", System.currentTimeMillis() + VALID_EXPIRATION_TIME_OFFSET);
56+
setField("msgAuditSdkRefCount", 0);
57+
58+
long acquired = config.acquireMsgAuditSdk();
59+
60+
Assert.assertEquals(acquired, fakeSdk, "应返回已缓存的有效 SDK");
61+
int refCount = (int) getField("msgAuditSdkRefCount");
62+
Assert.assertEquals(refCount, 1, "引用计数应增加到 1");
63+
}
64+
65+
/**
66+
* 验证 acquireMsgAuditSdk 在 SDK 已过期时返回 0
67+
*/
68+
@Test
69+
public void testAcquireMsgAuditSdkWhenSdkExpired() throws Exception {
70+
long fakeSdk = 12345L;
71+
// 设置已过期的 SDK
72+
setField("msgAuditSdk", fakeSdk);
73+
setField("msgAuditSdkExpiresTime", System.currentTimeMillis() - 1000L);
74+
setField("msgAuditSdkRefCount", 0);
75+
76+
long acquired = config.acquireMsgAuditSdk();
77+
78+
Assert.assertEquals(acquired, 0L, "SDK 已过期,应返回 0");
79+
int refCount = (int) getField("msgAuditSdkRefCount");
80+
Assert.assertEquals(refCount, 0, "引用计数不应改变");
81+
}
82+
83+
/**
84+
* 核心测试:验证当引用计数降为 0 但 SDK 尚未过期时,SDK 不会被销毁
85+
* 这是修复 issue 的关键验证:避免每次 API 调用后频繁销毁和重新初始化 SDK
86+
*/
87+
@Test
88+
public void testReleaseMsgAuditSdkShouldNotDestroyWhenNotExpired() throws Exception {
89+
long fakeSdk = 12345L;
90+
// 设置一个有效的(未过期的)SDK,引用计数为 1
91+
setField("msgAuditSdk", fakeSdk);
92+
setField("msgAuditSdkExpiresTime", System.currentTimeMillis() + VALID_EXPIRATION_TIME_OFFSET);
93+
setField("msgAuditSdkRefCount", 1);
94+
95+
// 释放引用,引用计数应降为 0,但 SDK 尚未过期,不应被销毁
96+
config.releaseMsgAuditSdk(fakeSdk);
97+
98+
long sdkAfterRelease = (long) getField("msgAuditSdk");
99+
int refCountAfterRelease = (int) getField("msgAuditSdkRefCount");
100+
101+
Assert.assertEquals(sdkAfterRelease, fakeSdk, "SDK 尚未过期,引用计数归零后不应被销毁,应继续缓存");
102+
Assert.assertEquals(refCountAfterRelease, 0, "引用计数应为 0");
103+
}
104+
105+
/**
106+
* 验证:SDK 在未过期、引用计数为 0 时,下次调用 acquireMsgAuditSdk 应直接复用,无需重新初始化
107+
* 这是修复后的核心行为:避免频繁初始化
108+
*/
109+
@Test
110+
public void testSdkReuseAfterReleaseWhenNotExpired() throws Exception {
111+
long fakeSdk = 99999L;
112+
// 模拟:SDK 有效,引用计数为 1(正在被使用)
113+
setField("msgAuditSdk", fakeSdk);
114+
setField("msgAuditSdkExpiresTime", System.currentTimeMillis() + VALID_EXPIRATION_TIME_OFFSET);
115+
setField("msgAuditSdkRefCount", 1);
116+
117+
// 模拟方法调用结束,释放引用
118+
config.releaseMsgAuditSdk(fakeSdk);
119+
120+
// 模拟下一次方法调用,应该直接复用缓存的 SDK
121+
long reacquired = config.acquireMsgAuditSdk();
122+
123+
Assert.assertEquals(reacquired, fakeSdk, "SDK 应被复用,而不是返回 0(需要重新初始化)");
124+
int refCount = (int) getField("msgAuditSdkRefCount");
125+
Assert.assertEquals(refCount, 1, "复用后引用计数应为 1");
126+
}
127+
128+
/**
129+
* 验证:多次 acquire/release 的引用计数正确性(串行验证)
130+
*/
131+
@Test
132+
public void testMultipleAcquireAndReleaseSequential() throws Exception {
133+
long fakeSdk = 77777L;
134+
setField("msgAuditSdk", fakeSdk);
135+
setField("msgAuditSdkExpiresTime", System.currentTimeMillis() + VALID_EXPIRATION_TIME_OFFSET);
136+
setField("msgAuditSdkRefCount", 0);
137+
138+
// 三次 acquire,引用计数依次递增
139+
long sdk1 = config.acquireMsgAuditSdk();
140+
long sdk2 = config.acquireMsgAuditSdk();
141+
long sdk3 = config.acquireMsgAuditSdk();
142+
143+
Assert.assertEquals(sdk1, fakeSdk);
144+
Assert.assertEquals(sdk2, fakeSdk);
145+
Assert.assertEquals(sdk3, fakeSdk);
146+
Assert.assertEquals((int) getField("msgAuditSdkRefCount"), 3, "应有 3 个引用");
147+
148+
// 逐一释放,SDK 未过期,不应被销毁
149+
config.releaseMsgAuditSdk(fakeSdk);
150+
Assert.assertEquals((int) getField("msgAuditSdkRefCount"), 2, "释放一个后应有 2 个引用");
151+
Assert.assertEquals((long) getField("msgAuditSdk"), fakeSdk, "SDK 仍有引用,不应被销毁");
152+
153+
config.releaseMsgAuditSdk(fakeSdk);
154+
Assert.assertEquals((int) getField("msgAuditSdkRefCount"), 1, "释放两个后应有 1 个引用");
155+
156+
config.releaseMsgAuditSdk(fakeSdk);
157+
Assert.assertEquals((int) getField("msgAuditSdkRefCount"), 0, "全部释放后引用计数应为 0");
158+
// SDK 未过期,不应被销毁
159+
Assert.assertEquals((long) getField("msgAuditSdk"), fakeSdk, "SDK 未过期,全部引用释放后不应被销毁");
160+
}
161+
162+
/**
163+
* 验证 incrementMsgAuditSdkRefCount 在 SDK 匹配时正确增加引用计数
164+
*/
165+
@Test
166+
public void testIncrementRefCount() throws Exception {
167+
long fakeSdk = 11111L;
168+
setField("msgAuditSdk", fakeSdk);
169+
setField("msgAuditSdkRefCount", 2);
170+
171+
int result = config.incrementMsgAuditSdkRefCount(fakeSdk);
172+
173+
Assert.assertEquals(result, 3, "引用计数应增加到 3");
174+
}
175+
176+
/**
177+
* 验证 incrementMsgAuditSdkRefCount 在 SDK 不匹配时返回 -1
178+
*/
179+
@Test
180+
public void testIncrementRefCountWithWrongSdk() throws Exception {
181+
setField("msgAuditSdk", 11111L);
182+
setField("msgAuditSdkRefCount", 2);
183+
184+
int result = config.incrementMsgAuditSdkRefCount(99999L);
185+
186+
Assert.assertEquals(result, -1, "SDK 不匹配时应返回 -1");
187+
}
188+
189+
/**
190+
* 验证 getMsgAuditSdkRefCount 的正确性
191+
*/
192+
@Test
193+
public void testGetMsgAuditSdkRefCount() throws Exception {
194+
long fakeSdk = 55555L;
195+
setField("msgAuditSdk", fakeSdk);
196+
setField("msgAuditSdkRefCount", 5);
197+
198+
int count = config.getMsgAuditSdkRefCount(fakeSdk);
199+
Assert.assertEquals(count, 5, "应返回正确的引用计数");
200+
201+
int wrongCount = config.getMsgAuditSdkRefCount(99L);
202+
Assert.assertEquals(wrongCount, -1, "SDK 不匹配时应返回 -1");
203+
}
204+
205+
/**
206+
* 验证:引用计数归零且 SDK 已过期时,releaseMsgAuditSdk 应尝试销毁 SDK
207+
* 由于 Finance.DestroySdk 是原生方法,测试环境中不加载原生库时会抛出 UnsatisfiedLinkError,
208+
* 但引用计数已在 Finance 调用前递减(可验证代码路径已进入销毁分支)。
209+
* 当原生库可用时,应进一步断言 msgAuditSdk 和 msgAuditSdkExpiresTime 均被清零。
210+
*/
211+
@Test
212+
public void testReleaseMsgAuditSdkShouldDestroyWhenExpired() throws Exception {
213+
long fakeSdk = 22222L;
214+
// 设置已过期的 SDK,引用计数为 1
215+
setField("msgAuditSdk", fakeSdk);
216+
setField("msgAuditSdkExpiresTime", System.currentTimeMillis() - 1000L); // 已过期
217+
setField("msgAuditSdkRefCount", 1);
218+
219+
try {
220+
config.releaseMsgAuditSdk(fakeSdk);
221+
// 原生库可用:断言字段已清零
222+
Assert.assertEquals((long) getField("msgAuditSdk"), 0L, "过期且引用归零后 msgAuditSdk 应被清零");
223+
Assert.assertEquals((long) getField("msgAuditSdkExpiresTime"), 0L, "过期时间应被清零");
224+
} catch (UnsatisfiedLinkError e) {
225+
// 测试环境未加载原生库:Finance.DestroySdk 被调用但抛出 UnsatisfiedLinkError
226+
// 这证明代码路径正确进入了"过期时销毁 SDK"的分支,与"未过期时跳过销毁"的分支形成对比
227+
Assert.assertEquals((int) getField("msgAuditSdkRefCount"), 0, "引用计数应已递减到 0(Finance 调用前完成)");
228+
}
229+
}
230+
}

weixin-java-cp/src/test/resources/testng.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
<class name="me.chanjar.weixin.cp.api.WxCpMessageRouterTest"/>
99
<class name="me.chanjar.weixin.cp.tp.service.impl.WxCpTpServiceApacheHttpClientImplTest"/>
1010
<class name="me.chanjar.weixin.cp.config.impl.WxCpTpDefaultConfigImplTest"/>
11+
<class name="me.chanjar.weixin.cp.config.impl.WxCpDefaultConfigImplMsgAuditSdkTest"/>
1112
<class name="me.chanjar.weixin.cp.tp.service.impl.WxCpTpTagServiceImplTest"/>
1213
<class name="me.chanjar.weixin.cp.tp.service.impl.WxCpTpMessageServiceImplTest"/>
1314
<class name="me.chanjar.weixin.cp.api.impl.WxCpHrServiceImplTest"/>

0 commit comments

Comments
 (0)