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,26 @@
<?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-sandbox</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-sandbox-api</artifactId>
<dependencies>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-sandbox-spec</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-sandbox-application</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-sandbox-sdk</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,286 @@
package com.xspaceagi.sandbox.api;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.xspaceagi.sandbox.api.vo.SandboxServerConfig;
import com.xspaceagi.sandbox.application.dto.SandboxBindInfoDto;
import com.xspaceagi.sandbox.application.dto.SandboxConfigDto;
import com.xspaceagi.sandbox.application.service.SandboxConfigApplicationService;
import com.xspaceagi.sandbox.infra.dao.entity.SandboxConfig;
import com.xspaceagi.sandbox.infra.dao.service.SandboxConfigService;
import com.xspaceagi.sandbox.infra.network.ReverseServerContainer;
import com.xspaceagi.sandbox.sdk.server.ISandboxConfigRpcService;
import com.xspaceagi.sandbox.sdk.service.dto.SandboxConfigRpcDto;
import com.xspaceagi.sandbox.sdk.service.dto.SandboxConfigValue;
import com.xspaceagi.sandbox.sdk.service.dto.SandboxGlobalConfigDto;
import com.xspaceagi.sandbox.sdk.service.dto.SandboxServerInfo;
import com.xspaceagi.sandbox.sdk.service.enums.IsolationEnum;
import com.xspaceagi.sandbox.spec.enums.SandboxScopeEnum;
import com.xspaceagi.system.application.dto.TenantConfigDto;
import com.xspaceagi.system.application.service.TenantConfigApplicationService;
import com.xspaceagi.system.spec.common.RequestContext;
import com.xspaceagi.system.spec.enums.ErrorCodeEnum;
import com.xspaceagi.system.spec.exception.BizException;
import com.xspaceagi.system.spec.exception.BizExceptionCodeEnum;
import com.xspaceagi.system.spec.tenant.thread.TenantFunctions;
import com.xspaceagi.system.spec.utils.RedisUtil;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
/**
* 沙盒配置 RPC 服务实现
*/
@Slf4j
@Service("iSandboxConfigRpcService")
public class SandboxConfigRpcServiceImpl implements ISandboxConfigRpcService {
@Resource
private SandboxConfigService sandboxConfigService;
@Resource
private SandboxConfigApplicationService sandboxConfigApplicationService;
@Resource
private ReverseServerContainer reverseServerContainer;
@Resource
private TenantConfigApplicationService tenantConfigApplicationService;
@Resource
private RedisUtil redisUtil;
private final AtomicInteger sandboxIndex = new AtomicInteger(0);
@PostConstruct
public void init() {
TenantFunctions.runWithIgnoreCheck(() -> {
try {
List<SandboxConfigRpcDto> sandboxConfigRpcDtos = queryGlobalConfigs(1L);
if (!sandboxConfigRpcDtos.isEmpty()) {
return;
}
//初始化租户id为1的全局配置私有化部署的id为1L
TenantConfigDto tenantConfig = tenantConfigApplicationService.getTenantConfig(1L);
//全局没有配置时读取系统设置
String sandboxServerConfigStr = tenantConfig.getSandboxConfig();
SandboxServerConfig sandboxServerConfig = JSON.parseObject(sandboxServerConfigStr, SandboxServerConfig.class);
if (sandboxServerConfig != null && sandboxServerConfig.getSandboxServers() != null) {
com.xspaceagi.sandbox.application.dto.SandboxGlobalConfigDto sandboxGlobalConfigDto = new com.xspaceagi.sandbox.application.dto.SandboxGlobalConfigDto();
sandboxGlobalConfigDto.setPerUserCpuCores(sandboxServerConfig.getPerUserCpuCores() != 0 ? String.valueOf(sandboxServerConfig.getPerUserCpuCores()) : "2");
sandboxGlobalConfigDto.setPerUserMemoryGB(sandboxServerConfig.getPerUserMemoryGB() != 0 ? String.valueOf(sandboxServerConfig.getPerUserMemoryGB()) : "4");
sandboxConfigApplicationService.updateGlobalConfig(1L, sandboxGlobalConfigDto);
sandboxServerConfig.getSandboxServers().forEach(sandboxServer -> {
try {
SandboxConfig sandboxConfig = new SandboxConfig();
sandboxConfig.setId(Long.valueOf(sandboxServer.getServerId()));
sandboxConfig.setScope(SandboxScopeEnum.GLOBAL);
sandboxConfig.setTenantId(tenantConfig.getTenantId());
sandboxConfig.setName(sandboxServer.getServerName());
sandboxConfig.setDescription(sandboxServer.getServerName());
sandboxConfig.setConfigKey(UUID.randomUUID().toString().replace("-", ""));
SandboxConfigValue sandboxConfigValue = new SandboxConfigValue();
URL serverUrl = new URL(sandboxServer.getServerAgentUrl());
sandboxConfigValue.setHostWithScheme(serverUrl.getProtocol() + "://" + serverUrl.getHost());
sandboxConfigValue.setAgentPort(serverUrl.getPort());
serverUrl = new URL(sandboxServer.getServerFileUrl());
sandboxConfigValue.setFileServerPort(serverUrl.getPort());
serverUrl = new URL(sandboxServer.getServerVncUrl());
sandboxConfigValue.setVncPort(serverUrl.getPort());
sandboxConfigValue.setApiKey(sandboxServer.getServerApiKey());
sandboxConfigValue.setMaxUsers(sandboxServer.getMaxUsers());
sandboxConfig.setConfigValue(JSON.toJSONString(sandboxConfigValue));
sandboxConfig.setIsActive(true);
sandboxConfigService.save(sandboxConfig);
} catch (MalformedURLException e) {
log.error("初始化沙箱配置失败 {}", sandboxServer, e);
}
});
}
} catch (Exception e) {
log.error("初始化全局沙箱配置失败", e);
}
});
}
@Override
public List<SandboxConfigRpcDto> queryUserConfigs(Long userId) {
LambdaQueryWrapper<SandboxConfig> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SandboxConfig::getScope, SandboxScopeEnum.USER)
.eq(SandboxConfig::getUserId, userId)
.eq(SandboxConfig::getIsActive, true)
.orderByAsc(SandboxConfig::getId);
List<SandboxConfig> configs = sandboxConfigService.list(queryWrapper);
return convertToRpcDtoList(configs);
}
@Override
public List<SandboxConfigRpcDto> queryGlobalConfigs(Long tenantId) {
LambdaQueryWrapper<SandboxConfig> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SandboxConfig::getScope, SandboxScopeEnum.GLOBAL)
.eq(SandboxConfig::getIsActive, true)
.eq(SandboxConfig::getTenantId, tenantId)
.eq(SandboxConfig::getType, "Agent")
.orderByAsc(SandboxConfig::getId);
List<SandboxConfig> configs = TenantFunctions.callWithIgnoreCheck(() -> sandboxConfigService.list(queryWrapper));
return convertToRpcDtoList(configs);
}
@Override
public SandboxConfigRpcDto queryUserConfigByKey(String configKey) {
SandboxConfig config = sandboxConfigService.queryUserConfigByKey(configKey);
return convertToRpcDto(config);
}
@Override
public SandboxConfigRpcDto queryGlobalConfigByKey(String configKey) {
SandboxConfig config = sandboxConfigService.queryGlobalConfigByKey(configKey);
return convertToRpcDto(config);
}
@Override
public SandboxConfigRpcDto queryById(Long id) {
SandboxConfig config = TenantFunctions.callWithIgnoreCheck(() -> sandboxConfigService.getById(id));
return convertToRpcDto(config);
}
/**
* 转换为 RPC DTO 列表
*/
private List<SandboxConfigRpcDto> convertToRpcDtoList(List<SandboxConfig> configs) {
if (CollectionUtils.isEmpty(configs)) {
return List.of();
}
return configs.stream()
.map(this::convertToRpcDto)
.collect(Collectors.toList());
}
/**
* 转换为 RPC DTO
*/
private SandboxConfigRpcDto convertToRpcDto(SandboxConfig entity) {
if (entity == null) {
return null;
}
SandboxConfigRpcDto dto = new SandboxConfigRpcDto();
BeanUtils.copyProperties(entity, dto);
// 将 JSON 字符串转换为对象
if (StringUtils.isNotBlank(entity.getConfigValue())) {
try {
dto.setConfigValue(JSON.parseObject(entity.getConfigValue(), SandboxConfigValue.class));
} catch (Exception e) {
log.error("Failed to parse config value", e);
}
}
if (StringUtils.isNotBlank(entity.getServerInfo())) {
try {
dto.setSandboxServerInfo(JSON.parseObject(entity.getServerInfo(), SandboxServerInfo.class));
} catch (Exception e) {
log.error("Failed to parse config value", e);
}
}
if (entity.getScope() == SandboxScopeEnum.USER) {
dto.setOnline(reverseServerContainer.getUserSandboxAliveTime(entity.getConfigKey()) != null);
}
return dto;
}
@Override
public SandboxGlobalConfigDto getGlobalConfig(Long tenantId) {
com.xspaceagi.sandbox.application.dto.SandboxGlobalConfigDto globalConfig = sandboxConfigApplicationService.getGlobalConfig(tenantId);
if (globalConfig != null) {
SandboxGlobalConfigDto dto = new SandboxGlobalConfigDto();
BeanUtils.copyProperties(globalConfig, dto);
return dto;
}
return null;
}
@Override
public Long queryUserSelectedSandboxId(Long userId, Long agentId) {
if (agentId == null || userId == null) {
return null;
}
Object val = redisUtil.hashGet("user-sandbox-selected:" + RequestContext.get().getUserId(), agentId.toString());
if (val != null) {
try {
return Long.parseLong(val.toString());
} catch (NumberFormatException ignored) {
}
}
return null;
}
@Override
public SandboxConfigRpcDto selectAppDevelopmentSandbox(Long tenantId, Long userId, Long spaceId, Long projectId, Long sandboxId) {
SandboxConfigDto configDto = null;
if (sandboxId != null) {
configDto = sandboxConfigApplicationService.getById(sandboxId);
} else {
List<SandboxConfigDto> sandboxConfigs = sandboxConfigApplicationService.listPageDevelopmentSandboxes();
if (CollectionUtils.isEmpty(sandboxConfigs)) {
return null;
}
List<SandboxConfigDto> collect = sandboxConfigs.stream().filter(sandboxConfigDto -> {
if (CollectionUtils.isEmpty(sandboxConfigDto.getBindItems())) {
return false;
}
return sandboxConfigDto.getBindItems().stream().anyMatch(bindItem -> {
if (bindItem.getTargetType() == SandboxBindInfoDto.BindTargetType.User && userId.equals(bindItem.getTargetId())) {
return true;
}
return bindItem.getTargetType() == SandboxBindInfoDto.BindTargetType.Space && spaceId.equals(bindItem.getTargetId());
});
}).toList();
if (!collect.isEmpty()) {
configDto = collect.get(sandboxIndex.incrementAndGet() % collect.size());
} else {
sandboxConfigs.removeIf(sandboxConfigDto -> !CollectionUtils.isEmpty(sandboxConfigDto.getBindItems()));
if (!sandboxConfigs.isEmpty()) {
configDto = sandboxConfigs.get(sandboxIndex.incrementAndGet() % sandboxConfigs.size());
}
}
}
if (configDto == null) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.configNotFound);
}
SandboxConfigRpcDto rpcDto = new SandboxConfigRpcDto();
BeanUtils.copyProperties(configDto, rpcDto);
SandboxConfigValue configValue = new SandboxConfigValue();
BeanUtils.copyProperties(configDto.getConfigValue(), configValue);
rpcDto.setConfigValue(configValue);
if (configDto.getIsolation() != null) {
rpcDto.setIsolation(IsolationEnum.valueOf(configDto.getIsolation()));
} else {
rpcDto.setIsolation(IsolationEnum.Space);
}
if (rpcDto.getIsolation() == IsolationEnum.Tenant) {
rpcDto.setIsolationKey(tenantId.toString());
}
if (rpcDto.getIsolation() == IsolationEnum.Space) {
rpcDto.setIsolationKey(tenantId + "-" + spaceId.toString());
}
if (rpcDto.getIsolation() == IsolationEnum.Project) {
rpcDto.setIsolationKey(tenantId + "-" + spaceId.toString() + "-" + projectId.toString());
}
return rpcDto;
}
}

View File

@@ -0,0 +1,27 @@
package com.xspaceagi.sandbox.api.vo;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
@Data
public class SandboxServerConfig implements Serializable {
private List<SandboxServer> sandboxServers;
private double perUserMemoryGB;
private int perUserCpuCores;
@Data
public static class SandboxServer {
private String serverId;
private String serverName;
private String serverAgentUrl;
private String serverVncUrl;
private String serverFileUrl;
private String serverApiKey;
private int maxUsers;
private double perUserMemoryGB;
private int perUserCpuCores;
}
}

View File

@@ -0,0 +1,34 @@
<?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-sandbox</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-sandbox-application</artifactId>
<dependencies>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-sandbox-spec</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-sandbox-infra</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-sandbox-domain</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>system-spec</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>system-application</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,16 @@
package com.xspaceagi.sandbox.application.dto;
import lombok.Data;
@Data
public class SandboxBindInfoDto {
private BindTargetType targetType;
private Long targetId;
private String targetName;
public enum BindTargetType {
User,
Space
}
}

View File

@@ -0,0 +1,84 @@
package com.xspaceagi.sandbox.application.dto;
import com.xspaceagi.sandbox.infra.dao.vo.SandboxConfigValue;
import com.xspaceagi.sandbox.infra.dao.vo.SandboxServerInfo;
import com.xspaceagi.sandbox.spec.enums.SandboxScopeEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Date;
import java.util.List;
/**
* 沙盒配置 DTO
*/
@Schema(description = "沙盒配置信息")
@Data
public class SandboxConfigDto {
@Schema(description = "配置ID")
private Long id;
@Schema(description = "配置范围global-全局配置 user-个人配置")
private SandboxScopeEnum scope;
@Schema(description = "用户IDscope为user时必填")
private Long userId;
@Schema(description = "配置名称")
private String name;
@Schema(description = "唯一标识,用户智能体电脑连接有用")
private String configKey;
@Schema(description = "配置值")
private SandboxConfigValue configValue;
@Schema(description = "沙盒服务端信息(个人智能体电脑专有)")
private SandboxServerInfo serverInfo;
@Schema(description = "配置描述")
private String description;
@Schema(description = "是否启用true-启用 false-禁用")
private Boolean isActive;
@Schema(description = "是否在线true-在线 false-离线")
private boolean online;
@Schema(description = "正在使用的用户数")
private int usingCount;
@Schema(description = "最大并发执行Agent数量")
private Integer maxAgentCount;
@Schema(description = "穿透服务端地址")
private String serverHost;
@Schema(description = "给智能体电脑分配的agentId")
private Long agentId;
@Schema(description = "会话ID")
private Long conversationId;
@Schema(description = "穿透服务端端口")
private int serverPort;
@Schema(description = "会话密钥,仅首次注册时有效")
private String token;
@Schema(description = "沙盒类型:智能体 Agent应用开发 PageApp")
private String type;
@Schema(description = "绑定信息")
private List<SandboxBindInfoDto> bindItems;
@Schema(description = "沙盒隔离Tenant-租户维度隔离Space-空间维度隔离Project-项目维度隔离")
private String isolation;
@Schema(description = "创建时间")
private Date created;
@Schema(description = "更新时间")
private Date modified;
}

View File

@@ -0,0 +1,31 @@
package com.xspaceagi.sandbox.application.dto;
import com.xspaceagi.sandbox.spec.enums.SandboxScopeEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 沙盒配置查询 DTO
*/
@Schema(description = "沙盒配置查询条件")
@Data
public class SandboxConfigQueryDto {
@Schema(description = "配置范围global-全局配置 user-个人配置")
private SandboxScopeEnum scope;
@Schema(description = "用户ID")
private Long userId;
@Schema(description = "配置名称(模糊查询)")
private String name;
@Schema(description = "是否启用")
private Boolean isActive;
@Schema(description = "页码", example = "1")
private Integer pageNum = 1;
@Schema(description = "每页大小", example = "10")
private Integer pageSize = 10;
}

View File

@@ -0,0 +1,18 @@
package com.xspaceagi.sandbox.application.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 沙盒全局配置 DTO
*/
@Schema(description = "沙盒全局配置")
@Data
public class SandboxGlobalConfigDto {
@Schema(description = "每个用户分配的内存大小")
private String perUserMemoryGB;
@Schema(description = "每个用户分配的CPU核数")
private String perUserCpuCores;
}

View File

@@ -0,0 +1,41 @@
package com.xspaceagi.sandbox.application.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Date;
/**
* 临时代理 DTO
*/
@Schema(description = "临时代理信息")
@Data
public class SandboxProxyDto {
@Schema(description = "代理ID")
private Long id;
@Schema(description = "租户ID")
private Long tenantId;
@Schema(description = "用户ID")
private Long userId;
@Schema(description = "沙盒ID")
private Long sandboxId;
@Schema(description = "代理键")
private String proxyKey;
@Schema(description = "后端主机地址")
private String backendHost;
@Schema(description = "后端端口")
private Integer backendPort;
@Schema(description = "创建时间")
private Date created;
@Schema(description = "更新时间")
private Date modified;
}

View File

@@ -0,0 +1,96 @@
package com.xspaceagi.sandbox.application.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.xspaceagi.sandbox.application.dto.SandboxConfigDto;
import com.xspaceagi.sandbox.application.dto.SandboxConfigQueryDto;
import com.xspaceagi.sandbox.application.dto.SandboxGlobalConfigDto;
import java.util.List;
/**
* 沙盒配置应用服务接口
*/
public interface SandboxConfigApplicationService {
/**
* 分页查询配置列表
*
* @param queryDto 查询条件
* @return 分页结果
*/
Page<SandboxConfigDto> pageQuery(SandboxConfigQueryDto queryDto);
/**
* 查询配置详情
*
* @param id 配置ID
* @return 配置详情
*/
SandboxConfigDto getById(Long id);
/**
* 根据配置键查询配置详情
*
* @param key 配置键
* @return 配置详情
*/
SandboxConfigDto getByKey(String key);
/**
* 查询用户配置列表
*
* @param userId 用户ID为空时查询当前用户
* @return 配置列表
*/
List<SandboxConfigDto> listUserConfigsByType(Long userId);
/**
* 查询全局配置列表
*
* @return 配置列表
*/
List<SandboxConfigDto> listGlobalConfigsByType();
List<SandboxConfigDto> listPageDevelopmentSandboxes();
boolean hasGlobalConfigsForSelect();
/**
* 创建配置
*
* @param dto 配置信息
*/
void create(SandboxConfigDto dto, boolean userAdd);
/**
* 更新配置
*
* @param dto 配置信息
*/
void update(SandboxConfigDto dto);
/**
* 删除配置
*
* @param id 配置ID
*/
void delete(Long id);
/**
* 启用/禁用配置
*
* @param id 配置ID
*/
void toggle(Long id);
/**
* 查询全局配置
*
* @return 全局配置
*/
SandboxGlobalConfigDto getGlobalConfig(Long tenantId);
void updateGlobalConfig(Long tenantId, SandboxGlobalConfigDto dto);
void testConnection(Long sandboxId);
}

View File

@@ -0,0 +1,37 @@
package com.xspaceagi.sandbox.application.service;
import com.xspaceagi.sandbox.application.dto.SandboxProxyDto;
import java.util.List;
/**
* 临时代理应用服务接口
*/
public interface SandboxProxyApplicationService {
/**
* 创建代理配置
*
* @param dto 代理信息
* @return 创建后的代理信息
*/
SandboxProxyDto create(SandboxProxyDto dto);
/**
* 获取代理列表
*
* @param userId 用户id
* @param sandboxId 沙盒id
* @return 代理列表
*/
List<SandboxProxyDto> querySandboxyProxyList(Long userId, Long sandboxId);
/**
* 删除代理配置
*
* @param proxyKey 代理key
*/
void deleteByProxyKey(Long userId, String proxyKey);
void deleteById(Long userId, Long id);
}

View File

