开发者日记:2023年11月25日 周六 阴
项目名称:跨平台大文件传输系统(WebUploader+Vue3+SpringBoot+腾讯云COS)
项目背景与核心挑战
作为河南独立开发者,近期承接了一个高复杂度外包项目,客户要求实现20G级文件/文件夹跨浏览器上传下载,技术栈涉及:
- 前端:Vue3(TypeScript)+ WebUploader(兼容IE8)
- 后端:SpringBoot 3.0 + Oracle 21c
- 存储:腾讯云COS(需处理分片上传与合并)
- 兼容性:从IE8到现代浏览器(Chrome/Firefox/Safari/Edge/Opera)
现存痛点:
- 网上开源方案仅支持单文件上传,无完整文件夹层级解析逻辑
- IE8的ActiveX控件在Windows 11上频繁崩溃
- Oracle数据库与MySQL的序列化差异导致进度存储失败
- 20G文件传输时,SpringBoot默认超时时间(1分钟)严重不足
技术方案设计
前端架构(Vue3 + WebUploader)
graph TD
A[用户选择文件/文件夹] --> B{浏览器类型}
B -->|Chrome/Firefox| C[使用FileSystemDirectoryHandle API]
B -->|IE8| D[调用ActiveX控件递归读取]
C & D --> E[生成文件树结构(JSON)]
E --> F[计算文件ETag(替代MD5,兼容IE8)]
F --> G[分片上传(WebUploader)]
G --> H[本地存储进度(IndexedDB+localStorage)]
后端架构(SpringBoot + Oracle)
graph TD
A[接收分片] --> B{是否首片}
B -->|是| C[创建Oracle任务记录(序列+BLOB)]
B -->|否| D[更新分片进度(JDBC批处理)]
C & D --> E[存储分片到临时目录]
E --> F{是否全部分片完成}
F -->|是| G[合并文件并上传COS]
F -->|否| H[返回继续上传指令]
关键数据库设计(Oracle)
-- 上传任务表
CREATE TABLE UPLOAD_TASK (
TASK_ID VARCHAR2(36) PRIMARY KEY,
FILE_ETAG VARCHAR2(64) NOT NULL,
RELATIVE_PATH VARCHAR2(1024), -- 保留文件夹层级(如 /project/src/)
TOTAL_CHUNKS NUMBER,
UPLOADED_CHUNKS NUMBER DEFAULT 0,
STATUS VARCHAR2(20) CHECK (STATUS IN ('PENDING','UPLOADING','COMPLETED','FAILED')),
COS_KEY VARCHAR2(1024),
CREATE_TIME TIMESTAMP DEFAULT SYSTIMESTAMP
);
-- 分片存储表(Oracle BLOB优化)
CREATE TABLE UPLOAD_CHUNK (
CHUNK_ID VARCHAR2(72) PRIMARY KEY, -- TASK_ID+CHUNK_INDEX
TASK_ID VARCHAR2(36) REFERENCES UPLOAD_TASK(TASK_ID),
CHUNK_INDEX NUMBER,
CHUNK_DATA BLOB,
CONSTRAINT UK_TASK_CHUNK UNIQUE (TASK_ID, CHUNK_INDEX)
);
核心代码实现
前端:文件夹上传与断点续传(Vue3 + TypeScript)
// src/components/FolderUploader.vue
import { ref, onMounted } from 'vue';
import WebUploader from 'webuploader';
import { calculateFileETag } from '@/utils/fileHash'; // 自定义ETag计算(兼容IE8)
export default {
setup() {
const taskList = ref>([]);
const uploader = ref(null);
// 初始化上传器(兼容IE8)
const initUploader = () => {
const isIE8 = document.documentMode === 8;
uploader.value = WebUploader.create({
swf: '/static/Uploader.swf', // IE8回退
server: '/api/upload/chunk',
chunked: true,
chunkSize: isIE8 ? 4 * 1024 * 1024 : 20 * 1024 * 1024, // IE8限制4MB分片
threads: isIE8 ? 1 : 5,
formData: {
taskId: localStorage.getItem('currentTaskId') || ''
},
timeout: 0 // 禁用超时(由后端控制)
});
// 恢复未完成任务(从IndexedDB)
restoreTasksFromDB();
};
// 递归解析文件夹(跨浏览器)
const handleFolderSelect = async (e: Event) => {
const input = e.target as HTMLInputElement;
const files = input.files;
if (!files?.length) return;
const parseFolder = async (entries: FileSystemEntry[], parentPath = '') => {
for (let entry of entries) {
if (entry.isFile) {
const file = (entry as FileSystemFileEntry).file!;
const relativePath = parentPath ? `${parentPath}/${entry.name}` : entry.name;
await addUploadTask(file, relativePath);
} else if (entry.isDirectory) {
const dirReader = (entry as FileSystemDirectoryEntry).createReader();
const newEntries = await new Promise(resolve => {
dirReader.readEntries(resolve);
});
await parseFolder(newEntries, parentPath ? `${parentPath}/${entry.name}` : entry.name);
}
}
};
// Chrome/Firefox使用showDirectoryPicker
if (files[0].webkitGetAsEntry) {
const entry = files[0].webkitGetAsEntry();
if (entry?.isDirectory) {
const dirReader = entry.createReader();
const entries = await new Promise(resolve => {
dirReader.readEntries(resolve);
});
await parseFolder(entries);
} else {
await addUploadTask(files[0], files[0].name);
}
}
// IE8使用ActiveX(需用户授权)
else if (window.ActiveXObject) {
// ActiveX实现代码省略(需处理权限弹窗)
}
};
// 添加上传任务
const addUploadTask = async (file: File, relativePath: string) => {
const etag = await calculateFileETag(file); // 使用CRC32+文件大小替代MD5
const task = {
file,
relativePath,
etag,
chunkCount: Math.ceil(file.size / uploader.value!.options.chunkSize),
uploadedChunks: 0
};
// 检查本地是否有未完成记录
const existingTask = await getTaskFromDB(etag);
if (existingTask) {
task.uploadedChunks = existingTask.uploadedChunks;
}
taskList.value.push(task);
saveTaskToDB(task);
startUpload(task);
};
onMounted(() => {
initUploader();
document.getElementById('folderInput')?.addEventListener('change', handleFolderSelect);
});
return { taskList, uploader };
}
};
后端:SpringBoot分片处理与COS上传
// src/main/java/com/example/uploader/controller/UploadController.java
@RestController
@RequestMapping("/api/upload")
public class UploadController {
@Autowired
private UploadTaskRepository taskRepository;
@Autowired
private UploadChunkRepository chunkRepository;
@Autowired
private COSClient cosClient; // 腾讯云COS客户端
// 分片上传接口
@PostMapping("/chunk")
public ResponseEntity handleChunk(
@RequestParam("file") MultipartFile file,
@RequestParam("taskId") String taskId,
@RequestParam("chunkIndex") int chunkIndex,
@RequestParam("totalChunks") int totalChunks,
@RequestParam("etag") String etag,
@RequestParam("relativePath") String relativePath) {
// 1. 查询或创建任务
UploadTask task = taskRepository.findById(taskId)
.orElseGet(() -> {
UploadTask newTask = new UploadTask();
newTask.setTaskId(taskId);
newTask.setFileEtag(etag);
newTask.setRelativePath(relativePath);
newTask.setTotalChunks(totalChunks);
newTask.setStatus("UPLOADING");
return taskRepository.save(newTask);
});
// 2. 保存分片到Oracle BLOB
UploadChunk chunk = new UploadChunk();
chunk.setChunkId(taskId + "_" + chunkIndex);
chunk.setTaskId(taskId);
chunk.setChunkIndex(chunkIndex);
try {
chunk.setChunkData(file.getBytes());
chunkRepository.save(chunk);
} catch (IOException e) {
return ResponseEntity.status(500).body("分片保存失败");
}
// 3. 更新任务进度
task.setUploadedChunks(chunkIndex + 1);
taskRepository.save(task);
// 4. 检查是否全部上传完成
if (task.getUploadedChunks() >= task.getTotalChunks()) {
// 启动异步合并任务
mergeAndUploadToCOS(task);
return ResponseEntity.ok("{\"status\":\"COMPLETED\"}");
}
return ResponseEntity.ok("{\"status\":\"SUCCESS\"}");
}
// 异步合并并上传COS
@Async
public void mergeAndUploadToCOS(UploadTask task) {
try {
// 1. 从Oracle读取所有分片
List chunks = chunkRepository.findByTaskIdOrderByChunkIndex(task.getTaskId());
// 2. 合并文件(流式处理避免内存溢出)
Path tempFile = Files.createTempFile("upload_", ".tmp");
try (OutputStream out = Files.newOutputStream(tempFile)) {
for (UploadChunk chunk : chunks) {
out.write(chunk.getChunkData());
}
}
// 3. 上传到腾讯云COS
String cosKey = "uploads/" + task.getTaskId() + "/" + task.getRelativePath();
cosClient.putObject(new PutObjectRequest("your-bucket", cosKey, tempFile.toFile()));
// 4. 更新任务状态
task.setStatus("COMPLETED");
task.setCosKey(cosKey);
taskRepository.save(task);
// 5. 清理临时文件和分片
Files.deleteIfExists(tempFile);
chunkRepository.deleteByTaskId(task.getTaskId());
} catch (Exception e) {
task.setStatus("FAILED");
taskRepository.save(task);
}
}
}
关键问题解决
-
IE8兼容性:
- 使用
标签替代已废弃的加载Uploader.swf - 通过
document.execCommand('SaveAs')实现IE8的下载功能
- 使用
-
Oracle性能优化:
- 对
UPLOAD_CHUNK表的TASK_ID字段建立索引 - 使用JDBC批处理更新进度(
addBatch()+executeBatch())
- 对
-
大文件传输超时:
- 在SpringBoot配置中增加:
server: tomcat: connection-timeout: 0 # 禁用连接超时 spring: servlet: multipart: max-file-size: 20GB max-request-size: 20GB
- 在SpringBoot配置中增加:
-
文件夹层级保留:
- 前端传递
relativePath参数(如/docs/2023/report.pdf) - 后端直接存储该路径,上传COS时保持原样
- 前端传递
求助与社区支持
目前遇到以下难题,已在QQ群(374992201)发布详细日志:
- IE8的ActiveX控件在Windows 11上频繁弹出权限警告
- Oracle BLOB存储20G文件时出现
ORA-01653表空间不足错误 - SpringBoot异步任务在合并大文件时被K8s容器终止
完整代码仓库:
- 前端:https://gitee.com/yourname/vue3-folder-uploader
- 后端:https://gitee.com/yourname/springboot-cos-uploader
明日计划:
- 实现WebUploader的IE8进度条显示
- 编写Oracle分片表的分区策略(按任务ID哈希分区)
- 测试20G文件在低带宽(2Mbps)下的传输稳定性
(日记结束)
附:技术栈对比表
| 模块 | 原方案 | 当前方案 | 优化点 |
|---|---|---|---|
| 前端框架 | jQuery | Vue3 + TypeScript | 类型安全+组件化 |
| 上传组件 | WebUploader基础版 | 定制版(支持文件夹+ETag) | 递归解析文件夹结构 |
| 后端语言 | JSP | SpringBoot 3.0 | 响应式编程+Oracle优化 |
| 数据库 | MySQL | Oracle 21c | BLOB存储优化+分区表 |
| 对象存储 | 百度OBS | 腾讯云COS | 适配不同的分片API规范 |
如需完整项目或调试协助,请联系QQ群或留言获取测试账号!
SQL示例
创建数据库

配置数据库连接

自动下载maven依赖

启动项目

启动成功

访问及测试
默认页面接口定义

在浏览器中访问

数据表中的数据

效果预览
文件上传

文件刷新续传
支持离线保存文件进度,在关闭浏览器,刷新浏览器后进行不丢失,仍然能够继续上传

文件夹上传
支持上传文件夹并保留层级结构,同样支持进度信息离线保存,刷新页面,关闭页面,重启系统不丢失上传进度。

批量下载
支持文件批量下载

下载续传
文件下载支持离线保存进度信息,刷新页面,关闭页面,重启系统均不会丢失进度信息。

文件夹下载
支持下载文件夹,并保留层级结构,不打包,不占用服务器资源。

2700

被折叠的 条评论
为什么被折叠?



