feat(channel-wecom): 实现企微会话存档接收骨架(Finance SDK 接口 + 回调 + 异步拉取)

This commit is contained in:
2026-07-07 19:42:49 +08:00
parent 7edbb0a178
commit 7b16160bba
11 changed files with 845 additions and 1 deletions

View File

@@ -23,11 +23,21 @@
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.github.binarywang</groupId>
<artifactId>weixin-java-cp</artifactId>

View File

@@ -0,0 +1,29 @@
package com.sino.mci.channel.wecom.archive;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 企微会话存档 Finance SDK 客户端自动配置。
*
* <p>默认注入 {@link MockFinanceSdkClient};设置 {@code sino.channel.wecom.finance-sdk.native=true}
* 可切换为 {@link NativeFinanceSdkClient}(需自行完成 JNI 接入)。</p>
*/
@Configuration
public class FinanceSdkAutoConfiguration {
@Bean
@ConditionalOnMissingBean(FinanceSdkClient.class)
@ConditionalOnProperty(name = "sino.channel.wecom.finance-sdk.native", havingValue = "true")
public FinanceSdkClient nativeFinanceSdkClient() {
return new NativeFinanceSdkClient();
}
@Bean
@ConditionalOnMissingBean(FinanceSdkClient.class)
public FinanceSdkClient mockFinanceSdkClient() {
return new MockFinanceSdkClient();
}
}

View File

@@ -0,0 +1,51 @@
package com.sino.mci.channel.wecom.archive;
import com.sino.mci.channel.wecom.archive.model.FinanceMessage;
import java.util.List;
/**
* 企业微信会话存档 Finance SDK 客户端接口。
*
* <p>对应官方 C/JNI SDK 的封装:</p>
* <ul>
* <li>{@link #init(String, String)} 对应 NewSdk / DestroySdk 的初始化</li>
* <li>{@link #getChatData(long, int)} 对应 GetChatData 拉取加密数据</li>
* <li>{@link #decryptData(String, String)} 对应 DecryptData 解密单条消息</li>
* </ul>
*
* <p>实现说明:</p>
* <ul>
* <li>{@link MockFinanceSdkClient}:默认实现,返回模拟数据,用于无 JNI 环境开发与测试。</li>
* <li>{@link NativeFinanceSdkClient}:预留 JNI 接入路径,真实环境可替换。</li>
* </ul>
*/
public interface FinanceSdkClient {
/**
* 初始化 SDK 会话。
*
* @param corpId 企业 ID
* @param secret 会话存档 Secret
* @return 是否初始化成功
*/
boolean init(String corpId, String secret);
/**
* 按 seq 游标分页拉取加密聊天数据。
*
* @param seq 起始 seq游标
* @param limit 本次拉取条数,建议 100-1000
* @return 加密消息列表;无新消息时返回空列表
*/
List<FinanceMessage> getChatData(long seq, int limit);
/**
* 解密单条加密聊天消息。
*
* @param encryptKey RSA 解密后的消息密钥encrypt_random_key 解密结果)
* @param encryptChatMsg 加密消息体encrypt_chat_msg
* @return 解密后的明文 JSON 字符串
*/
String decryptData(String encryptKey, String encryptChatMsg);
}

View File

