From f350313395a58b53928d8b463f653717c0b2c4e1 Mon Sep 17 00:00:00 2001 From: marsal Date: Tue, 7 Jul 2026 20:43:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(send):=20=E5=AE=9E=E7=8E=B0=20outbound=20r?= =?UTF-8?q?ule=20=E7=9B=AE=E6=A0=87=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sino/mci/send/service/SendService.java | 362 +++++++++++++++++- .../send/controller/SendControllerTest.java | 165 +++++++- 2 files changed, 525 insertions(+), 2 deletions(-) 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 969ecee..4b9b796 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 @@ -14,10 +14,14 @@ import com.sino.mci.channel.repository.ChannelAccountMapper; import com.sino.mci.channel.repository.ConversationMapper; import com.sino.mci.channel.entity.ConversationTagBinding; import com.sino.mci.crm.entity.ChannelIdentity; +import com.sino.mci.crm.entity.Person; import com.sino.mci.crm.entity.PersonTagBinding; +import com.sino.mci.crm.entity.Tag; import com.sino.mci.channel.repository.ConversationTagBindingMapper; 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.send.dto.SendRecordResponse; import com.sino.mci.send.dto.SendRequest; @@ -33,12 +37,19 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.toolkit.support.SFunction; + import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.UUID; +import java.util.function.Function; import java.util.stream.Collectors; @Slf4j @@ -59,6 +70,8 @@ public class SendService { private final ChannelAccountMapper accountMapper; private final ChannelAccountConversationMapper accountConversationMapper; private final ChannelIdentityMapper channelIdentityMapper; + private final PersonMapper personMapper; + private final TagMapper tagMapper; private final PersonTagBindingMapper personTagBindingMapper; private final ConversationTagBindingMapper conversationTagBindingMapper; private final ChannelAdapterRegistry adapterRegistry; @@ -158,7 +171,7 @@ public class SendService { String scope = (String) targetValue.getOrDefault("scope", "all"); return resolveTagTarget(tenantCode, tagId.longValue(), channelType, scope); case "rule": - throw new BusinessException("rule 目标解析尚未实现"); + return resolveRuleTarget(tenantCode, targetValue, channelType, channelStrategy); default: throw new BusinessException("不支持的目标类型: " + targetType); } @@ -235,6 +248,290 @@ public class SendService { return targets; } + private List resolveRuleTarget(String tenantCode, Map targetValue, + ChannelType channelType, Map channelStrategy) { + RuleTargetValue ruleTarget = parseRuleTargetValue(targetValue); + List results = new ArrayList<>(); + for (RuleCondition condition : ruleTarget.getRules()) { + results.add(evaluateRuleCondition(tenantCode, condition)); + } + Set personIds = combinePersonIds(ruleTarget.getLogic(), results); + Set conversationIds = combineConversationIds(ruleTarget.getLogic(), results); + + List targets = new ArrayList<>(); + for (Long personId : personIds) { + targets.addAll(resolvePersonTarget(tenantCode, personId, channelType)); + } + for (Long conversationId : conversationIds) { + targets.addAll(resolveConversationTarget(tenantCode, conversationId, channelType, channelStrategy)); + } + return targets; + } + + private RuleTargetValue parseRuleTargetValue(Map targetValue) { + if (targetValue == null) { + throw new BusinessException("rule 目标缺少 rules"); + } + Object rulesObj = targetValue.get("rules"); + if (!(rulesObj instanceof List) || ((List) rulesObj).isEmpty()) { + throw new BusinessException("rule 目标缺少 rules"); + } + List rules = new ArrayList<>(); + for (Object item : (List) rulesObj) { + if (!(item instanceof Map)) { + throw new BusinessException("rule 条件格式错误"); + } + Map map = (Map) item; + String field = toRuleString(map.get("field")); + String operator = toRuleString(map.get("operator")); + if (!StringUtils.hasText(field) || !StringUtils.hasText(operator)) { + throw new BusinessException("rule 条件缺少 field 或 operator"); + } + rules.add(new RuleCondition(field, operator, map.get("value"))); + } + String logic = toRuleString(targetValue.getOrDefault("logic", "and")); + if (!"and".equals(logic) && !"or".equals(logic)) { + throw new BusinessException("rule 逻辑仅支持 and 或 or"); + } + return new RuleTargetValue(rules, logic); + } + + private RuleMatchResult evaluateRuleCondition(String tenantCode, RuleCondition condition) { + switch (condition.getField()) { + case "tag": + return evaluateTagCondition(tenantCode, condition); + case "contract_id": + return new RuleMatchResult(Collections.emptySet(), + evaluateFieldCondition(tenantCode, condition, conversationMapper, + Conversation::getTenantCode, Conversation::getId, Conversation::getContractId, this::toLong)); + case "institution_id": + return new RuleMatchResult( + evaluateFieldCondition(tenantCode, condition, personMapper, + Person::getTenantCode, Person::getId, Person::getInstitutionId, this::toLong), + evaluateFieldCondition(tenantCode, condition, conversationMapper, + Conversation::getTenantCode, Conversation::getId, Conversation::getInstitutionId, this::toLong)); + case "supplier_id": + return new RuleMatchResult( + evaluateFieldCondition(tenantCode, condition, personMapper, + Person::getTenantCode, Person::getId, Person::getSupplierId, this::toLong), + evaluateFieldCondition(tenantCode, condition, conversationMapper, + Conversation::getTenantCode, Conversation::getId, Conversation::getSupplierId, this::toLong)); + case "person_type": + return new RuleMatchResult( + evaluateFieldCondition(tenantCode, condition, personMapper, + Person::getTenantCode, Person::getId, Person::getPersonType, this::toInteger), + Collections.emptySet()); + case "channel_type": + return new RuleMatchResult(Collections.emptySet(), + evaluateFieldCondition(tenantCode, condition, conversationMapper, + Conversation::getTenantCode, Conversation::getId, Conversation::getChannelType, this::toLowerCaseString)); + default: + throw new BusinessException("rule 不支持的字段: " + condition.getField()); + } + } + + private Set evaluateFieldCondition(String tenantCode, RuleCondition condition, BaseMapper mapper, + SFunction tenantCodeGetter, SFunction idGetter, + SFunction fieldGetter, Function converter) { + List values = normalizeValues(condition.getValue()).stream() + .map(converter) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(tenantCodeGetter, tenantCode); + if (values.isEmpty()) { + if ("not_in".equals(condition.getOperator())) { + return mapper.selectList(wrapper).stream().map(idGetter).collect(Collectors.toSet()); + } + return Collections.emptySet(); + } + applyOperator(wrapper, fieldGetter, condition.getOperator(), values); + return mapper.selectList(wrapper).stream().map(idGetter).collect(Collectors.toSet()); + } + + private void applyOperator(LambdaQueryWrapper wrapper, SFunction fieldGetter, + String operator, List values) { + switch (operator) { + case "eq": + wrapper.eq(fieldGetter, values.get(0)); + break; + case "ne": + wrapper.ne(fieldGetter, values.get(0)); + break; + case "in": + wrapper.in(fieldGetter, values); + break; + case "not_in": + wrapper.notIn(fieldGetter, values); + break; + default: + throw new BusinessException("rule 不支持的算子: " + operator); + } + } + + private RuleMatchResult evaluateTagCondition(String tenantCode, RuleCondition condition) { + List values = normalizeValues(condition.getValue()); + Set tagIds = new HashSet<>(); + for (Object value : values) { + if (value instanceof Number) { + tagIds.add(((Number) value).longValue()); + } else if (value instanceof String) { + List tags = tagMapper.selectList( + new LambdaQueryWrapper() + .eq(Tag::getTenantCode, tenantCode) + .eq(Tag::getTagPath, value)); + for (Tag tag : tags) { + tagIds.add(tag.getId()); + } + } + } + if (tagIds.isEmpty()) { + if ("not_in".equals(condition.getOperator())) { + return new RuleMatchResult( + selectAllPersonIds(tenantCode), + selectAllConversationIds(tenantCode)); + } + return new RuleMatchResult(Collections.emptySet(), Collections.emptySet()); + } + Set personIds = personTagBindingMapper.selectList( + new LambdaQueryWrapper() + .eq(PersonTagBinding::getTenantCode, tenantCode) + .in(PersonTagBinding::getTagId, tagIds)) + .stream() + .map(PersonTagBinding::getPersonId) + .collect(Collectors.toSet()); + Set conversationIds = conversationTagBindingMapper.selectList( + new LambdaQueryWrapper() + .eq(ConversationTagBinding::getTenantCode, tenantCode) + .in(ConversationTagBinding::getTagId, tagIds)) + .stream() + .map(ConversationTagBinding::getConversationId) + .collect(Collectors.toSet()); + if ("ne".equals(condition.getOperator()) || "not_in".equals(condition.getOperator())) { + Set allPersonIds = selectAllPersonIds(tenantCode); + Set allConversationIds = selectAllConversationIds(tenantCode); + allPersonIds.removeAll(personIds); + allConversationIds.removeAll(conversationIds); + return new RuleMatchResult(allPersonIds, allConversationIds); + } + return new RuleMatchResult(personIds, conversationIds); + } + + private Set selectAllPersonIds(String tenantCode) { + return personMapper.selectList( + new LambdaQueryWrapper().eq(Person::getTenantCode, tenantCode)) + .stream() + .map(Person::getId) + .collect(Collectors.toSet()); + } + + private Set selectAllConversationIds(String tenantCode) { + return conversationMapper.selectList( + new LambdaQueryWrapper().eq(Conversation::getTenantCode, tenantCode)) + .stream() + .map(Conversation::getId) + .collect(Collectors.toSet()); + } + + private Set combinePersonIds(String logic, List results) { + return "or".equals(logic) ? unionPersonIds(results) : intersectPersonIds(results); + } + + private Set unionPersonIds(List results) { + Set ids = new HashSet<>(); + for (RuleMatchResult result : results) { + ids.addAll(result.getPersonIds()); + } + return ids; + } + + private Set intersectPersonIds(List results) { + Set ids = null; + for (RuleMatchResult result : results) { + if (!result.getPersonIds().isEmpty()) { + if (ids == null) { + ids = new HashSet<>(result.getPersonIds()); + } else { + ids.retainAll(result.getPersonIds()); + } + } + } + return ids == null ? Collections.emptySet() : ids; + } + + private Set combineConversationIds(String logic, List results) { + return "or".equals(logic) ? unionConversationIds(results) : intersectConversationIds(results); + } + + private Set unionConversationIds(List results) { + Set ids = new HashSet<>(); + for (RuleMatchResult result : results) { + ids.addAll(result.getConversationIds()); + } + return ids; + } + + private Set intersectConversationIds(List results) { + Set ids = null; + for (RuleMatchResult result : results) { + if (!result.getConversationIds().isEmpty()) { + if (ids == null) { + ids = new HashSet<>(result.getConversationIds()); + } else { + ids.retainAll(result.getConversationIds()); + } + } + } + return ids == null ? Collections.emptySet() : ids; + } + + private List normalizeValues(Object value) { + if (value == null) { + return Collections.emptyList(); + } + if (value instanceof List) { + return new ArrayList<>((List) value); + } + return Collections.singletonList(value); + } + + private Long toLong(Object value) { + if (value instanceof Number) { + return ((Number) value).longValue(); + } + if (value instanceof String) { + try { + return Long.parseLong((String) value); + } catch (NumberFormatException e) { + return null; + } + } + return null; + } + + private Integer toInteger(Object value) { + if (value instanceof Number) { + return ((Number) value).intValue(); + } + if (value instanceof String) { + try { + return Integer.parseInt((String) value); + } catch (NumberFormatException e) { + return null; + } + } + return null; + } + + private String toLowerCaseString(Object value) { + String str = toRuleString(value); + return str != null ? str.toLowerCase() : null; + } + + private String toRuleString(Object value) { + return value != null ? String.valueOf(value) : null; + } + private Long selectSendAccount(Long conversationId, ChannelType channelType, Map channelStrategy) { String strategy = extractStrategy(channelStrategy); int bindingType = "backup".equals(strategy) ? BINDING_SEND_BACKUP : BINDING_SEND_PRIMARY; @@ -395,6 +692,69 @@ public class SendService { return response; } + private static class RuleCondition { + + private final String field; + private final String operator; + private final Object value; + + RuleCondition(String field, String operator, Object value) { + this.field = field; + this.operator = operator; + this.value = value; + } + + public String getField() { + return field; + } + + public String getOperator() { + return operator; + } + + public Object getValue() { + return value; + } + } + + private static class RuleTargetValue { + + private final List rules; + private final String logic; + + RuleTargetValue(List rules, String logic) { + this.rules = rules; + this.logic = logic; + } + + public List getRules() { + return rules; + } + + public String getLogic() { + return logic; + } + } + + private static class RuleMatchResult { + + private final Set personIds; + private final Set conversationIds; + + RuleMatchResult(Set personIds, Set conversationIds) { + this.personIds = personIds; + this.conversationIds = conversationIds; + } + + public Set getPersonIds() { + return personIds; + } + + public Set getConversationIds() { + return conversationIds; + } + } + private static class SendTarget { private Long conversationId; diff --git a/backend/mci-server/src/test/java/com/sino/mci/send/controller/SendControllerTest.java b/backend/mci-server/src/test/java/com/sino/mci/send/controller/SendControllerTest.java index e8cccde..31a8668 100644 --- a/backend/mci-server/src/test/java/com/sino/mci/send/controller/SendControllerTest.java +++ b/backend/mci-server/src/test/java/com/sino/mci/send/controller/SendControllerTest.java @@ -91,7 +91,7 @@ public class SendControllerTest { } @Test - void shouldRejectUnsupportedTargetType() throws Exception { + void shouldRejectEmptyRuleTarget() throws Exception { String tenantCode = "send-" + UUID.randomUUID().toString().substring(0, 8); String authToken = createTenantAndLogin(mockMvc, tenantCode, "发送测试"); @@ -105,4 +105,167 @@ public class SendControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(-1)); } + + @Test + void shouldResolveRuleTargetWithAndLogic() throws Exception { + String tenantCode = "send-" + UUID.randomUUID().toString().substring(0, 8); + String authToken = createTenantAndLogin(mockMvc, tenantCode, "发送测试"); + + Long accountId = createChannelAccount(authToken, tenantCode); + Long tagId = createTag(authToken, tenantCode, "rule-and"); + Long personId = createPerson(authToken, tenantCode, 3); + bindPersonTag(authToken, tenantCode, personId, tagId); + createPersonIdentity(authToken, tenantCode, personId, "wecom", "user-001"); + Long conversationId = createConversation(authToken, tenantCode, "wecom", "room-and-001"); + bindConversationTag(authToken, tenantCode, conversationId, tagId); + bindConversationAccount(authToken, tenantCode, conversationId, accountId); + + String sendPayload = String.format( + "{\"targetType\":\"rule\",\"targetValue\":{\"rules\":[{\"field\":\"tag\",\"operator\":\"in\",\"value\":[%d]},{\"field\":\"person_type\",\"operator\":\"eq\",\"value\":3},{\"field\":\"channel_type\",\"operator\":\"eq\",\"value\":\"wecom\"}],\"logic\":\"and\"},\"contentType\":\"text\",\"content\":{\"text\":\"hello\"},\"channelStrategy\":{\"channel_type\":\"wecom\",\"strategy\":\"primary\"}}", + tagId); + mockMvc.perform(post("/msg-platform/api/v1/send/test") + .contextPath("/msg-platform") + .header("Authorization", authToken) + .header("X-Tenant-Code", tenantCode) + .contentType(MediaType.APPLICATION_JSON) + .content(sendPayload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.totalCount").value(2)) + .andExpect(jsonPath("$.data.status").value("simulated")); + } + + @Test + void shouldResolveRuleTargetWithOrLogic() throws Exception { + String tenantCode = "send-" + UUID.randomUUID().toString().substring(0, 8); + String authToken = createTenantAndLogin(mockMvc, tenantCode, "发送测试"); + + Long accountId = createChannelAccount(authToken, tenantCode); + Long personId = createPerson(authToken, tenantCode, 3); + createPersonIdentity(authToken, tenantCode, personId, "wecom", "user-002"); + Long conversationId = createConversation(authToken, tenantCode, "wecom", "room-or-001"); + bindConversationAccount(authToken, tenantCode, conversationId, accountId); + + String sendPayload = "{\"targetType\":\"rule\",\"targetValue\":{\"rules\":[{\"field\":\"person_type\",\"operator\":\"eq\",\"value\":3},{\"field\":\"channel_type\",\"operator\":\"eq\",\"value\":\"wecom\"}],\"logic\":\"or\"},\"contentType\":\"text\",\"content\":{\"text\":\"hello\"},\"channelStrategy\":{\"channel_type\":\"wecom\",\"strategy\":\"primary\"}}"; + mockMvc.perform(post("/msg-platform/api/v1/send/test") + .contextPath("/msg-platform") + .header("Authorization", authToken) + .header("X-Tenant-Code", tenantCode) + .contentType(MediaType.APPLICATION_JSON) + .content(sendPayload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.totalCount").value(2)) + .andExpect(jsonPath("$.data.status").value("simulated")); + } + + private Long createChannelAccount(String authToken, String tenantCode) throws Exception { + String accountPayload = "{\"accountType\":1,\"channelType\":\"wecom\",\"accountId\":\"ZhangSan\",\"accountName\":\"张三\"}"; + String accountResponse = mockMvc.perform(post("/msg-platform/api/v1/channel-account") + .contextPath("/msg-platform") + .header("Authorization", authToken) + .header("X-Tenant-Code", tenantCode) + .contentType(MediaType.APPLICATION_JSON) + .content(accountPayload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(); + return ((Number) com.jayway.jsonpath.JsonPath.read(accountResponse, "$.data.id")).longValue(); + } + + private Long createTag(String authToken, String tenantCode, String tagName) throws Exception { + String tagPayload = String.format("{\"parentId\":0,\"tagName\":\"%s\"}", tagName); + String tagResponse = mockMvc.perform(post("/msg-platform/api/v1/tag") + .contextPath("/msg-platform") + .header("Authorization", authToken) + .header("X-Tenant-Code", tenantCode) + .contentType(MediaType.APPLICATION_JSON) + .content(tagPayload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(); + return ((Number) com.jayway.jsonpath.JsonPath.read(tagResponse, "$.data.id")).longValue(); + } + + private Long createPerson(String authToken, String tenantCode, int personType) throws Exception { + String personPayload = String.format("{\"personCode\":\"P-%d\",\"personName\":\"张三\",\"phone\":\"13800138000\",\"personType\":%d}", personType, personType); + String personResponse = mockMvc.perform(post("/msg-platform/api/v1/person") + .contextPath("/msg-platform") + .header("Authorization", authToken) + .header("X-Tenant-Code", tenantCode) + .contentType(MediaType.APPLICATION_JSON) + .content(personPayload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(); + return ((Number) com.jayway.jsonpath.JsonPath.read(personResponse, "$.data.id")).longValue(); + } + + private void createPersonIdentity(String authToken, String tenantCode, Long personId, + String channelType, String channelUserId) throws Exception { + String identityPayload = String.format("{\"channelType\":\"%s\",\"channelUserId\":\"%s\"}", channelType, channelUserId); + mockMvc.perform(post("/msg-platform/api/v1/person/" + personId + "/identities") + .contextPath("/msg-platform") + .header("Authorization", authToken) + .header("X-Tenant-Code", tenantCode) + .contentType(MediaType.APPLICATION_JSON) + .content(identityPayload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + private void bindPersonTag(String authToken, String tenantCode, Long personId, Long tagId) throws Exception { + String bindPayload = String.format("{\"tagIds\":[%d]}", tagId); + mockMvc.perform(post("/msg-platform/api/v1/person/" + personId + "/bind-tags") + .contextPath("/msg-platform") + .header("Authorization", authToken) + .header("X-Tenant-Code", tenantCode) + .contentType(MediaType.APPLICATION_JSON) + .content(bindPayload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + private Long createConversation(String authToken, String tenantCode, String channelType, + String channelConversationId) throws Exception { + String conversationPayload = String.format( + "{\"conversationType\":2,\"channelType\":\"%s\",\"channelConversationId\":\"%s\",\"conversationName\":\"测试群\"}", + channelType, channelConversationId); + String conversationResponse = mockMvc.perform(post("/msg-platform/api/v1/conversation") + .contextPath("/msg-platform") + .header("Authorization", authToken) + .header("X-Tenant-Code", tenantCode) + .contentType(MediaType.APPLICATION_JSON) + .content(conversationPayload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(); + return ((Number) com.jayway.jsonpath.JsonPath.read(conversationResponse, "$.data.id")).longValue(); + } + + private void bindConversationTag(String authToken, String tenantCode, Long conversationId, + Long tagId) throws Exception { + String bindPayload = String.format("{\"tagIds\":[%d]}", tagId); + mockMvc.perform(post("/msg-platform/api/v1/conversation/" + conversationId + "/bind-tags") + .contextPath("/msg-platform") + .header("Authorization", authToken) + .header("X-Tenant-Code", tenantCode) + .contentType(MediaType.APPLICATION_JSON) + .content(bindPayload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + private void bindConversationAccount(String authToken, String tenantCode, Long conversationId, + Long accountId) throws Exception { + String bindPayload = String.format("{\"accountId\":%d,\"bindingType\":1,\"sortOrder\":1}", accountId); + mockMvc.perform(post("/msg-platform/api/v1/conversation/" + conversationId + "/bind-account") + .contextPath("/msg-platform") + .header("Authorization", authToken) + .header("X-Tenant-Code", tenantCode) + .contentType(MediaType.APPLICATION_JSON) + .content(bindPayload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } }