最近在做一个内部文档管理系统,需求方给了一堆按部门、年份、项目名层层归档的文件夹,要求原样搬到服务器上。一开始我用的是WebUploader插件,分块上传、断点续传都现成,但真正落到文件夹上传时就傻眼了:WebUploader默认只能把单个文件平铺传上去,目录结构完全没了。折腾了一周,终于扩展出一个支持文件夹目录结构的分块上传方案。这里把完整思路和踩坑过程整理出来,尤其是后端合并时怎么保留路径、怎么避免高保密文档传输中的权限和审计漏洞,给同样做内部系统的人一个参考。
先说明一下,虽然标题里写的是某个特殊行业场景,但思路完全通用。任何需要保留文件夹层级、又有严格安全要求的文档上传需求,都可以按照这套方案落地。
1. 需求拆解与方案选型:为什么WebUploader需要扩展
1.1 需求场景还原
这类需求通常出现在大型企业内部或科研协作系统:业务部门把资料按固定规则整理成树状目录,比如/部门A/2025年度/项目X/立项文档/xxx.docx,然后在Web端一次性选择整个文件夹上传。如果系统接收后是平铺文件列表,那么文件关联、检索、权限继承全都要重做,业务方根本不接受。
需求可以拆成四点:
- 支持选择整个文件夹,而不是手动一个个选文件。
- 上传过程中自动跳过空目录(空目录没有文件,没必要创建)。
- 分块上传不能破坏文件完整性,断点续传要继续工作。
- 最终在服务器端完整还原出文件对应的目录树。
其中第四点是核心。WebUploader本身提供文件队列、分块、并发控制、进度展示,甚至MD5秒传能力,这些都非常成熟。它唯一缺的,就是“文件夹级的上传入口”和“相对路径传递通道”。所以我们不需要扔掉它重写一套,只需要在它的队列模型外面加一层路径映射。
1.2 WebUploader核心能力盘点
WebUploader的完整能力包括文件筛选、多线程上传、分块传输、断点续传(依赖localStorage记录已上传分块)、重试机制、进度条管理等。它的分块思路很经典:
- 大文件切成若干个chunk,每个chunk单独发起请求。
- 后端接收到全部chunk后,再按顺序合并成完整文件。
- 每个分块请求都带相同的文件唯一标识(通常是MD5或客户端生成的fileKey),后端据此找到对应分块目录。
- 已上传分块会被记录,再次上传时跳过。
这个机制的好处是大文件失败后不需要重传全部内容,坏处是它默认按“文件”维度组织,没有预留“文件原来所在目录”的字段。我们的扩展点就在这里:想办法给每个文件绑定一个相对路径,然后在每个分块请求中带上这个路径,后端合并分块时按路径创建目录。
1.3 扩展的总体思路
一句话版本:不修改WebUploader源码,而是通过监听它暴露的生命周期事件,把文件夹里每个文件的webkitRelativePath属性保存到文件对象的自定义字段上,再在上传请求的formData中注入relativePath参数,后端收到后在合并阶段以该参数作为目录拼接依据。
这套方案的好处是侵入性极低,只依赖前端标准方法和插件事件,后续WebUploader升级也不会被覆盖掉。坏处是需要后端配合改造,否则前端传了路径也没有意义。所以下文会花不少篇幅讲后端接口该怎么设计。
2. 目录结构保留与分块上传的核心原理
2.1 分块上传到底在传什么
分块上传并不神秘,本质就是把大文件拆成一个一个小块,逐个请求上传。服务端接收到这些小块后,不是立刻合并,而是先存到一个临时目录,等全部块到齐了再合并。
// 前端分块参数示意 chunked: true, chunkSize: 2 * 1024 * 1024 // 每块2MB如果文件是10MB,就会被拆成5个块。服务端需要知道每个块属于哪个文件,以及块的顺序。所以每个请求要携带fileKey、chunkIndex、totalChunks等信息。WebUploader在分块模式下会自动生成这些参数,并随请求发送,这部分我们不需要改。
但文件所属的目录信息是额外的,WebUploader没有内置字段。所以我们得在uploadBeforeSend事件里插入自己的参数。这个事件会在每个分块发送前触发,接收两个参数:file和data,data就是即将发送的请求体字典。我们往这个字典里加一个relativePath字段,服务端就能拿到。
2.2 webkitRelativePath:文件夹选择的突破口
浏览器里的<input type="file">支持webkitdirectory属性。设置该属性后,用户选择的不再是单个文件,而是整个文件夹,浏览器会把文件夹内所有文件展开返回。
每个文件对象上会多出一个webkitRelativePath属性,内容就是相对于所选择文件夹的完整路径,例如项目A/立项文档/申报书.docx。这个属性不是标准属性,但现代主流浏览器都支持,包括Chrome、Edge、Firefox、新版Opera,Safari在较新版本中也已经支持。实测下来Chrome和Firefox最稳。
看一下基础用法:
<input type="file" id="folderPicker" webkitdirectory directory multiple />document.getElementById('folderPicker').addEventListener('change', function(e) { const files = Array.from(e.target.files); console.log(files.map(f => f.webkitRelativePath)); });选择项目A文件夹后,会得到类似["项目A/立项文档/申报书.docx", "项目A/立项文档/预算表.xlsx", "项目A/评审意见.pdf"]这样的路径列表。
这一步是整个扩展方案的技术基石。有了相对路径,目录结构就能保留;没有它,就只能在服务端靠文件名模糊归类,非常不可靠。
2.3 服务端分块模型设计
既然要做到按目录分块上传,服务端就不能只按fileKey存分块了。更合理的模型是增加一个路径字段。
这里我推荐用三个层级的数据结构:
- 上传任务表:记录一次文件上传的完整上下文,包括fileKey、原始文件名、相对路径、总分块数、文件大小、MD5值、所属用户、任务状态。
- 分块记录表:记录每个分块的上传状态,字段包括fileKey、chunkIndex、分块大小、上传时间、存储路径。
- 文件信息表:合并成功后的最终文件记录,包含相对路径、实际存储路径、文件大小、创建人、访问权限标识。
每次分块请求到达服务端时,先校验任务是否存在,如果不存在则基于relativePath创建任务。分块文件存储在临时上传目录中,目录的命名可以用fileKey,这样不同任务不会混淆。全部块上传完成后,触发合并接口,服务端解析relativePath,提取出目录前缀,在正式存储目录下逐级创建目录,然后把临时分块文件合并到目标路径。
需要注意一个容易被忽视的问题:relativePath来自客户端,不可信。服务端必须做合法性校验,至少要防止路径穿越,否则用户传一个../../就可能覆盖系统其它目录。校验方法后面会详细讲。
3. jQuery环境下的WebUploader扩展实战
3.1 初始化基础上传组件
项目里用的还是jQuery环境,WebUploader本身也依赖jQuery,所以集成很顺畅。先引入JS和CSS,然后初始化一个上传器。
var uploader = WebUploader.Uploader({ pick: { id: '#filePicker', multiple: true }, accept: { title: '文档', extensions: 'doc,docx,xls,xlsx,pdf,zip,rar' }, auto: false, chunked: true, chunkSize: 2 * 1024 * 1024, threads: 3, fileNumLimit: 1000, duplicate: true });注意这里auto: false,因为我们要等用户选完文件夹后手动触发上传,而不是选完立刻传。chunked开启分块,threads控制同时上传的分块数,建议不要设置太大,否则后端并发压力会很大,尤其是大文件夹场景。
3.2 自定义文件夹选择:绕过原生的文件选择器
WebUploader的基础功能是文件选择,没有文件夹选择入口。所以我直接隐藏掉默认picker,另外放一个“选择文件夹”按钮,内部挂一个原生文件夹input。
HTML结构大致是这样:
<div id="folderPickerBtn" class="btn">选择文件夹</div> <input type="file" id="folderInput" webkitdirectory directory multiple style="display:none;" />然后监听按钮点击,触发文件夹选择:
$('#folderPickerBtn').on('click', function() { $('#folderInput').trigger('click'); });这一步看起来简单,但有个细节:同一个input在第二次选择相同文件夹时可能不会触发change事件,因为文件列表没有变化。为了保证每次点击都能响应,我习惯在点击时自动替换input节点,或者将input.value清空。
$('#folderPickerBtn').on('click', function() { var input = $('#folderInput'); input.val(''); input.trigger('click'); });清空value再触发,才能保证重复选择同一个文件夹也能进入change事件。
3.3 将文件夹文件注入WebUploader队列并绑定路径
关键代码在这里。在input的change事件里,拿到文件列表,给每个文件对象绑定一个自定义的relativePath属性,然后调用uploader.addFiles加入队列。
$('#folderInput').on('change', function(e) { var files = Array.from(e.target.files); if (!files.length) return; // 建立原文件路径和文件名映射 var fileList = files.map(function(f) { // WebUploader封装后的文件对象会以sourceFile作为原始引用 return f; }); // 先存文件对象,入队后再通过sourceFile取相对路径 uploader.addFiles(fileList); // 自动开始上传,也可以交给用户手动点 uploader.upload(); });这里存在一个兼容性问题:uploader.addFiles()接受的参数是File对象数组,但WebUploader把对象包装后,原始File对象被放置在file.sourceFile里。所以比较稳妥的做法是等文件入队后,在fileQueued事件里读取file.sourceFile.webkitRelativePath并保存到file.relativePath自定义属性上。
uploader.on('fileQueued', function(file) { var source = file.sourceFile; if (source && source.webkitRelativePath) { file.relativePath = source.webkitRelativePath; } else { // 兼容单个文件选择场景,没有相对路径就用文件名 file.relativePath = file.name; } });这样处理后,队列中的每个文件对象都拥有了relativePath。这个属性后续会被我们放进请求参数中。
3.4 在分块请求中注入相对路径参数
接下来是核心的第二部:在每个分块上传请求里带上relativePath。WebUploader提供了uploadBeforeSend事件,在每次分块请求发送之前触发。
uploader.on('uploadBeforeSend', function(file, data) { data.relativePath = file.relativePath; data.fileKey = file.id; // 确保每个文件的fileKey一致 data.size = file.size; });data对象最终会被序列化为请求的form data,所以后端可以从request参数里拿到relativePath。
这里我额外加了一个fileKey。WebUploader默认有内置的文件id,但那个id是文件入队时分配的,用来关联分块完全没问题。我们也把它一起传上去,方便后端按fileKey归档分块。
如果还需要传递用户身份,比如安全场景下的审计需求,可以在uploadBeforeSend里继续加字段:
uploader.on('uploadBeforeSend', function(file, data) { data.relativePath = file.relativePath; data.fileKey = file.id; data.userId = currentUser.id; data.token = currentUser.token; });上传接口是受控接口,每次请求都要校验令牌,防止未授权用户直接调接口传文件。
3.5 合并接口与目录树创建
分块上传完成后,前端调用合并接口。这个合并接口是整个方案的关键。
uploader.on('uploadFinished', function() { var files = uploader.getFiles(); $.each(files, function(i, file) { // 文件已完成上传,通知后端合并 $.ajax({ url: '/api/merge', method: 'POST', data: { fileKey: file.id, relativePath: file.relativePath, fileName: file.name, totalChunks: file.chunks ? file.chunks.length : 1 }, dataType: 'json' }); }); });后端合并的核心逻辑可以用伪代码表示。这里以Java为例,因为内部系统用Java较多:
Path root = Paths.get("/data/uploads").toAbsolutePath().normalize(); Path target = root.resolve(relativePath).normalize(); if (!target.getParent().startsWith(root)) { throw new SecurityException("非法路径"); } Files.createDirectories(target.getParent()); // 将临时分块按顺序合并 try (FileChannel out = FileChannel.open(target, CREATE, WRITE)) { for (int i = 0; i < totalChunks; i++) { Path chunkPath = tempDir.resolve(fileKey + "_" + i); try (FileChannel in = FileChannel.open(chunkPath, READ)) { for (long p = 0; p < in.size(); ) { p += in.transferTo(p, in.size() - p, out); } } } }relativePath.normalize()后再做startsWith校验,能有效防止路径穿越攻击。比如客户端传了../../etc/passwd,通过normalize和startsWith就能拦截掉。
3.6 完整的前端调用时序
串起来看,整个流程是:
- 用户点击“选择文件夹”,触发原生input。
- input的change事件拿到FileList。
- uploader.addFiles加入队列,fileQueued里记录relativePath。
- 调用uploader.upload()开始上传。
- 每发一个分块,uploadBeforeSend把relativePath和fileKey写入请求参数。
- 全部块上传完成后,uploadFinished循环调用合并接口。
- 后端校验路径后按目录创建文件。
- 前端根据返回值刷新文件列表页面。
4. 高保真目录还原中的常见问题与排查实录
4.1 问题一:选择文件夹后文件队列为空
这个原因通常是fileQueued事件监听在addFiles之后才注册,导致漏掉第一批文件入队事件。WebUploader的addFiles是异步入队的,监听必须提前绑定。
排查顺序建议:
- 检查
uploader.addFiles是否真的传入了文件数组。 - 检查
fileQueued事件是否在页面初始化时就绑定。 - 检查input是否带有
webkitdirectory属性。
我遇到过最典型的坑是网页控制台看不到文件对象,原因是input的webkitdirectory属性写成了webkit-directory,或者没有在原生DOM上设置,只设置了jQuery的attr。用attr设置没问题,但必须是input元素渲染完成后。
4.2 问题二:目录结构错乱,文件全部堆在根目录
这个是典型的relativePath没有传递成功。最常见的原因是在uploadBeforeSend里用file.relativePath取值,但该字段没有保存到file对象上。
我的建议是打印file对象的完整结构,确认sourceFile里有没有webkitRelativePath。注意WebUploader内部文件对象和原生File对象不是同一个,别搞混。另外,如果是通过拖拽方式添加的文件夹,而不是通过input选择,webkitRelativePath很有可能为空,这时候要唤醒用户使用“选择文件夹”入口。
4.3 问题三:分块断点续传后目录信息丢失
WebUploader的断点续传是基于localStorage记录文件MD5和已上传分块编号的。如果用户第一次上传时relativePath字段没保存到本地记录中,刷新页面后队列恢复时,file.relativePath就是undefined。
解决方法是把relativePath一并写入localStorage,或者在后端按fileKey记录相对路径,刷新后通过接口查询。我更推荐后者,因为相对路径属于任务元数据,理应由服务端维护。前端重新入队时,从selectFile事件里匹配到旧fileKey,再回填relativePath。
4.4 问题四:大文件夹并发导致浏览器卡死
一次性加入几千个文件,浏览器渲染进度条会卡。WebUploader的队列机制本身支持懒渲染,但并发上传线程数如果太高,每个分块的进度回调会频繁更新DOM,导致性能骤降。
我的经验是:
threads设置为2到3,不要超过5。- 关闭每个文件的独立进度条,只在底部显示总进度条。
- 分块大小可以提高到5MB甚至10MB,减少请求数量。
- 服务端合并分块时要尽量采用文件通道迁移,不用一条一条读字节,否则大文件合并时I/O会爆炸。
4.5 问题五:保密文档在传输中被篡改或泄露怎么办
虽然标题里的“军工涉密文档”场景不展开说,但任何高安全级别内部系统都必须考虑传输链路、权限校验和审计。这部分虽然是独立于WebUploader的功能,但必须组合进来:
第一,全链路HTTPS,禁止HTTP明文传输。上传接口应该是POST,且带有随机token,防止CSRF。
第二,上传服务端必须做白名单校验,文件名、扩展名、路径都必须通过正则。对内部系统来说,宁可拒绝也不放过。
第三,合并后的文件建议再重新计算MD5,和前端提交的MD5比对,不一致就标记为异常任务并隔离。
第四,记录完整审计日志:谁在什么时间上传了什么路径的文件,每个分块的上传IP和用户ID也记录下来。
第五,如果有条件,在服务端对落地文件做透明加密存储,这样即使磁盘被拖走,文件也无法直接读取。
这些能力可以独立封装成中间件,在uploadBeforeSend里带上审计ID,在接收接口的统一入口处处理,形成完整的上传链路闭环。
5. 后端上传接口与合并接口的关键代码参考
5.1 接收分块请求的接口示例
下面给一个简化的Java接口用于接收分块:
@PostMapping("/api/upload/chunk") public Result uploadChunk( @RequestParam("fileKey") String fileKey, @RequestParam("chunkIndex") Integer chunkIndex, @RequestParam("totalChunks") Integer totalChunks, @RequestParam("relativePath") String relativePath, @RequestParam(value = "token", required = false) String token, MultipartFile chunk) { // 1. 验证token和用户权限 User user = authService.getUser(token); if (user == null) { return Result.error(401, "未认证"); } // 2. 校验相对路径合法性 Path root = Paths.get(uploadRoot).toAbsolutePath().normalize(); Path p = root.resolve(relativePath).normalize(); if (!p.startsWith(root)) { return Result.error(400, "非法上传路径"); } // 3. 存储分块 Path chunkDir = tempDir.resolve(fileKey); Files.createDirectories(chunkDir); chunk.transferTo(chunkDir.resolve(String.valueOf(chunkIndex)).toFile()); // 4. 记录分块信息到数据库 uploadRecordMapper.saveChunk(fileKey, chunkIndex, relativePath, user.getId()); return Result.ok(); }接口看上去简单,但校验顺序不能乱。先鉴权,再验路径,最后落盘。如果先落盘再校验,恶意请求会不断写脏数据。
5.2 合并接口示例
@PostMapping("/api/upload/merge") public Result merge(@RequestParam("fileKey") String fileKey, @RequestParam("relativePath") String relativePath, @RequestParam("totalChunks") Integer totalChunks) { Path root = Paths.get(uploadRoot).toAbsolutePath().normalize(); Path target = root.resolve(relativePath).normalize(); if (!target.getParent().startsWith(root)) { return Result.error(400, "非法路径"); } Files.createDirectories(target.getParent()); ChunkRecord record = uploadRecordMapper.selectByFileKey(fileKey); if (record == null || record.getTotalChunks() != totalChunks) { return Result.error(400, "分块信息不完整"); } // 检查所有分块都存在 Path chunkDir = tempDir.resolve(fileKey); for (int i = 0; i < totalChunks; i++) { if (!Files.exists(chunkDir.resolve(String.valueOf(i)))) { return Result.error(400, "缺少分块" + i); } } // 合并分块 mergeChunks(target, chunkDir, totalChunks); // 清理临时分块目录 deleteRecursively(chunkDir); // 记录审计日志 auditService.log(userId, "UPLOAD_MERGE", fileKey, relativePath); return Result.ok(); }这里有一个实际经验:千万不要用Files.copy逐字节合并分块,性能太差。用FileChannel.transferTo或带缓冲的流,速度可以快几十倍。
5.3 目录重名或文件重名的处理策略
文件夹目录结构还原时,如果目标路径下已经存在同名文件,直接覆盖肯定是危险的。我的建议是:
- 后端存储时,在目标目录中生成
原始文件名_时间戳.ext,避免覆盖。 - 前端队列中显示原始文件名字段,不影响用户对目录结构的认知。
- 如果业务要求不允许重名,则在合并前检查目标文件存在就返回冲突,并把冲突信息返回前端提示用户。
业务上取舍取决于需求,但从工程安全角度,保留两个副本总比悄悄覆盖好。
6. 从一次生产事故中总结的优化建议
有次在生产环境上线这个功能之后,某个部门一次性上传了三千多个文件,总计约40GB。结果上传过程倒是平稳,但合并阶段把服务器磁盘IO跑满了,导致其它业务接口超时。后来复盘优化了三个地方:
第一,合并操作异步化。前端提交合并请求后不要同步等待,而是返回一个任务ID,后台线程池合并,前端轮询或通过WebSocket通知合并结果。
第二,合并时限制并发合并数,比如统一在后台队列中串行合并,避免同时多任务争抢磁盘。
第三,大文件不落临时分块直接即时合并的话,可以实现流式上传,但这样断点续传就很难做。所以我保留了分块临时目录,但在分块到达后就按照文件序号命名,合并时只做连续读,不反复创建临时副本。
另外,还优化了MD5校验算法。WebUploader的MD5计算在纯JS下比较慢,大文件夹场景会拖慢入队。我的做法是只在合并阶段由后端计算最终MD5,前端入队时只校验文件大小和扩展名。如果业务要求秒传,再单独用增量MD5算法,否则没必要每个文件都全量跑一遍哈希。
对于高保密场景,常见做法是:
- 每个分块上传完成后,后端立即对分块计算SHA-256并记录。
- 合并时对所有分块哈希做聚合校验。
- 随后计算整个文件的哈希,并与总上传哈希比对。
- 校验通过后,删除分块临时文件,避免中间文件残留。
这样既能保证完整性,也能满足审计需求。
最后聊一个容易被忽略的细节:文件夹中的空目录不会产生文件对象,所以默认不会在服务器上创建。如果业务要求保留空目录(比如某些项目文档需要占位目录),前端需要在解析FileList的同时上报目录结构,或者在合并后由后端扫描文件路径反推。我在实际项目里没有让前端上传空目录,而是要求业务方用占位文件,比如.keep,更简单也更可靠。
回到最初的问题:jQuery环境下的WebUploader能不能支持文件夹目录结构分块上传?答案是能,而且改造量不大。核心就是拿到webkitRelativePath,在uploadBeforeSend里注入,在后端合并时按路径创建目录。真正的难点其实不在分块上传本身,而是在服务端的路径安全校验、合并性能和高保密场景下的权限审计。把这些想清楚,文件夹上传就不再是问题。
如果后续要在这个方案上继续扩展,我建议考虑文件夹拖拽上传、目录结构实时预览、以及断点续传状态中目录结构的恢复。尤其是拖拽上传,浏览器对拖拽文件夹的路径支持目前不如input稳定,我在一些浏览器上测试时发现webkitRelativePath偶尔为空,所以最终还是保留了“选择文件夹”按钮作为主要入口。这也是为什么我坚持用这个方式来做的原因。