feat(send): 实现 outbound rule 目标解析

This commit is contained in:
2026-07-07 20:43:15 +08:00
parent d28c959494
commit f350313395
2 changed files with 525 additions and 2 deletions

View File

@@ -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<SendTarget> resolveRuleTarget(String tenantCode, Map<String, Object> targetValue,
ChannelType channelType, Map<String, Object> channelStrategy) {
RuleTargetValue ruleTarget = parseRuleTargetValue(targetValue);
List<RuleMatchResult> results = new ArrayList<>();
for (RuleCondition condition : ruleTarget.getRules()) {
results.add(evaluateRuleCondition(tenantCode, condition));
}
Set<Long> personIds = combinePersonIds(ruleTarget.getLogic(), results);
Set<Long> conversationIds = combineConversationIds(ruleTarget.getLogic(), results);
List<SendTarget> 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<String, Object> 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<RuleCondition> 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 <T, R> Set<Long> evaluateFieldCondition(String tenantCode, RuleCondition condition, BaseMapper<T> mapper,
SFunction<T, String> tenantCodeGetter, SFunction<T, Long> idGetter,
SFunction<T, R> fieldGetter, Function<Object, R> converter) {
List<R> values = normalizeValues(condition.getValue()).stream()
.map(converter)
.filter(Objects::nonNull)
.collect(Collectors.toList());
LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<T>()
.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 <T, R> void applyOperator(LambdaQueryWrapper<T> wrapper, SFunction<T, R> fieldGetter,
String operator, List<R> 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<Object> values = normalizeValues(condition.getValue());
Set<Long> tagIds = new HashSet<>();
for (Object value : values) {
if (value instanceof Number) {
tagIds.add(((Number) value).longValue());
} else if (value instanceof String) {
List<Tag> tags = tagMapper.selectList(
new LambdaQueryWrapper<Tag>()
.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<Long> personIds = personTagBindingMapper.selectList(
new LambdaQueryWrapper<PersonTagBinding>()
.eq(PersonTagBinding::getTenantCode, tenantCode)
.in(PersonTagBinding::getTagId, tagIds))
.stream()
.map(PersonTagBinding::getPersonId)
.collect(Collectors.toSet());
Set<Long> conversationIds = conversationTagBindingMapper.selectList(
new LambdaQueryWrapper<ConversationTagBinding>()
.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<Long> allPersonIds = selectAllPersonIds(tenantCode);
Set<Long> allConversationIds = selectAllConversationIds(tenantCode);
allPersonIds.removeAll(personIds);
allConversationIds.removeAll(conversationIds);
return new RuleMatchResult(allPersonIds, allConversationIds);
}
return new RuleMatchResult(personIds, conversationIds);
}
private Set<Long> selectAllPersonIds(String tenantCode) {
return personMapper.selectList(
new LambdaQueryWrapper<Person>().eq(Person::getTenantCode, tenantCode))
.stream()
.map(Person::getId)
.collect(Collectors.toSet());
}
private Set<Long> selectAllConversationIds(String tenantCode) {
return conversationMapper.selectList(
new LambdaQueryWrapper<Conversation>().eq(Conversation::getTenantCode, tenantCode))
.stream()
.map(Conversation::getId)
.collect(Collectors.toSet());
}
private Set<Long> combinePersonIds(String logic, List<RuleMatchResult> results) {
return "or".equals(logic) ? unionPersonIds(results) : intersectPersonIds(results);
}
private Set<Long> unionPersonIds(List<RuleMatchResult> results) {
Set<Long> ids = new HashSet<>();
for (RuleMatchResult result : results) {
ids.addAll(result.getPersonIds());
}
return ids;
}
private Set<Long> intersectPersonIds(List<RuleMatchResult> results) {
Set<Long> 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<Long> combineConversationIds(String logic, List<RuleMatchResult> results) {
return "or".equals(logic) ? unionConversationIds(results) : intersectConversationIds(results);
}
private Set<Long> unionConversationIds(List<RuleMatchResult> results) {
Set<Long> ids = new HashSet<>();
for (RuleMatchResult result : results) {
ids.addAll(result.getConversationIds());
}
return ids;
}
private Set<Long> intersectConversationIds(List<RuleMatchResult> results) {
Set<Long> 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<Object> 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<String, Object> 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<RuleCondition> rules;
private final String logic;
RuleTargetValue(List<RuleCondition> rules, String logic) {
this.rules = rules;
this.logic = logic;
}
public List<RuleCondition> getRules() {
return rules;
}
public String getLogic() {
return logic;
}
}
private static class RuleMatchResult {
private final Set<Long> personIds;
private final Set<Long> conversationIds;
RuleMatchResult(Set<Long> personIds, Set<Long> conversationIds) {
this.personIds = personIds;
this.conversationIds = conversationIds;
}
public Set<Long> getPersonIds() {
return personIds;
}
public Set<Long> getConversationIds() {
return conversationIds;
}
}
private static class SendTarget {
private Long conversationId;

View File

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