fix(send): apply AccountSelector health checks to fallback owner account and add person-target validation tests

- Make AccountSelector.isHealthy public static so SendService can reuse it.
- Use isHealthy for the resolved/fallback conversation account instead of
  a weaker existence/status/channel check, covering risk level and PYWECHAT
  heartbeat/online status.
- Add null-safe channel-type comparison in resolvePersonTarget.
- Add @Min(0) to BindConversationAccountRequest.sortOrder.
- Rename misleading conversation fallback test and add tests for
  resolvePersonTarget disabled/cross-tenant/channel-mismatch cases.
- Remove unused java.util.Arrays import from test file.
This commit is contained in:
2026-07-08 17:42:51 +08:00
parent ed7c64de7c
commit fc6dbb2ca3
4 changed files with 75 additions and 7 deletions

View File

@@ -1,5 +1,6 @@
package com.sino.mci.channel.dto; package com.sino.mci.channel.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.Data; import lombok.Data;
import org.hibernate.validator.constraints.Range; import org.hibernate.validator.constraints.Range;
@@ -14,5 +15,6 @@ public class BindConversationAccountRequest {
@Range(min = 1, max = 4) @Range(min = 1, max = 4)
private Integer bindingType; private Integer bindingType;
@Min(0)
private Integer sortOrder; private Integer sortOrder;
} }

View File

@@ -71,7 +71,7 @@ public class AccountSelector {
return healthyAccountIds; return healthyAccountIds;
} }
private boolean isHealthy(ChannelAccount account, String tenantCode, ChannelType channelType) { public static boolean isHealthy(ChannelAccount account, String tenantCode, ChannelType channelType) {
if (account == null) { if (account == null) {
return false; return false;
} }

View File

@@ -336,9 +336,7 @@ public class SendService {
throw new BusinessException("会话未配置发送账号: " + conversationId); throw new BusinessException("会话未配置发送账号: " + conversationId);
} }
ChannelAccount account = accountMapper.selectById(accountId); ChannelAccount account = accountMapper.selectById(accountId);
if (account == null || !tenantCode.equals(account.getTenantCode()) if (!AccountSelector.isHealthy(account, tenantCode, channelType)) {
|| !channelType.name().equalsIgnoreCase(account.getChannelType())
|| account.getStatus() == null || account.getStatus() != STATUS_ENABLED) {
throw new BusinessException("会话没有可用的发送账号: " + conversationId); throw new BusinessException("会话没有可用的发送账号: " + conversationId);
} }
SendTarget target = new SendTarget(); SendTarget target = new SendTarget();
@@ -370,7 +368,7 @@ public class SendService {
|| account.getStatus() == null || account.getStatus() != STATUS_ENABLED) { || account.getStatus() == null || account.getStatus() != STATUS_ENABLED) {
throw new BusinessException("租户没有可用的发送账号: channel=" + channelType.name().toLowerCase()); throw new BusinessException("租户没有可用的发送账号: channel=" + channelType.name().toLowerCase());
} }
if (!channelType.name().equalsIgnoreCase(account.getChannelType())) { if (!Objects.equals(channelType.name().toLowerCase(), account.getChannelType())) {
throw new BusinessException("单聊发送账号渠道类型与目标身份不一致"); throw new BusinessException("单聊发送账号渠道类型与目标身份不一致");
} }
SendTarget target = new SendTarget(); SendTarget target = new SendTarget();

View File