@@ -0,0 +1,77 @@
package com.sino.mci.channel.wecom.archive;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sino.mci.channel.wecom.archive.model.FinanceMessage;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
/**
* 企业微信会话存档 Finance SDK 模拟客户端。
*
* <p>默认实现,不依赖 JNI/.so 库,返回固定结构的模拟数据,用于开发、联调与单元测试。</p>
*/
@Slf4j
public class MockFinanceSdkClient implements FinanceSdkClient {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final AtomicLong seqGenerator = new AtomicLong(1000);
@Override
public boolean init(String corpId, String secret) {
log.info("[MockFinanceSdk] init corpId={}, secret=***", corpId);
return true;
}
@Override
public List<FinanceMessage> getChatData(long seq, int limit) {
log.info("[MockFinanceSdk] getChatData seq={}, limit={}", seq, limit);
List<FinanceMessage> messages = new ArrayList<>();
int count = Math.min(limit, 2);
for (int i = 0; i < count; i++) {
long currentSeq = seq + i;
String msgId = "mock-msg-" + currentSeq;
messages.add(FinanceMessage.builder()
.seq(currentSeq)
.msgId(msgId)
.publicKeyVer(1)
.encryptRandomKey("mock-random-key-" + currentSeq)
.encryptChatMsg("mock-chat-msg-" + currentSeq)
.build());
}
return messages;
}
@Override
public String decryptData(String encryptKey, String encryptChatMsg) {
log.info("[MockFinanceSdk] decryptData encryptKey={}, msg={}", encryptKey, encryptChatMsg);
try {
// 模拟解密:直接返回一个结构化的 JSON 明文
Map<String, Object> payload = Map.of(
"msgid", extractSeq(encryptChatMsg),
"action", "send",
"from", "MockUser",
"tolist", List.of("ExternalUser"),
"msgtype", "text",
"text", Map.of("content", "模拟会话存档消息:" + encryptChatMsg),
"msgtime", System.currentTimeMillis()
);
return OBJECT_MAPPER.writeValueAsString(payload);
} catch (Exception e) {
log.warn("[MockFinanceSdk] decryptData failed", e);
return null;
}
}
private String extractSeq(String encryptChatMsg) {
if (encryptChatMsg == null) {
return "mock-msg";
}
int idx = encryptChatMsg.lastIndexOf('-');
return idx > 0 ? "mock-msg-" + encryptChatMsg.substring(idx + 1) : encryptChatMsg;
}
}

View File

@@ -0,0 +1,42 @@
package com.sino.mci.channel.wecom.archive;
import com.sino.mci.channel.wecom.archive.model.FinanceMessage;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
/**
* 企业微信会话存档 Finance SDK 原生 JNI 客户端(预留实现)。
*
* <p>该实现基于官方 C 库 {@code com.tencent.wework.Finance},通过 JNI 调用:</p>
* <ul>
* <li>{@code Finance.NewSdk()} / {@code Finance.DestroySdk(long)}</li>
* <li>{@code Finance.GetChatData(long, long, long, String, String, int, long)}</li>
* <li>{@code Finance.DecryptData(long, String, String, long)}</li>
* </ul>
*
* <p>注意:官方 SDK 为 C 库 + JNI不提供 Maven 坐标,也不应把 {@code .so/.dll} 二进制提交到仓库。
* 本期仅保留骨架;接入真实 JNI 时,请在运行环境放置对应平台的动态库,并在启动参数中配置
* {@code -Djava.library.path} 或通过 {@link System#loadLibrary(String)} 加载。</p>
*/
@Slf4j
public class NativeFinanceSdkClient implements FinanceSdkClient {
@Override
public boolean init(String corpId, String secret) {
throw new UnsupportedOperationException(
"NativeFinanceSdkClient 尚未接入 JNI请先放置官方 .so/.dll 并完成 JNI 绑定");
}
@Override
public List<FinanceMessage> getChatData(long seq, int limit) {
throw new UnsupportedOperationException(
"NativeFinanceSdkClient 尚未接入 JNI请先放置官方 .so/.dll 并完成 JNI 绑定");
}
@Override
public String decryptData(String encryptKey, String encryptChatMsg) {
throw new UnsupportedOperationException(
"NativeFinanceSdkClient 尚未接入 JNI请先放置官方 .so/.dll 并完成 JNI 绑定");
}
}

View File

@@ -0,0 +1,41 @@
package com.sino.mci.channel.wecom.archive.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 企业微信会话存档单条消息GetChatData 返回的加密消息结构)。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class FinanceMessage {
/**
* 消息序号,单调递增,用于断点续拉。
*/
private long seq;
/**
* 消息唯一 ID。
*/
private String msgId;
/**
* 公钥版本。
*/
private int publicKeyVer;
/**
* 经过 RSA 加密的随机密钥,需用企业私钥解密后得到消息密钥。
*/
private String encryptRandomKey;
/**
* 经过 AES 加密的消息体,需用消息密钥解密。
*/
private String encryptChatMsg;
}

