diff --git a/.github/agents/my-agent.agent.md b/.github/agents/my-agent.agent.md index 0c8481288a..bd1b4572eb 100644 --- a/.github/agents/my-agent.agent.md +++ b/.github/agents/my-agent.agent.md @@ -10,5 +10,7 @@ description: 需要用中文,包括PR标题和分析总结过程 # My Agent -1、请使用中文输出思考过程和总结,包括PR标题,提交commit信息也要使用中文; -2、生成代码时需要提供必要的单元测试代码。 +- 1、请使用中文输出思考过程和总结,包括PR标题,提交commit信息也要使用中文; +- 2、生成代码时需要提供必要的单元测试代码; +- 3、实现接口时请严格按照官方文档编写代码,严禁瞎编乱造、臆想并实现不存在的接口; +- 4、新增加的代码如果标记作者信息,请注意不要把作者名设为binarywang或者其他无关人员,要改为 GitHub Copilot。 diff --git a/README.md b/README.md index 69d5efbf34..94c52d7e07 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,10 @@ -### 微信`Java`开发工具包,支持包括微信支付、开放平台、公众号、企业微信、视频号、小程序等微信功能模块的后端开发。 +### 微信 `Java` 开发工具包,支持包括微信支付、开放平台、公众号、企业微信、视频号、小程序等微信功能模块的后端开发。 + +### 特别赞助
- 特别赞助
@@ -51,14 +52,44 @@ 赞助商招募中 - - ad + + ad
+### 目录索引 +- [快速开始(3分钟)](#快速开始3分钟) +- [我该选哪个模块?](#我该选哪个模块) +- [Maven 引用方式](#maven-引用方式) +- [最小示例](#最小示例) +- [重要信息](#重要信息) +- [其他说明](#其他说明) +- [版本说明](#版本说明) +- [应用案例](#应用案例) +- [特别赞助](#特别赞助) +- [贡献者列表](#贡献者列表) + +### 快速开始(3分钟) +1. 根据业务场景选择模块(见下方“我该选哪个模块?”) +2. 引入 Maven 依赖并选择对应模块 +3. 参考最小示例完成初始化并调用 API + +### 我该选哪个模块? + +| 业务场景 | 模块 | artifactId | +|---|---|---| +| 微信公众号开发 | MP | `weixin-java-mp` | +| 微信小程序开发 | MiniApp | `weixin-java-miniapp` | +| 微信支付 | Pay | `weixin-java-pay` | +| 企业微信 | CP | `weixin-java-cp` | +| 微信开放平台(第三方平台) | Open | `weixin-java-open` | +| 视频号 / 微信小店 | Channel | `weixin-java-channel` | + +> 移动端(iOS/Android)微信登录、分享等能力仍需集成微信官方客户端 SDK;本项目为服务端 SDK。 + ### 重要信息 1. [`WxJava` 荣获 `GitCode` 2024年度十大开源社区奖项](https://mp.weixin.qq.com/s/wM_UlMsDm3IZ1CPPDvcvQw)。 2. 项目合作洽谈请联系微信`binary0000`(在微信里自行搜索并添加好友,请注明来意,如有关于SDK问题需讨论请参考下文入群讨论,不要加此微信)。 @@ -112,50 +143,43 @@ - **微信开放平台**(`weixin-java-open`)主要用于第三方平台,代公众号或小程序进行开发和管理 ---------------------------------- -### HTTP 客户端支持 -本项目同时支持多种 HTTP 客户端实现,默认推荐使用 **Apache HttpClient 5.x**(最新稳定版本)。 +--------------------------------- +### 最小示例 -#### 支持的 HTTP 客户端类型 +
+公众号(MP)示例:获取 AccessToken -| HTTP 客户端 | 说明 | 配置值 | 推荐程度 | -|------------|------|--------|---------| -| Apache HttpClient 5.x | Apache HttpComponents Client 5.x,最新版本 | `HttpComponents` | ⭐⭐⭐⭐⭐ 推荐 | -| Apache HttpClient 4.x | Apache HttpClient 4.x,向后兼容 | `HttpClient` | ⭐⭐⭐⭐ 兼容 | -| OkHttp | Square OkHttp 客户端 | `OkHttp` | ⭐⭐⭐ 可选 | -| Jodd-http | Jodd 轻量级 HTTP 客户端 | `JoddHttp` | ⭐⭐ 可选 | +```java +WxMpDefaultConfigImpl config = new WxMpDefaultConfigImpl(); +config.setAppId("your-app-id"); +config.setSecret("your-secret"); -#### 配置方式 +WxMpService wxMpService = new WxMpServiceImpl(); +wxMpService.setWxMpConfigStorage(config); -**Spring Boot 配置示例:** +String accessToken = wxMpService.getAccessToken(); +System.out.println(accessToken); +``` -```properties -# 使用 HttpClient 5.x(推荐,MP/MiniApp/CP/Channel/QiDian 模块默认) -wx.mp.config-storage.http-client-type=HttpComponents +
-# 使用 HttpClient 4.x(兼容模式) -wx.mp.config-storage.http-client-type=HttpClient +
+小程序(MiniApp)示例:code2Session -# 使用 OkHttp -wx.mp.config-storage.http-client-type=OkHttp +```java +WxMaDefaultConfigImpl config = new WxMaDefaultConfigImpl(); +config.setAppid("your-app-id"); +config.setSecret("your-secret"); -# 使用 Jodd-http -wx.mp.config-storage.http-client-type=JoddHttp -``` +WxMaService wxMaService = new WxMaServiceImpl(); +wxMaService.setWxMaConfig(config); -**注意**:如果使用 Multi-Starter(如 `wx-java-mp-multi-spring-boot-starter`),枚举值需使用大写下划线格式: -```properties -# Multi-Starter 配置格式 -wx.mp.config-storage.http-client-type=HTTP_COMPONENTS # 注意使用大写下划线 +WxMaJscode2SessionResult result = wxMaService.getUserService().getSessionInfo("js-code"); +System.out.println(result.getOpenid()); ``` -**注意事项:** -1. **MP、MiniApp、Channel、QiDian 模块**已完整支持 HttpClient 5.x,默认推荐使用 -2. **CP 模块**的支持情况取决于具体使用的 Starter 版本,请参考对应模块文档 -3. 如需使用 OkHttp 或 Jodd-http,需在项目中添加对应的依赖(scope为provided) -4. HttpClient 4.x 和 HttpClient 5.x 可以共存,按需配置即可 - +
--------------------------------- ### 版本说明 diff --git a/demo.md b/demo.md index d6b55b89e2..d305fc2121 100644 --- a/demo.md +++ b/demo.md @@ -14,12 +14,11 @@ - [使用该 `starter` 实现的小程序 `Demo`](https://github.com/binarywang/wx-java-miniapp-demo) ### Demo 列表 -1. 微信支付 Demo:[GitHub](http://github.com/binarywang/weixin-java-pay-demo)、[码云](http://gitee.com/binary/weixin-java-pay-demo) [![Build Status](https://app.travis-ci.com/binarywang/weixin-java-pay-demo.svg?branch=master)](https://app.travis-ci.com/binarywang/weixin-java-pay-demo) -1. 企业号/企业微信 Demo:[GitHub](http://github.com/binarywang/weixin-java-cp-demo)、[码云](http://gitee.com/binary/weixin-java-cp-demo) [![Build Status](https://app.travis-ci.com/binarywang/weixin-java-cp-demo.svg?branch=master)](https://app.travis-ci.com/binarywang/weixin-java-cp-demo) -1. 微信小程序 Demo:[GitHub](http://github.com/binarywang/weixin-java-miniapp-demo)、[码云](http://gitee.com/binary/weixin-java-miniapp-demo) [![Build Status](https://app.travis-ci.com/binarywang/weixin-java-miniapp-demo.svg?branch=master)](https://app.travis-ci.com/binarywang/weixin-java-miniapp-demo) -1. 开放平台 Demo:[GitHub](http://github.com/Wechat-Group/weixin-java-open-demo)、[码云](http://gitee.com/binary/weixin-java-open-demo) [![Build Status](https://app.travis-ci.com/Wechat-Group/weixin-java-open-demo.svg?branch=master)](https://app.travis-ci.com/Wechat-Group/weixin-java-open-demo) +1. 微信支付 Demo:[GitHub](http://github.com/binarywang/weixin-java-pay-demo)、[码云](http://gitee.com/binary/weixin-java-pay-demo) +1. 企业号/企业微信 Demo:[GitHub](http://github.com/binarywang/weixin-java-cp-demo)、[码云](http://gitee.com/binary/weixin-java-cp-demo) +1. 微信小程序 Demo:[GitHub](http://github.com/binarywang/weixin-java-miniapp-demo)、[码云](http://gitee.com/binary/weixin-java-miniapp-demo) +1. 开放平台 Demo:[GitHub](http://github.com/Wechat-Group/weixin-java-open-demo)、[码云](http://gitee.com/binary/weixin-java-open-demo) 1. 微信公众号 Demo: - - 使用 `Spring MVC` 实现的公众号 Demo:[GitHub](http://github.com/binarywang/weixin-java-mp-demo-springmvc)、[码云](https://gitee.com/binary/weixin-java-mp-demo) [![Build Status](https://app.travis-ci.com/binarywang/weixin-java-mp-demo-springmvc.svg?branch=master)](https://app.travis-ci.com/binarywang/weixin-java-mp-demo-springmvc) - - 使用 `Spring Boot` 实现的公众号 Demo(支持多公众号):[GitHub](http://github.com/binarywang/weixin-java-mp-demo)、[码云](http://gitee.com/binary/weixin-java-mp-demo-springboot) [![Build Status](https://app.travis-ci.com/binarywang/weixin-java-mp-demo.svg?branch=master)](https://app.travis-ci.com/binarywang/weixin-java-mp-demo) - - 含公众号和部分微信支付代码的 Demo:[GitHub](http://github.com/Wechat-Group/weixin-java-demo-springmvc)、[码云](http://gitee.com/binary/weixin-java-tools-springmvc) [![Build Status](https://app.travis-ci.com/Wechat-Group/weixin-java-demo-springmvc.svg?branch=master)](https://app.travis-ci.com/Wechat-Group/weixin-java-demo-springmvc) - + - 使用 `Spring MVC` 实现的公众号 Demo:[GitHub](http://github.com/binarywang/weixin-java-mp-demo-springmvc)、[码云](https://gitee.com/binary/weixin-java-mp-demo) + - 使用 `Spring Boot` 实现的公众号 Demo(支持多公众号):[GitHub](http://github.com/binarywang/weixin-java-mp-demo)、[码云](http://gitee.com/binary/weixin-java-mp-demo-springboot) + - 含公众号和部分微信支付代码的 Demo:[GitHub](http://github.com/Wechat-Group/weixin-java-demo-springmvc)、[码云](http://gitee.com/binary/weixin-java-tools-springmvc) diff --git a/docs/CP_MSG_AUDIT_SDK_SAFE_USAGE.md b/docs/CP_MSG_AUDIT_SDK_SAFE_USAGE.md index b3a3ea1d33..b64e4612b9 100644 --- a/docs/CP_MSG_AUDIT_SDK_SAFE_USAGE.md +++ b/docs/CP_MSG_AUDIT_SDK_SAFE_USAGE.md @@ -1,5 +1,7 @@ # 企业微信会话存档SDK安全使用指南 +## 说明 +该方案已废弃,请使用新版本:[CP_MSG_AUDIT_THREADLOCAL_LIFECYCLE_REFACTOR.md](CP_MSG_AUDIT_THREADLOCAL_LIFECYCLE_REFACTOR.md) ## 问题背景 在使用企业微信会话存档功能时,部分开发者遇到了JVM崩溃的问题。典型错误信息如下: diff --git a/docs/CP_MSG_AUDIT_THREADLOCAL_LIFECYCLE_REFACTOR.md b/docs/CP_MSG_AUDIT_THREADLOCAL_LIFECYCLE_REFACTOR.md new file mode 100644 index 0000000000..072ceefd0c --- /dev/null +++ b/docs/CP_MSG_AUDIT_THREADLOCAL_LIFECYCLE_REFACTOR.md @@ -0,0 +1,204 @@ +# 会话存档SDK生命周期重构方案 + +## Context + +当前实现(4.8.x)通过"共享SDK + 引用计数 + 7200秒过期"来管理会话存档SDK生命周期。 +该方案存在以下核心问题: + +1. **频繁初始化/销毁**:每次调用 `releaseSdk()` 后引用计数归零即销毁SDK。对于"拉取→解密→下载媒体"这类典型串行调用链,每步操作都会触发重新初始化。 +2. **7200秒过期规则无依据**:官方文档FAQ明确说"不需要每次new/init sdk,可以在多次拉取中复用同一个sdk",无任何7200秒过期说明。 +3. **线程安全问题**:企微技术人员建议"一个线程一个SDK实例",当前设计多线程共享同一SDK实例,存在并发安全隐患。 + +--- + +## 推荐方案:ThreadLocal SDK 模式 + +> **核心原则**:每个线程拥有独立SDK实例,懒初始化,生命周期与线程绑定。 + +### 设计要点 + +- 使用 `ThreadLocal` 为每个线程持有独立SDK +- SDK在线程首次调用时初始化,后续所有操作复用(无需重复初始化) +- 移除7200秒过期机制 +- 移除引用计数机制(每线程独占,无需计数) +- 提供显式清理接口:`closeThreadLocalSdk()`(线程结束时调)、`closeAllSdks()`(应用关闭时调) + +### 生命周期示意 + +``` +Thread A: init SDK_A → getChatRecords → getDecryptChatData → downloadMediaFile → [任务结束后调closeThreadLocalSdk] +Thread B: init SDK_B → getChatRecords → getDecryptChatData → downloadMediaFile → ... +Thread C: init SDK_C → ... +``` + +--- + +## 涉及文件 + +| 文件 | 变更类型 | +|------|--------| +| `weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpMsgAuditServiceImpl.java` | 主要重构 | +| `weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpMsgAuditService.java` | 新增接口方法 | +| `weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/WxCpConfigStorage.java` | 废弃旧SDK管理方法 | +| `weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/impl/WxCpDefaultConfigImpl.java` | 废弃旧字段/方法 | +| `weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/WxCpMsgAuditTest.java` | 补充测试 | +| `docs/CP_MSG_AUDIT_SDK_SAFE_USAGE.md` | 更新文档 | + +--- + +## 详细变更 + +### 1. WxCpMsgAuditServiceImpl(主要变更) + +**新增字段:** +```java +/** 每个线程持有独立SDK实例 */ +private final ThreadLocal threadLocalSdk = new ThreadLocal<>(); + +/** 跟踪所有已创建SDK,用于统一清理 */ +private final Set managedSdks = ConcurrentHashMap.newKeySet(); +``` + +**废弃字段/方法:** +- 废弃常量 `SDK_EXPIRES_TIME = 7200`(无官方依据) +- 废弃 `initSdk()`(由 `getOrInitThreadLocalSdk()` 替代) +- 废弃 `acquireSdk()` / `releaseSdk()`(由ThreadLocal模式替代) + +**新增核心方法:** + +```java +/** + * 获取当前线程的SDK,不存在则创建。SDK在线程内跨调用复用,无需每次重新初始化。 + */ +private long getOrInitThreadLocalSdk() throws WxErrorException { + Long sdk = threadLocalSdk.get(); + if (sdk != null && sdk > 0) { + return sdk; + } + long newSdk = createSdk(); + threadLocalSdk.set(newSdk); + managedSdks.add(newSdk); + log.info("线程 [{}] 初始化会话存档SDK成功,sdk={}", Thread.currentThread().getName(), newSdk); + return newSdk; +} + +/** + * 创建并初始化一个新SDK(私有,只在当前线程无SDK时调用) + */ +private long createSdk() throws WxErrorException { + WxCpConfigStorage configStorage = cpService.getWxCpConfigStorage(); + // ... 与现有 initSdk() 内的库加载+Finance.NewSdk()+Finance.Init() 逻辑一致 ... + // 注意:Finance.loadingLibraries() 是幂等的(System.load内部防重复),多线程调用安全 +} + +/** + * 关闭当前线程持有的SDK,释放本地资源。 + * 在线程任务结束时调用(如定时任务finally块,或线程池线程销毁时)。 + */ +public void closeThreadLocalSdk() { + Long sdk = threadLocalSdk.get(); + if (sdk != null && sdk > 0) { + Finance.DestroySdk(sdk); + managedSdks.remove(sdk); + threadLocalSdk.remove(); + log.info("线程 [{}] 关闭会话存档SDK,sdk={}", Thread.currentThread().getName(), sdk); + } +} + +/** + * 关闭所有线程持有的SDK。应用关闭时调用(如Spring @PreDestroy / Shutdown Hook)。 + */ +public void closeAllSdks() { + managedSdks.forEach(sdk -> { + Finance.DestroySdk(sdk); + log.info("关闭会话存档SDK,sdk={}", sdk); + }); + managedSdks.clear(); + threadLocalSdk.remove(); +} +``` + +**更新新API方法(getChatRecords / getDecryptChatData / getChatRecordPlainText / downloadMediaFile):** +- 调用 `getOrInitThreadLocalSdk()` 替代 `acquireSdk()` +- 移除 try-finally 中的 `releaseSdk(sdk)` 调用(SDK不再每次释放) +- 方法变得更简洁:直接使用sdk,无需包装计数 + +**保留旧API方法不变(getChatDatas / getDecryptData / getChatPlainText / getMediaFile):** +- 保持 @Deprecated 标注 +- 内部调用改为 `getOrInitThreadLocalSdk()` 以保持一致性(旧方法也受益于ThreadLocal) +- 移除对 `initSdk()` 的依赖 + +### 2. WxCpMsgAuditService(接口新增) + +```java +/** + * 关闭当前线程持有的SDK,释放native资源。 + * Finance.DestroySdk() 不会随线程结束自动执行,无论线程池还是独立线程, + * 均应在任务结束的finally块中调用本方法,防止native内存、连接等资源泄漏。 + */ +void closeThreadLocalSdk(); + +/** + * 关闭所有会话存档SDK实例。 + * 适用于应用关闭时(如Spring Bean销毁阶段)统一释放资源。 + */ +void closeAllSdks(); +``` + +### 3. WxCpConfigStorage(废弃旧SDK管理API) + +对以下方法标记 `@Deprecated`(保留实现,不做破坏性删除): +- `getMsgAuditSdk()` / `updateMsgAuditSdk()` / `expireMsgAuditSdk()` / `isMsgAuditSdkExpired()` +- `acquireMsgAuditSdk()` / `releaseMsgAuditSdk()` +- `incrementMsgAuditSdkRefCount()` / `decrementMsgAuditSdkRefCount()` / `getMsgAuditSdkRefCount()` + +### 4. WxCpDefaultConfigImpl(废弃旧字段) + +- 将 `msgAuditSdk`、`msgAuditSdkExpiresTime`、`msgAuditSdkRefCount` 字段标记 `@Deprecated` +- 对应的 getter/setter/acquire/release 方法标记 `@Deprecated` +- 保留实现,确保向后兼容 + +--- + +## 使用示例(更新文档) + +```java +// ✅ 典型用法(一次任务中串行调用,SDK在同线程内复用,无重复初始化) +WxCpMsgAuditService msgAuditService = wxCpService.getMsgAuditService(); + +try { + List records = msgAuditService.getChatRecords(seq, 100L, null, null, 30L); + for (WxCpChatDatas.WxCpChatData record : records) { + WxCpChatModel model = msgAuditService.getDecryptChatData(record, 2); + if ("image".equals(model.getMsgType())) { + msgAuditService.downloadMediaFile(model.getImage().getSdkFileId(), null, null, 30L, "/tmp/img.jpg"); + } + } +} finally { + // 无论线程池还是独立线程,均建议在 finally 中显式调用。 + // Finance.DestroySdk() 不会随线程结束自动执行,依赖 closeAllSdks() 兜底会造成 + // native 内存/连接资源的延迟泄漏,对定时任务等长期运行场景尤其有害。 + msgAuditService.closeThreadLocalSdk(); +} + +// 应用关闭时(Spring @PreDestroy 或 Shutdown Hook) +// msgAuditService.closeAllSdks(); +``` + +--- + +## 注意事项 + +1. **线程池场景下必须调用 `closeThreadLocalSdk()`**:线程池中线程会被复用,如不主动清理,下次任务仍会使用旧线程的SDK。对于计划任务/批处理,建议在 finally 块中调用。 +2. **独立线程同样建议显式关闭**:`Finance.DestroySdk()` 是 native 调用,不会随线程结束自动执行,JVM GC 也不会触发它。依赖 `closeAllSdks()` 兜底意味着 native 内存、网络连接等资源在整个应用运行期间一直持有,对定时任务等高频场景会持续积累,建议统一在 finally 块中调用 `closeThreadLocalSdk()`。 +3. **多企业(多CorpId)场景**:`threadLocalSdk` 是实例字段(非static),不同 `WxCpMsgAuditServiceImpl` 实例(不同企业)的ThreadLocal独立,互不影响。 +4. **库加载幂等性**:`Finance.loadingLibraries()` 底层调用 `System.load()`,JVM保证同一库不重复加载,多线程并发调用安全。 + +--- + +## 验证方式 + +1. **单元测试**:在 `WxCpMsgAuditTest` 中添加测试,验证同线程多次调用不触发重新初始化(可通过日志或mock Finance验证) +2. **多线程压测**:多线程并发调用 `getChatRecords` + `getDecryptChatData`,观察无JVM崩溃 +3. **线程池复用测试**:使用固定线程池多次提交任务,验证 `closeThreadLocalSdk()` 后下次任务能正确重新初始化SDK +4. **应用关闭测试**:调用 `closeAllSdks()`,验证所有线程的SDK被正确销毁 diff --git a/docs/CommonUploadParam-FormFields-Usage.md b/docs/CommonUploadParam-FormFields-Usage.md new file mode 100644 index 0000000000..2d95d7c5c9 --- /dev/null +++ b/docs/CommonUploadParam-FormFields-Usage.md @@ -0,0 +1,169 @@ +# CommonUploadParam 额外表单字段功能使用示例 + +## 背景 + +微信公众号在上传永久视频素材时,需要在POST请求中同时提交文件和一个名为`description`的表单字段,该字段包含视频的描述信息(JSON格式)。 + +根据微信公众号文档: +> 在上传视频素材时需要POST另一个表单,id为description,包含素材的描述信息,内容格式为JSON,格式如下: +> ```json +> { +> "title": "VIDEO_TITLE", +> "introduction": "INTRODUCTION" +> } +> ``` + +## 解决方案 + +`CommonUploadParam` 类已经扩展支持额外的表单字段,可以在上传文件的同时提交其他表单数据。 + +## 使用示例 + +### 1. 基本用法 - 上传永久视频素材 + +```java +import me.chanjar.weixin.common.bean.CommonUploadParam; +import me.chanjar.weixin.common.util.json.WxGsonBuilder; +import me.chanjar.weixin.mp.api.WxMpService; + +import java.io.File; +import java.util.HashMap; +import java.util.Map; + +public class VideoMaterialUploadExample { + + public void uploadVideoMaterial(WxMpService wxMpService) throws Exception { + // 准备视频文件 + File videoFile = new File("/path/to/video.mp4"); + + // 创建上传参数 + CommonUploadParam uploadParam = CommonUploadParam.fromFile("media", videoFile); + + // 准备视频描述信息(JSON格式) + Map description = new HashMap<>(); + description.put("title", "我的视频标题"); + description.put("introduction", "这是一个精彩的视频介绍"); + String descriptionJson = WxGsonBuilder.create().toJson(description); + + // 添加description表单字段 + uploadParam.addFormField("description", descriptionJson); + + // 调用微信API上传 + String url = "https://api.weixin.qq.com/cgi-bin/material/add_material?access_token=ACCESS_TOKEN&type=video"; + String response = wxMpService.upload(url, uploadParam); + + System.out.println("上传成功:" + response); + } +} +``` + +### 2. 链式调用风格 + +```java +import me.chanjar.weixin.common.bean.CommonUploadParam; +import com.google.gson.JsonObject; + +public class ChainStyleExample { + + public void uploadWithChainStyle(WxMpService wxMpService) throws Exception { + File videoFile = new File("/path/to/video.mp4"); + + // 准备描述信息 + JsonObject description = new JsonObject(); + description.addProperty("title", "视频标题"); + description.addProperty("introduction", "视频介绍"); + + // 使用链式调用 + String response = wxMpService.upload( + "https://api.weixin.qq.com/cgi-bin/material/add_material?access_token=ACCESS_TOKEN&type=video", + CommonUploadParam.fromFile("media", videoFile) + .addFormField("description", description.toString()) + ); + + System.out.println("上传成功:" + response); + } +} +``` + +### 3. 多个额外表单字段 + +```java +import me.chanjar.weixin.common.bean.CommonUploadParam; + +public class MultipleFormFieldsExample { + + public void uploadWithMultipleFields(WxMpService wxMpService) throws Exception { + File file = new File("/path/to/file.jpg"); + + // 可以添加多个表单字段 + CommonUploadParam uploadParam = CommonUploadParam.fromFile("media", file) + .addFormField("field1", "value1") + .addFormField("field2", "value2") + .addFormField("field3", "value3"); + + String response = wxMpService.upload("https://api.weixin.qq.com/some/upload/url", uploadParam); + + System.out.println("上传成功:" + response); + } +} +``` + +### 4. 从字节数组上传并添加表单字段 + +```java +import me.chanjar.weixin.common.bean.CommonUploadParam; + +public class ByteArrayUploadExample { + + public void uploadFromBytes(WxMpService wxMpService) throws Exception { + // 从字节数组创建上传参数 + byte[] fileBytes = getFileBytes(); + + CommonUploadParam uploadParam = CommonUploadParam + .fromBytes("media", "video.mp4", fileBytes) + .addFormField("description", "{\"title\":\"标题\",\"introduction\":\"介绍\"}"); + + String response = wxMpService.upload( + "https://api.weixin.qq.com/cgi-bin/material/add_material?access_token=ACCESS_TOKEN&type=video", + uploadParam + ); + + System.out.println("上传成功:" + response); + } + + private byte[] getFileBytes() { + // 获取文件字节数组的逻辑 + return new byte[0]; + } +} +``` + +## API 说明 + +### CommonUploadParam 类 + +#### 构造方法 +- `fromFile(String name, File file)` - 从文件创建上传参数 +- `fromBytes(String name, String fileName, byte[] bytes)` - 从字节数组创建上传参数 + +#### 方法 +- `addFormField(String fieldName, String fieldValue)` - 添加额外的表单字段,返回当前对象支持链式调用 +- `getFormFields()` - 获取所有额外的表单字段(Map类型) +- `setFormFields(Map formFields)` - 设置额外的表单字段 + +#### 属性 +- `name` - 文件对应的接口参数名称(如:media) +- `data` - 上传数据(CommonUploadData对象) +- `formFields` - 额外的表单字段(可选,Map类型) + +## 注意事项 + +1. **表单字段是可选的**:如果不需要额外的表单字段,可以不调用`addFormField`方法 +2. **JSON格式**:对于需要JSON格式的表单字段(如description),需要先将对象转换为JSON字符串 +3. **编码**:表单字段值会使用UTF-8编码 +4. **所有HTTP客户端支持**:该功能在所有HTTP客户端实现中都得到支持(OkHttp、Apache HttpClient、HttpComponents、JoddHttp) + +## 兼容性 + +- 对于通过 `fromFile`、`fromBytes` 等工厂方法创建 `CommonUploadParam` 的代码,本功能在行为层面是向后兼容的,现有代码无需修改即可继续工作。 +- 如果之前直接使用构造函数(例如 `new CommonUploadParam(name, data)`)创建对象,由于新增了 `formFields` 字段,构造函数签名可能发生变化,升级后需要改为使用上述工厂方法或根据新构造函数签名调整代码。 diff --git a/pom.xml b/pom.xml index 8a08484f0e..f3be565062 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 com.github.binarywang wx-java - 4.8.1.B + 4.8.2.B pom WxJava - Weixin/Wechat Java SDK 微信开发Java SDK @@ -138,6 +138,7 @@ 4.5.13 5.5.2 9.4.57.v20241219 + 1.84 @@ -258,7 +259,7 @@ org.mockito mockito-core - 5.14.2 + 4.11.0 test @@ -335,7 +336,12 @@ org.bouncycastle bcpkix-jdk18on - 1.80 + ${bouncycastle.version} + + + org.bouncycastle + bcprov-jdk18on + ${bouncycastle.version} diff --git a/solon-plugins/pom.xml b/solon-plugins/pom.xml index d49beabc7c..9a375a60cc 100644 --- a/solon-plugins/pom.xml +++ b/solon-plugins/pom.xml @@ -6,7 +6,7 @@ com.github.binarywang wx-java - 4.8.1.B + 4.8.2.B pom wx-java-solon-plugins diff --git a/solon-plugins/wx-java-channel-multi-solon-plugin/pom.xml b/solon-plugins/wx-java-channel-multi-solon-plugin/pom.xml index 4d75010262..908e3957ee 100644 --- a/solon-plugins/wx-java-channel-multi-solon-plugin/pom.xml +++ b/solon-plugins/wx-java-channel-multi-solon-plugin/pom.xml @@ -5,7 +5,7 @@ wx-java-solon-plugins com.github.binarywang - 4.8.1.B + 4.8.2.B 4.0.0 diff --git a/solon-plugins/wx-java-channel-solon-plugin/pom.xml b/solon-plugins/wx-java-channel-solon-plugin/pom.xml index e52aadc71e..1e3f457cfe 100644 --- a/solon-plugins/wx-java-channel-solon-plugin/pom.xml +++ b/solon-plugins/wx-java-channel-solon-plugin/pom.xml @@ -3,7 +3,7 @@ wx-java-solon-plugins com.github.binarywang - 4.8.1.B + 4.8.2.B 4.0.0 diff --git a/solon-plugins/wx-java-cp-multi-solon-plugin/pom.xml b/solon-plugins/wx-java-cp-multi-solon-plugin/pom.xml index 6518a55994..c0d1dcc180 100644 --- a/solon-plugins/wx-java-cp-multi-solon-plugin/pom.xml +++ b/solon-plugins/wx-java-cp-multi-solon-plugin/pom.xml @@ -4,7 +4,7 @@ wx-java-solon-plugins com.github.binarywang - 4.8.1.B + 4.8.2.B 4.0.0 diff --git a/solon-plugins/wx-java-cp-solon-plugin/pom.xml b/solon-plugins/wx-java-cp-solon-plugin/pom.xml index 50f6b22c75..b8d2c43351 100644 --- a/solon-plugins/wx-java-cp-solon-plugin/pom.xml +++ b/solon-plugins/wx-java-cp-solon-plugin/pom.xml @@ -4,7 +4,7 @@ wx-java-solon-plugins com.github.binarywang - 4.8.1.B + 4.8.2.B 4.0.0 diff --git a/solon-plugins/wx-java-miniapp-multi-solon-plugin/pom.xml b/solon-plugins/wx-java-miniapp-multi-solon-plugin/pom.xml index bd69cd0ed1..6ca319ad7f 100644 --- a/solon-plugins/wx-java-miniapp-multi-solon-plugin/pom.xml +++ b/solon-plugins/wx-java-miniapp-multi-solon-plugin/pom.xml @@ -5,7 +5,7 @@ wx-java-solon-plugins com.github.binarywang - 4.8.1.B + 4.8.2.B 4.0.0 diff --git a/solon-plugins/wx-java-miniapp-solon-plugin/pom.xml b/solon-plugins/wx-java-miniapp-solon-plugin/pom.xml index a52eab54c9..28f80f00b1 100644 --- a/solon-plugins/wx-java-miniapp-solon-plugin/pom.xml +++ b/solon-plugins/wx-java-miniapp-solon-plugin/pom.xml @@ -4,7 +4,7 @@ wx-java-solon-plugins com.github.binarywang - 4.8.1.B + 4.8.2.B 4.0.0 diff --git a/solon-plugins/wx-java-mp-multi-solon-plugin/pom.xml b/solon-plugins/wx-java-mp-multi-solon-plugin/pom.xml index c9329fd290..f78f9b5d59 100644 --- a/solon-plugins/wx-java-mp-multi-solon-plugin/pom.xml +++ b/solon-plugins/wx-java-mp-multi-solon-plugin/pom.xml @@ -5,7 +5,7 @@ wx-java-solon-plugins com.github.binarywang - 4.8.1.B + 4.8.2.B 4.0.0 diff --git a/solon-plugins/wx-java-mp-solon-plugin/pom.xml b/solon-plugins/wx-java-mp-solon-plugin/pom.xml index 00f7a3951b..6ca5283c18 100644 --- a/solon-plugins/wx-java-mp-solon-plugin/pom.xml +++ b/solon-plugins/wx-java-mp-solon-plugin/pom.xml @@ -5,7 +5,7 @@ wx-java-solon-plugins com.github.binarywang - 4.8.1.B + 4.8.2.B 4.0.0 diff --git a/solon-plugins/wx-java-open-solon-plugin/pom.xml b/solon-plugins/wx-java-open-solon-plugin/pom.xml index 1d0a03fb73..dcd856dc26 100644 --- a/solon-plugins/wx-java-open-solon-plugin/pom.xml +++ b/solon-plugins/wx-java-open-solon-plugin/pom.xml @@ -5,7 +5,7 @@ wx-java-solon-plugins com.github.binarywang - 4.8.1.B + 4.8.2.B 4.0.0 diff --git a/solon-plugins/wx-java-pay-solon-plugin/pom.xml b/solon-plugins/wx-java-pay-solon-plugin/pom.xml index ab870301ae..26e0b7faca 100644 --- a/solon-plugins/wx-java-pay-solon-plugin/pom.xml +++ b/solon-plugins/wx-java-pay-solon-plugin/pom.xml @@ -5,7 +5,7 @@ wx-java-solon-plugins com.github.binarywang - 4.8.1.B + 4.8.2.B 4.0.0 diff --git a/solon-plugins/wx-java-qidian-solon-plugin/pom.xml b/solon-plugins/wx-java-qidian-solon-plugin/pom.xml index d46c9ca32c..cb0caaa1e4 100644 --- a/solon-plugins/wx-java-qidian-solon-plugin/pom.xml +++ b/solon-plugins/wx-java-qidian-solon-plugin/pom.xml @@ -3,7 +3,7 @@ wx-java-solon-plugins com.github.binarywang - 4.8.1.B + 4.8.2.B 4.0.0 diff --git a/spring-boot-starters/pom.xml b/spring-boot-starters/pom.xml index f37903c7e6..ff2ce88236 100644 --- a/spring-boot-starters/pom.xml +++ b/spring-boot-starters/pom.xml @@ -6,7 +6,7 @@ com.github.binarywang wx-java - 4.8.1.B + 4.8.2.B pom wx-java-spring-boot-starters diff --git a/spring-boot-starters/wx-java-channel-multi-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-channel-multi-spring-boot-starter/pom.xml index 083072f604..de7a389532 100644 --- a/spring-boot-starters/wx-java-channel-multi-spring-boot-starter/pom.xml +++ b/spring-boot-starters/wx-java-channel-multi-spring-boot-starter/pom.xml @@ -5,7 +5,7 @@ wx-java-spring-boot-starters com.github.binarywang - 4.8.1.B + 4.8.2.B 4.0.0 diff --git a/spring-boot-starters/wx-java-channel-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-channel-spring-boot-starter/pom.xml index 0d9e5e4e47..9f22f79503 100644 --- a/spring-boot-starters/wx-java-channel-spring-boot-starter/pom.xml +++ b/spring-boot-starters/wx-java-channel-spring-boot-starter/pom.xml @@ -3,7 +3,7 @@ wx-java-spring-boot-starters com.github.binarywang - 4.8.1.B + 4.8.2.B 4.0.0 diff --git a/spring-boot-starters/wx-java-cp-multi-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-cp-multi-spring-boot-starter/pom.xml index 6aa13ae814..514a67b3ec 100644 --- a/spring-boot-starters/wx-java-cp-multi-spring-boot-starter/pom.xml +++ b/spring-boot-starters/wx-java-cp-multi-spring-boot-starter/pom.xml @@ -4,7 +4,7 @@ wx-java-spring-boot-starters com.github.binarywang - 4.8.1.B + 4.8.2.B 4.0.0 diff --git a/spring-boot-starters/wx-java-cp-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-cp-spring-boot-starter/pom.xml index a560bf8e07..df23601c73 100644 --- a/spring-boot-starters/wx-java-cp-spring-boot-starter/pom.xml +++ b/spring-boot-starters/wx-java-cp-spring-boot-starter/pom.xml @@ -4,7 +4,7 @@ wx-java-spring-boot-starters com.github.binarywang - 4.8.1.B + 4.8.2.B 4.0.0 diff --git a/spring-boot-starters/wx-java-cp-tp-multi-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-cp-tp-multi-spring-boot-starter/pom.xml index 2d6c78009d..fa0b98aabf 100644 --- a/spring-boot-starters/wx-java-cp-tp-multi-spring-boot-starter/pom.xml +++ b/spring-boot-starters/wx-java-cp-tp-multi-spring-boot-starter/pom.xml @@ -4,7 +4,7 @@ wx-java-spring-boot-starters com.github.binarywang - 4.8.1.B + 4.8.2.B 4.0.0 diff --git a/spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/pom.xml index 32431c28c6..05f595ac26 100644 --- a/spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/pom.xml +++ b/spring-boot-starters/wx-java-miniapp-multi-spring-boot-starter/pom.xml @@ -5,7 +5,7 @@ wx-java-spring-boot-starters com.github.binarywang - 4.8.1.B + 4.8.2.B 4.0.0 diff --git a/spring-boot-starters/wx-java-miniapp-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-miniapp-spring-boot-starter/pom.xml index 7e9ffbe308..25d5f66758 100644 --- a/spring-boot-starters/wx-java-miniapp-spring-boot-starter/pom.xml +++ b/spring-boot-starters/wx-java-miniapp-spring-boot-starter/pom.xml @@ -4,7 +4,7 @@ wx-java-spring-boot-starters com.github.binarywang - 4.8.1.B + 4.8.2.B 4.0.0 diff --git a/spring-boot-starters/wx-java-mp-multi-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-mp-multi-spring-boot-starter/pom.xml index 0857fc4e1d..88b11099a3 100644 --- a/spring-boot-starters/wx-java-mp-multi-spring-boot-starter/pom.xml +++ b/spring-boot-starters/wx-java-mp-multi-spring-boot-starter/pom.xml @@ -5,7 +5,7 @@ wx-java-spring-boot-starters com.github.binarywang - 4.8.1.B + 4.8.2.B 4.0.0 diff --git a/spring-boot-starters/wx-java-mp-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-mp-spring-boot-starter/pom.xml index 5d0c2d0269..9e95574bc2 100644 --- a/spring-boot-starters/wx-java-mp-spring-boot-starter/pom.xml +++ b/spring-boot-starters/wx-java-mp-spring-boot-starter/pom.xml @@ -5,7 +5,7 @@ wx-java-spring-boot-starters com.github.binarywang - 4.8.1.B + 4.8.2.B 4.0.0 diff --git a/spring-boot-starters/wx-java-open-multi-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-open-multi-spring-boot-starter/pom.xml index 3b01d26f47..c5cf07e799 100644 --- a/spring-boot-starters/wx-java-open-multi-spring-boot-starter/pom.xml +++ b/spring-boot-starters/wx-java-open-multi-spring-boot-starter/pom.xml @@ -5,7 +5,7 @@ wx-java-spring-boot-starters com.github.binarywang - 4.8.1.B + 4.8.2.B 4.0.0 diff --git a/spring-boot-starters/wx-java-open-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-open-spring-boot-starter/pom.xml index 0b9e203b83..72c856f27c 100644 --- a/spring-boot-starters/wx-java-open-spring-boot-starter/pom.xml +++ b/spring-boot-starters/wx-java-open-spring-boot-starter/pom.xml @@ -5,7 +5,7 @@ wx-java-spring-boot-starters com.github.binarywang - 4.8.1.B + 4.8.2.B 4.0.0 diff --git a/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/pom.xml index c8b08a8a0c..1964bcbbfe 100644 --- a/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/pom.xml +++ b/spring-boot-starters/wx-java-pay-multi-spring-boot-starter/pom.xml @@ -5,7 +5,7 @@ wx-java-spring-boot-starters com.github.binarywang - 4.8.1.B + 4.8.2.B 4.0.0 diff --git a/spring-boot-starters/wx-java-pay-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-pay-spring-boot-starter/pom.xml index 7bbfdbfbf1..ecdb925730 100644 --- a/spring-boot-starters/wx-java-pay-spring-boot-starter/pom.xml +++ b/spring-boot-starters/wx-java-pay-spring-boot-starter/pom.xml @@ -5,7 +5,7 @@ wx-java-spring-boot-starters com.github.binarywang - 4.8.1.B + 4.8.2.B 4.0.0 diff --git a/spring-boot-starters/wx-java-qidian-spring-boot-starter/pom.xml b/spring-boot-starters/wx-java-qidian-spring-boot-starter/pom.xml index f26d5dd881..7e314df780 100644 --- a/spring-boot-starters/wx-java-qidian-spring-boot-starter/pom.xml +++ b/spring-boot-starters/wx-java-qidian-spring-boot-starter/pom.xml @@ -3,7 +3,7 @@ wx-java-spring-boot-starters com.github.binarywang - 4.8.1.B + 4.8.2.B 4.0.0 diff --git a/weixin-graal/pom.xml b/weixin-graal/pom.xml index 272e07de55..a55cc19226 100644 --- a/weixin-graal/pom.xml +++ b/weixin-graal/pom.xml @@ -6,7 +6,7 @@ com.github.binarywang wx-java - 4.8.1.B + 4.8.2.B weixin-graal diff --git a/weixin-java-channel/pom.xml b/weixin-java-channel/pom.xml index b994a98cb5..68edb075ce 100644 --- a/weixin-java-channel/pom.xml +++ b/weixin-java-channel/pom.xml @@ -6,7 +6,7 @@ com.github.binarywang wx-java - 4.8.1.B + 4.8.2.B weixin-java-channel @@ -14,7 +14,7 @@ 微信视频号/微信小店 Java SDK - 2.18.4 + 2.18.6 diff --git a/weixin-java-common/pom.xml b/weixin-java-common/pom.xml index 33fd85d4b3..98b7ddda62 100644 --- a/weixin-java-common/pom.xml +++ b/weixin-java-common/pom.xml @@ -6,7 +6,7 @@ com.github.binarywang wx-java - 4.8.1.B + 4.8.2.B weixin-java-common diff --git a/weixin-java-common/src/main/java/me/chanjar/weixin/common/api/WxConsts.java b/weixin-java-common/src/main/java/me/chanjar/weixin/common/api/WxConsts.java index d7e8936e62..4924682e5e 100644 --- a/weixin-java-common/src/main/java/me/chanjar/weixin/common/api/WxConsts.java +++ b/weixin-java-common/src/main/java/me/chanjar/weixin/common/api/WxConsts.java @@ -53,6 +53,7 @@ public static class XmlMsgType { public static final String DEVICE_STATUS = "device_status"; public static final String HARDWARE = "hardware"; public static final String TRANSFER_CUSTOMER_SERVICE = "transfer_customer_service"; + public static final String TRANSFER_BIZ_AI_IVR = "transfer_biz_ai_ivr"; public static final String UPDATE_TASKCARD = "update_taskcard"; public static final String UPDATE_BUTTON = "update_button"; } diff --git a/weixin-java-common/src/main/java/me/chanjar/weixin/common/bean/CommonUploadParam.java b/weixin-java-common/src/main/java/me/chanjar/weixin/common/bean/CommonUploadParam.java index 3a9872fc92..42e1869502 100644 --- a/weixin-java-common/src/main/java/me/chanjar/weixin/common/bean/CommonUploadParam.java +++ b/weixin-java-common/src/main/java/me/chanjar/weixin/common/bean/CommonUploadParam.java @@ -10,6 +10,8 @@ import java.io.ByteArrayInputStream; import java.io.File; import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; /** * 通用文件上传参数 @@ -34,6 +36,27 @@ public class CommonUploadParam implements Serializable { @NotNull private CommonUploadData data; + /** + * 额外的表单字段,用于在上传文件的同时提交其他表单数据 + * 例如:上传视频素材时需要提交description字段(JSON格式的视频描述信息) + */ + @Nullable + private Map formFields; + + /** + * 为保持向后兼容保留的 2 参数构造函数。 + *

