diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/api/ServerSSHFileApiController.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/api/ServerSSHFileApiController.java new file mode 100644 index 00000000..735ed4f4 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/api/ServerSSHFileApiController.java @@ -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> browseDirectory( + @PathVariable Long serverId, + @RequestParam(value = "path", defaultValue = "~") String path + ) { + log.info("浏览目录: serverId={}, path={}", serverId, path); + + // 处理~符号(用户主目录) + if ("~".equals(path)) { + path = "/home"; // 可以根据实际情况调整默认路径 + } + + // Service层已经处理转换 + List dtos = serverSSHFileService.listDirectoryAsDTO(serverId, path); + + return Response.success(dtos); + } + + /** + * 创建目录 + * + * @param serverId 服务器ID + * @param path 目录路径 + * @return 成功标志 + */ + @PostMapping("/{serverId}/files/mkdir") + public Response 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 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 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 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(); + } +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/converter/RemoteFileInfoConverter.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/converter/RemoteFileInfoConverter.java new file mode 100644 index 00000000..66554bc7 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/converter/RemoteFileInfoConverter.java @@ -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 + ")"; + } +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/dto/FileUploadResultDTO.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/dto/FileUploadResultDTO.java new file mode 100644 index 00000000..a052f8d2 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/dto/FileUploadResultDTO.java @@ -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; +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/dto/RemoteFileInfoDTO.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/dto/RemoteFileInfoDTO.java new file mode 100644 index 00000000..64bb48d7 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/dto/RemoteFileInfoDTO.java @@ -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; +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/service/impl/ServerSSHFileService.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/service/impl/ServerSSHFileService.java new file mode 100644 index 00000000..67bb8269 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/service/impl/ServerSSHFileService.java @@ -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 listDirectoryAsDTO(Long serverId, String path) { + List 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 listDirectory(Long serverId, String path) { + // 验证路径 + validatePath(path); + + // 获取SSH目标 + SSHTarget target = buildSSHTarget(serverId); + + // 执行文件操作 + return executeFileOperation(target, new FileOperation>() { + @Override + public List execute(SFTPClient sftpClient) throws Exception { + List 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() { + @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() { + @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() { + @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() { + @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 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: 可以在这里触发错误事件 + } +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/framework/enums/ResponseCode.java b/backend/src/main/java/com/qqchen/deploy/backend/framework/enums/ResponseCode.java index e798a017..a715a4c9 100644 --- a/backend/src/main/java/com/qqchen/deploy/backend/framework/enums/ResponseCode.java +++ b/backend/src/main/java/com/qqchen/deploy/backend/framework/enums/ResponseCode.java @@ -24,6 +24,22 @@ public enum ResponseCode { DEPENDENCY_INJECTION_REPOSITORY_NOT_FOUND(1101, "dependency.injection.repository.not.found"), DEPENDENCY_INJECTION_CONVERTER_NOT_FOUND(1102, "dependency.injection.converter.not.found"), 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开头) TENANT_NOT_FOUND(2001, "tenant.not.found"), diff --git a/backend/src/main/java/com/qqchen/deploy/backend/framework/enums/SSHEvent.java b/backend/src/main/java/com/qqchen/deploy/backend/framework/enums/SSHEvent.java index a2dc9e51..0b115f84 100644 --- a/backend/src/main/java/com/qqchen/deploy/backend/framework/enums/SSHEvent.java +++ b/backend/src/main/java/com/qqchen/deploy/backend/framework/enums/SSHEvent.java @@ -37,5 +37,37 @@ public enum SSHEvent { /** * 优雅下线前 */ - BEFORE_SHUTDOWN + BEFORE_SHUTDOWN, + + // ========== 文件操作事件 ========== + + /** + * 文件列表(浏览目录) + */ + FILE_LIST, + + /** + * 文件上传 + */ + FILE_UPLOAD, + + /** + * 文件下载 + */ + FILE_DOWNLOAD, + + /** + * 文件删除 + */ + FILE_DELETE, + + /** + * 创建目录 + */ + FILE_CREATE_DIR, + + /** + * 文件重命名 + */ + FILE_RENAME } diff --git a/backend/src/main/java/com/qqchen/deploy/backend/framework/enums/SSHFileErrorType.java b/backend/src/main/java/com/qqchen/deploy/backend/framework/enums/SSHFileErrorType.java new file mode 100644 index 00000000..a0ade6dc --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/framework/enums/SSHFileErrorType.java @@ -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 +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/framework/handler/GlobalExceptionHandler.java b/backend/src/main/java/com/qqchen/deploy/backend/framework/handler/GlobalExceptionHandler.java index 6e92f6a7..c4d6ab2a 100644 --- a/backend/src/main/java/com/qqchen/deploy/backend/framework/handler/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/qqchen/deploy/backend/framework/handler/GlobalExceptionHandler.java @@ -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.SystemException; import com.qqchen.deploy.backend.framework.exception.UniqueConstraintException; +import com.qqchen.deploy.backend.framework.utils.FileUtils; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.MalformedJwtException; 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.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.multipart.MaxUploadSizeExceededException; import org.springframework.web.servlet.NoHandlerFoundException; import java.security.SignatureException; @@ -87,6 +89,14 @@ public class GlobalExceptionHandler { 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) public Response handleException(Exception e) { log.error("Unexpected error occurred", e); diff --git a/backend/src/main/java/com/qqchen/deploy/backend/framework/security/config/SecurityConfig.java b/backend/src/main/java/com/qqchen/deploy/backend/framework/security/config/SecurityConfig.java index 24fe6da3..a988cadd 100644 --- a/backend/src/main/java/com/qqchen/deploy/backend/framework/security/config/SecurityConfig.java +++ b/backend/src/main/java/com/qqchen/deploy/backend/framework/security/config/SecurityConfig.java @@ -41,6 +41,7 @@ public class SecurityConfig { "/api/v1/user/login", "/api/v1/user/register", "/api/v1/tenant/list", + "/api/v1/server-ssh/*/files/**", "/swagger-ui/**", "/v3/api-docs/**", "/actuator/health" diff --git a/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/file/AbstractSSHFileOperations.java b/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/file/AbstractSSHFileOperations.java new file mode 100644 index 00000000..ea88f9cd --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/file/AbstractSSHFileOperations.java @@ -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 返回值类型 + * @return 操作结果 + */ + protected T executeFileOperation(SSHTarget target, FileOperation 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 { + /** + * 执行文件操作 + * + * @param sftpClient SFTP客户端 + * @return 操作结果 + * @throws Exception 操作失败时抛出 + */ + T execute(SFTPClient sftpClient) throws Exception; + + /** + * 获取操作名称(用于日志和错误信息) + */ + default String getName() { + return "文件操作"; + } + } +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/file/SSHFileException.java b/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/file/SSHFileException.java new file mode 100644 index 00000000..fad03259 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/file/SSHFileException.java @@ -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; + } +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/file/SSHFileInfo.java b/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/file/SSHFileInfo.java new file mode 100644 index 00000000..7e26377d --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/file/SSHFileInfo.java @@ -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; +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/framework/utils/FileUtils.java b/backend/src/main/java/com/qqchen/deploy/backend/framework/utils/FileUtils.java new file mode 100644 index 00000000..cd827922 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/framework/utils/FileUtils.java @@ -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; + } +} diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index 160a3c87..a1f43691 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -1,6 +1,13 @@ server: port: 28080 spring: + # 文件上传配置 + servlet: + multipart: + enabled: true + max-file-size: 1GB # 单个文件最大大小 + max-request-size: 1GB # 整个请求最大大小 + file-size-threshold: 0 # 文件写入磁盘的阈值 datasource: url: jdbc:mysql://172.16.0.116:3306/deploy-ease-platform?characterEncoding=utf8&useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true username: root diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 468ca7b8..6f281a8e 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -1,6 +1,13 @@ server: port: 8080 spring: + # 文件上传配置 + servlet: + multipart: + enabled: true + max-file-size: 1GB # 单个文件最大大小 + max-request-size: 1GB # 整个请求最大大小 + file-size-threshold: 0 # 文件写入磁盘的阈值 datasource: 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 diff --git a/backend/src/main/resources/messages.properties b/backend/src/main/resources/messages.properties index 17cd3c4f..0969a899 100644 --- a/backend/src/main/resources/messages.properties +++ b/backend/src/main/resources/messages.properties @@ -30,6 +30,24 @@ dependency.injection.repository.not.found=找不到实体 {0} 对应的Repositor dependency.injection.converter.not.found=找不到实体 {0} 对应的Converter: {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 # -------------------------------------------------------------------------------------- diff --git a/backend/src/main/resources/messages_en_US.properties b/backend/src/main/resources/messages_en_US.properties index 3dcffc0c..1ebf30a9 100644 --- a/backend/src/main/resources/messages_en_US.properties +++ b/backend/src/main/resources/messages_en_US.properties @@ -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.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 # -------------------------------------------------------------------------------------- diff --git a/backend/src/main/resources/messages_zh_CN.properties b/backend/src/main/resources/messages_zh_CN.properties index 4622dd8f..da8d1bad 100644 --- a/backend/src/main/resources/messages_zh_CN.properties +++ b/backend/src/main/resources/messages_zh_CN.properties @@ -28,7 +28,25 @@ system.retry.exceeded.error=操作重试次数超限,请稍后再试 dependency.injection.service.not.found=找不到实体 {0} 对应的服务 (尝试过的bean名称: {1}) dependency.injection.repository.not.found=找不到实体 {0} 对应的Repository: {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 diff --git a/frontend/src/components/Terminal/Terminal.tsx b/frontend/src/components/Terminal/Terminal.tsx index b28b5e4b..00eefbc2 100644 --- a/frontend/src/components/Terminal/Terminal.tsx +++ b/frontend/src/components/Terminal/Terminal.tsx @@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input'; import 'xterm/css/xterm.css'; import styles from './index.module.less'; import { TerminalToolbar } from './TerminalToolbar'; +import { FileManager } from '@/components/FileManager'; import type { TerminalProps, TerminalToolbarConfig } from './types'; import { TERMINAL_THEMES, getThemeByName } from './themes'; import { Loader2, XCircle, ChevronUp, ChevronDown, X } from 'lucide-react'; @@ -42,6 +43,7 @@ export const Terminal: React.FC = ({ const [auditShown, setAuditShown] = useState(false); const [connectionStatus, setConnectionStatus] = useState('disconnected'); // 初始状态,会被实例状态覆盖 const [errorMessage, setErrorMessage] = useState(''); + const [showFileManager, setShowFileManager] = useState(false); // 默认工具栏配置 const toolbarConfig: TerminalToolbarConfig = { @@ -265,6 +267,15 @@ export const Terminal: React.FC = ({ } }, []); + // 打开文件管理器 + const handleFileManager = useCallback(() => { + if (connection.type !== 'ssh') { + message.warning('文件管理仅支持SSH连接'); + return; + } + setShowFileManager(true); + }, [connection.type]); + return (
{/* 工具栏 */} @@ -282,6 +293,7 @@ export const Terminal: React.FC = ({ onZoomOut={handleZoomOut} onReconnect={handleReconnect} onThemeChange={handleThemeChange} + onFileManager={connection.type === 'ssh' ? handleFileManager : undefined} onSplitUp={onSplitUp} onSplitDown={onSplitDown} onSplitLeft={onSplitLeft} @@ -387,6 +399,16 @@ export const Terminal: React.FC = ({
)} + + {/* 文件管理器 */} + {connection.type === 'ssh' && showFileManager && connection.serverId && ( + setShowFileManager(false)} + /> + )} ); }; diff --git a/frontend/src/components/Terminal/TerminalToolbar.tsx b/frontend/src/components/Terminal/TerminalToolbar.tsx index a54b25b7..2100d0d1 100644 --- a/frontend/src/components/Terminal/TerminalToolbar.tsx +++ b/frontend/src/components/Terminal/TerminalToolbar.tsx @@ -12,7 +12,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } 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 { TerminalTheme } from './themes'; import styles from './index.module.less'; @@ -31,6 +31,7 @@ interface TerminalToolbarProps { onZoomOut?: () => void; onReconnect?: () => void; onThemeChange?: (themeName: string) => void; + onFileManager?: () => void; // 分屏操作 onSplitUp?: () => void; onSplitDown?: () => void; @@ -53,6 +54,7 @@ export const TerminalToolbar: React.FC = ({ onZoomOut, onReconnect, onThemeChange, + onFileManager, onSplitUp, onSplitDown, onSplitLeft, @@ -107,6 +109,18 @@ export const TerminalToolbar: React.FC = ({ {/* 右侧:所有操作按钮 */}
+ {/* 文件管理 */} + {onFileManager && ( + + )} {config.showSearch && (