diff --git a/backend/mci-server/src/main/java/com/sino/mci/MciServerApplication.java b/backend/mci-server/src/main/java/com/sino/mci/MciServerApplication.java index 48f300f..cc1efe0 100644 --- a/backend/mci-server/src/main/java/com/sino/mci/MciServerApplication.java +++ b/backend/mci-server/src/main/java/com/sino/mci/MciServerApplication.java @@ -2,7 +2,9 @@ package com.sino.mci; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; +@EnableAsync @SpringBootApplication public class MciServerApplication { 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 390b95e..ffb850d 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"}) +@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"}) public class MybatisPlusConfig { @Bean diff --git a/backend/mci-server/src/main/java/com/sino/mci/flow/controller/FlowDefinitionController.java b/backend/mci-server/src/main/java/com/sino/mci/flow/controller/FlowDefinitionController.java new file mode 100644 index 0000000..d54a367 --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/flow/controller/FlowDefinitionController.java @@ -0,0 +1,59 @@ +package com.sino.mci.flow.controller; + +import com.sino.mci.flow.dto.CreateFlowRequest; +import com.sino.mci.flow.service.FlowDefinitionService; +import com.sino.mci.shared.web.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/inbound/flow") +@RequiredArgsConstructor +public class FlowDefinitionController { + + private final FlowDefinitionService flowDefinitionService; + + @PostMapping + public ApiResponse createFlow( + @RequestHeader("X-Tenant-Code") String tenantCode, + @Valid @RequestBody CreateFlowRequest request) { + return ApiResponse.ok(flowDefinitionService.createFlow(tenantCode, request)); + } + + @GetMapping("/{flow_code}") + public ApiResponse getFlow( + @RequestHeader("X-Tenant-Code") String tenantCode, + @PathVariable("flow_code") String flowCode) { + return ApiResponse.ok(flowDefinitionService.getFlow(tenantCode, flowCode)); + } + + @GetMapping("/list") + public ApiResponse listFlows(@RequestHeader("X-Tenant-Code") String tenantCode) { + return ApiResponse.ok(flowDefinitionService.listFlows(tenantCode)); + } + + @PostMapping("/{flow_code}") + public ApiResponse updateFlow( + @RequestHeader("X-Tenant-Code") String tenantCode, + @PathVariable("flow_code") String flowCode, + @Valid @RequestBody CreateFlowRequest request) { + return ApiResponse.ok(flowDefinitionService.updateFlow(tenantCode, flowCode, request)); + } + + @PostMapping("/{flow_code}/publish") + public ApiResponse publishFlow( + @RequestHeader("X-Tenant-Code") String tenantCode, + @PathVariable("flow_code") String flowCode) { + flowDefinitionService.publishFlow(tenantCode, flowCode); + return ApiResponse.ok(); + } + + @PostMapping("/{flow_code}/offline") + public ApiResponse offlineFlow( + @RequestHeader("X-Tenant-Code") String tenantCode, + @PathVariable("flow_code") String flowCode) { + flowDefinitionService.offlineFlow(tenantCode, flowCode); + return ApiResponse.ok(); + } +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/flow/dto/CreateFlowRequest.java b/backend/mci-server/src/main/java/com/sino/mci/flow/dto/CreateFlowRequest.java new file mode 100644 index 0000000..451c0c4 --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/flow/dto/CreateFlowRequest.java @@ -0,0 +1,23 @@ +package com.sino.mci.flow.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.Map; + +@Data +public class CreateFlowRequest { + + @NotBlank + private String flowCode; + + @NotBlank + private String flowName; + + @NotBlank + private String flowType; + + @NotNull + private Map definitionJson; +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/flow/entity/FlowDefinition.java b/backend/mci-server/src/main/java/com/sino/mci/flow/entity/FlowDefinition.java new file mode 100644 index 0000000..0e205ee --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/flow/entity/FlowDefinition.java @@ -0,0 +1,19 @@ +package com.sino.mci.flow.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.sino.mci.shared.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("mci_flow_definition") +public class FlowDefinition extends BaseEntity { + + private String tenantCode; + private String flowCode; + private String flowName; + private String flowType; + private String definitionJson; + private Integer status; +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/flow/repository/FlowDefinitionMapper.java b/backend/mci-server/src/main/java/com/sino/mci/flow/repository/FlowDefinitionMapper.java new file mode 100644 index 0000000..de77266 --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/flow/repository/FlowDefinitionMapper.java @@ -0,0 +1,9 @@ +package com.sino.mci.flow.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.sino.mci.flow.entity.FlowDefinition; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface FlowDefinitionMapper extends BaseMapper { +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/flow/service/FlowDefinitionService.java b/backend/mci-server/src/main/java/com/sino/mci/flow/service/FlowDefinitionService.java new file mode 100644 index 0000000..61ca08c --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/flow/service/FlowDefinitionService.java @@ -0,0 +1,84 @@ +package com.sino.mci.flow.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.sino.mci.exception.BusinessException; +import com.sino.mci.flow.dto.CreateFlowRequest; +import com.sino.mci.flow.entity.FlowDefinition; +import com.sino.mci.flow.repository.FlowDefinitionMapper; +import com.sino.mci.shared.util.JsonUtils; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class FlowDefinitionService { + + private final FlowDefinitionMapper flowDefinitionMapper; + + @Transactional + public FlowDefinition createFlow(String tenantCode, CreateFlowRequest request) { + FlowDefinition existing = flowDefinitionMapper.selectOne( + new LambdaQueryWrapper() + .eq(FlowDefinition::getTenantCode, tenantCode) + .eq(FlowDefinition::getFlowCode, request.getFlowCode())); + if (existing != null) { + throw new BusinessException("流程编码已存在: " + request.getFlowCode()); + } + + FlowDefinition flow = new FlowDefinition(); + flow.setTenantCode(tenantCode); + flow.setFlowCode(request.getFlowCode()); + flow.setFlowName(request.getFlowName()); + flow.setFlowType(request.getFlowType()); + flow.setDefinitionJson(JsonUtils.toJson(request.getDefinitionJson())); + flow.setStatus(0); + flowDefinitionMapper.insert(flow); + return flow; + } + + public FlowDefinition getFlow(String tenantCode, String flowCode) { + FlowDefinition flow = flowDefinitionMapper.selectOne( + new LambdaQueryWrapper() + .eq(FlowDefinition::getTenantCode, tenantCode) + .eq(FlowDefinition::getFlowCode, flowCode)); + if (flow == null) { + throw new BusinessException("流程不存在: " + flowCode); + } + return flow; + } + + public List listFlows(String tenantCode) { + return flowDefinitionMapper.selectList( + new LambdaQueryWrapper() + .eq(FlowDefinition::getTenantCode, tenantCode) + .eq(FlowDefinition::getDeleted, 0) + .orderByDesc(FlowDefinition::getCreateTime)); + } + + @Transactional + public FlowDefinition updateFlow(String tenantCode, String flowCode, CreateFlowRequest request) { + FlowDefinition flow = getFlow(tenantCode, flowCode); + flow.setFlowName(request.getFlowName()); + flow.setFlowType(request.getFlowType()); + flow.setDefinitionJson(JsonUtils.toJson(request.getDefinitionJson())); + flowDefinitionMapper.updateById(flow); + return flow; + } + + @Transactional + public void publishFlow(String tenantCode, String flowCode) { + FlowDefinition flow = getFlow(tenantCode, flowCode); + flow.setStatus(1); + flowDefinitionMapper.updateById(flow); + } + + @Transactional + public void offlineFlow(String tenantCode, String flowCode) { + FlowDefinition flow = getFlow(tenantCode, flowCode); + flow.setStatus(0); + flowDefinitionMapper.updateById(flow); + } +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/inbound/controller/InboundMessageController.java b/backend/mci-server/src/main/java/com/sino/mci/inbound/controller/InboundMessageController.java new file mode 100644 index 0000000..11ddc6c --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/inbound/controller/InboundMessageController.java @@ -0,0 +1,23 @@ +package com.sino.mci.inbound.controller; + +import com.sino.mci.inbound.dto.ReceiveMessageRequest; +import com.sino.mci.inbound.service.InboundMessageService; +import com.sino.mci.shared.web.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/inbound") +@RequiredArgsConstructor +public class InboundMessageController { + + private final InboundMessageService inboundMessageService; + + @PostMapping("/receive") + public ApiResponse receiveMessage( + @RequestHeader("X-Tenant-Code") String tenantCode, + @Valid @RequestBody ReceiveMessageRequest request) { + return ApiResponse.ok(inboundMessageService.receiveMessage(tenantCode, request)); + } +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/inbound/dto/ReceiveMessageRequest.java b/backend/mci-server/src/main/java/com/sino/mci/inbound/dto/ReceiveMessageRequest.java new file mode 100644 index 0000000..b553bd3 --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/inbound/dto/ReceiveMessageRequest.java @@ -0,0 +1,32 @@ +package com.sino.mci.inbound.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +import java.util.Map; + +@Data +public class ReceiveMessageRequest { + + @NotBlank + private String eventId; + + @NotBlank + private String channelType; + + private Long channelSubjectId; + + @NotBlank + private String eventType; + + @NotBlank + private String messageSource; + + private Long conversationId; + private Long personId; + private Long accountId; + + private Map content; + private Map rawPayload; + private Map businessContext; +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/inbound/engine/FlowEngine.java b/backend/mci-server/src/main/java/com/sino/mci/inbound/engine/FlowEngine.java new file mode 100644 index 0000000..58d623d --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/inbound/engine/FlowEngine.java @@ -0,0 +1,73 @@ +package com.sino.mci.inbound.engine; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.sino.mci.flow.entity.FlowDefinition; +import com.sino.mci.flow.repository.FlowDefinitionMapper; +import com.sino.mci.inbound.entity.MessageEvent; +import com.sino.mci.inbound.repository.MessageEventMapper; +import com.sino.mci.shared.util.JsonUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FlowEngine { + + private final FlowDefinitionMapper flowDefinitionMapper; + private final MessageEventMapper messageEventMapper; + private final IntentRecognitionService intentRecognitionService; + private final WebhookPushService webhookPushService; + + public void executeInboundFlow(Long eventId) { + MessageEvent event = messageEventMapper.selectById(eventId); + if (event == null) { + log.warn("Message event not found: {}", eventId); + return; + } + + FlowDefinition flow = findPublishedFlow(event.getTenantCode()); + if (flow == null) { + log.info("No published inbound flow for tenant {}, skipping flow execution", event.getTenantCode()); + markProcessed(event); + return; + } + + try { + Map definition = JsonUtils.fromJson(flow.getDefinitionJson(), Map.class); + if (definition == null) { + log.warn("Invalid flow definition JSON for flow {}", flow.getFlowCode()); + markProcessed(event); + return; + } + + IntentRecognitionService.IntentResult intent = intentRecognitionService.recognize(event); + log.info("Intent recognized: eventId={}, intent={}, confidence={}", + event.getEventId(), intent.intent(), intent.confidence()); + + webhookPushService.pushToBusiness(event.getTenantCode(), event, intent); + markProcessed(event); + } catch (Exception e) { + log.error("Flow execution failed for event {}", eventId, e); + } + } + + private FlowDefinition findPublishedFlow(String tenantCode) { + List flows = flowDefinitionMapper.selectList( + new LambdaQueryWrapper() + .eq(FlowDefinition::getTenantCode, tenantCode) + .eq(FlowDefinition::getFlowType, "inbound") + .eq(FlowDefinition::getStatus, 1) + .orderByDesc(FlowDefinition::getUpdateTime)); + return flows.isEmpty() ? null : flows.get(0); + } + + private void markProcessed(MessageEvent event) { + event.setIsProcessed(1); + messageEventMapper.updateById(event); + } +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/inbound/engine/IntentRecognitionService.java b/backend/mci-server/src/main/java/com/sino/mci/inbound/engine/IntentRecognitionService.java new file mode 100644 index 0000000..fd06df4 --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/inbound/engine/IntentRecognitionService.java @@ -0,0 +1,11 @@ +package com.sino.mci.inbound.engine; + +import com.sino.mci.inbound.entity.MessageEvent; + +public interface IntentRecognitionService { + + IntentResult recognize(MessageEvent event); + + record IntentResult(String intent, double confidence, String reason) { + } +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/inbound/engine/MockIntentRecognitionService.java b/backend/mci-server/src/main/java/com/sino/mci/inbound/engine/MockIntentRecognitionService.java new file mode 100644 index 0000000..c3e265d --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/inbound/engine/MockIntentRecognitionService.java @@ -0,0 +1,43 @@ +package com.sino.mci.inbound.engine; + +import com.sino.mci.inbound.entity.MessageEvent; +import com.sino.mci.shared.util.JsonUtils; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service +public class MockIntentRecognitionService implements IntentRecognitionService { + + @Override + public IntentResult recognize(MessageEvent event) { + String text = extractText(event); + if (text == null || text.isBlank()) { + return new IntentResult("unknown", 0.0, "empty content"); + } + String lower = text.toLowerCase(); + if (lower.contains("救援") || lower.contains("拖车") || lower.contains("故障")) { + return new IntentResult("rescue_request", 0.95, "contains rescue keywords"); + } + if (lower.contains("咨询") || lower.contains("问一下")) { + return new IntentResult("consult", 0.85, "contains consult keywords"); + } + if (lower.contains("投诉") || lower.contains("不满")) { + return new IntentResult("complaint", 0.88, "contains complaint keywords"); + } + return new IntentResult("other", 0.6, "no clear intent matched"); + } + + private String extractText(MessageEvent event) { + String contentJson = event.getContent(); + if (contentJson == null) { + return null; + } + Map content = JsonUtils.fromJson(contentJson, Map.class); + if (content == null) { + return null; + } + Object body = content.get("body"); + return body != null ? body.toString() : null; + } +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/inbound/engine/WebhookPushService.java b/backend/mci-server/src/main/java/com/sino/mci/inbound/engine/WebhookPushService.java new file mode 100644 index 0000000..9c84f22 --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/inbound/engine/WebhookPushService.java @@ -0,0 +1,58 @@ +package com.sino.mci.inbound.engine; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.sino.mci.admin.entity.Tenant; +import com.sino.mci.admin.repository.TenantMapper; +import com.sino.mci.inbound.entity.MessageEvent; +import com.sino.mci.shared.util.JsonUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WebhookPushService { + + private final TenantMapper tenantMapper; + private final RestTemplate restTemplate = new RestTemplate(); + + public void pushToBusiness(String tenantCode, MessageEvent event, IntentRecognitionService.IntentResult intent) { + Tenant tenant = tenantMapper.selectOne( + new LambdaQueryWrapper().eq(Tenant::getTenantCode, tenantCode)); + if (tenant == null) { + log.warn("Tenant not found for webhook push: {}", tenantCode); + return; + } + String webhookUrl = tenant.getInboundWebhookUrl(); + if (webhookUrl == null || webhookUrl.isBlank()) { + log.warn("Tenant {} has no inbound webhook URL configured", tenantCode); + return; + } + + Map payload = new HashMap<>(); + payload.put("eventId", event.getEventId()); + payload.put("tenantCode", tenantCode); + payload.put("channelType", event.getChannelType()); + payload.put("conversationId", event.getConversationId()); + payload.put("personId", event.getPersonId()); + payload.put("accountId", event.getAccountId()); + payload.put("content", event.getContent()); + payload.put("intent", intent.intent()); + payload.put("confidence", intent.confidence()); + payload.put("reason", intent.reason()); + + try { + ResponseEntity response = restTemplate.postForEntity(webhookUrl, payload, String.class); + log.info("Webhook pushed to {}, status={}", webhookUrl, response.getStatusCode()); + } catch (RestClientException e) { + log.warn("Webhook push failed: {}", e.getMessage()); + } + } +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/inbound/entity/MessageEvent.java b/backend/mci-server/src/main/java/com/sino/mci/inbound/entity/MessageEvent.java new file mode 100644 index 0000000..ff07c58 --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/inbound/entity/MessageEvent.java @@ -0,0 +1,28 @@ +package com.sino.mci.inbound.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.sino.mci.shared.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("mci_message_event") +public class MessageEvent extends BaseEntity { + + private String eventId; + private String tenantCode; + private String channelType; + private Long channelSubjectId; + private Integer direction; + private String eventType; + private String messageSource; + private Long conversationId; + private Long personId; + private Long accountId; + private String content; + private String rawPayload; + private String businessContext; + private Integer isProcessed; + private Integer status; +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/inbound/repository/MessageEventMapper.java b/backend/mci-server/src/main/java/com/sino/mci/inbound/repository/MessageEventMapper.java new file mode 100644 index 0000000..eeb9a5c --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/inbound/repository/MessageEventMapper.java @@ -0,0 +1,9 @@ +package com.sino.mci.inbound.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.sino.mci.inbound.entity.MessageEvent; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface MessageEventMapper extends BaseMapper { +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/inbound/service/InboundMessageService.java b/backend/mci-server/src/main/java/com/sino/mci/inbound/service/InboundMessageService.java new file mode 100644 index 0000000..25a775b --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/inbound/service/InboundMessageService.java @@ -0,0 +1,46 @@ +package com.sino.mci.inbound.service; + +import com.sino.mci.inbound.dto.ReceiveMessageRequest; +import com.sino.mci.inbound.entity.MessageEvent; +import com.sino.mci.inbound.engine.FlowEngine; +import com.sino.mci.inbound.repository.MessageEventMapper; +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; + +@Slf4j +@Service +@RequiredArgsConstructor +public class InboundMessageService { + + private final MessageEventMapper messageEventMapper; + private final FlowEngine flowEngine; + + @Transactional + public MessageEvent receiveMessage(String tenantCode, ReceiveMessageRequest request) { + MessageEvent event = new MessageEvent(); + event.setEventId(request.getEventId()); + event.setTenantCode(tenantCode); + event.setChannelType(request.getChannelType()); + event.setChannelSubjectId(request.getChannelSubjectId()); + event.setDirection(1); + event.setEventType(request.getEventType()); + event.setMessageSource(request.getMessageSource()); + event.setConversationId(request.getConversationId()); + event.setPersonId(request.getPersonId()); + event.setAccountId(request.getAccountId()); + event.setContent(JsonUtils.toJson(request.getContent())); + event.setRawPayload(JsonUtils.toJson(request.getRawPayload())); + event.setBusinessContext(JsonUtils.toJson(request.getBusinessContext())); + event.setIsProcessed(0); + event.setStatus(1); + messageEventMapper.insert(event); + log.info("Inbound message received: tenant={}, eventId={}, conversationId={}", + tenantCode, request.getEventId(), request.getConversationId()); + + flowEngine.executeInboundFlow(event.getId()); + return messageEventMapper.selectById(event.getId()); + } +} diff --git a/backend/mci-server/src/main/resources/db/migration/V5__init_flow_message.sql b/backend/mci-server/src/main/resources/db/migration/V5__init_flow_message.sql new file mode 100644 index 0000000..51aeb2f --- /dev/null +++ b/backend/mci-server/src/main/resources/db/migration/V5__init_flow_message.sql @@ -0,0 +1,40 @@ +CREATE TABLE IF NOT EXISTS mci_flow_definition ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_code VARCHAR(64) NOT NULL COMMENT '所属租户', + flow_code VARCHAR(64) NOT NULL COMMENT '流程编码', + flow_name VARCHAR(128) NOT NULL COMMENT '流程名称', + flow_type VARCHAR(32) NOT NULL COMMENT '流程类型:inbound / ivr', + definition_json JSON NOT NULL COMMENT '流程定义 JSON', + status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0下线 1上线', + 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_tenant_flow_code (tenant_code, flow_code, deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='流程定义表'; + +CREATE TABLE IF NOT EXISTS mci_message_event ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + event_id VARCHAR(128) NOT NULL COMMENT '事件唯一 ID', + tenant_code VARCHAR(64) NOT NULL COMMENT '所属租户/业务系统', + channel_type VARCHAR(32) NOT NULL COMMENT '渠道', + channel_subject_id BIGINT COMMENT '渠道主体', + direction TINYINT NOT NULL DEFAULT 1 COMMENT '方向:1入站 2出站', + event_type VARCHAR(32) NOT NULL COMMENT '事件类型:message / callback / status / keypress', + message_source VARCHAR(32) NOT NULL COMMENT '来源:realtime / session_archive', + conversation_id BIGINT COMMENT '会话 ID', + person_id BIGINT COMMENT '联系人 ID', + account_id BIGINT COMMENT '发送/接收账号', + content JSON COMMENT '内容:{type, body}', + raw_payload JSON COMMENT '渠道原始报文', + business_context JSON COMMENT '业务上下文', + is_processed TINYINT NOT NULL DEFAULT 0 COMMENT '是否已处理:0否 1是', + status TINYINT NOT NULL DEFAULT 1 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_event_id (event_id), + KEY idx_tenant_source_time (tenant_code, message_source, create_time), + KEY idx_conversation_time (conversation_id, create_time), + KEY idx_person_time (person_id, create_time), + KEY idx_account_time (account_id, create_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息事件表'; diff --git a/backend/mci-server/src/test/java/com/sino/mci/inbound/controller/InboundFlowControllerTest.java b/backend/mci-server/src/test/java/com/sino/mci/inbound/controller/InboundFlowControllerTest.java new file mode 100644 index 0000000..e49af00 --- /dev/null +++ b/backend/mci-server/src/test/java/com/sino/mci/inbound/controller/InboundFlowControllerTest.java @@ -0,0 +1,72 @@ +package com.sino.mci.inbound.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 InboundFlowControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void shouldCreateFlowAndReceiveMessage() throws Exception { + String tenantCode = "flow-" + UUID.randomUUID().toString().substring(0, 8); + String tenantPayload = String.format("{\"tenantCode\":\"%s\",\"tenantName\":\"流程测试\",\"inboundWebhookUrl\":\"http://localhost:9999/webhook\"}", 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 flowPayload = "{\"flowCode\":\"inbound_default\",\"flowName\":\"默认入站流程\",\"flowType\":\"inbound\",\"definitionJson\":{\"nodes\":[{\"id\":\"receive\",\"type\":\"receive\"},{\"id\":\"intent\",\"type\":\"intent\"},{\"id\":\"webhook\",\"type\":\"webhook\"}]}}"; + mockMvc.perform(post("/msg-platform/api/v1/inbound/flow") + .contextPath("/msg-platform") + .header("X-Tenant-Code", tenantCode) + .contentType(MediaType.APPLICATION_JSON) + .content(flowPayload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + mockMvc.perform(post("/msg-platform/api/v1/inbound/flow/inbound_default/publish") + .contextPath("/msg-platform") + .header("X-Tenant-Code", tenantCode)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + mockMvc.perform(get("/msg-platform/api/v1/inbound/flow/inbound_default") + .contextPath("/msg-platform") + .header("X-Tenant-Code", tenantCode)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value(1)); + + String eventId = "evt-" + UUID.randomUUID().toString().substring(0, 8); + String messagePayload = String.format( + "{\"eventId\":\"%s\",\"channelType\":\"wecom\",\"eventType\":\"message\",\"messageSource\":\"realtime\",\"conversationId\":1,\"personId\":1,\"content\":{\"type\":\"text\",\"body\":\"我需要救援\"}}", + eventId); + mockMvc.perform(post("/msg-platform/api/v1/inbound/receive") + .contextPath("/msg-platform") + .header("X-Tenant-Code", tenantCode) + .contentType(MediaType.APPLICATION_JSON) + .content(messagePayload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.eventId").value(eventId)); + } +}