@@ -0,0 +1,451 @@
package com.xspaceagi.sandbox.application.service.impl;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.dynamic.datasource.annotation.DSTransactional;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.xspaceagi.agent.core.sdk.IAgentRpcService;
import com.xspaceagi.agent.core.sdk.dto.ReqResult;
import com.xspaceagi.sandbox.application.dto.SandboxBindInfoDto;
import com.xspaceagi.sandbox.application.dto.SandboxConfigDto;
import com.xspaceagi.sandbox.application.dto.SandboxConfigQueryDto;
import com.xspaceagi.sandbox.application.dto.SandboxGlobalConfigDto;
import com.xspaceagi.sandbox.application.service.SandboxConfigApplicationService;
import com.xspaceagi.sandbox.infra.config.ReverseServerProperties;
import com.xspaceagi.sandbox.infra.dao.entity.SandboxConfig;
import com.xspaceagi.sandbox.infra.dao.service.SandboxConfigService;
import com.xspaceagi.sandbox.infra.dao.vo.SandboxConfigValue;
import com.xspaceagi.sandbox.infra.dao.vo.SandboxServerInfo;
import com.xspaceagi.sandbox.infra.network.ReverseServerContainer;
import com.xspaceagi.sandbox.spec.enums.SandboxScopeEnum;
import com.xspaceagi.system.application.dto.TenantConfigDto;
import com.xspaceagi.system.spec.common.RequestContext;
import com.xspaceagi.system.spec.enums.ErrorCodeEnum;
import com.xspaceagi.system.spec.exception.BizException;
import com.xspaceagi.system.spec.exception.BizExceptionCodeEnum;
import com.xspaceagi.system.spec.utils.RedisUtil;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
/**
* 沙盒配置应用服务实现
*/
@Slf4j
@Service
public class SandboxConfigApplicationServiceImpl implements SandboxConfigApplicationService {
@Resource
private SandboxConfigService sandboxConfigService;
@Resource
private RedisUtil redisUtil;
@Resource
private ReverseServerProperties reverseServerProperties;
@Resource
private ReverseServerContainer reverseServerContainer;
@Resource
private IAgentRpcService iAgentRpcService;
static {
// disable keep alive暂不使用连接池
System.setProperty("jdk.httpclient.keepalive.timeout", "0");
}
private final HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build();
@Override
public Page<SandboxConfigDto> pageQuery(SandboxConfigQueryDto queryDto) {
LambdaQueryWrapper<SandboxConfig> queryWrapper = buildQueryWrapper(queryDto);
Page<SandboxConfig> page = sandboxConfigService.page(
new Page<>(queryDto.getPageNum(), queryDto.getPageSize()),
queryWrapper
);
return convertDtoPage(page);
}
@Override
public SandboxConfigDto getById(Long id) {
SandboxConfig entity = sandboxConfigService.getById(id);
if (entity == null) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.configNotFound);
}
return convertToDto(entity);
}
@Override
public SandboxConfigDto getByKey(String key) {
Assert.hasText(key, "配置key不能为空");
LambdaQueryWrapper<SandboxConfig> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SandboxConfig::getConfigKey, key);
SandboxConfig sandboxConfig = sandboxConfigService.getOne(queryWrapper);
if (sandboxConfig == null) {
return null;
}
SandboxConfigDto dto = convertToDto(sandboxConfig);
String host = reverseServerProperties.getOuter().getHost();
if (StringUtils.isBlank(host)) {
TenantConfigDto tenantConfig = (TenantConfigDto) RequestContext.get().getTenantConfig();
try {
host = new URL(tenantConfig.getSiteUrl()).getHost();
} catch (MalformedURLException e) {
throw new RuntimeException(e);
}
}
if (StringUtils.isBlank(host)) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.sandboxClientAddressUnavailable);
}
//host为多个英文逗号隔开的地址帮我轮询选择
String[] hosts = host.split(",");
if (hosts.length == 1) {
dto.setServerHost(hosts[0]);
} else {
Long increment = redisUtil.increment("sandbox:config:host:round:index", 1);
dto.setServerHost(hosts[increment.intValue() % hosts.length]);
}
dto.setServerPort(reverseServerProperties.getOuter().getPort());
return dto;
}
@Override
public List<SandboxConfigDto> listUserConfigsByType(Long userId) {
List<SandboxConfig> configs = sandboxConfigService.queryUserConfigsByType(userId);
return configs.stream()
.map(this::convertToDto)
.collect(Collectors.toList());
}
@Override
public List<SandboxConfigDto> listGlobalConfigsByType() {
List<SandboxConfig> configs = sandboxConfigService.queryGlobalConfigs(null);
List<SandboxConfigDto> collect = configs.stream()
.map(this::convertToDto)
.collect(Collectors.toList());
collect.forEach(dto -> {
try {
int i = totalUsingCount(dto);
dto.setUsingCount(i);
dto.setOnline(true);
} catch (Exception e) {
log.warn("获取配置使用数量失败 {}", dto, e);
}
});
return collect;
}
@Override
public List<SandboxConfigDto> listPageDevelopmentSandboxes() {
List<SandboxConfig> configs = sandboxConfigService.queryGlobalConfigs(true);
configs.removeIf(config -> "Agent".equals(config.getType()));
return configs.stream()
.map(this::convertToDto)
.collect(Collectors.toList());
}
@Override
public boolean hasGlobalConfigsForSelect() {
return !sandboxConfigService.queryGlobalConfigs(true).isEmpty();
}
/**
* 获取配置使用数量(顺带用于测试连通性)
*/
private int totalUsingCount(SandboxConfigDto sandboxConfigDto) throws Exception {
String url = sandboxConfigDto.getConfigValue().getHostWithScheme() + ":" + sandboxConfigDto.getConfigValue().getAgentPort() + "/computer/pod/count";
HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url))
.header("x-api-key", sandboxConfigDto.getConfigValue().getApiKey() == null ? "" : sandboxConfigDto.getConfigValue().getApiKey())
.timeout(java.time.Duration.ofSeconds(2))
.GET().build();
String serverStatusStr = httpClient.send(request, HttpResponse.BodyHandlers.ofString()).body();
JSONObject jsonObject = JSON.parseObject(serverStatusStr);
JSONObject data = jsonObject.getJSONObject("data");
return data.getInteger("total_count");
}
@DSTransactional(rollbackFor = Exception.class)
@Override
public void create(SandboxConfigDto dto, boolean userAdd) {
validateConfigKey(dto);
SandboxConfig entity = convertToEntity(dto);
entity.setCreated(new Date());
entity.setModified(new Date());
// 设置默认值
if (entity.getIsActive() == null) {
entity.setIsActive(true);
}
// 个人配置自动设置用户ID
if (entity.getScope() == SandboxScopeEnum.USER && entity.getUserId() == null) {
entity.setUserId(RequestContext.get().getUserId());
}
sandboxConfigService.save(entity);
// 创建用户沙盒代理
if (entity.getScope() == SandboxScopeEnum.USER) {
ReqResult<Long> userSandboxAgent = iAgentRpcService.createUserSandboxAgent(entity.getUserId(), entity.getId(), userAdd ? entity.getName() : null);
if (!userSandboxAgent.isSuccess()) {
log.error("创建用户沙盒代理失败:{}", userSandboxAgent.getMessage());
throw BizException.of(ErrorCodeEnum.ERROR_REQUEST, BizExceptionCodeEnum.sandboxUserProxyCreateFailed);
}
entity.setAgentId(userSandboxAgent.getData());
if (userAdd) {
entity.setName(entity.getName());
} else {
entity.setName(entity.getName() + entity.getId());
}
sandboxConfigService.updateById(entity);
}
}
@DSTransactional(rollbackFor = Exception.class)
@Override
public void update(SandboxConfigDto dto) {
if (dto.getId() == null) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.fieldRequiredButEmpty, "配置ID");
}
SandboxConfig existingEntity = sandboxConfigService.getById(dto.getId());
if (existingEntity == null) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.configNotFound);
}
// 如果修改了 config_key需要验证唯一性
if (StringUtils.isNotBlank(dto.getConfigKey()) && !dto.getConfigKey().equals(existingEntity.getConfigKey())) {
validateConfigKey(dto);
}
SandboxConfig entity = convertToEntity(dto);
entity.setId(dto.getId());
entity.setModified(new Date());
sandboxConfigService.updateById(entity);
if (StringUtils.isNotBlank(entity.getName())) {
//更新智能体名称
iAgentRpcService.updateUserSandboxAgentName(existingEntity.getAgentId(), existingEntity.getName(), entity.getName());
}
}
@DSTransactional(rollbackFor = Exception.class)
@Override
public void delete(Long id) {
SandboxConfig entity = sandboxConfigService.getById(id);
if (entity == null) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.configNotFound);
}
if (entity.getScope() == SandboxScopeEnum.USER) {
iAgentRpcService.deleteUserSandboxAgent(entity.getAgentId(), entity.getId());
}
sandboxConfigService.removeById(id);
}
@DSTransactional(rollbackFor = Exception.class)
@Override
public void toggle(Long id) {
SandboxConfig entity = sandboxConfigService.getById(id);
if (entity == null) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.configNotFound);
}
entity.setIsActive(!entity.getIsActive());
entity.setModified(new Date());
sandboxConfigService.updateById(entity);
if (entity.getScope() == SandboxScopeEnum.USER && !entity.getIsActive()) {
reverseServerContainer.offlineClient(entity.getConfigKey());
}
}
@Override
public SandboxGlobalConfigDto getGlobalConfig(Long tenantId) {
Object o = redisUtil.get("sandbox:global:config:" + tenantId);
if (o != null) {
try {
return JSON.parseObject(o.toString(), SandboxGlobalConfigDto.class);
} catch (Exception e) {
// 忽略
log.warn("反序列化沙盒全局配置失败", e);
}
}
SandboxGlobalConfigDto dto = new SandboxGlobalConfigDto();
dto.setPerUserMemoryGB("4");
dto.setPerUserCpuCores("2");
return dto;
}
@Override
public void updateGlobalConfig(Long tenantId, SandboxGlobalConfigDto dto) {
try {
redisUtil.set("sandbox:global:config:" + tenantId, JSON.toJSONString(dto));
} catch (Exception e) {
// 忽略
log.warn("序列化沙盒全局配置失败", e);
}
}
@Override
public void testConnection(Long sandboxId) {
SandboxConfigDto byId = getById(sandboxId);
if (byId == null) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.configNotFound);
}
try {
totalUsingCount(byId);
} catch (Exception e) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.sandboxDeployFailedWithHelp,
(e.getMessage() != null ? e.getMessage() : "") + "\n点击查看 <a target=\"_blank\" href=\"https://qiming.com/agent-computer-deploy.html#%E5%85%AD%E3%80%81%E6%95%85%E9%9A%9C%E6%8E%92%E6%9F%A5\">常见问题</a>");
}
}
/**
* 构建查询条件
*/
private LambdaQueryWrapper<SandboxConfig> buildQueryWrapper(SandboxConfigQueryDto queryDto) {
LambdaQueryWrapper<SandboxConfig> queryWrapper = new LambdaQueryWrapper<>();
if (queryDto.getScope() != null) {
queryWrapper.eq(SandboxConfig::getScope, queryDto.getScope());
}
if (queryDto.getUserId() != null) {
queryWrapper.eq(SandboxConfig::getUserId, queryDto.getUserId());
}
if (StringUtils.isNotBlank(queryDto.getName())) {
queryWrapper.like(SandboxConfig::getName, queryDto.getName());
}
if (queryDto.getIsActive() != null) {
queryWrapper.eq(SandboxConfig::getIsActive, queryDto.getIsActive());
}
queryWrapper.orderByAsc(SandboxConfig::getId);
return queryWrapper;
}
/**
* 验证配置键唯一性
*/
private void validateConfigKey(SandboxConfigDto dto) {
if (StringUtils.isBlank(dto.getConfigKey())) {
return;
}
LambdaQueryWrapper<SandboxConfig> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SandboxConfig::getScope, dto.getScope())
.eq(SandboxConfig::getConfigKey, dto.getConfigKey());
if (dto.getScope() == SandboxScopeEnum.USER) {
Long userId = dto.getUserId() != null ? dto.getUserId() : RequestContext.get().getUserId();
queryWrapper.eq(SandboxConfig::getUserId, userId);
}
if (dto.getId() != null) {
queryWrapper.ne(SandboxConfig::getId, dto.getId());
}
long count = sandboxConfigService.count(queryWrapper);
if (count > 0) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.sandboxConfigKeyDuplicate);
}
}
/**
* 转换为 DTO
*/
private SandboxConfigDto convertToDto(SandboxConfig entity) {
SandboxConfigDto dto = new SandboxConfigDto();
BeanUtils.copyProperties(entity, dto);
// 将 JSON 字符串转换为对象
if (StringUtils.isNotBlank(entity.getConfigValue())) {
try {
dto.setConfigValue(JSON.parseObject(entity.getConfigValue(), SandboxConfigValue.class));
} catch (Exception e) {
log.error("Failed to parse config value", e);
}
}
if (StringUtils.isNotBlank(entity.getServerInfo())) {
try {
dto.setServerInfo(JSON.parseObject(entity.getServerInfo(), SandboxServerInfo.class));
} catch (Exception e) {
log.error("解析服务端信息失败", e);
}
}
if (entity.getBindInfo() != null) {
try {
dto.setBindItems(JSON.parseArray(entity.getBindInfo(), SandboxBindInfoDto.class));
} catch (Exception e) {
log.warn("关系绑定数据解析失败 {}", entity.getBindInfo());
}
}
if (entity.getScope() == SandboxScopeEnum.USER) {
dto.setOnline(reverseServerContainer.getUserSandboxAliveTime(entity.getConfigKey()) != null);
}
return dto;
}
/**
* 转换为实体
*/
private SandboxConfig convertToEntity(SandboxConfigDto dto) {
SandboxConfig entity = new SandboxConfig();
BeanUtils.copyProperties(dto, entity);
// 将对象转换为 JSON 字符串
if (dto.getConfigValue() != null) {
try {
entity.setConfigValue(JSON.toJSONString(dto.getConfigValue()));
} catch (Exception e) {
log.error("序列化配置值失败", e);
}
}
if (dto.getBindItems() != null) {
try {
entity.setBindInfo(JSON.toJSONString(dto.getBindItems()));
} catch (Exception e) {
log.warn("关系绑定数据转换失败 {}", dto.getBindItems());
}
}
return entity;
}
/**
* 转换分页结果
*/
private Page<SandboxConfigDto> convertDtoPage(Page<SandboxConfig> page) {
Page<SandboxConfigDto> dtoPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
List<SandboxConfigDto> dtoList = page.getRecords().stream()
.map(this::convertToDto)
.collect(Collectors.toList());
dtoPage.setRecords(dtoList);
return dtoPage;
}
}

View File

@@ -0,0 +1,70 @@
package com.xspaceagi.sandbox.application.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.xspaceagi.sandbox.application.dto.SandboxProxyDto;
import com.xspaceagi.sandbox.application.service.SandboxProxyApplicationService;
import com.xspaceagi.sandbox.infra.dao.entity.SandboxProxy;
import com.xspaceagi.sandbox.infra.dao.service.SandboxProxyService;
import jakarta.annotation.Resource;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
import java.util.List;
import java.util.UUID;
/**
* 临时代理应用服务实现
*/
@Service
public class SandboxProxyApplicationServiceImpl implements SandboxProxyApplicationService {
@Resource
private SandboxProxyService sandboxProxyService;
@Override
public SandboxProxyDto create(SandboxProxyDto dto) {
Assert.notNull(dto.getUserId(), "用户ID不能为空");
Assert.notNull(dto.getSandboxId(), "沙盒ID不能为空");
Assert.notNull(dto.getBackendHost(), "后端主机地址不能为空");
Assert.notNull(dto.getBackendPort(), "后端端口不能为空");
SandboxProxy sandboxProxy = new SandboxProxy();
sandboxProxy.setUserId(dto.getUserId());
sandboxProxy.setSandboxId(dto.getSandboxId());
sandboxProxy.setProxyKey(UUID.randomUUID().toString().replace("-", ""));
sandboxProxy.setBackendHost(dto.getBackendHost());
sandboxProxy.setBackendPort(dto.getBackendPort());
sandboxProxyService.save(sandboxProxy);
return toDto(sandboxProxy);
}
@Override
public List<SandboxProxyDto> querySandboxyProxyList(Long userId, Long sandboxId) {
return sandboxProxyService.listByUserIdAndSandboxId(userId, sandboxId).stream().map(this::toDto).toList();
}
@Override
public void deleteByProxyKey(Long userId, String proxyKey) {
sandboxProxyService.removeByProxyKey(userId, proxyKey);
}
@Override
public void deleteById(Long userId, Long id) {
QueryWrapper<SandboxProxy> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("id", id);
queryWrapper.eq("user_id", userId);
sandboxProxyService.remove(queryWrapper);
}
private SandboxProxyDto toDto(SandboxProxy sandboxProxy) {
if (sandboxProxy == null) {
return null;
}
SandboxProxyDto dto = new SandboxProxyDto();
BeanUtils.copyProperties(sandboxProxy, dto);
return dto;
}
}

View File

@@ -0,0 +1,110 @@
package com.xspaceagi.sandbox.application.task;
import com.xspaceagi.agent.core.sdk.IConversationRpcService;
import com.xspaceagi.agent.core.sdk.dto.ConversationRpcDto;
import com.xspaceagi.sandbox.infra.dao.entity.SandboxConfig;
import com.xspaceagi.sandbox.infra.dao.service.SandboxConfigService;
import com.xspaceagi.sandbox.infra.network.ReverseServerContainer;
import com.xspaceagi.system.sdk.service.ScheduleTaskApiService;
import com.xspaceagi.system.sdk.service.TaskExecuteService;
import com.xspaceagi.system.sdk.service.dto.ScheduleTaskDto;
import com.xspaceagi.system.spec.tenant.thread.TenantFunctions;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
@Slf4j
@Service("userSandboxAgentAliveCheckTask")
public class UserSandboxAgentAliveCheckTask implements TaskExecuteService {
@Resource
private ScheduleTaskApiService scheduleTaskApiService;
@Resource
private SandboxConfigService sandboxConfigService;
@Resource
private IConversationRpcService iConversationRpcService;
@Resource
private ReverseServerContainer reverseServerContainer;
@PostConstruct
public void init() {
scheduleTaskApiService.start(ScheduleTaskDto.builder()
.taskId("userSandboxAgentAliveCheckTask")
.beanId("userSandboxAgentAliveCheckTask")
.maxExecTimes(Long.MAX_VALUE)
.cron(ScheduleTaskDto.Cron.EVERY_5_MINUTE.getCron())
.params(Map.of())
.build());
}
@Override
public Mono<Boolean> asyncExecute(ScheduleTaskDto scheduleTask) {
TenantFunctions.runWithIgnoreCheck(this::execute);
return Mono.just(false);
}
private void execute() {
List<SandboxConfig> sandboxConfigs = sandboxConfigService.queryGlobalConfigs(true);
// 获取ID列表
List<Long> sandboxIds = sandboxConfigs.stream().map(SandboxConfig::getId).toList();
List<ConversationRpcDto> conversationRpcDtos = iConversationRpcService.queryLatestSandboxConversationList(sandboxIds);
log.debug("查询最新会话 {}", conversationRpcDtos);
//获取sandboxServerIds并去重
List<Long> sandboxServerIds = conversationRpcDtos.stream().map(ConversationRpcDto::getSandboxServerId).distinct().toList();
if (CollectionUtils.isEmpty(sandboxServerIds)) {
return;
}
sandboxServerIds.forEach(sandboxServerId -> {
SandboxConfig sandboxConfig = sandboxConfigService.querySandboxConfigById(sandboxServerId);
if (sandboxConfig.getIsActive() != null && !sandboxConfig.getIsActive()) {
log.debug("sandboxServerId {} 已停用", sandboxServerId);
return;
}
if (reverseServerContainer.getUserSandboxAliveTime(sandboxConfig.getConfigKey()) == null) {
log.debug("sandboxServerId {} 不在线", sandboxServerId);
return;//不在线
}
List<ConversationRpcDto> sandboxAliveConversations = iConversationRpcService.queryLatestConversationListBySandboxId(sandboxServerId);
log.debug("查询 {} 的最新会话 {}", sandboxServerId, sandboxAliveConversations);
long now = System.currentTimeMillis();
//24小时以上没有使用的会话直接停止
sandboxAliveConversations.forEach(conversationRpcDto -> {
if (now - conversationRpcDto.getModified().getTime() > 1000 * 60 * 60 * 24) {
try {
log.debug("停止代理 {}", conversationRpcDto);
iConversationRpcService.stopAgent(conversationRpcDto.getAgentId(), conversationRpcDto.getId());
log.debug("停止代理成功 {}", conversationRpcDto);
} catch (Exception e) {
log.warn("停止代理异常 {}, {}", conversationRpcDto, e.getMessage());
}
}
});
sandboxAliveConversations.removeIf(conversationRpcDto -> now - conversationRpcDto.getModified().getTime() > 1000 * 60 * 60 * 24);
if (sandboxConfig.getMaxAgentCount() == null || sandboxAliveConversations.size() <= sandboxConfig.getMaxAgentCount()) {
return;//没有超过最大代理数
}
//停止超过最大代理数的代理,按照时间最旧的停止
sandboxAliveConversations.sort(Comparator.comparing(ConversationRpcDto::getModified).reversed());
for (int i = sandboxConfig.getMaxAgentCount(); i < sandboxAliveConversations.size(); i++) {
try {
log.debug("停止超过数量的代理 {}", sandboxAliveConversations.get(i));
iConversationRpcService.stopAgent(sandboxAliveConversations.get(i).getAgentId(), sandboxAliveConversations.get(i).getId());
log.debug("停止超过数量的代理成功 {}", sandboxAliveConversations.get(i));
} catch (Exception e) {
log.warn("停止超过数量的代理异常 {}, {}", sandboxAliveConversations.get(i), e.getMessage());
}
}
});
}
}

