591
docs/05-文件上传下载功能.md
Normal file
591
docs/05-文件上传下载功能.md
Normal file
@@ -0,0 +1,591 @@
|
||||
# 模块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
|
||||
- 标准间距:16px(1rem)
|
||||
- 组件内边距: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扩展方法
|
||||
|
||||
```java
|
||||
// 上传文件到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扩展方法
|
||||
|
||||
```java
|
||||
// 上传本地文件到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扩展接口
|
||||
|
||||
```java
|
||||
@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 上传界面
|
||||
|
||||
```html
|
||||
<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 上传实现
|
||||
|
||||
```javascript
|
||||
// 文件选择
|
||||
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 下载实现
|
||||
|
||||
```javascript
|
||||
// 下载文件
|
||||
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 跨服务器传输
|
||||
|
||||
```javascript
|
||||
// 传输到对面面板
|
||||
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 断点续传(高级功能)
|
||||
|
||||
```java
|
||||
// 支持断点的上传
|
||||
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. 上传文件
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/sftp-manager/api/files/upload \
|
||||
-F "file=@test.txt" \
|
||||
-F "targetSessionId=local" \
|
||||
-F "targetPath=C:/test"
|
||||
```
|
||||
|
||||
### 2. 下载文件
|
||||
|
||||
```bash
|
||||
curl "http://localhost:8080/sftp-manager/api/files/download?sessionId=local&path=C:/test/test.txt" \
|
||||
--output downloaded.txt
|
||||
```
|
||||
|
||||
### 3. 服务器间传输
|
||||
|
||||
```bash
|
||||
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:文件删除功能
|
||||
Reference in New Issue
Block a user