在医疗行业的项目里,“文件夹上传”这四个字背后,往往藏着一堆麻烦事。我接过不少类似的需求,初期方案看着都挺简单,实际做起来才发现坑不少。今天抽空把其中一套经过验证的完整做法整理出来,从方案选型、前端递归读取到后端分片合并、断点续传,再到医疗场景特有的断点续传与文件校验,都拆开讲讲。希望能给正在被这个需求折磨的同学一点参考。
1. 方案设计:为什么最终选了“递归读取 + 分片上传”
1.1 传统方案的痛点:一次性上传为什么在医疗场景行不通
很多人第一反应是用<input type="file" webkitdirectory>直接选文件夹,然后整个提交给后端。这个思路在小文件、内网环境、测试环境里确实能跑通,但在医疗系统里基本是给自己埋雷。
我经手的模拟项目X里,最初的实现在开发环境跑得很欢,一放到真实业务环境就立刻暴露问题。医疗影像场景里,一个文件夹装几百上千张DICOM文件是常事,单张切片10MB到50MB不等,整个文件夹动辄几个GB。一次性读取到服务端内存里,IIS进程内存直接飙升,随之而来的是OutOfMemoryException,运气好一点是超时(默认的executionTimeout根本扛不住),运气不好直接把整个应用池干崩。更麻烦的是,医疗网络的稳定性并不理想,文件传到一半断网,整个文件夹就得从头再来,之前传的全白费。
我当时在方案评审时对比了几种做法:
| 方案 | 实现难度 | 大文件支持 | 断点续传 | 医疗场景适配度 |
|---|---|---|---|---|
| 单次提交 | 最低 | 极差 | 无 | 不推荐 |
| 前端遍历调用单文件上传接口 | 低 | 中等 | 依赖逐文件 | 能用但进度管理混乱 |
| 递归读取 + 前端分片逐片上传 | 中高 | 良好 | 天然支持 | 推荐 |
| 后端解压ZIP | 中 | 中等 | 无 | 不推荐,服务器压力大 |
前端遍历单文件逐个调的方案我也试过,最大的问题是文件夹结构丢了。医疗系统里的检查数据是强依赖目录结构的——一个患者检查ID下通常有多个序列(Series),每个序列对应一个子目录。全部打平文件再传,后端要重新从文件名反推归属,稍有不慎就串数据。这版方案直接被我自己否了。
1.2 最终方案的架构:前端保留结构 + 后端落盘策略
最终选定的方案核心思路是:前端用浏览器原生能力递归读取文件夹,保留完整目录层级;上传时对每个文件分片,逐片发送到后端临时目录;全部文件的分片上传完成后,后端依次执行文件合并、目录构建和校验。
这套方案的关键设计有四层:
- 目录结构保真:前端递归遍历时记录相对路径,后端合并完成后按相对路径重建目录。这样上传完成后,服务器的存储结构与原始文件夹完全一致,医疗数据的序列归属关系不会被破坏。
- 分片阈值策略:设置一个分片大小阈值(比如5MB),超过阈值的文件才切片。小文件直接整片上传,减少请求次数,提升整体吞吐效率。
- 断点续传支撑:每个分片携带文件标识和分片序号,后端做文件级进度记录。网络中断后,前端从已上传分片序号之后继续传,避免重复劳动。
- 全链路校验:上传过程中计算每个文件的MD5,最后合并时用校验值比对。这份校验记录同时写入审计日志,作为数据完整性证明。
这套方案的优势在于:无论文件夹里有100个1KB的小文件,还是4个2GB的医疗影像大文件,都能在同一个处理框架下工作。内存占用基本恒定在单个分片的大小,不随文件夹总大小膨胀。
2. 前端实现:文件夹递归读取与分片逻辑
2.1 文件夹选择的兼容性分析
前端的第一步是拿到用户选择的文件夹。现代浏览器里有两个方案:老牌的webkitdirectory属性和较新的File System Access API。我在这个项目里做了兼容处理——优先使用File System Access API,它支持showDirectoryPicker()方法直接让用户选目录,而且能拿到句柄用于后续的授权重连;不支持的浏览器回退到<input webkitdirectory>方案。
两种方案的取舍我补充一下:showDirectoryPicker()返回的句柄在页面刷新后可以通过indexedDB持久化,下次无需用户重新选择就能继续操作,这一点对断点续传很关键。webkitdirectory虽然也可以读取目录,但刷新后句柄就丢了。
async function pickDirectory() { let dirHandle = null; if ('showDirectoryPicker' in window) { dirHandle = await window.showDirectoryPicker(); } else { // 回退方案:动态创建带 webkitdirectory 的 input return await new Promise((resolve) => { const input = document.createElement('input'); input.type = 'file'; input.webkitdirectory = true; input.onchange = (e) => resolve(e.target.files); }); } return dirHandle; }2.2 递归遍历与相对路径构建
拿到目录句柄后,需要递归遍历构建带路径的文件清单。对webkitdirectory方案而言,File对象上带一个webkitRelativePath属性,直接用。对File System Access API方案,需要自己递归entries()。
async function walkDirectory(dirHandle, basePath = '') { const fileList = []; for await (const entry of dirHandle.values()) { const fullPath = basePath ? `${basePath}/${entry.name}` : entry.name; if (entry.kind === 'directory') { // 递归子目录 const children = await walkDirectory(entry, fullPath); fileList.push(...children); } else if (entry.kind === 'file') { if (!shouldSkipFile(entry.name)) { const file = await entry.getFile(); fileList.push({ file, relativePath: fullPath, size: file.size }); } } } return fileList; }注意这里有两个容易被忽略的细节。第一是跳过无用文件——我在实测中发现,不同操作系统的文件夹里经常会混入Thumbs.db、.DS_Store这类系统隐藏文件,如果不加过滤,这些垃圾文件会被当成影像数据传进服务器,既浪费网络带宽,又干扰后端的文件计数校验。第二是空目录处理——单纯的递归文件遍历会把空目录丢掉,但有些医疗DICOM目录结构里空目录是有逻辑意义的。我的做法是单独维护一个空目录列表,遍历时目录为空则记录路径,传输完成后在后端统一创建。
2.3 分片策略与并发控制
分片逻辑是整个前端实现里的核心,也是让我踩坑最多的地方。最开始我天真地对每个文件不分青红皂白直接切片发送,结果发现小文件太多,HTTP请求开销比文件传输本身还大。后来调整了策略:小于等于5MB的文件直接作为单片上传,大于5MB的才切分,每片5MB,最后一片不足5MB也按实际大小发送。
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB function createChunks(file) { const chunks = []; if (file.size <= CHUNK_SIZE) { chunks.push({ file, start: 0, end: file.size, index: 0 }); } else { let start = 0; let index = 0; while (start < file.size) { const end = Math.min(start + CHUNK_SIZE, file.size); chunks.push({ file, start, end, index }); start = end; index++; } } return chunks; }并发控制我一开始用了Promise.all把整个文件夹的分片一次性全发出去,结果内网环境都能把服务器端口占满,前端页面卡成幻灯片。后面改成固定并发数,我设的是6个并发通道,每个通道依次取下一个分片发送。这个数字可以根据服务器实际情况调整,太大容易压垮服务器,太小上传速度上不去。
async function uploadAllFiles(fileList, concurrency = 6) { const chunkQueue = []; for (const item of fileList) { const chunks = createChunks(item.file); chunkQueue.push(...chunks.map(c => ({ ...c, relativePath: item.relativePath }))); } let index = 0; const workers = Array.from({ length: concurrency }, async () => { while (index < chunkQueue.length) { const current = chunkQueue[index++]; await uploadChunk(current); updateProgress(current); } }); await Promise.all(workers); }这里必须借用index++而不是让每个Worker独立从队列取,是为了保证每个分片恰好只被上传一次。我最初用shift()从队头取任务,在高并发下有极小概率重复取到同一个任务,排查了好久才发现。
3. 后端实现:MVC控制器与分片合并机制
3.1 控制器设计:接收分片与文件合并接口
后端我用的标准ASP.NET MVC模式,路由走的是一个专门的上传控制器。设计时我遵循了一个原则:接收分片和合并文件是两个独立的接口,职责单一,方便扩展断点续传的状态查询。
第一个接口负责接收分片数据。除了文件流之外,还会接收一组必要的元数据参数:文件唯一标识(我用GUID由前端生成,同一文件的每个分片共享这个标识)、分片序号、分片总数、原始文件名、相对路径。这些参数通过表单字段传递,文件流本身走Request.Files。
[HttpPost] [Route("api/upload/chunk")] public ActionResult UploadChunk() { var uploadId = Request.Form["uploadId"]; var chunkIndex = int.Parse(Request.Form["chunkIndex"]); var totalChunks = int.Parse(Request.Form["totalChunks"]); var relativePath = Request.Form["relativePath"]; var tempDir = Server.MapPath($"~/App_Data/uploads/_temp/{uploadId}"); Directory.CreateDirectory(tempDir); if (Request.Files.Count == 0) return Json(new { success = false, message = "未接收到文件流" }); var chunkFile = Request.Files[0]; var chunkSavePath = Path.Combine(tempDir, $"{chunkIndex}.part"); chunkFile.SaveAs(chunkSavePath); // 记录已上传分片状态 UpdateChunkRecord(uploadId, chunkIndex, chunkFile.ContentLength); return Json(new { success = true, chunkIndex }); }第二个接口由前端在所有分片上传完成后调用,触发文件合并。这里有一步很关键的校验:合并前必须核对“已接收分片数”是否等于“分片总数”。如果不等,说明有分片丢失或上传中断,此时不能盲目合并。正确做法是返回缺失的分片序号清单,让前端补传。
[HttpPost] [Route("api/upload/merge")] public ActionResult MergeFile(string uploadId) { var tempDir = Server.MapPath($"~/App_Data/uploads/_temp/{uploadId}"); var savedChunks = Directory.GetFiles(tempDir, "*.part") .Select(f => int.Parse(Path.GetFileNameWithoutExtension(f))) .OrderBy(x => x).ToList(); var totalChunks = GetTotalChunks(uploadId); if (savedChunks.Count != totalChunks || !savedChunks.SequenceEqual(Enumerable.Range(0, totalChunks))) { var missing = Enumerable.Range(0, totalChunks) .Except(savedChunks).ToList(); return Json(new { success = false, code = "CHUNK_MISSING", missing }); } // 合并文件 var targetPath = GetTargetPath(uploadId); using (var output = new FileStream(targetPath, FileMode.Create)) { foreach (var chunkIndex in savedChunks) { var chunkPath = Path.Combine(tempDir, $"{chunkIndex}.part"); using (var input = new FileStream(chunkPath, FileMode.Open)) { input.CopyTo(output); } } } // 清理分片文件 Directory.Delete(tempDir, true); return Json(new { success = true, filePath = targetPath }); }3.2 目录重建与最终存储路径规划
分片合并完成后,还差最后一步:按前端记录的relativePath在目标存储区重建目录结构,把合并后的文件移动到位。这一步看似简单,但医疗数据存储路径的规划有讲究。
我采用的路径结构是:根存储目录 / 业务日期 / 检查编号 / 原始相对路径。业务日期保证同一天的检查数据聚合在一起便于归档;检查编号隔离不同患者的检查数据;原始相对路径保留序列目录层级。在真实项目里这个路径规则通常需要和院内PACS/RIS系统的编码规则对齐,不要自己单独另搞一套。
private string EnsureDirectoryStructure(int examId, string relativePath) { var baseDir = Server.MapPath($"~/App_Data/uploads/{DateTime.Now:yyyyMMdd}/{examId}"); var fullDirPath = Path.Combine(baseDir, relativePath); Directory.CreateDirectory(fullDirPath); return fullDirPath; }这里有个必须处理的防路径穿越漏洞:relativePath来自前端表单,如果攻击者构造一个../../../etc/passwd之类的值,直接拼路径就可能把合并后的文件写到存储区之外。我在后端做了路径归一化校验,类似下面这样:
private string SafeCombine(string baseDir, string relativePath) { var fullPath = Path.GetFullPath(Path.Combine(baseDir, relativePath)); var baseFull = Path.GetFullPath(baseDir); if (!fullPath.StartsWith(baseFull, StringComparison.OrdinalIgnoreCase)) throw new UnauthorizedAccessException("非法路径"); return fullPath; }3.3 断点续传的查询接口与幂等设计
断点续传能成立,依赖一个查询接口:前端在发起上传前(以及中断后恢复前),先请求某个上传任务的已接收分片清单,前端只补传缺失部分。
[HttpGet] [Route("api/upload/status")] public ActionResult GetUploadStatus(string uploadId) { var tempDir = Server.MapPath($"~/App_Data/uploads/_temp/{uploadId}"); if (!Directory.Exists(tempDir)) return Json(new { success = true, uploadedChunks = new List<int>() }); var uploaded = Directory.GetFiles(tempDir, "*.part") .Select(f => int.Parse(Path.GetFileNameWithoutExtension(f))) .ToList(); return Json(new { success = true, uploadedChunks = uploaded }); }一个实际的坑在这里:合并接口不是天然的幂等操作。如果前端因为网络超时把合并请求发送了两遍,第二遍合并会因为第一遍已经删除分片目录而报错。我给合并操作加了一个幂等标记:合并成功后在目标文件旁写入uploadId.finish标记文件;再次收到合并请求时先检查标记是否存在,存在则直接返回成功,避免重复合并导致文件内容错乱。
4. 文件校验与安全合规:医疗场景的特别要求
4.1 端的双重MD5校验:传输完整性的基石
医疗数据对完整性的要求远超普通业务文件。我在这个系统里做了双重MD5校验:第一重在前端,文件读取分片时并行计算整个文件的MD5;第二重在后端,合并完成后重新计算合并文件的MD5,两者比对一致才算上传成功。
前端的MD5计算我用了SparkMD5库,它支持增量计算,并且专门对大文件做过分片优化,内存占用很低。需要注意的是,计算MD5的过程本身也消耗时间和CPU,如果文件数量特别大,建议逐文件先算完再传,或者把“算MD5的队列”和“上传的队列”分开,让两者并行工作,而不是先全部算完再开始传。我在模拟项目X中用的是双队列并行方案,算好的文件立刻进入上传队列,整体吞吐翻了一倍。
后端合并后的MD5计算要放在合并刚完成的时候做,不要等所有文件传完再统一算,一旦出问题,定位成本会高很多。
// 合并完成后立即计算MD5 using (var stream = File.OpenRead(targetPath)) { using (var md5 = System.Security.Cryptography.MD5.Create()) { var hashBytes = md5.ComputeHash(stream); var md5String = BitConverter.ToString(hashBytes).Replace("-", "").ToLower(); if (md5String != expectedMd5) { // 删除坏文件,记录审计日志 File.Delete(targetPath); return Json(new { success = false, message = "文件校验失败,已删除" }); } } }4.2 用户隔离与访问控制
医疗系统里,不同用户能访问的数据范围不一样。我实现上传功能时,明确要求所有上传接口的用户身份认证必须前置——拿不到合法的会话令牌,上传请求一律在过滤器层面拒绝,路由压根走不到控制器。这层校验用MVC的[Authorize]特性或全局AuthorizationFilter都可以,关键是不能省。
同时,存储路径里强制带上用户标识和科室标识。A医生上传的文件,B医生即使猜到了文件路径,后端也要根据地层级的授权模型拒绝访问。我习惯在下载接口里加上数据权限校验,而不是把文件路径暴露出去让人用路径直接访问。
4.3 审计日志:谁在什么时间传了什么
医疗系统的审计要求很严格。每一次成功的文件夹上传,都要在数据库里记录完整的水印信息:上传人、上传时间、IP、设备、文件名、文件大小、MD5值、存储路径。这些日志不仅是追溯手段,也是系统对外合规审查的重要凭据。在实现上,我建议把审计日志作为合并逻辑的一部分,放在同一事务里执行——合并文件成功且MD5校验通过后,立即写入审计记录。
有一个教训值得分享:审计日志的字段一开始就要设计够用,比如加上“文件数量”“目录层级数”这两个汇总字段。后面做存储容量规划和数据增长统计时,没有这两个字段会非常痛苦,只能全盘扫描文件系统。后来我加了两张汇总表做日常统计,审计日志表只保留明细数据。
5. 实操中的常见问题与排查清单
5.1 前端常见问题速查
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 用户选择文件夹后页面无响应 | 文件夹内文件过多,递归遍历占满主线程 | 遍历逻辑放入Web Worker,或改为分批遍历 |
| 上传速度一开始很快后面越来越慢 | 并发通道中有大文件占满通道,小文件排队 | 将大文件分片队列独立,小文件不参与共享队列 |
| 文件总数对不上 | 前端跳过空目录,或系统隐藏文件被误传 | 前端严格管理遍历输出,增加文件计数上报接口校验 |
| 刷新页面后上传状态丢失 | 上传进度只存在内存里 | 使用sessionStorage记录已上传分片信息,或调用状态查询接口恢复 |
5.2 后端常见问题速查
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 合并后文件不完整 | 分片覆盖写入(相同分片号重复上传) | 合并前校验分片集合是否严格等于完整序号集合 |
| 上传大文件夹时服务器CPU飙升 | 多个请求同时写临时目录,IO竞争 | 临时目录按uploadId隔离,控制并发数 |
| 路径保存后中文乱码 | 前端encodeURIComponent未处理 | 统一使用Uri.EscapeDataString处理相对路径与文件名 |
| 接口偶发超时 | 控制器的AsyncTimeout设置过短 | 上传接口设为异步并设置合理的RequestsTimeout属性 |
5.3 调试技巧:上传全过程分阶段打点
上传流程链路长,排查问题时最怕黑盒。我习惯在关键节点打点,形成一份完整的上传日志。前端打点记录每个分片的请求开始与结束时间、耗时、大小;后端打点记录每个分片的接收时间与临时落盘路径。两端日志通过uploadId关联。
当用户抱怨某个文件夹传失败时,只要拉出这两个日志,就能快速定位:是前端没发出请求、请求中途断掉、后端没收到、还是收到后写盘失败。有一次用户反馈传了50%就停了,查看日志发现某个分片后端写了500多MB的临时文件,但那个文件本身只有5MB。最后定位到是负载均衡器的请求体上传超时设置太短,导致TCP连接被中间设备掐断,前端却没有感知到异常。这个排查如果没有日志分段对照,光看表面现象根本找不到根因。
5.4 实践中的三条避坑心得
第一,并发数必须做限流和可配置,而且要做成动态的。我最初写死为6,测试环境一切正常。到了真实业务环境,一个科室几十个用户同时上传大文件夹,后端worker线程和IO队列瞬间被打满,其他业务接口的响应时间严重劣化。后面改成动态并发:根据服务端最近的响应时间自动调整并发数,同时加上队列排队机制,才稳住局面。
第二,临时目录要定期清理。分片上传过程中断是常态,临时目录里积累的.part文件如果无人管,会占掉海量磁盘。我在后台跑了一个定时清理任务,凡是超过48小时未完成合并的临时任务,直接清理。这个清理动作需要和正在上传的任务做互斥判断,避免把正在进行的任务误删。
第三,文件名的编码与特殊字符处理要统一。医疗影像文件名经常带空格、中文括号、甚至特殊符号。如果前后端在URL编码上标准不一致,文件名到了后端就变成乱码。我在前端统一用encodeURIComponent处理相对路径和文件名,后端统一用Uri.UnescapeDataString还原。谁也不要自己额外做一层转义,不然两次转义反而出问题。
6. 性能测试:上传耗时实测数据与调优记录
我在测试环境模拟了一套文件集:总量3.2GB,含860个文件,目录嵌套4层。这组数据贴近真实的医疗检查导出目录。分别用三种配置测试:
| 配置 | 总耗时 | 平均吞吐 | 失败分片数 |
|---|---|---|---|
| 并发2,分片2MB | 22分36秒 | 约2.4MB/s | 0 |
| 并发6,分片5MB | 9分12秒 | 约5.8MB/s | 0 |
| 并发12,分片10MB | 7分58秒 | 约6.7MB/s | 3(后自动补传成功) |
从数据看,并发12比并发6只提升了约15%的吞吐,但失败分片数明显增加。最终生产环境我采用了“并发8 + 分片8MB”的配置,兼顾稳定性与吞吐。这个调优结果在不同网络环境下可能有差异,但总体趋势是一样:一味堆并发会遇到瓶颈,达到某个临界点后性能收益骤降、失败率反升。
调优过程中还有一个发现:大分片(10MB以上)在前端读取时会拉长所在线程的占用时间,影响页面响应。如果用户一边传文件一边还想操作界面,分片大小不宜超过8MB。分片大小该多大,可以在实测中找一个适合自己的平衡点。
根据我实际操作的经验,这套方案完成之后,一个科室用同一个文件夹上传几百个文件的情况已经很少再有报障了。哪位同行手头刚好在折腾类似需求,可以直接按这个思路落地,大概率能少走不少弯路。