View File

@@ -0,0 +1,26 @@
<?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-sandbox</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-sandbox-domain</artifactId>
<dependencies>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-sandbox-spec</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-sandbox-infra</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>system-spec</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,72 @@
package com.xspaceagi.sandbox.domain.model;
import com.xspaceagi.sandbox.spec.enums.SandboxScopeEnum;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.Date;
/**
* 沙盒配置领域模型
*/
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
public class SandboxConfigModel implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 配置ID
*/
private Long id;
/**
* 配置范围global-全局配置 user-个人配置
*/
private SandboxScopeEnum scope;
/**
* 用户IDscope为user时必填
*/
private Long userId;
/**
* 配置名称
*/
private String name;
/**
* 唯一标识,用户智能体电脑连接有用
*/
private String configKey;
/**
* 配置值JSON格式字符串
*/
private String configValue;
/**
* 配置描述
*/
private String description;
/**
* 是否启用true-启用 false-禁用
*/
private Boolean isActive;
/**
* 创建时间
*/
private Date created;
/**
* 更新时间
*/
private Date modified;
}

View File

@@ -0,0 +1,22 @@
<?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-sandbox</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-sandbox-infra</artifactId>
<dependencies>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-sandbox-spec</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,135 @@
package com.xspaceagi.sandbox.infra.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* 反向代理服务器配置属性
*/
@Data
@Configuration
@ConfigurationProperties(prefix = "reverse.server")
public class ReverseServerProperties {
/**
* 内部服务器配置
*/
private Inner inner = new Inner();
/**
* 外部服务器配置
*/
private Outer outer = new Outer();
/**
* 内部服务器配置
*/
@Data
public static class Inner {
/**
* 内部服务器主机地址
* 默认: 127.0.0.1
*/
private String serviceHost = "127.0.0.1";
private String bindHost = "127.0.0.1";
/**
* 内部服务器端口范围
* 格式: "起始端口-结束端口",例如 "30000-40000"
* 默认: 30000-40000
*/
private String ports = "30000-40000";
/**
* 解析端口范围
*
* @return 端口范围数组 [起始端口, 结束端口]
*/
public int[] parsePortRange() {
if (ports == null || ports.trim().isEmpty()) {
throw new IllegalArgumentException("Port range cannot be empty");
}
String[] parts = ports.split("-");
if (parts.length != 2) {
throw new IllegalArgumentException("Invalid port range format, expected 'min-max', got: " + ports);
}
try {
int minPort = Integer.parseInt(parts[0].trim());
int maxPort = Integer.parseInt(parts[1].trim());
if (minPort <= 0 || maxPort <= 0) {
throw new IllegalArgumentException("Port numbers must be positive");
}
if (minPort > maxPort) {
throw new IllegalArgumentException("Min port cannot be greater than max port");
}
return new int[]{minPort, maxPort};
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid port number format", e);
}
}
/**
* 获取起始端口
*/
public int getMinPort() {
return parsePortRange()[0];
}
/**
* 获取结束端口
*/
public int getMaxPort() {
return parsePortRange()[1];
}
public String getServiceHost() {
//从环境变量中获取服务器主机地址
String envServiceHost = System.getenv("SERVICE_HOST");
if (envServiceHost != null && !envServiceHost.trim().isEmpty()) {
return envServiceHost;
}
return serviceHost;
}
public String getBindHost() {
//从环境变量中获取服务器主机地址
String envBindHost = System.getenv("BIND_HOST");
if (envBindHost != null && !envBindHost.trim().isEmpty()) {
return envBindHost;
}
return bindHost;
}
}
/**
* 外部服务器配置
*/
@Data
public static class Outer {
/**
* 外部服务器主机地址
* 默认: 空(需要配置)
*/
private String host = "";
/**
* 外部服务器端口
* 默认: 6443
*/
private int port = 6443;
/**
* 检查外部服务器是否已配置
*/
public boolean isConfigured() {
return host != null && !host.trim().isEmpty();
}
}
}

View File

@@ -0,0 +1,15 @@
package com.xspaceagi.sandbox.infra.config;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* Sandbox 模块配置类
*/
@Configuration
@EnableConfigurationProperties({
ReverseServerProperties.class
})
public class SandboxConfiguration {
// 配置类,用于启用配置属性扫描
}

View File

@@ -0,0 +1,78 @@
package com.xspaceagi.sandbox.infra.dao.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.xspaceagi.sandbox.spec.enums.SandboxScopeEnum;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.Date;
/**
* 沙盒配置实体类
*/
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
@TableName(value = "sandbox_config", autoResultMap = true)
public class SandboxConfig implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField(value = "scope")
private SandboxScopeEnum scope;
@TableField(value = "_tenant_id")
private Long tenantId;
@TableField(value = "user_id")
private Long userId;
@TableField(value = "name")
private String name;
@TableField(value = "config_key")
private String configKey;
@TableField(value = "config_value")
private String configValue;
@TableField(value = "description")
private String description;
@TableField(value = "is_active")
private Boolean isActive;
@TableField(value = "server_info")
private String serverInfo;
@TableField(value = "agent_id")
private Long agentId;
@TableField(value = "max_agent")
private Integer maxAgentCount;
@TableField(value = "bind_info")
private String bindInfo;
@TableField(value = "type")
private String type;
@TableField(value = "isolation")
private String isolation;
@TableField(value = "created")
private Date created;
@TableField(value = "modified")
private Date modified;
}

View File

@@ -0,0 +1,53 @@
package com.xspaceagi.sandbox.infra.dao.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.Date;
/**
* 临时代理实体类
*/
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
@TableName(value = "sandbox_proxy", autoResultMap = true)
public class SandboxProxy implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField(value = "_tenant_id")
private Long tenantId;
@TableField(value = "user_id")
private Long userId;
@TableField(value = "sandbox_id")
private Long sandboxId;
@TableField(value = "proxy_key")
private String proxyKey;
@TableField(value = "backend_host")
private String backendHost;
@TableField(value = "backend_port")
private Integer backendPort;
@TableField(value = "created")
private Date created;
@TableField(value = "modified")
private Date modified;
}

View File

@@ -0,0 +1,13 @@
package com.xspaceagi.sandbox.infra.dao.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xspaceagi.sandbox.infra.dao.entity.SandboxConfig;
import org.apache.ibatis.annotations.Mapper;
/**
* 沙盒配置 Mapper 接口
*/
@Mapper
public interface SandboxConfigMapper extends BaseMapper<SandboxConfig> {
}

View File

@@ -0,0 +1,13 @@
package com.xspaceagi.sandbox.infra.dao.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xspaceagi.sandbox.infra.dao.entity.SandboxProxy;
import org.apache.ibatis.annotations.Mapper;
/**
* 临时代理 Mapper 接口
*/
@Mapper
public interface SandboxProxyMapper extends BaseMapper<SandboxProxy> {
}

View File

@@ -0,0 +1,45 @@
package com.xspaceagi.sandbox.infra.dao.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.xspaceagi.sandbox.infra.dao.entity.SandboxConfig;
import java.util.List;
/**
* 沙盒配置服务接口
*/
public interface SandboxConfigService extends IService<SandboxConfig> {
/**
* 根据用户ID查询配置列表
*
* @param userId 用户ID
* @return 配置列表
*/
List<SandboxConfig> queryUserConfigsByType(Long userId);
/**
* 查询全局配置列表
*
* @return 配置列表
*/
List<SandboxConfig> queryGlobalConfigs(Boolean isActive);
/**
* 根据配置键查询用户配置
*
* @param configKey 配置键
* @return 配置信息
*/
SandboxConfig queryUserConfigByKey(String configKey);
/**
* 根据配置键查询全局配置
*
* @param configKey 配置键
* @return 配置信息
*/
SandboxConfig queryGlobalConfigByKey(String configKey);
SandboxConfig querySandboxConfigById(Long sandboxId);
}

View File

@@ -0,0 +1,44 @@
package com.xspaceagi.sandbox.infra.dao.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.xspaceagi.sandbox.infra.dao.entity.SandboxProxy;
import com.xspaceagi.sandbox.infra.dao.vo.SandboxProxyBackend;
import java.util.List;
/**
* 临时代理服务接口
*/
public interface SandboxProxyService extends IService<SandboxProxy> {
/**
* 根据代理键查询代理配置
*
* @param proxyKey 代理键
* @return 代理配置
*/
SandboxProxy getByProxyKey(String proxyKey);
/**
* 根据沙盒ID查询代理配置
*
* @param sandboxId 沙盒ID
* @return 代理配置
*/
SandboxProxy getBySandboxId(Long sandboxId);
SandboxProxyBackend getBackendByProxyKey(String proxyKey);
/**
* 根据用户ID查询代理配置列表
*
* @param userId 用户ID
* @return 代理配置列表
*/
java.util.List<SandboxProxy> listByUserId(Long userId);
void removeByProxyKey(Long userId, String proxyKey);
List<SandboxProxy> listByUserIdAndSandboxId(Long userId, Long sandboxId);
}

View File

@@ -0,0 +1,70 @@
package com.xspaceagi.sandbox.infra.dao.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.xspaceagi.sandbox.infra.dao.entity.SandboxConfig;
import com.xspaceagi.sandbox.infra.dao.mapper.SandboxConfigMapper;
import com.xspaceagi.sandbox.infra.dao.service.SandboxConfigService;
import com.xspaceagi.sandbox.spec.enums.SandboxScopeEnum;
import com.xspaceagi.system.spec.tenant.thread.TenantFunctions;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 沙盒配置服务实现
*/
@Service
public class SandboxConfigServiceImpl extends ServiceImpl<SandboxConfigMapper, SandboxConfig> implements SandboxConfigService {
@Override
public List<SandboxConfig> queryUserConfigsByType(Long userId) {
LambdaQueryWrapper<SandboxConfig> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SandboxConfig::getScope, SandboxScopeEnum.USER)
.eq(SandboxConfig::getUserId, userId)
.orderByDesc(SandboxConfig::getId);
return list(queryWrapper);
}
@Override
public List<SandboxConfig> queryGlobalConfigs(Boolean isActive) {
LambdaQueryWrapper<SandboxConfig> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SandboxConfig::getScope, SandboxScopeEnum.GLOBAL)
.eq(isActive != null, SandboxConfig::getIsActive, isActive)
.orderByAsc(SandboxConfig::getId);
return list(queryWrapper);
}
@Override
public SandboxConfig queryUserConfigByKey(String configKey) {
LambdaQueryWrapper<SandboxConfig> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SandboxConfig::getScope, SandboxScopeEnum.USER)
.eq(SandboxConfig::getConfigKey, configKey)
.eq(SandboxConfig::getIsActive, true)
.last("LIMIT 1");
return TenantFunctions.callWithIgnoreCheck(() -> getOne(queryWrapper));
}
@Override
public SandboxConfig queryGlobalConfigByKey(String configKey) {
LambdaQueryWrapper<SandboxConfig> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SandboxConfig::getScope, SandboxScopeEnum.GLOBAL)
.eq(SandboxConfig::getConfigKey, configKey)
.eq(SandboxConfig::getIsActive, true)
.last("LIMIT 1");
return getOne(queryWrapper);
}
@Override
public SandboxConfig querySandboxConfigById(Long sandboxId) {
LambdaQueryWrapper<SandboxConfig> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SandboxConfig::getId, sandboxId)
.eq(SandboxConfig::getIsActive, true)
.last("LIMIT 1");
return getOne(queryWrapper);
}
}

View File

@@ -0,0 +1,98 @@
package com.xspaceagi.sandbox.infra.dao.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.xspaceagi.sandbox.infra.dao.entity.SandboxConfig;
import com.xspaceagi.sandbox.infra.dao.entity.SandboxProxy;
import com.xspaceagi.sandbox.infra.dao.mapper.SandboxProxyMapper;
import com.xspaceagi.sandbox.infra.dao.service.SandboxConfigService;
import com.xspaceagi.sandbox.infra.dao.service.SandboxProxyService;
import com.xspaceagi.sandbox.infra.dao.vo.SandboxProxyBackend;
import com.xspaceagi.system.spec.cache.SimpleJvmHashCache;
import com.xspaceagi.system.spec.tenant.thread.TenantFunctions;
import com.xspaceagi.system.spec.utils.RedisUtil;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 临时代理服务实现
*/
@Service
public class SandboxProxyServiceImpl extends ServiceImpl<SandboxProxyMapper, SandboxProxy> implements SandboxProxyService {
@Resource
private SandboxConfigService sandboxConfigService;
@Resource
private RedisUtil redisUtil;
@Override
public SandboxProxy getByProxyKey(String proxyKey) {
LambdaQueryWrapper<SandboxProxy> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SandboxProxy::getProxyKey, proxyKey)
.last("LIMIT 1");
return getOne(queryWrapper);
}
@Override
public SandboxProxy getBySandboxId(Long sandboxId) {
LambdaQueryWrapper<SandboxProxy> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SandboxProxy::getSandboxId, sandboxId)
.last("LIMIT 1");
return getOne(queryWrapper);
}
@Override
public SandboxProxyBackend getBackendByProxyKey(String proxyKey) {
Object val = SimpleJvmHashCache.getHash(SandboxProxy.class.getName(), proxyKey);
if (val != null) {
return (SandboxProxyBackend) val;
}
SandboxProxyBackend backend = TenantFunctions.callWithIgnoreCheck(() -> {
SandboxProxy byProxyKey = getByProxyKey(proxyKey);
if (byProxyKey == null) {
return null;
}
SandboxConfig sandboxConfig = sandboxConfigService.querySandboxConfigById(byProxyKey.getSandboxId());
if (sandboxConfig == null) {
return null;
}
return SandboxProxyBackend.builder()
.sandboxId(byProxyKey.getSandboxId())
.proxyKey(byProxyKey.getProxyKey())
.backendHost(byProxyKey.getBackendHost())
.backendPort(byProxyKey.getBackendPort())
.sandboxConfigKey(sandboxConfig.getConfigKey())
.build();
});
SimpleJvmHashCache.putHash(SandboxProxy.class.getName(), proxyKey, backend, 10);
return backend;
}
@Override
public List<SandboxProxy> listByUserId(Long userId) {
LambdaQueryWrapper<SandboxProxy> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SandboxProxy::getUserId, userId)
.orderByDesc(SandboxProxy::getCreated);
return list(queryWrapper);
}
@Override
public void removeByProxyKey(Long userId, String proxyKey) {
LambdaQueryWrapper<SandboxProxy> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SandboxProxy::getUserId, userId)
.eq(SandboxProxy::getProxyKey, proxyKey);
remove(queryWrapper);
}
@Override
public List<SandboxProxy> listByUserIdAndSandboxId(Long userId, Long sandboxId) {
LambdaQueryWrapper<SandboxProxy> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SandboxProxy::getUserId, userId)
.eq(SandboxProxy::getSandboxId, sandboxId)
.orderByDesc(SandboxProxy::getCreated);
return list(queryWrapper);
}
}

View File

@@ -0,0 +1,30 @@
package com.xspaceagi.sandbox.infra.dao.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 沙盒配置值
*/
@Schema(description = "沙盒配置值")
@Data
public class SandboxConfigValue {
@Schema(description = "服务根地址,例如 http://192.168.1.11,不允许携带端口")
private String hostWithScheme;
@Schema(description = "Agent服务端口")
private int agentPort;
@Schema(description = "VNC服务端口")
private int vncPort;
@Schema(description = "文件服务端口")
private int fileServerPort;
@Schema(description = "API密钥")
private String apiKey;
@Schema(description = "最大用户数")
private Integer maxUsers;
}

View File

@@ -0,0 +1,28 @@
package com.xspaceagi.sandbox.infra.dao.vo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* 临时代理实体类
*/
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
public class SandboxProxyBackend implements Serializable {
private Long sandboxId;
private String proxyKey;
private String backendHost;
private Integer backendPort;
private String sandboxConfigKey;
}

View File

@@ -0,0 +1,30 @@
package com.xspaceagi.sandbox.infra.dao.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 沙盒配置值
*/
@Schema(description = "沙盒服务端内部交互信息")
@Data
public class SandboxServerInfo {
@Schema(description = "协议")
private String scheme;
@Schema(description = "主机地址")
private String host;
@Schema(description = "Agent服务端口")
private int agentPort;
@Schema(description = "VNC服务端口")
private int vncPort;
@Schema(description = "文件服务端口")
private int fileServerPort;
@Schema(description = "API密钥")
private String apiKey;
}

View File

