增加ssh链接框架

This commit is contained in:
dengqichen 2025-12-07 01:05:25 +08:00
parent d83a87b259
commit 308a5587cc
21 changed files with 1682 additions and 3 deletions

View File

@ -0,0 +1,137 @@
package com.qqchen.deploy.backend.deploy.api;
import com.qqchen.deploy.backend.deploy.dto.FileUploadResultDTO;
import com.qqchen.deploy.backend.deploy.dto.RemoteFileInfoDTO;
import com.qqchen.deploy.backend.deploy.service.impl.ServerSSHFileService;
import com.qqchen.deploy.backend.framework.api.Response;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/**
* 服务器SSH文件管理API业务层
*
* 提供文件浏览上传下载删除等功能
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/server-ssh")
public class ServerSSHFileApiController {
@Resource
private ServerSSHFileService serverSSHFileService;
/**
* 浏览远程目录
*
* @param serverId 服务器ID
* @param path 目录路径默认为用户主目录
* @return 文件列表
*/
@GetMapping("/{serverId}/files/browse")
public Response<List<RemoteFileInfoDTO>> browseDirectory(
@PathVariable Long serverId,
@RequestParam(value = "path", defaultValue = "~") String path
) {
log.info("浏览目录: serverId={}, path={}", serverId, path);
// 处理~符号用户主目录
if ("~".equals(path)) {
path = "/home"; // 可以根据实际情况调整默认路径
}
// Service层已经处理转换
List<RemoteFileInfoDTO> dtos = serverSSHFileService.listDirectoryAsDTO(serverId, path);
return Response.success(dtos);
}
/**
* 创建目录
*
* @param serverId 服务器ID
* @param path 目录路径
* @return 成功标志
*/
@PostMapping("/{serverId}/files/mkdir")
public Response<Void> createDirectory(
@PathVariable Long serverId,
@RequestParam("path") String path
) {
log.info("创建目录: serverId={}, path={}", serverId, path);
serverSSHFileService.createDirectory(serverId, path);
return Response.success();
}
/**
* 删除文件或目录
*
* @param serverId 服务器ID
* @param path 文件/目录路径
* @param recursive 是否递归删除目录时使用
* @return 成功标志
*/
@DeleteMapping("/{serverId}/files/remove")
public Response<Void> deleteFile(
@PathVariable Long serverId,
@RequestParam("path") String path,
@RequestParam(value = "recursive", defaultValue = "false") Boolean recursive
) {
log.info("删除文件: serverId={}, path={}, recursive={}", serverId, path, recursive);
serverSSHFileService.deleteFile(serverId, path, recursive);
return Response.success();
}
/**
* 上传文件
*
* @param serverId 服务器ID
* @param file 文件
* @param remotePath 远程路径完整路径包括文件名
* @param overwrite 是否覆盖已存在的文件
* @return 上传结果
*/
@PostMapping("/{serverId}/files/upload")
public Response<FileUploadResultDTO> uploadFile(
@PathVariable Long serverId,
@RequestParam("file") MultipartFile file,
@RequestParam("remotePath") String remotePath,
@RequestParam(value = "overwrite", defaultValue = "false") Boolean overwrite
) {
log.info("上传文件: serverId={}, fileName={}, remotePath={}, size={}, overwrite={}",
serverId, file.getOriginalFilename(), remotePath, file.getSize(), overwrite);
// Service层已经处理转换
FileUploadResultDTO result = serverSSHFileService.uploadFileAsDTO(serverId, file, remotePath, overwrite);
return Response.success(result);
}
/**
* 重命名文件或目录
*
* @param serverId 服务器ID
* @param oldPath 原路径
* @param newPath 新路径
* @return 成功标志
*/
@PutMapping("/{serverId}/files/rename")
public Response<Void> renameFile(
@PathVariable Long serverId,
@RequestParam("oldPath") String oldPath,
@RequestParam("newPath") String newPath
) {
log.info("重命名文件: serverId={}, oldPath={}, newPath={}", serverId, oldPath, newPath);
serverSSHFileService.renameFile(serverId, oldPath, newPath);
return Response.success();
}
}

View File

@ -0,0 +1,85 @@
package com.qqchen.deploy.backend.deploy.converter;
import com.qqchen.deploy.backend.deploy.dto.FileUploadResultDTO;
import com.qqchen.deploy.backend.deploy.dto.RemoteFileInfoDTO;
import com.qqchen.deploy.backend.framework.ssh.file.SSHFileInfo;
import com.qqchen.deploy.backend.framework.utils.FileUtils;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Named;
import java.util.Date;
/**
* 远程文件信息转换器使用MapStruct
*/
@Mapper(componentModel = "spring")
public interface RemoteFileInfoConverter {
@Mapping(target = "sizeFormatted", source = "size", qualifiedByName = "formatFileSize")
@Mapping(target = "permissionsOctal", source = "permissionMask", qualifiedByName = "formatPermissionsOctal")
@Mapping(target = "owner", source = "ownerUid", qualifiedByName = "formatOwner")
@Mapping(target = "group", source = "groupGid", qualifiedByName = "formatGroup")
@Mapping(target = "permissions", source = "permissionString")
RemoteFileInfoDTO toDTO(SSHFileInfo fileInfo);
/**
* 转换SSHFileInfo为上传结果DTO
*/
default FileUploadResultDTO toUploadResultDTO(SSHFileInfo fileInfo) {
return FileUploadResultDTO.builder()
.fileName(fileInfo.getName())
.remotePath(fileInfo.getPath())
.fileSize(fileInfo.getSize())
.fileSizeFormatted(formatFileSize(fileInfo.getSize()))
.uploadTime(new Date())
.permissions(fileInfo.getPermissionString())
.success(true)
.build();
}
/**
* 格式化文件大小
*/
@Named("formatFileSize")
default String formatFileSize(Long size) {
return FileUtils.formatFileSize(size);
}
/**
* 格式化权限为八进制字符串
*/
@Named("formatPermissionsOctal")
default String formatPermissionsOctal(Integer permissionMask) {
if (permissionMask == null) {
return null;
}
return String.format("0%o", permissionMask);
}
/**
* 格式化所有者信息
*/
@Named("formatOwner")
default String formatOwner(Integer uid) {
if (uid == null) {
return null;
}
// TODO: 未来可以通过执行 getent passwd 命令获取用户名
// 目前只返回 UID 的格式
return "(" + uid + ")";
}
/**
* 格式化组信息
*/
@Named("formatGroup")
default String formatGroup(Integer gid) {
if (gid == null) {
return null;
}
// TODO: 未来可以通过执行 getent group 命令获取组名
// 目前只返回 GID 的格式
return "(" + gid + ")";
}
}

View File

