
在医院信息化干了这些年碰上最让人头疼的需求之一就是“大文件上传”。CT影像、病理切片、手术录像、远程会诊记录动辄几百MB甚至几个GB网络一抖动传了半小时直接失败患者那边等着报告科室那边催着数据压力全在工程师身上。更麻烦的是医疗数据还有合规要求患者隐私不能泄露文件落盘不能是明文等保测评一来检察人员首先问的就是“上传过程数据有没有加密存储有没有加密密钥怎么管的”这篇文章就围绕“ASP.NET大文件上传”这个场景把断点续传和加密这两块怎么落地一次说透。内容是基于我实际做过的医疗集成平台项目整理出来的会给出完整的方案设计、关键代码、参数取舍和踩坑记录适合正在做医院信息化、医疗影像上传、远程医疗系统的.NET开发人员参考。1. 先想清楚医疗大文件上传到底难在哪1.1 医疗数据的特殊性决定了方案不能照搬通用做法普通系统里的大文件上传无非是调个组件、改个超时时间顶多再加个进度条。但医疗系统不一样数据性质决定了技术选型必须足够谨慎。首先是文件体积一张乳腺钼靶的无损影像可能300MB一段4K手术录像能到2GB以上这类文件用传统的multipart/form-data一次性POSTWeb服务器默认请求体限制就是30秒超时、4MB大小不改配置连小文件都传不上去。其次是网络环境。医院的网络不是互联网公司那种IDC机房环境不少基层医院内网带宽只有百兆跨院区专线丢包率还高。我之前见过一个项目影像科上传断层扫描数据到区域平台网络一波动整包重传骨科的医生直接在群里开骂。所以断点续传不是“锦上添花”而是“没有这个功能系统根本不敢上线”。再就是合规要求。医疗数据受法律和等级保护约束患者影像属于隐私数据如果明文落盘一旦数据库或者存储被拖库责任不是罚钱那么简单。这里就引申出两个硬性需求传输通道要加密存储文件要加密。两者缺一不可。1.2 常规上传方案的瓶颈在哪里很多人遇到大文件上传第一反应是调大maxAllowedContentLength、maxRequestLength或者改IIS的requestFiltering再不行就上Uploadify、WebUploader这类现成组件。这么做的确能解决一部分问题但有几个硬伤整包上传没有断点能力一旦网络中断、浏览器崩溃、服务器回收进程整个文件就得重新传用户的耐心和医院的带宽都耗不起。服务器内存压力大大文件整体读入内存再写盘并发一高ASP.NET进程的内存直接飙升IIS应用池重启正在上传的文件全部中断。明文传输风险默认HTTP通道下文件在传输中可被中间人截获。医疗影像如果被篡改影响的是诊断结论这是医疗事故级别的风险。无完整性校验文件传完也不知道有没有丢字节影像数据坏了一个字节阅片软件就可能打不开。所以这个项目从一开始就定了思路前端把文件切成多个分片逐片上传每片独立校验后端接收后落盘到临时目录全部完成后合并传输走HTTPS落盘用AES加密另外再用状态记录表跟踪每个文件上传到哪里随时可以续传。1.3 方案选型的核心思路技术栈选的是ASP.NET Core Web API 前端原生JavaScript配合Web Worker做分片计算。之所以不选ASP.NET Framework的老方案一是跨平台部署方便医院机房linux上也能跑二是ASP.NET Core内置了更灵活的中间件管道处理分片上传和超大请求体更顺手三是System.Security.Cryptography类库同时支持AES-GCM这些新算法医疗场景下加密需求完全可以覆盖。技术环节推荐方案理由后端框架ASP.NET Core Web API跨平台、中间件灵活、性能好前端分片JavaScript File API Blob.slice原生支持不需要额外依赖分片计算Web Worker避免大文件读MD5时卡死UI线程传输加密HTTPS/TLS 1.2防窃听、防中间人篡改存储加密AES-256-GCM性能好带认证加密防篡改断点记录数据库存储上传状态支持多端续传、进度查询2. 断点续传的完整落地从分片到合并再到状态恢复2.1 前端分片策略先算再传断点续传的第一步是在浏览器端把大文件切成小片。前端核心逻辑很简单用File对象的slice方法把文件按固定大小切开然后逐片上传。但有一个细节必须提前处理好——计算整个文件的唯一标识MD5或SHA256否则服务端无法判断“这些分片属于哪个文件”。医疗场景里文件动不动1GB以上对整个文件计算MD5可真不是个秒级操作。直接在UI线程里跑浏览器会直接提示“页面无响应”。所以这里我用了Web Worker来做分片和哈希计算。// worker.js self.onmessage function(event) { const file event.data.file; const chunkSize event.data.chunkSize; // 建议 5MB 或 10MB const chunkCount Math.ceil(file.size / chunkSize); for (let i 0; i chunkCount; i) { const start i * chunkSize; const end Math.min(start chunkSize, file.size); const chunk file.slice(start, end); // 这里用 FileReader 读取分片内容再算每一片的 md5 const reader new FileReader(); reader.onload function() { const md5 SparkMD5.ArrayBuffer.hash(reader.result); self.postMessage({ index: i, md5 }); }; reader.readAsArrayBuffer(chunk); } };前端主线程拿到每个分片的md5后再逐片上传服务端接收分片时也要校验分片md5防止传输过程中数据损坏。分片大小怎么选我实测下来有个经验局域网内建议5MB公网/专网建议1~2MB。分片太小请求次数暴增网络握手开销大分片太大单片传输时间长一旦失败重传成本高。医疗系统如果部署在院内5MB基本是性价比最高的如果是跨院区公网传输建议2MB。2.2 前端断点续传的恢复逻辑断点续传不是“重新传一遍”而是“跳过已经传好的分片”。前端需要做两件事上传前查询服务端看看该文件已接收到哪些分片只上传缺失的分片。查询接口设计成GET /api/upload/status?fileMd5xxx返回结构类似{ fileMd5: 5d41402abc4b2a76b9719d911017c592, uploadedChunks: [0, 1, 2, 5, 6], totalChunks: 10 }前端拿到uploadedChunks数组后用Set过滤需要上传的分片索引从缺失位置继续传。这里有个隐藏细节如果第3片传了一半网络断了服务端没有记录它完成那么它就不在uploadedChunks里下一次会重新传第3片完全没问题。另外医院内网经常出现“同一个患者房间里网络差换个房间Wi-Fi就断了”的混乱场景所以我把断点信息放在后端数据库里存着而不是放在浏览器本地存储。这样做的好处是换电脑、换浏览器、甚至换了个人操作只要知道文件的md5都能继续传到一半的任务。2.3 后端分片接收与临时文件管理后端接收分片的接口我设计了两个一个是POST /api/upload/chunk负责接收单个分片一个是POST /api/upload/merge负责合并所有分片。每个分片请求都带上文件的唯一标识、当前分片索引、总分片数、分片大小等元数据。[HttpPost(chunk)] public async TaskIActionResult UploadChunk([FromForm] ChunkUploadRequest request) { var file request.File; // IFormFile var fileMd5 request.FileMd5; // 整个文件的MD5 var chunkIndex request.ChunkIndex; // 当前分片索引 var totalChunks request.TotalChunks; // 建立临时目录/uploads/temp/{fileMd5}/ var tempDir Path.Combine(_env.ContentRootPath, uploads, temp, fileMd5); Directory.CreateDirectory(tempDir); var chunkPath Path.Combine(tempDir, ${chunkIndex}.part); // 先算一下收到的分片MD5与前端传来的md5对比 using var stream file.OpenReadStream(); var sha256 SHA256.Create(); var receivedHash Convert.ToHexString(await sha256.ComputeHashAsync(stream)); stream.Position 0; if (!receivedHash.Equals(request.ChunkHash, StringComparison.OrdinalIgnoreCase)) { return BadRequest(new { message 分片校验失败请重传 }); } await using (var fs new FileStream(chunkPath, FileMode.Create)) { await file.CopyToAsync(fs); } // 记录已上传分片到数据库或Redis _uploadProgressService.MarkChunkUploaded(fileMd5, chunkIndex, totalChunks); return Ok(new { message chunk uploaded }); }这里有几个操作细节必须说清楚分片文件命名用{index}.part方便合并时按索引从小到大拼接避免乱序问题。临时目录以文件MD5命名不同文件互不干扰。每个分片写完就close释放句柄避免高并发时文件锁定冲突。校验分片哈希的服务端CPU开销不小但医疗数据不能省宁可慢一点不能错一点。2.4 合并分片环节的关键处理所有分片传完后前端请求合并接口。服务端要做三步检查是否所有分片都到达按索引顺序合并分片文件合并后再计算整个文件的SHA256与前端传的整个文件哈希做比对。[HttpPost(merge)] public async TaskIActionResult MergeChunks([FromBody] MergeRequest request) { var tempDir Path.Combine(_env.ContentRootPath, uploads, temp, request.FileMd5); if (!Directory.Exists(tempDir)) return NotFound(未找到上传记录); // 检查分片完整性 var expectedChunks request.TotalChunks; for (int i 0; i expectedChunks; i) { var partPath Path.Combine(tempDir, ${i}.part); if (!File.Exists(partPath)) return BadRequest($缺少分片 {i}); } var finalPath Path.Combine(_env.ContentRootPath, uploads, raw, ${request.FileMd5}_{DateTime.Now:yyyyMMddHHmmss}.dat); await using (var finalStream new FileStream(finalPath, FileMode.Create)) { for (int i 0; i expectedChunks; i) { var partPath Path.Combine(tempDir, ${i}.part); await using var partStream new FileStream(partPath, FileMode.Open); await partStream.CopyToAsync(finalStream); } } // 校验完整文件哈希 await using var verifyStream File.OpenRead(finalPath); var sha256 SHA256.Create(); var fileHash Convert.ToHexString(await sha256.ComputeHashAsync(verifyStream)); if (!fileHash.Equals(request.FileHash, StringComparison.OrdinalIgnoreCase)) { // 哈希不匹配说明合并有问题删掉重来 File.Delete(finalPath); return BadRequest(文件完整性校验失败); } // 删除临时分片文件 Directory.Delete(tempDir, true); // 在这里触发加密落盘逻辑见下文 await _encryptionService.EncryptFile(finalPath); return Ok(new { fileId request.FileMd5, size new FileInfo(finalPath).Length }); }分片合并这一步最大的坑是磁盘空间。医院环境里多个科室同时上传大文件临时目录会以惊人的速度膨胀。我建议运维上做两层保障一是设置.NET后台定时任务定期清理超过24小时未合并的临时目录二是在合并前先检查磁盘剩余空间低于预警值就拒绝新的分片上传请求防止磁盘写满导致系统崩溃。3. 加密功能传输加密、存储加密、密钥管理三步走3.1 传输层加密HTTPS不是可选项先说最基础但也是最重要的一层——传输加密。医疗系统的Web API和前端之间必须用HTTPS协议。这一条没什么可商量的在ASP.NET Core里配置HTTPS重定向中间件只用几行代码app.UseHttpsRedirection();但要注意仅仅是配置了HTTPS还不够IIS或Nginx的TLS版本必须限制在1.2及以上禁掉SSLv3、TLS 1.0这些老协议。不然等保测评人员拿扫描器一扫直接给你贴个“启用了弱加密协议”的漏洞标签。医院这种环境我更推荐网关层做HTTPS卸载把证书管理统一放在Nginx/负载均衡器上应用层专注处理业务逻辑。还有个小细节分片上传接口如果走CDN或者云存储直传那云存储那边的访问链接也要强制HTTPS而且签名链接必须设置有效期不能给个永久公网链接让别人随便下载患者影像。3.2 存储加密方案选型AES-GCM优于AES-CBC文件传到服务器后如果直接落盘成明文那前面做的加密传输意义就减半了。数据库可能被拖库服务器磁盘可能被拆走或者运维人员直接拷走原始文件这些都是真实潜在风险点。针对这类文件加密我用的是AES-256-GCM。选GCM而不是大家更熟悉的CBC原因有两点GCM是AEAD算法加密的同时产生认证标签能检测密文是否被篡改。医疗影像一旦被篡改轻则误诊重则出人命完整性必须优先保证。GCM可以并行计算性能比CBC好对动辄GB级别的影像文件来说加密耗时直接关系到用户等待时间。加密接口可以封装成一个服务核心逻辑如下public async Task EncryptFile(string plainFilePath) { var key _keyProvider.GetCurrentKey(); // 从密钥管理系统获取 var nonce new byte[12]; // GCM 推荐 96-bit nonce RandomNumberGenerator.Fill(nonce); var plainName Path.GetFileName(plainFilePath); var encryptedPath Path.Combine(_env.ContentRootPath, uploads, encrypted, ${plainName}.enc); await using var plainStream File.OpenRead(plainFilePath); await using var encryptedStream File.Create(encryptedPath); // 先写文件元信息加密文件名、nonce、密钥版本等方便解密时还原 await encryptedStream.WriteAsync(nonce); await using var cryptoStream new CryptoStream( encryptedStream, new AesGcmCryptoTransform(key, nonce), CryptoStreamMode.Write); await plainStream.CopyToAsync(cryptoStream); await cryptoStream.FlushFinalBlockAsync(); }实际项目里.NET内置AesGcm类可以直接用不需要自己写AesGcmCryptoTransform上面的写法是为了示意封装。注意几点每个文件加密时都要生成独立的随机nonce绝对不能复用否则相同的明文会得出相同的密文前缀给攻击者提供分析素材。加密后的文件头要保存nonce和密钥版本号解密时先读头部信息再拿对应版本密钥解密。加密完成后删除明文临时文件避免明文残留在磁盘上。3.3 密钥管理医疗行业最容易被忽略的一环很多团队把加密做好了但密钥就写死在Web.config或appsettings.json里这就等于把保险柜钥匙贴在保险柜门上。医疗行业对加密密钥的管理检查人员不只看算法更看密钥的生命周期管理。我在项目中采用的是“集中密钥管理 数据库存储密钥版本 定期轮换”的组合方案密钥不落应用配置文件而是放在独立的密钥管理服务里可以是Azure Key Vault也可以是自建的加密机/HSM医院内网环境更多用后者。加密时通过服务鉴权获取当前活动密钥解密时根据文件头里的密钥版本号获取对应密钥。每年至少轮换一次主密钥轮换后旧密钥仍然保留解密能力但不再用于新文件的加密。用表格总结一下密钥管理策略管理点做法目的密钥存储独立密钥服务/HSM防止密钥随代码泄露密钥版本密文头部写入版本号支持密钥轮换与历史解密轮换周期每年至少一次降低单密钥长期泄露风险审计记录密钥访问日志等保要求的可追溯性备份密钥分片备份多人保管防止密钥丢失导致数据永久不可解密这里特别提醒一句不确定的密钥千万别弄丢了。医疗数据要保存很长时间有些病历资料法定保存期限是30年你加密做得再牛密钥丢了文件一样等于废了。我见过某个PACS项目运维把加密机搞挂了又没有做主备容灾和备份几千份历史影像直接打不开这个事故差点成法律纠纷。所以做加密方案之前先把密钥备份和灾备方案考虑好这不是小事。3.4 透明加密和数据库加密的补充除了文件本身医疗系统还有大量结构化数据存在数据库里比如患者姓名、身份证号、诊断结论。文件上传方案通常还关联一个上传记录表表里包含患者ID、文件名、大小、操作人等字段。这些字段如果以明文存数据库那数据库一旦被脱库加密文件本身再强也没用——因为攻击者可以通过数据库里的关联关系定位敏感文件。我的建议是数据库里的敏感字段患者ID、身份证、诊断信息用对称加密存储应用层负责加解密文件存储路径本身要加密或至少用无规则GUID命名不要暴露“患者姓名-文件名”这样的明文对应关系如果医院要求更严格可以考虑部署透明的数据库加密插件但这类产品大多绑定数据库厂商选型时要注意兼容性。另外有朋友问到Linux环境下的透明加密。坦白说那套方案更适合服务器本地文件系统统一加密场景它会钩子到VFS层对应用透明。但医疗系统一旦上云或者多节点共享存储透明加密的密钥管理会变得很复杂而且它防不了应用层漏洞导致的数据泄露。所以我个人不做首选优先在应用层做好显式加密这样可控性最强。4. 常见问题与排查技巧实录4.1 大文件上传老是超时页面502这种问题十有八九不是代码的问题而是IIS和ASP.NET Core的请求体大小限制没放开。ASP.NET Core默认请求体限制是30MBIIS的maxAllowedContentLength默认是30000000字节约28.6MB。两层都得改!-- web.config -- system.webServer security requestFiltering requestLimits maxAllowedContentLength4294967295 / /requestFiltering /security /system.webServer// Program.cs builder.Services.ConfigureFormOptions(options { options.MultipartBodyLengthLimit 1099511627776; // 1TB为分片预留足够余量 options.ValueLengthLimit int.MaxValue; options.MultipartHeadersLengthLimit int.MaxValue; });同时Kestrel的MaxRequestBodySize也要设置为null不限制不然改完IIS还有Kestrel这一层卡着。4.2 分片传完了合并时发现缺少中间某一片这种问题的出现原因比较隐蔽。很多前端框架的并发上传队列有错误重试机制但如果分片请求超时后前端把请求“放弃”了而服务端其实已经写盘成功此时数据库记录还没更新就会出现“服务端有文件但状态记录缺失”的情况。我排查了半夜后定位到的原因就是这个前端并发数太高部分慢分片被中断前端重新生成新的分片ID导致旧的分片变成孤儿文件。解决办法有三条合并前做全量分片检查缺哪片就精确重传哪片前端并发数控制在3~5个不要开几十个并发服务端记录分片状态时加幂等判断同一个chunkIndex重复提交时直接覆盖旧文件并返回成功。4.3 AES加密后文件变大甚至解密失败GCM加密会在密文后面追加16字节的认证标签所以加密文件比明文多出28字节左右nonce 12字节 tag 16字节这是正常现象。如果解密失败最常见的原因是nonce没有正确保存或读取错位。我见过一个同事把nonce写到密文末尾结果解密时先读了末尾数据当nonce用怎么都解不开。约定好格式很重要。我的建议是固定文件头结构前4字节存nonce长度接着存nonce再存4字节的密钥版本号然后才是真正的密文。加解密逻辑写在同一模块里避免两端理解不一致。4.4 加密性能太差CPU飙高服务器在做AES-GCM加密大文件时CPU密集计算是无法避免的但可以通过几种方式把开销降下来加密操作放到异步后台任务执行不要卡住API请求线程。前端只负责上传和发起“加密请求”服务器返回“加密处理中”的状态前端定时轮询加密结果。用更高性能的服务器或启用AES-NI指令集现在绝大多数X86服务器都支持.NET在支持AES-NI时会自动利用硬件加速。文件合并完成后再整体加密比边接收边加密性能更好因为加密前的分片可以直接写入减少反复加解密。实际项目中1GB的文件用AES-256-GCM加密在主流服务器上也就2~3秒的事几乎可以忽略。真正耗时的瓶颈反而是磁盘IO所以给系统配SSD比纠结加密算法更有效。4.5 兼容性坑医院里还有一堆老浏览器和国产化客户端医院信息化环境鱼龙混杂有的科室电脑还是Windows 7 IE11有的装的是国产安全浏览器。前端分片上传用的是File API、Blob.sliceIE11对Blob.slice支持不好有的版本还需要带前缀的msSlice。虽然现在大多数项目可以用现代浏览器但做医疗系统还是要有一颗兼容的心。如果实在要兼容老浏览器我的建议是对IE类浏览器降级成“整包上传 服务端断点续传”方案——也就是前端不切片但服务端仍将收到的流式文件按块存储断点时的最后一块通过重试补传或者直接和医院沟通要求阅片室浏览器升级到Chrome/Edge内核很多时候只要信息科出面就能搞定。4.6 文件损坏、MD5计算耗时太长怎么优化前面用了Web Worker计算整个文件MD5但1GB文件算MD5仍然要好几秒。如果想加速可以并行计算每个分片的md5然后用分片md5组成新字符串再算一次整体标识。这样既做到“每个分片快速校验”又用分片摘要作为该文件的唯一标识。代码里可以这样// 每片算完先存到数组 chunkMd5s.push(md5); if (chunkMd5s.length chunkCount) { const fileId SparkMD5.hash(chunkMd5s.join()); // 用 fileId 作为文件上传的唯一标识 }这种做法虽然不能和“整文件MD5”严格等价但对断点续传来说已经足够因为分片清单本身就是对该文件内容的约束不同文件产生相同分片摘要组合的概率可以忽略。5. 上线前别忘了这些检查项方案写完了代码跑通了上线前还要对照检查一遍。我通常按这样一个清单来验收传输协议是不是全部强制HTTPSHTTP请求是否被重定向或拒绝上传临时目录是否有权限管控是否禁止脚本执行加密后的文件是否落到了独立存储区明文文件是否彻底清理密钥有没有备份密钥服务有没有做高可用部署数据库里的患者关联字段是否已加密有没有上传审计日志谁在什么时候上传了什么文件记录可查询断点续传是否能在模拟断网拔网线、杀进程后正常恢复并发上传场景下分片记录是否出现状态错乱。这些项目全部通过系统才敢拿到医院真实环境里跑。尤其是最后两项我建议在执行大版本上线或等保测评演练前拉一台测试机模拟弱网环境真实地传一个1GB以上的文件看看恢复情况别到患者真正要传报告的时候掉链子。好思路和代码都写在上面了。如果你也在做类似医疗大文件上传的项目可以直接按这套思路搭骨架再根据医院的实际网络条件、合规等级做微调。如果你在实施过程中碰到了别的问题也欢迎评论区交流我尽量把坑提前帮你踩了。