大文件上传系统开发指南(基于原生JS+SpringBoot)
项目概述
大家好,我是一个陕西的Java程序员,最近接了个"刺激"的外包项目 - 要开发一个支持20G文件上传下载的系统,还得兼容IE9这种古董浏览器。客户要求用原生JS实现(不能借jQuery的力),还要支持文件夹上传、加密传输、断点续传等高级功能。预算只有100元?没问题,咱们程序员最擅长的就是用爱发电!
技术选型分析
经过深思熟虑(和几根头发的牺牲),我决定采用以下技术方案:
- 前端:Vue3 CLI + 原生JS实现WebUploader功能(兼容IE9)
- 后端:SpringBoot + 阿里云OSS
- 数据库:MySQL(主要存用户信息和文件元数据)
- 加密:前端SM4(国密) + 后端AES双重加密
- 断点续传:基于文件分片和本地存储记录
前端实现(Vue3 + 原生JS)
1. 兼容IE9的文件夹上传组件
export default {
name: 'FileUploader',
data() {
return {
fileList: [],
chunkSize: 5 * 1024 * 1024, // 5MB分片
concurrent: 3 // 并发上传数
}
},
methods: {
triggerFileInput() {
document.getElementById('fileInput').click();
},
handleFileChange(e) {
const files = e.target.files;
if (!files.length) return;
// 处理文件夹结构
const fileTree = this.buildFileTree(files);
this.prepareUpload(fileTree);
},
// 构建文件树结构(保留文件夹层级)
buildFileTree(files) {
const tree = {};
for (let i = 0; i < files.length; i++) {
const file = files[i];
const path = file.webkitRelativePath || file.relativePath || file.name;
const parts = path.split('/');
let currentLevel = tree;
for (let j = 0; j < parts.length - 1; j++) {
const dir = parts[j];
if (!currentLevel[dir]) {
currentLevel[dir] = { __files__: [] };
}
currentLevel = currentLevel[dir];
}
// 添加文件信息
currentLevel.__files__.push({
file: file,
relativePath: path,
size: file.size,
loaded: 0,
progress: 0,
chunks: Math.ceil(file.size / this.chunkSize),
uploadedChunks: 0
});
}
return tree;
},
// 准备上传队列
prepareUpload(fileTree) {
const flattenFiles = [];
// 扁平化文件树(保留路径信息)
const traverse = (node, parentPath = '') => {
for (const key in node) {
if (key === '__files__') {
node[key].forEach(fileItem => {
flattenFiles.push({
...fileItem,
relativePath: parentPath ? `${parentPath}/${fileItem.relativePath}` : fileItem.relativePath
});
});
} else {
const newPath = parentPath ? `${parentPath}/${key}` : key;
traverse(node[key], newPath);
}
}
};
traverse(fileTree);
this.fileList = flattenFiles;
// 开始上传
this.startUpload();
},
// 开始上传(带并发控制)
startUpload() {
let activeUploads = 0;
const uploadNext = () => {
if (activeUploads >= this.concurrent) return;
const fileItem = this.fileList.find(f => f.progress < 100);
if (!fileItem) {
if (activeUploads === 0) {
this.$emit('upload-complete');
}
return;
}
activeUploads++;
this.uploadFile(fileItem).finally(() => {
activeUploads--;
uploadNext();
});
// 立即检查下一个
uploadNext();
};
// 初始启动
for (let i = 0; i < this.concurrent; i++) {
uploadNext();
}
},
// 分片上传文件
async uploadFile(fileItem) {
const file = fileItem.file;
const totalChunks = Math.ceil(file.size / this.chunkSize);
// 检查断点续传信息
const uploadInfo = this.getUploadInfo(fileItem.relativePath);
let startChunk = uploadInfo ? uploadInfo.uploadedChunks : 0;
for (let i = startChunk; i < totalChunks; i++) {
const start = i * this.chunkSize;
const end = Math.min(start + this.chunkSize, file.size);
const chunk = file.slice(start, end);
// 读取分片内容(兼容IE9)
const chunkData = await this.readFileAsArrayBuffer(chunk);
// SM4加密(前端加密)
const encryptedChunk = this.sm4Encrypt(chunkData);
// 上传分片
const formData = new FormData();
formData.append('file', new Blob([encryptedChunk]), file.name);
formData.append('chunkIndex', i);
formData.append('totalChunks', totalChunks);
formData.append('relativePath', fileItem.relativePath);
formData.append('fileSize', file.size);
formData.append('fileMd5', await this.calculateMD5(chunk));
try {
const response = await fetch('/api/upload/chunk', {
method: 'POST',
body: formData
});
if (!response.ok) throw new Error('Upload failed');
// 更新进度
fileItem.uploadedChunks = i + 1;
fileItem.loaded = end;
fileItem.progress = Math.round((fileItem.uploadedChunks / totalChunks) * 100);
// 保存上传进度(使用localStorage)
this.saveUploadInfo(fileItem.relativePath, {
uploadedChunks: fileItem.uploadedChunks,
totalChunks: totalChunks,
fileSize: file.size
});
this.$forceUpdate();
} catch (error) {
console.error('Chunk upload failed:', error);
// 失败后重试当前分片
i--;
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
// 所有分片上传完成,通知合并
if (fileItem.uploadedChunks === totalChunks) {
await fetch('/api/upload/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
relativePath: fileItem.relativePath,
fileSize: file.size,
totalChunks: totalChunks
})
});
// 清除本地存储的上传信息
this.clearUploadInfo(fileItem.relativePath);
}
},
// 兼容IE9的文件读取方法
readFileAsArrayBuffer(file) {
return new Promise((resolve, reject) => {
if (typeof FileReader === 'undefined') {
// IE9 fallback
const reader = new ActiveXObject("Scripting.FileSystemObject");
const stream = new ActiveXObject("ADODB.Stream");
stream.Type = 1; // binary
stream.Open();
stream.LoadFromFile(file);
const arrayBuffer = stream.Read();
stream.Close();
resolve(arrayBuffer);
} else {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsArrayBuffer(file);
}
});
},
// 简化的SM4加密(实际项目中应该使用成熟的加密库)
sm4Encrypt(data) {
// 这里应该是真正的SM4加密实现
// 为了示例,我们只是返回原始数据(实际项目中不要这样做!)
console.warn('实际项目中请替换为真正的SM4加密实现');
return data;
},
// 计算MD5(用于分片校验)
calculateMD5(file) {
return new Promise((resolve) => {
// 实际项目中应该使用真正的MD5计算
// 这里简化为固定值(实际项目中不要这样做!)
resolve('dummy-md5-hash');
});
},
// 断点续传相关方法(使用localStorage)
getUploadInfo(relativePath) {
const key = `upload_progress_${relativePath}`;
const data = localStorage.getItem(key);
return data ? JSON.parse(data) : null;
},
}
}
后端实现(SpringBoot)
1. 文件上传控制器
@RestController
@RequestMapping("/api/upload")
public class FileUploadController {
@Autowired
private FileChunkService fileChunkService;
@Autowired
private OSSClient ossClient;
@Value("${oss.bucketName}")
private String bucketName;
// 上传分片
@PostMapping("/chunk")
public ResponseEntity uploadChunk(
@RequestParam("file") MultipartFile file,
@RequestParam("chunkIndex") int chunkIndex,
@RequestParam("totalChunks") int totalChunks,
@RequestParam("relativePath") String relativePath,
@RequestParam("fileSize") long fileSize,
@RequestParam("fileMd5") String fileMd5) {
try {
// AES解密(后端解密)
byte[] decryptedBytes = AesUtil.decrypt(file.getBytes(), "your-secret-key-123");
// 保存分片到临时目录
String tempDir = System.getProperty("java.io.tmpdir") + "/upload_chunks/" + fileMd5;
File chunkFile = new File(tempDir + "/" + chunkIndex);
Files.createParentDirs(chunkFile);
Files.write(decryptedBytes, chunkFile);
// 记录分片信息到数据库
FileChunk chunk = new FileChunk();
chunk.setFileMd5(fileMd5);
chunk.setChunkIndex(chunkIndex);
chunk.setTotalChunks(totalChunks);
chunk.setRelativePath(relativePath);
chunk.setFileSize(fileSize);
chunk.setUploadTime(new Date());
fileChunkService.save(chunk);
return ResponseEntity.ok().build();
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage());
}
}
// 合并分片
@PostMapping("/merge")
public ResponseEntity mergeChunks(@RequestBody MergeRequest request) {
try {
String fileMd5 = request.getFileMd5();
String tempDir = System.getProperty("java.io.tmpdir") + "/upload_chunks/" + fileMd5;
// 检查所有分片是否已上传
List chunks = fileChunkService.findByFileMd5(fileMd5);
if (chunks.size() != request.getTotalChunks()) {
return ResponseEntity.badRequest().body("Not all chunks uploaded");
}
// 创建临时合并文件
File mergedFile = new File(tempDir + "/merged_" + System.currentTimeMillis());
try (FileOutputStream fos = new FileOutputStream(mergedFile);
BufferedOutputStream mergingStream = new BufferedOutputStream(fos)) {
// 按顺序合并分片
for (int i = 0; i < request.getTotalChunks(); i++) {
File chunkFile = new File(tempDir + "/" + i);
Files.copy(chunkFile, mergingStream);
}
}
// 计算合并后文件的MD5(校验用)
String actualMd5 = DigestUtils.md5DigestAsHex(new FileInputStream(mergedFile));
if (!actualMd5.equals(fileMd5)) {
return ResponseEntity.badRequest().body("File MD5 mismatch");
}
// 上传到OSS(保留路径结构)
String ossKey = "uploads/" + request.getRelativePath();
ossClient.putObject(bucketName, ossKey, mergedFile);
// 保存文件元数据到数据库
FileInfo fileInfo = new FileInfo();
fileInfo.setRelativePath(request.getRelativePath());
fileInfo.setFileSize(request.getFileSize());
fileInfo.setOssKey(ossKey);
fileInfo.setUploadTime(new Date());
fileInfo.setLastModified(new Date());
fileInfoService.save(fileInfo);
// 清理临时文件
FileUtils.deleteDirectory(new File(tempDir));
return ResponseEntity.ok().build();
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage());
}
}
}
2. 文件下载控制器
@RestController
@RequestMapping("/api/download")
public class FileDownloadController {
@GetMapping("/info")
public ResponseEntity> getFileInfo(@RequestParam String path) {
List files = fileInfoService.findByPathPrefix(path);
return ResponseEntity.ok(files.stream()
.map(this::convertToDTO)
.collect(Collectors.toList()));
}
// 分片下载文件(大文件支持)
@GetMapping("/file")
public ResponseEntity downloadFile(
@RequestParam String ossKey,
@RequestParam(required = false) Long start,
@RequestParam(required = false) Long end) {
try {
// 从OSS获取文件对象
OSSObject ossObject = ossClient.getObject(bucketName, ossKey);
// 如果请求了范围下载
if (start != null && end != null) {
InputStream inputStream = ossObject.getObjectContent();
long contentLength = end - start + 1;
// 跳过前面的字节
inputStream.skip(start);
// 创建限制长度的输入流
InputStream limitedStream = new LimitedInputStream(inputStream, contentLength);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + URLEncoder.encode(ossKey.substring(ossKey.lastIndexOf('/') + 1), "UTF-8") + "\"")
.header(HttpHeaders.CONTENT_RANGE, "bytes " + start + "-" + end + "/*")
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.contentLength(contentLength)
.body(new InputStreamResource(limitedStream));
} else {
// 完整文件下载
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + URLEncoder.encode(ossKey.substring(ossKey.lastIndexOf('/') + 1), "UTF-8") + "\"")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(new InputStreamResource(ossObject.getObjectContent()));
}
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}
数据库设计
-- 文件信息表
CREATE TABLE file_info (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
relative_path VARCHAR(1000) NOT NULL COMMENT '文件相对路径(保留层级结构)',
file_size BIGINT NOT NULL COMMENT '文件大小(字节)',
oss_key VARCHAR(500) NOT NULL COMMENT 'OSS存储key',
upload_time DATETIME NOT NULL COMMENT '上传时间',
last_modified DATETIME NOT NULL COMMENT '最后修改时间',
UNIQUE KEY uk_relative_path (relative_path(255))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 文件分片表(用于断点续传)
CREATE TABLE file_chunk (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
file_md5 VARCHAR(32) NOT NULL COMMENT '文件MD5(作为唯一标识)',
chunk_index INT NOT NULL COMMENT '分片索引',
total_chunks INT NOT NULL COMMENT '总分片数',
relative_path VARCHAR(1000) NOT NULL COMMENT '文件相对路径',
file_size BIGINT NOT NULL COMMENT '文件总大小',
upload_time DATETIME NOT NULL COMMENT '上传时间',
INDEX idx_file_md5 (file_md5)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
项目部署说明
-
前端部署:
- 使用Vue CLI构建生产版本:
npm run build - 将生成的
dist目录内容部署到Nginx或Apache
- 使用Vue CLI构建生产版本:
-
后端部署:
- 使用Maven打包:
mvn clean package - 生成JAR文件后上传到阿里云ECS
- 使用
java -jar命令运行,或配置为系统服务
- 使用Maven打包:
-
Nginx配置示例(支持大文件上传):
server {
listen 80;
server_name your-domain.com;
client_max_body_size 102400m; # 100GB
proxy_read_timeout 600s;
proxy_send_timeout 600s;
location / {
root /path/to/frontend/dist;
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
兼容性处理要点
-
IE9兼容性:
- 使用
ActiveXObject替代FileReader - 避免使用ES6+语法
- 使用
fetch的polyfill或改用XMLHttpRequest
- 使用
-
文件夹上传:
- 利用
webkitdirectory属性(Chrome等) - IE中使用``并手动解析路径
- 利用
-
加密处理:
- 前端使用SM4(国密算法)加密
- 后端使用AES二次加密
- 提供加密开关配置
开发建议
-
分阶段开发:
- 第一阶段:实现基本文件上传下载
- 第二阶段:添加文件夹支持
- 第三阶段:实现断点续传
- 第四阶段:添加加密功能
-
测试要点:
- 大文件上传(>5GB)
- 网络中断后恢复上传
- 文件夹层级结构验证
- 跨浏览器兼容性测试
-
性能优化:
- 分片大小调整(5MB-10MB比较合适)
- 并发上传数控制(3-5个并发)
- 使用Web Worker处理加密计算
完整项目结构
large-file-upload/
├── frontend/ # 前端Vue3项目
│ ├── src/
│ │ ├── components/
│ │ │ └── FileUploader.vue
│ │ ├── App.vue
│ │ └── main.js
│ ├── public/
│ └── package.json
├── backend/ # 后端SpringBoot项目
│ ├── src/
│ │ ├── main/
│ │ │ ├── java/com/example/upload/
│ │ │ │ ├── controller/
│ │ │ │ ├── service/
│ │ │ │ ├── model/
│ │ │ │ └── Application.java
│ │ │ └── resources/
│ │ │ ├── application.yml
│ │ │ └── application-dev.yml
│ └── pom.xml
├── docs/ # 开发文档
│ ├── api.md
│ ├── deployment.md
│ └── compatibility.md
└── README.md
最后的话
兄弟,这个项目确实有点挑战性,特别是100元预算还要兼容IE9。不过咱们程序员不就是喜欢挑战吗?我建议:
- 先实现核心功能(文件上传下载)
- 再逐步添加高级功能
- 重点测试断点续传和文件夹结构保留
- 加密功能可以先用简化版,后续再完善
我已经提供了核心代码框架,你可以基于这个继续开发。如果遇到具体问题,欢迎加入我们的QQ群(374992201)交流,群里大佬众多,说不定能找到帮你调试的兄弟。
记住,咱们虽然预算有限,但志气不能限!用爱发电,照亮代码之路!💪🔥
导入项目
导入到Eclipse:点南查看教程
导入到IDEA:点击查看教程
springboot统一配置:点击查看教程
工程

NOSQL
NOSQL示例不需要任何配置,可以直接访问测试

创建数据表
选择对应的数据表脚本,这里以SQL为例


修改数据库连接信息

访问页面进行测试

文件存储路径
up6/upload/年/月/日/guid/filename


效果预览
文件上传

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

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


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