@ -0,0 +1,60 @@
package com.qqchen.deploy.backend.deploy.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
/**
* 文件上传结果DTO业务层
*
* 用于返回给前端的上传结果信息
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class FileUploadResultDTO {
/**
* 文件名
*/
private String fileName;
/**
* 远程路径
*/
private String remotePath;
/**
* 文件大小字节
*/
private Long fileSize;
/**
* 格式化的文件大小1.5 MB
*/
private String fileSizeFormatted;
/**
* 上传时间
*/
private Date uploadTime;
/**
* 权限字符串
*/
private String permissions;
/**
* 是否成功
*/
private Boolean success;
/**
* 错误消息如果失败
*/
private String errorMessage;
}

View File

@ -0,0 +1,95 @@
package com.qqchen.deploy.backend.deploy.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
/**
* 远程文件信息DTO业务层
*
* 用于返回给前端的文件信息
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RemoteFileInfoDTO {
/**
* 文件/目录名称
*/
private String name;
/**
* 完整路径
*/
private String path;
/**
* 是否是目录
*/
private Boolean isDirectory;
/**
* 文件大小字节
*/
private Long size;
/**
* 格式化的文件大小1.5 MB
*/
private String sizeFormatted;
/**
* 权限字符串rwxr-xr-x
*/
private String permissions;
/**
* 权限八进制表示0755
*/
private String permissionsOctal;
/**
* 修改时间
*/
private Date modifyTime;
/**
* 所有者格式用户名 (UID)root (0)
*/
private String owner;
/**
* 所有者UID
*/
private Integer ownerUid;
/**
* 格式组名 (GID)root (0)
*/
private String group;
/**
* 组GID
*/
private Integer groupGid;
/**
* 文件扩展名
*/
private String extension;
/**
* 是否是符号链接
*/
private Boolean isSymlink;
/**
* 符号链接目标
*/
private String linkTarget;
}

View File

@ -0,0 +1,452 @@
package com.qqchen.deploy.backend.deploy.service.impl;
import com.qqchen.deploy.backend.deploy.converter.RemoteFileInfoConverter;
import com.qqchen.deploy.backend.deploy.dto.RemoteFileInfoDTO;
import com.qqchen.deploy.backend.deploy.entity.Server;
import com.qqchen.deploy.backend.deploy.service.IServerService;
import com.qqchen.deploy.backend.framework.enums.ResponseCode;
import com.qqchen.deploy.backend.framework.enums.SSHEvent;
import com.qqchen.deploy.backend.framework.enums.SSHTargetType;
import com.qqchen.deploy.backend.framework.exception.BusinessException;
import com.qqchen.deploy.backend.framework.ssh.SSHCommandServiceFactory;
import com.qqchen.deploy.backend.framework.ssh.file.AbstractSSHFileOperations;
import com.qqchen.deploy.backend.framework.ssh.file.SSHFileInfo;
import com.qqchen.deploy.backend.framework.ssh.websocket.SSHTarget;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import net.schmizz.sshj.sftp.FileAttributes;
import net.schmizz.sshj.sftp.FileMode;
import net.schmizz.sshj.sftp.RemoteResourceInfo;
import net.schmizz.sshj.sftp.SFTPClient;
import net.schmizz.sshj.xfer.InMemorySourceFile;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
/**
* 服务器SSH文件操作服务Deploy层
*
* 继承Framework的AbstractSSHFileOperations
* 实现服务器文件管理的具体业务逻辑
*/
@Slf4j
@Service
public class ServerSSHFileService extends AbstractSSHFileOperations {
@Resource
private IServerService serverService;
@Resource
private RemoteFileInfoConverter remoteFileInfoConverter;
/**
* 构造函数
*/
public ServerSSHFileService(SSHCommandServiceFactory sshCommandServiceFactory) {
super(sshCommandServiceFactory);
}
// ========== 公共API方法返回DTO ==========
/**
* 浏览远程目录返回DTO
*
* @param serverId 服务器ID
* @param path 目录路径
* @return 文件DTO列表
*/
public List<RemoteFileInfoDTO> listDirectoryAsDTO(Long serverId, String path) {
List<SSHFileInfo> files = listDirectory(serverId, path);
return files.stream()
.map(remoteFileInfoConverter::toDTO)
.collect(Collectors.toList());
}
/**
* 上传文件返回上传结果DTO
*
* @param serverId 服务器ID
* @param file 要上传的文件
* @param remotePath 远程目标路径完整路径包括文件名
* @param overwrite 是否覆盖已存在的文件
* @return 上传结果DTO
*/
public com.qqchen.deploy.backend.deploy.dto.FileUploadResultDTO uploadFileAsDTO(
Long serverId, MultipartFile file, String remotePath, boolean overwrite) {
SSHFileInfo fileInfo = uploadFile(serverId, file, remotePath, overwrite);
return remoteFileInfoConverter.toUploadResultDTO(fileInfo);
}
// ========== 公共API方法返回实体 ==========
/**
* 浏览远程目录
*
* @param serverId 服务器ID
* @param path 目录路径
* @return 文件列表
*/
public List<SSHFileInfo> listDirectory(Long serverId, String path) {
// 验证路径
validatePath(path);
// 获取SSH目标
SSHTarget target = buildSSHTarget(serverId);
// 执行文件操作
return executeFileOperation(target, new FileOperation<List<SSHFileInfo>>() {
@Override
public List<SSHFileInfo> execute(SFTPClient sftpClient) throws Exception {
List<RemoteResourceInfo> resources = sftpClient.ls(path);
return resources.stream()
.filter(resource -> !".".equals(resource.getName()) && !"..".equals(resource.getName()))
.map(resource -> convertToSSHFileInfo(resource))
.collect(Collectors.toList());
}
@Override
public String getName() {
return "浏览目录";
}
});
}
/**
* 创建目录
*
* @param serverId 服务器ID
* @param path 目录路径
*/
public void createDirectory(Long serverId, String path) {
// 验证路径
validatePath(path);
// 获取SSH目标
SSHTarget target = buildSSHTarget(serverId);
// 执行文件操作
executeFileOperation(target, new FileOperation<Void>() {
@Override
public Void execute(SFTPClient sftpClient) throws Exception {
// 递归创建目录
sftpClient.mkdirs(path);
return null;
}
@Override
public String getName() {
return "创建目录";
}
});
}
/**
* 删除文件或目录
*
* @param serverId 服务器ID
* @param path 文件/目录路径
* @param recursive 是否递归删除目录时使用
*/
public void deleteFile(Long serverId, String path, boolean recursive) {
// 验证路径
validatePath(path);
// 获取SSH目标
SSHTarget target = buildSSHTarget(serverId);
// 执行文件操作
executeFileOperation(target, new FileOperation<Void>() {
@Override
public Void execute(SFTPClient sftpClient) throws Exception {
// 检查文件类型
FileAttributes attrs = sftpClient.stat(path);
if (attrs.getType() == FileMode.Type.DIRECTORY) {
if (recursive) {
// 递归删除目录
deleteDirectoryRecursive(sftpClient, path);
} else {
// 只删除空目录
sftpClient.rmdir(path);
}
} else {
// 删除文件
sftpClient.rm(path);
}
return null;
}
@Override
public String getName() {
return "删除文件";
}
});
}
/**
* 上传文件
*
* @param serverId 服务器ID
* @param file 要上传的文件
* @param remotePath 远程目标路径完整路径包括文件名
* @param overwrite 是否覆盖已存在的文件
* @return 文件信息
*/
public SSHFileInfo uploadFile(Long serverId, MultipartFile file, String remotePath, boolean overwrite) {
// 验证路径
validatePath(remotePath);
// 验证文件
if (file == null || file.isEmpty()) {
throw new BusinessException(ResponseCode.INVALID_PARAM, new Object[]{"文件不能为空"});
}
// 获取SSH目标
SSHTarget target = buildSSHTarget(serverId);
// 执行文件操作
return executeFileOperation(target, new FileOperation<SSHFileInfo>() {
@Override
public SSHFileInfo execute(SFTPClient sftpClient) throws Exception {
// 检查文件是否已存在
if (!overwrite) {
try {
sftpClient.stat(remotePath);
throw new BusinessException(ResponseCode.ERROR,
new Object[]{"文件已存在: " + remotePath});
} catch (IOException e) {
// 文件不存在继续上传
}
}
// 使用内存流上传避免临时文件
sftpClient.put(new InMemorySourceFile() {
@Override
public String getName() {
return file.getOriginalFilename();
}
@Override
public long getLength() {
return file.getSize();
}
@Override
public InputStream getInputStream() throws IOException {
return file.getInputStream();
}
}, remotePath);
// 获取上传后的文件信息
FileAttributes attrs = sftpClient.stat(remotePath);
return convertToSSHFileInfo(remotePath, attrs);
}
@Override
public String getName() {
return "上传文件";
}
});
}
/**
* 重命名文件或目录
*
* @param serverId 服务器ID
* @param oldPath 原路径
* @param newPath 新路径
*/
public void renameFile(Long serverId, String oldPath, String newPath) {
// 验证路径
validatePath(oldPath);
validatePath(newPath);
// 获取SSH目标
SSHTarget target = buildSSHTarget(serverId);
// 执行文件操作
executeFileOperation(target, new FileOperation<Void>() {
@Override
public Void execute(SFTPClient sftpClient) throws Exception {
sftpClient.rename(oldPath, newPath);
return null;
}
@Override
public String getName() {
return "重命名文件";
}
});
}
// ========== 辅助方法 ==========
/**
* 从服务器ID构建SSH目标
*/
private SSHTarget buildSSHTarget(Long serverId) {
Server server = serverService.findEntityById(serverId);
if (server == null) {
throw new BusinessException(ResponseCode.DATA_NOT_FOUND,
new Object[]{"服务器不存在: " + serverId});
}
SSHTarget target = new SSHTarget();
target.setTargetType(SSHTargetType.SERVER);
target.setHost(server.getHostIp());
target.setPort(server.getSshPort());
target.setUsername(server.getSshUser());
target.setAuthType(server.getAuthType());
target.setPassword(server.getSshPassword());
target.setPrivateKey(server.getSshPrivateKey());
target.setPassphrase(server.getSshPassphrase());
target.setOsType(server.getOsType());
target.setMetadata(serverId);
return target;
}
/**
* 转换RemoteResourceInfo为SSHFileInfo
*/
private SSHFileInfo convertToSSHFileInfo(RemoteResourceInfo resource) {
FileAttributes attrs = resource.getAttributes();
String fileName = resource.getName();
return SSHFileInfo.builder()
.name(fileName)
.path(resource.getPath())
.isDirectory(attrs.getType() == FileMode.Type.DIRECTORY)
.size(attrs.getSize())
.permissionMask(attrs.getMode().getPermissionsMask())
.permissionString(formatPermissions(attrs.getMode().getPermissionsMask()))
.modifyTime(new Date(attrs.getMtime() * 1000L))
.ownerUid(attrs.getUID())
.groupGid(attrs.getGID())
.extension(extractExtension(fileName))
.isSymlink(attrs.getType() == FileMode.Type.SYMLINK)
.build();
}
/**
* 转换FileAttributes为SSHFileInfo用于上传后获取文件信息
*/
private SSHFileInfo convertToSSHFileInfo(String path, FileAttributes attrs) {
String fileName = path.substring(path.lastIndexOf('/') + 1);
return SSHFileInfo.builder()
.name(fileName)
.path(path)
.isDirectory(attrs.getType() == FileMode.Type.DIRECTORY)
.size(attrs.getSize())
.permissionMask(attrs.getMode().getPermissionsMask())
.permissionString(formatPermissions(attrs.getMode().getPermissionsMask()))
.modifyTime(new Date(attrs.getMtime() * 1000L))
.ownerUid(attrs.getUID())
.groupGid(attrs.getGID())
.extension(extractExtension(fileName))
.isSymlink(attrs.getType() == FileMode.Type.SYMLINK)
.build();
}
/**
* 格式化权限为字符串rwxr-xr-x
*/
private String formatPermissions(int permissionMask) {
StringBuilder sb = new StringBuilder();
// 所有者权限
sb.append((permissionMask & 0400) != 0 ? 'r' : '-');
sb.append((permissionMask & 0200) != 0 ? 'w' : '-');
sb.append((permissionMask & 0100) != 0 ? 'x' : '-');
// 组权限
sb.append((permissionMask & 0040) != 0 ? 'r' : '-');
sb.append((permissionMask & 0020) != 0 ? 'w' : '-');
sb.append((permissionMask & 0010) != 0 ? 'x' : '-');
// 其他用户权限
sb.append((permissionMask & 0004) != 0 ? 'r' : '-');
sb.append((permissionMask & 0002) != 0 ? 'w' : '-');
sb.append((permissionMask & 0001) != 0 ? 'x' : '-');
return sb.toString();
}
/**
* 提取文件扩展名
*/
private String extractExtension(String fileName) {
if (fileName == null || fileName.isEmpty()) {
return null;
}
int dotIndex = fileName.lastIndexOf('.');
if (dotIndex > 0 && dotIndex < fileName.length() - 1) {
return fileName.substring(dotIndex + 1).toLowerCase();
}
return null;
}
/**
* 递归删除目录
*/
private void deleteDirectoryRecursive(SFTPClient sftpClient, String path) throws IOException {
List<RemoteResourceInfo> resources = sftpClient.ls(path);
for (RemoteResourceInfo resource : resources) {
String name = resource.getName();
if (".".equals(name) || "..".equals(name)) {
continue;
}
String itemPath = path.endsWith("/") ? path + name : path + "/" + name;
FileAttributes attrs = resource.getAttributes();
if (attrs.getType() == FileMode.Type.DIRECTORY) {
// 递归删除子目录
deleteDirectoryRecursive(sftpClient, itemPath);
} else {
// 删除文件
sftpClient.rm(itemPath);
}
}
// 删除空目录
sftpClient.rmdir(path);
}
// ========== 重写钩子方法事件处理 ==========
@Override
protected void beforeFileOperation(SSHTarget target, String operationName) {
super.beforeFileOperation(target, operationName);
log.info("开始文件操作: operation={}, serverId={}", operationName, target.getMetadata());
}
@Override
protected void afterFileOperation(SSHTarget target, String operationName, Object result) {
super.afterFileOperation(target, operationName, result);
log.info("文件操作成功: operation={}, serverId={}", operationName, target.getMetadata());
// TODO: 可以在这里触发事件记录审计日志
// 例如eventPublisher.publishEvent(new SSHFileEvent(...))
}
@Override
protected void onFileOperationError(SSHTarget target, String operationName, Exception error) {
super.onFileOperationError(target, operationName, error);
log.error("文件操作失败: operation={}, serverId={}, error={}",
operationName, target.getMetadata(), error.getMessage());
// TODO: 可以在这里触发错误事件
}
}

View File

@ -24,6 +24,22 @@ public enum ResponseCode {
DEPENDENCY_INJECTION_REPOSITORY_NOT_FOUND(1101, "dependency.injection.repository.not.found"), DEPENDENCY_INJECTION_REPOSITORY_NOT_FOUND(1101, "dependency.injection.repository.not.found"),
DEPENDENCY_INJECTION_CONVERTER_NOT_FOUND(1102, "dependency.injection.converter.not.found"), DEPENDENCY_INJECTION_CONVERTER_NOT_FOUND(1102, "dependency.injection.converter.not.found"),
DEPENDENCY_INJECTION_ENTITYPATH_FAILED(1103, "dependency.injection.entitypath.failed"), DEPENDENCY_INJECTION_ENTITYPATH_FAILED(1103, "dependency.injection.entitypath.failed"),
// SSH文件操作相关错误 (1200-1299)
SSH_FILE_CONNECTION_FAILED(1200, "ssh.file.connection.failed"),
SSH_FILE_AUTHENTICATION_FAILED(1201, "ssh.file.authentication.failed"),
SSH_FILE_NOT_FOUND(1202, "ssh.file.not.found"),
SSH_FILE_DIRECTORY_NOT_FOUND(1203, "ssh.file.directory.not.found"),
SSH_FILE_PERMISSION_DENIED(1204, "ssh.file.permission.denied"),
SSH_FILE_DISK_FULL(1205, "ssh.file.disk.full"),
SSH_FILE_INVALID_PATH(1206, "ssh.file.invalid.path"),
SSH_FILE_ALREADY_EXISTS(1207, "ssh.file.already.exists"),
SSH_FILE_TOO_LARGE(1208, "ssh.file.too.large"),
SSH_FILE_UNSUPPORTED_TYPE(1209, "ssh.file.unsupported.type"),
SSH_FILE_IO_ERROR(1210, "ssh.file.io.error"),
SSH_FILE_TIMEOUT(1211, "ssh.file.timeout"),
SSH_FILE_OPERATION_FAILED(1212, "ssh.file.operation.failed"),
SSH_FILE_UPLOAD_SIZE_EXCEEDED(1213, "ssh.file.upload.size.exceeded"),
// 业务异常 (2开头) // 业务异常 (2开头)
TENANT_NOT_FOUND(2001, "tenant.not.found"), TENANT_NOT_FOUND(2001, "tenant.not.found"),

View File

@ -37,5 +37,37 @@ public enum SSHEvent {
/** /**
* 优雅下线前 * 优雅下线前
*/ */
BEFORE_SHUTDOWN BEFORE_SHUTDOWN,
// ========== 文件操作事件 ==========
/**
* 文件列表浏览目录
*/
FILE_LIST,
/**
* 文件上传
*/
FILE_UPLOAD,
/**
* 文件下载
*/
FILE_DOWNLOAD,
/**
* 文件删除
*/
FILE_DELETE,
/**
* 创建目录
*/
FILE_CREATE_DIR,
/**
* 文件重命名
*/
FILE_RENAME
} }

View File

@ -0,0 +1,71 @@
package com.qqchen.deploy.backend.framework.enums;
/**
* SSH文件操作错误类型枚举Framework层
*/
public enum SSHFileErrorType {
/**
* 连接失败
*/
CONNECTION_FAILED,
/**
* 认证失败
*/
AUTHENTICATION_FAILED,
/**
* 文件不存在
*/
FILE_NOT_FOUND,
/**
* 目录不存在
*/
DIRECTORY_NOT_FOUND,
/**
* 权限不足
*/
PERMISSION_DENIED,
/**
* 磁盘空间不足
*/
DISK_FULL,
/**
* 路径无效包含非法字符或路径遍历
*/
INVALID_PATH,
/**
* 文件已存在
*/
FILE_EXISTS,
/**
* 文件过大
*/
FILE_TOO_LARGE,
/**
* 不支持的文件类型
*/
UNSUPPORTED_FILE_TYPE,
/**
* IO错误
*/
IO_ERROR,
/**
* 超时
*/
TIMEOUT,
/**
* 未知错误
*/
UNKNOWN
}

View File

