增加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: 可以在这里触发错误事件
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -24,6 +24,22 @@ public enum ResponseCode {
|
|||||||
DEPENDENCY_INJECTION_REPOSITORY_NOT_FOUND(1101, "dependency.injection.repository.not.found"),
|
DEPENDENCY_INJECTION_REPOSITORY_NOT_FOUND(1101, "dependency.injection.repository.not.found"),
|
||||||
DEPENDENCY_INJECTION_CONVERTER_NOT_FOUND(1102, "dependency.injection.converter.not.found"),
|
DEPENDENCY_INJECTION_CONVERTER_NOT_FOUND(1102, "dependency.injection.converter.not.found"),
|
||||||
DEPENDENCY_INJECTION_ENTITYPATH_FAILED(1103, "dependency.injection.entitypath.failed"),
|
DEPENDENCY_INJECTION_ENTITYPATH_FAILED(1103, "dependency.injection.entitypath.failed"),
|
||||||
|
|
||||||
|
// SSH文件操作相关错误 (1200-1299)
|
||||||
|
SSH_FILE_CONNECTION_FAILED(1200, "ssh.file.connection.failed"),
|
||||||
|
SSH_FILE_AUTHENTICATION_FAILED(1201, "ssh.file.authentication.failed"),
|
||||||
|
SSH_FILE_NOT_FOUND(1202, "ssh.file.not.found"),
|
||||||
|
SSH_FILE_DIRECTORY_NOT_FOUND(1203, "ssh.file.directory.not.found"),
|
||||||
|
SSH_FILE_PERMISSION_DENIED(1204, "ssh.file.permission.denied"),
|
||||||
|
SSH_FILE_DISK_FULL(1205, "ssh.file.disk.full"),
|
||||||
|
SSH_FILE_INVALID_PATH(1206, "ssh.file.invalid.path"),
|
||||||
|
SSH_FILE_ALREADY_EXISTS(1207, "ssh.file.already.exists"),
|
||||||
|
SSH_FILE_TOO_LARGE(1208, "ssh.file.too.large"),
|
||||||
|
SSH_FILE_UNSUPPORTED_TYPE(1209, "ssh.file.unsupported.type"),
|
||||||
|
SSH_FILE_IO_ERROR(1210, "ssh.file.io.error"),
|
||||||
|
SSH_FILE_TIMEOUT(1211, "ssh.file.timeout"),
|
||||||
|
SSH_FILE_OPERATION_FAILED(1212, "ssh.file.operation.failed"),
|
||||||
|
SSH_FILE_UPLOAD_SIZE_EXCEEDED(1213, "ssh.file.upload.size.exceeded"),
|
||||||
|
|
||||||
// 业务异常 (2开头)
|
// 业务异常 (2开头)
|
||||||
TENANT_NOT_FOUND(2001, "tenant.not.found"),
|
TENANT_NOT_FOUND(2001, "tenant.not.found"),
|
||||||
|
|||||||
@ -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.BusinessException;
|
||||||
import com.qqchen.deploy.backend.framework.exception.SystemException;
|
import com.qqchen.deploy.backend.framework.exception.SystemException;
|
||||||
import com.qqchen.deploy.backend.framework.exception.UniqueConstraintException;
|
import com.qqchen.deploy.backend.framework.exception.UniqueConstraintException;
|
||||||
|
import com.qqchen.deploy.backend.framework.utils.FileUtils;
|
||||||
import io.jsonwebtoken.ExpiredJwtException;
|
import io.jsonwebtoken.ExpiredJwtException;
|
||||||
import io.jsonwebtoken.MalformedJwtException;
|
import io.jsonwebtoken.MalformedJwtException;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@ -16,6 +17,7 @@ import org.springframework.security.authentication.BadCredentialsException;
|
|||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||||
|
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||||
import org.springframework.web.servlet.NoHandlerFoundException;
|
import org.springframework.web.servlet.NoHandlerFoundException;
|
||||||
|
|
||||||
import java.security.SignatureException;
|
import java.security.SignatureException;
|
||||||
@ -87,6 +89,14 @@ public class GlobalExceptionHandler {
|
|||||||
return Response.error(ResponseCode.NOT_FOUND, message);
|
return Response.error(ResponseCode.NOT_FOUND, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(MaxUploadSizeExceededException.class)
|
||||||
|
public Response<?> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e) {
|
||||||
|
long maxSize = e.getMaxUploadSize();
|
||||||
|
String maxSizeFormatted = FileUtils.formatFileSize(maxSize);
|
||||||
|
log.warn("File upload size exceeded: maxSize={}", maxSizeFormatted);
|
||||||
|
return Response.error(ResponseCode.SSH_FILE_UPLOAD_SIZE_EXCEEDED, maxSizeFormatted);
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(Exception.class)
|
@ExceptionHandler(Exception.class)
|
||||||
public Response<?> handleException(Exception e) {
|
public Response<?> handleException(Exception e) {
|
||||||
log.error("Unexpected error occurred", e);
|
log.error("Unexpected error occurred", e);
|
||||||
|
|||||||
@ -41,6 +41,7 @@ public class SecurityConfig {
|
|||||||
"/api/v1/user/login",
|
"/api/v1/user/login",
|
||||||
"/api/v1/user/register",
|
"/api/v1/user/register",
|
||||||
"/api/v1/tenant/list",
|
"/api/v1/tenant/list",
|
||||||
|
"/api/v1/server-ssh/*/files/**",
|
||||||
"/swagger-ui/**",
|
"/swagger-ui/**",
|
||||||
"/v3/api-docs/**",
|
"/v3/api-docs/**",
|
||||||
"/actuator/health"
|
"/actuator/health"
|
||||||
|
|||||||
@ -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:
|
server:
|
||||||
port: 28080
|
port: 28080
|
||||||
spring:
|
spring:
|
||||||
|
# 文件上传配置
|
||||||
|
servlet:
|
||||||
|
multipart:
|
||||||
|
enabled: true
|
||||||
|
max-file-size: 1GB # 单个文件最大大小
|
||||||
|
max-request-size: 1GB # 整个请求最大大小
|
||||||
|
file-size-threshold: 0 # 文件写入磁盘的阈值
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:mysql://172.16.0.116:3306/deploy-ease-platform?characterEncoding=utf8&useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true
|
url: jdbc:mysql://172.16.0.116:3306/deploy-ease-platform?characterEncoding=utf8&useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true
|
||||||
username: root
|
username: root
|
||||||
|
|||||||
@ -1,6 +1,13 @@
|
|||||||
server:
|
server:
|
||||||
port: 8080
|
port: 8080
|
||||||
spring:
|
spring:
|
||||||
|
# 文件上传配置
|
||||||
|
servlet:
|
||||||
|
multipart:
|
||||||
|
enabled: true
|
||||||
|
max-file-size: 1GB # 单个文件最大大小
|
||||||
|
max-request-size: 1GB # 整个请求最大大小
|
||||||
|
file-size-threshold: 0 # 文件写入磁盘的阈值
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:mysql://172.22.222.111:3306/deploy-ease-platform?characterEncoding=utf8&useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true
|
url: jdbc:mysql://172.22.222.111:3306/deploy-ease-platform?characterEncoding=utf8&useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true
|
||||||
username: deploy-ease-platform
|
username: deploy-ease-platform
|
||||||
|
|||||||
@ -30,6 +30,24 @@ dependency.injection.repository.not.found=找不到实体 {0} 对应的Repositor
|
|||||||
dependency.injection.converter.not.found=找不到实体 {0} 对应的Converter: {1}
|
dependency.injection.converter.not.found=找不到实体 {0} 对应的Converter: {1}
|
||||||
dependency.injection.entitypath.failed=初始化实体 {0} 的EntityPath失败: {1}
|
dependency.injection.entitypath.failed=初始化实体 {0} 的EntityPath失败: {1}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------------------
|
||||||
|
# SSH文件操作相关 (SSH File Operations) - 1200-1299
|
||||||
|
# --------------------------------------------------------------------------------------
|
||||||
|
ssh.file.connection.failed=SSH连接失败
|
||||||
|
ssh.file.authentication.failed=SSH认证失败
|
||||||
|
ssh.file.not.found=文件不存在
|
||||||
|
ssh.file.directory.not.found=目录不存在
|
||||||
|
ssh.file.permission.denied=权限不足
|
||||||
|
ssh.file.disk.full=磁盘空间不足
|
||||||
|
ssh.file.invalid.path=路径无效
|
||||||
|
ssh.file.already.exists=文件已存在
|
||||||
|
ssh.file.too.large=文件过大
|
||||||
|
ssh.file.unsupported.type=不支持的文件类型
|
||||||
|
ssh.file.io.error=文件IO错误
|
||||||
|
ssh.file.timeout=操作超时
|
||||||
|
ssh.file.operation.failed=文件操作失败
|
||||||
|
ssh.file.upload.size.exceeded=文件过大,最大允许上传 {0}
|
||||||
|
|
||||||
# --------------------------------------------------------------------------------------
|
# --------------------------------------------------------------------------------------
|
||||||
# 业务异常 (Business Exceptions) - 2xxx
|
# 业务异常 (Business Exceptions) - 2xxx
|
||||||
# --------------------------------------------------------------------------------------
|
# --------------------------------------------------------------------------------------
|
||||||
|
|||||||
@ -30,6 +30,24 @@ dependency.injection.repository.not.found=Cannot find repository for entity {0}:
|
|||||||
dependency.injection.converter.not.found=Cannot find converter for entity {0}: {1}
|
dependency.injection.converter.not.found=Cannot find converter for entity {0}: {1}
|
||||||
dependency.injection.entitypath.failed=Failed to initialize EntityPath for entity {0}: {1}
|
dependency.injection.entitypath.failed=Failed to initialize EntityPath for entity {0}: {1}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------------------
|
||||||
|
# SSH File Operations - 1200-1299
|
||||||
|
# --------------------------------------------------------------------------------------
|
||||||
|
ssh.file.connection.failed=SSH connection failed
|
||||||
|
ssh.file.authentication.failed=SSH authentication failed
|
||||||
|
ssh.file.not.found=File not found
|
||||||
|
ssh.file.directory.not.found=Directory not found
|
||||||
|
ssh.file.permission.denied=Permission denied
|
||||||
|
ssh.file.disk.full=Disk space full
|
||||||
|
ssh.file.invalid.path=Invalid path
|
||||||
|
ssh.file.already.exists=File already exists
|
||||||
|
ssh.file.too.large=File too large
|
||||||
|
ssh.file.unsupported.type=Unsupported file type
|
||||||
|
ssh.file.io.error=File IO error
|
||||||
|
ssh.file.timeout=Operation timeout
|
||||||
|
ssh.file.operation.failed=File operation failed
|
||||||
|
ssh.file.upload.size.exceeded=File too large, maximum upload size is {0}
|
||||||
|
|
||||||
# --------------------------------------------------------------------------------------
|
# --------------------------------------------------------------------------------------
|
||||||
# Business Exceptions - 2xxx
|
# Business Exceptions - 2xxx
|
||||||
# --------------------------------------------------------------------------------------
|
# --------------------------------------------------------------------------------------
|
||||||
|
|||||||
@ -28,7 +28,25 @@ system.retry.exceeded.error=操作重试次数超限,请稍后再试
|
|||||||
dependency.injection.service.not.found=找不到实体 {0} 对应的服务 (尝试过的bean名称: {1})
|
dependency.injection.service.not.found=找不到实体 {0} 对应的服务 (尝试过的bean名称: {1})
|
||||||
dependency.injection.repository.not.found=找不到实体 {0} 对应的Repository: {1}
|
dependency.injection.repository.not.found=找不到实体 {0} 对应的Repository: {1}
|
||||||
dependency.injection.converter.not.found=找不到实体 {0} 对应的Converter: {1}
|
dependency.injection.converter.not.found=找不到实体 {0} 对应的Converter: {1}
|
||||||
dependency.injection.entitypath.failed=初始化实体 {0} 的EntityPath失败: {1}
|
dependency.injection.entitypath.failed=获取实体 {0} 的QClass失败
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------------------
|
||||||
|
# SSH文件操作相关 (SSH File Operations) - 1200-1299
|
||||||
|
# --------------------------------------------------------------------------------------
|
||||||
|
ssh.file.connection.failed=SSH连接失败
|
||||||
|
ssh.file.authentication.failed=SSH认证失败
|
||||||
|
ssh.file.not.found=文件不存在
|
||||||
|
ssh.file.directory.not.found=目录不存在
|
||||||
|
ssh.file.permission.denied=权限不足
|
||||||
|
ssh.file.disk.full=磁盘空间不足
|
||||||
|
ssh.file.invalid.path=路径无效
|
||||||
|
ssh.file.already.exists=文件已存在
|
||||||
|
ssh.file.too.large=文件过大
|
||||||
|
ssh.file.unsupported.type=不支持的文件类型
|
||||||
|
ssh.file.io.error=文件IO错误
|
||||||
|
ssh.file.timeout=操作超时
|
||||||
|
ssh.file.operation.failed=文件操作失败
|
||||||
|
ssh.file.upload.size.exceeded=文件过大,最大允许上传 {0}
|
||||||
|
|
||||||
# --------------------------------------------------------------------------------------
|
# --------------------------------------------------------------------------------------
|
||||||
# 业务异常 (Business Exceptions) - 2xxx
|
# 业务异常 (Business Exceptions) - 2xxx
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import 'xterm/css/xterm.css';
|
import 'xterm/css/xterm.css';
|
||||||
import styles from './index.module.less';
|
import styles from './index.module.less';
|
||||||
import { TerminalToolbar } from './TerminalToolbar';
|
import { TerminalToolbar } from './TerminalToolbar';
|
||||||
|
import { FileManager } from '@/components/FileManager';
|
||||||
import type { TerminalProps, TerminalToolbarConfig } from './types';
|
import type { TerminalProps, TerminalToolbarConfig } from './types';
|
||||||
import { TERMINAL_THEMES, getThemeByName } from './themes';
|
import { TERMINAL_THEMES, getThemeByName } from './themes';
|
||||||
import { Loader2, XCircle, ChevronUp, ChevronDown, X } from 'lucide-react';
|
import { Loader2, XCircle, ChevronUp, ChevronDown, X } from 'lucide-react';
|
||||||
@ -42,6 +43,7 @@ export const Terminal: React.FC<TerminalProps> = ({
|
|||||||
const [auditShown, setAuditShown] = useState(false);
|
const [auditShown, setAuditShown] = useState(false);
|
||||||
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected'); // 初始状态,会被实例状态覆盖
|
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected'); // 初始状态,会被实例状态覆盖
|
||||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||||
|
const [showFileManager, setShowFileManager] = useState(false);
|
||||||
|
|
||||||
// 默认工具栏配置
|
// 默认工具栏配置
|
||||||
const toolbarConfig: TerminalToolbarConfig = {
|
const toolbarConfig: TerminalToolbarConfig = {
|
||||||
@ -265,6 +267,15 @@ export const Terminal: React.FC<TerminalProps> = ({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 打开文件管理器
|
||||||
|
const handleFileManager = useCallback(() => {
|
||||||
|
if (connection.type !== 'ssh') {
|
||||||
|
message.warning('文件管理仅支持SSH连接');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowFileManager(true);
|
||||||
|
}, [connection.type]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={wrapperRef} className={styles.terminalWrapper}>
|
<div ref={wrapperRef} className={styles.terminalWrapper}>
|
||||||
{/* 工具栏 */}
|
{/* 工具栏 */}
|
||||||
@ -282,6 +293,7 @@ export const Terminal: React.FC<TerminalProps> = ({
|
|||||||
onZoomOut={handleZoomOut}
|
onZoomOut={handleZoomOut}
|
||||||
onReconnect={handleReconnect}
|
onReconnect={handleReconnect}
|
||||||
onThemeChange={handleThemeChange}
|
onThemeChange={handleThemeChange}
|
||||||
|
onFileManager={connection.type === 'ssh' ? handleFileManager : undefined}
|
||||||
onSplitUp={onSplitUp}
|
onSplitUp={onSplitUp}
|
||||||
onSplitDown={onSplitDown}
|
onSplitDown={onSplitDown}
|
||||||
onSplitLeft={onSplitLeft}
|
onSplitLeft={onSplitLeft}
|
||||||
@ -387,6 +399,16 @@ export const Terminal: React.FC<TerminalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 文件管理器 */}
|
||||||
|
{connection.type === 'ssh' && showFileManager && connection.serverId && (
|
||||||
|
<FileManager
|
||||||
|
serverId={Number(connection.serverId)}
|
||||||
|
serverName={`服务器 ${connection.serverId}`}
|
||||||
|
open={showFileManager}
|
||||||
|
onClose={() => setShowFileManager(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Search, Trash2, Copy, ZoomIn, ZoomOut, RotateCcw, Loader2, XCircle, Palette, SplitSquareVertical, SplitSquareHorizontal, Plus, ChevronDown } from 'lucide-react';
|
import { Search, Trash2, Copy, ZoomIn, ZoomOut, RotateCcw, Loader2, XCircle, Palette, SplitSquareVertical, SplitSquareHorizontal, Plus, ChevronDown, FolderOpen } from 'lucide-react';
|
||||||
import type { ConnectionStatus, TerminalToolbarConfig } from './types';
|
import type { ConnectionStatus, TerminalToolbarConfig } from './types';
|
||||||
import type { TerminalTheme } from './themes';
|
import type { TerminalTheme } from './themes';
|
||||||
import styles from './index.module.less';
|
import styles from './index.module.less';
|
||||||
@ -31,6 +31,7 @@ interface TerminalToolbarProps {
|
|||||||
onZoomOut?: () => void;
|
onZoomOut?: () => void;
|
||||||
onReconnect?: () => void;
|
onReconnect?: () => void;
|
||||||
onThemeChange?: (themeName: string) => void;
|
onThemeChange?: (themeName: string) => void;
|
||||||
|
onFileManager?: () => void;
|
||||||
// 分屏操作
|
// 分屏操作
|
||||||
onSplitUp?: () => void;
|
onSplitUp?: () => void;
|
||||||
onSplitDown?: () => void;
|
onSplitDown?: () => void;
|
||||||
@ -53,6 +54,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
|||||||
onZoomOut,
|
onZoomOut,
|
||||||
onReconnect,
|
onReconnect,
|
||||||
onThemeChange,
|
onThemeChange,
|
||||||
|
onFileManager,
|
||||||
onSplitUp,
|
onSplitUp,
|
||||||
onSplitDown,
|
onSplitDown,
|
||||||
onSplitLeft,
|
onSplitLeft,
|
||||||
@ -107,6 +109,18 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
|||||||
|
|
||||||
{/* 右侧:所有操作按钮 */}
|
{/* 右侧:所有操作按钮 */}
|
||||||
<div className={styles.right}>
|
<div className={styles.right}>
|
||||||
|
{/* 文件管理 */}
|
||||||
|
{onFileManager && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 px-2"
|
||||||
|
onClick={onFileManager}
|
||||||
|
title="文件管理"
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{config.showSearch && (
|
{config.showSearch && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user