增加ssh链接框架
This commit is contained in:
parent
d83a87b259
commit
308a5587cc
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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 + ")";
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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: 可以在这里触发错误事件
|
||||
}
|
||||
}
|
||||
@ -25,6 +25,22 @@ public enum ResponseCode {
|
||||
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"),
|
||||
DATA_NOT_FOUND(2002, "data.not.found"),
|
||||
|
||||
@ -37,5 +37,37 @@ public enum SSHEvent {
|
||||
/**
|
||||
* 优雅下线前
|
||||
*/
|
||||
BEFORE_SHUTDOWN
|
||||
BEFORE_SHUTDOWN,
|
||||
|
||||
// ========== 文件操作事件 ==========
|
||||
|
||||
/**
|
||||
* 文件列表(浏览目录)
|
||||
*/
|
||||
FILE_LIST,
|
||||
|
||||
/**
|
||||
* 文件上传
|
||||
*/
|
||||
FILE_UPLOAD,
|
||||
|
||||
/**
|
||||
* 文件下载
|
||||
*/
|
||||
FILE_DOWNLOAD,
|
||||
|
||||
/**
|
||||
* 文件删除
|
||||
*/
|
||||
FILE_DELETE,
|
||||
|
||||
/**
|
||||
* 创建目录
|
||||
*/
|
||||
FILE_CREATE_DIR,
|
||||
|
||||
/**
|
||||
* 文件重命名
|
||||
*/
|
||||
FILE_RENAME
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 "文件操作";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
# --------------------------------------------------------------------------------------
|
||||
|
||||
@ -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
|
||||
# --------------------------------------------------------------------------------------
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<TerminalProps> = ({
|
||||
const [auditShown, setAuditShown] = useState(false);
|
||||
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected'); // 初始状态,会被实例状态覆盖
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const [showFileManager, setShowFileManager] = useState(false);
|
||||
|
||||
// 默认工具栏配置
|
||||
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 (
|
||||
<div ref={wrapperRef} className={styles.terminalWrapper}>
|
||||
{/* 工具栏 */}
|
||||
@ -282,6 +293,7 @@ export const Terminal: React.FC<TerminalProps> = ({
|
||||
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<TerminalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 文件管理器 */}
|
||||
{connection.type === 'ssh' && showFileManager && connection.serverId && (
|
||||
<FileManager
|
||||
serverId={Number(connection.serverId)}
|
||||
serverName={`服务器 ${connection.serverId}`}
|
||||
open={showFileManager}
|
||||
onClose={() => setShowFileManager(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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<TerminalToolbarProps> = ({
|
||||
onZoomOut,
|
||||
onReconnect,
|
||||
onThemeChange,
|
||||
onFileManager,
|
||||
onSplitUp,
|
||||
onSplitDown,
|
||||
onSplitLeft,
|
||||
@ -107,6 +109,18 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
|
||||
{/* 右侧:所有操作按钮 */}
|
||||
<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 && (
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user