73 KiB
Deploy Linux SSH/SFTP Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 在 system-ops/deploy 中实现 Linux 服务器连接配置、SFTP 文件管理和 SSH WebSocket 终端基础能力。
Architecture: 保留现有 /deploy/overview 基础入口,新增独立的服务器配置、文件操作和终端会话职责。连接配置使用本地 JSON 文件存储,密码由 DeployCryptoService 加密后落盘;SFTP 每次请求创建短连接,终端通过 Spring WebSocket 维持 SSH Shell 长连接。
Tech Stack: Java 8、Spring Boot 2.3.12、Maven 多模块、JSch、Spring WebSocket、Jackson、Lombok、PowerShell 静态校验
File Structure
Files To Modify
D:/Work/SourceCode/CN_Tool/system-ops/deploy/pom.xml作用:增加 JSch、Spring WebSocket 和 Jackson 依赖。D:/Work/SourceCode/CN_Tool/entrance/src/main/resources/application.yml作用:增加deploy配置项,使用环境变量承载加密密钥。D:/Work/SourceCode/CN_Tool/system-ops/deploy/README.md作用:同步 deploy 模块真实能力和使用边界。
Files To Create
D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/config/DeployProperties.java作用:绑定deploy.storage-dir、deploy.crypto-key、deploy.terminal-idle-timeout-minutes等配置。D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/config/DeployWebSocketConfig.java作用:注册/deploy/terminalWebSocket endpoint。D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/controller/DeployServerController.java作用:连接配置 CRUD 和测试连接。D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/controller/DeployFileController.java作用:SFTP 文件列表、新建目录、删除、上传、下载。D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/dto/DeployServerConfigDTO.java作用:JSON 文件中的服务器连接配置模型,包含加密后的密码。D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/dto/DeployServerConfigStoreDTO.java作用:JSON 根对象,承载服务器列表。D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/dto/DeploySshSessionDTO.java作用:终端 WebSocket 与 SSH Session/Channel 的绑定对象。D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/enums/DeployFileTypeEnum.java作用:统一文件类型:FILE、DIRECTORY、LINK。D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/param/DeployServerParam.java作用:连接配置接口入参。D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/param/DeployFileParam.java作用:文件操作接口入参。D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/param/DeployTerminalMessageParam.java作用:WebSocket 终端输入消息模型。D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/vo/DeployServerVO.java作用:连接配置列表返回模型,不包含密码。D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/vo/DeployConnectionTestVO.java作用:连接测试返回模型。D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/vo/DeployFileVO.java作用:远程文件列表返回模型。D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/repository/DeployServerConfigRepository.java作用:封装 JSON 文件读写、初始化和并发写入保护。D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/DeployCryptoService.java作用:服务器密码加密解密。D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/DeployServerConfigService.java作用:连接配置业务接口。D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/DeploySftpService.java作用:SFTP 文件操作接口。D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/DeploySshTerminalService.java作用:SSH Shell 终端会话接口。D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/impl/DeployCryptoServiceImpl.java作用:AES 加解密实现。D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/impl/DeployServerConfigServiceImpl.java作用:连接配置 CRUD、校验、连接测试。D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/impl/DeploySftpServiceImpl.java作用:SFTP 列表、上传、下载、删除、新建目录。D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/impl/DeploySshTerminalServiceImpl.java作用:SSH Shell 创建、输入、resize、关闭。D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/util/DeployPathUtil.java作用:远程路径规范化和危险路径拦截。D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/websocket/DeployTerminalWebSocketHandler.java作用:WebSocket 消息解析、输出推送、连接关闭时释放 SSH 资源。
Assumptions
- 本期只支持 Linux,所有连接均使用 SSH 密码认证。
- 当前仓库默认不执行
mvn编译、打包、测试;本计划验证步骤默认使用静态检查和人工接口验证。 deploy.crypto-key通过环境变量或外部配置提供;默认配置不写入真实密钥。- 如果后续执行时用户明确允许 Maven 校验,可在每个任务完成后补充运行
mvn -pl system-ops/deploy -am -DskipTests compile。 - 计划中的提交命令只提交对应任务文件;工作区已有其他改动不纳入提交。
Task 1: Add Dependencies And Deploy Configuration
Files:
-
Modify:
D:/Work/SourceCode/CN_Tool/system-ops/deploy/pom.xml -
Modify:
D:/Work/SourceCode/CN_Tool/entrance/src/main/resources/application.yml -
Create:
D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/config/DeployProperties.java -
Step 1: Confirm current deploy dependencies and config do not already contain SSH/SFTP settings
Run:
Select-String -Path system-ops\deploy\pom.xml -Pattern 'jsch|spring-boot-starter-websocket|jackson-databind'
Select-String -Path entrance\src\main\resources\application.yml -Pattern '^deploy:|storage-dir|terminal-idle-timeout'
Expected: no matches for JSch/WebSocket deploy-specific entries. If deploy: already exists, merge the fields below into the existing block.
- Step 2: Add SSH, WebSocket and JSON dependencies
Modify system-ops/deploy/pom.xml and append these dependencies inside <dependencies>:
<dependency>
<groupId>com.jcraft</groupId>
<artifactId>jsch</artifactId>
<version>0.1.55</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
- Step 3: Add deploy runtime configuration
Append this block to entrance/src/main/resources/application.yml:
deploy:
storage-dir: ${log.homeDir}/deploy
crypto-key: ${DEPLOY_CRYPTO_KEY:}
ssh-connect-timeout-ms: 5000
terminal-idle-timeout-minutes: 30
- Step 4: Create
DeployProperties
Create system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/config/DeployProperties.java:
package com.njcn.gather.systemops.deploy.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 系统部署模块配置。
*/
@Data
@Component
@ConfigurationProperties(prefix = "deploy")
public class DeployProperties {
/**
* 连接配置文件存储目录。
*/
private String storageDir;
/**
* 密码加密密钥,通过外部配置注入,不能写死在代码中。
*/
private String cryptoKey;
/**
* SSH 连接超时时间。
*/
private int sshConnectTimeoutMs = 5000;
/**
* 终端空闲超时时间,单位分钟。
*/
private int terminalIdleTimeoutMinutes = 30;
}
- Step 5: Verify static wiring
Run:
Select-String -Path system-ops\deploy\pom.xml -Pattern '<artifactId>jsch</artifactId>|<artifactId>spring-boot-starter-websocket</artifactId>|<artifactId>jackson-databind</artifactId>'
Select-String -Path entrance\src\main\resources\application.yml -Pattern 'DEPLOY_CRYPTO_KEY|storage-dir|terminal-idle-timeout-minutes'
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\config\DeployProperties.java -Pattern '@ConfigurationProperties\(prefix = "deploy"\)|sshConnectTimeoutMs'
Expected: all commands return matches.
- Step 6: Commit
git add system-ops/deploy/pom.xml entrance/src/main/resources/application.yml system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/config/DeployProperties.java
git commit -m "deploy: add Linux SSH runtime configuration"
Task 2: Add Core Models And JSON Repository
Files:
-
Create:
D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/dto/DeployServerConfigDTO.java -
Create:
D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/dto/DeployServerConfigStoreDTO.java -
Create:
D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/vo/DeployServerVO.java -
Create:
D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/repository/DeployServerConfigRepository.java -
Step 1: Create stored config DTO
Create DeployServerConfigDTO.java:
package com.njcn.gather.systemops.deploy.pojo.dto;
import lombok.Data;
import java.io.Serializable;
/**
* 服务器连接配置落盘模型,password 保存加密后的密文。
*/
@Data
public class DeployServerConfigDTO implements Serializable {
private static final long serialVersionUID = 7798801709696949115L;
private String id;
private String name;
private String host;
private Integer sshPort;
private String username;
private String password;
private String description;
private String createdTime;
private String updatedTime;
}
- Step 2: Create JSON store DTO
Create DeployServerConfigStoreDTO.java:
package com.njcn.gather.systemops.deploy.pojo.dto;
import lombok.Data;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/**
* 服务器连接配置文件根对象。
*/
@Data
public class DeployServerConfigStoreDTO implements Serializable {
private static final long serialVersionUID = 7814307413743236622L;
private List<DeployServerConfigDTO> servers = new ArrayList<>();
}
- Step 3: Create list response VO without password
Create DeployServerVO.java:
package com.njcn.gather.systemops.deploy.pojo.vo;
import lombok.Data;
import java.io.Serializable;
/**
* 服务器连接配置返回模型,不包含密码。
*/
@Data
public class DeployServerVO implements Serializable {
private static final long serialVersionUID = 8392283918210194314L;
private String id;
private String name;
private String host;
private Integer sshPort;
private String username;
private String description;
private String createdTime;
private String updatedTime;
}
- Step 4: Create file repository
Create DeployServerConfigRepository.java:
package com.njcn.gather.systemops.deploy.repository;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.njcn.gather.systemops.deploy.config.DeployProperties;
import com.njcn.gather.systemops.deploy.pojo.dto.DeployServerConfigDTO;
import com.njcn.gather.systemops.deploy.pojo.dto.DeployServerConfigStoreDTO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
/**
* 服务器连接配置文件仓储。
*/
@Repository
@RequiredArgsConstructor
public class DeployServerConfigRepository {
private static final String STORE_FILE_NAME = "deploy-server-connections.json";
private final DeployProperties deployProperties;
private final ObjectMapper objectMapper;
private final Object lock = new Object();
public List<DeployServerConfigDTO> list() {
synchronized (lock) {
return new ArrayList<>(readStore().getServers());
}
}
public Optional<DeployServerConfigDTO> findById(String id) {
synchronized (lock) {
return readStore().getServers().stream()
.filter(item -> item.getId().equals(id))
.findFirst();
}
}
public void save(Consumer<List<DeployServerConfigDTO>> consumer) {
synchronized (lock) {
DeployServerConfigStoreDTO store = readStore();
consumer.accept(store.getServers());
writeStore(store);
}
}
private DeployServerConfigStoreDTO readStore() {
Path storePath = getStorePath();
ensureStoreFile(storePath);
try {
return objectMapper.readValue(storePath.toFile(), DeployServerConfigStoreDTO.class);
} catch (IOException e) {
throw new IllegalStateException("读取服务器连接配置失败", e);
}
}
private void writeStore(DeployServerConfigStoreDTO store) {
Path storePath = getStorePath();
ensureStoreFile(storePath);
Path tempPath = storePath.resolveSibling(STORE_FILE_NAME + ".tmp");
try {
objectMapper.writerWithDefaultPrettyPrinter().writeValue(tempPath.toFile(), store);
Files.move(tempPath, storePath, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
throw new IllegalStateException("写入服务器连接配置失败", e);
}
}
private Path getStorePath() {
return Paths.get(deployProperties.getStorageDir(), STORE_FILE_NAME);
}
private void ensureStoreFile(Path storePath) {
try {
Files.createDirectories(storePath.getParent());
if (!Files.exists(storePath)) {
objectMapper.writerWithDefaultPrettyPrinter()
.writeValue(storePath.toFile(), new DeployServerConfigStoreDTO());
}
} catch (IOException e) {
throw new IllegalStateException("初始化服务器连接配置文件失败", e);
}
}
}
- Step 5: Verify repository structure
Run:
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\repository\DeployServerConfigRepository.java -Pattern 'deploy-server-connections.json|StandardCopyOption.REPLACE_EXISTING|synchronized'
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\pojo\dto\DeployServerConfigDTO.java -Pattern 'private String password'
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\pojo\vo\DeployServerVO.java -Pattern 'password'
Expected:
-
repository contains the JSON filename, temp replace write, and synchronized access
-
stored DTO contains
password -
VO command returns no match, confirming password is not exposed
-
Step 6: Commit
git add system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/dto system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/vo/DeployServerVO.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/repository/DeployServerConfigRepository.java
git commit -m "deploy: add server config file repository"
Task 3: Implement Password Crypto And Server Config CRUD
Files:
-
Create:
D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/DeployCryptoService.java -
Create:
D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/impl/DeployCryptoServiceImpl.java -
Create:
D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/param/DeployServerParam.java -
Create:
D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/vo/DeployConnectionTestVO.java -
Create:
D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/DeployServerConfigService.java -
Create:
D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/impl/DeployServerConfigServiceImpl.java -
Create:
D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/controller/DeployServerController.java -
Step 1: Create crypto service contract
Create DeployCryptoService.java:
package com.njcn.gather.systemops.deploy.service;
/**
* 部署模块敏感字段加解密服务。
*/
public interface DeployCryptoService {
String encrypt(String plaintext);
String decrypt(String ciphertext);
}
- Step 2: Create AES crypto implementation
Create DeployCryptoServiceImpl.java:
package com.njcn.gather.systemops.deploy.service.impl;
import com.njcn.gather.systemops.deploy.config.DeployProperties;
import com.njcn.gather.systemops.deploy.service.DeployCryptoService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
/**
* 基于 AES 的部署敏感字段加解密实现。
*/
@Service
@RequiredArgsConstructor
public class DeployCryptoServiceImpl implements DeployCryptoService {
private static final String AES = "AES";
private final DeployProperties deployProperties;
@Override
public String encrypt(String plaintext) {
return doCipher(Cipher.ENCRYPT_MODE, plaintext);
}
@Override
public String decrypt(String ciphertext) {
return doCipher(Cipher.DECRYPT_MODE, ciphertext);
}
private String doCipher(int mode, String input) {
if (input == null) {
return null;
}
try {
Cipher cipher = Cipher.getInstance(AES);
cipher.init(mode, new SecretKeySpec(resolveKey(), AES));
byte[] source = mode == Cipher.ENCRYPT_MODE
? input.getBytes(StandardCharsets.UTF_8)
: Base64.getDecoder().decode(input);
byte[] result = cipher.doFinal(source);
return mode == Cipher.ENCRYPT_MODE
? Base64.getEncoder().encodeToString(result)
: new String(result, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new IllegalStateException("服务器连接密码加解密失败", e);
}
}
private byte[] resolveKey() throws Exception {
String cryptoKey = deployProperties.getCryptoKey();
if (cryptoKey == null || cryptoKey.trim().isEmpty()) {
throw new IllegalStateException("deploy.crypto-key 未配置,不能保存服务器密码");
}
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(cryptoKey.getBytes(StandardCharsets.UTF_8));
byte[] key = new byte[16];
System.arraycopy(hash, 0, key, 0, key.length);
return key;
}
}
- Step 3: Create server request params
Create DeployServerParam.java:
package com.njcn.gather.systemops.deploy.pojo.param;
import lombok.Data;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.io.Serializable;
/**
* 服务器连接配置参数。
*/
public class DeployServerParam {
@Data
public static class QueryParam implements Serializable {
private static final long serialVersionUID = -8124738859090254151L;
private String keyword;
}
@Data
public static class AddParam implements Serializable {
private static final long serialVersionUID = -7439507513729950886L;
@NotBlank(message = "服务器名称不能为空")
private String name;
@NotBlank(message = "主机地址不能为空")
private String host;
@NotNull(message = "SSH端口不能为空")
@Min(value = 1, message = "SSH端口不能小于1")
@Max(value = 65535, message = "SSH端口不能大于65535")
private Integer sshPort;
@NotBlank(message = "用户名不能为空")
private String username;
@NotBlank(message = "密码不能为空")
private String password;
private String description;
}
@Data
public static class UpdateParam implements Serializable {
private static final long serialVersionUID = 452443833197581777L;
@NotBlank(message = "服务器ID不能为空")
private String id;
@NotBlank(message = "服务器名称不能为空")
private String name;
@NotBlank(message = "主机地址不能为空")
private String host;
@NotNull(message = "SSH端口不能为空")
@Min(value = 1, message = "SSH端口不能小于1")
@Max(value = 65535, message = "SSH端口不能大于65535")
private Integer sshPort;
@NotBlank(message = "用户名不能为空")
private String username;
private String password;
private String description;
}
@Data
public static class IdParam implements Serializable {
private static final long serialVersionUID = 195332015618863732L;
@NotBlank(message = "服务器ID不能为空")
private String id;
}
}
- Step 4: Create connection test VO
Create DeployConnectionTestVO.java:
package com.njcn.gather.systemops.deploy.pojo.vo;
import lombok.Data;
import java.io.Serializable;
/**
* SSH 连接测试结果。
*/
@Data
public class DeployConnectionTestVO implements Serializable {
private static final long serialVersionUID = 352678084415038771L;
private Boolean success;
private String message;
}
- Step 5: Create server config service contract
Create DeployServerConfigService.java:
package com.njcn.gather.systemops.deploy.service;
import com.njcn.gather.systemops.deploy.pojo.dto.DeployServerConfigDTO;
import com.njcn.gather.systemops.deploy.pojo.param.DeployServerParam;
import com.njcn.gather.systemops.deploy.pojo.vo.DeployConnectionTestVO;
import com.njcn.gather.systemops.deploy.pojo.vo.DeployServerVO;
import java.util.List;
/**
* 服务器连接配置服务。
*/
public interface DeployServerConfigService {
List<DeployServerVO> list(DeployServerParam.QueryParam param);
Boolean add(DeployServerParam.AddParam param);
Boolean update(DeployServerParam.UpdateParam param);
Boolean delete(DeployServerParam.IdParam param);
DeployConnectionTestVO test(DeployServerParam.IdParam param);
DeployServerConfigDTO getRequiredConfig(String serverId);
}
- Step 6: Create server config service implementation
Create DeployServerConfigServiceImpl.java with the key methods below. Keep helper methods private in the same class:
package com.njcn.gather.systemops.deploy.service.impl;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.Session;
import com.njcn.gather.systemops.deploy.config.DeployProperties;
import com.njcn.gather.systemops.deploy.pojo.dto.DeployServerConfigDTO;
import com.njcn.gather.systemops.deploy.pojo.param.DeployServerParam;
import com.njcn.gather.systemops.deploy.pojo.vo.DeployConnectionTestVO;
import com.njcn.gather.systemops.deploy.pojo.vo.DeployServerVO;
import com.njcn.gather.systemops.deploy.repository.DeployServerConfigRepository;
import com.njcn.gather.systemops.deploy.service.DeployCryptoService;
import com.njcn.gather.systemops.deploy.service.DeployServerConfigService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.Properties;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* 服务器连接配置服务实现。
*/
@Service
@RequiredArgsConstructor
public class DeployServerConfigServiceImpl implements DeployServerConfigService {
private final DeployServerConfigRepository repository;
private final DeployCryptoService cryptoService;
private final DeployProperties deployProperties;
@Override
public List<DeployServerVO> list(DeployServerParam.QueryParam param) {
String keyword = param == null ? null : param.getKeyword();
return repository.list().stream()
.filter(item -> !StringUtils.hasText(keyword)
|| item.getName().contains(keyword)
|| item.getHost().contains(keyword))
.map(this::toVO)
.collect(Collectors.toList());
}
@Override
public Boolean add(DeployServerParam.AddParam param) {
repository.save(list -> {
ensureNotDuplicate(list, null, param.getHost(), param.getSshPort(), param.getUsername());
DeployServerConfigDTO config = new DeployServerConfigDTO();
config.setId(UUID.randomUUID().toString().replace("-", ""));
config.setName(param.getName());
config.setHost(param.getHost());
config.setSshPort(param.getSshPort());
config.setUsername(param.getUsername());
config.setPassword(cryptoService.encrypt(param.getPassword()));
config.setDescription(param.getDescription());
config.setCreatedTime(now());
config.setUpdatedTime(config.getCreatedTime());
list.add(config);
});
return true;
}
@Override
public Boolean update(DeployServerParam.UpdateParam param) {
repository.save(list -> {
ensureNotDuplicate(list, param.getId(), param.getHost(), param.getSshPort(), param.getUsername());
DeployServerConfigDTO config = list.stream()
.filter(item -> item.getId().equals(param.getId()))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("服务器连接配置不存在"));
config.setName(param.getName());
config.setHost(param.getHost());
config.setSshPort(param.getSshPort());
config.setUsername(param.getUsername());
if (StringUtils.hasText(param.getPassword())) {
config.setPassword(cryptoService.encrypt(param.getPassword()));
}
config.setDescription(param.getDescription());
config.setUpdatedTime(now());
});
return true;
}
@Override
public Boolean delete(DeployServerParam.IdParam param) {
repository.save(list -> {
boolean removed = list.removeIf(item -> item.getId().equals(param.getId()));
if (!removed) {
throw new IllegalArgumentException("服务器连接配置不存在");
}
});
return true;
}
@Override
public DeployConnectionTestVO test(DeployServerParam.IdParam param) {
DeployConnectionTestVO result = new DeployConnectionTestVO();
Session session = null;
try {
DeployServerConfigDTO config = getRequiredConfig(param.getId());
session = createSession(config);
result.setSuccess(true);
result.setMessage("SSH连接成功");
} catch (JSchException e) {
result.setSuccess(false);
result.setMessage(resolveSshError(e));
} finally {
if (session != null && session.isConnected()) {
session.disconnect();
}
}
return result;
}
@Override
public DeployServerConfigDTO getRequiredConfig(String serverId) {
DeployServerConfigDTO config = repository.findById(serverId)
.orElseThrow(() -> new IllegalArgumentException("服务器连接配置不存在"));
config.setPassword(cryptoService.decrypt(config.getPassword()));
return config;
}
private Session createSession(DeployServerConfigDTO config) throws JSchException {
JSch jSch = new JSch();
Session session = jSch.getSession(config.getUsername(), config.getHost(), config.getSshPort());
session.setPassword(config.getPassword());
Properties properties = new Properties();
properties.put("StrictHostKeyChecking", "no");
session.setConfig(properties);
session.connect(deployProperties.getSshConnectTimeoutMs());
return session;
}
private void ensureNotDuplicate(List<DeployServerConfigDTO> list, String id, String host, Integer port, String username) {
boolean duplicate = list.stream().anyMatch(item ->
!item.getId().equals(id)
&& item.getHost().equals(host)
&& item.getSshPort().equals(port)
&& item.getUsername().equals(username));
if (duplicate) {
throw new IllegalArgumentException("相同主机、端口和用户名的连接配置已存在");
}
}
private DeployServerVO toVO(DeployServerConfigDTO config) {
DeployServerVO vo = new DeployServerVO();
vo.setId(config.getId());
vo.setName(config.getName());
vo.setHost(config.getHost());
vo.setSshPort(config.getSshPort());
vo.setUsername(config.getUsername());
vo.setDescription(config.getDescription());
vo.setCreatedTime(config.getCreatedTime());
vo.setUpdatedTime(config.getUpdatedTime());
return vo;
}
private String now() {
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
}
private String resolveSshError(JSchException e) {
String message = e.getMessage() == null ? "" : e.getMessage();
if (message.contains("Auth fail")) {
return "SSH认证失败";
}
if (message.contains("Connection refused")) {
return "SSH端口连接失败";
}
return "连接服务器失败";
}
}
- Step 7: Create server controller
Create DeployServerController.java:
package com.njcn.gather.systemops.deploy.controller;
import com.njcn.common.pojo.annotation.OperateInfo;
import com.njcn.common.pojo.constant.OperateType;
import com.njcn.common.pojo.enums.common.LogEnum;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.response.HttpResult;
import com.njcn.gather.systemops.deploy.pojo.param.DeployServerParam;
import com.njcn.gather.systemops.deploy.pojo.vo.DeployConnectionTestVO;
import com.njcn.gather.systemops.deploy.pojo.vo.DeployServerVO;
import com.njcn.gather.systemops.deploy.service.DeployServerConfigService;
import com.njcn.web.controller.BaseController;
import com.njcn.web.utils.HttpResultUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* Linux 服务器连接配置接口。
*/
@Slf4j
@Api(tags = "Linux服务器连接配置")
@RestController
@RequestMapping("/deploy/server")
@RequiredArgsConstructor
public class DeployServerController extends BaseController {
private final DeployServerConfigService deployServerConfigService;
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("查询服务器连接配置")
@PostMapping("/list")
public HttpResult<List<DeployServerVO>> list(@RequestBody(required = false) DeployServerParam.QueryParam param) {
String methodDescribe = getMethodDescribe("list");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
deployServerConfigService.list(param), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.ADD)
@ApiOperation("新增服务器连接配置")
@PostMapping("/add")
public HttpResult<Boolean> add(@RequestBody @Validated DeployServerParam.AddParam param) {
String methodDescribe = getMethodDescribe("add");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
deployServerConfigService.add(param), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.UPDATE)
@ApiOperation("修改服务器连接配置")
@PostMapping("/update")
public HttpResult<Boolean> update(@RequestBody @Validated DeployServerParam.UpdateParam param) {
String methodDescribe = getMethodDescribe("update");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
deployServerConfigService.update(param), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.DELETE)
@ApiOperation("删除服务器连接配置")
@PostMapping("/delete")
public HttpResult<Boolean> delete(@RequestBody @Validated DeployServerParam.IdParam param) {
String methodDescribe = getMethodDescribe("delete");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
deployServerConfigService.delete(param), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("测试SSH连接")
@PostMapping("/test")
public HttpResult<DeployConnectionTestVO> test(@RequestBody @Validated DeployServerParam.IdParam param) {
String methodDescribe = getMethodDescribe("test");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
deployServerConfigService.test(param), methodDescribe);
}
}
- Step 8: Verify server API static behavior
Run:
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\controller\DeployServerController.java -Pattern '/deploy/server|/add|/update|/delete|/test'
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\service\impl\DeployServerConfigServiceImpl.java -Pattern 'StrictHostKeyChecking|Auth fail|setPassword|cryptoService.decrypt'
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\pojo\vo\DeployServerVO.java -Pattern 'password'
Expected:
-
controller exposes the five endpoints
-
service uses JSch password authentication and decrypts only when needed
-
DeployServerVOstill has no password match -
Step 9: Commit
git add system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/DeployCryptoService.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/DeployServerConfigService.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/impl/DeployCryptoServiceImpl.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/impl/DeployServerConfigServiceImpl.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/param/DeployServerParam.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/vo/DeployConnectionTestVO.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/controller/DeployServerController.java
git commit -m "deploy: implement Linux server config management"
Task 4: Implement SFTP File Management
Files:
-
Create:
D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/enums/DeployFileTypeEnum.java -
Create:
D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/param/DeployFileParam.java -
Create:
D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/vo/DeployFileVO.java -
Create:
D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/util/DeployPathUtil.java -
Create:
D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/DeploySftpService.java -
Create:
D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/impl/DeploySftpServiceImpl.java -
Create:
D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/controller/DeployFileController.java -
Step 1: Create file type enum
Create DeployFileTypeEnum.java:
package com.njcn.gather.systemops.deploy.pojo.enums;
/**
* 远程文件类型。
*/
public enum DeployFileTypeEnum {
FILE,
DIRECTORY,
LINK
}
- Step 2: Create file params
Create DeployFileParam.java:
package com.njcn.gather.systemops.deploy.pojo.param;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import java.io.Serializable;
/**
* 远程文件操作参数。
*/
public class DeployFileParam {
@Data
public static class PathParam implements Serializable {
private static final long serialVersionUID = -8848807296768191244L;
@NotBlank(message = "服务器ID不能为空")
private String serverId;
@NotBlank(message = "远程路径不能为空")
private String path;
}
@Data
public static class MkdirParam implements Serializable {
private static final long serialVersionUID = -2758794589492869208L;
@NotBlank(message = "服务器ID不能为空")
private String serverId;
@NotBlank(message = "父目录不能为空")
private String parentPath;
@NotBlank(message = "目录名称不能为空")
private String name;
}
}
- Step 3: Create file VO
Create DeployFileVO.java:
package com.njcn.gather.systemops.deploy.pojo.vo;
import com.njcn.gather.systemops.deploy.pojo.enums.DeployFileTypeEnum;
import lombok.Data;
import java.io.Serializable;
/**
* 远程文件信息。
*/
@Data
public class DeployFileVO implements Serializable {
private static final long serialVersionUID = -2268703146258555013L;
private String name;
private String path;
private DeployFileTypeEnum type;
private Long size;
private String permissions;
private String modifiedTime;
}
- Step 4: Create remote path utility
Create DeployPathUtil.java:
package com.njcn.gather.systemops.deploy.util;
/**
* Linux 远程路径工具。
*/
public final class DeployPathUtil {
private DeployPathUtil() {
}
public static String normalize(String path) {
if (path == null || path.trim().isEmpty()) {
throw new IllegalArgumentException("远程路径不能为空");
}
String normalized = path.trim().replace("\\", "/");
while (normalized.contains("//")) {
normalized = normalized.replace("//", "/");
}
if (!normalized.startsWith("/")) {
normalized = "/" + normalized;
}
if (normalized.contains("/../") || normalized.endsWith("/..")) {
throw new IllegalArgumentException("远程路径不合法");
}
return normalized;
}
public static void rejectRootDelete(String path) {
if ("/".equals(normalize(path))) {
throw new IllegalArgumentException("禁止删除根目录");
}
}
public static String child(String parent, String name) {
if (name == null || name.trim().isEmpty() || name.contains("/") || name.contains("\\")
|| ".".equals(name.trim()) || "..".equals(name.trim())) {
throw new IllegalArgumentException("目录名称不合法");
}
String normalizedParent = normalize(parent);
return "/".equals(normalizedParent) ? "/" + name.trim() : normalizedParent + "/" + name.trim();
}
}
- Step 5: Create SFTP service contract
Create DeploySftpService.java:
package com.njcn.gather.systemops.deploy.service;
import com.njcn.gather.systemops.deploy.pojo.param.DeployFileParam;
import com.njcn.gather.systemops.deploy.pojo.vo.DeployFileVO;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
/**
* SFTP 文件操作服务。
*/
public interface DeploySftpService {
List<DeployFileVO> list(DeployFileParam.PathParam param);
Boolean mkdir(DeployFileParam.MkdirParam param);
Boolean delete(DeployFileParam.PathParam param);
Boolean upload(String serverId, String path, MultipartFile file);
void download(DeployFileParam.PathParam param, HttpServletResponse response);
}
- Step 6: Create SFTP service implementation
Create DeploySftpServiceImpl.java. Include helper methods in the same file:
package com.njcn.gather.systemops.deploy.service.impl;
import com.jcraft.jsch.ChannelSftp;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.Session;
import com.jcraft.jsch.SftpATTRS;
import com.jcraft.jsch.SftpException;
import com.njcn.gather.systemops.deploy.config.DeployProperties;
import com.njcn.gather.systemops.deploy.pojo.dto.DeployServerConfigDTO;
import com.njcn.gather.systemops.deploy.pojo.enums.DeployFileTypeEnum;
import com.njcn.gather.systemops.deploy.pojo.param.DeployFileParam;
import com.njcn.gather.systemops.deploy.pojo.vo.DeployFileVO;
import com.njcn.gather.systemops.deploy.service.DeployServerConfigService;
import com.njcn.gather.systemops.deploy.service.DeploySftpService;
import com.njcn.gather.systemops.deploy.util.DeployPathUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import java.net.URLEncoder;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
import java.util.Vector;
/**
* SFTP 文件操作实现。
*/
@Service
@RequiredArgsConstructor
public class DeploySftpServiceImpl implements DeploySftpService {
private final DeployServerConfigService deployServerConfigService;
private final DeployProperties deployProperties;
@Override
public List<DeployFileVO> list(DeployFileParam.PathParam param) {
return withSftp(param.getServerId(), channel -> {
String path = DeployPathUtil.normalize(param.getPath());
Vector<?> entries = channel.ls(path);
List<DeployFileVO> result = new ArrayList<>();
for (Object entry : entries) {
ChannelSftp.LsEntry lsEntry = (ChannelSftp.LsEntry) entry;
if (".".equals(lsEntry.getFilename()) || "..".equals(lsEntry.getFilename())) {
continue;
}
result.add(toVO(path, lsEntry));
}
return result;
});
}
@Override
public Boolean mkdir(DeployFileParam.MkdirParam param) {
return withSftp(param.getServerId(), channel -> {
channel.mkdir(DeployPathUtil.child(param.getParentPath(), param.getName()));
return true;
});
}
@Override
public Boolean delete(DeployFileParam.PathParam param) {
return withSftp(param.getServerId(), channel -> {
String path = DeployPathUtil.normalize(param.getPath());
DeployPathUtil.rejectRootDelete(path);
SftpATTRS attrs = channel.lstat(path);
if (attrs.isDir()) {
channel.rmdir(path);
} else {
channel.rm(path);
}
return true;
});
}
@Override
public Boolean upload(String serverId, String path, MultipartFile file) {
return withSftp(serverId, channel -> {
String targetDir = DeployPathUtil.normalize(path);
SftpATTRS attrs = channel.lstat(targetDir);
if (!attrs.isDir()) {
throw new IllegalArgumentException("上传目标必须是远程目录");
}
channel.put(file.getInputStream(), DeployPathUtil.child(targetDir, file.getOriginalFilename()));
return true;
});
}
@Override
public void download(DeployFileParam.PathParam param, HttpServletResponse response) {
withSftp(param.getServerId(), channel -> {
String path = DeployPathUtil.normalize(param.getPath());
SftpATTRS attrs = channel.lstat(path);
if (attrs.isDir()) {
throw new IllegalArgumentException("只能下载普通文件");
}
String fileName = path.substring(path.lastIndexOf('/') + 1);
response.setContentType("application/octet-stream");
response.setHeader("Content-Disposition",
"attachment; filename=" + URLEncoder.encode(fileName, "UTF-8"));
channel.get(path, response.getOutputStream());
return null;
});
}
private DeployFileVO toVO(String parentPath, ChannelSftp.LsEntry entry) {
SftpATTRS attrs = entry.getAttrs();
DeployFileVO vo = new DeployFileVO();
vo.setName(entry.getFilename());
vo.setPath(DeployPathUtil.child(parentPath, entry.getFilename()));
vo.setType(resolveType(attrs));
vo.setSize(attrs.isDir() ? null : attrs.getSize());
vo.setPermissions(attrs.getPermissionsString());
vo.setModifiedTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(attrs.getMTime() * 1000L));
return vo;
}
private DeployFileTypeEnum resolveType(SftpATTRS attrs) {
if (attrs.isLink()) {
return DeployFileTypeEnum.LINK;
}
if (attrs.isDir()) {
return DeployFileTypeEnum.DIRECTORY;
}
return DeployFileTypeEnum.FILE;
}
private <T> T withSftp(String serverId, SftpCallback<T> callback) {
Session session = null;
ChannelSftp channel = null;
try {
DeployServerConfigDTO config = deployServerConfigService.getRequiredConfig(serverId);
JSch jSch = new JSch();
session = jSch.getSession(config.getUsername(), config.getHost(), config.getSshPort());
session.setPassword(config.getPassword());
Properties properties = new Properties();
properties.put("StrictHostKeyChecking", "no");
session.setConfig(properties);
session.connect(deployProperties.getSshConnectTimeoutMs());
channel = (ChannelSftp) session.openChannel("sftp");
channel.connect(deployProperties.getSshConnectTimeoutMs());
return callback.doInSftp(channel);
} catch (Exception e) {
throw new IllegalStateException("SFTP文件操作失败", e);
} finally {
if (channel != null) {
channel.disconnect();
}
if (session != null) {
session.disconnect();
}
}
}
private interface SftpCallback<T> {
T doInSftp(ChannelSftp channel) throws Exception;
}
}
- Step 7: Create file controller
Create DeployFileController.java:
package com.njcn.gather.systemops.deploy.controller;
import com.njcn.common.pojo.annotation.OperateInfo;
import com.njcn.common.pojo.constant.OperateType;
import com.njcn.common.pojo.enums.common.LogEnum;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.response.HttpResult;
import com.njcn.gather.systemops.deploy.pojo.param.DeployFileParam;
import com.njcn.gather.systemops.deploy.pojo.vo.DeployFileVO;
import com.njcn.gather.systemops.deploy.service.DeploySftpService;
import com.njcn.web.controller.BaseController;
import com.njcn.web.utils.HttpResultUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
/**
* Linux 服务器远程文件接口。
*/
@Slf4j
@Api(tags = "Linux服务器文件管理")
@RestController
@RequestMapping("/deploy/file")
@RequiredArgsConstructor
public class DeployFileController extends BaseController {
private final DeploySftpService deploySftpService;
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("查询远程目录文件列表")
@PostMapping("/list")
public HttpResult<List<DeployFileVO>> list(@RequestBody @Validated DeployFileParam.PathParam param) {
String methodDescribe = getMethodDescribe("list");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
deploySftpService.list(param), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.ADD)
@ApiOperation("新建远程目录")
@PostMapping("/mkdir")
public HttpResult<Boolean> mkdir(@RequestBody @Validated DeployFileParam.MkdirParam param) {
String methodDescribe = getMethodDescribe("mkdir");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
deploySftpService.mkdir(param), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.DELETE)
@ApiOperation("删除远程文件或目录")
@PostMapping("/delete")
public HttpResult<Boolean> delete(@RequestBody @Validated DeployFileParam.PathParam param) {
String methodDescribe = getMethodDescribe("delete");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
deploySftpService.delete(param), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.ADD)
@ApiOperation("上传文件到远程目录")
@PostMapping(value = "/upload", consumes = {"multipart/form-data"})
public HttpResult<Boolean> upload(@RequestParam String serverId,
@RequestParam String path,
@RequestPart("file") MultipartFile file) {
String methodDescribe = getMethodDescribe("upload");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
deploySftpService.upload(serverId, path, file), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.DOWNLOAD)
@ApiOperation("下载远程普通文件")
@PostMapping("/download")
public void download(@RequestBody @Validated DeployFileParam.PathParam param,
HttpServletResponse response) {
deploySftpService.download(param, response);
}
}
- Step 8: Verify SFTP static behavior
Run:
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\service\impl\DeploySftpServiceImpl.java -Pattern 'openChannel\("sftp"\)|channel.ls|channel.put|channel.get|channel.rmdir|channel.rm'
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\util\DeployPathUtil.java -Pattern '禁止删除根目录|contains\("/../"\)|child'
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\controller\DeployFileController.java -Pattern '/deploy/file|/upload|/download'
Expected: all key operations and safety checks are present.
- Step 9: Commit
git add system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/enums system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/param/DeployFileParam.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/vo/DeployFileVO.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/util/DeployPathUtil.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/DeploySftpService.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/impl/DeploySftpServiceImpl.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/controller/DeployFileController.java
git commit -m "deploy: implement Linux SFTP file management"
Task 5: Implement SSH Terminal WebSocket
Files:
-
Create:
D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/config/DeployWebSocketConfig.java -
Create:
D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/dto/DeploySshSessionDTO.java -
Create:
D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/param/DeployTerminalMessageParam.java -
Create:
D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/DeploySshTerminalService.java -
Create:
D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/impl/DeploySshTerminalServiceImpl.java -
Create:
D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/websocket/DeployTerminalWebSocketHandler.java -
Step 1: Create WebSocket config
Create DeployWebSocketConfig.java:
package com.njcn.gather.systemops.deploy.config;
import com.njcn.gather.systemops.deploy.websocket.DeployTerminalWebSocketHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
/**
* deploy 终端 WebSocket 配置。
*/
@Configuration
@EnableWebSocket
@EnableScheduling
@RequiredArgsConstructor
public class DeployWebSocketConfig implements WebSocketConfigurer {
private final DeployTerminalWebSocketHandler deployTerminalWebSocketHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(deployTerminalWebSocketHandler, "/deploy/terminal")
.setAllowedOrigins("*");
}
}
- Step 2: Create SSH session DTO
Create DeploySshSessionDTO.java:
package com.njcn.gather.systemops.deploy.pojo.dto;
import com.jcraft.jsch.ChannelShell;
import com.jcraft.jsch.Session;
import lombok.Data;
import java.io.OutputStream;
/**
* SSH 终端会话上下文。
*/
@Data
public class DeploySshSessionDTO {
private Session session;
private ChannelShell channel;
private OutputStream input;
private long lastAccessTime;
}
- Step 3: Create terminal message param
Create DeployTerminalMessageParam.java:
package com.njcn.gather.systemops.deploy.pojo.param;
import lombok.Data;
/**
* 终端 WebSocket 消息。
*/
@Data
public class DeployTerminalMessageParam {
private String type;
private String data;
private Integer cols;
private Integer rows;
}
- Step 4: Create terminal service contract
Create DeploySshTerminalService.java:
package com.njcn.gather.systemops.deploy.service;
import com.njcn.gather.systemops.deploy.pojo.dto.DeploySshSessionDTO;
import java.util.function.Consumer;
/**
* SSH 终端服务。
*/
public interface DeploySshTerminalService {
DeploySshSessionDTO open(String serverId, Consumer<String> outputConsumer);
void input(DeploySshSessionDTO session, String data);
void resize(DeploySshSessionDTO session, Integer cols, Integer rows);
void close(DeploySshSessionDTO session);
}
- Step 5: Create terminal service implementation
Create DeploySshTerminalServiceImpl.java:
package com.njcn.gather.systemops.deploy.service.impl;
import com.jcraft.jsch.ChannelShell;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.Session;
import com.njcn.gather.systemops.deploy.config.DeployProperties;
import com.njcn.gather.systemops.deploy.pojo.dto.DeployServerConfigDTO;
import com.njcn.gather.systemops.deploy.pojo.dto.DeploySshSessionDTO;
import com.njcn.gather.systemops.deploy.service.DeployServerConfigService;
import com.njcn.gather.systemops.deploy.service.DeploySshTerminalService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Properties;
import java.util.function.Consumer;
/**
* SSH Shell 终端服务实现。
*/
@Service
@RequiredArgsConstructor
public class DeploySshTerminalServiceImpl implements DeploySshTerminalService {
private final DeployServerConfigService deployServerConfigService;
private final DeployProperties deployProperties;
@Override
public DeploySshSessionDTO open(String serverId, Consumer<String> outputConsumer) {
try {
DeployServerConfigDTO config = deployServerConfigService.getRequiredConfig(serverId);
JSch jSch = new JSch();
Session session = jSch.getSession(config.getUsername(), config.getHost(), config.getSshPort());
session.setPassword(config.getPassword());
Properties properties = new Properties();
properties.put("StrictHostKeyChecking", "no");
session.setConfig(properties);
session.connect(deployProperties.getSshConnectTimeoutMs());
ChannelShell channel = (ChannelShell) session.openChannel("shell");
channel.setPty(true);
InputStream output = channel.getInputStream();
channel.connect(deployProperties.getSshConnectTimeoutMs());
DeploySshSessionDTO context = new DeploySshSessionDTO();
context.setSession(session);
context.setChannel(channel);
context.setInput(channel.getOutputStream());
context.setLastAccessTime(System.currentTimeMillis());
startOutputReader(output, outputConsumer);
return context;
} catch (Exception e) {
throw new IllegalStateException("打开SSH终端失败", e);
}
}
@Override
public void input(DeploySshSessionDTO session, String data) {
try {
session.getInput().write(data.getBytes(StandardCharsets.UTF_8));
session.getInput().flush();
session.setLastAccessTime(System.currentTimeMillis());
} catch (Exception e) {
throw new IllegalStateException("写入SSH终端失败", e);
}
}
@Override
public void resize(DeploySshSessionDTO session, Integer cols, Integer rows) {
if (cols != null && rows != null && session.getChannel() != null) {
session.getChannel().setPtySize(cols, rows, 0, 0);
session.setLastAccessTime(System.currentTimeMillis());
}
}
@Override
public void close(DeploySshSessionDTO session) {
if (session == null) {
return;
}
if (session.getChannel() != null) {
session.getChannel().disconnect();
}
if (session.getSession() != null) {
session.getSession().disconnect();
}
}
private void startOutputReader(InputStream output, Consumer<String> outputConsumer) {
Thread thread = new Thread(() -> {
byte[] buffer = new byte[4096];
try {
int length;
while ((length = output.read(buffer)) != -1) {
outputConsumer.accept(new String(buffer, 0, length, StandardCharsets.UTF_8));
}
} catch (Exception ignored) {
outputConsumer.accept("\r\nSSH终端输出读取结束\r\n");
}
}, "deploy-ssh-terminal-reader");
thread.setDaemon(true);
thread.start();
}
}
- Step 6: Create terminal WebSocket handler
Create DeployTerminalWebSocketHandler.java:
package com.njcn.gather.systemops.deploy.websocket;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.njcn.gather.systemops.deploy.config.DeployProperties;
import com.njcn.gather.systemops.deploy.pojo.dto.DeploySshSessionDTO;
import com.njcn.gather.systemops.deploy.pojo.param.DeployTerminalMessageParam;
import com.njcn.gather.systemops.deploy.service.DeploySshTerminalService;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import org.springframework.web.util.UriComponentsBuilder;
import java.net.URI;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* deploy SSH 终端 WebSocket 处理器。
*/
@Component
@RequiredArgsConstructor
public class DeployTerminalWebSocketHandler extends TextWebSocketHandler {
private static final String TYPE_INPUT = "input";
private static final String TYPE_RESIZE = "resize";
private final DeploySshTerminalService deploySshTerminalService;
private final DeployProperties deployProperties;
private final ObjectMapper objectMapper;
private final Map<String, DeploySshSessionDTO> sshSessions = new ConcurrentHashMap<>();
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
String serverId = resolveServerId(session.getUri());
DeploySshSessionDTO sshSession = deploySshTerminalService.open(serverId, output -> sendOutput(session, output));
sshSessions.put(session.getId(), sshSession);
sendStatus(session, "CONNECTED");
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
DeploySshSessionDTO sshSession = sshSessions.get(session.getId());
DeployTerminalMessageParam param = objectMapper.readValue(message.getPayload(), DeployTerminalMessageParam.class);
if (TYPE_INPUT.equals(param.getType())) {
deploySshTerminalService.input(sshSession, param.getData());
return;
}
if (TYPE_RESIZE.equals(param.getType())) {
deploySshTerminalService.resize(sshSession, param.getCols(), param.getRows());
}
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
deploySshTerminalService.close(sshSessions.remove(session.getId()));
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) {
deploySshTerminalService.close(sshSessions.remove(session.getId()));
}
@Scheduled(fixedDelay = 60000)
public void closeIdleSessions() {
long timeoutMs = deployProperties.getTerminalIdleTimeoutMinutes() * 60L * 1000L;
long now = System.currentTimeMillis();
sshSessions.entrySet().removeIf(entry -> {
DeploySshSessionDTO sshSession = entry.getValue();
if (now - sshSession.getLastAccessTime() <= timeoutMs) {
return false;
}
deploySshTerminalService.close(sshSession);
return true;
});
}
private String resolveServerId(URI uri) {
String serverId = UriComponentsBuilder.fromUri(uri).build().getQueryParams().getFirst("serverId");
if (serverId == null || serverId.trim().isEmpty()) {
throw new IllegalArgumentException("serverId不能为空");
}
return serverId;
}
private void sendOutput(WebSocketSession session, String data) {
sendJson(session, "output", data);
}
private void sendStatus(WebSocketSession session, String status) {
sendJson(session, "status", status);
}
private void sendJson(WebSocketSession session, String type, String value) {
try {
if (!session.isOpen()) {
return;
}
Map<String, String> payload = new HashMap<>();
payload.put("type", type);
if ("output".equals(type)) {
payload.put("data", value);
} else {
payload.put("status", value);
}
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(payload)));
} catch (Exception ignored) {
deploySshTerminalService.close(sshSessions.remove(session.getId()));
}
}
}
- Step 7: Verify terminal static behavior
Run:
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\config\DeployWebSocketConfig.java -Pattern '/deploy/terminal|EnableWebSocket|EnableScheduling'
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\service\impl\DeploySshTerminalServiceImpl.java -Pattern 'openChannel\("shell"\)|setPty|setPtySize|deploy-ssh-terminal-reader'
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\websocket\DeployTerminalWebSocketHandler.java -Pattern 'serverId|input|resize|afterConnectionClosed|closeIdleSessions'
Expected: endpoint registration, scheduling, SSH shell operations, WebSocket cleanup, and idle session cleanup are present.
- Step 8: Commit
git add system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/config/DeployWebSocketConfig.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/dto/DeploySshSessionDTO.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/param/DeployTerminalMessageParam.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/DeploySshTerminalService.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/impl/DeploySshTerminalServiceImpl.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/websocket/DeployTerminalWebSocketHandler.java
git commit -m "deploy: implement Linux SSH terminal websocket"
Task 6: Update Documentation And Perform Final Validation
Files:
-
Modify:
D:/Work/SourceCode/CN_Tool/system-ops/deploy/README.md -
Step 1: Update deploy README
Replace system-ops/deploy/README.md with:
# deploy 模块说明
## 模块定位
`deploy` 是 `system-ops` 下的系统部署运维模块,当前提供 Linux 服务器远程运维基础能力。
## 当前接口
- `GET /deploy/overview`
- 查询系统部署基础信息。
- `POST /deploy/server/list`
- 查询 Linux 服务器连接配置。
- `POST /deploy/server/add`
- 新增 Linux 服务器连接配置。
- `POST /deploy/server/update`
- 修改 Linux 服务器连接配置。
- `POST /deploy/server/delete`
- 删除 Linux 服务器连接配置。
- `POST /deploy/server/test`
- 测试 SSH 连接。
- `POST /deploy/file/list`
- 查询远程目录文件列表。
- `POST /deploy/file/mkdir`
- 新建远程目录。
- `POST /deploy/file/delete`
- 删除远程文件或空目录。
- `POST /deploy/file/upload`
- 上传本地文件到远程目录。
- `POST /deploy/file/download`
- 下载远程普通文件。
- `WebSocket /deploy/terminal?serverId={serverId}`
- 打开 Linux SSH Shell 终端。
## 配置项
运行前需要配置:
```yaml
deploy:
storage-dir: ${log.homeDir}/deploy
crypto-key: ${DEPLOY_CRYPTO_KEY:}
ssh-connect-timeout-ms: 5000
terminal-idle-timeout-minutes: 30
deploy.crypto-key 必须通过环境变量或外部配置提供真实值,否则无法保存服务器密码。
当前限制
- 仅支持 Linux 服务器。
- 仅支持 SSH 密码登录。
- 文件传输使用 SFTP,不支持 FTP。
- 下载只支持远程普通文件。
- 删除目录仅支持空目录。
- 不保存命令历史。
- 不提供命令审批、命令黑名单或部署任务编排。
- [ ] **Step 2: Run static validation commands**
Run:
```powershell
Select-String -Path system-ops\deploy\pom.xml -Pattern '<artifactId>jsch</artifactId>|<artifactId>spring-boot-starter-websocket</artifactId>'
Select-String -Path entrance\src\main\resources\application.yml -Pattern 'deploy:|DEPLOY_CRYPTO_KEY|terminal-idle-timeout-minutes'
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\controller\*.java -Pattern '/deploy/server|/deploy/file'
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\config\DeployWebSocketConfig.java -Pattern '/deploy/terminal'
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\pojo\vo\DeployServerVO.java -Pattern 'password'
Expected:
-
dependency and config checks return matches
-
server/file controller routes return matches
-
terminal route returns one match
-
DeployServerVOpassword check returns no match -
Step 3: Review final diff
Run:
git diff -- system-ops/deploy entrance/src/main/resources/application.yml
Expected: diff only contains deploy module implementation, deploy README, deploy pom dependency changes, and application.yml deploy config block.
- Step 4: Manual interface verification when a Linux test host is available
Use a Linux server with SSH password login enabled and DEPLOY_CRYPTO_KEY configured.
Expected results:
-
POST /deploy/server/addcreatesdeploy-server-connections.jsonunder${log.homeDir}/deploy. -
POST /deploy/server/listreturns nopasswordfield. -
JSON file contains encrypted
passwordtext, not the submitted plaintext. -
POST /deploy/server/testreturnssuccess=truefor valid credentials. -
POST /deploy/file/listwith/returns remote directory entries. -
POST /deploy/file/uploaduploads one file to a writable directory. -
POST /deploy/file/downloadreturns the same file content. -
POST /deploy/file/deleteremoves the uploaded test file. -
WebSocket
/deploy/terminal?serverId={serverId}opens a shell and returns output forpwdandls -la. -
Closing the WebSocket releases the SSH channel.
-
Step 5: Commit
git add system-ops/deploy/README.md
git commit -m "docs: describe deploy Linux remote operations"
Self-Review
Spec Coverage
- Linux-only scope: Task 1 through Task 6 keep SSH/SFTP only and README states Linux-only.
- Connection config save/edit/delete/list: Task 3.
- File storage instead of database: Task 2.
- Password encryption and no password in responses: Task 3 and Task 6 validation.
- SFTP upload/download/list/mkdir/delete: Task 4.
- Xshell-like SSH terminal over WebSocket: Task 5.
- Frontend page layout: covered by the approved spec; no frontend code exists in this repository.
- No Maven execution by default: Assumptions and Task 6 validation use static checks and manual API checks.
Placeholder Scan
- 未发现未完成标记。
- 未发现空任务标记。
- Every task lists exact files, concrete code blocks, verification commands, expected results, and commit commands.
- No task depends on a later undefined class; shared models and services are introduced before consumers.
Type Consistency
- Server config model names are consistent:
DeployServerConfigDTO,DeployServerConfigStoreDTO,DeployServerVO. - Server endpoints consistently use
/deploy/server. - File endpoints consistently use
/deploy/file. - Terminal endpoint consistently uses
/deploy/terminal. - SSH port property is consistently named
sshPort. - Runtime config names are consistent with
DeployProperties:storageDir,cryptoKey,sshConnectTimeoutMs,terminalIdleTimeoutMinutes.