ASP.NET Core大文件上传:断点续传与加密实战解析

发布时间:2026/9/16 5:00:22
ASP.NET Core大文件上传:断点续传与加密实战解析 做医疗系统开发这些年我踩过最深的坑之一就是大文件上传。CT影像、核磁共振、数字病理切片、手术录像这些动辄几百MB甚至几个GB的文件用传统的表单上传方式根本扛不住。用户在网页上点了上传等上十几分钟结果一个网络波动就前功尽弃整个文件从头再传——这在门诊高峰期简直就是事故现场。更要命的是医疗数据的敏感程度和普通业务数据完全不是一个量级光靠HTTPS传输加密根本不够看落盘文件必须做加密存储密钥还得单独管起来。这篇文章我就把自己在医疗项目里落地ASP.NET大文件上传方案的过程完整拆一遍覆盖断点续传和加密两条主线。核心思路很简单前端按固定大小把文件切成块逐块上传服务端记录每一块的状态中途断了就从最后成功的那一块接着传加密方面则是传输层走TLS存储层用AES-256做文件级加密密钥统一走独立的管理设施不放代码里。适合正在做医疗、金融等对数据安全有硬性要求的系统的后端和全栈同学参考前端部分我也会给出Web Worker分片上传的具体实现拿过去改改就能用。1. 项目背景医疗系统里的大文件上传到底难在哪1.1 医疗数据天然就是“大文件”先说说我为什么会对这个问题这么敏感。医疗系统里的大文件不是偶尔出现而是日常操作。DICOM格式的医学影像是最典型的例子一台CT扫描下来就是几百张断层图像单次检查的数据量轻松达到几百MB如果是增强CT或者高分辨率PET-CT一个序列直接奔着GB级别去。数字病理切片更是夸张扫描仪出的一张全切片图像分辨率动辄上万乘上万像素文件体量在2到5GB之间。再加上近几年医院都在上手术录播系统、远程会诊系统一段4K手术录像动不动就是几十GB。这些文件不是只存在某个科室的电脑里就完了它们需要经过Web系统上传到中心存储再分发给相关科室的医生查看。上传环节就成了整个业务链路上最容易出问题的瓶颈。我见过很多医院还在用早期的HTTP上传方案或者依赖一些ActiveX控件、浏览器插件来传大文件用户换台电脑就装不了控件浏览器一升级插件就失效维护成本和用户抱怨一直居高不下。这也是我在设计新方案时几乎没犹豫就直接放弃插件路线的原因——纯浏览器原生的上传能力加上前端Web Worker进行文件分片才是能长期稳定运行的方向。1.2 传统上传方案的三个致命伤聊方案之前先把我认为传统上传方式最致命的三个问题摆到台面上后面所有设计都是围绕解决这三个问题展开的。第一个问题是传输中断导致全量重传。医疗网络环境并没有很多人想象的那么稳定。医院大楼里无线覆盖不全医生办公室到机房的链路经过多层交换机高峰期网络一拥堵大文件传了一半断掉是家常便饭。传统表单上传没有断点恢复能力断了就得从头开始。我见过一个把几GB的病理切片上传到80%结果断网的案例那位医生当时脸色是真的难看。第二个问题是内存和超时限制。ASP.NET的传统Web Forms模式里maxRequestLength默认才4MB也就是说你不改配置连超过4MB的文件都提交不上去。就算把限制调大了IIS和ASP.NET管线在接收大请求时会把整个请求体加载到内存里几十个并发大文件上传就能把应用服务器的内存吃光。ASP.NET Core虽然改进了不少但如果你还按原来单次POST整个文件的思路做KestrelServerOptions.Limits.MaxRequestBodySize照样给你拦下来。第三个问题比前两个更隐蔽就是数据安全问题。医疗数据的脱敏、加密、审计要求在法规层面是明确写死的。大型三甲医院过等级保护、互联互通测评的时候评审组一定会查这块。很多系统把大文件明文传到服务器数据库里存个路径就算完事。一旦存储介质被拿走、备份被拖库患者的影像资料就是裸奔状态。所以在这个项目里加密不是可选项是硬性要求。2. 整体方案设计与技术选型断点续传和加密怎么搭配合适2.1 断点续传核心思路一句话讲透断点续传的原理说穿了其实很简单把一个完整文件切成若干固定大小的块每块独立上传服务端记录哪些块已经上传成功客户端下次只需从失败的块继续传。这就好比搬家一车装不完就分好几车拉中间某车出了事故损失的只是那一车的东西不用把已经搬到新家的家具再搬回去。分块大小是第一个要拍的参数。我在医疗系统里建议用5MB到10MB的块理由有三条块太小会导致请求数过多每块都有HTTP头部和状态查询开销一个1GB文件切成1MB的块就是1024次请求服务端压力太大块太大则失去了断点续传的意义10MB以上的块在弱网环境下依然容易中途失败而且重传代价高。实测下来5MB在大多数医院内网和专线环境下的综合表现最均衡。如果是面向互联网的互联网医院上传场景可以考虑降到2MB到4MB因为公网的丢包率和小运营商的网络质量波动更大较小的块能降低单次失败成本。文件唯一标识是另一个关键设计点。服务端怎么知道客户端传来的这些块属于同一个文件我用的是FileId策略前端根据文件的名称、大小、最后修改时间三个字段做一次MD5拼接作为文件标识。这个方案的好处是零网络开销就能在客户端生成稳定的ID同一个文件被重复选择时会自动复用已有的上传进度。注意这里不用对文件全文做MD5那需要先完整读一遍文件太慢了。文件完整性的校验放在分块合并完成之后做是另一套逻辑。2.2 加密传输层加存储层双层防护很多开发者的误区是我有HTTPS数据在传输过程中是加密的这不就安全了吗这个认知在医疗场景下是不完整的。HTTPS只保护了数据从浏览器到服务器之间的这一段链路数据一旦落到服务器的磁盘上就是明文的。数据库被拖库、存储阵列被直接拿走、日志文件中出现临时文件的路径任何一个环节泄了底患者的影像数据就全部暴露了。所以我把加密拆成两层来设计。传输层加密交给TLS/HTTPS这个不多说所有的生产环境都应该是强制HTTPS不能用HTTP明文回退。存储层加密则要在文件落盘时做文章每个文件使用独立的密钥DEKData Encryption Key进行AES-256加密DEK本身再用主密钥KEKKey Encryption Key加密后存储。这样即使存储介质泄露攻击者拿到的是密文文件和解密DEK的密文但没有主密钥依然无法还原明文。这种设计在医疗项目里还被要求在数据库层面记录加密算法的参数。具体来说我用AES-CBC模式每个文件生成独立的随机IV加密完成后将IV和加密后的DEK一起存到数据库的元数据表里。解密的时候先从数据库读出这些参数用主密钥解开DEK再用DEK和IV解文件。每个文件不同IV也保证了同样的明文内容加密后的密文完全不同从源头杜绝了基于密文比对的反推攻击。2.3 医疗场景的特殊约束选型和架构上还有一个医疗场景特有的约束就是兼容Web Worker在前端的使用。我们项目的目标浏览器是Chrome和Edge的较新版本IE早已不在支持列表里所以我可以放心用Web Worker做前端分片上传不用担心兼容性问题。如果你的项目里还需要兼容老旧的浏览器那你得考虑降级方案比如主线程分片加小体积块或者干脆提示用户更换浏览器总之不要在方案里保留对那些既有浏览器插件的依赖。另外医疗系统的部署环境也影响技术选型。很多医院的内网服务器是不允许直接访问外网的NuGet包的管理、密钥管理服务的连通性都要提前确认。我们最后使用的是ASP.NET Core 6因为它是LTS版本支持周期长医院这种节奏慢、升级周期长的客户环境最适合LTS。IIS反向代理的方式依然可行但Nginx在静态文件和并发处理上的表现更优如果医院运维团队能接受我更推荐Nginx。3. 断点续传核心实现前端分片、状态记录、服务端合并3.1 前端用Web Worker做分片上传前端分片的核心代码并不复杂难的是不要阻塞主线程。一个1GB的文件如果你在主线程里做file.slice()和循环上传页面会直接卡死用户拖拽窗口、点按钮全部没响应体验极差。Web Worker的作用就是把这份体力活挪到后台线程去干UI线程保持流畅。先看主线程怎么把任务交给Workerconst uploadButton document.getElementById(uploadBtn); uploadButton.addEventListener(change, function(e) { const file e.target.files[0]; if (!file) return; const chunkSize 5 * 1024 * 1024; // 5MB 每块 const totalChunks Math.ceil(file.size / chunkSize); const fileId md5(file.name file.size file.lastModified); // 检查服务端是否已有该文件的记录秒传逻辑 fetch(/api/upload/status?fileId${fileId}fileName${encodeURIComponent(file.name)}) .then(r r.json()) .then(status { if (status.isUploaded) { alert(文件已存在秒传成功); return; } const worker new Worker(/js/uploadWorker.js); worker.postMessage({ file: file, chunkSize: chunkSize, totalChunks: totalChunks, fileId: fileId, uploadedChunks: status.uploadedChunks || [] }); worker.onmessage function(msg) { if (msg.data.type progress) { updateProgress(msg.data.percent); } else if (msg.data.type complete) { finishedUpload(fileId, file.name); } else if (msg.data.type error) { alert(上传失败 msg.data.message); } }; }); });注意这里我特意加了一步秒传检查。服务端如果已经存在相同fileId的完整文件前端就什么都不用传了直接提示成功。这在医院里非常实用医生经常会对同一份影像多次选择上传秒传能省掉大量重复流量。Worker内部的逻辑更关键因为它要处理切片、上传、失败重试、进度上报这一整套流程self.onmessage async function(e) { const { file, chunkSize, totalChunks, fileId, uploadedChunks } e.data; const uploadedSet new Set(uploadedChunks); for (let i 0; i totalChunks; i) { if (uploadedSet.has(i)) continue; const start i * chunkSize; const end Math.min(start chunkSize, file.size); const chunkBlob file.slice(start, end); const formData new FormData(); formData.append(fileId, fileId); formData.append(chunkIndex, i); formData.append(totalChunks, totalChunks); formData.append(fileName, file.name); formData.append(chunk, chunkBlob); let success false; for (let retry 0; retry 3; retry) { try { const resp await fetch(/api/upload/chunk, { method: POST, body: formData }); if (resp.ok) { success true; break; } } catch (err) { // 网络错误等一秒重试 await new Promise(resolve setTimeout(resolve, 1000)); } } if (!success) { self.postMessage({ type: error, message: 第${i 1}块上传失败请检查网络后重试 }); return; } self.postMessage({ type: progress, percent: Math.round((i 1) / totalChunks * 100) }); } // 所有块传完请求服务端合并 const mergeResp await fetch(/api/upload/merge, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ fileId: fileId, fileName: file.name, totalChunks: totalChunks }) }); if (mergeResp.ok) { self.postMessage({ type: complete }); } else { self.postMessage({ type: error, message: 文件合并失败 }); } };这里有三点实操经验供你参考。第一重试一定要有指数退避不要每块失败都立刻重试否则网络抖动时会产生大量并发请求打爆服务端。第二进度条不要用块数除以总数这种线性算法要考虑每块大小一致所以线性没问题但网络波动会导致最后几个块特别慢用户在进度条卡在99%的时候最容易关页面。第三所有fetch都要设置超时Worker里的请求如果服务端假死没有超时控制的话这个Worker会永远挂在等待里。3.2 服务端接口设计与状态管理服务端我用ASP.NET Core Web API提供了三个核心接口查询状态、上传分块、合并文件。先看状态查询和分块上传[ApiController] [Route(api/upload)] public class UploadController : ControllerBase { private readonly IChunkStore _chunkStore; private readonly IFileRepository _fileRepository; private readonly IEncryptionService _encryptionService; public UploadController( IChunkStore chunkStore, IFileRepository fileRepository, IEncryptionService encryptionService) { _chunkStore chunkStore; _fileRepository fileRepository; _encryptionService encryptionService; } /// summary /// 查询文件上传状态已上传哪些块、是否已完成 /// /summary [HttpGet(status)] public async TaskIActionResult GetStatus([FromQuery] string fileId, [FromQuery] string fileName) { var entity await _fileRepository.FindByFileIdAsync(fileId); if (entity ! null entity.IsCompleted) { return Ok(new { isUploaded true, uploadedChunks new int[] { } }); } var uploadedChunks await _chunkStore.GetUploadedChunksAsync(fileId); return Ok(new { isUploaded false, uploadedChunks uploadedChunks }); } /// summary /// 上传单个分块 /// /summary [HttpPost(chunk)] [RequestSizeLimit(10 * 1024 * 1024)] // 单块上限10MB比块大小多留余量 public async TaskIActionResult UploadChunk([FromForm] ChunkUploadRequest request) { if (request.Chunk null || request.Chunk.Length 0) { return BadRequest(分块内容为空); } // 先对分块做内存流处理避免直接操作磁盘临时文件 using var memoryStream new MemoryStream(); await request.Chunk.CopyToAsync(memoryStream); var chunkBytes memoryStream.ToArray(); // 分块到达后先加密再落盘 var encryptedChunk _encryptionService.EncryptChunk(chunkBytes, request.FileId, request.ChunkIndex); await _chunkStore.SaveChunkAsync(request.FileId, request.ChunkIndex, encryptedChunk); return Ok(new { received request.ChunkIndex }); } }分块上传这个接口有几个细节值得注意。RequestSizeLimit属性要设置成比前端块大小略大的值防止请求体超限被Kestrel直接拒绝。每个分块不要直接存成明文的.part文件而是要经加密服务处理后落盘。这里有个取舍加密会消耗CPU加密每个5MB的分块在普通服务器上大约耗时几十毫秒这个开销在医疗场景的合规要求下是值得的。另外每个分块的加密IV也要单独处理不能所有分块共用同一个IV这会在分块间引入固定的明文-密文对应关系降低整个文件的安全强度。分块状态用什么存储我也纠结过一阵。最开始我用的是一张SQL Server表每条记录保存FileId、ChunkIndex、IsUploaded查询和更新都很直观。但在高并发上传场景下每个分块都要写一次数据库几百个分块就是几百次数据库连接压力不小。后来我改成Redis的Set结构每个FileId对应一个Set成员就是已上传的块索引查询时SMEMBERS一次拿全写入时SADD一条命令搞定性能好了一个数量级。如果医院环境里不想额外引入Redis用内存ConcurrentDictionarystring, HashSetint也能应付单机部署但要注意应用重启后状态会丢失断点续传能力就退化了。3.3 合并文件与完整性校验所有分块上传完成后前端会调用合并接口。合并的逻辑比较简单——把所有分块按索引顺序读出、拼接成完整文件但有两个点一定要处理好。第一个是并发合并的幂等性。前端在最后一个分块上传成功后会立即调用合并接口但如果前端有多个标签页同时操作同一个文件或者用户手抖点了两次上传就可能触发两次合并请求。所以合并接口必须要做幂等控制我在合并前用Redis的SETNX加了一把锁只有拿到锁的请求才执行合并另一个请求直接返回“正在合并中”。第二个是合并后的完整性校验。光把文件拼起来不算完医疗系统里文件损坏比文件丢失更隐蔽也更危险。我在合并完成后会计算整个文件的SHA256然后和前端上传时携带的校验值比对。但这里有个现实问题如果每个分块都经过加密那合并后的文件是密文的拼接对密文做SHA256只能验证密文的完整性不能证明解密后的明文没损坏。所以我的做法是每个分块在加密前先计算明文的SHA256把块哈希列表随文件元数据一起保存合并完成后再按同样的分块大小对密文文件重新分块逐块计算SHA256并与记录比对。这样一旦某块在传输或存储过程中出现比特翻转能精确定位到是哪一块出了问题重新上传那一块即可不用整个文件重传。[HttpPost(merge)] public async TaskIActionResult MergeChunks([FromBody] MergeRequest request) { // 幂等锁 var lockAcquired await _redis.LockTakeAsync($merge:{request.FileId}, Guid.NewGuid().ToString(), TimeSpan.FromMinutes(10)); if (!lockAcquired) { return Ok(new { status merging }); } try { var chunkDir Path.Combine(_config.UploadTempDir, request.FileId); var mergedFilePath Path.Combine(_config.StorageDir, ${request.FileId}_{request.FileName}); using (var output new FileStream(mergedFilePath, FileMode.Create, FileAccess.Write)) { for (int i 0; i request.TotalChunks; i) { var chunkPath Path.Combine(chunkDir, ${i}.part); var encryptedChunk await File.ReadAllBytesAsync(chunkPath); var plainChunk _encryptionService.DecryptChunk(encryptedChunk, request.FileId, i); await output.WriteAsync(plainChunk); } } // 计算明文文件哈希 var fileHash await ComputeSha256Async(mergedFilePath); await _fileRepository.UpdateAsCompletedAsync(request.FileId, mergedFilePath, fileHash); // 清理分块目录 Directory.Delete(chunkDir, true); return Ok(new { status completed, fileHash }); } finally { await _redis.LockReleaseAsync($merge:{request.FileId}); } }这里我选择在合并时逐块解密后写明文文件所以存储目录里的最终文件是明文状态。实际生产环境更严格的做法是合并成密文文件保持全程加密态只在访问层通过文件解密服务对外提供解密后的数据流。两种方案各有取舍前者简单直观、读取性能好后者安全态更强。我在项目里最终采用的是前者加目录权限管控加密操作集中在临时目录最终存储目录通过NTFS权限限制到只有应用池账户可访问同时开启Windows的EFS或者BitLocker做卷级加密兜底。如果是部署在Linux上对应方案是目录权限加上LUKS全盘加密或者eCryptfs目录加密。4. 加密功能落地上传加密、存储加密、密钥管理4.1 传输加密TLS是底线不是全部传输层的加密我只强调一个原则全链路的TLS必须有但绝不能就此收工。在ASP.NET Core里启用HTTPS很简单配置一下UseHttpsRedirection和HTTPS端口就行但有几个细节需要医疗项目特别留意。第一TLS的版本要禁用老旧的TLS 1.0和TLS 1.1这两者都已经有公开的漏洞利用方式。我在部署时强制服务端只启用TLS 1.2和TLS 1.3。第二密码套件要配置为支持前向保密的套件比如以ECDHE开头的套件这样即使服务器的私钥被泄露也无法解密以前录制的流量。第三如果是通过Nginx反向代理ASP.NET Core应用要确认浏览器到Nginx这一段以及Nginx到应用后端这一段的TLS都配置好很多部署只给外层加了HTTPSNginx到Kestrel之间却是明文HTTP数据在内网照样裸奔。但就像前面说的传输加密只是半程护送。数据进了服务器落到磁盘之后TLS就管不着了接下来该存储加密上场。4.2 存储加密AES-256实战在这一版方案里文件存储加密我用的算法是AES-256-CBC模式。之所以不用更高级的GCM模式虽然GCM带认证更安全但在分块加密的场景里管理认证标签的复杂度会显著上升——每个分块都要单独保存一份Tag合并、校验、断点续传都要跟着改逻辑。CBC模式配合每个分块独立随机IV加上文件级的SHA256完整性校验在安全性和工程复杂度上取得了平衡。加密服务的核心逻辑如下public class AesEncryptionService : IEncryptionService { private readonly IKeyManagementClient _keyClient; private readonly IFileMetadataRepository _metadataRepository; public AesEncryptionService(IKeyManagementClient keyClient, IFileMetadataRepository metadataRepository) { _keyClient keyClient; _metadataRepository metadataRepository; } public async Task(byte[] cipherText, byte[] iv) EncryptChunkAsync(byte[] plainChunk, string fileId, int chunkIndex) { // 每一个分块独立生成随机IV using var aes Aes.Create(); aes.KeySize 256; aes.Mode CipherMode.CBC; aes.Padding PaddingMode.PKCS7; aes.GenerateIV(); // 获取该文件的DEK首次上传时创建 var dek await GetOrCreateDekAsync(fileId); using var encryptor aes.CreateEncryptor(dek, aes.IV); using var ms new MemoryStream(); using (var cs new CryptoStream(ms, encryptor, CryptoStreamMode.Write)) { cs.Write(plainChunk, 0, plainChunk.Length); } var cipherText ms.ToArray(); // IV本身不用保密放在元数据里即可 await _metadataRepository.SaveChunkIVAsync(fileId, chunkIndex, aes.IV); return (cipherText, aes.IV); } }你会注意到IV是随每个分块动态生成的并且放进数据库元数据表。这是CBC模式的标准做法因为IV不需要保密需要的是唯一性和不可预测性。AES在.NET里的实现是System.Security.Cryptography.Aes初始化时如果你不指定Key它会生成随机的Key和IV但这里我们必须使用从密钥管理服务获取的DEK所以只调用了GenerateIV()。关于加密性能我也测过一组数据。用5MB分块、C#的AES实现在普通的Xeon服务器上加密和解密的吞吐量大约在每秒100到200MB之间具体取决于CPU是否支持AES-NI硬件指令集。现在的现代服务器CPU基本都支持所以加解密一个1GB文件大约用时5到10秒对整个上传流程来说基本无感。如果发现CPU不支持AES-NI加密速度会慢好几倍这时候就要考虑是不是该升级服务器了而不是去改算法。4.3 密钥管理医疗系统的关键命门密钥管理是整个加密方案里最难做也最容易被做砸的部分。我看到不少项目把AES密钥写死在配置文件里甚至硬编码在C#代码中这等于把保险柜密码贴在了保险柜门后面加密的意义直接归零。正确的做法是引入独立的密钥管理机制。我用的方案是信封加密Envelope Encryption系统为每个上传的文件生成一把随机的DEK用DEK加密文件DEK本身再用一把全局的主密钥KEK加密后存储。数据库里保存的是“被KEK加密后的DEK”而不是明文DEK。KEK存放在独立的密钥管理服务中比如云上的KMS或者自建的Vault服务应用运行时通过API接口向KMS申请解密DEK而不是把KEK下载到应用服务器本地。这个设计有一个医疗项目里非常实用的好处可以做到按文件级别的密钥撤销。如果怀疑某个患者的某份影像文件泄密只需要把对应的DEK记录删除即可这个文件即使被盗走也无法解密。其他文件使用的DEK各不相同完全不受影响。如果所有文件共用一个密钥一旦泄露就要全网大换血成本和风险都不可接受。密钥轮换的策略也要提前想好。我的做法是KEK定期轮换比如每年换一次或者人员变动时立即换。KEK换了之后旧文件里被旧KEK加密的DEK变怎么办需要在系统后台做一次“二次加密”——读出现有的DEK密文用旧KEK解密得到DEK明文再用新KEK加密写回去。这个过程可以后台异步跑不影响在线业务。DEK本身不换所以文件的密文也不用重新加密开销小得多。5. 医疗场景的合规加固与权限管控5.1 审计日志什么时候、谁、做了什么医疗系统做完断点续传和加密技术上已经能跑通了但离真正可上线还差一层——完整审计。医院的信息科和第三方测评机构在验收时对审计日志的要求非常细谁在什么时间上传了哪个文件、大小多少、文件的SHA256是多少、合并是否成功、下载过几次、每次下载的IP是什么、有没有试图访问权限之外的文件。我在这套方案里专门加了一张审计表记录所有上传和下载操作。上传的审计日志在分块上传阶段就开始记录但不是每块都记那样日志量太大而是记录“文件上传会话开始”、“文件合并完成”两个关键事件。下载则每次访问都记录包括调用文件解密接口的上下文信息。日志要防篡改写入后不允许更新只能追加。数据库层面把日志表放到独立的文件组并设置只追加的权限策略。这里我踩过一个坑审计日志和服务端日志混在一起。ASP.NET Core的ILogger输出的一大堆调试信息、警告信息全堆同一张表或者同一个文件里审计需要的关键事件被淹没泄密调查时根本没法快速定位。后来我把审计日志单独拆成独立的消息管道直接写入独立的审计表和普通日志物理隔离。实际检查时只需要按患者ID或者文件ID索引就能拉出完整的操作轨迹。5.2 访问控制加密再加一把业务层面的锁好的安全方案永远是技术加密加业务控制的组合。文件即使被正确解密成明文如果业务系统允许任何登录用户下载它那等于加密白做了。我在这套系统里对文件访问做了三层控制服务器端的授权校验、下载链接的时效性控制、以及基于角色的文件可见范围。下载操作不走静态文件路径而是通过带授权校验的接口转发。用户在页面上点击下载前端从后端拿到的不是文件的物理路径而是一个短时有效的临时下载凭证凭证里包含文件ID、用户ID、过期时间戳签名后返回。真正的文件读取接口校验凭证的签名和有效期确认当前用户对目标文件有访问权限后才通过文件流把解密后的内容输出给客户端。这样就把“文件系统层面的访问”和“业务系统层面的权限”结合了起来即使内网攻击者拿到了文件存储路径也无法绕过业务授权直接读取。临时凭证的有效期我设置的是5分钟足够用户完成下载又不会因为时间太长留下过大的滥用窗口。6. 常见问题与排查技巧实录6.1 断点续传类问题先列一张我在实际项目中反复遇到的问题对照表后面再挑几个典型的展开说。现象可能原因处理办法上传到一半进度回退到0前端fileId生成算法不稳定检查文件名、大小、修改时间是否变了合并后文件打不开分块顺序被并发颠倒合并时严格按索引顺序用锁保证串行断点续传失效服务端分块状态存内存进程重启丢失改用Redis或者数据库存储状态上传很慢且CPU打满每个分块加密开销异常检查服务器CPU是否支持AES-NI秒传不生效fileId重复但内容不同加入修改时间或内容抽样哈希提升唯一性最典型的坑是用户修改文件名导致断点失效。我的fileId算法里包含文件名医生如果在上传过程中因为某种原因改动了文件名或者系统在重试时改变了文件标题fileId就变了服务端会认为这是另一个新文件旧的上传记录完全匹配不上。后来我在生成fileId时增加了一个容错优先使用文件内容的抽样哈希——取文件头、中间、尾部的各64KB数据做哈希再结合文件大小生成ID。这样文件名变化不影响续传而且两个内容不同的文件即使同名抽样哈希也能区分开。第二个常见问题是合并时多个分块同时写同一个文件流。如果合并接口没有做幂等控制两个并发请求就会同时打开同一个输出文件产生文件覆盖和顺序错乱。我前面对合并接口加了Redis锁这就是针对这个问题的。开发环境里可能不会触发但生产环境用户多、重复点击的情况很常见一定要提前堵上。6.2 加密类问题加密相关的问题排查起来比上传问题要隐蔽得多因为表象往往是“文件打不开”“解密失败”根因却各不相同。我遇到最多的是IV不匹配导致解密失败。Web应用的请求是无状态的分块上传过程中如果应用重启内存里缓存的IV就会丢失。后来我改成每个分块上传时直接把IV写入数据库解密时从库里取彻底解决了这个问题。另外要特别小心字符编码问题IV存储时如果用的是nvarchar字段且代码转换时用了错误的编码读回来再转成字节数组就会多出几个或者少几个字节AES解密时直接抛异常。第二次遇到的是CBC模式的填充攻击风险。AES-CBC模式配合PKCS7填充如果密钥或IV出错解密时往往不是报错就是出来一堆乱码乱码在医疗场景里是很危险的——医生看到的可能是一张被损坏的影像图有的系统甚至不报错而是静默地渲染出错误图像。所以我强烈建议在解密环节加一个完整性断言解密后立即计算明文SHA256和上传时记录的块哈希比对不一致就立即中断读取并告警防止被篡改的数据流继续往下游走。6.3 性能与环境问题医疗系统上线后性能问题主要集中在两个地方。一个是上传高峰期上午9点到11点大量医生同时上传影像如果所有分块都同步走加密逻辑应用服务器的CPU会迅速飙升。我在优化时用了一个简单的限流策略每个FileId的处理信号量设为2超过就排队等待避免加密服务被并发请求击穿。另一个策略是把加密操作放到后台队列异步执行前端上传接口接收到分块后先落盘到临时明文目录立即返回成功后台Worker从队列里拿到分块再做加密转存。这样接口响应时间从几百毫秒降到几十毫秒用户体验提升明显代价是故障点变多需要队列系统的高可用保障。关于存储空间也提醒一句。分块文件落在临时目录合并完成后我会上面的代码里执行Directory.Delete(chunkDir, true)清理。但如果进程在清理前崩溃临时目录会残留大量.part文件。我加了一个定时清理任务每小时扫描一次临时目录删除超过24小时未更新的分块目录。这个清理任务在医疗现场特别重要因为医院内部偶尔会断电、服务器会有计划内维护没有兜底清理机制的话一个不小心磁盘就被残留分块占满。最后再分享一个我在项目上线时踩过的环境坑医院的防病毒软件会锁定正在写入的文件。我们部署的服务器上装了某安全软件它实时扫描所有新增文件当上传分块落盘时防病毒软件会短暂锁定文件句柄导致ASP.NET Core进程读取分块时出现“文件被另一个进程占用”的异常。排查这个问题的过程很痛苦最后通过在分块读取时增加重试机制遇到IOException就等待200毫秒再试最多重试5次才稳定下来。如果你在客户现场遇到上传偶尔失败、失败时报文件占用错误不妨先怀疑一下服务器上的安全软件。我在这个项目里最大的体会是大文件上传和加密单一做都不难难的是把它们组合起来放进一个真实的生产环境。分块让加密的粒度变细加密又让断点续传的状态管理多了一层复杂度每一步都在做权衡。但设计原则始终清晰——断点续传保证的是可用性加密保证的是安全性两者服务于同一个目标让医生在稳定的系统里安全地拿到他们需要的影像数据。