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:
2026-07-07 12:24:05 +08:00
parent 953d36fe2c
commit df2d253c7b
13 changed files with 791 additions and 1 deletions

View File

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

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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> {
}

View File

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

View File

@@ -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='发送记录表';

View File

@@ -0,0 +1,2 @@
ALTER TABLE mci_channel_account_conversation
ADD COLUMN deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除0未删除 1已删除' AFTER status;

View File

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