fix(channel/send): restore AccountSelector delegation and validate conversation account binding
- SendService.selectSendAccount now delegates to AccountSelector with extractStrategy(channelStrategy) instead of an inline filter that only checked tenant/status, restoring risk/online/heartbeat checks. - ConversationService.bindAccount validates the account exists, belongs to the same tenant, and matches the conversation channel type before creating the binding. - Add unit tests for ConversationService.bindAccount validation and SendService.resolveConversationTarget strategy/health behaviors.
This commit is contained in:
@@ -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<Conversation> 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<ChannelAccountConversation>()
|
||||
|
||||
@@ -691,25 +691,11 @@ public class SendService {
|
||||
return value != null ? String.valueOf(value) : null;
|
||||
}
|
||||
|
||||
private Long selectSendAccount(String tenantCode, Long conversationId, ChannelType channelType, Map<String, Object> channelStrategy) {
|
||||
List<Integer> bindingTypes = List.of(1, 2);
|
||||
for (Integer bindingType : bindingTypes) {
|
||||
List<ChannelAccountConversation> bindings = channelAccountConversationMapper.selectList(
|
||||
new LambdaQueryWrapper<ChannelAccountConversation>()
|
||||
.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<String, Object> channelStrategy) {
|
||||
List<Long> accounts = accountSelector.selectSendAccounts(tenantCode, conversationId, channelType,
|
||||
extractStrategy(channelStrategy));
|
||||
return CollectionUtils.isEmpty(accounts) ? null : accounts.get(0);
|
||||
}
|
||||
|
||||
private Long selectDefaultAccount(String tenantCode, ChannelType channelType) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<SendTarget> 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<SendTarget> 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<SendTarget> targets = sendService.resolveTestTargets("tenant", createConversationRequest("backup"));
|
||||
|
||||
assertEquals(1, targets.size());
|
||||
assertEquals(backupAccountId, targets.get(0).getAccountId());
|
||||
|
||||
ArgumentCaptor<String> 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<String, Object> targetValue = new HashMap<>();
|
||||
targetValue.put("conversation_id", 1L);
|
||||
request.setTargetValue(targetValue);
|
||||
request.setContentType("text");
|
||||
request.setContent(Collections.singletonMap("text", "hello"));
|
||||
|
||||
Map<String, Object> 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user