diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/adapter/registry/ChannelAdapterRegistry.java b/backend/mci-server/src/main/java/com/sino/mci/channel/adapter/registry/ChannelAdapterRegistry.java new file mode 100644 index 0000000..963cafe --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/channel/adapter/registry/ChannelAdapterRegistry.java @@ -0,0 +1,25 @@ +package com.sino.mci.channel.adapter.registry; + +import com.sino.mci.channel.common.model.ChannelType; +import com.sino.mci.channel.common.spi.ChannelAdapter; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class ChannelAdapterRegistry { + + private final Map adapters = new ConcurrentHashMap<>(); + + public ChannelAdapterRegistry(List adapterList) { + for (ChannelAdapter adapter : adapterList) { + adapters.put(adapter.getChannelType(), adapter); + } + } + + public ChannelAdapter getAdapter(ChannelType channelType) { + return adapters.get(channelType); + } +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/config/MybatisPlusConfig.java b/backend/mci-server/src/main/java/com/sino/mci/config/MybatisPlusConfig.java index ffb850d..0f7a15f 100644 --- a/backend/mci-server/src/main/java/com/sino/mci/config/MybatisPlusConfig.java +++ b/backend/mci-server/src/main/java/com/sino/mci/config/MybatisPlusConfig.java @@ -11,7 +11,7 @@ import org.springframework.context.annotation.Configuration; import javax.sql.DataSource; @Configuration -@MapperScan({"com.sino.mci.admin.repository", "com.sino.mci.channel.repository", "com.sino.mci.crm.repository", "com.sino.mci.flow.repository", "com.sino.mci.inbound.repository"}) +@MapperScan({"com.sino.mci.admin.repository", "com.sino.mci.channel.repository", "com.sino.mci.crm.repository", "com.sino.mci.flow.repository", "com.sino.mci.inbound.repository", "com.sino.mci.send.repository"}) public class MybatisPlusConfig { @Bean diff --git a/backend/mci-server/src/main/java/com/sino/mci/send/controller/SendController.java b/backend/mci-server/src/main/java/com/sino/mci/send/controller/SendController.java new file mode 100644 index 0000000..5e31089 --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/send/controller/SendController.java @@ -0,0 +1,42 @@ +package com.sino.mci.send.controller; + +import com.sino.mci.send.dto.SendRecordResponse; +import com.sino.mci.send.dto.SendRequest; +import com.sino.mci.send.dto.SendResponse; +import com.sino.mci.send.dto.SendTestRequest; +import com.sino.mci.send.service.SendService; +import com.sino.mci.shared.web.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/send") +@RequiredArgsConstructor +public class SendController { + + private final SendService sendService; + + @PostMapping + public ApiResponse send( + @RequestHeader("X-Tenant-Code") String tenantCode, + @Valid @RequestBody SendRequest request) { + return ApiResponse.ok(sendService.send(tenantCode, request)); + } + + @PostMapping("/test") + public ApiResponse testSend( + @RequestHeader("X-Tenant-Code") String tenantCode, + @Valid @RequestBody SendTestRequest request) { + return ApiResponse.ok(sendService.testSend(tenantCode, request)); + } + + @GetMapping("/{task_id}/records") + public ApiResponse> listRecords( + @RequestHeader("X-Tenant-Code") String tenantCode, + @PathVariable("task_id") String taskId) { + return ApiResponse.ok(sendService.listRecords(tenantCode, taskId)); + } +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/send/dto/SendRecordResponse.java b/backend/mci-server/src/main/java/com/sino/mci/send/dto/SendRecordResponse.java new file mode 100644 index 0000000..4ea24d6 --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/send/dto/SendRecordResponse.java @@ -0,0 +1,23 @@ +package com.sino.mci.send.dto; + +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class SendRecordResponse { + + private Long id; + private String taskId; + private String targetType; + private String targetValue; + private Long conversationId; + private Long personId; + private Long accountId; + private String channelType; + private String contentType; + private Integer sendStatus; + private String channelResult; + private LocalDateTime sendTime; + private LocalDateTime createTime; +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/send/dto/SendRequest.java b/backend/mci-server/src/main/java/com/sino/mci/send/dto/SendRequest.java new file mode 100644 index 0000000..5134900 --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/send/dto/SendRequest.java @@ -0,0 +1,27 @@ +package com.sino.mci.send.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.Map; + +@Data +public class SendRequest { + + @NotBlank + private String targetType; + + @NotNull + private Map targetValue; + + @NotBlank + private String contentType; + + @NotNull + private Map content; + + private Map channelStrategy; + + private String callbackUrl; +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/send/dto/SendResponse.java b/backend/mci-server/src/main/java/com/sino/mci/send/dto/SendResponse.java new file mode 100644 index 0000000..6fa8135 --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/send/dto/SendResponse.java @@ -0,0 +1,15 @@ +package com.sino.mci.send.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SendResponse { + + private String taskId; + private Integer totalCount; + private String status; +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/send/dto/SendTestRequest.java b/backend/mci-server/src/main/java/com/sino/mci/send/dto/SendTestRequest.java new file mode 100644 index 0000000..9ddc680 --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/send/dto/SendTestRequest.java @@ -0,0 +1,27 @@ +package com.sino.mci.send.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.Map; + +@Data +public class SendTestRequest { + + @NotBlank + private String targetType; + + @NotNull + private Map targetValue; + + @NotBlank + private String contentType; + + @NotNull + private Map content; + + private Map channelStrategy; + + private String callbackUrl; +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/send/entity/SendRecord.java b/backend/mci-server/src/main/java/com/sino/mci/send/entity/SendRecord.java new file mode 100644 index 0000000..e330deb --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/send/entity/SendRecord.java @@ -0,0 +1,32 @@ +package com.sino.mci.send.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.sino.mci.shared.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("mci_send_record") +public class SendRecord extends BaseEntity { + + private String taskId; + private String tenantCode; + private String targetType; + private String targetValue; + private String audienceRule; + private Long conversationId; + private Long personId; + private Long accountId; + private String channelType; + private String channelStrategy; + private String contentType; + private String contentBody; + private String callbackUrl; + private Integer sendStatus; + private String channelResult; + private LocalDateTime sendTime; + private LocalDateTime finishTime; +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/send/repository/SendRecordMapper.java b/backend/mci-server/src/main/java/com/sino/mci/send/repository/SendRecordMapper.java new file mode 100644 index 0000000..65581a2 --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/send/repository/SendRecordMapper.java @@ -0,0 +1,9 @@ +package com.sino.mci.send.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.sino.mci.send.entity.SendRecord; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SendRecordMapper extends BaseMapper { +} 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 new file mode 100644 index 0000000..e979fcb --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/send/service/SendService.java @@ -0,0 +1,447 @@ +package com.sino.mci.send.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.sino.mci.channel.adapter.registry.ChannelAdapterRegistry; +import com.sino.mci.channel.common.dto.SendMessageRequest; +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.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.entity.ConversationTagBinding; +import com.sino.mci.crm.entity.ChannelIdentity; +import com.sino.mci.crm.entity.PersonTagBinding; +import com.sino.mci.channel.repository.ConversationTagBindingMapper; +import com.sino.mci.crm.repository.ChannelIdentityMapper; +import com.sino.mci.crm.repository.PersonTagBindingMapper; +import com.sino.mci.exception.BusinessException; +import com.sino.mci.send.dto.SendRecordResponse; +import com.sino.mci.send.dto.SendRequest; +import com.sino.mci.send.dto.SendResponse; +import com.sino.mci.send.dto.SendTestRequest; +import com.sino.mci.send.entity.SendRecord; +import com.sino.mci.send.repository.SendRecordMapper; +import com.sino.mci.shared.util.JsonUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SendService { + + private static final int BINDING_SEND_PRIMARY = 1; + private static final int BINDING_SEND_BACKUP = 2; + private static final int STATUS_ENABLED = 1; + private static final int SEND_STATUS_PENDING = 0; + private static final int SEND_STATUS_SENDING = 1; + private static final int SEND_STATUS_SUCCESS = 2; + private static final int SEND_STATUS_FAILED = 3; + + private final SendRecordMapper sendRecordMapper; + private final ConversationMapper conversationMapper; + private final ChannelAccountMapper accountMapper; + private final ChannelAccountConversationMapper accountConversationMapper; + private final ChannelIdentityMapper channelIdentityMapper; + private final PersonTagBindingMapper personTagBindingMapper; + private final ConversationTagBindingMapper conversationTagBindingMapper; + private final ChannelAdapterRegistry adapterRegistry; + + @Transactional + public SendResponse send(String tenantCode, SendRequest request) { + String taskId = generateTaskId(); + List targets = resolveTargets(tenantCode, request); + List records = createRecords(taskId, tenantCode, request, targets); + + int successCount = 0; + int failedCount = 0; + for (SendRecord record : records) { + dispatchRecord(record); + if (record.getSendStatus() == SEND_STATUS_SUCCESS) { + successCount++; + } else if (record.getSendStatus() == SEND_STATUS_FAILED) { + failedCount++; + } + } + + String status = failedCount == 0 ? "success" : (successCount == 0 ? "failed" : "partial"); + return new SendResponse(taskId, records.size(), status); + } + + @Transactional + public SendResponse testSend(String tenantCode, SendTestRequest request) { + String taskId = generateTaskId(); + List targets = resolveTestTargets(tenantCode, request); + List records = createTestRecords(taskId, tenantCode, request, targets); + return new SendResponse(taskId, records.size(), "simulated"); + } + + public List listRecords(String tenantCode, String taskId) { + List records = sendRecordMapper.selectList( + new LambdaQueryWrapper() + .eq(SendRecord::getTenantCode, tenantCode) + .eq(SendRecord::getTaskId, taskId) + .orderByAsc(SendRecord::getId)); + return records.stream().map(this::toResponse).collect(Collectors.toList()); + } + + private String generateTaskId() { + return "SND" + UUID.randomUUID().toString().replace("-", "").toUpperCase(); + } + + private List resolveTargets(String tenantCode, SendRequest request) { + return resolveByType(tenantCode, request.getTargetType(), request.getTargetValue(), request.getChannelStrategy()); + } + + private List resolveTestTargets(String tenantCode, SendTestRequest request) { + return resolveByType(tenantCode, request.getTargetType(), request.getTargetValue(), request.getChannelStrategy()); + } + + @SuppressWarnings("unchecked") + private List resolveByType(String tenantCode, String targetType, Map targetValue, + Map channelStrategy) { + String channelTypeStr = extractChannelType(channelStrategy); + ChannelType channelType = parseChannelType(channelTypeStr); + + switch (targetType) { + case "conversation": + Number convId = (Number) targetValue.get("conversation_id"); + if (convId == null) { + throw new BusinessException("conversation 目标缺少 conversation_id"); + } + return resolveConversationTarget(tenantCode, convId.longValue(), channelType, channelStrategy); + case "person": + Number personId = (Number) targetValue.get("person_id"); + if (personId == null) { + throw new BusinessException("person 目标缺少 person_id"); + } + return resolvePersonTarget(tenantCode, personId.longValue(), channelType); + case "person_list": + List personIds = (List) targetValue.get("person_ids"); + if (CollectionUtils.isEmpty(personIds)) { + throw new BusinessException("person_list 目标缺少 person_ids"); + } + List targets = new ArrayList<>(); + for (Number pid : personIds) { + targets.addAll(resolvePersonTarget(tenantCode, pid.longValue(), channelType)); + } + 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); + case "rule": + throw new BusinessException("rule 目标解析尚未实现"); + default: + throw new BusinessException("不支持的目标类型: " + targetType); + } + } + + private List resolveConversationTarget(String tenantCode, Long conversationId, + ChannelType channelType, Map channelStrategy) { + Conversation conversation = conversationMapper.selectById(conversationId); + if (conversation == null || !tenantCode.equals(conversation.getTenantCode())) { + throw new BusinessException("会话不存在: " + conversationId); + } + Long accountId = selectSendAccount(conversationId, channelType, channelStrategy); + if (accountId == null) { + accountId = conversation.getOwnerAccountId(); + } + if (accountId == null) { + throw new BusinessException("会话未配置发送账号: " + conversationId); + } + SendTarget target = new SendTarget(); + target.setConversationId(conversationId); + target.setPersonId(null); + target.setAccountId(accountId); + target.setChannelType(channelType); + target.setChannelConversationId(conversation.getChannelConversationId()); + target.setReceiverId(conversation.getChannelConversationId()); + return Collections.singletonList(target); + } + + private List resolvePersonTarget(String tenantCode, Long personId, ChannelType channelType) { + ChannelIdentity identity = channelIdentityMapper.selectOne( + new LambdaQueryWrapper() + .eq(ChannelIdentity::getTenantCode, tenantCode) + .eq(ChannelIdentity::getPersonId, personId) + .eq(ChannelIdentity::getChannelType, channelType.name().toLowerCase()) + .eq(ChannelIdentity::getStatus, STATUS_ENABLED)); + if (identity == null) { + throw new BusinessException("人员未配置渠道身份: person_id=" + personId + ", channel=" + channelType.name().toLowerCase()); + } + Long accountId = selectDefaultAccount(tenantCode, channelType); + if (accountId == null) { + throw new BusinessException("租户未配置可用发送账号: channel=" + channelType.name().toLowerCase()); + } + SendTarget target = new SendTarget(); + target.setConversationId(null); + target.setPersonId(personId); + target.setAccountId(accountId); + target.setChannelType(channelType); + target.setChannelConversationId(null); + target.setReceiverId(identity.getChannelUserId()); + return Collections.singletonList(target); + } + + private List resolveTagTarget(String tenantCode, Long tagId, ChannelType channelType, String scope) { + List targets = new ArrayList<>(); + 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, + 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)); + } + } + return targets; + } + + private Long selectSendAccount(Long conversationId, ChannelType channelType, Map channelStrategy) { + String strategy = extractStrategy(channelStrategy); + int bindingType = "backup".equals(strategy) ? BINDING_SEND_BACKUP : BINDING_SEND_PRIMARY; + ChannelAccountConversation binding = accountConversationMapper.selectOne( + new LambdaQueryWrapper() + .eq(ChannelAccountConversation::getConversationId, conversationId) + .eq(ChannelAccountConversation::getBindingType, bindingType) + .eq(ChannelAccountConversation::getStatus, STATUS_ENABLED)); + if (binding != null) { + ChannelAccount account = accountMapper.selectById(binding.getAccountId()); + if (account != null && STATUS_ENABLED == account.getStatus() + && channelType.name().equalsIgnoreCase(account.getChannelType())) { + return account.getId(); + } + } + return null; + } + + private Long selectDefaultAccount(String tenantCode, ChannelType channelType) { + 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(); + } + + private String extractChannelType(Map channelStrategy) { + if (channelStrategy == null) { + return "wecom"; + } + Object value = channelStrategy.get("channel_type"); + if (value == null) { + value = channelStrategy.get("channelType"); + } + return value != null ? String.valueOf(value) : "wecom"; + } + + private String extractStrategy(Map channelStrategy) { + if (channelStrategy == null) { + return "primary"; + } + Object value = channelStrategy.get("strategy"); + return value != null ? String.valueOf(value) : "primary"; + } + + private ChannelType parseChannelType(String channelTypeStr) { + if (!StringUtils.hasText(channelTypeStr)) { + return ChannelType.WECOM; + } + try { + return ChannelType.valueOf(channelTypeStr.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new BusinessException("不支持的渠道类型: " + channelTypeStr); + } + } + + private List createRecords(String taskId, String tenantCode, SendRequest request, + List targets) { + List records = new ArrayList<>(); + for (SendTarget target : targets) { + SendRecord record = new SendRecord(); + record.setTaskId(taskId); + record.setTenantCode(tenantCode); + record.setTargetType(request.getTargetType()); + record.setTargetValue(JsonUtils.toJson(request.getTargetValue())); + record.setConversationId(target.getConversationId()); + record.setPersonId(target.getPersonId()); + record.setAccountId(target.getAccountId()); + record.setChannelType(target.getChannelType().name().toLowerCase()); + record.setChannelStrategy(JsonUtils.toJson(request.getChannelStrategy())); + record.setContentType(request.getContentType()); + record.setContentBody(JsonUtils.toJson(request.getContent())); + record.setCallbackUrl(request.getCallbackUrl()); + record.setSendStatus(SEND_STATUS_PENDING); + record.setSendTime(LocalDateTime.now()); + sendRecordMapper.insert(record); + records.add(record); + } + return records; + } + + private List createTestRecords(String taskId, String tenantCode, SendTestRequest request, + List targets) { + List records = new ArrayList<>(); + for (SendTarget target : targets) { + SendRecord record = new SendRecord(); + record.setTaskId(taskId); + record.setTenantCode(tenantCode); + record.setTargetType(request.getTargetType()); + record.setTargetValue(JsonUtils.toJson(request.getTargetValue())); + record.setConversationId(target.getConversationId()); + record.setPersonId(target.getPersonId()); + record.setAccountId(target.getAccountId()); + record.setChannelType(target.getChannelType().name().toLowerCase()); + record.setChannelStrategy(JsonUtils.toJson(request.getChannelStrategy())); + record.setContentType(request.getContentType()); + record.setContentBody(JsonUtils.toJson(request.getContent())); + record.setCallbackUrl(request.getCallbackUrl()); + record.setSendStatus(SEND_STATUS_SUCCESS); + record.setChannelResult(JsonUtils.toJson(Collections.singletonMap("simulated", true))); + record.setSendTime(LocalDateTime.now()); + record.setFinishTime(LocalDateTime.now()); + sendRecordMapper.insert(record); + records.add(record); + } + return records; + } + + private void dispatchRecord(SendRecord record) { + ChannelType channelType = parseChannelType(record.getChannelType()); + ChannelAdapter adapter = adapterRegistry.getAdapter(channelType); + if (adapter == null) { + record.setSendStatus(SEND_STATUS_FAILED); + record.setChannelResult(JsonUtils.toJson(Collections.singletonMap("error", "渠道适配器未实现: " + channelType))); + record.setFinishTime(LocalDateTime.now()); + sendRecordMapper.updateById(record); + return; + } + + record.setSendStatus(SEND_STATUS_SENDING); + sendRecordMapper.updateById(record); + + try { + SendMessageRequest messageRequest = new SendMessageRequest(); + messageRequest.setConversationId(record.getConversationId() != null ? String.valueOf(record.getConversationId()) : null); + messageRequest.setReceiverId(record.getPersonId() != null ? String.valueOf(record.getPersonId()) : null); + messageRequest.setContentType(record.getContentType()); + messageRequest.setContent(JsonUtils.fromJson(record.getContentBody(), Map.class)); + + SendMessageResult result = adapter.sendMessage(messageRequest); + record.setSendStatus(result.isSuccess() ? SEND_STATUS_SUCCESS : SEND_STATUS_FAILED); + record.setChannelResult(JsonUtils.toJson(result)); + } catch (Exception e) { + log.warn("发送消息失败: task_id={}, record_id={}", record.getTaskId(), record.getId(), e); + record.setSendStatus(SEND_STATUS_FAILED); + record.setChannelResult(JsonUtils.toJson(Collections.singletonMap("error", e.getMessage()))); + } + record.setFinishTime(LocalDateTime.now()); + sendRecordMapper.updateById(record); + } + + private SendRecordResponse toResponse(SendRecord record) { + SendRecordResponse response = new SendRecordResponse(); + response.setId(record.getId()); + response.setTaskId(record.getTaskId()); + response.setTargetType(record.getTargetType()); + response.setTargetValue(record.getTargetValue()); + response.setConversationId(record.getConversationId()); + response.setPersonId(record.getPersonId()); + response.setAccountId(record.getAccountId()); + response.setChannelType(record.getChannelType()); + response.setContentType(record.getContentType()); + response.setSendStatus(record.getSendStatus()); + response.setChannelResult(record.getChannelResult()); + response.setSendTime(record.getSendTime()); + response.setCreateTime(record.getCreateTime()); + return response; + } + + private static class SendTarget { + + private Long conversationId; + private Long personId; + private Long accountId; + private ChannelType channelType; + private String channelConversationId; + private String receiverId; + + public Long getConversationId() { + return conversationId; + } + + public void setConversationId(Long conversationId) { + this.conversationId = conversationId; + } + + public Long getPersonId() { + return personId; + } + + public void setPersonId(Long personId) { + this.personId = personId; + } + + public Long getAccountId() { + return accountId; + } + + public void setAccountId(Long accountId) { + this.accountId = accountId; + } + + public ChannelType getChannelType() { + return channelType; + } + + public void setChannelType(ChannelType channelType) { + this.channelType = channelType; + } + + public String getChannelConversationId() { + return channelConversationId; + } + + public void setChannelConversationId(String channelConversationId) { + this.channelConversationId = channelConversationId; + } + + public String getReceiverId() { + return receiverId; + } + + public void setReceiverId(String receiverId) { + this.receiverId = receiverId; + } + } +} diff --git a/backend/mci-server/src/main/resources/db/migration/V6__init_send_record.sql b/backend/mci-server/src/main/resources/db/migration/V6__init_send_record.sql new file mode 100644 index 0000000..176933c --- /dev/null +++ b/backend/mci-server/src/main/resources/db/migration/V6__init_send_record.sql @@ -0,0 +1,27 @@ +CREATE TABLE IF NOT EXISTS mci_send_record ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + task_id VARCHAR(64) NOT NULL COMMENT '发送任务 ID,一次 API 调用一个 task_id', + tenant_code VARCHAR(64) NOT NULL COMMENT '所属租户/业务系统', + target_type VARCHAR(32) NOT NULL COMMENT '目标类型:person / person_list / conversation / tag / rule', + target_value JSON COMMENT '目标值:具体 ID 列表', + audience_rule JSON COMMENT '受众规则:标签/属性组合条件', + conversation_id BIGINT COMMENT '实际发送到的会话 ID', + person_id BIGINT COMMENT '实际发送到的联系人 ID', + account_id BIGINT COMMENT '实际使用的发送账号 ID', + channel_type VARCHAR(32) COMMENT '实际使用的渠道', + channel_strategy JSON COMMENT '渠道策略', + content_type VARCHAR(32) NOT NULL COMMENT '内容类型:text / markdown / image / file 等', + content_body TEXT NOT NULL COMMENT '实际发送内容', + callback_url VARCHAR(512) COMMENT '发送结果回调地址(任务级)', + send_status TINYINT NOT NULL DEFAULT 0 COMMENT '发送状态:0待发送 1发送中 2成功 3失败', + channel_result JSON COMMENT '渠道返回结果', + send_time DATETIME COMMENT '实际发送时间', + finish_time DATETIME COMMENT '最终完成时间', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted TINYINT NOT NULL DEFAULT 0, + UNIQUE KEY uk_task_target (task_id, conversation_id, person_id), + KEY idx_tenant_task (tenant_code, task_id), + KEY idx_tenant_time (tenant_code, create_time), + KEY idx_conversation_time (conversation_id, create_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='发送记录表'; diff --git a/backend/mci-server/src/main/resources/db/migration/V7__fix_channel_account_conversation_deleted.sql b/backend/mci-server/src/main/resources/db/migration/V7__fix_channel_account_conversation_deleted.sql new file mode 100644 index 0000000..23d513c --- /dev/null +++ b/backend/mci-server/src/main/resources/db/migration/V7__fix_channel_account_conversation_deleted.sql @@ -0,0 +1,2 @@ +ALTER TABLE mci_channel_account_conversation + ADD COLUMN deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0未删除 1已删除' AFTER status; 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 new file mode 100644 index 0000000..8e5ae32 --- /dev/null +++ b/backend/mci-server/src/test/java/com/sino/mci/send/controller/SendControllerTest.java @@ -0,0 +1,114 @@ +package com.sino.mci.send.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +public class SendControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void shouldTestSendToConversationAndQueryRecords() throws Exception { + String tenantCode = "send-" + UUID.randomUUID().toString().substring(0, 8); + + String tenantPayload = String.format("{\"tenantCode\":\"%s\",\"tenantName\":\"发送测试\"}", tenantCode); + mockMvc.perform(post("/msg-platform/api/v1/account/tenant") + .contextPath("/msg-platform") + .contentType(MediaType.APPLICATION_JSON) + .content(tenantPayload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + String accountPayload = "{\"accountType\":1,\"channelType\":\"wecom\",\"accountId\":\"ZhangSan\",\"accountName\":\"张三\"}"; + String accountResponse = mockMvc.perform(post("/msg-platform/api/v1/channel-account") + .contextPath("/msg-platform") + .header("X-Tenant-Code", tenantCode) + .contentType(MediaType.APPLICATION_JSON) + .content(accountPayload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(); + Long accountId = ((Number) com.jayway.jsonpath.JsonPath.read(accountResponse, "$.data.id")).longValue(); + + String conversationPayload = "{\"conversationType\":2,\"channelType\":\"wecom\",\"channelConversationId\":\"room-001\",\"conversationName\":\"测试群\"}"; + String conversationResponse = mockMvc.perform(post("/msg-platform/api/v1/conversation") + .contextPath("/msg-platform") + .header("X-Tenant-Code", tenantCode) + .contentType(MediaType.APPLICATION_JSON) + .content(conversationPayload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(); + Long conversationId = ((Number) com.jayway.jsonpath.JsonPath.read(conversationResponse, "$.data.id")).longValue(); + + 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("X-Tenant-Code", tenantCode) + .contentType(MediaType.APPLICATION_JSON) + .content(bindPayload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + String sendPayload = String.format( + "{\"targetType\":\"conversation\",\"targetValue\":{\"conversation_id\":%d},\"contentType\":\"text\",\"content\":{\"text\":\"hello\"},\"channelStrategy\":{\"channel_type\":\"wecom\",\"strategy\":\"primary\"}}", + conversationId); + String sendResponse = mockMvc.perform(post("/msg-platform/api/v1/send/test") + .contextPath("/msg-platform") + .header("X-Tenant-Code", tenantCode) + .contentType(MediaType.APPLICATION_JSON) + .content(sendPayload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.status").value("simulated")) + .andReturn().getResponse().getContentAsString(); + String taskId = com.jayway.jsonpath.JsonPath.read(sendResponse, "$.data.taskId"); + + mockMvc.perform(get("/msg-platform/api/v1/send/" + taskId + "/records") + .contextPath("/msg-platform") + .header("X-Tenant-Code", tenantCode)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].sendStatus").value(2)) + .andExpect(jsonPath("$.data[0].channelType").value("wecom")); + } + + @Test + void shouldRejectUnsupportedTargetType() throws Exception { + String tenantCode = "send-" + UUID.randomUUID().toString().substring(0, 8); + String tenantPayload = String.format("{\"tenantCode\":\"%s\",\"tenantName\":\"发送测试\"}", tenantCode); + mockMvc.perform(post("/msg-platform/api/v1/account/tenant") + .contextPath("/msg-platform") + .contentType(MediaType.APPLICATION_JSON) + .content(tenantPayload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + String sendPayload = "{\"targetType\":\"rule\",\"targetValue\":{},\"contentType\":\"text\",\"content\":{\"text\":\"hello\"}}"; + mockMvc.perform(post("/msg-platform/api/v1/send/test") + .contextPath("/msg-platform") + .header("X-Tenant-Code", tenantCode) + .contentType(MediaType.APPLICATION_JSON) + .content(sendPayload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(-1)); + } +}