feat(channel): add mci-channel-wechat-personal adapter

- New module: mci-channel-wechat-personal
- Implements ChannelAdapter SPI for personal WeChat
- Calls Python channel-service pywechat HTTP APIs:
  - /v1/wechat-personal/login/start for initAccount QR code
  - /v1/wechat-personal/send/text and /send/image for sendMessage
- Configurable base URL via sino.channel.wechat-personal.base-url
- Added MockWebServer unit tests
- Wired module into backend parent and mci-server
This commit is contained in:
2026-07-07 20:13:39 +08:00
parent f5d07b7eb5
commit 79fed8b607
8 changed files with 532 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.sino</groupId>
<artifactId>sino-mci-parent</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>mci-channel-wechat-personal</artifactId>
<name>mci-channel-wechat-personal</name>
<description>Personal WeChat channel adapter</description>
<dependencies>
<dependency>
<groupId>com.sino</groupId>
<artifactId>mci-channel-common</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>mockwebserver</artifactId>
<version>4.12.0</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,17 @@
package com.sino.mci.channel.wechat.personal;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties(WechatPersonalProperties.class)
public class WechatPersonalAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public WechatPersonalChannelAdapter wechatPersonalChannelAdapter(WechatPersonalProperties properties) {
return new WechatPersonalChannelAdapter(properties);
}
}

View File

