diff --git a/docs/CP_MSG_AUDIT_SDK_SAFE_USAGE.md b/docs/CP_MSG_AUDIT_SDK_SAFE_USAGE.md
index b64e4612b9..83d9c08158 100644
--- a/docs/CP_MSG_AUDIT_SDK_SAFE_USAGE.md
+++ b/docs/CP_MSG_AUDIT_SDK_SAFE_USAGE.md
@@ -196,19 +196,22 @@ msgAuditService.downloadMediaFile(sdkFileId, null, null, 1000L, data -> {
1. **获取SDK时**:引用计数 +1
2. **使用完成后**:引用计数 -1
-3. **计数归零时**:SDK被自动释放
+3. **计数归零且SDK已过期时**:SDK被销毁并清理缓存
+4. **计数归零但SDK未过期时**:保留缓存,供后续调用直接复用
+
+> **注意**:引用计数归零并不等同于立即销毁SDK。只有在 SDK 已超过有效期的情况下,框架才会调用 `Finance.DestroySdk()` 释放资源。这一机制避免了每次 API 调用后的频繁初始化/销毁循环。
```java
// 框架内部实现(简化版)
public void downloadMediaFile(String sdkFileId, ...) {
- long sdk = initSdk(); // 获取或初始化SDK
+ long sdk = initSdk(); // 获取或初始化SDK(有效期内直接复用缓存)
configStorage.incrementMsgAuditSdkRefCount(sdk); // 引用计数 +1
-
+
try {
// 执行实际操作
getMediaFile(sdk, sdkFileId, ...);
} finally {
- // 确保引用计数一定会减少
+ // 确保引用计数一定会减少;仅在归零且过期时销毁
configStorage.decrementMsgAuditSdkRefCount(sdk); // 引用计数 -1
}
}
@@ -216,13 +219,13 @@ public void downloadMediaFile(String sdkFileId, ...) {
### SDK缓存机制
-SDK初始化后会缓存7200秒(企业微信官方文档规定),避免频繁初始化:
+SDK初始化后会缓存7200秒,避免频繁初始化:
- **首次调用**:初始化新的SDK
-- **7200秒内**:复用缓存的SDK
-- **超过7200秒**:重新初始化SDK
+- **7200秒内**:复用缓存的SDK(即使引用计数曾归零也不重新初始化)
+- **超过7200秒**:下次 `acquireMsgAuditSdk()` 返回0,触发重新初始化,旧SDK在重新初始化时被销毁
-新API的引用计数机制与缓存机制完美配合,确保SDK不会被提前销毁。
+新API的引用计数机制与缓存机制完美配合,确保SDK不会被提前销毁,也不会永久残留。
## 迁移指南
diff --git a/docs/CP_MSG_AUDIT_THREADLOCAL_LIFECYCLE_REFACTOR.md b/docs/CP_MSG_AUDIT_THREADLOCAL_LIFECYCLE_REFACTOR.md
index 072ceefd0c..1f073905f4 100644
--- a/docs/CP_MSG_AUDIT_THREADLOCAL_LIFECYCLE_REFACTOR.md
+++ b/docs/CP_MSG_AUDIT_THREADLOCAL_LIFECYCLE_REFACTOR.md
@@ -5,7 +5,7 @@
当前实现(4.8.x)通过"共享SDK + 引用计数 + 7200秒过期"来管理会话存档SDK生命周期。
该方案存在以下核心问题:
-1. **频繁初始化/销毁**:每次调用 `releaseSdk()` 后引用计数归零即销毁SDK。对于"拉取→解密→下载媒体"这类典型串行调用链,每步操作都会触发重新初始化。
+1. ~~**频繁初始化/销毁**:每次调用 `releaseSdk()` 后引用计数归零即销毁SDK。对于"拉取→解密→下载媒体"这类典型串行调用链,每步操作都会触发重新初始化。~~ ✅ 已在 4.8.3.B+ 修复:引用计数归零时,仅在 SDK 已过期的情况下才销毁,有效期内继续复用缓存。
2. **7200秒过期规则无依据**:官方文档FAQ明确说"不需要每次new/init sdk,可以在多次拉取中复用同一个sdk",无任何7200秒过期说明。
3. **线程安全问题**:企微技术人员建议"一个线程一个SDK实例",当前设计多线程共享同一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 fe6acf12d3..e66e6ee21d 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
@@ -351,10 +351,11 @@ public interface WxCpConfigStorage {
/**
* 减少会话存档SDK的引用计数
- * 当引用计数降为0时,自动销毁SDK以释放资源
+ * 当引用计数降为0且SDK已过期时,才自动销毁SDK以释放资源
+ * 如果SDK尚未过期,保留SDK缓存以供后续调用复用
*
* @param sdk sdk id
- * @return 减少后的引用计数,如果返回0表示SDK已被销毁,如果SDK不匹配返回-1
+ * @return 减少后的引用计数;SDK不匹配或引用计数已为0时返回-1
* @deprecated 引用计数机制已废弃,由 ThreadLocal 模式替代。
*/
@Deprecated
@@ -383,7 +384,8 @@ public interface WxCpConfigStorage {
/**
* 减少SDK引用计数并在必要时释放(原子操作)
- * 此方法确保引用计数递减和SDK检查在同一个同步块内完成
+ * 当引用计数降为0且SDK已过期时,才销毁SDK以释放资源
+ * 如果SDK尚未过期,保留SDK缓存以供后续调用复用,避免频繁初始化和销毁
*
* @param sdk sdk id
* @deprecated 引用计数机制已废弃,由 ThreadLocal 模式替代。
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 c7b300ba48..351d6f666a 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
@@ -556,9 +556,9 @@ public synchronized int incrementMsgAuditSdkRefCount(long sdk) {
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) {
+ // 当引用计数降为0且SDK已过期时,才销毁SDK以释放资源
+ // 如果SDK尚未过期,保留SDK缓存以供后续调用复用,避免频繁初始化和销毁
+ if (newCount == 0 && this.msgAuditSdk == sdk && isMsgAuditSdkExpired()) {
Finance.DestroySdk(sdk);
this.msgAuditSdk = 0;
this.msgAuditSdkExpiresTime = 0;
@@ -593,9 +593,9 @@ public synchronized long acquireMsgAuditSdk() {
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) {
+ // 当引用计数降为0且SDK已过期时,才销毁SDK以释放资源
+ // 如果SDK尚未过期,保留SDK缓存以供后续调用复用,避免频繁初始化和销毁
+ if (newCount == 0 && this.msgAuditSdk == sdk && isMsgAuditSdkExpired()) {
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 2ba71fffb6..a478f40684 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
@@ -559,9 +559,9 @@ public synchronized int incrementMsgAuditSdkRefCount(long sdk) {
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) {
+ // 当引用计数降为0且SDK已过期时,才销毁SDK以释放资源
+ // 如果SDK尚未过期,保留SDK缓存以供后续调用复用,避免频繁初始化和销毁
+ if (newCount == 0 && this.msgAuditSdk == sdk && isMsgAuditSdkExpired()) {
Finance.DestroySdk(sdk);
this.msgAuditSdk = 0;
this.msgAuditSdkExpiresTime = 0;
@@ -593,9 +593,9 @@ public synchronized long acquireMsgAuditSdk() {
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) {
+ // 当引用计数降为0且SDK已过期时,才销毁SDK以释放资源
+ // 如果SDK尚未过期,保留SDK缓存以供后续调用复用,避免频繁初始化和销毁
+ if (newCount == 0 && this.msgAuditSdk == sdk && isMsgAuditSdkExpired()) {
Finance.DestroySdk(sdk);
this.msgAuditSdk = 0;
this.msgAuditSdkExpiresTime = 0;
diff --git a/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/config/impl/WxCpDefaultConfigImplMsgAuditSdkTest.java b/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/config/impl/WxCpDefaultConfigImplMsgAuditSdkTest.java
new file mode 100644
index 0000000000..f006ce0a70
--- /dev/null
+++ b/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/config/impl/WxCpDefaultConfigImplMsgAuditSdkTest.java
@@ -0,0 +1,230 @@
+package me.chanjar.weixin.cp.config.impl;
+
+import org.testng.Assert;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import java.lang.reflect.Field;
+
+/**
+ * 测试 WxCpDefaultConfigImpl 中会话存档 SDK 引用计数的正确性
+ * 验证修复:SDK 在引用计数降为 0 但尚未过期时,不应被销毁
+ *
+ * @author GitHub Copilot
+ */
+public class WxCpDefaultConfigImplMsgAuditSdkTest {
+
+ /**
+ * 用于测试的未过期时间偏移量(毫秒),模拟 SDK 有效状态
+ */
+ private static final long VALID_EXPIRATION_TIME_OFFSET = 7_000_000L;
+
+ private WxCpDefaultConfigImpl config;
+
+ @BeforeMethod
+ public void setUp() {
+ config = new WxCpDefaultConfigImpl();
+ }
+
+ /**
+ * 通过反射设置内部字段
+ */
+ private void setField(String fieldName, Object value) throws Exception {
+ Field field = WxCpDefaultConfigImpl.class.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ field.set(config, value);
+ }
+
+ /**
+ * 通过反射获取内部字段值
+ */
+ private Object getField(String fieldName) throws Exception {
+ Field field = WxCpDefaultConfigImpl.class.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ return field.get(config);
+ }
+
+ /**
+ * 验证 acquireMsgAuditSdk 在 SDK 有效时能正确返回 SDK 并增加引用计数
+ */
+ @Test
+ public void testAcquireMsgAuditSdkWhenSdkValid() throws Exception {
+ long fakeSdk = 12345L;
+ // 设置一个有效的(未过期的)SDK
+ setField("msgAuditSdk", fakeSdk);
+ setField("msgAuditSdkExpiresTime", System.currentTimeMillis() + VALID_EXPIRATION_TIME_OFFSET);
+ setField("msgAuditSdkRefCount", 0);
+
+ long acquired = config.acquireMsgAuditSdk();
+
+ Assert.assertEquals(acquired, fakeSdk, "应返回已缓存的有效 SDK");
+ int refCount = (int) getField("msgAuditSdkRefCount");
+ Assert.assertEquals(refCount, 1, "引用计数应增加到 1");
+ }
+
+ /**
+ * 验证 acquireMsgAuditSdk 在 SDK 已过期时返回 0
+ */
+ @Test
+ public void testAcquireMsgAuditSdkWhenSdkExpired() throws Exception {
+ long fakeSdk = 12345L;
+ // 设置已过期的 SDK
+ setField("msgAuditSdk", fakeSdk);
+ setField("msgAuditSdkExpiresTime", System.currentTimeMillis() - 1000L);
+ setField("msgAuditSdkRefCount", 0);
+
+ long acquired = config.acquireMsgAuditSdk();
+
+ Assert.assertEquals(acquired, 0L, "SDK 已过期,应返回 0");
+ int refCount = (int) getField("msgAuditSdkRefCount");
+ Assert.assertEquals(refCount, 0, "引用计数不应改变");
+ }
+
+ /**
+ * 核心测试:验证当引用计数降为 0 但 SDK 尚未过期时,SDK 不会被销毁
+ * 这是修复 issue 的关键验证:避免每次 API 调用后频繁销毁和重新初始化 SDK
+ */
+ @Test
+ public void testReleaseMsgAuditSdkShouldNotDestroyWhenNotExpired() throws Exception {
+ long fakeSdk = 12345L;
+ // 设置一个有效的(未过期的)SDK,引用计数为 1
+ setField("msgAuditSdk", fakeSdk);
+ setField("msgAuditSdkExpiresTime", System.currentTimeMillis() + VALID_EXPIRATION_TIME_OFFSET);
+ setField("msgAuditSdkRefCount", 1);
+
+ // 释放引用,引用计数应降为 0,但 SDK 尚未过期,不应被销毁
+ config.releaseMsgAuditSdk(fakeSdk);
+
+ long sdkAfterRelease = (long) getField("msgAuditSdk");
+ int refCountAfterRelease = (int) getField("msgAuditSdkRefCount");
+
+ Assert.assertEquals(sdkAfterRelease, fakeSdk, "SDK 尚未过期,引用计数归零后不应被销毁,应继续缓存");
+ Assert.assertEquals(refCountAfterRelease, 0, "引用计数应为 0");
+ }
+
+ /**
+ * 验证:SDK 在未过期、引用计数为 0 时,下次调用 acquireMsgAuditSdk 应直接复用,无需重新初始化
+ * 这是修复后的核心行为:避免频繁初始化
+ */
+ @Test
+ public void testSdkReuseAfterReleaseWhenNotExpired() throws Exception {
+ long fakeSdk = 99999L;
+ // 模拟:SDK 有效,引用计数为 1(正在被使用)
+ setField("msgAuditSdk", fakeSdk);
+ setField("msgAuditSdkExpiresTime", System.currentTimeMillis() + VALID_EXPIRATION_TIME_OFFSET);
+ setField("msgAuditSdkRefCount", 1);
+
+ // 模拟方法调用结束,释放引用
+ config.releaseMsgAuditSdk(fakeSdk);
+
+ // 模拟下一次方法调用,应该直接复用缓存的 SDK
+ long reacquired = config.acquireMsgAuditSdk();
+
+ Assert.assertEquals(reacquired, fakeSdk, "SDK 应被复用,而不是返回 0(需要重新初始化)");
+ int refCount = (int) getField("msgAuditSdkRefCount");
+ Assert.assertEquals(refCount, 1, "复用后引用计数应为 1");
+ }
+
+ /**
+ * 验证:多次 acquire/release 的引用计数正确性(串行验证)
+ */
+ @Test
+ public void testMultipleAcquireAndReleaseSequential() throws Exception {
+ long fakeSdk = 77777L;
+ setField("msgAuditSdk", fakeSdk);
+ setField("msgAuditSdkExpiresTime", System.currentTimeMillis() + VALID_EXPIRATION_TIME_OFFSET);
+ setField("msgAuditSdkRefCount", 0);
+
+ // 三次 acquire,引用计数依次递增
+ long sdk1 = config.acquireMsgAuditSdk();
+ long sdk2 = config.acquireMsgAuditSdk();
+ long sdk3 = config.acquireMsgAuditSdk();
+
+ Assert.assertEquals(sdk1, fakeSdk);
+ Assert.assertEquals(sdk2, fakeSdk);
+ Assert.assertEquals(sdk3, fakeSdk);
+ Assert.assertEquals((int) getField("msgAuditSdkRefCount"), 3, "应有 3 个引用");
+
+ // 逐一释放,SDK 未过期,不应被销毁
+ config.releaseMsgAuditSdk(fakeSdk);
+ Assert.assertEquals((int) getField("msgAuditSdkRefCount"), 2, "释放一个后应有 2 个引用");
+ Assert.assertEquals((long) getField("msgAuditSdk"), fakeSdk, "SDK 仍有引用,不应被销毁");
+
+ config.releaseMsgAuditSdk(fakeSdk);
+ Assert.assertEquals((int) getField("msgAuditSdkRefCount"), 1, "释放两个后应有 1 个引用");
+
+ config.releaseMsgAuditSdk(fakeSdk);
+ Assert.assertEquals((int) getField("msgAuditSdkRefCount"), 0, "全部释放后引用计数应为 0");
+ // SDK 未过期,不应被销毁
+ Assert.assertEquals((long) getField("msgAuditSdk"), fakeSdk, "SDK 未过期,全部引用释放后不应被销毁");
+ }
+
+ /**
+ * 验证 incrementMsgAuditSdkRefCount 在 SDK 匹配时正确增加引用计数
+ */
+ @Test
+ public void testIncrementRefCount() throws Exception {
+ long fakeSdk = 11111L;
+ setField("msgAuditSdk", fakeSdk);
+ setField("msgAuditSdkRefCount", 2);
+
+ int result = config.incrementMsgAuditSdkRefCount(fakeSdk);
+
+ Assert.assertEquals(result, 3, "引用计数应增加到 3");
+ }
+
+ /**
+ * 验证 incrementMsgAuditSdkRefCount 在 SDK 不匹配时返回 -1
+ */
+ @Test
+ public void testIncrementRefCountWithWrongSdk() throws Exception {
+ setField("msgAuditSdk", 11111L);
+ setField("msgAuditSdkRefCount", 2);
+
+ int result = config.incrementMsgAuditSdkRefCount(99999L);
+
+ Assert.assertEquals(result, -1, "SDK 不匹配时应返回 -1");
+ }
+
+ /**
+ * 验证 getMsgAuditSdkRefCount 的正确性
+ */
+ @Test
+ public void testGetMsgAuditSdkRefCount() throws Exception {
+ long fakeSdk = 55555L;
+ setField("msgAuditSdk", fakeSdk);
+ setField("msgAuditSdkRefCount", 5);
+
+ int count = config.getMsgAuditSdkRefCount(fakeSdk);
+ Assert.assertEquals(count, 5, "应返回正确的引用计数");
+
+ int wrongCount = config.getMsgAuditSdkRefCount(99L);
+ Assert.assertEquals(wrongCount, -1, "SDK 不匹配时应返回 -1");
+ }
+
+ /**
+ * 验证:引用计数归零且 SDK 已过期时,releaseMsgAuditSdk 应尝试销毁 SDK
+ * 由于 Finance.DestroySdk 是原生方法,测试环境中不加载原生库时会抛出 UnsatisfiedLinkError,
+ * 但引用计数已在 Finance 调用前递减(可验证代码路径已进入销毁分支)。
+ * 当原生库可用时,应进一步断言 msgAuditSdk 和 msgAuditSdkExpiresTime 均被清零。
+ */
+ @Test
+ public void testReleaseMsgAuditSdkShouldDestroyWhenExpired() throws Exception {
+ long fakeSdk = 22222L;
+ // 设置已过期的 SDK,引用计数为 1
+ setField("msgAuditSdk", fakeSdk);
+ setField("msgAuditSdkExpiresTime", System.currentTimeMillis() - 1000L); // 已过期
+ setField("msgAuditSdkRefCount", 1);
+
+ try {
+ config.releaseMsgAuditSdk(fakeSdk);
+ // 原生库可用:断言字段已清零
+ Assert.assertEquals((long) getField("msgAuditSdk"), 0L, "过期且引用归零后 msgAuditSdk 应被清零");
+ Assert.assertEquals((long) getField("msgAuditSdkExpiresTime"), 0L, "过期时间应被清零");
+ } catch (UnsatisfiedLinkError e) {
+ // 测试环境未加载原生库:Finance.DestroySdk 被调用但抛出 UnsatisfiedLinkError
+ // 这证明代码路径正确进入了"过期时销毁 SDK"的分支,与"未过期时跳过销毁"的分支形成对比
+ Assert.assertEquals((int) getField("msgAuditSdkRefCount"), 0, "引用计数应已递减到 0(Finance 调用前完成)");
+ }
+ }
+}
diff --git a/weixin-java-cp/src/test/resources/testng.xml b/weixin-java-cp/src/test/resources/testng.xml
index 48589e709a..0a86cccd1f 100644
--- a/weixin-java-cp/src/test/resources/testng.xml
+++ b/weixin-java-cp/src/test/resources/testng.xml
@@ -8,6 +8,7 @@
+