Files
sftp-manager/docs/05-文件上传下载功能.md
liu 14289beb66 Initial commit
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 10:10:11 +08:00

18 KiB
Raw Permalink Blame History

模块05文件上传下载功能


🎨 UI设计系统概览

完整设计系统文档请参考: UI设计系统.md

核心设计原则

  • 现代简约:界面清晰,层次分明
  • 专业高效:减少操作步骤,提升工作效率
  • 一致性:统一的视觉语言和交互模式
  • 可访问性符合WCAG 2.1 AA标准

关键设计令牌

颜色系统:

  • 主色:#0d6efd(操作按钮、选中状态)
  • 成功:#198754(连接成功状态)
  • 危险:#dc3545(删除操作、错误提示)
  • 深灰:#212529(导航栏背景)
  • 浅灰:#e9ecef(工具栏背景)

字体系统:

  • 字体族:系统字体栈(-apple-system, Segoe UI, Roboto等
  • 正文14px行高1.5
  • 标题20-32px行高1.2-1.4
  • 小号文字12px文件大小、日期等

间距系统:

  • 基础单位8px
  • 标准间距16px1rem
  • 组件内边距8px-16px

组件规范:

  • 导航栏高度48px深色背景
  • 工具栏浅灰背景按钮间距8px
  • 文件项最小高度44px悬停效果150ms
  • 按钮圆角4px过渡150ms

交互规范:

  • 悬停效果150ms过渡
  • 触摸目标最小44x44px
  • 键盘导航Tab、Enter、Delete、F2、F5、Esc
  • 焦点状态2px蓝色轮廓

响应式断点:

  • 移动端:< 768px双面板垂直排列
  • 平板768px - 1024px
  • 桌面:> 1024px标准布局

5.1 功能概述

实现本地与SFTP服务器之间、以及两个SFTP服务器之间的文件上传和下载功能。

5.2 后端设计

5.2.1 SftpService扩展方法

// 上传文件到SFTP
public void uploadFile(String sessionId, InputStream inputStream,
                      String remotePath, long fileSize) throws Exception {
    ChannelSftp channel = sessionManager.getSession(sessionId);
    if (channel == null) {
        throw new Exception("会话不存在或已断开");
    }

    try {
        channel.put(inputStream, remotePath);
    } catch (SftpException e) {
        throw new Exception("上传失败: " + e.getMessage(), e);
    } finally {
        if (inputStream != null) {
            try {
                inputStream.close();
            } catch (Exception e) {
                // 忽略关闭异常
            }
        }
    }
}

// 从SFTP下载文件
public void downloadFile(String sessionId, String remotePath,
                        OutputStream outputStream) throws Exception {
    ChannelSftp channel = sessionManager.getSession(sessionId);
    if (channel == null) {
        throw new Exception("会话不存在或已断开");
    }

    try {
        channel.get(remotePath, outputStream);
    } catch (SftpException e) {
        throw new Exception("下载失败: " + e.getMessage(), e);
    } finally {
        if (outputStream != null) {
            try {
                outputStream.close();
            } catch (Exception e) {
                // 忽略关闭异常
            }
        }
    }
}

// SFTP间传输
public void transferBetweenSftp(String sourceSessionId, String sourcePath,
                               String targetSessionId, String targetPath) throws Exception {
    // 创建临时文件
    String tempDir = System.getProperty("java.io.tmpdir");
    String tempFile = tempDir + File.separator + UUID.randomUUID().toString();

    try {
        // 从源SFTP下载到临时文件
        ChannelSftp sourceChannel = sessionManager.getSession(sourceSessionId);
        if (sourceChannel == null) {
            throw new Exception("源会话不存在或已断开");
        }

        sourceChannel.get(sourcePath, tempFile);

        // 上传临时文件到目标SFTP
        ChannelSftp targetChannel = sessionManager.getSession(targetSessionId);
        if (targetChannel == null) {
            throw new Exception("目标会话不存在或已断开");
        }

        targetChannel.put(tempFile, targetPath);

    } finally {
        // 删除临时文件
        File file = new File(tempFile);
        if (file.exists()) {
            file.delete();
        }
    }
}

5.2.2 LocalFileService扩展方法

// 上传本地文件到SFTP
public void uploadToSftp(String localPath, String sessionId,
                        String remotePath, SftpService sftpService) throws Exception {
    File file = new File(localPath);
    if (!file.exists()) {
        throw new Exception("本地文件不存在: " + localPath);
    }

    try (InputStream inputStream = new FileInputStream(file)) {
        sftpService.uploadFile(sessionId, inputStream, remotePath, file.length());
    }
}

// 从SFTP下载到本地
public void downloadFromSftp(String sessionId, String remotePath,
                             String localPath, SftpService sftpService) throws Exception {
    File file = new File(localPath);
    File parentDir = file.getParentFile();

    // 确保父目录存在
    if (parentDir != null && !parentDir.exists()) {
        parentDir.mkdirs();
    }

    try (OutputStream outputStream = new FileOutputStream(file)) {
        sftpService.downloadFile(sessionId, remotePath, outputStream);
    }
}

5.2.3 FileController扩展接口

@Autowired
private LocalFileService localFileService;

// 上传文件
@PostMapping("/upload")
public ApiResponse<Void> uploadFile(@RequestParam("file") MultipartFile file,
                                   @RequestParam("targetSessionId") String targetSessionId,
                                   @RequestParam("targetPath") String targetPath) {
    try {
        if ("local".equals(targetSessionId)) {
            // 上传到本地
            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());
    }
}

// 下载文件
@GetMapping("/download")
public void downloadFile(@RequestParam String sessionId,
                        @RequestParam String path,
                        HttpServletResponse response) {
    try {
        if ("local".equals(sessionId)) {
            // 下载本地文件
            File file = new File(path);
            response.setContentType("application/octet-stream");
            response.setHeader("Content-Disposition",
                    "attachment; filename=" + URLEncoder.encode(file.getName(), "UTF-8"));
            response.setContentLengthLong(file.length());

            try (InputStream inputStream = new FileInputStream(file);
                 OutputStream outputStream = response.getOutputStream()) {
                byte[] buffer = new byte[4096];
                int bytesRead;
                while ((bytesRead = inputStream.read(buffer)) != -1) {
                    outputStream.write(buffer, 0, bytesRead);
                }
                outputStream.flush();
            }
        } else {
            // 下载SFTP文件
            ChannelSftp channel = sessionManager.getSession(sessionId);
            if (channel == null) {
                throw new Exception("会话不存在或已断开");
            }

            FileInfo fileInfo = sftpService.getFileInfo(sessionId, path);

            response.setContentType("application/octet-stream");
            response.setHeader("Content-Disposition",
                    "attachment; filename=" + URLEncoder.encode(fileInfo.getName(), "UTF-8"));
            response.setContentLengthLong(fileInfo.getSize());

            try (OutputStream outputStream = response.getOutputStream()) {
                sftpService.downloadFile(sessionId, path, outputStream);
                outputStream.flush();
            }
        }
    } catch (Exception e) {
        response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
        try {
            response.getWriter().write("下载失败: " + e.getMessage());
        } catch (Exception ex) {
            // 忽略
        }
    }
}

// 服务器间传输
@PostMapping("/transfer")
public ApiResponse<Void> transferFiles(@RequestBody TransferRequest request) {
    try {
        String sourceSessionId = request.getSourceSessionId();
        String sourcePath = request.getSourcePath();
        String targetSessionId = request.getTargetSessionId();
        String targetPath = request.getTargetPath();

        // 获取源文件名
        String fileName;
        if ("local".equals(sourceSessionId)) {
            File file = new File(sourcePath);
            fileName = file.getName();
        } else {
            FileInfo fileInfo = sftpService.getFileInfo(sourceSessionId, sourcePath);
            fileName = fileInfo.getName();
        }

        // 构建目标路径
        String finalTargetPath = targetPath.endsWith("/") ?
                targetPath + fileName :
                targetPath + "/" + fileName;

        if ("local".equals(sourceSessionId) && "local".equals(targetSessionId)) {
            // 本地到本地
            Files.copy(new File(sourcePath).toPath(), new File(finalTargetPath).toPath());
        } else if ("local".equals(sourceSessionId)) {
            // 本地到SFTP
            localFileService.uploadToSftp(sourcePath, targetSessionId, finalTargetPath, sftpService);
        } else if ("local".equals(targetSessionId)) {
            // SFTP到本地
            localFileService.downloadFromSftp(sourceSessionId, sourcePath, finalTargetPath, sftpService);
        } else {
            // SFTP到SFTP
            sftpService.transferBetweenSftp(sourceSessionId, sourcePath,
                    targetSessionId, finalTargetPath);
        }

        return ApiResponse.success("传输成功", null);
    } catch (Exception e) {
        return ApiResponse.error("传输失败: " + e.getMessage());
    }
}

5.3 前端设计

5.3.1 上传界面

<div class="upload-area">
    <input type="file" id="file-input" multiple style="display:none">
    <button onclick="document.getElementById('file-input').click()">选择文件</button>
    <div class="drop-zone" id="drop-zone">
        <p>拖拽文件到此处或点击选择文件</p>
    </div>
    <div class="upload-progress" id="upload-progress" style="display:none;">
        <div class="progress">
            <div class="progress-bar" role="progressbar" style="width: 0%">0%</div>
        </div>
    </div>
</div>

5.3.2 上传实现

// 文件选择
document.getElementById('file-input').addEventListener('change', function(e) {
    const files = e.target.files;
    uploadFiles(files);
});

// 拖拽上传
const dropZone = document.getElementById('drop-zone');

dropZone.addEventListener('dragover', function(e) {
    e.preventDefault();
    dropZone.style.backgroundColor = '#f0f0f0';
});

dropZone.addEventListener('dragleave', function(e) {
    e.preventDefault();
    dropZone.style.backgroundColor = '';
});

dropZone.addEventListener('drop', function(e) {
    e.preventDefault();
    dropZone.style.backgroundColor = '';

    const files = e.dataTransfer.files;
    uploadFiles(files);
});

// 上传文件
function uploadFiles(files) {
    const targetPanelId = getTargetPanelId(); // 获取目标面板ID
    const targetSessionId = panelState[targetPanelId].sessionId;
    const targetPath = panelState[targetPanelId].currentPath;

    Array.from(files).forEach(file => {
        uploadSingleFile(file, targetSessionId, targetPath);
    });
}

// 上传单个文件
function uploadSingleFile(file, targetSessionId, targetPath) {
    let formData = new FormData();
    formData.append('file', file);
    formData.append('targetSessionId', targetSessionId);
    formData.append('targetPath', targetPath);

    showUploadProgress(true);

    $.ajax({
        url: '/api/files/upload',
        method: 'POST',
        data: formData,
        processData: false,
        contentType: false,
        xhr: function() {
            let xhr = new window.XMLHttpRequest();
            xhr.upload.addEventListener('progress', function(e) {
                if (e.lengthComputable) {
                    let percent = Math.round((e.loaded / e.total) * 100);
                    updateUploadProgress(percent);
                }
            });
            return xhr;
        },
        success: function(response) {
            if (response.success) {
                alert(file.name + ' 上传成功');
                loadFiles(targetPanelId);
            } else {
                alert('上传失败: ' + response.message);
            }
        },
        error: function(xhr, status, error) {
            alert('上传失败: ' + error);
        },
        complete: function() {
            showUploadProgress(false);
        }
    });
}

// 显示上传进度
function showUploadProgress(show) {
    const progressDiv = document.getElementById('upload-progress');
    progressDiv.style.display = show ? 'block' : 'none';
}

// 更新上传进度
function updateUploadProgress(percent) {
    const progressBar = document.querySelector('#upload-progress .progress-bar');
    progressBar.style.width = percent + '%';
    progressBar.textContent = percent + '%';
}

5.3.3 下载实现

// 下载文件
function downloadFile(sessionId, path) {
    window.location.href = '/api/files/download?sessionId=' +
                          encodeURIComponent(sessionId) +
                          '&path=' + encodeURIComponent(path);
}

// 批量下载
function downloadSelectedFiles(panelId) {
    const selectedFiles = panelState[panelId].selectedFiles;
    selectedFiles.forEach(path => {
        downloadFile(panelState[panelId].sessionId, path);
    });
}

5.3.4 跨服务器传输

// 传输到对面面板
function transferToOppositePanel() {
    const sourcePanelId = getSourcePanelId();
    const targetPanelId = getTargetPanelId();

    const sourceSessionId = panelState[sourcePanelId].sessionId;
    const targetSessionId = panelState[targetPanelId].sessionId;
    const targetPath = panelState[targetPanelId].currentPath;

    const selectedFiles = panelState[sourcePanelId].selectedFiles;

    if (selectedFiles.length === 0) {
        alert('请先选择要传输的文件');
        return;
    }

    selectedFiles.forEach(sourcePath => {
        $.ajax({
            url: '/api/files/transfer',
            method: 'POST',
            contentType: 'application/json',
            data: JSON.stringify({
                sourceSessionId: sourceSessionId,
                sourcePath: sourcePath,
                targetSessionId: targetSessionId,
                targetPath: targetPath
            }),
            success: function(response) {
                if (response.success) {
                    alert('传输成功');
                    loadFiles(targetPanelId);
                } else {
                    alert('传输失败: ' + response.message);
                }
            },
            error: function(xhr, status, error) {
                alert('传输失败: ' + error);
            }
        });
    });
}

5.4 性能优化

5.4.1 大文件处理

  • 使用流式传输避免内存溢出
  • 设置合理的超时时间
  • 显示实时进度
  • 支持断点续传(可选)

5.4.2 断点续传(高级功能)

// 支持断点的上传
public void uploadFileWithResume(String sessionId, InputStream inputStream,
                                String remotePath, long fileSize,
                                long resumeFrom) throws Exception {
    ChannelSftp channel = sessionManager.getSession(sessionId);
    if (channel == null) {
        throw new Exception("会话不存在或已断开");
    }

    try {
        if (resumeFrom > 0) {
            // 断点续传,跳过已传输的字节
            inputStream.skip(resumeFrom);
            channel.put(inputStream, remotePath, ChannelSftp.RESUME);
        } else {
            channel.put(inputStream, remotePath);
        }
    } catch (SftpException e) {
        throw new Exception("上传失败: " + e.getMessage(), e);
    }
}

5.4.3 并发控制

  • 限制同时上传/下载数量
  • 队列管理机制
  • 任务取消功能

5.4.4 临时文件清理

  • SFTP间传输后删除临时文件
  • 定期清理超时临时文件
  • 使用系统临时目录

实施步骤

  1. 更新SftpService:添加上传、下载、传输方法

  2. 更新LocalFileService添加与SFTP交互的方法

  3. 更新FileController:添加上传、下载、传输接口

  4. 编译测试

    mvn clean compile
    
  5. 启动服务

    mvn spring-boot:run
    

测试验证

1. 上传文件

curl -X POST http://localhost:8080/sftp-manager/api/files/upload \
  -F "file=@test.txt" \
  -F "targetSessionId=local" \
  -F "targetPath=C:/test"

2. 下载文件

curl "http://localhost:8080/sftp-manager/api/files/download?sessionId=local&path=C:/test/test.txt" \
  --output downloaded.txt

3. 服务器间传输

curl -X POST http://localhost:8080/sftp-manager/api/files/transfer \
  -H "Content-Type: application/json" \
  -d '{
    "sourceSessionId": "sftp-uuid1",
    "sourcePath": "/home/source.txt",
    "targetSessionId": "sftp-uuid2",
    "targetPath": "/home/target/"
  }'

注意事项

  1. 文件大小限制application.yml中配置最大文件大小
  2. 超时设置:大文件传输需要增加超时时间
  3. 磁盘空间:确保目标位置有足够空间
  4. 权限检查:上传/下载前检查文件权限
  5. 临时文件:及时清理临时文件

下一步

完成模块05后继续模块06文件删除功能