Enhance security and reliability across SFTP workflows
This commit is contained in:
190
AGENTS.md
Normal file
190
AGENTS.md
Normal 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`)。
|
||||||
|
|
||||||
|
## 2)Cursor / 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 体系替换。
|
||||||
|
|
||||||
|
## 5)Agent 工作流程建议
|
||||||
|
|
||||||
|
- 开始改动前,先阅读目标文件及相邻文件风格。
|
||||||
|
- 以最小必要改动为原则,避免无关重构。
|
||||||
|
- 未明确要求时,不更改端口、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`
|
||||||
|
|
||||||
|
请随项目演进持续维护本文件,确保其内容始终与仓库实际配置一致。
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
src/main/java/com/sftp/manager/dto/BatchDeleteRequest.java
Normal file
11
src/main/java/com/sftp/manager/dto/BatchDeleteRequest.java
Normal 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;
|
||||||
|
}
|
||||||
9
src/main/java/com/sftp/manager/dto/DirectoryRequest.java
Normal file
9
src/main/java/com/sftp/manager/dto/DirectoryRequest.java
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package com.sftp.manager.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class DirectoryRequest {
|
||||||
|
private String sessionId;
|
||||||
|
private String path;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.sftp.manager.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class DisconnectRequest {
|
||||||
|
private String sessionId;
|
||||||
|
}
|
||||||
10
src/main/java/com/sftp/manager/dto/RenameRequest.java
Normal file
10
src/main/java/com/sftp/manager/dto/RenameRequest.java
Normal 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;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; // 连接超时时间
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user