医疗信息化中的文件夹上传:.NET MVC 实现与安全避坑指南

发布时间:2026/10/19 7:37:47
医疗信息化中的文件夹上传:.NET MVC 实现与安全避坑指南
1. 为什么传文件夹在医疗系统里是个真问题先说个前段时间的真实场景。医院信息科同事跑过来找我说影像科那边反馈系统体验太差一个病人一次检查就可能产生两三百张图像老系统一次只能选一张图片上传科室老师得在文件选择框里一张一张点点完还要等半天。他们提的需求很简单我要能把整个检查文件夹直接拖上去上传完目录还是原来的样子。这个需求听起来简单但真正做过医疗信息化的人都懂它背后藏着三个层面的问题业务层面、技术层面、合规层面。业务上影像数据、病理切片、检验报告这类东西通常不是散文件而是按患者/检查日期/检查编号/序列组织的目录树如果上传后目录结构丢了后面对接诊断流程、归档调阅都会出大麻烦。技术上浏览器原生input typefile只能选文件选不了文件夹而且即使支持多选拿到手的也是一堆扁平文件相对路径信息全部丢失。合规上医疗数据涉及患者隐私文件不能乱传、不能乱存、路径不能溢出这些在方案设计阶段就得想清楚而不是功能跑通之后再来打补丁。所以这篇文章我就用 .NET MVC 的技术栈把文件夹上传从需求拆解到前端采集、后端重建、安全校验、实测避坑完整讲一遍。方案整体不依赖第三方控件用 HTML5 File API 加一个简单的控制器就能落地适合正在做医疗信息化、或是遇到同类HIS/EMR/PACS系统里要批量传目录需求的朋友参考。2. 前端实现让文件夹选择框把目录结构一起带回来2.1 关键属性webkitdirectory 是怎么打开文件夹的浏览器默认的文件选择框是不让你选文件夹的这是安全模型决定的。但 HTML5 时代各家浏览器陆续支持了一个带前缀的属性webkitdirectory加在input typefile上之后弹出来的选择对话框会从选文件变成选文件夹。input typefile idfolderPicker webkitdirectory directory multiple /这里directory是标准化写法webkitdirectory是兼容写法两者同时写上Chrome、Edge、Firefox、Safari 都能覆盖到。选完文件夹之后input.files里返回的不是一个文件夹对象而是该目录下所有文件的扁平列表但每个文件对象上多出一个属性webkitRelativePath。这个属性是整个方案的核心。它的值形如检查目录/张三/20241010/CT0001/IMG0001.dcm注意两点第一路径分隔符统一是正斜杠/不管用户操作系统是 Windows 还是 macOS第二路径的第一段是用户选中的那个文件夹本身的名字也就是说你选了外层文件夹就会从外层开始记录。2.2 用 JavaScript 收集文件对象和相对路径拿到files之后不能直接把文件append进FormData就完事因为后端重建目录树需要知道每个文件在源目录里的相对路径。所以前端要做的事是把文件对象和它的webkitRelativePath对齐一起提交。document.getElementById(folderPicker).addEventListener(change, function (e) { const fileList e.target.files; if (!fileList || fileList.length 0) { return; } // 一次把所有文件放进 FormData const formData new FormData(); // 用一个数组记录每个文件对应的相对路径 const fileArray []; const pathArray []; for (let i 0; i fileList.length; i) { const file fileList[i]; // 空目录里的空文件、以及长度为 0 的文件可以先跳过 if (file.size 0) { continue; } fileArray.push(file); pathArray.push(file.webkitRelativePath); } // 注意这里按索引一一对应追加后端才能用相同索引对齐 for (let j 0; j fileArray.length; j) { formData.append(files, fileArray[j]); formData.append(relativePaths, pathArray[j]); } // 提交 fetch(/Upload/UploadFolder, { method: POST, body: formData }) .then(res res.json()) .then(data { // 处理返回结果 console.log(上传成功, data); }) .catch(err { console.error(上传失败, err); }); });这里有个特别容易踩的坑files和relativePaths是两个独立的表单字段后端拿到的是两个并行数组顺序必须对齐。如果你在收集文件过程中用了filter、sort之类的方法或者在前端循环里用了异步操作顺序一变后端重建的目录结构就会张冠李戴。2.3 为什么不能只用 multiple而要专门处理路径有人会问input typefile multiple不也能一次选很多文件吗对能选很多文件但选完之后你拿到的File对象只有name、size、type这些属性webkitRelativePath是空字符串。也就是说浏览器把文件的原始目录信息丢掉了。对于医疗影像这种强依赖目录结构的数据丢失结构等于白传。我也见过一些团队图省事让用户把文件夹压缩成 zip 再上传后端解压。这个思路在内部系统偶尔能用但医疗场景里不推荐临床科室的医生护士普遍不是技术背景让他们找压缩软件、选压缩格式、再点上传中间多出来的步骤会直接降低使用率而且压缩上传到服务器再解压文件落地两次磁盘 IO 和临时文件管理都是额外负担。直接用webkitdirectory采集结构清晰、逻辑简单对用户零学习成本。3. 后端 Action 设计接收多文件并安全地重建目录树3.1 控制器方法签名两种写法效果差别很大MVC 后端接收多文件最常见的写法是[HttpPost] public ActionResult UploadFolder(ListHttpPostedFileBase files, Liststring relativePaths) { // ... }HttpPostedFileBase这个类型在 ASP.NET MVC 5 里是抽象基类模型绑定器会把 multipart/form-data 里同名表单字段绑定成ListHttpPostedFileBase。但这里有个隐藏问题默认模型绑定只保证同名绑定两个 list 的索引是否严格一致取决于前端提交顺序。在上一节的实现里我是按顺序先append文件再append路径的MVC 的绑定器会按表单字段出现顺序填充所以索引对齐是可以保证的但前提是你千万别在前端做乱序操作。如果你用的是 ASP.NET Core类型会有变化对应写成ListIFormFile本质上是一样的思路[HttpPost] public async TaskIActionResult UploadFolder(ListIFormFile files, Liststring relativePaths)提示无论哪种框架我建议在 Action 开头就做一次files.Count relativePaths.Count的校验不一致直接返回错误。这是防止文件传上去了、路径丢了在服务器上变成孤儿文件的第一道防线。3.2 保存文件的正确姿势别用 SaveAs改用流写入老写法里随处可见file.SaveAs(path)但这个 API 有几个毛病一是它依赖HttpPostedFileBase内部实现在某些代理或反向代理环境下表现不稳定二是它默认处理不好中文路径和超长路径三是它没法在写入前做校验。更稳妥的做法是读取输入流自己用FileStream写盘。[HttpPost] public ActionResult UploadFolder(ListHttpPostedFileBase files, Liststring relativePaths) { if (files null || relativePaths null || files.Count 0) { return Json(new { success false, message 未收到有效的文件数据 }); } if (files.Count ! relativePaths.Count) { return Json(new { success false, message 文件数量与路径数量不一致 }); } // 存储根目录按日期分目录方便后续归档和清理 string rootDir Server.MapPath(~/Uploads/MedicalData/ DateTime.Now.ToString(yyyyMMdd)); Directory.CreateDirectory(rootDir); int savedCount 0; long totalBytes 0; for (int i 0; i files.Count; i) { var file files[i]; if (file null || file.ContentLength 0) { continue; } // 关键只取相对路径里的合法部分防止路径穿越 string relativePath SanitizeRelativePath(relativePaths[i]); string targetDir Path.Combine(rootDir, Path.GetDirectoryName(relativePath)); string targetFile Path.Combine(targetDir, Path.GetFileName(relativePath)); Directory.CreateDirectory(targetDir); using (var input file.InputStream) using (var output new FileStream(targetFile, FileMode.Create)) { input.CopyTo(output); } savedCount; totalBytes file.ContentLength; } return Json(new { success true, count savedCount, totalBytes totalBytes }); }SanitizeRelativePath是安全重灾区我单独在下一节讲这里先记住一个原则任何从客户端传来的路径字符串都不能直接拼进服务器路径。3.3 目录树重建的边界空目录怎么办用webkitdirectory选文件夹时浏览器返回的只有文件没有目录对象。如果源目录里有一个完全空白的子文件夹浏览器不会把它作为文件返回所以服务器端重建出来的目录树里自然也不会出现这个空目录。如果你业务上必须保留空目录比如某些检查类型的目录结构约定必须有待处理这个空文件夹占位那就得在前端额外处理目前没有一个浏览器 API 能直接列出空文件夹。我的实际经验是尽量在业务层面规避要求上传前确保目录非空实在规避不了就约定用户在空目录里放一个说明文件比如.gitkeep之类的占位文件上传后再由后端清理掉占位文件。这个方法土但稳定可靠。4. 医疗数据的校验与合规细节不能只看传上去4.1 路径穿越后端必须做的一道算术题这是整套方案里我最想强调的部分。webkitRelativePath是客户端传过来的字符串理论上用户完全可以构造一个恶意的../../../../Windows/System32/xxx.dll之类的路径。如果后端直接Path.Combine(rootDir, relativePath)攻击者就能把文件写到服务器任意位置这就是经典的路径穿越漏洞。我的处理方式是标准化之后做前缀校验private string SanitizeRelativePath(string rawPath) { if (string.IsNullOrEmpty(rawPath)) { throw new ArgumentException(路径不能为空); } // 统一转成正斜杠再按层级拆分过滤掉空段 string normalized rawPath.Replace(\\, /); var segments normalized.Split(new[] { / }, StringSplitOptions.RemoveEmptyEntries); // 过滤掉 . 和 .. 这种特殊目录段 var safeSegments segments .Where(s s ! . s ! ..) .ToArray(); if (safeSegments.Length 0) { throw new ArgumentException(非法路径); } // 重新拼成相对路径 string safeRelative string.Join(/, safeSegments); // 再做一次物理路径前缀校验双保险 string rootDir Server.MapPath(~/Uploads/MedicalData); string fullRoot Path.GetFullPath(rootDir); string fullTarget Path.GetFullPath(Path.Combine(fullRoot, safeRelative)); if (!fullTarget.StartsWith(fullRoot, StringComparison.OrdinalIgnoreCase)) { throw new ArgumentException(非法路径); } return safeRelative; }这个方法的思路是先把用户的路径拆成段把..这种危险段直接过滤掉再用Path.GetFullPath算出最终物理路径验证它确实落在存储根目录下。两道校验下来路径穿越基本堵死了。4.2 文件类型白名单别只信扩展名医疗系统里能接受的文件类型相对固定DICOM 医学影像.dcm、常用图片.jpg、.png、PDF 报告、少量压缩包。我的建议是做一个扩展名白名单同时再做一层魔数校验防止有人把 exe 改名成 jpg 传上来。扩展名校验很简单private static readonly HashSetstring AllowedExtensions new HashSetstring(StringComparer.OrdinalIgnoreCase) { .dcm, .jpg, .jpeg, .png, .pdf, .zip }; if (!AllowedExtensions.Contains(Path.GetExtension(targetFile))) { // 记录日志并拒绝 throw new InvalidOperationException(不允许的文件类型); }魔数校验是读文件头几个字节判断真实格式。比如 JPEG 文件头通常是FF D8 FFPNG 是89 50 4E 47PDF 是%PDF。DICOM 文件头则有固定的 128 字节 preamble 加DICM标识可以读出来判断是不是真的医学影像文件。对于严格要求的影像上传场景我会在业务线里加这一步避免脏数据污染 PACS 归档流程。4.3 文件名落地策略用 GUID 保底保留原始目录医疗数据还有个特殊性患者姓名、检查号这些敏感信息可能直接出现在原始文件名里例如张三_CT_20241010.dcm。如果直接按原始文件名落盘文件系统层面就泄露了患者隐私权限模型一旦被绕过整棵目录树都能被枚举。我常用的折中方案是目录结构按原始相对路径保留但叶子文件名替换成 GUID原始文件名存进数据库台账。这样目录树可读性尚在但文件系统里不会出现患者可识别信息。上传完成后再把原始相对路径、新文件名、上传人、上传时间、所属检查ID写入一张上传日志表后面排查问题、追溯数据都有依据。4.4 上传日志与审计别等出事了才想起来医疗系统上线任何功能都绕不开审计。文件夹上传涉及批量数据落地至少要在数据库里记录上传人、上传时间、文件来源目录名、文件数量、总大小、成功与失败明细、目标存储路径。我的习惯是在上传方法里同步写日志表同一个事务里完成避免文件成功但日志丢失的情况。这块功能不复杂但没做的话等医务科或信息科来查这批数据谁传的、从哪传的时你就会非常被动。5. 实测踩坑记录大目录、中文路径、IIS限制这几个坎5.1 IIS 的两道大小闸门默认值低到离谱本地开发时一切正常一部署到 IIS 上就发现超过 30MB 就直接报错这是几乎所有 .NET 开发者都会撞上的第一道坎。IIS 对请求大小有两层限制经常被搞混配置项位置默认值作用范围maxAllowedContentLengthsystem.webServer/security/requestFiltering/requestLimits30000000约 30MBIIS 层请求内容长度限制maxRequestLengthsystem.web/httpRuntime4096约 4MBASP.NET 层请求大小限制两个都必须调大只改一个没用。比如只改httpRuntimeIIS 这层还是会先拦下来。文件夹上传动辄上百兆我的配置参考如下system.web httpRuntime targetFramework4.7.2 maxRequestLength1048576 executionTimeout3600 / /system.web system.webServer security requestFiltering requestLimits maxAllowedContentLength1073741824 / /requestFiltering /security /system.webServer这里maxRequestLength单位是 KB1048576即 1GBmaxAllowedContentLength单位是字节1073741824即 1GBexecutionTimeout单位是秒设 3600 秒防止大文件传到一半被 ASP.NET 掐断。提示executionTimeout只对同步操作生效如果用了异步 Controller超时行为不完全一样大上传最好走异步或者干脆上分片后面会说。5.2 中文文件名和中文目录名乱码与~符号危机医疗系统的文件大多来自 Windows 环境中文文件名是常态。我在验证阶段遇到过两个典型问题。第一个是乱码。早期 IE 时代 multipart 表单可能不带 UTF-8 编码导致服务器端拿到的文件名全是???。现代浏览器基本都按 UTF-8 编码 multipart 了问题不大但保险起见可以在web.config里显式声明globalization requestEncodingutf-8 responseEncodingutf-8 fileEncodingutf-8 /第二个问题比较隐蔽有些文件从医院的某些老旧采集软件里导出来文件名带~、%、这些特殊字符或者路径特别长。Path.GetFileName处理普通特殊字符没问题但超长路径在 Windows 上有 260 字符限制一旦目录层级深、文件名又长FileStream就会抛PathTooLongException。我的对策是服务器重建目录时不直接沿用原始目录名到底而是用检查编号序列号这种短目录名映射原始目录既保留层级又缩短路径长度。5.3 几千个文件一起传浏览器和后端各自的压力文件夹上传和单文件上传最大的区别是文件数量。我在测试环境传过一个包含 4200 多个文件的 DICOM 目录单个文件不大但数量巨大这时候有两个现象值得注意。浏览器端File对象一次性全放进FormData内存占用是真实文件大小的总和至少是引用和元数据超大目录会卡死页面。所以前端最好限制单次选择目录的文件数量或者至少给一个文件过多请分批上传的提示。后端端循环 4200 次FileStream写入如果不做缓存控制磁盘随机写性能会很差。但好在医疗影像大多是小文件实测CopyTo逐文件写入尚可接受我跑完 4200 个文件大概耗时 40 秒属于能用的范围。5.4 一个最容易忽视的坑ListHttpPostedFileBase的索引对齐这块我前面提过但值得再强调一次因为我在正式环境里真的碰到过。项目早期版本前端用了第三方上传组件组件内部可能对文件做了异步读取结果提交到后端的files和relativePaths顺序错位导致病人的片子被存到了别人的检查目录下。这种错位不报错、不提示等你发现时数据已经乱了。所以我在后端做了三重保障一是进去先比对两个List的Count二是对每个文件记录原始文件名和相对路径数据库中二者必须匹配才落库三是落盘时用 GUID 重命名叶子文件即使路径错位至少不会互相覆盖。这套组合下来顺序问题基本被焊死了。6. 后续还能怎么玩进度条、拖拽上传与分片6.1 给上传加上真正的进度反馈文件夹上传往往要几十秒甚至几分钟没有进度条的话用户会以为系统卡死了。用XMLHttpRequest替代fetch挂在xhr.upload.onprogress上就能拿到上传进度const xhr new XMLHttpRequest(); xhr.open(POST, /Upload/UploadFolder, true); xhr.upload.onprogress function (e) { if (e.lengthComputable) { const percent Math.round((e.loaded / e.total) * 100); // 更新页面上的进度条 document.getElementById(progressBar).style.width percent %; document.getElementById(progressText).textContent percent %; } }; xhr.onload function () { if (xhr.status 200) { const data JSON.parse(xhr.responseText); // 处理结果 } }; xhr.send(formData);注意一个细节XHR的progress事件反映的是字节数进度不是文件数进度。如果你更关心文件数比如影像科看的是传了多少张那就要在后端做分片响应或者定时返回计数实现成本高一些。一开始用字节进度就够直观了。6.2 拖拽上传文件夹把选择文件夹变成拖进去很多用户觉得弹文件选择框这一步是多余的他们更习惯直接把文件夹从资源管理器拖到网页里。这个用 DataTransfer API 也能实现核心是递归遍历目录条目document.getElementById(dropZone).addEventListener(dragover, function (e) { e.preventDefault(); }); document.getElementById(dropZone).addEventListener(drop, function (e) { e.preventDefault(); const items e.dataTransfer.items; const entries []; for (let i 0; i items.length; i) { const entry items[i].webkitGetAsEntry(); if (entry) { entries.push(entry); } } // 递归读取目录组装成 { file, relativePath } 数组 const collected []; entries.forEach(entry walkEntry(entry, , collected)); // 收集完成后走和 webkitdirectory 相同的 FormData 逻辑 }); function walkEntry(entry, parentPath, result) { if (entry.isFile) { entry.file(file { result.push({ file: file, relativePath: parentPath entry.name }); }); } else if (entry.isDirectory) { const reader entry.createReader(); reader.readEntries(entries { entries.forEach(child { walkEntry(child, parentPath entry.name /, result); }); }); } }这个方案要特别注意readEntries每次最多返回 100 条目录条目文件数多的时候需要循环调用readEntries直到返回空数组否则会漏文件。我自己实现时用 Promise 包了一层等所有递归结束再统一提交避免异步竞争。6.3 超大文件怎么办分片上传的落地思路如果医疗影像单文件动不动就几百 MB比如病理切片扫描文件前面说的 1GB 上限就不够用了更稳妥的是分片。思路是前端用Blob.prototype.slice把大文件切成 5MB 或 10MB 一块每块带着文件标识、分片序号、总分片数、相对路径传给后端后端先落临时分片目录全部传完后触发合并。MVC 后端接收分片的 Action 和普通上传几乎一样只是多了几个分片参数[HttpPost] public ActionResult UploadChunk(HttpPostedFileBase chunk, string fileId, int chunkIndex, int totalChunks) { string chunkDir Server.MapPath(~/TempChunks/ fileId); Directory.CreateDirectory(chunkDir); chunk.SaveAs(Path.Combine(chunkDir, chunkIndex.ToString())); return Json(new { success true }); }合并时按顺序读所有分片字节写入最终文件然后删除临时目录。这块代码量不大但要注意分片顺序、并发控制和断点续传的交接问题我建议等文件夹上传的完整流程跑稳了之后再考虑不要一上来就上分片会显著拉高复杂度。6.4 另一个方向前端压缩、后端解压的备选方案偶尔也会遇到网络环境特别差、文件夹又特别大的场景比如基层医院带宽只有几兆。这时候webkitdirectory直传反而不是最优解。备选思路是前端把文件列表交给浏览器的ZIP库比如纯 JS 的 zip 库打包上传单个压缩包后端解压到目标目录。优点是对网络友好、IO 次数少缺点是浏览器端压缩大目录会卡 UI、内存占用高而且压缩这一步对临床用户不可见出问题时很难排查。所以我一般把它定位成最后一个备选不做默认方案。最后说点实战体会文件夹上传这个功能技术上真的不难难的从来都是边界情况和医疗场景的特殊性。我在最初版本里也犯过不少低级错误第一次上线忘了调 IIS 限制影像科一传 60MB 的目录就直接 404第一次做拖拽上传时漏了readEntries的循环读取几百个文件的目录总是少传十几个还有一次没有做路径消毒虽然没出事但被安全审计提了一次整改。这些坑回头看基本都是文档里写了、但没当回事的细节。如果你也准备在医疗系统里做这个功能我建议按这个顺序推进先跑通webkitdirectory加 MVC 接收的最小闭环再补路径消毒和类型白名单接着处理 IIS 配置和超时最后再做进度条和拖拽优化。基础版本其实一天就能出来但要把每个环节的边界想清楚才敢真正放到临床科室用。啰嗦一句上线前一定要用真实的临床目录结构测一遍别用文件夹里放三个 txt 的小样糊弄自己——文件数量、目录深度、中文名、文件名长度这些变量在真实环境里都会一起冒出来的。