Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions docs/CP_MSG_AUDIT_SDK_SAFE_USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,33 +196,36 @@ 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
}
}
```

### SDK缓存机制

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

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

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

## 迁移指南

Expand Down
2 changes: 1 addition & 1 deletion docs/CP_MSG_AUDIT_THREADLOCAL_LIFECYCLE_REFACTOR.md
Original file line number Diff line number Diff line change
Expand Up @@ -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实例,存在并发安全隐患。

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -351,10 +351,11 @@ public interface WxCpConfigStorage {

/**
* 减少会话存档SDK的引用计数
* 当引用计数降为0时,自动销毁SDK以释放资源
* 当引用计数降为0且SDK已过期时,才自动销毁SDK以释放资源
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

本次语义变更后,仓库里其它外部文档/示例中“引用计数归零即释放 SDK”的表述(例如 docs/CP_MSG_AUDIT_SDK_SAFE_USAGE.md 的实现原理段落)可能会与这里不一致。建议检查并同步相关文档,避免用户误解 SDK 生命周期行为。

Severity: low

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

* 如果SDK尚未过期,保留SDK缓存以供后续调用复用
*
* @param sdk sdk id
* @return 减少后的引用计数,如果返回0表示SDK已被销毁,如果SDK不匹配返回-1
* @return 减少后的引用计数;SDK不匹配或引用计数已为0时返回-1
* @deprecated 引用计数机制已废弃,由 ThreadLocal 模式替代。
*/
@Deprecated
Expand Down Expand Up @@ -383,7 +384,8 @@ public interface WxCpConfigStorage {

/**
* 减少SDK引用计数并在必要时释放(原子操作)
* 此方法确保引用计数递减和SDK检查在同一个同步块内完成
* 当引用计数降为0且SDK已过期时,才销毁SDK以释放资源
* 如果SDK尚未过期,保留SDK缓存以供后续调用复用,避免频繁初始化和销毁
*
* @param sdk sdk id
* @deprecated 引用计数机制已废弃,由 ThreadLocal 模式替代。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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 未过期,全部引用释放后不应被销毁");
}
Comment on lines +128 to +160

/**
* 验证 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 调用前完成)");
}
}
}
1 change: 1 addition & 0 deletions weixin-java-cp/src/test/resources/testng.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<class name="me.chanjar.weixin.cp.api.WxCpMessageRouterTest"/>
<class name="me.chanjar.weixin.cp.tp.service.impl.WxCpTpServiceApacheHttpClientImplTest"/>
<class name="me.chanjar.weixin.cp.config.impl.WxCpTpDefaultConfigImplTest"/>
<class name="me.chanjar.weixin.cp.config.impl.WxCpDefaultConfigImplMsgAuditSdkTest"/>
<class name="me.chanjar.weixin.cp.tp.service.impl.WxCpTpTagServiceImplTest"/>
<class name="me.chanjar.weixin.cp.api.impl.WxCpHrServiceImplTest"/>
</classes>
Expand Down