diff --git a/docs/superpowers/plans/2026-05-21-deploy-linux-ssh-sftp.md b/docs/superpowers/plans/2026-05-21-deploy-linux-ssh-sftp.md new file mode 100644 index 0000000..ad07368 --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-deploy-linux-ssh-sftp.md @@ -0,0 +1,1922 @@ +# 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`.