View File

@@ -1,16 +1,35 @@
package com.sino.mci.channel.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.channel.entity.ChannelSubject;
import com.sino.mci.channel.messaging.RabbitConfig;
import com.sino.mci.channel.messaging.SessionArchivePullCommand;
import com.sino.mci.channel.repository.ChannelSubjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.web.bind.annotation.*;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.StringReader;
import java.util.Enumeration;
@Slf4j
@RestController
@RequestMapping("/callback/wecom")
@RequiredArgsConstructor
public class WeComCallbackController {
private final ChannelSubjectMapper channelSubjectMapper;
private final RabbitTemplate rabbitTemplate;
@GetMapping
public String verifyUrl(HttpServletRequest request) {
log.info("WeCom callback URL verification: {}", request.getQueryString());
@@ -26,7 +45,107 @@ public class WeComCallbackController {
String name = headerNames.nextElement();
log.debug("Header {}: {}", name, request.getHeader(name));
}
// Phase 2: only accept and acknowledge; event parsing and session archive pull will be implemented later
try {
CallbackInfo info = parseCallbackXml(body);
log.info("WeCom callback parsed: event={}, corpId={}", info.getEvent(), info.getCorpId());
if ("msgaudit_notify".equalsIgnoreCase(info.getEvent())) {
handleMsgAuditNotify(info);
}
} catch (Exception e) {
log.warn("WeCom callback parse failed, still ack", e);
}
return "success";
}
private void handleMsgAuditNotify(CallbackInfo info) {
String corpId = info.getCorpId();
if (corpId == null || corpId.isBlank()) {
log.warn("[WeComCallback] msgaudit_notify 缺少 corpId");
return;
}
ChannelSubject subject = channelSubjectMapper.selectOne(
new LambdaQueryWrapper<ChannelSubject>()
.eq(ChannelSubject::getCorpId, corpId)
.eq(ChannelSubject::getStatus, 1)
.last("LIMIT 1"));
if (subject == null) {
log.warn("[WeComCallback] 未找到 corpId={} 的渠道主体", corpId);
return;
}
if (subject.getSessionArchiveEnabled() == null || subject.getSessionArchiveEnabled() != 1) {
log.warn("[WeComCallback] 渠道主体未开启会话存档 corpId={}", corpId);
return;
}
SessionArchivePullCommand command = new SessionArchivePullCommand();
command.setChannelSubjectId(subject.getId());
command.setTenantCode(subject.getTenantCode());
command.setCorpId(subject.getCorpId());
command.setSecret(subject.getCorpSecret());
command.setPrivateKey(subject.getSessionArchivePrivateKey());
rabbitTemplate.convertAndSend(RabbitConfig.SESSION_ARCHIVE_EXCHANGE,
RabbitConfig.SESSION_ARCHIVE_ROUTING_KEY, command);
log.info("[WeComCallback] 已发送会话存档拉取命令 subjectId={}, corpId={}", subject.getId(), corpId);
}
private CallbackInfo parseCallbackXml(String body) {
CallbackInfo info = new CallbackInfo();
try {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
DocumentBuilder builder = factory.newDocumentBuilder();
Document document = builder.parse(new InputSource(new StringReader(body)));
Element root = document.getDocumentElement();
info.setCorpId(getTagValue(root, "ToUserName"));
info.setEvent(getTagValue(root, "Event"));
info.setRawBody(body);
} catch (Exception e) {
log.warn("XML parse failed: {}", e.getMessage());
}
return info;
}
private String getTagValue(Element parent, String tagName) {
NodeList nodeList = parent.getElementsByTagName(tagName);
if (nodeList.getLength() == 0) {
return null;
}
Node node = nodeList.item(0);
return node.getTextContent();
}
private static class CallbackInfo {
private String corpId;
private String event;
private String rawBody;
public String getCorpId() {
return corpId;
}
public void setCorpId(String corpId) {
this.corpId = corpId;
}
public String getEvent() {
return event;
}
public void setEvent(String event) {
this.event = event;
}
public String getRawBody() {
return rawBody;
}
public void setRawBody(String rawBody) {
this.rawBody = rawBody;
}
}
}