@@ -0,0 +1,408 @@
package com.xspaceagi.sandbox.infra.network;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 端口池管理器
*
* 功能:
* 1. 管理指定范围的端口池
* 2. 支持端口借用和归还
* 3. 归还的端口需要等待冷却时间默认1分钟后才能再次被使用
* 4. 线程安全
*/
@Slf4j
public class PortPoolManager {
/**
* 端口信息
*/
private static class PortInfo {
private final int port;
private long allocateTime; // 分配时间
private long releaseTime; // 释放时间(用于冷却)
private boolean inUse; // 是否在使用中
private boolean cooling; // 是否在冷却中
public PortInfo(int port) {
this.port = port;
this.inUse = false;
this.cooling = false;
}
public void allocate() {
this.inUse = true;
this.allocateTime = System.currentTimeMillis();
this.releaseTime = 0;
}
public void release() {
this.inUse = false;
this.cooling = true;
this.releaseTime = System.currentTimeMillis();
}
public boolean isAvailable(long coolDownMillis) {
if (inUse) {
return false;
}
if (cooling) {
return System.currentTimeMillis() - releaseTime >= coolDownMillis;
}
return true;
}
public void reset() {
this.inUse = false;
this.cooling = false;
this.releaseTime = 0;
}
public int getPort() {
return port;
}
public long getAllocateTime() {
return allocateTime;
}
public long getReleaseTime() {
return releaseTime;
}
}
private final int minPort;
private final int maxPort;
private final long coolDownMillis; // 冷却时间(毫秒)
// 端口信息映射
private final Map<Integer, PortInfo> portInfoMap;
// 可用端口队列(线程安全)
private final BlockingQueue<PortInfo> availablePorts;
// 定时检查冷却端口
private final ScheduledExecutorService scheduler;
/**
* 构造函数
*
* @param minPort 起始端口(包含)
* @param maxPort 结束端口(包含)
* @param coolDownSeconds 冷却时间(秒)
*/
public PortPoolManager(int minPort, int maxPort, int coolDownSeconds) {
if (minPort <= 0 || maxPort <= 0 || minPort > maxPort) {
throw new IllegalArgumentException("Invalid port range");
}
if (coolDownSeconds <= 0) {
throw new IllegalArgumentException("Cool down time must be positive");
}
this.minPort = minPort;
this.maxPort = maxPort;
this.coolDownMillis = coolDownSeconds * 1000L;
this.portInfoMap = new ConcurrentHashMap<>();
this.availablePorts = new LinkedBlockingQueue<>();
this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread thread = new Thread(r, "port-pool-cooler");
thread.setDaemon(true);
return thread;
});
initializePortPool();
startCoolDownChecker();
}
/**
* 默认构造函数1分钟冷却时间
*
* @param minPort 起始端口(包含)
* @param maxPort 结束端口(包含)
*/
public PortPoolManager(int minPort, int maxPort) {
this(minPort, maxPort, 60);
}
/**
* 初始化端口池
*/
private void initializePortPool() {
log.info("Initializing port pool: {} -> {}, cool down: {}ms", minPort, maxPort, coolDownMillis);
for (int port = minPort; port <= maxPort; port++) {
PortInfo portInfo = new PortInfo(port);
portInfoMap.put(port, portInfo);
availablePorts.offer(portInfo);
}
log.info("Port pool initialized with {} ports", maxPort - minPort + 1);
}
/**
* 启动冷却端口检查器
*/
private void startCoolDownChecker() {
// 每10秒检查一次冷却端口
scheduler.scheduleAtFixedRate(() -> {
try {
checkAndReturnCoolingPorts();
} catch (Exception e) {
log.error("Error checking cooling ports", e);
}
}, 10, 10, TimeUnit.SECONDS);
}
/**
* 检查并返回已冷却的端口
*/
private void checkAndReturnCoolingPorts() {
int returnedCount = 0;
for (PortInfo portInfo : portInfoMap.values()) {
if (portInfo.cooling && portInfo.isAvailable(0)) {
// 冷却完成,重置状态并放回可用队列
portInfo.reset();
if (availablePorts.offer(portInfo)) {
returnedCount++;
log.debug("Port {} returned to pool after cooling", portInfo.getPort());
}
}
}
if (returnedCount > 0) {
log.info("Returned {} ports to pool after cooling", returnedCount);
}
}
/**
* 借用端口(阻塞等待)
*
* @return 可用的端口
* @throws InterruptedException 如果等待被中断
*/
public int borrow() throws Exception {
return borrow(0, TimeUnit.MILLISECONDS);
}
/**
* 借用端口(带超时)
*
* @param timeout 超时时间
* @param unit 时间单位
* @return 可用的端口
* @throws InterruptedException 如果等待被中断
*/
public int borrow(long timeout, TimeUnit unit) throws Exception {
PortInfo portInfo;
if (timeout > 0) {
portInfo = availablePorts.poll(timeout, unit);
} else {
portInfo = availablePorts.take();
}
if (portInfo == null) {
throw new TimeoutException("No available ports in pool");
}
portInfo.allocate();
log.debug("Port {} borrowed", portInfo.getPort());
return portInfo.getPort();
}
/**
* 归还端口
*
* @param port 要归还的端口
* @throws IllegalArgumentException 如果端口不在池中
*/
public void release(int port) {
PortInfo portInfo = portInfoMap.get(port);
if (portInfo == null) {
throw new IllegalArgumentException("Port " + port + " is not managed by this pool");
}
if (!portInfo.inUse) {
log.warn("Port {} is not in use, ignore release", port);
return;
}
portInfo.release();
log.debug("Port {} released, entering cooling period", port);
}
/**
* 强制归还端口(不进入冷却期)
*
* @param port 要归还的端口
*/
public void releaseImmediately(int port) {
PortInfo portInfo = portInfoMap.get(port);
if (portInfo == null) {
throw new IllegalArgumentException("Port " + port + " is not managed by this pool");
}
boolean wasInUse = portInfo.inUse;
portInfo.reset();
if (wasInUse) {
// 如果之前在使用中,放回可用队列
availablePorts.offer(portInfo);
log.debug("Port {} released immediately (no cooling)", port);
}
}
/**
* 获取池中的端口总数
*
* @return 端口总数
*/
public int getTotalPorts() {
return portInfoMap.size();
}
/**
* 获取可用端口数
*
* @return 可用端口数
*/
public int getAvailablePortCount() {
return availablePorts.size();
}
/**
* 获取使用中的端口数
*
* @return 使用中的端口数
*/
public int getInUsePortCount() {
int count = 0;
for (PortInfo portInfo : portInfoMap.values()) {
if (portInfo.inUse) {
count++;
}
}
return count;
}
/**
* 获取冷却中的端口数
*
* @return 冷却中的端口数
*/
public int getCoolingPortCount() {
int count = 0;
for (PortInfo portInfo : portInfoMap.values()) {
if (portInfo.cooling && !portInfo.inUse) {
count++;
}
}
return count;
}
/**
* 检查端口是否在池中
*
* @param port 端口
* @return 是否在池中
*/
public boolean containsPort(int port) {
return portInfoMap.containsKey(port);
}
/**
* 检查端口是否可用
*
* @param port 端口
* @return 是否可用
*/
public boolean isAvailable(int port) {
PortInfo portInfo = portInfoMap.get(port);
return portInfo != null && portInfo.isAvailable(coolDownMillis);
}
/**
* 获取端口状态
*
* @param port 端口
* @return 状态字符串AVAILABLE, IN_USE, COOLING, NOT_FOUND
*/
public String getPortStatus(int port) {
PortInfo portInfo = portInfoMap.get(port);
if (portInfo == null) {
return "NOT_FOUND";
}
if (portInfo.inUse) {
return "IN_USE";
}
if (portInfo.cooling) {
return "COOLING";
}
return "AVAILABLE";
}
/**
* 关闭端口池
*/
public void shutdown() {
log.info("Shutting down port pool manager");
scheduler.shutdown();
try {
if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
scheduler.shutdownNow();
}
} catch (InterruptedException e) {
scheduler.shutdownNow();
Thread.currentThread().interrupt();
}
log.info("Port pool manager shutdown complete");
}
/**
* 获取端口池统计信息
*
* @return 统计信息
*/
public PortPoolStats getStats() {
int inUse = getInUsePortCount();
int available = getAvailablePortCount();
int cooling = getCoolingPortCount();
return new PortPoolStats(
minPort,
maxPort,
getTotalPorts(),
available,
inUse,
cooling,
coolDownMillis / 1000
);
}
/**
* 端口池统计信息
*/
public record PortPoolStats(
int minPort,
int maxPort,
int totalPorts,
int availablePorts,
int inUsePorts,
int coolingPorts,
long coolDownSeconds
) {
@Override
public String toString() {
return String.format(
"PortPoolStats{range=%d-%d, total=%d, available=%d, inUse=%d, cooling=%d, coolDown=%ds}",
minPort, maxPort, totalPorts, availablePorts, inUsePorts, coolingPorts, coolDownSeconds
);
}
}
}

View File

@@ -0,0 +1,208 @@
package com.xspaceagi.sandbox.infra.network;
import com.xspaceagi.sandbox.infra.network.protocol.Constants;
import io.netty.channel.Channel;
import io.netty.util.AttributeKey;
import lombok.Builder;
import lombok.Data;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.InetSocketAddress;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 代理服务连接管理(代理客户端连接+用户请求连接)
*
* @author fengfei
*/
public class ProxyChannelManager {
private static final Logger logger = LoggerFactory.getLogger(ProxyChannelManager.class);
public static final AttributeKey<Map<String, Channel>> USER_CHANNELS = AttributeKey.newInstance("user_channels");
private static final AttributeKey<String> REQUEST_LAN_INFO = AttributeKey.newInstance("request_lan_info");
public static final AttributeKey<List<Integer>> CHANNEL_PORT = AttributeKey.newInstance("channel_port");
public static final AttributeKey<String> CHANNEL_CLIENT_KEY = AttributeKey.newInstance("channel_client_key");
private static final Map<Integer, Channel> portCmdChannelMapping = new ConcurrentHashMap<Integer, Channel>();
private static final Map<String, Channel> cmdChannels = new ConcurrentHashMap<String, Channel>();
private static final Map<Integer, LanInfoHolder> portLanInfoMapping = new ConcurrentHashMap<>();
public static void addCmdChannel(List<Integer> ports, String clientKey, Channel channel) {
if (ports == null) {
throw new IllegalArgumentException("port can not be null");
}
// 客户端proxy-client相对较少这里同步的比较重
// 保证服务器对外端口与客户端到服务器的连接关系在临界情况时调用removeChannel(Channel channel)时不出问题
synchronized (portCmdChannelMapping) {
for (int port : ports) {
portCmdChannelMapping.put(port, channel);
}
}
channel.attr(CHANNEL_PORT).set(ports);
channel.attr(CHANNEL_CLIENT_KEY).set(clientKey);
channel.attr(USER_CHANNELS).set(new ConcurrentHashMap<String, Channel>());
cmdChannels.put(clientKey, channel);
}
/**
* 代理客户端连接断开后清除关系
*
* @param channel
*/
public static void removeCmdChannel(Channel channel) {
logger.info("channel closed, clear user channels, {}", channel);
if (channel.attr(CHANNEL_PORT).get() == null) {
return;
}
String clientKey = channel.attr(CHANNEL_CLIENT_KEY).get();
Channel channel0 = cmdChannels.remove(clientKey);
logger.info("remove cmd channel, {}", channel0);
List<Integer> ports = channel.attr(CHANNEL_PORT).get();
for (int port : ports) {
Channel proxyChannel = portCmdChannelMapping.remove(port);
if (proxyChannel == null) {
continue;
}
// 在执行断连之前新的连接已经连上来了
if (!proxyChannel.id().equals(channel.id())) {
portCmdChannelMapping.put(port, proxyChannel);
}
}
if (channel.isActive()) {
logger.info("disconnect proxy channel {}", channel);
channel.close();
}
Map<String, Channel> userChannels = getUserChannels(channel);
for (String s : new ArrayList<>(userChannels.keySet())) {
Channel userChannel = userChannels.get(s);
if (userChannel.isActive()) {
userChannel.close();
logger.info("disconnect user channel {}", userChannel);
}
}
}
public static Channel getCmdChannel(Integer port) {
return portCmdChannelMapping.get(port);
}
public static Channel getCmdChannel(String clientKey) {
return cmdChannels.get(clientKey);
}
/**
* 增加用户连接与代理客户端连接关系
*
* @param cmdChannel
* @param userId
* @param userChannel
*/
public static void addUserChannelToCmdChannel(Channel cmdChannel, String userId, Channel userChannel) {
InetSocketAddress sa = (InetSocketAddress) userChannel.localAddress();
//String lanInfo = ProxyConfig.getInstance().getLanInfo(sa.getPort());
userChannel.attr(Constants.USER_ID).set(userId);
//userChannel.attr(REQUEST_LAN_INFO).set(lanInfo);
cmdChannel.attr(USER_CHANNELS).get().put(userId, userChannel);
}
/**
* 删除用户连接与代理客户端连接关系
*
* @param cmdChannel
* @param userId
* @return
*/
public static Channel removeUserChannelFromCmdChannel(Channel cmdChannel, String userId) {
if (cmdChannel.attr(USER_CHANNELS).get() == null) {
return null;
}
synchronized (cmdChannel) {
return cmdChannel.attr(USER_CHANNELS).get().remove(userId);
}
}
/**
* 根据代理客户端连接与用户编号获取用户连接
*
* @param cmdChannel
* @param userId
* @return
*/
public static Channel getUserChannel(Channel cmdChannel, String userId) {
return cmdChannel.attr(USER_CHANNELS).get().get(userId);
}
/**
* 获取用户编号
*
* @param userChannel
* @return
*/
public static String getUserChannelUserId(Channel userChannel) {
return userChannel.attr(Constants.USER_ID).get();
}
/**
* 获取用户请求的内网IP端口信息
*
* @param userChannel
* @return
*/
public static String getUserChannelRequestLanInfo(Channel userChannel) {
return userChannel.attr(REQUEST_LAN_INFO).get();
}
/**
* 获取代理控制客户端连接绑定的所有用户连接
*
* @param cmdChannel
* @return
*/
public static Map<String, Channel> getUserChannels(Channel cmdChannel) {
return cmdChannel.attr(USER_CHANNELS).get();
}
public static void addPortLanInfo(int port, String lanInfo, boolean isVnc) {
portLanInfoMapping.put(port, new LanInfoHolder(lanInfo, isVnc));
}
public static String getPortLanInfo(int port) {
LanInfoHolder lanInfoHolder = portLanInfoMapping.get(port);
return lanInfoHolder != null ? lanInfoHolder.getLanInfo() : null;
}
public static boolean isVncPort(int port) {
LanInfoHolder lanInfoHolder = portLanInfoMapping.get(port);
return lanInfoHolder != null && lanInfoHolder.isVnc();
}
public static void removePortLanInfo(int port) {
portLanInfoMapping.remove(port);
}
@Data
@Builder
private static class LanInfoHolder {
private String lanInfo;
private boolean isVnc;
}
}

View File

@@ -0,0 +1,78 @@
package com.xspaceagi.sandbox.infra.network;
import com.xspaceagi.sandbox.infra.dao.service.SandboxProxyService;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.HttpServerCodec;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* 模型代理服务器
*/
@Slf4j
@Component
public class ReverseHttpProxyServer {
@Value("${sandbox.temp-link.proxy.port:18087}")
private int port;
@Resource
private SandboxProxyService sandboxProxyService;
private EventLoopGroup bossGroup;
private EventLoopGroup workerGroup;
private Channel serverChannel;
@PostConstruct
public void start() throws Exception {
bossGroup = new NioEventLoopGroup(1);
workerGroup = new NioEventLoopGroup(Runtime.getRuntime().availableProcessors() * 2);
try {
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline p = ch.pipeline();
p.addLast("httpServerCodec", new HttpServerCodec());
p.addLast(new TempLinkHttpProxyHandler(sandboxProxyService));
}
})
.option(ChannelOption.SO_BACKLOG, 128)
.childOption(ChannelOption.SO_KEEPALIVE, true);
ChannelFuture f = b.bind(port).sync();
serverChannel = f.channel();
log.info("sandbox proxy server started on port {}", port);
} catch (Exception e) {
log.error("Failed to start sandbox proxy server", e);
shutdown();
throw e;
}
}
@PreDestroy
public void shutdown() {
if (serverChannel != null) {
serverChannel.close();
}
if (bossGroup != null) {
bossGroup.shutdownGracefully();
}
if (workerGroup != null) {
workerGroup.shutdownGracefully();
}
}
}

View File

@@ -0,0 +1,223 @@
package com.xspaceagi.sandbox.infra.network;
import com.xspaceagi.sandbox.infra.config.ReverseServerProperties;
import com.xspaceagi.sandbox.infra.dao.service.SandboxConfigService;
import com.xspaceagi.sandbox.infra.network.protocol.IdleCheckHandler;
import com.xspaceagi.sandbox.infra.network.protocol.ProxyMessageDecoder;
import com.xspaceagi.sandbox.infra.network.protocol.ProxyMessageEncoder;
import com.xspaceagi.system.spec.utils.RedisUtil;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.handler.ssl.SslHandler;
import io.netty.handler.stream.ChunkedWriteHandler;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import jakarta.annotation.Resource;
import lombok.Getter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLEngine;
import java.net.BindException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class ReverseServerContainer {
/**
* max packet is 2M.
*/
private static final int MAX_FRAME_LENGTH = 2 * 1024 * 1024;
private static final int LENGTH_FIELD_OFFSET = 0;
private static final int LENGTH_FIELD_LENGTH = 4;
private static final int INITIAL_BYTES_TO_STRIP = 0;
private static final int LENGTH_ADJUSTMENT = 0;
private static final Logger logger = LoggerFactory.getLogger(ReverseServerContainer.class);
private NioEventLoopGroup serverWorkerGroup;
private NioEventLoopGroup serverBossGroup;
private ServerBootstrap clientBootstrap;
private volatile boolean started = false;
@Resource
private SandboxConfigService sandboxConfigService;
@Getter
private PortPoolManager portPoolManager;
@Getter
@Resource
private ReverseServerProperties reverseServerProperties;
@Resource
private RedisUtil redisUtil;
private final Map<Integer, ChannelFuture> channelFutureMap = new ConcurrentHashMap<>();
@PostConstruct
public void init() {
synchronized (ReverseServerContainer.class) {
if (!started) {
start();
started = true;
}
}
}
/**
* 最大HTTP内容长度 (8MB)
*/
private static final int MAX_HTTP_CONTENT_LENGTH = 8 * 1024 * 1024;
/**
* 最大WebSocket帧长度 (10MB)
*/
private static final int MAX_FRAME_SIZE = 10 * 1024 * 1024;
public void start() {
serverBossGroup = new NioEventLoopGroup(Runtime.getRuntime().availableProcessors());
serverWorkerGroup = new NioEventLoopGroup(Runtime.getRuntime().availableProcessors() * 2);
clientBootstrap = new ServerBootstrap();
clientBootstrap.group(serverBossGroup, serverWorkerGroup).channel(NioServerSocketChannel.class).childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) {
int port = ch.localAddress().getPort();
if (ProxyChannelManager.isVncPort(port)) {
// HTTP Codec - 解码HTTP请求
ch.pipeline().addLast("httpCodec", new HttpServerCodec());
// HTTP Object Aggregator - 将HTTP消息聚合为完整请求
ch.pipeline().addLast("httpAggregator", new HttpObjectAggregator(MAX_HTTP_CONTENT_LENGTH));
// Chunked Write Handler - 支持大文件传输
ch.pipeline().addLast("chunkedWrite", new ChunkedWriteHandler());
// WebSocket Server Protocol Handler - 处理握手和协议升级
ch.pipeline().addLast("wsProtocol", new WebSocketServerProtocolHandler(
"/websockify",
null, // subprotocols
true, // allowExtensions
MAX_FRAME_SIZE // maxFrameSize
));
// WebSocket Frame to ByteBuf Handler - WebSocket与TCP转换层
ch.pipeline().addLast("wsConverter", new WebSocketFrameToByteBufHandler());
}
ch.pipeline().addLast(new UserChannelHandler());
}
});
initializeSSLTCPTransport(new SslContextCreator().initSSLContext());
portPoolManager = new PortPoolManager(reverseServerProperties.getInner().getMinPort(), reverseServerProperties.getInner().getMaxPort(), 30);
}
private void initializeSSLTCPTransport(final SSLContext sslContext) {
ServerBootstrap b = new ServerBootstrap();
b.group(serverBossGroup, serverWorkerGroup).channel(NioServerSocketChannel.class).childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();
try {
pipeline.addLast("ssl", createSslHandler(sslContext));
ch.pipeline().addLast(new ProxyMessageDecoder(MAX_FRAME_LENGTH, LENGTH_FIELD_OFFSET, LENGTH_FIELD_LENGTH, LENGTH_ADJUSTMENT, INITIAL_BYTES_TO_STRIP));
ch.pipeline().addLast(new ProxyMessageEncoder());
ch.pipeline().addLast(new IdleCheckHandler(IdleCheckHandler.READ_IDLE_TIME, IdleCheckHandler.WRITE_IDLE_TIME, 0));
ch.pipeline().addLast(new ServerChannelHandler(sandboxConfigService, ReverseServerContainer.this));
} catch (Throwable th) {
logger.error("Severe error during pipeline creation", th);
throw th;
}
}
});
try {
// Bind and start to accept incoming connections.
b.bind("0.0.0.0", reverseServerProperties.getOuter().getPort()).get();
logger.info("proxy ssl server start on port {}", reverseServerProperties.getOuter().getPort());
} catch (Exception ex) {
logger.error("An interruptedException was caught while initializing server", ex);
}
}
public boolean startUserPort(String host, Integer port) {
Assert.notNull(port, "port must not be null");
try {
ChannelFuture future = clientBootstrap.bind(port);
future.get();
channelFutureMap.put(port, future);
logger.info("bind user port {}", port);
} catch (Exception ex) {
// BindException表示该端口已经绑定过
if (!(ex.getCause() instanceof BindException)) {
logger.error("An interruptedException was caught while initializing server", ex);
throw new RuntimeException(ex);
}
logger.warn("bind port {} error", port, ex);
return false;
}
return true;
}
public void releaseUserPort(Integer port) {
ChannelFuture channelFuture = channelFutureMap.get(port);
if (channelFuture != null) {
channelFuture.channel().close();
channelFutureMap.remove(port);
logger.info("release user port {}", port);
}
}
@PreDestroy
public void stop() {
serverBossGroup.shutdownGracefully();
serverWorkerGroup.shutdownGracefully();
}
private ChannelHandler createSslHandler(SSLContext sslContext) {
SSLEngine sslEngine = sslContext.createSSLEngine();
sslEngine.setUseClientMode(false);
return new SslHandler(sslEngine);
}
public void userSandboxOnlineTouch(String key) {
redisUtil.set("user:sandbox:status:" + key, String.valueOf(System.currentTimeMillis()), 60);
}
public Long getUserSandboxAliveTime(String key) {
Object value = redisUtil.get("user:sandbox:status:" + key);
if (value != null) {
return Long.parseLong(value.toString());
}
return null;
}
public void userSandboxOfflineTouch(String key) {
redisUtil.expire("user:sandbox:status:" + key, 0);
}
public void offlineClient(String key) {
Channel cmdChannel = ProxyChannelManager.getCmdChannel(key);
if (cmdChannel != null && cmdChannel.isActive()) {
cmdChannel.close();
}
}
}

View File

