feat(send): add outbound send and send record APIs
- Add mci_send_record table and entity/mapper
- Add SendService with target resolution (conversation/person/person_list/tag/rule)
- Add /api/v1/send, /api/v1/send/test, /api/v1/send/{task_id}/records
- Add test send simulation without channel dispatch
- Dispatch real sends through ChannelAdapterRegistry (placeholder until Phase 6)
- Add SendControllerTest and fix missing deleted column on account-conversation binding
This commit is contained in:
@@ -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<ChannelType, ChannelAdapter> adapters = new ConcurrentHashMap<>();
|
||||
|
||||
public ChannelAdapterRegistry(List<ChannelAdapter> adapterList) {
|
||||
for (ChannelAdapter adapter : adapterList) {
|
||||
adapters.put(adapter.getChannelType(), adapter);
|
||||
}
|
||||
}
|
||||
|
||||
public ChannelAdapter getAdapter(ChannelType channelType) {
|
||||
return adapters.get(channelType);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<SendResponse> send(
|
||||
@RequestHeader("X-Tenant-Code") String tenantCode,
|
||||
@Valid @RequestBody SendRequest request) {
|
||||
return ApiResponse.ok(sendService.send(tenantCode, request));
|
||||
}
|
||||
|
||||
@PostMapping("/test")
|
||||
public ApiResponse<SendResponse> testSend(
|
||||
@RequestHeader("X-Tenant-Code") String tenantCode,
|
||||
@Valid @RequestBody SendTestRequest request) {
|
||||
return ApiResponse.ok(sendService.testSend(tenantCode, request));
|
||||
}
|
||||
|
||||
@GetMapping("/{task_id}/records")
|
||||
public ApiResponse<List<SendRecordResponse>> listRecords(
|
||||
@RequestHeader("X-Tenant-Code") String tenantCode,
|
||||
@PathVariable("task_id") String taskId) {
|
||||
return ApiResponse.ok(sendService.listRecords(tenantCode, taskId));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<String, Object> targetValue;
|
||||
|
||||
@NotBlank
|
||||
private String contentType;
|
||||
|
||||
@NotNull
|
||||
private Map<String, Object> content;
|
||||
|
||||
private Map<String, Object> channelStrategy;
|
||||
|
||||
private String callbackUrl;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<String, Object> targetValue;
|
||||
|
||||
@NotBlank
|
||||
private String contentType;
|
||||
|
||||
@NotNull
|
||||
private Map<String, Object> content;
|
||||
|
||||
private Map<String, Object> channelStrategy;
|
||||
|
||||
private String callbackUrl;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<SendRecord> {
|
||||
}
|
||||
@@ -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<SendTarget> targets = resolveTargets(tenantCode, request);
|
||||
List<SendRecord> 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<SendTarget> targets = resolveTestTargets(tenantCode, request);
|
||||
List<SendRecord> records = createTestRecords(taskId, tenantCode, request, targets);
|
||||
return new SendResponse(taskId, records.size(), "simulated");
|
||||
}
|
||||
|
||||
public List<SendRecordResponse> listRecords(String tenantCode, String taskId) {
|
||||
List<SendRecord> records = sendRecordMapper.selectList(
|
||||
new LambdaQueryWrapper<SendRecord>()
|
||||
.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<SendTarget> resolveTargets(String tenantCode, SendRequest request) {
|
||||
return resolveByType(tenantCode, request.getTargetType(), request.getTargetValue(), request.getChannelStrategy());
|
||||
}
|
||||
|
||||
private List<SendTarget> resolveTestTargets(String tenantCode, SendTestRequest request) {
|
||||
return resolveByType(tenantCode, request.getTargetType(), request.getTargetValue(), request.getChannelStrategy());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<SendTarget> resolveByType(String tenantCode, String targetType, Map<String, Object> targetValue,
|
||||
Map<String, Object> 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<Number> personIds = (List<Number>) targetValue.get("person_ids");
|
||||
if (CollectionUtils.isEmpty(personIds)) {
|
||||
throw new BusinessException("person_list 目标缺少 person_ids");
|
||||
}
|
||||
List<SendTarget> 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<SendTarget> resolveConversationTarget(String tenantCode, Long conversationId,
|
||||
ChannelType channelType, Map<String, Object> 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<SendTarget> resolvePersonTarget(String tenantCode, Long personId, ChannelType channelType) {
|
||||
ChannelIdentity identity = channelIdentityMapper.selectOne(
|
||||
new LambdaQueryWrapper<ChannelIdentity>()
|
||||
.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<SendTarget> resolveTagTarget(String tenantCode, Long tagId, ChannelType channelType, String scope) {
|
||||
List<SendTarget> targets = new ArrayList<>();
|
||||
if ("all".equals(scope) || "conversation".equals(scope)) {
|
||||
List<ConversationTagBinding> convBindings = conversationTagBindingMapper.selectList(
|
||||
new LambdaQueryWrapper<ConversationTagBinding>()
|
||||
.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<PersonTagBinding> personBindings = personTagBindingMapper.selectList(
|
||||
new LambdaQueryWrapper<PersonTagBinding>()
|
||||
.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<String, Object> channelStrategy) {
|
||||
String strategy = extractStrategy(channelStrategy);
|
||||
int bindingType = "backup".equals(strategy) ? BINDING_SEND_BACKUP : BINDING_SEND_PRIMARY;
|
||||
ChannelAccountConversation binding = accountConversationMapper.selectOne(
|
||||
new LambdaQueryWrapper<ChannelAccountConversation>()
|
||||
.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<ChannelAccount> accounts = accountMapper.selectList(
|
||||
new LambdaQueryWrapper<ChannelAccount>()
|
||||
.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<String, Object> 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<String, Object> 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<SendRecord> createRecords(String taskId, String tenantCode, SendRequest request,
|
||||
List<SendTarget> targets) {
|
||||
List<SendRecord> 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<SendRecord> createTestRecords(String taskId, String tenantCode, SendTestRequest request,
|
||||
List<SendTarget> targets) {
|
||||
List<SendRecord> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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='发送记录表';
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE mci_channel_account_conversation
|
||||
ADD COLUMN deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0未删除 1已删除' AFTER status;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user