@ -5,6 +5,7 @@ import com.qqchen.deploy.backend.framework.enums.ResponseCode;
import com.qqchen.deploy.backend.framework.exception.BusinessException; import com.qqchen.deploy.backend.framework.exception.BusinessException;
import com.qqchen.deploy.backend.framework.exception.SystemException; import com.qqchen.deploy.backend.framework.exception.SystemException;
import com.qqchen.deploy.backend.framework.exception.UniqueConstraintException; import com.qqchen.deploy.backend.framework.exception.UniqueConstraintException;
import com.qqchen.deploy.backend.framework.utils.FileUtils;
import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.MalformedJwtException; import io.jsonwebtoken.MalformedJwtException;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -16,6 +17,7 @@ import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.servlet.NoHandlerFoundException; import org.springframework.web.servlet.NoHandlerFoundException;
import java.security.SignatureException; import java.security.SignatureException;
@ -87,6 +89,14 @@ public class GlobalExceptionHandler {
return Response.error(ResponseCode.NOT_FOUND, message); return Response.error(ResponseCode.NOT_FOUND, message);
} }
@ExceptionHandler(MaxUploadSizeExceededException.class)
public Response<?> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e) {
long maxSize = e.getMaxUploadSize();
String maxSizeFormatted = FileUtils.formatFileSize(maxSize);
log.warn("File upload size exceeded: maxSize={}", maxSizeFormatted);
return Response.error(ResponseCode.SSH_FILE_UPLOAD_SIZE_EXCEEDED, maxSizeFormatted);
}
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
public Response<?> handleException(Exception e) { public Response<?> handleException(Exception e) {
log.error("Unexpected error occurred", e); log.error("Unexpected error occurred", e);

View File

@ -41,6 +41,7 @@ public class SecurityConfig {
"/api/v1/user/login", "/api/v1/user/login",
"/api/v1/user/register", "/api/v1/user/register",
"/api/v1/tenant/list", "/api/v1/tenant/list",
"/api/v1/server-ssh/*/files/**",
"/swagger-ui/**", "/swagger-ui/**",
"/v3/api-docs/**", "/v3/api-docs/**",
"/actuator/health" "/actuator/health"

View File

@ -0,0 +1,370 @@
package com.qqchen.deploy.backend.framework.ssh.file;
import com.qqchen.deploy.backend.framework.enums.ResponseCode;
import com.qqchen.deploy.backend.framework.enums.SSHFileErrorType;
import com.qqchen.deploy.backend.framework.exception.BusinessException;
import com.qqchen.deploy.backend.framework.ssh.ISSHCommandService;
import com.qqchen.deploy.backend.framework.ssh.SSHCommandServiceFactory;
import com.qqchen.deploy.backend.framework.ssh.websocket.SSHTarget;
import lombok.extern.slf4j.Slf4j;
import net.schmizz.sshj.SSHClient;
import net.schmizz.sshj.sftp.SFTPClient;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* SSH文件操作抽象基类Framework层
*
* 提供SFTP文件操作的通用能力
* - 连接管理复用现有SSH连接逻辑
* - 模板方法模式
* - 路径安全验证
* - 统一异常处理
* - 资源自动释放
*
* 子类只需实现业务逻辑和钩子方法
*/
@Slf4j
public abstract class AbstractSSHFileOperations {
/**
* SSH命令服务工厂用于创建连接
*/
protected final SSHCommandServiceFactory sshCommandServiceFactory;
/**
* 构造函数
*
* @param sshCommandServiceFactory SSH命令服务工厂
*/
protected AbstractSSHFileOperations(SSHCommandServiceFactory sshCommandServiceFactory) {
this.sshCommandServiceFactory = sshCommandServiceFactory;
}
// ========== 核心模板方法 ==========
/**
* 执行文件操作模板方法
*
* 统一管理
* 1. SSH连接创建
* 2. SFTP客户端创建
* 3. 前置钩子
* 4. 实际操作
* 5. 后置钩子
* 6. 异常处理
* 7. 资源释放
*
* @param target SSH目标
* @param operation 文件操作
* @param <T> 返回值类型
* @return 操作结果
*/
protected <T> T executeFileOperation(SSHTarget target, FileOperation<T> operation) {
validateSSHTarget(target);
SSHClient sshClient = null;
SFTPClient sftpClient = null;
try {
// 1. 创建SSH连接复用现有逻辑
sshClient = createSSHConnection(target);
// 2. 创建SFTP客户端
sftpClient = createSFTPClient(sshClient);
// 3. 前置钩子
beforeFileOperation(target, operation.getName());
// 4. 执行具体操作
T result = operation.execute(sftpClient);
// 5. 后置钩子
afterFileOperation(target, operation.getName(), result);
return result;
} catch (Exception e) {
// 6. 错误处理钩子
onFileOperationError(target, operation.getName(), e);
// 转换为统一异常
throw convertException(e, operation.getName());
} finally {
// 7. 资源释放
closeQuietly(sftpClient);
closeQuietly(sshClient);
}
}
// ========== SSH/SFTP连接管理 ==========
/**
* 创建SSH连接复用现有逻辑
*/
protected SSHClient createSSHConnection(SSHTarget target) throws Exception {
ISSHCommandService sshService = sshCommandServiceFactory.getService(target.getOsType());
String password = null, privateKey = null, passphrase = null;
switch (target.getAuthType()) {
case PASSWORD:
if (target.getPassword() == null || target.getPassword().isEmpty()) {
throw new BusinessException(ResponseCode.INVALID_PARAM,
new Object[]{"SSH密码不能为空"});
}
password = target.getPassword();
break;
case KEY:
if (target.getPrivateKey() == null || target.getPrivateKey().isEmpty()) {
throw new BusinessException(ResponseCode.INVALID_PARAM,
new Object[]{"SSH私钥不能为空"});
}
privateKey = target.getPrivateKey();
passphrase = target.getPassphrase();
break;
default:
throw new BusinessException(ResponseCode.INVALID_PARAM,
new Object[]{"不支持的认证类型: " + target.getAuthType()});
}
return sshService.createConnection(
target.getHost(),
target.getPort(),
target.getUsername(),
password,
privateKey,
passphrase
);
}
/**
* 从SSH客户端创建SFTP客户端
*/
protected SFTPClient createSFTPClient(SSHClient sshClient) throws IOException {
return sshClient.newSFTPClient();
}
/**
* 验证SSH目标参数
*/
protected void validateSSHTarget(SSHTarget target) {
if (target == null) {
throw new BusinessException(ResponseCode.INVALID_PARAM, new Object[]{"SSH目标不能为空"});
}
if (target.getHost() == null || target.getHost().isEmpty()) {
throw new BusinessException(ResponseCode.INVALID_PARAM, new Object[]{"主机地址不能为空"});
}
if (target.getUsername() == null || target.getUsername().isEmpty()) {
throw new BusinessException(ResponseCode.INVALID_PARAM, new Object[]{"用户名不能为空"});
}
if (target.getAuthType() == null) {
throw new BusinessException(ResponseCode.INVALID_PARAM, new Object[]{"认证方式不能为空"});
}
if (target.getOsType() == null) {
throw new BusinessException(ResponseCode.INVALID_PARAM, new Object[]{"操作系统类型不能为空"});
}
}
// ========== 路径安全验证 ==========
/**
* 验证路径安全性防止路径遍历攻击
*
* @param path 待验证的路径
* @throws SSHFileException 路径不安全时抛出
*/
protected void validatePath(String path) {
if (path == null || path.isEmpty()) {
throw new SSHFileException(
SSHFileErrorType.INVALID_PATH,
ResponseCode.SSH_FILE_INVALID_PATH
);
}
// 检查路径遍历攻击
if (path.contains("..")) {
throw new SSHFileException(
SSHFileErrorType.INVALID_PATH,
ResponseCode.SSH_FILE_INVALID_PATH
);
}
// 规范化路径
try {
Path normalizedPath = Paths.get(path).normalize();
String normalized = normalizedPath.toString();
// 确保规范化后的路径仍然是绝对路径Unix风格
if (!normalized.startsWith("/")) {
throw new SSHFileException(
SSHFileErrorType.INVALID_PATH,
ResponseCode.SSH_FILE_INVALID_PATH
);
}
} catch (Exception e) {
throw new SSHFileException(
SSHFileErrorType.INVALID_PATH,
ResponseCode.SSH_FILE_INVALID_PATH,
e
);
}
}
// ========== 钩子方法子类可选实现 ==========
/**
* 文件操作前置钩子
*
* @param target SSH目标
* @param operationName 操作名称
*/
protected void beforeFileOperation(SSHTarget target, String operationName) {
log.debug("开始文件操作: operation={}, host={}", operationName, target.getHost());
}
/**
* 文件操作后置钩子
*
* @param target SSH目标
* @param operationName 操作名称
* @param result 操作结果
*/
protected void afterFileOperation(SSHTarget target, String operationName, Object result) {
log.debug("文件操作完成: operation={}, host={}", operationName, target.getHost());
}
/**
* 文件操作错误钩子
*
* @param target SSH目标
* @param operationName 操作名称
* @param error 异常
*/
protected void onFileOperationError(SSHTarget target, String operationName, Exception error) {
log.error("文件操作失败: operation={}, host={}, error={}",
operationName, target.getHost(), error.getMessage(), error);
}
// ========== 资源管理 ==========
/**
* 安静关闭SFTP客户端
*/
protected void closeQuietly(SFTPClient sftpClient) {
if (sftpClient != null) {
try {
sftpClient.close();
log.debug("SFTP客户端已关闭");
} catch (IOException e) {
log.warn("关闭SFTP客户端失败", e);
}
}
}
/**
* 安静关闭SSH客户端
*/
protected void closeQuietly(SSHClient sshClient) {
if (sshClient != null) {
try {
if (sshClient.isConnected()) {
sshClient.disconnect();
log.debug("SSH客户端已关闭");
}
} catch (IOException e) {
log.warn("关闭SSH客户端失败", e);
}
}
}
// ========== 异常转换 ==========
/**
* 将各种异常转换为统一的SSHFileException
*/
protected SSHFileException convertException(Exception e, String operationName) {
if (e instanceof SSHFileException) {
return (SSHFileException) e;
}
String message = e.getMessage();
if (message == null) {
message = "未知错误";
}
// 根据异常消息判断错误类型并返回对应的ResponseCode
if (message.contains("No such file") || message.contains("not found")) {
return new SSHFileException(
SSHFileErrorType.FILE_NOT_FOUND,
ResponseCode.SSH_FILE_NOT_FOUND,
e
);
} else if (message.contains("Permission denied") || message.contains("Access denied")) {
return new SSHFileException(
SSHFileErrorType.PERMISSION_DENIED,
ResponseCode.SSH_FILE_PERMISSION_DENIED,
e
);
} else if (message.contains("Disk full") || message.contains("No space left")) {
return new SSHFileException(
SSHFileErrorType.DISK_FULL,
ResponseCode.SSH_FILE_DISK_FULL,
e
);
} else if (message.contains("timeout") || message.contains("timed out")) {
return new SSHFileException(
SSHFileErrorType.TIMEOUT,
ResponseCode.SSH_FILE_TIMEOUT,
e
);
} else if (message.contains("Auth fail") || message.contains("Authentication")) {
return new SSHFileException(
SSHFileErrorType.AUTHENTICATION_FAILED,
ResponseCode.SSH_FILE_AUTHENTICATION_FAILED,
e
);
} else if (message.contains("Connection") || message.contains("connect")) {
return new SSHFileException(
SSHFileErrorType.CONNECTION_FAILED,
ResponseCode.SSH_FILE_CONNECTION_FAILED,
e
);
} else {
return new SSHFileException(
SSHFileErrorType.UNKNOWN,
ResponseCode.SSH_FILE_OPERATION_FAILED,
e
);
}
}
// ========== 内部接口 ==========
/**
* 文件操作接口函数式接口
*/
@FunctionalInterface
protected interface FileOperation<T> {
/**
* 执行文件操作
*
* @param sftpClient SFTP客户端
* @return 操作结果
* @throws Exception 操作失败时抛出
*/
T execute(SFTPClient sftpClient) throws Exception;
/**
* 获取操作名称用于日志和错误信息
*/
default String getName() {
return "文件操作";
}
}
}

View File

@ -0,0 +1,50 @@
package com.qqchen.deploy.backend.framework.ssh.file;
import com.qqchen.deploy.backend.framework.enums.ResponseCode;
import com.qqchen.deploy.backend.framework.enums.SSHFileErrorType;
import com.qqchen.deploy.backend.framework.exception.BusinessException;
/**
* SSH文件操作异常Framework层
*
* 继承BusinessException使用框架的异常体系和国际化
*/
public class SSHFileException extends BusinessException {
/**
* 异常类型用于错误详情记录
*/
private final SSHFileErrorType errorType;
/**
* 原始异常
*/
private final Throwable originalCause;
public SSHFileException(SSHFileErrorType errorType, ResponseCode errorCode) {
super(errorCode);
this.errorType = errorType;
this.originalCause = null;
}
public SSHFileException(SSHFileErrorType errorType, ResponseCode errorCode, Object... args) {
super(errorCode, args);
this.errorType = errorType;
this.originalCause = null;
}
public SSHFileException(SSHFileErrorType errorType, ResponseCode errorCode, Throwable cause) {
super(errorCode);
this.errorType = errorType;
this.originalCause = cause;
initCause(cause);
}
public SSHFileErrorType getErrorType() {
return errorType;
}
public Throwable getOriginalCause() {
return originalCause;
}
}

View File

@ -0,0 +1,91 @@
package com.qqchen.deploy.backend.framework.ssh.file;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
/**
* SSH文件信息模型Framework层
*
* 封装SFTP返回的文件/目录信息
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SSHFileInfo {
/**
* 文件/目录名称
*/
private String name;
/**
* 完整路径
*/
private String path;
/**
* 是否是目录
*/
private Boolean isDirectory;
/**
* 文件大小字节
* 目录时为null
*/
private Long size;
/**
* 权限掩码八进制493 = 0755
*/
private Integer permissionMask;
/**
* 权限字符串rwxr-xr-x
*/
private String permissionString;
/**
* 修改时间
*/
private Date modifyTime;
/**
* 所有者UID
*/
private Integer ownerUid;
/**
* 所有者用户名如果能解析
*/
private String owner;
/**
* 组GID
*/
private Integer groupGid;
/**
* 组名如果能解析
*/
private String group;
/**
* 文件扩展名方便前端显示图标
*/
private String extension;
/**
* 是否是符号链接
*/
private Boolean isSymlink;
/**
* 符号链接目标路径
*/
private String linkTarget;
}

View File

@ -0,0 +1,105 @@
package com.qqchen.deploy.backend.framework.utils;
import java.text.DecimalFormat;
/**
* 文件工具类
*
* 提供文件相关的通用工具方法
*/
public class FileUtils {
private static final DecimalFormat DECIMAL_FORMAT = new DecimalFormat("#.##");
/**
* 格式化文件大小
*
* @param size 文件大小字节
* @return 格式化后的字符串1.5 MB
*/
public static String formatFileSize(Long size) {
if (size == null || size == 0) {
return "0 B";
}
if (size < 1024) {
return size + " B";
} else if (size < 1024 * 1024) {
return DECIMAL_FORMAT.format(size / 1024.0) + " KB";
} else if (size < 1024 * 1024 * 1024) {
return DECIMAL_FORMAT.format(size / (1024.0 * 1024.0)) + " MB";
} else {
return DECIMAL_FORMAT.format(size / (1024.0 * 1024.0 * 1024.0)) + " GB";
}
}
/**
* 格式化文件大小long类型参数重载
*
* @param size 文件大小字节
* @return 格式化后的字符串1.5 MB
*/
public static String formatFileSize(long size) {
return formatFileSize(Long.valueOf(size));
}
/**
* 获取文件扩展名
*
* @param filename 文件名
* @return 文件扩展名不含点如果没有扩展名则返回null
*/
public static String getFileExtension(String filename) {
if (filename == null || filename.isEmpty()) {
return null;
}
int lastDotIndex = filename.lastIndexOf('.');
if (lastDotIndex == -1 || lastDotIndex == filename.length() - 1) {
return null;
}
return filename.substring(lastDotIndex + 1);
}
/**
* 获取文件名不含扩展名
*
* @param filename 文件名
* @return 不含扩展名的文件名
*/
public static String getFileNameWithoutExtension(String filename) {
if (filename == null || filename.isEmpty()) {
return filename;
}
int lastDotIndex = filename.lastIndexOf('.');
if (lastDotIndex == -1) {
return filename;
}
return filename.substring(0, lastDotIndex);
}
/**
* 验证文件名是否合法不包含非法字符
*
* @param filename 文件名
* @return 是否合法
*/
public static boolean isValidFilename(String filename) {
if (filename == null || filename.isEmpty()) {
return false;
}
// 检查是否包含非法字符
String illegalChars = "/\\:*?\"<>|";
for (char c : illegalChars.toCharArray()) {
if (filename.indexOf(c) != -1) {
return false;
}
}
return true;
}
}

View File

@ -1,6 +1,13 @@
server: server:
port: 28080 port: 28080
spring: spring:
# 文件上传配置
servlet:
multipart:
enabled: true
max-file-size: 1GB # 单个文件最大大小
max-request-size: 1GB # 整个请求最大大小
file-size-threshold: 0 # 文件写入磁盘的阈值
datasource: datasource:
url: jdbc:mysql://172.16.0.116:3306/deploy-ease-platform?characterEncoding=utf8&useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true url: jdbc:mysql://172.16.0.116:3306/deploy-ease-platform?characterEncoding=utf8&useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true
username: root username: root

View File

@ -1,6 +1,13 @@
server: server:
port: 8080 port: 8080
spring: spring:
# 文件上传配置
servlet:
multipart:
enabled: true
max-file-size: 1GB # 单个文件最大大小
max-request-size: 1GB # 整个请求最大大小
file-size-threshold: 0 # 文件写入磁盘的阈值
datasource: datasource:
url: jdbc:mysql://172.22.222.111:3306/deploy-ease-platform?characterEncoding=utf8&useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true url: jdbc:mysql://172.22.222.111:3306/deploy-ease-platform?characterEncoding=utf8&useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true
username: deploy-ease-platform username: deploy-ease-platform

View File

@ -30,6 +30,24 @@ dependency.injection.repository.not.found=找不到实体 {0} 对应的Repositor
dependency.injection.converter.not.found=找不到实体 {0} 对应的Converter: {1} dependency.injection.converter.not.found=找不到实体 {0} 对应的Converter: {1}
dependency.injection.entitypath.failed=初始化实体 {0} 的EntityPath失败: {1} dependency.injection.entitypath.failed=初始化实体 {0} 的EntityPath失败: {1}
# --------------------------------------------------------------------------------------
# SSH文件操作相关 (SSH File Operations) - 1200-1299
# --------------------------------------------------------------------------------------
ssh.file.connection.failed=SSH连接失败
ssh.file.authentication.failed=SSH认证失败
ssh.file.not.found=文件不存在
ssh.file.directory.not.found=目录不存在
ssh.file.permission.denied=权限不足
ssh.file.disk.full=磁盘空间不足
ssh.file.invalid.path=路径无效
ssh.file.already.exists=文件已存在
ssh.file.too.large=文件过大
ssh.file.unsupported.type=不支持的文件类型
ssh.file.io.error=文件IO错误
ssh.file.timeout=操作超时
ssh.file.operation.failed=文件操作失败
ssh.file.upload.size.exceeded=文件过大,最大允许上传 {0}
# -------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------
# 业务异常 (Business Exceptions) - 2xxx # 业务异常 (Business Exceptions) - 2xxx
# -------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------

View File

@ -30,6 +30,24 @@ dependency.injection.repository.not.found=Cannot find repository for entity {0}:
dependency.injection.converter.not.found=Cannot find converter for entity {0}: {1} dependency.injection.converter.not.found=Cannot find converter for entity {0}: {1}
dependency.injection.entitypath.failed=Failed to initialize EntityPath for entity {0}: {1} dependency.injection.entitypath.failed=Failed to initialize EntityPath for entity {0}: {1}
# --------------------------------------------------------------------------------------
# SSH File Operations - 1200-1299
# --------------------------------------------------------------------------------------
ssh.file.connection.failed=SSH connection failed
ssh.file.authentication.failed=SSH authentication failed
ssh.file.not.found=File not found
ssh.file.directory.not.found=Directory not found
ssh.file.permission.denied=Permission denied
ssh.file.disk.full=Disk space full
ssh.file.invalid.path=Invalid path
ssh.file.already.exists=File already exists
ssh.file.too.large=File too large
ssh.file.unsupported.type=Unsupported file type
ssh.file.io.error=File IO error
ssh.file.timeout=Operation timeout
ssh.file.operation.failed=File operation failed
ssh.file.upload.size.exceeded=File too large, maximum upload size is {0}
# -------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------
# Business Exceptions - 2xxx # Business Exceptions - 2xxx
# -------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------

View File

@ -28,7 +28,25 @@ system.retry.exceeded.error=操作重试次数超限,请稍后再试
dependency.injection.service.not.found=找不到实体 {0} 对应的服务 (尝试过的bean名称: {1}) dependency.injection.service.not.found=找不到实体 {0} 对应的服务 (尝试过的bean名称: {1})
dependency.injection.repository.not.found=找不到实体 {0} 对应的Repository: {1} dependency.injection.repository.not.found=找不到实体 {0} 对应的Repository: {1}
dependency.injection.converter.not.found=找不到实体 {0} 对应的Converter: {1} dependency.injection.converter.not.found=找不到实体 {0} 对应的Converter: {1}
dependency.injection.entitypath.failed=初始化实体 {0} 的EntityPath失败: {1} dependency.injection.entitypath.failed=获取实体 {0} 的QClass失败
# --------------------------------------------------------------------------------------
# SSH文件操作相关 (SSH File Operations) - 1200-1299
# --------------------------------------------------------------------------------------
ssh.file.connection.failed=SSH连接失败
ssh.file.authentication.failed=SSH认证失败
ssh.file.not.found=文件不存在
ssh.file.directory.not.found=目录不存在
ssh.file.permission.denied=权限不足
ssh.file.disk.full=磁盘空间不足
ssh.file.invalid.path=路径无效
ssh.file.already.exists=文件已存在
ssh.file.too.large=文件过大
ssh.file.unsupported.type=不支持的文件类型
ssh.file.io.error=文件IO错误
ssh.file.timeout=操作超时
ssh.file.operation.failed=文件操作失败
ssh.file.upload.size.exceeded=文件过大,最大允许上传 {0}
# -------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------
# 业务异常 (Business Exceptions) - 2xxx # 业务异常 (Business Exceptions) - 2xxx

View File

@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input';
import 'xterm/css/xterm.css'; import 'xterm/css/xterm.css';
import styles from './index.module.less'; import styles from './index.module.less';
import { TerminalToolbar } from './TerminalToolbar'; import { TerminalToolbar } from './TerminalToolbar';
import { FileManager } from '@/components/FileManager';
import type { TerminalProps, TerminalToolbarConfig } from './types'; import type { TerminalProps, TerminalToolbarConfig } from './types';
import { TERMINAL_THEMES, getThemeByName } from './themes'; import { TERMINAL_THEMES, getThemeByName } from './themes';
import { Loader2, XCircle, ChevronUp, ChevronDown, X } from 'lucide-react'; import { Loader2, XCircle, ChevronUp, ChevronDown, X } from 'lucide-react';
@ -42,6 +43,7 @@ export const Terminal: React.FC<TerminalProps> = ({
const [auditShown, setAuditShown] = useState(false); const [auditShown, setAuditShown] = useState(false);
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected'); // 初始状态,会被实例状态覆盖 const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected'); // 初始状态,会被实例状态覆盖
const [errorMessage, setErrorMessage] = useState<string>(''); const [errorMessage, setErrorMessage] = useState<string>('');
const [showFileManager, setShowFileManager] = useState(false);
// 默认工具栏配置 // 默认工具栏配置
const toolbarConfig: TerminalToolbarConfig = { const toolbarConfig: TerminalToolbarConfig = {
@ -265,6 +267,15 @@ export const Terminal: React.FC<TerminalProps> = ({
} }
}, []); }, []);
// 打开文件管理器
const handleFileManager = useCallback(() => {
if (connection.type !== 'ssh') {
message.warning('文件管理仅支持SSH连接');
return;
}
setShowFileManager(true);
}, [connection.type]);
return ( return (
<div ref={wrapperRef} className={styles.terminalWrapper}> <div ref={wrapperRef} className={styles.terminalWrapper}>
{/* 工具栏 */} {/* 工具栏 */}
@ -282,6 +293,7 @@ export const Terminal: React.FC<TerminalProps> = ({
onZoomOut={handleZoomOut} onZoomOut={handleZoomOut}
onReconnect={handleReconnect} onReconnect={handleReconnect}
onThemeChange={handleThemeChange} onThemeChange={handleThemeChange}
onFileManager={connection.type === 'ssh' ? handleFileManager : undefined}
onSplitUp={onSplitUp} onSplitUp={onSplitUp}
onSplitDown={onSplitDown} onSplitDown={onSplitDown}
onSplitLeft={onSplitLeft} onSplitLeft={onSplitLeft}
@ -387,6 +399,16 @@ export const Terminal: React.FC<TerminalProps> = ({
</div> </div>
)} )}
</div> </div>
{/* 文件管理器 */}
{connection.type === 'ssh' && showFileManager && connection.serverId && (
<FileManager
serverId={Number(connection.serverId)}
serverName={`服务器 ${connection.serverId}`}
open={showFileManager}
onClose={() => setShowFileManager(false)}
/>
)}
</div> </div>
); );
}; };