@@ -0,0 +1,337 @@
package com.xspaceagi.sandbox.infra.network;
import cn.hutool.core.collection.ConcurrentHashSet;
import com.alibaba.fastjson2.JSON;
import com.xspaceagi.sandbox.infra.dao.entity.SandboxConfig;
import com.xspaceagi.sandbox.infra.dao.service.SandboxConfigService;
import com.xspaceagi.sandbox.infra.dao.vo.SandboxConfigValue;
import com.xspaceagi.sandbox.infra.dao.vo.SandboxServerInfo;
import com.xspaceagi.sandbox.infra.network.protocol.Constants;
import com.xspaceagi.sandbox.infra.network.protocol.ProxyMessage;
import com.xspaceagi.system.spec.tenant.thread.TenantFunctions;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.*;
import io.netty.util.ReferenceCountUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.Set;
import static com.xspaceagi.sandbox.infra.network.ProxyChannelManager.*;
/**
* @author fengfei
*/
public class ServerChannelHandler extends SimpleChannelInboundHandler<ProxyMessage> {
private static final Logger logger = LoggerFactory.getLogger(ServerChannelHandler.class);
private static final Set<String> authLockSet = new ConcurrentHashSet<>();
private final SandboxConfigService sandboxConfigService;
private final ReverseServerContainer reverseServerContainer;
public ServerChannelHandler(SandboxConfigService sandboxConfigService, ReverseServerContainer reverseServerContainer) {
this.sandboxConfigService = sandboxConfigService;
this.reverseServerContainer = reverseServerContainer;
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, ProxyMessage proxyMessage) throws Exception {
logger.debug("ProxyMessage received {}", proxyMessage.getType());
switch (proxyMessage.getType()) {
case ProxyMessage.TYPE_HEARTBEAT:
handleHeartbeatMessage(ctx, proxyMessage);
break;
case ProxyMessage.C_TYPE_AUTH:
try {
if (!authLockSet.add(proxyMessage.getUri())) {
ctx.channel().close();
return;
}
handleAuthMessage(ctx, proxyMessage);
} finally {
authLockSet.remove(proxyMessage.getUri());
}
break;
case ProxyMessage.TYPE_CONNECT:
handleConnectMessage(ctx, proxyMessage);
break;
case ProxyMessage.TYPE_DISCONNECT:
handleDisconnectMessage(ctx, proxyMessage);
break;
case ProxyMessage.P_TYPE_TRANSFER:
handleTransferMessage(ctx, proxyMessage);
break;
default:
break;
}
}
private void handleTransferMessage(ChannelHandlerContext ctx, ProxyMessage proxyMessage) {
Channel userChannel = ctx.channel().attr(Constants.NEXT_CHANNEL).get();
if (userChannel != null) {
ByteBuf buf = ctx.alloc().buffer(proxyMessage.getData().length);
buf.writeBytes(proxyMessage.getData());
userChannel.writeAndFlush(buf);
}
}
private void handleDisconnectMessage(ChannelHandlerContext ctx, ProxyMessage proxyMessage) {
String clientKey = ctx.channel().attr(Constants.CLIENT_KEY).get();
// 代理连接没有连上服务器由控制连接发送用户端断开连接消息
if (clientKey == null) {
String userId = proxyMessage.getUri();
Channel userChannel = ProxyChannelManager.removeUserChannelFromCmdChannel(ctx.channel(), userId);
if (userChannel != null) {
// 数据发送完成后再关闭连接解决http1.0数据传输问题
userChannel.writeAndFlush(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE);
}
return;
}
Channel cmdChannel = ProxyChannelManager.getCmdChannel(clientKey);
if (cmdChannel == null) {
logger.warn("ConnectMessage:error cmd channel key {}", ctx.channel().attr(Constants.CLIENT_KEY).get());
return;
}
Channel userChannel = ProxyChannelManager.removeUserChannelFromCmdChannel(cmdChannel, ctx.channel().attr(Constants.USER_ID).get());
if (userChannel != null) {
// 数据发送完成后再关闭连接解决http1.0数据传输问题
userChannel.writeAndFlush(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE);
ctx.channel().attr(Constants.NEXT_CHANNEL).set(null);
ctx.channel().attr(Constants.CLIENT_KEY).set(null);
ctx.channel().attr(Constants.USER_ID).set(null);
}
}
private void handleConnectMessage(ChannelHandlerContext ctx, ProxyMessage proxyMessage) {
String uri = proxyMessage.getUri();
if (uri == null) {
ctx.channel().close();
logger.warn("ConnectMessage:null uri");
return;
}
String[] tokens = uri.split("@");
if (tokens.length != 2) {
ctx.channel().close();
logger.warn("ConnectMessage:error uri");
return;
}
Channel cmdChannel = ProxyChannelManager.getCmdChannel(tokens[1]);
if (cmdChannel == null) {
ctx.channel().close();
logger.warn("ConnectMessage:error cmd channel key {}", tokens[1]);
return;
}
Channel userChannel = ProxyChannelManager.getUserChannel(cmdChannel, tokens[0]);
if (userChannel != null) {
ctx.channel().attr(Constants.USER_ID).set(tokens[0]);
ctx.channel().attr(Constants.CLIENT_KEY).set(tokens[1]);
ctx.channel().attr(Constants.NEXT_CHANNEL).set(userChannel);
userChannel.attr(Constants.NEXT_CHANNEL).set(ctx.channel());
// 读取http代理 TempLinkHttpProxyHandler 连接时的缓存数据
Queue<Object> objects = userChannel.attr(Constants.MESSAGE_QUEUE).get();
if (objects != null) {
while (!objects.isEmpty()) {
Object poll = objects.poll();
if (poll instanceof ByteBuf buf) {
byte[] bytes = new byte[buf.readableBytes()];
buf.readBytes(bytes);
String userId = ProxyChannelManager.getUserChannelUserId(userChannel);
ProxyMessage proxyMessage0 = new ProxyMessage();
proxyMessage0.setType(ProxyMessage.P_TYPE_TRANSFER);
proxyMessage0.setUri(userId);
proxyMessage0.setData(bytes);
ctx.channel().writeAndFlush(proxyMessage0);
}
ReferenceCountUtil.release(poll);
}
}
// 代理客户端与后端服务器连接成功,修改用户连接为可读状态
userChannel.config().setOption(ChannelOption.AUTO_READ, true);
}
}
private void handleHeartbeatMessage(ChannelHandlerContext ctx, ProxyMessage proxyMessage) {
ProxyMessage heartbeatMessage = new ProxyMessage();
heartbeatMessage.setSerialNumber(heartbeatMessage.getSerialNumber());
heartbeatMessage.setType(ProxyMessage.TYPE_HEARTBEAT);
logger.debug("response heartbeat message {}", ctx.channel());
ctx.channel().writeAndFlush(heartbeatMessage);
String clientKey = ctx.channel().attr(CHANNEL_CLIENT_KEY).get();
Map<String, Channel> channelMap = ctx.channel().attr(USER_CHANNELS).get();
if (clientKey != null && channelMap != null) {
reverseServerContainer.userSandboxOnlineTouch(clientKey);
}
}
private void handleAuthMessage(ChannelHandlerContext ctx, ProxyMessage proxyMessage) {
String clientKey = proxyMessage.getUri();
SandboxConfig sandboxConfig = sandboxConfigService.queryUserConfigByKey(clientKey);
if (sandboxConfig == null) {
logger.warn("error clientKey {}, {}", clientKey, ctx.channel());
ctx.channel().close();
return;
}
Channel channel = ProxyChannelManager.getCmdChannel(clientKey);
if (channel != null) {
logger.warn("exist channel for key {}, {}", clientKey, channel);
ctx.channel().close();
channel.close();
return;
}
SandboxConfigValue configValue = JSON.parseObject(sandboxConfig.getConfigValue(), SandboxConfigValue.class);
if (configValue == null) {
logger.warn("error configValue for key {}, {}", clientKey, ctx.channel());
ctx.channel().close();
return;
}
Integer agentInnerPort = null;
Integer vncInnerPort = null;
Integer fileServerPort = null;
try {
agentInnerPort = acquireUserPort();
vncInnerPort = acquireUserPort();
fileServerPort = acquireUserPort();
URL url;
try {
url = new URL(configValue.getHostWithScheme());
} catch (MalformedURLException e) {
logger.warn("error get url for key {}, {}", clientKey, ctx.channel(), e);
ctx.channel().close();
return;
}
ProxyChannelManager.addPortLanInfo(agentInnerPort, url.getHost() + ":" + configValue.getAgentPort(), false);
ProxyChannelManager.addPortLanInfo(vncInnerPort, url.getHost() + ":" + configValue.getVncPort(), false);//后续支持直连vnc
ProxyChannelManager.addPortLanInfo(fileServerPort, url.getHost() + ":" + configValue.getFileServerPort(), false);
String innerHost = reverseServerContainer.getReverseServerProperties().getInner().getServiceHost();
SandboxServerInfo sandboxServerInfo = new SandboxServerInfo();
sandboxServerInfo.setScheme(url.getProtocol());
sandboxServerInfo.setHost(innerHost);
sandboxServerInfo.setAgentPort(agentInnerPort);
sandboxServerInfo.setVncPort(vncInnerPort);
sandboxServerInfo.setFileServerPort(fileServerPort);
sandboxServerInfo.setApiKey(configValue.getApiKey());
SandboxConfig sandboxConfigUpdate = new SandboxConfig();
sandboxConfigUpdate.setId(sandboxConfig.getId());
sandboxConfigUpdate.setServerInfo(JSON.toJSONString(sandboxServerInfo));
TenantFunctions.runWithIgnoreCheck(() -> sandboxConfigService.updateById(sandboxConfigUpdate));
List<Integer> ports = List.of(agentInnerPort, vncInnerPort, fileServerPort);
logger.info("set port => channel, {}, {}, {}", clientKey, ports, ctx.channel());
ProxyChannelManager.addCmdChannel(ports, clientKey, ctx.channel());
reverseServerContainer.userSandboxOnlineTouch(clientKey);
} catch (Exception e) {
if (agentInnerPort != null) {
reverseServerContainer.getPortPoolManager().release(agentInnerPort);
}
if (vncInnerPort != null) {
reverseServerContainer.getPortPoolManager().release(vncInnerPort);
}
if (fileServerPort != null) {
reverseServerContainer.getPortPoolManager().release(fileServerPort);
}
logger.warn("error get port for key {}, {}", clientKey, ctx.channel(), e);
ctx.channel().close();
}
}
// acquire user port
private Integer acquireUserPort() throws Exception {
Integer port = null;
int i = 0;
while (i < 10) {
port = reverseServerContainer.getPortPoolManager().borrow();
boolean bind = reverseServerContainer.startUserPort(reverseServerContainer.getReverseServerProperties().getInner().getBindHost(), port);
if (!bind) {
logger.warn("port {} bind failed", port);
reverseServerContainer.getPortPoolManager().release(port);
port = null;
} else {
break;
}
i++;
}
if (port == null) {
logger.warn("acquire port failed");
throw new RuntimeException("acquire port failed");
}
return port;
}
@Override
public void channelWritabilityChanged(ChannelHandlerContext ctx) throws Exception {
Channel userChannel = ctx.channel().attr(Constants.NEXT_CHANNEL).get();
if (userChannel != null) {
userChannel.config().setOption(ChannelOption.AUTO_READ, ctx.channel().isWritable());
}
super.channelWritabilityChanged(ctx);
}
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
Channel userChannel = ctx.channel().attr(Constants.NEXT_CHANNEL).get();
if (userChannel != null && userChannel.isActive()) {
String clientKey = ctx.channel().attr(Constants.CLIENT_KEY).get();
String userId = ctx.channel().attr(Constants.USER_ID).get();
Channel cmdChannel = ProxyChannelManager.getCmdChannel(clientKey);
if (cmdChannel != null) {
ProxyChannelManager.removeUserChannelFromCmdChannel(cmdChannel, userId);
} else {
logger.warn("null cmdChannel, clientKey is {}", clientKey);
}
// 数据发送完成后再关闭连接解决http1.0数据传输问题
userChannel.writeAndFlush(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE);
} else {
Map<String, Channel> stringChannelMap = ctx.channel().attr(USER_CHANNELS).get();
List<Integer> ports = ctx.channel().attr(CHANNEL_PORT).get();
if (ports != null) {
for (int port : ports) {
ProxyChannelManager.removePortLanInfo(port);
// 如果之前有连接在使用,避免客户端重试导致混乱,端口进入冷却期
if (stringChannelMap != null && !stringChannelMap.isEmpty()) {
reverseServerContainer.getPortPoolManager().release(port);
} else {
reverseServerContainer.getPortPoolManager().releaseImmediately(port);
}
reverseServerContainer.releaseUserPort(port);
}
}
String clientKey = ctx.channel().attr(CHANNEL_CLIENT_KEY).get();
if (clientKey != null) {
reverseServerContainer.userSandboxOfflineTouch(clientKey);
}
ProxyChannelManager.removeCmdChannel(ctx.channel());
}
super.channelInactive(ctx);
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
logger.error("exception caught", cause);
super.exceptionCaught(ctx, cause);
}
}

View File

@@ -0,0 +1,77 @@
package com.xspaceagi.sandbox.infra.network;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import java.io.*;
import java.net.URL;
import java.security.*;
import java.security.cert.CertificateException;
public class SslContextCreator {
private static Logger logger = LoggerFactory.getLogger(SslContextCreator.class);
public SSLContext initSSLContext() {
logger.info("Checking SSL configuration properties...");
final String jksPath = "ssl.jks";
logger.info("Initializing SSL context. KeystorePath = {}.", jksPath);
// if we have the port also the jks then keyStorePassword and
// keyManagerPassword
// has to be defined
final String keyStorePassword = "123456";
final String keyManagerPassword = "123456";
// if client authentification is enabled a trustmanager needs to be
// added to the ServerContext
boolean needsClientAuth = false;
try {
logger.info("Loading keystore. KeystorePath = {}.", jksPath);
try (InputStream jksInputStream = jksDatastore(jksPath)) {
if (jksInputStream == null) {
logger.warn("The keystore input stream is null. The SSL context won't be initialized.");
return null;
}
SSLContext serverContext = SSLContext.getInstance("TLS");
final KeyStore ks = KeyStore.getInstance("JKS");
ks.load(jksInputStream, keyStorePassword.toCharArray());
logger.info("Initializing key manager...");
final KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(ks, keyManagerPassword.toCharArray());
// init sslContext
logger.info("Initializing SSL context...");
serverContext.init(kmf.getKeyManagers(), null, null);
logger.info("The SSL context has been initialized successfully.");
return serverContext;
}
} catch (NoSuchAlgorithmException | UnrecoverableKeyException | CertificateException | KeyStoreException
| KeyManagementException | IOException ex) {
logger.error("Unable to initialize SSL context. Cause = {}, errorMessage = {}.", ex.getCause(),
ex.getMessage());
return null;
}
}
private InputStream jksDatastore(String jksPath) throws FileNotFoundException {
URL jksUrl = getClass().getClassLoader().getResource(jksPath);
if (jksUrl != null) {
logger.info("Starting with jks at {}, jks normal {}", jksUrl.toExternalForm(), jksUrl);
return getClass().getClassLoader().getResourceAsStream(jksPath);
}
logger.warn("No keystore has been found in the bundled resources. Scanning filesystem...");
File jksFile = new File(jksPath);
if (jksFile.exists()) {
logger.info("Loading external keystore. Url = {}.", jksFile.getAbsolutePath());
return new FileInputStream(jksFile);
}
logger.warn("The keystore file does not exist. Url = {}.", jksFile.getAbsolutePath());
return null;
}
}

View File

@@ -0,0 +1,189 @@
package com.xspaceagi.sandbox.infra.network;
import com.xspaceagi.sandbox.infra.dao.service.SandboxProxyService;
import com.xspaceagi.sandbox.infra.dao.vo.SandboxProxyBackend;
import com.xspaceagi.sandbox.infra.network.protocol.Constants;
import com.xspaceagi.sandbox.infra.network.protocol.ProxyMessage;
import com.xspaceagi.system.spec.cache.SimpleJvmHashCache;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.buffer.Unpooled;
import io.netty.channel.*;
import io.netty.handler.codec.http.*;
import io.netty.util.CharsetUtil;
import io.netty.util.ReferenceCountUtil;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.net.ssl.SSLHandshakeException;
import java.net.InetAddress;
import java.util.LinkedList;
import java.util.Queue;
import static com.xspaceagi.sandbox.infra.network.UserChannelHandler.newUserId;
/**
* 客户端临时代理
*/
@Slf4j
public class TempLinkHttpProxyHandler extends ChannelInboundHandlerAdapter {
private static final Logger logger = LoggerFactory.getLogger(TempLinkHttpProxyHandler.class);
private final SandboxProxyService sandboxProxyService;
private final Queue<Object> receivedLastMessagesWhenConnect = new LinkedList<>();
public TempLinkHttpProxyHandler(SandboxProxyService sandboxProxyService) {
this.sandboxProxyService = sandboxProxyService;
}
@Override
public void channelRead(final ChannelHandlerContext ctx, final Object msg) {
if (msg instanceof HttpRequest) {
DefaultHttpRequest request = (DefaultHttpRequest) msg;
String host = request.headers().get("Host");
//获取域名开头字符串
String proxyKey = extractDomainPrefix(host);
SandboxProxyBackend sandboxProxyBackend = proxyKey == null ? null : sandboxProxyService.getBackendByProxyKey(proxyKey);
if (sandboxProxyBackend == null) {
sendError(ctx, "Invalid domain", msg);
return;
}
ctx.channel().pipeline().remove("httpServerCodec");
Channel cmdChannel = ProxyChannelManager.getCmdChannel(sandboxProxyBackend.getSandboxConfigKey());
if (cmdChannel == null) {
log.warn("no cmd channel {}", sandboxProxyBackend);
// 该端口还没有代理客户端
sendError(ctx, "Claw is offline", msg);
return;
} else {
String userId = newUserId();
Channel userChannel = ctx.channel();
// 用户连接到代理服务器时,设置用户连接不可读,等待代理后端服务器连接成功后再改变为可读状态
userChannel.config().setOption(ChannelOption.AUTO_READ, false);
ProxyChannelManager.addUserChannelToCmdChannel(cmdChannel, userId, userChannel);
ProxyMessage proxyMessage = new ProxyMessage();
proxyMessage.setType(ProxyMessage.TYPE_CONNECT);
proxyMessage.setUri(userId);
proxyMessage.setData((sandboxProxyBackend.getBackendHost() + ":" + sandboxProxyBackend.getBackendPort()).getBytes());
cmdChannel.writeAndFlush(proxyMessage);
}
ByteBuf firstRequest = convertToByteBuf(request);
receivedLastMessagesWhenConnect.offer(firstRequest);
ctx.channel().attr(Constants.MESSAGE_QUEUE).set(receivedLastMessagesWhenConnect);
ReferenceCountUtil.release(request);
} else {
Channel targetChannel = ctx.channel().attr(Constants.NEXT_CHANNEL).get();
if (targetChannel != null) {
if (msg instanceof ByteBuf buf) {
byte[] bytes = new byte[buf.readableBytes()];
buf.readBytes(bytes);
String userId = ProxyChannelManager.getUserChannelUserId(ctx.channel());
ProxyMessage proxyMessage0 = new ProxyMessage();
proxyMessage0.setType(ProxyMessage.P_TYPE_TRANSFER);
proxyMessage0.setUri(userId);
proxyMessage0.setData(bytes);
targetChannel.writeAndFlush(proxyMessage0);
ReferenceCountUtil.release(buf);
}
} else {
if (msg instanceof HttpContent) {
ByteBuf content = ((HttpContent) msg).content();
receivedLastMessagesWhenConnect.offer(content);
} else if (msg instanceof ByteBuf) {
receivedLastMessagesWhenConnect.offer(msg);
} else {
log.warn("unexpected message type {}", msg.getClass());
ReferenceCountUtil.release(msg);
}
}
}
}
private void sendError(ChannelHandlerContext ctx, String message, Object msg) {
FullHttpResponse resp = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.NOT_FOUND,
Unpooled.copiedBuffer("{\"error\":{\"message\":\"" + message + "\"}}", CharsetUtil.UTF_8));
resp.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/json; charset=UTF-8");
ctx.channel().writeAndFlush(resp).addListener(ChannelFutureListener.CLOSE);
ReferenceCountUtil.release(msg);
}
@Override
public void channelWritabilityChanged(ChannelHandlerContext ctx) throws Exception {
Channel targetChannel = ctx.channel().attr(Constants.NEXT_CHANNEL).get();
if (targetChannel != null) {
targetChannel.config().setOption(ChannelOption.AUTO_READ, ctx.channel().isWritable());
}
super.channelWritabilityChanged(ctx);
}
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
super.channelActive(ctx);
}
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
Channel targetChannel = ctx.channel().attr(Constants.NEXT_CHANNEL).get();
if (targetChannel != null) {
try {
targetChannel.writeAndFlush(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE);
} catch (Exception ignored) {
}
}
super.channelInactive(ctx);
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
if (cause.getCause() != null && (cause.getCause() instanceof SSLHandshakeException)) {
logger.warn("SSLHandshakeException: {}", cause.getCause().getMessage());
return;
}
super.exceptionCaught(ctx, cause);
}
private static String extractDomainPrefix(String domain) {
if (domain == null || domain.isEmpty()) {
return null;
}
int dotIndex = domain.indexOf('.');
if (dotIndex == -1) {
return domain;
}
return domain.substring(0, dotIndex);
}
// 方法1: 将HttpRequest序列化为ByteBuf
public static ByteBuf convertToByteBuf(HttpRequest request) {
ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer();
try {
// 写入请求行
buffer.writeBytes(request.method().name().getBytes());
buffer.writeByte(' ');
buffer.writeBytes(request.uri().getBytes());
buffer.writeByte(' ');
buffer.writeBytes(request.protocolVersion().text().getBytes());
buffer.writeBytes("\r\n".getBytes());
// 写入请求头
request.headers().forEach(entry -> {
buffer.writeBytes(entry.getKey().getBytes());
buffer.writeBytes(": ".getBytes());
buffer.writeBytes(entry.getValue().getBytes());
buffer.writeBytes("\r\n".getBytes());
});
// 写入空行
buffer.writeBytes("\r\n".getBytes());
return buffer;
} catch (Exception e) {
buffer.release();
throw e;
}
}
}

