Enhance security and reliability across SFTP workflows

This commit is contained in:
liumangmang
2026-03-10 16:15:46 +08:00
parent 56c40410dc
commit 0c443b029d
23 changed files with 1477 additions and 394 deletions

190
AGENTS.md Normal file
View File

@@ -0,0 +1,190 @@
# `sftp-manager` AGENTS 指南
本文件面向在本仓库内执行任务的自动化 Coding Agent。
请将其作为本项目在构建、测试、编码规范方面的本地权威说明。
## 1项目概览
- 技术栈Spring Boot 2.7.18、Java 8、Maven、Spring Data JPA、H2、JSch、原生 HTML/CSS/JS。
- 启动入口:`src/main/java/com/sftp/manager/SftpManagerApplication.java`
- 主要目录结构:
- `src/main/java/com/sftp/manager/controller/`HTTP 控制器
- `src/main/java/com/sftp/manager/service/`业务逻辑SFTP、本地文件
- `src/main/java/com/sftp/manager/model/`:实体模型
- `src/main/java/com/sftp/manager/repository/`JPA 仓库
- `src/main/java/com/sftp/manager/dto/`:接口请求/响应 DTO
- `src/main/resources/static/`:前端静态资源
- `src/main/resources/application*.yml`:运行配置
- 默认端口:`48081`(见 `application.yml`)。
- 生产环境可启用 `prod` profile`application-prod.yml`)。
## 2Cursor / Copilot 规则检查结果
已检查以下路径,当前未发现规则文件:
- `.cursor/rules/`
- `.cursorrules`
- `.github/copilot-instructions.md`
若后续新增上述文件,请立即同步更新本 `AGENTS.md`
新增规则应视为更高优先级约束并优先遵循。
## 3构建、运行、测试命令
默认在仓库根目录执行命令。
### 3.1 Maven 构建与打包
- 仅编译:`mvn clean compile`
- 完整打包(含测试):`mvn clean package`
- 跳过测试打包:`mvn clean package -DskipTests`
- 安装到本地仓库:`mvn clean install`
### 3.2 本地运行
- 开发运行:`mvn spring-boot:run`
- 运行 JAR`java -jar target/sftp-manager-1.0.0.jar`
- 生产配置运行:`java -jar target/sftp-manager-1.0.0.jar --spring.profiles.active=prod`
### 3.3 Docker 运行
- Linux/Mac 一键启动:`./run.sh`
- Windows 一键启动:`run.bat`
- 手动启动:`docker compose up -d --build`
- 手动停止:`docker compose down`
### 3.4 测试命令(重点:单测定向执行)
当前仓库尚无 `src/test/**/*.java` 文件。
当新增测试后,按以下方式执行:
- 执行全部测试:`mvn test`
- 执行单个测试类:`mvn -Dtest=ConnectionServiceTest test`
- 执行单个测试方法:`mvn -Dtest=ConnectionServiceTest#connect_success test`
- 执行多个测试类:`mvn -Dtest=ConnectionServiceTest,FileControllerTest test`
若出现“测试未执行”,优先检查命名是否满足 Surefire 默认规则:
- `*Test`
- `*Tests`
- `*TestCase`
### 3.5 Lint / 质量门禁
`pom.xml` 当前未配置 Checkstyle、Spotless、PMD 等专用 lint 插件。
因此以“编译 + 测试”作为质量门禁:
- 快速门禁:`mvn clean compile`
- 完整门禁:`mvn clean test`
### 3.6 当前测试覆盖(供 Agent 参考)
目前已存在以下单测类,可按需定向执行:
- `ConnectionControllerTest`
- `FileControllerTest`
- `ConnectionServiceTest`
- `SessionManagerTest`
- `LocalFileServiceTest`
示例:
- 执行单个测试类:`mvn -Dtest=LocalFileServiceTest test`
- 执行单个测试方法:`mvn -Dtest=LocalFileServiceTest#renameFile_shouldAllowRenameInSameDirectory test`
## 4代码风格与工程约定
## 4.1 Java / Spring 通用约定
- 必须保持 Java 8 兼容,不使用 Java 9+ 语法。
- 包路径维持在 `com.sftp.manager` 下。
- 控制器保持轻量,核心逻辑放到 `service` 层。
- API 返回统一使用 `ApiResponse<T>`
- 延续既有接口前缀:
- `/api/connection/*`
- `/api/files/*`
- 沿用已有术语命名:`sessionId``sourcePath``targetPath``recursive`
## 4.2 依赖注入风格
- 现有代码大量使用字段注入 `@Autowired`,修改旧文件时保持一致。
- 新增类可用构造器注入,但同一类内不要混用多种注入风格。
- 避免“仅改一点却全文件重构注入方式”的无关变更。
## 4.3 Imports 与格式
- 保持文件现有 import 分组习惯,尽量与周边代码一致。
- 一般顺序:项目/第三方 → Spring → `javax.*``java.*`
- 避免通配符导入(`*`),优先显式导入。
- 使用 4 空格缩进,不使用 Tab。
- 保持现有大括号、注解换行和空行风格。
## 4.4 类型、DTO 与实体
- 避免原始类型集合,优先使用泛型(如 `List<FileInfo>`)。
- DTO/实体中,允许为空的数值或布尔优先用包装类型(`Integer``Long``Boolean`)。
- DTO 只做传输,不承载业务逻辑。
- 若同类文件已使用 Lombok `@Data`,新增同类对象可延续该模式。
## 4.5 命名规范
- 类名PascalCase`ConnectionService`)。
- 方法/变量/参数camelCase`getFileInfo`)。
- 常量UPPER_SNAKE_CASE`API_BASE`)。
- REST 路径:小写,必要时使用连字符,优先复用已有路径。
- Service 方法名优先“动词 + 对象”(如 `uploadToSftp``downloadFromSftp`)。
## 4.6 错误处理规范
- 先做参数校验,再执行核心逻辑。
- 控制器边界统一捕获异常,并映射为 `ApiResponse.error(...)`
- 错误信息要包含上下文(路径、会话、操作类型),但不得泄露敏感信息。
- 清理流程中的异常可按现有模式忽略,但主流程异常不可静默吞掉。
- 保持面向用户的提示语风格与现有代码一致(当前以中文提示为主)。
## 4.7 文件与流处理
- 所有 `InputStream` / `OutputStream` 优先使用 `try-with-resources`
- 明确“由谁负责关闭流”,避免重复关闭或泄漏。
- 递归文件操作需考虑部分失败、非法路径、权限不足等场景。
- 跨平台路径处理继续使用现有模式(`File.separator`、路径规范化)。
- 本地路径处理应统一规范化(`getCanonicalFile`),拒绝 `..` 上级目录穿越。
- 删除操作应防护根目录与系统关键目录Windows 与 Unix 路径均需考虑)。
- 重命名默认按“同目录重命名”语义实现,不在重命名接口里隐式做跨目录移动。
## 4.8 持久化与配置约定
- 实体默认值与时间戳钩子(`@PrePersist` / `@PreUpdate`)保持一致。
- 不提交 `data/` 下数据库产物(`.gitignore` 已忽略相关文件)。
- 生产配置中的环境变量占位符应保留(如 `DB_USERNAME``DB_PASSWORD`)。
- 非需求驱动情况下,不扩展 Actuator 暴露范围。
## 4.9 前端静态资源约定
- 项目未配置 Node 构建链路,前端资源直接编辑 `static` 目录。
- 保持当前 jQuery 风格与状态驱动结构(见 `static/js/app.js`)。
- 保持双面板交互和响应式行为,不破坏现有 UX。
- 保持 CSS 变量与设计令牌风格,不做大规模 UI 体系替换。
## 5Agent 工作流程建议
- 开始改动前,先阅读目标文件及相邻文件风格。
- 以最小必要改动为原则,避免无关重构。
- 未明确要求时不更改端口、context-path、profile 行为。
- 新增功能优先补充可验证逻辑;有测试目录后优先补单元测试。
- 完成后至少运行一次编译校验;如有测试则执行相关测试。
- 若新增关键命令或流程,请同步更新本 `AGENTS.md`
- 涉及安全边界认证、文件系统、CORS、主机密钥变更后必须补至少一条对应单测。
## 6常用命令速查
- `mvn clean compile`
- `mvn test`
- `mvn -Dtest=ClassName test`
- `mvn -Dtest=ClassName#methodName test`
- `mvn spring-boot:run`
- `mvn clean package -DskipTests`
- `java -jar target/sftp-manager-1.0.0.jar --spring.profiles.active=prod`
- `docker compose up -d --build`
请随项目演进持续维护本文件,确保其内容始终与仓库实际配置一致。

View File