+ * 仅设置文件参数名和上传数据,额外表单字段将为 {@code null}。 + * + * @param name 参数名,如:media + * @param data 上传数据 + * @deprecated 请使用包含 formFields 参数的构造函数或静态工厂方法 {@link #fromFile(String, File)}、{@link #fromBytes(String, String, byte[])} + */ + @Deprecated + public CommonUploadParam(@NotNull String name, @NotNull CommonUploadData data) { + this(name, data, null); + } + /** * 从文件构造 * @@ -43,7 +66,7 @@ public class CommonUploadParam implements Serializable { */ @SneakyThrows public static CommonUploadParam fromFile(String name, File file) { - return new CommonUploadParam(name, CommonUploadData.fromFile(file)); + return new CommonUploadParam(name, CommonUploadData.fromFile(file), null); } /** @@ -55,11 +78,32 @@ public static CommonUploadParam fromFile(String name, File file) { */ @SneakyThrows public static CommonUploadParam fromBytes(String name, @Nullable String fileName, byte[] bytes) { - return new CommonUploadParam(name, new CommonUploadData(fileName, new ByteArrayInputStream(bytes), bytes.length)); + return new CommonUploadParam(name, new CommonUploadData(fileName, new ByteArrayInputStream(bytes), bytes.length), null); + } + + /** + * 添加额外的表单字段 + * + * @param fieldName 表单字段名 + * @param fieldValue 表单字段值 + * @return 当前对象,支持链式调用 + */ + public CommonUploadParam addFormField(String fieldName, String fieldValue) { + if (fieldName == null || fieldName.trim().isEmpty()) { + throw new IllegalArgumentException("表单字段名不能为空"); + } + if (fieldValue == null) { + throw new IllegalArgumentException("表单字段值不能为null"); + } + if (this.formFields == null) { + this.formFields = new HashMap<>(); + } + this.formFields.put(fieldName, fieldValue); + return this; } @Override public String toString() { - return String.format("{name:%s, data:%s}", name, data); + return String.format("{name:%s, data:%s, formFields:%s}", name, data, formFields); } } diff --git a/weixin-java-common/src/main/java/me/chanjar/weixin/common/error/WxMaErrorMsgEnum.java b/weixin-java-common/src/main/java/me/chanjar/weixin/common/error/WxMaErrorMsgEnum.java index ffe9b5e3ea..3f380543b0 100644 --- a/weixin-java-common/src/main/java/me/chanjar/weixin/common/error/WxMaErrorMsgEnum.java +++ b/weixin-java-common/src/main/java/me/chanjar/weixin/common/error/WxMaErrorMsgEnum.java @@ -838,6 +838,34 @@ public enum WxMaErrorMsgEnum { */ CODE_89424(89424, "授权次数到达上限"), + /** + * 微信小程序虚拟支付错误码 + * + * @see 虚拟支付 API 文档 + */ + CODE_268490001(268490001, "openid错误"), + CODE_268490002(268490002, "请求参数字段错误,具体看errmsg"), + CODE_268490003(268490003, "签名错误"), + CODE_268490004(268490004, "重复操作(赠送和代币支付和充值广告金相关接口会返回,表示之前的操作已经成功)"), + CODE_268490005(268490005, "订单已经通过cancel_currency_pay接口退款,不支持再退款"), + CODE_268490006(268490006, "代币的退款/支付操作金额不足"), + CODE_268490007(268490007, "图片或文字存在敏感内容,禁止使用"), + CODE_268490008(268490008, "代币未发布,不允许进行代币操作"), + CODE_268490009(268490009, "用户session_key不存在或已过期,请重新登录"), + CODE_268490011(268490011, "数据生成中,请稍后调用本接口获取"), + CODE_268490012(268490012, "批量任务运行中,请等待完成后才能再次运行"), + CODE_268490013(268490013, "禁止对核销状态的单进行退款"), + CODE_268490014(268490014, "退款操作进行中,稍后可以使用相同参数重试"), + CODE_268490015(268490015, "频率限制"), + CODE_268490016(268490016, "退款的left_fee字段与实际不符,请通过query_order接口查询确认"), + CODE_268490018(268490018, "广告金充值账户行业id不匹配"), + CODE_268490019(268490019, "广告金充值账户id已绑定其他appid"), + CODE_268490020(268490020, "广告金充值账户主体名称错误"), + CODE_268490021(268490021, "账户未完成进件"), + CODE_268490022(268490022, "广告金充值账户无效"), + CODE_268490023(268490023, "广告金余额不足"), + CODE_268490024(268490024, "广告金充值金额必须大于0"), + ; private final int code; diff --git a/weixin-java-common/src/main/java/me/chanjar/weixin/common/executor/CommonUploadRequestExecutorApacheImpl.java b/weixin-java-common/src/main/java/me/chanjar/weixin/common/executor/CommonUploadRequestExecutorApacheImpl.java index 7f19241cdb..dba92e27da 100644 --- a/weixin-java-common/src/main/java/me/chanjar/weixin/common/executor/CommonUploadRequestExecutorApacheImpl.java +++ b/weixin-java-common/src/main/java/me/chanjar/weixin/common/executor/CommonUploadRequestExecutorApacheImpl.java @@ -44,11 +44,19 @@ public String execute(String uri, CommonUploadParam param, WxType wxType) throws if (param != null) { CommonUploadData data = param.getData(); InnerStreamBody part = new InnerStreamBody(data.getInputStream(), ContentType.DEFAULT_BINARY, data.getFileName(), data.getLength()); - HttpEntity entity = MultipartEntityBuilder + MultipartEntityBuilder entityBuilder = MultipartEntityBuilder .create() .addPart(param.getName(), part) - .setMode(HttpMultipartMode.RFC6532) - .build(); + .setMode(HttpMultipartMode.RFC6532); + + // 添加额外的表单字段 + if (param.getFormFields() != null && !param.getFormFields().isEmpty()) { + for (java.util.Map.Entry entry : param.getFormFields().entrySet()) { + entityBuilder.addTextBody(entry.getKey(), entry.getValue(), ContentType.TEXT_PLAIN.withCharset("UTF-8")); + } + } + + HttpEntity entity = entityBuilder.build(); httpPost.setEntity(entity); } String responseContent = requestHttp.getRequestHttpClient().execute(httpPost, Utf8ResponseHandler.INSTANCE); diff --git a/weixin-java-common/src/main/java/me/chanjar/weixin/common/executor/CommonUploadRequestExecutorHttpComponentsImpl.java b/weixin-java-common/src/main/java/me/chanjar/weixin/common/executor/CommonUploadRequestExecutorHttpComponentsImpl.java index f79eaa49b8..f79e4cd96f 100644 --- a/weixin-java-common/src/main/java/me/chanjar/weixin/common/executor/CommonUploadRequestExecutorHttpComponentsImpl.java +++ b/weixin-java-common/src/main/java/me/chanjar/weixin/common/executor/CommonUploadRequestExecutorHttpComponentsImpl.java @@ -41,11 +41,19 @@ public String execute(String uri, CommonUploadParam param, WxType wxType) throws if (param != null) { CommonUploadData data = param.getData(); InnerStreamBody part = new InnerStreamBody(data.getInputStream(), ContentType.DEFAULT_BINARY, data.getFileName(), data.getLength()); - HttpEntity entity = MultipartEntityBuilder + MultipartEntityBuilder entityBuilder = MultipartEntityBuilder .create() .addPart(param.getName(), part) - .setMode(HttpMultipartMode.EXTENDED) - .build(); + .setMode(HttpMultipartMode.EXTENDED); + + // 添加额外的表单字段 + if (param.getFormFields() != null && !param.getFormFields().isEmpty()) { + for (java.util.Map.Entry entry : param.getFormFields().entrySet()) { + entityBuilder.addTextBody(entry.getKey(), entry.getValue(), ContentType.TEXT_PLAIN.withCharset("UTF-8")); + } + } + + HttpEntity entity = entityBuilder.build(); httpPost.setEntity(entity); } String responseContent = requestHttp.getRequestHttpClient().execute(httpPost, Utf8ResponseHandler.INSTANCE); diff --git a/weixin-java-common/src/main/java/me/chanjar/weixin/common/executor/CommonUploadRequestExecutorJoddHttpImpl.java b/weixin-java-common/src/main/java/me/chanjar/weixin/common/executor/CommonUploadRequestExecutorJoddHttpImpl.java index 36e8660f77..182820d076 100644 --- a/weixin-java-common/src/main/java/me/chanjar/weixin/common/executor/CommonUploadRequestExecutorJoddHttpImpl.java +++ b/weixin-java-common/src/main/java/me/chanjar/weixin/common/executor/CommonUploadRequestExecutorJoddHttpImpl.java @@ -39,6 +39,14 @@ public String execute(String uri, CommonUploadParam param, WxType wxType) throws } request.withConnectionProvider(requestHttp.getRequestHttpClient()); request.form(param.getName(), new CommonUploadParamToUploadableAdapter(param.getData())); + + // 添加额外的表单字段 + if (param.getFormFields() != null && !param.getFormFields().isEmpty()) { + for (java.util.Map.Entry entry : param.getFormFields().entrySet()) { + request.form(entry.getKey(), entry.getValue()); + } + } + HttpResponse response = request.send(); response.charset(StandardCharsets.UTF_8.name()); String responseContent = response.bodyText(); diff --git a/weixin-java-common/src/main/java/me/chanjar/weixin/common/executor/CommonUploadRequestExecutorOkHttpImpl.java b/weixin-java-common/src/main/java/me/chanjar/weixin/common/executor/CommonUploadRequestExecutorOkHttpImpl.java index 40a4622b89..6a0343980f 100644 --- a/weixin-java-common/src/main/java/me/chanjar/weixin/common/executor/CommonUploadRequestExecutorOkHttpImpl.java +++ b/weixin-java-common/src/main/java/me/chanjar/weixin/common/executor/CommonUploadRequestExecutorOkHttpImpl.java @@ -31,10 +31,18 @@ public CommonUploadRequestExecutorOkHttpImpl(RequestHttp entry : param.getFormFields().entrySet()) { + bodyBuilder.addFormDataPart(entry.getKey(), entry.getValue()); + } + } + + RequestBody body = bodyBuilder.build(); Request request = new Request.Builder().url(uri).post(body).build(); try (Response response = requestHttp.getRequestHttpClient().newCall(request).execute()) { diff --git a/weixin-java-common/src/main/java/me/chanjar/weixin/common/util/xml/XStreamCDataListConverter.java b/weixin-java-common/src/main/java/me/chanjar/weixin/common/util/xml/XStreamCDataListConverter.java new file mode 100644 index 0000000000..0b55a9c037 --- /dev/null +++ b/weixin-java-common/src/main/java/me/chanjar/weixin/common/util/xml/XStreamCDataListConverter.java @@ -0,0 +1,54 @@ +package me.chanjar.weixin.common.util.xml; + +import com.thoughtworks.xstream.converters.Converter; +import com.thoughtworks.xstream.converters.MarshallingContext; +import com.thoughtworks.xstream.converters.UnmarshallingContext; +import com.thoughtworks.xstream.io.HierarchicalStreamReader; +import com.thoughtworks.xstream.io.HierarchicalStreamWriter; + +/** + * 兼容两种格式的字符串列表转换器: + *

    + *
  • 旧格式(4.8.0之前):<MemChangeList><![CDATA[id1,id2]]></MemChangeList>
  • + *
  • 新格式(4.8.0起):<MemChangeList><Item><![CDATA[id1]]></Item></MemChangeList>
  • + *
+ * 解析结果统一为逗号分隔的字符串。 + */ +public class XStreamCDataListConverter implements Converter { + + @Override + public void marshal(Object source, HierarchicalStreamWriter writer, MarshallingContext context) { + if (source != null) { + writer.setValue(""); + } + } + + @Override + public Object unmarshal(HierarchicalStreamReader reader, UnmarshallingContext context) { + if (reader.hasMoreChildren()) { + // 新格式:含有 子元素 + StringBuilder sb = new StringBuilder(); + while (reader.hasMoreChildren()) { + reader.moveDown(); + String value = reader.getValue(); + if (value != null && !value.isEmpty()) { + if (sb.length() > 0) { + sb.append(","); + } + sb.append(value); + } + reader.moveUp(); + } + return sb.length() > 0 ? sb.toString() : null; + } else { + // 旧格式:直接 CDATA 文本 + String value = reader.getValue(); + return (value != null && !value.isEmpty()) ? value : null; + } + } + + @Override + public boolean canConvert(Class type) { + return type == String.class; + } +} diff --git a/weixin-java-common/src/test/java/me/chanjar/weixin/common/bean/CommonUploadParamTest.java b/weixin-java-common/src/test/java/me/chanjar/weixin/common/bean/CommonUploadParamTest.java new file mode 100644 index 0000000000..05c8b379d3 --- /dev/null +++ b/weixin-java-common/src/test/java/me/chanjar/weixin/common/bean/CommonUploadParamTest.java @@ -0,0 +1,119 @@ +package me.chanjar.weixin.common.bean; + +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.io.File; +import java.util.HashMap; +import java.util.Map; + +/** + * CommonUploadParam 单元测试 + * + * @author Binary Wang + */ +@Test +public class CommonUploadParamTest { + + @Test + public void testFromFile() { + File file = new File("test.txt"); + CommonUploadParam param = CommonUploadParam.fromFile("media", file); + + Assert.assertNotNull(param); + Assert.assertEquals(param.getName(), "media"); + Assert.assertNotNull(param.getData()); + Assert.assertNull(param.getFormFields()); + } + + @Test + public void testFromBytes() { + byte[] bytes = "test content".getBytes(); + CommonUploadParam param = CommonUploadParam.fromBytes("media", "test.txt", bytes); + + Assert.assertNotNull(param); + Assert.assertEquals(param.getName(), "media"); + Assert.assertNotNull(param.getData()); + Assert.assertEquals(param.getData().getFileName(), "test.txt"); + Assert.assertNull(param.getFormFields()); + } + + @Test + public void testAddFormField() { + File file = new File("test.txt"); + CommonUploadParam param = CommonUploadParam.fromFile("media", file); + + // 添加单个表单字段 + param.addFormField("title", "测试标题"); + + Assert.assertNotNull(param.getFormFields()); + Assert.assertEquals(param.getFormFields().size(), 1); + Assert.assertEquals(param.getFormFields().get("title"), "测试标题"); + + // 添加多个表单字段 + param.addFormField("introduction", "测试介绍"); + + Assert.assertEquals(param.getFormFields().size(), 2); + Assert.assertEquals(param.getFormFields().get("introduction"), "测试介绍"); + } + + @Test + public void testAddFormFieldChaining() { + File file = new File("test.txt"); + CommonUploadParam param = CommonUploadParam.fromFile("media", file) + .addFormField("title", "测试标题") + .addFormField("introduction", "测试介绍"); + + Assert.assertNotNull(param.getFormFields()); + Assert.assertEquals(param.getFormFields().size(), 2); + Assert.assertEquals(param.getFormFields().get("title"), "测试标题"); + Assert.assertEquals(param.getFormFields().get("introduction"), "测试介绍"); + } + + @Test + public void testConstructorWithFormFields() { + CommonUploadData data = new CommonUploadData("test.txt", null, 0); + Map formFields = new HashMap<>(); + formFields.put("title", "测试标题"); + formFields.put("introduction", "测试介绍"); + + CommonUploadParam param = new CommonUploadParam("media", data, formFields); + + Assert.assertNotNull(param.getFormFields()); + Assert.assertEquals(param.getFormFields().size(), 2); + Assert.assertEquals(param.getFormFields().get("title"), "测试标题"); + Assert.assertEquals(param.getFormFields().get("introduction"), "测试介绍"); + } + + @Test + public void testToString() { + File file = new File("test.txt"); + CommonUploadParam param = CommonUploadParam.fromFile("media", file) + .addFormField("title", "测试标题"); + + String str = param.toString(); + Assert.assertTrue(str.contains("name:media")); + Assert.assertTrue(str.contains("formFields:")); + } + + @Test(expectedExceptions = IllegalArgumentException.class) + public void testAddFormFieldWithNullFieldName() { + File file = new File("test.txt"); + CommonUploadParam param = CommonUploadParam.fromFile("media", file); + param.addFormField(null, "value"); + } + + @Test(expectedExceptions = IllegalArgumentException.class) + public void testAddFormFieldWithEmptyFieldName() { + File file = new File("test.txt"); + CommonUploadParam param = CommonUploadParam.fromFile("media", file); + param.addFormField("", "value"); + } + + @Test(expectedExceptions = IllegalArgumentException.class) + public void testAddFormFieldWithNullFieldValue() { + File file = new File("test.txt"); + CommonUploadParam param = CommonUploadParam.fromFile("media", file); + param.addFormField("fieldName", null); + } +} diff --git a/weixin-java-common/src/test/java/me/chanjar/weixin/common/error/WxMaErrorMsgEnumTest.java b/weixin-java-common/src/test/java/me/chanjar/weixin/common/error/WxMaErrorMsgEnumTest.java new file mode 100644 index 0000000000..66147bb7ec --- /dev/null +++ b/weixin-java-common/src/test/java/me/chanjar/weixin/common/error/WxMaErrorMsgEnumTest.java @@ -0,0 +1,62 @@ +package me.chanjar.weixin.common.error; + +import org.testng.annotations.Test; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertNull; + +/** + * 微信小程序错误码枚举测试 + * + * @author GitHub Copilot + */ +@Test +public class WxMaErrorMsgEnumTest { + + public void testFindMsgByCodeForExistingCode() { + String msg = WxMaErrorMsgEnum.findMsgByCode(40001); + assertNotNull(msg); + } + + public void testFindMsgByCodeForNonExistingCode() { + String msg = WxMaErrorMsgEnum.findMsgByCode(999999); + assertNull(msg); + } + + /** + * 验证微信小程序虚拟支付错误码 + */ + public void testVirtualPaymentErrorCodes() { + assertEquals(WxMaErrorMsgEnum.findMsgByCode(268490001), "openid错误"); + assertEquals(WxMaErrorMsgEnum.findMsgByCode(268490002), "请求参数字段错误,具体看errmsg"); + assertEquals(WxMaErrorMsgEnum.findMsgByCode(268490003), "签名错误"); + assertEquals(WxMaErrorMsgEnum.findMsgByCode(268490004), "重复操作(赠送和代币支付和充值广告金相关接口会返回,表示之前的操作已经成功)"); + assertEquals(WxMaErrorMsgEnum.findMsgByCode(268490005), "订单已经通过cancel_currency_pay接口退款,不支持再退款"); + assertEquals(WxMaErrorMsgEnum.findMsgByCode(268490006), "代币的退款/支付操作金额不足"); + assertEquals(WxMaErrorMsgEnum.findMsgByCode(268490007), "图片或文字存在敏感内容,禁止使用"); + assertEquals(WxMaErrorMsgEnum.findMsgByCode(268490008), "代币未发布,不允许进行代币操作"); + assertEquals(WxMaErrorMsgEnum.findMsgByCode(268490009), "用户session_key不存在或已过期,请重新登录"); + assertEquals(WxMaErrorMsgEnum.findMsgByCode(268490011), "数据生成中,请稍后调用本接口获取"); + assertEquals(WxMaErrorMsgEnum.findMsgByCode(268490012), "批量任务运行中,请等待完成后才能再次运行"); + assertEquals(WxMaErrorMsgEnum.findMsgByCode(268490013), "禁止对核销状态的单进行退款"); + assertEquals(WxMaErrorMsgEnum.findMsgByCode(268490014), "退款操作进行中,稍后可以使用相同参数重试"); + assertEquals(WxMaErrorMsgEnum.findMsgByCode(268490015), "频率限制"); + assertEquals(WxMaErrorMsgEnum.findMsgByCode(268490016), "退款的left_fee字段与实际不符,请通过query_order接口查询确认"); + assertEquals(WxMaErrorMsgEnum.findMsgByCode(268490018), "广告金充值账户行业id不匹配"); + assertEquals(WxMaErrorMsgEnum.findMsgByCode(268490019), "广告金充值账户id已绑定其他appid"); + assertEquals(WxMaErrorMsgEnum.findMsgByCode(268490020), "广告金充值账户主体名称错误"); + assertEquals(WxMaErrorMsgEnum.findMsgByCode(268490021), "账户未完成进件"); + assertEquals(WxMaErrorMsgEnum.findMsgByCode(268490022), "广告金充值账户无效"); + assertEquals(WxMaErrorMsgEnum.findMsgByCode(268490023), "广告金余额不足"); + assertEquals(WxMaErrorMsgEnum.findMsgByCode(268490024), "广告金充值金额必须大于0"); + } + + /** + * 验证虚拟支付错误码中不存在的编号(如268490010、268490017)返回null + */ + public void testVirtualPaymentMissingCodes() { + assertNull(WxMaErrorMsgEnum.findMsgByCode(268490010)); + assertNull(WxMaErrorMsgEnum.findMsgByCode(268490017)); + } +} diff --git a/weixin-java-cp/pom.xml b/weixin-java-cp/pom.xml index 922d4f6b84..4d5d172ed2 100644 --- a/weixin-java-cp/pom.xml +++ b/weixin-java-cp/pom.xml @@ -7,7 +7,7 @@ com.github.binarywang wx-java - 4.8.1.B + 4.8.2.B weixin-java-cp @@ -96,7 +96,6 @@ org.bouncycastle bcprov-jdk18on - 1.80 diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpHrService.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpHrService.java new file mode 100644 index 0000000000..d9d6ed0129 --- /dev/null +++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpHrService.java @@ -0,0 +1,68 @@ +package me.chanjar.weixin.cp.api; + +import me.chanjar.weixin.common.error.WxErrorException; +import me.chanjar.weixin.cp.bean.hr.WxCpHrEmployeeFieldData; +import me.chanjar.weixin.cp.bean.hr.WxCpHrEmployeeFieldDataResp; +import me.chanjar.weixin.cp.bean.hr.WxCpHrEmployeeFieldInfoResp; + +import java.util.List; + +/** + * 人事助手相关接口. + * 官方文档:... + * + * @author copilot + */ +public interface WxCpHrService { + + /** + * 获取员工档案字段信息. + *

+ * 请求方式:POST(HTTPS) + * 请求地址:... + * 权限说明: + * 需要配置人事助手的secret,调用接口前需给对应成员赋予人事小助手应用的权限。 + * + * @param fields 指定字段key列表,不填则返回全部字段 + * @return 字段信息响应 wx cp hr employee field info resp + * @throws WxErrorException the wx error exception + */ + WxCpHrEmployeeFieldInfoResp getFieldInfo(List fields) throws WxErrorException; + + /** + * 获取员工档案数据. + *

+ * 请求方式:POST(HTTPS) + * 请求地址:... + * 权限说明: + * 需要配置人事助手的secret,调用接口前需给对应成员赋予人事小助手应用的权限。 + * + * @param userid 员工userid + * @param fields 指定字段key列表 + * @return 员工档案数据响应 wx cp hr employee field data resp + * @throws WxErrorException the wx error exception + */ + WxCpHrEmployeeFieldDataResp getEmployeeFieldInfo(String userid, List fields) throws WxErrorException; + + /** + * 获取员工档案数据. + *

+ * 请求方式:POST(HTTPS) + * 请求地址:... + */ + WxCpHrEmployeeFieldDataResp getEmployeeFieldInfo(String userid, boolean getAll, List fields) throws WxErrorException; + + /** + * 更新员工档案数据. + *

+ * 请求方式:POST(HTTPS) + * 请求地址:... + * 权限说明: + * 需要配置人事助手的secret,调用接口前需给对应成员赋予人事小助手应用的权限。 + * + * @param userid 员工userid + * @param fieldList 字段数据列表 + * @throws WxErrorException the wx error exception + */ + void updateEmployeeFieldInfo(String userid, List fieldList) throws WxErrorException; +} diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpMsgAuditService.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpMsgAuditService.java index b754e32b7e..5e8811953f 100644 --- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpMsgAuditService.java +++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpMsgAuditService.java @@ -215,4 +215,20 @@ void downloadMediaFile(@NonNull String sdkfileid, String proxy, String passwd, @ */ WxCpAgreeInfo checkSingleAgree(@NonNull WxCpCheckAgreeRequest checkAgreeRequest) throws WxErrorException; + /** + * 关闭当前线程持有的SDK,释放本地资源。 + *

+ * 在线程池场景下,任务结束后必须在 finally 块中调用此方法,防止SDK实例随线程复用而泄漏。 + * 独立线程或一次性任务也建议调用,以主动释放原生资源。 + */ + void closeThreadLocalSdk(); + + /** + * 关闭所有会话存档SDK实例,释放全部原生资源。 + *

+ * 适用于应用关闭阶段(如 Spring Bean 销毁阶段 {@code @PreDestroy} 或 Shutdown Hook)。 + * 调用后,所有线程的SDK均不可再使用。 + */ + void closeAllSdks(); + } diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpOaWeDocService.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpOaWeDocService.java index d63d32694a..712bc2a89c 100644 --- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpOaWeDocService.java +++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpOaWeDocService.java @@ -5,6 +5,10 @@ import me.chanjar.weixin.cp.bean.WxCpBaseResp; import me.chanjar.weixin.cp.bean.oa.doc.*; +import java.io.File; +import java.util.Collections; +import java.util.List; + /** * 企业微信文档相关接口. * 文档 @@ -79,6 +83,89 @@ public interface WxCpOaWeDocService { */ WxCpDocShare docShare(@NonNull String docId) throws WxErrorException; + /** + * 分享文档/收集表 + * 该接口用于获取文档或收集表的分享链接。 + *

+ * 请求方式:POST(HTTPS) + * 请求地址: https://qyapi.weixin.qq.com/cgi-bin/wedoc/doc_share?access_token=ACCESS_TOKEN + * + * @param request 分享请求,docid/formid 二选一 + * @return url 文档分享链接 + * @throws WxErrorException the wx error exception + */ + WxCpDocShare docShare(@NonNull WxCpDocShareRequest request) throws WxErrorException; + + /** + * 获取文档权限信息 + * 该接口用于获取文档、表格、智能表格的查看规则、文档通知范围及权限、安全设置信息。 + *

+ * 请求方式:POST(HTTPS) + * 请求地址: https://qyapi.weixin.qq.com/cgi-bin/wedoc/doc_get_auth?access_token=ACCESS_TOKEN + * + * @param docId 文档docid + * @return 文档权限信息 + * @throws WxErrorException the wx error exception + */ + WxCpDocAuthInfo docGetAuth(@NonNull String docId) throws WxErrorException; + + /** + * 修改文档查看规则 + * 该接口用于修改文档、表格、智能表格查看规则。 + *

+ * 请求方式:POST(HTTPS) + * 请求地址: https://qyapi.weixin.qq.com/cgi-bin/wedoc/mod_doc_join_rule?access_token=ACCESS_TOKEN + * + * @param request 修改文档查看规则请求 + * @return wx cp base resp + * @throws WxErrorException the wx error exception + */ + WxCpBaseResp docModifyJoinRule(@NonNull WxCpDocModifyJoinRuleRequest request) throws WxErrorException; + + /** + * 修改文档通知范围及权限 + * 该接口用于修改文档、表格、智能表格通知范围列表。 + *

+ * 请求方式:POST(HTTPS) + * 请求地址: https://qyapi.weixin.qq.com/cgi-bin/wedoc/mod_doc_member?access_token=ACCESS_TOKEN + * + * @param request 修改文档通知范围及权限请求 + * @return wx cp base resp + * @throws WxErrorException the wx error exception + */ + WxCpBaseResp docModifyMember(@NonNull WxCpDocModifyMemberRequest request) throws WxErrorException; + + /** + * 修改文档安全设置 + * 该接口用于修改文档、表格、智能表格的安全设置。 + *

+ * 请求方式:POST(HTTPS) + * 请求地址: https://qyapi.weixin.qq.com/cgi-bin/wedoc/mod_doc_safty_setting?access_token=ACCESS_TOKEN + * + * @param request 修改文档安全设置请求 + * @return wx cp base resp + * @throws WxErrorException the wx error exception + */ + WxCpBaseResp docModifySafetySetting( + @NonNull WxCpDocModifySafetySettingRequest request + ) throws WxErrorException; + + /** + * @deprecated Use {@link #docModifySafetySetting(WxCpDocModifySafetySettingRequest)} instead. + */ + @Deprecated + default WxCpBaseResp docModifySaftySetting( + @NonNull WxCpDocModifySaftySettingRequest request + ) throws WxErrorException { + WxCpDocModifySafetySettingRequest newReq = + WxCpDocModifySafetySettingRequest.builder() + .docId(request.getDocId()) + .enableReadonlyCopy(request.getEnableReadonlyCopy()) + .watermark(request.getWatermark()) + .build(); + return docModifySafetySetting(newReq); + } + /** * 编辑表格内容 * 该接口可以对一个在线表格批量执行多个更新操作 @@ -127,4 +214,330 @@ public interface WxCpOaWeDocService { */ WxCpDocSheetData getSheetRangeData(@NonNull WxCpDocSheetGetDataRequest request) throws WxErrorException; + /** + * 获取文档数据 + * 该接口用于获取在线文档内容数据。 + *

+ * 请求方式:POST(HTTPS) + * 请求地址: https://qyapi.weixin.qq.com/cgi-bin/wedoc/get_doc_data?access_token=ACCESS_TOKEN + * + * @param request 获取文档数据请求参数 + * @return 文档内容数据 + * @throws WxErrorException the wx error exception + */ + WxCpDocData docGetData(@NonNull WxCpDocGetDataRequest request) throws WxErrorException; + + /** + * 编辑文档内容 + * 该接口用于编辑在线文档内容。 + *

+ * 请求方式:POST(HTTPS) + * 请求地址: https://qyapi.weixin.qq.com/cgi-bin/wedoc/mod_doc?access_token=ACCESS_TOKEN + * + * @param request 编辑文档内容请求参数 + * @return wx cp base resp + * @throws WxErrorException the wx error exception + */ + WxCpBaseResp docModify(@NonNull WxCpDocModifyRequest request) throws WxErrorException; + + /** + * 上传文档图片 + * 该接口用于上传在线文档编辑时使用的图片资源。 + *

+ * 请求方式:POST(HTTPS) + * 请求地址: https://qyapi.weixin.qq.com/cgi-bin/wedoc/upload_doc_image?access_token=ACCESS_TOKEN + * + * @param file 图片文件 + * @return 上传结果 + * @throws WxErrorException the wx error exception + */ + WxCpDocImageUploadResult docUploadImage(@NonNull File file) throws WxErrorException; + + /** + * 添加文档高级功能账号 + * 该接口用于为在线文档添加高级功能账号。 + *

+ * 请求方式:POST(HTTPS) + * 请求地址: https://qyapi.weixin.qq.com/cgi-bin/wedoc/add_admin?access_token=ACCESS_TOKEN + * + * @param request 文档高级功能账号请求 + * @return wx cp base resp + * @throws WxErrorException the wx error exception + */ + WxCpBaseResp docAddAdmin(@NonNull WxCpDocAdminRequest request) throws WxErrorException; + + /** + * 删除文档高级功能账号 + * 该接口用于删除在线文档的高级功能账号。 + *

+ * 请求方式:POST(HTTPS) + * 请求地址: https://qyapi.weixin.qq.com/cgi-bin/wedoc/del_admin?access_token=ACCESS_TOKEN + * + * @param request 文档高级功能账号请求 + * @return wx cp base resp + * @throws WxErrorException the wx error exception + */ + WxCpBaseResp docDeleteAdmin(@NonNull WxCpDocAdminRequest request) throws WxErrorException; + + /** + * 获取文档高级功能账号列表 + * 该接口用于获取在线文档的高级功能账号列表。 + *

+ * 请求方式:POST(HTTPS) + * 请求地址: https://qyapi.weixin.qq.com/cgi-bin/wedoc/get_admin_list?access_token=ACCESS_TOKEN + * + * @param docId 文档 docid + * @return 文档高级功能账号列表 + * @throws WxErrorException the wx error exception + */ + WxCpDocAdminListResult docGetAdminList(@NonNull String docId) throws WxErrorException; + + /** + * 获取智能表格内容权限 + * 该接口用于获取智能表格字段/记录等内容权限信息。 + *

+ * 请求方式:POST(HTTPS) + * 请求地址: https://qyapi.weixin.qq.com/cgi-bin/wedoc/smartsheet/get_sheet_auth?access_token=ACCESS_TOKEN + * + * @param request 智能表格内容权限请求 + * @return 智能表格内容权限 + * @throws WxErrorException the wx error exception + */ + WxCpDocSmartSheetAuth smartSheetGetAuth(@NonNull WxCpDocSmartSheetAuthRequest request) throws WxErrorException; + + /** + * 修改智能表格内容权限 + * 该接口用于修改智能表格字段/记录等内容权限信息。 + *

+ * 请求方式:POST(HTTPS) + * 请求地址: https://qyapi.weixin.qq.com/cgi-bin/wedoc/smartsheet/mod_sheet_auth?access_token=ACCESS_TOKEN + * + * @param request 修改智能表格内容权限请求 + * @return wx cp base resp + * @throws WxErrorException the wx error exception + */ + WxCpBaseResp smartSheetModifyAuth(@NonNull WxCpDocSmartSheetModifyAuthRequest request) throws WxErrorException; + + /** + * 获取智能表格工作表信息. + * + * @param request 智能表格请求 + * @return 智能表格工作表信息 + * @throws WxErrorException the wx error exception + */ + WxCpDocSmartSheetResult smartSheetGetSheet(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException; + + /** + * 新增智能表格工作表. + * + * @param request 智能表格请求 + * @return 智能表格工作表信息 + * @throws WxErrorException the wx error exception + */ + WxCpDocSmartSheetResult smartSheetAddSheet(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException; + + /** + * 删除智能表格工作表. + * + * @param request 智能表格请求 + * @return wx cp base resp + * @throws WxErrorException the wx error exception + */ + WxCpBaseResp smartSheetDeleteSheet(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException; + + /** + * 更新智能表格工作表. + * + * @param request 智能表格请求 + * @return wx cp base resp + * @throws WxErrorException the wx error exception + */ + WxCpBaseResp smartSheetUpdateSheet(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException; + + /** + * 获取智能表格视图. + * + * @param request 智能表格请求 + * @return 智能表格视图 + * @throws WxErrorException the wx error exception + */ + WxCpDocSmartSheetResult smartSheetGetViews(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException; + + /** + * 新增智能表格视图. + * + * @param request 智能表格请求 + * @return 智能表格视图 + * @throws WxErrorException the wx error exception + */ + WxCpDocSmartSheetResult smartSheetAddView(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException; + + /** + * 删除智能表格视图. + * + * @param request 智能表格请求 + * @return wx cp base resp + * @throws WxErrorException the wx error exception + */ + WxCpBaseResp smartSheetDeleteViews(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException; + + /** + * 更新智能表格视图. + * + * @param request 智能表格请求 + * @return wx cp base resp + * @throws WxErrorException the wx error exception + */ + WxCpBaseResp smartSheetUpdateView(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException; + + /** + * 获取智能表格字段. + * + * @param request 智能表格请求 + * @return 智能表格字段 + * @throws WxErrorException the wx error exception + */ + WxCpDocSmartSheetResult smartSheetGetFields(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException; + + /** + * 新增智能表格字段. + * + * @param request 智能表格请求 + * @return 智能表格字段 + * @throws WxErrorException the wx error exception + */ + WxCpDocSmartSheetResult smartSheetAddFields(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException; + + /** + * 删除智能表格字段. + * + * @param request 智能表格请求 + * @return wx cp base resp + * @throws WxErrorException the wx error exception + */ + WxCpBaseResp smartSheetDeleteFields(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException; + + /** + * 更新智能表格字段. + * + * @param request 智能表格请求 + * @return wx cp base resp + * @throws WxErrorException the wx error exception + */ + WxCpBaseResp smartSheetUpdateFields(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException; + + /** + * 获取智能表格记录. + * + * @param request 智能表格请求 + * @return 智能表格记录 + * @throws WxErrorException the wx error exception + */ + WxCpDocSmartSheetResult smartSheetGetRecords(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException; + + /** + * 新增智能表格记录. + * + * @param request 智能表格请求 + * @return 智能表格记录 + * @throws WxErrorException the wx error exception + */ + WxCpDocSmartSheetResult smartSheetAddRecords(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException; + + /** + * 删除智能表格记录. + * + * @param request 智能表格请求 + * @return wx cp base resp + * @throws WxErrorException the wx error exception + */ + WxCpBaseResp smartSheetDeleteRecords(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException; + + /** + * 更新智能表格记录. + * + * @param request 智能表格请求 + * @return wx cp base resp + * @throws WxErrorException the wx error exception + */ + WxCpBaseResp smartSheetUpdateRecords(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException; + + /** + * 创建收集表 + * 该接口用于创建收集表。 + *

+ * 请求方式:POST(HTTPS) + * 请求地址: https://qyapi.weixin.qq.com/cgi-bin/wedoc/create_collect?access_token=ACCESS_TOKEN + * + * @param request 创建收集表请求 + * @return 创建收集表结果 + * @throws WxErrorException the wx error exception + */ + WxCpFormCreateResult formCreate(@NonNull WxCpFormCreateRequest request) throws WxErrorException; + + /** + * 编辑收集表 + * 该接口用于编辑收集表。 + *

+ * 请求方式:POST(HTTPS) + * 请求地址: https://qyapi.weixin.qq.com/cgi-bin/wedoc/modify_collect?access_token=ACCESS_TOKEN + * + * @param request 编辑收集表请求 + * @return wx cp base resp + * @throws WxErrorException the wx error exception + */ + WxCpBaseResp formModify(@NonNull WxCpFormModifyRequest request) throws WxErrorException; + + /** + * 获取收集表信息 + * 该接口用于读取收集表的信息。 + *

+ * 请求方式:POST(HTTPS) + * 请求地址: https://qyapi.weixin.qq.com/cgi-bin/wedoc/get_form_info?access_token=ACCESS_TOKEN + * + * @param formId 收集表id + * @return 收集表信息 + * @throws WxErrorException the wx error exception + */ + WxCpFormInfoResult formInfo(@NonNull String formId) throws WxErrorException; + + /** + * 获取收集表统计信息 + * 该接口用于获取收集表的统计信息、已回答成员列表和未回答成员列表。 + *

+ * 请求方式:POST(HTTPS) + * 请求地址: https://qyapi.weixin.qq.com/cgi-bin/wedoc/get_form_statistic?access_token=ACCESS_TOKEN + * + * @param requests 收集表统计请求数组 + * @return 收集表统计结果(包含 statistic_list) + * @throws WxErrorException the wx error exception + */ + WxCpFormStatisticResult formStatistic(@NonNull List requests) throws WxErrorException; + + /** + * 单个收集表统计查询的兼容封装,底层仍按官方数组请求发送。 + * + * @param request 收集表统计请求 + * @return 收集表统计信息 + * @throws WxErrorException the wx error exception + */ + default WxCpFormStatistic formStatistic(@NonNull WxCpFormStatisticRequest request) throws WxErrorException { + WxCpFormStatisticResult result = formStatistic(Collections.singletonList(request)); + List list = result == null ? null : result.getStatisticList(); + return list == null || list.isEmpty() ? null : list.get(0); + } + + /** + * 获取收集表答案 + * 该接口用于读取收集表的答案。 + *

+ * 请求方式:POST(HTTPS) + * 请求地址: https://qyapi.weixin.qq.com/cgi-bin/wedoc/get_form_answer?access_token=ACCESS_TOKEN + * + * @param request 收集表答案请求 + * @return 收集表答案 + * @throws WxErrorException the wx error exception + */ + WxCpFormAnswer formAnswer(@NonNull WxCpFormAnswerRequest request) throws WxErrorException; + } diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpService.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpService.java index 76012a2812..f66acc0252 100644 --- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpService.java +++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpService.java @@ -57,6 +57,19 @@ public interface WxCpService extends WxService { */ String getAccessToken(boolean forceRefresh) throws WxErrorException; + /** + *

+   * 获取会话存档access_token,本方法线程安全
+   * 会话存档相关接口需要使用会话存档secret获取单独的access_token
+   * 详情请见: https://developer.work.weixin.qq.com/document/path/91782
+   * 
+ * + * @param forceRefresh 强制刷新 + * @return 会话存档专用的access token + * @throws WxErrorException the wx error exception + */ + String getMsgAuditAccessToken(boolean forceRefresh) throws WxErrorException; + /** * 获得jsapi_ticket,不强制刷新jsapi_ticket * @@ -194,6 +207,19 @@ public interface WxCpService extends WxService { */ String postWithoutToken(String url, String postData) throws WxErrorException; + /** + *
+   * 使用会话存档access token发起post请求
+   * 会话存档相关API需要使用会话存档专用的secret获取独立的access token
+   * 
+ * + * @param url 接口地址 + * @param postData 请求body字符串 + * @return the string + * @throws WxErrorException the wx error exception + */ + String postForMsgAudit(String url, String postData) throws WxErrorException; + /** *
    * Service没有实现某个API的时候,可以用这个,
@@ -455,6 +481,13 @@ public interface WxCpService extends WxService {
    */
   WxCpOaWeDriveService getOaWeDriveService();
 
+  /**
+   * 获取OA效率工具 文档的服务类对象
+   *
+   * @return oa we doc service
+   */
+  WxCpOaWeDocService getOaWeDocService();
+
   /**
    * 获取会话存档相关接口的服务类对象
    *
@@ -594,4 +627,11 @@ public interface WxCpService extends WxService {
    * @return 智能机器人服务 intelligent robot service
    */
   WxCpIntelligentRobotService getIntelligentRobotService();
+
+  /**
+   * 获取人事助手服务
+   *
+   * @return 人事助手服务 hr service
+   */
+  WxCpHrService getHrService();
 }
diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/BaseWxCpServiceImpl.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/BaseWxCpServiceImpl.java
index bc18c9bc7a..7c72cb9a8c 100644
--- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/BaseWxCpServiceImpl.java
+++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/BaseWxCpServiceImpl.java
@@ -59,6 +59,7 @@ public abstract class BaseWxCpServiceImpl implements WxCpService, RequestH
   private final WxCpLivingService livingService = new WxCpLivingServiceImpl(this);
   private final WxCpOaAgentService oaAgentService = new WxCpOaAgentServiceImpl(this);
   private final WxCpOaWeDriveService oaWeDriveService = new WxCpOaWeDriveServiceImpl(this);
+  private final WxCpOaWeDocService oaWeDocService = new WxCpOaWeDocServiceImpl(this);
   private final WxCpMsgAuditService msgAuditService = new WxCpMsgAuditServiceImpl(this);
   private final WxCpTaskCardService taskCardService = new WxCpTaskCardServiceImpl(this);
   private final WxCpExternalContactService externalContactService = new WxCpExternalContactServiceImpl(this);
@@ -75,6 +76,7 @@ public abstract class BaseWxCpServiceImpl implements WxCpService, RequestH
   private final WxCpMeetingService meetingService = new WxCpMeetingServiceImpl(this);
   private final WxCpCorpGroupService corpGroupService = new WxCpCorpGroupServiceImpl(this);
   private final WxCpIntelligentRobotService intelligentRobotService = new WxCpIntelligentRobotServiceImpl(this);
+  private final WxCpHrService hrService = new WxCpHrServiceImpl(this);
 
   /**
    * 全局的是否正在刷新access token的锁.
@@ -301,6 +303,16 @@ public String postWithoutToken(String url, String postData) throws WxErrorExcept
     return this.executeNormal(SimplePostRequestExecutor.create(this), url, postData);
   }
 
+  @Override
+  public String postForMsgAudit(String url, String postData) throws WxErrorException {
+    // 获取会话存档专用的access token
+    String msgAuditAccessToken = getMsgAuditAccessToken(false);
+    // 拼接access_token参数
+    String urlWithToken = url + (url.contains("?") ? "&" : "?") + "access_token=" + msgAuditAccessToken;
+    // 使用executeNormal方法,不自动添加token
+    return this.executeNormal(SimplePostRequestExecutor.create(this), urlWithToken, postData);
+  }
+
   /**
    * 向微信端发送请求,在这里执行的策略是当发生access_token过期时才去刷新,然后重新执行请求,而不是全局定时请求.
    */
@@ -584,6 +596,11 @@ public WxCpOaWeDriveService getOaWeDriveService() {
     return oaWeDriveService;
   }
 
+  @Override
+  public WxCpOaWeDocService getOaWeDocService() {
+    return oaWeDocService;
+  }
+
   @Override
   public WxCpMsgAuditService getMsgAuditService() {
     return msgAuditService;
@@ -708,4 +725,9 @@ public WxCpCorpGroupService getCorpGroupService() {
   public WxCpIntelligentRobotService getIntelligentRobotService() {
     return this.intelligentRobotService;
   }
+
+  @Override
+  public WxCpHrService getHrService() {
+    return this.hrService;
+  }
 }
diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpHrServiceImpl.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpHrServiceImpl.java
new file mode 100644
index 0000000000..df71643d4c
--- /dev/null
+++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpHrServiceImpl.java
@@ -0,0 +1,80 @@
+package me.chanjar.weixin.cp.api.impl;
+
+import com.google.gson.JsonObject;
+import lombok.RequiredArgsConstructor;
+import me.chanjar.weixin.common.error.WxErrorException;
+import me.chanjar.weixin.cp.api.WxCpHrService;
+import me.chanjar.weixin.cp.api.WxCpService;
+import me.chanjar.weixin.cp.bean.hr.WxCpHrEmployeeFieldData;
+import me.chanjar.weixin.cp.bean.hr.WxCpHrEmployeeFieldDataResp;
+import me.chanjar.weixin.cp.bean.hr.WxCpHrEmployeeFieldInfoResp;
+import me.chanjar.weixin.cp.util.json.WxCpGsonBuilder;
+
+import java.util.List;
+
+import static me.chanjar.weixin.cp.constant.WxCpApiPathConsts.Hr.*;
+
+/**
+ * 人事助手相关接口实现类.
+ * 官方文档:https://developer.work.weixin.qq.com/document/path/99132
+ *
+ * @author leejoker created on 2024-01-01
+ */
+@RequiredArgsConstructor
+public class WxCpHrServiceImpl implements WxCpHrService {
+
+  private final WxCpService cpService;
+
+  @Override
+  public WxCpHrEmployeeFieldInfoResp getFieldInfo(List fields) throws WxErrorException {
+    JsonObject jsonObject = new JsonObject();
+    if (fields != null && !fields.isEmpty()) {
+      jsonObject.add("fields", WxCpGsonBuilder.create().toJsonTree(fields));
+    }
+    String response = this.cpService.post(
+      this.cpService.getWxCpConfigStorage().getApiUrl(GET_FIELD_INFO),
+      jsonObject.toString()
+    );
+    return WxCpHrEmployeeFieldInfoResp.fromJson(response);
+  }
+
+  @Override
+  public WxCpHrEmployeeFieldDataResp getEmployeeFieldInfo(String userid, List fields) throws WxErrorException {
+    return getEmployeeFieldInfo(userid, false, fields);
+  }
+
+  @Override
+  public WxCpHrEmployeeFieldDataResp getEmployeeFieldInfo(String userid, boolean getAll, List fields) throws WxErrorException {
+    if (userid == null || userid.trim().isEmpty()) {
+      throw new IllegalArgumentException("userid 不能为空");
+    }
+    JsonObject jsonObject = new JsonObject();
+    jsonObject.addProperty("userid", userid);
+    jsonObject.addProperty("get_all", getAll);
+    if (fields != null && !fields.isEmpty()) {
+      jsonObject.add("fields", WxCpGsonBuilder.create().toJsonTree(fields));
+    }
+    String response = this.cpService.post(
+      this.cpService.getWxCpConfigStorage().getApiUrl(GET_EMPLOYEE_FIELD_INFO),
+      jsonObject.toString()
+    );
+    return WxCpHrEmployeeFieldDataResp.fromJson(response);
+  }
+
+  @Override
+  public void updateEmployeeFieldInfo(String userid, List fieldList) throws WxErrorException {
+    if (userid == null || userid.trim().isEmpty()) {
+      throw new IllegalArgumentException("userid 不能为空");
+    }
+    if (fieldList == null || fieldList.isEmpty()) {
+      throw new IllegalArgumentException("fieldList 不能为空");
+    }
+    JsonObject jsonObject = new JsonObject();
+    jsonObject.addProperty("userid", userid);
+    jsonObject.add("field_list", WxCpGsonBuilder.create().toJsonTree(fieldList));
+    this.cpService.post(
+      this.cpService.getWxCpConfigStorage().getApiUrl(UPDATE_EMPLOYEE_FIELD_INFO),
+      jsonObject.toString()
+    );
+  }
+}
diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpMsgAuditServiceImpl.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpMsgAuditServiceImpl.java
index 63dc7ac007..be6588bc7b 100644
--- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpMsgAuditServiceImpl.java
+++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpMsgAuditServiceImpl.java
@@ -23,6 +23,8 @@
 import java.util.Collections;
 import java.util.LinkedList;
 import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.function.Consumer;
 
 import static me.chanjar.weixin.cp.constant.WxCpApiPathConsts.MsgAudit.*;
@@ -37,16 +39,17 @@
 public class WxCpMsgAuditServiceImpl implements WxCpMsgAuditService {
   private final WxCpService cpService;
 
-  /**
-   * SDK初始化有效期,根据企微文档为7200秒
-   */
-  private static final int SDK_EXPIRES_TIME = 7200;
+  /** 每个线程持有独立 SDK 实例,懒初始化,线程内跨调用复用 */
+  private final ThreadLocal threadLocalSdk = new ThreadLocal<>();
+
+  /** 跟踪所有已创建的 SDK,用于 closeAllSdks() 统一清理 */
+  private final Set managedSdks = ConcurrentHashMap.newKeySet();
 
   @Override
   public WxCpChatDatas getChatDatas(long seq, @NonNull long limit, String proxy, String passwd,
                                     @NonNull long timeout) throws Exception {
-    // 获取或初始化SDK
-    long sdk = this.initSdk();
+    // 旧版 API:每次调用创建新 SDK,由调用方负责通过 Finance.DestroySdk(chatDatas.getSdk()) 释放
+    long sdk = this.createSdk();
 
     long slice = Finance.NewSlice();
     long ret = Finance.GetChatData(sdk, seq, limit, proxy, passwd, timeout, slice);
@@ -68,23 +71,39 @@ public WxCpChatDatas getChatDatas(long seq, @NonNull long limit, String proxy, S
   }
 
   /**
-   * 获取或初始化SDK,如果SDK已过期则重新初始化
+   * 获取当前线程的 SDK,不存在则初始化。
+   * SDK 在线程内跨调用复用,无需每次重新初始化。
    *
    * @return sdk id
    * @throws WxErrorException 初始化失败时抛出异常
    */
-  private synchronized long initSdk() throws WxErrorException {
-    WxCpConfigStorage configStorage = cpService.getWxCpConfigStorage();
-
-    // 检查SDK是否已缓存且未过期
-    if (!configStorage.isMsgAuditSdkExpired()) {
-      long cachedSdk = configStorage.getMsgAuditSdk();
-      if (cachedSdk > 0) {
-        return cachedSdk;
+  private long getOrInitThreadLocalSdk() throws WxErrorException {
+    Long sdk = threadLocalSdk.get();
+    if (sdk != null && sdk > 0) {
+      // 校验句柄是否仍受管理:closeAllSdks() 后其他线程 ThreadLocal 可能保留已销毁的 id
+      if (managedSdks.contains(sdk)) {
+        return sdk;
       }
+      log.warn("线程 [{}] 发现已失效的会话存档SDK句柄 sdk={},请检查调用逻辑", Thread.currentThread().getName(), sdk);
+      threadLocalSdk.remove();
+      throw new WxErrorException("线程 [" + Thread.currentThread().getName() + "] 获取会话存档SDK失败,请检查是否已调用 closeAllSdks()");
     }
+    long newSdk = createSdk();
+    threadLocalSdk.set(newSdk);
+    managedSdks.add(newSdk);
+    log.info("线程 [{}] 初始化会话存档SDK成功,sdk={}", Thread.currentThread().getName(), newSdk);
+    return newSdk;
+  }
+
+  /**
+   * 创建并初始化一个新的会话存档 SDK 实例。
+   * 

通常通过 {@link #getOrInitThreadLocalSdk()} 间接调用以复用 ThreadLocal 中的实例; + * 旧版直接暴露 sdk 的 API(如 {@link #getChatDatas})也会直接调用本方法,此时 SDK 由调用方自行管理。

+ *

Finance.loadingLibraries() 底层依赖 System.load(),JVM 保证同一库不重复加载,多线程并发调用安全。

+ */ + private long createSdk() throws WxErrorException { + WxCpConfigStorage configStorage = cpService.getWxCpConfigStorage(); - // SDK未初始化或已过期,需要重新初始化 String configPath = configStorage.getMsgAuditLibPath(); if (StringUtils.isEmpty(configPath)) { throw new WxErrorException("请配置会话存档sdk文件的路径,不要配错了!!"); @@ -130,55 +149,31 @@ private synchronized long initSdk() throws WxErrorException { Finance.DestroySdk(sdk); throw new WxErrorException("init sdk err ret " + ret); } - - // 缓存SDK - configStorage.updateMsgAuditSdk(sdk, SDK_EXPIRES_TIME); - log.debug("初始化会话存档SDK成功,sdk={}", sdk); - 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(); + @Override + public void closeThreadLocalSdk() { + Long sdk = threadLocalSdk.get(); + // 先从 managedSdks 摘除,摘除成功才调 DestroySdk,防止与 closeAllSdks() 并发时 double-free + if (sdk != null && managedSdks.remove(sdk)) { + Finance.DestroySdk(sdk); + log.info("线程 [{}] 关闭会话存档SDK,sdk={}", Thread.currentThread().getName(), sdk); } - - return sdk; + threadLocalSdk.remove(); } - /** - * 释放SDK引用计数 - * - * @param sdk sdk id - */ - private void releaseSdk(long sdk) { - if (sdk > 0) { - cpService.getWxCpConfigStorage().releaseMsgAuditSdk(sdk); + @Override + public void closeAllSdks() { + // 逐一 remove 后再 Destroy,防止与 closeThreadLocalSdk() 并发时 double-free + Long[] sdks = managedSdks.toArray(new Long[0]); + for (Long sdk : sdks) { + if (managedSdks.remove(sdk)) { + Finance.DestroySdk(sdk); + log.info("关闭会话存档SDK,sdk={}", sdk); + } } + threadLocalSdk.remove(); } @Override @@ -240,17 +235,18 @@ public void getMediaFile(@NonNull long sdk, @NonNull String sdkfileid, String pr * 为空字符串,拉取后续分片时直接填入上次返回的indexbuf即可。 */ File targetFile = new File(targetFilePath); - if (!targetFile.getParentFile().exists()) { - targetFile.getParentFile().mkdirs(); + File parentDir = targetFile.getParentFile(); + if (parentDir != null && !parentDir.exists()) { + parentDir.mkdirs(); } this.getMediaFile(sdk, sdkfileid, proxy, passwd, timeout, i -> { try { // 大于512k的文件会分片拉取,此处需要使用追加写,避免后面的分片覆盖之前的数据。 - FileOutputStream outputStream = new FileOutputStream(targetFile, true); - outputStream.write(i); - outputStream.close(); + try (FileOutputStream outputStream = new FileOutputStream(targetFile, true)) { + outputStream.write(i); + } } catch (Exception e) { - e.printStackTrace(); + log.error("写入媒体文件分片失败,targetFilePath={}", targetFilePath, e); } }); } @@ -280,7 +276,7 @@ public void getMediaFile(@NonNull long sdk, @NonNull String sdkfileid, String pr // 大于512k的文件会分片拉取,此处需要使用追加写,避免后面的分片覆盖之前的数据。 action.accept(Finance.GetData(mediaData)); } catch (Exception e) { - e.printStackTrace(); + log.error("处理媒体文件分片失败,sdkfileid={}", sdkfileid, e); } if (Finance.IsMediaDataFinish(mediaData) == 1) { @@ -302,7 +298,7 @@ public List getPermitUserList(Integer type) throws WxErrorException { if (type != null) { jsonObject.addProperty("type", type); } - String responseContent = this.cpService.post(apiUrl, jsonObject.toString()); + String responseContent = this.cpService.postForMsgAudit(apiUrl, jsonObject.toString()); return WxCpGsonBuilder.create().fromJson(GsonParser.parse(responseContent).getAsJsonArray("ids"), new TypeToken>() { }.getType()); @@ -313,83 +309,62 @@ public WxCpGroupChat getGroupChat(@NonNull String roomid) throws WxErrorExceptio final String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(GET_GROUP_CHAT); JsonObject jsonObject = new JsonObject(); jsonObject.addProperty("roomid", roomid); - String responseContent = this.cpService.post(apiUrl, jsonObject.toString()); + String responseContent = this.cpService.postForMsgAudit(apiUrl, jsonObject.toString()); return WxCpGroupChat.fromJson(responseContent); } @Override public WxCpAgreeInfo checkSingleAgree(@NonNull WxCpCheckAgreeRequest checkAgreeRequest) throws WxErrorException { String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(CHECK_SINGLE_AGREE); - String responseContent = this.cpService.post(apiUrl, checkAgreeRequest.toJson()); + String responseContent = this.cpService.postForMsgAudit(apiUrl, checkAgreeRequest.toJson()); return WxCpAgreeInfo.fromJson(responseContent); } @Override public List 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); - } + long sdk = this.getOrInitThreadLocalSdk(); - // 拉取会话存档 - String content = Finance.GetContentFromSlice(slice); + long slice = Finance.NewSlice(); + long ret = Finance.GetChatData(sdk, seq, limit, proxy, passwd, timeout, slice); + if (ret != 0) { Finance.FreeSlice(slice); - WxCpChatDatas chatDatas = WxCpChatDatas.fromJson(content); - if (chatDatas.getErrCode().intValue() != 0) { - throw new WxErrorException(chatDatas.toJson()); - } + throw new WxErrorException("getchatdata err ret " + ret); + } - List chatDataList = chatDatas.getChatData(); - return chatDataList != null ? chatDataList : Collections.emptyList(); - } finally { - // 释放SDK引用计数(原子操作) - this.releaseSdk(sdk); + // 拉取会话存档 + String content = Finance.GetContentFromSlice(slice); + Finance.FreeSlice(slice); + WxCpChatDatas chatDatas = WxCpChatDatas.fromJson(content); + if (chatDatas.getErrCode().intValue() != 0) { + throw new WxErrorException(chatDatas.toJson()); } + + List chatDataList = chatDatas.getChatData(); + return chatDataList != null ? chatDataList : Collections.emptyList(); } @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); - } + long sdk = this.getOrInitThreadLocalSdk(); + String plainText = this.decryptChatData(sdk, chatData, pkcs1); + return WxCpChatModel.fromJson(plainText); } @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); - } + long sdk = this.getOrInitThreadLocalSdk(); + return this.decryptChatData(sdk, chatData, pkcs1); } @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(); + sdk = this.getOrInitThreadLocalSdk(); } catch (Exception e) { throw new WxErrorException(e); } @@ -397,54 +372,43 @@ public void downloadMediaFile(@NonNull String sdkfileid, String proxy, String pa // 使用AtomicReference捕获Lambda中的异常,以便在执行完后抛出 final java.util.concurrent.atomic.AtomicReference exceptionHolder = new java.util.concurrent.atomic.AtomicReference<>(); - try { - File targetFile = new File(targetFilePath); - if (!targetFile.getParentFile().exists()) { - targetFile.getParentFile().mkdirs(); + File targetFile = new File(targetFilePath); + File parentDir = targetFile.getParentFile(); + if (parentDir != null && !parentDir.exists()) { + parentDir.mkdirs(); + } + this.getMediaFile(sdk, sdkfileid, proxy, passwd, timeout, i -> { + // 如果之前已经发生异常,不再继续处理 + if (exceptionHolder.get() != null) { + return; } - this.getMediaFile(sdk, sdkfileid, proxy, passwd, timeout, i -> { - // 如果之前已经发生异常,不再继续处理 - if (exceptionHolder.get() != null) { - return; - } - try { - // 大于512k的文件会分片拉取,此处需要使用追加写,避免后面的分片覆盖之前的数据。 - FileOutputStream outputStream = new FileOutputStream(targetFile, true); + try { + // 大于512k的文件会分片拉取,此处需要使用追加写,避免后面的分片覆盖之前的数据。 + try (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); + } catch (Exception e) { + exceptionHolder.set(e); } - } finally { - // 释放SDK引用计数(原子操作) - this.releaseSdk(sdk); + }); + + // 检查是否发生异常,如果有则抛出 + Exception caughtException = exceptionHolder.get(); + if (caughtException != null) { + throw new WxErrorException(caughtException); } } @Override public void downloadMediaFile(@NonNull String sdkfileid, String proxy, String passwd, @NonNull long timeout, @NonNull Consumer action) throws WxErrorException { - // 获取SDK并自动增加引用计数(原子操作) long sdk; try { - sdk = this.acquireSdk(); + sdk = this.getOrInitThreadLocalSdk(); } catch (Exception e) { throw new WxErrorException(e); } - - try { - this.getMediaFile(sdk, sdkfileid, proxy, passwd, timeout, action); - } finally { - // 释放SDK引用计数(原子操作) - this.releaseSdk(sdk); - } + this.getMediaFile(sdk, sdkfileid, proxy, passwd, timeout, action); } } diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpOaWeDocServiceImpl.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpOaWeDocServiceImpl.java index fc5379dc73..7f5bf004db 100644 --- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpOaWeDocServiceImpl.java +++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpOaWeDocServiceImpl.java @@ -4,18 +4,22 @@ import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import me.chanjar.weixin.common.bean.CommonUploadParam; import me.chanjar.weixin.common.error.WxErrorException; import me.chanjar.weixin.cp.api.WxCpOaWeDocService; import me.chanjar.weixin.cp.api.WxCpService; import me.chanjar.weixin.cp.bean.WxCpBaseResp; import me.chanjar.weixin.cp.bean.oa.doc.*; +import java.io.File; +import java.util.List; + import static me.chanjar.weixin.cp.constant.WxCpApiPathConsts.Oa.*; /** - * 企业微信微盘接口实现类. + * 企业微信文档接口实现类. * - * @author Wang_Wong created on 2022-04-22 + * @author Wang_Wong created on 2022-04-22 */ @Slf4j @RequiredArgsConstructor @@ -57,11 +61,47 @@ public WxCpDocInfo docInfo(@NonNull String docId) throws WxErrorException { @Override public WxCpDocShare docShare(@NonNull String docId) throws WxErrorException { + return docShare(WxCpDocShareRequest.builder().docId(docId).build()); + } + + @Override + public WxCpDocShare docShare(@NonNull WxCpDocShareRequest request) throws WxErrorException { String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_DOC_SHARE); + String responseContent = this.cpService.post(apiUrl, request.toJson()); + return WxCpDocShare.fromJson(responseContent); + } + + @Override + public WxCpDocAuthInfo docGetAuth(@NonNull String docId) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_DOC_GET_AUTH); JsonObject jsonObject = new JsonObject(); jsonObject.addProperty("docid", docId); String responseContent = this.cpService.post(apiUrl, jsonObject.toString()); - return WxCpDocShare.fromJson(responseContent); + return WxCpDocAuthInfo.fromJson(responseContent); + } + + @Override + public WxCpBaseResp docModifyJoinRule(@NonNull WxCpDocModifyJoinRuleRequest request) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_MOD_DOC_JOIN_RULE); + String responseContent = this.cpService.post(apiUrl, request.toJson()); + return WxCpBaseResp.fromJson(responseContent); + } + + @Override + public WxCpBaseResp docModifyMember(@NonNull WxCpDocModifyMemberRequest request) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_MOD_DOC_MEMBER); + String responseContent = this.cpService.post(apiUrl, request.toJson()); + return WxCpBaseResp.fromJson(responseContent); + } + + @Override + public WxCpBaseResp docModifySafetySetting( + @NonNull WxCpDocModifySafetySettingRequest request + ) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage() + .getApiUrl(WEDOC_MOD_DOC_SAFETY_SETTING); + String responseContent = this.cpService.post(apiUrl, request.toJson()); + return WxCpBaseResp.fromJson(responseContent); } @Override @@ -86,4 +126,211 @@ public WxCpDocSheetData getSheetRangeData(@NonNull WxCpDocSheetGetDataRequest re String responseContent = this.cpService.post(apiUrl, request.toJson()); return WxCpDocSheetData.fromJson(responseContent); } + + @Override + public WxCpDocData docGetData(@NonNull WxCpDocGetDataRequest request) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_GET_DOC_DATA); + String responseContent = this.cpService.post(apiUrl, request.toJson()); + return WxCpDocData.fromJson(responseContent); + } + + @Override + public WxCpBaseResp docModify(@NonNull WxCpDocModifyRequest request) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_MOD_DOC); + String responseContent = this.cpService.post(apiUrl, request.toJson()); + return WxCpBaseResp.fromJson(responseContent); + } + + @Override + public WxCpDocImageUploadResult docUploadImage(@NonNull File file) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_UPLOAD_DOC_IMAGE); + String responseContent = this.cpService.upload(apiUrl, CommonUploadParam.fromFile("media", file)); + return WxCpDocImageUploadResult.fromJson(responseContent); + } + + @Override + public WxCpBaseResp docAddAdmin(@NonNull WxCpDocAdminRequest request) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_ADD_ADMIN); + String responseContent = this.cpService.post(apiUrl, request.toJson()); + return WxCpBaseResp.fromJson(responseContent); + } + + @Override + public WxCpBaseResp docDeleteAdmin(@NonNull WxCpDocAdminRequest request) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_DEL_ADMIN); + String responseContent = this.cpService.post(apiUrl, request.toJson()); + return WxCpBaseResp.fromJson(responseContent); + } + + @Override + public WxCpDocAdminListResult docGetAdminList(@NonNull String docId) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_GET_ADMIN_LIST); + JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty("docid", docId); + String responseContent = this.cpService.post(apiUrl, jsonObject.toString()); + return WxCpDocAdminListResult.fromJson(responseContent); + } + + @Override + public WxCpDocSmartSheetAuth smartSheetGetAuth(@NonNull WxCpDocSmartSheetAuthRequest request) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_SMARTSHEET_GET_SHEET_AUTH); + String responseContent = this.cpService.post(apiUrl, request.toJson()); + return WxCpDocSmartSheetAuth.fromJson(responseContent); + } + + @Override + public WxCpBaseResp smartSheetModifyAuth(@NonNull WxCpDocSmartSheetModifyAuthRequest request) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_SMARTSHEET_MOD_SHEET_AUTH); + String responseContent = this.cpService.post(apiUrl, request.toJson()); + return WxCpBaseResp.fromJson(responseContent); + } + + @Override + public WxCpDocSmartSheetResult smartSheetGetSheet(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_SMARTSHEET_GET_SHEET); + String responseContent = this.cpService.post(apiUrl, request.toJson()); + return WxCpDocSmartSheetResult.fromJson(responseContent); + } + + @Override + public WxCpDocSmartSheetResult smartSheetAddSheet(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_SMARTSHEET_ADD_SHEET); + String responseContent = this.cpService.post(apiUrl, request.toJson()); + return WxCpDocSmartSheetResult.fromJson(responseContent); + } + + @Override + public WxCpBaseResp smartSheetDeleteSheet(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_SMARTSHEET_DELETE_SHEET); + String responseContent = this.cpService.post(apiUrl, request.toJson()); + return WxCpBaseResp.fromJson(responseContent); + } + + @Override + public WxCpBaseResp smartSheetUpdateSheet(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_SMARTSHEET_UPDATE_SHEET); + String responseContent = this.cpService.post(apiUrl, request.toJson()); + return WxCpBaseResp.fromJson(responseContent); + } + + @Override + public WxCpDocSmartSheetResult smartSheetGetViews(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_SMARTSHEET_GET_VIEWS); + String responseContent = this.cpService.post(apiUrl, request.toJson()); + return WxCpDocSmartSheetResult.fromJson(responseContent); + } + + @Override + public WxCpDocSmartSheetResult smartSheetAddView(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_SMARTSHEET_ADD_VIEW); + String responseContent = this.cpService.post(apiUrl, request.toJson()); + return WxCpDocSmartSheetResult.fromJson(responseContent); + } + + @Override + public WxCpBaseResp smartSheetDeleteViews(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_SMARTSHEET_DELETE_VIEWS); + String responseContent = this.cpService.post(apiUrl, request.toJson()); + return WxCpBaseResp.fromJson(responseContent); + } + + @Override + public WxCpBaseResp smartSheetUpdateView(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_SMARTSHEET_UPDATE_VIEW); + String responseContent = this.cpService.post(apiUrl, request.toJson()); + return WxCpBaseResp.fromJson(responseContent); + } + + @Override + public WxCpDocSmartSheetResult smartSheetGetFields(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_SMARTSHEET_GET_FIELDS); + String responseContent = this.cpService.post(apiUrl, request.toJson()); + return WxCpDocSmartSheetResult.fromJson(responseContent); + } + + @Override + public WxCpDocSmartSheetResult smartSheetAddFields(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_SMARTSHEET_ADD_FIELDS); + String responseContent = this.cpService.post(apiUrl, request.toJson()); + return WxCpDocSmartSheetResult.fromJson(responseContent); + } + + @Override + public WxCpBaseResp smartSheetDeleteFields(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_SMARTSHEET_DELETE_FIELDS); + String responseContent = this.cpService.post(apiUrl, request.toJson()); + return WxCpBaseResp.fromJson(responseContent); + } + + @Override + public WxCpBaseResp smartSheetUpdateFields(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_SMARTSHEET_UPDATE_FIELDS); + String responseContent = this.cpService.post(apiUrl, request.toJson()); + return WxCpBaseResp.fromJson(responseContent); + } + + @Override + public WxCpDocSmartSheetResult smartSheetGetRecords(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_SMARTSHEET_GET_RECORDS); + String responseContent = this.cpService.post(apiUrl, request.toJson()); + return WxCpDocSmartSheetResult.fromJson(responseContent); + } + + @Override + public WxCpDocSmartSheetResult smartSheetAddRecords(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_SMARTSHEET_ADD_RECORDS); + String responseContent = this.cpService.post(apiUrl, request.toJson()); + return WxCpDocSmartSheetResult.fromJson(responseContent); + } + + @Override + public WxCpBaseResp smartSheetDeleteRecords(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_SMARTSHEET_DELETE_RECORDS); + String responseContent = this.cpService.post(apiUrl, request.toJson()); + return WxCpBaseResp.fromJson(responseContent); + } + + @Override + public WxCpBaseResp smartSheetUpdateRecords(@NonNull WxCpDocSmartSheetRequest request) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_SMARTSHEET_UPDATE_RECORDS); + String responseContent = this.cpService.post(apiUrl, request.toJson()); + return WxCpBaseResp.fromJson(responseContent); + } + + @Override + public WxCpFormCreateResult formCreate(@NonNull WxCpFormCreateRequest request) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_CREATE_FORM); + String responseContent = this.cpService.post(apiUrl, request.toJson()); + return WxCpFormCreateResult.fromJson(responseContent); + } + + @Override + public WxCpBaseResp formModify(@NonNull WxCpFormModifyRequest request) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_MODIFY_FORM); + String responseContent = this.cpService.post(apiUrl, request.toJson()); + return WxCpBaseResp.fromJson(responseContent); + } + + @Override + public WxCpFormInfoResult formInfo(@NonNull String formId) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_GET_FORM_INFO); + JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty("formid", formId); + String responseContent = this.cpService.post(apiUrl, jsonObject.toString()); + return WxCpFormInfoResult.fromJson(responseContent); + } + + @Override + public WxCpFormStatisticResult formStatistic(@NonNull List requests) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_GET_FORM_STATISTIC); + String responseContent = this.cpService.post(apiUrl, WxCpFormStatisticRequest.toJson(requests)); + return WxCpFormStatisticResult.fromJson(responseContent); + } + + @Override + public WxCpFormAnswer formAnswer(@NonNull WxCpFormAnswerRequest request) throws WxErrorException { + String apiUrl = this.cpService.getWxCpConfigStorage().getApiUrl(WEDOC_GET_FORM_ANSWER); + String responseContent = this.cpService.post(apiUrl, request.toJson()); + return WxCpFormAnswer.fromJson(responseContent); + } } diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpServiceApacheHttpClientImpl.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpServiceApacheHttpClientImpl.java index 1042f88d67..ef78116e12 100644 --- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpServiceApacheHttpClientImpl.java +++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpServiceApacheHttpClientImpl.java @@ -17,6 +17,7 @@ import org.apache.http.impl.client.CloseableHttpClient; import java.io.IOException; +import java.util.concurrent.locks.Lock; /** * The type Wx cp service apache http client. @@ -74,6 +75,51 @@ public String getAccessToken(boolean forceRefresh) throws WxErrorException { return this.configStorage.getAccessToken(); } + @Override + public String getMsgAuditAccessToken(boolean forceRefresh) throws WxErrorException { + if (!this.configStorage.isMsgAuditAccessTokenExpired() && !forceRefresh) { + return this.configStorage.getMsgAuditAccessToken(); + } + + Lock lock = this.configStorage.getMsgAuditAccessTokenLock(); + lock.lock(); + try { + // 拿到锁之后,再次判断一下最新的token是否过期,避免重刷 + if (!this.configStorage.isMsgAuditAccessTokenExpired() && !forceRefresh) { + return this.configStorage.getMsgAuditAccessToken(); + } + // 使用会话存档secret获取access_token + String msgAuditSecret = this.configStorage.getMsgAuditSecret(); + if (msgAuditSecret == null || msgAuditSecret.trim().isEmpty()) { + throw new WxErrorException("会话存档secret未配置"); + } + String url = String.format(this.configStorage.getApiUrl(WxCpApiPathConsts.GET_TOKEN), + this.configStorage.getCorpId(), msgAuditSecret); + + try { + HttpGet httpGet = new HttpGet(url); + if (this.httpProxy != null) { + RequestConfig config = RequestConfig.custom() + .setProxy(this.httpProxy).build(); + httpGet.setConfig(config); + } + String resultContent = getRequestHttpClient().execute(httpGet, ApacheBasicResponseHandler.INSTANCE); + WxError error = WxError.fromJson(resultContent, WxType.CP); + if (error.getErrorCode() != 0) { + throw new WxErrorException(error); + } + + WxAccessToken accessToken = WxAccessToken.fromJson(resultContent); + this.configStorage.updateMsgAuditAccessToken(accessToken.getAccessToken(), accessToken.getExpiresIn()); + } catch (IOException e) { + throw new WxRuntimeException(e); + } + } finally { + lock.unlock(); + } + return this.configStorage.getMsgAuditAccessToken(); + } + @Override public void initHttp() { ApacheHttpClientBuilder apacheHttpClientBuilder = this.configStorage diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpServiceHttpComponentsImpl.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpServiceHttpComponentsImpl.java index 4b6a1e36ff..3ca041e7ec 100644 --- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpServiceHttpComponentsImpl.java +++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpServiceHttpComponentsImpl.java @@ -17,6 +17,7 @@ import org.apache.hc.core5.http.HttpHost; import java.io.IOException; +import java.util.concurrent.locks.Lock; /** * The type Wx cp service apache http client. @@ -75,6 +76,51 @@ public String getAccessToken(boolean forceRefresh) throws WxErrorException { return this.configStorage.getAccessToken(); } + @Override + public String getMsgAuditAccessToken(boolean forceRefresh) throws WxErrorException { + if (!this.configStorage.isMsgAuditAccessTokenExpired() && !forceRefresh) { + return this.configStorage.getMsgAuditAccessToken(); + } + + Lock lock = this.configStorage.getMsgAuditAccessTokenLock(); + lock.lock(); + try { + // 拿到锁之后,再次判断一下最新的token是否过期,避免重刷 + if (!this.configStorage.isMsgAuditAccessTokenExpired() && !forceRefresh) { + return this.configStorage.getMsgAuditAccessToken(); + } + // 使用会话存档secret获取access_token + String msgAuditSecret = this.configStorage.getMsgAuditSecret(); + if (msgAuditSecret == null || msgAuditSecret.trim().isEmpty()) { + throw new WxErrorException("会话存档secret未配置"); + } + String url = String.format(this.configStorage.getApiUrl(WxCpApiPathConsts.GET_TOKEN), + this.configStorage.getCorpId(), msgAuditSecret); + + try { + HttpGet httpGet = new HttpGet(url); + if (this.httpProxy != null) { + RequestConfig config = RequestConfig.custom() + .setProxy(this.httpProxy).build(); + httpGet.setConfig(config); + } + String resultContent = getRequestHttpClient().execute(httpGet, BasicResponseHandler.INSTANCE); + WxError error = WxError.fromJson(resultContent, WxType.CP); + if (error.getErrorCode() != 0) { + throw new WxErrorException(error); + } + + WxAccessToken accessToken = WxAccessToken.fromJson(resultContent); + this.configStorage.updateMsgAuditAccessToken(accessToken.getAccessToken(), accessToken.getExpiresIn()); + } catch (IOException e) { + throw new WxRuntimeException(e); + } + } finally { + lock.unlock(); + } + return this.configStorage.getMsgAuditAccessToken(); + } + @Override public void initHttp() { HttpComponentsClientBuilder apacheHttpClientBuilder = DefaultHttpComponentsClientBuilder.get(); diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpServiceImpl.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpServiceImpl.java index f2a50db471..7b651cbc08 100644 --- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpServiceImpl.java +++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpServiceImpl.java @@ -70,6 +70,49 @@ public String getAccessToken(boolean forceRefresh) throws WxErrorException { return configStorage.getAccessToken(); } + @Override + public String getMsgAuditAccessToken(boolean forceRefresh) throws WxErrorException { + final WxCpConfigStorage configStorage = getWxCpConfigStorage(); + if (!configStorage.isMsgAuditAccessTokenExpired() && !forceRefresh) { + return configStorage.getMsgAuditAccessToken(); + } + Lock lock = configStorage.getMsgAuditAccessTokenLock(); + lock.lock(); + try { + // 拿到锁之后,再次判断一下最新的token是否过期,避免重刷 + if (!configStorage.isMsgAuditAccessTokenExpired() && !forceRefresh) { + return configStorage.getMsgAuditAccessToken(); + } + // 使用会话存档secret获取access_token + String msgAuditSecret = configStorage.getMsgAuditSecret(); + if (msgAuditSecret == null || msgAuditSecret.trim().isEmpty()) { + throw new WxErrorException("会话存档secret未配置"); + } + String url = String.format(configStorage.getApiUrl(WxCpApiPathConsts.GET_TOKEN), + this.configStorage.getCorpId(), msgAuditSecret); + try { + HttpGet httpGet = new HttpGet(url); + if (getRequestHttpProxy() != null) { + RequestConfig config = RequestConfig.custom().setProxy(getRequestHttpProxy()).build(); + httpGet.setConfig(config); + } + String resultContent = getRequestHttpClient().execute(httpGet, ApacheBasicResponseHandler.INSTANCE); + WxError error = WxError.fromJson(resultContent, WxType.CP); + if (error.getErrorCode() != 0) { + throw new WxErrorException(error); + } + + WxAccessToken accessToken = WxAccessToken.fromJson(resultContent); + configStorage.updateMsgAuditAccessToken(accessToken.getAccessToken(), accessToken.getExpiresIn()); + } catch (IOException e) { + throw new WxRuntimeException(e); + } + } finally { + lock.unlock(); + } + return configStorage.getMsgAuditAccessToken(); + } + @Override public String getAgentJsapiTicket(boolean forceRefresh) throws WxErrorException { final WxCpConfigStorage configStorage = getWxCpConfigStorage(); diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpServiceJoddHttpImpl.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpServiceJoddHttpImpl.java index 5081341851..eba9315649 100644 --- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpServiceJoddHttpImpl.java +++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpServiceJoddHttpImpl.java @@ -13,6 +13,8 @@ import me.chanjar.weixin.cp.config.WxCpConfigStorage; import me.chanjar.weixin.cp.constant.WxCpApiPathConsts; +import java.util.concurrent.locks.Lock; + /** * The type Wx cp service jodd http. * @@ -63,6 +65,45 @@ public String getAccessToken(boolean forceRefresh) throws WxErrorException { return this.configStorage.getAccessToken(); } + @Override + public String getMsgAuditAccessToken(boolean forceRefresh) throws WxErrorException { + if (!this.configStorage.isMsgAuditAccessTokenExpired() && !forceRefresh) { + return this.configStorage.getMsgAuditAccessToken(); + } + + Lock lock = this.configStorage.getMsgAuditAccessTokenLock(); + lock.lock(); + try { + // 拿到锁之后,再次判断一下最新的token是否过期,避免重刷 + if (!this.configStorage.isMsgAuditAccessTokenExpired() && !forceRefresh) { + return this.configStorage.getMsgAuditAccessToken(); + } + // 使用会话存档secret获取access_token + String msgAuditSecret = this.configStorage.getMsgAuditSecret(); + if (msgAuditSecret == null || msgAuditSecret.trim().isEmpty()) { + throw new WxErrorException("会话存档secret未配置"); + } + HttpRequest request = HttpRequest.get(String.format(this.configStorage.getApiUrl(WxCpApiPathConsts.GET_TOKEN), + this.configStorage.getCorpId(), msgAuditSecret)); + if (this.httpProxy != null) { + httpClient.useProxy(this.httpProxy); + } + request.withConnectionProvider(httpClient); + HttpResponse response = request.send(); + + String resultContent = response.bodyText(); + WxError error = WxError.fromJson(resultContent, WxType.CP); + if (error.getErrorCode() != 0) { + throw new WxErrorException(error); + } + WxAccessToken accessToken = WxAccessToken.fromJson(resultContent); + this.configStorage.updateMsgAuditAccessToken(accessToken.getAccessToken(), accessToken.getExpiresIn()); + } finally { + lock.unlock(); + } + return this.configStorage.getMsgAuditAccessToken(); + } + @Override public void initHttp() { if (this.configStorage.getHttpProxyHost() != null && this.configStorage.getHttpProxyPort() > 0) { diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpServiceOkHttpImpl.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpServiceOkHttpImpl.java index 511c440e64..ce77b37805 100644 --- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpServiceOkHttpImpl.java +++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpServiceOkHttpImpl.java @@ -12,6 +12,7 @@ import okhttp3.*; import java.io.IOException; +import java.util.concurrent.locks.Lock; import static me.chanjar.weixin.cp.constant.WxCpApiPathConsts.GET_TOKEN; @@ -74,6 +75,52 @@ public String getAccessToken(boolean forceRefresh) throws WxErrorException { return this.configStorage.getAccessToken(); } + @Override + public String getMsgAuditAccessToken(boolean forceRefresh) throws WxErrorException { + if (!this.configStorage.isMsgAuditAccessTokenExpired() && !forceRefresh) { + return this.configStorage.getMsgAuditAccessToken(); + } + + Lock lock = this.configStorage.getMsgAuditAccessTokenLock(); + lock.lock(); + try { + // 拿到锁之后,再次判断一下最新的token是否过期,避免重刷 + if (!this.configStorage.isMsgAuditAccessTokenExpired() && !forceRefresh) { + return this.configStorage.getMsgAuditAccessToken(); + } + // 使用会话存档secret获取access_token + String msgAuditSecret = this.configStorage.getMsgAuditSecret(); + if (msgAuditSecret == null || msgAuditSecret.trim().isEmpty()) { + throw new WxErrorException("会话存档secret未配置"); + } + //得到httpClient + OkHttpClient client = getRequestHttpClient(); + //请求的request + Request request = new Request.Builder() + .url(String.format(this.configStorage.getApiUrl(GET_TOKEN), this.configStorage.getCorpId(), + msgAuditSecret)) + .get() + .build(); + String resultContent = null; + try (Response response = client.newCall(request).execute()) { + resultContent = response.body().string(); + } catch (IOException e) { + log.error(e.getMessage(), e); + } + + WxError error = WxError.fromJson(resultContent, WxType.CP); + if (error.getErrorCode() != 0) { + throw new WxErrorException(error); + } + WxAccessToken accessToken = WxAccessToken.fromJson(resultContent); + this.configStorage.updateMsgAuditAccessToken(accessToken.getAccessToken(), + accessToken.getExpiresIn()); + } finally { + lock.unlock(); + } + return this.configStorage.getMsgAuditAccessToken(); + } + @Override public void initHttp() { log.debug("WxCpServiceOkHttpImpl initHttp"); diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/external/WxCpMsgTemplate.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/external/WxCpMsgTemplate.java index 42e51afbb8..3c2d9fb977 100644 --- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/external/WxCpMsgTemplate.java +++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/external/WxCpMsgTemplate.java @@ -6,6 +6,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import me.chanjar.weixin.cp.bean.external.msg.Attachment; +import me.chanjar.weixin.cp.bean.external.msg.TagFilter; import me.chanjar.weixin.cp.bean.external.msg.Text; import me.chanjar.weixin.cp.util.json.WxCpGsonBuilder; @@ -43,6 +44,12 @@ public class WxCpMsgTemplate implements Serializable { @SerializedName("chat_id_list") private List chatIdList; + /** + * 要进行群发的客户标签列表,同组标签之间按或关系进行筛选,不同组标签按且关系筛选,每组最多指定100个标签,支持规则组标签 + */ + @SerializedName("tag_filter") + private TagFilter tagFilter; + /** * 发送企业群发消息的成员userid,当类型为发送给客户群时必填 */ diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/external/msg/TagFilter.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/external/msg/TagFilter.java new file mode 100644 index 0000000000..da7851adbf --- /dev/null +++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/external/msg/TagFilter.java @@ -0,0 +1,20 @@ +package me.chanjar.weixin.cp.bean.external.msg; + +import com.google.gson.annotations.SerializedName; +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +/** + * 群发的客户标签 + * + * @author Winnie + */ +@Data +public class TagFilter implements Serializable { + private static final long serialVersionUID = -6756444546744020234L; + + @SerializedName("group_list") + private List groupList; +} diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/external/msg/TagList.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/external/msg/TagList.java new file mode 100644 index 0000000000..10ed191e90 --- /dev/null +++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/external/msg/TagList.java @@ -0,0 +1,20 @@ +package me.chanjar.weixin.cp.bean.external.msg; + +import com.google.gson.annotations.SerializedName; +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +/** + * 客户标签列表 + * + * @author Winnie + */ +@Data +public class TagList implements Serializable { + private static final long serialVersionUID = 1133054307780310675L; + + @SerializedName("tag_list") + private List tagList; +} diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/hr/WxCpHrEmployeeFieldData.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/hr/WxCpHrEmployeeFieldData.java new file mode 100644 index 0000000000..971e5958d1 --- /dev/null +++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/hr/WxCpHrEmployeeFieldData.java @@ -0,0 +1,52 @@ +package me.chanjar.weixin.cp.bean.hr; + +import com.google.gson.annotations.SerializedName; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.util.List; + +/** + * 人事助手-员工档案数据(单个员工). + * + * @author leejoker created on 2024-01-01 + */ +@Data +@NoArgsConstructor +public class WxCpHrEmployeeFieldData implements Serializable { + private static final long serialVersionUID = 4593693598671765396L; + + /** + * 员工userid. + */ + @SerializedName("userid") + private String userid; + + /** + * 字段数据列表. + */ + @SerializedName("field_list") + private List fieldList; + + /** + * 字段数据项. + */ + @Data + @NoArgsConstructor + public static class FieldItem implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 字段key. + */ + @SerializedName("field_key") + private String fieldKey; + + /** + * 字段值. + */ + @SerializedName("field_value") + private WxCpHrEmployeeFieldValue fieldValue; + } +} diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/hr/WxCpHrEmployeeFieldDataResp.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/hr/WxCpHrEmployeeFieldDataResp.java new file mode 100644 index 0000000000..07e286c2ef --- /dev/null +++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/hr/WxCpHrEmployeeFieldDataResp.java @@ -0,0 +1,38 @@ +package me.chanjar.weixin.cp.bean.hr; + +import com.google.gson.annotations.SerializedName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import me.chanjar.weixin.cp.bean.WxCpBaseResp; +import me.chanjar.weixin.cp.util.json.WxCpGsonBuilder; + +import java.util.List; + +/** + * 人事助手-获取员工档案数据响应. + * + * @author leejoker created on 2024-01-01 + */ +@Data +@NoArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class WxCpHrEmployeeFieldDataResp extends WxCpBaseResp { + private static final long serialVersionUID = 6593693598671765396L; + + /** + * 员工档案数据列表. + */ + @SerializedName("employee_field_list") + private List employeeFieldList; + + /** + * From json wx cp hr employee field data resp. + * + * @param json the json + * @return the wx cp hr employee field data resp + */ + public static WxCpHrEmployeeFieldDataResp fromJson(String json) { + return WxCpGsonBuilder.create().fromJson(json, WxCpHrEmployeeFieldDataResp.class); + } +} diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/hr/WxCpHrEmployeeFieldInfo.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/hr/WxCpHrEmployeeFieldInfo.java new file mode 100644 index 0000000000..e355d8cc6a --- /dev/null +++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/bean/hr/WxCpHrEmployeeFieldInfo.java @@ -0,0 +1,103 @@ +package me.chanjar.weixin.cp.bean.hr; + +import com.google.gson.annotations.SerializedName; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.util.List; + +/** + * 人事助手-员工档案字段信息. + * + * @author leejoker created on 2024-01-01 + */ +@Data +@NoArgsConstructor +public class WxCpHrEmployeeFieldInfo implements Serializable { + private static final long serialVersionUID = 2593693598671765396L; + + /** + * 字段key. + */ + @SerializedName("field_key") + private String fieldKey; + + /** + * 字段英文名称. + */ + @SerializedName("field_en_name") + private String fieldEnName; + + /** + * 字段中文名称. + */ + @SerializedName("field_zh_name") + private String fieldZhName; + + /** + * 字段类型. + * 具体取值参见 {@link WxCpHrFieldType} + */ + @SerializedName("field_type") + private Integer fieldType; + + /** + * 获取字段类型枚举. + * + * @return 字段类型枚举,未匹配时返回 null + */ + public WxCpHrFieldType getFieldTypeEnum() { + return fieldType == null ? null : WxCpHrFieldType.fromCode(fieldType); + } + + /** + * 是否系统字段. + * 0: 否 + * 1: 是 + */ + @SerializedName("is_sys") + private Integer isSys; + + /** + * 字段详情. + */ + @SerializedName("field_detail") + private FieldDetail fieldDetail; + + /** + * 字段详情. + */ + @Data + @NoArgsConstructor + public static class FieldDetail implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 选项列表(单选/多选字段专用). + */ + @SerializedName("option_list") + private List