☰
Vue3实现加密分片断点续传:军工级文件上传方案
2026/10/15 8:37:23 网站建设 项目流程

接到这个需求的时候,我第一反应是:这比普通后台系统的上传功能麻烦得多。标题里的三个关键词——“加密”“层级结构”“续传”,单拆出来任何一个都不是新东西,但合在一起,落在军工科研这种高安全要求的场景下,很多常规方案会直接失效。尤其是用Vue3来做前端承载,既要保住实验数据文件夹的目录关系,又要保证每个文件在传输过程中以密文形态存在,还得在断网、刷新、甚至浏览器崩溃后能接着传——这套设计绕不开几个关键决策点。

这篇文章我会按自己的实际处理过程来拆解整套方案,包括加密选型、树结构设计、切片续传的协议怎么定、Vue3的组合式API怎么组织这些业务逻辑,以及后面踩过的一些真实坑。适合正在做高安全要求下数据管理系统的前端同学参考,也适合第一次接触“加密续传”这类需求的人拿来当设计清单看。

1. 先把需求拆透:三个关键词叠在一起意味着什么

1.1 军工科研平台的特殊约束

军工领域的科研数据平台,和普通企业网盘、实验管理系统有一个本质区别:数据敏感等级很高,网络环境通常是隔离的专网,对加密算法有明确的验收标准。很多开源组件、CDN资源和第三方云存储SDK在这种环境下默认不可用,所有前端依赖都必须本地化离线部署,所有依赖包的来源要能追溯。

这带来的直接影响是:你不能直接拿一个现成的断点续传组件套上去。常见的云厂商上传SDK普遍绑定了他们的云端服务,内网环境根本不通;开源的upload组件大多只处理“文件上传”,不处理“文件夹层级”“端侧加密”和“会话恢复”这三件事的联动。换句话说,这套东西得自己拼。

另一个约束是权限。平台需要按科研课题、实验批次、参与人员做节点级的数据隔离。一个研究人员登录后,能看到的文件夹树是他权限范围内的一部分,而不是全量目录。这个权限模型会影响前端树结构的加载方式,也影响续传会话的校验逻辑。

1.2 “加密”“层级结构”“续传”三个关键词拆开看

加密层:普通项目往往依赖HTTPS链路保护就认为够了,但这个场景要求在端侧就对文件内容加密,服务端落盘的是密文,即使存储介质被拷贝出来也无法还原明文。加密还要考虑国密算法适配,因为验收和使用环境往往只认可合规算法。

层级结构层:实验数据天然有目录结构,比如按“课题/批次/仪器/时间”组织的大量文件。平台不能把这些文件拍平成一个大列表,必须在传输和展示两个维度都保留树形关系。这意味着数据结构里要同时表达文件夹节点、文件节点、父子关系、节点加密元数据。

续传层:科研人员传一批实验数据往往是大批量文件,网络不稳定是常态,浏览器刷新、电脑休眠甚至断网都会让上传中断。如果每次断了就从头再来,几百GB的实验数据谁都受不了。续传的核心是“会话恢复”,也就是用户重新打开页面后,上传任务的状态、进度、已传分片都要能恢复。

1.3 这套方案的总体分层

我把整个能力拆成前端、传输协议、服务端存储三个层次:

  • 前端负责:遍历文件夹生成树、对文件分片并加密、按会话元数据调度上传、实时刷新树节点状态。
  • 传输协议负责:建立上传会话、校验分片、查询已传状态、合并分片。
  • 服务端负责:接收密文分片、按上传会话关联存储、在全部块上传完成后合并出密文文件,同时维护树的逻辑结构。

前端的定位很明确:只上传密文。密钥使用完毕后不出前端内存,服务端拿不到明文分片,也不感知明文内容。这样才能满足“全链路密文”的安全诉求。

2. 技术选型:为什么用Vue3来做这个重交互系统

2.1 组合式API在复杂状态管理上的优势

这套系统的前端复杂度主要集中在状态联动:树节点有状态、上传队列有状态、加密Worker有状态、全局还有会话状态。用Vue2的Options API写这种多实例管理会非常吃力——大量的this赋值、mixin混用、事件总线满天飞,代码很快就腐化。

