chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -0,0 +1,142 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-bootstrap</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-web-bootstrap</artifactId>
<dependencies>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-mcp-ui</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-agent-core-ui</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-im-web</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-knowledge-core-ui</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-knowledge-api</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-compose-api</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-compose-ui</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-log-web</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-log-api</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-eco-market-client-web</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-eco-market-api</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-custom-page-ui</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-sandbox-ui</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-memory-api</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-model-proxy-application</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-file-web</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>system-api</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>system-web</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-credit-web</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-subscription-web</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-pricing-web</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-pay-web</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-bill-web</artifactId>
</dependency>
</dependencies>
<profiles>
<profile>
<id>dev</id>
<properties>
<!-- 环境标识,需要与配置文件的名称相对应 -->
<activatedProperties>dev</activatedProperties>
</properties>
<activation>
<!-- 默认环境 -->
<activeByDefault>true</activeByDefault>
</activation>
</profile>
<profile>
<id>test</id>
<properties>
<activatedProperties>test</activatedProperties>
</properties>
</profile>
<profile>
<id>prod</id>
<properties>
<activatedProperties>prod</activatedProperties>
</properties>
</profile>
</profiles>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,19 @@
package com.xspaceagi;
import com.xspaceagi.system.domain.log.EnableLogPrint;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.scheduling.annotation.EnableScheduling;
@EnableScheduling
@EnableCaching
@EnableLogPrint
@SpringBootApplication(scanBasePackages = {"com.xspaceagi", "io.springfox"})
public class PlatformApiApplication {
public static void main(String[] args) {
SpringApplication.run(PlatformApiApplication.class, args);
}
}

View File

@@ -0,0 +1,20 @@
//package com.xspaceagi.config;
//
//import org.springframework.context.annotation.Bean;
//import org.springframework.context.annotation.Configuration;
//
//
//@Configuration
//@EnableSwagger
//public class SwaggerConfig {
//
// @Bean
// public Docket createRestApiOpRecord() {
// return new Docket(DocumentationType.SWAGGER_2).groupName("接口文档").useDefaultResponseMessages(false).apiInfo(apiInfo()).select()
// .apis(RequestHandlerSelectors.basePackage("com.xspaceagi.agent.web.ui")).paths(PathSelectors.any()).build();
// }
//
// private ApiInfo apiInfo() {
// return new ApiInfoBuilder().title("应用发布页接口文档v1").description("接口文档").version("v1").build();
// }
//}

View File

@@ -0,0 +1,46 @@
package com.xspaceagi.config;
import java.time.LocalDateTime;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.fasterxml.jackson.core.StreamReadConstraints;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.xspaceagi.log.spec.CustomLocalDateTimeSerializer;
import com.xspaceagi.log.spec.FlexibleDateTimeDeserializer;
/**
* UTC 时间序列化配置
*/
@Configuration
public class UTCDateTimeConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() {
return builder -> {
// 使用 SimpleModule 统一注册序列化器和反序列化器
SimpleModule module = new SimpleModule();
// 注册自定义序列化器
module.addSerializer(LocalDateTime.class, new CustomLocalDateTimeSerializer());
// 注册自定义反序列化器
module.addDeserializer(LocalDateTime.class, new FlexibleDateTimeDeserializer());
builder.modules(module);
// 配置 StreamReadConstraints 以支持更大的字符串长度
// 设置最大字符串长度为 100MB (100 * 1024 * 1024 字符),用于支持大文件内容
builder.postConfigurer(objectMapper -> {
StreamReadConstraints constraints = StreamReadConstraints.builder()
.maxStringLength(100 * 1024 * 1024)
.build();
objectMapper.getFactory().setStreamReadConstraints(constraints);
});
};
}
}

View File

@@ -0,0 +1,34 @@
package com.xspaceagi.config;
import io.milvus.v2.client.ConnectConfig;
import io.milvus.v2.client.MilvusClientV2;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
@Slf4j
@Configuration
public class VectorDBConfig {
@Value("${milvus.uri}")
private String milvusUri;
@Lazy
@Bean
public MilvusClientV2 milvusClient() {
log.info("milvus uri:{}", milvusUri);
ConnectConfig config = ConnectConfig.builder()
.uri(milvusUri)
.build();
MilvusClientV2 client = new MilvusClientV2(config);
log.info("milvus client init success, uri:{}", milvusUri);
return client;
}
}

View File

@@ -0,0 +1,101 @@
package com.xspaceagi.config;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.core.convert.converter.ConverterFactory;
import org.springframework.format.FormatterRegistry;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.util.Assert;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import com.baomidou.mybatisplus.annotation.IEnum;
import com.xspaceagi.interceptor.ApiKeyInterceptor;
import com.xspaceagi.interceptor.AuthInterceptor;
import com.xspaceagi.interceptor.HttpInterceptor;
import com.xspaceagi.interceptor.TIdInterceptor;
import jakarta.annotation.Resource;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private HttpInterceptor httpInterceptor;
@Autowired
private AuthInterceptor jwtInterceptor;
/**
* tid统一标识处理
*/
@Resource
private TIdInterceptor tidInterceptor;
@Resource
private ApiKeyInterceptor apiKeyInterceptor;
@Value("${access.control.allow-origin}")
private String accessControlAllowOrigin;
@Override
public void addInterceptors(InterceptorRegistry interceptorRegistry) {
// 请求参数记录日志拦截器
interceptorRegistry.addInterceptor(tidInterceptor).order(1).addPathPatterns("/api/**");
interceptorRegistry.addInterceptor(httpInterceptor).order(21).addPathPatterns("/api/**");
interceptorRegistry.addInterceptor(jwtInterceptor).order(22).addPathPatterns("/api/**");
interceptorRegistry.addInterceptor(apiKeyInterceptor).order(23).addPathPatterns("/api/**");
}
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**") // 允许所有请求路径跨域访问
.allowCredentials(true) // 是否携带Cookie默认false
.allowedHeaders("Origin", "X-Requested-With", "Content-Type", "Accept", "Authorization",
"Cache-Control", "Fragment") // 允许的请求头类型
.maxAge(3600) // 预检请求的缓存时间(单位:秒)
.allowedMethods("HEAD", "GET", "POST", "PUT", "DELETE", "OPTIONS") // 允许的请求方法类型
.allowedOriginPatterns(accessControlAllowOrigin); // 允许哪些域名进行跨域访问
}
@Bean
public HttpMessageConverter<String> responseBodyConverter() {
Charset defaultCharset = StandardCharsets.UTF_8;
return new StringHttpMessageConverter(defaultCharset);
}
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
converters.add(responseBodyConverter());
}
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverterFactory(new ConverterFactory<String, IEnum>() {
@Override
public <T extends IEnum> Converter<String, T> getConverter(Class<T> targetType) {
return source -> {
Assert.isTrue(targetType.isEnum(), () -> "the class what implements IEnum must be the enum type");
return Arrays.stream(targetType.getEnumConstants())
.filter(t -> String.valueOf(t.getValue()).equals(source))
.findFirst().orElse(null);
};
}
});
}
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
}
}

View File

@@ -0,0 +1,18 @@
package com.xspaceagi.ctl;
import com.xspaceagi.system.spec.dto.ReqResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/")
@Slf4j
public class HealthController {
@RequestMapping(path = "/health")
public ReqResult<Void> health() {
return ReqResult.success();
}
}

View File

@@ -0,0 +1,110 @@
package com.xspaceagi.filter;
import com.xspaceagi.sandbox.SandboxApiRewriteProperties;
import com.xspaceagi.sandbox.SandboxRequestAttributes;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import java.io.IOException;
import java.util.List;
/**
* Sandbox 兼容 API URL 重写过滤器(全局生效)
*/
@Component
@Order(1)
@Slf4j
public class SandboxApiRewriteFilter implements Filter {
private static final String SANDBOX_PREFIX = "/api/v1/4sandbox";
/**
* 允许重写的路径模式(相对于 /api/v1/4sandbox 之后的部分匹配)
* 支持通配符:*、**AntPathMatcher
*/
private final SandboxApiRewriteProperties rewriteProperties;
private final AntPathMatcher pathMatcher = new AntPathMatcher();
public SandboxApiRewriteFilter(SandboxApiRewriteProperties rewriteProperties) {
this.rewriteProperties = rewriteProperties;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String requestURI = httpRequest.getRequestURI();
if (requestURI == null || !requestURI.startsWith(SANDBOX_PREFIX)) {
chain.doFilter(request, response);
return;
}
// 标记请求来源,供 controller 做沙箱/前端差异处理
httpRequest.setAttribute(SandboxRequestAttributes.REQUEST_SOURCE, SandboxRequestAttributes.SOURCE_SANDBOX);
String suffix = requestURI.substring(SANDBOX_PREFIX.length());
String pathAfterPrefix = suffix.startsWith("/") ? suffix.substring(1) : suffix;
if (!isAllowRewrite(pathAfterPrefix)) {
chain.doFilter(request, response);
return;
}
// 保留原始 URI/api/v1/4sandbox/...),给鉴权拦截器做白名单/AK 判断
httpRequest.setAttribute(SandboxRequestAttributes.ORIGINAL_REQUEST_URI, requestURI);
String rewrittenURI = "/api" + suffix; // suffix 以 '/' 开头更安全
log.debug("Rewrite 4Sandbox API URL: {} -> {}", requestURI, rewrittenURI);
HttpServletRequest wrappedRequest = new RewriteHttpServletRequestWrapper(httpRequest, rewrittenURI);
chain.doFilter(wrappedRequest, response);
}
private boolean isAllowRewrite(String pathAfterPrefix) {
List<String> patterns = rewriteProperties.getAllowPath();
if (patterns == null || patterns.isEmpty()) {
return false;
}
for (String rawPattern : patterns) {
String pattern = rawPattern == null ? "" : rawPattern.trim();
if (pattern.startsWith("/")) {
pattern = pattern.substring(1);
}
if (pattern.isEmpty()) {
continue;
}
if (pathMatcher.match(pattern, pathAfterPrefix)) {
return true;
}
}
return false;
}
private static class RewriteHttpServletRequestWrapper extends HttpServletRequestWrapper {
private final String rewrittenURI;
public RewriteHttpServletRequestWrapper(HttpServletRequest request, String rewrittenURI) {
super(request);
this.rewrittenURI = rewrittenURI;
}
@Override
public String getRequestURI() {
return rewrittenURI;
}
@Override
public String getServletPath() {
// Spring MVC 路由匹配使用 servletPath/requestURI 等信息;统一返回改写后的路径。
return rewrittenURI;
}
}
}

View File

@@ -0,0 +1,106 @@
package com.xspaceagi.interceptor;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
/**
* AES加解密工具类
* 支持多种加密模式和填充方式
*
* @author Qiming
*/
public class AESCrypto {
// 加密算法
private static final String ALGORITHM = "AES";
// 默认使用AES-256-CBC模式
private static final String DEFAULT_TRANSFORMATION = "AES/CBC/PKCS5Padding";
/**
* AES加密支持自定义模式
*
* @param plainText 明文
* @param key 密钥
* @param iv 初始化向量ECB模式可为null
* @param transformation 转换模式AES/CBC/PKCS5Padding
* @return Base64编码的密文
*/
public static String encrypt(String plainText, String key, String iv, String transformation) throws Exception {
SecretKeySpec secretKey = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), ALGORITHM);
Cipher cipher = Cipher.getInstance(transformation);
// 判断是否需要IV
if (transformation.contains("ECB")) {
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
} else {
IvParameterSpec ivSpec = new IvParameterSpec(iv.getBytes(StandardCharsets.UTF_8));
cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec);
}
byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(encrypted);
}
/**
* AES解密支持自定义模式
*
* @param cipherText Base64编码的密文
* @param key 密钥
* @param iv 初始化向量ECB模式可为null
* @param transformation 转换模式
* @return 解密后的明文
*/
public static String decrypt(String cipherText, String key, String iv, String transformation) throws Exception {
SecretKeySpec secretKey = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), ALGORITHM);
Cipher cipher = Cipher.getInstance(transformation);
// 判断是否需要IV
if (transformation.contains("ECB")) {
cipher.init(Cipher.DECRYPT_MODE, secretKey);
} else {
IvParameterSpec ivSpec = new IvParameterSpec(iv.getBytes(StandardCharsets.UTF_8));
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec);
}
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(cipherText));
return new String(decrypted, StandardCharsets.UTF_8);
}
/**
* AES解密使用Hex密钥
*
* @param cipherText Base64编码的密文
* @param keyHex 十六进制密钥
* @param ivHex 十六进制IV
* @return 解密后的明文
*/
public static String decryptWithHexKey(String cipherText, String keyHex, String ivHex) throws Exception {
byte[] keyBytes = hexStringToByteArray(keyHex);
byte[] ivBytes = hexStringToByteArray(ivHex);
SecretKeySpec secretKey = new SecretKeySpec(keyBytes, ALGORITHM);
Cipher cipher = Cipher.getInstance(DEFAULT_TRANSFORMATION);
IvParameterSpec ivSpec = new IvParameterSpec(ivBytes);
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec);
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(cipherText));
return new String(decrypted, StandardCharsets.UTF_8);
}
/**
* 十六进制字符串转字节数组
*/
private static byte[] hexStringToByteArray(String hex) {
int len = hex.length();
byte[] data = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4)
+ Character.digit(hex.charAt(i + 1), 16));
}
return data;
}
}

View File