View File

@@ -0,0 +1,146 @@
package com.xspaceagi.sandbox.infra.network;
import com.xspaceagi.sandbox.infra.network.protocol.Constants;
import com.xspaceagi.sandbox.infra.network.protocol.ProxyMessage;
import io.netty.buffer.ByteBuf;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelOption;
import io.netty.channel.SimpleChannelInboundHandler;
import lombok.extern.slf4j.Slf4j;
import java.net.InetSocketAddress;
import java.util.concurrent.atomic.AtomicLong;
/**
* 处理服务端 channel.
*/
@Slf4j
public class UserChannelHandler extends SimpleChannelInboundHandler<ByteBuf> {
private static final AtomicLong userIdProducer = new AtomicLong(0);
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
// 当出现异常就关闭连接
ctx.close();
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf buf) {
// 通知代理客户端
Channel userChannel = ctx.channel();
Channel proxyChannel = userChannel.attr(Constants.NEXT_CHANNEL).get();
if (proxyChannel == null) {
// 该端口还没有代理客户端
ctx.channel().close();
} else {
byte[] bytes = new byte[buf.readableBytes()];
buf.readBytes(bytes);
String userId = ProxyChannelManager.getUserChannelUserId(userChannel);
ProxyMessage proxyMessage = new ProxyMessage();
proxyMessage.setType(ProxyMessage.P_TYPE_TRANSFER);
proxyMessage.setUri(userId);
proxyMessage.setData(bytes);
proxyChannel.writeAndFlush(proxyMessage);
}
}
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
Channel userChannel = ctx.channel();
InetSocketAddress sa = (InetSocketAddress) userChannel.localAddress();
Channel cmdChannel = ProxyChannelManager.getCmdChannel(sa.getPort());
if (cmdChannel == null) {
log.warn("no cmd channel for port {}", sa.getPort());
// 该端口还没有代理客户端
ctx.channel().close();
} else {
String userId = newUserId();
String lanInfo = ProxyChannelManager.getPortLanInfo(sa.getPort());
if (lanInfo == null) {
log.warn("no lan info for port {}", sa.getPort());
ctx.channel().close();
return;
}
// 用户连接到代理服务器时,设置用户连接不可读,等待代理后端服务器连接成功后再改变为可读状态
userChannel.config().setOption(ChannelOption.AUTO_READ, false);
ProxyChannelManager.addUserChannelToCmdChannel(cmdChannel, userId, userChannel);
ProxyMessage proxyMessage = new ProxyMessage();
proxyMessage.setType(ProxyMessage.TYPE_CONNECT);
proxyMessage.setUri(userId);
proxyMessage.setData(lanInfo.getBytes());
cmdChannel.writeAndFlush(proxyMessage);
}
super.channelActive(ctx);
}
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
// 通知代理客户端
Channel userChannel = ctx.channel();
InetSocketAddress sa = (InetSocketAddress) userChannel.localAddress();
Channel cmdChannel = ProxyChannelManager.getCmdChannel(sa.getPort());
if (cmdChannel == null) {
// 该端口还没有代理客户端
ctx.channel().close();
} else {
// 用户连接断开,从控制连接中移除
String userId = ProxyChannelManager.getUserChannelUserId(userChannel);
ProxyChannelManager.removeUserChannelFromCmdChannel(cmdChannel, userId);
Channel proxyChannel = userChannel.attr(Constants.NEXT_CHANNEL).get();
if (proxyChannel != null && proxyChannel.isActive()) {
proxyChannel.attr(Constants.NEXT_CHANNEL).remove();
proxyChannel.attr(Constants.CLIENT_KEY).remove();
proxyChannel.attr(Constants.USER_ID).remove();
proxyChannel.config().setOption(ChannelOption.AUTO_READ, true);
// 通知客户端,用户连接已经断开
ProxyMessage proxyMessage = new ProxyMessage();
proxyMessage.setType(ProxyMessage.TYPE_DISCONNECT);
proxyMessage.setUri(userId);
proxyChannel.writeAndFlush(proxyMessage);
}
}
super.channelInactive(ctx);
}
@Override
public void channelWritabilityChanged(ChannelHandlerContext ctx) throws Exception {
// 通知代理客户端
Channel userChannel = ctx.channel();
InetSocketAddress sa = (InetSocketAddress) userChannel.localAddress();
Channel cmdChannel = ProxyChannelManager.getCmdChannel(sa.getPort());
if (cmdChannel == null) {
// 该端口还没有代理客户端
ctx.channel().close();
} else {
Channel proxyChannel = userChannel.attr(Constants.NEXT_CHANNEL).get();
if (proxyChannel != null) {
proxyChannel.config().setOption(ChannelOption.AUTO_READ, userChannel.isWritable());
}
}
super.channelWritabilityChanged(ctx);
}
/**
* 为用户连接产生ID
*
* @return
*/
public static String newUserId() {
return String.valueOf(userIdProducer.incrementAndGet());
}
}

View File

@@ -0,0 +1,412 @@
package com.xspaceagi.sandbox.infra.network;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelDuplexHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelPromise;
import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame;
import io.netty.handler.codec.http.websocketx.CloseWebSocketFrame;
import io.netty.handler.codec.http.websocketx.ContinuationWebSocketFrame;
import io.netty.handler.codec.http.websocketx.PingWebSocketFrame;
import io.netty.handler.codec.http.websocketx.PongWebSocketFrame;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.handler.codec.http.websocketx.WebSocketFrame;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import lombok.extern.slf4j.Slf4j;
import java.util.Queue;
/**
* WebSocket Frame转换为ByteBuf的Handler
* <p>
* 该Handler作为透明转换层位于WebSocket解码器和UserChannelHandler之间
* 将WebSocket Frame转换为ByteBuf使UserChannelHandler无感知地处理WebSocket数据
* <p>
* 数据流向:
* WebSocket Frame -> WebSocketFrameToByteBufHandler -> ByteBuf -> UserChannelHandler
*
* @author sandbox
*/
@Slf4j
public class WebSocketFrameToByteBufHandler extends ChannelDuplexHandler {
/**
* WebSocket握手是否完成
*/
private volatile boolean handshakeComplete = false;
/**
* 握手期间的待发送数据队列
*/
private final java.util.Queue<Object> pendingWrites = new java.util.LinkedList<>();
/**
* 用于累加分片消息的缓冲区
*/
private ByteBuf fragmentationBuffer;
/**
* 当前分片消息的类型true为文本false为二进制
*/
private Boolean isTextFragment;
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof WebSocketServerProtocolHandler.HandshakeComplete) {
handshakeComplete = true;
log.info("🤝 WebSocket handshake completed! Channel: {}, flushing {} pending messages...",
ctx.channel().id().asShortText(), pendingWrites.size());
// 发送所有缓存的数据
Object pendingMsg;
int count = 0;
while ((pendingMsg = pendingWrites.poll()) != null) {
ctx.write(pendingMsg);
count++;
}
// 批量flush
if (count > 0) {
ctx.flush();
log.info("✅ Flushed {} pending messages after handshake", count);
}
}
super.userEventTriggered(ctx, evt);
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
if (!(msg instanceof WebSocketFrame)) {
// 非WebSocket Frame直接传递给下一个handler
log.trace("Passing non-WebSocket message: {}", msg.getClass().getSimpleName());
ctx.fireChannelRead(msg);
return;
}
WebSocketFrame frame = (WebSocketFrame) msg;
log.trace("Received WebSocket frame: {}", frame.getClass().getSimpleName());
try {
if (frame instanceof CloseWebSocketFrame) {
handleCloseFrame(ctx, (CloseWebSocketFrame) frame);
} else if (frame instanceof PingWebSocketFrame) {
handlePingFrame(ctx, (PingWebSocketFrame) frame);
} else if (frame instanceof PongWebSocketFrame) {
handlePongFrame(ctx, (PongWebSocketFrame) frame);
} else if (frame instanceof TextWebSocketFrame) {
handleTextFrame(ctx, (TextWebSocketFrame) frame);
} else if (frame instanceof BinaryWebSocketFrame) {
handleBinaryFrame(ctx, (BinaryWebSocketFrame) frame);
} else if (frame instanceof ContinuationWebSocketFrame) {
handleContinuationFrame(ctx, (ContinuationWebSocketFrame) frame);
} else {
log.warn("Unsupported WebSocket frame type: {}", frame.getClass().getName());
frame.release();
}
} catch (Exception e) {
log.error("Error processing WebSocket frame", e);
frame.release();
throw e;
}
}
/**
* 处理关闭帧
* Close帧不应该传递给UserChannelHandler而是直接关闭连接
*/
private void handleCloseFrame(ChannelHandlerContext ctx, CloseWebSocketFrame frame) {
log.info("Received Close WebSocket Frame, closing connection");
try {
// 回复Close帧
ctx.writeAndFlush(frame.retain()).addListener(future -> {
ctx.close();
log.debug("WebSocket connection closed after receiving Close frame");
});
} catch (Exception e) {
log.error("Error handling close frame", e);
ctx.close();
}
}
/**
* 处理Ping帧
*/
private void handlePingFrame(ChannelHandlerContext ctx, PingWebSocketFrame frame) {
log.debug("Received Ping WebSocket Frame");
try {
// 自动回复Pong帧
ByteBuf content = frame.content();
if (content.isReadable()) {
ctx.writeAndFlush(new PongWebSocketFrame(content.retainedDuplicate()));
} else {
ctx.writeAndFlush(new PongWebSocketFrame());
}
} finally {
frame.release();
}
}
/**
* 处理Pong帧
*/
private void handlePongFrame(ChannelHandlerContext ctx, PongWebSocketFrame frame) {
log.debug("Received Pong WebSocket Frame");
// Pong帧不需要转发给UserChannelHandler
frame.release();
}
/**
* 处理文本帧
*/
private void handleTextFrame(ChannelHandlerContext ctx, TextWebSocketFrame frame) {
try {
String text = frame.text();
log.debug("Received Text WebSocket Frame, length: {}", text.length());
// 检查是否为分片的最后一帧
boolean isFinal = frame.isFinalFragment();
if (!isFinal) {
// 开始接收分片消息
if (fragmentationBuffer == null) {
fragmentationBuffer = ctx.alloc().buffer();
isTextFragment = true;
log.debug("Start receiving text fragment");
}
// 累积分片数据
fragmentationBuffer.writeBytes(text.getBytes(io.netty.util.CharsetUtil.UTF_8));
} else {
// 最后一帧或完整的消息
if (fragmentationBuffer != null && Boolean.TRUE.equals(isTextFragment)) {
// 分片结束
fragmentationBuffer.writeBytes(text.getBytes(io.netty.util.CharsetUtil.UTF_8));
// 转发给UserChannelHandler
ByteBuf completeBuffer = fragmentationBuffer.retainedSlice();
ctx.fireChannelRead(completeBuffer);
log.debug("Completed text fragment, total length: {}", fragmentationBuffer.readableBytes());
// 释放并重置缓冲区
fragmentationBuffer.release();
fragmentationBuffer = null;
isTextFragment = null;
} else {
// 单个完整的消息转换为ByteBuf转发
byte[] textBytes = text.getBytes(io.netty.util.CharsetUtil.UTF_8);
ByteBuf buffer = Unpooled.wrappedBuffer(textBytes);
ctx.fireChannelRead(buffer);
log.debug("Forwarded text data as ByteBuf, length: {}", textBytes.length);
}
}
} finally {
frame.release();
}
}
/**
* 处理二进制帧
*/
private void handleBinaryFrame(ChannelHandlerContext ctx, BinaryWebSocketFrame frame) {
try {
ByteBuf binaryData = frame.content();
int length = binaryData.readableBytes();
log.debug("Received Binary WebSocket Frame, length: {}, final: {}", length, frame.isFinalFragment());
// 检查是否为分片的最后一帧
boolean isFinal = frame.isFinalFragment();
if (!isFinal) {
// 开始接收分片消息
if (fragmentationBuffer == null) {
fragmentationBuffer = ctx.alloc().buffer();
isTextFragment = false;
log.info("Start receiving binary fragment");
}
// 累积分片数据
fragmentationBuffer.writeBytes(binaryData);
log.trace("Accumulated binary fragment, current size: {}", fragmentationBuffer.readableBytes());
} else {
// 最后一帧或完整的消息
if (fragmentationBuffer != null && Boolean.FALSE.equals(isTextFragment)) {
// 分片结束
fragmentationBuffer.writeBytes(binaryData);
// 转发给UserChannelHandler
ByteBuf completeBuffer = fragmentationBuffer.retainedSlice();
log.info("Completed binary fragment, total length: {}, forwarding to UserChannelHandler", completeBuffer.readableBytes());
ctx.fireChannelRead(completeBuffer);
log.trace("Binary fragment successfully forwarded to UserChannelHandler");
// 释放并重置缓冲区
fragmentationBuffer.release();
fragmentationBuffer = null;
isTextFragment = null;
} else {
// 单个完整的消息,直接转发
ByteBuf forwardBuffer = binaryData.retain();
log.info("Forwarding complete binary message to UserChannelHandler, length: {}", length);
ctx.fireChannelRead(forwardBuffer);
log.trace("Binary message successfully forwarded to UserChannelHandler");
}
}
} finally {
frame.release();
}
}
/**
* 处理续传帧(分片消息的中间帧)
*/
private void handleContinuationFrame(ChannelHandlerContext ctx, ContinuationWebSocketFrame frame) {
try {
log.debug("Received Continuation WebSocket Frame");
// 检查是否有正在进行的分片接收
if (fragmentationBuffer == null) {
log.warn("Received ContinuationFrame without active fragmentation, discarding");
return;
}
ByteBuf continuationData = frame.content();
boolean isFinal = frame.isFinalFragment();
// 累加续传数据
fragmentationBuffer.writeBytes(continuationData);
if (isFinal) {
// 分片结束,转发完整数据
ByteBuf completeBuffer = fragmentationBuffer.retainedSlice();
ctx.fireChannelRead(completeBuffer);
log.debug("Completed continuation fragment, total length: {}, type: {}",
completeBuffer.readableBytes(), isTextFragment ? "TEXT" : "BINARY");
// 释放并重置缓冲区
fragmentationBuffer.release();
fragmentationBuffer = null;
isTextFragment = null;
}
} finally {
frame.release();
}
}
/**
* 写数据时将ByteBuf转换为WebSocket Frame
* 这个方法会在Channel.write()时被调用
*/
@Override
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
// 检查WebSocket握手是否完成
if (!handshakeComplete) {
String msgType = msg.getClass().getSimpleName();
// 握手期间只缓存需要转换为WebSocket Frame的数据
// HTTP相关消息HttpResponse等不缓存让WebSocketServerProtocolHandler处理
if (msg instanceof ByteBuf || msg instanceof byte[] || msg instanceof String) {
log.debug("⏳ WebSocket handshake in progress, buffering message. Type: {}, Queue size: {}",
msgType, pendingWrites.size());
Object convertedMsg = convertToWebSocketFrame(ctx, msg);
if (convertedMsg != null) {
pendingWrites.offer(convertedMsg);
promise.setSuccess();
return;
}
}
// HTTP消息握手响应等直接透传不缓存
log.trace("Passing through HTTP message during handshake: {}", msgType);
ctx.write(msg, promise);
return;
}
// 检查channel是否可写
if (!ctx.channel().isWritable()) {
log.warn("⚠️ Channel is NOT writable! Data will be buffered. Channel: {}, write buffer: {}",
ctx.channel().id().asShortText(), ctx.channel().bytesBeforeUnwritable());
}
try {
Object convertedMsg = convertToWebSocketFrame(ctx, msg);
if (convertedMsg != null) {
// 关键修复使用writeAndFlush而不是write
ctx.writeAndFlush(convertedMsg, promise);
log.trace("✅ WebSocket frame sent successfully, type: {}", convertedMsg.getClass().getSimpleName());
} else {
// 其他类型直接透传
ctx.write(msg, promise);
}
} catch (Exception e) {
log.error("❌ Error in write, channel: {}, isActive: {}", ctx.channel(), ctx.channel().isActive(), e);
promise.setFailure(e);
throw e;
}
}
/**
* 将消息转换为WebSocket Frame
*/
private Object convertToWebSocketFrame(ChannelHandlerContext ctx, Object msg) {
if (msg instanceof ByteBuf) {
ByteBuf byteBuf = (ByteBuf) msg;
log.debug("📤 Convert ByteBuf to BinaryWebSocketFrame, length: {}, channel: {}",
byteBuf.readableBytes(), ctx.channel().id().asShortText());
return new BinaryWebSocketFrame(byteBuf.retain());
} else if (msg instanceof byte[]) {
byte[] bytes = (byte[]) msg;
log.debug("📤 Convert byte[] to BinaryWebSocketFrame, length: {}", bytes.length);
return new BinaryWebSocketFrame(Unpooled.wrappedBuffer(bytes));
} else if (msg instanceof String) {
String text = (String) msg;
log.debug("📤 Convert String to TextWebSocketFrame, length: {}", text.length());
return new TextWebSocketFrame(text);
} else if (msg instanceof BinaryWebSocketFrame || msg instanceof TextWebSocketFrame) {
// 已经是WebSocket Frame直接返回
return msg;
} else {
log.trace("Passing through non-converted message: {}", msg.getClass().getSimpleName());
return null; // 表示不需要转换
}
}
@Override
public void flush(ChannelHandlerContext ctx) throws Exception {
ctx.flush();
}
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
log.debug("WebSocket to ByteBuf converter inactive");
cleanupResources();
super.channelInactive(ctx);
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
log.error("WebSocket to ByteBuf converter exception", cause);
cleanupResources();
ctx.fireExceptionCaught(cause);
}
/**
* 清理资源
*/
private void cleanupResources() {
if (fragmentationBuffer != null) {
fragmentationBuffer.release();
fragmentationBuffer = null;
}
isTextFragment = null;
// 清空待发送队列
if (!pendingWrites.isEmpty()) {
log.warn("⚠️ Cleaning up {} pending messages due to channel inactive", pendingWrites.size());
Object msg;
while ((msg = pendingWrites.poll()) != null) {
if (msg instanceof io.netty.util.ReferenceCounted) {
((io.netty.util.ReferenceCounted) msg).release();
}
}
}
}
}

View File

@@ -0,0 +1,18 @@
package com.xspaceagi.sandbox.infra.network.protocol;
import io.netty.channel.Channel;
import io.netty.util.AttributeKey;
import java.util.Queue;
public interface Constants {
AttributeKey<Channel> NEXT_CHANNEL = AttributeKey.newInstance("nxt_channel");
AttributeKey<String> USER_ID = AttributeKey.newInstance("user_id");
AttributeKey<String> CLIENT_KEY = AttributeKey.newInstance("client_key");
AttributeKey<Queue<Object>> MESSAGE_QUEUE = AttributeKey.newInstance("message_queue");
}

View File

@@ -0,0 +1,43 @@
package com.xspaceagi.sandbox.infra.network.protocol;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.timeout.IdleStateEvent;
import io.netty.handler.timeout.IdleStateHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* check idle chanel.
*
* @author fengfei
*
*/
public class IdleCheckHandler extends IdleStateHandler {
public static final int USER_CHANNEL_READ_IDLE_TIME = 1200;
public static final int READ_IDLE_TIME = 60;
public static final int WRITE_IDLE_TIME = 40;
private static Logger logger = LoggerFactory.getLogger(IdleCheckHandler.class);
public IdleCheckHandler(int readerIdleTimeSeconds, int writerIdleTimeSeconds, int allIdleTimeSeconds) {
super(readerIdleTimeSeconds, writerIdleTimeSeconds, allIdleTimeSeconds);
}
@Override
protected void channelIdle(ChannelHandlerContext ctx, IdleStateEvent evt) throws Exception {
if (IdleStateEvent.FIRST_WRITER_IDLE_STATE_EVENT == evt) {
logger.debug("channel write timeout {}", ctx.channel());
ProxyMessage proxyMessage = new ProxyMessage();
proxyMessage.setType(ProxyMessage.TYPE_HEARTBEAT);
ctx.channel().writeAndFlush(proxyMessage);
} else if (IdleStateEvent.FIRST_READER_IDLE_STATE_EVENT == evt) {
logger.warn("channel read timeout {}", ctx.channel());
ctx.channel().close();
}
super.channelIdle(ctx, evt);
}
}

View File

