Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/superpowers/plans/2026-08-22-channel-merchant-kf.md
Original file line number Diff line number Diff line change
@@ -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`.
18 changes: 18 additions & 0 deletions docs/superpowers/specs/2026-08-22-channel-merchant-kf-design.md
Original file line number Diff line number Diff line change
@@ -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 创建后以替代说明关闭。
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@
*/
public interface WxChannelService extends BaseWxChannelService {

/**
* 商家客服服务。
*
* @return 商家客服服务
*/
default WxChannelKfService getKfService() {
throw new UnsupportedOperationException("WxChannelService implementation does not support getKfService()");
}

/**
* 基础接口服务
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ public abstract class BaseWxChannelServiceImpl<H, P> 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;
Expand Down Expand Up @@ -548,4 +549,12 @@ public synchronized WxChannelEwaybillService getEwaybillService() {
return ewaybillService;
}

@Override
public synchronized WxChannelKfService getKfService() {
if (kfService == null) {
kfService = new WxChannelKfServiceImpl(this);
}
return kfService;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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.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 {

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)
Comment thread
binarywang marked this conversation as resolved.
Comment thread
binarywang marked this conversation as resolved.
Comment thread
binarywang marked this conversation as resolved.
.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.executeWithoutLog(SimplePostRequestExecutor.create(channelService), SEND_MSG_URL,
JsonUtils.encode(param));
return ResponseUtils.decode(responseJson, WxChannelKfSendMsgResponse.class);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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;

/** 图片消息内容。 */
@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 {

private static final long serialVersionUID = 1L;

@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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down
Loading