@@ -0,0 +1,180 @@
package com.xspaceagi.interceptor;
import com.xspaceagi.agent.core.adapter.application.AgentApplicationService;
import com.xspaceagi.agent.core.adapter.dto.AgentDetailDto;
import com.xspaceagi.sandbox.SandboxRequestAttributes;
import com.xspaceagi.system.application.dto.TenantConfigDto;
import com.xspaceagi.system.application.dto.UserDto;
import com.xspaceagi.system.application.service.UserApiKeyApplicationService;
import com.xspaceagi.system.application.service.UserApplicationService;
import com.xspaceagi.system.infra.dao.entity.OpenApiDefinition;
import com.xspaceagi.system.infra.dao.entity.User;
import com.xspaceagi.system.sdk.service.UserAccessKeyApiService;
import com.xspaceagi.system.sdk.service.dto.UserAccessKeyDto;
import com.xspaceagi.system.spec.common.RequestContext;
import com.xspaceagi.system.spec.enums.ErrorCodeEnum;
import com.xspaceagi.system.spec.enums.YesOrNoEnum;
import com.xspaceagi.system.spec.exception.BizException;
import com.xspaceagi.system.spec.exception.BizExceptionCodeEnum;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import java.util.List;
@Component
public class ApiKeyInterceptor implements HandlerInterceptor {
@Resource
private UserApplicationService userApplicationService;
@Resource
private AgentApplicationService agentApplicationService;
@Resource
private UserAccessKeyApiService userAccessKeyApiService;
@Resource
private UserApiKeyApplicationService userApiKeyApplicationService;
private final AntPathMatcher pathMatcher = new AntPathMatcher();
/**
* 请求处理完之后
*/
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object obj, Exception exc) throws Exception {
RequestContext.remove();
}
/**
* 请求处理完成
*/
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object obj, ModelAndView model) throws Exception {
}
/**
* 请求处理之前
*/
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String originalRequestUri = getOriginalRequestUri(request);
String authorization = request.getHeader("Authorization");
if (authorization != null) {
authorization = authorization.replaceFirst("Basic", "").replaceFirst("Bearer", "").trim();
}
//是否为ak校验
if (authorization != null && (authorization.startsWith("ak-") || authorization.startsWith("ck-"))) {
UserAccessKeyDto userAccessKeyDto = userAccessKeyApiService.queryAccessKey(authorization);
if (userAccessKeyDto == null || (userAccessKeyDto.getStatus() != null && userAccessKeyDto.getStatus() != 1)) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.apiKeyInvalid);
}
if (userAccessKeyDto.getExpire() != null && userAccessKeyDto.getExpire().getTime() < System.currentTimeMillis()) {
throw BizException.of(ErrorCodeEnum.PERMISSION_DENIED, BizExceptionCodeEnum.apiKeyExpired);
}
if (!completeAuthContext(request, userAccessKeyDto)) {
throw BizException.of(ErrorCodeEnum.PERMISSION_DENIED, BizExceptionCodeEnum.apiKeyAccessDenied);
}
return true;
}
if (originalRequestUri.startsWith("/api/v1/")) {
throw BizException.of(ErrorCodeEnum.PERMISSION_DENIED, BizExceptionCodeEnum.apiKeyMissing);
}
return true;
}
private boolean completeAuthContext(HttpServletRequest request, UserAccessKeyDto userAccessKeyDto) {
String originalRequestUri = getOriginalRequestUri(request);
UserDto userDto = userApplicationService.queryById(userAccessKeyDto.getUserId());
if (userDto == null || userDto.getStatus() == User.Status.Disabled || userDto.getStatus() == User.Status.Deleted) {
throw BizException.of(ErrorCodeEnum.PERMISSION_DENIED, BizExceptionCodeEnum.apiKeyUserDisabled);
}
RequestContext.get().setUserId(userDto.getId());
RequestContext.get().setUser(userDto);
RequestContext.get().setUserContext(UserDto.convertToUserContext(userDto));
RequestContext.get().setLogin(true);
RequestContext.get().setUserAccessKey(userAccessKeyDto);
if (userAccessKeyDto.getTargetType() == UserAccessKeyDto.AKTargetType.Agent) {
if (originalRequestUri.startsWith("/api/v1/") || originalRequestUri.startsWith("/api/file/")) {
TenantConfigDto tenantConfigDto = (TenantConfigDto) RequestContext.get().getTenantConfig();
if (tenantConfigDto.getAllowAgentApi() != null && tenantConfigDto.getAllowAgentApi().equals(YesOrNoEnum.N.getKey()) && userDto.getRole() != User.Role.Admin) {
throw BizException.of(ErrorCodeEnum.PERMISSION_DENIED, BizExceptionCodeEnum.apiKeyAgentApiDisabled);
}
AgentDetailDto agentDetailDto = agentApplicationService.queryAgentDetail(Long.parseLong(userAccessKeyDto.getTargetId()), false);
RequestContext.get().setAkTarget(agentDetailDto);
return true;
}
}
if (userAccessKeyDto.getTargetType() == UserAccessKeyDto.AKTargetType.OpenApi) {
String path = request.getRequestURI();//path参考格式 /api/v1/user/add、/api/v1/user/1011OpenApiDefinition的配置可能为/api/v1/user/:id做匹配时要适配 :xx 为实际值)
List<OpenApiDefinition> openApiDefinitions = userApiKeyApplicationService.queryOpenApiDefinitions(userDto.getId());
OpenApiDefinition openApiDefinition = matchOpenApiDefinition(path, openApiDefinitions);// 从openApiDefinitions中过滤得到
if (openApiDefinition == null) {
throw BizException.of(ErrorCodeEnum.PERMISSION_DENIED, BizExceptionCodeEnum.apiKeyOpenApiNotFound);
}
request.setAttribute("currentAPI", openApiDefinition);
request.setAttribute("userAccessKey", userAccessKeyDto);
//普通用户不能使用管理员接口
if (openApiDefinition.getRole() == User.Role.Admin && userDto.getRole() != User.Role.Admin) {
throw BizException.of(ErrorCodeEnum.PERMISSION_DENIED, BizExceptionCodeEnum.permissionDenied);
}
List<UserAccessKeyDto.ApiConfig> apiConfigs = userAccessKeyDto.getConfig().getApiConfigs();
// apiConfigs如果为空则有全部接口权限
if (CollectionUtils.isNotEmpty(apiConfigs) && apiConfigs.stream().noneMatch(apiConfig -> openApiDefinition.getKey().equals(apiConfig.getKey()))) {
throw BizException.of(ErrorCodeEnum.PERMISSION_DENIED, BizExceptionCodeEnum.permissionDenied);
}
userApiKeyApplicationService.requestLimitCheck(userDto.getId(), openApiDefinition);
return true;
}
// 允许沙箱访问的接口范围
if (userAccessKeyDto.getTargetType() == UserAccessKeyDto.AKTargetType.Sandbox && originalRequestUri.startsWith("/api/v1/4sandbox")) {
return true;
}
return userAccessKeyDto.getTargetType() == UserAccessKeyDto.AKTargetType.Tenant;
}
public OpenApiDefinition matchOpenApiDefinition(String path, List<OpenApiDefinition> definitions) {
if (definitions == null || definitions.isEmpty()) {
return null;
}
for (OpenApiDefinition def : definitions) {
// 当前节点匹配
if (def.getPath() != null && def.getKey().equals("api.v1.chat.file") && pathMatcher.matchStart(convertPathPattern(def.getPath()), path)) {
return def;
}
if (def.getPath() != null && pathMatcher.match(convertPathPattern(def.getPath()), path)) {
return def;
}
// 递归匹配子节点
OpenApiDefinition matched = matchOpenApiDefinition(path, def.getApiList());
if (matched != null) {
return matched;
}
}
return null;
}
private static String convertPathPattern(String path) {
if (path == null || !path.contains(":")) {
return path;
}
return path.replaceAll(":([a-zA-Z_][a-zA-Z0-9_]*)", "{$1}");
}
private String getOriginalRequestUri(HttpServletRequest request) {
Object val = request.getAttribute(SandboxRequestAttributes.ORIGINAL_REQUEST_URI);
if (val instanceof String s && !s.isEmpty()) {
return s;
}
return request.getRequestURI();
}
}

View File

@@ -0,0 +1,125 @@
package com.xspaceagi.interceptor;
import com.xspaceagi.pay.spec.exception.PayGatewayBizException;
import com.xspaceagi.system.spec.common.RequestContext;
import com.xspaceagi.system.spec.dto.ReqResult;
import com.xspaceagi.system.spec.enums.ErrorCodeEnum;
import com.xspaceagi.system.spec.exception.*;
import jakarta.validation.UnexpectedTypeException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.resource.NoResourceFoundException;
@Slf4j
@ControllerAdvice
public class AppExceptionHandler {
/**
* 处理异常消息,如果是业务异常将通过json的格式返回否则根据模式来处理异常的显示.
*/
@ExceptionHandler
@ResponseBody
public ReqResult<?> processException(Exception ex) {
ReqResult<?> responseData;
if (ex instanceof PayGatewayBizException payGatewayBizException) {
responseData = ReqResult.error(
payGatewayBizException.getCode(),
payGatewayBizException.getDisplayCode(),
payGatewayBizException.getMessage());
} else if (ex instanceof BizException bizException) {
String message = resolveBizExceptionMessage(bizException);
responseData = ReqResult.error(bizException.getCode(), message);
} else {
if (ex instanceof SpacePermissionException) {
log.warn("Exception[SpacePermissionException] ", ex);
responseData = ReqResult.error(ErrorCodeEnum.PERMISSION_DENIED.getCode(),
ErrorCodeEnum.PERMISSION_DENIED.getMsg());
} else if (ex instanceof ResourcePermissionException) {
responseData = ReqResult.error(ErrorCodeEnum.RESOURCE_PERMISSION_DENIED.getCode(),
ErrorCodeEnum.RESOURCE_PERMISSION_DENIED.getMsg());
} else if (ex instanceof HttpRequestMethodNotSupportedException) {
log.warn("Exception[HttpRequestMethodNotSupportedException] ", ex);
responseData = ReqResult.error(ErrorCodeEnum.METHOD_NOT_ALLOWED.getCode(), ex.getMessage());
} else if (ex instanceof MissingServletRequestParameterException) {
log.warn("Exception[MissingServletRequestParameterException] ", ex);
responseData = ReqResult.error(ErrorCodeEnum.INVALID_PARAM.getCode(), ex.getMessage());
} else if (ex instanceof HttpMessageNotReadableException) {
log.warn("Exception[HttpMessageNotReadableException] ", ex);
responseData = ReqResult.error(ErrorCodeEnum.INVALID_PARAM.getCode(), ex.getMessage());
} else if (ex instanceof MethodArgumentNotValidException methodArgumentNotValidException) {
log.warn("Exception[MethodArgumentNotValidException] ", ex);
String msg = methodArgumentNotValidException.getBindingResult().getFieldError().getDefaultMessage();
responseData = ReqResult.error(ErrorCodeEnum.INVALID_PARAM.getCode(), msg);
} else if (ex instanceof UnexpectedTypeException) {
log.warn("Exception[UnexpectedTypeException] ", ex);
responseData = ReqResult.error(ErrorCodeEnum.INVALID_PARAM.getCode(), ex.getMessage());
} else if (ex instanceof HttpMediaTypeNotSupportedException) {
log.warn("Exception[HttpMediaTypeNotSupportedException] ", ex);
responseData = ReqResult.error(ErrorCodeEnum.INVALID_PARAM.getCode(), ex.getMessage());
} else if (ex instanceof KnowledgeException knowledgeException) {
log.warn("Exception[KnowledgeException] ", ex);
responseData = ReqResult.error(knowledgeException.getCode(), knowledgeException.getDisplayCode(),
ex.getMessage());
} else if (ex instanceof SystemManagerException systemManagerException) {
log.warn("Exception[SystemManagerException] ", ex);
responseData = ReqResult.error(systemManagerException.getCode(),
systemManagerException.getDisplayCode(), ex.getMessage());
} else if (ex instanceof ComposeException composeException) {
responseData = ReqResult.error(composeException.getCode(), composeException.getDisplayCode(),
composeException.getMessage());
} else if (ex instanceof EcoMarketException ecoMarketException) {
responseData = ReqResult.error(ecoMarketException.getCode(), ecoMarketException.getDisplayCode(),
ecoMarketException.getMessage());
} else if (ex instanceof IllegalArgumentException) {
responseData = ReqResult.error(ErrorCodeEnum.INVALID_PARAM.getCode(), ex.getMessage());
} else if (ex instanceof NoResourceFoundException) {
responseData = ReqResult.error(ErrorCodeEnum.API_NOT_FOUND.getCode(), ex.getMessage());
} else {
log.error("System error ", ex);
responseData = ReqResult.error(ErrorCodeEnum.SYS_ERROR.getCode(), "系统开小差啦,请稍后重试");
}
}
return responseData;
}
/**
* BizException按 {@link RequestContext#getLang()} 选择文案;{@code zh-CN}(忽略大小写)用中文模板,否则用英文模板;
* 使用 {@link BizException#getMessageFormatArgs()} 与 {@link BizExceptionCodeEnum} 模板重新 {@link String#format}。
* 无 {@code msgName}、无上下文、无 {@code lang} 或枚举名无法解析时回退 {@link Throwable#getMessage()}。
*/
private static String resolveBizExceptionMessage(BizException ex) {
String fallback = ex.getMessage();
String msgName = ex.getMsgName();
if (msgName == null || msgName.isEmpty()) {
return fallback;
}
RequestContext<?> ctx = RequestContext.get();
if (ctx == null) {
return fallback;
}
String lang = ctx.getLang();
if (lang == null || lang.isBlank()) {
return fallback;
}
try {
BizExceptionCodeEnum codeEnum = BizExceptionCodeEnum.valueOf(msgName);
boolean zhCn = "zh-CN".equalsIgnoreCase(lang.trim());
String tpl = zhCn ? codeEnum.getMessageZh() : codeEnum.getMessageEn();
Object[] args = ex.getMessageFormatArgs();
if (args == null || args.length == 0) {
return tpl;
}
return String.format(tpl, args);
} catch (IllegalArgumentException e) {
return fallback;
}
}
}

View File