@@ -0,0 +1,83 @@
package com.xspaceagi.sandbox.infra.network.protocol;
import java.util.Arrays;
/**
* 代理客户端与代理服务器消息交换协议
*
* @author fengfei
*
*/
public class ProxyMessage {
/** 心跳消息 */
public static final byte TYPE_HEARTBEAT = 0x07;
/** 认证消息检测clientKey是否正确 */
public static final byte C_TYPE_AUTH = 0x01;
// /** 保活确认消息 */
// public static final byte TYPE_ACK = 0x02;
/** 代理后端服务器建立连接消息 */
public static final byte TYPE_CONNECT = 0x03;
/** 代理后端服务器断开连接消息 */
public static final byte TYPE_DISCONNECT = 0x04;
/** 代理数据传输 */
public static final byte P_TYPE_TRANSFER = 0x05;
/** 用户与代理服务器以及代理客户端与真实服务器连接是否可写状态同步 */
public static final byte C_TYPE_WRITE_CONTROL = 0x06;
/** 消息类型 */
private byte type;
/** 消息流水号 */
private long serialNumber;
/** 消息命令请求信息 */
private String uri;
/** 消息传输数据 */
private byte[] data;
public void setUri(String uri) {
this.uri = uri;
}
public String getUri() {
return uri;
}
public byte[] getData() {
return data;
}
public void setData(byte[] data) {
this.data = data;
}
public byte getType() {
return type;
}
public void setType(byte type) {
this.type = type;
}
public long getSerialNumber() {
return serialNumber;
}
public void setSerialNumber(long serialNumber) {
this.serialNumber = serialNumber;
}
@Override
public String toString() {
return "ProxyMessage [type=" + type + ", serialNumber=" + serialNumber + ", uri=" + uri + ", data=" + Arrays.toString(data) + "]";
}
}

View File

@@ -0,0 +1,78 @@
package com.xspaceagi.sandbox.infra.network.protocol;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.LengthFieldBasedFrameDecoder;
public class ProxyMessageDecoder extends LengthFieldBasedFrameDecoder {
private static final byte HEADER_SIZE = 4;
private static final int TYPE_SIZE = 1;
private static final int SERIAL_NUMBER_SIZE = 8;
private static final int URI_LENGTH_SIZE = 1;
/**
* @param maxFrameLength
* @param lengthFieldOffset
* @param lengthFieldLength
* @param lengthAdjustment
* @param initialBytesToStrip
*/
public ProxyMessageDecoder(int maxFrameLength, int lengthFieldOffset, int lengthFieldLength, int lengthAdjustment,
int initialBytesToStrip) {
super(maxFrameLength, lengthFieldOffset, lengthFieldLength, lengthAdjustment, initialBytesToStrip);
}
/**
* @param maxFrameLength
* @param lengthFieldOffset
* @param lengthFieldLength
* @param lengthAdjustment
* @param initialBytesToStrip
* @param failFast
*/
public ProxyMessageDecoder(int maxFrameLength, int lengthFieldOffset, int lengthFieldLength, int lengthAdjustment,
int initialBytesToStrip, boolean failFast) {
super(maxFrameLength, lengthFieldOffset, lengthFieldLength, lengthAdjustment, initialBytesToStrip, failFast);
}
@Override
protected ProxyMessage decode(ChannelHandlerContext ctx, ByteBuf in2) throws Exception {
ByteBuf in = (ByteBuf) super.decode(ctx, in2);
if (in == null) {
return null;
}
if (in.readableBytes() < HEADER_SIZE) {
return null;
}
int frameLength = in.readInt();
if (in.readableBytes() < frameLength) {
return null;
}
ProxyMessage proxyMessage = new ProxyMessage();
byte type = in.readByte();
long sn = in.readLong();
proxyMessage.setSerialNumber(sn);
proxyMessage.setType(type);
byte uriLength = in.readByte();
byte[] uriBytes = new byte[uriLength];
in.readBytes(uriBytes);
proxyMessage.setUri(new String(uriBytes));
byte[] data = new byte[frameLength - TYPE_SIZE - SERIAL_NUMBER_SIZE - URI_LENGTH_SIZE - uriLength];
in.readBytes(data);
proxyMessage.setData(data);
in.release();
return proxyMessage;
}
}

View File

@@ -0,0 +1,45 @@
package com.xspaceagi.sandbox.infra.network.protocol;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToByteEncoder;
public class ProxyMessageEncoder extends MessageToByteEncoder<ProxyMessage> {
private static final int TYPE_SIZE = 1;
private static final int SERIAL_NUMBER_SIZE = 8;
private static final int URI_LENGTH_SIZE = 1;
@Override
protected void encode(ChannelHandlerContext ctx, ProxyMessage msg, ByteBuf out) throws Exception {
int bodyLength = TYPE_SIZE + SERIAL_NUMBER_SIZE + URI_LENGTH_SIZE;
byte[] uriBytes = null;
if (msg.getUri() != null) {
uriBytes = msg.getUri().getBytes();
bodyLength += uriBytes.length;
}
if (msg.getData() != null) {
bodyLength += msg.getData().length;
}
// write the total packet length but without length field's length.
out.writeInt(bodyLength);
out.writeByte(msg.getType());
out.writeLong(msg.getSerialNumber());
if (uriBytes != null) {
out.writeByte((byte) uriBytes.length);
out.writeBytes(uriBytes);
} else {
out.writeByte((byte) 0x00);
}
if (msg.getData() != null) {
out.writeBytes(msg.getData());
}
}
}

View File

@@ -0,0 +1,18 @@
<?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-sandbox</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-sandbox-sdk</artifactId>
<dependencies>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-sandbox-spec</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,68 @@
package com.xspaceagi.sandbox.sdk.server;
import com.xspaceagi.sandbox.sdk.service.dto.SandboxConfigRpcDto;
import com.xspaceagi.sandbox.sdk.service.dto.SandboxGlobalConfigDto;
import java.util.List;
/**
* 沙盒配置 RPC 服务接口
*/
public interface ISandboxConfigRpcService {
/**
* 查询用户配置列表
*
* @param userId 用户ID
* @return 配置列表
*/
List<SandboxConfigRpcDto> queryUserConfigs(Long userId);
/**
* 查询全局配置列表(通用智能体沙箱)
*
* @return 配置列表
*/
List<SandboxConfigRpcDto> queryGlobalConfigs(Long tenantId);
/**
* 根据配置键查询用户配置
*
* @param configKey 配置键
* @return 配置信息
*/
SandboxConfigRpcDto queryUserConfigByKey(String configKey);
/**
* 根据配置键查询全局配置
*
* @param configKey 配置键
* @return 配置信息
*/
SandboxConfigRpcDto queryGlobalConfigByKey(String configKey);
/**
* 根据ID查询配置
*
* @param id 配置ID
* @return 配置信息
*/
SandboxConfigRpcDto queryById(Long id);
SandboxGlobalConfigDto getGlobalConfig(Long tenantId);
Long queryUserSelectedSandboxId(Long userId, Long agentId);
/**
* 选择应用开发沙盒
*
* @param tenantId 租户ID
* @param userId 用户ID
* @param spaceId 空间ID
* @param projectId 项目ID
* @param sandboxId 沙盒ID可选项目如果已经有了就传递
* @return 沙盒信息
*/
SandboxConfigRpcDto selectAppDevelopmentSandbox(Long tenantId, Long userId, Long spaceId, Long projectId, Long sandboxId);
}

View File

@@ -0,0 +1,59 @@
package com.xspaceagi.sandbox.sdk.service.dto;
import com.xspaceagi.sandbox.sdk.service.enums.IsolationEnum;
import com.xspaceagi.sandbox.spec.enums.SandboxScopeEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 沙盒配置 RPC DTO
*/
@Schema(description = "沙盒配置信息RPC")
@Data
public class SandboxConfigRpcDto implements Serializable {
@Schema(description = "配置ID")
private Long id;
@Schema(description = "配置范围global-全局配置 user-个人配置")
private SandboxScopeEnum scope;
@Schema(description = "用户IDscope为user时必填")
private Long userId;
@Schema(description = "配置名称")
private String name;
@Schema(description = "唯一标识")
private String configKey;
@Schema(description = "配置值,服务器端沙盒配置信息")
private SandboxConfigValue configValue;
@Schema(description = "沙盒服务器信息qimingclaw客户端专用")
private SandboxServerInfo sandboxServerInfo;
@Schema(description = "配置描述")
private String description;
@Schema(description = "是否启用true-启用 false-禁用")
private Boolean isActive;
@Schema(description = "是否在线true-在线 false-离线")
private boolean online;
@Schema(description = "隔离级别")
private IsolationEnum isolation;
@Schema(description = "隔离标识")
private String isolationKey;
@Schema(description = "创建时间")
private Date created;
@Schema(description = "更新时间")
private Date modified;
}

View File

@@ -0,0 +1,30 @@
package com.xspaceagi.sandbox.sdk.service.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 沙盒配置值RPC
*/
@Schema(description = "沙盒配置值")
@Data
public class SandboxConfigValue {
@Schema(description = "服务根地址,例如 http://192.168.1.11,不允许携带端口")
private String hostWithScheme;
@Schema(description = "Agent服务端口")
private int agentPort;
@Schema(description = "VNC服务端口")
private int vncPort;
@Schema(description = "文件服务端口")
private int fileServerPort;
@Schema(description = "API密钥")
private String apiKey;
@Schema(description = "最大用户数")
private Integer maxUsers;
}

View File

@@ -0,0 +1,15 @@
package com.xspaceagi.sandbox.sdk.service.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "沙盒全局配置")
@Data
public class SandboxGlobalConfigDto {
@Schema(description = "每个用户分配的内存大小")
private String perUserMemoryGB;
@Schema(description = "每个用户分配的CPU核数")
private String perUserCpuCores;
}

View File

@@ -0,0 +1,30 @@
package com.xspaceagi.sandbox.sdk.service.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 沙盒配置值
*/
@Schema(description = "沙盒服务端内部交互信息")
@Data
public class SandboxServerInfo {
@Schema(description = "协议")
private String scheme;
@Schema(description = "主机地址")
private String host;
@Schema(description = "Agent服务端口")
private int agentPort;
@Schema(description = "VNC服务端口")
private int vncPort;
@Schema(description = "文件服务端口")
private int fileServerPort;
@Schema(description = "API密钥")
private String apiKey;
}

View File

@@ -0,0 +1,7 @@
package com.xspaceagi.sandbox.sdk.service.enums;
public enum IsolationEnum {
Tenant,
Space,
Project
}

View File

@@ -0,0 +1,14 @@
<?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-sandbox</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-sandbox-spec</artifactId>
<dependencies>
</dependencies>
</project>

View File

@@ -0,0 +1,41 @@
package com.xspaceagi.sandbox.spec.enums;
import lombok.Getter;
/**
* 沙盒配置类型枚举
*/
@Getter
public enum SandboxConfigTypeEnum {
RESOURCE(0, "resource", "资源配置"),
SERVERS(1, "servers", "服务器配置"),
POLICY(2, "policy", "策略配置");
private final Integer key;
private final String code;
private final String description;
SandboxConfigTypeEnum(Integer key, String code, String description) {
this.key = key;
this.code = code;
this.description = description;
}
public static SandboxConfigTypeEnum fromCode(String code) {
for (SandboxConfigTypeEnum value : SandboxConfigTypeEnum.values()) {
if (value.getCode().equals(code)) {
return value;
}
}
return null;
}
public static SandboxConfigTypeEnum fromKey(Integer key) {
for (SandboxConfigTypeEnum value : SandboxConfigTypeEnum.values()) {
if (value.getKey().equals(key)) {
return value;
}
}
return null;
}
}

View File

@@ -0,0 +1,40 @@
package com.xspaceagi.sandbox.spec.enums;
import lombok.Getter;
/**
* 沙盒配置范围枚举
*/
@Getter
public enum SandboxScopeEnum {
GLOBAL(0, "global", "全局配置"),
USER(1, "user", "个人配置");
private final Integer key;
private final String code;
private final String description;
SandboxScopeEnum(Integer key, String code, String description) {
this.key = key;
this.code = code;
this.description = description;
}
public static SandboxScopeEnum fromCode(String code) {
for (SandboxScopeEnum value : SandboxScopeEnum.values()) {
if (value.getCode().equals(code)) {
return value;
}
}
return null;
}
public static SandboxScopeEnum fromKey(Integer key) {
for (SandboxScopeEnum value : SandboxScopeEnum.values()) {
if (value.getKey().equals(key)) {
return value;
}
}
return null;
}
}

View File

@@ -0,0 +1,30 @@
<?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-sandbox</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-sandbox-ui</artifactId>
<dependencies>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-sandbox-spec</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-sandbox-application</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-sandbox-api</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>system-web</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,72 @@
package com.xspaceagi.sandbox.ui.web.controller;
import com.xspaceagi.sandbox.application.dto.SandboxProxyDto;
import com.xspaceagi.sandbox.application.service.SandboxProxyApplicationService;
import com.xspaceagi.sandbox.ui.web.dto.SandboxTempLinkDto;
import com.xspaceagi.system.application.dto.TenantConfigDto;
import com.xspaceagi.system.spec.common.RequestContext;
import com.xspaceagi.system.spec.dto.ReqResult;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 沙箱内部可调用接口
*/
@Tag(name = "沙箱内部可调用接口")
@RestController
@RequestMapping("/api/v1/4sandbox")
public class ApiForSandboxController {
@Resource
private SandboxProxyApplicationService sandboxProxyApplicationService;
@Value("${sandbox.temp-link.base-domain:}")
private String baseDomain;
@Operation(summary = "创建临时代理配置")
@PostMapping("/proxy/create")
public ReqResult<SandboxTempLinkDto> create(@RequestBody SandboxProxyDto dto) {
dto.setUserId(RequestContext.get().getUserId());
SandboxProxyDto sandboxProxyDto = sandboxProxyApplicationService.create(dto);
SandboxTempLinkDto sandboxTempLinkDto = convertToSandboxTempLinkDto(sandboxProxyDto);
return ReqResult.success(sandboxTempLinkDto);
}
@Operation(summary = "删除临时代理配置")
@PostMapping("/proxy/delete/{id}")
public ReqResult<Void> delete(
@Parameter(description = "代理ID") @PathVariable Long id) {
sandboxProxyApplicationService.deleteById(RequestContext.get().getUserId(), id);
return ReqResult.success();
}
@Operation(summary = "查询所有临时代理配置")
@PostMapping("/proxy/list/{sandboxId}")
public ReqResult<List<SandboxTempLinkDto>> listAll(
@Parameter(description = "代理ID") @PathVariable Long sandboxId) {
List<SandboxTempLinkDto> list = sandboxProxyApplicationService.querySandboxyProxyList(RequestContext.get().getUserId(), sandboxId).stream().map(this::convertToSandboxTempLinkDto).toList();
return ReqResult.success(list);
}
private SandboxTempLinkDto convertToSandboxTempLinkDto(SandboxProxyDto sandboxProxyDto) {
SandboxTempLinkDto sandboxTempLinkDto = new SandboxTempLinkDto();
sandboxTempLinkDto.setId(sandboxProxyDto.getId());
if (StringUtils.isNotBlank(baseDomain)) {
sandboxTempLinkDto.setTempLink(sandboxProxyDto.getProxyKey() + "." + baseDomain);
} else {
TenantConfigDto tenantConfigDto = (TenantConfigDto) RequestContext.get().getTenantConfig();
String siteUrl = tenantConfigDto.getSiteUrl();
siteUrl = siteUrl.endsWith("/") ? siteUrl.substring(0, siteUrl.length() - 1) : siteUrl;
sandboxTempLinkDto.setTempLink(siteUrl + "/api/proxy/sandbox/" + sandboxProxyDto.getProxyKey());
}
return sandboxTempLinkDto;
}
}

View File

