From ae7b04e7dd96e4e22673284873fc34a34b6121ec Mon Sep 17 00:00:00 2001 From: Binary Wang Date: Fri, 21 Aug 2026 11:22:44 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E4=BC=81?= =?UTF-8?q?=E4=B8=9A=E5=BE=AE=E4=BF=A1=E6=99=BA=E8=83=BD=E6=9C=BA=E5=99=A8?= =?UTF-8?q?=E4=BA=BAAPI=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...026-08-21-cp-intelligent-robot-api-mode.md | 116 ++++++++++++++++++ ...21-cp-intelligent-robot-api-mode-design.md | 30 +++++ weixin-java-cp/INTELLIGENT_ROBOT.md | 54 ++++---- .../cp/api/WxCpIntelligentRobotService.java | 32 +++++ .../impl/WxCpIntelligentRobotServiceImpl.java | 16 +++ .../crypto/WxCpIntelligentRobotCryptUtil.java | 48 ++++++++ ...xCpIntelligentRobotApiModeServiceTest.java | 57 +++++++++ .../WxCpIntelligentRobotCryptUtilTest.java | 28 +++++ 8 files changed, 353 insertions(+), 28 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-21-cp-intelligent-robot-api-mode.md create mode 100644 docs/superpowers/specs/2026-08-21-cp-intelligent-robot-api-mode-design.md create mode 100644 weixin-java-cp/src/main/java/me/chanjar/weixin/cp/util/crypto/WxCpIntelligentRobotCryptUtil.java create mode 100644 weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/impl/WxCpIntelligentRobotApiModeServiceTest.java create mode 100644 weixin-java-cp/src/test/java/me/chanjar/weixin/cp/util/crypto/WxCpIntelligentRobotCryptUtilTest.java diff --git a/docs/superpowers/plans/2026-08-21-cp-intelligent-robot-api-mode.md b/docs/superpowers/plans/2026-08-21-cp-intelligent-robot-api-mode.md new file mode 100644 index 0000000000..ca82d3a4d3 --- /dev/null +++ b/docs/superpowers/plans/2026-08-21-cp-intelligent-robot-api-mode.md @@ -0,0 +1,116 @@ +# 企业微信智能机器人 API 模式支持 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在不影响旧 access-token 接口的前提下,支持新版智能机器人 API 模式回调解密和 response_url 加密回复。 + +**Architecture:** 用独立密码工具承载机器人 API 模式的 Token、AESKey、机器人 ID,避免误用企业应用配置。服务接口复用该工具解析回调,并以原始临时 URL 执行 HTTP POST,文档只展示这条链路。 + +**Tech Stack:** Java 8、Gson、现有 `WxCryptUtil`、Apache HttpClient 5、JUnit 5。 + +## Global Constraints + +- 不删除或改变现有 `WxCpIntelligentRobotService` 的 access-token 方法。 +- 不在 `response_url` 上拼接 `access_token`。 +- 所有生产行为先由失败的单测定义。 + +--- + +### Task 1: API 模式密码工具 + +**Files:** +- Create: `weixin-java-cp/src/main/java/me/chanjar/weixin/cp/util/crypto/WxCpIntelligentRobotCryptUtil.java` +- Test: `weixin-java-cp/src/test/java/me/chanjar/weixin/cp/util/crypto/WxCpIntelligentRobotCryptUtilTest.java` + +**Interfaces:** +- Produces: `WxCpIntelligentRobotCryptUtil(String token, String encodingAesKey, String aiBotId)` with `decrypt`, `encrypt`, and `verifyUrl` methods. + +- [ ] **Step 1: Write failing encryption round-trip tests** + +```java +assertEquals(plainJson, cryptUtil.decrypt(signature, timestamp, nonce, + cryptUtil.encrypt(plainJson, timestamp, nonce))); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `mvn -pl weixin-java-cp -Dtest=WxCpIntelligentRobotCryptUtilTest test` +Expected: compilation failure because the utility does not exist. + +- [ ] **Step 3: Implement the minimal utility** + +Extend `WxCryptUtil`, initialize `token`, decoded `aesKey`, and `appidOrCorpid` from robot settings; delegate its encrypt/decrypt mechanics and convert encrypted JSON to the API-mode envelope. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `mvn -pl weixin-java-cp -Dtest=WxCpIntelligentRobotCryptUtilTest test` +Expected: PASS. + +### Task 2: Service parsing and response posting + +**Files:** +- Modify: `weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpIntelligentRobotService.java` +- Modify: `weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpIntelligentRobotServiceImpl.java` +- Test: `weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/impl/WxCpIntelligentRobotServiceImplTest.java` + +**Interfaces:** +- Consumes: `WxCpIntelligentRobotCryptUtil` from Task 1. +- Produces: encrypted callback parser and `response_url` reply method. + +- [ ] **Step 1: Write failing tests for encrypted callback parsing and reply request** + +```java +assertEquals("text", service.parseEncryptedCallbackMessage(...).getMsgType()); +assertEquals(plainJson, decryptPostedBody(localResponseUrl)); +``` + +- [ ] **Step 2: Run the targeted tests to verify they fail** + +Run: `mvn -pl weixin-java-cp -Dtest=WxCpIntelligentRobotServiceImplTest test` +Expected: compilation failure because new service methods do not exist. + +- [ ] **Step 3: Implement minimal service methods** + +Construct the dedicated crypt utility, parse its plaintext with `WxCpIntelligentRobotMessage.fromJson`, encrypt outgoing JSON, and invoke the raw URL through the existing HTTP client abstraction without token refresh. + +- [ ] **Step 4: Run targeted tests to verify they pass** + +Run: `mvn -pl weixin-java-cp -Dtest=WxCpIntelligentRobotServiceImplTest test` +Expected: PASS. + +### Task 3: Correct public documentation + +**Files:** +- Modify: `weixin-java-cp/INTELLIGENT_ROBOT.md` + +**Interfaces:** +- Consumes: final API names from Tasks 1 and 2. + +- [ ] **Step 1: Replace XML and access-token examples for API mode** + +Document robot-console configuration, encrypted JSON callback parsing, and `response_url` replies. Mark the pre-existing create/chat/send methods as legacy access-token endpoints. + +- [ ] **Step 2: Verify all documented symbols exist** + +Run: `rg -n 'parseEncryptedCallbackMessage|replyMessage' weixin-java-cp/src/main/java` +Expected: both APIs are found. + +### Task 4: Full verification and publication + +**Files:** +- Modify: all files from Tasks 1–3. + +- [ ] **Step 1: Run module test suite** + +Run: `mvn -pl weixin-java-cp test` +Expected: PASS. + +- [ ] **Step 2: Inspect scope and commit only intended files** + +Run: `git status --short && git diff --check` +Expected: only API-mode implementation, tests, and docs are changed; no whitespace errors. + +- [ ] **Step 3: Publish a draft PR** + +Run: `git add -- && git commit -m 'feat: 支持企业微信智能机器人 API 模式' && git pull --rebase && git push` +Expected: branch is pushed and a draft PR targets `develop`. diff --git a/docs/superpowers/specs/2026-08-21-cp-intelligent-robot-api-mode-design.md b/docs/superpowers/specs/2026-08-21-cp-intelligent-robot-api-mode-design.md new file mode 100644 index 0000000000..4eb43d2d9d --- /dev/null +++ b/docs/superpowers/specs/2026-08-21-cp-intelligent-robot-api-mode-design.md @@ -0,0 +1,30 @@ +# 企业微信智能机器人 API 模式支持设计 + +## 目标 + +让 `weixin-java-cp` 能接入企业微信当前的智能机器人 API 模式:解密回调 JSON、解析消息,并将加密回复 POST 到回调给出的 `response_url`。 + +## 范围与边界 + +- 保留已有基于企业应用 `access_token` 的 `WxCpIntelligentRobotService` 方法,避免破坏兼容性;这些方法不用于新版 API 模式。 +- 新增独立的 API 模式密码工具,使用机器人后台配置的 Token、EncodingAESKey 和机器人 ID(作为接收方标识),不依赖 `WxCpConfigStorage` 的 `secret`。 +- 回调入口将加密 JSON 信封解密成明文 JSON,再交给已有 `WxCpIntelligentRobotMessage` 解析。 +- 回复 API 接收 `response_url` 与明文业务 JSON,完成加密、签名和 POST;它不追加 `access_token`。 +- 文档只展示新版 API 模式的正确链路,并明确旧服务的方法边界。 + +## 接口设计 + +新增 `WxCpIntelligentRobotCryptUtil(token, encodingAesKey, aiBotId)`: + +- `decrypt(msgSignature, timestamp, nonce, encryptedJson)` 返回回调明文 JSON; +- `encrypt(plainJson, timestamp, nonce)` 返回可直接 POST 的加密 JSON 信封; +- `verifyUrl(msgSignature, timestamp, nonce, echoStr)` 验证 URL 时解密 echo 字段。 + +在 `WxCpIntelligentRobotService` 新增: + +- `parseEncryptedCallbackMessage(...)`,解密后返回 `WxCpIntelligentRobotMessage`; +- `replyMessage(responseUrl, plainJson, ...)`,将加密响应 POST 到临时 URL。 + +## 验证 + +单测覆盖已知密文回调可解密并解析,及加密后的回复可被同一配置解密回原文;HTTP 层以本地 mock 服务验证不携带 access token、正文为加密 JSON。模块测试与格式检查通过。 diff --git a/weixin-java-cp/INTELLIGENT_ROBOT.md b/weixin-java-cp/INTELLIGENT_ROBOT.md index 18dd0c677f..0fbe205f1a 100644 --- a/weixin-java-cp/INTELLIGENT_ROBOT.md +++ b/weixin-java-cp/INTELLIGENT_ROBOT.md @@ -1,6 +1,9 @@ # 企业微信智能机器人接口 -本模块提供企业微信智能机器人相关的API接口实现。 +本模块提供企业微信智能机器人相关的 API 接口实现。 + +> `createRobot`、`chat`、`sendMessage` 等既有方法走企业应用 `access_token` 接口, +> 需要在 `WxCpConfigStorage` 中配置应用 `agentId` 和 `secret`。它们不适用于机器人后台创建的新版 API 模式。 ## 官方文档 @@ -73,7 +76,7 @@ String sessionId = "session123"; robotService.resetSession(robotId, userid, sessionId); ``` -### 主动发送消息 +### 旧版 access_token 主动发送消息 智能机器人可以主动向用户发送消息,用于推送通知或提醒。 @@ -89,34 +92,29 @@ String msgId = response.getMsgId(); String sessionId = response.getSessionId(); ``` -### 接收用户消息 +### 新版 API 模式:接收回调与回复消息 -当用户向智能机器人发送消息时,企业微信会通过回调接口推送消息。可以使用 `WxCpXmlMessage` 接收和解析这些消息: +在机器人后台开启 API 模式后,配置 URL、Token、EncodingAESKey。企业微信会推送加密 JSON 回调; +它不是 XML,也不需要企业应用 `secret`。从请求参数取得 `msg_signature`、`timestamp`、`nonce`, +从请求体取得 `encrypt` 字段后,可以直接解密和解析: ```java -// 在接收回调消息的接口中 -WxCpXmlMessage message = WxCpXmlMessage.fromEncryptedXml( - requestBody, wxCpConfigStorage, timestamp, nonce, msgSignature -); - -// 获取智能机器人相关字段 -String robotId = message.getRobotId(); // 机器人ID -String sessionId = message.getSessionId(); // 会话ID -String content = message.getContent(); // 消息内容 -String fromUser = message.getFromUserName(); // 发送用户 - -// 处理消息并回复 -// ... +WxCpIntelligentRobotMessage callbackMessage = + robotService.parseEncryptedCallbackMessage( + msgSignature, timestamp, nonce, encryptedJson, + token, encodingAesKey, aiBotId); + +String responseUrl = callbackMessage.getResponseUrl(); +String content = callbackMessage.getText().getContent(); ``` -对于智能机器人 API 模式的 JSON 回调消息,可使用 `WxCpIntelligentRobotMessage` 解析: +回复时使用回调中的短期 `response_url`,不调用基于 `access_token` 的 `sendMessage`: ```java -WxCpIntelligentRobotMessage callbackMessage = - robotService.parseCallbackMessage(jsonBody); -String botId = callbackMessage.getAiBotId(); -String userId = callbackMessage.getFrom().getUserid(); -String msgType = callbackMessage.getMsgType(); +String replyJson = "{\"msgtype\":\"text\",\"text\":{\"content\":\"您好\"}}"; +robotService.replyMessage( + responseUrl, replyJson, token, encodingAesKey, aiBotId, + String.valueOf(System.currentTimeMillis() / 1000), java.util.UUID.randomUUID().toString()); ``` ### 删除智能机器人 @@ -144,7 +142,8 @@ robotService.deleteRobot(robotId); ### 消息接收 -- `WxCpXmlMessage`: 支持接收智能机器人回调消息,包含 `robotId` 和 `sessionId` 字段 +- `WxCpIntelligentRobotMessage`: 智能机器人 API 模式的已解密 JSON 回调消息 +- `WxCpIntelligentRobotCryptUtil`: 智能机器人 API 模式的消息加解密工具 ### 服务接口 @@ -153,7 +152,6 @@ robotService.deleteRobot(robotId); ## 注意事项 -1. 需要确保企业微信应用具有智能机器人相关权限 -2. 智能机器人功能可能需要特定的企业微信版本支持 -3. 会话ID可以用于保持对话的连续性,提升用户体验 -4. 机器人状态: 0表示停用,1表示启用 +1. 新版 API 模式的 Token、EncodingAESKey 和机器人 ID 由机器人后台配置,不要填写企业应用 secret。 +2. `response_url` 是回调附带的临时地址,应及时使用,且不应持久化。 +3. `parseCallbackMessage` 仅用于已解密的 JSON;HTTP 回调入口应使用 `parseEncryptedCallbackMessage`。 diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpIntelligentRobotService.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpIntelligentRobotService.java index 58f4373ceb..75e24b6878 100644 --- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpIntelligentRobotService.java +++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpIntelligentRobotService.java @@ -82,4 +82,36 @@ public interface WxCpIntelligentRobotService { */ WxCpIntelligentRobotMessage parseCallbackMessage(String callbackMessageJson); + /** + * 解密并解析智能机器人 API 模式回调消息. + * + * @param msgSignature 回调 URL 参数中的签名 + * @param timestamp 回调 URL 参数中的时间戳 + * @param nonce 回调 URL 参数中的随机串 + * @param encryptedJson 回调 JSON 信封中的 encrypt 字段 + * @param token 机器人后台配置的 Token + * @param encodingAesKey 机器人后台配置的 EncodingAESKey + * @param aiBotId 机器人 ID + * @return 解密并解析后的回调消息 + */ + WxCpIntelligentRobotMessage parseEncryptedCallbackMessage(String msgSignature, String timestamp, String nonce, + String encryptedJson, String token, String encodingAesKey, + String aiBotId); + + /** + * 加密并向智能机器人 API 模式的临时 response_url 回复消息. + * + * @param responseUrl 回调消息中的 response_url + * @param plainJson 回复的明文 JSON + * @param token 机器人后台配置的 Token + * @param encodingAesKey 机器人后台配置的 EncodingAESKey + * @param aiBotId 机器人 ID + * @param timestamp 回复时间戳 + * @param nonce 回复随机串 + * @return 企业微信响应内容 + * @throws WxErrorException 微信接口异常 + */ + String replyMessage(String responseUrl, String plainJson, String token, String encodingAesKey, String aiBotId, + String timestamp, String nonce) throws WxErrorException; + } diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpIntelligentRobotServiceImpl.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpIntelligentRobotServiceImpl.java index aba1ee85c4..a5ccdca5e1 100644 --- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpIntelligentRobotServiceImpl.java +++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpIntelligentRobotServiceImpl.java @@ -6,6 +6,7 @@ import me.chanjar.weixin.cp.api.WxCpIntelligentRobotService; import me.chanjar.weixin.cp.api.WxCpService; import me.chanjar.weixin.cp.bean.intelligentrobot.*; +import me.chanjar.weixin.cp.util.crypto.WxCpIntelligentRobotCryptUtil; import me.chanjar.weixin.cp.util.json.WxCpGsonBuilder; import static me.chanjar.weixin.cp.constant.WxCpApiPathConsts.IntelligentRobot.*; @@ -72,4 +73,19 @@ public WxCpIntelligentRobotMessage parseCallbackMessage(String callbackMessageJs return WxCpIntelligentRobotMessage.fromJson(callbackMessageJson); } + @Override + public WxCpIntelligentRobotMessage parseEncryptedCallbackMessage(String msgSignature, String timestamp, String nonce, + String encryptedJson, String token, + String encodingAesKey, String aiBotId) { + WxCpIntelligentRobotCryptUtil cryptUtil = new WxCpIntelligentRobotCryptUtil(token, encodingAesKey, aiBotId); + return parseCallbackMessage(cryptUtil.decrypt(msgSignature, timestamp, nonce, encryptedJson)); + } + + @Override + public String replyMessage(String responseUrl, String plainJson, String token, String encodingAesKey, + String aiBotId, String timestamp, String nonce) throws WxErrorException { + WxCpIntelligentRobotCryptUtil cryptUtil = new WxCpIntelligentRobotCryptUtil(token, encodingAesKey, aiBotId); + return this.cpService.postWithoutToken(responseUrl, cryptUtil.encrypt(plainJson, timestamp, nonce)); + } + } diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/util/crypto/WxCpIntelligentRobotCryptUtil.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/util/crypto/WxCpIntelligentRobotCryptUtil.java new file mode 100644 index 0000000000..2da2de13da --- /dev/null +++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/util/crypto/WxCpIntelligentRobotCryptUtil.java @@ -0,0 +1,48 @@ +package me.chanjar.weixin.cp.util.crypto; + +import com.google.gson.JsonObject; +import me.chanjar.weixin.common.util.crypto.SHA1; +import me.chanjar.weixin.common.util.crypto.WxCryptUtil; +import me.chanjar.weixin.cp.util.json.WxCpGsonBuilder; + +import java.util.UUID; + +/** + * 企业微信智能机器人 API 模式消息加解密工具. + * + *

