feat(inbound): add flow definition, message event, intent recognition and webhook push

This commit is contained in:
2026-07-07 12:15:27 +08:00
parent 3f2c0d7d16
commit 953d36fe2c
18 changed files with 632 additions and 1 deletions

View File

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

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

View File

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

View File

@@ -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<String, Object> definitionJson;
}

View File

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

View File

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

View File

@@ -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<FlowDefinition>()
.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<FlowDefinition>()
.eq(FlowDefinition::getTenantCode, tenantCode)
.eq(FlowDefinition::getFlowCode, flowCode));
if (flow == null) {
throw new BusinessException("流程不存在: " + flowCode);
}
return flow;
}
public List<FlowDefinition> listFlows(String tenantCode) {
return flowDefinitionMapper.selectList(
new LambdaQueryWrapper<FlowDefinition>()
.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);
}
}

View File

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

View File

@@ -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<String, Object> content;
private Map<String, Object> rawPayload;
private Map<String, Object> businessContext;
}

View File

@@ -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<String, Object> 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<FlowDefinition> flows = flowDefinitionMapper.selectList(
new LambdaQueryWrapper<FlowDefinition>()
.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);
}
}

View File

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

View File

@@ -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<String, Object> content = JsonUtils.fromJson(contentJson, Map.class);
if (content == null) {
return null;
}
Object body = content.get("body");
return body != null ? body.toString() : null;
}
}

View File

@@ -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<Tenant>().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<String, Object> 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<String> 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());
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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='消息事件表';

View File

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