wip: baseline changes before channel-conversation-task boundary implementation
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -13,4 +13,8 @@ public class CreateTenantRequest {
|
||||
private String tenantName;
|
||||
|
||||
private String inboundWebhookUrl;
|
||||
|
||||
private String initAdminUsername;
|
||||
|
||||
private String initAdminPassword;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -14,5 +14,6 @@ public class Tenant extends BaseEntity {
|
||||
private String tenantName;
|
||||
private String authConfig;
|
||||
private String inboundWebhookUrl;
|
||||
private String extInfo;
|
||||
private Integer status;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,6 +15,8 @@ public class ChannelAccountInitRequest {
|
||||
@NotNull
|
||||
private Integer accountType;
|
||||
|
||||
private Long accountId;
|
||||
|
||||
private Long subjectId;
|
||||
|
||||
private Map<String, Object> config;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.sino.mci.inbound.engine;
|
||||
|
||||
/**
|
||||
* 入站流程定义边。
|
||||
*/
|
||||
public record FlowEdge(String source, String target, String label) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}),
|
||||
* 其次使用默认规则;规则未命中时调用 LLM;LLM 未配置时返回 {@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) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE mci_tenant
|
||||
ADD COLUMN ext_info JSON NULL COMMENT '扩展信息 JSON';
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package com.sino.mci.send.service;
|
||||
|
||||
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.repository.ChannelAccountMapper;
|
||||
import com.sino.mci.channel.repository.ConversationMapper;
|
||||
import com.sino.mci.channel.repository.ConversationTagBindingMapper;
|
||||
import com.sino.mci.channel.service.AccountSelector;
|
||||
import com.sino.mci.crm.repository.ChannelIdentityMapper;
|
||||
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.policy.engine.PolicyEngine;
|
||||
import com.sino.mci.policy.repository.PolicyGroupMapper;
|
||||
import com.sino.mci.send.entity.SendRecord;
|
||||
import com.sino.mci.send.repository.SendRecordMapper;
|
||||
import com.sino.mci.shared.util.JsonUtils;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
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.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
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 SendServiceFailoverTest {
|
||||
|
||||
@Mock
|
||||
private SendRecordMapper sendRecordMapper;
|
||||
@Mock
|
||||
private ConversationMapper conversationMapper;
|
||||
@Mock
|
||||
private ChannelAccountMapper accountMapper;
|
||||
@Mock
|
||||
private ChannelIdentityMapper channelIdentityMapper;
|
||||
@Mock
|
||||
private PersonMapper personMapper;
|
||||
@Mock
|
||||
private TagMapper tagMapper;
|
||||
@Mock
|
||||
private PersonTagBindingMapper personTagBindingMapper;
|
||||
@Mock
|
||||
private ConversationTagBindingMapper conversationTagBindingMapper;
|
||||
@Mock
|
||||
private ChannelAdapterRegistry adapterRegistry;
|
||||
@Mock
|
||||
private PolicyGroupMapper policyGroupMapper;
|
||||
@Mock
|
||||
private PolicyEngine policyEngine;
|
||||
@Mock
|
||||
private AccountSelector accountSelector;
|
||||
@Mock
|
||||
private ChannelAdapter adapter;
|
||||
|
||||
@InjectMocks
|
||||
private SendService sendService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
when(adapterRegistry.getAdapter(ChannelType.WECHAT_PERSONAL)).thenReturn(adapter);
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatchRecordSucceedsAfterFailoverToBackup() {
|
||||
SendRecord record = createRecord();
|
||||
Long primaryAccountId = 100L;
|
||||
Long backupAccountId = 200L;
|
||||
|
||||
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECHAT_PERSONAL, "primary"))
|
||||
.thenReturn(Arrays.asList(primaryAccountId, backupAccountId));
|
||||
when(accountMapper.selectById(primaryAccountId)).thenReturn(createPywechatAccount(primaryAccountId, "wxid-primary"));
|
||||
when(accountMapper.selectById(backupAccountId)).thenReturn(createPywechatAccount(backupAccountId, "wxid-backup"));
|
||||
|
||||
SendMessageResult failureResult = new SendMessageResult();
|
||||
failureResult.setSuccess(false);
|
||||
failureResult.setMessage("login expired");
|
||||
|
||||
SendMessageResult successResult = new SendMessageResult();
|
||||
successResult.setSuccess(true);
|
||||
successResult.setChannelMessageId("msg-backup-001");
|
||||
|
||||
when(adapter.sendMessage(any(SendMessageRequest.class))).thenReturn(failureResult, successResult);
|
||||
|
||||
sendService.dispatchRecord(record);
|
||||
|
||||
assertEquals(2, record.getSendStatus().intValue());
|
||||
assertEquals(backupAccountId, record.getAccountId());
|
||||
|
||||
ArgumentCaptor<SendMessageRequest> requestCaptor = ArgumentCaptor.forClass(SendMessageRequest.class);
|
||||
verify(adapter, times(2)).sendMessage(requestCaptor.capture());
|
||||
List<SendMessageRequest> requests = requestCaptor.getAllValues();
|
||||
assertEquals("wxid-primary", requests.get(0).getExtra().get("account_id"));
|
||||
assertEquals("wxid-backup", requests.get(1).getExtra().get("account_id"));
|
||||
|
||||
ArgumentCaptor<ChannelAccount> accountCaptor = ArgumentCaptor.forClass(ChannelAccount.class);
|
||||
verify(accountMapper, times(1)).updateById(accountCaptor.capture());
|
||||
ChannelAccount markedAccount = accountCaptor.getValue();
|
||||
assertEquals(primaryAccountId, markedAccount.getId());
|
||||
assertEquals(2, markedAccount.getRiskLevel().intValue());
|
||||
assertEquals(0, markedAccount.getOnlineStatus().intValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatchRecordFailsWhenAllAccountsUnhealthy() {
|
||||
SendRecord record = createRecord();
|
||||
Long accountId = 100L;
|
||||
|
||||
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECHAT_PERSONAL, "primary"))
|
||||
.thenReturn(Collections.singletonList(accountId));
|
||||
when(accountMapper.selectById(accountId)).thenReturn(createPywechatAccount(accountId, "wxid-primary"));
|
||||
|
||||
SendMessageResult failureResult = new SendMessageResult();
|
||||
failureResult.setSuccess(false);
|
||||
failureResult.setMessage("network error");
|
||||
when(adapter.sendMessage(any(SendMessageRequest.class))).thenReturn(failureResult);
|
||||
|
||||
sendService.dispatchRecord(record);
|
||||
|
||||
assertEquals(3, record.getSendStatus().intValue());
|
||||
assertEquals(accountId, record.getAccountId());
|
||||
|
||||
verify(adapter, times(1)).sendMessage(any(SendMessageRequest.class));
|
||||
|
||||
ArgumentCaptor<ChannelAccount> accountCaptor = ArgumentCaptor.forClass(ChannelAccount.class);
|
||||
verify(accountMapper, times(1)).updateById(accountCaptor.capture());
|
||||
ChannelAccount markedAccount = accountCaptor.getValue();
|
||||
assertEquals(2, markedAccount.getRiskLevel().intValue());
|
||||
assertEquals(0, markedAccount.getOnlineStatus().intValue());
|
||||
|
||||
Map<String, Object> result = JsonUtils.fromJson(record.getChannelResult(), Map.class);
|
||||
assertNotNull(result);
|
||||
assertEquals("all_accounts_failed", result.get("error"));
|
||||
List<?> attempts = (List<?>) result.get("attempts");
|
||||
assertFalse(attempts.isEmpty());
|
||||
assertEquals(accountId, ((Number) ((Map<?, ?>) attempts.get(0)).get("account_id")).longValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatchRecordFailsWithNoHealthyAccount() {
|
||||
SendRecord record = createRecord();
|
||||
|
||||
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECHAT_PERSONAL, "primary"))
|
||||
.thenReturn(Collections.emptyList());
|
||||
|
||||
sendService.dispatchRecord(record);
|
||||
|
||||
assertEquals(3, record.getSendStatus().intValue());
|
||||
verify(adapter, never()).sendMessage(any(SendMessageRequest.class));
|
||||
verify(accountMapper, never()).updateById(any(ChannelAccount.class));
|
||||
|
||||
Map<String, Object> result = JsonUtils.fromJson(record.getChannelResult(), Map.class);
|
||||
assertEquals("no_healthy_account", result.get("error"));
|
||||
}
|
||||
|
||||
private SendRecord createRecord() {
|
||||
SendRecord record = new SendRecord();
|
||||
record.setId(1L);
|
||||
record.setTaskId("SND001");
|
||||
record.setTenantCode("tenant");
|
||||
record.setConversationId(1L);
|
||||
record.setAccountId(100L);
|
||||
record.setChannelType("wechat_personal");
|
||||
record.setChannelStrategy("{\"channel_type\":\"wechat_personal\",\"strategy\":\"primary\"}");
|
||||
record.setContentType("text");
|
||||
record.setContentBody("{\"text\":\"hello\"}");
|
||||
record.setSendStatus(0);
|
||||
return record;
|
||||
}
|
||||
|
||||
private ChannelAccount createPywechatAccount(Long id, String accountId) {
|
||||
ChannelAccount account = new ChannelAccount();
|
||||
account.setId(id);
|
||||
account.setTenantCode("tenant");
|
||||
account.setAccountType(3);
|
||||
account.setChannelType("wechat_personal");
|
||||
account.setAccountId(accountId);
|
||||
account.setStatus(1);
|
||||
account.setOnlineStatus(1);
|
||||
account.setRiskLevel(1);
|
||||
return account;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user