wip: baseline changes before channel-conversation-task boundary implementation

This commit is contained in:
2026-07-08 16:23:09 +08:00
parent 89c9e6a066
commit 1298a558e8
139 changed files with 11781 additions and 666 deletions

View File

@@ -1,10 +1,14 @@
# syntax=docker/dockerfile:1
FROM eclipse-temurin:21-jre-alpine
FROM eclipse-temurin:21-jre
RUN apk add --no-cache curl
RUN groupadd -g 1001 app && \
useradd -u 1001 -g app -s /usr/sbin/nologin -M app
WORKDIR /app
COPY target/*.jar app.jar
RUN chown app:app app.jar
USER app
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar app.jar"]

View File

@@ -70,6 +70,11 @@
<artifactId>sa-token-spring-boot3-starter</artifactId>
<version>1.39.0</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>1.0.0-M6</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>

View File

@@ -5,7 +5,10 @@ import com.sino.mci.admin.dto.CreateUserRequest;
import com.sino.mci.admin.dto.LoginRequest;
import com.sino.mci.admin.dto.PlatformUserResponse;
import com.sino.mci.admin.dto.TokenResponse;
import com.sino.mci.admin.dto.UpdateTenantRequest;
import com.sino.mci.admin.dto.UpdateUserRequest;
import com.sino.mci.admin.dto.UserResponse;
import com.sino.mci.admin.entity.PlatformRole;
import com.sino.mci.admin.entity.Tenant;
import com.sino.mci.admin.security.AuthContext;
import com.sino.mci.admin.service.AccountService;
@@ -28,11 +31,29 @@ public class AccountController {
return ApiResponse.ok(accountService.createTenant(request));
}
@PutMapping("/tenant/{id}")
public ApiResponse<Tenant> updateTenant(
@PathVariable Long id,
@Valid @RequestBody UpdateTenantRequest request) {
return ApiResponse.ok(accountService.updateTenant(id, request));
}
@DeleteMapping("/tenant/{id}")
public ApiResponse<Void> deleteTenant(@PathVariable Long id) {
accountService.deleteTenant(id);
return ApiResponse.ok();
}
@GetMapping("/tenant/list")
public ApiResponse<List<Tenant>> listTenants() {
return ApiResponse.ok(accountService.listTenants());
}
@GetMapping("/role/list")
public ApiResponse<List<PlatformRole>> listRoles() {
return ApiResponse.ok(accountService.listRoles());
}
@PostMapping("/user")
public ApiResponse<UserResponse> createUser(
@RequestHeader("X-Tenant-Code") String tenantCode,
@@ -40,9 +61,23 @@ public class AccountController {
return ApiResponse.ok(accountService.createUser(tenantCode, request));
}
@PutMapping("/user/{id}")
public ApiResponse<UserResponse> updateUser(
@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest request) {
return ApiResponse.ok(accountService.updateUser(id, request));
}
@DeleteMapping("/user/{id}")
public ApiResponse<Void> deleteUser(@PathVariable Long id) {
accountService.deleteUser(id);
return ApiResponse.ok();
}
@GetMapping("/user/list")
public ApiResponse<List<PlatformUserResponse>> listUsers() {
return ApiResponse.ok(accountService.listUsers(AuthContext.currentTenantCode()));
public ApiResponse<List<PlatformUserResponse>> listUsers(
@RequestParam(required = false) String tenantCode) {
return ApiResponse.ok(accountService.listUsers(tenantCode));
}
@PostMapping("/login")

View File

@@ -0,0 +1,36 @@
package com.sino.mci.admin.controller;
import com.sino.mci.admin.dto.TestWebhookRequest;
import com.sino.mci.admin.dto.TestWebhookResponse;
import com.sino.mci.admin.dto.TenantWebhookResponse;
import com.sino.mci.admin.dto.UpdateTenantWebhookRequest;
import com.sino.mci.admin.security.AuthContext;
import com.sino.mci.admin.service.TenantService;
import com.sino.mci.shared.web.ApiResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/account/tenant")
@RequiredArgsConstructor
public class TenantController {
private final TenantService tenantService;
@GetMapping("/webhook")
public ApiResponse<TenantWebhookResponse> getWebhookConfig() {
return ApiResponse.ok(tenantService.getWebhookConfig(AuthContext.currentTenantCode()));
}
@PostMapping("/webhook")
public ApiResponse<TenantWebhookResponse> updateWebhookConfig(
@Valid @RequestBody UpdateTenantWebhookRequest request) {
return ApiResponse.ok(tenantService.updateWebhookConfig(AuthContext.currentTenantCode(), request));
}
@PostMapping("/webhook/test")
public ApiResponse<TestWebhookResponse> testWebhook(@Valid @RequestBody TestWebhookRequest request) {
return ApiResponse.ok(tenantService.testWebhook(AuthContext.currentTenantCode(), request));
}
}

View File

@@ -13,4 +13,8 @@ public class CreateTenantRequest {
private String tenantName;
private String inboundWebhookUrl;
private String initAdminUsername;
private String initAdminPassword;
}

View File

@@ -0,0 +1,16 @@
package com.sino.mci.admin.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.Map;
@Data
public class TenantWebhookResponse {
@JsonProperty("inbound_webhook_url")
private String inboundWebhookUrl;
@JsonProperty("auth_config")
private Map<String, Object> authConfig;
}

View File

@@ -0,0 +1,11 @@
package com.sino.mci.admin.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class TestWebhookRequest {
@NotBlank
private String url;
}

View File

@@ -0,0 +1,14 @@
package com.sino.mci.admin.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TestWebhookResponse {
private boolean success;
private String message;
}

View File

@@ -0,0 +1,15 @@
package com.sino.mci.admin.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class UpdateTenantRequest {
@NotBlank
private String tenantName;
private String inboundWebhookUrl;
private Integer status;
}

View File

@@ -0,0 +1,18 @@
package com.sino.mci.admin.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.util.Map;
@Data
public class UpdateTenantWebhookRequest {
@NotBlank
@JsonProperty("inbound_webhook_url")
private String inboundWebhookUrl;
@JsonProperty("auth_config")
private Map<String, Object> authConfig;
}

View File

@@ -0,0 +1,19 @@
package com.sino.mci.admin.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.util.List;
@Data
public class UpdateUserRequest {
@NotBlank
private String realName;
private String phone;
private Integer status;
private List<String> roleCodes;
}

View File

@@ -14,5 +14,6 @@ public class Tenant extends BaseEntity {
private String tenantName;
private String authConfig;
private String inboundWebhookUrl;
private String extInfo;
private Integer status;
}

View File

@@ -0,0 +1,117 @@
package com.sino.mci.admin.init;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.admin.entity.PlatformRole;
import com.sino.mci.admin.entity.PlatformUser;
import com.sino.mci.admin.entity.Tenant;
import com.sino.mci.admin.entity.UserRole;
import com.sino.mci.admin.repository.PlatformRoleMapper;
import com.sino.mci.admin.repository.PlatformUserMapper;
import com.sino.mci.admin.repository.TenantMapper;
import com.sino.mci.admin.repository.UserRoleMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.security.crypto.bcrypt.BCrypt;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Component
@RequiredArgsConstructor
public class DataInitializer implements CommandLineRunner {
private static final String DEFAULT_TENANT_CODE = "system";
private static final String DEFAULT_TENANT_NAME = "系统默认租户";
private static final String GLOBAL_ADMIN_USERNAME = "superadmin";
private static final String GLOBAL_ADMIN_PASSWORD = "admin123";
private static final String GLOBAL_ADMIN_ROLE_CODE = "platform_admin";
private static final String TENANT_ADMIN_ROLE_CODE = "tenant_admin";
private final TenantMapper tenantMapper;
private final PlatformUserMapper userMapper;
private final PlatformRoleMapper roleMapper;
private final UserRoleMapper userRoleMapper;
@Override
@Transactional
public void run(String... args) {
initDefaultTenant();
initPlatformAdminRole();
initTenantAdminRole();
initGlobalAdmin();
}
private void initDefaultTenant() {
Tenant existing = tenantMapper.selectOne(
new LambdaQueryWrapper<Tenant>().eq(Tenant::getTenantCode, DEFAULT_TENANT_CODE));
if (existing != null) {
return;
}
Tenant tenant = new Tenant();
tenant.setTenantCode(DEFAULT_TENANT_CODE);
tenant.setTenantName(DEFAULT_TENANT_NAME);
tenant.setStatus(1);
tenantMapper.insert(tenant);
log.info("已创建默认租户: {}", DEFAULT_TENANT_CODE);
}
private void initPlatformAdminRole() {
PlatformRole existing = roleMapper.selectOne(
new LambdaQueryWrapper<PlatformRole>().eq(PlatformRole::getRoleCode, GLOBAL_ADMIN_ROLE_CODE));
if (existing != null) {
return;
}
PlatformRole role = new PlatformRole();
role.setRoleCode(GLOBAL_ADMIN_ROLE_CODE);
role.setRoleName("平台管理员");
role.setPermissions("[\"tenant:manage\", \"user:manage\", \"*\"]");
role.setStatus(1);
roleMapper.insert(role);
log.info("已创建平台管理员角色: {}", GLOBAL_ADMIN_ROLE_CODE);
}
private void initTenantAdminRole() {
PlatformRole existing = roleMapper.selectOne(
new LambdaQueryWrapper<PlatformRole>().eq(PlatformRole::getRoleCode, TENANT_ADMIN_ROLE_CODE));
if (existing != null) {
return;
}
PlatformRole role = new PlatformRole();
role.setRoleCode(TENANT_ADMIN_ROLE_CODE);
role.setRoleName("租户管理员");
role.setPermissions("[\"user:manage\", \"tenant:view\"]");
role.setStatus(1);
roleMapper.insert(role);
log.info("已创建租户管理员角色: {}", TENANT_ADMIN_ROLE_CODE);
}
private void initGlobalAdmin() {
PlatformUser existing = userMapper.selectOne(
new LambdaQueryWrapper<PlatformUser>()
.eq(PlatformUser::getTenantCode, DEFAULT_TENANT_CODE)
.eq(PlatformUser::getUsername, GLOBAL_ADMIN_USERNAME));
if (existing != null) {
return;
}
PlatformUser user = new PlatformUser();
user.setTenantCode(DEFAULT_TENANT_CODE);
user.setUsername(GLOBAL_ADMIN_USERNAME);
user.setPassword(BCrypt.hashpw(GLOBAL_ADMIN_PASSWORD, BCrypt.gensalt()));
user.setRealName("平台管理员");
user.setStatus(1);
userMapper.insert(user);
PlatformRole role = roleMapper.selectOne(
new LambdaQueryWrapper<PlatformRole>().eq(PlatformRole::getRoleCode, GLOBAL_ADMIN_ROLE_CODE));
if (role != null) {
UserRole userRole = new UserRole();
userRole.setUserId(user.getId());
userRole.setRoleId(role.getId());
userRoleMapper.insert(userRole);
}
log.info("已创建全局管理员: {}/{}", DEFAULT_TENANT_CODE, GLOBAL_ADMIN_USERNAME);
}
}

View File

@@ -7,6 +7,8 @@ import com.sino.mci.admin.dto.CreateUserRequest;
import com.sino.mci.admin.dto.LoginResponse;
import com.sino.mci.admin.dto.PlatformUserResponse;
import com.sino.mci.admin.dto.TokenResponse;
import com.sino.mci.admin.dto.UpdateTenantRequest;
import com.sino.mci.admin.dto.UpdateUserRequest;
import com.sino.mci.admin.dto.UserResponse;
import com.sino.mci.admin.entity.PlatformRole;
import com.sino.mci.admin.entity.PlatformUser;
@@ -16,6 +18,7 @@ import com.sino.mci.admin.repository.PlatformRoleMapper;
import com.sino.mci.admin.repository.PlatformUserMapper;
import com.sino.mci.admin.repository.TenantMapper;
import com.sino.mci.admin.repository.UserRoleMapper;
import com.sino.mci.admin.security.AuthContext;
import com.sino.mci.exception.BusinessException;
import com.sino.mci.exception.UnauthorizedException;
import lombok.RequiredArgsConstructor;
@@ -23,14 +26,21 @@ import org.springframework.security.crypto.bcrypt.BCrypt;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class AccountService {
private static final String TENANT_ADMIN_ROLE_CODE = "tenant_admin";
private static final String PLATFORM_ADMIN_ROLE_CODE = "platform_admin";
private static final String SYSTEM_TENANT_CODE = "system";
private static final String GLOBAL_ADMIN_USERNAME = "superadmin";
private final TenantMapper tenantMapper;
private final PlatformUserMapper userMapper;
private final PlatformRoleMapper roleMapper;
@@ -50,11 +60,51 @@ public class AccountService {
tenant.setInboundWebhookUrl(request.getInboundWebhookUrl());
tenant.setStatus(1);
tenantMapper.insert(tenant);
if (StringUtils.hasText(request.getInitAdminUsername()) && StringUtils.hasText(request.getInitAdminPassword())) {
createUserInternal(tenant.getTenantCode(), request.getInitAdminUsername(), request.getInitAdminPassword(),
request.getInitAdminUsername(), null, List.of(TENANT_ADMIN_ROLE_CODE));
}
return tenant;
}
@Transactional
public Tenant updateTenant(Long id, UpdateTenantRequest request) {
Tenant tenant = tenantMapper.selectById(id);
if (tenant == null) {
throw new BusinessException("租户不存在: " + id);
}
tenant.setTenantName(request.getTenantName());
tenant.setInboundWebhookUrl(request.getInboundWebhookUrl());
if (request.getStatus() != null) {
tenant.setStatus(request.getStatus());
}
tenantMapper.updateById(tenant);
return tenant;
}
@Transactional
public void deleteTenant(Long id) {
Tenant tenant = tenantMapper.selectById(id);
if (tenant == null) {
throw new BusinessException("租户不存在: " + id);
}
if (SYSTEM_TENANT_CODE.equals(tenant.getTenantCode())) {
throw new BusinessException("不能删除系统默认租户");
}
tenantMapper.deleteById(id);
}
@Transactional
public UserResponse createUser(String tenantCode, CreateUserRequest request) {
PlatformUser user = createUserInternal(tenantCode, request.getUsername(), request.getPassword(),
request.getRealName(), request.getPhone(), request.getRoleCodes());
return toUserResponse(user);
}
private PlatformUser createUserInternal(String tenantCode, String username, String password,
String realName, String phone, List<String> roleCodes) {
Tenant tenant = tenantMapper.selectOne(
new LambdaQueryWrapper<Tenant>().eq(Tenant::getTenantCode, tenantCode));
if (tenant == null) {
@@ -64,37 +114,57 @@ public class AccountService {
PlatformUser existing = userMapper.selectOne(
new LambdaQueryWrapper<PlatformUser>()
.eq(PlatformUser::getTenantCode, tenantCode)
.eq(PlatformUser::getUsername, request.getUsername()));
.eq(PlatformUser::getUsername, username));
if (existing != null) {
throw new BusinessException("用户名已存在: " + request.getUsername());
throw new BusinessException("用户名已存在: " + username);
}
PlatformUser user = new PlatformUser();
user.setTenantCode(tenantCode);
user.setUsername(request.getUsername());
user.setPassword(BCrypt.hashpw(request.getPassword(), BCrypt.gensalt()));
user.setRealName(request.getRealName());
user.setPhone(request.getPhone());
user.setUsername(username);
user.setPassword(BCrypt.hashpw(password, BCrypt.gensalt()));
user.setRealName(realName);
user.setPhone(phone);
user.setStatus(1);
userMapper.insert(user);
if (!CollectionUtils.isEmpty(request.getRoleCodes())) {
for (String roleCode : request.getRoleCodes()) {
PlatformRole role = roleMapper.selectOne(
new LambdaQueryWrapper<PlatformRole>().eq(PlatformRole::getRoleCode, roleCode));
if (role == null) {
throw new BusinessException("角色不存在: " + roleCode);
}
UserRole userRole = new UserRole();
userRole.setUserId(user.getId());
userRole.setRoleId(role.getId());
userRoleMapper.insert(userRole);
}
}
bindUserRoles(user.getId(), roleCodes);
return user;
}
@Transactional
public UserResponse updateUser(Long id, UpdateUserRequest request) {
PlatformUser user = userMapper.selectById(id);
if (user == null) {
throw new BusinessException("用户不存在: " + id);
}
user.setRealName(request.getRealName());
user.setPhone(request.getPhone());
if (request.getStatus() != null) {
user.setStatus(request.getStatus());
}
userMapper.updateById(user);
bindUserRoles(user.getId(), request.getRoleCodes());
return toUserResponse(user);
}
@Transactional
public void deleteUser(Long id) {
PlatformUser user = userMapper.selectById(id);
if (user == null) {
throw new BusinessException("用户不存在: " + id);
}
if (SYSTEM_TENANT_CODE.equals(user.getTenantCode()) && GLOBAL_ADMIN_USERNAME.equals(user.getUsername())) {
throw new BusinessException("不能删除全局管理员");
}
AuthContext.AuthUser current = AuthContext.get();
if (current != null && Objects.equals(current.getUserId(), user.getId())) {
throw new BusinessException("不能删除当前登录用户");
}
userMapper.deleteById(id);
}
public TokenResponse login(String tenantCode, String username, String password) {
PlatformUser user = userMapper.selectOne(
new LambdaQueryWrapper<PlatformUser>()
@@ -125,12 +195,48 @@ public class AccountService {
return tenantMapper.selectList(null);
}
public List<PlatformRole> listRoles() {
return roleMapper.selectList(null);
}
public List<PlatformUserResponse> listUsers(String tenantCode) {
List<PlatformUser> users = userMapper.selectList(
new LambdaQueryWrapper<PlatformUser>().eq(PlatformUser::getTenantCode, tenantCode));
LambdaQueryWrapper<PlatformUser> wrapper = new LambdaQueryWrapper<>();
if (StringUtils.hasText(tenantCode)) {
wrapper.eq(PlatformUser::getTenantCode, tenantCode);
} else if (!isPlatformAdmin()) {
String currentTenant = AuthContext.currentTenantCode();
if (currentTenant != null) {
wrapper.eq(PlatformUser::getTenantCode, currentTenant);
}
}
List<PlatformUser> users = userMapper.selectList(wrapper);
return users.stream().map(this::toPlatformUserResponse).collect(Collectors.toList());
}
private boolean isPlatformAdmin() {
AuthContext.AuthUser current = AuthContext.get();
return current != null && current.getRoleCodes() != null
&& current.getRoleCodes().contains(PLATFORM_ADMIN_ROLE_CODE);
}
private void bindUserRoles(Long userId, List<String> roleCodes) {
if (CollectionUtils.isEmpty(roleCodes)) {
return;
}
userRoleMapper.delete(new LambdaQueryWrapper<UserRole>().eq(UserRole::getUserId, userId));
for (String roleCode : roleCodes) {
PlatformRole role = roleMapper.selectOne(
new LambdaQueryWrapper<PlatformRole>().eq(PlatformRole::getRoleCode, roleCode));
if (role == null) {
throw new BusinessException("角色不存在: " + roleCode);
}
UserRole userRole = new UserRole();
userRole.setUserId(userId);
userRole.setRoleId(role.getId());
userRoleMapper.insert(userRole);
}
}
private UserResponse toUserResponse(PlatformUser user) {
UserResponse response = new UserResponse();
response.setId(user.getId());

View File

@@ -0,0 +1,129 @@
package com.sino.mci.admin.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.admin.dto.TestWebhookRequest;
import com.sino.mci.admin.dto.TestWebhookResponse;
import com.sino.mci.admin.dto.TenantWebhookResponse;
import com.sino.mci.admin.dto.UpdateTenantWebhookRequest;
import com.sino.mci.admin.entity.Tenant;
import com.sino.mci.admin.repository.TenantMapper;
import com.sino.mci.exception.BusinessException;
import com.sino.mci.inbound.webhook.WebhookSignature;
import com.sino.mci.inbound.webhook.WebhookSignatureService;
import com.sino.mci.shared.util.JsonUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
import java.util.LinkedHashMap;
import java.util.Map;
@Slf4j
@Service
@RequiredArgsConstructor
public class TenantService {
private static final String AUTH_CONFIG_WEBHOOK_SECRET = "webhook_secret";
private static final String AUTH_CONFIG_SIGN_ALGORITHM = "sign_algorithm";
private static final String DEFAULT_ALGORITHM = "hmac-sha256";
private final TenantMapper tenantMapper;
private final RestTemplate restTemplate;
private final WebhookSignatureService signatureService;
public TenantWebhookResponse getWebhookConfig(String tenantCode) {
Tenant tenant = getTenant(tenantCode);
TenantWebhookResponse response = new TenantWebhookResponse();
response.setInboundWebhookUrl(tenant.getInboundWebhookUrl() != null ? tenant.getInboundWebhookUrl() : "");
response.setAuthConfig(parseAuthConfig(tenant.getAuthConfig()));
return response;
}
@Transactional
public TenantWebhookResponse updateWebhookConfig(String tenantCode, UpdateTenantWebhookRequest request) {
Tenant tenant = getTenant(tenantCode);
tenant.setInboundWebhookUrl(request.getInboundWebhookUrl());
tenant.setAuthConfig(JsonUtils.toJson(request.getAuthConfig()));
tenantMapper.updateById(tenant);
return getWebhookConfig(tenantCode);
}
public TestWebhookResponse testWebhook(String tenantCode, TestWebhookRequest request) {
Tenant tenant = getTenant(tenantCode);
Map<String, Object> authConfig = parseAuthConfig(tenant.getAuthConfig());
String secret = getString(authConfig, AUTH_CONFIG_WEBHOOK_SECRET);
if (!StringUtils.hasText(secret)) {
return new TestWebhookResponse(false, "未配置 webhook_secret无法签名测试请求");
}
String algorithm = getString(authConfig, AUTH_CONFIG_SIGN_ALGORITHM);
if (!StringUtils.hasText(algorithm)) {
algorithm = DEFAULT_ALGORITHM;
}
String payloadJson = JsonUtils.toJson(buildSamplePayload(tenantCode));
WebhookSignature signature = signatureService.sign(tenantCode, payloadJson, secret, algorithm);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Msg-Platform-Signature", signature.signature());
headers.set("X-Msg-Platform-Timestamp", String.valueOf(signature.timestamp()));
headers.set("X-Msg-Platform-Tenant", tenantCode);
HttpEntity<String> entity = new HttpEntity<>(payloadJson, headers);
try {
ResponseEntity<String> response = restTemplate.exchange(
request.getUrl(), HttpMethod.POST, entity, String.class);
if (response.getStatusCode().is2xxSuccessful()) {
return new TestWebhookResponse(true, "测试推送成功HTTP " + response.getStatusCode());
}
return new TestWebhookResponse(false, "测试推送失败HTTP " + response.getStatusCode());
} catch (RestClientException e) {
log.warn("Webhook test failed for tenant {} to {}", tenantCode, request.getUrl(), e);
return new TestWebhookResponse(false, "测试推送失败: " + e.getMessage());
}
}
private Tenant getTenant(String tenantCode) {
Tenant tenant = tenantMapper.selectOne(
new LambdaQueryWrapper<Tenant>().eq(Tenant::getTenantCode, tenantCode));
if (tenant == null) {
throw new BusinessException("租户不存在: " + tenantCode);
}
return tenant;
}
@SuppressWarnings("unchecked")
private Map<String, Object> parseAuthConfig(String authConfigJson) {
if (authConfigJson == null || authConfigJson.isBlank()) {
return new LinkedHashMap<>();
}
Map<String, Object> config = JsonUtils.fromJson(authConfigJson, Map.class);
return config != null ? config : new LinkedHashMap<>();
}
private String getString(Map<String, Object> config, String key) {
Object value = config.get(key);
return value != null ? value.toString() : null;
}
private Map<String, Object> buildSamplePayload(String tenantCode) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("event_id", "test_evt_001");
payload.put("tenant_code", tenantCode);
payload.put("channel_type", "wecom");
payload.put("direction", "in");
payload.put("event_type", "message");
payload.put("content", Map.of("text", "这是一条 webhook 测试消息"));
payload.put("timestamp", System.currentTimeMillis() / 1000);
return payload;
}
}

View File

@@ -5,6 +5,7 @@ import com.sino.mci.channel.dto.ChannelAccountInitRequest;
import com.sino.mci.channel.dto.CreateChannelAccountRequest;
import com.sino.mci.channel.dto.HeartbeatRequest;
import com.sino.mci.channel.dto.SyncResult;
import com.sino.mci.channel.dto.UpdateStatusRequest;
import com.sino.mci.channel.entity.ChannelAccount;
import com.sino.mci.channel.service.ChannelAccountService;
import com.sino.mci.shared.web.ApiResponse;
@@ -28,6 +29,37 @@ public class ChannelAccountController {
return ApiResponse.ok(accountService.createAccount(tenantCode, request));
}
@PutMapping("/{id}")
public ApiResponse<?> updateAccount(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("id") Long id,
@Valid @RequestBody CreateChannelAccountRequest request) {
return ApiResponse.ok(accountService.updateAccount(id, tenantCode, request));
}
@DeleteMapping("/{id}")
public ApiResponse<?> deleteAccount(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("id") Long id) {
accountService.deleteAccount(id, tenantCode);
return ApiResponse.ok(null);
}
@PatchMapping("/{id}/status")
public ApiResponse<?> updateAccountStatus(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("id") Long id,
@Valid @RequestBody UpdateStatusRequest request) {
return ApiResponse.ok(accountService.updateAccountStatus(id, tenantCode, request));
}
@GetMapping("/{id}/status")
public ApiResponse<ChannelAccount> getAccountStatus(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("id") Long id) {
return ApiResponse.ok(accountService.getAccountStatus(id, tenantCode));
}
@PostMapping("/heartbeat")
public ApiResponse<ChannelAccount> heartbeat(@Valid @RequestBody HeartbeatRequest request) {
return ApiResponse.ok(accountService.heartbeat(request.getAccountId(), request));

View File

@@ -2,6 +2,7 @@ package com.sino.mci.channel.controller;
import com.sino.mci.admin.security.AuthContext;
import com.sino.mci.channel.dto.CreateChannelSubjectRequest;
import com.sino.mci.channel.dto.UpdateStatusRequest;
import com.sino.mci.channel.entity.ChannelSubject;
import com.sino.mci.channel.service.ChannelSubjectService;
import com.sino.mci.shared.web.ApiResponse;
@@ -25,6 +26,30 @@ public class ChannelSubjectController {
return ApiResponse.ok(subjectService.createSubject(tenantCode, request));
}
@PutMapping("/{id}")
public ApiResponse<?> updateSubject(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("id") Long id,
@Valid @RequestBody CreateChannelSubjectRequest request) {
return ApiResponse.ok(subjectService.updateSubject(id, tenantCode, request));
}
@DeleteMapping("/{id}")
public ApiResponse<?> deleteSubject(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("id") Long id) {
subjectService.deleteSubject(id, tenantCode);
return ApiResponse.ok(null);
}
@PatchMapping("/{id}/status")
public ApiResponse<?> updateSubjectStatus(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("id") Long id,
@Valid @RequestBody UpdateStatusRequest request) {
return ApiResponse.ok(subjectService.updateSubjectStatus(id, tenantCode, request));
}
@GetMapping("/list")
public ApiResponse<List<ChannelSubject>> listSubjects() {
return ApiResponse.ok(subjectService.listSubjects(AuthContext.currentTenantCode()));

View File

@@ -4,6 +4,7 @@ import com.sino.mci.admin.security.AuthContext;
import com.sino.mci.channel.dto.BindConversationAccountRequest;
import com.sino.mci.channel.dto.BindConversationTagsRequest;
import com.sino.mci.channel.dto.CreateConversationRequest;
import com.sino.mci.channel.dto.UpdateConversationRequest;
import com.sino.mci.channel.entity.Conversation;
import com.sino.mci.channel.service.ConversationService;
import com.sino.mci.shared.web.ApiResponse;
@@ -39,6 +40,22 @@ public class ConversationController {
return ApiResponse.ok(conversationService.getConversation(tenantCode, conversationId));
}
@PutMapping("/{conversation_id}")
public ApiResponse<?> updateConversation(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("conversation_id") Long conversationId,
@Valid @RequestBody UpdateConversationRequest request) {
return ApiResponse.ok(conversationService.updateConversation(tenantCode, conversationId, request));
}
@DeleteMapping("/{conversation_id}")
public ApiResponse<?> deleteConversation(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("conversation_id") Long conversationId) {
conversationService.deleteConversation(tenantCode, conversationId);
return ApiResponse.ok();
}
@PostMapping("/{conversation_id}/bind-account")
public ApiResponse<?> bindAccount(
@RequestHeader("X-Tenant-Code") String tenantCode,

View File

@@ -15,6 +15,8 @@ public class ChannelAccountInitRequest {
@NotNull
private Integer accountType;
private Long accountId;
private Long subjectId;
private Map<String, Object> config;

View File

@@ -0,0 +1,18 @@
package com.sino.mci.channel.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.util.Map;
@Data
public class UpdateConversationRequest {
@NotBlank
private String conversationName;
private Long ownerAccountId;
private Long subjectId;
private Integer status;
private Map<String, Object> receiveConfig;
}

View File

@@ -0,0 +1,11 @@
package com.sino.mci.channel.dto;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class UpdateStatusRequest {
@NotNull
private Integer status;
}

View File

@@ -4,6 +4,7 @@ import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
@@ -41,4 +42,14 @@ public class RabbitConfig {
template.setMessageConverter(new Jackson2JsonMessageConverter());
return template;
}
@Bean
public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(ConnectionFactory connectionFactory) {
SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
factory.setConnectionFactory(connectionFactory);
Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter(
"com.sino.mci.*", "java.util", "java.lang");
factory.setMessageConverter(converter);
return factory;
}
}

View File

@@ -0,0 +1,102 @@
package com.sino.mci.channel.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.channel.common.model.ChannelType;
import com.sino.mci.channel.entity.ChannelAccount;
import com.sino.mci.channel.entity.ChannelAccountConversation;
import com.sino.mci.channel.repository.ChannelAccountConversationMapper;
import com.sino.mci.channel.repository.ChannelAccountMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class AccountSelector {
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 ONLINE_STATUS_ONLINE = 1;
private static final int RISK_LEVEL_HIGH = 3;
private static final int ACCOUNT_TYPE_PYWECHAT = 3;
private static final int HEARTBEAT_TIMEOUT_MINUTES = 5;
private final ChannelAccountConversationMapper accountConversationMapper;
private final ChannelAccountMapper accountMapper;
public Long selectSendAccount(String tenantCode, Long conversationId, ChannelType channelType, String preferredStrategy) {
List<Long> accounts = selectSendAccounts(tenantCode, conversationId, channelType, preferredStrategy);
return CollectionUtils.isEmpty(accounts) ? null : accounts.get(0);
}
public List<Long> selectSendAccounts(String tenantCode, Long conversationId, ChannelType channelType, String preferredStrategy) {
if (conversationId == null || channelType == null) {
return Collections.emptyList();
}
List<ChannelAccountConversation> bindings = accountConversationMapper.selectList(
new LambdaQueryWrapper<ChannelAccountConversation>()
.eq(ChannelAccountConversation::getConversationId, conversationId)
.in(ChannelAccountConversation::getBindingType, BINDING_SEND_PRIMARY, BINDING_SEND_BACKUP)
.eq(ChannelAccountConversation::getStatus, STATUS_ENABLED)
.orderByAsc(ChannelAccountConversation::getBindingType)
.orderByAsc(ChannelAccountConversation::getSortOrder));
if (CollectionUtils.isEmpty(bindings)) {
return Collections.emptyList();
}
if ("backup".equals(preferredStrategy)) {
bindings.sort(Comparator.comparingInt(ChannelAccountConversation::getBindingType).reversed()
.thenComparingInt(b -> b.getSortOrder() == null ? 0 : b.getSortOrder()));
}
List<Long> healthyAccountIds = new ArrayList<>();
for (ChannelAccountConversation binding : bindings) {
ChannelAccount account = accountMapper.selectById(binding.getAccountId());
if (isHealthy(account, tenantCode, channelType)) {
healthyAccountIds.add(account.getId());
}
}
return healthyAccountIds;
}
private boolean isHealthy(ChannelAccount account, String tenantCode, ChannelType channelType) {
if (account == null) {
return false;
}
if (tenantCode != null && !tenantCode.equals(account.getTenantCode())) {
return false;
}
if (account.getStatus() == null || account.getStatus() != STATUS_ENABLED) {
return false;
}
if (!channelType.name().equalsIgnoreCase(account.getChannelType())) {
return false;
}
if (account.getRiskLevel() != null && account.getRiskLevel() >= RISK_LEVEL_HIGH) {
return false;
}
if (account.getAccountType() != null && account.getAccountType() == ACCOUNT_TYPE_PYWECHAT) {
if (account.getOnlineStatus() == null || account.getOnlineStatus() != ONLINE_STATUS_ONLINE) {
return false;
}
LocalDateTime lastHeartbeat = account.getLastHeartbeat();
if (lastHeartbeat == null
|| lastHeartbeat.isBefore(LocalDateTime.now().minusMinutes(HEARTBEAT_TIMEOUT_MINUTES))) {
return false;
}
}
return true;
}
}

View File

@@ -2,6 +2,7 @@ package com.sino.mci.channel.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.channel.dto.CreateChannelSubjectRequest;
import com.sino.mci.channel.dto.UpdateStatusRequest;
import com.sino.mci.channel.entity.ChannelSubject;
import com.sino.mci.channel.repository.ChannelSubjectMapper;
import com.sino.mci.exception.BusinessException;
@@ -25,6 +26,14 @@ public class ChannelSubjectService {
.orderByDesc(ChannelSubject::getId));
}
public ChannelSubject getSubject(Long id, String tenantCode) {
ChannelSubject subject = subjectMapper.selectById(id);
if (subject == null || !tenantCode.equals(subject.getTenantCode())) {
throw new BusinessException("渠道主体不存在: " + id);
}
return subject;
}
@Transactional
public ChannelSubject createSubject(String tenantCode, CreateChannelSubjectRequest request) {
ChannelSubject existing = subjectMapper.selectOne(
@@ -51,4 +60,47 @@ public class ChannelSubjectService {
subjectMapper.insert(subject);
return subject;
}
@Transactional
public ChannelSubject updateSubject(Long id, String tenantCode, CreateChannelSubjectRequest request) {
ChannelSubject subject = getSubject(id, tenantCode);
if (!subject.getSubjectCode().equals(request.getSubjectCode())) {
ChannelSubject existing = subjectMapper.selectOne(
new LambdaQueryWrapper<ChannelSubject>()
.eq(ChannelSubject::getTenantCode, tenantCode)
.eq(ChannelSubject::getChannelType, request.getChannelType())
.eq(ChannelSubject::getSubjectCode, request.getSubjectCode()));
if (existing != null) {
throw new BusinessException("渠道主体编码已存在: " + request.getSubjectCode());
}
}
subject.setChannelType(request.getChannelType());
subject.setSubjectCode(request.getSubjectCode());
subject.setSubjectName(request.getSubjectName());
subject.setCorpId(request.getCorpId());
subject.setCorpSecret(request.getCorpSecret());
subject.setAgentId(request.getAgentId());
subject.setAgentSecret(request.getAgentSecret());
subject.setSessionArchiveEnabled(request.getSessionArchiveEnabled() != null ? request.getSessionArchiveEnabled() : 0);
subject.setSessionArchivePrivateKey(request.getSessionArchivePrivateKey());
subjectMapper.updateById(subject);
return subject;
}
@Transactional
public void deleteSubject(Long id, String tenantCode) {
ChannelSubject subject = getSubject(id, tenantCode);
subject.setStatus(0);
subjectMapper.updateById(subject);
}
@Transactional
public ChannelSubject updateSubjectStatus(Long id, String tenantCode, UpdateStatusRequest request) {
ChannelSubject subject = getSubject(id, tenantCode);
subject.setStatus(request.getStatus());
subjectMapper.updateById(subject);
return subject;
}
}

View File

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.channel.dto.BindConversationAccountRequest;
import com.sino.mci.channel.dto.BindConversationTagsRequest;
import com.sino.mci.channel.dto.CreateConversationRequest;
import com.sino.mci.channel.dto.UpdateConversationRequest;
import com.sino.mci.channel.entity.ChannelAccountConversation;
import com.sino.mci.channel.entity.Conversation;
import com.sino.mci.channel.entity.ConversationTagBinding;
@@ -73,6 +74,32 @@ public class ConversationService {
return conversation;
}
@Transactional
public Conversation updateConversation(String tenantCode, Long conversationId, UpdateConversationRequest request) {
Conversation conversation = getConversation(tenantCode, conversationId);
conversation.setConversationName(request.getConversationName());
if (request.getOwnerAccountId() != null) {
conversation.setOwnerAccountId(request.getOwnerAccountId());
}
if (request.getSubjectId() != null) {
conversation.setSubjectId(request.getSubjectId());
}
if (request.getStatus() != null) {
conversation.setStatus(request.getStatus());
}
if (request.getReceiveConfig() != null) {
conversation.setReceiveConfig(JsonUtils.toJson(request.getReceiveConfig()));
}
conversationMapper.updateById(conversation);
return conversation;
}
@Transactional
public void deleteConversation(String tenantCode, Long conversationId) {
getConversation(tenantCode, conversationId);
conversationMapper.deleteById(conversationId);
}
@Transactional
public void bindAccount(String tenantCode, Long conversationId, BindConversationAccountRequest request) {
getConversation(tenantCode, conversationId);

View File

@@ -6,6 +6,7 @@ import com.sino.mci.channel.wecom.archive.FinanceSdkClient;
import com.sino.mci.channel.wecom.archive.model.FinanceMessage;
import com.sino.mci.inbound.entity.MessageEvent;
import com.sino.mci.inbound.repository.MessageEventMapper;
import com.sino.mci.inbound.service.InboundMessageService;
import com.sino.mci.message.entity.MessageMedia;
import com.sino.mci.message.repository.MessageMediaMapper;
import com.sino.mci.shared.util.JsonUtils;
@@ -40,6 +41,7 @@ public class SessionArchivePullService {
private final StringRedisTemplate redisTemplate;
private final MessageEventMapper messageEventMapper;
private final MessageMediaMapper messageMediaMapper;
private final InboundMessageService inboundMessageService;
/**
* 消费会话存档拉取命令。
@@ -131,6 +133,7 @@ public class SessionArchivePullService {
event.setIsProcessed(0);
event.setStatus(1);
messageEventMapper.insert(event);
inboundMessageService.processInboundMessage(event);
}
private void saveMediaIfNeeded(SessionArchivePullCommand command, FinanceMessage msg, String decryptedJson) {

View File

@@ -4,6 +4,7 @@ import com.sino.mci.admin.security.AuthContext;
import com.sino.mci.crm.dto.BindTagsRequest;
import com.sino.mci.crm.dto.CreateIdentityRequest;
import com.sino.mci.crm.dto.CreatePersonRequest;
import com.sino.mci.crm.dto.UpdatePersonRequest;
import com.sino.mci.crm.entity.Person;
import com.sino.mci.crm.service.PersonService;
import com.sino.mci.shared.web.ApiResponse;
@@ -39,6 +40,22 @@ public class PersonController {
return ApiResponse.ok(personService.getPerson(tenantCode, personId));
}
@PutMapping("/{person_id}")
public ApiResponse<?> updatePerson(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("person_id") Long personId,
@Valid @RequestBody UpdatePersonRequest request) {
return ApiResponse.ok(personService.updatePerson(tenantCode, personId, request));
}
@DeleteMapping("/{person_id}")
public ApiResponse<?> deletePerson(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("person_id") Long personId) {
personService.deletePerson(tenantCode, personId);
return ApiResponse.ok();
}
@PostMapping("/{person_id}/identities")
public ApiResponse<?> createIdentity(
@RequestHeader("X-Tenant-Code") String tenantCode,
@@ -55,6 +72,15 @@ public class PersonController {
return ApiResponse.ok(personService.listIdentities(tenantCode, personId));
}
@DeleteMapping("/{person_id}/identities/{identity_id}")
public ApiResponse<?> deleteIdentity(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("person_id") Long personId,
@PathVariable("identity_id") Long identityId) {
personService.deleteIdentity(tenantCode, personId, identityId);
return ApiResponse.ok();
}
@PostMapping("/{person_id}/bind-tags")
public ApiResponse<?> bindTags(
@RequestHeader("X-Tenant-Code") String tenantCode,

View File

@@ -1,6 +1,7 @@
package com.sino.mci.crm.controller;
import com.sino.mci.crm.dto.CreateTagRequest;
import com.sino.mci.crm.dto.UpdateTagRequest;
import com.sino.mci.crm.service.TagService;
import com.sino.mci.shared.web.ApiResponse;
import jakarta.validation.Valid;
@@ -25,4 +26,20 @@ public class TagController {
public ApiResponse<?> listTags(@RequestHeader("X-Tenant-Code") String tenantCode) {
return ApiResponse.ok(tagService.listTags(tenantCode));
}
@PutMapping("/{id}")
public ApiResponse<?> updateTag(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable Long id,
@Valid @RequestBody UpdateTagRequest request) {
return ApiResponse.ok(tagService.updateTag(tenantCode, id, request));
}
@DeleteMapping("/{id}")
public ApiResponse<?> deleteTag(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable Long id) {
tagService.deleteTag(tenantCode, id);
return ApiResponse.ok();
}
}

View File

@@ -0,0 +1,17 @@
package com.sino.mci.crm.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class UpdatePersonRequest {
@NotBlank
private String personName;
private String phone;
private Integer personType;
private Integer status;
private Long institutionId;
private Long supplierId;
}

View File

@@ -0,0 +1,13 @@
package com.sino.mci.crm.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class UpdateTagRequest {
@NotBlank
private String tagName;
private Integer status;
}

View File

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.crm.dto.BindTagsRequest;
import com.sino.mci.crm.dto.CreateIdentityRequest;
import com.sino.mci.crm.dto.CreatePersonRequest;
import com.sino.mci.crm.dto.UpdatePersonRequest;
import com.sino.mci.crm.entity.ChannelIdentity;
import com.sino.mci.crm.entity.Person;
import com.sino.mci.crm.entity.PersonTagBinding;
@@ -62,6 +63,35 @@ public class PersonService {
return person;
}
@Transactional
public Person updatePerson(String tenantCode, Long personId, UpdatePersonRequest request) {
Person person = getPerson(tenantCode, personId);
person.setPersonName(request.getPersonName());
if (request.getPhone() != null) {
person.setPhone(request.getPhone());
}
if (request.getPersonType() != null) {
person.setPersonType(request.getPersonType());
}
if (request.getStatus() != null) {
person.setStatus(request.getStatus());
}
if (request.getInstitutionId() != null) {
person.setInstitutionId(request.getInstitutionId());
}
if (request.getSupplierId() != null) {
person.setSupplierId(request.getSupplierId());
}
personMapper.updateById(person);
return person;
}
@Transactional
public void deletePerson(String tenantCode, Long personId) {
getPerson(tenantCode, personId);
personMapper.deleteById(personId);
}
public List<ChannelIdentity> listIdentities(String tenantCode, Long personId) {
getPerson(tenantCode, personId);
return identityMapper.selectList(
@@ -96,6 +126,17 @@ public class PersonService {
return identity;
}
@Transactional
public void deleteIdentity(String tenantCode, Long personId, Long identityId) {
getPerson(tenantCode, personId);
ChannelIdentity identity = identityMapper.selectById(identityId);
if (identity == null || !tenantCode.equals(identity.getTenantCode())
|| !personId.equals(identity.getPersonId())) {
throw new BusinessException("渠道身份不存在");
}
identityMapper.deleteById(identityId);
}
@Transactional
public void bindTags(String tenantCode, Long personId, BindTagsRequest request) {
getPerson(tenantCode, personId);

View File

@@ -2,6 +2,7 @@ package com.sino.mci.crm.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.crm.dto.CreateTagRequest;
import com.sino.mci.crm.dto.UpdateTagRequest;
import com.sino.mci.crm.entity.Tag;
import com.sino.mci.crm.repository.TagMapper;
import com.sino.mci.exception.BusinessException;
@@ -54,6 +55,30 @@ public class TagService {
return tag;
}
public Tag getTag(String tenantCode, Long tagId) {
Tag tag = tagMapper.selectById(tagId);
if (tag == null || !tenantCode.equals(tag.getTenantCode())) {
throw new BusinessException("标签不存在");
}
return tag;
}
@Transactional
public Tag updateTag(String tenantCode, Long tagId, UpdateTagRequest request) {
Tag tag = getTag(tenantCode, tagId);
if (request.getStatus() != null) {
tag.setStatus(request.getStatus());
}
tagMapper.updateById(tag);
return tag;
}
@Transactional
public void deleteTag(String tenantCode, Long tagId) {
getTag(tenantCode, tagId);
tagMapper.deleteById(tagId);
}
public List<Tag> listTags(String tenantCode) {
return tagMapper.selectList(
new LambdaQueryWrapper<Tag>()

View File

@@ -0,0 +1,20 @@
package com.sino.mci.exception;
/**
* 入站 webhook 推送异常。
*
* <p>当向业务系统推送入站消息失败时抛出,由流程引擎捕获并记录执行轨迹。</p>
*/
public class WebhookPushException extends BusinessException {
public WebhookPushException(String message) {
super(message);
}
public WebhookPushException(String message, Throwable cause) {
super(message);
if (cause != null) {
initCause(cause);
}
}
}