View File

@@ -0,0 +1,44 @@
package com.sino.mci.channel.messaging;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 企微会话存档拉取任务 RabbitMQ 配置。
*/
@Configuration
public class RabbitConfig {
public static final String SESSION_ARCHIVE_QUEUE = "mci.wecom.session_archive.pull";
public static final String SESSION_ARCHIVE_EXCHANGE = "mci.wecom";
public static final String SESSION_ARCHIVE_ROUTING_KEY = "session_archive.pull";
@Bean
public Queue sessionArchiveQueue() {
return new Queue(SESSION_ARCHIVE_QUEUE, true);
}
@Bean
public TopicExchange sessionArchiveExchange() {
return new TopicExchange(SESSION_ARCHIVE_EXCHANGE, true, false);
}
@Bean
public Binding sessionArchiveBinding(Queue sessionArchiveQueue, TopicExchange sessionArchiveExchange) {
return BindingBuilder.bind(sessionArchiveQueue).to(sessionArchiveExchange).with(SESSION_ARCHIVE_ROUTING_KEY);
}
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
RabbitTemplate template = new RabbitTemplate(connectionFactory);
template.setMessageConverter(new Jackson2JsonMessageConverter());
return template;
}
}

View File

@@ -0,0 +1,24 @@
package com.sino.mci.channel.messaging;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* 触发企微会话存档拉取任务的命令消息。
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SessionArchivePullCommand implements Serializable {
private static final long serialVersionUID = 1L;
private Long channelSubjectId;
private String tenantCode;
private String corpId;
private String secret;
private String privateKey;
}

View File