@@ -3,9 +3,11 @@ package com.sftp.manager;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
@EnableJpaRepositories @EnableJpaRepositories
@EnableScheduling
public class SftpManagerApplication { public class SftpManagerApplication {
public static void main(String[] args) { public static void main(String[] args) {

View File

@@ -1,5 +1,6 @@
package com.sftp.manager.config; package com.sftp.manager.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
@@ -9,10 +10,18 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration @Configuration
public class WebConfig implements WebMvcConfigurer { public class WebConfig implements WebMvcConfigurer {
@Value("${app.cors.allowed-origins:http://localhost:48081,http://127.0.0.1:48081}")
private String allowedOrigins;
@Override @Override
public void addCorsMappings(CorsRegistry registry) { public void addCorsMappings(CorsRegistry registry) {
String[] origins = allowedOrigins.split(",");
for (int i = 0; i < origins.length; i++) {
origins[i] = origins[i].trim();
}
registry.addMapping("/**") registry.addMapping("/**")
.allowedOrigins("*") .allowedOriginPatterns(origins)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*") .allowedHeaders("*")
.maxAge(3600); .maxAge(3600);

View File

@@ -2,9 +2,12 @@ package com.sftp.manager.controller;
import com.sftp.manager.dto.ApiResponse; import com.sftp.manager.dto.ApiResponse;
import com.sftp.manager.dto.ConnectionRequest; import com.sftp.manager.dto.ConnectionRequest;
import com.sftp.manager.dto.DisconnectRequest;
import com.sftp.manager.model.Connection; import com.sftp.manager.model.Connection;
import com.sftp.manager.service.ConnectionService; import com.sftp.manager.service.ConnectionService;
import com.sftp.manager.service.SessionManager; import com.sftp.manager.service.SessionManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -13,9 +16,10 @@ import java.util.Map;
@RestController @RestController
@RequestMapping("/api/connection") @RequestMapping("/api/connection")
@CrossOrigin(origins = "*")
public class ConnectionController { public class ConnectionController {
private static final Logger log = LoggerFactory.getLogger(ConnectionController.class);
@Autowired @Autowired
private ConnectionService connectionService; private ConnectionService connectionService;
@@ -23,44 +27,35 @@ public class ConnectionController {
private SessionManager sessionManager; private SessionManager sessionManager;
@PostMapping("/connect") @PostMapping("/connect")
public ApiResponse<String> connect(@RequestBody ConnectionRequest request) { public ApiResponse<String> connect(@RequestBody ConnectionRequest request) throws Exception {
try { String sessionId = connectionService.connect(request);
String sessionId = connectionService.connect(request); log.info("连接接口调用成功: sessionId={}", sessionId);
return ApiResponse.success("连接成功", sessionId); return ApiResponse.success("连接成功", sessionId);
} catch (Exception e) {
return ApiResponse.error("连接失败: " + e.getMessage());
}
} }
@PostMapping("/disconnect") @PostMapping("/disconnect")
public ApiResponse<Void> disconnect(@RequestBody Map<String, String> request) { public ApiResponse<Void> disconnect(@RequestBody DisconnectRequest request) {
try { if (request == null || request.getSessionId() == null || request.getSessionId().trim().isEmpty()) {
String sessionId = request.get("sessionId"); throw new IllegalArgumentException("会话ID不能为空");
connectionService.disconnect(sessionId);
return ApiResponse.success("断开成功", null);
} catch (Exception e) {
return ApiResponse.error("断开失败: " + e.getMessage());
} }
String sessionId = request.getSessionId().trim();
connectionService.disconnect(sessionId);
log.info("断开连接接口调用成功: sessionId={}", sessionId);
return ApiResponse.success("断开成功", null);
} }
@PostMapping("/save") @PostMapping("/save")
public ApiResponse<Connection> saveConnection(@RequestBody Connection connection) { public ApiResponse<Connection> saveConnection(@RequestBody Connection connection) {
try { Connection saved = connectionService.saveConnection(connection);
Connection saved = connectionService.saveConnection(connection); log.info("保存连接成功: id={}, name={}", saved.getId(), saved.getName());
return ApiResponse.success("保存成功", saved); return ApiResponse.success("保存成功", saved);
} catch (Exception e) {
return ApiResponse.error("保存失败: " + e.getMessage());
}
} }
@GetMapping("/list") @GetMapping("/list")
public ApiResponse<List<Connection>> listConnections() { public ApiResponse<List<Connection>> listConnections() {
try { List<Connection> connections = connectionService.listConnections();
List<Connection> connections = connectionService.listConnections(); return ApiResponse.success("查询成功", connections);
return ApiResponse.success("查询成功", connections);
} catch (Exception e) {
return ApiResponse.error("查询失败: " + e.getMessage());
}
} }
@GetMapping("/{id}") @GetMapping("/{id}")
@@ -79,20 +74,13 @@ public class ConnectionController {
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public ApiResponse<Void> deleteConnection(@PathVariable Long id) { public ApiResponse<Void> deleteConnection(@PathVariable Long id) {
try { connectionService.deleteConnection(id);
connectionService.deleteConnection(id); log.info("删除连接成功: id={}", id);
return ApiResponse.success("删除成功", null); return ApiResponse.success("删除成功", null);
} catch (Exception e) {
return ApiResponse.error("删除失败: " + e.getMessage());
}
} }
@GetMapping("/active") @GetMapping("/active")
public ApiResponse<Map<String, Connection>> getActiveConnections() { public ApiResponse<Map<String, Connection>> getActiveConnections() {
try { return ApiResponse.success("查询成功", sessionManager.getAllActiveConnections());
return ApiResponse.success("查询成功", sessionManager.getAllActiveConnections());
} catch (Exception e) {
return ApiResponse.error("查询失败: " + e.getMessage());
}
} }
} }

View File

@@ -2,14 +2,19 @@ package com.sftp.manager.controller;
import com.jcraft.jsch.ChannelSftp; import com.jcraft.jsch.ChannelSftp;
import com.sftp.manager.dto.ApiResponse; import com.sftp.manager.dto.ApiResponse;
import com.sftp.manager.dto.BatchDeleteRequest;
import com.sftp.manager.dto.BatchDeleteResult; import com.sftp.manager.dto.BatchDeleteResult;
import com.sftp.manager.dto.DirectoryRequest;
import com.sftp.manager.dto.FileListRequest; import com.sftp.manager.dto.FileListRequest;
import com.sftp.manager.dto.FileOperationRequest; import com.sftp.manager.dto.FileOperationRequest;
import com.sftp.manager.dto.RenameRequest;
import com.sftp.manager.dto.TransferRequest; import com.sftp.manager.dto.TransferRequest;
import com.sftp.manager.model.FileInfo; import com.sftp.manager.model.FileInfo;
import com.sftp.manager.service.LocalFileService; import com.sftp.manager.service.LocalFileService;
import com.sftp.manager.service.SessionManager; import com.sftp.manager.service.SessionManager;
import com.sftp.manager.service.SftpService; import com.sftp.manager.service.SftpService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@@ -21,6 +26,7 @@ import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -28,9 +34,10 @@ import java.util.UUID;
@RestController @RestController
@RequestMapping("/api/files") @RequestMapping("/api/files")
@CrossOrigin(origins = "*")
public class FileController { public class FileController {
private static final Logger log = LoggerFactory.getLogger(FileController.class);
@Autowired @Autowired
private LocalFileService localFileService; private LocalFileService localFileService;
@@ -41,99 +48,126 @@ public class FileController {
private SessionManager sessionManager; private SessionManager sessionManager;
@PostMapping("/list") @PostMapping("/list")
public ApiResponse<List<FileInfo>> listFiles(@RequestBody FileListRequest request) { public ApiResponse<List<FileInfo>> listFiles(@RequestBody FileListRequest request) throws Exception {
try { if (request == null || request.getSessionId() == null || request.getSessionId().trim().isEmpty()) {
String sessionId = request.getSessionId(); throw new IllegalArgumentException("会话ID不能为空");
String path = request.getPath();
boolean showHidden = Boolean.TRUE.equals(request.getShowHidden());
List<FileInfo> files;
if ("local".equals(sessionId)) {
files = localFileService.listFiles(path, showHidden);
} else {
files = sftpService.listFiles(sessionId, path, showHidden);
}
return ApiResponse.success("查询成功", files);
} catch (Exception e) {
// SftpService 已抛出带「列出文件失败:」前缀的消息,此处不再重复拼接
String msg = e.getMessage();
return ApiResponse.error(msg != null ? msg : "列出文件失败");
} }
String sessionId = request.getSessionId().trim();
String path = request.getPath();
boolean showHidden = Boolean.TRUE.equals(request.getShowHidden());
List<FileInfo> files;
if ("local".equals(sessionId)) {
files = localFileService.listFiles(path, showHidden);
} else {
files = sftpService.listFiles(sessionId, path, showHidden);
}
log.debug("查询文件列表成功: sessionId={}, path={}, count={}", sessionId, path, files != null ? files.size() : 0);
return ApiResponse.success("查询成功", files);
} }
@PostMapping("/info") @PostMapping("/info")
public ApiResponse<FileInfo> getFileInfo(@RequestBody FileOperationRequest request) { public ApiResponse<FileInfo> getFileInfo(@RequestBody FileOperationRequest request) throws Exception {
try { if (request == null || request.getSessionId() == null || request.getSessionId().trim().isEmpty()) {
String sessionId = request.getSessionId(); throw new IllegalArgumentException("会话ID不能为空");
String path = request.getPath();
FileInfo fileInfo;
if ("local".equals(sessionId)) {
fileInfo = localFileService.getFileInfo(path);
} else {
fileInfo = sftpService.getFileInfo(sessionId, path);
}
return ApiResponse.success("查询成功", fileInfo);
} catch (Exception e) {
return ApiResponse.error("获取文件信息失败: " + e.getMessage());
} }
String sessionId = request.getSessionId().trim();
String path = request.getPath();
FileInfo fileInfo;
if ("local".equals(sessionId)) {
fileInfo = localFileService.getFileInfo(path);
} else {
fileInfo = sftpService.getFileInfo(sessionId, path);
}
return ApiResponse.success("查询成功", fileInfo);
} }
@GetMapping("/path") @GetMapping("/path")
public ApiResponse<Map<String, String>> getCurrentPath(@RequestParam String sessionId) { public ApiResponse<Map<String, String>> getCurrentPath(@RequestParam String sessionId) throws Exception {
try { if (sessionId == null || sessionId.trim().isEmpty()) {
Map<String, String> result = new HashMap<>(); throw new IllegalArgumentException("会话ID不能为空");
if ("local".equals(sessionId)) {
result.put("path", System.getProperty("user.home"));
} else {
String path = sftpService.pwd(sessionId);
result.put("path", path);
}
return ApiResponse.success("查询成功", result);
} catch (Exception e) {
return ApiResponse.error("获取路径失败: " + e.getMessage());
} }
String effectiveSessionId = sessionId.trim();
Map<String, String> result = new HashMap<>();
if ("local".equals(effectiveSessionId)) {
result.put("path", System.getProperty("user.home"));
} else {
String path = sftpService.pwd(effectiveSessionId);
result.put("path", path);
}
return ApiResponse.success("查询成功", result);
} }
// 上传文件 // 上传文件
@PostMapping("/upload") @PostMapping("/upload")
public ApiResponse<Void> uploadFile(@RequestParam("file") MultipartFile file, public ApiResponse<Void> uploadFile(@RequestParam("file") MultipartFile file,
@RequestParam("targetSessionId") String targetSessionId, @RequestParam("targetSessionId") String targetSessionId,
@RequestParam("targetPath") String targetPath) { @RequestParam("targetPath") String targetPath) throws Exception {
try { if (file == null || file.isEmpty()) {
if ("local".equals(targetSessionId)) { throw new IllegalArgumentException("上传文件不能为空");
// 上传到本地
File destFile = new File(targetPath, file.getOriginalFilename());
file.transferTo(destFile);
} else {
// 上传到SFTP
try (InputStream inputStream = file.getInputStream()) {
String remotePath = targetPath.endsWith("/") ?
targetPath + file.getOriginalFilename() :
targetPath + "/" + file.getOriginalFilename();
sftpService.uploadFile(targetSessionId, inputStream, remotePath, file.getSize());
}
}
return ApiResponse.success("上传成功", null);
} catch (Exception e) {
return ApiResponse.error("上传失败: " + e.getMessage());
} }
if (targetSessionId == null || targetSessionId.trim().isEmpty()) {
throw new IllegalArgumentException("目标会话ID不能为空");
}
if (targetPath == null || targetPath.trim().isEmpty()) {
throw new IllegalArgumentException("目标路径不能为空");
}
String effectiveTargetSessionId = targetSessionId.trim();
String effectiveTargetPath = targetPath.trim();
if ("local".equals(effectiveTargetSessionId)) {
// 上传到本地
File destFile = new File(effectiveTargetPath, file.getOriginalFilename());
file.transferTo(destFile);
log.info("上传到本地成功: targetPath={}, filename={}, size={}", effectiveTargetPath, file.getOriginalFilename(), file.getSize());
} else {
// 上传到SFTP
try (InputStream inputStream = file.getInputStream()) {
String remotePath = effectiveTargetPath.endsWith("/") ?
effectiveTargetPath + file.getOriginalFilename() :
effectiveTargetPath + "/" + file.getOriginalFilename();
sftpService.uploadFile(effectiveTargetSessionId, inputStream, remotePath, file.getSize());
log.info("上传到SFTP成功: targetSessionId={}, remotePath={}, size={}", effectiveTargetSessionId, remotePath, file.getSize());
}
}
return ApiResponse.success("上传成功", null);
} }
// 下载文件 // 下载文件
@GetMapping("/download") @GetMapping("/download")
public void downloadFile(@RequestParam String sessionId, public void downloadFile(@RequestParam String sessionId,
@RequestParam String path, @RequestParam String path,
HttpServletResponse response) { HttpServletResponse response) {
if (sessionId == null || sessionId.trim().isEmpty()) {
writeErrorResponse(response, HttpServletResponse.SC_BAD_REQUEST, "会话ID不能为空");
return;
}
if (path == null || path.trim().isEmpty()) {
writeErrorResponse(response, HttpServletResponse.SC_BAD_REQUEST, "文件路径不能为空");
return;
}
String effectiveSessionId = sessionId.trim();
String effectivePath = path.trim();
try { try {
if ("local".equals(sessionId)) { if ("local".equals(effectiveSessionId)) {
// 下载本地文件 // 下载本地文件
File file = new File(path); File file = new File(effectivePath);
if (!file.exists() || !file.isFile()) {
throw new IllegalArgumentException("文件不存在: " + effectivePath);
}
response.setContentType("application/octet-stream"); response.setContentType("application/octet-stream");
response.setHeader("Content-Disposition", response.setHeader("Content-Disposition",
"attachment; filename=" + URLEncoder.encode(file.getName(), "UTF-8")); "attachment; filename=" + encodeFileName(file.getName()));
response.setContentLengthLong(file.length()); response.setContentLengthLong(file.length());
try (InputStream inputStream = new FileInputStream(file); try (InputStream inputStream = new FileInputStream(file);
@@ -145,42 +179,79 @@ public class FileController {
} }
outputStream.flush(); outputStream.flush();
} }
log.info("本地文件下载成功: path={}", effectivePath);
} else { } else {
// 下载SFTP文件 // 下载SFTP文件
ChannelSftp channel = sessionManager.getSession(sessionId); ChannelSftp channel = sessionManager.getSession(effectiveSessionId);
if (channel == null) { if (channel == null) {
throw new Exception("会话不存在或已断开"); throw new IllegalArgumentException("会话不存在或已断开");
} }
FileInfo fileInfo = sftpService.getFileInfo(sessionId, path); FileInfo fileInfo = sftpService.getFileInfo(effectiveSessionId, effectivePath);
response.setContentType("application/octet-stream"); response.setContentType("application/octet-stream");
response.setHeader("Content-Disposition", response.setHeader("Content-Disposition",
"attachment; filename=" + URLEncoder.encode(fileInfo.getName(), "UTF-8")); "attachment; filename=" + encodeFileName(fileInfo.getName()));
response.setContentLengthLong(fileInfo.getSize()); response.setContentLengthLong(fileInfo.getSize());
try (OutputStream outputStream = response.getOutputStream()) { try (OutputStream outputStream = response.getOutputStream()) {
sftpService.downloadFile(sessionId, path, outputStream); sftpService.downloadFile(effectiveSessionId, effectivePath, outputStream);
outputStream.flush(); outputStream.flush();
} }
log.info("SFTP文件下载成功: sessionId={}, path={}", effectiveSessionId, effectivePath);
} }
} catch (IllegalArgumentException e) {
log.warn("下载请求参数错误: sessionId={}, path={}, error={}", effectiveSessionId, effectivePath, e.getMessage());
writeErrorResponse(response, HttpServletResponse.SC_BAD_REQUEST, e.getMessage());
} catch (Exception e) { } catch (Exception e) {
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); String msg = e.getMessage() != null ? e.getMessage() : "未知错误";
try { log.warn("下载失败: sessionId={}, path={}, error={}", effectiveSessionId, effectivePath, msg);
response.getWriter().write("下载失败: " + e.getMessage()); writeErrorResponse(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "下载失败: " + msg);
} catch (Exception ex) { }
// 忽略 }
}
private String encodeFileName(String fileName) throws Exception {
return URLEncoder.encode(fileName, "UTF-8").replace("+", "%20");
}
private void writeErrorResponse(HttpServletResponse response, int status, String message) {
if (response == null || response.isCommitted()) {
return;
}
response.setStatus(status);
response.setCharacterEncoding("UTF-8");
response.setContentType("text/plain;charset=UTF-8");
try {
response.getWriter().write(message);
} catch (Exception ignored) {
// 忽略
} }
} }
// 服务器间传输 // 服务器间传输
@PostMapping("/transfer") @PostMapping("/transfer")
public ApiResponse<Void> transferFiles(@RequestBody TransferRequest request) { public ApiResponse<Void> transferFiles(@RequestBody TransferRequest request) throws Exception {
String sourceSessionId = request.getSourceSessionId(); String sourceSessionId = request.getSourceSessionId();
String targetSessionId = request.getTargetSessionId(); String targetSessionId = request.getTargetSessionId();
String targetPath = request.getTargetPath(); String targetPath = request.getTargetPath();
if (sourceSessionId == null || sourceSessionId.trim().isEmpty()) {
throw new IllegalArgumentException("源会话ID不能为空");
}
if (targetSessionId == null || targetSessionId.trim().isEmpty()) {
throw new IllegalArgumentException("目标会话ID不能为空");
}
if (targetPath == null || targetPath.trim().isEmpty()) {
throw new IllegalArgumentException("目标路径不能为空");
}
sourceSessionId = sourceSessionId.trim();
targetSessionId = targetSessionId.trim();
targetPath = targetPath.trim();
log.info("开始文件传输: sourceSessionId={}, targetSessionId={}, targetPath={}", sourceSessionId, targetSessionId, targetPath);
String prefix; String prefix;
if ("local".equals(sourceSessionId) && !"local".equals(targetSessionId)) { if ("local".equals(sourceSessionId) && !"local".equals(targetSessionId)) {
prefix = "上传到服务器失败"; prefix = "上传到服务器失败";
@@ -192,127 +263,123 @@ public class FileController {
prefix = "传输失败"; prefix = "传输失败";
} }
try { List<String> sourcePaths = request.getSourcePaths();
if (sourcePaths == null || sourcePaths.isEmpty()) {
// 兼容旧字段:仅当 sourcePaths 为空时才使用单个 sourcePath
if (request.getSourcePath() == null || request.getSourcePath().isEmpty()) {
throw new IllegalArgumentException("源路径不能为空");
}
sourcePaths = java.util.Collections.singletonList(request.getSourcePath());
}
List<String> sourcePaths = request.getSourcePaths(); boolean recursive = Boolean.TRUE.equals(request.getRecursive());
if (sourcePaths == null || sourcePaths.isEmpty()) {
// 兼容旧字段:仅当 sourcePaths 为空时才使用单个 sourcePath int successCount = 0;
if (request.getSourcePath() == null || request.getSourcePath().isEmpty()) { int failCount = 0;
return ApiResponse.error("源路径不能为空"); StringBuilder failMessages = new StringBuilder();
}
sourcePaths = java.util.Collections.singletonList(request.getSourcePath()); for (String sourcePath : sourcePaths) {
if (sourcePath == null || sourcePath.isEmpty()) {
failCount++;
failMessages.append("源路径为空; ");
continue;
} }
boolean recursive = Boolean.TRUE.equals(request.getRecursive()); boolean isDirectory;
String fileName;
if (targetPath == null || targetPath.isEmpty()) { if ("local".equals(sourceSessionId)) {
return ApiResponse.error("目标路径不能为空"); File file = new File(sourcePath);
} if (!file.exists()) {
int successCount = 0;
int failCount = 0;
StringBuilder failMessages = new StringBuilder();
for (String sourcePath : sourcePaths) {
if (sourcePath == null || sourcePath.isEmpty()) {
failCount++; failCount++;
failMessages.append("源路径为空; "); failMessages.append(sourcePath).append(" 不存在; ");
continue; continue;
} }
isDirectory = file.isDirectory();
fileName = file.getName();
} else {
FileInfo fileInfo = sftpService.getFileInfo(sourceSessionId, sourcePath);
isDirectory = fileInfo.isDirectory();
fileName = fileInfo.getName();
}
boolean isDirectory; // 构建目标路径(目标目录下的文件/目录名称与源名称保持一致)
String fileName; String finalTargetPath = targetPath.endsWith("/") ?
if ("local".equals(sourceSessionId)) { targetPath + fileName :
File file = new File(sourcePath); targetPath + "/" + fileName;
if (!file.exists()) {
failCount++; try {
failMessages.append(sourcePath).append(" 不存在; "); if ("local".equals(sourceSessionId) && "local".equals(targetSessionId)) {
continue; // 本地到本地
if (isDirectory) {
if (!recursive) {
throw new Exception("目录传输需要开启递归recursive=true");
}
copyLocalDirectory(sourcePath, finalTargetPath);
} else {
Files.copy(new File(sourcePath).toPath(), new File(finalTargetPath).toPath(), StandardCopyOption.REPLACE_EXISTING);
}
} else if ("local".equals(sourceSessionId)) {
// 本地到SFTP上传到服务器
if (isDirectory) {
if (!recursive) {
throw new Exception("目录传输需要开启递归recursive=true");
}
localFileService.uploadDirectoryToSftp(sourcePath, targetSessionId, finalTargetPath, sftpService);
} else {
localFileService.uploadToSftp(sourcePath, targetSessionId, finalTargetPath, sftpService);
}
} else if ("local".equals(targetSessionId)) {
// SFTP到本地从服务器下载
if (isDirectory) {
if (!recursive) {
throw new Exception("目录传输需要开启递归recursive=true");
}
localFileService.downloadDirectoryFromSftp(sourceSessionId, sourcePath, finalTargetPath, sftpService);
} else {
localFileService.downloadFromSftp(sourceSessionId, sourcePath, finalTargetPath, sftpService);
} }
isDirectory = file.isDirectory();
fileName = file.getName();
} else { } else {
FileInfo fileInfo = sftpService.getFileInfo(sourceSessionId, sourcePath); // SFTP到SFTP
isDirectory = fileInfo.isDirectory(); if (isDirectory) {
fileName = fileInfo.getName(); if (!recursive) {
} throw new Exception("目录传输需要开启递归recursive=true");
// 构建目标路径(目标目录下的文件/目录名称与源名称保持一致)
String finalTargetPath = targetPath.endsWith("/") ?
targetPath + fileName :
targetPath + "/" + fileName;
try {
if ("local".equals(sourceSessionId) && "local".equals(targetSessionId)) {
// 本地到本地
if (isDirectory) {
if (!recursive) {
throw new Exception("目录传输需要开启递归recursive=true");
}
copyLocalDirectory(sourcePath, finalTargetPath);
} else {
Files.copy(new File(sourcePath).toPath(), new File(finalTargetPath).toPath());
} }
} else if ("local".equals(sourceSessionId)) { // 通过本地临时目录实现 SFTP 之间的目录传输
// 本地到SFTP上传到服务器 String tempRoot = System.getProperty("java.io.tmpdir") +
if (isDirectory) { File.separator + "sftp-manager-" + UUID.randomUUID();
if (!recursive) { File tempDir = new File(tempRoot);
throw new Exception("目录传输需要开启递归recursive=true"); try {
} localFileService.downloadDirectoryFromSftp(sourceSessionId, sourcePath, tempRoot, sftpService);
localFileService.uploadDirectoryToSftp(sourcePath, targetSessionId, finalTargetPath, sftpService); localFileService.uploadDirectoryToSftp(tempRoot, targetSessionId, finalTargetPath, sftpService);
} else { } finally {
localFileService.uploadToSftp(sourcePath, targetSessionId, finalTargetPath, sftpService); deleteLocalDirectory(tempDir);
}
} else if ("local".equals(targetSessionId)) {
// SFTP到本地从服务器下载
if (isDirectory) {
if (!recursive) {
throw new Exception("目录传输需要开启递归recursive=true");
}
localFileService.downloadDirectoryFromSftp(sourceSessionId, sourcePath, finalTargetPath, sftpService);
} else {
localFileService.downloadFromSftp(sourceSessionId, sourcePath, finalTargetPath, sftpService);
} }
} else { } else {
// SFTP到SFTP sftpService.transferBetweenSftp(sourceSessionId, sourcePath,
if (isDirectory) { targetSessionId, finalTargetPath);
if (!recursive) {
throw new Exception("目录传输需要开启递归recursive=true");
}
// 通过本地临时目录实现 SFTP 之间的目录传输
String tempRoot = System.getProperty("java.io.tmpdir") +
File.separator + "sftp-manager-" + UUID.randomUUID();
File tempDir = new File(tempRoot);
try {
localFileService.downloadDirectoryFromSftp(sourceSessionId, sourcePath, tempRoot, sftpService);
localFileService.uploadDirectoryToSftp(tempRoot, targetSessionId, finalTargetPath, sftpService);
} finally {
deleteLocalDirectory(tempDir);
}
} else {
sftpService.transferBetweenSftp(sourceSessionId, sourcePath,
targetSessionId, finalTargetPath);
}
} }
successCount++;
} catch (Exception e) {
failCount++;
String msg = e.getMessage() != null ? e.getMessage() : "未知错误";
failMessages.append(sourcePath).append(" - ").append(msg).append("; ");
} }
successCount++;
} catch (Exception e) {
failCount++;
String msg = e.getMessage() != null ? e.getMessage() : "未知错误";
failMessages.append(sourcePath).append(" - ").append(msg).append("; ");
} }
}
if (failCount == 0) { if (failCount == 0) {
return ApiResponse.success("传输成功", null); log.info("文件传输完成: 全部成功, sourceSessionId={}, targetSessionId={}, total={}",
} else if (successCount == 0) { sourceSessionId, targetSessionId, successCount);
String msg = failMessages.length() > 0 ? failMessages.toString() : "全部传输失败"; return ApiResponse.success("传输成功", null);
return ApiResponse.error(prefix + ": " + msg); } else if (successCount == 0) {
} else { String msg = failMessages.length() > 0 ? failMessages.toString() : "全部传输失败";
String msg = "部分文件传输失败,成功 " + successCount + " 个,失败 " + failCount + " 个。详情: " + failMessages; log.warn("文件传输完成: 全部失败, sourceSessionId={}, targetSessionId={}, failCount={}, detail={}",
return ApiResponse.error(prefix + ": " + msg); sourceSessionId, targetSessionId, failCount, msg);
} return ApiResponse.error(prefix + ": " + msg);
} catch (Exception e) { } else {
String msg = e.getMessage() != null ? e.getMessage() : "未知错误"; String msg = "部分文件传输失败,成功 " + successCount + " 个,失败 " + failCount + " 个。详情: " + failMessages;
log.warn("文件传输完成: 部分失败, sourceSessionId={}, targetSessionId={}, successCount={}, failCount={}, detail={}",
sourceSessionId, targetSessionId, successCount, failCount, failMessages.toString());
return ApiResponse.error(prefix + ": " + msg); return ApiResponse.error(prefix + ": " + msg);
} }
} }
@@ -341,7 +408,7 @@ public class FileController {
if (file.isDirectory()) { if (file.isDirectory()) {
copyLocalDirectory(file.getAbsolutePath(), targetChild.getAbsolutePath()); copyLocalDirectory(file.getAbsolutePath(), targetChild.getAbsolutePath());
} else { } else {
Files.copy(file.toPath(), targetChild.toPath()); Files.copy(file.toPath(), targetChild.toPath(), StandardCopyOption.REPLACE_EXISTING);
} }
} }
} }
@@ -375,107 +442,108 @@ public class FileController {
// 删除单个文件 // 删除单个文件
@DeleteMapping("/delete") @DeleteMapping("/delete")
public ApiResponse<Void> deleteFile(@RequestParam String sessionId, public ApiResponse<Void> deleteFile(@RequestParam String sessionId,
@RequestParam String path) { @RequestParam String path) throws Exception {
try { if (sessionId == null || sessionId.trim().isEmpty()) {
if ("local".equals(sessionId)) { throw new IllegalArgumentException("会话ID不能为空");
localFileService.deleteFile(path);
} else {
sftpService.deleteFile(sessionId, path);
}
return ApiResponse.success("删除成功", null);
} catch (Exception e) {
return ApiResponse.error("删除失败: " + e.getMessage());
} }
if (path == null || path.trim().isEmpty()) {
throw new IllegalArgumentException("路径不能为空");
}
String effectiveSessionId = sessionId.trim();
String effectivePath = path.trim();
if ("local".equals(effectiveSessionId)) {
localFileService.deleteFile(effectivePath);
} else {
sftpService.deleteFile(effectiveSessionId, effectivePath);
}
log.info("删除成功: sessionId={}, path={}", effectiveSessionId, effectivePath);
return ApiResponse.success("删除成功", null);
} }
// 创建目录(单级) // 创建目录(单级)
@PostMapping("/mkdir") @PostMapping("/mkdir")
public ApiResponse<Void> createDirectory(@RequestBody Map<String, String> request) { public ApiResponse<Void> createDirectory(@RequestBody DirectoryRequest request) throws Exception {
try { if (request == null || request.getSessionId() == null || request.getSessionId().trim().isEmpty()) {
String sessionId = request.get("sessionId"); throw new IllegalArgumentException("会话ID不能为空");
String path = request.get("path");
if (path == null || path.isEmpty()) {
return ApiResponse.error("路径不能为空");
}
boolean result;
if ("local".equals(sessionId)) {
result = localFileService.createDirectory(path);
} else {
result = sftpService.createDirectory(sessionId, path);
}
if (result) {
return ApiResponse.success("创建成功", null);
} else {
return ApiResponse.error("创建失败");
}
} catch (Exception e) {
return ApiResponse.error("创建失败: " + e.getMessage());
} }
if (request.getPath() == null || request.getPath().trim().isEmpty()) {
throw new IllegalArgumentException("路径不能为空");
}
String sessionId = request.getSessionId().trim();
String path = request.getPath().trim();
if ("local".equals(sessionId)) {
localFileService.createDirectory(path);
} else {
sftpService.createDirectory(sessionId, path);
}
log.info("创建目录成功: sessionId={}, path={}", sessionId, path);
return ApiResponse.success("创建成功", null);
} }
// 创建多级目录 // 创建多级目录
@PostMapping("/mkdir-p") @PostMapping("/mkdir-p")
public ApiResponse<Void> createDirectories(@RequestBody Map<String, String> request) { public ApiResponse<Void> createDirectories(@RequestBody DirectoryRequest request) throws Exception {
try { if (request == null || request.getSessionId() == null || request.getSessionId().trim().isEmpty()) {
String sessionId = request.get("sessionId"); throw new IllegalArgumentException("会话ID不能为空");
String path = request.get("path");
if (path == null || path.isEmpty()) {
return ApiResponse.error("路径不能为空");
}
boolean result;
if ("local".equals(sessionId)) {
result = localFileService.createDirectories(path);
} else {
result = sftpService.createDirectories(sessionId, path);
}
if (result) {
return ApiResponse.success("创建成功", null);
} else {
return ApiResponse.error("创建失败");
}
} catch (Exception e) {
return ApiResponse.error("创建失败: " + e.getMessage());
} }
if (request.getPath() == null || request.getPath().trim().isEmpty()) {
throw new IllegalArgumentException("路径不能为空");
}
String sessionId = request.getSessionId().trim();
String path = request.getPath().trim();
if ("local".equals(sessionId)) {
localFileService.createDirectories(path);
} else {
sftpService.createDirectories(sessionId, path);
}
log.info("创建多级目录成功: sessionId={}, path={}", sessionId, path);
return ApiResponse.success("创建成功", null);
} }
// 重命名文件 // 重命名文件
@PostMapping("/rename") @PostMapping("/rename")
public ApiResponse<Void> renameFile(@RequestBody Map<String, String> request) { public ApiResponse<Void> renameFile(@RequestBody RenameRequest request) throws Exception {
try { if (request == null || request.getSessionId() == null || request.getSessionId().trim().isEmpty()) {
String sessionId = request.get("sessionId"); throw new IllegalArgumentException("会话ID不能为空");
String oldPath = request.get("oldPath");
String newName = request.get("newName");
if (newName == null || newName.trim().isEmpty()) {
return ApiResponse.error("新文件名不能为空");
}
newName = newName.trim();
String newPath;
if ("local".equals(sessionId)) {
File oldFile = new File(oldPath);
File parentDir = oldFile.getParentFile();
if (parentDir == null) {
return ApiResponse.error("无法获取父目录");
}
newPath = parentDir.getPath() + File.separator + newName;
localFileService.renameFile(oldPath, newPath);
} else {
String parentPath = getParentPathFromString(oldPath);
newPath = parentPath.endsWith("/") ? parentPath + newName : parentPath + "/" + newName;
sftpService.renameFile(sessionId, oldPath, newPath);
}
return ApiResponse.success("重命名成功", null);
} catch (Exception e) {
return ApiResponse.error("重命名失败: " + e.getMessage());
} }
if (request.getOldPath() == null || request.getOldPath().trim().isEmpty()) {
throw new IllegalArgumentException("原路径不能为空");
}
if (request.getNewName() == null || request.getNewName().trim().isEmpty()) {
throw new IllegalArgumentException("新文件名不能为空");
}
String sessionId = request.getSessionId().trim();
String oldPath = request.getOldPath().trim();
String newName = request.getNewName().trim();
String newPath;
if ("local".equals(sessionId)) {
File oldFile = new File(oldPath);
File parentDir = oldFile.getParentFile();
if (parentDir == null) {
throw new IllegalArgumentException("无法获取父目录");
}
newPath = parentDir.getPath() + File.separator + newName;
localFileService.renameFile(oldPath, newPath);
} else {
String parentPath = getParentPathFromString(oldPath);
newPath = parentPath.endsWith("/") ? parentPath + newName : parentPath + "/" + newName;
sftpService.renameFile(sessionId, oldPath, newPath);
}
log.info("重命名成功: sessionId={}, oldPath={}, newPath={}", sessionId, oldPath, newPath);
return ApiResponse.success("重命名成功", null);
} }
// 从路径字符串获取父路径(用于 SFTP 路径) // 从路径字符串获取父路径(用于 SFTP 路径)
@@ -493,25 +561,27 @@ public class FileController {
// 批量删除 // 批量删除
@PostMapping("/batch-delete") @PostMapping("/batch-delete")
public ApiResponse<BatchDeleteResult> batchDelete(@RequestBody Map<String, Object> request) { public ApiResponse<BatchDeleteResult> batchDelete(@RequestBody BatchDeleteRequest request) {
try { if (request == null || request.getSessionId() == null || request.getSessionId().trim().isEmpty()) {
String sessionId = (String) request.get("sessionId"); throw new IllegalArgumentException("会话ID不能为空");
@SuppressWarnings("unchecked")
List<String> paths = (List<String>) request.get("paths");
if (paths == null) {
paths = new java.util.ArrayList<>();
}
BatchDeleteResult result;
if ("local".equals(sessionId)) {
result = localFileService.batchDelete(paths);
} else {
result = sftpService.batchDelete(sessionId, paths);
}
return ApiResponse.success("删除完成", result);
} catch (Exception e) {
return ApiResponse.error("批量删除失败: " + e.getMessage());
} }
String sessionId = request.getSessionId().trim();
List<String> paths = request.getPaths();
if (paths == null) {
paths = new java.util.ArrayList<>();
}
BatchDeleteResult result;
if ("local".equals(sessionId)) {
result = localFileService.batchDelete(paths);
} else {
result = sftpService.batchDelete(sessionId, paths);
}
log.info("批量删除完成: sessionId={}, total={}, successCount={}, failCount={}",
sessionId, paths.size(), result.getSuccessCount(), result.getFailCount());
return ApiResponse.success("删除完成", result);
} }
} }

View File

@@ -0,0 +1,11 @@
package com.sftp.manager.dto;
import lombok.Data;
import java.util.List;
@Data
public class BatchDeleteRequest {
private String sessionId;
private List<String> paths;
}

View File

@@ -0,0 +1,9 @@
package com.sftp.manager.dto;
import lombok.Data;
@Data
public class DirectoryRequest {
private String sessionId;
private String path;
}

View File

@@ -0,0 +1,8 @@
package com.sftp.manager.dto;
import lombok.Data;
@Data
public class DisconnectRequest {
private String sessionId;
}

View File

@@ -0,0 +1,10 @@
package com.sftp.manager.dto;
import lombok.Data;
@Data
public class RenameRequest {
private String sessionId;
private String oldPath;
private String newName;
}

View File

@@ -0,0 +1,41 @@
package com.sftp.manager.exception;
import com.sftp.manager.dto.ApiResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<Void>> handleIllegalArgumentException(IllegalArgumentException e) {
String message = e.getMessage() == null || e.getMessage().isEmpty() ? "参数错误" : e.getMessage();
log.warn("请求参数错误: {}", message);
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(message));
}
@ExceptionHandler(org.springframework.web.multipart.MaxUploadSizeExceededException.class)
public ResponseEntity<ApiResponse<Void>> handleMaxUploadSizeExceededException() {
log.warn("上传文件超过大小限制");
return ResponseEntity
.status(HttpStatus.PAYLOAD_TOO_LARGE)
.body(ApiResponse.error("上传文件超过大小限制"));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleException(Exception e) {
String message = e.getMessage() == null || e.getMessage().isEmpty() ? "系统错误" : e.getMessage();
log.error("系统异常: {}", message, e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("系统异常: " + message));
}
}

View File

@@ -1,5 +1,6 @@
package com.sftp.manager.model; package com.sftp.manager.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data; import lombok.Data;
import javax.persistence.*; import javax.persistence.*;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -24,10 +25,12 @@ public class Connection {
private String username; // 用户名 private String username; // 用户名
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
@JsonIgnore
private String password; // 密码(加密存储) private String password; // 密码(加密存储)
private String privateKeyPath; // 私钥路径(可选) private String privateKeyPath; // 私钥路径(可选)
@JsonIgnore
private String passPhrase; // 私钥密码(可选) private String passPhrase; // 私钥密码(可选)
private Integer connectTimeout; // 连接超时时间 private Integer connectTimeout; // 连接超时时间

View File

@@ -6,16 +6,21 @@ import com.jcraft.jsch.Session;
import com.sftp.manager.dto.ConnectionRequest; import com.sftp.manager.dto.ConnectionRequest;
import com.sftp.manager.model.Connection; import com.sftp.manager.model.Connection;
import com.sftp.manager.repository.ConnectionRepository; import com.sftp.manager.repository.ConnectionRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.File;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@Service @Service
public class ConnectionService { public class ConnectionService {
private static final Logger log = LoggerFactory.getLogger(ConnectionService.class);
@Autowired @Autowired
private ConnectionRepository connectionRepository; private ConnectionRepository connectionRepository;
@@ -28,7 +33,20 @@ public class ConnectionService {
@Value("${app.sftp.max-retries:3}") @Value("${app.sftp.max-retries:3}")
private int maxRetries; private int maxRetries;
@Value("${app.sftp.strict-host-key-checking:false}")
private boolean strictHostKeyChecking;
@Value("${app.sftp.known-hosts-path:}")
private String knownHostsPath;
public String connect(ConnectionRequest request) throws Exception { public String connect(ConnectionRequest request) throws Exception {
ConnectionRequest effectiveRequest = buildEffectiveRequest(request);
String host = effectiveRequest.getHost();
Integer port = effectiveRequest.getPort() != null ? effectiveRequest.getPort() : 22;
String username = effectiveRequest.getUsername();
log.info("开始建立SFTP连接: host={}, port={}, username={}", host, port, username);
JSch jsch = new JSch(); JSch jsch = new JSch();
Session session = null; Session session = null;
Channel channel = null; Channel channel = null;
@@ -39,25 +57,33 @@ public class ConnectionService {
while (retryCount < maxRetries) { while (retryCount < maxRetries) {
try { try {
log.debug("连接尝试: host={}, port={}, username={}, attempt={}/{}",
host, port, username, retryCount + 1, maxRetries);
// 配置私钥(如果提供) // 配置私钥(如果提供)
if (request.getPrivateKeyPath() != null && !request.getPrivateKeyPath().isEmpty()) { if (effectiveRequest.getPrivateKeyPath() != null && !effectiveRequest.getPrivateKeyPath().isEmpty()) {
jsch.addIdentity(request.getPrivateKeyPath(), jsch.addIdentity(effectiveRequest.getPrivateKeyPath(),
request.getPassPhrase() != null ? request.getPassPhrase() : ""); effectiveRequest.getPassPhrase() != null ? effectiveRequest.getPassPhrase() : "");
} }
// 创建会话 // 创建会话
session = jsch.getSession(request.getUsername(), session = jsch.getSession(effectiveRequest.getUsername(),
request.getHost(), effectiveRequest.getHost(),
request.getPort() != null ? request.getPort() : 22); effectiveRequest.getPort() != null ? effectiveRequest.getPort() : 22);
// 配置密码(如果使用密码认证) // 配置密码(如果使用密码认证)
if (request.getPassword() != null && !request.getPassword().isEmpty()) { if (effectiveRequest.getPassword() != null && !effectiveRequest.getPassword().isEmpty()) {
session.setPassword(request.getPassword()); session.setPassword(effectiveRequest.getPassword());
} }
// 跳过主机密钥验证 // 主机密钥验证
java.util.Properties config = new java.util.Properties(); java.util.Properties config = new java.util.Properties();
config.put("StrictHostKeyChecking", "no"); if (strictHostKeyChecking) {
configureKnownHosts(jsch);
config.put("StrictHostKeyChecking", "yes");
} else {
config.put("StrictHostKeyChecking", "no");
}
session.setConfig(config); session.setConfig(config);
// 设置超时 // 设置超时
@@ -70,9 +96,9 @@ public class ConnectionService {
sftpChannel = (com.jcraft.jsch.ChannelSftp) channel; sftpChannel = (com.jcraft.jsch.ChannelSftp) channel;
// 如果指定了默认路径,切换到该路径 // 如果指定了默认路径,切换到该路径
if (request.getRootPath() != null && !request.getRootPath().isEmpty()) { if (effectiveRequest.getRootPath() != null && !effectiveRequest.getRootPath().isEmpty()) {
try { try {
sftpChannel.cd(request.getRootPath()); sftpChannel.cd(effectiveRequest.getRootPath());
} catch (Exception e) { } catch (Exception e) {
// 路径不存在,使用默认路径 // 路径不存在,使用默认路径
} }
@@ -80,22 +106,28 @@ public class ConnectionService {
// 创建连接对象(用于保存配置) // 创建连接对象(用于保存配置)
Connection connection = new Connection(); Connection connection = new Connection();
connection.setName(request.getName()); connection.setId(effectiveRequest.getId());
connection.setHost(request.getHost()); connection.setName(effectiveRequest.getName());
connection.setPort(request.getPort() != null ? request.getPort() : 22); connection.setHost(effectiveRequest.getHost());
connection.setUsername(request.getUsername()); connection.setPort(effectiveRequest.getPort() != null ? effectiveRequest.getPort() : 22);
connection.setPassword(request.getPassword()); connection.setUsername(effectiveRequest.getUsername());
connection.setPrivateKeyPath(request.getPrivateKeyPath()); connection.setPassword(effectiveRequest.getPassword());
connection.setPassPhrase(request.getPassPhrase()); connection.setPrivateKeyPath(effectiveRequest.getPrivateKeyPath());
connection.setRootPath(request.getRootPath()); connection.setPassPhrase(effectiveRequest.getPassPhrase());
connection.setRootPath(effectiveRequest.getRootPath());
connection.setConnectTimeout(connectionTimeout); connection.setConnectTimeout(connectionTimeout);
// 添加到会话管理器 // 添加到会话管理器
return sessionManager.addSession(sftpChannel, connection); String sessionId = sessionManager.addSession(sftpChannel, connection);
log.info("SFTP连接成功: host={}, port={}, username={}, sessionId={}",
host, port, username, sessionId);
return sessionId;
} catch (Exception e) { } catch (Exception e) {
lastException = e; lastException = e;
retryCount++; retryCount++;
log.warn("SFTP连接失败: host={}, port={}, username={}, attempt={}/{}, error={}",
host, port, username, retryCount, maxRetries, e.getMessage());
// 清理资源 // 清理资源
if (channel != null && channel.isConnected()) { if (channel != null && channel.isConnected()) {
@@ -114,6 +146,8 @@ public class ConnectionService {
} }
if (retryCount >= maxRetries) { if (retryCount >= maxRetries) {
log.error("SFTP连接最终失败: host={}, port={}, username={}, error={}",
host, port, username, e.getMessage());
throw new Exception("连接失败: " + e.getMessage(), e); throw new Exception("连接失败: " + e.getMessage(), e);
} }
@@ -134,7 +168,99 @@ public class ConnectionService {
throw new Exception("连接失败", lastException); throw new Exception("连接失败", lastException);
} }
private ConnectionRequest buildEffectiveRequest(ConnectionRequest request) throws Exception {
if (request == null) {
throw new Exception("连接参数不能为空");
}
ConnectionRequest effective = new ConnectionRequest();
effective.setId(request.getId());
if (request.getId() != null) {
Connection saved = connectionRepository.findById(request.getId()).orElse(null);
if (saved == null) {
throw new Exception("连接不存在");
}
effective.setName(saved.getName());
effective.setHost(saved.getHost());
effective.setPort(saved.getPort());
effective.setUsername(saved.getUsername());
effective.setPassword(saved.getPassword());
effective.setPrivateKeyPath(saved.getPrivateKeyPath());
effective.setPassPhrase(saved.getPassPhrase());
effective.setRootPath(saved.getRootPath());
// 允许调用方覆盖部分字段
if (request.getHost() != null && !request.getHost().isEmpty()) {
effective.setHost(request.getHost());
}
if (request.getPort() != null) {
effective.setPort(request.getPort());
}
if (request.getUsername() != null && !request.getUsername().isEmpty()) {
effective.setUsername(request.getUsername());
}
if (request.getPassword() != null && !request.getPassword().isEmpty()) {
effective.setPassword(request.getPassword());
}
if (request.getPrivateKeyPath() != null && !request.getPrivateKeyPath().isEmpty()) {
effective.setPrivateKeyPath(request.getPrivateKeyPath());
}
if (request.getPassPhrase() != null && !request.getPassPhrase().isEmpty()) {
effective.setPassPhrase(request.getPassPhrase());
}
if (request.getRootPath() != null && !request.getRootPath().isEmpty()) {
effective.setRootPath(request.getRootPath());
}
} else {
effective.setName(request.getName());
effective.setHost(request.getHost());
effective.setPort(request.getPort());
effective.setUsername(request.getUsername());
effective.setPassword(request.getPassword());
effective.setPrivateKeyPath(request.getPrivateKeyPath());
effective.setPassPhrase(request.getPassPhrase());
effective.setRootPath(request.getRootPath());
}
if (effective.getHost() == null || effective.getHost().isEmpty()) {
throw new Exception("主机地址不能为空");
}
if (effective.getUsername() == null || effective.getUsername().isEmpty()) {
throw new Exception("用户名不能为空");
}
if ((effective.getPassword() == null || effective.getPassword().isEmpty())
&& (effective.getPrivateKeyPath() == null || effective.getPrivateKeyPath().isEmpty())) {
throw new Exception("密码和私钥不能同时为空");
}
if (effective.getPort() == null || effective.getPort() <= 0) {
effective.setPort(22);
}
return effective;
}
private void configureKnownHosts(JSch jsch) throws Exception {
String configuredPath = knownHostsPath == null ? "" : knownHostsPath.trim();
if (!configuredPath.isEmpty()) {
jsch.setKnownHosts(configuredPath);
return;
}
String defaultPath = System.getProperty("user.home") + File.separator + ".ssh" + File.separator + "known_hosts";
File knownHosts = new File(defaultPath);
if (!knownHosts.exists() || !knownHosts.isFile()) {
throw new Exception("主机密钥校验已启用,但未找到 known_hosts 文件,请配置 app.sftp.known-hosts-path");
}
jsch.setKnownHosts(defaultPath);
}
public void disconnect(String sessionId) { public void disconnect(String sessionId) {
log.info("断开SFTP会话: sessionId={}", sessionId);
sessionManager.removeSession(sessionId); sessionManager.removeSession(sessionId);
} }

View File

@@ -7,6 +7,7 @@ import org.springframework.stereotype.Service;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.nio.file.FileVisitOption; import java.nio.file.FileVisitOption;
@@ -16,8 +17,8 @@ import java.nio.file.attribute.BasicFileAttributes;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId; import java.time.ZoneId;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List; import java.util.List;
import java.util.Locale;
@Service @Service
public class LocalFileService { public class LocalFileService {
@@ -28,10 +29,10 @@ public class LocalFileService {
public List<FileInfo> listFiles(String path, boolean showHidden) throws Exception { public List<FileInfo> listFiles(String path, boolean showHidden) throws Exception {
List<FileInfo> files = new ArrayList<>(); List<FileInfo> files = new ArrayList<>();
File directory = new File(path); File directory = normalizeFile(path);
if (!directory.exists() || !directory.isDirectory()) { if (!directory.exists() || !directory.isDirectory()) {
throw new Exception("目录不存在: " + path); throw new Exception("目录不存在: " + directory.getPath());
} }
File[] fileArray = directory.listFiles(); File[] fileArray = directory.listFiles();
@@ -69,13 +70,17 @@ public class LocalFileService {
} }
public boolean fileExists(String path) { public boolean fileExists(String path) {
return new File(path).exists(); try {
return normalizeFile(path).exists();
} catch (Exception e) {
return false;
}
} }
public FileInfo getFileInfo(String path) throws Exception { public FileInfo getFileInfo(String path) throws Exception {
File file = new File(path); File file = normalizeFile(path);
if (!file.exists()) { if (!file.exists()) {
throw new Exception("文件不存在: " + path); throw new Exception("文件不存在: " + file.getPath());
} }
FileInfo fileInfo = new FileInfo(); FileInfo fileInfo = new FileInfo();
@@ -94,17 +99,20 @@ public class LocalFileService {
} }
public String getParentPath(String path) { public String getParentPath(String path) {
File file = new File(path); try {
String parent = file.getParent(); File file = normalizeFile(path);
return parent; return file.getParent();
} catch (Exception e) {
return null;
}
} }
// 上传本地文件到SFTP // 上传本地文件到SFTP
public void uploadToSftp(String localPath, String sessionId, public void uploadToSftp(String localPath, String sessionId,
String remotePath, SftpService sftpService) throws Exception { String remotePath, SftpService sftpService) throws Exception {
File file = new File(localPath); File file = normalizeFile(localPath);
if (!file.exists()) { if (!file.exists()) {
throw new Exception("本地文件不存在: " + localPath); throw new Exception("本地文件不存在: " + file.getPath());
} }
try (InputStream inputStream = new FileInputStream(file)) { try (InputStream inputStream = new FileInputStream(file)) {
@@ -121,9 +129,9 @@ public class LocalFileService {
*/ */
public void uploadDirectoryToSftp(String localDirPath, String sessionId, public void uploadDirectoryToSftp(String localDirPath, String sessionId,
String remoteDirPath, SftpService sftpService) throws Exception { String remoteDirPath, SftpService sftpService) throws Exception {
File root = new File(localDirPath); File root = normalizeFile(localDirPath);
if (!root.exists() || !root.isDirectory()) { if (!root.exists() || !root.isDirectory()) {
throw new Exception("本地目录不存在: " + localDirPath); throw new Exception("本地目录不存在: " + root.getPath());
} }
Path rootPath = root.toPath(); Path rootPath = root.toPath();
@@ -170,7 +178,7 @@ public class LocalFileService {
// 从SFTP下载到本地 // 从SFTP下载到本地
public void downloadFromSftp(String sessionId, String remotePath, public void downloadFromSftp(String sessionId, String remotePath,
String localPath, SftpService sftpService) throws Exception { String localPath, SftpService sftpService) throws Exception {
File file = new File(localPath); File file = normalizeFile(localPath);
File parentDir = file.getParentFile(); File parentDir = file.getParentFile();
// 确保父目录存在 // 确保父目录存在
@@ -192,9 +200,9 @@ public class LocalFileService {
*/ */
public void downloadDirectoryFromSftp(String sessionId, String remoteDirPath, public void downloadDirectoryFromSftp(String sessionId, String remoteDirPath,
String localDirPath, SftpService sftpService) throws Exception { String localDirPath, SftpService sftpService) throws Exception {
File root = new File(localDirPath); File root = normalizeFile(localDirPath);
if (!root.exists() && !root.mkdirs()) { if (!root.exists() && !root.mkdirs()) {
throw new Exception("创建本地目录失败: " + localDirPath); throw new Exception("创建本地目录失败: " + root.getPath());
} }
downloadDirectoryFromSftpRecursive(sessionId, remoteDirPath, root, sftpService); downloadDirectoryFromSftpRecursive(sessionId, remoteDirPath, root, sftpService);
} }
@@ -226,9 +234,9 @@ public class LocalFileService {
// 删除单个文件或目录 // 删除单个文件或目录
public boolean deleteFile(String path) throws Exception { public boolean deleteFile(String path) throws Exception {
File file = new File(path); File file = normalizeFile(path);
if (!file.exists()) { if (!file.exists()) {
throw new Exception("文件不存在: " + path); throw new Exception("文件不存在: " + file.getPath());
} }
checkDeletePermission(file); checkDeletePermission(file);
if (file.isDirectory()) { if (file.isDirectory()) {
@@ -257,27 +265,31 @@ public class LocalFileService {
// 检查删除权限(本地) // 检查删除权限(本地)
private void checkDeletePermission(File file) throws Exception { private void checkDeletePermission(File file) throws Exception {
if (!file.canWrite()) { File canonicalFile = file.getCanonicalFile();
throw new Exception("没有删除权限: " + file.getPath()); String canonicalPath = canonicalFile.getPath();
// 禁止删除根目录
if (isRootPath(canonicalFile)) {
throw new Exception("根目录禁止删除: " + canonicalPath);
} }
String systemPaths = "C:\\Windows,C:\\Program Files,C:\\Program Files (x86),C:\\System32";
String[] paths = systemPaths.split(","); // 保护系统关键目录
for (String systemPath : paths) { if (isProtectedSystemPath(canonicalPath)) {
String p = systemPath.trim().toLowerCase(); throw new Exception("系统目录禁止删除: " + canonicalPath);
if (p.isEmpty()) continue; }
if (file.getPath().toLowerCase().startsWith(p)) {
throw new Exception("系统目录禁止删除: " + file.getPath()); if (!file.canWrite()) {
} throw new Exception("没有删除权限: " + canonicalPath);
} }
} }
// 重命名文件 // 重命名文件
public boolean renameFile(String oldPath, String newPath) throws Exception { public boolean renameFile(String oldPath, String newPath) throws Exception {
File oldFile = new File(oldPath); File oldFile = normalizeFile(oldPath);
File newFile = new File(newPath); File newFile = normalizeFile(newPath);
if (!oldFile.exists()) { if (!oldFile.exists()) {
throw new Exception("源文件不存在: " + oldPath); throw new Exception("源文件不存在: " + oldFile.getPath());
} }
if (newFile.exists()) { if (newFile.exists()) {
@@ -289,6 +301,13 @@ public class LocalFileService {
throw new Exception("文件名包含非法字符: " + newFileName); throw new Exception("文件名包含非法字符: " + newFileName);
} }
File oldParent = oldFile.getParentFile();
File newParent = newFile.getParentFile();
if (oldParent == null || newParent == null
|| !oldParent.getCanonicalPath().equals(newParent.getCanonicalPath())) {
throw new Exception("仅支持同目录重命名");
}
boolean result = oldFile.renameTo(newFile); boolean result = oldFile.renameTo(newFile);
if (!result) { if (!result) {
throw new Exception("重命名失败"); throw new Exception("重命名失败");
@@ -347,7 +366,7 @@ public class LocalFileService {
// 创建目录(单级,父目录必须存在) // 创建目录(单级,父目录必须存在)
public boolean createDirectory(String path) throws Exception { public boolean createDirectory(String path) throws Exception {
File directory = new File(path); File directory = normalizeFile(path);
if (directory.exists()) { if (directory.exists()) {
throw new Exception("目录已存在: " + path); throw new Exception("目录已存在: " + path);
@@ -373,7 +392,7 @@ public class LocalFileService {
// 创建多级目录 // 创建多级目录
public boolean createDirectories(String path) throws Exception { public boolean createDirectories(String path) throws Exception {
File directory = new File(path); File directory = normalizeFile(path);
if (directory.exists()) { if (directory.exists()) {
throw new Exception("目录已存在: " + path); throw new Exception("目录已存在: " + path);
@@ -453,4 +472,81 @@ public class LocalFileService {
result.setFailedFiles(failedFiles); result.setFailedFiles(failedFiles);
return result; return result;
} }
private File normalizeFile(String path) throws Exception {
if (path == null || path.trim().isEmpty()) {
throw new Exception("路径不能为空");
}
String raw = path.trim();
if (raw.indexOf('\0') >= 0) {
throw new Exception("路径包含非法字符");
}
if (containsParentTraversal(raw)) {
throw new Exception("路径不能包含上级目录引用(..): " + raw);
}
try {
return new File(raw).getCanonicalFile();
} catch (IOException e) {
throw new Exception("路径无效: " + raw, e);
}
}
private boolean containsParentTraversal(String path) {
String[] parts = path.replace('\\', '/').split("/");
for (String part : parts) {
if ("..".equals(part)) {
return true;
}
}
return false;
}
private boolean isRootPath(File file) {
Path p = file.toPath().normalize();
Path root = p.getRoot();
return root != null && root.equals(p);
}
private boolean isProtectedSystemPath(String path) {
String normalized = path.replace('\\', '/');
String normalizedLower = normalized.toLowerCase(Locale.ROOT);
String[] windowsPaths = {
"c:/windows",
"c:/program files",
"c:/program files (x86)",
"c:/system32"
};
String[] unixPaths = {
"/bin",
"/boot",
"/dev",
"/etc",
"/lib",
"/lib64",
"/proc",
"/root",
"/sbin",
"/sys",
"/usr",
"/var"
};
if (matchProtectedPath(normalizedLower, windowsPaths)) {
return true;
}
return matchProtectedPath(normalizedLower, unixPaths);
}
private boolean matchProtectedPath(String target, String[] protectedPaths) {
for (String protectedPath : protectedPaths) {
if (target.equals(protectedPath) || target.startsWith(protectedPath + "/")) {
return true;
}
}
return false;
}
} }

View File

@@ -2,8 +2,13 @@ package com.sftp.manager.service;
import com.jcraft.jsch.ChannelSftp; import com.jcraft.jsch.ChannelSftp;
import com.sftp.manager.model.Connection; import com.sftp.manager.model.Connection;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.Iterator;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
@@ -11,18 +16,35 @@ import java.util.concurrent.ConcurrentHashMap;
@Component @Component
public class SessionManager { public class SessionManager {
private static final Logger log = LoggerFactory.getLogger(SessionManager.class);
private final Map<String, ChannelSftp> activeSessions = new ConcurrentHashMap<>(); private final Map<String, ChannelSftp> activeSessions = new ConcurrentHashMap<>();
private final Map<String, Connection> sessionConnections = new ConcurrentHashMap<>(); private final Map<String, Connection> sessionConnections = new ConcurrentHashMap<>();
private final Map<String, Long> sessionLastAccessTime = new ConcurrentHashMap<>();
@Value("${app.sftp.session-timeout:30000}")
private long sessionTimeout;
public String addSession(ChannelSftp channel, Connection connection) { public String addSession(ChannelSftp channel, Connection connection) {
String sessionId = "sftp-" + UUID.randomUUID().toString(); String sessionId = "sftp-" + UUID.randomUUID().toString();
activeSessions.put(sessionId, channel); activeSessions.put(sessionId, channel);
sessionConnections.put(sessionId, connection); sessionConnections.put(sessionId, connection);
sessionLastAccessTime.put(sessionId, System.currentTimeMillis());
if (connection != null) {
log.info("新增SFTP会话: sessionId={}, host={}, username={}",
sessionId, connection.getHost(), connection.getUsername());
} else {
log.info("新增SFTP会话: sessionId={}", sessionId);
}
return sessionId; return sessionId;
} }
public ChannelSftp getSession(String sessionId) { public ChannelSftp getSession(String sessionId) {
return activeSessions.get(sessionId); ChannelSftp channel = activeSessions.get(sessionId);
if (channel != null) {
sessionLastAccessTime.put(sessionId, System.currentTimeMillis());
}
return channel;
} }
public Connection getConnection(String sessionId) { public Connection getConnection(String sessionId) {
@@ -30,16 +52,19 @@ public class SessionManager {
} }
public void removeSession(String sessionId) { public void removeSession(String sessionId) {
log.info("移除SFTP会话: sessionId={}", sessionId);
ChannelSftp channel = activeSessions.get(sessionId); ChannelSftp channel = activeSessions.get(sessionId);
if (channel != null) { if (channel != null) {
try { try {
channel.disconnect(); channel.disconnect();
} catch (Exception e) { } catch (Exception e) {
// 忽略关闭异常 // 忽略关闭异常
log.debug("关闭SFTP会话异常: sessionId={}, error={}", sessionId, e.getMessage());
} }
} }
activeSessions.remove(sessionId); activeSessions.remove(sessionId);
sessionConnections.remove(sessionId); sessionConnections.remove(sessionId);
sessionLastAccessTime.remove(sessionId);
} }
public boolean isActive(String sessionId) { public boolean isActive(String sessionId) {
@@ -53,4 +78,41 @@ public class SessionManager {
public int getActiveSessionCount() { public int getActiveSessionCount() {
return activeSessions.size(); return activeSessions.size();
} }
@Scheduled(fixedDelayString = "${app.sftp.session-cleanup-interval:60000}")
public void cleanupExpiredSessions() {
long now = System.currentTimeMillis();
Iterator<Map.Entry<String, ChannelSftp>> iterator = activeSessions.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, ChannelSftp> entry = iterator.next();
String sessionId = entry.getKey();
ChannelSftp channel = entry.getValue();
Long lastAccess = sessionLastAccessTime.get(sessionId);
boolean disconnected = channel == null || !channel.isConnected();
boolean expired = lastAccess == null || (now - lastAccess) > sessionTimeout;
if (disconnected || expired) {
if (channel != null) {
try {
channel.disconnect();
} catch (Exception e) {
// 忽略清理阶段的关闭异常
log.debug("清理会话时关闭异常: sessionId={}, error={}", sessionId, e.getMessage());
}
}
if (expired) {
log.info("清理超时会话: sessionId={}, timeoutMs={}", sessionId, sessionTimeout);
} else {
log.info("清理断开会话: sessionId={}", sessionId);
}
iterator.remove();
sessionConnections.remove(sessionId);
sessionLastAccessTime.remove(sessionId);
}
}
}
} }

View File

@@ -5,6 +5,8 @@ import com.jcraft.jsch.SftpATTRS;
import com.jcraft.jsch.SftpException; import com.jcraft.jsch.SftpException;
import com.sftp.manager.dto.BatchDeleteResult; import com.sftp.manager.dto.BatchDeleteResult;
import com.sftp.manager.model.FileInfo; import com.sftp.manager.model.FileInfo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -22,6 +24,8 @@ import java.util.Vector;
@Service @Service
public class SftpService { public class SftpService {
private static final Logger log = LoggerFactory.getLogger(SftpService.class);
@Autowired @Autowired
private SessionManager sessionManager; private SessionManager sessionManager;
@@ -30,6 +34,7 @@ public class SftpService {
} }
public List<FileInfo> listFiles(String sessionId, String path, boolean showHidden) throws Exception { public List<FileInfo> listFiles(String sessionId, String path, boolean showHidden) throws Exception {
log.debug("列出SFTP文件: sessionId={}, path={}, showHidden={}", sessionId, path, showHidden);
ChannelSftp channel = sessionManager.getSession(sessionId); ChannelSftp channel = sessionManager.getSession(sessionId);
if (channel == null) { if (channel == null) {
throw new Exception("会话不存在或已断开"); throw new Exception("会话不存在或已断开");
@@ -72,6 +77,7 @@ public class SftpService {
return files; return files;
} catch (SftpException e) { } catch (SftpException e) {
log.warn("列出SFTP文件失败: sessionId={}, path={}, error={}", sessionId, path, e.getMessage());
throw new Exception("列出文件失败: " + e.getMessage(), e); throw new Exception("列出文件失败: " + e.getMessage(), e);
} }
} }
@@ -139,7 +145,8 @@ public class SftpService {
// 上传文件到SFTP // 上传文件到SFTP
public void uploadFile(String sessionId, InputStream inputStream, public void uploadFile(String sessionId, InputStream inputStream,
String remotePath, long fileSize) throws Exception { String remotePath, long fileSize) throws Exception {
log.info("上传到SFTP: sessionId={}, remotePath={}, fileSize={}", sessionId, remotePath, fileSize);
ChannelSftp channel = sessionManager.getSession(sessionId); ChannelSftp channel = sessionManager.getSession(sessionId);
if (channel == null) { if (channel == null) {
throw new Exception("会话不存在或已断开"); throw new Exception("会话不存在或已断开");
@@ -148,6 +155,7 @@ public class SftpService {
try { try {
channel.put(inputStream, remotePath); channel.put(inputStream, remotePath);
} catch (SftpException e) { } catch (SftpException e) {
log.warn("上传到SFTP失败: sessionId={}, remotePath={}, error={}", sessionId, remotePath, e.getMessage());
throw new Exception("上传失败: " + e.getMessage(), e); throw new Exception("上传失败: " + e.getMessage(), e);
} }
// 注意不在此处关闭流由调用者使用try-with-resources管理 // 注意不在此处关闭流由调用者使用try-with-resources管理
@@ -155,7 +163,8 @@ public class SftpService {
// 从SFTP下载文件 // 从SFTP下载文件
public void downloadFile(String sessionId, String remotePath, public void downloadFile(String sessionId, String remotePath,
OutputStream outputStream) throws Exception { OutputStream outputStream) throws Exception {
log.info("从SFTP下载: sessionId={}, remotePath={}", sessionId, remotePath);
ChannelSftp channel = sessionManager.getSession(sessionId); ChannelSftp channel = sessionManager.getSession(sessionId);
if (channel == null) { if (channel == null) {
throw new Exception("会话不存在或已断开"); throw new Exception("会话不存在或已断开");
@@ -164,6 +173,7 @@ public class SftpService {
try { try {
channel.get(remotePath, outputStream); channel.get(remotePath, outputStream);
} catch (SftpException e) { } catch (SftpException e) {
log.warn("从SFTP下载失败: sessionId={}, remotePath={}, error={}", sessionId, remotePath, e.getMessage());
throw new Exception("下载失败: " + e.getMessage(), e); throw new Exception("下载失败: " + e.getMessage(), e);
} }
// 注意不在此处关闭流由调用者使用try-with-resources管理 // 注意不在此处关闭流由调用者使用try-with-resources管理
@@ -171,7 +181,9 @@ public class SftpService {
// SFTP间传输 // SFTP间传输
public void transferBetweenSftp(String sourceSessionId, String sourcePath, public void transferBetweenSftp(String sourceSessionId, String sourcePath,
String targetSessionId, String targetPath) throws Exception { String targetSessionId, String targetPath) throws Exception {
log.info("SFTP间传输开始: sourceSessionId={}, sourcePath={}, targetSessionId={}, targetPath={}",
sourceSessionId, sourcePath, targetSessionId, targetPath);
// 创建临时文件 // 创建临时文件
String tempDir = System.getProperty("java.io.tmpdir"); String tempDir = System.getProperty("java.io.tmpdir");
String tempFile = tempDir + File.separator + UUID.randomUUID().toString(); String tempFile = tempDir + File.separator + UUID.randomUUID().toString();
@@ -192,6 +204,8 @@ public class SftpService {
} }
targetChannel.put(tempFile, targetPath); targetChannel.put(tempFile, targetPath);
log.info("SFTP间传输成功: sourceSessionId={}, sourcePath={}, targetSessionId={}, targetPath={}",
sourceSessionId, sourcePath, targetSessionId, targetPath);
} finally { } finally {
// 删除临时文件 // 删除临时文件
@@ -204,6 +218,7 @@ public class SftpService {
// 删除单个文件或目录 // 删除单个文件或目录
public boolean deleteFile(String sessionId, String path) throws Exception { public boolean deleteFile(String sessionId, String path) throws Exception {
log.info("删除SFTP文件: sessionId={}, path={}", sessionId, path);
ChannelSftp channel = sessionManager.getSession(sessionId); ChannelSftp channel = sessionManager.getSession(sessionId);
if (channel == null) { if (channel == null) {
throw new Exception("会话不存在或已断开"); throw new Exception("会话不存在或已断开");
@@ -218,6 +233,7 @@ public class SftpService {
} }
return true; return true;
} catch (SftpException e) { } catch (SftpException e) {
log.warn("删除SFTP文件失败: sessionId={}, path={}, error={}", sessionId, path, e.getMessage());
throw new Exception("删除失败: " + e.getMessage(), e); throw new Exception("删除失败: " + e.getMessage(), e);
} }
} }

View File

@@ -27,10 +27,15 @@ spring:
max-request-size: 2GB max-request-size: 2GB
app: app:
cors:
allowed-origins: ${APP_CORS_ALLOWED_ORIGINS:http://localhost:48081}
sftp: sftp:
session-timeout: 60000 session-timeout: 60000
session-cleanup-interval: 60000
connection-timeout: 30000 connection-timeout: 30000
max-retries: 5 max-retries: 5
strict-host-key-checking: ${SFTP_STRICT_HOST_KEY_CHECKING:false}
known-hosts-path: ${KNOWN_HOSTS_PATH:}
# Actuator 生产环境暴露健康与指标 # Actuator 生产环境暴露健康与指标
management: management:

View File

@@ -34,10 +34,15 @@ spring:
# 自定义配置 # 自定义配置
app: app:
cors:
allowed-origins: http://localhost:48081,http://127.0.0.1:48081
sftp: sftp:
session-timeout: 30000 # SFTP会话超时时间(ms) session-timeout: 30000 # SFTP会话超时时间(ms)
session-cleanup-interval: 60000 # 会话清理周期(ms)
connection-timeout: 10000 # 连接超时时间(ms) connection-timeout: 10000 # 连接超时时间(ms)
max-retries: 3 # 连接失败重试次数 max-retries: 3 # 连接失败重试次数
strict-host-key-checking: false # 开发环境默认关闭,生产建议开启
known-hosts-path: ${KNOWN_HOSTS_PATH:}
# Actuator 监控端点(开发环境仅暴露 health、info # Actuator 监控端点(开发环境仅暴露 health、info
management: management:

View File

@@ -859,12 +859,10 @@ function connectToServer(connId) {
method: 'POST', method: 'POST',
contentType: 'application/json', contentType: 'application/json',
data: JSON.stringify({ data: JSON.stringify({
id: conn.id,
host: conn.host, host: conn.host,
port: conn.port, port: conn.port,
username: conn.username, username: conn.username
password: conn.password,
privateKeyPath: conn.privateKeyPath,
passPhrase: conn.passPhrase
}), }),
success: function(res) { success: function(res) {
if (res.success) { if (res.success) {

View File

@@ -0,0 +1,78 @@
package com.sftp.manager.controller;
import com.sftp.manager.dto.ConnectionRequest;
import com.sftp.manager.dto.DisconnectRequest;
import com.sftp.manager.dto.ApiResponse;
import com.sftp.manager.model.Connection;
import com.sftp.manager.service.ConnectionService;
import com.sftp.manager.service.SessionManager;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Collections;
import java.util.Map;
public class ConnectionControllerTest {
private ConnectionController controller;
private ConnectionService connectionService;
private SessionManager sessionManager;
@BeforeEach
public void setUp() {
controller = new ConnectionController();
connectionService = Mockito.mock(ConnectionService.class);
sessionManager = Mockito.mock(SessionManager.class);
ReflectionTestUtils.setField(controller, "connectionService", connectionService);
ReflectionTestUtils.setField(controller, "sessionManager", sessionManager);
}
@Test
public void disconnect_shouldThrowWhenSessionIdEmpty() {
DisconnectRequest request = new DisconnectRequest();
request.setSessionId(" ");
IllegalArgumentException ex = Assertions.assertThrows(IllegalArgumentException.class,
() -> controller.disconnect(request));
Assertions.assertEquals("会话ID不能为空", ex.getMessage());
}
@Test
public void listConnections_shouldReturnSuccessResponse() {
Mockito.when(connectionService.listConnections()).thenReturn(Collections.<Connection>emptyList());
ApiResponse<java.util.List<Connection>> response = controller.listConnections();
Assertions.assertTrue(response.isSuccess());
Assertions.assertEquals("查询成功", response.getMessage());
}
@Test
public void getActiveConnections_shouldReturnSuccessResponse() {
Mockito.when(sessionManager.getAllActiveConnections()).thenReturn(Collections.<String, Connection>emptyMap());
ApiResponse<Map<String, Connection>> response = controller.getActiveConnections();
Assertions.assertTrue(response.isSuccess());
Assertions.assertEquals("查询成功", response.getMessage());
}
@Test
public void connect_shouldDelegateToService() throws Exception {
ConnectionRequest request = new ConnectionRequest();
request.setHost("127.0.0.1");
request.setUsername("root");
Mockito.when(connectionService.connect(request)).thenReturn("sftp-1");
ApiResponse<String> response = controller.connect(request);
Assertions.assertTrue(response.isSuccess());
Assertions.assertEquals("sftp-1", response.getData());
}
}

View File

@@ -0,0 +1,83 @@
package com.sftp.manager.controller;
import com.sftp.manager.dto.ApiResponse;
import com.sftp.manager.dto.BatchDeleteRequest;
import com.sftp.manager.dto.BatchDeleteResult;
import com.sftp.manager.dto.DirectoryRequest;
import com.sftp.manager.dto.FileListRequest;
import com.sftp.manager.service.LocalFileService;
import com.sftp.manager.service.SessionManager;
import com.sftp.manager.service.SftpService;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Collections;
public class FileControllerTest {
private FileController controller;
private LocalFileService localFileService;
private SftpService sftpService;
@BeforeEach
public void setUp() {
controller = new FileController();
localFileService = Mockito.mock(LocalFileService.class);
sftpService = Mockito.mock(SftpService.class);
SessionManager sessionManager = Mockito.mock(SessionManager.class);
ReflectionTestUtils.setField(controller, "localFileService", localFileService);
ReflectionTestUtils.setField(controller, "sftpService", sftpService);
ReflectionTestUtils.setField(controller, "sessionManager", sessionManager);
}
@Test
public void listFiles_shouldThrowWhenSessionIdEmpty() {
FileListRequest request = new FileListRequest();
request.setSessionId(" ");
request.setPath("/tmp");
IllegalArgumentException ex = Assertions.assertThrows(IllegalArgumentException.class,
() -> controller.listFiles(request));
Assertions.assertEquals("会话ID不能为空", ex.getMessage());
}
@Test
public void createDirectory_local_shouldCallLocalService() throws Exception {
DirectoryRequest request = new DirectoryRequest();
request.setSessionId("local");
request.setPath("/tmp/new-dir");
Mockito.when(localFileService.createDirectory("/tmp/new-dir")).thenReturn(true);
ApiResponse<Void> response = controller.createDirectory(request);
Assertions.assertTrue(response.isSuccess());
Assertions.assertEquals("创建成功", response.getMessage());
Mockito.verify(localFileService).createDirectory("/tmp/new-dir");
}
@Test
public void batchDelete_nullPaths_shouldUseEmptyList() {
BatchDeleteRequest request = new BatchDeleteRequest();
request.setSessionId("local");
request.setPaths(null);
BatchDeleteResult result = new BatchDeleteResult();
result.setSuccessCount(0);
result.setFailCount(0);
result.setFailedFiles(Collections.<String>emptyList());
Mockito.when(localFileService.batchDelete(Mockito.anyList())).thenReturn(result);
ApiResponse<BatchDeleteResult> response = controller.batchDelete(request);
Assertions.assertTrue(response.isSuccess());
Assertions.assertNotNull(response.getData());
Mockito.verify(localFileService).batchDelete(Mockito.anyList());
}
}

View File

@@ -0,0 +1,115 @@
package com.sftp.manager.service;
import com.sftp.manager.dto.ConnectionRequest;
import com.sftp.manager.model.Connection;
import com.sftp.manager.repository.ConnectionRepository;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.test.util.ReflectionTestUtils;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
public class ConnectionServiceTest {
private ConnectionService connectionService;
private ConnectionRepository connectionRepository;
@BeforeEach
public void setUp() {
connectionService = new ConnectionService();
connectionRepository = Mockito.mock(ConnectionRepository.class);
ReflectionTestUtils.setField(connectionService, "connectionRepository", connectionRepository);
ReflectionTestUtils.setField(connectionService, "sessionManager", Mockito.mock(SessionManager.class));
}
@Test
public void buildEffectiveRequest_shouldLoadSavedCredentialsById() throws Exception {
Connection saved = new Connection();
saved.setId(1L);
saved.setName("saved-conn");
saved.setHost("10.0.0.1");
saved.setPort(null);
saved.setUsername("saved-user");
saved.setPassword("saved-pass");
saved.setPrivateKeyPath("/tmp/id_rsa");
saved.setPassPhrase("pp");
saved.setRootPath("/home/saved");
Mockito.when(connectionRepository.findById(1L)).thenReturn(Optional.of(saved));
ConnectionRequest request = new ConnectionRequest();
request.setId(1L);
request.setHost("10.0.0.2");
ConnectionRequest effective = invokeBuildEffectiveRequest(request);
Assertions.assertEquals("10.0.0.2", effective.getHost());
Assertions.assertEquals("saved-user", effective.getUsername());
Assertions.assertEquals("saved-pass", effective.getPassword());
Assertions.assertEquals(Integer.valueOf(22), effective.getPort());
}
@Test
public void buildEffectiveRequest_shouldFailWhenNoPasswordAndNoPrivateKey() {
ConnectionRequest request = new ConnectionRequest();
request.setHost("127.0.0.1");
request.setUsername("root");
Exception ex = Assertions.assertThrows(Exception.class, () -> invokeBuildEffectiveRequest(request));
Assertions.assertEquals("密码和私钥不能同时为空", ex.getMessage());
}
@Test
public void configureKnownHosts_shouldFailWhenDefaultKnownHostsMissing() {
String oldUserHome = System.getProperty("user.home");
try {
Path tempHome = Files.createTempDirectory("sftp-manager-home");
System.setProperty("user.home", tempHome.toString());
ReflectionTestUtils.setField(connectionService, "knownHostsPath", "");
Exception ex = Assertions.assertThrows(Exception.class, this::invokeConfigureKnownHosts);
Assertions.assertTrue(ex.getMessage().contains("未找到 known_hosts"));
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
if (oldUserHome != null) {
System.setProperty("user.home", oldUserHome);
}
}
}
private ConnectionRequest invokeBuildEffectiveRequest(ConnectionRequest request) throws Exception {
Method method = ConnectionService.class.getDeclaredMethod("buildEffectiveRequest", ConnectionRequest.class);
method.setAccessible(true);
try {
return (ConnectionRequest) method.invoke(connectionService, request);
} catch (InvocationTargetException e) {
Throwable target = e.getTargetException();
if (target instanceof Exception) {
throw (Exception) target;
}
throw new RuntimeException(target);
}
}
private void invokeConfigureKnownHosts() throws Exception {
Method method = ConnectionService.class.getDeclaredMethod("configureKnownHosts", com.jcraft.jsch.JSch.class);
method.setAccessible(true);
try {
method.invoke(connectionService, new com.jcraft.jsch.JSch());
} catch (InvocationTargetException e) {
Throwable target = e.getTargetException();
if (target instanceof Exception) {
throw (Exception) target;
}
throw new RuntimeException(target);
}
}
}

View File

@@ -0,0 +1,77 @@
package com.sftp.manager.service;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
public class LocalFileServiceTest {
private final LocalFileService localFileService = new LocalFileService();
@TempDir
Path tempDir;
@Test
public void createDirectory_shouldRejectParentTraversalPath() {
String badPath = tempDir.resolve("../escape-dir").toString();
Exception ex = Assertions.assertThrows(Exception.class,
() -> localFileService.createDirectory(badPath));
Assertions.assertTrue(ex.getMessage().contains("上级目录引用"));
}
@Test
public void deleteFile_shouldRejectRootPath() {
File[] roots = File.listRoots();
Assertions.assertNotNull(roots);
Assertions.assertTrue(roots.length > 0);
String rootPath = roots[0].getPath();
Exception ex = Assertions.assertThrows(Exception.class,
() -> localFileService.deleteFile(rootPath));
Assertions.assertTrue(ex.getMessage().contains("根目录禁止删除"));
}
@Test
public void renameFile_shouldRejectCrossDirectoryRename() throws Exception {
Path source = tempDir.resolve("source.txt");
Files.write(source, "data".getBytes(StandardCharsets.UTF_8));
Path subDir = tempDir.resolve("sub");
Files.createDirectories(subDir);
Path target = subDir.resolve("target.txt");
Exception ex = Assertions.assertThrows(Exception.class,
() -> localFileService.renameFile(source.toString(), target.toString()));
Assertions.assertTrue(ex.getMessage().contains("仅支持同目录重命名"));
}
@Test
public void renameFile_shouldAllowRenameInSameDirectory() throws Exception {
Path source = tempDir.resolve("old-name.txt");
Files.write(source, "data".getBytes(StandardCharsets.UTF_8));
Path target = tempDir.resolve("new-name.txt");
boolean result = localFileService.renameFile(source.toString(), target.toString());
Assertions.assertTrue(result);
Assertions.assertFalse(Files.exists(source));
Assertions.assertTrue(Files.exists(target));
}
@Test
public void fileExists_shouldReturnFalseWhenPathContainsTraversal() {
boolean exists = localFileService.fileExists("../sensitive-path");
Assertions.assertFalse(exists);
}
}

View File

@@ -0,0 +1,81 @@
package com.sftp.manager.service;
import com.jcraft.jsch.ChannelSftp;
import com.sftp.manager.model.Connection;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Map;
public class SessionManagerTest {
@Test
public void cleanupExpiredSessions_shouldRemoveDisconnectedSession() {
SessionManager sessionManager = new SessionManager();
ReflectionTestUtils.setField(sessionManager, "sessionTimeout", 60_000L);
ChannelSftp channel = Mockito.mock(ChannelSftp.class);
Mockito.when(channel.isConnected()).thenReturn(false);
Connection connection = new Connection();
connection.setName("test");
String sessionId = sessionManager.addSession(channel, connection);
sessionManager.cleanupExpiredSessions();
Assertions.assertNull(sessionManager.getSession(sessionId));
Assertions.assertNull(sessionManager.getConnection(sessionId));
}
@Test
public void cleanupExpiredSessions_shouldRemoveTimeoutSession() {
SessionManager sessionManager = new SessionManager();
ReflectionTestUtils.setField(sessionManager, "sessionTimeout", 100L);
ChannelSftp channel = Mockito.mock(ChannelSftp.class);
Mockito.when(channel.isConnected()).thenReturn(true);
Connection connection = new Connection();
connection.setName("timeout-test");
String sessionId = sessionManager.addSession(channel, connection);
@SuppressWarnings("unchecked")
Map<String, Long> accessMap = (Map<String, Long>) ReflectionTestUtils.getField(sessionManager, "sessionLastAccessTime");
Assertions.assertNotNull(accessMap);
accessMap.put(sessionId, System.currentTimeMillis() - 10_000L);
sessionManager.cleanupExpiredSessions();
Assertions.assertNull(sessionManager.getSession(sessionId));
Assertions.assertNull(sessionManager.getConnection(sessionId));
}
@Test
public void getSession_shouldRefreshLastAccessTime() throws Exception {
SessionManager sessionManager = new SessionManager();
ReflectionTestUtils.setField(sessionManager, "sessionTimeout", 60_000L);
ChannelSftp channel = Mockito.mock(ChannelSftp.class);
Mockito.when(channel.isConnected()).thenReturn(true);
Connection connection = new Connection();
connection.setName("access-test");
String sessionId = sessionManager.addSession(channel, connection);
@SuppressWarnings("unchecked")
Map<String, Long> accessMap = (Map<String, Long>) ReflectionTestUtils.getField(sessionManager, "sessionLastAccessTime");
Assertions.assertNotNull(accessMap);
accessMap.put(sessionId, 1L);
sessionManager.getSession(sessionId);
Long refreshedTime = accessMap.get(sessionId);
Assertions.assertNotNull(refreshedTime);
Assertions.assertTrue(refreshedTime > 1L);
}
}