From 7edbb0a17843e0bd64ab87c80086d6bbb39deb7a Mon Sep 17 00:00:00 2001 From: marsal Date: Tue, 7 Jul 2026 19:20:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(channel-wecom):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E4=BC=81=E5=BE=AE=20CP=20=E5=AE=98=E6=96=B9=20API=20=E5=8F=91?= =?UTF-8?q?=E9=80=81=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/mci-channel-wecom/pom.xml | 15 + .../channel/wecom/WeComChannelAdapter.java | 283 +++++++++++++++++- .../wecom/support/WeComAccessTokenCache.java | 92 ++++++ .../wecom/support/WeComServiceFactory.java | 92 ++++++ .../wecom/WeComChannelAdapterTest.java | 140 +++++++++ 5 files changed, 618 insertions(+), 4 deletions(-) create mode 100644 backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/support/WeComAccessTokenCache.java create mode 100644 backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/support/WeComServiceFactory.java create mode 100644 backend/mci-channel-wecom/src/test/java/com/sino/mci/channel/wecom/WeComChannelAdapterTest.java diff --git a/backend/mci-channel-wecom/pom.xml b/backend/mci-channel-wecom/pom.xml index 065641c..0a97a30 100644 --- a/backend/mci-channel-wecom/pom.xml +++ b/backend/mci-channel-wecom/pom.xml @@ -23,10 +23,25 @@ org.springframework spring-context + + org.springframework.data + spring-data-redis + provided + + + com.github.binarywang + weixin-java-cp + 4.6.0 + org.projectlombok lombok true + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/WeComChannelAdapter.java b/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/WeComChannelAdapter.java index d1731e9..185655a 100644 --- a/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/WeComChannelAdapter.java +++ b/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/WeComChannelAdapter.java @@ -7,11 +7,35 @@ 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 com.sino.mci.channel.wecom.support.WeComServiceFactory; +import lombok.RequiredArgsConstructor; +import me.chanjar.weixin.common.api.WxConsts; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import me.chanjar.weixin.common.error.WxErrorException; +import me.chanjar.weixin.cp.api.WxCpChatService; +import me.chanjar.weixin.cp.api.WxCpMessageService; +import me.chanjar.weixin.cp.api.WxCpService; +import me.chanjar.weixin.cp.bean.article.NewArticle; +import me.chanjar.weixin.cp.bean.message.WxCpAppChatMessage; +import me.chanjar.weixin.cp.bean.message.WxCpMessage; +import me.chanjar.weixin.cp.bean.message.WxCpMessageSendResult; +import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + @Component +@RequiredArgsConstructor public class WeComChannelAdapter implements ChannelAdapter { + private static final Logger log = LoggerFactory.getLogger(WeComChannelAdapter.class); + + private final WeComServiceFactory serviceFactory; + @Override public ChannelType getChannelType() { return ChannelType.WECOM; @@ -20,16 +44,73 @@ public class WeComChannelAdapter implements ChannelAdapter { @Override public ChannelInitResult initAccount(ChannelInitRequest request) { ChannelInitResult result = new ChannelInitResult(); - result.setSuccess(false); - result.setMessage("WeCom channel adapter is a stub and has not been implemented yet."); + Map config = request == null ? null : request.getConfig(); + WxCpService service = serviceFactory.getOrCreateService(config); + if (service == null) { + result.setSuccess(false); + result.setMessage("企微 CP 初始化失败:缺少 corp_id/agent_id/secret"); + return result; + } + + try { + // 触发一次 access_token 获取,验证配置可用性 + service.getAccessToken(false); + + String corpId = getString(config, "corp_id"); + String agentId = getString(config, "agent_id"); + + result.setSuccess(true); + result.setAccountId(corpId + ":" + agentId); + result.setAccountName("WeCom:" + corpId); + result.setMessage("企微 CP 账号初始化成功"); + } catch (WxErrorException e) { + result.setSuccess(false); + result.setMessage("企微 CP 初始化失败:" + e.getMessage()); + log.warn("[WeCom] 初始化账号失败", e); + } catch (Exception e) { + result.setSuccess(false); + result.setMessage("企微 CP 初始化失败:" + e.getMessage()); + log.warn("[WeCom] 初始化账号异常", e); + } return result; } @Override public SendMessageResult sendMessage(SendMessageRequest request) { SendMessageResult result = new SendMessageResult(); - result.setSuccess(false); - result.setMessage("WeCom channel adapter is a stub and has not been implemented yet."); + if (request == null) { + result.setSuccess(false); + result.setMessage("发送请求为空"); + return result; + } + + WxCpService service = resolveService(request); + if (service == null) { + result.setSuccess(false); + result.setMessage("企微 CP 发送失败:无法确定应用账号(请先初始化或在 extra 中提供 corp_id/agent_id/secret)"); + return result; + } + + String contentType = StringUtils.defaultString(request.getContentType()).toLowerCase(); + boolean toGroupChat = StringUtils.isNotBlank(request.getConversationId()); + + try { + if (toGroupChat) { + sendAppChatMessage(service, request, contentType); + } else { + WxCpMessageSendResult sendResult = sendAppMessage(service, request, contentType); + result.setChannelMessageId(sendResult == null ? null : sendResult.getMsgId()); + } + result.setSuccess(true); + } catch (WxErrorException e) { + result.setSuccess(false); + result.setMessage("企微 CP 发送失败:" + e.getMessage()); + log.warn("[WeCom] 发送消息失败", e); + } catch (Exception e) { + result.setSuccess(false); + result.setMessage("企微 CP 发送失败:" + e.getMessage()); + log.warn("[WeCom] 发送消息异常", e); + } return result; } @@ -40,4 +121,198 @@ public class WeComChannelAdapter implements ChannelAdapter { payload.setRawBody(rawBody); return payload; } + + private WxCpService resolveService(SendMessageRequest request) { + Map extra = request.getExtra(); + if (extra != null && extra.containsKey("corp_id") && extra.containsKey("agent_id") && extra.containsKey("secret")) { + return serviceFactory.getOrCreateService(extra); + } + + Map content = request.getContent(); + if (content != null && content.containsKey("corp_id") && content.containsKey("agent_id") && content.containsKey("secret")) { + return serviceFactory.getOrCreateService(content); + } + + // 未显式指定账号时,若缓存中仅有一个账号,则使用该账号 + if (serviceFactory.hasSingleAccount()) { + return serviceFactory.getSingleAccountService(); + } + return null; + } + + private WxCpMessageSendResult sendAppMessage(WxCpService service, SendMessageRequest request, + String contentType) throws WxErrorException { + WxCpMessage message = buildAppMessage(request, contentType); + WxCpMessageService messageService = service.getMessageService(); + return messageService.send(message); + } + + private void sendAppChatMessage(WxCpService service, SendMessageRequest request, + String contentType) throws WxErrorException { + WxCpAppChatMessage message = buildAppChatMessage(request, contentType); + WxCpChatService chatService = service.getChatService(); + chatService.sendMsg(message); + } + + private WxCpMessage buildAppMessage(SendMessageRequest request, String contentType) { + Map content = request.getContent(); + String toUser = normalizeUserIds(request.getReceiverId()); + Integer agentId = resolveAgentId(request); + + WxCpMessage message; + switch (contentType) { + case "image" -> message = WxCpMessage.IMAGE() + .agentId(agentId) + .toUser(toUser) + .mediaId(getString(content, "media_id")) + .build(); + case "file" -> message = WxCpMessage.FILE() + .agentId(agentId) + .toUser(toUser) + .mediaId(getString(content, "media_id")) + .build(); + case "markdown" -> message = WxCpMessage.MARKDOWN() + .agentId(agentId) + .toUser(toUser) + .content(getString(content, "text")) + .build(); + case "news" -> message = WxCpMessage.NEWS() + .agentId(agentId) + .toUser(toUser) + .articles(buildNewsArticles(content)) + .build(); + case "miniprogram", "miniprogram_notice" -> message = WxCpMessage.newMiniProgramNoticeBuilder() + .agentId(agentId) + .toUser(toUser) + .appId(getString(content, "app_id")) + .page(getString(content, "page")) + .title(getString(content, "title")) + .description(getString(content, "description")) + .emphasisFirstItem(getBoolean(content, "emphasis_first_item")) + .contentItems(getStringMap(content, "content_items")) + .build(); + default -> message = WxCpMessage.TEXT() + .agentId(agentId) + .toUser(toUser) + .content(getString(content, "text")) + .build(); + } + return message; + } + + private WxCpAppChatMessage buildAppChatMessage(SendMessageRequest request, String contentType) { + Map content = request.getContent(); + String chatId = request.getConversationId(); + + return switch (contentType) { + case "image" -> WxCpAppChatMessage.builder() + .msgType(WxConsts.KefuMsgType.IMAGE) + .chatId(chatId) + .mediaId(getString(content, "media_id")) + .build(); + case "file" -> WxCpAppChatMessage.builder() + .msgType(WxConsts.KefuMsgType.FILE) + .chatId(chatId) + .mediaId(getString(content, "media_id")) + .build(); + case "markdown" -> WxCpAppChatMessage.builder() + .msgType(WxConsts.KefuMsgType.MARKDOWN) + .chatId(chatId) + .content(getString(content, "text")) + .build(); + case "news" -> WxCpAppChatMessage.builder() + .msgType(WxConsts.KefuMsgType.NEWS) + .chatId(chatId) + .articles(buildNewsArticles(content)) + .build(); + default -> WxCpAppChatMessage.buildTextMsg(chatId, getString(content, "text"), false); + }; + } + + private List buildNewsArticles(Map content) { + Object articlesObj = content == null ? null : content.get("articles"); + if (!(articlesObj instanceof List list)) { + return List.of(); + } + List articles = new ArrayList<>(); + for (Object item : list) { + if (!(item instanceof Map map)) { + continue; + } + NewArticle article = NewArticle.builder() + .title(getString(map, "title")) + .description(getString(map, "description")) + .url(getString(map, "url")) + .picUrl(getString(map, "pic_url")) + .appid(getString(map, "app_id")) + .pagepath(getString(map, "page_path")) + .build(); + articles.add(article); + } + return articles; + } + + private Integer resolveAgentId(SendMessageRequest request) { + Map extra = request.getExtra(); + if (extra != null) { + String agentId = getString(extra, "agent_id"); + if (agentId != null) { + return Integer.valueOf(agentId); + } + } + Map content = request.getContent(); + if (content != null) { + String agentId = getString(content, "agent_id"); + if (agentId != null) { + return Integer.valueOf(agentId); + } + } + return null; + } + + private String normalizeUserIds(String receiverId) { + if (StringUtils.isBlank(receiverId)) { + return null; + } + return receiverId.replace(',', '|').replaceAll("\\s+", ""); + } + + private String getString(Map map, String key) { + if (map == null) { + return null; + } + Object value = map.get(key); + return value == null ? null : value.toString(); + } + + 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 Number n) { + return n.intValue() == 1; + } + if (value instanceof String s) { + return "true".equalsIgnoreCase(s) || "1".equals(s); + } + return null; + } + + @SuppressWarnings("unchecked") + private Map getStringMap(Map map, String key) { + if (map == null) { + return Map.of(); + } + Object value = map.get(key); + if (value instanceof Map rawMap) { + Map result = new java.util.HashMap<>(); + rawMap.forEach((k, v) -> result.put(Objects.toString(k, ""), v == null ? "" : v.toString())); + return result; + } + return Map.of(); + } } diff --git a/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/support/WeComAccessTokenCache.java b/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/support/WeComAccessTokenCache.java new file mode 100644 index 0000000..6410c2c --- /dev/null +++ b/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/support/WeComAccessTokenCache.java @@ -0,0 +1,92 @@ +package com.sino.mci.channel.wecom.support; + +import me.chanjar.weixin.common.bean.WxAccessToken; +import me.chanjar.weixin.cp.config.impl.WxCpDefaultConfigImpl; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.time.Duration; + +/** + * 企业微信 CP access_token 缓存配置存储。 + * + *

在 {@link WxCpDefaultConfigImpl} 的内存缓存基础上,增加 Redis 二级缓存。 + * 当 Spring 上下文中存在 {@link StringRedisTemplate} 时,token 会写入 Redis 并设置过期时间; + * 否则退化为内存缓存。

+ */ +public class WeComAccessTokenCache extends WxCpDefaultConfigImpl { + + private static final Logger log = LoggerFactory.getLogger(WeComAccessTokenCache.class); + + private static final String KEY_PREFIX = "wecom:cp:access_token:"; + + private final String accessTokenKey; + private final StringRedisTemplate redisTemplate; + + public WeComAccessTokenCache(String corpId, Integer agentId, String corpSecret, + StringRedisTemplate redisTemplate) { + this.accessTokenKey = KEY_PREFIX + corpId + ":" + agentId; + this.redisTemplate = redisTemplate; + this.setCorpId(corpId); + this.setCorpSecret(corpSecret); + this.setAgentId(agentId); + } + + @Override + public String getAccessToken() { + if (redisTemplate != null) { + try { + String token = redisTemplate.opsForValue().get(accessTokenKey); + if (token != null && !token.isBlank()) { + return token; + } + } catch (Exception e) { + log.warn("[WeCom] 读取 Redis access_token 失败,退化到内存缓存", e); + } + } + return super.getAccessToken(); + } + + @Override + public boolean isAccessTokenExpired() { + if (redisTemplate != null) { + try { + return !Boolean.TRUE.equals(redisTemplate.hasKey(accessTokenKey)); + } catch (Exception e) { + log.warn("[WeCom] 检查 Redis access_token 过期状态失败,退化到内存缓存", e); + } + } + return super.isAccessTokenExpired(); + } + + @Override + public void expireAccessToken() { + if (redisTemplate != null) { + try { + redisTemplate.delete(accessTokenKey); + } catch (Exception e) { + log.warn("[WeCom] 删除 Redis access_token 失败", e); + } + } + super.expireAccessToken(); + } + + @Override + public synchronized void updateAccessToken(WxAccessToken accessToken) { + updateAccessToken(accessToken.getAccessToken(), accessToken.getExpiresIn()); + } + + @Override + public synchronized void updateAccessToken(String accessToken, int expiresInSeconds) { + if (redisTemplate != null && accessToken != null) { + try { + long ttl = Math.max(expiresInSeconds - 200L, 1L); + redisTemplate.opsForValue().set(accessTokenKey, accessToken, Duration.ofSeconds(ttl)); + } catch (Exception e) { + log.warn("[WeCom] 写入 Redis access_token 失败", e); + } + } + super.updateAccessToken(accessToken, expiresInSeconds); + } +} diff --git a/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/support/WeComServiceFactory.java b/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/support/WeComServiceFactory.java new file mode 100644 index 0000000..196b677 --- /dev/null +++ b/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/support/WeComServiceFactory.java @@ -0,0 +1,92 @@ +package com.sino.mci.channel.wecom.support; + +import me.chanjar.weixin.cp.api.WxCpService; +import me.chanjar.weixin.cp.api.impl.WxCpServiceImpl; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 企业微信 CP {@link WxCpService} 工厂。 + * + *

根据企业 ID、应用 ID、应用 Secret 构建 {@link WxCpService},并按账号维度缓存。 + * 若上下文中存在 {@link StringRedisTemplate},则 token 使用 Redis 缓存。

+ */ +@Component +public class WeComServiceFactory { + + private static final Logger log = LoggerFactory.getLogger(WeComServiceFactory.class); + + private final StringRedisTemplate redisTemplate; + private final Map serviceCache = new ConcurrentHashMap<>(); + + public WeComServiceFactory(ObjectProvider redisTemplateProvider) { + this.redisTemplate = redisTemplateProvider.getIfAvailable(); + } + + /** + * 根据 config 获取或创建 WxCpService。 + * + * @param config 必须包含 corp_id、agent_id、secret + * @return WxCpService 实例;配置缺失时返回 null + */ + public WxCpService getOrCreateService(Map config) { + if (config == null) { + return null; + } + String corpId = getString(config, "corp_id"); + String agentId = getString(config, "agent_id"); + String secret = getString(config, "secret"); + if (corpId == null || agentId == null || secret == null) { + log.warn("[WeCom] 缺少必要配置:corp_id/agent_id/secret"); + return null; + } + return getOrCreateService(corpId, Integer.valueOf(agentId), secret); + } + + /** + * 根据 corpId/agentId/secret 获取或创建 WxCpService。 + */ + public WxCpService getOrCreateService(String corpId, Integer agentId, String secret) { + String key = corpId + ":" + agentId; + return serviceCache.computeIfAbsent(key, k -> createService(corpId, agentId, secret)); + } + + private WxCpService createService(String corpId, Integer agentId, String secret) { + WeComAccessTokenCache configStorage = new WeComAccessTokenCache(corpId, agentId, secret, redisTemplate); + WxCpServiceImpl service = new WxCpServiceImpl(); + service.setWxCpConfigStorage(configStorage); + log.info("[WeCom] 已创建 WxCpService:corpId={}, agentId={}", corpId, agentId); + return service; + } + + /** + * 当前缓存中是否只存在一个账号。 + */ + public boolean hasSingleAccount() { + return serviceCache.size() == 1; + } + + /** + * 获取缓存中唯一的账号服务;不存在或不唯一时返回 null。 + */ + public WxCpService getSingleAccountService() { + if (serviceCache.size() != 1) { + return null; + } + return serviceCache.values().iterator().next(); + } + + private String getString(Map map, String key) { + Object value = map.get(key); + if (value == null) { + return null; + } + return value.toString().trim(); + } +} diff --git a/backend/mci-channel-wecom/src/test/java/com/sino/mci/channel/wecom/WeComChannelAdapterTest.java b/backend/mci-channel-wecom/src/test/java/com/sino/mci/channel/wecom/WeComChannelAdapterTest.java new file mode 100644 index 0000000..8b5c3f8 --- /dev/null +++ b/backend/mci-channel-wecom/src/test/java/com/sino/mci/channel/wecom/WeComChannelAdapterTest.java @@ -0,0 +1,140 @@ +package com.sino.mci.channel.wecom; + +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.wecom.support.WeComServiceFactory; +import me.chanjar.weixin.common.error.WxError; +import me.chanjar.weixin.common.error.WxErrorException; +import me.chanjar.weixin.cp.api.WxCpChatService; +import me.chanjar.weixin.cp.api.WxCpMessageService; +import me.chanjar.weixin.cp.api.WxCpService; +import me.chanjar.weixin.cp.bean.message.WxCpAppChatMessage; +import me.chanjar.weixin.cp.bean.message.WxCpMessage; +import me.chanjar.weixin.cp.bean.message.WxCpMessageSendResult; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class WeComChannelAdapterTest { + + private static Map validConfig() { + Map config = new HashMap<>(); + config.put("corp_id", "ww123"); + config.put("agent_id", "1000002"); + config.put("secret", "secret-abc"); + return config; + } + + private static SendMessageRequest textMessageRequest() { + SendMessageRequest request = new SendMessageRequest(); + request.setReceiverId("user1,user2"); + request.setContentType("text"); + Map content = new HashMap<>(); + content.put("text", "hello"); + request.setContent(content); + request.setExtra(validConfig()); + return request; + } + + @Test + void shouldConstructAndSendTextMessage() throws WxErrorException { + WxCpMessageService messageService = mock(WxCpMessageService.class); + WxCpChatService chatService = mock(WxCpChatService.class); + WxCpService service = mock(WxCpService.class); + when(service.getMessageService()).thenReturn(messageService); + when(service.getChatService()).thenReturn(chatService); + when(messageService.send(any(WxCpMessage.class))).thenReturn(new WxCpMessageSendResult()); + + WeComServiceFactory factory = mock(WeComServiceFactory.class); + when(factory.getOrCreateService(any(Map.class))).thenReturn(service); + + WeComChannelAdapter adapter = new WeComChannelAdapter(factory); + SendMessageResult result = adapter.sendMessage(textMessageRequest()); + + assertThat(result.isSuccess()).isTrue(); + verify(service, never()).getChatService(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WxCpMessage.class); + verify(messageService).send(captor.capture()); + WxCpMessage message = captor.getValue(); + assertThat(message.getMsgType()).isEqualTo("text"); + assertThat(message.getContent()).isEqualTo("hello"); + assertThat(message.getToUser()).isEqualTo("user1|user2"); + assertThat(message.getAgentId()).isEqualTo(1000002); + } + + @Test + void shouldReturnFailureWhenConfigInvalid() throws WxErrorException { + WxCpService service = mock(WxCpService.class); + when(service.getAccessToken(false)).thenThrow(new WxErrorException(WxError.builder().errorMsg("invalid credential").build())); + + WeComServiceFactory factory = mock(WeComServiceFactory.class); + when(factory.getOrCreateService(any(Map.class))).thenReturn(service); + + WeComChannelAdapter adapter = new WeComChannelAdapter(factory); + + ChannelInitRequest request = new ChannelInitRequest(); + request.setConfig(validConfig()); + + ChannelInitResult result = adapter.initAccount(request); + + assertThat(result.isSuccess()).isFalse(); + assertThat(result.getMessage()).contains("invalid credential"); + } + + @Test + void shouldReturnFailureWhenConfigMissing() { + WeComServiceFactory factory = mock(WeComServiceFactory.class); + when(factory.getOrCreateService(any(Map.class))).thenReturn(null); + + WeComChannelAdapter adapter = new WeComChannelAdapter(factory); + + ChannelInitRequest request = new ChannelInitRequest(); + request.setConfig(new HashMap<>()); + + ChannelInitResult result = adapter.initAccount(request); + + assertThat(result.isSuccess()).isFalse(); + assertThat(result.getMessage()).contains("corp_id/agent_id/secret"); + } + + @Test + void shouldSendGroupChatMessageWhenConversationIdPresent() throws WxErrorException { + WxCpMessageService messageService = mock(WxCpMessageService.class); + WxCpChatService chatService = mock(WxCpChatService.class); + WxCpService service = mock(WxCpService.class); + when(service.getMessageService()).thenReturn(messageService); + when(service.getChatService()).thenReturn(chatService); + + WeComServiceFactory factory = mock(WeComServiceFactory.class); + when(factory.getOrCreateService(any(Map.class))).thenReturn(service); + + WeComChannelAdapter adapter = new WeComChannelAdapter(factory); + + SendMessageRequest request = textMessageRequest(); + request.setConversationId("chat123"); + + SendMessageResult result = adapter.sendMessage(request); + + assertThat(result.isSuccess()).isTrue(); + verify(service, never()).getMessageService(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WxCpAppChatMessage.class); + verify(chatService).sendMsg(captor.capture()); + WxCpAppChatMessage message = captor.getValue(); + assertThat(message.getMsgType()).isEqualTo("text"); + assertThat(message.getChatId()).isEqualTo("chat123"); + assertThat(message.getContent()).isEqualTo("hello"); + } +}