@@ -0,0 +1,181 @@
package com.sino.mci.channel.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sino.mci.channel.messaging.SessionArchivePullCommand;
import com.sino.mci.channel.wecom.archive.FinanceSdkClient;
import com.sino.mci.channel.wecom.archive.model.FinanceMessage;
import com.sino.mci.inbound.entity.MessageEvent;
import com.sino.mci.inbound.repository.MessageEventMapper;
import com.sino.mci.message.entity.MessageMedia;
import com.sino.mci.message.repository.MessageMediaMapper;
import com.sino.mci.shared.util.JsonUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* 企微会话存档消息拉取与落库服务。
*
* <p>消费 RabbitMQ 命令,按渠道主体维度使用 Redis 缓存 seq 游标,调用 Finance SDK 拉取、解密,
* 并将消息写入 {@code mci_message_event},媒体文件写入 {@code mci_message_media}。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SessionArchivePullService {
private static final String SEQ_KEY_PREFIX = "wecom:session_archive:seq:";
private static final int DEFAULT_LIMIT = 100;
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final FinanceSdkClient financeSdkClient;
private final StringRedisTemplate redisTemplate;
private final MessageEventMapper messageEventMapper;
private final MessageMediaMapper messageMediaMapper;
/**
* 消费会话存档拉取命令。
*/
@RabbitListener(queues = "${sino.channel.wecom.session-archive.queue:mci.wecom.session_archive.pull}")
@Transactional
public void onPullCommand(SessionArchivePullCommand command) {
log.info("[SessionArchive] 开始拉取会话存档 subjectId={}, corpId={}",
command.getChannelSubjectId(), command.getCorpId());
if (!StringUtils.hasText(command.getCorpId()) || !StringUtils.hasText(command.getSecret())) {
log.warn("[SessionArchive] 缺少 corpId 或 secret跳过拉取");
return;
}
if (!financeSdkClient.init(command.getCorpId(), command.getSecret())) {
log.warn("[SessionArchive] Finance SDK 初始化失败 corpId={}", command.getCorpId());
return;
}
long currentSeq = readCurrentSeq(command.getChannelSubjectId());
List<FinanceMessage> messages = financeSdkClient.getChatData(currentSeq, DEFAULT_LIMIT);
if (messages == null || messages.isEmpty()) {
log.info("[SessionArchive] 无新消息 subjectId={}, seq={}", command.getChannelSubjectId(), currentSeq);
return;
}
long maxSeq = currentSeq;
for (FinanceMessage msg : messages) {
String decryptedJson = decryptMessage(command, msg);
if (!StringUtils.hasText(decryptedJson)) {
log.warn("[SessionArchive] 解密失败 seq={}, msgId={}", msg.getSeq(), msg.getMsgId());
continue;
}
saveMessageEvent(command, msg, decryptedJson);
saveMediaIfNeeded(command, msg, decryptedJson);
maxSeq = Math.max(maxSeq, msg.getSeq() + 1);
}
updateSeq(command.getChannelSubjectId(), maxSeq);
log.info("[SessionArchive] 拉取完成 subjectId={}, messages={}, nextSeq={}",
command.getChannelSubjectId(), messages.size(), maxSeq);
}
private long readCurrentSeq(Long subjectId) {
String key = seqKey(subjectId);
String value = redisTemplate.opsForValue().get(key);
if (!StringUtils.hasText(value)) {
return 0L;
}
try {
return Long.parseLong(value);
} catch (NumberFormatException e) {
log.warn("[SessionArchive] seq 缓存格式异常,重置为 0 key={}", key);
return 0L;
}
}
private void updateSeq(Long subjectId, long seq) {
redisTemplate.opsForValue().set(seqKey(subjectId), String.valueOf(seq));
}
private String seqKey(Long subjectId) {
return SEQ_KEY_PREFIX + subjectId;
}
private String decryptMessage(SessionArchivePullCommand command, FinanceMessage msg) {
// 模拟 RSA 解密 encrypt_random_key真实环境应使用 command.getPrivateKey() 解密
String encryptKey = msg.getEncryptRandomKey();
return financeSdkClient.decryptData(encryptKey, msg.getEncryptChatMsg());
}
private void saveMessageEvent(SessionArchivePullCommand command, FinanceMessage msg, String decryptedJson) {
MessageEvent event = new MessageEvent();
event.setEventId(msg.getMsgId());
event.setTenantCode(command.getTenantCode());
event.setChannelType("wecom");
event.setChannelSubjectId(command.getChannelSubjectId());
event.setDirection(1);
event.setEventType("message");
event.setMessageSource("session_archive");
event.setContent(decryptedJson);
event.setRawPayload(JsonUtils.toJson(Map.of(
"seq", msg.getSeq(),
"publicKeyVer", msg.getPublicKeyVer(),
"encryptRandomKey", msg.getEncryptRandomKey(),
"encryptChatMsg", msg.getEncryptChatMsg()
)));
event.setIsProcessed(0);
event.setStatus(1);
messageEventMapper.insert(event);
}
private void saveMediaIfNeeded(SessionArchivePullCommand command, FinanceMessage msg, String decryptedJson) {
try {
Map<String, Object> payload = OBJECT_MAPPER.readValue(decryptedJson, Map.class);
String msgType = Optional.ofNullable(payload.get("msgtype")).map(Object::toString).orElse("");
if (!isMediaType(msgType)) {
return;
}
@SuppressWarnings("unchecked")
Map<String, Object> mediaBody = (Map<String, Object>) payload.get(msgType);
if (mediaBody == null) {
return;
}
MessageMedia media = new MessageMedia();
media.setTenantCode(command.getTenantCode());
media.setMsgid(msg.getMsgId());
media.setSdkfileid(Optional.ofNullable(mediaBody.get("sdkfileid")).map(Object::toString).orElse(null));
media.setMediaType(msgType);
media.setFileName(Optional.ofNullable(mediaBody.get("filename")).map(Object::toString).orElse(null));
media.setFileSize(parseLong(mediaBody.get("filesize")));
media.setMd5sum(Optional.ofNullable(mediaBody.get("md5sum")).map(Object::toString).orElse(null));
media.setStatus(0);
messageMediaMapper.insert(media);
} catch (Exception e) {
log.warn("[SessionArchive] 解析媒体消息失败 msgId={}", msg.getMsgId(), e);
}
}
private boolean isMediaType(String msgType) {
return "image".equals(msgType) || "voice".equals(msgType)
|| "video".equals(msgType) || "file".equals(msgType) || "emotion".equals(msgType);
}
private Long parseLong(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number n) {
return n.longValue();
}
try {
return Long.parseLong(value.toString());
} catch (NumberFormatException e) {
return null;
}
}
}

