这事要从一个安全整改需求说起。公司内部运维管理系统一直跑在 Vue2 + Element UI 这套老架构上,文件上传模块用的是百度 WebUploader,功能没毛病,支持大文件分片、并发、断点续传,甚至连 MD5 秒传都是现成的。但安全部门审计时提出一条硬性要求:所有存储的文件不能以明文落盘。这就意味着我得在不动现有业务逻辑的前提下,把上传链路上的文件在服务端变成密文,而且还得保证后端能正常解密使用。折腾了一周多,把 WebUploader、Vue2、CryptoJS、RSA 密钥封装这一整套串了起来,中间踩了不少坑,这里完整记录下来。
1. 需求拆解与加密方案选型
1.1 WebUploader 凭什么还留在 Vue2 项目里
先说选型。很多新项目直接用 Element UI 的 Upload 组件,或者用 vue-simple-uploader,但我们的情况不一样,系统里上传模块涉及大量历史逻辑和业务绑定:上传后回调、队列管理、失败重试、MD5 秒传、并发控制,这些都是 WebUploader 早就固化的能力。Element UI 的 Upload 组件在分片上传这块几乎没有原生支持,vue-simple-uploader 虽然功能接近,但要替换意味着上传接口、回调接口、前端队列状态全部重写,风险太大。
WebUploader 虽然官方停止维护很久了,但还是有它的价值:分片上传时自动携带chunk和chunks参数,后端拼接分片不需要自己设计协议;threads可以控制并发数,避免大文件上传把服务器带宽打满;内置的md5File方法可以计算文件 MD5 实现秒传。这些能力到今天依然能打,只是它比较老,和 Vue2 的组件化生命周期需要一点磨合,这个后面详细说。
1.2 加密存储为什么选“数字信封”方案
加密存储的第一个问题:用什么算法加密文件内容。直接上 RSA 吗?不行,RSA 加密大文件性能太差,而且有明文长度限制,最多加密几百字节。直接用固定的 AES 密钥?等于用一把钥匙开所有的锁,一旦密钥被逆向出来,历史所有文件全部泄露,密钥轮换也很麻烦。
最后选的是业界常用的“数字信封”结构:每次上传,前端随机生成一个 32 字节的 AES 密钥,用这个密钥加密文件内容;再用后端的 RSA 公钥去加密这个随机 AES 密钥,把加密后的密钥随文件一起传到服务端。服务端拿到密文后,先用 RSA 私钥解密出 AES 密钥,再用 AES 密钥解密文件内容做存储或处理。
这个结构有个很实际的好处:RSA 只加密几十字节的密钥,性能开销完全可以忽略;AES 负责加密大文件,速度快、安全强度够;每次上传的 AES 密钥都是随机生成的,单个文件密钥泄露不影响其他文件。打个比方,AES 密钥就像快递柜的取件码,RSA 公钥就像柜子的总钥匙,每次取的码都不一样,但只有管理员的总钥匙才能解开。后来如果要轮换 RSA 密钥对,也只需要重新封装一次 AES 密钥,不需要重加密历史文件。
1.3 前端加密的安全边界:必须想清楚
这里必须泼一盆冷水:前端加密拦不住“恶意用户”,也防不了“代码逆向”。因为加密用的 AES 密钥在前端生成、RSA 公钥在浏览器代码里可见,只要愿意,任何人可以调试前端拿到密钥流转逻辑。那做这件事还有什么意义?它的核心价值在存储层:文件上传后到服务端就以密文形式落盘,即使数据库泄露、存储磁盘被拖走、或者日志里不小心打出了文件内容,对方拿到的也是无法直接读取的密文。同时它也能增强传输层的安全性,即使 HTTPS 在某些内网代理场景下被降级或绕过,文件内容本身仍然处于加密状态。
所以正确姿势是:前端加密用于“存储安全”,后端还必须保留独立的鉴权体系,比如上传接口的 token 校验、文件类型白名单、文件大小限制、操作审计日志。这些和前端加密是互补关系,缺一不可。在实际推行这个方案时,我也和领导明确过边界:前端加密解决的核心痛点是“存储介质泄露不吃惊”,而不承诺“端到端不可破解”。
2. Vue2 项目里让 WebUploader 正常跑起来
2.1 依赖安装与 jQuery 全局注入
WebUploader 的老版本是强依赖 jQuery 的,这一点在 Vue 工程里特别容易卡住。直接npm install jquery webuploader装好后,组件里import WebUploader from 'webuploader'会报错说找不到$,这是因为 WebUploader 源码在初始化时会直接用全局的jQuery。解决办法是在构建工具里做一个 ProvidePlugin 全局注入。
我项目里用的是 vue.config.js,配置如下:
// vue.config.js const webpack = require('webpack'); module.exports = { configureWebpack: { plugins: [ new webpack.ProvidePlugin({ $: 'jquery', jQuery: 'jquery', 'window.jQuery': 'jquery' }) ] } };这样 WebUploader 在执行时就能自动拿到 jQuery 实例。样式文件也别漏了,在 main.js 里直接引入:
import 'webuploader/dist/webuploader.css';这里有个经验:WebUploader 的 npm 包版本停留在 0.1.8,代码风格比较老,ESLint 经常会告警,建议在构建配置里把这个文件排除掉,避免每次构建刷一堆警告,也防止老代码被 babel 转换时出幺蛾子。
2.2 组件的生命周期管理
WebUploader 是一个典型的 DOM 操作型组件,和 Vue2 的“数据驱动视图”理念天然冲突。它需要一个真实的 DOM 元素作为 pick 按钮的容器,不能用 Vue 的 render 函数动态生成。所以我的做法是:在模板里先放一个固定的容器 div,在 mounted 钩子里初始化 Uploader 实例,在 beforeDestroy 钩子里销毁。
<template> <div> <div id="uploader-picker">选择文件</div> <div id="uploader-list"></div> </div> </template> <script> export default { data() { return { uploader: null }; }, mounted() { this.initUploader(); }, beforeDestroy() { if (this.uploader) { this.uploader.destroy(); this.uploader = null; } }, methods: { initUploader() { this.uploader = WebUploader.create({ swf: '/static/Uploader.swf', server: '/api/file/upload', pick: '#uploader-picker', auto: false, chunked: true, chunkSize: 2 * 1024 * 1024, threads: 3, fileVal: 'file', accept: { title: 'Files', extensions: 'pdf,doc,docx,xls,xlsx,zip,rar,7z', mimeTypes: '*' } }); // 各种事件绑定... } } };特别提醒一个和keep-alive相关的问题。如果上传组件被包在<keep-alive>里,组件切走时不会走beforeDestroy,而是走deactivated。如果只在beforeDestroy里销毁 Uploader,切回页面时会发现 Uploader 实例还活着,但 DOM 可能已经被 Vue 缓存处理过,会出现按钮无响应、上传事件叠加触发、内存持续增长的情况。正确的做法是同时监听 activated 和 deactivated:
activated() { if (!this.uploader) this.initUploader(); }, deactivated() { if (this.uploader) { this.uploader.destroy(); this.uploader = null; } }这一点很关键,我们线上就出过问题:用户在 A 页面上传一半切到 B 页面再切回来,原来的文件还在队列里,但又重新初始化了一个 Uploader,两个实例同时绑定同一批 DOM,点击一次选择按钮弹两次文件框。
2.3 核心参数与事件梳理
这里整理一个我常用的核心参数速查表,后面排查问题会经常用到。
| 参数 | 取值示例 | 作用 |
|---|---|---|
| chunked | true | 是否开启分片上传 |
| chunkSize | 2 * 1024 * 1024 | 分片大小,单位字节,推荐 2MB~5MB |
| threads | 3 | 并发上传数,内网可以到 5,外网建议 1~3 |
| fileVal | 'file' | 后端接收文件的字段名 |
| duplicate | true | 是否允许重复文件再次入队 |
| auto | false | 默认不自动上传,等加密处理后再触发 |
| server | '/api/file/upload' | 上传接口地址 |
事件方面,核心的是这几个:
fileQueued:文件加入上传队列,加密流程从这里开始md5File:WebUploader 附带的方法,用于计算文件 MD5,实现秒传判断uploadBeforeSend:每个分片发送前触发,在这里塞加密参数和鉴权 tokenuploadProgress:上传进度,用于更新进度条uploadSuccess/uploadError:单文件上传成功或失败的最终回调
uploadBeforeSend这个事件的签名是(file, data, header),data对象就是最终发给后端的表单数据,WebUploader 会把分片序号chunk、总分片数chunks自动带进去,我们只需要往这个对象里追加业务字段即可,后端拼接分片时主要依赖这两个字段。
3. 加密存储核心实现解析
3.1 数据协议设计:一次上传请求带哪些字段
加密存储不是简单地对文件做一个 AES 加密就完事了,前后端必须约定一套数据格式。我的上传请求最终携带以下字段:
| 字段 | 说明 | 是否敏感 |
|---|---|---|
| file | 加密后的文件二进制,WebUploader 分片后自动传输 | 是,但已是密文 |
| encryptKey | RSA 公钥加密后的 AES 密钥,Base64 编码 | 是,需 HTTPS 传输 |
| iv | AES 加密时使用的初始向量,Base64 编码 | 否,iv 可以公开 |
| fileName | 原始文件名,URL 编码 | 部分敏感,建议截断或脱敏 |
| originalMd5 | 原始文件的 MD5,用于秒传和校验 | 否 |
| token | 登录态/接口鉴权 token | 是 |
关于 iv 能不能明文传,这里解释一下:AES 的 iv(初始向量)作用是让同一个明文在不同加密中产生不同的密文,它本身不包含密钥信息,即使攻击者拿到 iv,没有 AES 密钥也无法解密。所以 iv 随请求体一起传到后端是完全安全的。
服务端处理流程大致是:先校验 token,再根据originalMd5判断文件是否已存在,如果存在直接返回秒传成功;如果不存在,则接收分片并按chunk序号暂存,等所有分片到齐后按序号拼接成完整密文,再用 RSA 私钥解密encryptKey得到 AES 密钥,最后用该密钥和 iv 解密完整密文,写入存储/对象存储。
3.2 前端加密:中小文件最稳妥的直接方案
先讲最简单也最不容易出错的方案,适合几十 MB 之内的文件。核心思路是:先把原始文件完整读入内存,做一次 AES-256-CBC 加密,得到加密后的二进制 Blob,再把这个 Blob 交给 WebUploader。WebUploader 在传输层自己分片上传,后端收到的所有分片拼接起来就是完整的密文,最后做一次解密即可。加密边界和上传分片边界完全解耦,后端不需要理解 WebUploader 分片和加密分片的对应关系。
关键在于 CryptoJS 处理 key 的方式。CryptoJS 的AES.encrypt第二个参数如果传的是字符串,会被当成口令走 OpenSSL 的 KDF 派生,而不是直接作为密钥。这样后端没法还原同一个 key。所以必须把随机字节转换成 WordArray 再传:
import CryptoJS from 'crypto-js'; import JSEncrypt from 'jsencrypt'; // 生成随机 AES 密钥:32 字节即 AES-256 const aesKey = CryptoJS.lib.WordArray.random(32); // 生成随机 IV:16 字节 const iv = CryptoJS.lib.WordArray.random(16); function encryptFileToBlob(file, aesKey, iv) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = function (e) { try { const buffer = e.target.result; const wordArray = CryptoJS.enc.Uint8Array.parse(new Uint8Array(buffer)); const encrypted = CryptoJS.AES.encrypt(wordArray, aesKey, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); // ciphertext 是 WordArray,转回 Uint8Array 生成二进制 Blob const bytes = CryptoJS.enc.Uint8Array.stringify(encrypted.ciphertext); resolve(new Blob([bytes], { type: 'application/octet-stream' })); } catch (err) { reject(err); } }; reader.onerror = reject; reader.readAsArrayBuffer(file); }); }加密完成后,替换掉 WebUploader 待上传队列中的文件源,再触发上传。这一步要在fileQueued事件里做:
this.uploader.on('fileQueued', (file) => { // 先暂停自动上传 this.uploader.upload(); // 不要在这里调用,下面说明 });实际流程要处理一下异步顺序,不能急着调 upload,要等加密完成后再上传:
this.uploader.on('fileQueued', async (file) => { // 先把原始文件 MD5 算出来,再做秒传判断 const originalMd5 = await this.uploader.md5File(file); file.originalMd5 = originalMd5; const res = await this.checkFileExist(originalMd5); if (res.exist) { // 秒传命中,跳过上传 this.uploader.skipFile(file); return; } // 加密文件,生成新的 Blob const encryptedBlob = await encryptFileToBlob(file.source, aesKey, iv); // 替换文件源,让 WebUploader 上传加密后的内容 file.source = encryptedBlob; // 记录加密参数,供 uploadBeforeSend 使用 this.currentEncryptKey = aesKey.toString(CryptoJS.enc.Base64); this.currentIv = iv.toString(CryptoJS.enc.Base64); this.uploader.upload(file); });这里要特别强调一个我在代码里踩过的坑:file.source是 WebUploader 内部保存的源文件对象,把它替换成 Blob 是可行的,但必须在调用upload(file)之前完成,并且在替换前不要让它开始上传。所以我初始化 Uploader 时把auto设置为false,完全由加密完成后手动触发上传,保证不会出现“还没加密完就先上传明文”的情况。
3.3 大文件场景:增量流式加密
如果文件到了几百 MB 甚至 GB 级别,上面直接把整个文件读进内存的方案就行不通了,浏览器内存受不了,FileReader 一次读大文件容易白屏甚至崩溃。这时需要换一种思路:流式加密。
CryptoJS 其实不只有AES.encrypt这种一次性接口,它底层的 cipher 对象支持增量处理。用CryptoJS.algo.AES.createEncryptor(key, cfg)创建一个加密器,然后分块读取原始文件,每次调用process(wordArray)处理一小块,所有块处理完后调用finalize()收尾。模式这里我推荐 CTR(计数器模式),因为 CTR 是流密码,不需要 padding,分块加密后的片段按顺序拼接起来就是完整的连续密文,后端可以一次性整体解密。
这里给出一个示例的流式加密函数:
async function encryptFileStream(file, aesKey, iv, chunkSize = 512 * 1024) { const encryptor = CryptoJS.algo.AES.createEncryptor(aesKey, { iv: iv, mode: CryptoJS.mode.CTR, padding: CryptoJS.pad.NoPadding }); const encParts = []; let offset = 0; while (offset < file.size) { const slice = file.slice(offset, offset + chunkSize); const buffer = await readAsArrayBuffer(slice); const part = encryptor.process(CryptoJS.enc.Uint8Array.parse(new Uint8Array(buffer))); // 收集的是 WordArray,先转成 Uint8Array 存入数组 encParts.push(CryptoJS.enc.Uint8Array.stringify(part)); offset += chunkSize; } const finalPart = encryptor.finalize(); if (finalPart.sigBytes > 0) { encParts.push(CryptoJS.enc.Uint8Array.stringify(finalPart)); } return new Blob(encParts, { type: 'application/octet-stream' }); } function readAsArrayBuffer(blob) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsArrayBuffer(blob); }); }注意一个细节:CTR 模式下,所有分块必须使用同一个 iv 和同一个递增计数器序列,才能保证拼接后是连续密文。createEncryptor会维护内部的计数器状态,分块调用process正好满足这一点。千万不要对每个分块重新创建一个 encryptor 再从同一个 iv 开始,那样每个分块会复用相同的密钥流,等于把整个加密体系打穿,这是个非常隐蔽的安全漏洞。
CTR 模式还有一个兼容性问题要注意:不同语言实现的 CTR 计数器字节序、溢出规则可能不完全一致。如果你后端用 Node.js 原生 crypto 的aes-256-ctr解密,而前端用 CryptoJS 的 CTR,两者大部分情况下是一致的,但为了稳妥,联调时一定先用 1MB 以内的小文件跑通,确认解密结果完全一致后再上生产。如果发现不一致,要么后端也改用它自己的 CTR 实现,要么干脆回到 CBC 模式方案(整体加密后交给 WebUploader 分片上传)。我在实践中的建议是:能上 CBC 就优先 CBC,通用性最好;文件太大必须做流式时,才考虑 CTR,并做好前后端联调验证。
3.4 uploadBeforeSend 里塞参数,后端如何对应解密
加密完成后,真正上传时还要把加密参数带给后端。这一步在uploadBeforeSend里处理:
this.uploader.on('uploadBeforeSend', (file, data, header) => { // WebUploader 自动会带上 chunk、chunks 两个分片参数 data.fileName = encodeURIComponent(file.name); data.originalMd5 = file.originalMd5 || ''; data.encryptKey = this.currentEncryptKey; data.iv = this.currentIv; // 自定义 header,放鉴权 token header['X-Upload-Token'] = this.getToken(); });后端我用 Node.js 的 multer 接收分片,逻辑是:每个分片保存为一个临时文件,文件名按hash_chunk命名,所有分片到齐后按 chunk 序号拼接,再统一解密。
const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const NodeRSA = require('node-rsa'); const multer = require('multer'); const tmpDir = '/tmp/upload_chunks'; const privateKey = new NodeRSA(PRIVATE_KEY_STRING); async function handleUpload(req, res) { const { chunk, chunks, encryptKey, iv, fileName, originalMd5 } = req.body; const file = req.file; // multer 接收到的分片 const hash = originalMd5 || `${Date.now()}_${Math.random()}`; // 保存分片 const chunkPath = path.join(tmpDir, `${hash}_${chunk}`); fs.renameSync(file.path, chunkPath); // 如果还没到最后一个分片,直接返回 if (Number(chunk) < Number(chunks) - 1) { return res.json({ ok: true, message: 'chunk received' }); } // 所有分片到齐,按 chunk 序号拼接 const chunksPaths = []; for (let i = 0; i < Number(chunks); i++) { chunksPaths.push(path.join(tmpDir, `${hash}_${i}`)); } const mergedBuffer = Buffer.concat( chunksPaths.map((p) => fs.readFileSync(p)) ); // 解密 AES 密钥 const aesKeyBase64 = privateKey.decrypt(encryptKey, 'utf8'); const aesKey = Buffer.from(aesKeyBase64, 'base64'); const ivBuffer = Buffer.from(iv, 'base64'); // 使用 AES-256-CTR 解密文件内容 const decipher = crypto.createDecipheriv('aes-256-ctr', aesKey, ivBuffer); const decrypted = Buffer.concat([decipher.update(mergedBuffer), decipher.final()]); // 写入最终存储路径(这里按业务自行调整,可接 OSS / 本地磁盘) const finalPath = path.join('/data/file_store', `${hash}_${fileName}`); fs.writeFileSync(finalPath, decrypted); // 清理临时分片 chunksPaths.forEach((p) => fs.existsSync(p) && fs.unlinkSync(p)); res.json({ ok: true, url: `/file/${hash}_${fileName}` }); }这里后端要注意:分片拼接一定要按chunk序号排好序,不能依赖到达顺序。WebUploader 开了多个线程并发上传时,分片到达顺序是乱的,后端如果边收边加班,最后拼出来的密文一定是坏的,解密出来全是乱码。必须全部收齐后一次性按序号拼接。
3.5 秒传、MD5 校验和加密的先后关系
WebUploader 自带的md5File方法可以直接算原始文件的 MD5,这个能力要充分利用。完整的顺序是:文件入队后先算原始 MD5,再到后端查重;如果服务端存在相同 MD5 的文件,直接跳过上传,这就是秒传。如果不存在,再对文件做加密,然后上传密文。
密文上传成功后,建议让后端再计算一遍密文文件的 MD5,连同原始文件 MD5 一起存进元数据库。这样以后做完整性校验、文件去重、非法篡改排查都有依据。前端上传成功后,可以把这两个 MD5 一并返回给业务方,方便业务侧做对账。
这里容易踩的一个坑是:MD5 的计算对象和加密对象必须分开理解。秒传判断用原始 MD5,存储校验用密文 MD5,两者不能互相替代。如果你在加密后才算 MD5,那秒传功能就废了,因为同一个源文件每次加密用的随机 AES 密钥不同,密文 MD5 永远不同,永远无法命中秒传;反过来,如果只用原始 MD5 做存储校验,无法发现密文在传输过程中被篡改的问题。所以二者都要保留。
4. 常见问题与排查技巧实录
4.1 WebUploader 与 Vue2 组合的经典坑
这个模块弄完,我总结了一个高频问题清单,都是真实遇到过并排查过的。
picker 点击没反应或弹出两次文件框
大概率是 Uploader 实例没有正确销毁,或者组件被 keep-alive 缓存后重复初始化。排查方法:在控制台打印this.uploader,看看是否只有一个实例;再检查deactivated里有没有调用destroy()。还有一种情况是模板里的 pick 容器 id 被复用,比如多个组件共用了同一个 dom id,导致 WebUploader 绑定到了错误节点。
上传事件叠加,一个文件触发多次回调
这是典型的 Uploader 实例泄漏。每次进页面都新建了一个实例,但没有销毁旧的,新旧实例同时监听同一个队列,导致一次上传触发多个uploadSuccess。解决方法是严格遵循 activated/deactivated 或 mounted/beforeDestroy 的成对生命周期。
构建后控制台报 jQuery is not defined
WebUploader 源码在运行时会访问全局 jQuery。如果 ProvidePlugin 配置写错,或者构建缓存没刷新,就容易出这个问题。可以尝试在入口文件 main.js 里显式挂载一次:
import $ from 'jquery'; window.jQuery = $; window.$ = $;上传的文件在服务端是 0 字节
多半是fileVal和后端解析字段不匹配。比如前端默认字段名是file,但后端 multer 接收的是uploadFile,就会拿到空。检查fileVal参数和后端接口的解析字段是否一致。
老组件被安全扫描报告漏洞
WebUploader 毕竟停止维护了,安全扫描工具经常把它标记为“脆弱的 JavaScript 库”。我的建议是:不要把它暴露在公网,通过内网访问或者网关层做白名单;前端渲染文件名时不要用 innerHTML 拼接,避免 XSS;对外部上传做大小和类型限制。如果是面向互联网的公开产品,还是优先换更现代化的上传组件。
4.2 加密链路里的隐藏坑
加密本身也有一堆不容易察觉的坑,这里挑几个我最想提醒的。
CryptoJS 把字符串当 passphrase 而不是 key
这是最容易犯的错误。CryptoJS.AES.encrypt(data, 'myPassword')这种写法,CryptoJS 会用myPassword作为口令走 OpenSSL 的 EVP_BytesToKey 派生规则,生成密钥和盐。问题是同一个口令每次派生出来的密钥都是随机的(因为盐是随机生成的),后端根本没法用同样的字符串还原出同一个密钥。所以密钥必须是随机生成的 WordArray 或字节数组,加密解密两端使用同样的字节数据。我在联调时卡了很久才发现这个。
WordArray 和 Uint8Array 互转的字节序问题
CryptoJS 的 WordArray 内部是按大端存储的 32 位整数,直接转成 Uint8Array 时很容易弄错字节序。最稳妥的方式是使用 CryptoJS 自带的CryptoJS.enc.Uint8Array.parse和CryptoJS.enc.Uint8Array.stringify,不要自己写循环反转,省得踩大小端坑。
加密后内容用 Base64 字符串落入 Blob,体积膨胀 33%
如果直接把 CryptoJS 加密结果的toString()转成 Base64 字符串再生成 Blob,文件会大 1/3,后端还得先解 Base64 再解密。正确做法是取encrypted.ciphertext这个 WordArray,转成原始二进制,再生成 Blob。这样密文体积和原始文件几乎一致,只多了 16 字节 padding,性能也更好。
后端解密出来是乱码但没报错
一般有两个原因:一是分片拼接顺序错了,按到达顺序拼接而不是按 chunk 序号拼接;二是前后端使用的加密模式和填充不一致,比如前端 CBC + Pkcs7,后端按 ECB 解密。排查时先不看解密结果,直接把前端加密后的密文文件下载下来,在本地用同样的 key/iv/mode 解密一遍,如果能解出来,问题基本就在后端参数传递上。
下面是速查表:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 上传失败,请求返回 500 | 后端分片拼接逻辑错误 | 检查 chunks 参数,确保按 chunk 序号排序拼接 |
| 解密后文件乱码 | 加密模式/填充方式前后端不一致 | 统一使用 CBC + Pkcs7,参数从前端传后端并打印日志比对 |
| 大文件上传时页面卡顿 | FileReader 全量读入内存 | 改用流式加密,或把加密逻辑放到 Web Worker |
| 秒传永远不生效 | 加密后才计算 MD5 | 加密前先算原始文件 MD5 并调用秒传接口 |
| 密文文件比源文件大很多 | Base64 编码后落盘 | 使用二进制 Blob,不要转 Base64 字符串落盘 |
| 上传成功后后端无法打开文件 | AES key 被当字符串传了 | 确保前后端用字节形式传递和解析 key |
4.3 加密性能优化与后续扩展
加密操作确实会消耗 CPU,尤其是流式加密大文件时,遍历文件每一块都要做一次 AES 计算。实测下来,普通办公机 2MB 分片做 CBC 加密大概十几到几十毫秒,基本无感,但如果是几百 MB 的文件,多次加密耗时累计起来会让用户感觉到“卡在选择文件后的处理阶段”。优化方向有两个:一是把加密放到 Web Worker 里,不阻塞 UI 线程;二是把文件读取和加密的 chunkSize 适当调大,减少 process 调用次数,512KB 是平衡点,再大反而容易造成内存抖动。
对于后续扩展,我建议把 RSA 公钥做成可配置的,不同业务模块可以使用不同的密钥对,甚至做到密钥轮换时只需将旧的 AES 密钥用新公钥重新封装一次,文件内容不需要重新加密。这个操作在数字信封架构下非常自然,也是我选这个方案最满意的一点。
写在最后的一个经验
这一套改造做下来,最深的体会是:加密本身并不难,难点在于把“老的组件生命周期”和“新的加密职责”有效地黏合起来。WebUploader 虽然老,但它分片、并发、秒传的上传骨架是稳定的,只要做到实例随组件创建和销毁、加密完成后替换文件源、上传参数通过 uploadBeforeSend 完整传递,整个链路就能稳定跑在生产环境。这个方案上线后已经跑了两个业务季度,除了最开始联调时踩过的 iv 和 chunk 拼接问题,加密链路没有再出过事故。如果你也在 Vue2 项目里遇到类似诉求,可以照着我这套思路先搭一个小 demo 验证,遇到具体问题欢迎回来对照速查表排查。