Vue3的组合式API把逻辑按“能力域”聚合,而不是按“选项”切割。比如上传能力可以封装成一个自定义组合函数,内部持有响应式状态、定时器、异步任务句柄,组件里只需要调用入口函数,拿到状态引用去渲染即可。逻辑内聚,多个页面复用同一套上传能力变得自然。

配合Pinia做全局会话管理,树节点和上传队列之间的联动非常顺。上传队列更新某个节点的进度时,直接把该节点在store里的状态字段改掉,树组件通过响应式依赖自动刷新UI,不需要手动传事件。

2.2 加密算法选型:国密SM4为主、AES-GCM兜底

高安全要求场景下,加密算法不是“哪个性能好选哪个”的问题,而是“验收接受哪个”。当前主流需求是国密算法,SM4是分组对称加密算法,分组长度128位,密钥长度128位,在Web端有成熟实现,做分片加密完全够用。

SM4常见工作模式是CBC或ECB。文件分片加密时建议用CBC加上PKCS7填充。ECB模式不建议单独用,因为相同的明文块会产生相同的密文块,在大量实验数据场景下容易暴露数据模式。

密钥管理上采用密钥分层:

  • 主密钥(KMK):存放在平台的安全密钥服务里,不进入前端代码。
  • 数据密钥(DEK):由平台为每个上传任务颁发,前端只在当前会话中持有,加密传给后端的是经过KMK包裹后的密钥密文。

这样单个DEK泄露也只影响一个上传任务,不会波及全平台数据。

2.3 树组件与上传组件的选型

树结构展示使用的是Element Plus的Tree组件加自定义节点插槽,原因很简单:团队已经基于Element Plus做基础组件库,减少学习成本。需要自己扩展的地方是节点插槽里的进度条、状态徽标和右键菜单。

节点规模这块要提前预估。一个课题文件夹下几千个文件是常态,上万节点也可能出现。Element Plus的Tree组件在数据量上来后会明显变卡,所以我在最终实现的方案里做了一层节点懒加载:只有展开到具体文件夹层级时才拉取该层子节点。这样首屏只加载根节点,性能压力小很多。如果后续节点量继续膨胀,可以再换成虚拟滚动树组件。

上传能力没有用第三方上传SDK,而是自己基于原生XMLHttpRequest封装上传管理类。原因前面提过——外部SDK无法适配内网隔离环境,且我们需要在分片上传的每个环节嵌入加密逻辑、哈希校验和会话恢复,定制性要求很高。原生XHR对上传进度的监听很稳定,反而比fetch更容易拿到分片级进度。

3. 数据结构与加密层级设计

3.1 三张核心数据结构

前端要同时维护三类数据:树节点结构、上传会话、分片记录。我用TypeScript定义,放在独立模块里统一管理。

// 树节点结构 interface FileNode { nodeId: string; // 全局唯一节点ID parentId: string | null; // 父节点ID,根节点为null nodeType: 'folder' | 'file'; nameCipher: string; // 节点名称加密后的密文(Base64) fileSize?: number; // 文件字节大小 uploadStatus?: 'pending' | 'encrypting' | 'uploading' | 'paused' | 'done' | 'error'; progress: number; // 0-100 children?: FileNode[]; } // 上传会话信息 interface UploadSession { uploadId: string; virtualPath: string; // 如 /experiment/2025/records.xlsx totalSize: number; chunkSize: number; // 每个分片大小 totalChunks: number; dekCipherBase64: string; // KMK包裹后的数据密钥 algo: 'sm4-cbc' | 'sm4-ecb'; expiredAt: number; // 会话过期时间 status: 'active' | 'completed' | 'aborted'; } // 分片记录 interface ChunkRecord { chunkIndex: number; start: number; // 原文件字节起始位置 end: number; // 原文件字节结束位置 originalSize: number; // 未加密前本次分片长度 orgCipherSize: number; // PKCS7填充后加密分片长度(不含IV) iv: string; // 当前分片的初始向量,Base64 hash: string; // 明文分片的SM3哈希,用于校验 status: 'local' | 'uploaded' | 'verified'; }

这里最容易被忽略的是originalSize和orgCipherSize两个字段。SM4-CBC按16字节分组,PKCS7填充会让加密后的分片长度和原分片长度不一致。合并文件时,服务端必须知道去掉填充后的真实长度,才能还原出可用的密文文件元数据。这两个字段一个都不能省。

