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() {
       }