# 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/terminal` WebSocket 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: ```powershell 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 ``: ```xml com.jcraft jsch 0.1.55 org.springframework.boot spring-boot-starter-websocket com.fasterxml.jackson.core jackson-databind ``` - [ ] **Step 3: Add deploy runtime configuration** Append this block to `entrance/src/main/resources/application.yml`: ```yaml 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`: ```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: ```powershell 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_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** ```bash 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`: ```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`: ```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 servers = new ArrayList<>(); } ``` - [ ] **Step 3: Create list response VO without password** Create `DeployServerVO.java`: ```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`: ```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 list() { synchronized (lock) { return new ArrayList<>(readStore().getServers()); } } public Optional findById(String id) { synchronized (lock) { return readStore().getServers().stream() .filter(item -> item.getId().equals(id)) .findFirst(); } } public void save(Consumer> 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: ```powershell 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** ```bash 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`: ```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`: ```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`: ```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`: ```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`: ```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 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: ```java 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 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 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`: ```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(@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 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 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 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 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: ```powershell 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 - `DeployServerVO` still has no password match - [ ] **Step 9: Commit** ```bash 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`: ```java package com.njcn.gather.systemops.deploy.pojo.enums; /** * 远程文件类型。 */ public enum DeployFileTypeEnum { FILE, DIRECTORY, LINK } ``` - [ ] **Step 2: Create file params** Create `DeployFileParam.java`: ```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`: ```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`: ```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`: ```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 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: ```java 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 list(DeployFileParam.PathParam param) { return withSftp(param.getServerId(), channel -> { String path = DeployPathUtil.normalize(param.getPath()); Vector entries = channel.ls(path); List 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 withSftp(String serverId, SftpCallback 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 doInSftp(ChannelSftp channel) throws Exception; } } ``` - [ ] **Step 7: Create file controller** Create `DeployFileController.java`: ```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(@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 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 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 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: ```powershell 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** ```bash 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`: ```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`: ```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`: ```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`: ```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 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`: ```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 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 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`: ```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 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 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: ```powershell 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** ```bash 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: ```markdown # 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 'jsch|spring-boot-starter-websocket' 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 - `DeployServerVO` password check returns no match - [ ] **Step 3: Review final diff** Run: ```bash 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/add` creates `deploy-server-connections.json` under `${log.homeDir}/deploy`. - `POST /deploy/server/list` returns no `password` field. - JSON file contains encrypted `password` text, not the submitted plaintext. - `POST /deploy/server/test` returns `success=true` for valid credentials. - `POST /deploy/file/list` with `/` returns remote directory entries. - `POST /deploy/file/upload` uploads one file to a writable directory. - `POST /deploy/file/download` returns the same file content. - `POST /deploy/file/delete` removes the uploaded test file. - WebSocket `/deploy/terminal?serverId={serverId}` opens a shell and returns output for `pwd` and `ls -la`. - Closing the WebSocket releases the SSH channel. - [ ] **Step 5: Commit** ```bash 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`.