@@ -0,0 +1,361 @@
package com.xspaceagi.interceptor;
import com.xspaceagi.custompage.domain.model.CustomPageConfigModel;
import com.xspaceagi.custompage.infra.service.ProxyConfigService;
import com.xspaceagi.sandbox.SandboxRequestAttributes;
import com.xspaceagi.system.application.dto.I18nLangDto;
import com.xspaceagi.system.application.dto.TenantConfigDto;
import com.xspaceagi.system.application.dto.UserDto;
import com.xspaceagi.system.application.service.*;
import com.xspaceagi.system.infra.dao.entity.User;
import com.xspaceagi.system.infra.dao.entity.UserReq;
import com.xspaceagi.system.spec.auth.UserAuthProperties;
import com.xspaceagi.system.spec.common.RequestContext;
import com.xspaceagi.system.spec.enums.ErrorCodeEnum;
import com.xspaceagi.system.spec.enums.HttpStatusEnum;
import com.xspaceagi.system.spec.enums.I18nSideEnum;
import com.xspaceagi.system.spec.exception.BizException;
import com.xspaceagi.system.spec.exception.BizExceptionCodeEnum;
import com.xspaceagi.system.spec.utils.IPUtil;
import com.xspaceagi.system.spec.utils.JwtUtils;
import io.jsonwebtoken.Claims;
import jakarta.annotation.Resource;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
@Component
public class AuthInterceptor implements HandlerInterceptor {
private static final Logger log = LoggerFactory.getLogger(AuthInterceptor.class);
@Value("${jwt.secretKey}")
private String jwtSecretKey;
@Resource
private UserAuthProperties userAuthProperties;
@Resource
private AuthService authService;
// 页面开发代理,查询域名映射关系
@Resource
private ProxyConfigService proxyConfigService;
@Resource
private TenantConfigApplicationService tenantConfigApplicationService;
@Resource
private UserRequestApplicationService userRequestApplicationService;
@Resource
private I18nApplicationService i18nApplicationService;
@Resource
private I18nLangApplicationService i18nLangApplicationService;
@Value("${server.port:8080}")
private Integer port;
@Value("${license:}")
private String license;
/**
* 请求处理完之后
*/
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object obj, Exception exc) throws Exception {
RequestContext.remove();
}
/**
* 请求处理完成
*/
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object obj, ModelAndView model) throws Exception {
if (RequestContext.get() != null) {
userRequestApplicationService.addUserRequest(
UserReq.builder()
.tenantId(RequestContext.get().getTenantId())
.userId(RequestContext.get().getUserId() == null ? -1L : RequestContext.get().getUserId())
.build()
);
}
}
/**
* 请求处理之前
*/
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
request.setCharacterEncoding("UTF-8");
if (request.getMethod().equals("OPTIONS")) {
return true;
}
//license check
checkLicense();
// 优先使用 Sandbox Filter 记录的“原始 URI”
String originalRequestUri = getOriginalRequestUri(request);
String domainName = request.getHeader("Host");
if (domainName == null) {
return false;
}
String referer = request.getHeader("Referer");
Long tenantId = tenantConfigApplicationService.queryTenantIdByDomainName(domainName.split(":")[0]);
// 获取页面应用绑定域名场景下的租户ID
if (tenantId == null && request.getRequestURI().contains("/api/page/")) {
CustomPageConfigModel configModel = proxyConfigService.queryCustomPageConfigByDomain(domainName.split(":")[0]);
if (configModel != null) {
tenantId = configModel.getTenantId();
//构造referer页面的工作流插件调用基于referer中的页面项目id和关联的智能体ID进行关系确认
referer = "https://" + domainName + "/page/" + configModel.getId() + "-" + configModel.getDevAgentId() + "/prod";
request.setAttribute("referer", referer);//后面使用
}
}
if (tenantId == null) {
// 对于无需鉴权的路径(如飞书/钉钉 webhook使用默认租户不要求登录
if (isExcludedPath(originalRequestUri)) {
tenantId = 1L;
} else if (domainName.contains("qiming.com")) {
throw BizException.of(ErrorCodeEnum.UNAUTHORIZED_REDIRECT, BizExceptionCodeEnum.bootstrapAuthRedirectUrl, "https://qiming.com");
}
String headerTenantId = request.getHeader("x-tenant-id");
if (StringUtils.isNotBlank(headerTenantId)) {
try {
tenantId = Long.parseLong(headerTenantId);
} catch (NumberFormatException e) {
// ignore
}
}
if (tenantId == null) {
tenantId = 1L;
}
}
RequestContext<UserDto> requestContext = RequestContext.<UserDto>builder().tenantId(tenantId).build();
RequestContext.set(requestContext);
TenantConfigDto tenantConfig = tenantConfigApplicationService.getTenantConfig(requestContext.getTenantId());
String fragment = request.getHeader("Fragment");
if (StringUtils.isNotBlank(referer) && StringUtils.isNotBlank(fragment)) {
referer = referer + "#" + fragment;
}
if (referer != null && !log.isDebugEnabled() && !referer.contains("https://servicewechat.com")) {
try {
URL url = new URL(referer);
String baseUrl;
if (url.getHost().equals("localhost") || url.getHost().equals("127.0.0.1")) {
baseUrl = url.getProtocol() + "://" + url.getHost() + ":" + port;
} else {
baseUrl = url.getProtocol() + "://" + url.getHost() + (url.getPort() == 443 || url.getPort() == 80 || url.getPort() == -1 ? "" : ":" + url.getPort());
}
tenantConfig.setSiteConfigUrl(tenantConfig.getSiteUrl());
tenantConfig.setSiteUrl(baseUrl);
} catch (MalformedURLException e) {
// ignore
log.error("referer url error: {}", e.getMessage());
}
}
tenantConfig.setTenantId(requestContext.getTenantId());
requestContext.setTenantConfig(tenantConfig);
//获取终端语言
requestContext.setClientLang("en-us");
Locale locale = request.getLocale();
requestContext.setClientLang(locale.toLanguageTag().toLowerCase());
String token = null;
//优先使用cookie中的token
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals("ticket")) {
token = cookie.getValue();
}
if (cookie.getName().equals("lang")) {
requestContext.setClientCookieLang(cookie.getValue());
}
}
}
String authorization = request.getHeader("Authorization");
if (token == null && authorization != null && authorization.length() > 35) {//避免前端传递不符合token的字符串
token = authorization.replaceFirst("Basic", "").replaceFirst("Bearer", "").trim();
}
if (StringUtils.isNotBlank(token)) {
try {
log.debug("jwt token: {}", token);
UserDto userDto = authService.getLoginUserInfo(token);
if (userDto != null && userDto.getTenantId().equals(tenantId)) {
if (userDto.getStatus() == User.Status.Disabled || userDto.getStatus() == User.Status.Deleted) {
authService.expireUserAllToken(userDto.getId());
throw BizException.of(ErrorCodeEnum.PERMISSION_DENIED, BizExceptionCodeEnum.systemAccountDisabled);
}
var userContent = UserDto.convertToUserContext(userDto);
requestContext.setUserContext(userContent);
requestContext.setUser(userDto);
requestContext.setUserId(userDto.getId());
requestContext.setTenantId(userDto.getTenantId());
requestContext.setClientIp(IPUtil.getIpAddr(request));
requestContext.setLogin(true);
requestContext.setToken(token);
requestContext.setLangMap(userDto.getLangMap());
requestContext.setLang(userDto.getLang());
log.debug("JWT token OK, userId: {} ", userDto.getId());
if (originalRequestUri.startsWith("/api/system/")) {
//判断是不是管理员
if (userDto.getRole() != User.Role.Admin) {
throw BizException.of(ErrorCodeEnum.PERMISSION_DENIED, BizExceptionCodeEnum.permissionDenied);
}
}
String header = request.getHeader("X-Client-Type");
if (header != null || token.startsWith("ticket")) {
authService.renewToken(token);
} else {
int expire = (int) (tenantConfig.getAuthExpire() == null ? 86400 : tenantConfig.getAuthExpire() * 60);
Claims claims = JwtUtils.parseJwt(token, jwtSecretKey);
if (claims.getExpiration().getTime() < System.currentTimeMillis() + expire * 1000L * 0.8) {
//refresh token
String newToken = authService.refreshToken(token);
Cookie cookie = new Cookie("ticket", newToken);
cookie.setMaxAge(tenantConfig.getAuthExpire() == null ? 86400 * 30 : tenantConfig.getAuthExpire().intValue() * 60);
cookie.setPath("/");
response.addCookie(cookie);
requestContext.setToken(newToken);
}
}
return true;
}
log.debug("jwt token expired: {}", token);
} catch (Exception e) {
if (e instanceof BizException) {
throw e;
}
log.warn("JWT token error {}", e.getMessage());
}
}
log.debug("jwt token is null, check uri: {}", originalRequestUri);
try {
checkAndThrowAuthException(originalRequestUri);
} catch (Exception e) {
if (StringUtils.isNotBlank(referer)) {
throw BizException.of(ErrorCodeEnum.UNAUTHORIZED_REDIRECT, BizExceptionCodeEnum.bootstrapAuthRedirectUrl,
"/login?redirect=" + URLEncoder.encode(referer, StandardCharsets.UTF_8));
}
throw e;
}
if (requestContext.getLangMap() == null) {
String lang = requestContext.getClientCookieLang();
if (lang == null) {
I18nLangDto aDefault = i18nLangApplicationService.getDefault(tenantId);
if (aDefault != null) {
lang = aDefault.getLang();
} else {
lang = requestContext.getClientLang();
}
}
requestContext.setLang(lang);
requestContext.setLangMap(i18nApplicationService.querySystemLangMap(tenantId, I18nSideEnum.Backend.getSide(), lang));
}
return true;
}
private String getOriginalRequestUri(HttpServletRequest request) {
Object val = request.getAttribute(SandboxRequestAttributes.ORIGINAL_REQUEST_URI);
if (val instanceof String s && !s.isEmpty()) {
return s;
}
return request.getRequestURI();
}
private void checkLicense() {
if (StringUtils.isNotBlank(license)) {
String expireDate;
try {
String hexKey = "a0189de032115b6b7031a8fb5194ed68b3c716d3e09c21592fe4187b4586048b"; // 256位
String hexIV = "0ac97ac7d1081ebae42bfd987c5739eb";
expireDate = AESCrypto.decryptWithHexKey(license, hexKey, hexIV);
} catch (Exception e) {
log.error("license error: {}", e.getMessage());
throw BizException.of(ErrorCodeEnum.LICENSE_CONTENT_INVALID, BizExceptionCodeEnum.bootstrapLicenseContentInvalid);
}
if (parseToTimestamp(expireDate) * 1000L < System.currentTimeMillis()) {
throw BizException.of(ErrorCodeEnum.UNAUTHORIZED_REDIRECT, BizExceptionCodeEnum.bootstrapAuthRedirectUrl, "/license-expired");
}
}
}
public static long parseToTimestamp(String expireDate) {
if (expireDate == null || expireDate.isEmpty()) return -1;
try {
// 标准化分隔符
String normalized = expireDate
.replace('/', '-')
.replace('.', '-');
// 处理 8 位纯数字
if (normalized.matches("\\d{8}")) {
return LocalDate.parse(normalized, DateTimeFormatter.BASIC_ISO_DATE)
.atStartOfDay(ZoneId.systemDefault()).toEpochSecond();
}
// 处理带 T 和时区的 ISO 格式
if (normalized.contains("T")) {
if (normalized.endsWith("Z")) {
return Instant.parse(normalized).getEpochSecond();
}
return OffsetDateTime.parse(normalized, DateTimeFormatter.ISO_OFFSET_DATE_TIME)
.toInstant().getEpochSecond();
}
// 处理日期 + 时间(空格分隔)
if (normalized.contains(" ")) {
return LocalDateTime.parse(normalized,
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
.atZone(ZoneId.systemDefault()).toEpochSecond();
}
// 仅日期
return LocalDate.parse(normalized, DateTimeFormatter.ISO_LOCAL_DATE)
.atStartOfDay(ZoneId.systemDefault()).toEpochSecond();
} catch (Exception e) {
return -1;
}
}
private boolean isExcludedPath(String uri) {
List<String> excludePath = userAuthProperties.getExcludePath();
for (String ignoreLoginUri : excludePath) {
if (uri.startsWith(ignoreLoginUri)) {
return true;
}
}
return false;
}
private void checkAndThrowAuthException(String uri) {
if (isExcludedPath(uri)) {
return;
}
throw BizException.of(HttpStatusEnum.UNAUTHORIZED, ErrorCodeEnum.UNAUTHORIZED,
BizExceptionCodeEnum.systemUnauthorizedOrSessionExpired);
}
}

View File

