搞过图纸管理系统的人应该都遇到过这种场景本地按专业、按图号、按设计阶段分了几层目录总共几百张图纸往系统里传的时候文件选择框一次只能选一层目录里的文件传完目录树也丢了所有文件平铺在一个大列表里找一张图得翻半天。更头疼的是A0幅面的装配图动辄几百兆传到一半断网又得从头来。这次想聊的就是怎么用JS配合WebUploader插件把这两件事同时解决断点续传加上目录结构原样恢复。先说一个边界。军工科研单位的图纸管理有一部分涉及国家秘密那部分内容必须按国家有关保密法律法规执行不属于技术博客能讨论的范畴。下面这套方案面向的是企业或科研单位内部受控的研发图纸库、工艺文档库图纸属于企业资产但并非国家秘密系统部署在内网有权限管理和审计要求。把边界看清楚再往下看技术方案才踏实。1. 项目背景与核心需求拆解1.1 两条硬需求目录结构完整保真与断点续传图纸管理系统的上传需求和普通网盘上传不太一样。普通网盘可以接受文件按用户自选分类丢进去图纸管理要求的是“所见即所得”本地什么目录结构系统里就是什么目录结构因为图号、版本、所属专业往往就在目录名里目录一乱整套图纸的检索就废了。需求第一条就是目录结构保真。前端选择某个根目录后需要拿到每个文件的相对路径比如“机加件/壳体/壳体装配图-A2-Rev01.dwg”后续重建目录树直接按相对路径走。需求第二条是断点续传。大图纸文件普遍在几百MB到1GB内网带宽再快也不可能保证传输过程中不出现任何中断。中断可能是网络抖动、网关超时、浏览器内存暴涨导致页面崩溃甚至是用户临时要关电脑。没有续传机制一次中断浪费几十分钟在批量上传时体验极差。这两条需求叠在一起就变成了一个有含金量的技术问题怎么在单文件级别做断点续传同时保证每个文件上传完成后被准确放回它原本所在的目录层级里。1.2 为什么用WebUploader而不是手写XMLHttpRequest自己用原生XMLHttpRequest实现分片、并发、重试、进度估计工作量不小。分片上传本身不难难的是把分片状态管理和失败重试做完整哪些分片成功了、哪些失败了、并发数多少、失败多少次结束本轮、整个文件全部传完后如何触发合并。WebUploader是百度开源的上传组件官方维护虽然早就停了但它底子干净、依赖少、没有平台绑定很多PLM、文档系统、云盘项目里都在用属于经历过大量生产环境验证的轮子。对内网部署来说最大的好处是纯静态资源就能跑起来不依赖外网服务也没有闭源SDK的授权问题。它能覆盖的全部需求点恰好就是这里要用的分片上传、分片md5计算、失败重试、并发控制、进度回调。官方还提供了事件机制可以在每个分片发出前把自定义参数塞进请求里这对后面处理目录结构和断点续传特别重要。1.3 受控环境下的合规与部署前提图纸数据受控意味着系统大概率跑在内网前端资源不能走公网CDN第三方JS库必须离线引入。这个前提直接决定了方案里不能写任何外链所有依赖包都要提前下载好放进前端静态目录连同项目一起打包部署。受控系统通常还有额外的默认要求日志审计、权限分级、操作留痕。这些和上传组件本身关系不大但选型时要留意尽量别选那些会悄悄上报统计信息、请求公网接口的组件。WebUploader没有这类行为离线情况下也能正常工作这是它在这个场景里最稳妥的地方。2. 核心原理分片、续传与目录树重建2.1 分片上传机制与文件唯一标识WebUploader做分片上传时会把一个大文件切成若干块每个分片单独成为一个HTTP请求按顺序或并发发送到后端。默认分片大小是5MB通过chunkSize配置调整并发数由threads控制。断点续传的前提是后端能识别“这是同一个文件的第几个分片”。光靠文件名不够同名文件在不同目录下可能内容不同而且用户把文件挪过位置之后文件名也可能变。所以需要一个与路径无关的文件唯一标识业界通用做法是计算整个文件内容的MD5。WebUploader官方自带的是分片级md5每个分片单独计算它不能直接当整个文件的标识用。我通常的做法是引入spark-md5在前端流式读取文件内容计算整个文件的MD5作为文件的guid。文件越大计算越慢几百MB文件可能要几十秒但这是为正确性付出的必要代价。如果对速度有要求也可以退而求其次用“文件大小修改时间相对路径”拼一个标识但在严谨的图纸管理场景里不推荐容易撞车。2.2 断点续传的两层判断逻辑断点续传在实现上分两层。第一层在分片级别核心思路是“后端幂等”。每次分片请求到达后端时后端先去临时目录里检查“以这个guid和chunk编号命名的分片文件”是否已经存在。如果存在说明这个分片之前传成功过后端直接返回成功不再写入磁盘。前端继续按队列发送下一个分片整个过程对前端透明用户看到的效果就是续传时进度飞快很快跳到后面的分片。第二层在合并级别。只有当某个文件的所有分片都已上传完成前端才调用合并接口后端按分片编号顺序把临时分片拼接成完整文件。合并成功后再删除临时目录避免磁盘被残留分片占满。这一层的作用是保证最终产物的完整性避免某个分片缺失导致的文件损坏。2.3 目录结构数据的来源webkitRelativePath目录结构保真的关键不在WebUploader本身而在浏览器的文件选择能力。给input加上webkitdirectory属性以后用户选择的不是普通文件列表而是一个目录此时每个原生File对象上会多出一个webkitRelativePath字段记录了文件相对于所选根目录的路径。WebUploader在addFiles事件里暴露的是它包装过的File对象原生File藏在file.source.file里。从这里可以直接取到webkitRelativePath再把它绑定到对应的上传任务上后续发分片请求时作为表单参数一并提交。注意老IE和部分旧浏览器不支持webkitdirectory需要提前做浏览器兼容提示。2.4 服务端目录重建与安全校验后端拿到relativePath后不能直接当文件路径拼接必须做两层处理第一是用path.normalize去掉路径里的冗余段第二是确保最终路径仍然在存储根目录内防止“../”这类路径穿越。图纸系统的目录名一般不会有奇怪字符但不排除有人上传的文件名带了特殊符号服务端统一用path.basename处理原始文件名目录部分单独用normalize后的相对路径。到这里整条链路就通了文件被切开上传分片由guid标识元数据里带着relativePath合并时按relativePath创建目录按原始文件名落盘断点续传时guid不变分片状态由后端幂等判断。目录结构续传的核心就是这三件事的配合。3. 完整实操前后端代码与配置解析3.1 前端页面结构与基础初始化前端用一个隐藏的input承载目录选择初始化WebUploader时picker指向一个可见的按钮。关键配置如下div iduploaderBox div idfilePicker选择图纸目录/div div idprogressList/div /divvar uploader WebUploader.create({ pick: #filePicker, swf: ./Uploader.swf, server: /api/upload, chunked: true, chunkSize: 2 * 1024 * 1024, threads: 3, fileVal: file, duplicate: true, auto: false, formData: {} }); $(#filePicker).find(input[typefile]).attr(webkitdirectory, webkitdirectory); uploader.refresh();这几个参数是实践下来的推荐组合解释一下chunked开启分片chunkSize建议设置在2MB到5MB之间太小导致HTTP请求数过多太大让单次失败重传成本变高threads设成2到3个内网环境既可以吃满带宽又不至于把服务器打爆duplicate设为true允许同一个文件在不同目录下重复出现auto设成false让用户确认文件列表后再开始上传。有个容易踩的坑动态给input加webkitdirectory之后必须调用uploader.refresh()否则WebUploader内部对按钮的监听可能没生效。3.2 采集相对路径并绑定到每个文件和分片在addFiles事件里把原生File的webkitRelativePath拿出来存起来并记录到uploader的全局缓存。注意addFiles回传的files是数组要遍历处理。var filePathMap {}; var fileMd5Map {}; uploader.on(addFiles, function (files) { files.forEach(function (file) { var nativeFile file.source.file; var relativePath nativeFile.webkitRelativePath || ; filePathMap[file.id] relativePath; computeFileMd5(file).then(function (md5) { fileMd5Map[file.id] md5; }); }); uploader.refresh(filePicker); });文件指纹计算用spark-md5实现按分片读取避免一次性把大文件读进内存。实现如下function computeFileMd5(file) { return new Promise(function (resolve, reject) { var chunkSize 1 * 1024 * 1024; var blob file.source.file; var spark new SparkMD5.ArrayBuffer(); var offset 0; var fileReader new FileReader(); function readNext() { var slice blob.slice(offset, offset chunkSize); fileReader.readAsArrayBuffer(slice); } fileReader.onload function (e) { spark.append(e.target.result); offset e.target.result.byteLength; if (offset blob.size) { readNext(); } else { resolve(spark.end()); } }; fileReader.onerror function (e) { reject(e); }; readNext(); }); }每个分片发送前通过before-send事件把文件级元数据和分片级元数据一起塞进formData。这部分是把目录结构带往后端的关键一步。uploader.on(before-send, function (block) { var file block.file; var partData { guid: fileMd5Map[file.id] || , relativePath: filePathMap[file.id] || , originalName: file.name, chunk: block.chunk, chunks: block.chunks, chunkSize: block.end - block.start }; if (skipChunkList[file.id] skipChunkList[file.id].includes(block.chunk)) { return false; } block.formData partData; return true; });skipChunkList是后端查询回来的已上传分片列表。如果某个分片已经存在直接返回false让WebUploader跳过这是在前端层面进一步减少无效HTTP请求的手段。3.3 后端分片接收与幂等续传接口后端用Node.js加Express举例思路在所有语言里通用。分片接收接口做两件事判断分片是否已经存在存在就直接返回不存在就落盘。用文件系统层面的“存在性”实现幂等简单可靠。const express require(express); const multer require(multer); const fs require(fs-extra); const path require(path); const app express(); const STORE_ROOT /data/blueprint-store; const TEMP_ROOT /data/blueprint-tmp; const upload multer({ dest: path.join(TEMP_ROOT, _incoming) }); app.post(/api/upload, upload.single(file), async (req, res) { const { guid, chunk, originalName, relativePath } req.body; if (!guid) { return res.status(400).json({ ok: false, message: guid missing }); } const partDir path.join(TEMP_ROOT, guid); const partPath path.join(partDir, chunk- chunk); if (await fs.pathExists(partPath)) { await fs.remove(req.file.path); return res.json({ ok: true, skipped: true }); } await fs.ensureDir(partDir); await fs.move(req.file.path, partPath, { overwrite: true }); res.json({ ok: true, skipped: false }); });这里有两个容易被忽略的点第一判断分片已存在后必须把本次请求刚传上来的临时文件删掉否则每个续传分片都会在服务器上留一个没用的临时文件第二分片命名不要用随机串直接用chunk编号合并时按编号排序即可同时查询哪些分片已传也非常方便。3.4 合并分片与目录落盘所有分片传完后前端调用合并接口。合并接口收到guid和relativePath后先按顺序把所有分片读取出来拼接写盘写完后删除临时目录。app.post(/api/merge, async (req, res) { const { guid, chunks, originalName, relativePath } req.body; const partDir path.join(TEMP_ROOT, guid); const safeName path.basename(originalName || unnamed); const safeDir path.normalize(relativePath || ); const targetPath path.join(STORE_ROOT, safeDir, safeName); if (!targetPath.startsWith(STORE_ROOT)) { return res.status(400).json({ ok: false, message: invalid relativePath }); } const partFiles await fs.readdir(partDir); if (partFiles.length ! Number(chunks)) { return res.status(400).json({ ok: false, message: chunk count mismatch }); } await fs.ensureDir(path.dirname(targetPath)); const writeStream fs.createWriteStream(targetPath, { flags: w }); for (let i 0; i chunks; i) { const partPath path.join(partDir, chunk- i); const data await fs.readFile(partPath); writeStream.write(data); } writeStream.end(); writeStream.on(finish, async () { await fs.remove(partDir); res.json({ ok: true, targetPath }); }); });合并环节的坑非常多。最常见的是chunks参数和实际分片数量对不上比如某个分片在续传时丢了前端仍然认为传完了。所以合并前一定要统计partDir里的分片数数量不等于chunks就返回错误让前端重新补齐。另一个坑是写流finish事件触发前就返回成功用户会看到“上传完成”但文件还没完全落盘正确做法是严格在finish回调里再返回响应。3.5 暂停、恢复、进度与失败重试WebUploader的暂停和恢复是现成能力。暂停调用uploader.stop(true)恢复调用uploader.upload()。恢复后WebUploader会把没传完的分片重新发一遍后端幂等判断会直接跳过已经存在的分片所以“继续传”的效果天然就是断点续传。进度展示用uploadProgress事件。有个细节这个事件在分片级别回调做跨分片的累计计算才准确。var fileTotal {}; uploader.on(uploadProgress, function (file, percentage) { fileTotal[file.id] percentage; // 用 percentage 更新对应文件进度条 }); uploader.on(uploadError, function (file, reason) { // 记录失败队列连续失败阈值后停止任务 console.error(file.name, reason); });失败重试方面WebUploader自带几次重试但默认行为不够直观。我在项目里会监听uploadError记录失败队列如果连续失败达到一定次数就停止整个任务提示用户检查网络或服务器状态。并发数千万别调太高内网带宽有限时并发一高分片请求之间互相挤占反而拖慢速度。3.6 前端发起合并和目录确认最后一个环节是前端在文件上传完成后发起合并请求。这里还要做一步额外的处理每个文件只触发一次合并避免用户多次点击导致重复合并。uploader.on(uploadFinished, function () { var pendingMerge Object.keys(filePathMap).filter(function (fileId) { return !mergedMap[fileId]; }); pendingMerge.forEach(function (fileId) { var file uploader.getFile(fileId); $.post(/api/merge, { guid: fileMd5Map[fileId], chunks: file.chunks, originalName: file.name, relativePath: filePathMap[fileId] }).then(function () { mergedMap[fileId] true; }); }); });收到合并成功的响应后可以提示用户“目录已完整入库”再刷新目录树就能看到和本地一致的层级结构。4. 常见问题与排查实录4.1 目录层级全丢文件全堆在根目录这个问题十有八九是webkitRelativePath没取到。要么是input没有加上webkitdirectory属性要么是在自定义事件里取的file不是原生File。排查时先在addFiles里打印一下file.source.file.webkitRelativePath如果输出为空检查是否用了自定义picker并且input属性加错了位置。还有一种情况是相对路径取到了但从没进过formData。检查before-send回调里是否真的把relativePath赋值到了block.formData前端断点续传如果走了缓存的老配置也可能漏掉这个字段。4.2 续传后文件打开校验失败文件损坏多数发生在合并环节。按优先级排查第一检查分片是否按编号有序读取第二检查是否漏了某个分片第三检查合并时写流是否真正flush完成。我遇到过一种隐蔽情况合并接口在写流finish事件触发前就返回了成功前端以为传完了实际文件还在缓存里没落盘。解决办法是合并接口严格在writeStream的finish回调里再返回响应。还有一种情况是并发上传时同一文件的两个分片请求同时到达后端后端判断分片不存在后同时写入导致文件内容交错。解决方法是后端对同一个guid的分片写入做串行化比如给guid加锁或者使用rename临时文件。4.3 大图纸文件算md5卡死浏览器整文件MD5必须用FileReader分片读取循环计算不能一次把整个文件读进内存。对几百MB的DWG文件一次性arrayBuffer会直接让页面白屏。建议每片1MB左右读完一部分就append进spark同时用setTimeout给浏览器一点喘息时间。计算期间显示一个“正在计算文件指纹”的进度避免用户以为卡死。另一个优化点是只在“需要判断续传”时才计算完整文件MD5。比如上传前先请求后端接口如果该guid的目录不存在说明是新文件就没必要花几十秒算MD5了。这种细节在用户体验上差别很大。4.4 上传速度慢与服务器临时目录爆盘内网环境带宽一般没问题速度慢通常是并发设置不合理。分片太小导致HTTP请求数过多分片太大又让单次失败重传成本变高。我常用的组合是2到5MB分片、并发数2到3实测下来比较稳。服务器临时目录爆盘则要重点盯两件事合并成功后立即清理临时目录再加一个定时任务清理超过24小时未合并的guid目录。正常流程走到合并就会删异常中断上传的残留只能靠定时任务扫尾。4.5 离线内网部署时依赖资源装不上WebUploader没有npm包直接下载对应版本的静态资源放到项目public目录就行。spark-md5可以直接用打包好的spark-md5.min.js。把这些依赖连同前端页面一起打进部署包上线的时候就没有外网依赖问题。上传接口如果跨域内网环境同样受同源策略约束。最好的做法是前后端同域部署省去配置CORS的麻烦。如果必须跨域后端要配置白名单并把预检请求OPTIONS处理好上传接口会频繁触发预检。4.6 常见问题速查表问题可能原因排查建议目录层级丢失webkitRelativePath未采集检查input属性、检查file.source.file续传后文件损坏合并顺序错乱或写流未flush按chunk编号有序读取合并成功后再响应浏览器白屏整文件一次性读入内存改用流式读取计算文件指纹上传速度慢分片大小或并发数不合理调整chunkSize为2到5MBthreads为2到3临时目录爆盘合并后未清理增加定时清理任务上传后立即回调成功但文件缺失写流未等待finish合并完成事件后再返回响应中文文件名乱码前后端编码不一致统一UTF-8重复分片被重复落盘幂等判断失效检查分片是否存在后再写入写到这里想分享一个个人体会。这套方案里最容易被轻视的不是断点续传而是目录结构元数据。很多人在实现续传时花了大量功夫处理分片状态结果目录结构没保留传完一堆文件平铺在系统里用户只会觉得系统比之前更乱。我后来做这类系统第一件事永远是先把webkitRelativePath这段链路打通把它当作和分片状态同等重要的核心数据来管理。另外一个实用建议是上线前一定要模拟一次大图纸目录批量上传测试把几百个文件、几十层目录结构完整跑一遍再人为断网恢复确认断点续传后目录和文件一个都不少。这个过程会暴露很多文档里根本查不到的细节问题比读多少遍API文档都管用。