diff --git a/backend/mci-server/src/test/java/com/sino/mci/send/service/SendRoutingIntegrationTest.java b/backend/mci-server/src/test/java/com/sino/mci/send/service/SendRoutingIntegrationTest.java
new file mode 100644
index 0000000..a249a87
--- /dev/null
+++ b/backend/mci-server/src/test/java/com/sino/mci/send/service/SendRoutingIntegrationTest.java
@@ -0,0 +1,327 @@
+package com.sino.mci.send.service;
+
+import com.sino.mci.channel.common.model.AccountType;
+import com.sino.mci.channel.common.model.ChannelType;
+import com.sino.mci.channel.entity.ChannelAccount;
+import com.sino.mci.channel.entity.ChannelAccountConversation;
+import com.sino.mci.channel.entity.ChannelSubject;
+import com.sino.mci.channel.entity.Conversation;
+import com.sino.mci.channel.repository.ChannelAccountConversationMapper;
+import com.sino.mci.channel.repository.ChannelAccountMapper;
+import com.sino.mci.channel.repository.ChannelSubjectMapper;
+import com.sino.mci.channel.repository.ConversationMapper;
+import com.sino.mci.crm.entity.ChannelIdentity;
+import com.sino.mci.crm.entity.Person;
+import com.sino.mci.crm.repository.ChannelIdentityMapper;
+import com.sino.mci.crm.repository.PersonMapper;
+import com.sino.mci.send.entity.SendRecord;
+import com.sino.mci.send.repository.SendRecordMapper;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+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.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
+
+import static com.sino.mci.test.AuthTestHelper.createTenantAndLogin;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * 基于会话场景与目标类型的发送路由集成测试。
+ *
+ *
覆盖自动路由策略:
+ *
+ * - 内部群(conversation_scene = 1)→ WECOM / WECOM_BOT
+ * - 外部群(conversation_scene = 2)→ WECHAT_PERSONAL
+ * - 内部人员 → WECOM_AGENT
+ * - 外部人员 → WECOM_AGENT
+ *
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@Transactional
+@TestPropertySource(properties = {
+ "spring.rabbitmq.listener.simple.auto-startup=false"
+})
+class SendRoutingIntegrationTest {
+
+ private static final String CHANNEL_TYPE_WECOM = ChannelType.WECOM.name().toLowerCase();
+ private static final String CHANNEL_TYPE_WECHAT_PERSONAL = ChannelType.WECHAT_PERSONAL.name().toLowerCase();
+
+ private static final int CONVERSATION_SCENE_INTERNAL = 1;
+ private static final int CONVERSATION_SCENE_EXTERNAL = 2;
+
+ private static final int PERSON_TYPE_INTERNAL = 1;
+ private static final int PERSON_TYPE_EXTERNAL = 3;
+
+ private static final int STATUS_ENABLED = 1;
+ private static final int BINDING_TYPE_SEND_PRIMARY = 1;
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ChannelSubjectMapper subjectMapper;
+
+ @Autowired
+ private ChannelAccountMapper accountMapper;
+
+ @Autowired
+ private ConversationMapper conversationMapper;
+
+ @Autowired
+ private ChannelAccountConversationMapper accountConversationMapper;
+
+ @Autowired
+ private PersonMapper personMapper;
+
+ @Autowired
+ private ChannelIdentityMapper channelIdentityMapper;
+
+ @Autowired
+ private SendRecordMapper sendRecordMapper;
+
+ @Test
+ void shouldRouteInternalGroupToWeComBot() throws Exception {
+ String tenantCode = "routing-int-group-" + UUID.randomUUID().toString().substring(0, 8);
+ String authToken = createTenantAndLogin(mockMvc, tenantCode, "内部群路由测试");
+
+ ChannelSubject subject = createWeComSubject(tenantCode);
+ ChannelAccount botAccount = createWeComBotAccount(tenantCode, subject.getId());
+ Conversation conversation = createConversation(tenantCode, subject.getId(),
+ CONVERSATION_SCENE_INTERNAL, "内部群");
+ bindAccountToConversation(botAccount.getId(), conversation.getId());
+
+ String taskId = sendTestMessage(authToken, tenantCode,
+ buildConversationPayload(conversation.getId()));
+
+ List records = sendRecordMapper.selectList(
+ new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper()
+ .eq(SendRecord::getTenantCode, tenantCode)
+ .eq(SendRecord::getTaskId, taskId));
+
+ assertThat(records).hasSize(1);
+ SendRecord record = records.get(0);
+ assertThat(record.getChannelType()).isEqualTo(CHANNEL_TYPE_WECOM);
+ assertThat(record.getAccountId()).isEqualTo(botAccount.getId());
+ assertThat(record.getConversationId()).isEqualTo(conversation.getId());
+ }
+
+ @Test
+ void shouldRouteExternalGroupToWeChatPersonal() throws Exception {
+ String tenantCode = "routing-ext-group-" + UUID.randomUUID().toString().substring(0, 8);
+ String authToken = createTenantAndLogin(mockMvc, tenantCode, "外部群路由测试");
+
+ ChannelSubject subject = createWeComSubject(tenantCode);
+ ChannelAccount pywechatAccount = createWeChatPersonalAccount(tenantCode, subject.getId());
+ Conversation conversation = createConversation(tenantCode, subject.getId(),
+ CONVERSATION_SCENE_EXTERNAL, "外部群");
+ bindAccountToConversation(pywechatAccount.getId(), conversation.getId());
+
+ String taskId = sendTestMessage(authToken, tenantCode,
+ buildConversationPayload(conversation.getId()));
+
+ List records = sendRecordMapper.selectList(
+ new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper()
+ .eq(SendRecord::getTenantCode, tenantCode)
+ .eq(SendRecord::getTaskId, taskId));
+
+ assertThat(records).hasSize(1);
+ SendRecord record = records.get(0);
+ assertThat(record.getChannelType()).isEqualTo(CHANNEL_TYPE_WECHAT_PERSONAL);
+ assertThat(record.getAccountId()).isEqualTo(pywechatAccount.getId());
+ assertThat(record.getConversationId()).isEqualTo(conversation.getId());
+ }
+
+ @Test
+ void shouldRouteInternalPersonToWeComAgent() throws Exception {
+ String tenantCode = "routing-int-person-" + UUID.randomUUID().toString().substring(0, 8);
+ String authToken = createTenantAndLogin(mockMvc, tenantCode, "内部人员路由测试");
+
+ ChannelSubject subject = createWeComSubject(tenantCode);
+ ChannelAccount agentAccount = createWeComAgentAccount(tenantCode, subject.getId());
+ Person person = createPerson(tenantCode, "int-user", "内部人员", PERSON_TYPE_INTERNAL);
+ createChannelIdentity(tenantCode, person.getId(), subject.getId(), "int-user");
+
+ String taskId = sendTestMessage(authToken, tenantCode,
+ buildPersonPayload(person.getId()));
+
+ List records = sendRecordMapper.selectList(
+ new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper()
+ .eq(SendRecord::getTenantCode, tenantCode)
+ .eq(SendRecord::getTaskId, taskId));
+
+ assertThat(records).hasSize(1);
+ SendRecord record = records.get(0);
+ assertThat(record.getChannelType()).isEqualTo(CHANNEL_TYPE_WECOM);
+ assertThat(record.getAccountId()).isEqualTo(agentAccount.getId());
+ assertThat(record.getPersonId()).isEqualTo(person.getId());
+ }
+
+ @Test
+ void shouldRouteExternalPersonToWeComAgent() throws Exception {
+ String tenantCode = "routing-ext-person-" + UUID.randomUUID().toString().substring(0, 8);
+ String authToken = createTenantAndLogin(mockMvc, tenantCode, "外部人员路由测试");
+
+ ChannelSubject subject = createWeComSubject(tenantCode);
+ ChannelAccount agentAccount = createWeComAgentAccount(tenantCode, subject.getId());
+ Person person = createPerson(tenantCode, "ext-user", "外部人员", PERSON_TYPE_EXTERNAL);
+ createChannelIdentity(tenantCode, person.getId(), subject.getId(), "ext-user");
+
+ String taskId = sendTestMessage(authToken, tenantCode,
+ buildPersonPayload(person.getId()));
+
+ List records = sendRecordMapper.selectList(
+ new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper()
+ .eq(SendRecord::getTenantCode, tenantCode)
+ .eq(SendRecord::getTaskId, taskId));
+
+ assertThat(records).hasSize(1);
+ SendRecord record = records.get(0);
+ assertThat(record.getChannelType()).isEqualTo(CHANNEL_TYPE_WECOM);
+ assertThat(record.getAccountId()).isEqualTo(agentAccount.getId());
+ assertThat(record.getPersonId()).isEqualTo(person.getId());
+ }
+
+ private String sendTestMessage(String authToken, String tenantCode, String payload) throws Exception {
+ String response = mockMvc.perform(post("/msg-platform/api/v1/send/test")
+ .contextPath("/msg-platform")
+ .header("Authorization", authToken)
+ .header("X-Tenant-Code", tenantCode)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.totalCount").value(1))
+ .andExpect(jsonPath("$.data.status").value("simulated"))
+ .andReturn().getResponse().getContentAsString();
+ return com.jayway.jsonpath.JsonPath.read(response, "$.data.taskId");
+ }
+
+ private String buildConversationPayload(Long conversationId) {
+ return String.format(
+ "{\"targetType\":\"conversation\",\"targetValue\":{\"conversation_id\":%d},\"contentType\":\"text\",\"content\":{\"text\":\"hello\"},\"channelStrategy\":{\"channel_type\":\"wecom\",\"strategy\":\"primary\"}}",
+ conversationId);
+ }
+
+ private String buildPersonPayload(Long personId) {
+ return String.format(
+ "{\"targetType\":\"person\",\"targetValue\":{\"person_id\":%d},\"contentType\":\"text\",\"content\":{\"text\":\"hello\"},\"channelStrategy\":{\"channel_type\":\"wecom\"}}",
+ personId);
+ }
+
+ private ChannelSubject createWeComSubject(String tenantCode) {
+ ChannelSubject subject = new ChannelSubject();
+ subject.setTenantCode(tenantCode);
+ subject.setChannelType(CHANNEL_TYPE_WECOM);
+ subject.setSubjectCode("sub-" + UUID.randomUUID().toString().substring(0, 8));
+ subject.setSubjectName("测试主体");
+ subject.setCorpId("corp-" + UUID.randomUUID().toString().substring(0, 8));
+ subject.setAgentId("1000002");
+ subject.setAgentSecret("secret-" + UUID.randomUUID());
+ subject.setStatus(STATUS_ENABLED);
+ subjectMapper.insert(subject);
+ return subject;
+ }
+
+ private ChannelAccount createWeComBotAccount(String tenantCode, Long subjectId) {
+ ChannelAccount account = new ChannelAccount();
+ account.setTenantCode(tenantCode);
+ account.setAccountType(AccountType.WECOM_BOT.getCode());
+ account.setChannelType(CHANNEL_TYPE_WECOM);
+ account.setSubjectId(subjectId);
+ account.setAccountId("bot-" + UUID.randomUUID().toString().substring(0, 8));
+ account.setAccountName("企微群机器人");
+ account.setStatus(STATUS_ENABLED);
+ accountMapper.insert(account);
+ return account;
+ }
+
+ private ChannelAccount createWeComAgentAccount(String tenantCode, Long subjectId) {
+ ChannelAccount account = new ChannelAccount();
+ account.setTenantCode(tenantCode);
+ account.setAccountType(AccountType.WECOM_AGENT.getCode());
+ account.setChannelType(CHANNEL_TYPE_WECOM);
+ account.setSubjectId(subjectId);
+ account.setAccountId("agent-" + UUID.randomUUID().toString().substring(0, 8));
+ account.setAccountName("企微应用");
+ account.setStatus(STATUS_ENABLED);
+ accountMapper.insert(account);
+ return account;
+ }
+
+ private ChannelAccount createWeChatPersonalAccount(String tenantCode, Long subjectId) {
+ ChannelAccount account = new ChannelAccount();
+ account.setTenantCode(tenantCode);
+ account.setAccountType(AccountType.WECHAT_PERSONAL.getCode());
+ account.setChannelType(CHANNEL_TYPE_WECHAT_PERSONAL);
+ account.setSubjectId(subjectId);
+ account.setAccountId("wxid-" + UUID.randomUUID().toString().substring(0, 8));
+ account.setAccountName("pywechat 个人号");
+ account.setStatus(STATUS_ENABLED);
+ account.setOnlineStatus(STATUS_ENABLED);
+ account.setRiskLevel(1);
+ account.setLastHeartbeat(LocalDateTime.now());
+ accountMapper.insert(account);
+ return account;
+ }
+
+ private Conversation createConversation(String tenantCode, Long subjectId,
+ Integer conversationScene, String conversationName) {
+ Conversation conversation = new Conversation();
+ conversation.setTenantCode(tenantCode);
+ conversation.setConversationType(2);
+ conversation.setChannelType(CHANNEL_TYPE_WECOM);
+ conversation.setChannelConversationId("chat-" + UUID.randomUUID().toString().substring(0, 8));
+ conversation.setSubjectId(subjectId);
+ conversation.setConversationName(conversationName);
+ conversation.setConversationScene(conversationScene);
+ conversation.setCreateSource(1);
+ conversation.setStatus(STATUS_ENABLED);
+ conversationMapper.insert(conversation);
+ return conversation;
+ }
+
+ private void bindAccountToConversation(Long accountId, Long conversationId) {
+ ChannelAccountConversation binding = new ChannelAccountConversation();
+ binding.setAccountId(accountId);
+ binding.setConversationId(conversationId);
+ binding.setBindingType(BINDING_TYPE_SEND_PRIMARY);
+ binding.setSortOrder(1);
+ binding.setStatus(STATUS_ENABLED);
+ accountConversationMapper.insert(binding);
+ }
+
+ private Person createPerson(String tenantCode, String personCode, String personName, Integer personType) {
+ Person person = new Person();
+ person.setTenantCode(tenantCode);
+ person.setPersonCode(personCode + "-" + UUID.randomUUID().toString().substring(0, 8));
+ person.setPersonName(personName);
+ person.setPhone("13800138000");
+ person.setPersonType(personType);
+ person.setStatus(STATUS_ENABLED);
+ personMapper.insert(person);
+ return person;
+ }
+
+ private void createChannelIdentity(String tenantCode, Long personId, Long subjectId, String channelUserId) {
+ ChannelIdentity identity = new ChannelIdentity();
+ identity.setTenantCode(tenantCode);
+ identity.setPersonId(personId);
+ identity.setChannelType(CHANNEL_TYPE_WECOM);
+ identity.setChannelUserId(channelUserId + "-" + UUID.randomUUID().toString().substring(0, 8));
+ identity.setChannelUserName("测试用户");
+ identity.setSubjectId(subjectId);
+ identity.setStatus(STATUS_ENABLED);
+ channelIdentityMapper.insert(identity);
+ }
+}