@@ -0,0 +1,462 @@
package com.xspaceagi.sandbox.ui.web.controller;
import com.xspaceagi.agent.core.adapter.application.AgentApplicationService;
import com.xspaceagi.agent.core.adapter.application.ConversationApplicationService;
import com.xspaceagi.agent.core.adapter.dto.ConversationDto;
import com.xspaceagi.agent.core.adapter.dto.UserAgentDto;
import com.xspaceagi.sandbox.application.dto.SandboxConfigDto;
import com.xspaceagi.sandbox.application.service.SandboxConfigApplicationService;
import com.xspaceagi.sandbox.infra.dao.vo.SandboxConfigValue;
import com.xspaceagi.sandbox.infra.dao.vo.SandboxServerInfo;
import com.xspaceagi.sandbox.spec.enums.SandboxScopeEnum;
import com.xspaceagi.sandbox.ui.web.dto.SandboxConfigCreateDto;
import com.xspaceagi.sandbox.ui.web.dto.SandboxRegDto;
import com.xspaceagi.sandbox.ui.web.dto.UserSandBoxSelectDto;
import com.xspaceagi.system.application.dto.TenantConfigDto;
import com.xspaceagi.system.application.dto.UserDto;
import com.xspaceagi.system.application.service.AuthService;
import com.xspaceagi.system.application.service.I18nApplicationService;
import com.xspaceagi.system.application.service.UserApplicationService;
import com.xspaceagi.system.infra.dao.entity.User;
import com.xspaceagi.system.spec.common.RequestContext;
import com.xspaceagi.system.spec.dto.ReqResult;
import com.xspaceagi.system.spec.enums.I18nSideEnum;
import com.xspaceagi.system.spec.utils.I18nUtil;
import com.xspaceagi.system.spec.utils.MD5;
import com.xspaceagi.system.spec.utils.RedisUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.util.Assert;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* 沙盒配置管理接口
*/
@Tag(name = "用户沙盒相关接口")
@RestController
@RequestMapping("/api/sandbox/config")
public class SandboxConfigController {
private static final int START_VERSION = 90000;
@Resource
private SandboxConfigApplicationService sandboxConfigApplicationService;
@Resource
private UserApplicationService userApplicationService;
@Resource
private RedisUtil redisUtil;
@Resource
private AgentApplicationService agentApplicationService;
@Resource
private ConversationApplicationService conversationApplicationService;
@Resource
private AuthService authService;
@Resource
private I18nApplicationService i18nApplicationService;
@Value("${installation-source}")
private String installationSource;
static {
// disable keep alive暂不使用连接池
System.setProperty("jdk.httpclient.keepalive.timeout", "0");
}
private final HttpClient httpClient = java.net.http.HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
@Operation(summary = "查询用户沙盒列表")
@GetMapping("/list")
public ReqResult<List<SandboxConfigDto>> listUserConfigs() {
List<SandboxConfigDto> sandboxConfigs = sandboxConfigApplicationService.listUserConfigsByType(RequestContext.get().getUserId());
sandboxConfigs.forEach(sandboxConfigDto -> {
if (sandboxConfigDto.getConfigValue() != null && sandboxConfigDto.getConfigValue().getHostWithScheme() != null) {
sandboxConfigDto.setConfigKey(null);
}
});
return ReqResult.success(sandboxConfigs);
}
@Operation(summary = "用户可选沙盒选择列表")
@GetMapping("/select/list")
public ReqResult<UserSandBoxSelectDto> listUserSelectConfigs() {
UserSandBoxSelectDto dto = new UserSandBoxSelectDto();
List<SandboxConfigDto> sandboxConfigDtos = sandboxConfigApplicationService.listUserConfigsByType(RequestContext.get().getUserId()).stream().filter(SandboxConfigDto::isOnline).toList();
Map<String, Object> stringObjectMap = redisUtil.hashGetAll("user-sandbox-selected:" + RequestContext.get().getUserId());
Map<String, String> agentSelectedMap = new HashMap<>();
if (stringObjectMap != null) {
//移除stringObjectMap中value不在sandboxConfigDtos这里的项
stringObjectMap.forEach((key, value) -> {
if (sandboxConfigDtos.stream().anyMatch(sandboxConfigDto -> sandboxConfigDto.getId().equals(Long.parseLong(value.toString())))) {
agentSelectedMap.put(key, value.toString());
}
});
}
dto.setAgentSelected(agentSelectedMap);
//过滤出在线的
dto.setSandboxes(sandboxConfigDtos.stream().map(sandboxConfigDto -> {
UserSandBoxSelectDto.SelectDto selectDto = new UserSandBoxSelectDto.SelectDto();
selectDto.setSandboxId(sandboxConfigDto.getId().toString());
selectDto.setName(sandboxConfigDto.getName());
String notice = I18nUtil.systemMessage("Backend.Sandbox.RiskNotice");
if (StringUtils.isNotBlank(sandboxConfigDto.getDescription())) {
selectDto.setDescription(sandboxConfigDto.getDescription() + "\n");
} else {
selectDto.setDescription(notice);
}
return selectDto;
}).collect(Collectors.toList()));
if ("saas".equals(installationSource) || sandboxConfigApplicationService.hasGlobalConfigsForSelect()) {
UserSandBoxSelectDto.SelectDto selectDto = new UserSandBoxSelectDto.SelectDto();
selectDto.setSandboxId("-1");
selectDto.setName(I18nUtil.systemMessage("Backend.Sandbox.CloudComputer"));
selectDto.setDescription(I18nUtil.systemMessage("Backend.Sandbox.CloudComputerDesc"));
dto.getSandboxes().add(0, selectDto);
}
return ReqResult.success(dto);
}
@Operation(summary = "用户沙箱选中记录更新")
@PostMapping("/selected/{agentId}/{sandboxId}")
public ReqResult<Void> selected(@PathVariable Long agentId, @PathVariable String sandboxId) {
redisUtil.hashPut("user-sandbox-selected:" + RequestContext.get().getUserId(), agentId.toString(), sandboxId);
return ReqResult.success();
}
@Operation(summary = "客户端注册")
@PostMapping("/reg")
public ReqResult<SandboxConfigDto> create(@RequestBody SandboxRegDto sandboxRegDto, HttpServletRequest request) {
int versionNum = toVersionNumber(extractQimingVersion(request.getHeader("User-Agent")));
UserDto user = null;
if (versionNum > START_VERSION && StringUtils.isNotBlank(sandboxRegDto.getUsername()) && StringUtils.isNotBlank(sandboxRegDto.getPassword())) {
user = checkLoginInfo(sandboxRegDto);
}
Assert.notNull(sandboxRegDto.getSandboxConfigValue(), I18nUtil.systemMessage("Backend.Sandbox.TerminalConfigRequired"));
TenantConfigDto tenantConfigDto = (TenantConfigDto) RequestContext.get().getTenantConfig();
String host = request.getHeader("Host");
if (host != null) {
host = host.split(":")[0];
tenantConfigDto.setSiteUrl("https://" + host);//后续使用
}
if (StringUtils.isBlank(sandboxRegDto.getSandboxConfigValue().getHostWithScheme())) {
sandboxRegDto.getSandboxConfigValue().setHostWithScheme("http://127.0.0.1");
}
if (StringUtils.isNotBlank(sandboxRegDto.getSavedKey())) {
SandboxConfigDto byKey = sandboxConfigApplicationService.getByKey(sandboxRegDto.getSavedKey());
if (byKey != null) {
if (sandboxRegDto.getSandboxConfigValue().getVncPort() <= 0) {
sandboxRegDto.getSandboxConfigValue().setVncPort(6080);
}
SandboxConfigDto sandboxConfigUpdate = new SandboxConfigDto();
sandboxConfigUpdate.setId(byKey.getId());
sandboxConfigUpdate.setConfigValue(sandboxRegDto.getSandboxConfigValue());
sandboxConfigApplicationService.update(sandboxConfigUpdate);
byKey.setConfigValue(sandboxRegDto.getSandboxConfigValue());
if (user != null) {
String token = authService.createToken(user, StringUtils.isNotBlank(sandboxRegDto.getDeviceId()) ? sandboxRegDto.getDeviceId() : UUID.randomUUID().toString().replace("-", ""));
byKey.setToken(token);
}
return ReqResult.success(byKey);
}
}
Assert.isTrue(StringUtils.isNotBlank(sandboxRegDto.getUsername()), I18nUtil.systemMessage("Backend.Sandbox.UsernameRequired"));
Assert.isTrue(StringUtils.isNotBlank(sandboxRegDto.getPassword()), I18nUtil.systemMessage("Backend.Sandbox.PasswordRequired"));
if (user == null) {
user = checkLoginInfo(sandboxRegDto);
}
RequestContext.get().setUser(user);
RequestContext.get().setUserId(user.getId());
if (user.getLang() != null) {
user.setLangMap(i18nApplicationService.querySystemLangMap(user.getTenantId(), I18nSideEnum.Backend.getSide(), user.getLang()));
RequestContext.get().setLang(user.getLang());
RequestContext.get().setLangMap(user.getLangMap());
}
SandboxConfigDto dto = new SandboxConfigDto();
dto.setUserId(user.getId());
dto.setScope(SandboxScopeEnum.USER);
dto.setConfigKey(UUID.randomUUID().toString().replace("-", ""));
dto.setName(I18nUtil.systemMessage("Backend.Sandbox.MyComputer"));
dto.setConfigValue(sandboxRegDto.getSandboxConfigValue());
dto.setDescription("");
dto.setIsActive(true);
dto.setOnline(false);
sandboxConfigApplicationService.create(dto, false);
String token = authService.createToken(user, StringUtils.isNotBlank(sandboxRegDto.getDeviceId()) ? sandboxRegDto.getDeviceId() : UUID.randomUUID().toString().replace("-", ""));
dto.setToken(token);
return ReqResult.success(sandboxConfigApplicationService.getByKey(dto.getConfigKey()));
}
private UserDto checkLoginInfo(SandboxRegDto sandboxRegDto) {
TenantConfigDto tenantConfigDto = (TenantConfigDto) RequestContext.get().getTenantConfig();
String key = "sb:reg:" + tenantConfigDto.getTenantId() + ":" + MD5.MD5Encode(sandboxRegDto.getUsername());
Long increment = redisUtil.increment(key, 1);
if (increment <= 5) {
redisUtil.expire(key, 1800);
}
Assert.isTrue(increment <= 5, I18nUtil.systemMessage("Backend.Sandbox.LoginAttemptsExceeded"));
UserDto user;
if (sandboxRegDto.getUsername().matches("^[a-zA-Z0-9._-]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$")) {
user = userApplicationService.queryUserByEmail(sandboxRegDto.getUsername());
Assert.isTrue(user != null, I18nUtil.systemMessage("Backend.Sandbox.UserNotFoundPasswordError", increment.toString()));
} else {
user = userApplicationService.queryUserByPhone(sandboxRegDto.getUsername());
if (user == null) {
user = userApplicationService.queryUserByUserName(sandboxRegDto.getUsername());
}
Assert.isTrue(user != null, I18nUtil.systemMessage("Backend.Sandbox.UserNotFoundPasswordError", increment.toString()));
}
String userDynamicCode = userApplicationService.getUserDynamicCode(user.getId());
if (!userDynamicCode.equals(sandboxRegDto.getPassword().trim())) {
UserDto userDto = userApplicationService.queryUserByPhoneOrEmailWithPassword(sandboxRegDto.getUsername(), sandboxRegDto.getPassword().trim());
Assert.isTrue(userDto != null, I18nUtil.systemMessage("Backend.Sandbox.UserNotFoundAuthCodeError", increment.toString()));
}
redisUtil.expire(key, 0);
return user;
}
@Operation(summary = "创建个人电脑(客户端配置)")
@PostMapping("/create")
public ReqResult<Void> create(@RequestBody SandboxConfigCreateDto configCreateDto) {
Assert.notNull(configCreateDto.getName(), I18nUtil.systemMessage("Backend.Sandbox.NameRequired"));
SandboxConfigDto dto = new SandboxConfigDto();
dto.setName(configCreateDto.getName());
dto.setDescription(configCreateDto.getDescription());
dto.setUserId(RequestContext.get().getUserId());
dto.setScope(SandboxScopeEnum.USER);
dto.setConfigKey(UUID.randomUUID().toString().replace("-", ""));
dto.setConfigValue(new SandboxConfigValue());
sandboxConfigApplicationService.create(dto, true);
return ReqResult.success();
}
@Operation(summary = "更新配置")
@PostMapping("/update")
public ReqResult<Void> update(@RequestBody SandboxConfigDto dto) {
Assert.notNull(dto.getId(), I18nUtil.systemMessage("Backend.Sandbox.IdRequired"));
checkPermission(dto.getId());
sandboxConfigApplicationService.update(dto);
return ReqResult.success();
}
@Operation(summary = "删除配置")
@PostMapping("/delete/{id}")
public ReqResult<Void> delete(
@Parameter(description = "配置ID") @PathVariable Long id) {
checkPermission(id);
sandboxConfigApplicationService.delete(id);
return ReqResult.success();
}
@Operation(summary = "跳转到具体的电脑会话界面")
@GetMapping("/redirect/{id}")
public void redirect(HttpServletResponse response, @Parameter(description = "配置ID") @PathVariable Long id) {
String siteUrl = buildSiteUrl();
SandboxConfigDto byId = sandboxConfigApplicationService.getById(id);
if (byId == null) {
try {
response.sendRedirect(siteUrl);
} catch (IOException e) {
throw new RuntimeException(e);
}
return;
}
String redirectUri;
UserAgentDto userAgentDto = agentApplicationService.queryUserAgentRecentUse(byId.getUserId(), byId.getAgentId());
if (userAgentDto != null && userAgentDto.getLastConversationId() != null) {
redirectUri = "home/chat/" + userAgentDto.getLastConversationId() + "/" + byId.getAgentId() + "?hideMenu=true";
} else {
redirectUri = "agent/" + byId.getAgentId() + "?hideMenu=true";
}
try {
response.sendRedirect(siteUrl + redirectUri);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Operation(summary = "跳转到具体的会话界面")
@GetMapping("/redirect/chat/{id}")
public void redirectConversation(HttpServletResponse response, @Parameter(description = "会话ID") @PathVariable Long id) {
String siteUrl = buildSiteUrl();
ConversationDto byId = conversationApplicationService.getConversationByCid(id);
if (byId == null) {
try {
response.sendRedirect(siteUrl);
} catch (IOException e) {
throw new RuntimeException(e);
}
return;
}
try {
response.sendRedirect(siteUrl + "home/chat/" + id + "/" + byId.getAgentId() + "?hideMenu=true");
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Operation(summary = "跳转到智能体的新会话界面")
@GetMapping("/redirect/new/{id}")
public void redirectNewChat(HttpServletResponse response, @Parameter(description = "配置ID") @PathVariable Long id) {
SandboxConfigDto byId = sandboxConfigApplicationService.getById(id);
String siteUrl = buildSiteUrl();
if (byId == null) {
try {
response.sendRedirect(siteUrl);
} catch (IOException e) {
throw new RuntimeException(e);
}
return;
}
try {
response.sendRedirect(siteUrl + "agent/" + byId.getAgentId() + "?hideMenu=true");
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private String buildSiteUrl() {
TenantConfigDto tenantConfigDto = (TenantConfigDto) RequestContext.get().getTenantConfig();
String siteUrl = tenantConfigDto.getSiteUrl();
return siteUrl.trim().endsWith("/") ? siteUrl.trim() : siteUrl.trim() + "/";
}
@Operation(summary = "客户端健康检查")
@GetMapping("/health/{key}")
public ReqResult<Object> health(@Parameter(description = "配置ID") @PathVariable String key) {
SandboxConfigDto sandboxConfigDto = sandboxConfigApplicationService.getByKey(key);
if (sandboxConfigDto == null) {
return ReqResult.error(I18nUtil.systemMessage("Backend.Sandbox.ConfigNotFound"));
}
if (sandboxConfigDto.getIsActive() != null && !sandboxConfigDto.getIsActive()) {
return ReqResult.error(I18nUtil.systemMessage("Backend.Sandbox.ClientDisabled"));
}
if (!sandboxConfigDto.isOnline()) {
return ReqResult.error(I18nUtil.systemMessage("Backend.Sandbox.ClientOffline"));
}
SandboxServerInfo sandboxServerInfo = sandboxConfigDto.getServerInfo();
if (sandboxServerInfo == null) {
return ReqResult.error(I18nUtil.systemMessage("Backend.Sandbox.ConfigError"));
}
String healthUrl = sandboxServerInfo.getScheme() + "://" + sandboxServerInfo.getHost() + ":" + sandboxServerInfo.getAgentPort() + "/health";
HttpRequest request = HttpRequest.newBuilder().uri(URI.create(healthUrl))
.header("x-api-key", sandboxConfigDto.getConfigValue().getApiKey() == null ? "" : sandboxConfigDto.getConfigValue().getApiKey())
.timeout(java.time.Duration.ofSeconds(10))
.GET().build();
try {
String serverStatusStr = httpClient.send(request, HttpResponse.BodyHandlers.ofString()).body();
return ReqResult.success(serverStatusStr);
} catch (Exception e) {
return ReqResult.error(e.getMessage());
}
}
@Operation(summary = "启用/禁用配置")
@PostMapping("/toggle/{id}")
public ReqResult<Void> toggle(
@Parameter(description = "配置ID") @PathVariable Long id) {
checkPermission(id);
sandboxConfigApplicationService.toggle(id);
return ReqResult.success();
}
private void checkPermission(Long id) {
UserDto user = (UserDto) RequestContext.get().getUser();
if (user.getRole() == User.Role.Admin) {
return;
}
SandboxConfigDto byId = sandboxConfigApplicationService.getById(id);
Assert.isTrue(byId != null && byId.getUserId().equals(user.getId()), I18nUtil.systemMessage("Backend.Sandbox.NoPermission"));
}
/**
* 从User-Agent字符串中提取指定包的版本号
*
* @param userAgent User-Agent字符串
* @param packageName 包名,如 "@qiming-ai/qimingclaw"
* @return 版本号如果未找到则返回null
*/
private static String extractVersion(String userAgent, String packageName) {
// 转义包名中的特殊字符(如 @ 和 /
String escapedPackageName = Pattern.quote(packageName);
// 构建正则表达式:包名/版本号
String regex = escapedPackageName + "/([^\\s/]+)";
Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(userAgent);
if (matcher.find()) {
return matcher.group(1);
}
return null;
}
/**
* 从User-Agent字符串中提取 @qiming-ai/qimingclaw 的版本号
*
* @param userAgent User-Agent字符串
* @return 版本号如果未找到则返回null
*/
private static String extractQimingVersion(String userAgent) {
return extractVersion(userAgent, "@qiming-ai/qimingclaw");
}
/**
* 将版本号转换为整数,可用于版本对比
* 支持1-4位版本号统一格式每位版本号占2位数字不足补0
* 例如0.9.1 -> 0009011.2.3 -> 0102030.9.1.2 -> 00090102
*
* @param version 版本号字符串,如 "0.9.1"
* @return 整数,可直接用于版本大小对比
*/
private static int toVersionNumber(String version) {
if (version == null || version.isEmpty()) {
return 0;
}
// 按点号分割版本号
String[] parts = version.split("\\.");
int result = 0;
// 统一处理每部分占2位数字不足部分补0
for (int i = 0; i < 4; i++) {
result *= 100; // 每次左移2位
if (i < parts.length) {
result += Integer.parseInt(parts[i]);
}
}
return result;
}
}

View File

@@ -0,0 +1,122 @@
package com.xspaceagi.sandbox.ui.web.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.xspaceagi.sandbox.application.dto.SandboxConfigDto;
import com.xspaceagi.sandbox.application.dto.SandboxConfigQueryDto;
import com.xspaceagi.sandbox.application.dto.SandboxGlobalConfigDto;
import com.xspaceagi.sandbox.application.service.SandboxConfigApplicationService;
import com.xspaceagi.system.spec.annotation.RequireResource;
import com.xspaceagi.system.spec.common.RequestContext;
import com.xspaceagi.system.spec.dto.ReqResult;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import static com.xspaceagi.system.spec.enums.ResourceEnum.*;
/**
* 沙盒配置管理接口
*/
@Tag(name = "系统管理-沙盒配置相关接口")
@RestController
@RequestMapping("/api/system/sandbox/config")
public class SandboxConfigManageController {
@Resource
private SandboxConfigApplicationService sandboxConfigApplicationService;
@RequireResource(SANDBOX_CONFIG_QUERY)
@Operation(summary = "分页查询配置列表")
@PostMapping("/page")
public ReqResult<Page<SandboxConfigDto>> pageQuery(@RequestBody SandboxConfigQueryDto queryDto) {
return ReqResult.success(sandboxConfigApplicationService.pageQuery(queryDto));
}
@RequireResource(SANDBOX_CONFIG_QUERY)
@Operation(summary = "查询配置详情")
@GetMapping("/get/{id}")
public ReqResult<SandboxConfigDto> getById(
@Parameter(description = "配置ID") @PathVariable Long id) {
return ReqResult.success(sandboxConfigApplicationService.getById(id));
}
@Operation(summary = "沙箱连通性测试")
@GetMapping("/test/{id}")
public ReqResult<Void> testConnection(@Parameter(description = "配置ID") @PathVariable Long id) {
try {
sandboxConfigApplicationService.testConnection(id);
} catch (Exception e) {
return ReqResult.error("0007", e.getMessage());
}
return ReqResult.success();
}
@RequireResource(SANDBOX_CONFIG_QUERY)
@Operation(summary = "查询用户配置列表")
@GetMapping("/user/list")
public ReqResult<List<SandboxConfigDto>> listUserConfigsByType(
@Parameter(description = "用户ID为空时查询当前用户") @RequestParam(required = false) Long userId) {
return ReqResult.success(sandboxConfigApplicationService.listUserConfigsByType(userId));
}
@RequireResource(SANDBOX_CONFIG_QUERY)
@Operation(summary = "查询全局配置列表")
@GetMapping("/global/list")
public ReqResult<List<SandboxConfigDto>> listGlobalConfigsByType() {
return ReqResult.success(sandboxConfigApplicationService.listGlobalConfigsByType());
}
@RequireResource(SANDBOX_CONFIG_ADD)
@Operation(summary = "创建配置")
@PostMapping("/create")
public ReqResult<Void> create(@RequestBody SandboxConfigDto dto) {
sandboxConfigApplicationService.create(dto, false);
return ReqResult.success();
}
@RequireResource(SANDBOX_CONFIG_MODIFY)
@Operation(summary = "更新配置")
@PostMapping("/update")
public ReqResult<Void> update(@RequestBody SandboxConfigDto dto) {
sandboxConfigApplicationService.update(dto);
return ReqResult.success();
}
@RequireResource(SANDBOX_CONFIG_DELETE)
@Operation(summary = "删除配置")
@PostMapping("/delete/{id}")
public ReqResult<Void> delete(
@Parameter(description = "配置ID") @PathVariable Long id) {
sandboxConfigApplicationService.delete(id);
return ReqResult.success();
}
@RequireResource(SANDBOX_CONFIG_MODIFY)
@Operation(summary = "启用/禁用配置")
@PostMapping("/toggle/{id}")
public ReqResult<Void> toggle(
@Parameter(description = "配置ID") @PathVariable Long id) {
sandboxConfigApplicationService.toggle(id);
return ReqResult.success();
}
@RequireResource(SANDBOX_CONFIG_SAVE)
@Operation(summary = "更新沙箱全局配置")
@PostMapping("/global/update")
public ReqResult<Void> globalUpdate(@RequestBody SandboxGlobalConfigDto dto) {
sandboxConfigApplicationService.updateGlobalConfig(RequestContext.get().getTenantId(), dto);
return ReqResult.success();
}
@RequireResource(SANDBOX_CONFIG_QUERY)
@Operation(summary = "查询沙箱全局配置")
@PostMapping("/global")
public ReqResult<SandboxGlobalConfigDto> getGlobalConfig() {
SandboxGlobalConfigDto globalConfig = sandboxConfigApplicationService.getGlobalConfig(RequestContext.get().getTenantId());
return ReqResult.success(globalConfig);
}
}

View File

@@ -0,0 +1,13 @@
package com.xspaceagi.sandbox.ui.web.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
public class SandboxConfigCreateDto {
@Schema(description = "电脑名称", requiredMode = Schema.RequiredMode.REQUIRED)
private String name;
@Schema(description = "电脑描述")
private String description;
}

View File

@@ -0,0 +1,24 @@
package com.xspaceagi.sandbox.ui.web.dto;
import com.xspaceagi.sandbox.infra.dao.vo.SandboxConfigValue;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
public class SandboxRegDto {
@Schema(description = "用户名、邮箱或手机号码")
private String username;
@Schema(description = "密码")
private String password;
@Schema(description = "曾经保存的密钥,首次登录不需要")
private String savedKey;
@Schema(description = "设备ID")
private String deviceId;
@Schema(description = "终端配置信息")
private SandboxConfigValue sandboxConfigValue;
}

View File

@@ -0,0 +1,14 @@
package com.xspaceagi.sandbox.ui.web.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
public class SandboxTempLinkDto {
@Schema(description = "外部可访问的临时代理链接地址")
private String tempLink;
@Schema(description = "代理链接ID用于管理该链接比如删除时使用")
private Long id;
}

View File

@@ -0,0 +1,24 @@
package com.xspaceagi.sandbox.ui.web.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
public class UserSandBoxSelectDto {
@Schema(description = "可选的沙盒列表")
private List<SelectDto> sandboxes;
@Schema(description = "已选择的沙盒, key为agentIdvalue为sandboxId")
private Map<String, String> agentSelected;
@Data
public static class SelectDto {
private String sandboxId;
private String name;
private String description;
}
}

View File

@@ -0,0 +1,32 @@
<?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-modules</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-sandbox</artifactId>
<packaging>pom</packaging>
<modules>
<module>app-platform-sandbox-spec</module>
<module>app-platform-sandbox-infra</module>
<module>app-platform-sandbox-application</module>
<module>app-platform-sandbox-domain</module>
<module>app-platform-sandbox-ui</module>
<module>app-platform-sandbox-sdk</module>
<module>app-platform-sandbox-api</module>
</modules>
<dependencies>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>system-sdk</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-agent-core-sdk</artifactId>
</dependency>
</dependencies>
</project>