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 460886e..97a5019 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 @@ -60,6 +60,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -79,6 +80,12 @@ public class SendService { private static final int SEND_STATUS_SUCCESS = 2; private static final int SEND_STATUS_FAILED = 3; + private static final int CONVERSATION_SCENE_INTERNAL = 1; + private static final int CONVERSATION_SCENE_EXTERNAL = 2; + private static final Set ACCOUNT_TYPES_BOT = Set.of(6); + private static final Set ACCOUNT_TYPES_PYWECHAT = Set.of(3); + private static final Set ACCOUNT_TYPES_WECOM_AGENT = Set.of(2); + private final SendRecordMapper sendRecordMapper; private final ConversationMapper conversationMapper; private final ChannelAccountMapper accountMapper; @@ -309,28 +316,47 @@ public class SendService { } return targets; case "tag": - Number tagId = (Number) targetValue.get("tag_id"); - if (tagId == null) { - throw new BusinessException("tag 目标缺少 tag_id"); - } - String scope = (String) targetValue.getOrDefault("scope", "all"); - return resolveTagTarget(tenantCode, tagId.longValue(), channelType, scope); + return resolveTagTargetFromValue(tenantCode, targetValue, channelType); case "rule": - return resolveRuleTarget(tenantCode, targetValue, channelType, channelStrategy); + case "combined": + return resolveCombinedTarget(tenantCode, targetValue, channelType, channelStrategy); default: throw new BusinessException("不支持的目标类型: " + targetType); } } + private ChannelType resolveChannelTypeForScene(Integer scene) { + if (scene == null) { + throw new BusinessException("会话未设置场景,无法自动路由"); + } + return scene == CONVERSATION_SCENE_INTERNAL ? ChannelType.WECOM : ChannelType.WECHAT_PERSONAL; + } + + private Set resolveAccountTypesForScene(Integer scene) { + if (scene == null) { + throw new BusinessException("会话未设置场景,无法自动路由"); + } + return scene == CONVERSATION_SCENE_INTERNAL ? ACCOUNT_TYPES_BOT : ACCOUNT_TYPES_PYWECHAT; + } + private List resolveConversationTarget(String tenantCode, Long conversationId, - ChannelType channelType, Map channelStrategy) { + ChannelType ignoredChannelType, + Map channelStrategy) { Conversation conversation = conversationMapper.selectById(conversationId); if (conversation == null || !tenantCode.equals(conversation.getTenantCode())) { throw new BusinessException("会话不存在: " + conversationId); } - Long accountId = selectSendAccount(tenantCode, conversationId, channelType, channelStrategy); + + ChannelType channelType = resolveChannelTypeForScene(conversation.getConversationScene()); + Set accountTypes = resolveAccountTypesForScene(conversation.getConversationScene()); + + Long accountId = selectSendAccount(tenantCode, conversationId, channelType, accountTypes, channelStrategy); if (accountId == null) { accountId = conversation.getOwnerAccountId(); + ChannelAccount ownerAccount = accountMapper.selectById(accountId); + if (ownerAccount == null || !accountTypes.contains(ownerAccount.getAccountType())) { + accountId = null; + } } if (accountId == null) { throw new BusinessException("会话未配置发送账号: " + conversationId); @@ -349,17 +375,22 @@ public class SendService { return Collections.singletonList(target); } - private List resolvePersonTarget(String tenantCode, Long personId, ChannelType channelType) { + private List resolvePersonTarget(String tenantCode, Long personId, ChannelType ignoredChannelType) { + Person person = personMapper.selectById(personId); + if (person == null || !tenantCode.equals(person.getTenantCode())) { + throw new BusinessException("人员不存在: " + personId); + } ChannelIdentity identity = channelIdentityMapper.selectOne( new LambdaQueryWrapper() .eq(ChannelIdentity::getTenantCode, tenantCode) .eq(ChannelIdentity::getPersonId, personId) - .eq(ChannelIdentity::getChannelType, channelType.name().toLowerCase()) + .eq(ChannelIdentity::getChannelType, ChannelType.WECOM.name().toLowerCase()) .eq(ChannelIdentity::getStatus, STATUS_ENABLED)); if (identity == null) { - throw new BusinessException("人员未配置渠道身份: person_id=" + personId + ", channel=" + channelType.name().toLowerCase()); + throw new BusinessException("人员未配置渠道身份: person_id=" + personId); } - Long accountId = selectDefaultAccount(tenantCode, channelType); + ChannelType channelType = ChannelType.WECOM; + Long accountId = selectDefaultAccount(tenantCode, channelType, ACCOUNT_TYPES_WECOM_AGENT); if (accountId == null) { throw new BusinessException("租户未配置可用发送账号: channel=" + channelType.name().toLowerCase()); } @@ -377,30 +408,132 @@ public class SendService { return Collections.singletonList(target); } + private List resolveTagTargetFromValue(String tenantCode, Map targetValue, + ChannelType channelType) { + List tagIds = extractTagIds(targetValue); + if (CollectionUtils.isEmpty(tagIds)) { + throw new BusinessException("tag 目标缺少 tag_id 或 tag_ids"); + } + String scope = (String) targetValue.getOrDefault("scope", "all"); + String innerLogic = (String) targetValue.getOrDefault("inner_logic", "union"); + return resolveTagTarget(tenantCode, tagIds, channelType, scope, innerLogic); + } + + @SuppressWarnings("unchecked") + private List extractTagIds(Map targetValue) { + List tagIds = new ArrayList<>(); + Object tagIdsObj = targetValue.get("tag_ids"); + if (tagIdsObj instanceof List) { + for (Object item : (List) tagIdsObj) { + Long id = toLong(item); + if (id != null) { + tagIds.add(id); + } + } + } + Object singleTagId = targetValue.get("tag_id"); + if (singleTagId != null) { + Long id = toLong(singleTagId); + if (id != null && !tagIds.contains(id)) { + tagIds.add(id); + } + } + return tagIds; + } + private List resolveTagTarget(String tenantCode, Long tagId, ChannelType channelType, String scope) { + return resolveTagTarget(tenantCode, Collections.singletonList(tagId), channelType, scope, "union"); + } + + private List resolveTagTarget(String tenantCode, List tagIds, ChannelType channelType, + String scope, String innerLogic) { List targets = new ArrayList<>(); + String channelTypeStr = channelType.name().toLowerCase(); + boolean union = !"intersection".equals(innerLogic); + if ("all".equals(scope) || "conversation".equals(scope)) { - List convBindings = conversationTagBindingMapper.selectList( - new LambdaQueryWrapper() - .eq(ConversationTagBinding::getTenantCode, tenantCode) - .eq(ConversationTagBinding::getTagId, tagId)); - for (ConversationTagBinding binding : convBindings) { - targets.addAll(resolveConversationTarget(tenantCode, binding.getConversationId(), channelType, + Set conversationIds = union ? new HashSet<>() : null; + if (!union) { + conversationIds = selectConversationIdsByAllTags(tenantCode, tagIds); + } else { + for (Long tagId : tagIds) { + conversationIds.addAll(selectConversationIdsByTag(tenantCode, tagId)); + } + } + for (Long conversationId : conversationIds) { + Conversation conversation = conversationMapper.selectById(conversationId); + if (conversation == null || !tenantCode.equals(conversation.getTenantCode())) { + continue; + } + if (!channelTypeStr.equals(conversation.getChannelType())) { + continue; + } + targets.addAll(resolveConversationTarget(tenantCode, conversationId, channelType, Collections.singletonMap("strategy", "primary"))); } } if ("all".equals(scope) || "person".equals(scope)) { - List personBindings = personTagBindingMapper.selectList( - new LambdaQueryWrapper() - .eq(PersonTagBinding::getTenantCode, tenantCode) - .eq(PersonTagBinding::getTagId, tagId)); - for (PersonTagBinding binding : personBindings) { - targets.addAll(resolvePersonTarget(tenantCode, binding.getPersonId(), channelType)); + Set personIds = union ? new HashSet<>() : null; + if (!union) { + personIds = selectPersonIdsByAllTags(tenantCode, tagIds); + } else { + for (Long tagId : tagIds) { + personIds.addAll(selectPersonIdsByTag(tenantCode, tagId)); + } + } + for (Long personId : personIds) { + targets.addAll(resolvePersonTarget(tenantCode, personId, channelType)); } } return targets; } + private Set selectConversationIdsByTag(String tenantCode, Long tagId) { + return conversationTagBindingMapper.selectList( + new LambdaQueryWrapper() + .eq(ConversationTagBinding::getTenantCode, tenantCode) + .eq(ConversationTagBinding::getTagId, tagId)) + .stream() + .map(ConversationTagBinding::getConversationId) + .collect(Collectors.toSet()); + } + + private Set selectConversationIdsByAllTags(String tenantCode, List tagIds) { + Set ids = null; + for (Long tagId : tagIds) { + Set current = selectConversationIdsByTag(tenantCode, tagId); + if (ids == null) { + ids = new HashSet<>(current); + } else { + ids.retainAll(current); + } + } + return ids == null ? Collections.emptySet() : ids; + } + + private Set selectPersonIdsByTag(String tenantCode, Long tagId) { + return personTagBindingMapper.selectList( + new LambdaQueryWrapper() + .eq(PersonTagBinding::getTenantCode, tenantCode) + .eq(PersonTagBinding::getTagId, tagId)) + .stream() + .map(PersonTagBinding::getPersonId) + .collect(Collectors.toSet()); + } + + private Set selectPersonIdsByAllTags(String tenantCode, List tagIds) { + Set ids = null; + for (Long tagId : tagIds) { + Set current = selectPersonIdsByTag(tenantCode, tagId); + if (ids == null) { + ids = new HashSet<>(current); + } else { + ids.retainAll(current); + } + } + return ids == null ? Collections.emptySet() : ids; + } + private List resolveRuleTarget(String tenantCode, Map targetValue, ChannelType channelType, Map channelStrategy) { RuleTargetValue ruleTarget = parseRuleTargetValue(targetValue); @@ -421,6 +554,125 @@ public class SendService { return targets; } + @SuppressWarnings("unchecked") + private List resolveCombinedTarget(String tenantCode, Map targetValue, + ChannelType channelType, Map channelStrategy) { + if (targetValue == null) { + throw new BusinessException("combined 目标缺少 conditions"); + } + Object conditionsObj = targetValue.get("conditions"); + if (!(conditionsObj instanceof List) || ((List) conditionsObj).isEmpty()) { + throw new BusinessException("combined 目标缺少 conditions"); + } + String logic = toRuleString(targetValue.getOrDefault("logic", "and")); + if (!"and".equals(logic) && !"or".equals(logic)) { + throw new BusinessException("combined 逻辑仅支持 and 或 or"); + } + + List> groups = new ArrayList<>(); + for (Object item : (List) conditionsObj) { + if (!(item instanceof Map)) { + throw new BusinessException("combined 条件格式错误"); + } + Map condition = (Map) item; + List group; + // 支持嵌套条件组:{ logic, conditions } + if (condition.containsKey("logic") && condition.containsKey("conditions")) { + group = resolveCombinedTarget(tenantCode, condition, channelType, channelStrategy); + } else { + String type = toRuleString(condition.get("type")); + Object valueObj = condition.get("value"); + if (!StringUtils.hasText(type) || !(valueObj instanceof Map)) { + throw new BusinessException("combined 条件缺少 type 或 value"); + } + Map value = (Map) valueObj; + group = resolveCombinedCondition(tenantCode, type, value, channelType, channelStrategy); + } + if (!group.isEmpty()) { + groups.add(group); + } + } + + if (groups.isEmpty()) { + return Collections.emptyList(); + } + if ("and".equals(logic)) { + return intersectTargets(groups); + } + return unionTargets(groups); + } + + private List resolveCombinedCondition(String tenantCode, String type, Map value, + ChannelType channelType, Map channelStrategy) { + switch (type) { + case "tag": + return resolveTagTargetFromValue(tenantCode, value, channelType); + case "conversation": + Number convId = (Number) value.get("conversation_id"); + if (convId == null) { + throw new BusinessException("combined conversation 条件缺少 conversation_id"); + } + return resolveConversationTarget(tenantCode, convId.longValue(), channelType, channelStrategy); + case "person": + Number personId = (Number) value.get("person_id"); + if (personId == null) { + throw new BusinessException("combined person 条件缺少 person_id"); + } + return resolvePersonTarget(tenantCode, personId.longValue(), channelType); + default: + throw new BusinessException("combined 不支持的条件类型: " + type); + } + } + + private List unionTargets(List> groups) { + Map merged = new LinkedHashMap<>(); + for (List group : groups) { + for (SendTarget target : group) { + merged.put(targetKey(target), target); + } + } + return new ArrayList<>(merged.values()); + } + + private List intersectTargets(List> groups) { + List result = new ArrayList<>(); + Map firstGroup = new LinkedHashMap<>(); + for (SendTarget target : groups.get(0)) { + firstGroup.put(targetKey(target), target); + } + for (Map.Entry entry : firstGroup.entrySet()) { + String key = entry.getKey(); + boolean inAll = true; + for (int i = 1; i < groups.size(); i++) { + boolean found = false; + for (SendTarget target : groups.get(i)) { + if (key.equals(targetKey(target))) { + found = true; + break; + } + } + if (!found) { + inAll = false; + break; + } + } + if (inAll) { + result.add(entry.getValue()); + } + } + return result; + } + + private String targetKey(SendTarget target) { + if (target.getPersonId() != null) { + return "P:" + target.getPersonId() + ":" + target.getChannelType(); + } + if (target.getConversationId() != null) { + return "C:" + target.getConversationId() + ":" + target.getChannelType(); + } + return "U:" + System.identityHashCode(target); + } + private RuleTargetValue parseRuleTargetValue(Map targetValue) { if (targetValue == null) { throw new BusinessException("rule 目标缺少 rules"); @@ -687,18 +939,37 @@ public class SendService { private Long selectSendAccount(String tenantCode, Long conversationId, ChannelType channelType, Map channelStrategy) { + return selectSendAccount(tenantCode, conversationId, channelType, null, channelStrategy); + } + + private Long selectSendAccount(String tenantCode, Long conversationId, ChannelType channelType, + Set accountTypes, Map channelStrategy) { List accounts = accountSelector.selectSendAccounts(tenantCode, conversationId, channelType, - extractStrategy(channelStrategy)); + accountTypes, extractStrategy(channelStrategy)); return CollectionUtils.isEmpty(accounts) ? null : accounts.get(0); } private Long selectDefaultAccount(String tenantCode, ChannelType channelType) { + return selectDefaultAccount(tenantCode, channelType, null); + } + + private Long selectDefaultAccount(String tenantCode, ChannelType channelType, Set accountTypes) { List accounts = accountMapper.selectList( new LambdaQueryWrapper() .eq(ChannelAccount::getTenantCode, tenantCode) .eq(ChannelAccount::getChannelType, channelType.name().toLowerCase()) .eq(ChannelAccount::getStatus, STATUS_ENABLED)); - return CollectionUtils.isEmpty(accounts) ? null : accounts.get(0).getId(); + if (CollectionUtils.isEmpty(accounts)) { + return null; + } + if (accountTypes != null) { + return accounts.stream() + .filter(a -> accountTypes.contains(a.getAccountType())) + .findFirst() + .map(ChannelAccount::getId) + .orElse(null); + } + return accounts.get(0).getId(); } private String extractChannelType(Map channelStrategy) { @@ -822,7 +1093,16 @@ public class SendService { return; } - List candidateAccountIds = selectSendAccountCandidates(record, channelType); + Set accountTypes; + if (record.getConversationId() != null) { + Conversation conversation = conversationMapper.selectById(record.getConversationId()); + accountTypes = resolveAccountTypesForScene(conversation != null ? conversation.getConversationScene() : null); + } else if (record.getPersonId() != null) { + accountTypes = ACCOUNT_TYPES_WECOM_AGENT; + } else { + accountTypes = null; + } + List candidateAccountIds = selectSendAccountCandidates(record, channelType, accountTypes); if (candidateAccountIds.isEmpty()) { record.setSendStatus(SEND_STATUS_FAILED); record.setChannelResult(JsonUtils.toJson(Collections.singletonMap("error", "no_healthy_account"))); @@ -865,12 +1145,12 @@ public class SendService { sendRecordMapper.updateById(record); } - private List selectSendAccountCandidates(SendRecord record, ChannelType channelType) { + private List selectSendAccountCandidates(SendRecord record, ChannelType channelType, Set accountTypes) { if (record.getConversationId() == null) { return record.getAccountId() != null ? Collections.singletonList(record.getAccountId()) : Collections.emptyList(); } String preferredStrategy = extractStrategy(parseChannelStrategy(record.getChannelStrategy())); - return accountSelector.selectSendAccounts(record.getTenantCode(), record.getConversationId(), channelType, preferredStrategy); + return accountSelector.selectSendAccounts(record.getTenantCode(), record.getConversationId(), channelType, accountTypes, preferredStrategy); } private Map parseChannelStrategy(String strategyJson) { @@ -890,14 +1170,17 @@ public class SendService { private SendMessageResult doSendAttempt(SendRecord record, ChannelAdapter adapter, ChannelAccount account) { SendMessageRequest messageRequest = new SendMessageRequest(); - messageRequest.setConversationId(record.getConversationId() != null ? String.valueOf(record.getConversationId()) : null); + messageRequest.setConversationId(resolveChannelConversationId(record)); messageRequest.setReceiverId(record.getPersonId() != null ? String.valueOf(record.getPersonId()) : null); messageRequest.setContentType(record.getContentType()); messageRequest.setContent(JsonUtils.fromJson(record.getContentBody(), Map.class)); Map extra = new HashMap<>(); - if (account != null && StringUtils.hasText(account.getAccountId())) { - extra.put("account_id", account.getAccountId()); + if (account != null) { + extra.put("account_type", account.getAccountType()); + if (StringUtils.hasText(account.getAccountId())) { + extra.put("account_id", account.getAccountId()); + } } extra.put("record_id", record.getId()); extra.put("tenant_code", record.getTenantCode()); @@ -915,6 +1198,17 @@ public class SendService { } } + private String resolveChannelConversationId(SendRecord record) { + if (record.getConversationId() == null) { + return null; + } + Conversation conversation = conversationMapper.selectById(record.getConversationId()); + if (conversation != null && StringUtils.hasText(conversation.getChannelConversationId())) { + return conversation.getChannelConversationId(); + } + return String.valueOf(record.getConversationId()); + } + private void markAccountUnhealthy(ChannelAccount account) { if (account == null || account.getAccountType() == null || account.getAccountType() != 3) { return; diff --git a/backend/mci-server/src/test/java/com/sino/mci/send/service/SendServiceFailoverTest.java b/backend/mci-server/src/test/java/com/sino/mci/send/service/SendServiceFailoverTest.java index 5acbc0a..3712c81 100644 --- a/backend/mci-server/src/test/java/com/sino/mci/send/service/SendServiceFailoverTest.java +++ b/backend/mci-server/src/test/java/com/sino/mci/send/service/SendServiceFailoverTest.java @@ -6,6 +6,7 @@ import com.sino.mci.channel.common.dto.SendMessageResult; import com.sino.mci.channel.common.model.ChannelType; import com.sino.mci.channel.common.spi.ChannelAdapter; import com.sino.mci.channel.entity.ChannelAccount; +import com.sino.mci.channel.entity.Conversation; import com.sino.mci.channel.repository.ChannelAccountMapper; import com.sino.mci.channel.repository.ConversationMapper; import com.sino.mci.channel.repository.ConversationTagBindingMapper; @@ -31,6 +32,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -86,7 +88,8 @@ class SendServiceFailoverTest { Long primaryAccountId = 100L; Long backupAccountId = 200L; - when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECHAT_PERSONAL, "primary")) + when(conversationMapper.selectById(1L)).thenReturn(createExternalConversation()); + when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECHAT_PERSONAL, Set.of(3), "primary")) .thenReturn(Arrays.asList(primaryAccountId, backupAccountId)); when(accountMapper.selectById(primaryAccountId)).thenReturn(createPywechatAccount(primaryAccountId, "wxid-primary")); when(accountMapper.selectById(backupAccountId)).thenReturn(createPywechatAccount(backupAccountId, "wxid-backup")); @@ -110,7 +113,9 @@ class SendServiceFailoverTest { verify(adapter, times(2)).sendMessage(requestCaptor.capture()); List requests = requestCaptor.getAllValues(); assertEquals("wxid-primary", requests.get(0).getExtra().get("account_id")); + assertEquals(Integer.valueOf(3), requests.get(0).getExtra().get("account_type")); assertEquals("wxid-backup", requests.get(1).getExtra().get("account_id")); + assertEquals(Integer.valueOf(3), requests.get(1).getExtra().get("account_type")); ArgumentCaptor accountCaptor = ArgumentCaptor.forClass(ChannelAccount.class); verify(accountMapper, times(1)).updateById(accountCaptor.capture()); @@ -125,7 +130,8 @@ class SendServiceFailoverTest { SendRecord record = createRecord(); Long accountId = 100L; - when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECHAT_PERSONAL, "primary")) + when(conversationMapper.selectById(1L)).thenReturn(createExternalConversation()); + when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECHAT_PERSONAL, Set.of(3), "primary")) .thenReturn(Collections.singletonList(accountId)); when(accountMapper.selectById(accountId)).thenReturn(createPywechatAccount(accountId, "wxid-primary")); @@ -159,7 +165,8 @@ class SendServiceFailoverTest { void dispatchRecordFailsWithNoHealthyAccount() { SendRecord record = createRecord(); - when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECHAT_PERSONAL, "primary")) + when(conversationMapper.selectById(1L)).thenReturn(createExternalConversation()); + when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECHAT_PERSONAL, Set.of(3), "primary")) .thenReturn(Collections.emptyList()); sendService.dispatchRecord(record); @@ -187,6 +194,15 @@ class SendServiceFailoverTest { return record; } + private Conversation createExternalConversation() { + Conversation conversation = new Conversation(); + conversation.setId(1L); + conversation.setTenantCode("tenant"); + conversation.setConversationScene(2); + conversation.setChannelConversationId("conv-1"); + return conversation; + } + private ChannelAccount createPywechatAccount(Long id, String accountId) { ChannelAccount account = new ChannelAccount(); account.setId(id); @@ -197,6 +213,7 @@ class SendServiceFailoverTest { account.setStatus(1); account.setOnlineStatus(1); account.setRiskLevel(1); + account.setLastHeartbeat(java.time.LocalDateTime.now()); return account; } } 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 index 263459d..a8b309f 100644 --- 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 @@ -11,6 +11,7 @@ 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.entity.ChannelIdentity; +import com.sino.mci.crm.entity.Person; import com.sino.mci.crm.repository.PersonMapper; import com.sino.mci.crm.repository.PersonTagBindingMapper; import com.sino.mci.crm.repository.TagMapper; @@ -31,6 +32,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -76,13 +78,13 @@ class SendServiceResolveTargetTest { @Test void resolveConversationTarget_selectsHealthyPrimaryAccount() { - Conversation conversation = createConversation(); + Conversation conversation = createConversation(1); Long primaryAccountId = 10L; when(conversationMapper.selectById(1L)).thenReturn(conversation); - when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, "primary")) + when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, Set.of(6), "primary")) .thenReturn(Collections.singletonList(primaryAccountId)); - when(accountMapper.selectById(primaryAccountId)).thenReturn(createAccount(primaryAccountId, "wecom", 1)); + when(accountMapper.selectById(primaryAccountId)).thenReturn(createWecomBotAccount(primaryAccountId, 1)); List targets = sendService.resolveTestTargets("tenant", createConversationRequest("primary")); @@ -93,13 +95,13 @@ class SendServiceResolveTargetTest { @Test void resolveConversationTarget_rejectsUnhealthyOwnerAccount() { - Conversation conversation = createConversation(); + Conversation conversation = createConversation(1); conversation.setOwnerAccountId(20L); when(conversationMapper.selectById(1L)).thenReturn(conversation); - when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, "primary")) + when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, Set.of(6), "primary")) .thenReturn(Collections.emptyList()); - ChannelAccount disabledAccount = createAccount(20L, "wecom", 0); + ChannelAccount disabledAccount = createWecomBotAccount(20L, 0); when(accountMapper.selectById(20L)).thenReturn(disabledAccount); BusinessException ex = assertThrows(BusinessException.class, @@ -109,13 +111,13 @@ class SendServiceResolveTargetTest { @Test void resolveConversationTarget_usesAccountReturnedBySelector() { - Conversation conversation = createConversation(); + Conversation conversation = createConversation(1); Long backupAccountId = 20L; when(conversationMapper.selectById(1L)).thenReturn(conversation); - when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, "primary")) + when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, Set.of(6), "primary")) .thenReturn(Collections.singletonList(backupAccountId)); - when(accountMapper.selectById(backupAccountId)).thenReturn(createAccount(backupAccountId, "wecom", 1)); + when(accountMapper.selectById(backupAccountId)).thenReturn(createWecomBotAccount(backupAccountId, 1)); List targets = sendService.resolveTestTargets("tenant", createConversationRequest("primary")); @@ -125,13 +127,13 @@ class SendServiceResolveTargetTest { @Test void resolveConversationTarget_respectsBackupStrategy() { - Conversation conversation = createConversation(); + Conversation conversation = createConversation(1); Long backupAccountId = 20L; when(conversationMapper.selectById(1L)).thenReturn(conversation); - when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, "backup")) + when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, Set.of(6), "backup")) .thenReturn(Collections.singletonList(backupAccountId)); - when(accountMapper.selectById(backupAccountId)).thenReturn(createAccount(backupAccountId, "wecom", 1)); + when(accountMapper.selectById(backupAccountId)).thenReturn(createWecomBotAccount(backupAccountId, 1)); List targets = sendService.resolveTestTargets("tenant", createConversationRequest("backup")); @@ -139,15 +141,45 @@ class SendServiceResolveTargetTest { assertEquals(backupAccountId, targets.get(0).getAccountId()); ArgumentCaptor strategyCaptor = ArgumentCaptor.forClass(String.class); - verify(accountSelector).selectSendAccounts(eq("tenant"), eq(1L), eq(ChannelType.WECOM), strategyCaptor.capture()); + verify(accountSelector).selectSendAccounts(eq("tenant"), eq(1L), eq(ChannelType.WECOM), eq(Set.of(6)), strategyCaptor.capture()); assertEquals("backup", strategyCaptor.getValue()); } @Test - void resolvePersonTarget_rejectsDisabledAccount() { - ChannelIdentity identity = createIdentity(); - ChannelAccount account = createAccount(30L, "wecom", 0); + void resolveConversationTarget_routesExternalSceneToPywechat() { + Conversation conversation = createConversation(2); + Long pywechatAccountId = 30L; + when(conversationMapper.selectById(1L)).thenReturn(conversation); + when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECHAT_PERSONAL, Set.of(3), "primary")) + .thenReturn(Collections.singletonList(pywechatAccountId)); + when(accountMapper.selectById(pywechatAccountId)).thenReturn(createPywechatAccount(pywechatAccountId, 1)); + + List targets = sendService.resolveTestTargets("tenant", createConversationRequest("primary")); + + assertEquals(1, targets.size()); + assertEquals(pywechatAccountId, targets.get(0).getAccountId()); + assertEquals(ChannelType.WECHAT_PERSONAL, targets.get(0).getChannelType()); + } + + @Test + void resolveConversationTarget_rejectsNullScene() { + Conversation conversation = createConversation(null); + + when(conversationMapper.selectById(1L)).thenReturn(conversation); + + BusinessException ex = assertThrows(BusinessException.class, + () -> sendService.resolveTestTargets("tenant", createConversationRequest("primary"))); + assertEquals("会话未设置场景,无法自动路由", ex.getMessage()); + } + + @Test + void resolvePersonTarget_rejectsDisabledAccount() { + Person person = createPerson(); + ChannelIdentity identity = createIdentity(); + ChannelAccount account = createWecomAgentAccount(30L, 0); + + when(personMapper.selectById(1L)).thenReturn(person); when(channelIdentityMapper.selectOne(any())).thenReturn(identity); when(accountMapper.selectList(any())).thenReturn(Collections.singletonList(account)); when(accountMapper.selectById(30L)).thenReturn(account); @@ -159,10 +191,12 @@ class SendServiceResolveTargetTest { @Test void resolvePersonTarget_rejectsCrossTenantAccount() { + Person person = createPerson(); ChannelIdentity identity = createIdentity(); - ChannelAccount account = createAccount(30L, "wecom", 1); + ChannelAccount account = createWecomAgentAccount(30L, 1); account.setTenantCode("other"); + when(personMapper.selectById(1L)).thenReturn(person); when(channelIdentityMapper.selectOne(any())).thenReturn(identity); when(accountMapper.selectList(any())).thenReturn(Collections.singletonList(account)); when(accountMapper.selectById(30L)).thenReturn(account); @@ -174,16 +208,36 @@ class SendServiceResolveTargetTest { @Test void resolvePersonTarget_rejectsChannelTypeMismatch() { + Person person = createPerson(); ChannelIdentity identity = createIdentity(); - ChannelAccount account = createAccount(30L, "mobile", 1); + ChannelAccount account = createAccount(30L, "wecom", 1); + account.setAccountType(3); + when(personMapper.selectById(1L)).thenReturn(person); + when(channelIdentityMapper.selectOne(any())).thenReturn(identity); + when(accountMapper.selectList(any())).thenReturn(Collections.singletonList(account)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> sendService.resolveTestTargets("tenant", createPersonRequest())); + assertEquals("租户未配置可用发送账号: channel=wecom", ex.getMessage()); + } + + @Test + void resolvePersonTarget_selectsWecomAgentAccount() { + Person person = createPerson(); + ChannelIdentity identity = createIdentity(); + ChannelAccount account = createWecomAgentAccount(30L, 1); + + when(personMapper.selectById(1L)).thenReturn(person); 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()); + List targets = sendService.resolveTestTargets("tenant", createPersonRequest()); + + assertEquals(1, targets.size()); + assertEquals(Long.valueOf(30L), targets.get(0).getAccountId()); + assertEquals(ChannelType.WECOM, targets.get(0).getChannelType()); } private SendTestRequest createConversationRequest(String strategy) { @@ -217,16 +271,24 @@ class SendServiceResolveTargetTest { return request; } - private Conversation createConversation() { + private Conversation createConversation(Integer scene) { Conversation conversation = new Conversation(); conversation.setId(1L); conversation.setTenantCode("tenant"); conversation.setChannelType("wecom"); + conversation.setConversationScene(scene); conversation.setChannelConversationId("conv-1"); conversation.setStatus(1); return conversation; } + private Person createPerson() { + Person person = new Person(); + person.setId(1L); + person.setTenantCode("tenant"); + return person; + } + private ChannelAccount createAccount(Long id, String channelType, int status) { ChannelAccount account = new ChannelAccount(); account.setId(id); @@ -236,6 +298,27 @@ class SendServiceResolveTargetTest { return account; } + private ChannelAccount createWecomBotAccount(Long id, int status) { + ChannelAccount account = createAccount(id, "wecom", status); + account.setAccountType(6); + return account; + } + + private ChannelAccount createWecomAgentAccount(Long id, int status) { + ChannelAccount account = createAccount(id, "wecom", status); + account.setAccountType(2); + return account; + } + + private ChannelAccount createPywechatAccount(Long id, int status) { + ChannelAccount account = createAccount(id, "wechat_personal", status); + account.setAccountType(3); + account.setOnlineStatus(1); + account.setRiskLevel(1); + account.setLastHeartbeat(java.time.LocalDateTime.now()); + return account; + } + private ChannelIdentity createIdentity() { ChannelIdentity identity = new ChannelIdentity(); identity.setTenantCode("tenant");