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:
2026-07-08 17:31:18 +08:00
parent 6288f0a305
commit ed7c64de7c
4 changed files with 309 additions and 20 deletions

View File

@@ -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>()

View File

@@ -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) {

View File

@@ -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;
}
}

View File

@@ -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;
}
}