From 933a46858d2ffa8bd170494b95cca0d855792a1a Mon Sep 17 00:00:00 2001 From: Binary Wang Date: Sat, 22 Aug 2026 12:16:59 +0800 Subject: [PATCH 1/6] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=E5=95=86?= =?UTF-8?q?=E5=AE=B6=E5=AE=A2=E6=9C=8D=20API=20=E8=AE=BE=E8=AE=A1=E4=B8=8E?= =?UTF-8?q?=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-08-22-channel-merchant-kf.md | 45 +++++++++++++++++++ .../2026-08-22-channel-merchant-kf-design.md | 18 ++++++++ 2 files changed, 63 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-22-channel-merchant-kf.md create mode 100644 docs/superpowers/specs/2026-08-22-channel-merchant-kf-design.md diff --git a/docs/superpowers/plans/2026-08-22-channel-merchant-kf.md b/docs/superpowers/plans/2026-08-22-channel-merchant-kf.md new file mode 100644 index 000000000..99aa235cb --- /dev/null +++ b/docs/superpowers/plans/2026-08-22-channel-merchant-kf.md @@ -0,0 +1,45 @@ +# 视频号小店商家客服 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:** 为 `weixin-java-channel` 增加商家客服媒体上传和消息发送 API。 + +**Architecture:** 以独立的客服子服务封装两条官方 API,通过既有 `BaseWxChannelServiceImpl` 完成鉴权、JSON 请求与 multipart 上传。请求模型保持强类型,服务层测试使用可记录调用的测试替身,避免真实网络依赖。 + +**Tech Stack:** Java 8、Maven、TestNG、Lombok、Jackson 注解。 + +## Global Constraints + +- Java 8 兼容,不新增依赖。 +- API 路径固定为 `/channels/ec/commkf/cosupload` 和 `/channels/ec/commkf/sendmsg`。 +- 使用 TestNG,所有新增测试不使用真实微信凭据。 + +--- + +### Task 1: 请求与响应模型 + +**Files:** +- Create: `weixin-java-channel/src/main/java/me/chanjar/weixin/channel/bean/kf/WxChannelKfCosUploadResponse.java` +- Create: `weixin-java-channel/src/main/java/me/chanjar/weixin/channel/bean/kf/WxChannelKfSendMsgParam.java` +- Create: `weixin-java-channel/src/main/java/me/chanjar/weixin/channel/bean/kf/WxChannelKfSendMsgResponse.java` +- Test: `weixin-java-channel/src/test/java/me/chanjar/weixin/channel/bean/kf/WxChannelKfBeanTest.java` + +- [ ] Write JSON encode/decode tests for `request_id`, `open_id`, `msg_type`, `text.content`, `cos_url` and `msg_id`. +- [ ] Run the test and verify it fails because the classes do not exist. +- [ ] Add the minimal annotated model classes and nested message content types. +- [ ] Run the test and verify it passes. + +### Task 2: 服务入口与请求执行 + +**Files:** +- Create: `weixin-java-channel/src/main/java/me/chanjar/weixin/channel/api/WxChannelKfService.java` +- Create: `weixin-java-channel/src/main/java/me/chanjar/weixin/channel/api/impl/WxChannelKfServiceImpl.java` +- Modify: `weixin-java-channel/src/main/java/me/chanjar/weixin/channel/api/WxChannelService.java` +- Modify: `weixin-java-channel/src/main/java/me/chanjar/weixin/channel/api/impl/BaseWxChannelServiceImpl.java` +- Modify: `weixin-java-channel/src/main/java/me/chanjar/weixin/channel/constant/WxChannelApiUrlConstants.java` +- Test: `weixin-java-channel/src/test/java/me/chanjar/weixin/channel/api/impl/WxChannelKfServiceImplTest.java` + +- [ ] Write tests proving the service uses the documented URLs, uploads `file`, `open_id`, `msg_type`, decodes both responses, and caches `getKfService()`. +- [ ] Run the test and verify it fails because the API is absent. +- [ ] Add the minimal service API, implementation, constants and cached service entry point. +- [ ] Run the focused tests and module test suite, then inspect `git diff --check`. diff --git a/docs/superpowers/specs/2026-08-22-channel-merchant-kf-design.md b/docs/superpowers/specs/2026-08-22-channel-merchant-kf-design.md new file mode 100644 index 000000000..12ecfd95c --- /dev/null +++ b/docs/superpowers/specs/2026-08-22-channel-merchant-kf-design.md @@ -0,0 +1,18 @@ +# 视频号小店商家客服 API 设计 + +## 目标 + +实现 Issue #3991 所列的商家客服媒体上传与消息发送 API,并使调用入口、请求模型和响应解析与现有 `weixin-java-channel` 服务保持一致。 + +## 设计 + +- 在 `WxChannelService` 暴露 `getKfService()`,由 `BaseWxChannelServiceImpl` 缓存并懒加载 `WxChannelKfServiceImpl`。 +- `WxChannelKfService` 提供媒体上传(带文件名和便捷重载)以及接收强类型请求参数的消息发送方法。上传请求通过既有 `CommonUploadParam` 发送 multipart 数据。 +- 消息模型用 `@JsonProperty` 显式映射微信字段,支持 text、image、video、file、product_share 和 order_share 六类内容;响应继承项目既有基础响应。 +- API 常量使用官方文档确认的 `/channels/ec/commkf/cosupload` 和 `/channels/ec/commkf/sendmsg` 路径。 + +## 质量边界 + +- 保持 Java 8 兼容,不增加依赖,不变更现有公共 API。 +- 使用 TestNG 覆盖请求/响应 JSON 映射、服务 URL、上传表单字段及服务入口缓存;测试不依赖真实微信凭据。 +- PR 使用 `Closes #3991` 关联并关闭原始 Issue;旧 PR #4037 在新 PR 创建后以替代说明关闭。 From df78e3c39517ff825244f0a9a60a4a61879a74c5 Mon Sep 17 00:00:00 2001 From: Binary Wang Date: Sat, 22 Aug 2026 12:23:14 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E5=8F=B7=E5=AE=A2=E6=9C=8D=E6=B6=88=E6=81=AF=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .superpowers/sdd/task-1-report.md | 22 +++++++ .../bean/kf/WxChannelKfCosUploadResponse.java | 20 +++++++ .../bean/kf/WxChannelKfSendMsgParam.java | 40 +++++++++++++ .../bean/kf/WxChannelKfSendMsgResponse.java | 20 +++++++ .../channel/bean/kf/WxChannelKfBeanTest.java | 59 +++++++++++++++++++ 5 files changed, 161 insertions(+) create mode 100644 .superpowers/sdd/task-1-report.md create mode 100644 weixin-java-channel/src/main/java/me/chanjar/weixin/channel/bean/kf/WxChannelKfCosUploadResponse.java create mode 100644 weixin-java-channel/src/main/java/me/chanjar/weixin/channel/bean/kf/WxChannelKfSendMsgParam.java create mode 100644 weixin-java-channel/src/main/java/me/chanjar/weixin/channel/bean/kf/WxChannelKfSendMsgResponse.java create mode 100644 weixin-java-channel/src/test/java/me/chanjar/weixin/channel/bean/kf/WxChannelKfBeanTest.java diff --git a/.superpowers/sdd/task-1-report.md b/.superpowers/sdd/task-1-report.md new file mode 100644 index 000000000..d0b27b0f1 --- /dev/null +++ b/.superpowers/sdd/task-1-report.md @@ -0,0 +1,22 @@ +# Task 1 报告 + +## 修改 + +- 新增 `WxChannelKfCosUploadResponse`,映射 `cos_url` 并继承频道基础响应。 +- 新增 `WxChannelKfSendMsgParam`,映射 `request_id`、`open_id`、`msg_type` 和嵌套 `text.content`。 +- 新增 `WxChannelKfSendMsgResponse`,映射 `msg_id` 并继承频道基础响应。 +- 新增 TestNG JSON 编解码测试 `WxChannelKfBeanTest`。 + +## TDD 测试 + +- 红灯:`mvn -pl weixin-java-channel -Dtest=WxChannelKfBeanTest -DfailIfNoTests=false test`,因 3 个模型类不存在,在 testCompile 阶段失败。 +- 绿灯:`mvn -pl weixin-java-channel dependency:build-classpath -Dmdep.outputFile=/tmp/wxjava-channel-test-cp.txt -Dmdep.includeScope=test -q && TEST_CP="$(cat /tmp/wxjava-channel-test-cp.txt)" && java -cp "weixin-java-channel/target/test-classes:weixin-java-channel/target/classes:${TEST_CP}" org.testng.TestNG -testclass me.chanjar.weixin.channel.bean.kf.WxChannelKfBeanTest`,3 个测试通过,0 失败,0 跳过。 +- `git diff --check`:通过。 + +## Commit + +`d742d673b`(新增视频号客服消息模型)。 + +## 顾虑 + +仓库父 POM 的 Surefire pluginManagement 默认配置 `skip=true`,因此 Maven 测试命令会显示 Tests are skipped;已使用同一编译产物和依赖通过 TestNG CLI 实际执行测试。 diff --git a/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/bean/kf/WxChannelKfCosUploadResponse.java b/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/bean/kf/WxChannelKfCosUploadResponse.java new file mode 100644 index 000000000..e5261dbf6 --- /dev/null +++ b/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/bean/kf/WxChannelKfCosUploadResponse.java @@ -0,0 +1,20 @@ +package me.chanjar.weixin.channel.bean.kf; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import me.chanjar.weixin.channel.bean.base.WxChannelBaseResponse; + +/** 客服素材上传响应。 */ +@Data +@NoArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class WxChannelKfCosUploadResponse extends WxChannelBaseResponse { + + private static final long serialVersionUID = 1L; + + /** 素材在 COS 上的地址。 */ + @JsonProperty("cos_url") + private String cosUrl; +} diff --git a/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/bean/kf/WxChannelKfSendMsgParam.java b/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/bean/kf/WxChannelKfSendMsgParam.java new file mode 100644 index 000000000..0e1be25ad --- /dev/null +++ b/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/bean/kf/WxChannelKfSendMsgParam.java @@ -0,0 +1,40 @@ +package me.chanjar.weixin.channel.bean.kf; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.io.Serializable; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** 发送客服消息请求参数。 */ +@Data +@NoArgsConstructor +public class WxChannelKfSendMsgParam implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 请求幂等标识。 */ + @JsonProperty("request_id") + private String requestId; + + /** 接收消息的用户 openid。 */ + @JsonProperty("open_id") + private String openId; + + /** 消息类型。 */ + @JsonProperty("msg_type") + private String msgType; + + /** 文本消息内容。 */ + @JsonProperty("text") + private Text text; + + @Data + @NoArgsConstructor + public static class Text implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("content") + private String content; + } +} diff --git a/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/bean/kf/WxChannelKfSendMsgResponse.java b/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/bean/kf/WxChannelKfSendMsgResponse.java new file mode 100644 index 000000000..570e309fa --- /dev/null +++ b/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/bean/kf/WxChannelKfSendMsgResponse.java @@ -0,0 +1,20 @@ +package me.chanjar.weixin.channel.bean.kf; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import me.chanjar.weixin.channel.bean.base.WxChannelBaseResponse; + +/** 发送客服消息响应。 */ +@Data +@NoArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class WxChannelKfSendMsgResponse extends WxChannelBaseResponse { + + private static final long serialVersionUID = 1L; + + /** 消息 id。 */ + @JsonProperty("msg_id") + private String msgId; +} diff --git a/weixin-java-channel/src/test/java/me/chanjar/weixin/channel/bean/kf/WxChannelKfBeanTest.java b/weixin-java-channel/src/test/java/me/chanjar/weixin/channel/bean/kf/WxChannelKfBeanTest.java new file mode 100644 index 000000000..05adcaff6 --- /dev/null +++ b/weixin-java-channel/src/test/java/me/chanjar/weixin/channel/bean/kf/WxChannelKfBeanTest.java @@ -0,0 +1,59 @@ +package me.chanjar.weixin.channel.bean.kf; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotNull; + +import me.chanjar.weixin.channel.util.JsonUtils; +import org.testng.annotations.Test; + +/** JSON serialization tests for channel customer service models. */ +public class WxChannelKfBeanTest { + + @Test + public void testSendMsgParamJson() { + WxChannelKfSendMsgParam param = new WxChannelKfSendMsgParam(); + param.setRequestId("request-1"); + param.setOpenId("open-1"); + param.setMsgType("text"); + WxChannelKfSendMsgParam.Text text = new WxChannelKfSendMsgParam.Text(); + text.setContent("hello"); + param.setText(text); + + String json = JsonUtils.encode(param); + assertNotNull(json); + assertFalse(json.contains("requestId")); + assertFalse(json.contains("openId")); + assertFalse(json.contains("msgType")); + WxChannelKfSendMsgParam decoded = JsonUtils.decode(json, WxChannelKfSendMsgParam.class); + assertEquals(decoded.getRequestId(), "request-1"); + assertEquals(decoded.getOpenId(), "open-1"); + assertEquals(decoded.getMsgType(), "text"); + assertNotNull(decoded.getText()); + assertEquals(decoded.getText().getContent(), "hello"); + } + + @Test + public void testCosUploadResponseJson() { + WxChannelKfCosUploadResponse response = JsonUtils.decode( + "{\"errcode\":0,\"errmsg\":\"ok\",\"cos_url\":\"https://example.test/media\"}", + WxChannelKfCosUploadResponse.class); + + assertEquals(response.getErrCode(), 0); + assertEquals(response.getCosUrl(), "https://example.test/media"); + assertEquals(JsonUtils.decode(JsonUtils.encode(response), WxChannelKfCosUploadResponse.class) + .getCosUrl(), "https://example.test/media"); + } + + @Test + public void testSendMsgResponseJson() { + WxChannelKfSendMsgResponse response = JsonUtils.decode( + "{\"errcode\":0,\"errmsg\":\"ok\",\"msg_id\":\"msg-1\"}", + WxChannelKfSendMsgResponse.class); + + assertEquals(response.getErrCode(), 0); + assertEquals(response.getMsgId(), "msg-1"); + assertEquals(JsonUtils.decode(JsonUtils.encode(response), WxChannelKfSendMsgResponse.class) + .getMsgId(), "msg-1"); + } +} From e59cc99ad39e625d137a5f6829fc0a6a31d8d184 Mon Sep 17 00:00:00 2001 From: Binary Wang Date: Sat, 22 Aug 2026 12:31:27 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=95=86=E5=AE=B6?= =?UTF-8?q?=E5=AE=A2=E6=9C=8D=E6=9C=8D=E5=8A=A1=E5=85=A5=E5=8F=A3=E4=B8=8E?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E6=89=A7=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../channel/api/WxChannelKfService.java | 41 +++++++++ .../weixin/channel/api/WxChannelService.java | 7 ++ .../api/impl/BaseWxChannelServiceImpl.java | 9 ++ .../api/impl/WxChannelKfServiceImpl.java | 42 +++++++++ .../constant/WxChannelApiUrlConstants.java | 9 ++ .../api/impl/WxChannelKfServiceImplTest.java | 86 +++++++++++++++++++ .../src/test/resources/testng.xml | 5 ++ 7 files changed, 199 insertions(+) create mode 100644 weixin-java-channel/src/main/java/me/chanjar/weixin/channel/api/WxChannelKfService.java create mode 100644 weixin-java-channel/src/main/java/me/chanjar/weixin/channel/api/impl/WxChannelKfServiceImpl.java create mode 100644 weixin-java-channel/src/test/java/me/chanjar/weixin/channel/api/impl/WxChannelKfServiceImplTest.java diff --git a/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/api/WxChannelKfService.java b/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/api/WxChannelKfService.java new file mode 100644 index 000000000..41fb153eb --- /dev/null +++ b/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/api/WxChannelKfService.java @@ -0,0 +1,41 @@ +package me.chanjar.weixin.channel.api; + +import me.chanjar.weixin.channel.bean.kf.WxChannelKfSendMsgParam; +import me.chanjar.weixin.channel.bean.kf.WxChannelKfSendMsgResponse; +import me.chanjar.weixin.common.error.WxErrorException; + +/** 视频号小店商家客服服务。 */ +public interface WxChannelKfService { + + /** + * 上传多媒体资源。 + * + * @param openId 用户 open_id + * @param msgType 文件类型,仅支持 video、file、image + * @param file 文件字节内容 + * @return COS 地址 + * @throws WxErrorException 微信异常 + */ + String uploadMedia(String openId, String msgType, byte[] file) throws WxErrorException; + + /** + * 上传多媒体资源。 + * + * @param openId 用户 open_id + * @param msgType 文件类型,仅支持 video、file、image + * @param fileName 文件名 + * @param file 文件字节内容 + * @return COS 地址 + * @throws WxErrorException 微信异常 + */ + String uploadMedia(String openId, String msgType, String fileName, byte[] file) throws WxErrorException; + + /** + * 发送客服消息。 + * + * @param param 请求参数 + * @return 发送结果 + * @throws WxErrorException 微信异常 + */ + WxChannelKfSendMsgResponse sendMessage(WxChannelKfSendMsgParam param) throws WxErrorException; +} diff --git a/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/api/WxChannelService.java b/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/api/WxChannelService.java index 52cc924bc..3b0297412 100644 --- a/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/api/WxChannelService.java +++ b/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/api/WxChannelService.java @@ -7,6 +7,13 @@ */ public interface WxChannelService extends BaseWxChannelService { + /** + * 商家客服服务。 + * + * @return 商家客服服务 + */ + WxChannelKfService getKfService(); + /** * 基础接口服务 * diff --git a/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/api/impl/BaseWxChannelServiceImpl.java b/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/api/impl/BaseWxChannelServiceImpl.java index b167af0d4..427a24d3b 100644 --- a/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/api/impl/BaseWxChannelServiceImpl.java +++ b/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/api/impl/BaseWxChannelServiceImpl.java @@ -72,6 +72,7 @@ public abstract class BaseWxChannelServiceImpl implements WxChannelService private WxTalentService talentService = null; private WxChannelFavoriteService favoriteService = null; private WxChannelEwaybillService ewaybillService = null; + private WxChannelKfService kfService = null; protected WxChannelConfig config; private int retrySleepMillis = 1000; @@ -548,4 +549,12 @@ public synchronized WxChannelEwaybillService getEwaybillService() { return ewaybillService; } + @Override + public synchronized WxChannelKfService getKfService() { + if (kfService == null) { + kfService = new WxChannelKfServiceImpl(this); + } + return kfService; + } + } diff --git a/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/api/impl/WxChannelKfServiceImpl.java b/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/api/impl/WxChannelKfServiceImpl.java new file mode 100644 index 000000000..7086ab431 --- /dev/null +++ b/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/api/impl/WxChannelKfServiceImpl.java @@ -0,0 +1,42 @@ +package me.chanjar.weixin.channel.api.impl; + +import static me.chanjar.weixin.channel.constant.WxChannelApiUrlConstants.Kf.COS_UPLOAD_URL; +import static me.chanjar.weixin.channel.constant.WxChannelApiUrlConstants.Kf.SEND_MSG_URL; + +import me.chanjar.weixin.channel.api.WxChannelKfService; +import me.chanjar.weixin.channel.bean.kf.WxChannelKfCosUploadResponse; +import me.chanjar.weixin.channel.bean.kf.WxChannelKfSendMsgParam; +import me.chanjar.weixin.channel.bean.kf.WxChannelKfSendMsgResponse; +import me.chanjar.weixin.channel.util.ResponseUtils; +import me.chanjar.weixin.common.bean.CommonUploadParam; +import me.chanjar.weixin.common.error.WxErrorException; + +/** 视频号小店商家客服服务实现。 */ +public class WxChannelKfServiceImpl implements WxChannelKfService { + + private final BaseWxChannelServiceImpl channelService; + + public WxChannelKfServiceImpl(BaseWxChannelServiceImpl channelService) { + this.channelService = channelService; + } + + @Override + public String uploadMedia(String openId, String msgType, byte[] file) throws WxErrorException { + return uploadMedia(openId, msgType, null, file); + } + + @Override + public String uploadMedia(String openId, String msgType, String fileName, byte[] file) throws WxErrorException { + CommonUploadParam uploadParam = CommonUploadParam.fromBytes("file", fileName, file) + .addFormField("open_id", openId) + .addFormField("msg_type", msgType); + String responseJson = channelService.upload(COS_UPLOAD_URL, uploadParam); + return ResponseUtils.decode(responseJson, WxChannelKfCosUploadResponse.class).getCosUrl(); + } + + @Override + public WxChannelKfSendMsgResponse sendMessage(WxChannelKfSendMsgParam param) throws WxErrorException { + String responseJson = channelService.post(SEND_MSG_URL, param); + return ResponseUtils.decode(responseJson, WxChannelKfSendMsgResponse.class); + } +} diff --git a/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/constant/WxChannelApiUrlConstants.java b/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/constant/WxChannelApiUrlConstants.java index 86de88947..105879dae 100644 --- a/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/constant/WxChannelApiUrlConstants.java +++ b/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/constant/WxChannelApiUrlConstants.java @@ -49,6 +49,15 @@ public interface Favorite { String GET_FAVORITE_COUNT = "https://api.weixin.qq.com/channels/ec/favorites/count/get"; } + /** 商家客服相关接口 */ + public interface Kf { + + /** 上传客服素材 */ + String COS_UPLOAD_URL = "https://api.weixin.qq.com/channels/ec/commkf/cosupload"; + /** 发送客服消息 */ + String SEND_MSG_URL = "https://api.weixin.qq.com/channels/ec/commkf/sendmsg"; + } + /** 商品类目相关接口 */ public interface Category { diff --git a/weixin-java-channel/src/test/java/me/chanjar/weixin/channel/api/impl/WxChannelKfServiceImplTest.java b/weixin-java-channel/src/test/java/me/chanjar/weixin/channel/api/impl/WxChannelKfServiceImplTest.java new file mode 100644 index 000000000..8a3f5df9e --- /dev/null +++ b/weixin-java-channel/src/test/java/me/chanjar/weixin/channel/api/impl/WxChannelKfServiceImplTest.java @@ -0,0 +1,86 @@ +package me.chanjar.weixin.channel.api.impl; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertSame; + +import me.chanjar.weixin.channel.bean.kf.WxChannelKfSendMsgParam; +import me.chanjar.weixin.channel.bean.kf.WxChannelKfSendMsgResponse; +import me.chanjar.weixin.channel.util.JsonUtils; +import me.chanjar.weixin.common.bean.CommonUploadParam; +import me.chanjar.weixin.common.error.WxErrorException; +import org.testng.annotations.Test; + +/** 商家客服服务离线测试。 */ +public class WxChannelKfServiceImplTest { + + @Test + public void shouldUploadMediaWithDocumentedUrlAndMultipartFields() throws WxErrorException { + RecordingChannelService channelService = new RecordingChannelService(); + channelService.uploadResult = "{\"errcode\":0,\"errmsg\":\"ok\",\"cos_url\":\"https://cos.example.com/image.png\"}"; + + String cosUrl = new WxChannelKfServiceImpl(channelService) + .uploadMedia("open-id", "image", "image.png", new byte[]{1, 2, 3}); + + assertEquals(channelService.uploadUrl, "https://api.weixin.qq.com/channels/ec/commkf/cosupload"); + assertNotNull(channelService.uploadParam); + assertEquals(channelService.uploadParam.getName(), "file"); + assertEquals(channelService.uploadParam.getData().getFileName(), "image.png"); + assertEquals(channelService.uploadParam.getData().readAllBytes(), new byte[]{1, 2, 3}); + assertEquals(channelService.uploadParam.getFormFields().get("open_id"), "open-id"); + assertEquals(channelService.uploadParam.getFormFields().get("msg_type"), "image"); + assertEquals(cosUrl, "https://cos.example.com/image.png"); + } + + @Test + public void shouldSendJsonMessageAndDecodeResponse() throws WxErrorException { + RecordingChannelService channelService = new RecordingChannelService(); + channelService.postResult = "{\"errcode\":0,\"errmsg\":\"ok\",\"msg_id\":\"message-id\"}"; + WxChannelKfSendMsgParam param = new WxChannelKfSendMsgParam(); + param.setRequestId("request-id"); + param.setOpenId("open-id"); + param.setMsgType("text"); + WxChannelKfSendMsgParam.Text text = new WxChannelKfSendMsgParam.Text(); + text.setContent("hello"); + param.setText(text); + + WxChannelKfSendMsgResponse response = new WxChannelKfServiceImpl(channelService).sendMessage(param); + + assertEquals(channelService.postUrl, "https://api.weixin.qq.com/channels/ec/commkf/sendmsg"); + assertEquals(channelService.postJson, + "{\"request_id\":\"request-id\",\"open_id\":\"open-id\",\"msg_type\":\"text\",\"text\":{\"content\":\"hello\"}}"); + assertEquals(response.getMsgId(), "message-id"); + assertEquals(response.getErrCode(), 0); + } + + @Test + public void shouldCacheKfServiceEntryPoint() { + WxChannelServiceImpl channelService = new WxChannelServiceImpl(); + + assertSame(channelService.getKfService(), channelService.getKfService()); + } + + private static class RecordingChannelService extends WxChannelServiceImpl { + + private String uploadResult; + private String postResult; + private String uploadUrl; + private CommonUploadParam uploadParam; + private String postUrl; + private String postJson; + + @Override + public String upload(String url, CommonUploadParam param) { + this.uploadUrl = url; + this.uploadParam = param; + return uploadResult; + } + + @Override + public String post(String url, Object obj) { + this.postUrl = url; + this.postJson = JsonUtils.encode(obj); + return postResult; + } + } +} diff --git a/weixin-java-channel/src/test/resources/testng.xml b/weixin-java-channel/src/test/resources/testng.xml index f4850bdf2..d9c43eafc 100644 --- a/weixin-java-channel/src/test/resources/testng.xml +++ b/weixin-java-channel/src/test/resources/testng.xml @@ -26,4 +26,9 @@ + + + + + From 03c50b6333b6eb0679130d7b83ed804f4c25d1ec Mon Sep 17 00:00:00 2001 From: Binary Wang Date: Sat, 22 Aug 2026 12:39:03 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=A2=E6=9C=8D?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=85=A5=E5=8F=A3=E6=8E=A5=E5=8F=A3=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 --- .superpowers/sdd/task-2-report.md | 55 +++++++++++++++++++ .../weixin/channel/api/WxChannelService.java | 4 +- 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 .superpowers/sdd/task-2-report.md diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md new file mode 100644 index 000000000..f1ca2c6ac --- /dev/null +++ b/.superpowers/sdd/task-2-report.md @@ -0,0 +1,55 @@ +# Task 2 报告:商家客服服务入口与请求执行 + +## 修改 + +- 新增 `WxChannelKfService` 与 `WxChannelKfServiceImpl`。 +- 新增客服接口常量,使用官方路径: + - `/channels/ec/commkf/cosupload` + - `/channels/ec/commkf/sendmsg` +- `uploadMedia` 使用 multipart 字段 `file`、`open_id`、`msg_type`,并解析 `cos_url`。 +- `sendMessage` 通过现有 `post(url, Object)` JSON 流程发送请求,并解析 `msg_id`。 +- 在 `WxChannelService` 增加懒加载、同步缓存的 `getKfService()` 入口。 +- 新增不依赖微信凭据的 TestNG 测试,已纳入模块 `src/test/resources/testng.xml`。 + +## TDD 证据 + +- 红灯:先新增测试后执行 + `mvn -pl weixin-java-channel -Dtest=WxChannelKfServiceImplTest -DfailIfNoTests=false test`。 + 测试编译按预期失败,原因是 `WxChannelKfServiceImpl` 和 `getKfService()` 尚不存在。 +- 绿灯:实现后通过 TestNG CLI 实际执行 + `WxChannelKfServiceImplTest`:3 通过,0 失败,0 跳过。 +- 模块套件:通过 TestNG CLI 执行 `weixin-java-channel/src/test/resources/testng.xml`:18 通过,0 失败,0 跳过。 +- `git diff --check`:通过。 + +## 测试执行说明 + +父 POM 的 Surefire pluginManagement 默认配置 `skip=true`,故 Maven 可用于编译测试但会跳过执行;已以模块编译产物和 test scope 依赖运行 TestNG CLI,确保新增测试及模块 XML 套件均真实执行。 + +## 自审 + +- 上传和发送接口均使用官方 `commkf` URL,没有使用错误的 `/kf/` 路径。 +- 修改范围仅覆盖服务入口、请求执行、常量和离线测试;无新增依赖。 +- 保持 Java 8 兼容,沿用现有 `CommonUploadParam`、`ResponseUtils` 和服务缓存模式。 + +## Commit + +`f76b4db00`(新增商家客服服务入口与请求执行)。 + +## 兼容性修复(Task 2 审查 P1) + +- 根因:`WxChannelService#getKfService()` 原为抽象接口方法,会迫使既有下游实现重新编译,并可能使未实现新方法的旧实现运行时抛出 `AbstractMethodError`。 +- 修复:改为 Java 8 `default` 方法;默认抛出 `UnsupportedOperationException("WxChannelService implementation does not support getKfService()")`。 +- 保持 `BaseWxChannelServiceImpl` 中 `@Override synchronized getKfService()` 的懒加载缓存实现不变。 +- 检查结果:`WxChannelService` 接口目录未发现可复用的既有 default 方法风格;未修改其他实现或测试文件。 + +## 兼容性修复验证 + +- `git diff --check`:通过。 +- `mvn -pl weixin-java-channel -am -Dtest=me.chanjar.weixin.channel.api.impl.WxChannelKfServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test`:构建成功;父 POM Surefire 默认 `skip=true`,测试执行显示 `Tests are skipped`。 +- `mvn -pl weixin-java-channel -Dsurefire.skip=false -DskipTests=false -Dmaven.test.skip=false -Dsurefire.suiteXmlFiles=src/test/resources/testng.xml test`:构建成功,但仍受父 POM Surefire 配置影响而跳过测试。 +- `mvn -pl weixin-java-channel -Dskip=false -DskipTests=false -Dmaven.test.skip=false -Dsurefire.suiteXmlFiles=src/test/resources/testng.xml org.apache.maven.plugins:maven-surefire-plugin:2.17:test`:仍显示 `Tests are skipped`。 +- `mvn -pl weixin-java-channel dependency:build-classpath -Dmdep.outputAbsoluteArtifactFilename=true -Dmdep.outputFile=/tmp/wxjava-channel-test-classpath -DincludeScope=test` 后,以 TestNG CLI 执行 `weixin-java-channel/src/test/resources/testng.xml`:18 通过,0 失败,0 跳过。 + +## 兼容性修复 Commit + +待提交(仅包含接口变更与本报告;保留工作树中其他任务的未提交修改)。 diff --git a/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/api/WxChannelService.java b/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/api/WxChannelService.java index 3b0297412..5a4c4d3d4 100644 --- a/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/api/WxChannelService.java +++ b/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/api/WxChannelService.java @@ -12,7 +12,9 @@ public interface WxChannelService extends BaseWxChannelService { * * @return 商家客服服务 */ - WxChannelKfService getKfService(); + default WxChannelKfService getKfService() { + throw new UnsupportedOperationException("WxChannelService implementation does not support getKfService()"); + } /** * 基础接口服务 From 094a1148c648033c5d15193ee1ca2a597727c9cf Mon Sep 17 00:00:00 2001 From: Binary Wang Date: Sat, 22 Aug 2026 12:50:03 +0800 Subject: [PATCH 5/6] =?UTF-8?q?=E8=A1=A5=E5=85=A8=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E5=8F=B7=E5=AE=A2=E6=9C=8D=E6=B6=88=E6=81=AF=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .superpowers/sdd/task-1-report.md | 2 +- .superpowers/sdd/task-2-report.md | 2 +- .../bean/kf/WxChannelKfSendMsgParam.java | 50 +++++++++++ .../channel/bean/kf/WxChannelKfBeanTest.java | 90 ++++++++++++++++++- .../src/test/resources/testng.xml | 1 + 5 files changed, 139 insertions(+), 6 deletions(-) diff --git a/.superpowers/sdd/task-1-report.md b/.superpowers/sdd/task-1-report.md index d0b27b0f1..c19588573 100644 --- a/.superpowers/sdd/task-1-report.md +++ b/.superpowers/sdd/task-1-report.md @@ -15,7 +15,7 @@ ## Commit -`d742d673b`(新增视频号客服消息模型)。 +`a651123a1`(新增视频号客服消息模型)。 ## 顾虑 diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md index f1ca2c6ac..daa5aadd0 100644 --- a/.superpowers/sdd/task-2-report.md +++ b/.superpowers/sdd/task-2-report.md @@ -52,4 +52,4 @@ ## 兼容性修复 Commit -待提交(仅包含接口变更与本报告;保留工作树中其他任务的未提交修改)。 +`4b5575cce`(修复客服服务入口接口兼容性)。 diff --git a/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/bean/kf/WxChannelKfSendMsgParam.java b/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/bean/kf/WxChannelKfSendMsgParam.java index 0e1be25ad..0653c5edc 100644 --- a/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/bean/kf/WxChannelKfSendMsgParam.java +++ b/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/bean/kf/WxChannelKfSendMsgParam.java @@ -28,6 +28,26 @@ public class WxChannelKfSendMsgParam implements Serializable { @JsonProperty("text") private Text text; + /** 图片消息内容。 */ + @JsonProperty("image") + private CosUrlMessage image; + + /** 视频消息内容。 */ + @JsonProperty("video") + private CosUrlMessage video; + + /** 文件消息内容。 */ + @JsonProperty("file") + private CosUrlMessage file; + + /** 商品卡片消息内容。 */ + @JsonProperty("product_share") + private ProductShareMessage productShare; + + /** 订单卡片消息内容。 */ + @JsonProperty("order_share") + private OrderShareMessage orderShare; + @Data @NoArgsConstructor public static class Text implements Serializable { @@ -37,4 +57,34 @@ public static class Text implements Serializable { @JsonProperty("content") private String content; } + + @Data + @NoArgsConstructor + public static class CosUrlMessage implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("cos_url") + private String cosUrl; + } + + @Data + @NoArgsConstructor + public static class ProductShareMessage implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("product_id") + private String productId; + } + + @Data + @NoArgsConstructor + public static class OrderShareMessage implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("order_id") + private String orderId; + } } diff --git a/weixin-java-channel/src/test/java/me/chanjar/weixin/channel/bean/kf/WxChannelKfBeanTest.java b/weixin-java-channel/src/test/java/me/chanjar/weixin/channel/bean/kf/WxChannelKfBeanTest.java index 05adcaff6..458cf44f2 100644 --- a/weixin-java-channel/src/test/java/me/chanjar/weixin/channel/bean/kf/WxChannelKfBeanTest.java +++ b/weixin-java-channel/src/test/java/me/chanjar/weixin/channel/bean/kf/WxChannelKfBeanTest.java @@ -3,6 +3,7 @@ import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertFalse; import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; import me.chanjar.weixin.channel.util.JsonUtils; import org.testng.annotations.Test; @@ -12,10 +13,7 @@ public class WxChannelKfBeanTest { @Test public void testSendMsgParamJson() { - WxChannelKfSendMsgParam param = new WxChannelKfSendMsgParam(); - param.setRequestId("request-1"); - param.setOpenId("open-1"); - param.setMsgType("text"); + WxChannelKfSendMsgParam param = createSendMsgParam("text"); WxChannelKfSendMsgParam.Text text = new WxChannelKfSendMsgParam.Text(); text.setContent("hello"); param.setText(text); @@ -33,6 +31,67 @@ public void testSendMsgParamJson() { assertEquals(decoded.getText().getContent(), "hello"); } + @Test + public void testImageMessageJson() { + WxChannelKfSendMsgParam param = createSendMsgParam("image"); + WxChannelKfSendMsgParam.CosUrlMessage image = new WxChannelKfSendMsgParam.CosUrlMessage(); + image.setCosUrl("https://example.test/image"); + param.setImage(image); + + assertCosUrlMessageJson(param, "image", "https://example.test/image"); + } + + @Test + public void testVideoMessageJson() { + WxChannelKfSendMsgParam param = createSendMsgParam("video"); + WxChannelKfSendMsgParam.CosUrlMessage video = new WxChannelKfSendMsgParam.CosUrlMessage(); + video.setCosUrl("https://example.test/video"); + param.setVideo(video); + + assertCosUrlMessageJson(param, "video", "https://example.test/video"); + } + + @Test + public void testFileMessageJson() { + WxChannelKfSendMsgParam param = createSendMsgParam("file"); + WxChannelKfSendMsgParam.CosUrlMessage file = new WxChannelKfSendMsgParam.CosUrlMessage(); + file.setCosUrl("https://example.test/file"); + param.setFile(file); + + assertCosUrlMessageJson(param, "file", "https://example.test/file"); + } + + @Test + public void testProductShareMessageJson() { + WxChannelKfSendMsgParam param = createSendMsgParam("product_share"); + WxChannelKfSendMsgParam.ProductShareMessage product = + new WxChannelKfSendMsgParam.ProductShareMessage(); + product.setProductId("product-1"); + param.setProductShare(product); + + String json = JsonUtils.encode(param); + assertTrue(json.contains("\"product_share\":{\"product_id\":\"product-1\"}")); + assertFalse(json.contains("productShare")); + assertFalse(json.contains("productId")); + WxChannelKfSendMsgParam decoded = JsonUtils.decode(json, WxChannelKfSendMsgParam.class); + assertEquals(decoded.getProductShare().getProductId(), "product-1"); + } + + @Test + public void testOrderShareMessageJson() { + WxChannelKfSendMsgParam param = createSendMsgParam("order_share"); + WxChannelKfSendMsgParam.OrderShareMessage order = new WxChannelKfSendMsgParam.OrderShareMessage(); + order.setOrderId("order-1"); + param.setOrderShare(order); + + String json = JsonUtils.encode(param); + assertTrue(json.contains("\"order_share\":{\"order_id\":\"order-1\"}")); + assertFalse(json.contains("orderShare")); + assertFalse(json.contains("orderId")); + WxChannelKfSendMsgParam decoded = JsonUtils.decode(json, WxChannelKfSendMsgParam.class); + assertEquals(decoded.getOrderShare().getOrderId(), "order-1"); + } + @Test public void testCosUploadResponseJson() { WxChannelKfCosUploadResponse response = JsonUtils.decode( @@ -56,4 +115,27 @@ public void testSendMsgResponseJson() { assertEquals(JsonUtils.decode(JsonUtils.encode(response), WxChannelKfSendMsgResponse.class) .getMsgId(), "msg-1"); } + + private WxChannelKfSendMsgParam createSendMsgParam(String msgType) { + WxChannelKfSendMsgParam param = new WxChannelKfSendMsgParam(); + param.setRequestId("request-1"); + param.setOpenId("open-1"); + param.setMsgType(msgType); + return param; + } + + private void assertCosUrlMessageJson(WxChannelKfSendMsgParam param, String fieldName, + String cosUrl) { + String json = JsonUtils.encode(param); + assertTrue(json.contains("\"" + fieldName + "\":{\"cos_url\":\"" + cosUrl + "\"}")); + assertFalse(json.contains("cosUrl")); + WxChannelKfSendMsgParam decoded = JsonUtils.decode(json, WxChannelKfSendMsgParam.class); + if ("image".equals(fieldName)) { + assertEquals(decoded.getImage().getCosUrl(), cosUrl); + } else if ("video".equals(fieldName)) { + assertEquals(decoded.getVideo().getCosUrl(), cosUrl); + } else { + assertEquals(decoded.getFile().getCosUrl(), cosUrl); + } + } } diff --git a/weixin-java-channel/src/test/resources/testng.xml b/weixin-java-channel/src/test/resources/testng.xml index d9c43eafc..579acb325 100644 --- a/weixin-java-channel/src/test/resources/testng.xml +++ b/weixin-java-channel/src/test/resources/testng.xml @@ -28,6 +28,7 @@ + From 939bb144218a463cb1c8ea1cb69c4c426ae8794a Mon Sep 17 00:00:00 2001 From: Binary Wang Date: Sat, 22 Aug 2026 13:42:31 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(channel):=20=E4=BF=AE=E5=A4=8D=E5=AE=A2?= =?UTF-8?q?=E6=9C=8D=E4=B8=8A=E4=BC=A0=E9=87=8D=E8=AF=95=E4=B8=8E=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E6=B3=84=E9=9C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .superpowers/sdd/task-1-report.md | 22 -------- .superpowers/sdd/task-2-report.md | 55 ------------------- .../api/impl/WxChannelKfServiceImpl.java | 5 +- .../api/impl/WxChannelKfServiceImplTest.java | 34 ++++++++++++ .../weixin/common/bean/CommonUploadParam.java | 22 +++++++- 5 files changed, 59 insertions(+), 79 deletions(-) delete mode 100644 .superpowers/sdd/task-1-report.md delete mode 100644 .superpowers/sdd/task-2-report.md diff --git a/.superpowers/sdd/task-1-report.md b/.superpowers/sdd/task-1-report.md deleted file mode 100644 index c19588573..000000000 --- a/.superpowers/sdd/task-1-report.md +++ /dev/null @@ -1,22 +0,0 @@ -# Task 1 报告 - -## 修改 - -- 新增 `WxChannelKfCosUploadResponse`,映射 `cos_url` 并继承频道基础响应。 -- 新增 `WxChannelKfSendMsgParam`,映射 `request_id`、`open_id`、`msg_type` 和嵌套 `text.content`。 -- 新增 `WxChannelKfSendMsgResponse`,映射 `msg_id` 并继承频道基础响应。 -- 新增 TestNG JSON 编解码测试 `WxChannelKfBeanTest`。 - -## TDD 测试 - -- 红灯:`mvn -pl weixin-java-channel -Dtest=WxChannelKfBeanTest -DfailIfNoTests=false test`,因 3 个模型类不存在,在 testCompile 阶段失败。 -- 绿灯:`mvn -pl weixin-java-channel dependency:build-classpath -Dmdep.outputFile=/tmp/wxjava-channel-test-cp.txt -Dmdep.includeScope=test -q && TEST_CP="$(cat /tmp/wxjava-channel-test-cp.txt)" && java -cp "weixin-java-channel/target/test-classes:weixin-java-channel/target/classes:${TEST_CP}" org.testng.TestNG -testclass me.chanjar.weixin.channel.bean.kf.WxChannelKfBeanTest`,3 个测试通过,0 失败,0 跳过。 -- `git diff --check`:通过。 - -## Commit - -`a651123a1`(新增视频号客服消息模型)。 - -## 顾虑 - -仓库父 POM 的 Surefire pluginManagement 默认配置 `skip=true`,因此 Maven 测试命令会显示 Tests are skipped;已使用同一编译产物和依赖通过 TestNG CLI 实际执行测试。 diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md deleted file mode 100644 index daa5aadd0..000000000 --- a/.superpowers/sdd/task-2-report.md +++ /dev/null @@ -1,55 +0,0 @@ -# Task 2 报告:商家客服服务入口与请求执行 - -## 修改 - -- 新增 `WxChannelKfService` 与 `WxChannelKfServiceImpl`。 -- 新增客服接口常量,使用官方路径: - - `/channels/ec/commkf/cosupload` - - `/channels/ec/commkf/sendmsg` -- `uploadMedia` 使用 multipart 字段 `file`、`open_id`、`msg_type`,并解析 `cos_url`。 -- `sendMessage` 通过现有 `post(url, Object)` JSON 流程发送请求,并解析 `msg_id`。 -- 在 `WxChannelService` 增加懒加载、同步缓存的 `getKfService()` 入口。 -- 新增不依赖微信凭据的 TestNG 测试,已纳入模块 `src/test/resources/testng.xml`。 - -## TDD 证据 - -- 红灯:先新增测试后执行 - `mvn -pl weixin-java-channel -Dtest=WxChannelKfServiceImplTest -DfailIfNoTests=false test`。 - 测试编译按预期失败,原因是 `WxChannelKfServiceImpl` 和 `getKfService()` 尚不存在。 -- 绿灯:实现后通过 TestNG CLI 实际执行 - `WxChannelKfServiceImplTest`:3 通过,0 失败,0 跳过。 -- 模块套件:通过 TestNG CLI 执行 `weixin-java-channel/src/test/resources/testng.xml`:18 通过,0 失败,0 跳过。 -- `git diff --check`:通过。 - -## 测试执行说明 - -父 POM 的 Surefire pluginManagement 默认配置 `skip=true`,故 Maven 可用于编译测试但会跳过执行;已以模块编译产物和 test scope 依赖运行 TestNG CLI,确保新增测试及模块 XML 套件均真实执行。 - -## 自审 - -- 上传和发送接口均使用官方 `commkf` URL,没有使用错误的 `/kf/` 路径。 -- 修改范围仅覆盖服务入口、请求执行、常量和离线测试;无新增依赖。 -- 保持 Java 8 兼容,沿用现有 `CommonUploadParam`、`ResponseUtils` 和服务缓存模式。 - -## Commit - -`f76b4db00`(新增商家客服服务入口与请求执行)。 - -## 兼容性修复(Task 2 审查 P1) - -- 根因:`WxChannelService#getKfService()` 原为抽象接口方法,会迫使既有下游实现重新编译,并可能使未实现新方法的旧实现运行时抛出 `AbstractMethodError`。 -- 修复:改为 Java 8 `default` 方法;默认抛出 `UnsupportedOperationException("WxChannelService implementation does not support getKfService()")`。 -- 保持 `BaseWxChannelServiceImpl` 中 `@Override synchronized getKfService()` 的懒加载缓存实现不变。 -- 检查结果:`WxChannelService` 接口目录未发现可复用的既有 default 方法风格;未修改其他实现或测试文件。 - -## 兼容性修复验证 - -- `git diff --check`:通过。 -- `mvn -pl weixin-java-channel -am -Dtest=me.chanjar.weixin.channel.api.impl.WxChannelKfServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test`:构建成功;父 POM Surefire 默认 `skip=true`,测试执行显示 `Tests are skipped`。 -- `mvn -pl weixin-java-channel -Dsurefire.skip=false -DskipTests=false -Dmaven.test.skip=false -Dsurefire.suiteXmlFiles=src/test/resources/testng.xml test`:构建成功,但仍受父 POM Surefire 配置影响而跳过测试。 -- `mvn -pl weixin-java-channel -Dskip=false -DskipTests=false -Dmaven.test.skip=false -Dsurefire.suiteXmlFiles=src/test/resources/testng.xml org.apache.maven.plugins:maven-surefire-plugin:2.17:test`:仍显示 `Tests are skipped`。 -- `mvn -pl weixin-java-channel dependency:build-classpath -Dmdep.outputAbsoluteArtifactFilename=true -Dmdep.outputFile=/tmp/wxjava-channel-test-classpath -DincludeScope=test` 后,以 TestNG CLI 执行 `weixin-java-channel/src/test/resources/testng.xml`:18 通过,0 失败,0 跳过。 - -## 兼容性修复 Commit - -`4b5575cce`(修复客服服务入口接口兼容性)。 diff --git a/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/api/impl/WxChannelKfServiceImpl.java b/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/api/impl/WxChannelKfServiceImpl.java index 7086ab431..21bef92e1 100644 --- a/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/api/impl/WxChannelKfServiceImpl.java +++ b/weixin-java-channel/src/main/java/me/chanjar/weixin/channel/api/impl/WxChannelKfServiceImpl.java @@ -7,9 +7,11 @@ import me.chanjar.weixin.channel.bean.kf.WxChannelKfCosUploadResponse; import me.chanjar.weixin.channel.bean.kf.WxChannelKfSendMsgParam; import me.chanjar.weixin.channel.bean.kf.WxChannelKfSendMsgResponse; +import me.chanjar.weixin.channel.util.JsonUtils; import me.chanjar.weixin.channel.util.ResponseUtils; import me.chanjar.weixin.common.bean.CommonUploadParam; import me.chanjar.weixin.common.error.WxErrorException; +import me.chanjar.weixin.common.util.http.SimplePostRequestExecutor; /** 视频号小店商家客服服务实现。 */ public class WxChannelKfServiceImpl implements WxChannelKfService { @@ -36,7 +38,8 @@ public String uploadMedia(String openId, String msgType, String fileName, byte[] @Override public WxChannelKfSendMsgResponse sendMessage(WxChannelKfSendMsgParam param) throws WxErrorException { - String responseJson = channelService.post(SEND_MSG_URL, param); + String responseJson = channelService.executeWithoutLog(SimplePostRequestExecutor.create(channelService), SEND_MSG_URL, + JsonUtils.encode(param)); return ResponseUtils.decode(responseJson, WxChannelKfSendMsgResponse.class); } } diff --git a/weixin-java-channel/src/test/java/me/chanjar/weixin/channel/api/impl/WxChannelKfServiceImplTest.java b/weixin-java-channel/src/test/java/me/chanjar/weixin/channel/api/impl/WxChannelKfServiceImplTest.java index 8a3f5df9e..02353aab3 100644 --- a/weixin-java-channel/src/test/java/me/chanjar/weixin/channel/api/impl/WxChannelKfServiceImplTest.java +++ b/weixin-java-channel/src/test/java/me/chanjar/weixin/channel/api/impl/WxChannelKfServiceImplTest.java @@ -4,11 +4,15 @@ import static org.testng.Assert.assertNotNull; import static org.testng.Assert.assertSame; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; import me.chanjar.weixin.channel.bean.kf.WxChannelKfSendMsgParam; import me.chanjar.weixin.channel.bean.kf.WxChannelKfSendMsgResponse; import me.chanjar.weixin.channel.util.JsonUtils; import me.chanjar.weixin.common.bean.CommonUploadParam; import me.chanjar.weixin.common.error.WxErrorException; +import me.chanjar.weixin.common.util.http.RequestExecutor; import org.testng.annotations.Test; /** 商家客服服务离线测试。 */ @@ -49,10 +53,19 @@ public void shouldSendJsonMessageAndDecodeResponse() throws WxErrorException { assertEquals(channelService.postUrl, "https://api.weixin.qq.com/channels/ec/commkf/sendmsg"); assertEquals(channelService.postJson, "{\"request_id\":\"request-id\",\"open_id\":\"open-id\",\"msg_type\":\"text\",\"text\":{\"content\":\"hello\"}}"); + assertEquals(channelService.executeWithoutLogCalled, true); assertEquals(response.getMsgId(), "message-id"); assertEquals(response.getErrCode(), 0); } + @Test + public void shouldProvideFreshUploadStreamForEachAttempt() throws IOException { + CommonUploadParam uploadParam = CommonUploadParam.fromBytes("file", "image.png", new byte[]{1, 2, 3}); + + assertEquals(readAllBytes(uploadParam.getData().getInputStream()), new byte[]{1, 2, 3}); + assertEquals(readAllBytes(uploadParam.getData().getInputStream()), new byte[]{1, 2, 3}); + } + @Test public void shouldCacheKfServiceEntryPoint() { WxChannelServiceImpl channelService = new WxChannelServiceImpl(); @@ -68,6 +81,7 @@ private static class RecordingChannelService extends WxChannelServiceImpl { private CommonUploadParam uploadParam; private String postUrl; private String postJson; + private boolean executeWithoutLogCalled; @Override public String upload(String url, CommonUploadParam param) { @@ -82,5 +96,25 @@ public String post(String url, Object obj) { this.postJson = JsonUtils.encode(obj); return postResult; } + + @Override + @SuppressWarnings("unchecked") + public T executeWithoutLog(RequestExecutor executor, String uri, E data) { + this.executeWithoutLogCalled = true; + this.postUrl = uri; + this.postJson = (String) data; + return (T) postResult; + } + } + + private byte[] readAllBytes(InputStream inputStream) throws IOException { + try (InputStream stream = inputStream; ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + byte[] buffer = new byte[16]; + int count; + while ((count = stream.read(buffer)) != -1) { + outputStream.write(buffer, 0, count); + } + return outputStream.toByteArray(); + } } } diff --git a/weixin-java-common/src/main/java/me/chanjar/weixin/common/bean/CommonUploadParam.java b/weixin-java-common/src/main/java/me/chanjar/weixin/common/bean/CommonUploadParam.java index 42e186950..34fb66da1 100644 --- a/weixin-java-common/src/main/java/me/chanjar/weixin/common/bean/CommonUploadParam.java +++ b/weixin-java-common/src/main/java/me/chanjar/weixin/common/bean/CommonUploadParam.java @@ -78,7 +78,27 @@ public static CommonUploadParam fromFile(String name, File file) { */ @SneakyThrows public static CommonUploadParam fromBytes(String name, @Nullable String fileName, byte[] bytes) { - return new CommonUploadParam(name, new CommonUploadData(fileName, new ByteArrayInputStream(bytes), bytes.length), null); + return new CommonUploadParam(name, new ByteArrayUploadData(fileName, bytes), null); + } + + private static class ByteArrayUploadData extends CommonUploadData { + + private final byte[] bytes; + + private ByteArrayUploadData(@Nullable String fileName, byte[] bytes) { + super(fileName, new ByteArrayInputStream(bytes), bytes.length); + this.bytes = bytes; + } + + @Override + public ByteArrayInputStream getInputStream() { + return new ByteArrayInputStream(bytes); + } + + @Override + public byte[] readAllBytes() { + return bytes.clone(); + } } /**