feat(channel-wecom): 实现企微 CP 官方 API 发送器

This commit is contained in:
2026-07-07 19:20:55 +08:00
parent f88230687c
commit 7edbb0a178
5 changed files with 618 additions and 4 deletions

View File

@@ -23,10 +23,25 @@
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.github.binarywang</groupId>
<artifactId>weixin-java-cp</artifactId>
<version>4.6.0</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -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<String, Object> 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<String, Object> extra = request.getExtra();
if (extra != null && extra.containsKey("corp_id") && extra.containsKey("agent_id") && extra.containsKey("secret")) {
return serviceFactory.getOrCreateService(extra);
}
Map<String, Object> 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<String, Object> 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<String, Object> 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<NewArticle> buildNewsArticles(Map<String, Object> content) {
Object articlesObj = content == null ? null : content.get("articles");
if (!(articlesObj instanceof List<?> list)) {
return List.of();
}
List<NewArticle> 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<String, Object> extra = request.getExtra();
if (extra != null) {
String agentId = getString(extra, "agent_id");
if (agentId != null) {
return Integer.valueOf(agentId);
}
}
Map<String, Object> 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<String, Object> 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<String, String> getStringMap(Map<String, Object> map, String key) {
if (map == null) {
return Map.of();
}
Object value = map.get(key);
if (value instanceof Map<?, ?> rawMap) {
Map<String, String> result = new java.util.HashMap<>();
rawMap.forEach((k, v) -> result.put(Objects.toString(k, ""), v == null ? "" : v.toString()));
return result;
}
return Map.of();
}
}

View File

@@ -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 缓存配置存储。
*
* <p>在 {@link WxCpDefaultConfigImpl} 的内存缓存基础上,增加 Redis 二级缓存。
* 当 Spring 上下文中存在 {@link StringRedisTemplate} 时token 会写入 Redis 并设置过期时间;
* 否则退化为内存缓存。</p>
*/
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);
}
}

View File

@@ -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} 工厂。
*
* <p>根据企业 ID、应用 ID、应用 Secret 构建 {@link WxCpService},并按账号维度缓存。
* 若上下文中存在 {@link StringRedisTemplate},则 token 使用 Redis 缓存。</p>
*/
@Component
public class WeComServiceFactory {
private static final Logger log = LoggerFactory.getLogger(WeComServiceFactory.class);
private final StringRedisTemplate redisTemplate;
private final Map<String, WxCpService> serviceCache = new ConcurrentHashMap<>();
public WeComServiceFactory(ObjectProvider<StringRedisTemplate> redisTemplateProvider) {
this.redisTemplate = redisTemplateProvider.getIfAvailable();
}
/**
* 根据 config 获取或创建 WxCpService。
*
* @param config 必须包含 corp_id、agent_id、secret
* @return WxCpService 实例;配置缺失时返回 null
*/
public WxCpService getOrCreateService(Map<String, Object> 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] 已创建 WxCpServicecorpId={}, 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<String, Object> map, String key) {
Object value = map.get(key);
if (value == null) {
return null;
}
return value.toString().trim();
}
}

View File

@@ -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<String, Object> validConfig() {
Map<String, Object> 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<String, Object> 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<WxCpMessage> 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<WxCpAppChatMessage> 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");
}
}