feat(channel-wecom): 实现企微会话存档接收骨架(Finance SDK 接口 + 回调 + 异步拉取)
This commit is contained in:
@@ -23,11 +23,21 @@
|
|||||||
<groupId>org.springframework</groupId>
|
<groupId>org.springframework</groupId>
|
||||||
<artifactId>spring-context</artifactId>
|
<artifactId>spring-context</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-autoconfigure</artifactId>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.data</groupId>
|
<groupId>org.springframework.data</groupId>
|
||||||
<artifactId>spring-data-redis</artifactId>
|
<artifactId>spring-data-redis</artifactId>
|
||||||
<scope>provided</scope>
|
<scope>provided</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.github.binarywang</groupId>
|
<groupId>com.github.binarywang</groupId>
|
||||||
<artifactId>weixin-java-cp</artifactId>
|
<artifactId>weixin-java-cp</artifactId>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 绑定");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,16 +1,35 @@
|
|||||||
package com.sino.mci.channel.controller;
|
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 jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||||
import org.springframework.web.bind.annotation.*;
|
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;
|
import java.util.Enumeration;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/callback/wecom")
|
@RequestMapping("/callback/wecom")
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class WeComCallbackController {
|
public class WeComCallbackController {
|
||||||
|
|
||||||
|
private final ChannelSubjectMapper channelSubjectMapper;
|
||||||
|
private final RabbitTemplate rabbitTemplate;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public String verifyUrl(HttpServletRequest request) {
|
public String verifyUrl(HttpServletRequest request) {
|
||||||
log.info("WeCom callback URL verification: {}", request.getQueryString());
|
log.info("WeCom callback URL verification: {}", request.getQueryString());
|
||||||
@@ -26,7 +45,107 @@ public class WeComCallbackController {
|
|||||||
String name = headerNames.nextElement();
|
String name = headerNames.nextElement();
|
||||||
log.debug("Header {}: {}", name, request.getHeader(name));
|
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";
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user