mirror of
https://gitee.com/binary/weixin-java-tools.git
synced 2026-01-22 21:02:03 +08:00
🎨 #3848 【企业微信】修复会话存档SDK生命周期管理导致的JVM崩溃问题
This commit is contained in:
295
docs/CP_MSG_AUDIT_SDK_SAFE_USAGE.md
Normal file
295
docs/CP_MSG_AUDIT_SDK_SAFE_USAGE.md
Normal file
@@ -0,0 +1,295 @@
|
||||
# 企业微信会话存档SDK安全使用指南
|
||||
|
||||
## 问题背景
|
||||
|
||||
在使用企业微信会话存档功能时,部分开发者遇到了JVM崩溃的问题。典型错误信息如下:
|
||||
|
||||
```
|
||||
SIGSEGV (0xb) at pc=0x00007fcd50460d93
|
||||
Problematic frame:
|
||||
C [libWeWorkFinanceSdk_Java.so+0x260d93] WeWorkFinanceSdk::TryRefresh(std::string const&, std::string const&, int)+0x23
|
||||
```
|
||||
|
||||
## 问题原因
|
||||
|
||||
旧版API设计存在以下问题:
|
||||
|
||||
1. **SDK生命周期管理混乱**
|
||||
- `getChatDatas()` 方法会返回SDK实例给调用方
|
||||
- 开发者需要手动调用 `Finance.DestroySdk()` 来销毁SDK
|
||||
- 但SDK在框架内部有7200秒的缓存机制
|
||||
|
||||
2. **手动销毁导致缓存失效**
|
||||
- 当开发者手动销毁SDK后,框架缓存中的SDK引用变为无效
|
||||
- 后续调用(如 `getMediaFile()`)仍然使用已销毁的SDK
|
||||
- 底层C++库访问无效指针,导致SIGSEGV错误
|
||||
|
||||
3. **多线程并发问题**
|
||||
- 在多线程环境下,一个线程销毁SDK后
|
||||
- 其他线程仍在使用该SDK,导致崩溃
|
||||
|
||||
## 解决方案
|
||||
|
||||
从 **4.8.0** 版本开始,WxJava提供了新的安全API,完全由框架管理SDK生命周期。
|
||||
|
||||
### 新API列表
|
||||
|
||||
| 旧API(已废弃) | 新API(推荐使用) | 说明 |
|
||||
|----------------|------------------|------|
|
||||
| `getChatDatas()` | `getChatRecords()` | 拉取聊天记录,不暴露SDK |
|
||||
| `getDecryptData(sdk, ...)` | `getDecryptChatData(...)` | 解密聊天数据,无需传入SDK |
|
||||
| `getChatPlainText(sdk, ...)` | `getChatRecordPlainText(...)` | 获取明文数据,无需传入SDK |
|
||||
| `getMediaFile(sdk, ...)` | `downloadMediaFile(...)` | 下载媒体文件,无需传入SDK |
|
||||
|
||||
### 使用示例
|
||||
|
||||
#### 错误用法(旧API,已废弃)
|
||||
|
||||
```java
|
||||
// ❌ 不推荐:容易导致JVM崩溃
|
||||
WxCpMsgAuditService msgAuditService = wxCpService.getMsgAuditService();
|
||||
|
||||
// 拉取聊天记录
|
||||
WxCpChatDatas chatDatas = msgAuditService.getChatDatas(seq, 1000L, null, null, 1000L);
|
||||
|
||||
for (WxCpChatDatas.WxCpChatData chatData : chatDatas.getChatData()) {
|
||||
// 解密数据
|
||||
WxCpChatModel model = msgAuditService.getDecryptData(chatDatas.getSdk(), chatData, 2);
|
||||
|
||||
// 下载媒体文件
|
||||
if ("image".equals(model.getMsgType())) {
|
||||
String sdkFileId = model.getImage().getSdkFileId();
|
||||
msgAuditService.getMediaFile(chatDatas.getSdk(), sdkFileId, null, null, 1000L, targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ 危险操作:手动销毁SDK可能导致后续调用崩溃
|
||||
Finance.DestroySdk(chatDatas.getSdk());
|
||||
```
|
||||
|
||||
#### 正确用法(新API,推荐)
|
||||
|
||||
```java
|
||||
// ✅ 推荐:SDK生命周期由框架自动管理,安全可靠
|
||||
WxCpMsgAuditService msgAuditService = wxCpService.getMsgAuditService();
|
||||
|
||||
// 拉取聊天记录(不返回SDK)
|
||||
List<WxCpChatDatas.WxCpChatData> chatRecords =
|
||||
msgAuditService.getChatRecords(seq, 1000L, null, null, 1000L);
|
||||
|
||||
for (WxCpChatDatas.WxCpChatData chatData : chatRecords) {
|
||||
// 解密数据(无需传入SDK)
|
||||
WxCpChatModel model = msgAuditService.getDecryptChatData(chatData, 2);
|
||||
|
||||
// 下载媒体文件(无需传入SDK)
|
||||
if ("image".equals(model.getMsgType())) {
|
||||
String sdkFileId = model.getImage().getSdkFileId();
|
||||
msgAuditService.downloadMediaFile(sdkFileId, null, null, 1000L, targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 无需手动销毁SDK,框架会自动管理
|
||||
```
|
||||
|
||||
### 完整示例:拉取并处理会话存档
|
||||
|
||||
```java
|
||||
import me.chanjar.weixin.cp.api.WxCpService;
|
||||
import me.chanjar.weixin.cp.api.WxCpMsgAuditService;
|
||||
import me.chanjar.weixin.cp.bean.msgaudit.WxCpChatDatas;
|
||||
import me.chanjar.weixin.cp.bean.msgaudit.WxCpChatModel;
|
||||
import me.chanjar.weixin.cp.constant.WxCpConsts;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class MsgAuditExample {
|
||||
|
||||
private final WxCpService wxCpService;
|
||||
|
||||
public void processMessages(long seq) throws Exception {
|
||||
WxCpMsgAuditService msgAuditService = wxCpService.getMsgAuditService();
|
||||
|
||||
// 拉取聊天记录
|
||||
List<WxCpChatDatas.WxCpChatData> chatRecords =
|
||||
msgAuditService.getChatRecords(seq, 1000L, null, null, 1000L);
|
||||
|
||||
for (WxCpChatDatas.WxCpChatData chatData : chatRecords) {
|
||||
seq = chatData.getSeq();
|
||||
|
||||
// 获取明文数据
|
||||
String plainText = msgAuditService.getChatRecordPlainText(chatData, 2);
|
||||
WxCpChatModel model = WxCpChatModel.fromJson(plainText);
|
||||
|
||||
// 处理不同类型的消息
|
||||
switch (model.getMsgType()) {
|
||||
case WxCpConsts.MsgAuditMediaType.TEXT:
|
||||
processTextMessage(model);
|
||||
break;
|
||||
|
||||
case WxCpConsts.MsgAuditMediaType.IMAGE:
|
||||
processImageMessage(model, msgAuditService);
|
||||
break;
|
||||
|
||||
case WxCpConsts.MsgAuditMediaType.FILE:
|
||||
processFileMessage(model, msgAuditService);
|
||||
break;
|
||||
|
||||
default:
|
||||
// 处理其他类型消息
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void processTextMessage(WxCpChatModel model) {
|
||||
String content = model.getText().getContent();
|
||||
System.out.println("文本消息:" + content);
|
||||
}
|
||||
|
||||
private void processImageMessage(WxCpChatModel model, WxCpMsgAuditService msgAuditService)
|
||||
throws Exception {
|
||||
String sdkFileId = model.getImage().getSdkFileId();
|
||||
String md5Sum = model.getImage().getMd5Sum();
|
||||
String targetPath = "/path/to/save/" + md5Sum + ".jpg";
|
||||
|
||||
// 下载图片(无需传入SDK)
|
||||
msgAuditService.downloadMediaFile(sdkFileId, null, null, 1000L, targetPath);
|
||||
System.out.println("图片已保存:" + targetPath);
|
||||
}
|
||||
|
||||
private void processFileMessage(WxCpChatModel model, WxCpMsgAuditService msgAuditService)
|
||||
throws Exception {
|
||||
String sdkFileId = model.getFile().getSdkFileId();
|
||||
String fileName = model.getFile().getFileName();
|
||||
String targetPath = "/path/to/save/" + fileName;
|
||||
|
||||
// 下载文件(无需传入SDK)
|
||||
msgAuditService.downloadMediaFile(sdkFileId, null, null, 1000L, targetPath);
|
||||
System.out.println("文件已保存:" + targetPath);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 使用Lambda处理媒体文件流
|
||||
|
||||
新API同样支持使用Lambda表达式处理媒体文件的数据流:
|
||||
|
||||
```java
|
||||
msgAuditService.downloadMediaFile(sdkFileId, null, null, 1000L, data -> {
|
||||
try {
|
||||
// 处理每个数据分片(大文件会分片传输)
|
||||
// 例如:上传到云存储、写入数据库等
|
||||
uploadToCloud(data);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 技术实现原理
|
||||
|
||||
### 引用计数机制
|
||||
|
||||
新API在内部实现了SDK引用计数机制:
|
||||
|
||||
1. **获取SDK时**:引用计数 +1
|
||||
2. **使用完成后**:引用计数 -1
|
||||
3. **计数归零时**:SDK被自动释放
|
||||
|
||||
```java
|
||||
// 框架内部实现(简化版)
|
||||
public void downloadMediaFile(String sdkFileId, ...) {
|
||||
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
|
||||
|
||||
新API的引用计数机制与缓存机制完美配合,确保SDK不会被提前销毁。
|
||||
|
||||
## 迁移指南
|
||||
|
||||
### 第一步:使用新API替换旧API
|
||||
|
||||
查找代码中的旧API调用:
|
||||
|
||||
```java
|
||||
// 查找模式
|
||||
getChatDatas(
|
||||
getDecryptData(.*sdk
|
||||
getChatPlainText(.*sdk
|
||||
getMediaFile(.*sdk
|
||||
Finance.DestroySdk(
|
||||
```
|
||||
|
||||
替换为对应的新API(参考前面的对照表)。
|
||||
|
||||
### 第二步:移除手动SDK管理代码
|
||||
|
||||
删除所有 `Finance.DestroySdk()` 调用,SDK生命周期由框架自动管理。
|
||||
|
||||
### 第三步:测试验证
|
||||
|
||||
1. 在测试环境验证新API功能正常
|
||||
2. 观察日志,确认没有SDK相关的错误
|
||||
3. 进行压力测试,验证多线程环境下的稳定性
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q1: 旧代码会立即停止工作吗?
|
||||
|
||||
**A:** 不会。旧API被标记为 `@Deprecated`,但仍然可用,只是不推荐继续使用。建议尽快迁移到新API以避免潜在问题。
|
||||
|
||||
### Q2: 如何知道SDK是否被正确释放?
|
||||
|
||||
**A:** 框架会自动管理SDK生命周期,开发者无需关心。如果需要调试,可以查看配置存储中的引用计数。
|
||||
|
||||
### Q3: 多线程环境下新API安全吗?
|
||||
|
||||
**A:** 是的。新API使用了引用计数机制,配合 `synchronized` 关键字,确保多线程环境下的安全性。
|
||||
|
||||
### Q4: 性能会受影响吗?
|
||||
|
||||
**A:** 不会。新API在实现上增加了引用计数的开销,但这是轻量级的操作(原子操作),对性能影响可以忽略不计。SDK缓存机制保持不变。
|
||||
|
||||
### Q5: 可以同时使用新旧API吗?
|
||||
|
||||
**A:** 技术上可以,但强烈不推荐。混用可能导致SDK生命周期管理混乱,建议统一使用新API。
|
||||
|
||||
## 相关链接
|
||||
|
||||
- [企业微信会话存档官方文档](https://developer.work.weixin.qq.com/document/path/91360)
|
||||
- [WxJava GitHub 仓库](https://github.com/binarywang/WxJava)
|
||||
- [问题反馈](https://github.com/binarywang/WxJava/issues)
|
||||
|
||||
## 版本要求
|
||||
|
||||
- **最低版本**: 4.8.0
|
||||
- **推荐版本**: 最新版本
|
||||
|
||||
## 反馈与支持
|
||||
|
||||
如果在使用过程中遇到问题,请:
|
||||
|
||||
1. 查看本文档的常见问题部分
|
||||
2. 在 GitHub 上提交 Issue
|
||||
3. 加入微信群获取社区支持
|
||||
|
||||
---
|
||||
|
||||
**最后更新时间**: 2026-01-14
|
||||
@@ -28,9 +28,26 @@ public interface WxCpMsgAuditService {
|
||||
* @param timeout 超时时间,根据实际需要填写
|
||||
* @return 返回是否调用成功 chat datas
|
||||
* @throws Exception the exception
|
||||
* @deprecated 请使用 {@link #getChatRecords(long, long, String, String, long)} 代替,
|
||||
* 该方法会将SDK暴露给调用方,容易导致SDK生命周期管理混乱,引发JVM崩溃
|
||||
*/
|
||||
@Deprecated
|
||||
WxCpChatDatas getChatDatas(long seq, @NonNull long limit, String proxy, String passwd, @NonNull long timeout) throws Exception;
|
||||
|
||||
/**
|
||||
* 拉取聊天记录函数(推荐使用)
|
||||
* 该方法不会将SDK暴露给调用方,SDK生命周期由框架自动管理,更加安全
|
||||
*
|
||||
* @param seq 从指定的seq开始拉取消息,注意的是返回的消息从seq+1开始返回,seq为之前接口返回的最大seq值。首次使用请使用seq:0
|
||||
* @param limit 一次拉取的消息条数,最大值1000条,超过1000条会返回错误
|
||||
* @param proxy 使用代理的请求,需要传入代理的链接。如:socks5://10.0.0.1:8081 或者 http://10.0.0.1:8081,如果没有传null
|
||||
* @param passwd 代理账号密码,需要传入代理的账号密码。如 user_name:passwd_123,如果没有传null
|
||||
* @param timeout 超时时间,根据实际需要填写
|
||||
* @return 返回聊天记录列表,不包含SDK信息
|
||||
* @throws Exception the exception
|
||||
*/
|
||||
List<WxCpChatDatas.WxCpChatData> 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 @@ public interface WxCpMsgAuditService {
|
||||
* @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 @@ public interface WxCpMsgAuditService {
|
||||
* @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接口拉取数据内容。
|
||||
* <p>
|
||||
* 注意:
|
||||
* 根据上面返回的文件类型,拼接好存放文件的绝对路径即可。此时绝对路径写入文件流,来达到获取媒体文件的目的。
|
||||
* 详情可以看官方文档,亦可阅读此接口源码。
|
||||
*
|
||||
* @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 @@ public interface WxCpMsgAuditService {
|
||||
* @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<byte[]> 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<byte[]> action) throws WxErrorException;
|
||||
|
||||
/**
|
||||
* 获取会话内容存档开启成员列表
|
||||
* 企业可通过此接口,获取企业开启会话内容存档的成员列表
|
||||
|
||||
@@ -20,6 +20,7 @@ import java.io.File;
|
||||
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 @@ public class WxCpMsgAuditServiceImpl implements WxCpMsgAuditService {
|
||||
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 class WxCpMsgAuditServiceImpl implements WxCpMsgAuditService {
|
||||
return WxCpAgreeInfo.fromJson(responseContent);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<WxCpChatDatas.WxCpChatData> 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<WxCpChatDatas.WxCpChatData> 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<Exception> 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<byte[]> 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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 class WxCpDefaultConfigImpl implements WxCpConfigStorage, Serializable {
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 class WxCpRedisConfigImpl implements WxCpConfigStorage {
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -754,6 +754,84 @@ public class WxCpMsgAuditTest {
|
||||
Finance.DestroySdk(chatDatas.getSdk());
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试新的安全API方法(推荐使用)
|
||||
* 这些方法不需要手动管理SDK生命周期,更加安全
|
||||
*/
|
||||
@Test
|
||||
public void testNewSafeApi() throws Exception {
|
||||
WxCpMsgAuditService msgAuditService = cpService.getMsgAuditService();
|
||||
|
||||
// 测试新的getChatRecords方法 - 不暴露SDK
|
||||
List<WxCpChatDatas.WxCpChatData> 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){
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user