医疗大文件上传实战:WebUploader分片加密从选型到落地

发布时间:2026/10/8 2:59:24
医疗大文件上传实战:WebUploader分片加密从选型到落地 先交代背景。我在医疗信息化团队这几年接手过一个让全科室抓狂的“老大难”放射科每天导出的病历PDF动不动就三十几MB、甚至上百MB老系统用的是表单HTTP直接上传传几分钟后连接超时、页面卡死、进度条清零患者和医生两头干着急。后来我们改用百度WebUploader组件把PDF在浏览器端做分片加密再传上传成功率一下从不到70%提升到99.6%药师、分诊护士再没因为传病历骂过系统。这篇东西不吹不黑就把我们当时怎么选型、怎么设计分片加密、怎么填坑的过程完整写下来。给正在处理大文件上传或者被医疗数据加密合规追着跑的朋友一个可落地的参考。1. 项目背景病历PDF上传的“三座大山”先别急着看代码得先搞清楚病历PDF为什么难弄。很多开发一听到上传PDF脑子里默认就是几MB的小文档但医疗场景完全不是这个量级。1.1 病历PDF的真实体积基数临床上的PDF尤其是带影像学报告的、病理切片的本质上不是纯文字版PDF而是从PACS系统导出的图文混排文件。一张CT薄层扫描图导出成PDF动不动几十MB一份带高清病理图片的会诊报告甚至能到200MB。你可以把这类PDF想象成一袋子老照片的电子扫描本里面每一张图都是高分辨率点位数据文字只是配角图片才是体积大头。我们统计过某三甲医院放射科一个月的数据PDF文件大小分布大概是这样的文件大小区间占比典型场景500KB ~ 5MB约35%普通检验报告、出院小结5MB ~ 30MB约40%超声报告、内镜报告30MB ~ 100MB约20%CT/MRI图文报告、病理会诊100MB以上约5%全息影像压缩包、多序列核磁也就是说超过四分之一的PDF超过30MB。这个体量传统的同步上传接口根本扛不住尤其是医院内外网带宽并不像互联网公司机房那样奢侈很多院区间专线看着是千兆实际到医生工作站那条链路上传带宽可能只有20Mbps到50Mbps。1.2 隐私合规明文传输等于裸奔医疗数据不只是“大”更核心的是“敏感”。病历PDF里带着患者姓名、身份证号、诊断结论、影像资料按网络安全等级保护和数据安全法相关要求传输环节必须有加密手段。很多老系统以为“走HTTPS就算加密了”这在等保测评里只能说勉强过了网络传输这一关但关键问题是文件如果先完整传到服务器落盘再在服务端做加密那文件落盘的一瞬间就是明文状态。服务端被拖库或者运维误操作把文件拷到临时目录整个患者隐私就漏了。我们当时的整改要求很明确数据离开浏览器之前就必须是密文。这句话翻译成技术实现就是要在浏览器端完成加密服务端只能收到密文。1.3 用户体验传一半断了没人受得了还有一个很现实的问题用户体验。医生不是IT人员他们只关心点击上传后是不是能顺利提交。老的同步上传方案一个100MB的文件传10分钟中间手机一震动、楼层交换机偶发丢包整个HTTP连接就断了文件得从头传起。有一次门诊医生直接冲到信息科说“我再也不传这个报告了你们系统比蜗牛还慢”。这种背景下我们开始认真找方案结论就俩字分片。把大文件切成小片一片传失败只需要重传该片不需要从头再来。2. 方案选型为什么我们翻出了百度WebUploader市面上大文件上传方案不少原生XHR手写分片、各种现代框架里的上传库、商业云OSS/SDK、还有Flash时代的旧组件。我们最终用了百度WebUploader这个2015年开源的“老古董”原因值得展开说说。2.1 主流方案横向对比我在选型时列了一张对比表把几个候选方案的核心差异罗列清楚方案分片能力断点续传浏览器兼容性开发成本维护风险原生XHR手写需自己写需自己写现代浏览器可用高工作量大低但排期长商业云SDK成熟成熟好低依赖厂商文件可能必须上云现代上传库如Uppy成熟成熟好中需要对现代npm体系熟悉百度WebUploader内置内置极好含老浏览器中低开源组件资料多但也需二次改造最打动我们的其实是WebUploader的“兼容性”和“分片能力”组合。医疗行业终端环境非常复杂有的工作站还在用IE11内核的国产化浏览器有的医生电脑是万年不升级的Chrome老版本如果新方案只支持现代浏览器光终端升级就能折腾三个月。WebUploader的底层是HTML5但对老环境自动降级到Flash上传虽然Flash已经退出历史舞台但在当时我们改造期间部分终端还需要它顶上。这个“降级兼容”的覆盖面是很多新库不具备的。当然现在Flash彻底不能用了我们后来把降级策略换成了普通整包上传兜底但整体框架没动。2.2 核心能力恰好长在需求点上WebUploader有几个能力简直是给医疗大文件上传量身定做的第一内置分片和并发控制。chunked: true、chunkSize、threads核心参数一配分片调度、并发上传、失败重试就都有了。不需要自己写一个复杂的分片状态机。第二自带分片MD5校验。它可以在分片上传前后计算分片的MD5值服务端用这个值验证每个分片是否完整合并后再对整个文件算一次MD5确认最终文件没有损坏。第三事件机制足够细腻。before-send、uploadSuccess、uploadError、uploadComplete这些事件可以拿到分片块的上下文非常适合在做上传前塞一些自定义逻辑——我们后来就是在这些事件里把“加密”和分片状态管理绑在一起的。第四社区资料多。医院项目有个特点强制上线时间在那摆着不允许花一个月研究新框架。WebUploader的问题在CSDN、博客园、Stack Overflow上都有大量讨论组里新来的同事看两天文档就能上手维护这对一个核心业务系统来说是巨大的隐性收益。3. 架构思路先加密再分片还是分片后逐个加密说清楚一个关键取舍浏览器端处理加密和分片的先后顺序。我见过不少团队在这个问题上绕了弯路。3.1 一个必须想明白的先后顺序从标题看“分片加密”四个字容易让人理解成“先分片再对每个分片单独加密”。这种方案的好处是每个分片本身就是一段完整密文服务端可以逐片校验但代价是实现复杂度高还需要额外处理加密初始化向量IV的传递、每个分片的密文长度变更、以及合并时如何把分片边界对齐。我们最终选择了另一个方案先整体在浏览器端加密成一个密文Blob再把密文Blob交给WebUploader分片上传。很多同行可能觉得这不算“分片加密”。但其实我们站在服务端看收到的每一个分片都是加密数据每一片都是密文完全满足“分片传输过程中不可读”的合规要求。更重要的是这个方案可以把WebUploader的分片机制和加密逻辑解耦加密只关心文件整体分片只关心传输调度两件事互不干扰出问题了排查也容易。3.2 整体链路设计我们最后落地的完整链路是这样的浏览器读取原始PDF文件转成ArrayBuffer然后用会话密钥做AES-GCM加密并把加密后的密文重新包装成一个Blob再用WebUploader对这个密文Blob做分片上传。分片通过HTTPS发送到服务端服务端校验每个分片的MD5按顺序落盘到临时目录全部收齐后按分片序号合并成密文文件最后把会话密钥交给密钥管理服务统一保存密文落库。注意那个会话密钥不是放在前端写死的常量而是每次上传由浏览器临时生成通过独立的HTTPS通道交给后端密钥服务保存。后面做病历下载时再拿密钥到服务端解密或者直接做密文归档。3.3 分片参数推导过程分片大小不是随便拍的要根据几个维度算网络带宽、单请求耗时、服务端临时存储、失败重试成本。我们当时对着网络环境算了一笔账。假设某个院区上传可用带宽是20Mbps也就是理论峰值2.5MB/s。分片大小单片理论传输时间100MB文件分片数每片重试成本结论1MB约0.4s100片低但请求数量太多握手开销占比高不推荐服务端IO压力大2MB约0.8s50片适中一般网络凑合但效率偏低5MB约2s20片适中推荐在多数医院内网表现均衡10MB约4s10片单次失败重试成本增加偏大弱网超时风险高最后分片大小取5MB并发线程数threads设为4。为什么并发线程不是越大越好因为医院弱电线路通常不是独立专线和门诊挂号、LIS系统共用出口带宽开10个并发反而互相挤占单个分片传输速度更慢整体吞吐反而下降。实测下来4个并发在20Mbps的上传链路上能跑满带宽又不至于把网络打满影响其他业务。4. 核心实现WebUploader参数配置与源码级加密细节理论讲完上实操。这一部分会把前端如何配置WebUploader、如何在浏览器端加密PDF、以及服务端如何接收解密完整讲一遍。4.1 创建WebUploader实例配置分片参数先看最基础的uploader实例创建。下面是我们改造后的核心配置语言用的是JavaScript你要是在Vue或者React项目里用把它包在一个组件生命周期里就行。const uploader WebUploader.create({ // 上传接口 server: /api/medical/pdf/upload, // 选择按钮我们用的是自定义按钮而不是默认的file input pick: #filePicker, // 接受PDF文件限制mime类型和扩展名 accept: { title: 病历PDF, extensions: pdf, mimeTypes: application/pdf }, // 关键开启分片 chunked: true, // 分片大小我们按上文推演定为5MB chunkSize: 5 * 1024 * 1024, // 并发线程数 threads: 4, // 服务端接收文件的字段名 fileVal: file, // 分片上传时额外携带的自定义参数 formData: { encryptMode: AES-256-GCM }, // 断点续传开关 duplicate: true, // 单个文件上传失败重试次数 retries: 3 });这里几个参数背后的意图要说明白。chunkSize决定服务端接收多少个分片请求。如果PDF是50MB5MB一片正好10片。threads不是越大越好上面说过了4是我们在真实院区环境测出来的经验值。retries设为3意味着单片失败最多重试3次超过了才报错不至于因为一次网络抖动就让整个任务失败。4.2 在文件正式入队前做浏览器端加密WebUploader本身不关心你给它的是什么文件所以我们的办法是不直接选原始PDF而是选完文件后先读成ArrayBuffer加密再把加密结果转成Blob用这个新Blob代替原始文件进入上传队列。加密部分使用Web Crypto API的AES-GCM算法这在现代浏览器里是原生支持的标准接口。以下是封装的加密函数async function encryptFileToBlob(file, cryptoKey) { // 1. 把File对象转成ArrayBuffer const fileBuffer await file.arrayBuffer(); // 2. 生成12字节的随机初始化向量每份文件都不同 const iv crypto.getRandomValues(new Uint8Array(12)); // 3. 用AES-GCM加密tagLength是128位认证标签 const cipherBuffer await crypto.subtle.encrypt( { name: AES-GCM, iv: iv, tagLength: 128 }, cryptoKey, fileBuffer ); // 4. 把iv和密文拼接到一起方便服务端落盘时直接写入 const encryptedWithIv new Uint8Array(iv.byteLength cipherBuffer.byteLength); encryptedWithIv.set(iv, 0); encryptedWithIv.set(new Uint8Array(cipherBuffer), iv.byteLength); // 5. 返回blob类型改为application/octet-stream避免被当PDF解析 return new Blob([encryptedWithIv], { type: application/octet-stream }); }这里有两个细节必须说。第一IV一定要随机且每次不同绝不能复用。如果两次加密用了同一个IV密钥相同的情况下密文会泄露原文结构化信息这在AES-GCM里是致命的。第二把IV和密文拼接在一起传输服务端才能解密实际生产中IV是明文随密文一起走的不用怕泄露。会话密钥的生成也很简单async function generateSessionKey() { return await crypto.subtle.generateKey( { name: AES-GCM, length: 256 }, true, [encrypt, decrypt] ); }extractable: true的意思是这个密钥允许导出这样我们可以把密钥以JWK格式传给后端密钥服务。如果你不需要导出生产上更安全的是设为false但我们的场景需要后端以后解密文件所以这里保留了导出能力。4.3 用加密后的Blob替换原始文件接入上传队列加密得到密文Blob之后替换逻辑是这样的// 用户选择的原始PDF const rawFile event.file; // 生成本次上传的会话密钥 const sessionKey await generateSessionKey(); // 加密原始文件得到密文Blob const encryptedBlob await encryptFileToBlob(rawFile, sessionKey); // 注意这里替换的是WebUploader内部处理的文件对象 // 把rawFile.source指向加密后的Blob uploader.option(formData, { encryptMode: AES-256-GCM, originalName: rawFile.name, originalSize: rawFile.size }); uploader.addFiles([encryptedBlob]);这样加进去之后WebUploader在内部做分片时每个分片的数据都来自密文Blob。服务端拿到的每一片都是密文的一部分敏感信息在浏览器端就被彻底遮断了。再补充一个细节originalName和originalSize要放在formData里传给服务端因为密文Blob的文件名和大小已经不是原始信息了。服务端合并完密文后需要用这些元数据来恢复原始文件的展示名和大小。如果你不加这个下载的时候恢复不了原文件名。4.4 before-send阶段注入分片序号和MD5分片上传时服务端要按顺序拼装必须知道每一片是第几片。WebUploader实际上会通过参数chunk和chunks自动携带分片序号和总分片数我们只要在before-send事件里把分片MD5加上加密信息再补全。uploader.on(before-send, function(block) { const file block.file; // 给当前分片追加自定义参数 uploader.option(formData, { chunkIndex: block.chunk, chunkCount: block.chunks, chunkMd5: block.md5, encryptIv: ivHex // 这里取的是上面加密时生成的IV }); });block.md5是WebUploader内部在分片时就计算好的MD5值这也是我们选这个组件的一个重要原因。服务端拿到分片后先用这个MD5校验分片传输是否损坏如果损坏直接拒绝该片浏览器重新传这一片而不是整个文件。4.5 服务端解密与合并实现服务端我们用Node.jsExpress写的接收接口核心逻辑分三步。第一步接收分片存入临时目录校验MD5。const express require(express); const multer require(multer); const fs require(fs-extra); const path require(path); const crypto require(crypto); const app express(); const upload multer({ dest: /tmp/pdf_chunks/ }); app.post(/api/medical/pdf/upload, upload.single(file), async (req, res) { const { chunkIndex, chunkCount, chunkMd5, encryptIv, originalName, originalSize } req.body; const tmpDir /tmp/pdf_chunks/${req.body.identifier || Date.now()}; // 用MD5校验当前分片完整性 const receivedBuffer fs.readFileSync(req.file.path); const receivedMd5 crypto.createHash(md5).update(receivedBuffer).digest(hex); if (receivedMd5 ! chunkMd5) { return res.status(400).json({ code: CHUNK_MD5_ERROR, message: 分片校验失败 }); } // 按分片序号保存命名文件用padStart保证排序正确 await fs.ensureDir(tmpDir); await fs.move(req.file.path, path.join(tmpDir, chunk_${String(chunkIndex).padStart(6, 0)})); res.json({ code: OK, chunkIndex }); });第二步所有分片收齐后按序合并成完整的加密文件。app.post(/api/medical/pdf/merge, async (req, res) { const { identifier, originalName, originalSize } req.body; const tmpDir /tmp/pdf_chunks/${identifier}; const chunkFiles await fs.readdir(tmpDir); // 按序号排序保证合并顺序正确 chunkFiles.sort(); const mergedPath /data/medical_pdf_encrypted/${Date.now()}_${originalName}.enc; for (const chunkFile of chunkFiles) { const chunkBuffer await fs.readFile(path.join(tmpDir, chunkFile)); await fs.appendFile(mergedPath, chunkBuffer); } // 合并后校验文件大小防止丢片 const stat await fs.stat(mergedPath); if (stat.size ! parseInt(originalSize) 12 16) { // IV 12字节 GCM认证标签16字节 return res.status(500).json({ code: MERGE_SIZE_ERROR, message: 合并后大小校验失败 }); } res.json({ code: OK, filePath: mergedPath }); });第三步密钥单独存到密钥服务密文文件和密钥分开保管。这一步很关键即使密文文件被拖走没有密钥也解不出原始PDF。我们当时做等保整改评审专家最看重的就是这一点加密密钥和密文是否分离存储。解密的逻辑也简单服务端从密钥服务取出密钥读取密文文件把前面12字节IV拆出来后面部分做AES-GCM解密就能还原出原始PDF。5. 常见问题与排查技巧实录再稳的方案上线后面临的都是真刀真枪的兼容性问题。这里挑几个我们踩过而且有代表性的坑。5.1 问题速查表现象可能原因解决办法上传进度卡在某个分片上不动弱网导致分片请求超时调大chunkSize调小threads或增加retries加密后文件上传成功但解密失败IV长度或拼接方式不对确认IV是12字节且解密时的IV和加密时的IV完全一致服务端合并后大小不对分片丢失或顺序错乱统一按chunkIndex排序服务端判断收到的分片数是否等于chunkCount老浏览器报crypto.subtle是undefined浏览器版本过旧或非官方更新渠道升级到现代浏览器或提前做能力检测并给出业务提示分片上传请求反复重试但还是失败后端接口未兼容分片参数服务端必须从req.body读取chunkIndex等参数不能只取文件字段预览PDF时页面白屏Adobe Reader插件冲突改成内置PDF预览或直接用浏览器原生PDF viewer5.2 几个大坑的详细记录第一个坑老版本浏览器加密接口不可用。有台医生工作站的系统是浏览器老版本而且装的是第三方“汉化版”工具包。页面在HTTPS环境下运行但window.crypto.subtle就是undefined加密函数直接报错。排查了半天发现是浏览器版本太旧且不是标准官方渠道更新的版本导致Web Crypto API没有启用当时为了应急写了个降级方案如果crypto.subtle不存在就退出加密上传流程改用传统整包上传并把用户信息记录到日志里事后由信息科跟进升级。这个兜底逻辑在生产里帮我们挡了至少十次投诉。第二个坑IV处理错误导致解密全部失败。刚开始测试时服务端解密一直报错后来发现是前端把IV转成Hex字符串传给服务端时忘了补足位数。12字节的IV转Hex是24个字符代码里有一处用了toString(16)直接用成了整数形式结果每份文件的IV都丢了前导零。这个bug非常隐蔽因为偶尔几份文件解出来是对的大部分是乱码。修了一个下午才发现。第三个坑Adobe PDF预览闪退。这不是我们项目本身的问题但直接影响用户体验。有些客户端电脑上装了多款PDF阅读器名单相冲打开病历附件时经常出现“Adobe PDF闪退”的现象。我们排查中发现多数情况下不是文件坏了而是插件冲突或者内存占用过高。解决方法是引导用户改用浏览器内置的PDF预览或者卸载冗余阅读器保留一个顺带也把门诊和住院两个使用场景的默认打开方式统一了。第四个坑分片过多时服务端临时目录文件清理不及时。分片上传期间服务端会在临时目录里存下全部分片。如果用户传了一半就关掉浏览器临时文件就变成了“孤儿文件”一直占着磁盘空间。我们加了定时任务每小时清理超过两小时的临时目录并且在上传完成合并后立即清理对应目录。5.3 排查工具和思路给正在排查类似问题的朋友一个建议先开浏览器开发者工具在Network面板里看分片请求的响应码。如果分片返回4xx基本是后端接口参数没对齐 如果返回5xx多半是服务端保存临时文件权限或目录问题 如果是请求挂起不返回那八成是网络层丢包或超时先ping一下对端服务器再考虑调整分片大小和并发线程数。经验法则分片上传问题80%出在参数对齐和超时设置上只有20%出在加密算法本身。6. 实测效果与性能调优这一节把我们最后拿到的数据列出来顺便说一下为了达到这个效果做的几项关键调优。6.1 优化前后对照指标改造前改造后100MB PDF平均上传耗时约20分钟常超时约3分钟上传成功率68%99.6%失败重传成本整个文件重传只重传失败的分片超过50MB文件支持勉强易崩溃稳定测试过500MB传输过程明文暴露是文件落盘明文否全程密文6.2 调优细节第一把MD5校验调整到分片级别做。WebUploader支持对分片计算MD5我们要的不仅是文件最终完整性更要是“每一片传输时都完完整整”。按5MB分片一个100MB文件拆20片每一片算一次MD5计算量可接受但换来的是单片损坏精准定位不需要整个文件推倒重传。第二加密功能增加能力检测。在生成上传器之前先跑一个能力检测函数确认浏览器支持Web Crypto API、支持Blob.arrayBuffer()、支持File.slice。三个条件缺一不可否则就提示用户换现代浏览器或者走降级上传逻辑。这个前置检测把所有因为浏览器版本问题导致的诡异失败都挡在了上传动作发生之前。第三对超大文件做动态分片策略。普通PDF用固定5MB但超过300MB的罕见大文件我们会在前端判断文件大小自动把分片提到10MB。为什么因为超大文件分片太碎会导致服务端临时文件数量爆炸合并时间反而变长。这个动态调整逻辑虽然简单但对用户体验提升明显。7. 最后我在实际项目中的几点体会整个改造项目从选型到上线用了不到三周。最大的体会是技术选型真不是越新越好。百度WebUploader虽然年迈但它的分片调度、并发控制、MD5校验和丰富的生命周期事件恰好补齐了我们当时最大的短板。把分片这种通用能力交给成熟组件把精力集中在加密、密钥管理、合规这些医疗业务真正关心的事情上这才是正确分工。如果你也在处理类似场景我给你三条操作建议。第一先小流量试跑别一上来就全网切换选一个科室先跑两周把数据拿出来对比再铺开。第二密钥管理一定要独立于文件存储这是等保评审的红线一定不要把密钥和密文放同一个库。第三整个方案要留降级路径老终端、老浏览器永远比你想象的更老。这个方案后续还有很大的扩展空间。比如密钥托管可以进一步接入专业的KMS服务分片策略可以根据实时带宽做动态调节MD5也可以换成SHA-256强度更高。至少对我们团队来说病历PDF上传这个老大难终于可以不用再天天半夜爬起来处理“文件传不动”的工单了。