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();
+ }
}
/**