From 3298814fa327d11a15d49c6ebff32ae2deebe49e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:22:14 +0000 Subject: [PATCH 1/5] Initial plan From 0a9e488b787e18414e70e9da2e52d7518c526209 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:35:01 +0000 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E9=80=9A?= =?UTF-8?q?=E8=AE=AF=E5=BD=95=E5=90=8C=E6=AD=A5=E4=BD=BF=E7=94=A8=E5=8D=95?= =?UTF-8?q?=E7=8B=AC=E7=9A=84secret=E8=8E=B7=E5=8F=96=E7=8B=AC=E7=AB=8Bacc?= =?UTF-8?q?ess=20token?= 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 | 46 +++ .../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, 683 insertions(+) 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 f66acc0252..269a69a475 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 7c72cb9a8c..a3ec703ca4 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 String postForMsgAudit(String url, String postData) throws WxErrorExcepti
     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 ef78116e12..8285e59df2 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 String getAccessToken(boolean forceRefresh) throws WxErrorException {
     return this.configStorage.getAccessToken();
   }
 
+  @Override
+  public String getContactAccessToken(boolean forceRefresh) throws WxErrorException {
+    if (!this.configStorage.isContactAccessTokenExpired() && !forceRefresh) {
+      return this.configStorage.getContactAccessToken();
+    }
+
+    Lock lock = this.configStorage.getContactAccessTokenLock();
+    lock.lock();
+    try {
+      // 拿到锁之后,再次判断一下最新的token是否过期,避免重刷
+      if (!this.configStorage.isContactAccessTokenExpired() && !forceRefresh) {
+        return this.configStorage.getContactAccessToken();
+      }
+      // 使用通讯录同步secret获取access_token
+      String contactSecret = this.configStorage.getContactSecret();
+      if (contactSecret == null || contactSecret.trim().isEmpty()) {
+        throw new WxErrorException("通讯录同步secret未配置");
+      }
+      String url = String.format(this.configStorage.getApiUrl(WxCpApiPathConsts.GET_TOKEN),
+        this.configStorage.getCorpId(), contactSecret);
+
+      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.updateContactAccessToken(accessToken.getAccessToken(), accessToken.getExpiresIn());
+      } catch (IOException e) {
+        throw new WxRuntimeException(e);
+      }
+    } finally {
+      lock.unlock();
+    }
+    return this.configStorage.getContactAccessToken();
+  }
+
   @Override
   public String getMsgAuditAccessToken(boolean forceRefresh) throws WxErrorException {
     if (!this.configStorage.isMsgAuditAccessTokenExpired() && !forceRefresh) {
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 3ca041e7ec..129823df5a 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
@@ -76,6 +76,51 @@ public String getAccessToken(boolean forceRefresh) throws WxErrorException {
     return this.configStorage.getAccessToken();
   }
 
+  @Override
+  public String getContactAccessToken(boolean forceRefresh) throws WxErrorException {
+    if (!this.configStorage.isContactAccessTokenExpired() && !forceRefresh) {
+      return this.configStorage.getContactAccessToken();
+    }
+
+    Lock lock = this.configStorage.getContactAccessTokenLock();
+    lock.lock();
+    try {
+      // 拿到锁之后,再次判断一下最新的token是否过期,避免重刷
+      if (!this.configStorage.isContactAccessTokenExpired() && !forceRefresh) {
+        return this.configStorage.getContactAccessToken();
+      }
+      // 使用通讯录同步secret获取access_token
+      String contactSecret = this.configStorage.getContactSecret();
+      if (contactSecret == null || contactSecret.trim().isEmpty()) {
+        throw new WxErrorException("通讯录同步secret未配置");
+      }
+      String url = String.format(this.configStorage.getApiUrl(WxCpApiPathConsts.GET_TOKEN),
+        this.configStorage.getCorpId(), contactSecret);
+
+      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.updateContactAccessToken(accessToken.getAccessToken(), accessToken.getExpiresIn());
+      } catch (IOException e) {
+        throw new WxRuntimeException(e);
+      }
+    } finally {
+      lock.unlock();
+    }
+    return this.configStorage.getContactAccessToken();
+  }
+
   @Override
   public String getMsgAuditAccessToken(boolean forceRefresh) throws WxErrorException {
     if (!this.configStorage.isMsgAuditAccessTokenExpired() && !forceRefresh) {
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 7b651cbc08..69cc074be9 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 getContactAccessToken(boolean forceRefresh) throws WxErrorException {
+    final WxCpConfigStorage configStorage = getWxCpConfigStorage();
+    if (!configStorage.isContactAccessTokenExpired() && !forceRefresh) {
+      return configStorage.getContactAccessToken();
+    }
+    Lock lock = configStorage.getContactAccessTokenLock();
+    lock.lock();
+    try {
+      // 拿到锁之后,再次判断一下最新的token是否过期,避免重刷
+      if (!configStorage.isContactAccessTokenExpired() && !forceRefresh) {
+        return configStorage.getContactAccessToken();
+      }
+      // 使用通讯录同步secret获取access_token
+      String contactSecret = configStorage.getContactSecret();
+      if (contactSecret == null || contactSecret.trim().isEmpty()) {
+        throw new WxErrorException("通讯录同步secret未配置");
+      }
+      String url = String.format(configStorage.getApiUrl(WxCpApiPathConsts.GET_TOKEN),
+        this.configStorage.getCorpId(), contactSecret);
+      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.updateContactAccessToken(accessToken.getAccessToken(), accessToken.getExpiresIn());
+      } catch (IOException e) {
+        throw new WxRuntimeException(e);
+      }
+    } finally {
+      lock.unlock();
+    }
+    return configStorage.getContactAccessToken();
+  }
+
   @Override
   public String getMsgAuditAccessToken(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 eba9315649..ef6d86c665 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
@@ -65,6 +65,45 @@ public String getAccessToken(boolean forceRefresh) throws WxErrorException {
     return this.configStorage.getAccessToken();
   }
 
+  @Override
+  public String getContactAccessToken(boolean forceRefresh) throws WxErrorException {
+    if (!this.configStorage.isContactAccessTokenExpired() && !forceRefresh) {
+      return this.configStorage.getContactAccessToken();
+    }
+
+    Lock lock = this.configStorage.getContactAccessTokenLock();
+    lock.lock();
+    try {
+      // 拿到锁之后,再次判断一下最新的token是否过期,避免重刷
+      if (!this.configStorage.isContactAccessTokenExpired() && !forceRefresh) {
+        return this.configStorage.getContactAccessToken();
+      }
+      // 使用通讯录同步secret获取access_token
+      String contactSecret = this.configStorage.getContactSecret();
+      if (contactSecret == null || contactSecret.trim().isEmpty()) {
+        throw new WxErrorException("通讯录同步secret未配置");
+      }
+      HttpRequest request = HttpRequest.get(String.format(this.configStorage.getApiUrl(WxCpApiPathConsts.GET_TOKEN),
+        this.configStorage.getCorpId(), contactSecret));
+      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.updateContactAccessToken(accessToken.getAccessToken(), accessToken.getExpiresIn());
+    } finally {
+      lock.unlock();
+    }
+    return this.configStorage.getContactAccessToken();
+  }
+
   @Override
   public String getMsgAuditAccessToken(boolean forceRefresh) throws WxErrorException {
     if (!this.configStorage.isMsgAuditAccessTokenExpired() && !forceRefresh) {
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 ce77b37805..2f47a438eb 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
@@ -75,6 +75,52 @@ public String getAccessToken(boolean forceRefresh) throws WxErrorException {
     return this.configStorage.getAccessToken();
   }
 
+  @Override
+  public String getContactAccessToken(boolean forceRefresh) throws WxErrorException {
+    if (!this.configStorage.isContactAccessTokenExpired() && !forceRefresh) {
+      return this.configStorage.getContactAccessToken();
+    }
+
+    Lock lock = this.configStorage.getContactAccessTokenLock();
+    lock.lock();
+    try {
+      // 拿到锁之后,再次判断一下最新的token是否过期,避免重刷
+      if (!this.configStorage.isContactAccessTokenExpired() && !forceRefresh) {
+        return this.configStorage.getContactAccessToken();
+      }
+      // 使用通讯录同步secret获取access_token
+      String contactSecret = this.configStorage.getContactSecret();
+      if (contactSecret == null || contactSecret.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(),
+          contactSecret))
+        .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.updateContactAccessToken(accessToken.getAccessToken(),
+        accessToken.getExpiresIn());
+    } finally {
+      lock.unlock();
+    }
+    return this.configStorage.getContactAccessToken();
+  }
+
   @Override
   public String getMsgAuditAccessToken(boolean forceRefresh) throws WxErrorException {
     if (!this.configStorage.isMsgAuditAccessTokenExpired() && !forceRefresh) {
diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/WxCpConfigStorage.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/WxCpConfigStorage.java
index fe6acf12d3..4159e186f9 100644
--- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/WxCpConfigStorage.java
+++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/WxCpConfigStorage.java
@@ -258,6 +258,47 @@ public interface WxCpConfigStorage {
    */
   String getWebhookKey();
 
+  /**
+   * 获取通讯录同步的secret
+   *
+   * @return contact secret
+   */
+  String getContactSecret();
+
+  /**
+   * 获取通讯录同步的access token
+   *
+   * @return contact access token
+   */
+  String getContactAccessToken();
+
+  /**
+   * 获取通讯录同步access token的锁
+   *
+   * @return contact access token lock
+   */
+  Lock getContactAccessTokenLock();
+
+  /**
+   * 检查通讯录同步access token是否已过期
+   *
+   * @return true: 已过期, false: 未过期
+   */
+  boolean isContactAccessTokenExpired();
+
+  /**
+   * 强制将通讯录同步access token过期掉
+   */
+  void expireContactAccessToken();
+
+  /**
+   * 更新通讯录同步access token
+   *
+   * @param accessToken      通讯录同步access token
+   * @param expiresInSeconds 过期时间(秒)
+   */
+  void updateContactAccessToken(String accessToken, int expiresInSeconds);
+
   /**
    * 获取会话存档的secret
    *
diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/impl/WxCpDefaultConfigImpl.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/impl/WxCpDefaultConfigImpl.java
index c7b300ba48..8395ca28a5 100644
--- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/impl/WxCpDefaultConfigImpl.java
+++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/config/impl/WxCpDefaultConfigImpl.java
@@ -44,6 +44,16 @@ public class WxCpDefaultConfigImpl implements WxCpConfigStorage, Serializable {
   private volatile String token;
   private volatile String aesKey;
   private volatile long expiresTime;
+  /**
+   * 通讯录同步secret及其access token
+   */
+  private volatile String contactSecret;
+  private volatile String contactAccessToken;
+  private volatile long contactAccessTokenExpiresTime;
+  /**
+   * 通讯录同步access token锁
+   */
+  protected transient Lock contactAccessTokenLock = new ReentrantLock();
   /**
    * 会话存档私钥以及sdk路径
    */
@@ -465,6 +475,49 @@ public WxCpDefaultConfigImpl setWebhookKey(String webhookKey) {
     return this;
   }
 
+  @Override
+  public String getContactSecret() {
+    return this.contactSecret;
+  }
+
+  /**
+   * 设置通讯录同步secret.
+   *
+   * @param contactSecret 通讯录同步secret
+   * @return this
+   */
+  public WxCpDefaultConfigImpl setContactSecret(String contactSecret) {
+    this.contactSecret = contactSecret;
+    return this;
+  }
+
+  @Override
+  public String getContactAccessToken() {
+    return this.contactAccessToken;
+  }
+
+  @Override
+  public Lock getContactAccessTokenLock() {
+    return this.contactAccessTokenLock;
+  }
+
+  @Override
+  public boolean isContactAccessTokenExpired() {
+    return System.currentTimeMillis() > 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 2ba71fffb6..0e9aef7e91 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 String getMsgAuditSecret() {
     return null;
   }
 
+  @Override
+  public String getContactSecret() {
+    return null;
+  }
+
+  @Override
+  public String getContactAccessToken() {
+    return null;
+  }
+
+  @Override
+  public Lock getContactAccessTokenLock() {
+    return new ReentrantLock();
+  }
+
+  @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 87d2094e58..bdc85afcfc 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 String getMsgAuditAccessToken(boolean forceRefresh) throws WxErrorExcepti
         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 0000000000..5fb79aaaee
--- /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 da74c1d13a..edea88e01e 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 String getMsgAuditAccessToken(boolean forceRefresh) throws WxErrorExcepti
         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 String getMsgAuditAccessToken(boolean forceRefresh) throws WxErrorExcepti
         }
       }
 
+      @Override
+      public String getContactAccessToken(boolean forceRefresh) throws WxErrorException {
+        return "mock_contact_access_token";
+      }
+
       @Override
       public void initHttp() {
       }

From a69e8f83a1bcbd97651787cd3b16e12edb2b2d77 Mon Sep 17 00:00:00 2001
From: Binary Wang 
Date: Thu, 4 Jun 2026 10:18:18 +0800
Subject: [PATCH 3/5] Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---
 .../me/chanjar/weixin/cp/api/impl/WxCpServiceOkHttpImpl.java    | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

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 2f47a438eb..a720766520 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
@@ -106,8 +106,8 @@ public String getContactAccessToken(boolean forceRefresh) throws WxErrorExceptio
         resultContent = response.body().string();
       } catch (IOException e) {
         log.error(e.getMessage(), e);
+        throw new WxErrorException(e);
       }
-
       WxError error = WxError.fromJson(resultContent, WxType.CP);
       if (error.getErrorCode() != 0) {
         throw new WxErrorException(error);

From c4b69948b17fa053c50f6ae9b1b590793f1cf797 Mon Sep 17 00:00:00 2001
From: Binary Wang 
Date: Thu, 4 Jun 2026 10:18:45 +0800
Subject: [PATCH 4/5] Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---
 .../me/chanjar/weixin/cp/config/impl/WxCpRedisConfigImpl.java   | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

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 0e9aef7e91..01c61673a5 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
@@ -504,7 +504,7 @@ public String getContactAccessToken() {
 
   @Override
   public Lock getContactAccessTokenLock() {
-    return new ReentrantLock();
+    return this.msgAuditAccessTokenLock;
   }
 
   @Override

From 57a9da70b579cec1ff027334bd5a0bf4c52ddc6a Mon Sep 17 00:00:00 2001
From: "openai-code-agent[bot]" <242516109+Codex@users.noreply.github.com>
Date: Thu, 4 Jun 2026 02:24:45 +0000
Subject: [PATCH 5/5] =?UTF-8?q?=E4=BF=AE=E5=A4=8DOkHttp=E8=8E=B7=E5=8F=96t?=
 =?UTF-8?q?oken=E6=97=B6=E5=90=9E=E5=BC=82=E5=B8=B8=E4=B8=8E=E6=BD=9C?=
 =?UTF-8?q?=E5=9C=A8=E7=A9=BA=E6=8C=87=E9=92=88?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Co-authored-by: binarywang <1343140+binarywang@users.noreply.github.com>
---
 .../cp/api/impl/WxCpServiceOkHttpImpl.java    | 20 ++++++++++++++-----
 1 file changed, 15 insertions(+), 5 deletions(-)

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 a720766520..76847f1f93 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
@@ -56,12 +56,15 @@ public String getAccessToken(boolean forceRefresh) throws WxErrorException {
           this.configStorage.getCorpSecret()))
         .get()
         .build();
-      String resultContent = null;
-      try {
-        Response response = client.newCall(request).execute();
+      String resultContent;
+      try (Response response = client.newCall(request).execute()) {
+        if (response.body() == null) {
+          throw new WxErrorException("请求access token失败:响应内容为空");
+        }
         resultContent = response.body().string();
       } catch (IOException e) {
         log.error(e.getMessage(), e);
+        throw new WxErrorException(e);
       }
 
       WxError error = WxError.fromJson(resultContent, WxType.CP);
@@ -101,8 +104,11 @@ public String getContactAccessToken(boolean forceRefresh) throws WxErrorExceptio
           contactSecret))
         .get()
         .build();
-      String resultContent = null;
+      String resultContent;
       try (Response response = client.newCall(request).execute()) {
+        if (response.body() == null) {
+          throw new WxErrorException("请求通讯录同步access token失败:响应内容为空");
+        }
         resultContent = response.body().string();
       } catch (IOException e) {
         log.error(e.getMessage(), e);
@@ -147,11 +153,15 @@ public String getMsgAuditAccessToken(boolean forceRefresh) throws WxErrorExcepti
           msgAuditSecret))
         .get()
         .build();
-      String resultContent = null;
+      String resultContent;
       try (Response response = client.newCall(request).execute()) {
+        if (response.body() == null) {
+          throw new WxErrorException("请求会话存档access token失败:响应内容为空");
+        }
         resultContent = response.body().string();
       } catch (IOException e) {
         log.error(e.getMessage(), e);
+        throw new WxErrorException(e);
       }
 
       WxError error = WxError.fromJson(resultContent, WxType.CP);