Files
CN_Tool/docs/superpowers/plans/2026-05-21-deploy-linux-ssh-sftp.md

1923 lines
73 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `<dependencies>`:
```xml
<dependency>
<groupId>com.jcraft</groupId>
<artifactId>jsch</artifactId>
<version>0.1.55</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
```
- [ ] **Step 3: Add deploy runtime configuration**
Append this block to `entrance/src/main/resources/application.yml`:
```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 '<artifactId>jsch</artifactId>|<artifactId>spring-boot-starter-websocket</artifactId>|<artifactId>jackson-databind</artifactId>'
Select-String -Path entrance\src\main\resources\application.yml -Pattern 'DEPLOY_CRYPTO_KEY|storage-dir|terminal-idle-timeout-minutes'
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\config\DeployProperties.java -Pattern '@ConfigurationProperties\(prefix = "deploy"\)|sshConnectTimeoutMs'
```
Expected: all commands return matches.
- [ ] **Step 6: Commit**
```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<DeployServerConfigDTO> 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<DeployServerConfigDTO> list() {
synchronized (lock) {
return new ArrayList<>(readStore().getServers());
}
}
public Optional<DeployServerConfigDTO> findById(String id) {
synchronized (lock) {
return readStore().getServers().stream()
.filter(item -> item.getId().equals(id))
.findFirst();
}
}
public void save(Consumer<List<DeployServerConfigDTO>> consumer) {
synchronized (lock) {
DeployServerConfigStoreDTO store = readStore();
consumer.accept(store.getServers());
writeStore(store);
}
}
private DeployServerConfigStoreDTO readStore() {
Path storePath = getStorePath();
ensureStoreFile(storePath);
try {
return objectMapper.readValue(storePath.toFile(), DeployServerConfigStoreDTO.class);
} catch (IOException e) {
throw new IllegalStateException("读取服务器连接配置失败", e);
}
}
private void writeStore(DeployServerConfigStoreDTO store) {
Path storePath = getStorePath();
ensureStoreFile(storePath);
Path tempPath = storePath.resolveSibling(STORE_FILE_NAME + ".tmp");
try {
objectMapper.writerWithDefaultPrettyPrinter().writeValue(tempPath.toFile(), store);
Files.move(tempPath, storePath, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
throw new IllegalStateException("写入服务器连接配置失败", e);
}
}
private Path getStorePath() {
return Paths.get(deployProperties.getStorageDir(), STORE_FILE_NAME);
}
private void ensureStoreFile(Path storePath) {
try {
Files.createDirectories(storePath.getParent());
if (!Files.exists(storePath)) {
objectMapper.writerWithDefaultPrettyPrinter()
.writeValue(storePath.toFile(), new DeployServerConfigStoreDTO());
}
} catch (IOException e) {
throw new IllegalStateException("初始化服务器连接配置文件失败", e);
}
}
}
```
- [ ] **Step 5: Verify repository structure**
Run:
```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<DeployServerVO> list(DeployServerParam.QueryParam param);
Boolean add(DeployServerParam.AddParam param);
Boolean update(DeployServerParam.UpdateParam param);
Boolean delete(DeployServerParam.IdParam param);
DeployConnectionTestVO test(DeployServerParam.IdParam param);
DeployServerConfigDTO getRequiredConfig(String serverId);
}
```
- [ ] **Step 6: Create server config service implementation**
Create `DeployServerConfigServiceImpl.java` with the key methods below. Keep helper methods private in the same class:
```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<DeployServerVO> list(DeployServerParam.QueryParam param) {
String keyword = param == null ? null : param.getKeyword();
return repository.list().stream()
.filter(item -> !StringUtils.hasText(keyword)
|| item.getName().contains(keyword)
|| item.getHost().contains(keyword))
.map(this::toVO)
.collect(Collectors.toList());
}
@Override
public Boolean add(DeployServerParam.AddParam param) {
repository.save(list -> {
ensureNotDuplicate(list, null, param.getHost(), param.getSshPort(), param.getUsername());
DeployServerConfigDTO config = new DeployServerConfigDTO();
config.setId(UUID.randomUUID().toString().replace("-", ""));
config.setName(param.getName());
config.setHost(param.getHost());
config.setSshPort(param.getSshPort());
config.setUsername(param.getUsername());
config.setPassword(cryptoService.encrypt(param.getPassword()));
config.setDescription(param.getDescription());
config.setCreatedTime(now());
config.setUpdatedTime(config.getCreatedTime());
list.add(config);
});
return true;
}
@Override
public Boolean update(DeployServerParam.UpdateParam param) {
repository.save(list -> {
ensureNotDuplicate(list, param.getId(), param.getHost(), param.getSshPort(), param.getUsername());
DeployServerConfigDTO config = list.stream()
.filter(item -> item.getId().equals(param.getId()))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("服务器连接配置不存在"));
config.setName(param.getName());
config.setHost(param.getHost());
config.setSshPort(param.getSshPort());
config.setUsername(param.getUsername());
if (StringUtils.hasText(param.getPassword())) {
config.setPassword(cryptoService.encrypt(param.getPassword()));
}
config.setDescription(param.getDescription());
config.setUpdatedTime(now());
});
return true;
}
@Override
public Boolean delete(DeployServerParam.IdParam param) {
repository.save(list -> {
boolean removed = list.removeIf(item -> item.getId().equals(param.getId()));
if (!removed) {
throw new IllegalArgumentException("服务器连接配置不存在");
}
});
return true;
}
@Override
public DeployConnectionTestVO test(DeployServerParam.IdParam param) {
DeployConnectionTestVO result = new DeployConnectionTestVO();
Session session = null;
try {
DeployServerConfigDTO config = getRequiredConfig(param.getId());
session = createSession(config);
result.setSuccess(true);
result.setMessage("SSH连接成功");
} catch (JSchException e) {
result.setSuccess(false);
result.setMessage(resolveSshError(e));
} finally {
if (session != null && session.isConnected()) {
session.disconnect();
}
}
return result;
}
@Override
public DeployServerConfigDTO getRequiredConfig(String serverId) {
DeployServerConfigDTO config = repository.findById(serverId)
.orElseThrow(() -> new IllegalArgumentException("服务器连接配置不存在"));
config.setPassword(cryptoService.decrypt(config.getPassword()));
return config;
}
private Session createSession(DeployServerConfigDTO config) throws JSchException {
JSch jSch = new JSch();
Session session = jSch.getSession(config.getUsername(), config.getHost(), config.getSshPort());
session.setPassword(config.getPassword());
Properties properties = new Properties();
properties.put("StrictHostKeyChecking", "no");
session.setConfig(properties);
session.connect(deployProperties.getSshConnectTimeoutMs());
return session;
}
private void ensureNotDuplicate(List<DeployServerConfigDTO> list, String id, String host, Integer port, String username) {
boolean duplicate = list.stream().anyMatch(item ->
!item.getId().equals(id)
&& item.getHost().equals(host)
&& item.getSshPort().equals(port)
&& item.getUsername().equals(username));
if (duplicate) {
throw new IllegalArgumentException("相同主机、端口和用户名的连接配置已存在");
}
}
private DeployServerVO toVO(DeployServerConfigDTO config) {
DeployServerVO vo = new DeployServerVO();
vo.setId(config.getId());
vo.setName(config.getName());
vo.setHost(config.getHost());
vo.setSshPort(config.getSshPort());
vo.setUsername(config.getUsername());
vo.setDescription(config.getDescription());
vo.setCreatedTime(config.getCreatedTime());
vo.setUpdatedTime(config.getUpdatedTime());
return vo;
}
private String now() {
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
}
private String resolveSshError(JSchException e) {
String message = e.getMessage() == null ? "" : e.getMessage();
if (message.contains("Auth fail")) {
return "SSH认证失败";
}
if (message.contains("Connection refused")) {
return "SSH端口连接失败";
}
return "连接服务器失败";
}
}
```
- [ ] **Step 7: Create server controller**
Create `DeployServerController.java`:
```java
package com.njcn.gather.systemops.deploy.controller;
import com.njcn.common.pojo.annotation.OperateInfo;
import com.njcn.common.pojo.constant.OperateType;
import com.njcn.common.pojo.enums.common.LogEnum;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.response.HttpResult;
import com.njcn.gather.systemops.deploy.pojo.param.DeployServerParam;
import com.njcn.gather.systemops.deploy.pojo.vo.DeployConnectionTestVO;
import com.njcn.gather.systemops.deploy.pojo.vo.DeployServerVO;
import com.njcn.gather.systemops.deploy.service.DeployServerConfigService;
import com.njcn.web.controller.BaseController;
import com.njcn.web.utils.HttpResultUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* Linux 服务器连接配置接口。
*/
@Slf4j
@Api(tags = "Linux服务器连接配置")
@RestController
@RequestMapping("/deploy/server")
@RequiredArgsConstructor
public class DeployServerController extends BaseController {
private final DeployServerConfigService deployServerConfigService;
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("查询服务器连接配置")
@PostMapping("/list")
public HttpResult<List<DeployServerVO>> list(@RequestBody(required = false) DeployServerParam.QueryParam param) {
String methodDescribe = getMethodDescribe("list");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
deployServerConfigService.list(param), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.ADD)
@ApiOperation("新增服务器连接配置")
@PostMapping("/add")
public HttpResult<Boolean> add(@RequestBody @Validated DeployServerParam.AddParam param) {
String methodDescribe = getMethodDescribe("add");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
deployServerConfigService.add(param), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.UPDATE)
@ApiOperation("修改服务器连接配置")
@PostMapping("/update")
public HttpResult<Boolean> update(@RequestBody @Validated DeployServerParam.UpdateParam param) {
String methodDescribe = getMethodDescribe("update");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
deployServerConfigService.update(param), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.DELETE)
@ApiOperation("删除服务器连接配置")
@PostMapping("/delete")
public HttpResult<Boolean> delete(@RequestBody @Validated DeployServerParam.IdParam param) {
String methodDescribe = getMethodDescribe("delete");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
deployServerConfigService.delete(param), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("测试SSH连接")
@PostMapping("/test")
public HttpResult<DeployConnectionTestVO> test(@RequestBody @Validated DeployServerParam.IdParam param) {
String methodDescribe = getMethodDescribe("test");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
deployServerConfigService.test(param), methodDescribe);
}
}
```
- [ ] **Step 8: Verify server API static behavior**
Run:
```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<DeployFileVO> list(DeployFileParam.PathParam param);
Boolean mkdir(DeployFileParam.MkdirParam param);
Boolean delete(DeployFileParam.PathParam param);
Boolean upload(String serverId, String path, MultipartFile file);
void download(DeployFileParam.PathParam param, HttpServletResponse response);
}
```
- [ ] **Step 6: Create SFTP service implementation**
Create `DeploySftpServiceImpl.java`. Include helper methods in the same file:
```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<DeployFileVO> list(DeployFileParam.PathParam param) {
return withSftp(param.getServerId(), channel -> {
String path = DeployPathUtil.normalize(param.getPath());
Vector<?> entries = channel.ls(path);
List<DeployFileVO> result = new ArrayList<>();
for (Object entry : entries) {
ChannelSftp.LsEntry lsEntry = (ChannelSftp.LsEntry) entry;
if (".".equals(lsEntry.getFilename()) || "..".equals(lsEntry.getFilename())) {
continue;
}
result.add(toVO(path, lsEntry));
}
return result;
});
}
@Override
public Boolean mkdir(DeployFileParam.MkdirParam param) {
return withSftp(param.getServerId(), channel -> {
channel.mkdir(DeployPathUtil.child(param.getParentPath(), param.getName()));
return true;
});
}
@Override
public Boolean delete(DeployFileParam.PathParam param) {
return withSftp(param.getServerId(), channel -> {
String path = DeployPathUtil.normalize(param.getPath());
DeployPathUtil.rejectRootDelete(path);
SftpATTRS attrs = channel.lstat(path);
if (attrs.isDir()) {
channel.rmdir(path);
} else {
channel.rm(path);
}
return true;
});
}
@Override
public Boolean upload(String serverId, String path, MultipartFile file) {
return withSftp(serverId, channel -> {
String targetDir = DeployPathUtil.normalize(path);
SftpATTRS attrs = channel.lstat(targetDir);
if (!attrs.isDir()) {
throw new IllegalArgumentException("上传目标必须是远程目录");
}
channel.put(file.getInputStream(), DeployPathUtil.child(targetDir, file.getOriginalFilename()));
return true;
});
}
@Override
public void download(DeployFileParam.PathParam param, HttpServletResponse response) {
withSftp(param.getServerId(), channel -> {
String path = DeployPathUtil.normalize(param.getPath());
SftpATTRS attrs = channel.lstat(path);
if (attrs.isDir()) {
throw new IllegalArgumentException("只能下载普通文件");
}
String fileName = path.substring(path.lastIndexOf('/') + 1);
response.setContentType("application/octet-stream");
response.setHeader("Content-Disposition",
"attachment; filename=" + URLEncoder.encode(fileName, "UTF-8"));
channel.get(path, response.getOutputStream());
return null;
});
}
private DeployFileVO toVO(String parentPath, ChannelSftp.LsEntry entry) {
SftpATTRS attrs = entry.getAttrs();
DeployFileVO vo = new DeployFileVO();
vo.setName(entry.getFilename());
vo.setPath(DeployPathUtil.child(parentPath, entry.getFilename()));
vo.setType(resolveType(attrs));
vo.setSize(attrs.isDir() ? null : attrs.getSize());
vo.setPermissions(attrs.getPermissionsString());
vo.setModifiedTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(attrs.getMTime() * 1000L));
return vo;
}
private DeployFileTypeEnum resolveType(SftpATTRS attrs) {
if (attrs.isLink()) {
return DeployFileTypeEnum.LINK;
}
if (attrs.isDir()) {
return DeployFileTypeEnum.DIRECTORY;
}
return DeployFileTypeEnum.FILE;
}
private <T> T withSftp(String serverId, SftpCallback<T> callback) {
Session session = null;
ChannelSftp channel = null;
try {
DeployServerConfigDTO config = deployServerConfigService.getRequiredConfig(serverId);
JSch jSch = new JSch();
session = jSch.getSession(config.getUsername(), config.getHost(), config.getSshPort());
session.setPassword(config.getPassword());
Properties properties = new Properties();
properties.put("StrictHostKeyChecking", "no");
session.setConfig(properties);
session.connect(deployProperties.getSshConnectTimeoutMs());
channel = (ChannelSftp) session.openChannel("sftp");
channel.connect(deployProperties.getSshConnectTimeoutMs());
return callback.doInSftp(channel);
} catch (Exception e) {
throw new IllegalStateException("SFTP文件操作失败", e);
} finally {
if (channel != null) {
channel.disconnect();
}
if (session != null) {
session.disconnect();
}
}
}
private interface SftpCallback<T> {
T doInSftp(ChannelSftp channel) throws Exception;
}
}
```
- [ ] **Step 7: Create file controller**
Create `DeployFileController.java`:
```java
package com.njcn.gather.systemops.deploy.controller;
import com.njcn.common.pojo.annotation.OperateInfo;
import com.njcn.common.pojo.constant.OperateType;
import com.njcn.common.pojo.enums.common.LogEnum;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.response.HttpResult;
import com.njcn.gather.systemops.deploy.pojo.param.DeployFileParam;
import com.njcn.gather.systemops.deploy.pojo.vo.DeployFileVO;
import com.njcn.gather.systemops.deploy.service.DeploySftpService;
import com.njcn.web.controller.BaseController;
import com.njcn.web.utils.HttpResultUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
/**
* Linux 服务器远程文件接口。
*/
@Slf4j
@Api(tags = "Linux服务器文件管理")
@RestController
@RequestMapping("/deploy/file")
@RequiredArgsConstructor
public class DeployFileController extends BaseController {
private final DeploySftpService deploySftpService;
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("查询远程目录文件列表")
@PostMapping("/list")
public HttpResult<List<DeployFileVO>> list(@RequestBody @Validated DeployFileParam.PathParam param) {
String methodDescribe = getMethodDescribe("list");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
deploySftpService.list(param), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.ADD)
@ApiOperation("新建远程目录")
@PostMapping("/mkdir")
public HttpResult<Boolean> mkdir(@RequestBody @Validated DeployFileParam.MkdirParam param) {
String methodDescribe = getMethodDescribe("mkdir");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
deploySftpService.mkdir(param), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.DELETE)
@ApiOperation("删除远程文件或目录")
@PostMapping("/delete")
public HttpResult<Boolean> delete(@RequestBody @Validated DeployFileParam.PathParam param) {
String methodDescribe = getMethodDescribe("delete");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
deploySftpService.delete(param), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.ADD)
@ApiOperation("上传文件到远程目录")
@PostMapping(value = "/upload", consumes = {"multipart/form-data"})
public HttpResult<Boolean> upload(@RequestParam String serverId,
@RequestParam String path,
@RequestPart("file") MultipartFile file) {
String methodDescribe = getMethodDescribe("upload");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
deploySftpService.upload(serverId, path, file), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.DOWNLOAD)
@ApiOperation("下载远程普通文件")
@PostMapping("/download")
public void download(@RequestBody @Validated DeployFileParam.PathParam param,
HttpServletResponse response) {
deploySftpService.download(param, response);
}
}
```
- [ ] **Step 8: Verify SFTP static behavior**
Run:
```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<String> outputConsumer);
void input(DeploySshSessionDTO session, String data);
void resize(DeploySshSessionDTO session, Integer cols, Integer rows);
void close(DeploySshSessionDTO session);
}
```
- [ ] **Step 5: Create terminal service implementation**
Create `DeploySshTerminalServiceImpl.java`:
```java
package com.njcn.gather.systemops.deploy.service.impl;
import com.jcraft.jsch.ChannelShell;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.Session;
import com.njcn.gather.systemops.deploy.config.DeployProperties;
import com.njcn.gather.systemops.deploy.pojo.dto.DeployServerConfigDTO;
import com.njcn.gather.systemops.deploy.pojo.dto.DeploySshSessionDTO;
import com.njcn.gather.systemops.deploy.service.DeployServerConfigService;
import com.njcn.gather.systemops.deploy.service.DeploySshTerminalService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Properties;
import java.util.function.Consumer;
/**
* SSH Shell 终端服务实现。
*/
@Service
@RequiredArgsConstructor
public class DeploySshTerminalServiceImpl implements DeploySshTerminalService {
private final DeployServerConfigService deployServerConfigService;
private final DeployProperties deployProperties;
@Override
public DeploySshSessionDTO open(String serverId, Consumer<String> outputConsumer) {
try {
DeployServerConfigDTO config = deployServerConfigService.getRequiredConfig(serverId);
JSch jSch = new JSch();
Session session = jSch.getSession(config.getUsername(), config.getHost(), config.getSshPort());
session.setPassword(config.getPassword());
Properties properties = new Properties();
properties.put("StrictHostKeyChecking", "no");
session.setConfig(properties);
session.connect(deployProperties.getSshConnectTimeoutMs());
ChannelShell channel = (ChannelShell) session.openChannel("shell");
channel.setPty(true);
InputStream output = channel.getInputStream();
channel.connect(deployProperties.getSshConnectTimeoutMs());
DeploySshSessionDTO context = new DeploySshSessionDTO();
context.setSession(session);
context.setChannel(channel);
context.setInput(channel.getOutputStream());
context.setLastAccessTime(System.currentTimeMillis());
startOutputReader(output, outputConsumer);
return context;
} catch (Exception e) {
throw new IllegalStateException("打开SSH终端失败", e);
}
}
@Override
public void input(DeploySshSessionDTO session, String data) {
try {
session.getInput().write(data.getBytes(StandardCharsets.UTF_8));
session.getInput().flush();
session.setLastAccessTime(System.currentTimeMillis());
} catch (Exception e) {
throw new IllegalStateException("写入SSH终端失败", e);
}
}
@Override
public void resize(DeploySshSessionDTO session, Integer cols, Integer rows) {
if (cols != null && rows != null && session.getChannel() != null) {
session.getChannel().setPtySize(cols, rows, 0, 0);
session.setLastAccessTime(System.currentTimeMillis());
}
}
@Override
public void close(DeploySshSessionDTO session) {
if (session == null) {
return;
}
if (session.getChannel() != null) {
session.getChannel().disconnect();
}
if (session.getSession() != null) {
session.getSession().disconnect();
}
}
private void startOutputReader(InputStream output, Consumer<String> outputConsumer) {
Thread thread = new Thread(() -> {
byte[] buffer = new byte[4096];
try {
int length;
while ((length = output.read(buffer)) != -1) {
outputConsumer.accept(new String(buffer, 0, length, StandardCharsets.UTF_8));
}
} catch (Exception ignored) {
outputConsumer.accept("\r\nSSH终端输出读取结束\r\n");
}
}, "deploy-ssh-terminal-reader");
thread.setDaemon(true);
thread.start();
}
}
```
- [ ] **Step 6: Create terminal WebSocket handler**
Create `DeployTerminalWebSocketHandler.java`:
```java
package com.njcn.gather.systemops.deploy.websocket;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.njcn.gather.systemops.deploy.config.DeployProperties;
import com.njcn.gather.systemops.deploy.pojo.dto.DeploySshSessionDTO;
import com.njcn.gather.systemops.deploy.pojo.param.DeployTerminalMessageParam;
import com.njcn.gather.systemops.deploy.service.DeploySshTerminalService;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import org.springframework.web.util.UriComponentsBuilder;
import java.net.URI;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* deploy SSH 终端 WebSocket 处理器。
*/
@Component
@RequiredArgsConstructor
public class DeployTerminalWebSocketHandler extends TextWebSocketHandler {
private static final String TYPE_INPUT = "input";
private static final String TYPE_RESIZE = "resize";
private final DeploySshTerminalService deploySshTerminalService;
private final DeployProperties deployProperties;
private final ObjectMapper objectMapper;
private final Map<String, DeploySshSessionDTO> sshSessions = new ConcurrentHashMap<>();
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
String serverId = resolveServerId(session.getUri());
DeploySshSessionDTO sshSession = deploySshTerminalService.open(serverId, output -> sendOutput(session, output));
sshSessions.put(session.getId(), sshSession);
sendStatus(session, "CONNECTED");
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
DeploySshSessionDTO sshSession = sshSessions.get(session.getId());
DeployTerminalMessageParam param = objectMapper.readValue(message.getPayload(), DeployTerminalMessageParam.class);
if (TYPE_INPUT.equals(param.getType())) {
deploySshTerminalService.input(sshSession, param.getData());
return;
}
if (TYPE_RESIZE.equals(param.getType())) {
deploySshTerminalService.resize(sshSession, param.getCols(), param.getRows());
}
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
deploySshTerminalService.close(sshSessions.remove(session.getId()));
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) {
deploySshTerminalService.close(sshSessions.remove(session.getId()));
}
@Scheduled(fixedDelay = 60000)
public void closeIdleSessions() {
long timeoutMs = deployProperties.getTerminalIdleTimeoutMinutes() * 60L * 1000L;
long now = System.currentTimeMillis();
sshSessions.entrySet().removeIf(entry -> {
DeploySshSessionDTO sshSession = entry.getValue();
if (now - sshSession.getLastAccessTime() <= timeoutMs) {
return false;
}
deploySshTerminalService.close(sshSession);
return true;
});
}
private String resolveServerId(URI uri) {
String serverId = UriComponentsBuilder.fromUri(uri).build().getQueryParams().getFirst("serverId");
if (serverId == null || serverId.trim().isEmpty()) {
throw new IllegalArgumentException("serverId不能为空");
}
return serverId;
}
private void sendOutput(WebSocketSession session, String data) {
sendJson(session, "output", data);
}
private void sendStatus(WebSocketSession session, String status) {
sendJson(session, "status", status);
}
private void sendJson(WebSocketSession session, String type, String value) {
try {
if (!session.isOpen()) {
return;
}
Map<String, String> payload = new HashMap<>();
payload.put("type", type);
if ("output".equals(type)) {
payload.put("data", value);
} else {
payload.put("status", value);
}
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(payload)));
} catch (Exception ignored) {
deploySshTerminalService.close(sshSessions.remove(session.getId()));
}
}
}
```
- [ ] **Step 7: Verify terminal static behavior**
Run:
```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 '<artifactId>jsch</artifactId>|<artifactId>spring-boot-starter-websocket</artifactId>'
Select-String -Path entrance\src\main\resources\application.yml -Pattern 'deploy:|DEPLOY_CRYPTO_KEY|terminal-idle-timeout-minutes'
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\controller\*.java -Pattern '/deploy/server|/deploy/file'
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\config\DeployWebSocketConfig.java -Pattern '/deploy/terminal'
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\pojo\vo\DeployServerVO.java -Pattern 'password'
```
Expected:
- dependency and config checks return matches
- server/file controller routes return matches
- terminal route returns one match
- `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`.