@@ -10,6 +10,7 @@ import com.sino.mci.channel.repository.ConversationMapper;
import com.sino.mci.channel.repository.ConversationTagBindingMapper; import com.sino.mci.channel.repository.ConversationTagBindingMapper;
import com.sino.mci.channel.service.AccountSelector; import com.sino.mci.channel.service.AccountSelector;
import com.sino.mci.crm.repository.ChannelIdentityMapper; import com.sino.mci.crm.repository.ChannelIdentityMapper;
import com.sino.mci.crm.entity.ChannelIdentity;
import com.sino.mci.crm.repository.PersonMapper; import com.sino.mci.crm.repository.PersonMapper;
import com.sino.mci.crm.repository.PersonTagBindingMapper; import com.sino.mci.crm.repository.PersonTagBindingMapper;
import com.sino.mci.crm.repository.TagMapper; import com.sino.mci.crm.repository.TagMapper;
@@ -26,7 +27,6 @@ import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
@@ -108,7 +108,7 @@ class SendServiceResolveTargetTest {
} }
@Test @Test
void resolveConversationTarget_fallsBackToBackupWhenPrimaryUnhealthy() { void resolveConversationTarget_usesAccountReturnedBySelector() {
Conversation conversation = createConversation(); Conversation conversation = createConversation();
Long backupAccountId = 20L; Long backupAccountId = 20L;
@@ -143,6 +143,49 @@ class SendServiceResolveTargetTest {
assertEquals("backup", strategyCaptor.getValue()); assertEquals("backup", strategyCaptor.getValue());
} }
@Test
void resolvePersonTarget_rejectsDisabledAccount() {
ChannelIdentity identity = createIdentity();
ChannelAccount account = createAccount(30L, "wecom", 0);
when(channelIdentityMapper.selectOne(any())).thenReturn(identity);
when(accountMapper.selectList(any())).thenReturn(Collections.singletonList(account));
when(accountMapper.selectById(30L)).thenReturn(account);
BusinessException ex = assertThrows(BusinessException.class,
() -> sendService.resolveTestTargets("tenant", createPersonRequest()));
assertEquals("租户没有可用的发送账号: channel=wecom", ex.getMessage());
}
@Test
void resolvePersonTarget_rejectsCrossTenantAccount() {
ChannelIdentity identity = createIdentity();
ChannelAccount account = createAccount(30L, "wecom", 1);
account.setTenantCode("other");
when(channelIdentityMapper.selectOne(any())).thenReturn(identity);
when(accountMapper.selectList(any())).thenReturn(Collections.singletonList(account));
when(accountMapper.selectById(30L)).thenReturn(account);
BusinessException ex = assertThrows(BusinessException.class,
() -> sendService.resolveTestTargets("tenant", createPersonRequest()));
assertEquals("租户没有可用的发送账号: channel=wecom", ex.getMessage());
}
@Test
void resolvePersonTarget_rejectsChannelTypeMismatch() {
ChannelIdentity identity = createIdentity();
ChannelAccount account = createAccount(30L, "mobile", 1);
when(channelIdentityMapper.selectOne(any())).thenReturn(identity);
when(accountMapper.selectList(any())).thenReturn(Collections.singletonList(account));
when(accountMapper.selectById(30L)).thenReturn(account);
BusinessException ex = assertThrows(BusinessException.class,
() -> sendService.resolveTestTargets("tenant", createPersonRequest()));
assertEquals("单聊发送账号渠道类型与目标身份不一致", ex.getMessage());
}
private SendTestRequest createConversationRequest(String strategy) { private SendTestRequest createConversationRequest(String strategy) {
SendTestRequest request = new SendTestRequest(); SendTestRequest request = new SendTestRequest();
request.setTargetType("conversation"); request.setTargetType("conversation");
@@ -159,6 +202,21 @@ class SendServiceResolveTargetTest {
return request; return request;
} }
private SendTestRequest createPersonRequest() {
SendTestRequest request = new SendTestRequest();
request.setTargetType("person");
Map<String, Object> targetValue = new HashMap<>();
targetValue.put("person_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");
request.setChannelStrategy(channelStrategy);
return request;
}
private Conversation createConversation() { private Conversation createConversation() {
Conversation conversation = new Conversation(); Conversation conversation = new Conversation();
conversation.setId(1L); conversation.setId(1L);
@@ -177,4 +235,14 @@ class SendServiceResolveTargetTest {
account.setStatus(status); account.setStatus(status);
return account; return account;
} }
private ChannelIdentity createIdentity() {
ChannelIdentity identity = new ChannelIdentity();
identity.setTenantCode("tenant");
identity.setPersonId(1L);
identity.setChannelType("wecom");
identity.setChannelUserId("user-1");
identity.setStatus(1);
return identity;
}
} }