View File

@ -12,7 +12,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { Search, Trash2, Copy, ZoomIn, ZoomOut, RotateCcw, Loader2, XCircle, Palette, SplitSquareVertical, SplitSquareHorizontal, Plus, ChevronDown } from 'lucide-react'; import { Search, Trash2, Copy, ZoomIn, ZoomOut, RotateCcw, Loader2, XCircle, Palette, SplitSquareVertical, SplitSquareHorizontal, Plus, ChevronDown, FolderOpen } from 'lucide-react';
import type { ConnectionStatus, TerminalToolbarConfig } from './types'; import type { ConnectionStatus, TerminalToolbarConfig } from './types';
import type { TerminalTheme } from './themes'; import type { TerminalTheme } from './themes';
import styles from './index.module.less'; import styles from './index.module.less';
@ -31,6 +31,7 @@ interface TerminalToolbarProps {
onZoomOut?: () => void; onZoomOut?: () => void;
onReconnect?: () => void; onReconnect?: () => void;
onThemeChange?: (themeName: string) => void; onThemeChange?: (themeName: string) => void;
onFileManager?: () => void;
// 分屏操作 // 分屏操作
onSplitUp?: () => void; onSplitUp?: () => void;
onSplitDown?: () => void; onSplitDown?: () => void;
@ -53,6 +54,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
onZoomOut, onZoomOut,
onReconnect, onReconnect,
onThemeChange, onThemeChange,
onFileManager,
onSplitUp, onSplitUp,
onSplitDown, onSplitDown,
onSplitLeft, onSplitLeft,
@ -107,6 +109,18 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
{/* 右侧:所有操作按钮 */} {/* 右侧:所有操作按钮 */}
<div className={styles.right}> <div className={styles.right}>
{/* 文件管理 */}
{onFileManager && (
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
onClick={onFileManager}
title="文件管理"
>
<FolderOpen className="h-3.5 w-3.5" />
</Button>
)}
{config.showSearch && ( {config.showSearch && (
<Button <Button
size="sm" size="sm"