
医疗系统这类项目里“上传文件”从来不是简单一个input typefile能糊弄过去的。影像检查、病理切片、手术录像、导出的大批量报告单个文件动辄几百MB高分辨率影像序列到GB级别也很常见再加上医疗数据的敏感属性文件在传输过程中如果只是裸奔式的HTTP上传光想想就让人头皮发麻。如果你正好也在做Vue大文件加密上传DEMO或者想给团队搭一个能在技术评审时完整演示的上传骨架这篇文章应该能帮你省掉不少调研时间。我会把分片、断点续传、秒传、加密以及前端Worker的调度思路按一个可跑的DEMO维度拆开讲顺便把踩过的坑也一起列出来。1. 先想清楚医疗大文件上传到底要解决什么问题1.1 医疗数据的体量比你想的更夸张医疗系统里最常见的业务已经不只是传几张图片了。CT、MRI一个序列动辄几百张DICOM影像PACS导出的压缩包经常以GB计算病理科的全切片扫描WSI单张图可能就是几GB手术示教录像、内镜视频更是长时间连续写入。这些文件如果通过浏览器直接上传对前后端都是灾难。更关键的是医疗机构内部网络未必稳定。诊室、护士站、手术室之间往往隔着多级交换机和无线接入点偶尔一个Wi-Fi抖动就会中断传输。普通小文件断了重传无所谓GB级文件断了从头再来医生和技师的情绪会直接爆掉。所以医疗场景的诉求从来不是“能传上去”而是“在弱网、断网、大文件这三种因素叠加时依然能高效、安全、完整地把文件传到指定位置”。1.2 为什么一个input typefile传不了大文件很多人会把上传想得太简单前端选个文件FormData一包axios.post一发完事。这个流程在几MB的小文件上没有任何问题但一旦文件超过几百MB直接POST整个文件的痛感会逐条暴露。第一HTTP连接不稳定。长连接传输过程中只要网络抖一下整个请求失败后端拿不到完整文件前端只能重新传。第二浏览器内存扛不住。直接把整个File对象丢给后端实际上前端在构造请求体、读取文件内容时会产生极高的内存占用电脑卡死不是段子。第三进度条体验极差。浏览器原生上传进度虽然能拿到但一个进度条从0跳到99%再立刻失败重来用户会认为系统坏了。第四也是容易被忽略的一点——没有完整性校验。传完的文件是明文的、没有校验的传输过程中被篡改或损坏后端也不知道。所以大文件上传的标准解法就是把一个大文件切成很多小块逐块传输完成后在服务端按顺序拼接。这样单次请求体积可控失败重传的代价从整个文件变成一个小分片进度、断点续传、并发控制也都有了基础。这个思路本身不新鲜但在医疗场景里加上加密和敏感数据保护复杂度会上一个新台阶。2. 方案选型分片、加密、Worker三者怎么搭配2.1 分片上传的完整闭环我做的这个DEMO不是把分片、加密、断点续传三样东西简单堆在一起而是按一套完整的业务闭环来设计的。核心流程是选择文件后先计算整个文件指纹带着文件名、大小、指纹去后端注册一个上传任务后端返回uploadId、是否已经传过、以及已上传的分片序号前端根据这些信息把文件切成若干分片加密后并发上传所有分片传完后前端再请求合并接口后端把所有加密分片解密并按顺序拼成原始文件最后做完整性校验。其中“先注册任务、再分片上传、最后合并”的顺序不是随意的。先注册任务可以拿到一个全局唯一的uploadId所有分片都携带这个ID即使前端刷新、关闭、重新打开只要文件指纹一致就能通过查询已上传分片接口接着传。另外后端如果发现同样指纹的文件已经完整上传过可以直接返回“秒传成功”这一步对医疗系统里重复导出报表的场景特别节省带宽。2.2 加密策略前端加密不是“装样子”医疗数据最敏感的点在于传输过程中被监听、存储后被拖库两种情况都会造成严重的数据泄露。所以我的DEMO里把加密拆成两层来理解。第一层是传输通道加密也就是HTTPS。只要部署了正规的SSL证书TCP链路上的数据就是密文。但HTTPS解决不了服务端存储泄露问题一旦后端数据库或对象存储被拖走文件还是明文。第二层因此要做文件内容加密。前端生成一个随机会话密钥用AES-GCM算法对每个分片加密再用RSA公钥把会话密钥加密后一起传给后端后端持有RSA私钥解出会话密钥再逐片解密合并。这样文件在传输过程中是密文在后端落盘时也保持着密文状态只有真正进入合并环节才解密。很多人会质疑浏览器端加密没有意义因为密钥也在前端生成、也可能被攻击者从内存中读取。这个说法有一半道理。前端加密的定位从来不是对抗“用户本机被种了木马”这种极限场景而是对抗“传输链路被窃听”和“存储介质被复制”这两类常见威胁。它相当于把风险从“裸奔”降到“至少需要私钥才能打开文件”。在DEMO阶段只要让团队和评审看到这套机制能跑通就已经完成了它的使命。2.3 为什么要把切片和加密丢给Worker在纯前端实现里最容易踩的坑就是主线程卡死。文件切片、读ArrayBuffer、算哈希、AES加密这些操作如果在主线程同步执行文件一大界面就会假死进度条也不动了用户第一反应是“又死机了”。浏览器解决这个问题的标准工具是Web Worker。Worker相当于在浏览器里另开一个后台线程可以独立读取文件、切片、加密最后把结果通过postMessage传回主线程。主线程只负责调度向Worker派任务、接收加密分片、发HTTP请求、更新进度UI。这样即使文件有2GBUI依然流畅进度条能一秒一秒地走。我实际测试下来把分片、哈希、加密全部搬进Worker之后主线程的long task基本消失这个体验提升比任何花哨的进度动画都管用。后面讲核心实现时我会把Worker脚本的结构完整贴出来。3. 核心实现把一套可跑的DEMO从0搭到13.1 前端Vue组件任务注册与分片并发调度我采用Vue3的组合式API来组织代码组件里只放调度逻辑不碰文件读取和加密细节。核心状态包括当前选中的文件、总分片数、已上传分片数、上传进度、uploadId。选择文件后首先计算文件指纹然后调用注册接口。script setup import { ref } from vue import { createUploadTask, uploadChunk, queryUploadedChunks, mergeUpload } from /api/upload const file ref(null) const progress ref(0) const uploadId ref() const totalChunks ref(0) const uploadedChunks ref(new Set()) const CHUNK_SIZE 5 * 1024 * 1024 // 5MB const CONCURRENCY 3 let pendingQueue [] let activeCount 0 async function handleFileChange(e) { file.value e.target.files[0] } async function handleUpload() { if (!file.value) return const rawFile file.value // 第一步计算整个文件的SHA-256作为秒传和续传的指纹 const fileHash await computeFileHash(rawFile) // 第二步注册上传任务 const task await createUploadTask({ fileName: rawFile.name, fileSize: rawFile.size, fileHash }) uploadId.value task.uploadId // 后端告知文件已存在直接跳过上传 if (task.skipUpload) { progress.value 100 return } // 第三步构造分片队列跳过已经上传过的分片 totalChunks.value Math.ceil(rawFile.size / CHUNK_SIZE) uploadedChunks.value new Set(task.uploadedChunks || []) pendingQueue buildChunkTasks(rawFile, uploadId.value, totalChunks.value, uploadedChunks.value) // 第四步启动固定数量并发上传 for (let i 0; i CONCURRENCY; i) { runNext() } } function buildChunkTasks(rawFile, uploadId, totalChunks, uploadedChunks) { const tasks [] for (let i 0; i totalChunks; i) { if (uploadedChunks.has(i)) continue const start i * CHUNK_SIZE const end Math.min(start CHUNK_SIZE, rawFile.size) tasks.push({ uploadId, index: i, start, end }) } return tasks } async function runNext() { if (pendingQueue.length 0) { // 队列空了检查是否全部上传完成 if (activeCount 0) { await handleMerge() } return } const task pendingQueue.shift() activeCount try { // 真正上传分片内部通过Worker完成切片、加密、哈希 await uploadChunk(task) uploadedChunks.value.add(task.index) progress.value Math.round((uploadedChunks.value.size / totalChunks.value) * 100) } catch (err) { console.error(分片${task.index}上传失败, err) // 简单重试一次生产环境可加入退避策略 pendingQueue.unshift(task) } finally { activeCount-- runNext() } } async function handleMerge() { const res await mergeUpload({ uploadId: uploadId.value }) if (res.code 0) { progress.value 100 // 合并成功后服务端会做完整性校验 } } /script这段代码里最容易出问题的是runNext函数。它没有用循环去一次性发起所有请求而是每完成一个分片就补齐一个始终把活跃请求数控制在3以内。这样一个2GB文件虽然有400多个分片也不会一次性创建几百个并发请求。实测在并发3的情况下局域网内能跑满带宽公网弱网下也不会把后端打挂。3.2 Worker脚本切片、哈希、加密一条龙主线程里调用的computeFileHash和uploadChunk内部其实都是通过Worker来完成的。Worker脚本需要实现三个能力切文件、算哈希、加密。三者合在一起正好适合放后台线程。// chunk-worker.js self.onmessage async function (e) { const { taskId, file, start, end, index, sessionKey } e.data try { // 1. 切片 const blob file.slice(start, end) const buffer await blob.arrayBuffer() // 2. 计算当前分片的哈希用于后端校验 const digest await crypto.subtle.digest(SHA-256, buffer) const chunkHash Array.from(new Uint8Array(digest)) .map(b b.toString(16).padStart(2, 0)) .join() // 3. 使用AES-GCM加密分片内容 const iv crypto.getRandomValues(new Uint8Array(12)) const encrypted await crypto.subtle.encrypt( { name: AES-GCM, iv }, sessionKey, buffer ) // 4. 把密文和元数据传回主线程 self.postMessage( { taskId, index, chunkHash, encrypted, iv: Array.from(iv) }, [encrypted] // 使用transferable避免拷贝大对象 ) } catch (err) { self.postMessage({ taskId, error: err.message }) } }这里有一个非常容易被新人忽略的点postMessage最后一个参数传了[encrypted]意思是将这个ArrayBuffer的所有权从Worker转移给主线程而不是复制一份。如果不传这个参数浏览器会做一次深拷贝2GB大文件产生的分片数据会在内存里多占一倍后果是致命的。前端哈希计算和分片加密需要用到同一个会话密钥。这个密钥在主线程里生成后通过postMessage传给Worker。密钥本身不随请求发送而是用RSA公钥加密后再通过接口传给后端这就是我之前说的“信封加密”模型。加密密钥只存在内存中页面刷新后失效需要重新获取。3.3 后端Spring Boot接收分片与合并解密后端我用的是Spring Boot接口总共三个注册任务、上传分片、合并文件。这里最需要注意的是接收分片不能把整个MultipartFile读进内存再写文件而是直接落盘到临时分片目录否则并发上传时后端内存会瞬间被打满。PostMapping(/upload/chunk) public RString uploadChunk(RequestParam String uploadId, RequestParam Integer index, RequestParam String chunkHash, RequestParam String iv, RequestParam(data) MultipartFile data) throws Exception { String partDir uploadDir File.separator uploadId; File dir new File(partDir); if (!dir.exists()) dir.mkdirs(); File partFile new File(dir, index .part); data.transferTo(partFile); // 将index、chunkHash、iv等元数据写入分片记录表 chunkMetaService.save(uploadId, index, chunkHash, iv); return R.ok(); }合并接口的任务就重一些先校验分片是否齐全然后把所有分片按index排序逐个读取密文、用会话密钥解密、按顺序写入目标文件。解密用的会话密钥来自注册任务时前端传来的信封先用RSA私钥解开。PostMapping(/upload/merge) public RString merge(RequestParam String uploadId) throws Exception { // 1. 校验分片数量 int expected uploadTaskService.getChunkTotal(uploadId); if (chunkMetaService.count(uploadId) ! expected) { return R.fail(分片不完整无法合并); } // 2. 读取信封解开会话密钥 String envelope uploadTaskService.getEnvelope(uploadId); byte[] sessionKey rsaUtil.decryptByPrivateKey(Base64.getDecoder().decode(envelope)); // 3. 逐片解密并按顺序写入最终文件 File finalFile new File(uploadDir, uploadId .dat); for (int i 0; i expected; i) { byte[] iv chunkMetaService.getIv(uploadId, i); byte[] cipherData Files.readAllBytes( Paths.get(uploadDir, uploadId, i .part)); byte[] plainData aesUtil.decrypt(sessionKey, iv, cipherData); FileUtils.writeByteAppendToFile(finalFile, plainData); } // 4. 删除分片临时目录更新任务状态 uploadTaskService.markCompleted(uploadId); return R.ok(); }后端合并这一步最考验磁盘IO能力逐片解密再追加写入属于典型的流式处理。如果合并时一次性把所有分片读进内存两个1GB的文件就能把服务器搞内存溢出。分片数量在几百到上千时固定大小字节数组循环读取是更稳妥的方式。4. 关键参数怎么定分片大小、并发数与算法选择4.1 分片大小怎么算分片大小是这类DEMO里争论最多的参数。设小了分片数量暴增HTTP请求握手开销变大设大了重传粒度变大弱网下容易频繁失败。我一般用一个经验公式来估算期望分片大小 可用带宽(MB/s) × 单分片目标耗时(s)比如目标单分片在2秒左右传完可用带宽5MB/s那分片大小就是10MB左右。如果目标是公网弱网场景带宽可能只有200KB/s那分片大小取1MB~2MB更合理。我DEMO里默认填的5MB是一个折中值局域网能跑满带宽4G/5G网络也不会明显拖慢速度。从分片数量角度再验证一下2GB文件5MB一片共410片。如果每片都做一次HTTP请求局域网内并发3大约需要137轮按每轮300ms算总耗时约40秒这在实际体验里是完全可以接受的。4.2 并发数和内存的取舍并发数不是越大越好。每个并发上传都意味着浏览器要同时保持一个包含加密分片数据的ArrayBuffer在内存中同时后端也要为每个请求维持一个multipart流。我实测下来前端并发控制在3到5最稳妥。超过8个后总耗时几乎没有明显下降但浏览器的内存占用会明显上升后端出现偶发的一时无响应。有些文章会推荐一个“动态并发”策略根据最近N个分片的平均耗时和当前带宽动态调整并发数。这个思路在生产项目里确实有价值但DEMO阶段用一个固定值就够了。我的建议是代码里把并发数抽成配置常量评审时能改参数做对照演示比写死成3更有说服力。4.3 加密算法怎么选AES-GCM还是国密SM4DEMO里我默认用了AES-GCM原因是Web Crypto原生支持不用引额外依赖性能也很稳定。AES-GCM还自带认证能力解密时能检测密文是否被篡改这对医疗数据的完整性保护非常重要。如果你的项目面向国内医疗客户可能被要求支持国密算法。此时可以把AES换成SM4、RSA换成SM2浏览器端有成熟的第三方库比如sm-crypto可以调用。加密思路完全一样只是算法标识、密钥格式和分组模式要跟着调整。我建议在代码里把加解密封装成一个独立的crypto.js模块后续要切换国密时只改这个文件业务逻辑完全不动。5. 常见问题与排查技巧实录5.1 踩坑速查表现象、原因、排查方向我在做这个DEMO和后续改造真实业务时遇到过不少问题列一个速查表照着排查能省很多时间。现象可能原因排查方向进度条卡在99%不动最后一个分片没传完就调了合并接口在merge前检查已上传分片数量是否等于总分片数合并后文件损坏分片乱序写入或某分片缺失检查服务端是否按index排序合并前校验分片数量上传过程中页面卡死切片/加密逻辑没有放Worker打开DevTools Performance看是否有超长Task后端内存暴涨分片接口直接把MultipartFile读进内存改用transferTo落盘别调getBytes/readAllBytes传小文件秒传正常大文件进度不动文件哈希计算时间过长导致UI假死把哈希计算移到Worker大文件会算几秒到十几秒抓包看到的还是明文加密逻辑没生效或发送的是原始Blob检查上传请求体里的data是加密后的ArrayBuffer还是原始文件切片并发高时后端返回50x服务器连接数或内存被打满降低前端并发数或后端线程池调优分片重复上传前端重试和并发导致同一分片提交多次分片记录表加(uploadId, index)唯一索引5.2 几个必须绕开的“隐形坑”第一个坑是Worker里不要直接发起HTTP请求。很多同学觉得既然Worker能跑业务逻辑那就让它把上传请求也一起发了但axios这类库依赖window对象Worker里没有window根本跑不起来。就算绕过去用fetch后续也难做取消请求、统一报错、进度汇总。正确的分工是Worker只做文件处理HTTP请求全部回流到主线程通过FormData发送加密后的ArrayBuffer。第二个坑是加密和解密的性能不对称。前端用AES-GCM加密2GB文件即使放在Worker里也需要一定时间。如果每一片都等加密完再发网络请求整个流程会被加密拖慢。我试过把“加密”和“上传”做成流水线Worker一边向后传加密结果主线程一边向上传实测整体吞吐比“先全加密再全上传”高了将近一倍。这个流水线在代码里不复杂值得花时间优化。第三个坑是证书和密钥的管理。RSA公私钥如果直接写在前端代码里等于密钥泄露评审时会被直接打回来。生产上要把公钥下发做成一个接口私钥只保存在服务端硬件或专门的密钥管理系统里。DEMO阶段可以写在配置文件中但一定要在代码注释里标明“仅用于演示生产环境必须接入KMS或HSM”。5.3 用DEMO做演示的小技巧这个DEMO如果只是自己跑通了价值就少了一半。我的建议是在技术评审或面试展示时准备三个固定演示环节。演示秒传准备一个小文件上传完成后重新选择同文件观察进度条瞬间跳到100%后端不会产生新的分片记录。演示断点续传用DevTools的Network面板把网络切换为Offline上传到一半时让请求失败再切回Online重新点上传能看到前端只传剩余分片而不是从头开始。演示加密打开浏览器DevTools的Network选中一个分片上传请求查看Payload的二进制数据如果能看到一串不可读的密文比任何口头解释都有说服力。我在给团队演示时还发现一个细节不要把文件选得太大512MB左右是最合适的量级既能体现分片和并发又不会让评审等待时间太长。演示前先把该文件完整传过一次再演示续传节奏会非常流畅。这段DEMO做完之后最让我意外的并不是技术本身整个DEMO做完花了两天真正让我意外的是团队里最关心的其实不是加密用了AES还是SM4而是“前端算哈希会不会让页面卡死”“断网重传能不能找回原进度”这些最基础的体验问题。后来我把哈希计算和加密搬进Worker后主线程彻底不卡了大家才松了一口气。如果你也要把类似方案落到真实业务里我再多说一句一定要把分片元数据和文件指纹持久化到数据库而不要只存在内存里。我见过不少项目页面一刷新续传功能就失效原因就是上传状态没有落库。另外大文件上传往往要配合任务状态表记录每个上传任务的进度、状态、错误信息运维排查时能少走很多弯路。这套DEMO只是骨架但骨架的方向对了后面往里填业务逻辑才不会跑偏。