@@ -0,0 +1,208 @@
package com.xspaceagi.interceptor;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONWriter;
import com.xspaceagi.log.sdk.service.ILogRpcService;
import com.xspaceagi.log.sdk.vo.LogDocument;
import com.xspaceagi.system.application.dto.UserDto;
import com.xspaceagi.system.infra.dao.entity.OpenApiDefinition;
import com.xspaceagi.system.sdk.service.dto.UserAccessKeyDto;
import com.xspaceagi.system.spec.common.RequestContext;
import com.xspaceagi.system.spec.dto.ReqResult;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.MDC;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.mvc.method.annotation.RequestBodyAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
import java.lang.reflect.Method;
import java.lang.reflect.Type;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* 请求参数记录
*
* @apiNote http前端后请求参数记录
*/
@Slf4j
@Component
@ControllerAdvice
public class HttpInterceptor implements HandlerInterceptor, ResponseBodyAdvice, RequestBodyAdvice {
@Value("${access.control.allow-origin}")
private String accessControlAllowOrigin;
@Autowired
private jakarta.servlet.http.HttpServletRequest request;
@Resource
private ILogRpcService iLogRpcService;
@Override
public boolean preHandle(jakarta.servlet.http.HttpServletRequest request,
jakarta.servlet.http.HttpServletResponse response, Object handler) {
String origin = request.getHeader("Origin");
// 如果配置是 "*" 或者 origin 包含配置的值,则允许跨域
boolean allowOrigin = StringUtils.isNotBlank(origin) &&
("*".equals(accessControlAllowOrigin) || origin.contains(accessControlAllowOrigin));
if (allowOrigin) {
response.setHeader("Vary", "Origin");
response.setHeader("Access-Control-Allow-Origin", origin);
response.setHeader("Access-Control-Allow-Methods", "HEAD,GET,POST,PUT,DELETE,OPTIONS");
response.setHeader("Access-Control-Allow-Headers",
"Origin, X-Requested-With, Content-Type, Accept, Authorization, Cache-Control, Fragment");
response.setHeader("Access-Control-Allow-Credentials", "true");
}
if (request.getMethod().equals("OPTIONS")) {
return true;
}
String uri = request.getRequestURI();
if (!(handler instanceof HandlerMethod)) {
return true;
}
long reqBeginTime = Instant.now().toEpochMilli();
// 请求开始时间
request.setAttribute("reqBeginTime", reqBeginTime);
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
request.setAttribute("methodName", method.getDeclaringClass().getName() + "." + method.getName());
log.info("Request URI {}, X-Client-Type {}", uri, request.getHeader("X-Client-Type"));
return true;
}
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
return true;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
if (!(body instanceof ReqResult httpResult)) {
return body;
}
ServletServerHttpRequest servletServerHttpRequest = (ServletServerHttpRequest) request;
jakarta.servlet.http.HttpServletRequest httpServletRequest = servletServerHttpRequest.getServletRequest();
Map<String, String[]> params = httpServletRequest.getParameterMap();
Map<String, Object> req = new HashMap<>();
req.put("parameter", params);
Object reqBody = this.request.getAttribute("requestBody");
if (null != reqBody) {
req.put("body", reqBody);
}
RequestContext<Object> objectRequestContext = RequestContext.get();
if (null != objectRequestContext && objectRequestContext.getUser() != null && objectRequestContext.getUser() instanceof UserDto userDto) {
Map<String, Object> user = new HashMap<>();
user.put("tenantId", userDto.getTenantId());
user.put("userId", userDto.getId());
user.put("userName", userDto.getUserName() == null ? userDto.getNickName() : userDto.getUserName());
req.put("user", user);
}
long reqBeginTime = (long) httpServletRequest.getAttribute("reqBeginTime");
String methodName = (String) httpServletRequest.getAttribute("methodName");
OpenApiDefinition openApiDefinition = (OpenApiDefinition) httpServletRequest.getAttribute("currentAPI");
UserAccessKeyDto userAccessKey = (UserAccessKeyDto) httpServletRequest.getAttribute("userAccessKey");
try {
if (log.isInfoEnabled() || openApiDefinition != null) {
// 启用 LargeObject 特性,支持序列化包含大文件内容的请求和响应体
String requestBodyStr = JSON.toJSONString(req, JSONWriter.Feature.LargeObject);
String responseBody = JSON.toJSONString(body, JSONWriter.Feature.LargeObject);
if (openApiDefinition != null) {
requestBodyStr = httpServletRequest.getMethod() + " " + httpServletRequest.getRequestURI() + "\n" + requestBodyStr;
}
buildAndSendLogDocument(openApiDefinition, userAccessKey, requestBodyStr, responseBody, reqBeginTime, httpResult);
log.info("HTTP API call log\nService {}\nRequest {}\nResponse {}\nElapsed {}ms", methodName, requestBodyStr.length() > 1024 * 8 ? requestBodyStr.substring(0, 1024 * 8) : requestBodyStr, responseBody.length() > 1024 * 8 ? responseBody.substring(0, 1024 * 8) : responseBody
, System.currentTimeMillis() - reqBeginTime);
}
httpResult.setTid(MDC.get("tid"));
return httpResult;
} catch (Exception e) {
log.error("Failed to record HTTP access log", e);
// 使用 LargeObject 特性序列化请求参数,避免大文件内容导致序列化失败
String paramsStr = JSON.toJSONString(params, JSONWriter.Feature.LargeObject);
log.error("HTTP API call log\nService {}\nRequest {}\nError {}\nElapsed {}ms", methodName, paramsStr.length() > 1024 * 8 ? paramsStr.substring(0, 1024 * 8) : paramsStr,
e.getMessage(), System.currentTimeMillis() - reqBeginTime);
throw e;
}
}
private void buildAndSendLogDocument(OpenApiDefinition openApiDefinition, UserAccessKeyDto userAccessKey, String requestBodyStr, String responseBody, Long reqBeginTime, ReqResult httpResult) {
if (openApiDefinition == null) {
return;
}
UserDto user = (UserDto) RequestContext.get().getUser();
LogDocument logDocument = LogDocument.builder()
.id(UUID.randomUUID().toString().replace("-", ""))
.tenantId(RequestContext.get().getTenantId())
.spaceId(-1L)
.userId(RequestContext.get().getUserId())
.userName(user.getUserName())
.targetType("ApiKey")
.targetName(userAccessKey.getName())
.targetId(userAccessKey.getId().toString())
.input(requestBodyStr)
.output(responseBody)
.requestStartTime(reqBeginTime)
.requestEndTime(System.currentTimeMillis())
.resultCode(httpResult.getCode())
.resultMsg(httpResult.getMessage())
.conversationId(openApiDefinition.getPath())
.requestId(MDC.get("tid"))
.createTime(System.currentTimeMillis())
.from("ApiKey")
.build();
iLogRpcService.bulkIndex(List.of(logDocument));
}
@Override
public boolean supports(MethodParameter methodParameter, Type targetType,
Class<? extends HttpMessageConverter<?>> converterType) {
return true;
}
@Override
public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType,
Class<? extends HttpMessageConverter<?>> converterType) {
return inputMessage;
}
@Override
public Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType,
Class<? extends HttpMessageConverter<?>> converterType) {
request.setAttribute("requestBody", body);
return body;
}
@Override
public Object handleEmptyBody(Object body, HttpInputMessage inputMessage, MethodParameter parameter,
Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
return body;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
throws Exception {
HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
}
}

View File

@@ -0,0 +1,44 @@
package com.xspaceagi.interceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import java.lang.reflect.Method;
import java.time.Instant;
import java.util.concurrent.ThreadLocalRandom;
/**
* tid ,获取拦截器
*
* @author soddy
*/
@Slf4j
@Component
public class TIdInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
Integer reqId = ThreadLocalRandom.current().nextInt(100, 999999);
MDC.put("tid", reqId + "" + Instant.now().toEpochMilli());
// 请求开始时间
request.setAttribute("reqBeginTime", Instant.now().toEpochMilli());
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
request.setAttribute("methodName", method.getDeclaringClass().getName() + "." + method.getName());
} else {
request.setAttribute("methodName", "静态资源未识别的方法");
}
return true;
}
}

View File

@@ -0,0 +1,24 @@
package com.xspaceagi.sandbox;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* Sandbox API URL 重写配置(全局生效)
*/
@Component
@ConfigurationProperties(prefix = "sandbox.api.rewrite")
@Getter
@Setter
public class SandboxApiRewriteProperties {
/**
* 允许重写的路径模式(相对于 /api/v1/4sandbox 之后的部分匹配)。
*/
private List<String> allowPath = new ArrayList<>();
}

View File

