diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/service/ConversationService.java b/backend/mci-server/src/main/java/com/sino/mci/channel/service/ConversationService.java index f79cb9a..47d6c2d 100644 --- a/backend/mci-server/src/main/java/com/sino/mci/channel/service/ConversationService.java +++ b/backend/mci-server/src/main/java/com/sino/mci/channel/service/ConversationService.java @@ -5,10 +5,12 @@ import com.sino.mci.channel.dto.BindConversationAccountRequest; import com.sino.mci.channel.dto.BindConversationTagsRequest; import com.sino.mci.channel.dto.CreateConversationRequest; import com.sino.mci.channel.dto.UpdateConversationRequest; +import com.sino.mci.channel.entity.ChannelAccount; import com.sino.mci.channel.entity.ChannelAccountConversation; import com.sino.mci.channel.entity.Conversation; import com.sino.mci.channel.entity.ConversationTagBinding; import com.sino.mci.channel.repository.ChannelAccountConversationMapper; +import com.sino.mci.channel.repository.ChannelAccountMapper; import com.sino.mci.channel.repository.ConversationMapper; import com.sino.mci.channel.repository.ConversationTagBindingMapper; import com.sino.mci.crm.entity.Tag; @@ -31,6 +33,7 @@ public class ConversationService { private final ChannelAccountConversationMapper bindingMapper; private final ConversationTagBindingMapper conversationTagBindingMapper; private final TagMapper tagMapper; + private final ChannelAccountMapper accountMapper; public List listConversations(String tenantCode) { return conversationMapper.selectList( @@ -102,7 +105,15 @@ public class ConversationService { @Transactional public void bindAccount(String tenantCode, Long conversationId, BindConversationAccountRequest request) { - getConversation(tenantCode, conversationId); + Conversation conversation = getConversation(tenantCode, conversationId); + + ChannelAccount account = accountMapper.selectById(request.getAccountId()); + if (account == null || !tenantCode.equals(account.getTenantCode())) { + throw new BusinessException("账号不存在或不在当前租户"); + } + if (!conversation.getChannelType().equalsIgnoreCase(account.getChannelType())) { + throw new BusinessException("账号渠道类型与会话不一致"); + } bindingMapper.delete( new LambdaQueryWrapper() diff --git a/backend/mci-server/src/main/java/com/sino/mci/send/service/SendService.java b/backend/mci-server/src/main/java/com/sino/mci/send/service/SendService.java index 3195d0d..37aa0bf 100644 --- a/backend/mci-server/src/main/java/com/sino/mci/send/service/SendService.java +++ b/backend/mci-server/src/main/java/com/sino/mci/send/service/SendService.java @@ -691,25 +691,11 @@ public class SendService { return value != null ? String.valueOf(value) : null; } - private Long selectSendAccount(String tenantCode, Long conversationId, ChannelType channelType, Map channelStrategy) { - List bindingTypes = List.of(1, 2); - for (Integer bindingType : bindingTypes) { - List bindings = channelAccountConversationMapper.selectList( - new LambdaQueryWrapper() - .eq(ChannelAccountConversation::getConversationId, conversationId) - .eq(ChannelAccountConversation::getBindingType, bindingType) - .eq(ChannelAccountConversation::getStatus, STATUS_ENABLED) - .orderByAsc(ChannelAccountConversation::getSortOrder)); - for (ChannelAccountConversation binding : bindings) { - ChannelAccount account = accountMapper.selectById(binding.getAccountId()); - if (account != null && tenantCode.equals(account.getTenantCode()) - && channelType.name().equalsIgnoreCase(account.getChannelType()) - && account.getStatus() != null && account.getStatus() == STATUS_ENABLED) { - return account.getId(); - } - } - } - return null; + private Long selectSendAccount(String tenantCode, Long conversationId, ChannelType channelType, + Map channelStrategy) { + List accounts = accountSelector.selectSendAccounts(tenantCode, conversationId, channelType, + extractStrategy(channelStrategy)); + return CollectionUtils.isEmpty(accounts) ? null : accounts.get(0); } private Long selectDefaultAccount(String tenantCode, ChannelType channelType) { diff --git a/backend/mci-server/src/test/java/com/sino/mci/channel/service/ConversationServiceTest.java b/backend/mci-server/src/test/java/com/sino/mci/channel/service/ConversationServiceTest.java new file mode 100644 index 0000000..ed4a385 --- /dev/null +++ b/backend/mci-server/src/test/java/com/sino/mci/channel/service/ConversationServiceTest.java @@ -0,0 +1,112 @@ +package com.sino.mci.channel.service; + +import com.sino.mci.channel.dto.BindConversationAccountRequest; +import com.sino.mci.channel.entity.ChannelAccount; +import com.sino.mci.channel.entity.ChannelAccountConversation; +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.ConversationMapper; +import com.sino.mci.channel.repository.ConversationTagBindingMapper; +import com.sino.mci.crm.repository.TagMapper; +import com.sino.mci.exception.BusinessException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ConversationServiceTest { + + @Mock + private ConversationMapper conversationMapper; + @Mock + private ChannelAccountConversationMapper bindingMapper; + @Mock + private ConversationTagBindingMapper conversationTagBindingMapper; + @Mock + private TagMapper tagMapper; + @Mock + private ChannelAccountMapper accountMapper; + + @InjectMocks + private ConversationService conversationService; + + @Test + void bindAccount_rejectsAccountFromDifferentTenant() { + Conversation conversation = createConversation(); + ChannelAccount account = createAccount(10L, "wecom"); + account.setTenantCode("other-tenant"); + + when(conversationMapper.selectById(1L)).thenReturn(conversation); + when(accountMapper.selectById(10L)).thenReturn(account); + + BindConversationAccountRequest request = createBindRequest(10L); + + BusinessException ex = assertThrows(BusinessException.class, + () -> conversationService.bindAccount("tenant", 1L, request)); + assertEquals("账号不存在或不在当前租户", ex.getMessage()); + } + + @Test + void bindAccount_rejectsAccountWithMismatchedChannelType() { + Conversation conversation = createConversation(); + ChannelAccount account = createAccount(10L, "wechat_personal"); + + when(conversationMapper.selectById(1L)).thenReturn(conversation); + when(accountMapper.selectById(10L)).thenReturn(account); + + BindConversationAccountRequest request = createBindRequest(10L); + + BusinessException ex = assertThrows(BusinessException.class, + () -> conversationService.bindAccount("tenant", 1L, request)); + assertEquals("账号渠道类型与会话不一致", ex.getMessage()); + } + + @Test + void bindAccount_succeedsWhenAccountIsValid() { + Conversation conversation = createConversation(); + ChannelAccount account = createAccount(10L, "wecom"); + + when(conversationMapper.selectById(1L)).thenReturn(conversation); + when(accountMapper.selectById(10L)).thenReturn(account); + + BindConversationAccountRequest request = createBindRequest(10L); + conversationService.bindAccount("tenant", 1L, request); + + verify(bindingMapper).insert(any(ChannelAccountConversation.class)); + } + + private Conversation createConversation() { + Conversation conversation = new Conversation(); + conversation.setId(1L); + conversation.setTenantCode("tenant"); + conversation.setChannelType("wecom"); + conversation.setStatus(1); + return conversation; + } + + private ChannelAccount createAccount(Long id, String channelType) { + ChannelAccount account = new ChannelAccount(); + account.setId(id); + account.setTenantCode("tenant"); + account.setChannelType(channelType); + account.setStatus(1); + return account; + } + + private BindConversationAccountRequest createBindRequest(Long accountId) { + BindConversationAccountRequest request = new BindConversationAccountRequest(); + request.setAccountId(accountId); + request.setBindingType(1); + request.setSortOrder(0); + return request; + } +} diff --git a/backend/mci-server/src/test/java/com/sino/mci/send/service/SendServiceResolveTargetTest.java b/backend/mci-server/src/test/java/com/sino/mci/send/service/SendServiceResolveTargetTest.java new file mode 100644 index 0000000..736b67d --- /dev/null +++ b/backend/mci-server/src/test/java/com/sino/mci/send/service/SendServiceResolveTargetTest.java @@ -0,0 +1,180 @@ +package com.sino.mci.send.service; + +import com.sino.mci.channel.adapter.registry.ChannelAdapterRegistry; +import com.sino.mci.channel.common.model.ChannelType; +import com.sino.mci.channel.entity.ChannelAccount; +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.ConversationMapper; +import com.sino.mci.channel.repository.ConversationTagBindingMapper; +import com.sino.mci.channel.service.AccountSelector; +import com.sino.mci.crm.repository.ChannelIdentityMapper; +import com.sino.mci.crm.repository.PersonMapper; +import com.sino.mci.crm.repository.PersonTagBindingMapper; +import com.sino.mci.crm.repository.TagMapper; +import com.sino.mci.exception.BusinessException; +import com.sino.mci.policy.engine.PolicyEngine; +import com.sino.mci.policy.repository.PolicyGroupMapper; +import com.sino.mci.send.dto.SendTestRequest; +import com.sino.mci.send.mq.SendMessageProducer; +import com.sino.mci.send.repository.SendRecordMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SendServiceResolveTargetTest { + + @Mock + private SendRecordMapper sendRecordMapper; + @Mock + private ConversationMapper conversationMapper; + @Mock + private ChannelAccountMapper accountMapper; + @Mock + private ChannelAccountConversationMapper channelAccountConversationMapper; + @Mock + private ChannelIdentityMapper channelIdentityMapper; + @Mock + private PersonMapper personMapper; + @Mock + private TagMapper tagMapper; + @Mock + private PersonTagBindingMapper personTagBindingMapper; + @Mock + private ConversationTagBindingMapper conversationTagBindingMapper; + @Mock + private ChannelAdapterRegistry adapterRegistry; + @Mock + private PolicyGroupMapper policyGroupMapper; + @Mock + private PolicyEngine policyEngine; + @Mock + private AccountSelector accountSelector; + @Mock + private SendMessageProducer sendMessageProducer; + + @InjectMocks + private SendService sendService; + + @Test + void resolveConversationTarget_selectsHealthyPrimaryAccount() { + Conversation conversation = createConversation(); + Long primaryAccountId = 10L; + + when(conversationMapper.selectById(1L)).thenReturn(conversation); + when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, "primary")) + .thenReturn(Collections.singletonList(primaryAccountId)); + when(accountMapper.selectById(primaryAccountId)).thenReturn(createAccount(primaryAccountId, "wecom", 1)); + + List targets = sendService.resolveTestTargets("tenant", createConversationRequest("primary")); + + assertEquals(1, targets.size()); + assertEquals(primaryAccountId, targets.get(0).getAccountId()); + assertEquals(ChannelType.WECOM, targets.get(0).getChannelType()); + } + + @Test + void resolveConversationTarget_rejectsUnhealthyOwnerAccount() { + Conversation conversation = createConversation(); + conversation.setOwnerAccountId(20L); + + when(conversationMapper.selectById(1L)).thenReturn(conversation); + when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, "primary")) + .thenReturn(Collections.emptyList()); + ChannelAccount disabledAccount = createAccount(20L, "wecom", 0); + when(accountMapper.selectById(20L)).thenReturn(disabledAccount); + + BusinessException ex = assertThrows(BusinessException.class, + () -> sendService.resolveTestTargets("tenant", createConversationRequest("primary"))); + assertEquals("会话没有可用的发送账号: 1", ex.getMessage()); + } + + @Test + void resolveConversationTarget_fallsBackToBackupWhenPrimaryUnhealthy() { + Conversation conversation = createConversation(); + Long backupAccountId = 20L; + + when(conversationMapper.selectById(1L)).thenReturn(conversation); + when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, "primary")) + .thenReturn(Collections.singletonList(backupAccountId)); + when(accountMapper.selectById(backupAccountId)).thenReturn(createAccount(backupAccountId, "wecom", 1)); + + List targets = sendService.resolveTestTargets("tenant", createConversationRequest("primary")); + + assertEquals(1, targets.size()); + assertEquals(backupAccountId, targets.get(0).getAccountId()); + } + + @Test + void resolveConversationTarget_respectsBackupStrategy() { + Conversation conversation = createConversation(); + Long backupAccountId = 20L; + + when(conversationMapper.selectById(1L)).thenReturn(conversation); + when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, "backup")) + .thenReturn(Collections.singletonList(backupAccountId)); + when(accountMapper.selectById(backupAccountId)).thenReturn(createAccount(backupAccountId, "wecom", 1)); + + List targets = sendService.resolveTestTargets("tenant", createConversationRequest("backup")); + + assertEquals(1, targets.size()); + assertEquals(backupAccountId, targets.get(0).getAccountId()); + + ArgumentCaptor strategyCaptor = ArgumentCaptor.forClass(String.class); + verify(accountSelector).selectSendAccounts(eq("tenant"), eq(1L), eq(ChannelType.WECOM), strategyCaptor.capture()); + assertEquals("backup", strategyCaptor.getValue()); + } + + private SendTestRequest createConversationRequest(String strategy) { + SendTestRequest request = new SendTestRequest(); + request.setTargetType("conversation"); + Map targetValue = new HashMap<>(); + targetValue.put("conversation_id", 1L); + request.setTargetValue(targetValue); + request.setContentType("text"); + request.setContent(Collections.singletonMap("text", "hello")); + + Map channelStrategy = new HashMap<>(); + channelStrategy.put("channel_type", "wecom"); + channelStrategy.put("strategy", strategy); + request.setChannelStrategy(channelStrategy); + return request; + } + + private Conversation createConversation() { + Conversation conversation = new Conversation(); + conversation.setId(1L); + conversation.setTenantCode("tenant"); + conversation.setChannelType("wecom"); + conversation.setChannelConversationId("conv-1"); + conversation.setStatus(1); + return conversation; + } + + private ChannelAccount createAccount(Long id, String channelType, int status) { + ChannelAccount account = new ChannelAccount(); + account.setId(id); + account.setTenantCode("tenant"); + account.setChannelType(channelType); + account.setStatus(status); + return account; + } +}