3.2 树结构的加密层级怎么体现

“加密层级结构”不是指把整棵树都加密,而是指每个节点的敏感字段(文件名)和每个文件的内容都做加密,同时保留层级关系。

具体做法是:

  • 文件名在客户端用SM4加密后存为nameCipher,Base64编码后进入树结构。服务端即使拿到树的明文结构,看到的也只是密文名称。
  • 文件夹名称通常比较短,加密时注意统一编码为UTF-8字节再加密,避免中文乱码问题。
  • 树本身的父子关系不加密,因为平台需要按权限加载节点,父子关系一旦加密,服务端无法做权限过滤排序。
  • 文件内容按分片加密,每个分片接收时独立校验,不依赖整个文件的完整性。

这个设计的关键权衡在于:树结构是权限和导航的基础,必须半明文;文件内容是数据资产的核心,必须全密文。把加密重心放在文件内容上,是从实际威胁模型出发的选择——相比目录名称泄露,实验数据内容泄露才是最严重的事件。

3.3 虚拟路径与服务端物理文件的映射

前端展示的是虚拟路径,比如“某课题批次/2025春季/光谱记录.csv”。服务端存储时不能直接按这个路径存明文,因为路径本身就带信息。我的做法是:服务端为每个上传会话生成一个uuid作为文件物理文件名,虚拟路径映射关系单独建表,只允许通过平台接口查询,数据库存储本身也要加密。

虚拟路径到物理文件的映射表大致是:

virtual_path upload_id physical_file status /实验台账/1.csv f8a2...eq f8a2...eq.bin complete

前端在上传前会调用一个创建会话接口,把虚拟路径、分片大小、总片数、文件哈希提交给服务端,服务端返回uploadId和会话过期时间。后续所有分片上传请求都带上uploadId,服务端只认这个ID,不接收任何客户端自定义的文件路径参数。

4. 核心实现:加密、切片与上传调度

4.1 基于Web Worker的分片加密管线

大文件不能一次性读入内存加密,比如4GB的实验数据。直接在主线程做FileReader或Blob.arrayBuffer会卡死页面,而且每次传输大对象容易触发垃圾回收抖动。我用Web Worker来处理加密计算,主线程只负责任务调度。

加密Worker的核心逻辑比较简单:

// crypto.worker.ts 关键伪代码 self.onmessage = async (event) => { const { chunkBuffer, key, iv } = event.data; // 使用国密库执行SM4-CBC加密,务必保证输入是Uint8Array const encrypted = sm4.encrypt(chunkBuffer, key, { iv, mode: 'cbc' }); const hash = sm3.hash(chunkBuffer); self.postMessage( { encrypted, hash }, // 将chunkBuffer和encrypted的底层内存转移给主线程,避免拷贝 [chunkBuffer.buffer, encrypted.buffer] ); };

主线程调用Worker时,直接创建任务队列并把ArrayBuffer转移进去。这里有个典型的性能优化点:postMessage时第三个参数传递可转移对象列表,把ArrayBuffer的所有权转给Worker,避免结构化克隆的大内存拷贝。加密完成后Worker再把密文内存转移回主线程。

分片大小我推荐设置为4MB。太小会导致请求数剧增,服务端压力大;太大则单分片加密耗时过长,前端进度反馈不平滑。内网带宽好的话可以用8MB,但4MB在Web端是相对稳妥的平衡点。

4.2 切片与上传调度器

上传调度器需要处理的事情包括:并发控制、失败重试、进度汇总、分片状态回写。我写了一个UploadManager类来管理所有上传任务。

核心流程:

class UploadManager { private queue: ChunkRecord[] = []; private activeCount = 0; private maxConcurrent = 3; // 并发上限 async enqueueChunk(record: ChunkRecord, file: File) { // 1. 从文件里切出对应分片 const buf = await readBlob(file, record.start, record.end); // 2. 交给加密Worker处理 const encrypted = await this.cryptoWorker.encrypt(buf, this.sessionKey, record.iv); // 3. 加入待发送队列 this.queue.push({ ...record, data: encrypted }); this.pump(); } async pump() { while (this.activeCount < this.maxConcurrent && this.queue.length > 0) { const task = this.queue.shift(); this.activeCount++; this.upload(task); } } async upload(task) { const formData = new FormData(); formData.append('uploadId', this.session.uploadId); formData.append('chunkIndex', String(task.chunkIndex)); formData.append('hash', task.hash); formData.append('file', new Blob([task.encrypted])); try { await axios.post('/api/upload/chunk', formData, { onUploadProgress: (e) => this.updateProgress(task, e.loaded / e.total) }); task.status = 'uploaded'; } catch (err) { task.status = 'error'; if (this.shouldRetry(task)) this.retry(task); } finally { this.activeCount--; this.pump(); } } }

并发数这里非常考究。我一开始按普通文件上传的经验把并发调到5,结果压测时发现服务端数据库写入冲突率明显上升,而且浏览器内存暴涨。后来固定在3,内网环境下跑得很稳。如果你处理的是超大文件集群,建议先做一轮小规模压测,观察服务端的I/O压力再定并发值。

4.3 断点续传的完整恢复链路

续传的核心不是“把文件重新传一遍”,而是“告诉服务端我已经有哪些块,只要补上缺的块就行”。所以我会为每个上传会话维护两个状态源:

  • 前端IndexedDB里保存会话元数据和分片记录
  • 服务端维护已接收分片清单

用户刷新页面后,前端内存里的File对象会丢失,这点避不开。所以恢复流程的第一步不是自动续传,而是提示用户重新选择源文件夹,然后通过文件路径和大小匹配历史会话。

async function resumeUpload(sessionId: string, fileMap: Map<string, File>) { const session = await getSessionFromIndexedDB(sessionId); // 1. 重新计算待上传文件的哈希或引用已有哈希 const doneChunks = await axios.get(`/api/upload/${sessionId}/done`); const doneSet = new Set(doneChunks.data.map(c => c.chunkIndex)); // 2. 过滤掉已上传分片 const pendingChunks = session.chunks.filter(c => !doneSet.has(c.chunkIndex)); // 3. 对每个pending分片重新执行加密上传 for (const record of pendingChunks) { const file = fileMap.get(record.virtualPath); // 如果文件不存在或大小不匹配,说明源文件已被修改,直接标记该文件需重传 if (!file || file.size !== record.originalSize) { markForFullRetry(sessionId, record.virtualPath); continue; } await manager.enqueueChunk(record, file); } }

这个阶段注意一个真实场景:用户重新选择文件夹时可能选错,或者中间改动了文件内容。如果某个分片源数据已经变化,它的SM3哈希就会不匹配,服务端合并后会发现文件整体校验失败。所以恢复逻辑里必须做“大小校验 + 部分哈希校验”的双重判断。

5. 树节点状态与上传队列的联动交互

5.1 树节点状态管理

树节点不是静态的,上传过程中每个节点会经历多个状态,比如“等待上传”“加密中”“上传中”“已暂停”“已完成”“失败”。我直接用Pinia里的一个模块管理整棵树的节点状态,节点ID作为键,树组件通过响应式引用读取。

状态机设计如下:

  • pending:初始状态,分片已生成但未安排上传
  • encrypting:分片正在Web Worker里做SM4加密
  • uploading:当前有分片正在传输
  • paused:用户手动暂停或网络中断自动暂停
  • done:所有分片上传完毕,服务端已完成合并
  • error:某个分片重试达到上限

树组件通过自定义插槽渲染状态徽标,文件夹节点的进度取子节点的加权平均,公式是:

folderProgress = sum(子文件已完成字节数) / sum(子文件总字节数) * 100

注意文件夹节点进度不能简单平均,否则小文件多的文件夹会把进度拉高,看上去很不真实。按字节加权更符合“还剩多少数据要传”的直觉。

5.2 进度计算与剩余时间估算

进度反馈做得好不好,直接影响科研人员对这个系统的信任。我在进度这块做了两个细节优化:

一是分片级进度和字节级进度分开。分片上传走的是XHR的onUploadProgress,能拿到当前分片的已传字节。总进度计算公式为:

totalProgress = (已完全上传分片总字节 + 正在上传分片的已传字节) / 总字节 * 100

第二是剩余时间估算。不改用简单的“剩余字节/当前速度”,因为瞬时速度波动太大,我取最近10秒的平均上传速度作为估算基准,配合滑动窗口过滤掉峰值和低谷,展示出来的剩余时间不会跳来跳去。

树节点上还会展示当前活动任务数,比如“正在加密 2 个分片 / 并行上传 3 个分片”。这些细节看起来不起眼,但在真机环境下,用户能直观判断系统是不是卡死,是继续等待还是调整并发。

6. 常见问题与排查思路实录

6.1 问题速查表

问题现象可能原因排查方向解决方案
刷新后所有上传任务消失上传会话没有持久化到IndexedDB检查会话写入是否成功,存储是否被浏览器清理上传会话关键字段统一持久化,恢复时按uploadId查询
续传后部分分片校验失败用户重新选择的源文件内容有变化对比文件大小、修改时间和抽样哈希恢复前做大小校验,不一致则整文件重传
加密Worker内存持续增长大量ArrayBuffer没有及时释放检查postMessage是否转移了所有权传输时使用可转移对象列表,加密后立即释放源buffer
服务端报分片重复前端重试机制和后端幂等设计冲突查看服务端分片表是否唯一索引服务端对uploadId+chunkIndex做唯一约束,重试返回成功
页面卡死并发数过高导致浏览器内存暴增打开任务管理器看内存占用限制并发数,大文件场景建议不超过3-4个
树节点几万个以上滚动明显卡顿一次性渲染全量节点没有虚拟滚动检查树组件节点数量改为节点懒加载,或替换为虚拟滚动组件

6.2 踩坑实录

第一个比较深的坑是加密填充导致的分片大小不一致。我们第一版协议里分片元数据只用start和end记录原文件位置,没有记加密后的大小。结果服务端合并的时候,严格按照原文件大小拼接密文,最后一个分片因为PKCS7填充导致多出几个字节,整个文件就坏了。解决办法是每个分片都携带originalSize和orgCipherSize,服务端合并时按orgCipherSize拼接,解密时再用originalSize还原明文长度。

第二个坑是浏览器环境对国密算法的兼容性差异。同一个加密程序,在部分国产浏览器上跑出来的结果不一样,根源是不同实现处理中文字符的UTF-8编码方式有差异。后来统一约定:所有加密前的字符串和二进制数据都先转成Uint8Array,指定UTF-8编码,再进入加密函数。这之后跨浏览器表现稳定。

第三个坑是Web Worker环境下的三方库问题。有些加密库在Worker线程里无法正常工作,因为内部引用了window或document对象。选型时最好找一个纯逻辑、无DOM依赖的国密实现,先写一个简单的单测跑一下Worker环境,再决定是否大规模使用。

第四个坑和时间有关:上传会话过期时间设置太短。早期设置30分钟过期,科研人员中途开个会回来,会话早过期了,所有分片被服务端清理,前功尽弃。现在改成会话有效期按上传数据量估算,最小2小时,大文件任务自动延长到24小时,并在过期前给用户弹出续期提示。

7. 一点从实战里沉淀下来的个人体会

做了这几轮迭代之后,我对这类系统的理解是:加密和续传本身都不难,难的是它们的边界和异常处理。

如果是一个刚启动的项目,我建议先把上传协议完全定死再写页面。协议里要明确分片大小字段是否固定、填充后的大小怎么传递、重试的幂等策略、会话过期怎么处理。协议一旦跑起来,后面改协议的成本极高,前端、后端、测试要同步改。

还有一点很实在:任何涉及密钥和加密的代码,模块边界一定要清晰。前端不要把加密逻辑散落到各个组件里,统一收敛到一个加密服务模块,提供encryptChunk、decryptChunk、computeHash这类接口。这样后续如果算法从SM4升级到SM5,或者要叠加签名逻辑,只改一个模块就能完成。

最后,测试阶段一定要模拟断网恢复场景。不要只在优秀的内网环境里自测通过就觉得完成了。我当时在测试环境专门配置了一个proxy,随机丢弃请求,专门用来做断网恢复演练。这种方式能非常快地暴露协议设计里的漏洞,比人工手动点暂停再恢复都高效。

这个方案目前已经稳定运行了一段时间,后续如果要继续扩展,可以在端侧加上分片级完整性签名,或者把树节点元数据做成异步碎片化加密存储。但核心思路是稳定的:树结构做半明文、文件内容做全密文、分片记录做好持久化、会话恢复做成幂等流程。把这四条设计准绳守住,绝大多数高安全场景下的文件传输需求都能踏实落地。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询