@@ -0,0 +1,233 @@
spring:
mvc:
pathmatch:
matching-strategy: ANT_PATH_MATCHER
servlet:
multipart:
max-file-size: 100MB
max-request-size: 100MB
# 使用 dynamic-datasource-spring-boot-starter 配置
datasource:
# 动态SQL生成器配置
sql-generator:
# 指定使用的数据库类型mysql 或 doris
type: mysql
dynamic:
# 设置默认的数据源或者数据源组, 默认值即为 master
primary: master
# 设置严格模式,默认 false. 严格模式下未匹配到数据源直接报错, 非严格模式下则使用默认数据源 primary 配置的数据源
strict: false
# p6spy 开关, 默认 false
p6spy: false
# seata 开关, 默认 false
seata: false
# 数据源配置信息
datasource:
# 主数据源 (MySQL)
master:
url: jdbc:mysql://${DB_HOST:localhost}:3306/${DB_NAME:agent_platform}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&useSSL=false
driver-class-name: com.mysql.cj.jdbc.Driver
username: ${DB_USERNAME:root}
password: ${DB_PASSWORD:your_mysql_password}
# 以下为 Druid 连接池配置 (如果使用)
druid:
initial-size: 5
min-idle: 5
max-active: 20
max-wait: 60000
time-between-eviction-runs-millis: 60000
min-evictable-idle-time-millis: 300000
validation-query: SELECT 1 FROM DUAL
test-while-idle: true
test-on-borrow: false
test-on-return: false
pool-prepared-statements: true
max-pool-prepared-statement-per-connection-size: 20
filters: stat # 启用 stat filter 用于监控
# Doris 数据源
doris:
url: jdbc:mysql://${DORIS_HOST:localhost}:3306/${DORIS_DB_NAME:agent_custom_table}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&useSSL=false
driver-class-name: com.mysql.cj.jdbc.Driver
username: ${DORIS_USERNAME:root}
password: ${DORIS_PASSWORD:your_doris_password}
# Doris 表配置
table:
replication-num: 1 # 副本数生产环境通常为3开发环境可以为1
bucket-num: 10 # 分桶数
duplicate-key: id # 重复键字段名,默认使用第一个字段
distributed-key: id # 分布键字段名,默认使用第一个字段
# 可选: 如果 Doris 也用 Druid可以添加 druid 配置
druid:
initial-size: 2
min-idle: 2
max-active: 10
max-wait: 60000
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:your_redis_password}
database: ${REDIS_DB:1}
lettuce:
pool:
max-idle: 16
max-active: 32
min-idle: 8
main:
allow-circular-references: true
log:
level: DEBUG
server:
port: 8081
jwt:
secretKey: ${JWT_SECRET_KEY:your_jwt_secret_key_minimum_32_characters}
file:
uploadFolder: ${FILE_UPLOAD_FOLDER:/tmp/uploads}
baseUrl: ${FILE_BASE_URL:https://your-file-domain.com/files/}
# Storage configuration
storage:
type: ${STORAGE_TYPE:local} # Storage type: local, cos(Tencent Cloud), oss(Alibaba Cloud), s3(S3 protocol/MinIO)
local:
upload:
folder: ${LOCAL_UPLOAD_FOLDER:/tmp/uploads}
# Tencent Cloud COS configuration (takes effect when storage.type=cos)
cos:
secret-id: ${COS_SECRET_ID:}
secret-key: ${COS_SECRET_KEY:}
region: ${COS_REGION:ap-chengdu}
bucket-name: ${COS_BUCKET_NAME:}
endpoint: ${COS_ENDPOINT:}
# Legacy configuration below
secretId: ${COS_SECRET_ID:}
secretKey: ${COS_SECRET_KEY:}
baseUrl: ${COS_BASE_URL:}
# Alibaba Cloud OSS configuration (takes effect when storage.type=oss)
oss:
endpoint: ${OSS_ENDPOINT:}
access-key-id: ${OSS_ACCESS_KEY_ID:}
access-key-secret: ${OSS_ACCESS_KEY_SECRET:}
region-name: ${OSS_REGION_NAME:cn-chengdu}
bucket-name: ${OSS_BUCKET_NAME:}
# S3 protocol configuration (takes effect when storage.type=s3, supports MinIO etc.)
s3:
endpoint: ${S3_ENDPOINT:}
access-key: ${S3_ACCESS_KEY:}
secret-key: ${S3_SECRET_KEY:}
bucket-name: ${S3_BUCKET_NAME:}
region: ${S3_REGION:us-east-1}
vector-store:
engine: milvus
milvus:
uri: ${MILVUS_URI:http://localhost:19530}
user: ${MILVUS_USER:root}
password: ${MILVUS_PASSWORD:your_milvus_password}
code:
execute:
url: ${CODE_EXECUTE_URL:http://localhost:8020/api/run_code_with_log}
# 日志模块配置
log-module:
log:
rust-service:
# Rust 日志服务的基础 URL
base-url: ${LOG_SERVICE_URL:http://localhost:8097}
# 连接超时时间(秒)
connect-timeout-seconds: 10
# 写入超时时间(秒)
write-timeout-seconds: 30
# 读取超时时间(秒)
read-timeout-seconds: 30
# 是否启用连接池
enable-connection-pool: true
# 最大空闲连接数
max-idle-connections: 5
# 连接保持存活时间(分钟)
keep-alive-duration-minutes: 5
search:
elasticsearch:
url: ${ES_URL:http://localhost:9200}
username: ${ES_USERNAME:elastic}
password: ${ES_PASSWORD:your_elasticsearch_password}
api_key: ${ES_API_KEY:}
# 生态市场模块配置
eco-market:
# 服务端配置
server:
# 远程server端的 ip端口配置
base-url: ${ECO_MARKET_SERVER_URL:https://test-agent-market-api.xspaceagi.com}
client:
# 客户端配置
retry:
#自动注册客户端到 server端, 重试间隔时间,单位:秒;
interval: 300
mcp:
proxy-base-url: ${MCP_PROXY_URL:http://localhost:8020}
knife4j:
# 开启增强配置
enable: true
# 标识是否生产环境true-生产环境关闭文档false-显示文档
production: false
custom-page:
http-proxy:
host: ${CUSTOM_PAGE_PROXY_HOST:0.0.0.0}
port: ${CUSTOM_PAGE_PROXY_PORT:18082}
dev-server-host: ${DEV_SERVER_HOST:http://localhost}
prod-server-host: ${PROD_SERVER_HOST:http://localhost:8099}
build-server:
base-url: ${BUILD_SERVER_URL:http://localhost:60000/api}
ai-agent:
base-url: ${AI_AGENT_URL:http://localhost:8086}
docker-proxy:
enable: ${DOCKER_PROXY_ENABLE:true}
base-url: ${DOCKER_PROXY_URL:http://localhost:8088}
computer:
proxy:
host: ${COMPUTER_PROXY_HOST:0.0.0.0}
port: ${COMPUTER_PROXY_PORT:18085}
supportCustomDomain: true
# 内外穿透相关服务配置
reverse:
server:
# 内部服务器配置
inner:
# 内部服务可访问地址,如果分布式部署,可以在每台主机上通过环境变量制定 SERVICE_HOST
service-host: ${SERVICE_HOST:127.0.0.1}
# 绑定主机地址,如果分布式部署,可以在每台主机上通过环境变量制定 BIND_HOST
bind-host: ${BIND_HOST:0.0.0.0}
# 内部服务器端口范围
ports: ${REVERSE_PORTS:30000-40000}
# 外部服务器配置
outer:
# 外部用于与客户端保持连接的服务器主机地址,多个用英文逗号隔开,例如 192.168.1.12,192.168.1.13,留空时使用平台服务器地址
host: ${OUTER_HOST:}
# 外部服务器端口
port: ${OUTER_PORT:10076}
model-api-proxy:
base-api-url: ${MODEL_API_BASE_URL:}
enable-model-proxy: ${MODEL_PROXY_ENABLE:true}
port: ${MODEL_PROXY_PORT:18086}
#记录日志
save-log: ${MODEL_PROXY_SAVE_LOG:true}

View File

@@ -0,0 +1,232 @@
spring:
mvc:
pathmatch:
matching-strategy: ANT_PATH_MATCHER
servlet:
multipart:
max-file-size: 100MB
max-request-size: 100MB
# 使用 dynamic-datasource-spring-boot-starter 配置
datasource:
# 动态SQL生成器配置
sql-generator:
# 指定使用的数据库类型mysql 或 doris
type: mysql
dynamic:
# 设置默认的数据源或者数据源组, 默认值即为 master
primary: master
# 设置严格模式,默认 false. 严格模式下未匹配到数据源直接报错, 非严格模式下则使用默认数据源 primary 配置的数据源
strict: false
# p6spy 开关, 默认 false
p6spy: false
# seata 开关, 默认 false
seata: false
# 数据源配置信息
datasource:
# 主数据源 (MySQL)
master:
url: jdbc:mysql://${DB_HOST:localhost}:3306/${DB_NAME:agent_platform}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&useSSL=false
driver-class-name: com.mysql.cj.jdbc.Driver
username: ${DB_USERNAME:root}
password: ${DB_PASSWORD:your_mysql_password}
# 以下为 Druid 连接池配置 (如果使用)
druid:
initial-size: 5
min-idle: 5
max-active: 20
max-wait: 60000
time-between-eviction-runs-millis: 60000
min-evictable-idle-time-millis: 300000
validation-query: SELECT 1 FROM DUAL
test-while-idle: true
test-on-borrow: false
test-on-return: false
pool-prepared-statements: true
max-pool-prepared-statement-per-connection-size: 20
filters: stat # 启用 stat filter 用于监控
# Doris 数据源
doris:
url: jdbc:mysql://${DORIS_HOST:localhost}:3306/${DORIS_DB_NAME:agent_custom_table}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&useSSL=false
driver-class-name: com.mysql.cj.jdbc.Driver
username: ${DORIS_USERNAME:root}
password: ${DORIS_PASSWORD:your_doris_password}
# Doris 表配置
table:
replication-num: 1 # 副本数生产环境通常为3开发环境可以为1
bucket-num: 10 # 分桶数
duplicate-key: id # 重复键字段名,默认使用第一个字段
distributed-key: id # 分布键字段名,默认使用第一个字段
# 可选: 如果 Doris 也用 Druid可以添加 druid 配置
druid:
initial-size: 2
min-idle: 2
max-active: 10
max-wait: 60000
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:your_redis_password}
database: ${REDIS_DB:1}
lettuce:
pool:
max-idle: 16
max-active: 32
min-idle: 8
main:
allow-circular-references: true
log:
level: DEBUG
server:
port: 8081
jwt:
secretKey: ${JWT_SECRET_KEY:your_jwt_secret_key_minimum_32_characters}
file:
uploadFolder: ${FILE_UPLOAD_FOLDER:/tmp/uploads}
baseUrl: ${FILE_BASE_URL:https://your-file-domain.com/files/}
# Storage configuration
storage:
type: ${STORAGE_TYPE:local} # Storage type: local, cos(Tencent Cloud), oss(Alibaba Cloud), s3(S3 protocol/MinIO)
local:
upload:
folder: ${LOCAL_UPLOAD_FOLDER:/tmp/uploads}
# Tencent Cloud COS configuration (takes effect when storage.type=cos)
cos:
secret-id: ${COS_SECRET_ID:}
secret-key: ${COS_SECRET_KEY:}
region: ${COS_REGION:ap-chengdu}
bucket-name: ${COS_BUCKET_NAME:}
endpoint: ${COS_ENDPOINT:}
# Legacy configuration below
secretId: ${COS_SECRET_ID:}
secretKey: ${COS_SECRET_KEY:}
baseUrl: ${COS_BASE_URL:}
# Alibaba Cloud OSS configuration (takes effect when storage.type=oss)
oss:
endpoint: ${OSS_ENDPOINT:}
access-key-id: ${OSS_ACCESS_KEY_ID:}
access-key-secret: ${OSS_ACCESS_KEY_SECRET:}
region-name: ${OSS_REGION_NAME:cn-chengdu}
bucket-name: ${OSS_BUCKET_NAME:}
# S3 protocol configuration (takes effect when storage.type=s3, supports MinIO etc.)
s3:
endpoint: ${S3_ENDPOINT:}
access-key: ${S3_ACCESS_KEY:}
secret-key: ${S3_SECRET_KEY:}
bucket-name: ${S3_BUCKET_NAME:}
region: ${S3_REGION:us-east-1}
vector-store:
engine: milvus
milvus:
uri: ${MILVUS_URI:http://localhost:19530}
user: ${MILVUS_USER:root}
password: ${MILVUS_PASSWORD:your_milvus_password}
code:
execute:
url: ${CODE_EXECUTE_URL:http://localhost:8020/api/run_code_with_log}
# 日志模块配置
log-module:
log:
rust-service:
# Rust 日志服务的基础 URL
base-url: ${LOG_SERVICE_URL:http://localhost:8097}
# 连接超时时间(秒)
connect-timeout-seconds: 10
# 写入超时时间(秒)
write-timeout-seconds: 30
# 读取超时时间(秒)
read-timeout-seconds: 30
# 是否启用连接池
enable-connection-pool: true
# 最大空闲连接数
max-idle-connections: 5
# 连接保持存活时间(分钟)
keep-alive-duration-minutes: 5
search:
elasticsearch:
url: ${ES_URL:http://localhost:9200}
username: ${ES_USERNAME:elastic}
password: ${ES_PASSWORD:your_elasticsearch_password}
api_key: ${ES_API_KEY:}
# 生态市场模块配置
eco-market:
# 服务端配置
server:
# 远程server端的 ip端口配置
base-url: ${ECO_MARKET_SERVER_URL:https://agent-market-api.xspaceagi.com}
client:
# 客户端配置
retry:
#自动注册客户端到 server端, 重试间隔时间,单位:秒;
interval: 300
mcp:
proxy-base-url: ${MCP_PROXY_URL:http://localhost:8020}
knife4j:
# 开启增强配置
enable: true
# 标识是否生产环境true-生产环境关闭文档false-显示文档
production: false
custom-page:
http-proxy:
host: ${CUSTOM_PAGE_PROXY_HOST:0.0.0.0}
port: ${CUSTOM_PAGE_PROXY_PORT:18082}
dev-server-host: ${DEV_SERVER_HOST:http://localhost}
prod-server-host: ${PROD_SERVER_HOST:http://localhost:8099}
build-server:
base-url: ${BUILD_SERVER_URL:http://localhost:60000/api}
ai-agent:
base-url: ${AI_AGENT_URL:http://localhost:8086}
docker-proxy:
enable: ${DOCKER_PROXY_ENABLE:true}
base-url: ${DOCKER_PROXY_URL:http://localhost:8088}
computer:
proxy:
host: ${COMPUTER_PROXY_HOST:0.0.0.0}
port: ${COMPUTER_PROXY_PORT:18085}
supportCustomDomain: true
# 内外穿透相关服务配置
reverse:
server:
# 内部服务器配置
inner:
# 内部服务可访问地址,如果分布式部署,可以在每台主机上通过环境变量制定 SERVICE_HOST
service-host: ${SERVICE_HOST:127.0.0.1}
# 绑定主机地址,如果分布式部署,可以在每台主机上通过环境变量制定 BIND_HOST
bind-host: ${BIND_HOST:0.0.0.0}
# 内部服务器端口范围
ports: ${REVERSE_PORTS:30000-40000}
# 外部服务器配置
outer:
# 外部用于与客户端保持连接的服务器主机地址,多个用英文逗号隔开,例如 192.168.1.12,192.168.1.13,留空时使用平台服务器地址
host: ${OUTER_HOST:}
# 外部服务器端口
port: ${OUTER_PORT:10076}
model-api-proxy:
base-api-url: ${MODEL_API_BASE_URL:}
enable-model-proxy: ${MODEL_PROXY_ENABLE:true}
port: ${MODEL_PROXY_PORT:18086}
#记录日志
save-log: ${MODEL_PROXY_SAVE_LOG:true}

View File

@@ -0,0 +1,232 @@
spring:
mvc:
pathmatch:
matching-strategy: ANT_PATH_MATCHER
servlet:
multipart:
max-file-size: 100MB
max-request-size: 100MB
# 使用 dynamic-datasource-spring-boot-starter 配置
datasource:
# 动态SQL生成器配置
sql-generator:
# 指定使用的数据库类型mysql 或 doris
type: mysql
dynamic:
# 设置默认的数据源或者数据源组, 默认值即为 master
primary: master
# 设置严格模式,默认 false. 严格模式下未匹配到数据源直接报错, 非严格模式下则使用默认数据源 primary 配置的数据源
strict: false
# p6spy 开关, 默认 false
p6spy: false
# seata 开关, 默认 false
seata: false
# 数据源配置信息
datasource:
# 主数据源 (MySQL)
master:
url: jdbc:mysql://${DB_HOST:localhost}:3306/${DB_NAME:agent_platform}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&useSSL=false
driver-class-name: com.mysql.cj.jdbc.Driver
username: ${DB_USERNAME:root}
password: ${DB_PASSWORD:your_mysql_password}
# 以下为 Druid 连接池配置 (如果使用)
druid:
initial-size: 5
min-idle: 5
max-active: 20
max-wait: 60000
time-between-eviction-runs-millis: 60000
min-evictable-idle-time-millis: 300000
validation-query: SELECT 1 FROM DUAL
test-while-idle: true
test-on-borrow: false
test-on-return: false
pool-prepared-statements: true
max-pool-prepared-statement-per-connection-size: 20
filters: stat # 启用 stat filter 用于监控
# Doris 数据源
doris:
url: jdbc:mysql://${DORIS_HOST:localhost}:3306/${DORIS_DB_NAME:agent_custom_table}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&useSSL=false
driver-class-name: com.mysql.cj.jdbc.Driver
username: ${DORIS_USERNAME:root}
password: ${DORIS_PASSWORD:your_doris_password}
# Doris 表配置
table:
replication-num: 1 # 副本数生产环境通常为3开发环境可以为1
bucket-num: 10 # 分桶数
duplicate-key: id # 重复键字段名,默认使用第一个字段
distributed-key: id # 分布键字段名,默认使用第一个字段
# 可选: 如果 Doris 也用 Druid可以添加 druid 配置
druid:
initial-size: 2
min-idle: 2
max-active: 10
max-wait: 60000
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:your_redis_password}
database: ${REDIS_DB:1}
lettuce:
pool:
max-idle: 16
max-active: 32
min-idle: 8
main:
allow-circular-references: true
log:
level: DEBUG
server:
port: 8081
jwt:
secretKey: ${JWT_SECRET_KEY:your_jwt_secret_key_minimum_32_characters}
file:
uploadFolder: ${FILE_UPLOAD_FOLDER:/tmp/uploads}
baseUrl: ${FILE_BASE_URL:https://your-file-domain.com/files/}
# Storage configuration
storage:
type: ${STORAGE_TYPE:local} # Storage type: local, cos(Tencent Cloud), oss(Alibaba Cloud), s3(S3 protocol/MinIO)
local:
upload:
folder: ${LOCAL_UPLOAD_FOLDER:/tmp/uploads}
# Tencent Cloud COS configuration (takes effect when storage.type=cos)
cos:
secret-id: ${COS_SECRET_ID:}
secret-key: ${COS_SECRET_KEY:}
region: ${COS_REGION:ap-chengdu}
bucket-name: ${COS_BUCKET_NAME:}
endpoint: ${COS_ENDPOINT:}
# Legacy configuration below
secretId: ${COS_SECRET_ID:}
secretKey: ${COS_SECRET_KEY:}
baseUrl: ${COS_BASE_URL:}
# Alibaba Cloud OSS configuration (takes effect when storage.type=oss)
oss:
endpoint: ${OSS_ENDPOINT:}
access-key-id: ${OSS_ACCESS_KEY_ID:}
access-key-secret: ${OSS_ACCESS_KEY_SECRET:}
region-name: ${OSS_REGION_NAME:cn-chengdu}
bucket-name: ${OSS_BUCKET_NAME:}
# S3 protocol configuration (takes effect when storage.type=s3, supports MinIO etc.)
s3:
endpoint: ${S3_ENDPOINT:}
access-key: ${S3_ACCESS_KEY:}
secret-key: ${S3_SECRET_KEY:}
bucket-name: ${S3_BUCKET_NAME:}
region: ${S3_REGION:us-east-1}
vector-store:
engine: milvus
milvus:
uri: ${MILVUS_URI:http://localhost:19530}
user: ${MILVUS_USER:root}
password: ${MILVUS_PASSWORD:your_milvus_password}
code:
execute:
url: ${CODE_EXECUTE_URL:http://localhost:8020/api/run_code_with_log}
# 日志模块配置
log-module:
log:
rust-service:
# Rust 日志服务的基础 URL
base-url: ${LOG_SERVICE_URL:http://localhost:8097}
# 连接超时时间(秒)
connect-timeout-seconds: 10
# 写入超时时间(秒)
write-timeout-seconds: 30
# 读取超时时间(秒)
read-timeout-seconds: 30
# 是否启用连接池
enable-connection-pool: true
# 最大空闲连接数
max-idle-connections: 5
# 连接保持存活时间(分钟)
keep-alive-duration-minutes: 5
search:
elasticsearch:
url: ${ES_URL:http://localhost:9200}
username: ${ES_USERNAME:elastic}
password: ${ES_PASSWORD:your_elasticsearch_password}
api_key: ${ES_API_KEY:}
# 生态市场模块配置
eco-market:
# 服务端配置
server:
# 远程server端的 ip端口配置
base-url: ${ECO_MARKET_SERVER_URL:https://test-agent-market-api.xspaceagi.com}
client:
# 客户端配置
retry:
#自动注册客户端到 server端, 重试间隔时间,单位:秒;
interval: 300
mcp:
proxy-base-url: ${MCP_PROXY_URL:http://localhost:8020}
knife4j:
# 开启增强配置
enable: true
# 标识是否生产环境true-生产环境关闭文档false-显示文档
production: false
custom-page:
http-proxy:
host: ${CUSTOM_PAGE_PROXY_HOST:0.0.0.0}
port: ${CUSTOM_PAGE_PROXY_PORT:18082}
dev-server-host: ${DEV_SERVER_HOST:http://localhost}
prod-server-host: ${PROD_SERVER_HOST:http://localhost:8099}
build-server:
base-url: ${BUILD_SERVER_URL:http://localhost:60000/api}
ai-agent:
base-url: ${AI_AGENT_URL:http://localhost:8086}
docker-proxy:
enable: ${DOCKER_PROXY_ENABLE:true}
base-url: ${DOCKER_PROXY_URL:http://localhost:8088}
computer:
proxy:
host: ${COMPUTER_PROXY_HOST:0.0.0.0}
port: ${COMPUTER_PROXY_PORT:18085}
supportCustomDomain: true
# 内外穿透相关服务配置
reverse:
server:
# 内部服务器配置
inner:
# 内部服务可访问地址,如果分布式部署,可以在每台主机上通过环境变量制定 SERVICE_HOST
service-host: ${SERVICE_HOST:127.0.0.1}
# 绑定主机地址,如果分布式部署,可以在每台主机上通过环境变量制定 BIND_HOST
bind-host: ${BIND_HOST:0.0.0.0}
# 内部服务器端口范围
ports: ${REVERSE_PORTS:30000-40000}
# 外部服务器配置
outer:
# 外部用于与客户端保持连接的服务器主机地址,多个用英文逗号隔开,例如 192.168.1.12,192.168.1.13,留空时使用平台服务器地址
host: ${OUTER_HOST:}
# 外部服务器端口
port: ${OUTER_PORT:10076}
model-api-proxy:
base-api-url: ${MODEL_API_BASE_URL:}
enable-model-proxy: ${MODEL_PROXY_ENABLE:true}
port: ${MODEL_PROXY_PORT:18086}
#记录日志
save-log: ${MODEL_PROXY_SAVE_LOG:true}

View File

@@ -0,0 +1,134 @@
spring:
application:
name: app-platform
profiles:
active: @activatedProperties@
# 默认为false。如果开启的话在事务真正物理提交doCommit失败后会进行回滚。
transaction:
rollback-on-commit-failure: true
# [可选]配置,自行判断是否使用Spring Cache注解(@Cacheable 等)选项,如果不启用,在启动类上注释: @EnableCaching
cache:
type: CAFFEINE
#应用程序启动创建缓存的名称,必须将所有注释为@Cacheable缓存name或value罗列在这里
cache-names:
- queryOrgTree
- getUserInfo
- tableTotalCache
caffeine:
spec: "maximumSize=2000,expireAfterAccess=30s"
# 排除DruidDataSourceAutoConfigure,使用dynamic-datasource-spring-boot3-starter
# autoconfigure:
# exclude: com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceAutoConfigure
server:
compression:
enabled: true
min-response-size: 2KB
mime-types: application/json,application/xml,text/html,text/xml,text/plain,text/css,text/javascript,application/javascript
tomcat:
# 设置POST请求大小限制0表示不限制
max-http-form-post-size: 0
# 设置URI编码为UTF-8支持中文等非ASCII字符
uri-encoding: UTF-8
logging:
config: classpath:logback-custom.xml
#允许跨域的根域名
access:
control:
allow-origin: "*"
#无需检测登录的接口,前缀匹配
auth:
excludePath:
- /api/user/login
- /health
- /ready
- /favicon.ico
- /doc.html
- /webjars/**
- /v3/**
- /api/user/login
- /api/user/passwordLogin
- /api/user/codeLogin
- /api/user/mp/codeLogin
- /api/user/code/send
- /api/user/email/check
- /api/user/password/reset
- /api/tenant/config
- /api/system/eco/market/server
- /api/temp/chat/conversation
- /api/temp/chat/completions
- /api/logo/
- /api/file
- /api/f/
- /api/mcp/sse
- /api/mcp/message
- /api/v1/
- /api/ticket/redirect
- /api/audio
- /api/page/workflow
- /api/page/plugin
- /api/published/category
- /api/published/agent/list
- /api/computer/static
- /api/agent/conversation/share/detail
- /api/sys/v
- /api/sandbox/config/reg
- /api/sandbox/config/health/
- /api/sandbox/config/redirect/
# IM三方回调免登录
- /api/im/feishu
- /api/im/dingtalk
- /api/im/wework
- /api/i18n/
# DataSource Config
mybatis-plus:
mapper-locations: classpath*:/mapper/*.xml
tenant:
ignoreTenantTables:
- tenant
- pf_retry_data
- system_config
- card
- knowledge_task
- knowledge_task_history
- schedule_task
- eco_market_server_secret
- eco_market_server_config
- eco_market_server_publish_config
- eco_market_client_secret
# 扫描此包下pojo类生成短别名,在xml中可以使用类名,不用输全路径类名
type-aliases-package: com.xspaceagi.**.infra.dao.entity
# 此包下的类会由SqlSessionFactoryBean注册成TypeHandler
type-handlers-package:
global-config:
# 数据库配置
db-config:
# 默认值是ID_WORKER(框架生成的分布式唯一ID,Long型),其他选项AUTO(数据库自增),INPUT(自定义),UUID(框架生成uuid),ID_WORKER_STR(框架生成的分布式唯一ID,String型)
id-type: auto
# 字段策略涉及insert,update及wrapper内部的entity属性生成的where条件;有4个选项IGNORED(不判断),NOT_NULL(非null即可),NOT_EMPTY(非null且非空串),DEFAULT(非null即可)
# 比如User对象name是""空串,调用insert接口,IGNORED会插入空串,NOT_NULL会插入空串,NOT_EMPTY不会插入name字段
where-strategy: NOT_EMPTY
# qiming-cli、saas、manual
installation-source: saas
app:
version: 1.0.8.3
stt:
api:
base-url: https://stt-api.qiming.com
sandbox:
api:
rewrite:
allow-path:
- "skill/**"
- "publish/apply"
- "im/wechat/push-message"

View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration debug="true">
<springProperty name="LOG_LEVEL" scope="context" source="log.level"/>
<springProperty name="DEV_MODE" scope="context" source="spring.profiles.active"/>
<!-- 通过 logging.file.path 配置日志输出路径,默认输出到相对路径 logs/ -->
<springProperty name="logPath" scope="context" source="logging.file.path" defaultValue="logs"/>
<property name="outPattern"
value="[%-5p] %d{yyyy-MM-dd HH:mm:ss.SSS} [%t] [%c] [%X{tid}] [%X{prefix}] - %m%n"/>
<property name="maxHistory" value="7"/>
<property name="charSet" value="UTF-8"/>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${outPattern}</pattern>
</encoder>
</appender>
<appender name="APP_LOG_FILE"
class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${logPath}/app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<FileNamePattern>${logPath}/app.log.%d{yyyy-MM-dd}
</FileNamePattern>
<maxHistory>${maxHistory}</maxHistory>
</rollingPolicy>
<encoder>
<Pattern>${outPattern}</Pattern>
<charset>${charSet}</charset>
</encoder>
</appender>
<!-- 如果是可以容忍重启时丢失的应用日志,可以使用异步日志输出,大幅提升日志性能 -->
<appender name="ASYNC_APP_LOG_FILE" class="ch.qos.logback.classic.AsyncAppender">
<!-- 建议不丢失日志设置为0 -->
<discardingThreshold>0</discardingThreshold>
<!-- 更改默认的队列的长度该值会影响性能默认值为256 -->
<queueSize>512</queueSize>
<appender-ref ref="APP_LOG_FILE"/>
</appender>
<logger name="org.springframework" level="ERROR"/>
<logger name="org.apache.zookeeper" level="ERROR"/>
<logger name="com.baomidou" level="ERROR"/>
<logger name="org.mybatis.spring" level="ERROR"/>
<logger name="com.xspaceagi.system.infra.dao.mapper" level="ERROR"/>
<logger name="com.xspaceagi.agent.core.infra.dao.mapper" level="ERROR"/>
<logger name="io.lettuce.core" level="ERROR"/>
<logger name="org.redisson" level="ERROR"/>
<logger name="com.alibaba" level="ERROR"/>
<logger name="com.xspaceagi.system.domain.service.impl.ScheduleTaskDomainServiceImpl" level="ERROR"/>
<!-- 缺省应用日志输出 -->
<root level="${LOG_LEVEL}">
<if condition='"${DEV_MODE}".equals("dev")'>
<then>
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ASYNC_APP_LOG_FILE"/>
</then>
</if>
<if condition='"${DEV_MODE}".equals("prod")'>
<then>
<appender-ref ref="ASYNC_APP_LOG_FILE"/>
</then>
</if>
<if condition='"${DEV_MODE}".equals("test")'>
<then>
<appender-ref ref="ASYNC_APP_LOG_FILE"/>
</then>
</if>
</root>
</configuration>

View File

@@ -0,0 +1,67 @@
package com.xspaceagi.compose.api.service;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import com.alibaba.fastjson2.JSON;
import com.xspaceagi.PlatformApiApplication;
import com.xspaceagi.compose.sdk.DorisDataPage;
import com.xspaceagi.compose.sdk.request.DorisTableDefineRequest;
import com.xspaceagi.compose.sdk.request.QueryDorisTableDefinePageRequest;
import com.xspaceagi.compose.sdk.service.IComposeDbTableRpcService;
import com.xspaceagi.compose.sdk.vo.define.TableDefineVo;
import com.xspaceagi.system.spec.common.RequestContext;
import lombok.extern.slf4j.Slf4j;
@SpringBootTest(classes = PlatformApiApplication.class)
@Slf4j
public class ComposeDbTableRpcServiceImplQueryTableDefinitionTest {
@Autowired
private IComposeDbTableRpcService composeDbTableRpcService;
@Test
void testQueryTableDefinition() {
// 设置租户上下文,防止 MyBatis Plus 多租户插件报错
try {
RequestContext.setThreadTenantId(1L);
// RequestContext.get().setUserId(6L);
DorisTableDefineRequest request = new DorisTableDefineRequest();
request.setTableId(28L); // 请根据实际测试表ID设置
TableDefineVo definitionVo = composeDbTableRpcService.queryTableDefinition(request);
log.info("definitionVo:{}", JSON.toJSONString(definitionVo));
assertNotNull(definitionVo);
// 可根据实际业务补充更多断言
} finally {
RequestContext.remove();
}
}
@Test
void testQueryTableDefineBySpaceId() {
try {
RequestContext.setThreadTenantId(1L);
QueryDorisTableDefinePageRequest request = QueryDorisTableDefinePageRequest.builder()
.spaceId(37L) // 请根据实际测试空间ID设置
.pageNo(1)
.pageSize(10)
.build();
DorisDataPage<TableDefineVo> pageResult = composeDbTableRpcService.queryTableDefineBySpaceId(request);
log.info("pageResult:{}", JSON.toJSONString(pageResult));
assertNotNull(pageResult, "分页结果不应为null");
assertNotNull(pageResult.getRecords(), "记录列表不应为null");
// 可根据实际业务补充更多断言,比如校验返回数量、内容等
} finally {
RequestContext.remove();
}
}
}

View File

@@ -0,0 +1,322 @@
package com.xspaceagi.compose.api.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import com.alibaba.fastjson2.JSON;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import com.xspaceagi.PlatformApiApplication;
import com.xspaceagi.compose.sdk.request.DorisTableDataRequest;
import com.xspaceagi.compose.sdk.request.DorisTableDefineRequest;
import com.xspaceagi.compose.sdk.response.DorisTableDataResponse;
import com.xspaceagi.compose.sdk.vo.define.CreateTableDefineVo;
import com.xspaceagi.system.spec.common.RequestContext;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@SpringBootTest(classes = PlatformApiApplication.class)
public class ComposeDbTableRpcServiceImplTest {
@Autowired
private ComposeDbTableRpcServiceImpl composeDbTableRpcService;
@Test
void testQueryTableData_update() {
// 设置租户上下文,防止 MyBatis Plus 多租户插件报错
try {
RequestContext.setThreadTenantId(1L);
// 按照示例构造请求参数
DorisTableDataRequest request = new DorisTableDataRequest();
request.setTableId(28L);
request.setSql(
"UPDATE custom_table_table_name SET agent_name={{agent_name}} WHERE agent_id={{agent_id}} limit 100");
java.util.Map<String, Object> args = new java.util.HashMap<>();
args.put("agent_id", "agent_001");
args.put("agent_name", "智能体测试");
request.setArgs(args);
java.util.Map<String, Object> extArgs = new java.util.HashMap<>();
extArgs.put("uid", "6");
request.setExtArgs(extArgs);
DorisTableDataResponse response = composeDbTableRpcService.queryTableData(request);
log.info("update response: {}", JSON.toJSONString(response));
assertNotNull(response);
// 可根据实际数据断言返回内容
} finally {
RequestContext.remove();
}
}
@Test
void testQueryTableData_WithNullTableId() {
// 设置租户上下文,防止 MyBatis Plus 多租户插件报错
try {
RequestContext.setThreadTenantId(1L);
DorisTableDataRequest request = new DorisTableDataRequest();
// 设置空的tableId
request.setTableId(null);
// 验证异常抛出
assertThrows(RuntimeException.class, () -> {
composeDbTableRpcService.queryTableData(request);
});
} finally {
RequestContext.remove();
}
}
@Test
void testInsertCustomTable28() {
// 设置租户上下文,防止 MyBatis Plus 多租户插件报错
try {
RequestContext.setThreadTenantId(1L);
DorisTableDataRequest insertRequest = new DorisTableDataRequest();
insertRequest.setTableId(28L);
insertRequest.setSql(
"INSERT INTO custom_table_table_name ( agent_id, agent_name, nick_name, uid, user_name, created) VALUES ( 'agent_002', '测试智能体2', '测试昵称2', 'user_12346', 'user2', NOW())");
// insert 一般不需要 args/extArgs直接写死
DorisTableDataResponse response = composeDbTableRpcService.queryTableData(insertRequest);
log.info("insert response: {}", JSON.toJSONString(response));
assertNotNull(response);
// 可根据 insert 返回内容断言
} finally {
RequestContext.remove();
}
}
@Test
void testSelectCustomTable28() {
// 设置租户上下文,防止 MyBatis Plus 多租户插件报错
try {
RequestContext.setThreadTenantId(1L);
DorisTableDataRequest selectRequest = new DorisTableDataRequest();
selectRequest.setTableId(28L);
selectRequest.setSql("SELECT * FROM custom_table_table_name WHERE uid='user_12346'");
DorisTableDataResponse response = composeDbTableRpcService.queryTableData(selectRequest);
log.info("insert response: {}", JSON.toJSONString(response));
assertNotNull(response);
// 可断言返回数据包含 user_12346
} finally {
RequestContext.remove();
}
}
@Test
void testSelectLikeCustomTable28() {
// 设置租户上下文,防止 MyBatis Plus 多租户插件报错
try {
RequestContext.setThreadTenantId(1L);
DorisTableDataRequest selectRequest = new DorisTableDataRequest();
selectRequest.setTableId(28L);
Map<String, Object> args = new HashMap<>();
args.put("team_name", "user");
selectRequest.setArgs(args);
selectRequest.setSql("SELECT * FROM custom_table WHERE user_name LIKE '%${{team_name}}%'");
DorisTableDataResponse response = composeDbTableRpcService.queryTableData(selectRequest);
log.info("insert response: {}", JSON.toJSONString(response));
assertNotNull(response);
// 可断言返回数据包含 user_12346
} finally {
RequestContext.remove();
}
}
@Test
void testSelectCustomTable28_withLimit() {
// 设置租户上下文,防止 MyBatis Plus 多租户插件报错
try {
RequestContext.setThreadTenantId(1L);
DorisTableDataRequest selectRequest = new DorisTableDataRequest();
selectRequest.setTableId(28L);
selectRequest.setSql("SELECT * FROM custom_table_table_name WHERE uid='user_12346' limit 100");
DorisTableDataResponse response = composeDbTableRpcService.queryTableData(selectRequest);
log.info("insert response: {}", JSON.toJSONString(response));
assertNotNull(response);
// 可断言返回数据包含 user_12346
} finally {
RequestContext.remove();
}
}
@Test
void testDeleteCustomTable28() {
// 设置租户上下文,防止 MyBatis Plus 多租户插件报错
try {
RequestContext.setThreadTenantId(1L);
DorisTableDataRequest deleteRequest = new DorisTableDataRequest();
deleteRequest.setTableId(28L);
deleteRequest.setSql("DELETE FROM custom_table_table_name WHERE id=1002");
DorisTableDataResponse response = composeDbTableRpcService.queryTableData(deleteRequest);
assertNotNull(response);
// 可根据 delete 返回内容断言
} finally {
RequestContext.remove();
}
}
@Test
void testSelectCustomTable28WithJoin() {
// 设置租户上下文,防止 MyBatis Plus 多租户插件报错
try {
RequestContext.setThreadTenantId(1L);
DorisTableDataRequest joinRequest = new DorisTableDataRequest();
joinRequest.setTableId(28L); // 这里可用主表ID
joinRequest.setSql(
"SELECT a.*, b.nick_name AS b_nick_name FROM custom_table_table_name a LEFT JOIN custom_table_table_name b ON a.uid = b.uid WHERE a.uid='user_12346'");
DorisTableDataResponse response = composeDbTableRpcService.queryTableData(joinRequest);
log.info("SELECT response: {}", JSON.toJSONString(response));
assertNotNull(response);
// 可断言返回数据包含 user_12346 及 b_nick_name 字段
} finally {
RequestContext.remove();
}
}
@Test
void testSelectCustomTable28WithJoin_ListParams() {
// 设置租户上下文,防止 MyBatis Plus 多租户插件报错
try {
RequestContext.setThreadTenantId(1L);
DorisTableDataRequest joinRequest = new DorisTableDataRequest();
joinRequest.setTableId(28L); // 这里可用主表ID
var uidList = Arrays.asList("user_12346", "user_12347");
Map<String, Object> args = new HashMap<>();
args.put("uidList", uidList);
joinRequest.setArgs(args);
// 使用{{uidList}} 占位符, 替换sql中的参数,后面使用uidList 列表需要解析成英文逗号分割
joinRequest.setSql(
"SELECT a.*, b.nick_name AS b_nick_name FROM custom_table_table_name a LEFT JOIN custom_table_table_name b ON a.uid = b.uid WHERE a.uid in ({{uidList}})");
DorisTableDataResponse response = composeDbTableRpcService.queryTableData(joinRequest);
log.info("SELECT response: {}", JSON.toJSONString(response));
assertNotNull(response);
assertNotNull(response.getData());
// 可断言返回数据包含 user_12346 及 b_nick_name 字段
} finally {
RequestContext.remove();
}
}
@Test
void testBatchInsertCustomTable28() {
// 设置租户上下文,防止 MyBatis Plus 多租户插件报错
try {
RequestContext.setThreadTenantId(1L);
DorisTableDataRequest batchInsertRequest = new DorisTableDataRequest();
batchInsertRequest.setTableId(28L);
batchInsertRequest.setSql(
"INSERT INTO custom_table_table_name ( agent_id, agent_name, nick_name, uid, user_name, created) VALUES "
+
"( 'agent_003', '测试智能体3', '测试昵称3', 'user_12347', 'user3', NOW()), " +
"( 'agent_004', '测试智能体4', '测试昵称4', 'user_12348', 'user4', NOW()), " +
"( 'agent_005', '测试智能体5', '测试昵称5', 'user_12349', 'user5', NOW())");
DorisTableDataResponse response = composeDbTableRpcService.queryTableData(batchInsertRequest);
log.info("batch insert response: {}", JSON.toJSONString(response));
assertNotNull(response);
// 验证批量插入的行数应该为3
assertEquals(3L, response.getRowNum());
} finally {
RequestContext.remove();
}
}
@Test
void testBatchInsertWithSemicolonCustomTable28() {
// 设置租户上下文,防止 MyBatis Plus 多租户插件报错
try {
RequestContext.setThreadTenantId(1L);
DorisTableDataRequest batchInsertRequest = new DorisTableDataRequest();
batchInsertRequest.setTableId(28L);
batchInsertRequest.setSql(
"INSERT INTO custom_table_table_name ( agent_id, agent_name, nick_name, uid, user_name, created) VALUES ( 'agent_006', '测试智能体6', '测试昵称6', 'user_12350', 'user6', NOW());"
+
"INSERT INTO custom_table_table_name ( agent_id, agent_name, nick_name, uid, user_name, created) VALUES ( 'agent_007', '测试智能体7', '测试昵称7', 'user_12351', 'user7', NOW());"
+
"INSERT INTO custom_table_table_name ( agent_id, agent_name, nick_name, uid, user_name, created) VALUES ( 'agent_008', '测试智能体8', '测试昵称8', 'user_12352', 'user8', NOW());");
DorisTableDataResponse response = composeDbTableRpcService.queryTableData(batchInsertRequest);
log.info("batch insert with semicolon response: {}", JSON.toJSONString(response));
assertNotNull(response);
// 验证批量插入的行数,使用分号分割时每条语句都会执行成功
assertNotNull(response.getRowNum());
} finally {
RequestContext.remove();
}
}
@Test
void testBatchUpdateWithSemicolonCustomTable28() {
// 设置租户上下文,防止 MyBatis Plus 多租户插件报错
try {
RequestContext.setThreadTenantId(1L);
DorisTableDataRequest batchUpdateRequest = new DorisTableDataRequest();
batchUpdateRequest.setTableId(28L);
batchUpdateRequest.setSql(
"UPDATE custom_table_table_name SET agent_name='更新智能体3', nick_name='更新昵称3' WHERE agent_id='agent_003';"
+
"UPDATE custom_table_table_name SET agent_name='更新智能体4', nick_name='更新昵称4' WHERE agent_id='agent_004';"
+
"UPDATE custom_table_table_name SET agent_name='更新智能体5', nick_name='更新昵称5' WHERE agent_id='agent_005';");
DorisTableDataResponse response = composeDbTableRpcService.queryTableData(batchUpdateRequest);
log.info("batch update with semicolon response: {}", JSON.toJSONString(response));
assertNotNull(response);
// 验证批量更新成功
assertNotNull(response.getRowNum());
// 验证数据是否更新成功
DorisTableDataRequest selectRequest = new DorisTableDataRequest();
selectRequest.setTableId(28L);
selectRequest.setSql(
"SELECT * FROM custom_table_table_name WHERE agent_id IN ('agent_003', 'agent_004', 'agent_005')");
DorisTableDataResponse selectResponse = composeDbTableRpcService.queryTableData(selectRequest);
log.info("select after batch update response: {}", JSON.toJSONString(selectResponse));
assertNotNull(selectResponse);
assertNotNull(selectResponse.getData());
} finally {
RequestContext.remove();
}
}
@Test
public void testCreateTable_Success() {
try {
RequestContext.setThreadTenantId(1L);
// 第一步:先获取一个已存在表的定义,作为参考模板
DorisTableDefineRequest queryRequest = new DorisTableDefineRequest();
queryRequest.setTableId(113L); // 请根据实际情况修改
CreateTableDefineVo templateTable = composeDbTableRpcService.queryCreateTableInfo(queryRequest);
log.info("Loaded template table definition: {}", JSON.toJSONString(templateTable));
// 第三步:执行创建表操作
var tableId = composeDbTableRpcService.createTable(templateTable);
log.info("Table created, tableId={}",tableId);
// 可以再次查询验证表是否创建成功
// 这里省略验证步骤
} catch (Exception e) {
log.error("Create table failed", e);
assert false : "表创建应该成功,但抛出了异常: " + e.getMessage();
} finally {
RequestContext.remove();
}
}
}

View File

@@ -0,0 +1,103 @@
package com.xspaceagi.compose.api.service;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.xspaceagi.log.api.service.LogPlatformRpcServiceImpl;
import com.xspaceagi.log.sdk.reponse.AgentLogModelResponse;
import com.xspaceagi.log.sdk.request.AgentLogEntryRequest;
import com.xspaceagi.log.sdk.request.AgentLogSearchParamsRequest;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@SpringBootTest
public class LogPlatformRpcServiceImplTest {
@Autowired
private LogPlatformRpcServiceImpl logPlatformRpcService;
@Test
public void testAddLog_Success() {
// Arrange
AgentLogEntryRequest request = new AgentLogEntryRequest();
// 填充request的测试数据根据实际定义
request.setRequestId("req_001333");
request.setMessageId("msg_001");
request.setConversationId("session_001");
request.setUserUid("user_123");
request.setTenantId("1");
request.setSpaceId("space_001");
request.setUserInput("请帮我分析一下这个数据");
request.setOutput("根据您提供的数据,我分析出以下结论...");
request.setRequestStartTime(LocalDateTime.parse("2024-01-15T10:30:00Z", DateTimeFormatter.ISO_DATE_TIME));
request.setRequestEndTime(LocalDateTime.parse("2024-01-15T10:30:05Z", DateTimeFormatter.ISO_DATE_TIME));
request.setStatus("success");
request.setUserId(1L);
request.setUserName("张三");
// Act
boolean result = logPlatformRpcService.addLog(request);
// Assert
assert (result);
}
@Test
public void testSearchAgentLogs_Success() {
// Arrange - 准备搜索参数
AgentLogSearchParamsRequest searchParams = new AgentLogSearchParamsRequest();
// 设置时间范围 - 使用RFC 3339格式ISO_DATE_TIME
LocalDateTime startTime = LocalDateTime.parse("2024-01-01T00:00:00Z", DateTimeFormatter.ISO_DATE_TIME);
LocalDateTime endTime = LocalDateTime.parse("2024-12-31T23:59:59Z", DateTimeFormatter.ISO_DATE_TIME);
searchParams.setStartTime(startTime);
searchParams.setEndTime(endTime);
searchParams.setRequestId("req_001333");
// 设置其他搜索条件
searchParams.setTenantId("1"); // 当前租户
searchParams.setUserInput(Arrays.asList("分析", "数据")); // 搜索包含这些关键词的用户输入
// 设置当前页和页大小
Long current = 1L;
Long pageSize = 10L;
log.info("searchParams result", JSON.toJSONString(searchParams));
// Act - 执行搜索
IPage<AgentLogModelResponse> result = logPlatformRpcService.searchAgentLogs(searchParams, current, pageSize);
// Assert - 验证结果
log.info("Total search hits: {}", result.getTotal());
log.info("Current page row count: {}", result.getRecords().size());
log.info("result", JSON.toJSONString(result));
// check 结果记录, RequestId("req_001333") 必须都是这个值的
for (AgentLogModelResponse log : result.getRecords()) {
assert (log.getRequestId().equals("req_001333"));
}
// 如果有搜索结果,验证第一条记录的基本信息
if (!result.getRecords().isEmpty()) {
AgentLogModelResponse firstLog = result.getRecords().get(0);
log.info("First row requestId: {}", firstLog.getRequestId());
log.info("First row user input: {}", firstLog.getUserInput());
log.info("First row createdAt: {}", firstLog.getCreatedAt());
// 验证返回的记录包含必要的字段
assert (firstLog.getRequestId() != null);
assert (firstLog.getUserInput() != null);
}
}
}

View File

@@ -0,0 +1,59 @@
package com.xspaceagi.ecoMarket;
import com.xspaceagi.eco.market.sdk.reponse.ClientSecretResponse;
import com.xspaceagi.eco.market.sdk.request.ClientSecretRequest;
import com.xspaceagi.eco.market.sdk.service.IEcoMarketRpcService;
import com.xspaceagi.system.spec.common.RequestContext;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* EcoMarketRpcService 集成测试类
*/
@Slf4j
@SpringBootTest
public class EcoMarketRpcServiceTest {
@Resource
private IEcoMarketRpcService ecoMarketRpcService;
/**
* 测试查询客户端密钥功能 - 成功场景
*/
@Test
public void testQueryClientSecret_Success() {
try {
RequestContext.setThreadTenantId(1L);
// Arrange
ClientSecretRequest request = new ClientSecretRequest();
request.setTenantId(1L);
// Act
ClientSecretResponse response = ecoMarketRpcService.queryClientSecret(request);
log.info("Client secret query result, response={}", response);
// Assert
assertNotNull(response, "响应不应为空");
if (response != null) {
assertNotNull(response.getTenantId(), "租户ID不应为空");
assertEquals(1L, response.getTenantId(), "租户ID应该匹配");
log.info("Client secret: id={}, name={}, clientId={}, tenantId={}",
response.getId(), response.getName(), response.getClientId(), response.getTenantId());
}
} catch (Exception e) {
log.error("Exception while testing client secret query", e);
throw e;
} finally {
RequestContext.remove();
}
}
}

View File

@@ -0,0 +1,200 @@
package com.xspaceagi.knowledge;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import com.alibaba.fastjson2.JSON;
import com.xspaceagi.knowledge.api.KnowledgeConfigRpcService;
import com.xspaceagi.knowledge.api.KnowledgeQaSearchRpcService;
import com.xspaceagi.knowledge.sdk.request.KnowledgeCreateRequestVo;
import com.xspaceagi.knowledge.sdk.request.KnowledgeDocumentRequestVo;
import com.xspaceagi.knowledge.sdk.request.KnowledgeQaRequestVo;
import com.xspaceagi.knowledge.sdk.response.KnowledgeConfigVo;
import com.xspaceagi.knowledge.sdk.response.KnowledgeDocumentResponseVo;
import com.xspaceagi.knowledge.sdk.response.KnowledgeQaResponseVo;
import com.xspaceagi.knowledge.sdk.sevice.IKnowledgeDocumentSearchRpcService;
import com.xspaceagi.system.spec.common.RequestContext;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@SpringBootTest
public class KnowledegRpcServiceImplTest {
@Resource
private KnowledgeConfigRpcService knowledgeConfigRpcService;
@Resource
private IKnowledgeDocumentSearchRpcService knowledgeDocumentSearchRpcService;
@Resource
private KnowledgeQaSearchRpcService knowledgeQaSearchRpcService;
@Test
public void testCreateKnowledgeConfig() {
try {
RequestContext.setThreadTenantId(1L);
// Arrange
KnowledgeCreateRequestVo requestVo = KnowledgeCreateRequestVo.builder()
.spaceId(1L)
.name("Test Knowledge")
.description("This is a test knowledge")
.dataType(1)
.icon("https://example.com/icon.png")
.userId(1L)
.build();
// Act
Long result = knowledgeConfigRpcService.createKnowledgeConfig(requestVo);
log.info("Create database result, result={}", result);
// Assert
assertNotNull(result);
} finally {
RequestContext.remove();
}
}
@Test
public void testQueryKnowledgeConfigById() {
try {
RequestContext.setThreadTenantId(1L);
KnowledgeConfigVo knowledgeConfigVo = knowledgeConfigRpcService.queryKnowledgeConfigById(153L);
log.info("Knowledge config query result, knowledgeConfigVo={}", JSON.toJSONString(knowledgeConfigVo));
assertNotNull(knowledgeConfigVo);
} finally {
RequestContext.remove();
}
}
/**
* 测试知识库文档搜索功能
*/
@Test
public void testDocumentSearch() {
try {
RequestContext.setThreadTenantId(1L);
// Arrange
KnowledgeDocumentRequestVo requestVo = new KnowledgeDocumentRequestVo();
requestVo.setKbId(113L); // 使用上面查询的知识库ID
// Act
KnowledgeDocumentResponseVo responseVo = knowledgeDocumentSearchRpcService.documentSearch(requestVo);
log.info("Knowledge doc search result, responseVo={}", JSON.toJSONString(responseVo));
// Assert
assertNotNull(responseVo);
assertNotNull(responseVo.getDocumentVoList());
log.info("Document count: {}", responseVo.getDocumentVoList().size());
} finally {
RequestContext.remove();
}
}
/**
* 测试知识库文档搜索功能 - 测试空结果
*/
@Test
public void testDocumentSearchWithEmptyResult() {
try {
RequestContext.setThreadTenantId(1L);
// Arrange - 使用一个不存在的知识库ID
KnowledgeDocumentRequestVo requestVo = new KnowledgeDocumentRequestVo();
requestVo.setKbId(999999L);
// Act
KnowledgeDocumentResponseVo responseVo = knowledgeDocumentSearchRpcService.documentSearch(requestVo);
log.info("Knowledge doc search (empty), responseVo={}", JSON.toJSONString(responseVo));
// Assert
assertNotNull(responseVo);
assertNotNull(responseVo.getDocumentVoList());
log.info("Document count: {}", responseVo.getDocumentVoList().size());
} finally {
RequestContext.remove();
}
}
/**
* 测试知识库问答搜索功能
* 测试参数kbId=41, question="浮点数的定义是什么", 期望docId=24
*/
@Test
public void testQaSearch() {
try {
RequestContext.setThreadTenantId(1L);
// Arrange - 创建问答搜索请求
KnowledgeQaRequestVo requestVo = new KnowledgeQaRequestVo();
requestVo.setKbId(413L); // 知识库ID
requestVo.setQuestion("Antigravity"); // 问题
requestVo.setTopK(5); // 返回前5个结果
requestVo.setIgnoreDocStatus(true); // 不忽略文档状态
requestVo.setIgnoreTenantId(false); // 不忽略租户ID
// Act - 执行问答搜索
KnowledgeQaResponseVo responseVo = knowledgeQaSearchRpcService.search(requestVo);
log.info("Knowledge Q&A search result, responseVo={}", JSON.toJSONString(responseVo));
// Assert - 验证结果
assertNotNull(responseVo);
assertNotNull(responseVo.getQaVoList());
log.info("Q&A result count: {}", responseVo.getQaVoList().size());
// 打印详细结果特别是验证docId字段
if (responseVo.getQaVoList() != null && !responseVo.getQaVoList().isEmpty()) {
responseVo.getQaVoList().forEach(qaVo -> {
log.info("Q&A detail - qaId: {}, kbId: {}, docId: {}, question: {}, answer: {}, score: {}",
qaVo.getQaId(), qaVo.getKbId(), qaVo.getDocId(),
qaVo.getQuestion(), qaVo.getAnswer(), qaVo.getScore());
});
}
} finally {
RequestContext.remove();
}
}
/**
* 测试知识库问答搜索功能 - 测试空结果
*/
@Test
public void testQaSearchWithEmptyResult() {
try {
RequestContext.setThreadTenantId(1L);
// Arrange - 使用不存在的问题
KnowledgeQaRequestVo requestVo = new KnowledgeQaRequestVo();
requestVo.setKbId(41L); // 知识库ID
requestVo.setQuestion("一个不存在的问题 xyzabc123"); // 不存在的问题
requestVo.setTopK(5); // 返回前5个结果
requestVo.setIgnoreDocStatus(false); // 不忽略文档状态
requestVo.setIgnoreTenantId(false); // 不忽略租户ID
// Act - 执行问答搜索
KnowledgeQaResponseVo responseVo = knowledgeQaSearchRpcService.search(requestVo);
log.info("Knowledge Q&A search (empty), responseVo={}", JSON.toJSONString(responseVo));
// Assert - 验证结果
assertNotNull(responseVo);
assertNotNull(responseVo.getQaVoList());
log.info("Q&A result count: {}", responseVo.getQaVoList().size());
} finally {
RequestContext.remove();
}
}
}

View File

@@ -0,0 +1,266 @@
package com.xspaceagi.knowledge;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.xspaceagi.system.spec.common.RequestContext;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import com.xspaceagi.knowledge.sdk.request.KnowledgeFullTextSearchRequestVo;
import com.xspaceagi.knowledge.sdk.response.KnowledgeFullTextSearchResponseVo;
import com.xspaceagi.knowledge.sdk.sevice.IKnowledgeFullTextSearchRpcService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
/**
* 知识库全文检索 RPC 服务测试
*
* @author system
* @date 2025-03-31
*/
@Slf4j
@SpringBootTest
public class KnowledgeFullTextSearchRpcServiceTest {
@Resource
private IKnowledgeFullTextSearchRpcService knowledgeFullTextSearchRpcService;
/**
* 测试知识库全文检索功能 - 单知识库检索
*/
@Test
public void testFullTextSearch() {
try {
RequestContext.setThreadTenantId(1L);
// Arrange
KnowledgeFullTextSearchRequestVo requestVo = new KnowledgeFullTextSearchRequestVo();
requestVo.setKbIds(java.util.Arrays.asList(411L));
requestVo.setTenantId(1L);
requestVo.setQueryText("智能体");
requestVo.setTopK(10);
log.info("Start full-text search test: kbIds={}, queryText={}, topK={}",
requestVo.getKbIds(), requestVo.getQueryText(), requestVo.getTopK());
// Act
KnowledgeFullTextSearchResponseVo responseVo = knowledgeFullTextSearchRpcService.search(requestVo);
// Assert
assertNotNull(responseVo, "返回结果不能为空");
assertNotNull(responseVo.getResults(), "结果列表不能为空");
assertTrue(responseVo.getCostTimeMs() >= 0, "耗时应该大于等于0");
log.info("\n\n========================================");
log.info("=== Full-text search test result ===");
log.info("========================================");
log.info("Retrieval result count: {}", responseVo.getResults().size());
log.info("Elapsed: {}ms", responseVo.getCostTimeMs());
log.info("========================================\n");
// 打印前3个结果详情
if (!responseVo.getResults().isEmpty()) {
log.info("\n=== Top 3 results ===");
int index = 1;
for (var result : responseVo.getResults().stream().limit(3).toList()) {
String textPreview = result.getRawText() != null && result.getRawText().length() > 100
? result.getRawText().substring(0, 100) + "..."
: result.getRawText();
log.info("\n[Result #{}]", index++);
log.info(" rawSegmentId: {}", result.getRawSegmentId());
log.info(" docId: {}", result.getDocId());
log.info(" kbId: {}", result.getKbId());
log.info(" BM25 score: {}", result.getScore());
log.info(" sortIndex: {}", result.getSortIndex());
log.info(" documentName: {}", result.getDocumentName());
log.info(" Text: {}", textPreview);
}
log.info("\n========================================\n");
} else {
log.warn("\n⚠ No matching results found.\n");
}
} finally {
RequestContext.remove();
}
}
/**
* 测试全文检索 - 多知识库检索
*/
@Test
public void testFullTextSearchMultipleKbs() {
try {
RequestContext.setThreadTenantId(1L);
// Arrange - 测试多个知识库
KnowledgeFullTextSearchRequestVo requestVo = new KnowledgeFullTextSearchRequestVo();
requestVo.setKbIds(java.util.Arrays.asList(200L, 113L)); // 多个知识库
requestVo.setTenantId(1L);
requestVo.setQueryText("Python");
requestVo.setTopK(5);
log.info("Start multi-KB full-text test: kbIds={}, queryText={}, topK={}",
requestVo.getKbIds(), requestVo.getQueryText(), requestVo.getTopK());
// Act
KnowledgeFullTextSearchResponseVo responseVo = knowledgeFullTextSearchRpcService.search(requestVo);
// Assert
assertNotNull(responseVo);
assertNotNull(responseVo.getResults());
log.info("\n\n========================================");
log.info("=== Multi-KB search test result ===");
log.info("========================================");
log.info("Retrieval result count: {}", responseVo.getResults().size());
log.info("Elapsed: {}ms", responseVo.getCostTimeMs());
// 统计各知识库的结果数量
if (!responseVo.getResults().isEmpty()) {
java.util.Map<Long, Long> kbCountMap = responseVo.getResults().stream()
.collect(java.util.stream.Collectors.groupingBy(
result -> result.getKbId(),
java.util.stream.Collectors.counting()
));
log.info("\n=== Per-KB result distribution ===");
kbCountMap.forEach((kbId, count) ->
log.info("Result count for kbId {}: {}", kbId, count));
log.info("========================================\n");
} else {
log.warn("\n⚠ No matching results found.\n");
}
} finally {
RequestContext.remove();
}
}
/**
* 测试全文检索 - 指定文档ID检索
*/
@Test
public void testFullTextSearchWithDocIds() {
try {
RequestContext.setThreadTenantId(1L);
// Arrange - 测试指定文档ID检索
KnowledgeFullTextSearchRequestVo requestVo = new KnowledgeFullTextSearchRequestVo();
requestVo.setKbIds(java.util.Arrays.asList(200L));
requestVo.setTenantId(1L);
requestVo.setQueryText("Python");
requestVo.setTopK(10);
requestVo.setDocIds(java.util.Arrays.asList(1L, 2L, 3L)); // 指定文档ID
log.info("Start specified-doc search test: kbIds={}, docIds={}, queryText={}",
requestVo.getKbIds(), requestVo.getDocIds(), requestVo.getQueryText());
// Act
KnowledgeFullTextSearchResponseVo responseVo = knowledgeFullTextSearchRpcService.search(requestVo);
// Assert
assertNotNull(responseVo);
assertNotNull(responseVo.getResults());
log.info("\n\n========================================");
log.info("=== Specified-doc search test result ===");
log.info("========================================");
log.info("Retrieval result count: {}", responseVo.getResults().size());
log.info("Elapsed: {}ms", responseVo.getCostTimeMs());
// 验证所有结果都来自指定的文档
if (!responseVo.getResults().isEmpty()) {
boolean allFromSpecifiedDocs = responseVo.getResults().stream()
.allMatch(result -> requestVo.getDocIds().contains(result.getDocId()));
log.info("\nAll results from specified docs: {}", allFromSpecifiedDocs);
log.info("========================================\n");
} else {
log.warn("\n⚠ No matching results found.\n");
}
} finally {
RequestContext.remove();
}
}
/**
* 测试全文检索 - 空结果场景
*/
@Test
public void testFullTextSearchWithNoResults() {
try {
RequestContext.setThreadTenantId(1L);
// Arrange - 使用不存在的知识库ID
KnowledgeFullTextSearchRequestVo requestVo = new KnowledgeFullTextSearchRequestVo();
requestVo.setKbIds(java.util.Arrays.asList(999999L));
requestVo.setTenantId(1L);
requestVo.setQueryText("不存在的内容XYZABC123");
requestVo.setTopK(10);
log.info("Start empty-result test: kbIds={}, queryText={}",
requestVo.getKbIds(), requestVo.getQueryText());
// Act
KnowledgeFullTextSearchResponseVo responseVo = knowledgeFullTextSearchRpcService.search(requestVo);
// Assert
assertNotNull(responseVo);
assertNotNull(responseVo.getResults());
log.info("\n\n========================================");
log.info("=== Empty result test ===");
log.info("========================================");
log.info("Retrieval result count: {}", responseVo.getResults().size());
log.info("Elapsed: {}ms", responseVo.getCostTimeMs());
log.info("========================================\n");
} finally {
RequestContext.remove();
}
}
/**
* 测试全文检索 - 不同TopK值
*/
@Test
public void testFullTextSearchWithDifferentTopK() {
try {
RequestContext.setThreadTenantId(1L);
int[] topKValues = {1, 5, 10, 20};
for (int topK : topKValues) {
// Arrange
KnowledgeFullTextSearchRequestVo requestVo = new KnowledgeFullTextSearchRequestVo();
requestVo.setKbIds(java.util.Arrays.asList(200L));
requestVo.setTenantId(1L);
requestVo.setQueryText("Python");
requestVo.setTopK(topK);
log.info("Test TopK={}: kbIds={}, queryText={}",
topK, requestVo.getKbIds(), requestVo.getQueryText());
// Act
KnowledgeFullTextSearchResponseVo responseVo = knowledgeFullTextSearchRpcService.search(requestVo);
// Assert
assertNotNull(responseVo);
assertNotNull(responseVo.getResults());
assertTrue(responseVo.getResults().size() <= topK,
"返回结果数量不应超过TopK值");
log.info("TopK={}: returned {} rows, {}ms",
topK, responseVo.getResults().size(), responseVo.getCostTimeMs());
}
} finally {
RequestContext.remove();
}
}
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-bootstrap</artifactId>
<packaging>pom</packaging>
<modules>
<module>app-platform-web-bootstrap</module>
</modules>
</project>