View File

@@ -0,0 +1,226 @@
package com.sino.mci.channel.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sino.mci.channel.entity.ChannelSubject;
import com.sino.mci.channel.messaging.RabbitConfig;
import com.sino.mci.channel.messaging.SessionArchivePullCommand;
import com.sino.mci.channel.repository.ChannelSubjectMapper;
import com.sino.mci.channel.service.SessionArchivePullService;
import com.sino.mci.channel.wecom.archive.FinanceSdkClient;
import com.sino.mci.channel.wecom.archive.model.FinanceMessage;
import com.sino.mci.inbound.entity.MessageEvent;
import com.sino.mci.inbound.repository.MessageEventMapper;
import com.sino.mci.message.entity.MessageMedia;
import com.sino.mci.message.repository.MessageMediaMapper;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Bean;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static com.sino.mci.test.AuthTestHelper.createTenantAndLogin;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(properties = {
"spring.rabbitmq.listener.simple.auto-startup=false",
"spring.main.allow-bean-definition-overriding=true",
"sino.channel.wecom.session-archive.listener-enabled=false"
})
@Transactional
class WeComSessionArchiveTest {
private static final String CORP_ID = "ww-session-archive";
@Autowired
private MockMvc mockMvc;
@Autowired
private ChannelSubjectMapper channelSubjectMapper;
@Autowired
private MessageEventMapper messageEventMapper;
@Autowired
private MessageMediaMapper messageMediaMapper;
@Autowired
private SessionArchivePullService sessionArchivePullService;
@Autowired
private FinanceSdkClient financeSdkClient;
@Autowired
private RabbitTemplate rabbitTemplate;
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void shouldReceiveMsgAuditNotifyAndPublishPullCommand() throws Exception {
ChannelSubject subject = createSessionArchiveSubject();
String xml = """
<xml>
<ToUserName><![CDATA[%s]]></ToUserName>
<FromUserName><![CDATA[sys]]></FromUserName>
<CreateTime>1700000000</CreateTime>
<MsgType><![CDATA[event]]></MsgType>
<Event><![CDATA[msgaudit_notify]]></Event>
</xml>
""".formatted(CORP_ID);
mockMvc.perform(post("/msg-platform/callback/wecom")
.contextPath("/msg-platform")
.contentType(MediaType.APPLICATION_XML)
.content(xml))
.andExpect(status().isOk())
.andExpect(content().string("success"));
ArgumentCaptor<SessionArchivePullCommand> captor = ArgumentCaptor.forClass(SessionArchivePullCommand.class);
verify(rabbitTemplate).convertAndSend(eq(RabbitConfig.SESSION_ARCHIVE_EXCHANGE),
eq(RabbitConfig.SESSION_ARCHIVE_ROUTING_KEY), captor.capture());
SessionArchivePullCommand command = captor.getValue();
assertThat(command.getChannelSubjectId()).isEqualTo(subject.getId());
assertThat(command.getCorpId()).isEqualTo(CORP_ID);
assertThat(command.getTenantCode()).isEqualTo(subject.getTenantCode());
}
@Test
void shouldPullAndSaveMessageEvent() throws Exception {
ChannelSubject subject = createSessionArchiveSubject();
when(financeSdkClient.init(anyString(), anyString())).thenReturn(true);
when(financeSdkClient.getChatData(anyLong(), anyInt())).thenReturn(List.of(
FinanceMessage.builder()
.seq(100L)
.msgId("msg-100")
.publicKeyVer(1)
.encryptRandomKey("random-100")
.encryptChatMsg("chat-100")
.build()
));
when(financeSdkClient.decryptData(anyString(), anyString())).thenReturn(
objectMapper.writeValueAsString(Map.of(
"msgid", "msg-100",
"msgtype", "text",
"text", Map.of("content", "hello")
))
);
SessionArchivePullCommand command = new SessionArchivePullCommand();
command.setChannelSubjectId(subject.getId());
command.setTenantCode(subject.getTenantCode());
command.setCorpId(subject.getCorpId());
command.setSecret(subject.getCorpSecret());
command.setPrivateKey(subject.getSessionArchivePrivateKey());
sessionArchivePullService.onPullCommand(command);
MessageEvent event = messageEventMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<MessageEvent>()
.eq(MessageEvent::getEventId, "msg-100"));
assertThat(event).isNotNull();
assertThat(event.getMessageSource()).isEqualTo("session_archive");
assertThat(event.getChannelSubjectId()).isEqualTo(subject.getId());
assertThat(event.getChannelType()).isEqualTo("wecom");
assertThat(event.getContent()).contains("hello");
}
@Test
void shouldSaveMediaRecordForImageMessage() throws Exception {
ChannelSubject subject = createSessionArchiveSubject();
when(financeSdkClient.init(anyString(), anyString())).thenReturn(true);
when(financeSdkClient.getChatData(anyLong(), anyInt())).thenReturn(List.of(
FinanceMessage.builder()
.seq(200L)
.msgId("msg-200")
.publicKeyVer(1)
.encryptRandomKey("random-200")
.encryptChatMsg("chat-200")
.build()
));
when(financeSdkClient.decryptData(anyString(), anyString())).thenReturn(
objectMapper.writeValueAsString(Map.of(
"msgid", "msg-200",
"msgtype", "image",
"image", Map.of(
"sdkfileid", "sdkfileid-200",
"filesize", 1024,
"md5sum", "abc123"
)
))
);
SessionArchivePullCommand command = new SessionArchivePullCommand();
command.setChannelSubjectId(subject.getId());
command.setTenantCode(subject.getTenantCode());
command.setCorpId(subject.getCorpId());
command.setSecret(subject.getCorpSecret());
command.setPrivateKey(subject.getSessionArchivePrivateKey());
sessionArchivePullService.onPullCommand(command);
MessageMedia media = messageMediaMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<MessageMedia>()
.eq(MessageMedia::getMsgid, "msg-200"));
assertThat(media).isNotNull();
assertThat(media.getSdkfileid()).isEqualTo("sdkfileid-200");
assertThat(media.getMediaType()).isEqualTo("image");
assertThat(media.getStatus()).isEqualTo(0);
assertThat(media.getFileSize()).isEqualTo(1024L);
}
private ChannelSubject createSessionArchiveSubject() throws Exception {
String tenantCode = "sa-" + UUID.randomUUID().toString().substring(0, 8);
createTenantAndLogin(mockMvc, tenantCode, "会话存档租户");
ChannelSubject subject = new ChannelSubject();
subject.setTenantCode(tenantCode);
subject.setChannelType("wecom");
subject.setSubjectCode("archive");
subject.setSubjectName("会话存档主体");
subject.setCorpId(CORP_ID);
subject.setCorpSecret("secret-" + UUID.randomUUID());
subject.setSessionArchiveEnabled(1);
subject.setSessionArchivePrivateKey("private-key");
subject.setStatus(1);
channelSubjectMapper.insert(subject);
return subject;
}
@TestConfiguration
static class MockConfig {
@Bean
public FinanceSdkClient financeSdkClient() {
return mock(FinanceSdkClient.class);
}
@Bean
public RabbitTemplate rabbitTemplate() {
return mock(RabbitTemplate.class);
}
}
}