@@ -0,0 +1,234 @@
package com.sino.mci.channel.wechat.personal;
import com.sino.mci.channel.common.dto.CallbackPayload;
import com.sino.mci.channel.common.dto.ChannelInitRequest;
import com.sino.mci.channel.common.dto.ChannelInitResult;
import com.sino.mci.channel.common.dto.SendMessageRequest;
import com.sino.mci.channel.common.dto.SendMessageResult;
import com.sino.mci.channel.common.model.ChannelType;
import com.sino.mci.channel.common.spi.ChannelAdapter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
@Slf4j
public class WechatPersonalChannelAdapter implements ChannelAdapter {
private static final ParameterizedTypeReference<Map<String, Object>> MAP_TYPE =
new ParameterizedTypeReference<>() {};
private final WechatPersonalProperties properties;
private RestClient restClient;
public WechatPersonalChannelAdapter(WechatPersonalProperties properties) {
this.properties = properties;
}
private RestClient restClient() {
if (restClient == null) {
restClient = RestClient.builder().baseUrl(properties.getBaseUrl()).build();
}
return restClient;
}
@Override
public ChannelType getChannelType() {
return ChannelType.WECHAT_PERSONAL;
}
@Override
public ChannelInitResult initAccount(ChannelInitRequest request) {
ChannelInitResult result = new ChannelInitResult();
if (request == null || request.getConfig() == null) {
result.setSuccess(false);
result.setMessage("个人微信初始化失败:请求或配置为空");
return result;
}
String accountId = getString(request.getConfig(), "account_id");
if (isBlank(accountId)) {
result.setSuccess(false);
result.setMessage("个人微信初始化失败:缺少 account_id");
return result;
}
String accountName = getString(request.getConfig(), "account_name");
Map<String, Object> body = new HashMap<>();
body.put("account_id", accountId);
body.put("account_name", accountName);
try {
// Python 侧 login/start 会 get_or_create 账号,因此该调用同时完成注册校验并返回二维码
Map<String, Object> response = post("/v1/wechat-personal/login/start", body);
if (response == null) {
result.setSuccess(false);
result.setMessage("个人微信初始化失败Python 服务返回为空");
return result;
}
String qrCodeUrl = getString(response, "qr_code_url");
if (isBlank(qrCodeUrl)) {
result.setSuccess(false);
result.setMessage("个人微信初始化失败Python 服务未返回二维码");
return result;
}
result.setSuccess(true);
result.setAccountId(accountId);
result.setAccountName(isBlank(accountName) ? accountId : accountName);
result.setQrCodeUrl(qrCodeUrl);
result.setMessage("个人微信账号初始化成功,请扫描二维码登录");
} catch (RestClientException e) {
result.setSuccess(false);
result.setMessage("个人微信初始化失败:" + e.getMessage());
log.warn("[WechatPersonal] 初始化账号失败", e);
}
return result;
}
@Override
public SendMessageResult sendMessage(SendMessageRequest request) {
SendMessageResult result = new SendMessageResult();
if (request == null) {
result.setSuccess(false);
result.setMessage("发送请求为空");
return result;
}
String accountId = resolveAccountId(request);
if (isBlank(accountId)) {
result.setSuccess(false);
result.setMessage("个人微信发送失败:缺少 account_id");
return result;
}
String conversationId = defaultString(request.getConversationId());
if (isBlank(conversationId)) {
result.setSuccess(false);
result.setMessage("个人微信发送失败:缺少 conversation_id");
return result;
}
String contentType = defaultString(request.getContentType()).toLowerCase();
try {
Map<String, Object> response;
if ("image".equals(contentType)) {
response = sendImage(accountId, conversationId, request.getContent());
} else {
response = sendText(accountId, conversationId, request.getContent());
}
if (response == null) {
result.setSuccess(false);
result.setMessage("个人微信发送失败Python 服务返回为空");
return result;
}
Boolean success = getBoolean(response, "success");
if (!Boolean.TRUE.equals(success)) {
result.setSuccess(false);
result.setMessage("个人微信发送失败:" + getString(response, "error"));
return result;
}
result.setSuccess(true);
result.setChannelMessageId(getString(response, "message_id"));
} catch (RestClientException e) {
result.setSuccess(false);
result.setMessage("个人微信发送失败:" + e.getMessage());
log.warn("[WechatPersonal] 发送消息失败", e);
}
return result;
}
@Override
public CallbackPayload parseCallback(String rawBody) {
CallbackPayload payload = new CallbackPayload();
payload.setChannelType(ChannelType.WECHAT_PERSONAL.name());
payload.setRawBody(rawBody);
return payload;
}
private Map<String, Object> sendText(String accountId, String conversationId, Map<String, Object> content) {
String text = getString(content, "text");
if (isBlank(text)) {
throw new IllegalArgumentException("文本内容为空");
}
Map<String, Object> body = new HashMap<>();
body.put("account_id", accountId);
body.put("conversation_id", conversationId);
body.put("text", text);
return post("/v1/wechat-personal/send/text", body);
}
private Map<String, Object> sendImage(String accountId, String conversationId, Map<String, Object> content) {
String imageUrl = getString(content, "image_url");
if (isBlank(imageUrl)) {
throw new IllegalArgumentException("图片地址为空");
}
Map<String, Object> body = new HashMap<>();
body.put("account_id", accountId);
body.put("conversation_id", conversationId);
body.put("image_url", imageUrl);
return post("/v1/wechat-personal/send/image", body);
}
private Map<String, Object> post(String uri, Map<String, Object> body) {
return restClient().post()
.uri(uri)
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
.body(MAP_TYPE);
}
private String resolveAccountId(SendMessageRequest request) {
String accountId = getString(request.getExtra(), "account_id");
if (isNotBlank(accountId)) {
return accountId;
}
return getString(request.getContent(), "account_id");
}
private String getString(Map<?, ?> map, String key) {
if (map == null) {
return null;
}
Object value = map.get(key);
return value == null ? null : Objects.toString(value);
}
private Boolean getBoolean(Map<?, ?> map, String key) {
if (map == null) {
return null;
}
Object value = map.get(key);
if (value instanceof Boolean b) {
return b;
}
if (value instanceof String s) {
return "true".equalsIgnoreCase(s);
}
return null;
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
private boolean isNotBlank(String value) {
return !isBlank(value);
}
private String defaultString(String value) {
return value == null ? "" : value;
}
}

View File

@@ -0,0 +1,14 @@
package com.sino.mci.channel.wechat.personal;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
@Data
@ConfigurationProperties(prefix = "sino.channel.wechat-personal")
public class WechatPersonalProperties {
/**
* Python channel-service 基础地址。
*/
private String baseUrl = "http://mci-channel-service:8000";
}

View File

@@ -0,0 +1 @@
com.sino.mci.channel.wechat.personal.WechatPersonalAutoConfiguration

View File

@@ -0,0 +1,207 @@
package com.sino.mci.channel.wechat.personal;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sino.mci.channel.common.dto.ChannelInitRequest;
import com.sino.mci.channel.common.dto.ChannelInitResult;
import com.sino.mci.channel.common.dto.SendMessageRequest;
import com.sino.mci.channel.common.dto.SendMessageResult;
import com.sino.mci.channel.common.model.ChannelType;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class WechatPersonalChannelAdapterTest {
private MockWebServer mockWebServer;
private WechatPersonalChannelAdapter adapter;
@BeforeEach
void setUp() throws Exception {
mockWebServer = new MockWebServer();
mockWebServer.start();
WechatPersonalProperties properties = new WechatPersonalProperties();
properties.setBaseUrl(mockWebServer.url("/").toString());
adapter = new WechatPersonalChannelAdapter(properties);
}
@AfterEach
void tearDown() throws Exception {
mockWebServer.shutdown();
}
@Test
void shouldReturnChannelType() {
assertThat(adapter.getChannelType()).isEqualTo(ChannelType.WECHAT_PERSONAL);
}
@Test
void shouldInitAccountAndReturnQrCodeUrl() throws InterruptedException {
mockWebServer.enqueue(new MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody(toJson(Map.of(
"login_id", "login-123",
"qr_code_url", "https://example.com/qr.png",
"status", "pending"))));
ChannelInitRequest request = new ChannelInitRequest();
Map<String, Object> config = new HashMap<>();
config.put("account_id", "wx-123");
config.put("account_name", "Test Account");
request.setConfig(config);
ChannelInitResult result = adapter.initAccount(request);
assertThat(result.isSuccess()).isTrue();
assertThat(result.getAccountId()).isEqualTo("wx-123");
assertThat(result.getAccountName()).isEqualTo("Test Account");
assertThat(result.getQrCodeUrl()).isEqualTo("https://example.com/qr.png");
RecordedRequest recorded = mockWebServer.takeRequest();
assertThat(recorded.getMethod()).isEqualTo("POST");
assertThat(recorded.getPath()).isEqualTo("/v1/wechat-personal/login/start");
assertThat(recorded.getBody().readUtf8()).contains("wx-123", "Test Account");
}
@Test
void shouldFailInitAccountWhenQrCodeMissing() {
mockWebServer.enqueue(new MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody(toJson(Map.of(
"login_id", "login-123",
"status", "error"))));
ChannelInitRequest request = new ChannelInitRequest();
Map<String, Object> config = new HashMap<>();
config.put("account_id", "wx-123");
request.setConfig(config);
ChannelInitResult result = adapter.initAccount(request);
assertThat(result.isSuccess()).isFalse();
assertThat(result.getMessage()).contains("未返回二维码");
}
@Test
void shouldSendTextMessage() throws InterruptedException {
mockWebServer.enqueue(new MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody(toJson(Map.of(
"success", true,
"message_id", "msg-123"))));
SendMessageRequest request = new SendMessageRequest();
request.setConversationId("chat-123");
request.setContentType("text");
Map<String, Object> content = new HashMap<>();
content.put("text", "hello");
request.setContent(content);
Map<String, Object> extra = new HashMap<>();
extra.put("account_id", "wx-123");
request.setExtra(extra);
SendMessageResult result = adapter.sendMessage(request);
assertThat(result.isSuccess()).isTrue();
assertThat(result.getChannelMessageId()).isEqualTo("msg-123");
RecordedRequest recorded = mockWebServer.takeRequest();
assertThat(recorded.getMethod()).isEqualTo("POST");
assertThat(recorded.getPath()).isEqualTo("/v1/wechat-personal/send/text");
String body = recorded.getBody().readUtf8();
assertThat(body).contains("wx-123", "chat-123", "hello");
}
@Test
void shouldSendImageMessage() throws InterruptedException {
mockWebServer.enqueue(new MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody(toJson(Map.of(
"success", true,
"message_id", "img-123"))));
SendMessageRequest request = new SendMessageRequest();
request.setConversationId("chat-456");
request.setContentType("image");
Map<String, Object> content = new HashMap<>();
content.put("image_url", "https://example.com/img.png");
request.setContent(content);
Map<String, Object> extra = new HashMap<>();
extra.put("account_id", "wx-123");
request.setExtra(extra);
SendMessageResult result = adapter.sendMessage(request);
assertThat(result.isSuccess()).isTrue();
assertThat(result.getChannelMessageId()).isEqualTo("img-123");
RecordedRequest recorded = mockWebServer.takeRequest();
assertThat(recorded.getPath()).isEqualTo("/v1/wechat-personal/send/image");
}
@Test
void shouldReturnFailureWhenPythonReportsError() {
mockWebServer.enqueue(new MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody(toJson(Map.of(
"success", false,
"error", "account wx-123 not registered"))));
SendMessageRequest request = new SendMessageRequest();
request.setConversationId("chat-123");
request.setContentType("text");
Map<String, Object> content = new HashMap<>();
content.put("text", "hello");
request.setContent(content);
Map<String, Object> extra = new HashMap<>();
extra.put("account_id", "wx-123");
request.setExtra(extra);
SendMessageResult result = adapter.sendMessage(request);
assertThat(result.isSuccess()).isFalse();
assertThat(result.getMessage()).contains("account wx-123 not registered");
}
@Test
void shouldReturnFailureWhenAccountIdMissing() {
SendMessageRequest request = new SendMessageRequest();
request.setConversationId("chat-123");
request.setContentType("text");
request.setContent(Map.of("text", "hello"));
SendMessageResult result = adapter.sendMessage(request);
assertThat(result.isSuccess()).isFalse();
assertThat(result.getMessage()).contains("account_id");
}
@Test
void shouldParseCallback() {
assertThat(adapter.parseCallback("raw-body").getChannelType())
.isEqualTo(ChannelType.WECHAT_PERSONAL.name());
assertThat(adapter.parseCallback("raw-body").getRawBody()).isEqualTo("raw-body");
}
private String toJson(Object value) {
try {
return new ObjectMapper().writeValueAsString(value);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}

View File

@@ -24,6 +24,10 @@
<groupId>com.sino</groupId> <groupId>com.sino</groupId>
<artifactId>mci-channel-wecom</artifactId> <artifactId>mci-channel-wecom</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.sino</groupId>
<artifactId>mci-channel-wechat-personal</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>

View File

@@ -30,6 +30,7 @@
<module>mci-shared</module> <module>mci-shared</module>
<module>mci-channel-common</module> <module>mci-channel-common</module>
<module>mci-channel-wecom</module> <module>mci-channel-wecom</module>
<module>mci-channel-wechat-personal</module>
<module>mci-server</module> <module>mci-server</module>
</modules> </modules>
@@ -50,6 +51,11 @@
<artifactId>mci-channel-wecom</artifactId> <artifactId>mci-channel-wecom</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency>
<groupId>com.sino</groupId>
<artifactId>mci-channel-wechat-personal</artifactId>
<version>${project.version}</version>
</dependency>
<dependency> <dependency>
<groupId>com.baomidou</groupId> <groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId> <artifactId>mybatis-plus-boot-starter</artifactId>