From f2f5957777a4d630168d55e38f582e044f6dc74b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 18:24:10 +0800 Subject: [PATCH] =?UTF-8?q?:art:=20#4047=20=20=E3=80=90=E4=BC=81=E4=B8=9A?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E3=80=91=E9=80=9A=E8=AE=AF=E5=BD=95=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E7=9B=B8=E5=85=B3=E6=8E=A5=E5=8F=A3=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E2=80=9C=E7=8B=AC=E7=AB=8B=20secret=20+=20=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=20access=20token=E2=80=9D=E7=9A=84=E8=B0=83=E7=94=A8=E9=80=9A?= =?UTF-8?q?=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../me/chanjar/weixin/cp/api/WxCpService.java | 39 +++ .../cp/api/impl/BaseWxCpServiceImpl.java | 23 ++ .../impl/WxCpServiceApacheHttpClientImpl.java | 45 +++ .../impl/WxCpServiceHttpComponentsImpl.java | 45 +++ .../weixin/cp/api/impl/WxCpServiceImpl.java | 43 +++ .../cp/api/impl/WxCpServiceJoddHttpImpl.java | 39 +++ .../cp/api/impl/WxCpServiceOkHttpImpl.java | 64 ++++- .../weixin/cp/config/WxCpConfigStorage.java | 41 +++ .../cp/config/impl/WxCpDefaultConfigImpl.java | 53 ++++ .../cp/config/impl/WxCpRedisConfigImpl.java | 30 ++ .../cp/api/impl/BaseWxCpServiceImplTest.java | 5 + .../WxCpServiceGetContactAccessTokenTest.java | 264 ++++++++++++++++++ ...WxCpServiceGetMsgAuditAccessTokenTest.java | 10 + 13 files changed, 697 insertions(+), 4 deletions(-) create mode 100644 weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/impl/WxCpServiceGetContactAccessTokenTest.java 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 f66acc025..269a69a47 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/91579 + *+ * + * @param forceRefresh 强制刷新 + * @return 通讯录同步专用的access token + * @throws WxErrorException the wx error exception + */ + String getContactAccessToken(boolean forceRefresh) throws WxErrorException; + /** *
* 获取会话存档access_token,本方法线程安全
@@ -220,6 +233,32 @@ public interface WxCpService extends WxService {
*/
String postForMsgAudit(String url, String postData) throws WxErrorException;
+ /**
+ *
+ * 使用通讯录同步access token发起get请求
+ * 通讯录同步相关API需要使用通讯录同步专用的secret获取独立的access token
+ *
+ *
+ * @param url 接口地址
+ * @param queryParam 请求参数
+ * @return the string
+ * @throws WxErrorException the wx error exception
+ */
+ String getForContact(String url, String queryParam) throws WxErrorException;
+
+ /**
+ *
+ * 使用通讯录同步access token发起post请求
+ * 通讯录同步相关API需要使用通讯录同步专用的secret获取独立的access token
+ *
+ *
+ * @param url 接口地址
+ * @param postData 请求body字符串
+ * @return the string
+ * @throws WxErrorException the wx error exception
+ */
+ String postForContact(String url, String postData) throws WxErrorException;
+
/**
*
* Service没有实现某个API的时候,可以用这个,
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 7c72cb9a8..a3ec703ca 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
@@ -313,6 +313,29 @@ public abstract class BaseWxCpServiceImpl implements WxCpService, RequestH
return this.executeNormal(SimplePostRequestExecutor.create(this), urlWithToken, postData);
}
+ @Override
+ public String getForContact(String url, String queryParam) throws WxErrorException {
+ // 获取通讯录同步专用的access token
+ String contactAccessToken = getContactAccessToken(false);
+ // 拼接access_token参数
+ String urlWithToken = url + (url.contains("?") ? "&" : "?") + "access_token=" + contactAccessToken;
+ if (queryParam != null && !queryParam.isEmpty()) {
+ urlWithToken = urlWithToken + "&" + queryParam;
+ }
+ // 使用executeNormal方法,不自动添加token
+ return this.executeNormal(SimpleGetRequestExecutor.create(this), urlWithToken, null);
+ }
+
+ @Override
+ public String postForContact(String url, String postData) throws WxErrorException {
+ // 获取通讯录同步专用的access token
+ String contactAccessToken = getContactAccessToken(false);
+ // 拼接access_token参数
+ String urlWithToken = url + (url.contains("?") ? "&" : "?") + "access_token=" + contactAccessToken;
+ // 使用executeNormal方法,不自动添加token
+ return this.executeNormal(SimplePostRequestExecutor.create(this), urlWithToken, postData);
+ }
+
/**
* 向微信端发送请求,在这里执行的策略是当发生access_token过期时才去刷新,然后重新执行请求,而不是全局定时请求.
*/
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 ef78116e1..8285e59df 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
@@ -75,6 +75,51 @@ public class WxCpServiceApacheHttpClientImpl extends BaseWxCpServiceImpl this.contactAccessTokenExpiresTime;
+ }
+
+ @Override
+ public void expireContactAccessToken() {
+ this.contactAccessTokenExpiresTime = 0;
+ }
+
+ @Override
+ public synchronized void updateContactAccessToken(String accessToken, int expiresInSeconds) {
+ this.contactAccessToken = accessToken;
+ // 预留200秒的时间
+ this.contactAccessTokenExpiresTime = System.currentTimeMillis() + (expiresInSeconds - 200) * 1000L;
+ }
+
@Override
public String getMsgAuditSecret() {
return this.msgAuditSecret;
diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/impl/WxCpRedisConfigImpl.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/impl/WxCpRedisConfigImpl.java
index 2ba71fffb..01c61673a 100644
--- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/impl/WxCpRedisConfigImpl.java
+++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/impl/WxCpRedisConfigImpl.java
@@ -492,6 +492,36 @@ public class WxCpRedisConfigImpl implements WxCpConfigStorage {
return null;
}
+ @Override
+ public String getContactSecret() {
+ return null;
+ }
+
+ @Override
+ public String getContactAccessToken() {
+ return null;
+ }
+
+ @Override
+ public Lock getContactAccessTokenLock() {
+ return this.msgAuditAccessTokenLock;
+ }
+
+ @Override
+ public boolean isContactAccessTokenExpired() {
+ return true;
+ }
+
+ @Override
+ public void expireContactAccessToken() {
+ // 不支持
+ }
+
+ @Override
+ public void updateContactAccessToken(String accessToken, int expiresInSeconds) {
+ // 不支持
+ }
+
@Override
public String getMsgAuditAccessToken() {
return null;
diff --git a/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/impl/BaseWxCpServiceImplTest.java b/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/impl/BaseWxCpServiceImplTest.java
index 87d2094e5..bdc85afcf 100644
--- a/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/impl/BaseWxCpServiceImplTest.java
+++ b/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/impl/BaseWxCpServiceImplTest.java
@@ -106,6 +106,11 @@ public class BaseWxCpServiceImplTest {
return "mock_msg_audit_access_token";
}
+ @Override
+ public String getContactAccessToken(boolean forceRefresh) throws WxErrorException {
+ return "mock_contact_access_token";
+ }
+
@Override
public void initHttp() {
diff --git a/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/impl/WxCpServiceGetContactAccessTokenTest.java b/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/impl/WxCpServiceGetContactAccessTokenTest.java
new file mode 100644
index 000000000..5fb79aaae
--- /dev/null
+++ b/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/impl/WxCpServiceGetContactAccessTokenTest.java
@@ -0,0 +1,264 @@
+package me.chanjar.weixin.cp.api.impl;
+
+import me.chanjar.weixin.common.error.WxErrorException;
+import me.chanjar.weixin.common.util.http.HttpClientType;
+import me.chanjar.weixin.cp.config.WxCpConfigStorage;
+import me.chanjar.weixin.cp.config.impl.WxCpDefaultConfigImpl;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import java.util.concurrent.locks.Lock;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * 测试 getContactAccessToken 方法在各个实现类中的正确性
+ *
+ * @author Binary Wang
+ */
+@Test
+public class WxCpServiceGetContactAccessTokenTest {
+
+ private WxCpDefaultConfigImpl config;
+
+ @BeforeMethod
+ public void setUp() {
+ config = new WxCpDefaultConfigImpl();
+ config.setCorpId("testCorpId");
+ config.setCorpSecret("testCorpSecret");
+ config.setContactSecret("testContactSecret");
+ }
+
+ /**
+ * 测试通讯录同步access token的缓存机制
+ * 验证当token未过期时,直接从配置中返回缓存的token
+ */
+ @Test
+ public void testGetContactAccessToken_Cache() throws WxErrorException {
+ // 预先设置一个有效的token
+ config.updateContactAccessToken("cached_token", 7200);
+
+ BaseWxCpServiceImpl service = createTestService(config);
+
+ // 不强制刷新时应该返回缓存的token
+ String token = service.getContactAccessToken(false);
+ assertThat(token).isEqualTo("cached_token");
+ }
+
+ /**
+ * 测试强制刷新通讯录同步access token
+ * 验证forceRefresh=true时会重新获取token
+ */
+ @Test
+ public void testGetContactAccessToken_ForceRefresh() throws WxErrorException {
+ // 预先设置一个有效的token
+ config.updateContactAccessToken("old_token", 7200);
+
+ BaseWxCpServiceImpl service = createTestServiceWithMockToken(config, "new_token");
+
+ // 强制刷新应该获取新token
+ String token = service.getContactAccessToken(true);
+ assertThat(token).isEqualTo("new_token");
+ }
+
+ /**
+ * 测试token过期时自动刷新
+ * 验证当token已过期时,会自动重新获取
+ */
+ @Test
+ public void testGetContactAccessToken_Expired() throws WxErrorException {
+ // 设置一个已过期的token(过期时间为负数,确保立即过期)
+ config.updateContactAccessToken("expired_token", -1);
+
+ BaseWxCpServiceImpl service = createTestServiceWithMockToken(config, "refreshed_token");
+
+ // 过期的token应该被自动刷新
+ String token = service.getContactAccessToken(false);
+ assertThat(token).isEqualTo("refreshed_token");
+ }
+
+ /**
+ * 测试获取锁机制
+ * 验证配置中的锁可以正常获取和使用
+ */
+ @Test
+ public void testGetContactAccessToken_Lock() {
+ // 验证配置提供的锁不为null
+ assertThat(config.getContactAccessTokenLock()).isNotNull();
+
+ // 验证锁可以正常使用
+ config.getContactAccessTokenLock().lock();
+ try {
+ assertThat(config.getContactAccessToken()).isNull();
+ } finally {
+ config.getContactAccessTokenLock().unlock();
+ }
+ }
+
+ /**
+ * 检查token是否需要刷新的公共逻辑
+ */
+ private boolean shouldRefreshToken(WxCpConfigStorage storage, boolean forceRefresh) {
+ return storage.isContactAccessTokenExpired() || forceRefresh;
+ }
+
+ /**
+ * 验证通讯录同步secret是否已配置的公共逻辑
+ */
+ private void validateContactSecret(String contactSecret) throws WxErrorException {
+ if (contactSecret == null || contactSecret.trim().isEmpty()) {
+ throw new WxErrorException("通讯录同步secret未配置");
+ }
+ }
+
+ /**
+ * 创建一个用于测试的BaseWxCpServiceImpl实现,
+ * 用于测试缓存和过期逻辑
+ */
+ private BaseWxCpServiceImpl createTestService(WxCpConfigStorage config) {
+ return new BaseWxCpServiceImpl() {
+ @Override
+ public Object getRequestHttpClient() {
+ return null;
+ }
+
+ @Override
+ public Object getRequestHttpProxy() {
+ return null;
+ }
+
+ @Override
+ public HttpClientType getRequestType() {
+ return null;
+ }
+
+ @Override
+ public String getAccessToken(boolean forceRefresh) throws WxErrorException {
+ return "test_access_token";
+ }
+
+ @Override
+ public String getContactAccessToken(boolean forceRefresh) throws WxErrorException {
+ // 检查是否需要刷新
+ if (!shouldRefreshToken(getWxCpConfigStorage(), forceRefresh)) {
+ return getWxCpConfigStorage().getContactAccessToken();
+ }
+
+ // 使用通讯录同步secret获取access_token
+ String contactSecret = getWxCpConfigStorage().getContactSecret();
+ validateContactSecret(contactSecret);
+
+ // 返回缓存的token(用于测试缓存机制)
+ return getWxCpConfigStorage().getContactAccessToken();
+ }
+
+ @Override
+ public String getMsgAuditAccessToken(boolean forceRefresh) throws WxErrorException {
+ return "test_msg_audit_token";
+ }
+
+ @Override
+ public void initHttp() {
+ }
+
+ @Override
+ public WxCpConfigStorage getWxCpConfigStorage() {
+ return config;
+ }
+ };
+ }
+
+ /**
+ * 创建一个用于测试的BaseWxCpServiceImpl实现,
+ * 模拟返回指定的token(用于测试刷新逻辑)
+ */
+ private BaseWxCpServiceImpl createTestServiceWithMockToken(WxCpConfigStorage config, String mockToken) {
+ return new BaseWxCpServiceImpl() {
+ @Override
+ public Object getRequestHttpClient() {
+ return null;
+ }
+
+ @Override
+ public Object getRequestHttpProxy() {
+ return null;
+ }
+
+ @Override
+ public HttpClientType getRequestType() {
+ return null;
+ }
+
+ @Override
+ public String getAccessToken(boolean forceRefresh) throws WxErrorException {
+ return "test_access_token";
+ }
+
+ @Override
+ public String getContactAccessToken(boolean forceRefresh) throws WxErrorException {
+ // 使用锁机制
+ Lock lock = getWxCpConfigStorage().getContactAccessTokenLock();
+ lock.lock();
+ try {
+ // 检查是否需要刷新
+ if (!shouldRefreshToken(getWxCpConfigStorage(), forceRefresh)) {
+ return getWxCpConfigStorage().getContactAccessToken();
+ }
+
+ // 使用通讯录同步secret获取access_token
+ String contactSecret = getWxCpConfigStorage().getContactSecret();
+ validateContactSecret(contactSecret);
+
+ // 模拟获取新token并更新配置
+ getWxCpConfigStorage().updateContactAccessToken(mockToken, 7200);
+ return mockToken;
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ @Override
+ public String getMsgAuditAccessToken(boolean forceRefresh) throws WxErrorException {
+ return "test_msg_audit_token";
+ }
+
+ @Override
+ public void initHttp() {
+ }
+
+ @Override
+ public WxCpConfigStorage getWxCpConfigStorage() {
+ return config;
+ }
+ };
+ }
+
+ /**
+ * 测试当 ContactSecret 未配置时应该抛出异常
+ */
+ @Test
+ public void testGetContactAccessToken_WithoutSecret() {
+ config.setContactSecret(null);
+ BaseWxCpServiceImpl service = createTestService(config);
+
+ // 验证当 secret 为 null 时抛出异常
+ assertThatThrownBy(() -> service.getContactAccessToken(true))
+ .isInstanceOf(WxErrorException.class)
+ .hasMessageContaining("通讯录同步secret未配置");
+ }
+
+ /**
+ * 测试当 ContactSecret 为空字符串时应该抛出异常
+ */
+ @Test
+ public void testGetContactAccessToken_WithEmptySecret() {
+ config.setContactSecret(" ");
+ BaseWxCpServiceImpl service = createTestService(config);
+
+ // 验证当 secret 为空字符串时抛出异常
+ assertThatThrownBy(() -> service.getContactAccessToken(true))
+ .isInstanceOf(WxErrorException.class)
+ .hasMessageContaining("通讯录同步secret未配置");
+ }
+}
diff --git a/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/impl/WxCpServiceGetMsgAuditAccessTokenTest.java b/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/impl/WxCpServiceGetMsgAuditAccessTokenTest.java
index da74c1d13..edea88e01 100644
--- a/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/impl/WxCpServiceGetMsgAuditAccessTokenTest.java
+++ b/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/impl/WxCpServiceGetMsgAuditAccessTokenTest.java
@@ -153,6 +153,11 @@ public class WxCpServiceGetMsgAuditAccessTokenTest {
return getWxCpConfigStorage().getMsgAuditAccessToken();
}
+ @Override
+ public String getContactAccessToken(boolean forceRefresh) throws WxErrorException {
+ return "mock_contact_access_token";
+ }
+
@Override
public void initHttp() {
}
@@ -213,6 +218,11 @@ public class WxCpServiceGetMsgAuditAccessTokenTest {
}
}
+ @Override
+ public String getContactAccessToken(boolean forceRefresh) throws WxErrorException {
+ return "mock_contact_access_token";
+ }
+
@Override
public void initHttp() {
}