View File

@@ -56,4 +56,12 @@ public class FlowDefinitionController {
flowDefinitionService.offlineFlow(tenantCode, flowCode);
return ApiResponse.ok();
}
@DeleteMapping("/{flow_code}")
public ApiResponse<?> deleteFlow(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("flow_code") String flowCode) {
flowDefinitionService.deleteFlow(tenantCode, flowCode);
return ApiResponse.ok();
}
}

View File

@@ -0,0 +1,69 @@
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/flow")
@RequiredArgsConstructor
public class IvrFlowController {
private final FlowDefinitionService flowDefinitionService;
private static final Integer IVR_FLOW_TYPE = 2;
@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.listFlowsByType(tenantCode, IVR_FLOW_TYPE));
}
@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();
}
@DeleteMapping("/{flow_code}")
public ApiResponse<?> deleteFlow(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("flow_code") String flowCode) {
flowDefinitionService.deleteFlow(tenantCode, flowCode);
return ApiResponse.ok();
}
}

View File

@@ -58,6 +58,15 @@ public class FlowDefinitionService {
.orderByDesc(FlowDefinition::getCreateTime));
}
public List<FlowDefinition> listFlowsByType(String tenantCode, Integer flowType) {
return flowDefinitionMapper.selectList(
new LambdaQueryWrapper<FlowDefinition>()
.eq(FlowDefinition::getTenantCode, tenantCode)
.eq(FlowDefinition::getFlowType, flowType)
.eq(FlowDefinition::getDeleted, 0)
.orderByDesc(FlowDefinition::getCreateTime));
}
@Transactional
public FlowDefinition updateFlow(String tenantCode, String flowCode, CreateFlowRequest request) {
FlowDefinition flow = getFlow(tenantCode, flowCode);
@@ -78,10 +87,16 @@ public class FlowDefinitionService {
@Transactional
public void offlineFlow(String tenantCode, String flowCode) {
FlowDefinition flow = getFlow(tenantCode, flowCode);
flow.setStatus(0);
flow.setStatus(2);
flowDefinitionMapper.updateById(flow);
}
@Transactional
public void deleteFlow(String tenantCode, String flowCode) {
FlowDefinition flow = getFlow(tenantCode, flowCode);
flowDefinitionMapper.deleteById(flow.getId());
}
private Integer parseFlowType(String flowType) {
if (flowType == null || flowType.isBlank()) {
return 1;

View File

@@ -0,0 +1,31 @@
package com.sino.mci.inbound.controller;
import com.sino.mci.inbound.dto.IntentRecognizeRequest;
import com.sino.mci.inbound.intent.IntentRecognitionService;
import com.sino.mci.inbound.intent.IntentResult;
import com.sino.mci.shared.web.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 意图识别调试接口。
*/
@RestController
@RequestMapping("/api/v1/intent")
@RequiredArgsConstructor
public class IntentController {
private final IntentRecognitionService intentRecognitionService;
@PostMapping("/recognize")
public ApiResponse<IntentResult> recognize(
@RequestHeader("X-Tenant-Code") String tenantCode,
@RequestBody IntentRecognizeRequest request) {
return ApiResponse.ok(intentRecognitionService.recognize(
tenantCode, request.getText(), request.getContext()));
}
}

View File

@@ -0,0 +1,15 @@
package com.sino.mci.inbound.dto;
import lombok.Data;
import java.util.Map;
/**
* 意图识别调试请求。
*/
@Data
public class IntentRecognizeRequest {
private String text;
private Map<String, Object> context;
}

View File

@@ -0,0 +1,54 @@
package com.sino.mci.inbound.engine;
import com.sino.mci.inbound.entity.MessageEvent;
import com.sino.mci.inbound.intent.IntentResult;
import lombok.Data;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 流程执行上下文。
*
* <p>包含当前消息事件、业务上下文、意图识别结果以及流程变量,
* 供条件表达式SpEL与节点执行使用。</p>
*/
@Data
public class FlowContext {
private final MessageEvent event;
private final Map<String, Object> businessContext;
private IntentResult intent;
private final Map<String, Object> variables = new HashMap<>();
public FlowContext(MessageEvent event, Map<String, Object> businessContext) {
this.event = event;
this.businessContext = businessContext != null ? businessContext : new LinkedHashMap<>();
}
/**
* 构建用于 SpEL 表达式求值的根对象。
*/
public Map<String, Object> toEvaluationMap() {
Map<String, Object> root = new HashMap<>();
root.put("event", event);
root.put("businessContext", businessContext);
root.put("intent", toIntentMap());
root.put("variables", variables);
return root;
}
private Map<String, Object> toIntentMap() {
if (intent == null) {
return null;
}
Map<String, Object> map = new LinkedHashMap<>();
map.put("intent", intent.getIntent());
map.put("confidence", intent.getConfidence());
map.put("slots", intent.getSlots());
map.put("missingSlots", intent.getMissingSlots());
map.put("requiresConfirmation", intent.isRequiresConfirmation());
return map;
}
}

View File

@@ -0,0 +1,85 @@
package com.sino.mci.inbound.engine;
import com.sino.mci.shared.util.JsonUtils;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 流程定义 JSON 解析器。
*
* <p>将 {@code mci_flow_definition.definition_json} 中的 {@code nodes} 与 {@code edges}
* 解析为 {@link FlowNode} 与 {@link FlowEdge} 对象。</p>
*/
public final class FlowDefinitionParser {
private FlowDefinitionParser() {
}
@SuppressWarnings("unchecked")
public static List<FlowNode> parseNodes(String definitionJson) {
Map<String, Object> definition = JsonUtils.fromJson(definitionJson, Map.class);
if (definition == null) {
return List.of();
}
Object nodesObj = definition.get("nodes");
if (!(nodesObj instanceof List<?>)) {
return List.of();
}
List<FlowNode> nodes = new ArrayList<>();
for (Object item : (List<?>) nodesObj) {
if (!(item instanceof Map<?, ?> map)) {
continue;
}
String id = toString(map.get("id"));
String type = toString(map.get("type"));
if (id == null || type == null) {
continue;
}
nodes.add(new FlowNode(id, type, toConfigMap(map.get("config"))));
}
return nodes;
}
@SuppressWarnings("unchecked")
public static List<FlowEdge> parseEdges(String definitionJson) {
Map<String, Object> definition = JsonUtils.fromJson(definitionJson, Map.class);
if (definition == null) {
return List.of();
}
Object edgesObj = definition.get("edges");
if (!(edgesObj instanceof List<?>)) {
return List.of();
}
List<FlowEdge> edges = new ArrayList<>();
for (Object item : (List<?>) edgesObj) {
if (!(item instanceof Map<?, ?> map)) {
continue;
}
String source = toString(map.get("source"));
String target = toString(map.get("target"));
if (source == null || target == null) {
continue;
}
edges.add(new FlowEdge(source, target, toString(map.get("label"))));
}
return edges;
}
private static Map<String, Object> toConfigMap(Object configObj) {
if (!(configObj instanceof Map<?, ?> map)) {
return new HashMap<>();
}
Map<String, Object> config = new HashMap<>();
for (Map.Entry<?, ?> entry : map.entrySet()) {
config.put(String.valueOf(entry.getKey()), entry.getValue());
}
return config;
}
private static String toString(Object value) {
return value != null ? value.toString() : null;
}
}

View File

@@ -0,0 +1,7 @@
package com.sino.mci.inbound.engine;
/**
* 入站流程定义边。
*/
public record FlowEdge(String source, String target, String label) {
}

View File

@@ -5,15 +5,21 @@ import com.sino.mci.flow.entity.FlowDefinition;
import com.sino.mci.flow.repository.FlowDefinitionMapper;
import com.sino.mci.inbound.entity.FlowExecutionTrace;
import com.sino.mci.inbound.entity.MessageEvent;
import com.sino.mci.inbound.intent.IntentResult;
import com.sino.mci.inbound.repository.FlowExecutionTraceMapper;
import com.sino.mci.inbound.repository.MessageEventMapper;
import com.sino.mci.send.dto.SendRequest;
import com.sino.mci.send.service.SendService;
import com.sino.mci.shared.util.JsonUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Slf4j
@Component
@@ -25,6 +31,7 @@ public class FlowEngine {
private final IntentRecognitionService intentRecognitionService;
private final WebhookPushService webhookPushService;
private final FlowExecutionTraceMapper flowExecutionTraceMapper;
private final SendService sendService;
public void executeInboundFlow(Long eventId) {
MessageEvent event = messageEventMapper.selectById(eventId);
@@ -33,7 +40,7 @@ public class FlowEngine {
return;
}
trace(event, null, "flow_start", event.getEventId(), null, 1, now(), null);
trace(event, null, "flow_start", null, event.getEventId(), null, 1, now(), null);
FlowDefinition flow = findPublishedFlow(event.getTenantCode());
Long flowDefinitionId = flow != null ? flow.getId() : null;
@@ -44,35 +51,184 @@ public class FlowEngine {
}
try {
Map<String, Object> definition = JsonUtils.fromJson(flow.getDefinitionJson(), Map.class);
if (definition == null) {
log.warn("Invalid flow definition JSON for flow {}", flow.getFlowCode());
List<FlowNode> nodes = FlowDefinitionParser.parseNodes(flow.getDefinitionJson());
List<FlowEdge> edges = FlowDefinitionParser.parseEdges(flow.getDefinitionJson());
if (nodes.isEmpty()) {
log.warn("Flow {} has no nodes", flow.getFlowCode());
markProcessed(event, flowDefinitionId);
return;
}
long recognizeStart = now();
IntentRecognitionService.IntentResult intent = intentRecognitionService.recognize(event);
trace(event, flowDefinitionId, "intent_recognition",
event.getContent(), JsonUtils.toJson(intent), 1, recognizeStart, null);
log.info("Intent recognized: eventId={}, intent={}, confidence={}",
event.getEventId(), intent.intent(), intent.confidence());
long pushStart = now();
try {
webhookPushService.pushToBusiness(event.getTenantCode(), event, intent);
trace(event, flowDefinitionId, "webhook_push", event.getEventId(), "ok", 1, pushStart, null);
} catch (Exception e) {
trace(event, flowDefinitionId, "webhook_push", event.getEventId(), null, 2, pushStart, e.getMessage());
throw e;
FlowNode receiveNode = nodes.stream()
.filter(n -> "receive".equals(n.type()))
.findFirst()
.orElse(null);
if (receiveNode == null) {
log.warn("Flow {} has no receive node", flow.getFlowCode());
markProcessed(event, flowDefinitionId);
return;
}
markProcessed(event, flowDefinitionId);
Map<String, FlowNode> nodeMap = nodes.stream()
.collect(Collectors.toMap(FlowNode::id, n -> n));
Map<String, List<FlowEdge>> adjacency = edges.stream()
.collect(Collectors.groupingBy(FlowEdge::source));
Map<String, Object> businessContext = parseBusinessContext(event.getBusinessContext());
FlowContext context = new FlowContext(event, businessContext);
FlowNode current = receiveNode;
while (current != null) {
long nodeStart = now();
NodeResult result;
try {
result = executeNode(current, context, flowDefinitionId, adjacency, nodeStart);
} catch (Exception e) {
trace(event, flowDefinitionId, current.type(), JsonUtils.toJson(current.config()),
current.id(), e.getMessage(), 2, nodeStart, e.getMessage());
log.error("Node execution failed: eventId={}, nodeId={}, nodeType={}",
event.getEventId(), current.id(), current.type(), e);
return;
}
if (result.terminal() || result.nextNodeId() == null) {
if (result.success()) {
markProcessed(event, flowDefinitionId);
}
return;
}
current = nodeMap.get(result.nextNodeId());
if (current == null) {
log.warn("Next node {} not found in flow {}", result.nextNodeId(), flow.getFlowCode());
return;
}
}
} catch (Exception e) {
log.error("Flow execution failed for event {}", eventId, e);
}
}
private NodeResult executeNode(FlowNode node, FlowContext context, Long flowDefinitionId,
Map<String, List<FlowEdge>> adjacency, long startMs) {
String nodeConfigJson = JsonUtils.toJson(node.config());
MessageEvent event = context.getEvent();
switch (node.type()) {
case "receive" -> {
Object expectedChannel = node.config().get("channel_type");
if (expectedChannel != null && !expectedChannel.toString().equalsIgnoreCase(event.getChannelType())) {
throw new IllegalStateException("Channel type mismatch: expected " + expectedChannel
+ ", actual " + event.getChannelType());
}
trace(event, flowDefinitionId, node.type(), nodeConfigJson,
event.getEventId(), event.getChannelType(), 1, startMs, null);
return new NodeResult(firstTarget(adjacency.get(node.id())), false, true, "ok");
}
case "intent" -> {
IntentResult intent = intentRecognitionService.recognize(event);
context.setIntent(intent);
String intentJson = JsonUtils.toJson(intent);
trace(event, flowDefinitionId, node.type(), nodeConfigJson,
event.getContent(), intentJson, 1, startMs, null);
return new NodeResult(firstTarget(adjacency.get(node.id())), false, true, intentJson);
}
case "condition" -> {
String expression = node.config().get("expression") != null
? node.config().get("expression").toString() : "";
if (expression.isBlank()) {
String nextNodeId = firstTarget(adjacency.get(node.id()));
trace(event, flowDefinitionId, node.type(), nodeConfigJson,
expression, "empty -> first", 1, startMs, null);
return new NodeResult(nextNodeId, false, true, "empty");
}
boolean expressionResult = SpelConditionEvaluator.evaluate(expression, context);
String nextNodeId = selectConditionTarget(adjacency.get(node.id()), expressionResult);
trace(event, flowDefinitionId, node.type(), nodeConfigJson,
expression, String.valueOf(expressionResult), 1, startMs, null);
return new NodeResult(nextNodeId, false, true, String.valueOf(expressionResult));
}
case "webhook" -> {
String customUrl = node.config().get("url") != null
? node.config().get("url").toString() : null;
webhookPushService.pushToBusiness(event.getTenantCode(), event, context.getIntent(), customUrl);
trace(event, flowDefinitionId, node.type(), nodeConfigJson,
event.getEventId(), "pushed", 1, startMs, null);
return new NodeResult(null, true, true, "pushed");
}
case "reply" -> {
SendRequest request = buildReplyRequest(event, node.config());
sendService.send(event.getTenantCode(), request);
trace(event, flowDefinitionId, node.type(), nodeConfigJson,
event.getEventId(), JsonUtils.toJson(request), 1, startMs, null);
return new NodeResult(null, true, true, "sent");
}
default -> throw new IllegalArgumentException("Unsupported node type: " + node.type());
}
}
private String firstTarget(List<FlowEdge> edges) {
if (edges == null || edges.isEmpty()) {
return null;
}
return edges.get(0).target();
}
private String selectConditionTarget(List<FlowEdge> edges, boolean result) {
if (edges == null || edges.isEmpty()) {
return null;
}
String expectedLabel = String.valueOf(result);
return edges.stream()
.filter(e -> expectedLabel.equalsIgnoreCase(e.label()))
.findFirst()
.map(FlowEdge::target)
.orElseGet(() -> edges.get(0).target());
}
private SendRequest buildReplyRequest(MessageEvent event, Map<String, Object> config) {
Object contentObj = config.get("content");
Map<String, Object> content;
if (contentObj instanceof Map<?, ?> map) {
content = new HashMap<>();
for (Map.Entry<?, ?> entry : map.entrySet()) {
content.put(String.valueOf(entry.getKey()), entry.getValue());
}
} else {
content = new HashMap<>();
content.put("type", "text");
content.put("body", contentObj != null ? contentObj.toString() : "");
}
SendRequest request = new SendRequest();
request.setContentType(String.valueOf(content.get("type")));
request.setContent(content);
Map<String, Object> channelStrategy = new HashMap<>();
channelStrategy.put("channel_type", event.getChannelType());
request.setChannelStrategy(channelStrategy);
if (event.getConversationId() != null) {
request.setTargetType("conversation");
request.setTargetValue(Map.of("conversation_id", event.getConversationId()));
} else if (event.getPersonId() != null) {
request.setTargetType("person");
request.setTargetValue(Map.of("person_id", event.getPersonId()));
} else {
throw new IllegalStateException("Cannot reply: message event has no conversation_id or person_id");
}
return request;
}
@SuppressWarnings("unchecked")
private Map<String, Object> parseBusinessContext(String businessContextJson) {
if (businessContextJson == null || businessContextJson.isBlank()) {
return new LinkedHashMap<>();
}
Map<String, Object> context = JsonUtils.fromJson(businessContextJson, Map.class);
return context != null ? context : new LinkedHashMap<>();
}
private FlowDefinition findPublishedFlow(String tenantCode) {
List<FlowDefinition> flows = flowDefinitionMapper.selectList(
new LambdaQueryWrapper<FlowDefinition>()
@@ -86,16 +242,16 @@ public class FlowEngine {
private void markProcessed(MessageEvent event, Long flowDefinitionId) {
event.setIsProcessed(1);
messageEventMapper.updateById(event);
trace(event, flowDefinitionId, "mark_processed", event.getEventId(), "processed", 1, now(), null);
trace(event, flowDefinitionId, "mark_processed", null, event.getEventId(), "processed", 1, now(), null);
}
private void trace(MessageEvent event, Long flowDefinitionId, String nodeType,
private void trace(MessageEvent event, Long flowDefinitionId, String nodeType, String nodeConfig,
String input, String output, int status, long startMs, String errorMsg) {
FlowExecutionTrace record = new FlowExecutionTrace();
record.setEventId(event.getEventId());
record.setFlowDefinitionId(flowDefinitionId);
record.setNodeType(nodeType);
record.setNodeConfig(null);
record.setNodeConfig(nodeConfig);
record.setInput(input);
record.setOutput(output);
record.setStatus(status);
@@ -107,4 +263,7 @@ public class FlowEngine {
private long now() {
return System.currentTimeMillis();
}
private record NodeResult(String nextNodeId, boolean terminal, boolean success, String output) {
}
}

View File

@@ -0,0 +1,9 @@
package com.sino.mci.inbound.engine;
import java.util.Map;
/**
* 入站流程定义节点。
*/
public record FlowNode(String id, String type, Map<String, Object> config) {
}

View File

@@ -1,11 +1,9 @@
package com.sino.mci.inbound.engine;
import com.sino.mci.inbound.entity.MessageEvent;
import com.sino.mci.inbound.intent.IntentResult;
public interface IntentRecognitionService {
IntentResult recognize(MessageEvent event);
record IntentResult(String intent, double confidence, String reason) {
}
}

View File

@@ -1,31 +1,43 @@
package com.sino.mci.inbound.engine;
import com.sino.mci.inbound.entity.MessageEvent;
import com.sino.mci.inbound.intent.IntentResult;
import com.sino.mci.shared.util.JsonUtils;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Service
public class MockIntentRecognitionService implements IntentRecognitionService {
private static final Pattern CUSTOMER_NAME_PATTERN = Pattern.compile("客户\\s*[:]?\\s*([^\\s,]+)");
private static final Pattern ADDRESS_PATTERN = Pattern.compile("(?:地址|在)\\s*[:]?\\s*([^\\s,]+)");
private static final Pattern PHONE_PATTERN = Pattern.compile("(1[3-9]\\d{9})");
@Override
public IntentResult recognize(MessageEvent event) {
String text = extractText(event);
if (text == null || text.isBlank()) {
return new IntentResult("unknown", 0.0, "empty content");
return unknownResult();
}
String lower = text.toLowerCase();
if (lower.contains("救援") || lower.contains("拖车") || lower.contains("故障")) {
return new IntentResult("rescue_request", 0.95, "contains rescue keywords");
Map<String, String> slots = extractSlots(text);
List<String> missing = resolveMissingSlots(List.of("location", "contact_phone"), slots);
return new IntentResult("rescue_request", 0.95, slots, missing, !missing.isEmpty());
}
if (lower.contains("咨询") || lower.contains("问一下")) {
return new IntentResult("consult", 0.85, "contains consult keywords");
return new IntentResult("consult", 0.85, Map.of(), List.of(), false);
}
if (lower.contains("投诉") || lower.contains("不满")) {
return new IntentResult("complaint", 0.88, "contains complaint keywords");
return new IntentResult("complaint", 0.88, Map.of(), List.of(), false);
}
return new IntentResult("other", 0.6, "no clear intent matched");
return new IntentResult("other", 0.6, Map.of(), List.of(), false);
}
private String extractText(MessageEvent event) {
@@ -40,4 +52,39 @@ public class MockIntentRecognitionService implements IntentRecognitionService {
Object body = content.get("body");
return body != null ? body.toString() : null;
}
private Map<String, String> extractSlots(String text) {
Map<String, String> slots = new HashMap<>();
Matcher customerMatcher = CUSTOMER_NAME_PATTERN.matcher(text);
if (customerMatcher.find()) {
slots.put("customer_name", customerMatcher.group(1).trim());
}
Matcher addressMatcher = ADDRESS_PATTERN.matcher(text);
if (addressMatcher.find()) {
slots.put("address", addressMatcher.group(1).trim());
}
Matcher phoneMatcher = PHONE_PATTERN.matcher(text);
if (phoneMatcher.find()) {
slots.put("phone", phoneMatcher.group(1).trim());
}
return slots;
}
private List<String> resolveMissingSlots(List<String> expectedSlots, Map<String, String> slots) {
List<String> missing = new ArrayList<>();
for (String slot : expectedSlots) {
if (!slots.containsKey(slot) || slots.get(slot) == null || slots.get(slot).isBlank()) {
missing.add(slot);
}
}
return missing;
}
private IntentResult unknownResult() {
return new IntentResult("unknown", 0.0, Map.of(), List.of(), false);
}
}

View File

@@ -0,0 +1,38 @@
package com.sino.mci.inbound.engine;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.MapAccessor;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.util.StringUtils;
/**
* SpEL 条件表达式求值器。
*/
public final class SpelConditionEvaluator {
private static final SpelExpressionParser PARSER = new SpelExpressionParser();
private SpelConditionEvaluator() {
}
/**
* 对给定表达式进行求值。
*
* @param expression SpEL 表达式;为空或空白时返回 {@code true}
* @param context 流程执行上下文
* @return 表达式求值结果
*/
public static boolean evaluate(String expression, FlowContext context) {
if (!StringUtils.hasText(expression)) {
return true;
}
try {
StandardEvaluationContext evaluationContext = new StandardEvaluationContext(context.toEvaluationMap());
evaluationContext.addPropertyAccessor(new MapAccessor());
Boolean result = PARSER.parseExpression(expression).getValue(evaluationContext, Boolean.class);
return Boolean.TRUE.equals(result);
} catch (Exception e) {
throw new IllegalArgumentException("Failed to evaluate condition expression: " + expression, e);
}
}
}

View File

@@ -3,16 +3,25 @@ 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.exception.WebhookPushException;
import com.sino.mci.inbound.entity.MessageEvent;
import com.sino.mci.inbound.intent.IntentResult;
import com.sino.mci.inbound.webhook.WebhookPayloadBuilder;
import com.sino.mci.inbound.webhook.WebhookSignature;
import com.sino.mci.inbound.webhook.WebhookSignatureService;
import com.sino.mci.shared.util.JsonUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@@ -20,39 +29,91 @@ import java.util.Map;
@RequiredArgsConstructor
public class WebhookPushService {
private final TenantMapper tenantMapper;
private final RestTemplate restTemplate = new RestTemplate();
private static final String AUTH_CONFIG_WEBHOOK_SECRET = "webhook_secret";
private static final String AUTH_CONFIG_SIGN_ALGORITHM = "sign_algorithm";
public void pushToBusiness(String tenantCode, MessageEvent event, IntentRecognitionService.IntentResult intent) {
private final TenantMapper tenantMapper;
private final RestTemplate restTemplate;
private final WebhookPayloadBuilder payloadBuilder;
private final WebhookSignatureService signatureService;
public void pushToBusiness(String tenantCode, MessageEvent event, IntentResult intent) {
pushToBusiness(tenantCode, event, intent, null);
}
public void pushToBusiness(String tenantCode, MessageEvent event, IntentResult intent, String customUrl) {
Tenant tenant = loadTenant(tenantCode);
String webhookUrl = resolveWebhookUrl(tenant, customUrl);
Map<String, Object> authConfig = parseAuthConfig(tenant.getAuthConfig());
String secret = getString(authConfig, AUTH_CONFIG_WEBHOOK_SECRET);
if (!StringUtils.hasText(secret)) {
throw new WebhookPushException("Tenant " + tenantCode + " has no webhook_secret configured");
}
String algorithm = getString(authConfig, AUTH_CONFIG_SIGN_ALGORITHM);
if (!StringUtils.hasText(algorithm)) {
algorithm = "hmac-sha256";
}
Map<String, Object> payload = payloadBuilder.buildPayload(event, intent);
String payloadJson = JsonUtils.toJson(payload);
if (payloadJson == null) {
throw new WebhookPushException("Failed to serialize webhook payload for tenant " + tenantCode);
}
WebhookSignature signature = signatureService.sign(tenantCode, payloadJson, secret, algorithm);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Msg-Platform-Signature", signature.signature());
headers.set("X-Msg-Platform-Timestamp", String.valueOf(signature.timestamp()));
headers.set("X-Msg-Platform-Tenant", tenantCode);
HttpEntity<String> entity = new HttpEntity<>(payloadJson, headers);
try {
ResponseEntity<String> response = restTemplate.exchange(
webhookUrl, HttpMethod.POST, entity, String.class);
if (!response.getStatusCode().is2xxSuccessful()) {
throw new WebhookPushException("Webhook push failed with status " + response.getStatusCode()
+ " for tenant " + tenantCode);
}
log.info("Webhook pushed to {}, status={}, tenant={}", webhookUrl, response.getStatusCode(), tenantCode);
} catch (RestClientException e) {
throw new WebhookPushException("Webhook push failed for tenant " + tenantCode + ": " + e.getMessage(), e);
}
}
private Tenant loadTenant(String tenantCode) {
Tenant tenant = tenantMapper.selectOne(
new LambdaQueryWrapper<Tenant>().eq(Tenant::getTenantCode, tenantCode));
if (tenant == null) {
log.warn("Tenant not found for webhook push: {}", tenantCode);
return;
throw new WebhookPushException("Tenant not found for webhook push: " + tenantCode);
}
return tenant;
}
private String resolveWebhookUrl(Tenant tenant, String customUrl) {
if (StringUtils.hasText(customUrl)) {
return customUrl;
}
String webhookUrl = tenant.getInboundWebhookUrl();
if (webhookUrl == null || webhookUrl.isBlank()) {
log.warn("Tenant {} has no inbound webhook URL configured", tenantCode);
return;
if (!StringUtils.hasText(webhookUrl)) {
throw new WebhookPushException("Tenant " + tenant.getTenantCode() + " has no inbound webhook URL configured");
}
return webhookUrl;
}
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());
@SuppressWarnings("unchecked")
private Map<String, Object> parseAuthConfig(String authConfigJson) {
if (authConfigJson == null || authConfigJson.isBlank()) {
return Map.of();
}
Map<String, Object> config = JsonUtils.fromJson(authConfigJson, Map.class);
return config != null ? config : Map.of();
}
private String getString(Map<String, Object> config, String key) {
Object value = config.get(key);
return value != null ? value.toString() : null;
}
}

View File

@@ -0,0 +1,287 @@
package com.sino.mci.inbound.intent;
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.shared.util.JsonUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 规则 + LLM 意图识别服务。
*
* <p>优先使用租户自定义规则(配置在 {@code mci_tenant.ext_info.intent_rules}
* 其次使用默认规则;规则未命中时调用 LLMLLM 未配置时返回 {@code unknown}。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class IntentRecognitionService {
private static final double RULE_CONFIDENCE_THRESHOLD = 0.9;
private static final List<IntentRule> DEFAULT_RULES = List.of(
new IntentRule(List.of("下单", "我要下单"), "create_order", 1.0,
List.of("customer_name", "address", "phone")),
new IntentRule(List.of("查状态", "状态"), "query_status", 1.0, List.of())
);
private static final Pattern CUSTOMER_NAME_PATTERN = Pattern.compile("客户\\s*[:]?\\s*([^\\s,]+)");
private static final Pattern ADDRESS_PATTERN = Pattern.compile("(?:地址|在)\\s*[:]?\\s*([^\\s,]+)");
private static final Pattern PHONE_PATTERN = Pattern.compile("(1[3-9]\\d{9})");
private final TenantMapper tenantMapper;
@Autowired(required = false)
private ChatClient.Builder chatClientBuilder;
@Value("${spring.ai.openai.api-key:}")
private String apiKey;
/**
* 识别用户消息的意图。
*
* @param tenantCode 租户编码
* @param text 用户消息文本
* @param businessContext 业务上下文
* @return 意图识别结果
*/
public IntentResult recognize(String tenantCode, String text, Map<String, Object> businessContext) {
if (text == null || text.isBlank()) {
return unknownResult();
}
List<IntentRule> rules = resolveRules(tenantCode);
for (IntentRule rule : rules) {
if (matches(rule, text)) {
Map<String, String> slots = extractSlots(text);
List<String> missingSlots = resolveMissingSlots(rule, slots);
return new IntentResult(rule.intent(), rule.confidence(), slots,
missingSlots, !missingSlots.isEmpty());
}
}
if (!isLlmConfigured()) {
log.debug("No rule matched and LLM is not configured, returning unknown intent");
return unknownResult();
}
return callLlm(text, businessContext);
}
private List<IntentRule> resolveRules(String tenantCode) {
List<IntentRule> rules = new ArrayList<>(loadTenantRules(tenantCode));
rules.addAll(DEFAULT_RULES);
return rules;
}
private List<IntentRule> loadTenantRules(String tenantCode) {
Tenant tenant;
try {
tenant = tenantMapper.selectOne(
new LambdaQueryWrapper<Tenant>().eq(Tenant::getTenantCode, tenantCode));
} catch (Exception e) {
log.warn("Failed to load tenant rules for {}", tenantCode, e);
return List.of();
}
if (tenant == null || tenant.getExtInfo() == null || tenant.getExtInfo().isBlank()) {
return List.of();
}
Map<String, Object> ext = JsonUtils.fromJson(tenant.getExtInfo(), Map.class);
if (ext == null) {
return List.of();
}
Object rulesObj = ext.get("intent_rules");
if (!(rulesObj instanceof List<?>)) {
return List.of();
}
List<IntentRule> rules = new ArrayList<>();
for (Object item : (List<?>) rulesObj) {
if (!(item instanceof Map<?, ?> map)) {
continue;
}
List<String> keywords = toStringList(map.get("keywords"));
String intent = toString(map.get("intent"));
Double confidence = toDouble(map.get("confidence"));
List<String> expectedSlots = toStringList(map.get("expectedSlots"));
if (intent != null && !intent.isBlank() && !keywords.isEmpty()) {
rules.add(new IntentRule(keywords, intent,
confidence != null ? confidence : 1.0,
expectedSlots));
}
}
return rules;
}
private boolean matches(IntentRule rule, String text) {
for (String keyword : rule.keywords()) {
if (text.contains(keyword)) {
return true;
}
}
return false;
}
private Map<String, String> extractSlots(String text) {
Map<String, String> slots = new LinkedHashMap<>();
Matcher customerMatcher = CUSTOMER_NAME_PATTERN.matcher(text);
if (customerMatcher.find()) {
slots.put("customer_name", customerMatcher.group(1).trim());
}
Matcher addressMatcher = ADDRESS_PATTERN.matcher(text);
if (addressMatcher.find()) {
slots.put("address", addressMatcher.group(1).trim());
}
Matcher phoneMatcher = PHONE_PATTERN.matcher(text);
if (phoneMatcher.find()) {
slots.put("phone", phoneMatcher.group(1).trim());
}
return slots;
}
private List<String> resolveMissingSlots(IntentRule rule, Map<String, String> slots) {
List<String> missing = new ArrayList<>();
for (String slot : rule.expectedSlots()) {
if (!slots.containsKey(slot) || slots.get(slot) == null || slots.get(slot).isBlank()) {
missing.add(slot);
}
}
return missing;
}
private boolean isLlmConfigured() {
return chatClientBuilder != null && apiKey != null && !apiKey.isBlank();
}
private IntentResult callLlm(String text, Map<String, Object> businessContext) {
String prompt = buildPrompt(text, businessContext);
try {
String content = chatClientBuilder.build()
.prompt()
.user(prompt)
.call()
.content();
return parseLlmResponse(content);
} catch (Exception e) {
log.warn("LLM intent recognition failed: {}", e.getMessage());
return unknownResult();
}
}
private String buildPrompt(String text, Map<String, Object> businessContext) {
return "You are an intent classifier. Given the user message and context, output ONLY a JSON object "
+ "with fields: intent, confidence (0-1), slots (object), missingSlots (array), "
+ "requiresConfirmation (boolean).\n\n"
+ "User message: " + text + "\n"
+ "Context: " + JsonUtils.toJson(businessContext);
}
@SuppressWarnings("unchecked")
private IntentResult parseLlmResponse(String content) {
if (content == null || content.isBlank()) {
return unknownResult();
}
String json = stripMarkdownCodeBlock(content);
Map<String, Object> map = JsonUtils.fromJson(json, Map.class);
if (map == null) {
return unknownResult();
}
String intent = toString(map.get("intent"));
if (intent == null || intent.isBlank()) {
intent = "unknown";
}
double confidence = toDouble(map.get("confidence"));
Map<String, String> slots = toStringMap(map.get("slots"));
List<String> missingSlots = toStringList(map.get("missingSlots"));
boolean requiresConfirmation = Boolean.TRUE.equals(map.get("requiresConfirmation"));
return new IntentResult(intent, confidence, slots, missingSlots, requiresConfirmation);
}
private String stripMarkdownCodeBlock(String content) {
String trimmed = content.trim();
if (trimmed.startsWith("```")) {
int firstNewline = trimmed.indexOf('\n');
int lastFence = trimmed.lastIndexOf("```");
if (firstNewline > 0 && lastFence > firstNewline) {
return trimmed.substring(firstNewline + 1, lastFence).trim();
}
}
return trimmed;
}
private IntentResult unknownResult() {
return new IntentResult("unknown", 0.0, Map.of(), List.of(), false);
}
private static String toString(Object value) {
return value != null ? value.toString() : null;
}
private static Double toDouble(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number number) {
return number.doubleValue();
}
try {
return Double.parseDouble(value.toString());
} catch (NumberFormatException e) {
return null;
}
}
@SuppressWarnings("unchecked")
private static List<String> toStringList(Object value) {
if (!(value instanceof Collection<?> collection)) {
return List.of();
}
List<String> result = new ArrayList<>();
for (Object item : collection) {
if (item != null) {
result.add(item.toString());
}
}
return result;
}
@SuppressWarnings("unchecked")
private static Map<String, String> toStringMap(Object value) {
if (!(value instanceof Map<?, ?> map)) {
return new HashMap<>();
}
Map<String, String> result = new LinkedHashMap<>();
for (Map.Entry<?, ?> entry : map.entrySet()) {
if (entry.getValue() != null) {
result.put(entry.getKey().toString(), entry.getValue().toString());
}
}
return result;
}
private record IntentRule(List<String> keywords, String intent, double confidence,
List<String> expectedSlots) {
}
}

View File

@@ -0,0 +1,35 @@
package com.sino.mci.inbound.intent;
import lombok.Data;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 意图识别结果。
*/
@Data
public class IntentResult {
private String intent;
private double confidence;
private Map<String, String> slots;
private List<String> missingSlots;
private boolean requiresConfirmation;
public IntentResult() {
this.slots = new HashMap<>();
this.missingSlots = new ArrayList<>();
}
public IntentResult(String intent, double confidence, Map<String, String> slots,
List<String> missingSlots, boolean requiresConfirmation) {
this.intent = intent;
this.confidence = confidence;
this.slots = slots != null ? slots : new HashMap<>();
this.missingSlots = missingSlots != null ? missingSlots : new ArrayList<>();
this.requiresConfirmation = requiresConfirmation;
}
}

View File

@@ -0,0 +1,40 @@
package com.sino.mci.inbound.intent;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 手动装配 OpenAI ChatClient。
*
* <p>Spring AI 1.0.0-M6 的 {@code OpenAiAutoConfiguration} 引用了 Spring Boot 4.1 中
* 不再存在的 {@code RestClientAutoConfiguration},因此排除官方自动配置;本配置在
* {@code spring.ai.openai.api-key} 存在时创建一个基于 {@code RestClient} 的
* {@link ChatModel},并据此生成 {@link ChatClient.Builder}。</p>
*/
@Configuration
@ConditionalOnExpression("'${spring.ai.openai.api-key:}'.trim() != ''")
public class OpenAiChatClientConfig {
@Value("${spring.ai.openai.api-key}")
private String apiKey;
@Value("${spring.ai.openai.base-url:https://api.openai.com}")
private String baseUrl;
@Value("${spring.ai.openai.chat.options.model:gpt-4o-mini}")
private String model;
@Bean
public ChatModel openAiChatModel() {
return new OpenAiRestChatModel(baseUrl, apiKey, model);
}
@Bean
public ChatClient.Builder openAiChatClientBuilder(ChatModel openAiChatModel) {
return ChatClient.builder(openAiChatModel);
}
}

View File

@@ -0,0 +1,89 @@
package com.sino.mci.inbound.intent;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.web.client.RestClient;
import java.util.List;
import java.util.Map;
/**
* 基于 {@code RestClient} 的 OpenAI ChatModel 最小实现。
*
* <p>Spring AI 1.0.0-M6 的 {@code OpenAiChatModel} 与当前 Spring Boot 4.1 / Spring Framework 7
* 存在二进制不兼容({@code HttpHeaders.addAll} 返回类型变化),因此使用该实现替代,
* 仍通过 Spring AI {@link ChatModel} 接入 {@code ChatClient}。</p>
*/
@Slf4j
public class OpenAiRestChatModel implements ChatModel {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final String baseUrl;
private final String apiKey;
private final String model;
private final RestClient restClient;
public OpenAiRestChatModel(String baseUrl, String apiKey, String model) {
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
this.apiKey = apiKey;
this.model = model;
this.restClient = RestClient.builder()
.baseUrl(this.baseUrl)
.defaultHeader("Authorization", "Bearer " + apiKey)
.build();
}
@Override
public ChatResponse call(Prompt prompt) {
String text = prompt.getContents();
Map<String, Object> request = Map.of(
"model", model,
"messages", List.of(Map.of("role", "user", "content", text)));
try {
String responseBody = restClient.post()
.uri("/v1/chat/completions")
.body(request)
.retrieve()
.body(String.class);
String content = extractContent(responseBody);
return new ChatResponse(List.of(new Generation(new AssistantMessage(content))));
} catch (Exception e) {
log.warn("OpenAI REST call failed: {}", e.getMessage());
throw new RuntimeException("LLM call failed", e);
}
}
@SuppressWarnings("unchecked")
private String extractContent(String responseBody) {
if (responseBody == null || responseBody.isBlank()) {
return "";
}
try {
Map<String, Object> root = OBJECT_MAPPER.readValue(responseBody, Map.class);
Object choices = root.get("choices");
if (!(choices instanceof List<?> list) || list.isEmpty()) {
return "";
}
Object first = list.get(0);
if (!(first instanceof Map<?, ?> choice)) {
return "";
}
Object message = choice.get("message");
if (!(message instanceof Map<?, ?> msg)) {
return "";
}
Object content = msg.get("content");
return content != null ? content.toString() : "";
} catch (Exception e) {
log.warn("Failed to parse LLM response: {}", e.getMessage());
return "";
}
}
}

View File

@@ -0,0 +1,35 @@
package com.sino.mci.inbound.mq;
import lombok.Data;
import java.io.Serializable;
/**
* 入站消息事件 MQ 消息体。
*
* <p>用于将已丰富业务上下文的消息事件发送到 {@code mci.inbound.exchange}
* 供下游阶段(意图识别、流程编排等)消费。</p>
*/
@Data
public class InboundEventMessage implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
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;
private String createTime;
}

View File

@@ -0,0 +1,39 @@
package com.sino.mci.inbound.mq;
import com.sino.mci.inbound.engine.FlowEngine;
import com.sino.mci.inbound.entity.MessageEvent;
import com.sino.mci.inbound.repository.MessageEventMapper;
import com.sino.mci.mq.config.MqConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
/**
* 入站流程事件消费者。
*
* <p>消费 {@code mci.inbound.flow.queue} 中意图识别后的事件,
* 加载完整 {@link MessageEvent} 并交由 {@link FlowEngine} 执行入站流程。</p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class InboundFlowConsumer {
private final FlowEngine flowEngine;
private final MessageEventMapper messageEventMapper;
@RabbitListener(queues = MqConfig.INBOUND_FLOW_QUEUE)
public void handle(InboundEventMessage message) {
log.info("收到入站流程事件: eventId={}", message.getEventId());
MessageEvent event = messageEventMapper.selectById(message.getId());
if (event == null) {
log.warn("入站流程事件不存在: eventId={}", message.getEventId());
return;
}
flowEngine.executeInboundFlow(event.getId());
log.info("入站流程执行完成: eventId={}", event.getEventId());
}
}

View File

@@ -0,0 +1,56 @@
package com.sino.mci.inbound.mq;
import com.sino.mci.inbound.entity.MessageEvent;
import com.sino.mci.mq.config.MqConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Component;
/**
* 意图识别后的事件 MQ 生产者。
*
* <p>将已识别意图的入站消息事件发送到 {@code mci.inbound.flow.exchange}
* 供下游流程引擎Phase 3.8)消费。</p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class InboundFlowMessageProducer {
private final RabbitTemplate rabbitTemplate;
/**
* 发布意图识别后的入站消息事件。
*
* @param event 消息事件
*/
public void publish(MessageEvent event) {
InboundEventMessage message = convert(event);
rabbitTemplate.convertAndSend(MqConfig.INBOUND_FLOW_EXCHANGE,
MqConfig.INBOUND_FLOW_ROUTING_KEY, message);
log.info("Inbound flow event published: eventId={}", event.getEventId());
}
private InboundEventMessage convert(MessageEvent event) {
InboundEventMessage message = new InboundEventMessage();
message.setId(event.getId());
message.setEventId(event.getEventId());
message.setTenantCode(event.getTenantCode());
message.setChannelType(event.getChannelType());
message.setChannelSubjectId(event.getChannelSubjectId());
message.setDirection(event.getDirection());
message.setEventType(event.getEventType());
message.setMessageSource(event.getMessageSource());
message.setConversationId(event.getConversationId());
message.setPersonId(event.getPersonId());
message.setAccountId(event.getAccountId());
message.setContent(event.getContent());
message.setRawPayload(event.getRawPayload());
message.setBusinessContext(event.getBusinessContext());
message.setIsProcessed(event.getIsProcessed());
message.setStatus(event.getStatus());
message.setCreateTime(event.getCreateTime() != null ? event.getCreateTime().toString() : null);
return message;
}
}

View File

@@ -0,0 +1,79 @@
package com.sino.mci.inbound.mq;
import com.sino.mci.inbound.entity.MessageEvent;
import com.sino.mci.inbound.intent.IntentRecognitionService;
import com.sino.mci.inbound.intent.IntentResult;
import com.sino.mci.inbound.repository.MessageEventMapper;
import com.sino.mci.mq.config.MqConfig;
import com.sino.mci.shared.util.JsonUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 入站消息事件消费者。
*
* <p>消费 {@code mci.inbound.queue} 中的事件,进行意图识别,
* 将识别结果写回业务上下文,并发布到 {@code mci.inbound.flow.exchange}
* 供流程引擎处理。</p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class InboundMessageConsumer {
private final MessageEventMapper messageEventMapper;
private final IntentRecognitionService intentRecognitionService;
private final InboundFlowMessageProducer inboundFlowMessageProducer;
@RabbitListener(queues = MqConfig.INBOUND_QUEUE)
public void handle(InboundEventMessage message) {
log.info("收到入站消息事件: eventId={}", message.getEventId());
MessageEvent event = messageEventMapper.selectById(message.getId());
if (event == null) {
log.warn("入站消息事件不存在: eventId={}", message.getEventId());
return;
}
String text = extractText(message.getContent());
Map<String, Object> businessContext = parseBusinessContext(event.getBusinessContext());
IntentResult intent = intentRecognitionService.recognize(
event.getTenantCode(), text, businessContext);
businessContext.put("intent", intent);
event.setBusinessContext(JsonUtils.toJson(businessContext));
event.setIsProcessed(0);
messageEventMapper.updateById(event);
inboundFlowMessageProducer.publish(event);
log.info("入站消息意图识别完成: eventId={}, intent={}, confidence={}",
event.getEventId(), intent.getIntent(), intent.getConfidence());
}
private String extractText(String contentJson) {
if (contentJson == null || contentJson.isBlank()) {
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;
}
@SuppressWarnings("unchecked")
private Map<String, Object> parseBusinessContext(String businessContextJson) {
if (businessContextJson == null || businessContextJson.isBlank()) {
return new LinkedHashMap<>();
}
Map<String, Object> context = JsonUtils.fromJson(businessContextJson, Map.class);
return context != null ? context : new LinkedHashMap<>();
}
}

View File

@@ -0,0 +1,52 @@
package com.sino.mci.inbound.mq;
import com.sino.mci.inbound.entity.MessageEvent;
import com.sino.mci.mq.config.MqConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Component;
/**
* 入站消息事件 MQ 生产者。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class InboundMessageProducer {
private final RabbitTemplate rabbitTemplate;
/**
* 发布已丰富的入站消息事件。
*
* @param event 消息事件
*/
public void publish(MessageEvent event) {
InboundEventMessage message = convert(event);
rabbitTemplate.convertAndSend(MqConfig.INBOUND_EXCHANGE, MqConfig.INBOUND_ROUTING_KEY, message);
log.info("Inbound event published: eventId={}", event.getEventId());
}
private InboundEventMessage convert(MessageEvent event) {
InboundEventMessage message = new InboundEventMessage();
message.setId(event.getId());
message.setEventId(event.getEventId());
message.setTenantCode(event.getTenantCode());
message.setChannelType(event.getChannelType());
message.setChannelSubjectId(event.getChannelSubjectId());
message.setDirection(event.getDirection());
message.setEventType(event.getEventType());
message.setMessageSource(event.getMessageSource());
message.setConversationId(event.getConversationId());
message.setPersonId(event.getPersonId());
message.setAccountId(event.getAccountId());
message.setContent(event.getContent());
message.setRawPayload(event.getRawPayload());
message.setBusinessContext(event.getBusinessContext());
message.setIsProcessed(event.getIsProcessed());
message.setStatus(event.getStatus());
message.setCreateTime(event.getCreateTime() != null ? event.getCreateTime().toString() : null);
return message;
}
}

View File

@@ -0,0 +1,146 @@
package com.sino.mci.inbound.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.channel.entity.Conversation;
import com.sino.mci.channel.entity.ConversationTagBinding;
import com.sino.mci.channel.repository.ConversationMapper;
import com.sino.mci.channel.repository.ConversationTagBindingMapper;
import com.sino.mci.crm.entity.Person;
import com.sino.mci.crm.entity.PersonTagBinding;
import com.sino.mci.crm.entity.Tag;
import com.sino.mci.crm.repository.PersonMapper;
import com.sino.mci.crm.repository.PersonTagBindingMapper;
import com.sino.mci.crm.repository.TagMapper;
import com.sino.mci.inbound.entity.MessageEvent;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
/**
* 入站消息上下文匹配服务。
*
* <p>根据消息事件关联的会话与联系人,解析业务上下文(合同、机构、服务商、标签等),
* 供后续入站流程编排意图识别、webhook 推送)使用。</p>
*/
@Service
@RequiredArgsConstructor
public class InboundContextService {
private final ConversationMapper conversationMapper;
private final PersonMapper personMapper;
private final ConversationTagBindingMapper conversationTagBindingMapper;
private final PersonTagBindingMapper personTagBindingMapper;
private final TagMapper tagMapper;
/**
* 根据消息事件解析业务上下文。
*
* <p>解析规则:</p>
* <ul>
* <li>会话维度:合同、服务商、机构、业务渠道编码、会话标签、群主账号;</li>
* <li>联系人维度:人员类型、机构、服务商、人员标签;</li>
* <li>事件维度:渠道类型、收发账号。</li>
* </ul>
* <p>当会话与联系人的机构/服务商不一致时,优先保留会话维度的值。</p>
*
* @param event 消息事件
* @return 业务上下文键值对
*/
public Map<String, Object> resolveContext(MessageEvent event) {
Map<String, Object> context = new LinkedHashMap<>();
resolveConversationContext(event, context);
resolvePersonContext(event, context);
if (event.getChannelType() != null) {
context.put("channel_type", event.getChannelType());
}
if (event.getAccountId() != null) {
context.put("account_id", event.getAccountId());
}
return context;
}
private void resolveConversationContext(MessageEvent event, Map<String, Object> context) {
Long conversationId = event.getConversationId();
if (conversationId == null) {
return;
}
Conversation conversation = conversationMapper.selectById(conversationId);
if (conversation == null) {
return;
}
putIfNotNull(context, "contract_id", conversation.getContractId());
putIfNotNull(context, "supplier_id", conversation.getSupplierId());
putIfNotNull(context, "institution_id", conversation.getInstitutionId());
putIfNotNull(context, "channel_code", conversation.getChannelCode());
List<String> tagPaths = conversationTagBindingMapper.selectList(
new LambdaQueryWrapper<ConversationTagBinding>()
.eq(ConversationTagBinding::getConversationId, conversationId))
.stream()
.map(ConversationTagBinding::getTagId)
.map(tagMapper::selectById)
.filter(Objects::nonNull)
.map(Tag::getTagPath)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
if (!tagPaths.isEmpty()) {
context.put("conversation_tags", tagPaths);
}
putIfNotNull(context, "owner_account_id", conversation.getOwnerAccountId());
}
private void resolvePersonContext(MessageEvent event, Map<String, Object> context) {
Long personId = event.getPersonId();
if (personId == null) {
return;
}
Person person = personMapper.selectById(personId);
if (person == null) {
return;
}
putIfNotNull(context, "person_type", person.getPersonType());
putIfAbsentAndNotNull(context, "institution_id", person.getInstitutionId());
putIfAbsentAndNotNull(context, "supplier_id", person.getSupplierId());
List<String> tagPaths = personTagBindingMapper.selectList(
new LambdaQueryWrapper<PersonTagBinding>()
.eq(PersonTagBinding::getPersonId, personId))
.stream()
.map(PersonTagBinding::getTagId)
.map(tagMapper::selectById)
.filter(Objects::nonNull)
.map(Tag::getTagPath)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
if (!tagPaths.isEmpty()) {
context.put("person_tags", tagPaths);
}
}
private void putIfNotNull(Map<String, Object> context, String key, Object value) {
if (value != null) {
context.put(key, value);
}
}
private void putIfAbsentAndNotNull(Map<String, Object> context, String key, Object value) {
if (value != null) {
context.computeIfAbsent(key, k -> value);
}
}
}

View File

@@ -5,6 +5,7 @@ import com.sino.mci.inbound.dto.ReceiveMessageRequest;
import com.sino.mci.inbound.entity.FlowExecutionTrace;
import com.sino.mci.inbound.entity.MessageEvent;
import com.sino.mci.inbound.engine.FlowEngine;
import com.sino.mci.inbound.mq.InboundMessageProducer;
import com.sino.mci.inbound.repository.FlowExecutionTraceMapper;
import com.sino.mci.inbound.repository.MessageEventMapper;
import com.sino.mci.shared.util.JsonUtils;
@@ -14,6 +15,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
@Slf4j
@Service
@@ -23,6 +25,8 @@ public class InboundMessageService {
private final MessageEventMapper messageEventMapper;
private final FlowExecutionTraceMapper flowExecutionTraceMapper;
private final FlowEngine flowEngine;
private final InboundContextService inboundContextService;
private final InboundMessageProducer inboundMessageProducer;
@Transactional
public MessageEvent receiveMessage(String tenantCode, ReceiveMessageRequest request) {
@@ -46,6 +50,8 @@ public class InboundMessageService {
log.info("Inbound message received: tenant={}, eventId={}, conversationId={}",
tenantCode, request.getEventId(), request.getConversationId());
processInboundMessage(event);
FlowExecutionTrace receiveTrace = new FlowExecutionTrace();
receiveTrace.setEventId(event.getEventId());
receiveTrace.setNodeType("receive");
@@ -58,6 +64,20 @@ public class InboundMessageService {
return messageEventMapper.selectById(event.getId());
}
/**
* 处理入站消息:解析上下文、持久化并发布到 MQ。
*
* @param event 已持久化的入站消息事件
*/
public void processInboundMessage(MessageEvent event) {
Map<String, Object> context = inboundContextService.resolveContext(event);
event.setBusinessContext(JsonUtils.toJson(context));
messageEventMapper.updateById(event);
inboundMessageProducer.publish(event);
log.info("Inbound message processed and published: eventId={}, conversationId={}, personId={}",
event.getEventId(), event.getConversationId(), event.getPersonId());
}
public List<FlowExecutionTrace> listTraces(String eventId) {
return flowExecutionTraceMapper.selectList(
new LambdaQueryWrapper<FlowExecutionTrace>()

View File

@@ -0,0 +1,81 @@
package com.sino.mci.inbound.webhook;
import com.sino.mci.inbound.entity.MessageEvent;
import com.sino.mci.inbound.intent.IntentResult;
import com.sino.mci.shared.util.JsonUtils;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 入站 webhook 结构化 payload 构造器。
*
* <p>将 {@link MessageEvent} 与意图识别结果转换为业务系统可消费的标准 JSON 结构,
* 字段命名与架构文档 1.8 节保持一致snake_case。</p>
*/
@Component
public class WebhookPayloadBuilder {
private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
public Map<String, Object> buildPayload(MessageEvent event, IntentResult intent) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("event_id", event.getEventId());
payload.put("tenant_code", event.getTenantCode());
payload.put("channel_type", event.getChannelType());
payload.put("direction", mapDirection(event.getDirection()));
payload.put("event_type", event.getEventType());
payload.put("conversation_id", event.getConversationId());
payload.put("person_id", event.getPersonId());
payload.put("account_id", event.getAccountId());
payload.put("content", parseJson(event.getContent()));
payload.put("intent", buildIntentMap(intent));
payload.put("business_context", parseJson(event.getBusinessContext()));
payload.put("raw_payload", parseJson(event.getRawPayload()));
payload.put("create_time", formatTime(event.getCreateTime()));
return payload;
}
private Object buildIntentMap(IntentResult intent) {
if (intent == null) {
return null;
}
Map<String, Object> map = new LinkedHashMap<>();
map.put("intent", intent.getIntent());
map.put("confidence", intent.getConfidence());
map.put("slots", intent.getSlots());
map.put("missing_slots", intent.getMissingSlots());
map.put("requires_confirmation", intent.isRequiresConfirmation());
return map;
}
@SuppressWarnings("unchecked")
private Object parseJson(String json) {
if (json == null || json.isBlank()) {
return null;
}
Object result = JsonUtils.fromJson(json, Object.class);
return result != null ? result : json;
}
private String mapDirection(Integer direction) {
if (direction == null) {
return "in";
}
return switch (direction) {
case 1 -> "in";
case 2 -> "out";
default -> String.valueOf(direction);
};
}
private String formatTime(LocalDateTime time) {
if (time == null) {
return null;
}
return time.format(ISO_FORMATTER);
}
}

View File

@@ -0,0 +1,11 @@
package com.sino.mci.inbound.webhook;
/**
* 入站 webhook 签名结果。
*
* @param signature Base64 编码的 HMAC 签名
* @param algorithm 签名算法,如 hmac-sha256 / hmac-sha512
* @param timestamp 签名时的时间戳Unix 秒)
*/
public record WebhookSignature(String signature, String algorithm, long timestamp) {
}

View File

@@ -0,0 +1,67 @@
package com.sino.mci.inbound.webhook;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
/**
* 入站 webhook 签名服务。
*
* <p>使用 HMAC-SHA256 / HMAC-SHA512 对原始 payload 进行签名,
* 输出 Base64 编码签名、算法标识及时间戳,供业务系统验签使用。</p>
*/
@Slf4j
@Service
public class WebhookSignatureService {
private static final String DEFAULT_ALGORITHM = "hmac-sha256";
private static final String HMAC_SHA256 = "HmacSHA256";
private static final String HMAC_SHA512 = "HmacSHA512";
/**
* 对原始 payload 进行签名。
*
* @param tenantCode 租户编码(用于日志/追踪,不参与签名计算)
* @param payload 待签名的原始 payload 字符串
* @param secret 租户 webhook 密钥
* @param algorithm 签名算法,支持 hmac-sha256默认和 hmac-sha512
* @return 签名结果,包含签名值、算法及时间戳
*/
public WebhookSignature sign(String tenantCode, String payload, String secret, String algorithm) {
String normalizedAlgorithm = normalizeAlgorithm(algorithm);
String signature = hmacSign(tenantCode, payload, secret, normalizedAlgorithm);
long timestamp = System.currentTimeMillis() / 1000;
return new WebhookSignature(signature, normalizedAlgorithm, timestamp);
}
private String normalizeAlgorithm(String algorithm) {
if (algorithm == null || algorithm.isBlank()) {
return DEFAULT_ALGORITHM;
}
return switch (algorithm.toLowerCase().replace("_", "-")) {
case "hmac-sha256", "sha256" -> "hmac-sha256";
case "hmac-sha512", "sha512" -> "hmac-sha512";
default -> DEFAULT_ALGORITHM;
};
}
private String hmacSign(String tenantCode, String payload, String secret, String algorithm) {
String hmacAlgorithm = algorithm.equals("hmac-sha512") ? HMAC_SHA512 : HMAC_SHA256;
try {
Mac mac = Mac.getInstance(hmacAlgorithm);
SecretKeySpec keySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), hmacAlgorithm);
mac.init(keySpec);
byte[] signatureBytes = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(signatureBytes);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
log.error("Failed to sign webhook payload for tenant {}", tenantCode, e);
throw new IllegalStateException("Webhook signature failed: " + algorithm, e);
}
}
}

View File

@@ -0,0 +1,119 @@
package com.sino.mci.mq.config;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.DirectExchange;
import org.springframework.amqp.core.Queue;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
import java.util.HashMap;
import java.util.Map;
/**
* 消息发送 RabbitMQ 配置。
* <p>
* 重试机制主队列设置死信交换机DLX和死信路由键发送失败需要延迟重试时
* 将消息发送到 DLX 并设置 per-message TTL到期后由 DLX 重新路由回主队列消费。
*/
@Configuration
public class MqConfig {
public static final String SEND_EXCHANGE = "mci.send.exchange";
public static final String SEND_QUEUE = "mci.send.queue";
public static final String SEND_ROUTING_KEY = "mci.send";
public static final String SEND_DLX_EXCHANGE = "mci.send.dlx.exchange";
public static final String SEND_DLX_ROUTING_KEY = "mci.send.retry";
public static final String CALLBACK_EXCHANGE = "mci.callback.exchange";
public static final String CALLBACK_QUEUE = "mci.callback.queue";
public static final String CALLBACK_ROUTING_KEY = "mci.callback";
public static final String INBOUND_EXCHANGE = "mci.inbound.exchange";
public static final String INBOUND_QUEUE = "mci.inbound.queue";
public static final String INBOUND_ROUTING_KEY = "mci.inbound";
public static final String INBOUND_FLOW_EXCHANGE = "mci.inbound.flow.exchange";
public static final String INBOUND_FLOW_QUEUE = "mci.inbound.flow.queue";
public static final String INBOUND_FLOW_ROUTING_KEY = "mci.inbound.flow";
@Bean
public Queue sendQueue() {
Map<String, Object> args = new HashMap<>();
args.put("x-dead-letter-exchange", SEND_DLX_EXCHANGE);
args.put("x-dead-letter-routing-key", SEND_DLX_ROUTING_KEY);
return new Queue(SEND_QUEUE, true, false, false, args);
}
@Bean
public DirectExchange sendExchange() {
return new DirectExchange(SEND_EXCHANGE, true, false);
}
@Bean
public Binding sendBinding(Queue sendQueue, DirectExchange sendExchange) {
return BindingBuilder.bind(sendQueue).to(sendExchange).with(SEND_ROUTING_KEY);
}
@Bean
public DirectExchange sendDlxExchange() {
return new DirectExchange(SEND_DLX_EXCHANGE, true, false);
}
@Bean
public Binding sendDlxBinding(Queue sendQueue, DirectExchange sendDlxExchange) {
return BindingBuilder.bind(sendQueue).to(sendDlxExchange).with(SEND_DLX_ROUTING_KEY);
}
@Bean
public Queue callbackQueue() {
return new Queue(CALLBACK_QUEUE, true);
}
@Bean
public DirectExchange callbackExchange() {
return new DirectExchange(CALLBACK_EXCHANGE, true, false);
}
@Bean
public Binding callbackBinding(Queue callbackQueue, DirectExchange callbackExchange) {
return BindingBuilder.bind(callbackQueue).to(callbackExchange).with(CALLBACK_ROUTING_KEY);
}
@Bean
public Queue inboundQueue() {
return new Queue(INBOUND_QUEUE, true);
}
@Bean
public DirectExchange inboundExchange() {
return new DirectExchange(INBOUND_EXCHANGE, true, false);
}
@Bean
public Binding inboundBinding(Queue inboundQueue, DirectExchange inboundExchange) {
return BindingBuilder.bind(inboundQueue).to(inboundExchange).with(INBOUND_ROUTING_KEY);
}
@Bean
public Queue inboundFlowQueue() {
return new Queue(INBOUND_FLOW_QUEUE, true);
}
@Bean
public DirectExchange inboundFlowExchange() {
return new DirectExchange(INBOUND_FLOW_EXCHANGE, true, false);
}
@Bean
public Binding inboundFlowBinding(Queue inboundFlowQueue, DirectExchange inboundFlowExchange) {
return BindingBuilder.bind(inboundFlowQueue).to(inboundFlowExchange).with(INBOUND_FLOW_ROUTING_KEY);
}
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}

View File

@@ -0,0 +1,44 @@
package com.sino.mci.policy.engine;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Policy {
private PolicyType type;
// rate_limit
@JsonProperty("scope")
private String scope;
@JsonProperty("limit")
private Integer limit;
@JsonProperty("window")
private String window;
// time_window
@JsonProperty("allow")
private List<String> allow;
// blacklist
@JsonProperty("person_ids")
private List<Long> personIds;
@JsonProperty("conversation_ids")
private List<Long> conversationIds;
// retry
@JsonProperty("max_retry")
private Integer maxRetry;
@JsonProperty("interval")
private List<Integer> intervals;
}

View File

@@ -0,0 +1,18 @@
package com.sino.mci.policy.engine;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PolicyCheckResult {
private boolean allowed;
private String reasonCode;
private String reasonMessage;
private RetryConfig retryConfig;
}

View File

@@ -0,0 +1,240 @@
package com.sino.mci.policy.engine;
import com.sino.mci.send.entity.SendRecord;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Optional;
@Slf4j
@Service
@RequiredArgsConstructor
public class PolicyEngine {
private static final DateTimeFormatter HOURLY_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHH");
private static final DateTimeFormatter DAILY_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
private static final DateTimeFormatter WEEKLY_FORMATTER = DateTimeFormatter.ofPattern("YYYYww");
private final StringRedisTemplate redisTemplate;
public PolicyCheckResult check(SendRecord record, PolicyGroupConfig config) {
return check(record, config, LocalDateTime.now());
}
public PolicyCheckResult check(SendRecord record, PolicyGroupConfig config, LocalDateTime now) {
if (config == null || CollectionUtils.isEmpty(config.getPolicies())) {
return allowed(null);
}
PolicyCheckResult blacklistResult = checkBlacklist(record, config);
if (blacklistResult != null) {
return blacklistResult;
}
PolicyCheckResult timeWindowResult = checkTimeWindow(config, now);
if (timeWindowResult != null) {
return timeWindowResult;
}
PolicyCheckResult rateLimitResult = checkRateLimit(record, config, now);
if (rateLimitResult != null) {
return rateLimitResult;
}
return allowed(config.getRetryConfig().orElse(null));
}
public PolicyPreviewResult previewCheck(SendRecord record, PolicyGroupConfig config, LocalDateTime now) {
PolicyPreviewResult result = PolicyPreviewResult.builder()
.timeWindowOk(true)
.blacklistHit(0)
.build();
if (config == null || CollectionUtils.isEmpty(config.getPolicies())) {
return result;
}
if (isBlacklisted(record, config)) {
result.setBlacklistHit(1);
}
PolicyCheckResult timeWindowResult = checkTimeWindow(config, now);
result.setTimeWindowOk(timeWindowResult == null);
result.setRateLimitWarning(checkRateLimitDryRun(record, config, now));
return result;
}
private PolicyCheckResult allowed(RetryConfig retryConfig) {
return PolicyCheckResult.builder()
.allowed(true)
.retryConfig(retryConfig)
.build();
}
private PolicyCheckResult reject(String reasonCode, String reasonMessage) {
return PolicyCheckResult.builder()
.allowed(false)
.reasonCode(reasonCode)
.reasonMessage(reasonMessage)
.build();
}
private PolicyCheckResult checkBlacklist(SendRecord record, PolicyGroupConfig config) {
for (Policy policy : config.getPolicies()) {
if (policy.getType() != PolicyType.BLACKLIST) {
continue;
}
if (!CollectionUtils.isEmpty(policy.getPersonIds())
&& record.getPersonId() != null
&& policy.getPersonIds().contains(record.getPersonId())) {
return reject("blacklist_person", "发送目标人员在黑名单中");
}
if (!CollectionUtils.isEmpty(policy.getConversationIds())
&& record.getConversationId() != null
&& policy.getConversationIds().contains(record.getConversationId())) {
return reject("blacklist_conversation", "发送目标会话在黑名单中");
}
}
return null;
}
private boolean isBlacklisted(SendRecord record, PolicyGroupConfig config) {
return checkBlacklist(record, config) != null;
}
private PolicyCheckResult checkTimeWindow(PolicyGroupConfig config, LocalDateTime now) {
LocalTime current = now.toLocalTime();
for (Policy policy : config.getPolicies()) {
if (policy.getType() != PolicyType.TIME_WINDOW || CollectionUtils.isEmpty(policy.getAllow())) {
continue;
}
boolean insideAny = policy.getAllow().stream()
.map(this::parseTimeRange)
.filter(Optional::isPresent)
.map(Optional::get)
.anyMatch(range -> range.contains(current));
if (!insideAny) {
return reject("time_window_not_allowed", "当前时间不在允许发送窗口内");
}
}
return null;
}
private Optional<TimeRange> parseTimeRange(String range) {
if (range == null || !range.contains("-")) {
return Optional.empty();
}
String[] parts = range.split("-", 2);
try {
LocalTime start = LocalTime.parse(parts[0].trim());
LocalTime end = LocalTime.parse(parts[1].trim());
return Optional.of(new TimeRange(start, end));
} catch (Exception e) {
log.warn("时间窗口格式错误: {}", range);
return Optional.empty();
}
}
private PolicyCheckResult checkRateLimit(SendRecord record, PolicyGroupConfig config, LocalDateTime now) {
for (Policy policy : config.getPolicies()) {
if (policy.getType() != PolicyType.RATE_LIMIT) {
continue;
}
Long scopeId = resolveScopeId(record, policy.getScope());
if (scopeId == null || policy.getLimit() == null || policy.getLimit() <= 0) {
continue;
}
String key = buildRateLimitKey(record.getTenantCode(), policy.getScope(), scopeId, policy.getWindow(), now);
long ttlSeconds = resolveTtlSeconds(policy.getWindow());
Long count = redisTemplate.opsForValue().increment(key);
if (count != null && count == 1L) {
redisTemplate.expire(key, Duration.ofSeconds(ttlSeconds));
}
if (count != null && count > policy.getLimit()) {
return reject("rate_limit_exceeded",
String.format("发送频率超过限制: scope=%s, limit=%d, window=%s", policy.getScope(), policy.getLimit(), policy.getWindow()));
}
}
return null;
}
private String checkRateLimitDryRun(SendRecord record, PolicyGroupConfig config, LocalDateTime now) {
for (Policy policy : config.getPolicies()) {
if (policy.getType() != PolicyType.RATE_LIMIT) {
continue;
}
Long scopeId = resolveScopeId(record, policy.getScope());
if (scopeId == null || policy.getLimit() == null || policy.getLimit() <= 0) {
continue;
}
String key = buildRateLimitKey(record.getTenantCode(), policy.getScope(), scopeId, policy.getWindow(), now);
String value = redisTemplate.opsForValue().get(key);
long count = 0L;
if (value != null) {
try {
count = Long.parseLong(value);
} catch (NumberFormatException e) {
log.warn("Redis 限流计数格式错误: key={}, value={}", key, value);
}
}
if (count >= policy.getLimit()) {
return String.format("scope=%s, id=%d 已超过频率限制: limit=%d, window=%s, current=%d",
policy.getScope(), scopeId, policy.getLimit(), policy.getWindow(), count);
}
if (count >= policy.getLimit() * 0.8) {
return String.format("scope=%s, id=%d 接近频率上限: limit=%d, window=%s, current=%d",
policy.getScope(), scopeId, policy.getLimit(), policy.getWindow(), count);
}
}
return null;
}
private Long resolveScopeId(SendRecord record, String scope) {
return switch (scope) {
case "account" -> record.getAccountId();
case "person" -> record.getPersonId();
case "conversation" -> record.getConversationId();
default -> null;
};
}
private String buildRateLimitKey(String tenantCode, String scope, Long scopeId, String window, LocalDateTime now) {
String period = switch (window) {
case "1h" -> now.format(HOURLY_FORMATTER);
case "7d" -> now.format(WEEKLY_FORMATTER);
default -> now.format(DAILY_FORMATTER);
};
return String.format("mci:rate:%s:%s:%s:%s", tenantCode, scope, scopeId, period);
}
private long resolveTtlSeconds(String window) {
return switch (window) {
case "1h" -> 3600L;
case "7d" -> 7 * 86400L;
default -> 86400L;
};
}
private record TimeRange(LocalTime start, LocalTime end) {
boolean contains(LocalTime time) {
if (!start.isAfter(end)) {
return !time.isBefore(start) && !time.isAfter(end);
}
return !time.isBefore(start) || !time.isAfter(end);
}
}
}

View File

@@ -0,0 +1,47 @@
package com.sino.mci.policy.engine;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@Slf4j
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PolicyGroupConfig {
private List<Policy> policies = new ArrayList<>();
public static PolicyGroupConfig parseJson(String json) {
if (!StringUtils.hasText(json)) {
return new PolicyGroupConfig();
}
ObjectMapper mapper = new ObjectMapper();
try {
List<Policy> list = mapper.readValue(json, new TypeReference<List<Policy>>() {
});
return new PolicyGroupConfig(list != null ? list : new ArrayList<>());
} catch (JsonProcessingException e) {
log.warn("解析策略组配置失败: {}", json, e);
return new PolicyGroupConfig();
}
}
public Optional<RetryConfig> getRetryConfig() {
return policies.stream()
.filter(p -> p.getType() == PolicyType.RETRY)
.findFirst()
.map(p -> new RetryConfig(
p.getMaxRetry() == null ? 0 : p.getMaxRetry(),
p.getIntervals()));
}
}

View File

@@ -0,0 +1,17 @@
package com.sino.mci.policy.engine;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PolicyPreviewResult {
private String rateLimitWarning;
private boolean timeWindowOk;
private int blacklistHit;
}

View File

@@ -0,0 +1,18 @@
package com.sino.mci.policy.engine;
import com.fasterxml.jackson.annotation.JsonProperty;
public enum PolicyType {
@JsonProperty("rate_limit")
RATE_LIMIT,
@JsonProperty("time_window")
TIME_WINDOW,
@JsonProperty("blacklist")
BLACKLIST,
@JsonProperty("retry")
RETRY
}

View File

@@ -0,0 +1,16 @@
package com.sino.mci.policy.engine;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RetryConfig {
private int maxRetry;
private List<Integer> intervals;
}

View File

@@ -1,16 +1,33 @@
package com.sino.mci.send.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.sino.mci.admin.security.AuthContext;
import com.sino.mci.send.dto.SendPreviewRequest;
import com.sino.mci.send.dto.SendPreviewResponse;
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.SendStatisticsResponse;
import com.sino.mci.send.dto.SendTaskStatusResponse;
import com.sino.mci.send.dto.SendTestRequest;
import com.sino.mci.send.service.SendPreviewService;
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 org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
@RestController
@@ -19,6 +36,9 @@ import java.util.List;
public class SendController {
private final SendService sendService;
private final SendPreviewService sendPreviewService;
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
@PostMapping
public ApiResponse<SendResponse> send(
@@ -34,15 +54,69 @@ public class SendController {
return ApiResponse.ok(sendService.testSend(tenantCode, request));
}
@GetMapping("/{task_id}/records")
@PostMapping("/preview")
public ApiResponse<SendPreviewResponse> preview(
@RequestHeader("X-Tenant-Code") String tenantCode,
@Valid @RequestBody SendPreviewRequest request) {
return ApiResponse.ok(sendPreviewService.preview(tenantCode, request));
}
@RequestMapping(value = "/{task_id}/records", method = {RequestMethod.GET, RequestMethod.POST})
public ApiResponse<List<SendRecordResponse>> listRecords(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("task_id") String taskId) {
return ApiResponse.ok(sendService.listRecords(tenantCode, taskId));
}
@GetMapping("/{task_id}/status")
public ApiResponse<SendTaskStatusResponse> getTaskStatus(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("task_id") String taskId) {
return ApiResponse.ok(sendService.getTaskStatus(tenantCode, taskId));
}
@PostMapping("/{task_id}/cancel")
public ApiResponse<Integer> cancelTask(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("task_id") String taskId) {
return ApiResponse.ok(sendService.cancelTask(tenantCode, taskId));
}
@GetMapping("/records")
public ApiResponse<IPage<SendRecordResponse>> queryRecords(
@RequestHeader("X-Tenant-Code") String tenantCode,
@RequestParam(required = false) String taskId,
@RequestParam(required = false) String start,
@RequestParam(required = false) String end,
@RequestParam(required = false) String channelType,
@RequestParam(required = false) Integer status,
@RequestParam(defaultValue = "1") long page,
@RequestParam(defaultValue = "20") long size) {
LocalDateTime startTime = parseDateTime(start);
LocalDateTime endTime = parseDateTime(end);
return ApiResponse.ok(sendService.queryRecords(tenantCode, taskId, startTime, endTime,
channelType, status, page, size));
}
@GetMapping("/statistics")
public ApiResponse<SendStatisticsResponse> statistics(
@RequestHeader("X-Tenant-Code") String tenantCode,
@RequestParam(required = false) String start,
@RequestParam(required = false) String end) {
LocalDateTime startTime = parseDateTime(start);
LocalDateTime endTime = parseDateTime(end);
return ApiResponse.ok(sendService.getStatistics(tenantCode, startTime, endTime));
}
@GetMapping("/list")
public ApiResponse<List<SendRecordResponse>> listRecentRecords() {
return ApiResponse.ok(sendService.listRecentRecords(AuthContext.currentTenantCode()));
}
private LocalDateTime parseDateTime(String value) {
if (!StringUtils.hasText(value)) {
return null;
}
return LocalDateTime.parse(value, DATE_TIME_FORMATTER);
}
}

View File

@@ -0,0 +1,14 @@
package com.sino.mci.send.dto;
import lombok.Data;
@Data
public class ChannelStatistics {
private String channelType;
private long total;
private long pendingCount;
private long sendingCount;
private long successCount;
private long failedCount;
}

View File

@@ -0,0 +1,19 @@
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 SendPreviewRequest {
@NotBlank
private String targetType;
@NotNull
private Map<String, Object> targetValue;
private Map<String, Object> channelStrategy;
}

View File

@@ -0,0 +1,25 @@
package com.sino.mci.send.dto;
import lombok.Data;
import java.util.Map;
@Data
public class SendPreviewResponse {
private int totalPersonCount;
private int totalConversationCount;
private Map<String, Integer> channelBreakdown;
private int unreachableCount;
private Map<String, Integer> accountUsage;
private PolicyCheckInfo policyCheck;
private int estimatedTimeSeconds;
@Data
public static class PolicyCheckInfo {
private String rateLimitWarning;
private boolean timeWindowOk;
private int blacklistHit;
}
}

View File

@@ -18,6 +18,9 @@ public class SendRecordResponse {
private String contentType;
private Integer sendStatus;
private String channelResult;
private String contentBody;
private String callbackUrl;
private LocalDateTime sendTime;
private LocalDateTime finishTime;
private LocalDateTime createTime;
}

View File

@@ -0,0 +1,17 @@
package com.sino.mci.send.dto;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class SendStatisticsResponse {
private long total;
private long pendingCount;
private long sendingCount;
private long successCount;
private long failedCount;
private List<ChannelStatistics> channelStats = new ArrayList<>();
}

View File

@@ -0,0 +1,14 @@
package com.sino.mci.send.dto;
import lombok.Data;
@Data
public class SendTaskStatusResponse {
private String taskId;
private long total;
private long pendingCount;
private long sendingCount;
private long successCount;
private long failedCount;
}

View File

@@ -26,6 +26,9 @@ public class SendRecord extends BaseEntity {
private String contentBody;
private String callbackUrl;
private Integer sendStatus;
private Integer retryCount;
private Integer maxRetry;
private LocalDateTime nextRetryTime;
private String channelResult;
private LocalDateTime sendTime;
private LocalDateTime finishTime;

View File

@@ -0,0 +1,116 @@
package com.sino.mci.send.mq;
import com.sino.mci.mq.config.MqConfig;
import com.sino.mci.policy.engine.RetryConfig;
import com.sino.mci.send.entity.SendRecord;
import com.sino.mci.send.repository.SendRecordMapper;
import com.sino.mci.send.service.SendCallbackService;
import com.sino.mci.send.service.SendService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
@Slf4j
@Component
@RequiredArgsConstructor
public class SendMessageConsumer {
private static final int SEND_STATUS_SUCCESS = 2;
private static final int SEND_STATUS_FAILED = 3;
private final SendRecordMapper sendRecordMapper;
private final SendService sendService;
private final SendMessageProducer sendMessageProducer;
private final SendCallbackService sendCallbackService;
@RabbitListener(queues = MqConfig.SEND_QUEUE)
public void handle(SendRecordMessage message) {
log.info("收到异步发送消息: recordId={}, attempt={}", message.getRecordId(), message.getAttempt());
SendRecord record = sendRecordMapper.selectById(message.getRecordId());
if (record == null) {
log.warn("发送记录不存在: recordId={}", message.getRecordId());
return;
}
if (record.getSendStatus() != null && record.getSendStatus() == SEND_STATUS_SUCCESS) {
sendCallbackService.sendCallback(record);
return;
}
if (record.getSendStatus() != null && record.getSendStatus() == SEND_STATUS_FAILED) {
sendCallbackService.sendCallback(record);
return;
}
int attempt = message.getAttempt();
int maxRetry = record.getMaxRetry() != null ? record.getMaxRetry() : 0;
int currentRetry = record.getRetryCount() != null ? record.getRetryCount() : 0;
if (attempt < currentRetry) {
log.warn("丢弃过期重试消息: recordId={}, attempt={}, currentRetry={}",
record.getId(), attempt, currentRetry);
return;
}
if (attempt > maxRetry) {
record.setSendStatus(SEND_STATUS_FAILED);
record.setFinishTime(LocalDateTime.now());
sendRecordMapper.updateById(record);
sendCallbackService.sendCallback(record);
return;
}
try {
sendService.dispatchRecord(record);
record = sendRecordMapper.selectById(record.getId());
if (record == null) {
return;
}
if (record.getSendStatus() != null && record.getSendStatus() == SEND_STATUS_SUCCESS) {
sendCallbackService.sendCallback(record);
} else if (record.getSendStatus() != null && record.getSendStatus() == SEND_STATUS_FAILED) {
handleRetry(record, message);
}
} catch (Exception e) {
log.error("异步发送处理异常: recordId={}", message.getRecordId(), e);
}
}
private void handleRetry(SendRecord record, SendRecordMessage message) {
int currentRetry = record.getRetryCount() != null ? record.getRetryCount() : 0;
int maxRetry = record.getMaxRetry() != null ? record.getMaxRetry() : 0;
if (currentRetry >= maxRetry) {
sendCallbackService.sendCallback(record);
return;
}
int attempt = message.getAttempt() + 1;
int delaySeconds = resolveDelaySeconds(message.getRetryPlan(), attempt);
SendRecordMessage retryMessage = SendRecordMessage.builder()
.recordId(message.getRecordId())
.taskId(message.getTaskId())
.tenantCode(message.getTenantCode())
.attempt(attempt)
.retryPlan(message.getRetryPlan())
.build();
record.setRetryCount(attempt);
record.setNextRetryTime(LocalDateTime.now().plusSeconds(delaySeconds));
sendRecordMapper.updateById(record);
sendMessageProducer.publishRetryMessage(retryMessage, delaySeconds);
}
private int resolveDelaySeconds(RetryConfig retryPlan, int attempt) {
if (retryPlan != null && retryPlan.getIntervals() != null && !retryPlan.getIntervals().isEmpty()) {
int index = Math.min(attempt - 1, retryPlan.getIntervals().size() - 1);
return retryPlan.getIntervals().get(index);
}
return 5;
}
}

View File

@@ -0,0 +1,29 @@
package com.sino.mci.send.mq;
import com.sino.mci.mq.config.MqConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
public class SendMessageProducer {
private final RabbitTemplate rabbitTemplate;
public void publishSendMessage(SendRecordMessage message) {
log.info("发布异步发送消息: recordId={}, taskId={}", message.getRecordId(), message.getTaskId());
rabbitTemplate.convertAndSend(MqConfig.SEND_EXCHANGE, MqConfig.SEND_ROUTING_KEY, message);
}
public void publishRetryMessage(SendRecordMessage message, int delaySeconds) {
long delayMillis = delaySeconds * 1000L;
log.info("发布重试消息: recordId={}, attempt={}, delay={}ms", message.getRecordId(), message.getAttempt(), delayMillis);
rabbitTemplate.convertAndSend(MqConfig.SEND_DLX_EXCHANGE, MqConfig.SEND_DLX_ROUTING_KEY, message, msg -> {
msg.getMessageProperties().setExpiration(String.valueOf(delayMillis));
return msg;
});
}
}

View File

@@ -0,0 +1,22 @@
package com.sino.mci.send.mq;
import com.sino.mci.policy.engine.RetryConfig;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SendRecordMessage implements Serializable {
private Long recordId;
private String taskId;
private String tenantCode;
private int attempt;
private RetryConfig retryPlan;
}

View File

@@ -0,0 +1,70 @@
package com.sino.mci.send.service;
import com.sino.mci.send.entity.SendRecord;
import com.sino.mci.shared.util.JsonUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestTemplate;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Service
@RequiredArgsConstructor
public class SendCallbackService {
private static final int CALLBACK_STATUS_DELIVERED = 1;
private static final int CALLBACK_STATUS_FAILED = 3;
private final RestTemplate restTemplate;
public void sendCallback(SendRecord record) {
if (record == null || !StringUtils.hasText(record.getCallbackUrl())) {
return;
}
String event;
int status;
if (record.getSendStatus() != null && record.getSendStatus() == 2) {
event = "delivered";
status = CALLBACK_STATUS_DELIVERED;
} else {
event = "failed";
status = CALLBACK_STATUS_FAILED;
}
Map<String, Object> payload = new HashMap<>();
payload.put("task_id", record.getTaskId());
payload.put("record_id", record.getId());
payload.put("conversation_id", record.getConversationId());
payload.put("person_id", record.getPersonId());
payload.put("channel_type", record.getChannelType());
payload.put("event", event);
payload.put("status", status);
payload.put("channel_result", parseChannelResult(record.getChannelResult()));
payload.put("event_time", record.getFinishTime() != null ? record.getFinishTime() : LocalDateTime.now());
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);
try {
restTemplate.postForEntity(record.getCallbackUrl(), entity, String.class);
log.info("发送回调成功: recordId={}, event={}", record.getId(), event);
} catch (Exception e) {
log.warn("发送回调失败: recordId={}, url={}", record.getId(), record.getCallbackUrl(), e);
}
}
private Object parseChannelResult(String channelResult) {
if (!StringUtils.hasText(channelResult)) {
return new HashMap<>();
}
Object result = JsonUtils.fromJson(channelResult, Object.class);
return result != null ? result : new HashMap<>();
}
}

View File

@@ -0,0 +1,200 @@
package com.sino.mci.send.service;
import com.sino.mci.channel.entity.ChannelAccount;
import com.sino.mci.channel.repository.ChannelAccountMapper;
import com.sino.mci.policy.engine.PolicyEngine;
import com.sino.mci.policy.engine.PolicyGroupConfig;
import com.sino.mci.policy.engine.PolicyPreviewResult;
import com.sino.mci.policy.entity.PolicyGroup;
import com.sino.mci.policy.repository.PolicyGroupMapper;
import com.sino.mci.send.dto.SendPreviewRequest;
import com.sino.mci.send.dto.SendPreviewResponse;
import com.sino.mci.send.entity.SendRecord;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class SendPreviewService {
private static final int STATUS_ENABLED = 1;
private final SendService sendService;
private final ChannelAccountMapper accountMapper;
private final PolicyEngine policyEngine;
private final PolicyGroupMapper policyGroupMapper;
public SendPreviewResponse preview(String tenantCode, SendPreviewRequest request) {
List<SendTarget> targets = sendService.resolveByType(tenantCode, request.getTargetType(),
request.getTargetValue(), request.getChannelStrategy());
int totalPersonCount = 0;
int totalConversationCount = 0;
int unreachableCount = 0;
Map<String, Integer> channelBreakdown = new HashMap<>();
Map<Long, Integer> accountIdUsage = new HashMap<>();
for (SendTarget target : targets) {
if (target.getPersonId() != null) {
totalPersonCount++;
}
if (target.getConversationId() != null) {
totalConversationCount++;
}
if (target.getAccountId() == null) {
unreachableCount++;
} else {
accountIdUsage.merge(target.getAccountId(), 1, Integer::sum);
}
String channelType = target.getChannelType() != null ? target.getChannelType().name().toLowerCase() : "unknown";
channelBreakdown.merge(channelType, 1, Integer::sum);
}
Map<String, Integer> accountUsage = buildAccountUsage(accountIdUsage);
SendPreviewResponse.PolicyCheckInfo policyCheck = dryRunPolicyCheck(tenantCode, request, targets, accountIdUsage.keySet());
int estimatedTimeSeconds = (int) (1 + targets.size() * 0.5 + totalConversationCount * 2);
SendPreviewResponse response = new SendPreviewResponse();
response.setTotalPersonCount(totalPersonCount);
response.setTotalConversationCount(totalConversationCount);
response.setChannelBreakdown(channelBreakdown);
response.setUnreachableCount(unreachableCount);
response.setAccountUsage(accountUsage);
response.setPolicyCheck(policyCheck);
response.setEstimatedTimeSeconds(estimatedTimeSeconds);
return response;
}
private Map<String, Integer> buildAccountUsage(Map<Long, Integer> accountIdUsage) {
Map<String, Integer> accountUsage = new HashMap<>();
if (accountIdUsage.isEmpty()) {
return accountUsage;
}
Set<Long> accountIds = accountIdUsage.keySet();
List<ChannelAccount> accounts = accountMapper.selectBatchIds(accountIds);
Map<Long, String> accountNames = new HashMap<>();
for (ChannelAccount account : accounts) {
accountNames.put(account.getId(),
StringUtils.hasText(account.getAccountName()) ? account.getAccountName() : String.valueOf(account.getId()));
}
for (Map.Entry<Long, Integer> entry : accountIdUsage.entrySet()) {
String name = accountNames.getOrDefault(entry.getKey(), String.valueOf(entry.getKey()));
accountUsage.put(name, entry.getValue());
}
return accountUsage;
}
private SendPreviewResponse.PolicyCheckInfo dryRunPolicyCheck(String tenantCode, SendPreviewRequest request,
List<SendTarget> targets, Set<Long> accountIds) {
SendPreviewResponse.PolicyCheckInfo info = new SendPreviewResponse.PolicyCheckInfo();
info.setTimeWindowOk(true);
info.setBlacklistHit(0);
info.setRateLimitWarning(null);
PolicyGroup policyGroup = loadPolicyGroup(tenantCode, extractChannelType(request.getChannelStrategy()));
if (policyGroup == null) {
return info;
}
PolicyGroupConfig config = PolicyGroupConfig.parseJson(policyGroup.getPolicies());
if (CollectionUtils.isEmpty(config.getPolicies())) {
return info;
}
LocalDateTime now = LocalDateTime.now();
int blacklistHit = 0;
boolean timeWindowOk = true;
StringBuilder warning = new StringBuilder();
Set<Long> checkedAccounts = new HashSet<>(accountIds);
if (checkedAccounts.isEmpty()) {
checkedAccounts.add(null);
}
for (Long accountId : checkedAccounts) {
SendTarget representative = targets.stream()
.filter(t -> Objects.equals(t.getAccountId(), accountId))
.findFirst()
.orElse(null);
SendRecord dummy = new SendRecord();
dummy.setTenantCode(tenantCode);
dummy.setAccountId(accountId);
dummy.setPersonId(representative != null ? representative.getPersonId() : null);
dummy.setConversationId(representative != null ? representative.getConversationId() : null);
dummy.setChannelType(extractChannelType(request.getChannelStrategy()));
PolicyPreviewResult result = policyEngine.previewCheck(dummy, config, now);
if (!result.isTimeWindowOk()) {
timeWindowOk = false;
}
blacklistHit += result.getBlacklistHit();
if (result.getRateLimitWarning() != null) {
if (warning.length() > 0) {
warning.append("; ");
}
warning.append(result.getRateLimitWarning());
}
}
info.setTimeWindowOk(timeWindowOk);
info.setBlacklistHit(blacklistHit);
info.setRateLimitWarning(warning.length() > 0 ? warning.toString() : null);
return info;
}
private PolicyGroup loadPolicyGroup(String tenantCode, String channelType) {
com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<PolicyGroup> wrapper =
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>();
wrapper.eq(PolicyGroup::getTenantCode, tenantCode)
.eq(PolicyGroup::getStatus, STATUS_ENABLED);
if (StringUtils.hasText(channelType)) {
wrapper.eq(PolicyGroup::getChannelType, channelType);
} else {
wrapper.and(w -> w.isNull(PolicyGroup::getChannelType).or().eq(PolicyGroup::getChannelType, ""));
}
wrapper.orderByDesc(PolicyGroup::getId).last("LIMIT 1");
PolicyGroup group = policyGroupMapper.selectOne(wrapper);
if (group != null) {
return group;
}
group = policyGroupMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<PolicyGroup>()
.eq(PolicyGroup::getTenantCode, tenantCode)
.eq(PolicyGroup::getStatus, STATUS_ENABLED)
.and(w -> w.isNull(PolicyGroup::getChannelType).or().eq(PolicyGroup::getChannelType, ""))
.orderByDesc(PolicyGroup::getId)
.last("LIMIT 1"));
if (group != null) {
return group;
}
return policyGroupMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<PolicyGroup>()
.eq(PolicyGroup::getGroupCode, "default")
.eq(PolicyGroup::getStatus, STATUS_ENABLED)
.orderByDesc(PolicyGroup::getId)
.last("LIMIT 1"));
}
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";
}
}

View File

@@ -1,17 +1,18 @@
package com.sino.mci.send.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
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.service.AccountSelector;
import com.sino.mci.channel.entity.ConversationTagBinding;
import com.sino.mci.crm.entity.ChannelIdentity;
import com.sino.mci.crm.entity.Person;
@@ -23,11 +24,22 @@ import com.sino.mci.crm.repository.PersonMapper;
import com.sino.mci.crm.repository.PersonTagBindingMapper;
import com.sino.mci.crm.repository.TagMapper;
import com.sino.mci.exception.BusinessException;
import com.sino.mci.policy.engine.PolicyCheckResult;
import com.sino.mci.policy.engine.PolicyEngine;
import com.sino.mci.policy.engine.PolicyGroupConfig;
import com.sino.mci.policy.entity.PolicyGroup;
import com.sino.mci.policy.repository.PolicyGroupMapper;
import com.sino.mci.policy.engine.RetryConfig;
import com.sino.mci.send.dto.ChannelStatistics;
import com.sino.mci.send.dto.SendRecordResponse;
import com.sino.mci.send.dto.SendRequest;
import com.sino.mci.send.dto.SendStatisticsResponse;
import com.sino.mci.send.dto.SendTaskStatusResponse;
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.mq.SendMessageProducer;
import com.sino.mci.send.mq.SendRecordMessage;
import com.sino.mci.send.repository.SendRecordMapper;
import com.sino.mci.shared.util.JsonUtils;
import lombok.RequiredArgsConstructor;
@@ -41,8 +53,10 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
@@ -57,8 +71,6 @@ import java.util.stream.Collectors;
@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;
@@ -68,33 +80,36 @@ public class SendService {
private final SendRecordMapper sendRecordMapper;
private final ConversationMapper conversationMapper;
private final ChannelAccountMapper accountMapper;
private final ChannelAccountConversationMapper accountConversationMapper;
private final ChannelIdentityMapper channelIdentityMapper;
private final PersonMapper personMapper;
private final TagMapper tagMapper;
private final PersonTagBindingMapper personTagBindingMapper;
private final ConversationTagBindingMapper conversationTagBindingMapper;
private final ChannelAdapterRegistry adapterRegistry;
private final PolicyGroupMapper policyGroupMapper;
private final PolicyEngine policyEngine;
private final AccountSelector accountSelector;
private final SendMessageProducer sendMessageProducer;
@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);
RetryConfig retryConfig = loadRetryConfig(tenantCode, request);
List<SendRecord> records = createRecords(taskId, tenantCode, request, targets, retryConfig);
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++;
}
SendRecordMessage message = SendRecordMessage.builder()
.recordId(record.getId())
.taskId(record.getTaskId())
.tenantCode(record.getTenantCode())
.attempt(0)
.retryPlan(retryConfig)
.build();
sendMessageProducer.publishSendMessage(message);
}
String status = failedCount == 0 ? "success" : (successCount == 0 ? "failed" : "partial");
return new SendResponse(taskId, records.size(), status);
return new SendResponse(taskId, records.size(), "accepted");
}
@Transactional
@@ -122,21 +137,148 @@ public class SendService {
return records.stream().limit(50).map(this::toResponse).collect(Collectors.toList());
}
public com.baomidou.mybatisplus.core.metadata.IPage<SendRecordResponse> queryRecords(String tenantCode, String taskId, LocalDateTime start,
LocalDateTime end, String channelType, Integer status,
long page, long size) {
LambdaQueryWrapper<SendRecord> wrapper = new LambdaQueryWrapper<SendRecord>()
.eq(SendRecord::getTenantCode, tenantCode)
.orderByDesc(SendRecord::getId);
if (StringUtils.hasText(taskId)) {
wrapper.eq(SendRecord::getTaskId, taskId);
}
if (start != null) {
wrapper.ge(SendRecord::getSendTime, start);
}
if (end != null) {
wrapper.le(SendRecord::getSendTime, end);
}
if (StringUtils.hasText(channelType)) {
wrapper.eq(SendRecord::getChannelType, channelType);
}
if (status != null) {
wrapper.eq(SendRecord::getSendStatus, status);
}
Page<SendRecord> pageParam = new Page<>(page, size);
sendRecordMapper.selectPage(pageParam, wrapper);
return pageParam.convert(this::toResponse);
}
public SendTaskStatusResponse getTaskStatus(String tenantCode, String taskId) {
List<SendRecord> records = sendRecordMapper.selectList(
new LambdaQueryWrapper<SendRecord>()
.eq(SendRecord::getTenantCode, tenantCode)
.eq(SendRecord::getTaskId, taskId));
SendTaskStatusResponse response = new SendTaskStatusResponse();
response.setTaskId(taskId);
response.setTotal(records.size());
for (SendRecord record : records) {
switch (record.getSendStatus()) {
case SEND_STATUS_PENDING:
response.setPendingCount(response.getPendingCount() + 1);
break;
case SEND_STATUS_SENDING:
response.setSendingCount(response.getSendingCount() + 1);
break;
case SEND_STATUS_SUCCESS:
response.setSuccessCount(response.getSuccessCount() + 1);
break;
case SEND_STATUS_FAILED:
response.setFailedCount(response.getFailedCount() + 1);
break;
default:
break;
}
}
return response;
}
@Transactional
public int cancelTask(String tenantCode, String taskId) {
return sendRecordMapper.update(null,
new LambdaUpdateWrapper<SendRecord>()
.eq(SendRecord::getTenantCode, tenantCode)
.eq(SendRecord::getTaskId, taskId)
.eq(SendRecord::getSendStatus, SEND_STATUS_PENDING)
.set(SendRecord::getSendStatus, SEND_STATUS_FAILED)
.set(SendRecord::getFinishTime, LocalDateTime.now())
.set(SendRecord::getChannelResult,
JsonUtils.toJson(Collections.singletonMap("canceled", true))));
}
public SendStatisticsResponse getStatistics(String tenantCode, LocalDateTime start, LocalDateTime end) {
LambdaQueryWrapper<SendRecord> wrapper = new LambdaQueryWrapper<SendRecord>()
.eq(SendRecord::getTenantCode, tenantCode);
if (start != null) {
wrapper.ge(SendRecord::getSendTime, start);
}
if (end != null) {
wrapper.le(SendRecord::getSendTime, end);
}
List<SendRecord> records = sendRecordMapper.selectList(wrapper);
SendStatisticsResponse response = new SendStatisticsResponse();
Map<String, ChannelStatistics> channelMap = new HashMap<>();
for (SendRecord record : records) {
response.setTotal(response.getTotal() + 1);
switch (record.getSendStatus()) {
case SEND_STATUS_PENDING:
response.setPendingCount(response.getPendingCount() + 1);
break;
case SEND_STATUS_SENDING:
response.setSendingCount(response.getSendingCount() + 1);
break;
case SEND_STATUS_SUCCESS:
response.setSuccessCount(response.getSuccessCount() + 1);
break;
case SEND_STATUS_FAILED:
response.setFailedCount(response.getFailedCount() + 1);
break;
default:
break;
}
ChannelStatistics channelStats = channelMap.computeIfAbsent(record.getChannelType(),
k -> {
ChannelStatistics cs = new ChannelStatistics();
cs.setChannelType(k);
return cs;
});
channelStats.setTotal(channelStats.getTotal() + 1);
switch (record.getSendStatus()) {
case SEND_STATUS_PENDING:
channelStats.setPendingCount(channelStats.getPendingCount() + 1);
break;
case SEND_STATUS_SENDING:
channelStats.setSendingCount(channelStats.getSendingCount() + 1);
break;
case SEND_STATUS_SUCCESS:
channelStats.setSuccessCount(channelStats.getSuccessCount() + 1);
break;
case SEND_STATUS_FAILED:
channelStats.setFailedCount(channelStats.getFailedCount() + 1);
break;
default:
break;
}
}
response.setChannelStats(new ArrayList<>(channelMap.values()));
return response;
}
private String generateTaskId() {
return "SND" + UUID.randomUUID().toString().replace("-", "").toUpperCase();
}
private List<SendTarget> resolveTargets(String tenantCode, SendRequest request) {
List<SendTarget> resolveTargets(String tenantCode, SendRequest request) {
return resolveByType(tenantCode, request.getTargetType(), request.getTargetValue(), request.getChannelStrategy());
}
private List<SendTarget> resolveTestTargets(String tenantCode, SendTestRequest request) {
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) {
List<SendTarget> resolveByType(String tenantCode, String targetType, Map<String, Object> targetValue,
Map<String, Object> channelStrategy) {
String channelTypeStr = extractChannelType(channelStrategy);
ChannelType channelType = parseChannelType(channelTypeStr);
@@ -183,7 +325,7 @@ public class SendService {
if (conversation == null || !tenantCode.equals(conversation.getTenantCode())) {
throw new BusinessException("会话不存在: " + conversationId);
}
Long accountId = selectSendAccount(conversationId, channelType, channelStrategy);
Long accountId = selectSendAccount(tenantCode, conversationId, channelType, channelStrategy);
if (accountId == null) {
accountId = conversation.getOwnerAccountId();
}
@@ -532,22 +674,8 @@ public class SendService {
return value != null ? String.valueOf(value) : null;
}
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 selectSendAccount(String tenantCode, Long conversationId, ChannelType channelType, Map<String, Object> channelStrategy) {
return accountSelector.selectSendAccount(tenantCode, conversationId, channelType, extractStrategy(channelStrategy));
}
private Long selectDefaultAccount(String tenantCode, ChannelType channelType) {
@@ -589,9 +717,20 @@ public class SendService {
}
}
private RetryConfig loadRetryConfig(String tenantCode, SendRequest request) {
String channelTypeStr = extractChannelType(request.getChannelStrategy());
PolicyGroup policyGroup = loadPolicyGroup(tenantCode, channelTypeStr);
if (policyGroup == null) {
return null;
}
PolicyGroupConfig config = PolicyGroupConfig.parseJson(policyGroup.getPolicies());
return config.getRetryConfig().orElse(null);
}
private List<SendRecord> createRecords(String taskId, String tenantCode, SendRequest request,
List<SendTarget> targets) {
List<SendTarget> targets, RetryConfig retryConfig) {
List<SendRecord> records = new ArrayList<>();
int maxRetry = retryConfig != null ? retryConfig.getMaxRetry() : 0;
for (SendTarget target : targets) {
SendRecord record = new SendRecord();
record.setTaskId(taskId);
@@ -607,6 +746,8 @@ public class SendService {
record.setContentBody(JsonUtils.toJson(request.getContent()));
record.setCallbackUrl(request.getCallbackUrl());
record.setSendStatus(SEND_STATUS_PENDING);
record.setRetryCount(0);
record.setMaxRetry(maxRetry);
record.setSendTime(LocalDateTime.now());
sendRecordMapper.insert(record);
records.add(record);
@@ -641,7 +782,9 @@ public class SendService {
return records;
}
private void dispatchRecord(SendRecord record) {
@Transactional
public void dispatchRecord(SendRecord record) {
ChannelType channelType = parseChannelType(record.getChannelType());
ChannelAdapter adapter = adapterRegistry.getAdapter(channelType);
if (adapter == null) {
@@ -652,26 +795,200 @@ public class SendService {
return;
}
record.setSendStatus(SEND_STATUS_SENDING);
PolicyCheckResult policyResult = checkPolicy(record);
if (policyResult != null && !policyResult.isAllowed()) {
record.setSendStatus(SEND_STATUS_FAILED);
Map<String, Object> resultMap = new HashMap<>();
resultMap.put("policy_rejected", true);
resultMap.put("reason", policyResult.getReasonCode());
resultMap.put("message", policyResult.getReasonMessage());
record.setChannelResult(JsonUtils.toJson(resultMap));
record.setFinishTime(LocalDateTime.now());
sendRecordMapper.updateById(record);
return;
}
List<Long> candidateAccountIds = selectSendAccountCandidates(record, channelType);
if (candidateAccountIds.isEmpty()) {
record.setSendStatus(SEND_STATUS_FAILED);
record.setChannelResult(JsonUtils.toJson(Collections.singletonMap("error", "no_healthy_account")));
record.setFinishTime(LocalDateTime.now());
sendRecordMapper.updateById(record);
return;
}
List<Map<String, Object>> attempts = new ArrayList<>();
boolean success = false;
SendMessageResult lastResult = null;
for (Long accountId : candidateAccountIds) {
record.setAccountId(accountId);
record.setSendStatus(SEND_STATUS_SENDING);
sendRecordMapper.updateById(record);
ChannelAccount account = accountMapper.selectById(accountId);
SendMessageResult result = doSendAttempt(record, adapter, account);
lastResult = result;
attempts.add(buildAttemptResult(accountId, result));
if (result != null && result.isSuccess()) {
success = true;
break;
}
markAccountUnhealthy(account);
}
record.setFinishTime(LocalDateTime.now());
if (success) {
record.setSendStatus(SEND_STATUS_SUCCESS);
record.setChannelResult(JsonUtils.toJson(buildSuccessResult(lastResult, record.getAccountId(), attempts)));
} else {
record.setSendStatus(SEND_STATUS_FAILED);
record.setChannelResult(JsonUtils.toJson(buildAllAttemptsFailureResult(attempts, policyResult)));
}
sendRecordMapper.updateById(record);
}
private List<Long> selectSendAccountCandidates(SendRecord record, ChannelType channelType) {
if (record.getConversationId() == null) {
return record.getAccountId() != null ? Collections.singletonList(record.getAccountId()) : Collections.emptyList();
}
String preferredStrategy = extractStrategy(parseChannelStrategy(record.getChannelStrategy()));
return accountSelector.selectSendAccounts(record.getTenantCode(), record.getConversationId(), channelType, preferredStrategy);
}
private Map<String, Object> parseChannelStrategy(String strategyJson) {
if (!StringUtils.hasText(strategyJson)) {
return null;
}
Map<?, ?> map = JsonUtils.fromJson(strategyJson, Map.class);
if (map == null) {
return null;
}
Map<String, Object> result = new HashMap<>();
for (Map.Entry<?, ?> entry : map.entrySet()) {
result.put(String.valueOf(entry.getKey()), entry.getValue());
}
return result;
}
private SendMessageResult doSendAttempt(SendRecord record, ChannelAdapter adapter, ChannelAccount account) {
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));
Map<String, Object> extra = new HashMap<>();
if (account != null && StringUtils.hasText(account.getAccountId())) {
extra.put("account_id", account.getAccountId());
}
messageRequest.setExtra(extra);
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));
return adapter.sendMessage(messageRequest);
} 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())));
log.warn("发送消息失败: task_id={}, record_id={}, account_id={}", record.getTaskId(), record.getId(),
account != null ? account.getId() : null, e);
SendMessageResult result = new SendMessageResult();
result.setSuccess(false);
result.setMessage(e.getMessage());
return result;
}
}
private void markAccountUnhealthy(ChannelAccount account) {
if (account == null || account.getAccountType() == null || account.getAccountType() != 3) {
return;
}
account.setRiskLevel(2);
account.setOnlineStatus(0);
accountMapper.updateById(account);
}
private Map<String, Object> buildAttemptResult(Long accountId, SendMessageResult result) {
Map<String, Object> map = new HashMap<>();
map.put("account_id", accountId);
if (result != null) {
map.put("success", result.isSuccess());
map.put("message", result.getMessage());
} else {
map.put("success", false);
map.put("message", "unknown error");
}
return map;
}
private Map<String, Object> buildSuccessResult(SendMessageResult result, Long accountId, List<Map<String, Object>> attempts) {
Map<String, Object> map = new HashMap<>();
map.put("success", true);
map.put("account_id", accountId);
if (result != null) {
map.put("channel_message_id", result.getChannelMessageId());
map.put("message", result.getMessage());
}
map.put("attempts", attempts);
return map;
}
private Map<String, Object> buildAllAttemptsFailureResult(List<Map<String, Object>> attempts, PolicyCheckResult policyResult) {
Map<String, Object> map = new HashMap<>();
map.put("error", "all_accounts_failed");
map.put("attempts", attempts);
appendRetryPlan(map, policyResult);
return map;
}
private PolicyCheckResult checkPolicy(SendRecord record) {
PolicyGroup policyGroup = loadPolicyGroup(record.getTenantCode(), record.getChannelType());
if (policyGroup == null) {
return null;
}
PolicyGroupConfig config = PolicyGroupConfig.parseJson(policyGroup.getPolicies());
return policyEngine.check(record, config);
}
private PolicyGroup loadPolicyGroup(String tenantCode, String channelType) {
LambdaQueryWrapper<PolicyGroup> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(PolicyGroup::getTenantCode, tenantCode)
.eq(PolicyGroup::getStatus, STATUS_ENABLED);
if (StringUtils.hasText(channelType)) {
wrapper.eq(PolicyGroup::getChannelType, channelType);
} else {
wrapper.and(w -> w.isNull(PolicyGroup::getChannelType).or().eq(PolicyGroup::getChannelType, ""));
}
wrapper.orderByDesc(PolicyGroup::getId).last("LIMIT 1");
PolicyGroup group = policyGroupMapper.selectOne(wrapper);
if (group != null) {
return group;
}
group = policyGroupMapper.selectOne(
new LambdaQueryWrapper<PolicyGroup>()
.eq(PolicyGroup::getTenantCode, tenantCode)
.eq(PolicyGroup::getStatus, STATUS_ENABLED)
.and(w -> w.isNull(PolicyGroup::getChannelType).or().eq(PolicyGroup::getChannelType, ""))
.orderByDesc(PolicyGroup::getId)
.last("LIMIT 1"));
if (group != null) {
return group;
}
return policyGroupMapper.selectOne(
new LambdaQueryWrapper<PolicyGroup>()
.eq(PolicyGroup::getGroupCode, "default")
.eq(PolicyGroup::getStatus, STATUS_ENABLED)
.orderByDesc(PolicyGroup::getId)
.last("LIMIT 1"));
}
private void appendRetryPlan(Map<String, Object> map, PolicyCheckResult policyResult) {
if (policyResult != null && policyResult.getRetryConfig() != null) {
map.put("retry_plan", policyResult.getRetryConfig());
log.info("发送失败,按策略计划重试: {}", policyResult.getRetryConfig().getIntervals());
}
record.setFinishTime(LocalDateTime.now());
sendRecordMapper.updateById(record);
}
private SendRecordResponse toResponse(SendRecord record) {
@@ -687,7 +1004,10 @@ public class SendService {
response.setContentType(record.getContentType());
response.setSendStatus(record.getSendStatus());
response.setChannelResult(record.getChannelResult());
response.setContentBody(record.getContentBody());
response.setCallbackUrl(record.getCallbackUrl());
response.setSendTime(record.getSendTime());
response.setFinishTime(record.getFinishTime());
response.setCreateTime(record.getCreateTime());
return response;
}
@@ -755,61 +1075,4 @@ public class SendService {
}
}
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,15 @@
package com.sino.mci.send.service;
import com.sino.mci.channel.common.model.ChannelType;
import lombok.Data;
@Data
class SendTarget {
private Long conversationId;
private Long personId;
private Long accountId;
private ChannelType channelType;
private String channelConversationId;
private String receiverId;
}

View File

@@ -14,6 +14,17 @@ spring:
port: 5672
username: guest
password: guest
ai:
openai:
api-key: ${OPENAI_API_KEY:}
base-url: ${OPENAI_BASE_URL:https://api.openai.com}
chat:
options:
model: gpt-4o-mini
autoconfigure:
exclude:
- org.springframework.ai.autoconfigure.openai.OpenAiAutoConfiguration
- org.springframework.ai.autoconfigure.chat.client.ChatClientAutoConfiguration
flyway:
enabled: true
locations: classpath:db/migration

View File

@@ -0,0 +1,5 @@
-- Phase 3.4: 出站发送记录增加重试相关字段
ALTER TABLE mci_send_record
ADD COLUMN retry_count INT NOT NULL DEFAULT 0 COMMENT '已重试次数' AFTER send_status,
ADD COLUMN max_retry INT NOT NULL DEFAULT 0 COMMENT '最大重试次数' AFTER retry_count,
ADD COLUMN next_retry_time DATETIME NULL COMMENT '下次重试时间' AFTER max_retry;

View File

@@ -0,0 +1,2 @@
ALTER TABLE mci_tenant
ADD COLUMN ext_info JSON NULL COMMENT '扩展信息 JSON';

View File

@@ -0,0 +1,148 @@
package com.sino.mci.admin.controller;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
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.io.IOException;
import java.net.InetSocketAddress;
import java.util.UUID;
import static com.sino.mci.test.AuthTestHelper.createTenantAndLogin;
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 TenantWebhookControllerTest {
@Autowired
private MockMvc mockMvc;
private HttpServer localServer;
@AfterEach
void stopLocalServer() {
if (localServer != null) {
localServer.stop(0);
localServer = null;
}
}
@Test
void shouldReturnCurrentTenantWebhookConfig() throws Exception {
String tenantCode = "wh-" + UUID.randomUUID().toString().substring(0, 8);
String authToken = createTenantAndLogin(mockMvc, tenantCode, "Webhook 测试");
mockMvc.perform(get("/msg-platform/api/v1/account/tenant/webhook")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.inbound_webhook_url").isEmpty())
.andExpect(jsonPath("$.data.auth_config").isMap());
}
@Test
void shouldUpdateWebhookConfig() throws Exception {
String tenantCode = "wh-" + UUID.randomUUID().toString().substring(0, 8);
String authToken = createTenantAndLogin(mockMvc, tenantCode, "Webhook 测试");
String payload = "{\"inbound_webhook_url\":\"https://example.com/webhook\",\"auth_config\":{\"sign_algorithm\":\"hmac-sha256\",\"webhook_secret\":\"secret-123\"}}";
mockMvc.perform(post("/msg-platform/api/v1/account/tenant/webhook")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.inbound_webhook_url").value("https://example.com/webhook"))
.andExpect(jsonPath("$.data.auth_config.sign_algorithm").value("hmac-sha256"))
.andExpect(jsonPath("$.data.auth_config.webhook_secret").value("secret-123"));
mockMvc.perform(get("/msg-platform/api/v1/account/tenant/webhook")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.inbound_webhook_url").value("https://example.com/webhook"))
.andExpect(jsonPath("$.data.auth_config.webhook_secret").value("secret-123"));
}
@Test
void shouldReturnSuccessWhenTestWebhookReceives2xx() throws Exception {
String tenantCode = "wh-" + UUID.randomUUID().toString().substring(0, 8);
String authToken = createTenantAndLogin(mockMvc, tenantCode, "Webhook 测试");
updateWebhookConfig(authToken, tenantCode);
int port = startLocalHttpServer(200, "ok");
mockMvc.perform(post("/msg-platform/api/v1/account/tenant/webhook/test")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON)
.content(String.format("{\"url\":\"http://localhost:%d/webhook\"}", port)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.success").value(true))
.andExpect(jsonPath("$.data.message").value("测试推送成功HTTP 200 OK"));
}
@Test
void shouldReturnFailureWhenTestWebhookThrows() throws Exception {
String tenantCode = "wh-" + UUID.randomUUID().toString().substring(0, 8);
String authToken = createTenantAndLogin(mockMvc, tenantCode, "Webhook 测试");
updateWebhookConfig(authToken, tenantCode);
mockMvc.perform(post("/msg-platform/api/v1/account/tenant/webhook/test")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"url\":\"http://localhost:1/webhook\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.success").value(false))
.andExpect(jsonPath("$.data.message").value(org.hamcrest.Matchers.containsString("Connection refused")));
}
private void updateWebhookConfig(String authToken, String tenantCode) throws Exception {
String payload = "{\"inbound_webhook_url\":\"https://example.com/webhook\",\"auth_config\":{\"sign_algorithm\":\"hmac-sha256\",\"webhook_secret\":\"secret-123\"}}";
mockMvc.perform(post("/msg-platform/api/v1/account/tenant/webhook")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
private int startLocalHttpServer(int statusCode, String responseBody) throws IOException {
localServer = HttpServer.create(new InetSocketAddress(0), 0);
localServer.createContext("/webhook", exchange -> {
byte[] bytes = responseBody.getBytes();
exchange.sendResponseHeaders(statusCode, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
});
localServer.start();
return localServer.getAddress().getPort();
}
}

View File

@@ -0,0 +1,123 @@
package com.sino.mci.channel.service;
import com.sino.mci.channel.common.model.ChannelType;
import com.sino.mci.channel.entity.ChannelAccount;
import com.sino.mci.channel.entity.ChannelAccountConversation;
import com.sino.mci.channel.repository.ChannelAccountConversationMapper;
import com.sino.mci.channel.repository.ChannelAccountMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AccountSelectorTest {
@Mock
private ChannelAccountConversationMapper accountConversationMapper;
@Mock
private ChannelAccountMapper accountMapper;
private AccountSelector accountSelector;
@BeforeEach
void setUp() {
accountSelector = new AccountSelector(accountConversationMapper, accountMapper);
}
@Test
void primarySelectedWhenHealthy() {
ChannelAccountConversation primary = createBinding(1L, 1, 1);
when(accountConversationMapper.selectList(any())).thenReturn(Collections.singletonList(primary));
when(accountMapper.selectById(1L)).thenReturn(createHealthyAccount(1L));
Long accountId = accountSelector.selectSendAccount("tenant", 100L, ChannelType.WECHAT_PERSONAL, "primary");
assertEquals(1L, accountId);
}
@Test
void backupSelectedWhenPrimaryUnhealthy() {
ChannelAccountConversation primary = createBinding(1L, 1, 1);
ChannelAccountConversation backup = createBinding(2L, 2, 1);
when(accountConversationMapper.selectList(any())).thenReturn(Arrays.asList(primary, backup));
when(accountMapper.selectById(1L)).thenReturn(createOfflineAccount(1L));
when(accountMapper.selectById(2L)).thenReturn(createHealthyAccount(2L));
Long accountId = accountSelector.selectSendAccount("tenant", 100L, ChannelType.WECHAT_PERSONAL, "primary");
assertEquals(2L, accountId);
}
@Test
void returnsNullWhenNoHealthyAccount() {
ChannelAccountConversation primary = createBinding(1L, 1, 1);
ChannelAccountConversation backup = createBinding(2L, 2, 1);
when(accountConversationMapper.selectList(any())).thenReturn(Arrays.asList(primary, backup));
when(accountMapper.selectById(1L)).thenReturn(createOfflineAccount(1L));
when(accountMapper.selectById(2L)).thenReturn(createHighRiskAccount(2L));
Long accountId = accountSelector.selectSendAccount("tenant", 100L, ChannelType.WECHAT_PERSONAL, "primary");
assertNull(accountId);
}
@Test
void preferredBackupStrategySelectsBackupFirst() {
ChannelAccountConversation primary = createBinding(1L, 1, 1);
ChannelAccountConversation backup = createBinding(2L, 2, 1);
when(accountConversationMapper.selectList(any())).thenReturn(Arrays.asList(primary, backup));
when(accountMapper.selectById(1L)).thenReturn(createHealthyAccount(1L));
when(accountMapper.selectById(2L)).thenReturn(createHealthyAccount(2L));
Long accountId = accountSelector.selectSendAccount("tenant", 100L, ChannelType.WECHAT_PERSONAL, "backup");
assertEquals(2L, accountId);
}
private ChannelAccountConversation createBinding(Long accountId, int bindingType, int sortOrder) {
ChannelAccountConversation binding = new ChannelAccountConversation();
binding.setAccountId(accountId);
binding.setBindingType(bindingType);
binding.setSortOrder(sortOrder);
binding.setStatus(1);
return binding;
}
private ChannelAccount createHealthyAccount(Long id) {
ChannelAccount account = new ChannelAccount();
account.setId(id);
account.setTenantCode("tenant");
account.setAccountType(3);
account.setChannelType("wechat_personal");
account.setAccountId("wxid-" + id);
account.setStatus(1);
account.setOnlineStatus(1);
account.setRiskLevel(1);
account.setLastHeartbeat(LocalDateTime.now());
return account;
}
private ChannelAccount createOfflineAccount(Long id) {
ChannelAccount account = createHealthyAccount(id);
account.setOnlineStatus(0);
return account;
}
private ChannelAccount createHighRiskAccount(Long id) {
ChannelAccount account = createHealthyAccount(id);
account.setRiskLevel(3);
return account;
}
}

View File

@@ -0,0 +1,83 @@
package com.sino.mci.flow.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 com.sino.mci.test.AuthTestHelper.createTenantAndLogin;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
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 IvrFlowControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldManageIvrFlowLifecycle() throws Exception {
String tenantCode = "ivr-" + UUID.randomUUID().toString().substring(0, 8);
String authToken = createTenantAndLogin(mockMvc, tenantCode, "IVR 测试租户");
String flowPayload = "{\"flowCode\":\"ivr_default\",\"flowName\":\"默认 IVR 流程\",\"flowType\":\"ivr\",\"definitionJson\":{\"nodes\":[{\"id\":\"start\",\"type\":\"start\",\"config\":{\"triggerType\":\"inbound_call\",\"phoneNumber\":\"10086\"}},{\"id\":\"menu\",\"type\":\"menu\",\"config\":{\"prompt\":\"按 1 转人工\",\"timeout\":10,\"options\":[{\"key\":\"1\",\"label\":\"人工\"}]}}],\"edges\":[]}}";
mockMvc.perform(post("/msg-platform/api/v1/flow")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON)
.content(flowPayload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.flowType").value(2));
mockMvc.perform(get("/msg-platform/api/v1/flow/list")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.length()").value(1))
.andExpect(jsonPath("$.data[0].flowCode").value("ivr_default"));
mockMvc.perform(get("/msg-platform/api/v1/flow/ivr_default")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.flowCode").value("ivr_default"));
mockMvc.perform(post("/msg-platform/api/v1/flow/ivr_default/publish")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
mockMvc.perform(get("/msg-platform/api/v1/flow/ivr_default")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value(1));
mockMvc.perform(delete("/msg-platform/api/v1/flow/ivr_default")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
}

View File

@@ -0,0 +1,165 @@
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.intent.IntentResult;
import com.sino.mci.inbound.repository.FlowExecutionTraceMapper;
import com.sino.mci.inbound.repository.MessageEventMapper;
import com.sino.mci.send.dto.SendRequest;
import com.sino.mci.send.dto.SendResponse;
import com.sino.mci.send.service.SendService;
import com.sino.mci.shared.util.JsonUtils;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class FlowEngineTest {
@Mock
private FlowDefinitionMapper flowDefinitionMapper;
@Mock
private MessageEventMapper messageEventMapper;
@Mock
private IntentRecognitionService intentRecognitionService;
@Mock
private WebhookPushService webhookPushService;
@Mock
private FlowExecutionTraceMapper flowExecutionTraceMapper;
@Mock
private SendService sendService;
@InjectMocks
private FlowEngine flowEngine;
@Test
void shouldExecuteWebhookPathWhenConditionIsTrue() {
MessageEvent event = createEvent();
FlowDefinition flow = createFlowDefinition();
IntentResult intentResult =
new IntentResult("create_order", 0.95, Map.of("customer_name", "张三"), List.of("phone"), true);
when(messageEventMapper.selectById(event.getId())).thenReturn(event);
when(flowDefinitionMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(flow));
when(intentRecognitionService.recognize(event)).thenReturn(intentResult);
flowEngine.executeInboundFlow(event.getId());
verify(webhookPushService, times(1))
.pushToBusiness(eq("rescue"), eq(event), eq(intentResult), eq("https://order.sino.com/webhook"));
verify(sendService, never()).send(any(), any(SendRequest.class));
assertEventMarkedProcessed(event);
}
@Test
void shouldExecuteReplyPathWhenConditionIsFalse() {
MessageEvent event = createEvent();
FlowDefinition flow = createFlowDefinition();
IntentResult intentResult =
new IntentResult("other", 0.6, Map.of(), List.of(), false);
when(messageEventMapper.selectById(event.getId())).thenReturn(event);
when(flowDefinitionMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(flow));
when(intentRecognitionService.recognize(event)).thenReturn(intentResult);
when(sendService.send(any(), any(SendRequest.class)))
.thenReturn(new SendResponse("SND001", 1, "accepted"));
flowEngine.executeInboundFlow(event.getId());
verify(webhookPushService, never()).pushToBusiness(any(), any(), any(), any());
ArgumentCaptor<SendRequest> requestCaptor = ArgumentCaptor.forClass(SendRequest.class);
verify(sendService, times(1)).send(eq("rescue"), requestCaptor.capture());
SendRequest request = requestCaptor.getValue();
assertThat(request.getTargetType()).isEqualTo("conversation");
assertThat(request.getTargetValue()).containsEntry("conversation_id", 100L);
assertThat(request.getContentType()).isEqualTo("text");
assertThat(request.getContent()).containsEntry("type", "text")
.containsEntry("body", "已收到,正在处理");
assertThat(request.getChannelStrategy()).containsEntry("channel_type", "wecom");
assertEventMarkedProcessed(event);
}
@Test
void shouldMarkEventProcessedWhenNoPublishedFlow() {
MessageEvent event = createEvent();
when(messageEventMapper.selectById(event.getId())).thenReturn(event);
when(flowDefinitionMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.emptyList());
flowEngine.executeInboundFlow(event.getId());
verify(webhookPushService, never()).pushToBusiness(any(), any(), any(), any());
verify(sendService, never()).send(any(), any(SendRequest.class));
assertEventMarkedProcessed(event);
}
private MessageEvent createEvent() {
MessageEvent event = new MessageEvent();
event.setId(1L);
event.setEventId("evt_20260707_001");
event.setTenantCode("rescue");
event.setChannelType("wecom");
event.setConversationId(100L);
event.setPersonId(200L);
event.setContent("{\"type\":\"text\",\"body\":\"我要下单\"}");
event.setBusinessContext("{}");
event.setIsProcessed(0);
return event;
}
private FlowDefinition createFlowDefinition() {
FlowDefinition flow = new FlowDefinition();
flow.setId(10L);
flow.setTenantCode("rescue");
flow.setFlowCode("inbound_default");
flow.setFlowName("默认入站流程");
flow.setFlowType(1);
flow.setStatus(1);
Map<String, Object> definition = Map.of(
"nodes", List.of(
Map.of("id", "n1", "type", "receive", "config", Map.of("channel_type", "wecom")),
Map.of("id", "n2", "type", "intent", "config", Map.of("mode", "llm")),
Map.of("id", "n3", "type", "condition",
"config", Map.of("expression", "intent.intent == 'create_order'")),
Map.of("id", "n4", "type", "webhook",
"config", Map.of("url", "https://order.sino.com/webhook")),
Map.of("id", "n5", "type", "reply",
"config", Map.of("content", Map.of("type", "text", "body", "已收到,正在处理")))
),
"edges", List.of(
Map.of("source", "n1", "target", "n2"),
Map.of("source", "n2", "target", "n3"),
Map.of("source", "n3", "target", "n4", "label", "true"),
Map.of("source", "n3", "target", "n5", "label", "false")
)
);
flow.setDefinitionJson(JsonUtils.toJson(definition));
return flow;
}
private void assertEventMarkedProcessed(MessageEvent event) {
ArgumentCaptor<MessageEvent> captor = ArgumentCaptor.forClass(MessageEvent.class);
verify(messageEventMapper, times(1)).updateById(captor.capture());
assertThat(captor.getValue().getIsProcessed()).isEqualTo(1);
}
}

View File

@@ -0,0 +1,142 @@
package com.sino.mci.inbound.engine;
import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
import java.util.UUID;
import static com.sino.mci.test.AuthTestHelper.createTenantAndLogin;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
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;
/**
* 入站消息端到端测试。
*
* <p>覆盖完整业务链路:创建租户并配置入站 webhook → 创建并发布入站流程 →
* 接收消息事件 → 上下文解析 → 意图识别 → webhook 推送RestTemplate 被 mock。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(properties = {
"spring.rabbitmq.listener.simple.auto-startup=false",
"spring.main.allow-bean-definition-overriding=true"
})
@Transactional
class InboundEndToEndTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private RestTemplate restTemplate;
@Test
void shouldPushInboundMessageToBusinessWebhook() throws Exception {
String tenantCode = "inbound-e2e-" + UUID.randomUUID().toString().substring(0, 8);
String authToken = createTenantAndLogin(mockMvc, tenantCode, "入站端到端测试");
String webhookUrl = "https://example.com/inbound-webhook";
// 1. 配置租户入站 webhook 与签名密钥
String webhookConfig = String.format(
"{\"inbound_webhook_url\":\"%s\",\"auth_config\":{\"sign_algorithm\":\"hmac-sha256\",\"webhook_secret\":\"e2e-secret\"}}",
webhookUrl);
mockMvc.perform(post("/msg-platform/api/v1/account/tenant/webhook")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON)
.content(webhookConfig))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
// 2. 创建入站流程receive -> intent -> webhook
String flowPayload = """
{
"flowCode": "inbound_default",
"flowName": "E2E Inbound",
"flowType": "inbound",
"definitionJson": {
"nodes": [
{"id": "n1", "type": "receive", "config": {"channel_type": "wecom"}},
{"id": "n2", "type": "intent"},
{"id": "n3", "type": "webhook"}
],
"edges": [
{"source": "n1", "target": "n2"},
{"source": "n2", "target": "n3"}
]
}
}
""";
mockMvc.perform(post("/msg-platform/api/v1/inbound/flow")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON)
.content(flowPayload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
// 3. 发布流程
mockMvc.perform(post("/msg-platform/api/v1/inbound/flow/inbound_default/publish")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
when(restTemplate.exchange(anyString(), eq(HttpMethod.POST), any(HttpEntity.class), eq(String.class)))
.thenReturn(ResponseEntity.ok("ok"));
// 4. 接收入站消息
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);
String receiveResponse = mockMvc.perform(post("/msg-platform/api/v1/inbound/receive")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.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))
.andReturn().getResponse().getContentAsString();
Integer isProcessed = JsonPath.read(receiveResponse, "$.data.isProcessed");
org.junit.jupiter.api.Assertions.assertEquals(1, isProcessed);
// 5. 验证 webhook 被推送到业务系统
verify(restTemplate, atLeastOnce()).exchange(eq(webhookUrl), eq(HttpMethod.POST), any(HttpEntity.class), eq(String.class));
}
@TestConfiguration
static class MockConfig {
@Bean
public RestTemplate restTemplate() {
return mock(RestTemplate.class);
}
}
}

View File

@@ -0,0 +1,63 @@
package com.sino.mci.inbound.intent;
import com.sino.mci.admin.repository.TenantMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class IntentRecognitionServiceTest {
@Mock
private TenantMapper tenantMapper;
@InjectMocks
private IntentRecognitionService intentRecognitionService;
@Test
void shouldRecognizeCreateOrderByRule() {
when(tenantMapper.selectOne(any())).thenReturn(null);
IntentResult result = intentRecognitionService.recognize("test", "我要下单", Map.of());
assertThat(result.getIntent()).isEqualTo("create_order");
assertThat(result.getConfidence()).isEqualTo(1.0);
}
@Test
void shouldExtractCustomerNameSlot() {
when(tenantMapper.selectOne(any())).thenReturn(null);
IntentResult result = intentRecognitionService.recognize(
"test", "我要下单,客户张三,地址北京", Map.of());
assertThat(result.getIntent()).isEqualTo("create_order");
assertThat(result.getSlots())
.containsEntry("customer_name", "张三")
.containsEntry("address", "北京");
assertThat(result.getMissingSlots()).contains("phone");
assertThat(result.isRequiresConfirmation()).isTrue();
}
@Test
void shouldReturnUnknownWhenNoRuleMatchesAndLlmNotConfigured() {
when(tenantMapper.selectOne(any())).thenReturn(null);
IntentResult result = intentRecognitionService.recognize(
"test", "今天天气不错", Map.of());
assertThat(result.getIntent()).isEqualTo("unknown");
assertThat(result.getConfidence()).isEqualTo(0.0);
assertThat(result.getSlots()).isEmpty();
assertThat(result.getMissingSlots()).isEmpty();
assertThat(result.isRequiresConfirmation()).isFalse();
}
}

View File

@@ -0,0 +1,161 @@
package com.sino.mci.inbound.service;
import com.sino.mci.channel.entity.Conversation;
import com.sino.mci.channel.entity.ConversationTagBinding;
import com.sino.mci.channel.repository.ConversationMapper;
import com.sino.mci.channel.repository.ConversationTagBindingMapper;
import com.sino.mci.crm.entity.Person;
import com.sino.mci.crm.entity.PersonTagBinding;
import com.sino.mci.crm.entity.Tag;
import com.sino.mci.crm.repository.PersonMapper;
import com.sino.mci.crm.repository.PersonTagBindingMapper;
import com.sino.mci.crm.repository.TagMapper;
import com.sino.mci.inbound.entity.MessageEvent;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundContextServiceTest {
@Mock
private ConversationMapper conversationMapper;
@Mock
private PersonMapper personMapper;
@Mock
private ConversationTagBindingMapper conversationTagBindingMapper;
@Mock
private PersonTagBindingMapper personTagBindingMapper;
@Mock
private TagMapper tagMapper;
@InjectMocks
private InboundContextService inboundContextService;
@Test
void shouldResolveConversationContext() {
MessageEvent event = new MessageEvent();
event.setConversationId(1L);
event.setChannelType("wecom");
event.setAccountId(100L);
Conversation conversation = new Conversation();
conversation.setId(1L);
conversation.setContractId(10L);
conversation.setSupplierId(20L);
conversation.setInstitutionId(30L);
conversation.setChannelCode("C001");
conversation.setOwnerAccountId(200L);
when(conversationMapper.selectById(1L)).thenReturn(conversation);
ConversationTagBinding binding = new ConversationTagBinding();
binding.setTagId(101L);
when(conversationTagBindingMapper.selectList(any())).thenReturn(List.of(binding));
Tag tag = new Tag();
tag.setId(101L);
tag.setTagPath("销售/华北组");
when(tagMapper.selectById(101L)).thenReturn(tag);
Map<String, Object> context = inboundContextService.resolveContext(event);
assertThat(context)
.containsEntry("contract_id", 10L)
.containsEntry("supplier_id", 20L)
.containsEntry("institution_id", 30L)
.containsEntry("channel_code", "C001")
.containsEntry("owner_account_id", 200L)
.containsEntry("channel_type", "wecom")
.containsEntry("account_id", 100L);
assertThat(context).containsKey("conversation_tags");
@SuppressWarnings("unchecked")
List<String> tags = (List<String>) context.get("conversation_tags");
assertThat(tags).containsExactly("销售/华北组");
}
@Test
void shouldResolvePersonContext() {
MessageEvent event = new MessageEvent();
event.setPersonId(2L);
event.setChannelType("wecom");
event.setAccountId(100L);
Person person = new Person();
person.setId(2L);
person.setPersonType(2);
person.setInstitutionId(40L);
person.setSupplierId(50L);
when(personMapper.selectById(2L)).thenReturn(person);
PersonTagBinding binding = new PersonTagBinding();
binding.setTagId(102L);
when(personTagBindingMapper.selectList(any())).thenReturn(List.of(binding));
Tag tag = new Tag();
tag.setId(102L);
tag.setTagPath("供应商/技师");
when(tagMapper.selectById(102L)).thenReturn(tag);
Map<String, Object> context = inboundContextService.resolveContext(event);
assertThat(context)
.containsEntry("person_type", 2)
.containsEntry("institution_id", 40L)
.containsEntry("supplier_id", 50L)
.containsEntry("channel_type", "wecom")
.containsEntry("account_id", 100L);
assertThat(context).containsKey("person_tags");
@SuppressWarnings("unchecked")
List<String> tags = (List<String>) context.get("person_tags");
assertThat(tags).containsExactly("供应商/技师");
}
@Test
void shouldMergeContextsWithoutOverwriting() {
MessageEvent event = new MessageEvent();
event.setConversationId(1L);
event.setPersonId(2L);
event.setChannelType("wecom");
event.setAccountId(100L);
Conversation conversation = new Conversation();
conversation.setId(1L);
conversation.setContractId(10L);
conversation.setSupplierId(20L);
conversation.setInstitutionId(30L);
conversation.setChannelCode("C001");
conversation.setOwnerAccountId(200L);
Person person = new Person();
person.setId(2L);
person.setPersonType(3);
person.setInstitutionId(40L);
person.setSupplierId(60L);
when(conversationMapper.selectById(1L)).thenReturn(conversation);
when(personMapper.selectById(2L)).thenReturn(person);
when(conversationTagBindingMapper.selectList(any())).thenReturn(List.of());
when(personTagBindingMapper.selectList(any())).thenReturn(List.of());
Map<String, Object> context = inboundContextService.resolveContext(event);
assertThat(context)
.containsEntry("contract_id", 10L)
.containsEntry("channel_code", "C001")
.containsEntry("owner_account_id", 200L)
.containsEntry("person_type", 3)
.containsEntry("institution_id", 30L)
.containsEntry("supplier_id", 20L);
}
}

View File

@@ -0,0 +1,117 @@
package com.sino.mci.inbound.webhook;
import com.sino.mci.inbound.entity.MessageEvent;
import com.sino.mci.inbound.intent.IntentResult;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class WebhookPayloadBuilderTest {
private final WebhookPayloadBuilder builder = new WebhookPayloadBuilder();
@Test
void shouldBuildPayloadWithAllStandardFields() {
MessageEvent event = createEvent();
IntentResult intent = new IntentResult("create_order", 0.95,
Map.of("customer_name", "张三", "address", "北京"),
List.of("phone"), true);
Map<String, Object> payload = builder.buildPayload(event, intent);
assertThat(payload).containsEntry("event_id", "evt_20260707_001")
.containsEntry("tenant_code", "rescue")
.containsEntry("channel_type", "wecom")
.containsEntry("direction", "in")
.containsEntry("event_type", "message")
.containsEntry("conversation_id", 1001L)
.containsEntry("person_id", 2001L)
.containsEntry("account_id", 3001L)
.containsEntry("create_time", "2026-07-03T12:00:00");
assertThat(payload).containsKey("content");
@SuppressWarnings("unchecked")
Map<String, Object> content = (Map<String, Object>) payload.get("content");
assertThat(content).containsEntry("type", "text")
.containsEntry("body", "我要下单");
assertThat(payload).containsKey("intent");
@SuppressWarnings("unchecked")
Map<String, Object> intentMap = (Map<String, Object>) payload.get("intent");
assertThat(intentMap).containsEntry("intent", "create_order")
.containsEntry("confidence", 0.95)
.containsEntry("requires_confirmation", true);
@SuppressWarnings("unchecked")
Map<String, Object> slots = (Map<String, Object>) intentMap.get("slots");
assertThat(slots).containsEntry("customer_name", "张三")
.containsEntry("address", "北京");
@SuppressWarnings("unchecked")
List<String> missingSlots = (List<String>) intentMap.get("missing_slots");
assertThat(missingSlots).containsExactly("phone");
assertThat(payload).containsKey("business_context");
@SuppressWarnings("unchecked")
Map<String, Object> businessContext = (Map<String, Object>) payload.get("business_context");
assertThat(businessContext).containsEntry("contract_id", "C001");
assertThat(payload).containsKey("raw_payload");
}
@Test
void shouldMapOutboundDirection() {
MessageEvent event = createEvent();
event.setDirection(2);
Map<String, Object> payload = builder.buildPayload(event, unknownIntent());
assertThat(payload).containsEntry("direction", "out");
}
@Test
void shouldHandleNullIntent() {
MessageEvent event = createEvent();
Map<String, Object> payload = builder.buildPayload(event, null);
assertThat(payload).containsEntry("intent", null);
}
@Test
void shouldHandleBlankJsonFields() {
MessageEvent event = createEvent();
event.setContent(null);
event.setBusinessContext("");
event.setRawPayload(null);
Map<String, Object> payload = builder.buildPayload(event, unknownIntent());
assertThat(payload).containsEntry("content", null);
assertThat(payload).containsEntry("business_context", null);
assertThat(payload).containsEntry("raw_payload", null);
}
private MessageEvent createEvent() {
MessageEvent event = new MessageEvent();
event.setEventId("evt_20260707_001");
event.setTenantCode("rescue");
event.setChannelType("wecom");
event.setDirection(1);
event.setEventType("message");
event.setConversationId(1001L);
event.setPersonId(2001L);
event.setAccountId(3001L);
event.setContent("{\"type\":\"text\",\"body\":\"我要下单\"}");
event.setRawPayload("{\"seq\":12345}");
event.setBusinessContext("{\"contract_id\":\"C001\"}");
event.setCreateTime(LocalDateTime.of(2026, 7, 3, 12, 0, 0));
return event;
}
private IntentResult unknownIntent() {
return new IntentResult("unknown", 0.0, Map.of(), List.of(), false);
}
}

View File

@@ -0,0 +1,79 @@
package com.sino.mci.inbound.webhook;
import org.junit.jupiter.api.Test;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import static org.assertj.core.api.Assertions.assertThat;
class WebhookSignatureServiceTest {
private final WebhookSignatureService signatureService = new WebhookSignatureService();
@Test
void shouldSignPayloadWithHmacSha256() throws Exception {
String payload = "{\"event_id\":\"evt_001\",\"tenant_code\":\"rescue\"}";
String secret = "tenant-secret";
WebhookSignature signature = signatureService.sign("rescue", payload, secret, "hmac-sha256");
String expected = manualHmac(payload, secret, "HmacSHA256");
assertThat(signature.signature()).isEqualTo(expected);
assertThat(signature.algorithm()).isEqualTo("hmac-sha256");
assertThat(signature.timestamp()).isGreaterThan(0);
}
@Test
void shouldSignPayloadWithHmacSha512() throws Exception {
String payload = "{\"event_id\":\"evt_001\"}";
String secret = "tenant-secret";
WebhookSignature signature = signatureService.sign("rescue", payload, secret, "hmac-sha512");
String expected = manualHmac(payload, secret, "HmacSHA512");
assertThat(signature.signature()).isEqualTo(expected);
assertThat(signature.algorithm()).isEqualTo("hmac-sha512");
}
@Test
void shouldDefaultToHmacSha256WhenAlgorithmIsBlank() {
String payload = "{\"event_id\":\"evt_001\"}";
String secret = "tenant-secret";
WebhookSignature signature = signatureService.sign("rescue", payload, secret, null);
assertThat(signature.algorithm()).isEqualTo("hmac-sha256");
}
@Test
void shouldProduceDifferentSignaturesForDifferentSecrets() {
String payload = "{\"event_id\":\"evt_001\"}";
WebhookSignature first = signatureService.sign("rescue", payload, "secret-a", "hmac-sha256");
WebhookSignature second = signatureService.sign("rescue", payload, "secret-b", "hmac-sha256");
assertThat(first.signature()).isNotEqualTo(second.signature());
}
@Test
void shouldProduceDifferentSignaturesForDifferentAlgorithms() {
String payload = "{\"event_id\":\"evt_001\"}";
String secret = "tenant-secret";
WebhookSignature sha256 = signatureService.sign("rescue", payload, secret, "hmac-sha256");
WebhookSignature sha512 = signatureService.sign("rescue", payload, secret, "hmac-sha512");
assertThat(sha256.signature()).isNotEqualTo(sha512.signature());
}
private String manualHmac(String payload, String secret, String algorithm) throws Exception {
Mac mac = Mac.getInstance(algorithm);
SecretKeySpec keySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), algorithm);
mac.init(keySpec);
byte[] signatureBytes = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(signatureBytes);
}
}

View File

@@ -0,0 +1,266 @@
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.data.redis.core.StringRedisTemplate;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
import static com.sino.mci.test.AuthTestHelper.createTenantAndLogin;
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 SendPreviewControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private StringRedisTemplate redisTemplate;
@Test
void shouldPreviewConversationTarget() throws Exception {
String tenantCode = "preview-" + UUID.randomUUID().toString().substring(0, 8);
String authToken = createTenantAndLogin(mockMvc, tenantCode, "预览测试");
Long accountId = createChannelAccount(authToken, tenantCode, "ZhangSan", "张三");
Long conversationId = createConversation(authToken, tenantCode, "wecom", "room-preview-001");
bindConversationAccount(authToken, tenantCode, conversationId, accountId);
String payload = String.format(
"{\"targetType\":\"conversation\",\"targetValue\":{\"conversation_id\":%d},\"channelStrategy\":{\"channel_type\":\"wecom\",\"strategy\":\"primary\"}}",
conversationId);
mockMvc.perform(post("/msg-platform/api/v1/send/preview")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.totalPersonCount").value(0))
.andExpect(jsonPath("$.data.totalConversationCount").value(1))
.andExpect(jsonPath("$.data.channelBreakdown.wecom").value(1))
.andExpect(jsonPath("$.data.unreachableCount").value(0))
.andExpect(jsonPath("$.data.accountUsage.张三").value(1))
.andExpect(jsonPath("$.data.policyCheck.blacklistHit").value(0))
.andExpect(jsonPath("$.data.policyCheck.timeWindowOk").value(true))
.andExpect(jsonPath("$.data.estimatedTimeSeconds").value(3));
}
@Test
void shouldPreviewRuleTargetWithChannelBreakdownAndAccountUsage() throws Exception {
String tenantCode = "preview-" + UUID.randomUUID().toString().substring(0, 8);
String authToken = createTenantAndLogin(mockMvc, tenantCode, "预览测试");
Long account1 = createChannelAccount(authToken, tenantCode, "Bot01", "机器人一号");
Long account2 = createChannelAccount(authToken, tenantCode, "Bot02", "机器人二号");
Long tagId = createTag(authToken, tenantCode, "preview-rule");
Long personId = createPerson(authToken, tenantCode, 3);
bindPersonTag(authToken, tenantCode, personId, tagId);
createPersonIdentity(authToken, tenantCode, personId, "wecom", "user-preview-001");
Long conversationId = createConversation(authToken, tenantCode, "wecom", "room-preview-rule-001");
bindConversationTag(authToken, tenantCode, conversationId, tagId);
bindConversationAccount(authToken, tenantCode, conversationId, account2);
String payload = String.format(
"{\"targetType\":\"rule\",\"targetValue\":{\"rules\":[{\"field\":\"tag\",\"operator\":\"in\",\"value\":[%d]}],\"logic\":\"and\"},\"channelStrategy\":{\"channel_type\":\"wecom\",\"strategy\":\"primary\"}}",
tagId);
mockMvc.perform(post("/msg-platform/api/v1/send/preview")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.totalPersonCount").value(1))
.andExpect(jsonPath("$.data.totalConversationCount").value(1))
.andExpect(jsonPath("$.data.channelBreakdown.wecom").value(2))
.andExpect(jsonPath("$.data.accountUsage.机器人一号").value(1))
.andExpect(jsonPath("$.data.accountUsage.机器人二号").value(1));
}
@Test
void shouldDetectBlacklistHitWithoutIncrementingRateLimitCounter() throws Exception {
String tenantCode = "preview-" + UUID.randomUUID().toString().substring(0, 8);
String authToken = createTenantAndLogin(mockMvc, tenantCode, "预览测试");
Long accountId = createChannelAccount(authToken, tenantCode, "ZhangSan", "张三");
Long personId = createPerson(authToken, tenantCode, 3);
createPersonIdentity(authToken, tenantCode, personId, "wecom", "user-preview-002");
Long conversationId = createConversation(authToken, tenantCode, "wecom", "room-preview-blacklist-001");
bindConversationAccount(authToken, tenantCode, conversationId, accountId);
String policies = "[{\"type\":\"blacklist\",\"person_ids\":["
+ personId + "]},{\"type\":\"rate_limit\",\"scope\":\"account\",\"limit\":2,\"window\":\"1h\"}]";
createPolicyGroup(authToken, tenantCode, "default", "wecom", policies);
String rateKey = String.format("mci:rate:%s:account:%d:%s", tenantCode, accountId,
java.time.LocalDateTime.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMddHH")));
redisTemplate.opsForValue().set(rateKey, "1");
try {
String payload = String.format(
"{\"targetType\":\"rule\",\"targetValue\":{\"rules\":[{\"field\":\"person_type\",\"operator\":\"eq\",\"value\":3},{\"field\":\"channel_type\",\"operator\":\"eq\",\"value\":\"wecom\"}],\"logic\":\"or\"},\"channelStrategy\":{\"channel_type\":\"wecom\",\"strategy\":\"primary\"}}");
mockMvc.perform(post("/msg-platform/api/v1/send/preview")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.policyCheck.blacklistHit").value(1))
.andExpect(jsonPath("$.data.totalPersonCount").value(1))
.andExpect(jsonPath("$.data.totalConversationCount").value(1));
String value = redisTemplate.opsForValue().get(rateKey);
org.junit.jupiter.api.Assertions.assertEquals("1", value, "预览不应修改 Redis 限流计数器");
} finally {
redisTemplate.delete(rateKey);
}
}
private Long createChannelAccount(String authToken, String tenantCode, String accountId, String accountName) throws Exception {
String accountPayload = String.format(
"{\"accountType\":1,\"channelType\":\"wecom\",\"accountId\":\"%s\",\"accountName\":\"%s\"}",
accountId, accountName);
String accountResponse = mockMvc.perform(post("/msg-platform/api/v1/channel-account")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON)
.content(accountPayload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andReturn().getResponse().getContentAsString();
return ((Number) com.jayway.jsonpath.JsonPath.read(accountResponse, "$.data.id")).longValue();
}
private Long createTag(String authToken, String tenantCode, String tagName) throws Exception {
String tagPayload = String.format("{\"parentId\":0,\"tagName\":\"%s\"}", tagName);
String tagResponse = mockMvc.perform(post("/msg-platform/api/v1/tag")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON)
.content(tagPayload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andReturn().getResponse().getContentAsString();
return ((Number) com.jayway.jsonpath.JsonPath.read(tagResponse, "$.data.id")).longValue();
}
private Long createPerson(String authToken, String tenantCode, int personType) throws Exception {
String personPayload = String.format(
"{\"personCode\":\"P-%d\",\"personName\":\"张三\",\"phone\":\"13800138000\",\"personType\":%d}",
personType, personType);
String personResponse = mockMvc.perform(post("/msg-platform/api/v1/person")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON)
.content(personPayload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andReturn().getResponse().getContentAsString();
return ((Number) com.jayway.jsonpath.JsonPath.read(personResponse, "$.data.id")).longValue();
}
private void createPersonIdentity(String authToken, String tenantCode, Long personId,
String channelType, String channelUserId) throws Exception {
String identityPayload = String.format("{\"channelType\":\"%s\",\"channelUserId\":\"%s\"}", channelType, channelUserId);
mockMvc.perform(post("/msg-platform/api/v1/person/" + personId + "/identities")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON)
.content(identityPayload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
private void bindPersonTag(String authToken, String tenantCode, Long personId, Long tagId) throws Exception {
String bindPayload = String.format("{\"tagIds\":[%d]}", tagId);
mockMvc.perform(post("/msg-platform/api/v1/person/" + personId + "/bind-tags")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON)
.content(bindPayload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
private Long createConversation(String authToken, String tenantCode, String channelType,
String channelConversationId) throws Exception {
String conversationPayload = String.format(
"{\"conversationType\":2,\"channelType\":\"%s\",\"channelConversationId\":\"%s\",\"conversationName\":\"测试群\"}",
channelType, channelConversationId);
String conversationResponse = mockMvc.perform(post("/msg-platform/api/v1/conversation")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON)
.content(conversationPayload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andReturn().getResponse().getContentAsString();
return ((Number) com.jayway.jsonpath.JsonPath.read(conversationResponse, "$.data.id")).longValue();
}
private void bindConversationTag(String authToken, String tenantCode, Long conversationId, Long tagId) throws Exception {
String bindPayload = String.format("{\"tagIds\":[%d]}", tagId);
mockMvc.perform(post("/msg-platform/api/v1/conversation/" + conversationId + "/bind-tags")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON)
.content(bindPayload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
private void bindConversationAccount(String authToken, String tenantCode, Long conversationId,
Long accountId) throws Exception {
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("Authorization", authToken)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON)
.content(bindPayload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
private void createPolicyGroup(String authToken, String tenantCode, String groupCode,
String channelType, String policiesJson) throws Exception {
String payload = String.format(
"{\"groupCode\":\"%s\",\"groupName\":\"%s\",\"channelType\":\"%s\",\"policies\":%s}",
groupCode, groupCode, channelType, policiesJson);
mockMvc.perform(post("/msg-platform/api/v1/policy-group")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
}

View File

@@ -0,0 +1,211 @@
package com.sino.mci.send.mq;
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.policy.engine.RetryConfig;
import com.sino.mci.send.entity.SendRecord;
import com.sino.mci.send.repository.SendRecordMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.TestPropertySource;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
import java.util.Arrays;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringBootTest
@TestPropertySource(properties = {
"spring.rabbitmq.listener.simple.auto-startup=false",
"spring.main.allow-bean-definition-overriding=true"
})
@Transactional
class SendMessageConsumerTest {
@Autowired
private SendMessageConsumer consumer;
@Autowired
private SendRecordMapper sendRecordMapper;
@Autowired
private ChannelAdapterRegistry adapterRegistry;
@Autowired
private SendMessageProducer sendMessageProducer;
@Autowired
private RestTemplate restTemplate;
private ChannelAdapter adapter;
@BeforeEach
void setUp() {
reset(adapterRegistry, sendMessageProducer, restTemplate);
adapter = mock(ChannelAdapter.class);
when(adapter.getChannelType()).thenReturn(ChannelType.WECHAT_PERSONAL);
when(adapterRegistry.getAdapter(ChannelType.WECHAT_PERSONAL)).thenReturn(adapter);
when(restTemplate.postForEntity(any(String.class), any(), eq(String.class)))
.thenReturn(ResponseEntity.ok("ok"));
}
@Test
void successfulSendTriggersCallbackWithExpectedPayloadStructure() {
SendRecord record = createRecord();
SendRecordMessage message = createMessage(record, null);
SendMessageResult successResult = new SendMessageResult();
successResult.setSuccess(true);
successResult.setChannelMessageId("msg-ok-001");
when(adapter.sendMessage(any(SendMessageRequest.class))).thenReturn(successResult);
consumer.handle(message);
SendRecord updated = sendRecordMapper.selectById(record.getId());
assertEquals(2, updated.getSendStatus().intValue());
ArgumentCaptor<HttpEntity> captor = ArgumentCaptor.forClass(HttpEntity.class);
verify(restTemplate).postForEntity(eq("http://example.com/callback"), captor.capture(), eq(String.class));
@SuppressWarnings("unchecked")
Map<String, Object> payload = (Map<String, Object>) captor.getValue().getBody();
assertEquals(record.getTaskId(), payload.get("task_id"));
assertEquals(record.getId(), payload.get("record_id"));
assertEquals(record.getConversationId(), payload.get("conversation_id"));
assertEquals(record.getPersonId(), payload.get("person_id"));
assertEquals("wechat_personal", payload.get("channel_type"));
assertEquals("delivered", payload.get("event"));
assertEquals(1, payload.get("status"));
assertNotNull(payload.get("channel_result"));
assertNotNull(payload.get("event_time"));
verify(sendMessageProducer, never()).publishRetryMessage(any(), anyInt());
}
@Test
void failedSendWithRetryPublishesRetryMessage() {
SendRecord record = createRecord();
record.setMaxRetry(3);
sendRecordMapper.updateById(record);
RetryConfig retryPlan = new RetryConfig(3, Arrays.asList(1, 2, 3));
SendRecordMessage message = createMessage(record, retryPlan);
SendMessageResult failureResult = new SendMessageResult();
failureResult.setSuccess(false);
failureResult.setMessage("network error");
when(adapter.sendMessage(any(SendMessageRequest.class))).thenReturn(failureResult);
consumer.handle(message);
SendRecord updated = sendRecordMapper.selectById(record.getId());
assertEquals(3, updated.getSendStatus().intValue());
assertEquals(1, updated.getRetryCount().intValue());
assertNotNull(updated.getNextRetryTime());
ArgumentCaptor<SendRecordMessage> retryCaptor = ArgumentCaptor.forClass(SendRecordMessage.class);
verify(sendMessageProducer).publishRetryMessage(retryCaptor.capture(), eq(1));
SendRecordMessage retryMessage = retryCaptor.getValue();
assertEquals(record.getId(), retryMessage.getRecordId());
assertEquals(1, retryMessage.getAttempt());
assertEquals(retryPlan, retryMessage.getRetryPlan());
verify(restTemplate, never()).postForEntity(any(String.class), any(), eq(String.class));
}
@Test
void maxRetryExceededTriggersFinalCallback() {
SendRecord record = createRecord();
record.setRetryCount(4);
record.setMaxRetry(3);
sendRecordMapper.updateById(record);
RetryConfig retryPlan = new RetryConfig(3, Arrays.asList(1, 2, 3));
SendRecordMessage message = createMessage(record, retryPlan);
consumer.handle(message);
SendRecord updated = sendRecordMapper.selectById(record.getId());
assertEquals(3, updated.getSendStatus().intValue());
ArgumentCaptor<HttpEntity> captor = ArgumentCaptor.forClass(HttpEntity.class);
verify(restTemplate).postForEntity(eq("http://example.com/callback"), captor.capture(), eq(String.class));
@SuppressWarnings("unchecked")
Map<String, Object> payload = (Map<String, Object>) captor.getValue().getBody();
assertEquals(record.getId(), payload.get("record_id"));
assertEquals("failed", payload.get("event"));
assertEquals(3, payload.get("status"));
verify(sendMessageProducer, never()).publishRetryMessage(any(), anyInt());
verify(adapter, never()).sendMessage(any());
}
private SendRecord createRecord() {
SendRecord record = new SendRecord();
record.setTaskId("SNDTEST001");
record.setTenantCode("test-tenant");
record.setTargetType("person");
record.setTargetValue("{\"person_id\":2001}");
record.setPersonId(2001L);
record.setAccountId(100L);
record.setChannelType("wechat_personal");
record.setChannelStrategy("{\"channel_type\":\"wechat_personal\",\"strategy\":\"primary\"}");
record.setContentType("text");
record.setContentBody("{\"text\":\"hello\"}");
record.setCallbackUrl("http://example.com/callback");
record.setSendStatus(0);
record.setRetryCount(0);
record.setMaxRetry(0);
sendRecordMapper.insert(record);
return record;
}
private SendRecordMessage createMessage(SendRecord record, RetryConfig retryPlan) {
return SendRecordMessage.builder()
.recordId(record.getId())
.taskId(record.getTaskId())
.tenantCode(record.getTenantCode())
.attempt(record.getRetryCount() != null ? record.getRetryCount() : 0)
.retryPlan(retryPlan)
.build();
}
@TestConfiguration
static class MockConfig {
@Bean
public ChannelAdapterRegistry channelAdapterRegistry() {
return mock(ChannelAdapterRegistry.class);
}
@Bean
public SendMessageProducer sendMessageProducer() {
return mock(SendMessageProducer.class);
}
@Bean
public RestTemplate restTemplate() {
return mock(RestTemplate.class);
}
}
}

View File

@@ -0,0 +1,130 @@
package com.sino.mci.send.service;
import com.sino.mci.policy.engine.PolicyCheckResult;
import com.sino.mci.policy.engine.PolicyEngine;
import com.sino.mci.policy.engine.PolicyGroupConfig;
import com.sino.mci.send.entity.SendRecord;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.time.Duration;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
public class PolicyEngineTest {
@Mock
private StringRedisTemplate redisTemplate;
@Mock
private ValueOperations<String, String> valueOperations;
@Test
void rateLimitBlocksAfterNMessagesInWindow() {
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.increment(anyString())).thenReturn(1L, 2L, 3L);
PolicyEngine engine = new PolicyEngine(redisTemplate);
PolicyGroupConfig config = PolicyGroupConfig.parseJson(
"[{\"type\":\"rate_limit\",\"scope\":\"account\",\"limit\":2,\"window\":\"1h\"}]");
SendRecord record = createRecord("rescue", 1001L, 2001L, 3001L);
LocalDateTime now = LocalDateTime.of(2026, 7, 7, 14, 30);
PolicyCheckResult first = engine.check(record, config, now);
assertTrue(first.isAllowed());
PolicyCheckResult second = engine.check(record, config, now);
assertTrue(second.isAllowed());
PolicyCheckResult third = engine.check(record, config, now);
assertFalse(third.isAllowed());
assertEquals("rate_limit_exceeded", third.getReasonCode());
verify(valueOperations, times(3)).increment(anyString());
verify(redisTemplate, atLeastOnce()).expire(anyString(), eq(Duration.ofHours(1)));
}
@Test
void timeWindowRejectsOutsideAllowedRange() {
PolicyEngine engine = new PolicyEngine(redisTemplate);
PolicyGroupConfig config = PolicyGroupConfig.parseJson(
"[{\"type\":\"time_window\",\"allow\":[\"09:00-21:00\"]}]");
SendRecord record = createRecord("rescue", 1001L, 2001L, 3001L);
LocalDateTime inside = LocalDateTime.of(2026, 7, 7, 10, 0);
LocalDateTime outside = LocalDateTime.of(2026, 7, 7, 22, 0);
assertTrue(engine.check(record, config, inside).isAllowed());
PolicyCheckResult rejected = engine.check(record, config, outside);
assertFalse(rejected.isAllowed());
assertEquals("time_window_not_allowed", rejected.getReasonCode());
}
@Test
void blacklistRejectsBlockedPersonOrConversation() {
PolicyEngine engine = new PolicyEngine(redisTemplate);
PolicyGroupConfig config = PolicyGroupConfig.parseJson(
"[{\"type\":\"blacklist\",\"person_ids\":[1001],\"conversation_ids\":[3001]}]");
SendRecord normal = createRecord("rescue", 1L, 2L, 3L);
assertTrue(engine.check(normal, config, LocalDateTime.now()).isAllowed());
SendRecord blockedPerson = createRecord("rescue", 1L, 1001L, 3L);
PolicyCheckResult personResult = engine.check(blockedPerson, config, LocalDateTime.now());
assertFalse(personResult.isAllowed());
assertEquals("blacklist_person", personResult.getReasonCode());
SendRecord blockedConversation = createRecord("rescue", 1L, 2L, 3001L);
PolicyCheckResult conversationResult = engine.check(blockedConversation, config, LocalDateTime.now());
assertFalse(conversationResult.isAllowed());
assertEquals("blacklist_conversation", conversationResult.getReasonCode());
}
@Test
void combinedPoliciesAllowAndRejectCorrectly() {
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.increment(anyString())).thenReturn(1L, 2L, 3L);
PolicyEngine engine = new PolicyEngine(redisTemplate);
PolicyGroupConfig config = PolicyGroupConfig.parseJson("["
+ "{\"type\":\"blacklist\",\"person_ids\":[999]},"
+ "{\"type\":\"time_window\",\"allow\":[\"09:00-21:00\"]},"
+ "{\"type\":\"rate_limit\",\"scope\":\"account\",\"limit\":2,\"window\":\"1d\"}"
+ "]");
SendRecord record = createRecord("rescue", 1001L, 2001L, 3001L);
LocalDateTime now = LocalDateTime.of(2026, 7, 7, 10, 0);
assertTrue(engine.check(record, config, now).isAllowed());
assertTrue(engine.check(record, config, now).isAllowed());
PolicyCheckResult rejected = engine.check(record, config, now);
assertFalse(rejected.isAllowed());
assertEquals("rate_limit_exceeded", rejected.getReasonCode());
}
private SendRecord createRecord(String tenantCode, Long accountId, Long personId, Long conversationId) {
SendRecord record = new SendRecord();
record.setTenantCode(tenantCode);
record.setAccountId(accountId);
record.setPersonId(personId);
record.setConversationId(conversationId);
return record;
}
}

View File

@@ -0,0 +1,130 @@
package com.sino.mci.send.service;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
import static com.sino.mci.test.AuthTestHelper.createTenantAndLogin;
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;
/**
* 出站发送端到端测试。
*
* <p>覆盖完整业务链路:创建租户 → 创建渠道主体 → 创建渠道账号 → 创建会话 → 绑定发送账号 →
* 调用模拟发送接口 → 查询发送记录并校验字段。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class SendEndToEndTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldCompleteSendFlowFromTenantToRecord() throws Exception {
String tenantCode = "send-e2e-" + UUID.randomUUID().toString().substring(0, 8);
String authToken = createTenantAndLogin(mockMvc, tenantCode, "发送端到端测试");
// 1. 创建渠道主体
String subjectCode = "sub-" + UUID.randomUUID().toString().substring(0, 8);
String subjectPayload = String.format(
"{\"channelType\":\"wecom\",\"subjectCode\":\"%s\",\"subjectName\":\"E2E Subject\"}",
subjectCode);
String subjectResponse = mockMvc.perform(post("/msg-platform/api/v1/channel-subject")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON)
.content(subjectPayload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.channelType").value("wecom"))
.andReturn().getResponse().getContentAsString();
Long subjectId = ((Number) com.jayway.jsonpath.JsonPath.read(subjectResponse, "$.data.id")).longValue();
// 2. 创建渠道账号
String accountPayload = String.format(
"{\"accountType\":1,\"channelType\":\"wecom\",\"subjectId\":%d,\"accountId\":\"ZhangSan\",\"accountName\":\"张三\"}",
subjectId);
String accountResponse = mockMvc.perform(post("/msg-platform/api/v1/channel-account")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.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();
// 3. 创建会话
String channelConversationId = "room-" + UUID.randomUUID().toString().substring(0, 8);
String conversationPayload = String.format(
"{\"conversationType\":2,\"channelType\":\"wecom\",\"channelConversationId\":\"%s\",\"conversationName\":\"E2E群\",\"subjectId\":%d}",
channelConversationId, subjectId);
String conversationResponse = mockMvc.perform(post("/msg-platform/api/v1/conversation")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.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();
// 4. 绑定发送主号
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("Authorization", authToken)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON)
.content(bindPayload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
// 5. 发送消息(模拟发送)
String sendPayload = String.format(
"{\"targetType\":\"conversation\",\"targetValue\":{\"conversation_id\":%d},\"contentType\":\"text\",\"content\":{\"text\":\"hello e2e\"},\"channelStrategy\":{\"channel_type\":\"wecom\",\"strategy\":\"primary\"}}",
conversationId);
String sendResponse = mockMvc.perform(post("/msg-platform/api/v1/send/test")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.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");
// 6. 查询发送记录并校验
mockMvc.perform(get("/msg-platform/api/v1/send/" + taskId + "/records")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.length()").value(1))
.andExpect(jsonPath("$.data[0].taskId").value(taskId))
.andExpect(jsonPath("$.data[0].sendStatus").value(2))
.andExpect(jsonPath("$.data[0].channelType").value("wecom"))
.andExpect(jsonPath("$.data[0].conversationId").value(conversationId))
.andExpect(jsonPath("$.data[0].accountId").value(accountId))
.andExpect(jsonPath("$.data[0].contentType").value("text"))
.andExpect(jsonPath("$.data[0].contentBody").value(org.hamcrest.Matchers.containsString("hello e2e")));
}
}

Some files were not shown because too many files have changed in this diff Show More