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");
+ }
+}