机器人 API 模式使用机器人后台配置的 Token、EncodingAESKey 和机器人 ID, + * 与企业应用 access_token 无关。

+ */ +public class WxCpIntelligentRobotCryptUtil extends WxCryptUtil { + + public WxCpIntelligentRobotCryptUtil(String token, String encodingAesKey, String aiBotId) { + super(token, encodingAesKey, aiBotId); + } + + /** + * 解密机器人 API 模式的 JSON 回调消息. + */ + public String decrypt(String msgSignature, String timestamp, String nonce, String encryptedContent) { + return decryptContent(msgSignature, timestamp, nonce, encryptedContent); + } + + /** + * 加密机器人 API 模式的 JSON 回复消息. + */ + public String encrypt(String plainJson, String timestamp, String nonce) { + String encryptedContent = encrypt(UUID.randomUUID().toString().replace("-", "").substring(0, 16), plainJson); + JsonObject result = new JsonObject(); + result.addProperty("encrypt", encryptedContent); + result.addProperty("msg_signature", SHA1.gen(this.token, timestamp, nonce, encryptedContent)); + result.addProperty("timestamp", timestamp); + result.addProperty("nonce", nonce); + return WxCpGsonBuilder.create().toJson(result); + } + + /** + * 解密 URL 校验请求中的 echostr. + */ + public String verifyUrl(String msgSignature, String timestamp, String nonce, String echoStr) { + return decrypt(msgSignature, timestamp, nonce, echoStr); + } +} diff --git a/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/impl/WxCpIntelligentRobotApiModeServiceTest.java b/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/impl/WxCpIntelligentRobotApiModeServiceTest.java new file mode 100644 index 0000000000..67ade67f1c --- /dev/null +++ b/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/impl/WxCpIntelligentRobotApiModeServiceTest.java @@ -0,0 +1,57 @@ +package me.chanjar.weixin.cp.api.impl; + +import com.google.gson.JsonObject; +import me.chanjar.weixin.common.util.json.GsonParser; +import me.chanjar.weixin.cp.api.WxCpService; +import me.chanjar.weixin.cp.bean.intelligentrobot.WxCpIntelligentRobotMessage; +import me.chanjar.weixin.cp.util.crypto.WxCpIntelligentRobotCryptUtil; +import org.mockito.ArgumentCaptor; +import org.testng.annotations.Test; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; + +public class WxCpIntelligentRobotApiModeServiceTest { + private static final String TOKEN = "test-token"; + private static final String AES_KEY = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFA"; + private static final String AI_BOT_ID = "bot_1"; + private static final String TIMESTAMP = "1710000000"; + private static final String NONCE = "test-nonce"; + + @Test + public void shouldParseEncryptedCallbackMessage() { + String callbackJson = "{\"msgid\":\"msg_1\",\"aibotid\":\"bot_1\",\"msgtype\":\"text\"," + + "\"from\":{\"userid\":\"user_1\"},\"text\":{\"content\":\"hello\"}}"; + WxCpIntelligentRobotCryptUtil cryptUtil = new WxCpIntelligentRobotCryptUtil(TOKEN, AES_KEY, AI_BOT_ID); + JsonObject encrypted = GsonParser.parse(cryptUtil.encrypt(callbackJson, TIMESTAMP, NONCE)); + WxCpIntelligentRobotServiceImpl service = new WxCpIntelligentRobotServiceImpl(mock(WxCpService.class)); + + WxCpIntelligentRobotMessage message = service.parseEncryptedCallbackMessage( + encrypted.get("msg_signature").getAsString(), TIMESTAMP, NONCE, encrypted.get("encrypt").getAsString(), + TOKEN, AES_KEY, AI_BOT_ID); + + assertEquals(message.getMsgId(), "msg_1"); + assertEquals(message.getText().getContent(), "hello"); + } + + @Test + public void shouldReplyThroughResponseUrlWithoutAccessToken() throws Exception { + WxCpService cpService = mock(WxCpService.class); + when(cpService.postWithoutToken(anyString(), anyString())).thenReturn("ok"); + WxCpIntelligentRobotServiceImpl service = new WxCpIntelligentRobotServiceImpl(cpService); + String responseUrl = "https://example.com/response"; + String plainJson = "{\"msgtype\":\"text\"}"; + + assertEquals(service.replyMessage(responseUrl, plainJson, TOKEN, AES_KEY, AI_BOT_ID, TIMESTAMP, NONCE), "ok"); + + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class); + verify(cpService).postWithoutToken(org.mockito.ArgumentMatchers.eq(responseUrl), bodyCaptor.capture()); + JsonObject encrypted = GsonParser.parse(bodyCaptor.getValue()); + WxCpIntelligentRobotCryptUtil cryptUtil = new WxCpIntelligentRobotCryptUtil(TOKEN, AES_KEY, AI_BOT_ID); + assertEquals(cryptUtil.decrypt(encrypted.get("msg_signature").getAsString(), TIMESTAMP, NONCE, + encrypted.get("encrypt").getAsString()), plainJson); + } +} diff --git a/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/util/crypto/WxCpIntelligentRobotCryptUtilTest.java b/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/util/crypto/WxCpIntelligentRobotCryptUtilTest.java new file mode 100644 index 0000000000..79b9634cd0 --- /dev/null +++ b/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/util/crypto/WxCpIntelligentRobotCryptUtilTest.java @@ -0,0 +1,28 @@ +package me.chanjar.weixin.cp.util.crypto; + +import com.google.gson.JsonObject; +import me.chanjar.weixin.common.util.json.GsonParser; +import org.testng.annotations.Test; + +import static org.testng.Assert.assertEquals; + +public class WxCpIntelligentRobotCryptUtilTest { + private static final String TOKEN = "test-token"; + private static final String AES_KEY = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFA"; + private static final String AI_BOT_ID = "aibot-123"; + private static final String TIMESTAMP = "1710000000"; + private static final String NONCE = "test-nonce"; + + @Test + public void encryptShouldProduceDecryptableJsonEnvelope() { + WxCpIntelligentRobotCryptUtil cryptUtil = new WxCpIntelligentRobotCryptUtil(TOKEN, AES_KEY, AI_BOT_ID); + String plainJson = "{\"msgtype\":\"text\",\"text\":{\"content\":\"hello\"}}"; + + JsonObject encrypted = GsonParser.parse(cryptUtil.encrypt(plainJson, TIMESTAMP, NONCE)); + + assertEquals(encrypted.get("timestamp").getAsString(), TIMESTAMP); + assertEquals(encrypted.get("nonce").getAsString(), NONCE); + assertEquals(cryptUtil.decrypt(encrypted.get("msg_signature").getAsString(), TIMESTAMP, NONCE, + encrypted.get("encrypt").getAsString()), plainJson); + } +} From 65e77fdf26e444466b79c9aa30cb88f96ded735d Mon Sep 17 00:00:00 2001 From: Binary Wang Date: Fri, 21 Aug 2026 11:41:41 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E6=9C=BA=E5=99=A8=E4=BA=BAAPI=E6=A8=A1=E5=BC=8F=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...026-08-21-cp-intelligent-robot-api-mode.md | 116 ------------------ ...21-cp-intelligent-robot-api-mode-design.md | 30 ----- .../cp/api/WxCpIntelligentRobotService.java | 14 ++- .../crypto/WxCpIntelligentRobotCryptUtil.java | 42 ++++++- .../WxCpIntelligentRobotCryptUtilTest.java | 12 ++ weixin-java-cp/src/test/resources/testng.xml | 2 + 6 files changed, 64 insertions(+), 152 deletions(-) delete mode 100644 docs/superpowers/plans/2026-08-21-cp-intelligent-robot-api-mode.md delete mode 100644 docs/superpowers/specs/2026-08-21-cp-intelligent-robot-api-mode-design.md diff --git a/docs/superpowers/plans/2026-08-21-cp-intelligent-robot-api-mode.md b/docs/superpowers/plans/2026-08-21-cp-intelligent-robot-api-mode.md deleted file mode 100644 index ca82d3a4d3..0000000000 --- a/docs/superpowers/plans/2026-08-21-cp-intelligent-robot-api-mode.md +++ /dev/null @@ -1,116 +0,0 @@ -# 企业微信智能机器人 API 模式支持 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 在不影响旧 access-token 接口的前提下,支持新版智能机器人 API 模式回调解密和 response_url 加密回复。 - -**Architecture:** 用独立密码工具承载机器人 API 模式的 Token、AESKey、机器人 ID,避免误用企业应用配置。服务接口复用该工具解析回调,并以原始临时 URL 执行 HTTP POST,文档只展示这条链路。 - -**Tech Stack:** Java 8、Gson、现有 `WxCryptUtil`、Apache HttpClient 5、JUnit 5。 - -## Global Constraints - -- 不删除或改变现有 `WxCpIntelligentRobotService` 的 access-token 方法。 -- 不在 `response_url` 上拼接 `access_token`。 -- 所有生产行为先由失败的单测定义。 - ---- - -### Task 1: API 模式密码工具 - -**Files:** -- Create: `weixin-java-cp/src/main/java/me/chanjar/weixin/cp/util/crypto/WxCpIntelligentRobotCryptUtil.java` -- Test: `weixin-java-cp/src/test/java/me/chanjar/weixin/cp/util/crypto/WxCpIntelligentRobotCryptUtilTest.java` - -**Interfaces:** -- Produces: `WxCpIntelligentRobotCryptUtil(String token, String encodingAesKey, String aiBotId)` with `decrypt`, `encrypt`, and `verifyUrl` methods. - -- [ ] **Step 1: Write failing encryption round-trip tests** - -```java -assertEquals(plainJson, cryptUtil.decrypt(signature, timestamp, nonce, - cryptUtil.encrypt(plainJson, timestamp, nonce))); -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `mvn -pl weixin-java-cp -Dtest=WxCpIntelligentRobotCryptUtilTest test` -Expected: compilation failure because the utility does not exist. - -- [ ] **Step 3: Implement the minimal utility** - -Extend `WxCryptUtil`, initialize `token`, decoded `aesKey`, and `appidOrCorpid` from robot settings; delegate its encrypt/decrypt mechanics and convert encrypted JSON to the API-mode envelope. - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `mvn -pl weixin-java-cp -Dtest=WxCpIntelligentRobotCryptUtilTest test` -Expected: PASS. - -### Task 2: Service parsing and response posting - -**Files:** -- Modify: `weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpIntelligentRobotService.java` -- Modify: `weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpIntelligentRobotServiceImpl.java` -- Test: `weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/impl/WxCpIntelligentRobotServiceImplTest.java` - -**Interfaces:** -- Consumes: `WxCpIntelligentRobotCryptUtil` from Task 1. -- Produces: encrypted callback parser and `response_url` reply method. - -- [ ] **Step 1: Write failing tests for encrypted callback parsing and reply request** - -```java -assertEquals("text", service.parseEncryptedCallbackMessage(...).getMsgType()); -assertEquals(plainJson, decryptPostedBody(localResponseUrl)); -``` - -- [ ] **Step 2: Run the targeted tests to verify they fail** - -Run: `mvn -pl weixin-java-cp -Dtest=WxCpIntelligentRobotServiceImplTest test` -Expected: compilation failure because new service methods do not exist. - -- [ ] **Step 3: Implement minimal service methods** - -Construct the dedicated crypt utility, parse its plaintext with `WxCpIntelligentRobotMessage.fromJson`, encrypt outgoing JSON, and invoke the raw URL through the existing HTTP client abstraction without token refresh. - -- [ ] **Step 4: Run targeted tests to verify they pass** - -Run: `mvn -pl weixin-java-cp -Dtest=WxCpIntelligentRobotServiceImplTest test` -Expected: PASS. - -### Task 3: Correct public documentation - -**Files:** -- Modify: `weixin-java-cp/INTELLIGENT_ROBOT.md` - -**Interfaces:** -- Consumes: final API names from Tasks 1 and 2. - -- [ ] **Step 1: Replace XML and access-token examples for API mode** - -Document robot-console configuration, encrypted JSON callback parsing, and `response_url` replies. Mark the pre-existing create/chat/send methods as legacy access-token endpoints. - -- [ ] **Step 2: Verify all documented symbols exist** - -Run: `rg -n 'parseEncryptedCallbackMessage|replyMessage' weixin-java-cp/src/main/java` -Expected: both APIs are found. - -### Task 4: Full verification and publication - -**Files:** -- Modify: all files from Tasks 1–3. - -- [ ] **Step 1: Run module test suite** - -Run: `mvn -pl weixin-java-cp test` -Expected: PASS. - -- [ ] **Step 2: Inspect scope and commit only intended files** - -Run: `git status --short && git diff --check` -Expected: only API-mode implementation, tests, and docs are changed; no whitespace errors. - -- [ ] **Step 3: Publish a draft PR** - -Run: `git add -- && git commit -m 'feat: 支持企业微信智能机器人 API 模式' && git pull --rebase && git push` -Expected: branch is pushed and a draft PR targets `develop`. diff --git a/docs/superpowers/specs/2026-08-21-cp-intelligent-robot-api-mode-design.md b/docs/superpowers/specs/2026-08-21-cp-intelligent-robot-api-mode-design.md deleted file mode 100644 index 4eb43d2d9d..0000000000 --- a/docs/superpowers/specs/2026-08-21-cp-intelligent-robot-api-mode-design.md +++ /dev/null @@ -1,30 +0,0 @@ -# 企业微信智能机器人 API 模式支持设计 - -## 目标 - -让 `weixin-java-cp` 能接入企业微信当前的智能机器人 API 模式:解密回调 JSON、解析消息,并将加密回复 POST 到回调给出的 `response_url`。 - -## 范围与边界 - -- 保留已有基于企业应用 `access_token` 的 `WxCpIntelligentRobotService` 方法,避免破坏兼容性;这些方法不用于新版 API 模式。 -- 新增独立的 API 模式密码工具,使用机器人后台配置的 Token、EncodingAESKey 和机器人 ID(作为接收方标识),不依赖 `WxCpConfigStorage` 的 `secret`。 -- 回调入口将加密 JSON 信封解密成明文 JSON,再交给已有 `WxCpIntelligentRobotMessage` 解析。 -- 回复 API 接收 `response_url` 与明文业务 JSON,完成加密、签名和 POST;它不追加 `access_token`。 -- 文档只展示新版 API 模式的正确链路,并明确旧服务的方法边界。 - -## 接口设计 - -新增 `WxCpIntelligentRobotCryptUtil(token, encodingAesKey, aiBotId)`: - -- `decrypt(msgSignature, timestamp, nonce, encryptedJson)` 返回回调明文 JSON; -- `encrypt(plainJson, timestamp, nonce)` 返回可直接 POST 的加密 JSON 信封; -- `verifyUrl(msgSignature, timestamp, nonce, echoStr)` 验证 URL 时解密 echo 字段。 - -在 `WxCpIntelligentRobotService` 新增: - -- `parseEncryptedCallbackMessage(...)`,解密后返回 `WxCpIntelligentRobotMessage`; -- `replyMessage(responseUrl, plainJson, ...)`,将加密响应 POST 到临时 URL。 - -## 验证 - -单测覆盖已知密文回调可解密并解析,及加密后的回复可被同一配置解密回原文;HTTP 层以本地 mock 服务验证不携带 access token、正文为加密 JSON。模块测试与格式检查通过。 diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpIntelligentRobotService.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpIntelligentRobotService.java index 75e24b6878..1d71bac99a 100644 --- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpIntelligentRobotService.java +++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpIntelligentRobotService.java @@ -94,9 +94,11 @@ public interface WxCpIntelligentRobotService { * @param aiBotId 机器人 ID * @return 解密并解析后的回调消息 */ - WxCpIntelligentRobotMessage parseEncryptedCallbackMessage(String msgSignature, String timestamp, String nonce, - String encryptedJson, String token, String encodingAesKey, - String aiBotId); + default WxCpIntelligentRobotMessage parseEncryptedCallbackMessage(String msgSignature, String timestamp, String nonce, + String encryptedJson, String token, String encodingAesKey, + String aiBotId) { + throw new UnsupportedOperationException("当前智能机器人服务不支持 API 模式回调解析"); + } /** * 加密并向智能机器人 API 模式的临时 response_url 回复消息. @@ -111,7 +113,9 @@ WxCpIntelligentRobotMessage parseEncryptedCallbackMessage(String msgSignature, S * @return 企业微信响应内容 * @throws WxErrorException 微信接口异常 */ - String replyMessage(String responseUrl, String plainJson, String token, String encodingAesKey, String aiBotId, - String timestamp, String nonce) throws WxErrorException; + default String replyMessage(String responseUrl, String plainJson, String token, String encodingAesKey, String aiBotId, + String timestamp, String nonce) throws WxErrorException { + throw new UnsupportedOperationException("当前智能机器人服务不支持 API 模式消息回复"); + } } diff --git a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/util/crypto/WxCpIntelligentRobotCryptUtil.java b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/util/crypto/WxCpIntelligentRobotCryptUtil.java index 2da2de13da..512c3073c6 100644 --- a/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/util/crypto/WxCpIntelligentRobotCryptUtil.java +++ b/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/util/crypto/WxCpIntelligentRobotCryptUtil.java @@ -4,7 +4,14 @@ import me.chanjar.weixin.common.util.crypto.SHA1; import me.chanjar.weixin.common.util.crypto.WxCryptUtil; import me.chanjar.weixin.cp.util.json.WxCpGsonBuilder; +import me.chanjar.weixin.common.error.WxRuntimeException; +import org.apache.commons.codec.binary.Base64; +import javax.crypto.Cipher; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; import java.util.UUID; /** @@ -23,7 +30,40 @@ public WxCpIntelligentRobotCryptUtil(String token, String encodingAesKey, String * 解密机器人 API 模式的 JSON 回调消息. */ public String decrypt(String msgSignature, String timestamp, String nonce, String encryptedContent) { - return decryptContent(msgSignature, timestamp, nonce, encryptedContent); + String signature = SHA1.gen(this.token, timestamp, nonce, encryptedContent); + if (!signature.equals(msgSignature)) { + throw new WxRuntimeException("加密消息签名校验失败"); + } + + try { + Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(this.aesKey, "AES"), + new IvParameterSpec(Arrays.copyOfRange(this.aesKey, 0, 16))); + byte[] bytes = me.chanjar.weixin.common.util.crypto.PKCS7Encoder.decode( + cipher.doFinal(Base64.decodeBase64(encryptedContent))); + if (bytes.length < 20) { + throw new WxRuntimeException("解密后数据长度异常,可能为错误的密文或EncodingAESKey"); + } + + int plainTextLength = 0; + for (int index = 16; index < 20; index++) { + plainTextLength = (plainTextLength << 8) | (bytes[index] & 0xff); + } + int plainTextEnd = 20 + plainTextLength; + if (plainTextLength < 0 || plainTextEnd > bytes.length) { + throw new WxRuntimeException("解密后数据格式非法:消息长度不正确,可能为错误的密文或EncodingAESKey"); + } + + String receiverId = new String(Arrays.copyOfRange(bytes, plainTextEnd, bytes.length), StandardCharsets.UTF_8); + if (!this.appidOrCorpid.equals(receiverId)) { + throw new WxRuntimeException("智能机器人ID不正确,请核实!"); + } + return new String(Arrays.copyOfRange(bytes, 20, plainTextEnd), StandardCharsets.UTF_8); + } catch (WxRuntimeException e) { + throw e; + } catch (Exception e) { + throw new WxRuntimeException(e); + } } /** diff --git a/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/util/crypto/WxCpIntelligentRobotCryptUtilTest.java b/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/util/crypto/WxCpIntelligentRobotCryptUtilTest.java index 79b9634cd0..f1e6123fab 100644 --- a/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/util/crypto/WxCpIntelligentRobotCryptUtilTest.java +++ b/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/util/crypto/WxCpIntelligentRobotCryptUtilTest.java @@ -2,6 +2,7 @@ import com.google.gson.JsonObject; import me.chanjar.weixin.common.util.json.GsonParser; +import me.chanjar.weixin.common.error.WxRuntimeException; import org.testng.annotations.Test; import static org.testng.Assert.assertEquals; @@ -25,4 +26,15 @@ public void encryptShouldProduceDecryptableJsonEnvelope() { assertEquals(cryptUtil.decrypt(encrypted.get("msg_signature").getAsString(), TIMESTAMP, NONCE, encrypted.get("encrypt").getAsString()), plainJson); } + + @Test(expectedExceptions = WxRuntimeException.class) + public void decryptShouldRejectMessageForAnotherRobot() { + String plainJson = "{\"msgtype\":\"text\"}"; + WxCpIntelligentRobotCryptUtil source = new WxCpIntelligentRobotCryptUtil(TOKEN, AES_KEY, AI_BOT_ID); + JsonObject encrypted = GsonParser.parse(source.encrypt(plainJson, TIMESTAMP, NONCE)); + WxCpIntelligentRobotCryptUtil otherRobot = new WxCpIntelligentRobotCryptUtil(TOKEN, AES_KEY, "aibot-456"); + + otherRobot.decrypt(encrypted.get("msg_signature").getAsString(), TIMESTAMP, NONCE, + encrypted.get("encrypt").getAsString()); + } } diff --git a/weixin-java-cp/src/test/resources/testng.xml b/weixin-java-cp/src/test/resources/testng.xml index cb3b8362e8..ed724b75cd 100644 --- a/weixin-java-cp/src/test/resources/testng.xml +++ b/weixin-java-cp/src/test/resources/testng.xml @@ -12,6 +12,7 @@ + @@ -25,6 +26,7 @@ + From bf6c3a1fed0c7e7998ad6880ab6f7bbee8464a22 Mon Sep 17 00:00:00 2001 From: Binary Wang Date: Fri, 21 Aug 2026 11:42:55 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=E8=84=B1=E6=95=8F=E6=97=A0=E4=BB=A4?= =?UTF-8?q?=E7=89=8C=E8=AF=B7=E6=B1=82=E5=9C=B0=E5=9D=80=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cp/api/impl/BaseWxCpServiceImpl.java | 12 +- .../cp/api/impl/BaseWxCpServiceImplTest.java | 136 +----------------- weixin-java-cp/src/test/resources/testng.xml | 1 + 3 files changed, 16 insertions(+), 133 deletions(-) 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 e351e58444..eb8abd773f 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 @@ -430,23 +430,29 @@ protected T executeInternal(RequestExecutor executor, String uri, E * 普通请求,不自动带accessToken */ private T executeNormal(RequestExecutor executor, String uri, E data) throws WxErrorException { + String uriForLog = redactQueryString(uri); try { T result = executor.execute(uri, data, WxType.CP); - log.debug("\n【请求地址】: {}\n【请求参数】:{}\n【响应数据】:{}", uri, data, result); + log.debug("\n【请求地址】: {}\n【请求参数】:{}\n【响应数据】:{}", uriForLog, data, result); return result; } catch (WxErrorException e) { WxError error = e.getError(); if (error.getErrorCode() != 0) { - log.error("\n【请求地址】: {}\n【请求参数】:{}\n【错误信息】:{}", uri, data, error); + log.error("\n【请求地址】: {}\n【请求参数】:{}\n【错误信息】:{}", uriForLog, data, error); throw new WxErrorException(error, e); } return null; } catch (IOException e) { - log.error("\n【请求地址】: {}\n【请求参数】:{}\n【异常信息】:{}", uri, data, e.getMessage()); + log.error("\n【请求地址】: {}\n【请求参数】:{}\n【异常信息】:{}", uriForLog, data, e.getMessage()); throw new WxErrorException(e); } } + static String redactQueryString(String uri) { + int queryStart = uri.indexOf('?'); + return queryStart < 0 ? uri : uri.substring(0, queryStart) + "?******"; + } + @Override public void setWxCpConfigStorage(WxCpConfigStorage wxConfigProvider) { this.configStorage = wxConfigProvider; 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 115eafd182..df6df86ace 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 @@ -1,143 +1,19 @@ package me.chanjar.weixin.cp.api.impl; -import com.google.inject.Inject; -import me.chanjar.weixin.common.error.WxError; -import me.chanjar.weixin.common.error.WxErrorException; -import me.chanjar.weixin.common.error.WxMpErrorMsgEnum; -import me.chanjar.weixin.common.util.http.HttpClientType; -import me.chanjar.weixin.common.util.http.RequestExecutor; -import me.chanjar.weixin.cp.api.ApiTestModule; -import me.chanjar.weixin.cp.api.WxCpService; -import me.chanjar.weixin.cp.config.WxCpConfigStorage; -import me.chanjar.weixin.cp.config.impl.WxCpDefaultConfigImpl; -import org.mockito.Mockito; -import org.testng.Assert; -import org.testng.annotations.Guice; import org.testng.annotations.Test; -import java.io.IOException; -import java.util.HashMap; -import java.util.concurrent.atomic.AtomicInteger; +import static org.testng.Assert.assertEquals; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; - -/** - *
- *  Created by BinaryWang on 2019/3/31.
- * 
- * - * @author Binary Wang - */ -@Test -@Guice(modules = ApiTestModule.class) public class BaseWxCpServiceImplTest { - /** - * The Wx service. - */ - @Inject - protected WxCpService wxService; - /** - * Test get agent jsapi ticket. - * - * @throws WxErrorException the wx error exception - */ @Test - public void testGetAgentJsapiTicket() throws WxErrorException { - assertThat(this.wxService.getAgentJsapiTicket()).isNotEmpty(); - assertThat(this.wxService.getAgentJsapiTicket(true)).isNotEmpty(); + public void redactQueryStringShouldHideTemporaryResponseUrlCredentials() { + assertEquals(BaseWxCpServiceImpl.redactQueryString("https://example.com/reply?token=temporary-secret&nonce=123"), + "https://example.com/reply?******"); } - /** - * Test js code 2 session. - * - * @throws WxErrorException the wx error exception - */ @Test - public void testJsCode2Session() throws WxErrorException { - assertThat(this.wxService.jsCode2Session("111")).isNotNull(); - } - - /** - * Test get provider token. - * - * @throws WxErrorException the wx error exception - */ - @Test - public void testGetProviderToken() throws WxErrorException { - assertThat(this.wxService.getProviderToken("111", "123")).isNotNull(); - } - - - /** - * Test execute auto refresh token. - * - * @throws WxErrorException the wx error exception - * @throws IOException the io exception - */ - @Test - public void testExecuteAutoRefreshToken() throws WxErrorException, IOException { - //测试access token获取时的重试机制 - WxCpDefaultConfigImpl config = new WxCpDefaultConfigImpl(); - BaseWxCpServiceImpl service = 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 "模拟一个过期的access token:" + System.currentTimeMillis(); - } - - @Override - public String getMsgAuditAccessToken(boolean forceRefresh) throws WxErrorException { - return "mock_msg_audit_access_token"; - } - - @Override - public String getContactAccessToken(boolean forceRefresh) throws WxErrorException { - return "mock_contact_access_token"; - } - - @Override - public void initHttp() { - - } - - @Override - public WxCpConfigStorage getWxCpConfigStorage() { - return config; - } - }; - config.setAgentId(1L); - service.setWxCpConfigStorage(config); - RequestExecutor re = mock(RequestExecutor.class); - - AtomicInteger counter = new AtomicInteger(); - Mockito.when(re.execute(Mockito.anyString(), Mockito.any(), Mockito.any())).thenAnswer(invocation -> { - counter.incrementAndGet(); - WxError error = - WxError.builder().errorCode(WxMpErrorMsgEnum.CODE_40001.getCode()).errorMsg(WxMpErrorMsgEnum.CODE_40001.getMsg()).build(); - throw new WxErrorException(error); - }); - try { - Object execute = service.execute(re, "http://baidu.com", new HashMap<>()); - Assert.fail("代码应该不会执行到这里"); - } catch (WxErrorException e) { - Assert.assertEquals(WxMpErrorMsgEnum.CODE_40001.getCode(), e.getError().getErrorCode()); - Assert.assertEquals(2, counter.get()); - } + public void redactQueryStringShouldKeepUrlWithoutQueryString() { + assertEquals(BaseWxCpServiceImpl.redactQueryString("https://example.com/reply"), "https://example.com/reply"); } } diff --git a/weixin-java-cp/src/test/resources/testng.xml b/weixin-java-cp/src/test/resources/testng.xml index ed724b75cd..78b9a2886f 100644 --- a/weixin-java-cp/src/test/resources/testng.xml +++ b/weixin-java-cp/src/test/resources/testng.xml @@ -12,6 +12,7 @@ + From 816556de186d23ded6f9bfc45e40f918e220f138 Mon Sep 17 00:00:00 2001 From: Binary Wang Date: Fri, 21 Aug 2026 11:45:10 +0800 Subject: [PATCH 4/4] =?UTF-8?q?test:=20=E6=81=A2=E5=A4=8D=E5=9F=BA?= =?UTF-8?q?=E7=A1=80=E6=9C=8D=E5=8A=A1=E6=97=A2=E6=9C=89=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/impl/BaseWxCpServiceImplLogTest.java | 19 +++ .../cp/api/impl/BaseWxCpServiceImplTest.java | 136 +++++++++++++++++- weixin-java-cp/src/test/resources/testng.xml | 2 +- 3 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/impl/BaseWxCpServiceImplLogTest.java diff --git a/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/impl/BaseWxCpServiceImplLogTest.java b/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/impl/BaseWxCpServiceImplLogTest.java new file mode 100644 index 0000000000..2164cbeacd --- /dev/null +++ b/weixin-java-cp/src/test/java/me/chanjar/weixin/cp/api/impl/BaseWxCpServiceImplLogTest.java @@ -0,0 +1,19 @@ +package me.chanjar.weixin.cp.api.impl; + +import org.testng.annotations.Test; + +import static org.testng.Assert.assertEquals; + +public class BaseWxCpServiceImplLogTest { + + @Test + public void redactQueryStringShouldHideTemporaryResponseUrlCredentials() { + assertEquals(BaseWxCpServiceImpl.redactQueryString("https://example.com/reply?token=temporary-secret&nonce=123"), + "https://example.com/reply?******"); + } + + @Test + public void redactQueryStringShouldKeepUrlWithoutQueryString() { + assertEquals(BaseWxCpServiceImpl.redactQueryString("https://example.com/reply"), "https://example.com/reply"); + } +} 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 df6df86ace..115eafd182 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 @@ -1,19 +1,143 @@ package me.chanjar.weixin.cp.api.impl; +import com.google.inject.Inject; +import me.chanjar.weixin.common.error.WxError; +import me.chanjar.weixin.common.error.WxErrorException; +import me.chanjar.weixin.common.error.WxMpErrorMsgEnum; +import me.chanjar.weixin.common.util.http.HttpClientType; +import me.chanjar.weixin.common.util.http.RequestExecutor; +import me.chanjar.weixin.cp.api.ApiTestModule; +import me.chanjar.weixin.cp.api.WxCpService; +import me.chanjar.weixin.cp.config.WxCpConfigStorage; +import me.chanjar.weixin.cp.config.impl.WxCpDefaultConfigImpl; +import org.mockito.Mockito; +import org.testng.Assert; +import org.testng.annotations.Guice; import org.testng.annotations.Test; -import static org.testng.Assert.assertEquals; +import java.io.IOException; +import java.util.HashMap; +import java.util.concurrent.atomic.AtomicInteger; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + *
+ *  Created by BinaryWang on 2019/3/31.
+ * 
+ * + * @author Binary Wang + */ +@Test +@Guice(modules = ApiTestModule.class) public class BaseWxCpServiceImplTest { + /** + * The Wx service. + */ + @Inject + protected WxCpService wxService; + /** + * Test get agent jsapi ticket. + * + * @throws WxErrorException the wx error exception + */ @Test - public void redactQueryStringShouldHideTemporaryResponseUrlCredentials() { - assertEquals(BaseWxCpServiceImpl.redactQueryString("https://example.com/reply?token=temporary-secret&nonce=123"), - "https://example.com/reply?******"); + public void testGetAgentJsapiTicket() throws WxErrorException { + assertThat(this.wxService.getAgentJsapiTicket()).isNotEmpty(); + assertThat(this.wxService.getAgentJsapiTicket(true)).isNotEmpty(); } + /** + * Test js code 2 session. + * + * @throws WxErrorException the wx error exception + */ @Test - public void redactQueryStringShouldKeepUrlWithoutQueryString() { - assertEquals(BaseWxCpServiceImpl.redactQueryString("https://example.com/reply"), "https://example.com/reply"); + public void testJsCode2Session() throws WxErrorException { + assertThat(this.wxService.jsCode2Session("111")).isNotNull(); + } + + /** + * Test get provider token. + * + * @throws WxErrorException the wx error exception + */ + @Test + public void testGetProviderToken() throws WxErrorException { + assertThat(this.wxService.getProviderToken("111", "123")).isNotNull(); + } + + + /** + * Test execute auto refresh token. + * + * @throws WxErrorException the wx error exception + * @throws IOException the io exception + */ + @Test + public void testExecuteAutoRefreshToken() throws WxErrorException, IOException { + //测试access token获取时的重试机制 + WxCpDefaultConfigImpl config = new WxCpDefaultConfigImpl(); + BaseWxCpServiceImpl service = 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 "模拟一个过期的access token:" + System.currentTimeMillis(); + } + + @Override + public String getMsgAuditAccessToken(boolean forceRefresh) throws WxErrorException { + return "mock_msg_audit_access_token"; + } + + @Override + public String getContactAccessToken(boolean forceRefresh) throws WxErrorException { + return "mock_contact_access_token"; + } + + @Override + public void initHttp() { + + } + + @Override + public WxCpConfigStorage getWxCpConfigStorage() { + return config; + } + }; + config.setAgentId(1L); + service.setWxCpConfigStorage(config); + RequestExecutor re = mock(RequestExecutor.class); + + AtomicInteger counter = new AtomicInteger(); + Mockito.when(re.execute(Mockito.anyString(), Mockito.any(), Mockito.any())).thenAnswer(invocation -> { + counter.incrementAndGet(); + WxError error = + WxError.builder().errorCode(WxMpErrorMsgEnum.CODE_40001.getCode()).errorMsg(WxMpErrorMsgEnum.CODE_40001.getMsg()).build(); + throw new WxErrorException(error); + }); + try { + Object execute = service.execute(re, "http://baidu.com", new HashMap<>()); + Assert.fail("代码应该不会执行到这里"); + } catch (WxErrorException e) { + Assert.assertEquals(WxMpErrorMsgEnum.CODE_40001.getCode(), e.getError().getErrorCode()); + Assert.assertEquals(2, counter.get()); + } } } diff --git a/weixin-java-cp/src/test/resources/testng.xml b/weixin-java-cp/src/test/resources/testng.xml index 78b9a2886f..a8f5713235 100644 --- a/weixin-java-cp/src/test/resources/testng.xml +++ b/weixin-java-cp/src/test/resources/testng.xml @@ -12,7 +12,7 @@ - +