From 79fed8b60740d92b03fa87652d255a1d58ec0a1d Mon Sep 17 00:00:00 2001 From: marsal Date: Tue, 7 Jul 2026 20:13:39 +0800 Subject: [PATCH] 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 --- backend/mci-channel-wechat-personal/pom.xml | 49 ++++ .../WechatPersonalAutoConfiguration.java | 17 ++ .../WechatPersonalChannelAdapter.java | 234 ++++++++++++++++++ .../personal/WechatPersonalProperties.java | 14 ++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../WechatPersonalChannelAdapterTest.java | 207 ++++++++++++++++ backend/mci-server/pom.xml | 4 + backend/pom.xml | 6 + 8 files changed, 532 insertions(+) create mode 100644 backend/mci-channel-wechat-personal/pom.xml create mode 100644 backend/mci-channel-wechat-personal/src/main/java/com/sino/mci/channel/wechat/personal/WechatPersonalAutoConfiguration.java create mode 100644 backend/mci-channel-wechat-personal/src/main/java/com/sino/mci/channel/wechat/personal/WechatPersonalChannelAdapter.java create mode 100644 backend/mci-channel-wechat-personal/src/main/java/com/sino/mci/channel/wechat/personal/WechatPersonalProperties.java create mode 100644 backend/mci-channel-wechat-personal/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 backend/mci-channel-wechat-personal/src/test/java/com/sino/mci/channel/wechat/personal/WechatPersonalChannelAdapterTest.java diff --git a/backend/mci-channel-wechat-personal/pom.xml b/backend/mci-channel-wechat-personal/pom.xml new file mode 100644 index 0000000..ecce9b7 --- /dev/null +++ b/backend/mci-channel-wechat-personal/pom.xml @@ -0,0 +1,49 @@ + + + 4.0.0 + + + com.sino + sino-mci-parent + 0.0.1-SNAPSHOT + + + mci-channel-wechat-personal + mci-channel-wechat-personal + Personal WeChat channel adapter + + + + com.sino + mci-channel-common + + + org.springframework.boot + spring-boot-starter-web + + + com.fasterxml.jackson.core + jackson-databind + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + com.squareup.okhttp3 + mockwebserver + 4.12.0 + test + + + diff --git a/backend/mci-channel-wechat-personal/src/main/java/com/sino/mci/channel/wechat/personal/WechatPersonalAutoConfiguration.java b/backend/mci-channel-wechat-personal/src/main/java/com/sino/mci/channel/wechat/personal/WechatPersonalAutoConfiguration.java new file mode 100644 index 0000000..f7cc90a --- /dev/null +++ b/backend/mci-channel-wechat-personal/src/main/java/com/sino/mci/channel/wechat/personal/WechatPersonalAutoConfiguration.java @@ -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); + } +} diff --git a/backend/mci-channel-wechat-personal/src/main/java/com/sino/mci/channel/wechat/personal/WechatPersonalChannelAdapter.java b/backend/mci-channel-wechat-personal/src/main/java/com/sino/mci/channel/wechat/personal/WechatPersonalChannelAdapter.java new file mode 100644 index 0000000..166d1c0 --- /dev/null +++ b/backend/mci-channel-wechat-personal/src/main/java/com/sino/mci/channel/wechat/personal/WechatPersonalChannelAdapter.java @@ -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_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 body = new HashMap<>(); + body.put("account_id", accountId); + body.put("account_name", accountName); + + try { + // Python 侧 login/start 会 get_or_create 账号,因此该调用同时完成注册校验并返回二维码 + Map 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 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 sendText(String accountId, String conversationId, Map content) { + String text = getString(content, "text"); + if (isBlank(text)) { + throw new IllegalArgumentException("文本内容为空"); + } + Map 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 sendImage(String accountId, String conversationId, Map content) { + String imageUrl = getString(content, "image_url"); + if (isBlank(imageUrl)) { + throw new IllegalArgumentException("图片地址为空"); + } + Map 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 post(String uri, Map 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; + } +} diff --git a/backend/mci-channel-wechat-personal/src/main/java/com/sino/mci/channel/wechat/personal/WechatPersonalProperties.java b/backend/mci-channel-wechat-personal/src/main/java/com/sino/mci/channel/wechat/personal/WechatPersonalProperties.java new file mode 100644 index 0000000..562bcd3 --- /dev/null +++ b/backend/mci-channel-wechat-personal/src/main/java/com/sino/mci/channel/wechat/personal/WechatPersonalProperties.java @@ -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"; +} diff --git a/backend/mci-channel-wechat-personal/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/backend/mci-channel-wechat-personal/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..f5d1d27 --- /dev/null +++ b/backend/mci-channel-wechat-personal/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.sino.mci.channel.wechat.personal.WechatPersonalAutoConfiguration diff --git a/backend/mci-channel-wechat-personal/src/test/java/com/sino/mci/channel/wechat/personal/WechatPersonalChannelAdapterTest.java b/backend/mci-channel-wechat-personal/src/test/java/com/sino/mci/channel/wechat/personal/WechatPersonalChannelAdapterTest.java new file mode 100644 index 0000000..3ddb10a --- /dev/null +++ b/backend/mci-channel-wechat-personal/src/test/java/com/sino/mci/channel/wechat/personal/WechatPersonalChannelAdapterTest.java @@ -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 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 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 content = new HashMap<>(); + content.put("text", "hello"); + request.setContent(content); + Map 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 content = new HashMap<>(); + content.put("image_url", "https://example.com/img.png"); + request.setContent(content); + Map 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 content = new HashMap<>(); + content.put("text", "hello"); + request.setContent(content); + Map 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); + } + } +} diff --git a/backend/mci-server/pom.xml b/backend/mci-server/pom.xml index 990fb14..7950359 100644 --- a/backend/mci-server/pom.xml +++ b/backend/mci-server/pom.xml @@ -24,6 +24,10 @@ com.sino mci-channel-wecom + + com.sino + mci-channel-wechat-personal + org.springframework.boot spring-boot-starter-web diff --git a/backend/pom.xml b/backend/pom.xml index c135720..57c1d82 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -30,6 +30,7 @@ mci-shared mci-channel-common mci-channel-wecom + mci-channel-wechat-personal mci-server @@ -50,6 +51,11 @@ mci-channel-wecom ${project.version} + + com.sino + mci-channel-wechat-personal + ${project.version} + com.baomidou mybatis-plus-boot-starter