
1. 项目背景与方案选型这套系统是给医疗行业做的核心需求是把动辄几百MB的病历PDF文件安全传到服务器。选型的时候我直接锁定了百度WebUploader来做浏览器端分片上传再在每个分片真正发出的前一刻插入AES-256-GCM加密。做完整个项目后我最大的感受是分片和加密单独拎出来都不难难的是让它们在浏览器端、老机器、弱网环境里稳定协作。这篇就记录我在医疗场景下用WebUploader做PDF分片加密的完整方案、踩过的坑和最终落地的可参考配置。1.1 医疗病历PDF上传的痛点细节科室每天要上传的电子病历、检查报告、影像胶片PDF少则几十MB多则几百MB。早年我们用的是原生input file加表单直传结果被投诉到崩溃急诊科医生传一份200MB的CT报告PDF传了十来分钟卡在95%患者资料全部丢失还得重新排队打印、重新扫描。更麻烦的是病历PDF里面包含患者姓名、身份证号、诊断结论、主诉病史这些敏感信息明文直传等于告诉整条链路“数据裸奔”任何一个环节出问题都是重大安全事故。这个场景的核心约束是三个大、密、稳。大是文件动辄上百MB密是医疗数据受个人信息保护相关要求约束必须全链路保密稳是医院内网环境非常复杂断网、弱网、老旧浏览器并存上传必须可重试、可断点续传。这三个约束叠在一起普通的文件上传方案根本扛不住。1.2 为什么不用原生input直传原生input加FormData的优势是简单但对这个场景来说劣势几乎是致命的。首先是不知道卡在哪、断了要重来虽然有upload.onprogress可以做进度条但要自己做断点续传、并发控制、失败重试代码量一点不比业务逻辑少。其次是没有分片能力大文件一次性塞进FormData浏览器要完整读取进内存几十MB还能忍上百MB直接卡死页面老一点的科室机直接白屏。最后也是最关键的是原生方案没有分片级的完整性校验。TCP层的校验只能保证字节传输不差错但网络代理、磁盘写入异常、前端压缩处理都可能导致文件损坏而这种损坏在“文件全部传完”之前根本发现不了。医疗病历上传是低频高价值的操作一次失败浪费的是患者的等待时间这种体验不能被接受。1.3 为什么选百度WebUploader说实话百度WebUploader官方已经很多年不更新了社区活跃度也远不如当年但我在实践中还是选了它。原因有两条。第一它的分片上传能力非常成熟chunked、并发线程、文件MD5、断点续传这些核心机制开箱即用不需要我从零造轮子。第二在医疗内网这种半封闭环境里它的HTML5优先、Flash兜底兼容策略覆盖了科室里大量老旧Windows机器和低版本浏览器虽然Flash现在已经名存实亡但HTML5模式本身足够稳定。WebUploader的本质是给浏览器端文件上传做了一层“任务调度”抽象把文件切分成小块以队列方式管理每个分片都能单独发送、单独重试。这正是我想要的骨架。我只需要在它的事件钩子里插入加密逻辑改动量可控、风险最小后续就算要替换上传组件加密与调度也是两个独立模块方便剥离。从工程角度讲这是性价比最高的方案。2. 浏览器端分片加密的核心设计2.1 分片大小到底定多少合适WebUploader默认的chunkSize是512KB但实际项目中我建议把它做成可配置项不要直接沿用默认值。分片大小直接影响三个指标并发吞吐效率、失败重试粒度、MD5计算耗时。我在医院内网千兆环境下实测5MB一个分片、并发3个整文件上传速度基本能跑满带宽但到公网远程会诊场景下512KB到1MB更稳单分片失败重传的成本低网络抖动的影响范围也小。最终我给了一个前端配置项默认1MB弱网环境可以降到512KB。分片太小的问题是分片数量爆炸每个分片都要带元数据、做校验请求数量和CPU开销会拖垮整体速度分片太大则断点续传粒度变粗一个分片损坏就要重传几MB数据。1MB这个值在医疗内网和互联网场景之间取了个还算舒服的平衡点。需要注意chunkSize必须是整数Chrome 90之后对File.slice的入参要求非常严格小数会引发非常隐蔽的分片错乱问题。2.2 加密算法选型AES-GCM而不是AES-CBC浏览器端加密绕不开W3C的Web Crypto API也就是SubtleCrypto。现代浏览器都支持AES-GCM、AES-CBC、RSA-OAEP这些算法。我最终选了AES-256-GCM有四个理由。第一GCM是AEAD加密全称Authenticated Encryption with Associated Data它天然带完整性认证。分片在网络传输过程中如果被篡改或损坏解密时会直接抛错不需要额外再叠一层HMAC。而AES-CBC只保证机密性不保证完整性必须自己再算HMAC做校验复杂度直接翻倍还容易把校验逻辑写漏。第二GCM性能极好。SubtleCrypto底层是浏览器原生实现在Windows/Linux上走OpenSSLIntel处理器基本都会开启AES-NI硬件加速。我实测一个5MB分片的AES-GCM加密耗时在30毫秒左右用户完全感知不到。如果换用纯JS库比如crypto-js同样的分片要几百毫秒一旦并发分片数上去CPU直接拉满页面假死。第三GCM支持12字节随机IV。每次加密都必须生成新的随机IV每个分片独立使用新IV这样即使同一份文件重复上传密文也完全不同有效防止重放攻击。AES-CBC虽然也支持IV但在实践中很容易因为IV复用踩坑而GCM对IV的随机性要求更显式、更容易写对。第四GCM的输出结构天然适合分片场景。密文主体长度和明文等长末尾额外多出16字节认证标签。这个tag单独提取、单独传输服务端解密时用setAuthTag还原逻辑清晰不容易把密文搞乱。2.3 密钥从哪来会话密钥与双层加密浏览器端加密有个绕不开的问题密钥本质上存在于JS内存中用户能看到理论上XSS攻击也能偷走。所以我的方案是双层加密而不是把宝全押在应用层。第一层是传输层TLS保证浏览器到网关之间的链路安全第二层才是应用层的分片加密密钥由服务端在会话建立时用RSA-OAEP加密下发前端只把密钥放在内存里不落盘、不入localStorage、不写日志。具体流程是前端调用/api/upload/session接口服务端生成一个AES-256-GCM会话密钥sessionKey用服务器的RSA公钥加密后返回。前端拿到后用crypto.subtle.decrypt配合RSA私钥解密出sessionKey之后所有分片都用这把会话密钥加密。会话有效期设为2小时超时或上传完毕立即废弃服务端也不持久化。这套设计要解决的核心问题是即使某个分片密文在网络中被截获没有会话密钥也无法解密即使某个前端页面被XSS注入攻击者能拿到的也只是内存里的会话密钥无法逆向推导服务端私钥。当然真正的强安全还得配合CSP内容安全策略收紧脚本来源把XSS的注入面尽量缩小这个我在后面的实操章节会再提。2.4 加密与断点续传如何共存这是整个设计里最容易踩坑的地方。WebUploader的断点续传逻辑是上传前先计算整个文件的MD5上传过程中把已完成的MD5记录在localStorage里下次选择同一文件时直接查询服务端已有分片只上传缺失的部分。但这里有个致命细节文件MD5是基于明文内容算的还是基于密文分片算的如果每个分片都带随机IV那么同一份文件每次加密后的密文分片完全不同MD5对不上断点续传直接失效。我的方案是文件级MD5用原始明文计算分片级校验用GCM的认证标签。具体操作是before-send-file事件里计算整个明文件的MD5用于去重和断点续传判断每个分片加密后生成16字节GCM tag随分片元数据一起提交服务端服务端解密后用tag验证分片完整性。这样既保留了断点续传的能力又不影响加密强度两边的好处都拿住了。有人会担忧明文文件MD5是否会泄露信息。对医疗系统来说MD5本身不可逆而且这个MD5只作为会话内的去重标识不落数据库、不参与全局检索会话结束就丢弃实际泄露面非常小。如果合规要求更严可以用加盐MD5但我觉得没必要为这个过度设计。3. 实操落地WebUploader配置与加密实现3.1 初始化WebUploader的核心参数直接上代码这是我在实际项目里基于WebUploader 0.1.5的初始化配置依赖环境是jQuery 3.x加WebUploader的UMD包var uploader WebUploader.create({ swf: /static/js/Uploader.swf, server: /api/upload/chunk, pick: #picker, accept: { title: PDF, extensions: pdf, mimeTypes: application/pdf }, chunked: true, chunkSize: 1024 * 1024, threads: 3, fileVal: file, formData: { sessionToken: getSessionToken() }, duplicate: true, auto: false });几个参数值得展开说明。chunked: true是分片总开关必须配合chunkSize使用threads: 3控制并发分片数实测在医疗内网环境中并发3到5个分片是吞吐量拐点超过5个反而因为TCP窗口竞争导致总体速度下降弱网环境我改成2。formData.sessionToken是会话凭证WebUploader会自动把formData里的字段附加到每个分片请求上服务端凭token从会话存储中拉出对应的sessionKey进行解密这个设计比每次请求都带密钥安全得多。duplicate: true是允许重复文件进入队列。这个必须开因为不同患者可能上传同名PDF而且医疗上传场景需要做完整的审计记录不能简单按文件名去重。accept里的extensions只能做前端约束真正的格式校验一定要在服务端再做一道前端校验只是用户体验层的过滤。auto: false是关闭选择文件后自动上传等用户点了“确认上传”再开始这给加密会话建立留出了时间窗口。3.2 在分片发送前注入AES-GCM加密WebUploader提供了一个关键钩子before-send-chunk它在每个分片真正发起请求之前触发。我们在这里拿到分片的原始Blob加密后替换成密文Blob同时把IV和GCM tag塞进分片请求的formData里uploader.on(before-send-chunk, function (block) { var d $.Deferred(); encryptChunk(block.blob, sessionKey).then(function (encrypted) { block.blob encrypted.blob; block.formData { iv: arrayToHex(encrypted.iv), tag: arrayToHex(encrypted.tag), chunkIndex: block.chunk, totalChunks: block.chunks }; d.resolve(); }, function (err) { block.owner.trigger(uploadError, err); d.reject(); }); return d.promise(); });这里必须强调一个顺序问题文件级MD5的计算必须发生在before-send-file阶段也就是加密之前。因为WebUploader内部在断点续传时会读取文件原始Blob计算MD5如果等分片加密完再算文件级指纹就废了。我的做法是在fileQueued事件里先异步计算整个明文件的MD5并缓存before-send-file时只做查询与上报避免重复计算。encryptChunk的核心实现依赖SubtleCrypto代码不复杂但有两个细节非常关键。一是每次都生成全新的12字节随机IV绝不能复用二是AES-GCM加密结果的最后16字节是认证标签要从中分离出来单独提交否则服务端会把tag当密文解密结果必然乱码async function encryptChunk(blob, key) { const iv crypto.getRandomValues(new Uint8Array(12)); const plainText await blob.arrayBuffer(); const cipherBuffer await crypto.subtle.encrypt( { name: AES-GCM, iv: iv, tagLength: 128 }, key, plainText ); const tag cipherBuffer.slice(cipherBuffer.byteLength - 16); const data cipherBuffer.slice(0, cipherBuffer.byteLength - 16); return { blob: new Blob([data], { type: application/octet-stream }), iv: iv, tag: new Uint8Array(tag) }; }crypto.subtle只能在安全上下文中使用也就是HTTPS或localhost。医院内网很多服务器是HTTP裸奔这个坑我踩得很深报错信息永远是模糊的“API only in secure context”。最终的解法是在网关前面挂自签HTTPS证书并把医院根证书导入电脑否则Web Crypto API直接罢工。这条务必提前规划千万别等上线前再补救。3.3 服务端解密与完整性校验服务端我用Node.js做参考实现医疗后端用Java Spring Boot的更常见但解密逻辑完全一致只是语言API不同。核心校验流程必须按这个顺序先用GCM tag验证分片完整性再按索引落盘最后合并校验文件MD5缺一步都不行const crypto require(crypto); const express require(express); const app express(); app.post(/api/upload/chunk, (req, res) { const { sessionToken, iv, tag, chunkIndex } req.body; const sessionKey sessionStore.get(sessionToken); const ivBuf Buffer.from(iv, hex); const tagBuf Buffer.from(tag, hex); const cipherChunk fs.readFileSync(req.file.path); const decipher crypto.createDecipheriv(aes-256-gcm, sessionKey, ivBuf); decipher.setAuthTag(tagBuf); const plainChunk Buffer.concat([ decipher.update(cipherChunk), decipher.final() ]); // 如果tag校验失败decipher.final()会直接抛错 // 此时应返回403前端捕获后单独重传该分片。 fs.writeFileSync(chunkPath(req.sessionId, chunkIndex), plainChunk); res.json({ ok: true, chunkIndex }); });分片因为并发上传会乱序到达后端不能边收边拼正确做法是先按chunkIndex写临时文件等所有分片到齐后再按序拼接成完整PDF。拼接完成后计算整文件MD5与前端上报的明文MD5比对一致才允许写入正式存储区。这一步能拦截绝大部分的传输损坏问题。还有一个看似多余但非常实用的步骤拼接完成的PDF用解析库做一次可读性探针尝试读取页数。如果页数为0或者抛异常说明文件在加密前的源头上就有问题直接判定上传失败返回提示让用户重新上传。我靠这个小探针挡掉了很多“加密后PDF打不开”的误会因为这根本不是加密的锅而是源文件本身损坏。3.4 前端进度、并发与内存控制WebUploader自带的进度回调展示的是“分片发送完成”但在加密场景下before-send-chunk里的加密耗时并不会实时反映到进度条上导致用户看到进度条卡顿或回跳。我的做法是在进入加密逻辑时手动记录当前累计进度加密完成后、真正发送前再把进度设置为记录的累计值避免回跳。这个体验细节很多文档都没写但实测非常影响医生群体对系统稳定性的判断。内存方面有个大坑Blob转ArrayBuffer会把整个分片加载进内存。1MB分片还好但如果有人把chunkSize调到10MB、并发开到5峰值内存就是50MB起步再加页面上其他业务组件低配客户机直接崩溃。我加了一个全局信号量限制正在加密的分片总数不超过6个let encrypting 0; const MAX_ENCRYPTING 6; uploader.on(before-send-chunk, function (block) { if (encrypting MAX_ENCRYPTING) { uploader.stop(); setTimeout(() uploader.upload(), 100); return false; } encrypting; // 加密逻辑finally 里 encrypting-- });这个粗糙但有效的限流救了我好几台低配机器。如果追求优雅可以用Promise队列库改写但医疗场景里简单可靠远比花哨重要。同样的思路也适用于文件队列fileNumLimit建议设成10fileSizeLimit设成2GB避免用户一次拖入几十个文件把队列直接打爆。4. 常见问题与排查技巧实录4.1 Chrome 90以上版本的兼容性坑WebUploader早期大量依赖Flash做HTML5不支持时的降级因为FEX团队最初的设计是“HTML5加Flash双模式”。但从Chrome 88开始Flash默认禁用Chrome 90之后彻底移除Flash这条路已经死了。很多老项目还试图维持Flash兜底结果是上传按钮点了没反应控制台一片空白查半天才发现是Flash插件没了。现代Chrome 90以上环境里WebUploader会自动切到HTML5模式Flash那只“僵尸代码”根本不会执行本身没问题。真正出问题的是那些还在用IE内核、360兼容模式、低版本Chrome的医院行政机。我的建议是放弃一切Flash幻想前端检测不到window.FileReader或Blob.prototype.slice就直接给出明确提示声明“仅支持Chrome 90、Edge、Firefox现代浏览器”。这套系统我在兼容模式的老机器上被折磨了两个月最后用一句话锁死范围反而安静了。还有一个Chrome专有的隐蔽坑Chrome 90之后对File.slice的调用强制要求参数是整数如果你在before-send-file里手动调file.slice(0, 1024)但chunkSize配的是浮点数分片会错乱而且这种错乱不影响报错只会让最终文件损坏。排查这种“幽灵问题”极其崩溃建议在所有涉及slice的地方强制Math.floor一劳永逸。4.2 加密后PDF打不开的问题这是整个项目被投诉最多的点用户上传后下载解密文件打开提示“文件已损坏”。逐层排查后我发现根因几乎都是同一个GCM的16字节认证标签没有被正确分离。AES-GCM算法的密文主体和明文等长但末尾多出的16字节tag如果前端在加密时没有把tag从密文中分出来而是整段丢给后端服务端就会把tag也当成密文去解密结果自然乱码。解决方案就是3.2节代码里写的加密后把cipherBuffer切成data tagtag单独走formData提交服务端用setAuthTag(tagBuf)还原。如果你不想在传输层做分离也可以把data和tag拼在一起作为整体Blob上传服务端从密文末尾截取16字节作为tag。两种方案都行但全链路必须统一最怕前端用分离方案、后端用拼接方案这种“一半一半”的对接方式排查起来最浪费时间。还有一类“PDF打不开”是预览场景。医疗系统常有在线预览需求加密后的PDF密文不能直接交给pdf.js或浏览器内置阅读器必须先解密成明文再交给预览组件。如果解密逻辑处理不当页面就白屏或报“Couldnt parse PDF”。我建议在线预览走独立的/api/preview/:fileId接口服务端解密后以流式返回明文PDF设置Content-Disposition: inline同时加一次性预览token限制访问时长。这样浏览器原生预览就能正常工作前端完全不用碰解密逻辑安全边界更清晰。4.3 上传加密导致CPU占用和浏览器卡顿医疗影像PDF动辄两三百MB分片数量轻松上千。如果加密用的是纯JS库比如crypto-jsCPU占用能冲到100%以上页面直接假死尤其在老旧的科室机上表现更明显。我在Intel i3-4130这种老U上实测过纯JS AES加密5MB分片耗时约1.2秒换成原生SubtleCrypto只要30毫秒差距40倍。这个差距在生产环境就是“能用”和“不能用”的区别。排查这个问题我通常会先打开Performance面板确认耗时集中在脚本执行再检查加密路径用的是不是window.crypto.subtle。如果项目因为兼容性原因被迫用纯JS库那就把并发线程降到1或2同时把分片调到512KB牺牲一点吞吐换稳定性。如果所在环境是院内可控的千兆内网更彻底的做法是把加密挪到服务端做前端只负责上传给前端留一个“加密上传关闭”的开关这个我在第5章会详细说。还有一个容易被忽视的GCM坑IV重复。GCM要求同一密钥下绝不能复用IV否则两段相同IV的密文会泄露明文的异或关系这在医疗场景是不可接受的。我写过一个bug把随机IV生成放在循环外面导致所有分片共用同一个IV加密功能形同虚设。后来给前端加了一行IV唯一性自检发现重复直接报警才彻底杜绝这类问题。4.4 分片乱序、重试与断点续传的经典坑WebUploader本身不保证分片到达顺序并发3个线程时服务端收到的分片顺序是乱的。我踩过的一个坑是直接把chunkIndex写进分片文件名服务端多线程同时写磁盘时出现覆盖最终合并出来的PDF缺页而且缺的是哪几页都不确定。后来改成“分片临时文件加内存索引”的方案合并前先校验每个分片是否存在、尺寸是否一致、GCM tag是否通过任何一个分片异常就要求前端重传该分片。断点续传还有一个隐蔽坑如果前端在计算文件MD5时读取的是原始明文而服务端已经把之前加密分片解密成明文存了临时文件续传复用是可以正常工作的。但如果把明文MD5也存入数据库做全局去重就会导致同一个文件被不同患者上传时第二次直接秒传错误——因为第一次的密文密钥已经过期无法解密。所以医疗场景下文件级去重必须绑定“患者加就诊记录”维度不能只看文件指纹。最后给一个关于PDF文件本身的小提醒很多影像系统的PDF是灰度大图扫描件metadata里带了完整的患者姓名、查体信息。即使做了AES传输加密这些metadata在解密后的明文PDF里依然可搜索。上传前务必用脚本把/Title、/Author、/Producer这些字段里的敏感信息清理掉这属于内容级脱敏和传输加密是两码事。我这个教训是数据传完后才发现的结果所有历史病历PDF里残留了患者姓名返工成本极高提出来希望你能绕开。4.5 PDF客户端查看器闪退问题的避坑提示科室反馈过“PDF打不开闪退”有人甚至按网上的偏方去删Adobe Reader的配置文件、缓存目录结果越删越糟。我排查过几次结论是这些闪退绝大多数和上传链路无关而是客户端机器上的PDF阅读器版本过旧无法打开较高PDF标准版本的文件比如PDF 1.7以上的部分特性新老版本解析器支持不完整。医疗系统不建议全院强制装Adobe全家桶更稳妥的做法是在系统内嵌pdf.js做在线预览或者统一推一个轻量阅读器并规定PDF生成端统一使用PDF/A-1b存档标准。这样既避免客户端版本碎片化也符合病历长期归档的要求。Adobe自带的“首选项重置”偶尔有用但让非IT的医生去操作门槛太高不如从系统层面根治。顺带一提如果有人让你去删除Adobe安装目录下某个配置文件来“修复闪退”千万别在正式环境照做。这类操作经常引发权限问题导致PDF阅读器彻底无法启动届时就真得上重装系统的流程了。排查客户端闪退做对一次就够先升级阅读器、再清理过度占用的字体缓存、最后才是重装按这个顺序走能覆盖九成的情况。5. 分级加密策略与后续扩展5.1 分级加密不要一刀切写到这里我想补一个容易被忽略的问题浏览器端分片加密是不是过度设计对纯内网、可信环境、有TLS保护的医疗系统来说应用层加密确实是锦上添花核心价值在于防脱库——也就是数据库或存储被拖走后攻击者拿到的是密文无法直接还原患者数据。但它也有代价前端复杂度显著上升、问题排查难度加大、浏览器兼容要求更高。我的实践经验是分级处理。对外共享的远程会诊平台必须开启分片加密数据会经过公网、第三方机构、云服务商暴露面大院内高速内网可以默认关闭应用层加密只开TLS加文件MD5校验把分片加密作为一个可配置开关留着以应对合规审计。这个开关我不能给你一个固定答案因为不同地区、不同等级医院的合规要求不一样方案设计上留好弹性比强行规定更重要。5.2 后续还能往哪个方向扩展这套架构跑稳之后有两个很自然的扩展方向。一是接入审计日志把每一次上传的患者信息、文件名、MD5、加密算法版本、耗时、失败原因全部落库形成完整的上传轨迹这既是合规需要也是日后排查问题的底账。二是把密钥轮换自动化当前2小时会话密钥过期后下一会话重新生成但可以再加一层主密钥对会话密钥做包装主密钥定期手动轮换形成“主密钥加会话密钥”的两级密钥体系。PDF本身的处理也可以再做深一层比如对高敏字段做区域级脱敏或者对扫描件做OCR后再加密归档。这些功能单项都不难难的是和现有的分片加密链路优雅共存。我的建议是先把基础传输链路打磨到“稳定得像没有加密一样”再去叠加业务功能顺序错了后面每加一个功能都会翻一次车。这套维护了两年的系统帮我攒下的教训差不多都在上面了希望你能比我省去一半的弯路。