Skip to content

大文件上传系统设计与框架落地方案 ​

适合人群:需要在 Vue2、Vue3、React、Nuxt、小程序、Node.js、NestJS、Express 项目中实现大文件上传、暂停、继续、断点重传、秒传、失败重试、并发控制和上传进度的开发者。

学习目标:不仅知道“分片上传”这个概念,还能设计前后端协议,并把方案落地到具体框架。

核对时间:2026-06-30。本文参考 MDN File API / Blob.slice / AbortController / FormData / Web Worker、NestJS File Upload、Express、Multer、微信小程序 uploadFile、uni-app/Taro uploadFile、Amazon S3 Multipart Upload、tus resumable upload protocol 等官方资料整理。


目录 ​

  1. 大文件上传要解决什么问题
  2. 三种主流上传方案
  3. 核心概念:分片、暂停、断点、秒传
  4. 推荐接口协议设计
  5. 前端通用实现
  6. Vue3 项目实现
  7. Vue2 项目实现
  8. React 项目实现
  9. Nuxt 项目实现
  10. 小程序项目实现
  11. Express 服务端实现
  12. NestJS 服务端实现
  13. 对象存储 Multipart Upload 方案
  14. 暂停、继续、断点重传的真实处理逻辑
  15. 安全、可靠性与生产注意事项
  16. 测试清单
  17. 官方参考资料

1. 大文件上传要解决什么问题 ​

普通上传通常是:

text
选择文件 -> FormData -> POST /upload -> 服务端保存

这对 5MB、20MB 的文件可以接受,但对 500MB、2GB、10GB 的文件会遇到问题:

  • 网络中断后要重传整个文件。
  • 上传耗时长,用户想暂停。
  • 浏览器刷新后进度丢失。
  • 服务端一次性接收压力大。
  • 代理层可能有 body size 限制。
  • 移动端和小程序弱网失败率高。
  • 多文件并发时容易卡死。
  • 用户不知道上传到哪里了。

大文件上传的目标是:

  • 可暂停。
  • 可继续。
  • 可断点重传。
  • 可失败重试。
  • 可显示准确进度。
  • 可并发但不压垮网络。
  • 服务端可校验完整性。
  • 上传完成后能合并或提交对象存储。

2. 三种主流上传方案 ​

2.1 普通表单上传 ​

适合:

  • 小文件。
  • 头像。
  • 图片。
  • Excel。
  • 低频后台上传。

协议:

http
POST /upload
Content-Type: multipart/form-data

优点:

  • 简单。
  • 框架支持好。

缺点:

  • 失败后通常要重传。
  • 暂停和断点难做。
  • 大文件容易超时。

2.2 应用服务器分片上传 ​

适合:

  • 中大文件。
  • 需要业务权限校验。
  • 文件最终存本地、内网存储或对象存储。
  • 想完全控制上传协议。

流程:

text
前端切片 -> init -> 上传 chunk -> 查询状态 -> complete 合并

优点:

  • 暂停、续传、重试可控。
  • 业务逻辑灵活。
  • 容易和权限、审计、配额结合。

缺点:

  • 应用服务器承受上传流量。
  • 合并文件需要 IO。
  • 多实例部署需要共享临时存储或对象存储。

2.3 对象存储 Multipart Upload ​

适合:

  • 超大文件。
  • 高并发。
  • 生产级视频、安装包、素材上传。
  • 希望绕过应用服务器带宽。

流程:

text
前端请求服务端创建 multipart upload
服务端返回 uploadId 和预签名 URL
前端直传每个 part 到 S3/OSS/COS/MinIO
前端把 ETag 列表交给服务端
服务端 complete multipart upload

优点:

  • 应用服务器不承受文件流量。
  • 对象存储原生支持分片、合并、失败重试。
  • 更适合生产高并发。

缺点:

  • 接入复杂。
  • 需要处理签名、跨域、part ETag。
  • 和具体云厂商 API 相关。

3. 核心概念:分片、暂停、断点、秒传 ​

3.1 分片 ​

分片就是把一个大文件切成多个小块:

text
file.mp4 1GB
  chunk 0: 0MB - 5MB
  chunk 1: 5MB - 10MB
  chunk 2: 10MB - 15MB
  ...

浏览器里用 Blob.slice():

ts
const chunk = file.slice(start, end);

3.2 暂停 ​

前端没有真正“冻结 HTTP 上传”的通用能力。

实际暂停一般是:

  1. 中断当前正在上传的请求。
  2. 停止调度新的 chunk。
  3. 保留已成功上传的 chunk 记录。
  4. 用户点击继续时,从未上传 chunk 继续。

浏览器中断请求用 AbortController。

小程序中断上传用 UploadTask.abort() 或 RequestTask.abort()。

3.3 断点重传 ​

断点重传不是前端单方面能实现的。

必须依赖服务端记录:

  • uploadId。
  • fileHash。
  • totalChunks。
  • 已上传 chunk index。
  • 每个 chunk 的大小或 hash。
  • 上传会话状态。

继续上传时:

text
前端重新选择同一个文件
计算 hash 或 fingerprint
调用 /uploads/init 或 /uploads/status
服务端返回已上传 chunk
前端只上传缺失 chunk

3.4 秒传 ​

秒传指服务端已经存在完整文件,前端不用再上传。

前提:

  • 前端能计算完整文件 hash。
  • 服务端根据 hash 找到已存在文件。

流程:

text
前端计算 fileHash
POST /uploads/init
服务端发现 fileHash 已存在
返回 shouldUpload: false
前端直接显示上传完成

注意:

  • 完整 hash 计算大文件会耗时。
  • 可以用 Web Worker 避免阻塞 UI。
  • 只用文件名和大小不能作为安全秒传依据,只能做快速 fingerprint。

3.5 内部机制:秒传与断点续传的完整性校验设计 ​

秒传和断点续传都是“服务端用某种指纹代表文件内容”,一旦指纹判断错误,后果是用户 A 拿到了用户 B 的文件内容或残缺文件。完整性必须分层设计:

text
第一层 快速 fingerprint(秒传判定入口)
        name + size + lastModified 组合,或抽样 hash
        作用:快速返回,命中后仍要能兜底复核
第二层 内容 hash(完整性依据)
        MD5/SHA-256 全文摘要
        作用:真正的“同内容=同文件”判定
第三层 分片 hash(传输通道校验)
        每个 chunk 单独 hash,服务端落盘前比对
        作用:定位坏片,续传时只重传坏片
第四层 合并后复核(最终防线)
        complete 时服务端把合并文件重算全文 hash,与 init 声明值比对
        不一致 → 删除合并产物、标记 session 失效、返回明确错误码

关键决策点:

问题推荐做法理由
用什么 hash 算法?秒传去重场景 MD5 通常够用且快(Web Crypto crypto.subtle.digest("MD5", ...) 不支持,需用 wasm 版 hash-wasm);安全敏感(防篡改、签名)必须 SHA-256MD5 碰撞可被人为构造,绝不能用于“证明文件来源可信”;纯去重则两可
大文件算不动全文 hash?抽样 fingerprint:头部 N 字节 + 中间 M 字节 + 尾部 K 字节 + size 做 hash(sparkline 式)抽样只能当“疑似相同”,命中秒传后异步回源复核或要求前端补传全文 hash 校验;不能当最终依据
hash 与文件元信息绑不绑?秒传命中返回 fileUrl 时,同时返回 size + hash 让前端自检;私有存储加访问鉴权防止“猜 hash 白嫖他人文件”:纯内容寻址会让知道 hash 的人都能拿到文件,必须叠加“上传者/租户可见”的 ACL
chunk 边界变了指纹就变?上传协议里把“chunkSize 参与会话标识”写清楚:resume 时 chunkSize 不一致就拒绝复用旧 session同一文件按 4MB/5MB 切片,服务端已存 chunk 不可混用
断网瞬间“服务端已存、客户端以为失败”chunk 上传响应要幂等可重试 + status 接口以服务端记录为准客户端的 uploadedChunks 只是缓存,真相在服务端

⚠️ 最常见的错误设计:init 用 name-size-lastModified 当 fileHash 直接查“已完成文件表”做秒传。同名同大小同修改时间的两个不同内容文件会被错误秒传,且无法在 complete 阶段发现(根本没有真 hash 可比)。fingerprint 只能定位 session,不能证明内容。

💡 增量式 hash 的工程折中:把“完整 fileHash”从 init 前置改为随最后一个 chunk 提交(每个分片带自己的 hash,最后一片带“分片 hash 列表的聚合 hash”),服务端合并时顺手重算校验。前端免去了阻塞上传启动的全文件预扫描,代价是秒传判定推迟——大文件产品通常“首片先行 + 后台补算全文 hash 再决定要不要中断已传流”。

3.6 协议标准化选项:tus ​

自研协议之外,断点续传有开放标准 tus(v1.0.0),核心只有三个方法:

text
POST /files                    创建上传 → 201 + Location
HEAD  /files/{id}              查询进度 → Upload-Offset: <已收字节数>
PATCH /files/{id}              续传:Content-Type: application/offset+octet-stream
                               + Upload-Offset 必须等于服务端当前 offset,
                               否则 409 Conflict(客户端应重新 HEAD 再续)
  • 每个请求/响应都带 Tus-Resumable: 1.0.0 协商版本。
  • 偏移是字节不是分片号,天然容忍任意切片大小调整。
  • 扩展:creation-with-upload(创建时带上首块)、termination(DELETE)、checksum(每块带校验和)、concatenation(并行分片拼接)。
  • 前端 tus-js-client、Node 端 tusd/tus-node-server,小程序支持有限。

与本文自研协议对比:tus 省掉了“设计/评审/测试一套私有协议”的成本,且幂等语义(409/412/415)定义得很清楚;代价是它按字节流建模,和“固定 5MB 分片 + 分片 hash”的对象存储风格或既有业务表结构需要适配层。选型时值得优先评估。


4. 推荐接口协议设计 ​

4.1 创建上传会话 ​

http
POST /uploads/init
Content-Type: application/json

请求:

json
{
  "fileName": "video.mp4",
  "fileSize": 1048576000,
  "contentType": "video/mp4",
  "fileHash": "sha256-or-md5",
  "chunkSize": 5242880,
  "totalChunks": 200
}

响应:

json
{
  "uploadId": "u_123",
  "shouldUpload": true,
  "uploadedChunks": [0, 1, 2],
  "chunkSize": 5242880
}

如果秒传:

json
{
  "uploadId": "u_123",
  "shouldUpload": false,
  "fileUrl": "https://cdn.example.com/video.mp4"
}

4.2 上传单个 chunk ​

multipart 方式:

http
POST /uploads/{uploadId}/chunks/{index}
Content-Type: multipart/form-data

字段:

text
chunk: Blob/File
chunkHash: string

raw binary 方式:

http
PUT /uploads/{uploadId}/chunks/{index}
Content-Type: application/octet-stream
X-Chunk-Hash: xxx

multipart 更容易兼容 Web 表单和 Multer。

raw binary 更适合小程序 wx.request 上传 ArrayBuffer。

4.3 查询上传状态 ​

http
GET /uploads/{uploadId}/status

响应:

json
{
  "uploadId": "u_123",
  "status": "uploading",
  "uploadedChunks": [0, 1, 2, 5],
  "totalChunks": 200
}

4.4 完成上传 ​

http
POST /uploads/{uploadId}/complete

响应:

json
{
  "fileUrl": "https://cdn.example.com/video.mp4",
  "fileHash": "sha256-or-md5",
  "size": 1048576000
}

4.5 取消上传 ​

http
DELETE /uploads/{uploadId}

服务端处理:

  • 标记会话取消。
  • 清理临时 chunk。
  • 如果是对象存储,调用 abort multipart upload。

5. 前端通用实现 ​

5.1 切片函数 ​

ts
export interface FileChunk {
  index: number;
  start: number;
  end: number;
  blob: Blob;
}

export function createFileChunks(file: File, chunkSize = 5 * 1024 * 1024) {
  const chunks: FileChunk[] = [];
  let start = 0;
  let index = 0;

  while (start < file.size) {
    const end = Math.min(start + chunkSize, file.size);
    chunks.push({
      index,
      start,
      end,
      blob: file.slice(start, end),
    });
    start = end;
    index += 1;
  }

  return chunks;
}

5.2 计算文件 hash ​

大文件 hash 应放到 Web Worker 里,避免阻塞主线程。

简化版主线程示例:

ts
export async function calculateSha256(file: File) {
  const buffer = await file.arrayBuffer();
  const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
  const hashArray = Array.from(new Uint8Array(hashBuffer));

  return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
}

生产建议:

  • 小文件可以直接完整 hash。
  • 大文件用 Worker 分片计算。
  • 如果秒传不是强需求,可以先用 fingerprint 创建会话,再后台校验完整 hash。

⚠️ 上面 calculateSha256 的示例一次 file.arrayBuffer() 会把整个文件读进内存——5MB 可以,1GB 文件会直接把标签页内存打爆(arrayBuffer 是完整拷贝)。正确做法是流式分片读 + 增量聚合:

ts
// hash-worker.ts:按 chunk 读取,峰值内存只有单个 chunk
const CHUNK = 4 * 1024 * 1024;

async function hashFile(file: File) {
  // crypto.subtle.digest 不支持增量,两种选择:
  // a) 循环里逐片 digest 后拼接(得到的是"分片摘要的摘要",与整文件摘要定义不同但同样可做指纹,需服务端同算法)
  // b) 引入 hash-wasm(支持 createSHA256().update(chunk).digest() 真增量),推荐
  const { createSHA256 } = await import("hash-wasm");
  const hasher = createSHA256();
  hasher.init();

  for (let offset = 0; offset < file.size; offset += CHUNK) {
    const buf = await file.slice(offset, offset + CHUNK).arrayBuffer();
    hasher.update(new Uint8Array(buf));
    self.postMessage({ percent: Math.round(((offset + CHUNK) / file.size) * 100) });
  }
  return hasher.digest("hex");
}

self.onmessage = async ({ data }: { data: File }) => {
  const hash = await hashFile(data);
  self.postMessage({ hash });
};

要点:File.slice() 只建立引用不占内存,arrayBuffer() 才读;循环读+update 让峰值内存固定在一个 chunk;每片之间天然让出线程,UI 不卡;进度消息回主线程渲染“校验中 43%”。跨浏览器一致性注意:hash 必须基于文件字节,不能混入 name/lastModified,否则服务端无法复算比对。

5.3 上传单个 chunk ​

ts
export async function uploadChunk(options: {
  uploadId: string;
  index: number;
  chunk: Blob;
  signal: AbortSignal;
}) {
  const formData = new FormData();
  formData.append("chunk", options.chunk);

  const response = await fetch(
    `/uploads/${options.uploadId}/chunks/${options.index}`,
    {
      method: "POST",
      body: formData,
      signal: options.signal,
    }
  );

  if (!response.ok) {
    throw new Error(`Chunk ${options.index} upload failed`);
  }
}

5.4 并发上传调度 ​

ts
export async function uploadChunksWithLimit(options: {
  uploadId: string;
  chunks: FileChunk[];
  uploadedIndexes: Set<number>;
  concurrency: number;
  onProgress: (uploadedCount: number, totalCount: number) => void;
  isPaused: () => boolean;
  addController: (controller: AbortController) => void;
  removeController: (controller: AbortController) => void;
}) {
  const pendingChunks = options.chunks.filter(
    (chunk) => !options.uploadedIndexes.has(chunk.index)
  );

  let cursor = 0;
  let uploadedCount = options.uploadedIndexes.size;

  async function worker() {
    while (cursor < pendingChunks.length && !options.isPaused()) {
      const chunk = pendingChunks[cursor];
      cursor += 1;

      const controller = new AbortController();
      options.addController(controller);

      try {
        await uploadChunk({
          uploadId: options.uploadId,
          index: chunk.index,
          chunk: chunk.blob,
          signal: controller.signal,
        });

        options.uploadedIndexes.add(chunk.index);
        uploadedCount += 1;
        options.onProgress(uploadedCount, options.chunks.length);
      } finally {
        options.removeController(controller);
      }
    }
  }

  await Promise.all(
    Array.from({ length: options.concurrency }, () => worker())
  );
}

5.5 暂停 ​

ts
let paused = false;
const controllers = new Set<AbortController>();

export function pauseUpload() {
  paused = true;
  for (const controller of controllers) {
    controller.abort();
  }
  controllers.clear();
}

暂停后:

  • 当前正在上传的 chunk 会失败或被 abort。
  • 已成功上传的 chunk 不需要重传。
  • 继续时先查服务端状态,再补传缺失 chunk。

5.6 完整上传流程 ​

ts
export async function uploadLargeFile(file: File) {
  const chunkSize = 5 * 1024 * 1024;
  const chunks = createFileChunks(file, chunkSize);
  const fileHash = await calculateSha256(file);

  const initResponse = await fetch("/uploads/init", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      fileName: file.name,
      fileSize: file.size,
      contentType: file.type,
      fileHash,
      chunkSize,
      totalChunks: chunks.length,
    }),
  });

  const initResult = await initResponse.json();

  if (!initResult.shouldUpload) {
    return initResult.fileUrl;
  }

  const uploadedIndexes = new Set<number>(initResult.uploadedChunks);

  await uploadChunksWithLimit({
    uploadId: initResult.uploadId,
    chunks,
    uploadedIndexes,
    concurrency: 4,
    onProgress(uploaded, total) {
      console.log(Math.round((uploaded / total) * 100));
    },
    isPaused: () => paused,
    addController: (controller) => controllers.add(controller),
    removeController: (controller) => controllers.delete(controller),
  });

  if (paused) {
    return null;
  }

  const completeResponse = await fetch(
    `/uploads/${initResult.uploadId}/complete`,
    { method: "POST" }
  );

  return completeResponse.json();
}

6. Vue3 项目实现 ​

6.1 composable ​

src/features/upload/model/useChunkUpload.ts:

ts
import { computed, ref } from "vue";
import { createFileChunks, uploadChunksWithLimit } from "./chunkUpload";

export function useChunkUpload() {
  const status = ref<"idle" | "hashing" | "uploading" | "paused" | "done" | "error">("idle");
  const progress = ref(0);
  const uploadId = ref<string | null>(null);
  const controllers = new Set<AbortController>();

  const isUploading = computed(() => status.value === "uploading");

  async function start(file: File) {
    status.value = "hashing";

    const chunkSize = 5 * 1024 * 1024;
    const chunks = createFileChunks(file, chunkSize);
    const fileHash = `${file.name}-${file.size}-${file.lastModified}`;

    const initResult = await $fetch<{
      uploadId: string;
      shouldUpload: boolean;
      uploadedChunks: number[];
      fileUrl?: string;
    }>("/uploads/init", {
      method: "POST",
      body: {
        fileName: file.name,
        fileSize: file.size,
        contentType: file.type,
        fileHash,
        chunkSize,
        totalChunks: chunks.length,
      },
    });

    if (!initResult.shouldUpload) {
      progress.value = 100;
      status.value = "done";
      return initResult.fileUrl;
    }

    uploadId.value = initResult.uploadId;
    status.value = "uploading";

    const uploadedIndexes = new Set<number>(initResult.uploadedChunks);

    await uploadChunksWithLimit({
      uploadId: initResult.uploadId,
      chunks,
      uploadedIndexes,
      concurrency: 4,
      onProgress(uploaded, total) {
        progress.value = Math.round((uploaded / total) * 100);
      },
      isPaused: () => status.value === "paused",
      addController: (controller) => controllers.add(controller),
      removeController: (controller) => controllers.delete(controller),
    });

    if (status.value === "paused") {
      return null;
    }

    const result = await $fetch(`/uploads/${initResult.uploadId}/complete`, {
      method: "POST",
    });

    status.value = "done";
    progress.value = 100;
    return result;
  }

  function pause() {
    status.value = "paused";
    controllers.forEach((controller) => controller.abort());
    controllers.clear();
  }

  return {
    status,
    progress,
    isUploading,
    start,
    pause,
  };
}

如果不是 Nuxt 项目,把 $fetch 换成普通 fetch 或 axios。

6.2 页面使用 ​

vue
<script setup lang="ts">
import { useChunkUpload } from "@/features/upload/model/useChunkUpload";

const { status, progress, start, pause } = useChunkUpload();

function onFileChange(event: Event) {
  const input = event.target as HTMLInputElement;
  const file = input.files?.[0];
  if (file) {
    start(file);
  }
}
</script>

<template>
  <input type="file" @change="onFileChange" />
  <progress :value="progress" max="100" />
  <button v-if="status === 'uploading'" @click="pause">暂停</button>
  <span>{{ status }} {{ progress }}%</span>
</template>

7. Vue2 项目实现 ​

7.1 组件方式 ​

vue
<template>
  <div>
    <input type="file" @change="onFileChange" />
    <progress :value="progress" max="100"></progress>
    <button v-if="status === 'uploading'" @click="pause">暂停</button>
    <button v-if="status === 'paused'" @click="resume">继续</button>
  </div>
</template>

<script>
import { createFileChunks, uploadChunksWithLimit } from "@/utils/chunkUpload";

export default {
  data() {
    return {
      file: null,
      uploadId: null,
      status: "idle",
      progress: 0,
      uploadedChunks: [],
      controllers: new Set(),
    };
  },
  methods: {
    async onFileChange(event) {
      this.file = event.target.files[0];
      await this.start();
    },
    async start() {
      const chunkSize = 5 * 1024 * 1024;
      const chunks = createFileChunks(this.file, chunkSize);
      const fileHash = `${this.file.name}-${this.file.size}-${this.file.lastModified}`;

      const initResult = await this.$http.post("/uploads/init", {
        fileName: this.file.name,
        fileSize: this.file.size,
        contentType: this.file.type,
        fileHash,
        chunkSize,
        totalChunks: chunks.length,
      });

      this.uploadId = initResult.uploadId;
      this.status = "uploading";

      await uploadChunksWithLimit({
        uploadId: initResult.uploadId,
        chunks,
        uploadedIndexes: new Set(initResult.uploadedChunks),
        concurrency: 3,
        onProgress: (uploaded, total) => {
          this.progress = Math.round((uploaded / total) * 100);
        },
        isPaused: () => this.status === "paused",
        addController: (controller) => this.controllers.add(controller),
        removeController: (controller) => this.controllers.delete(controller),
      });

      if (this.status !== "paused") {
        await this.$http.post(`/uploads/${this.uploadId}/complete`);
        this.status = "done";
        this.progress = 100;
      }
    },
    pause() {
      this.status = "paused";
      this.controllers.forEach((controller) => controller.abort());
      this.controllers.clear();
    },
    async resume() {
      if (!this.file || !this.uploadId) return;
      await this.start();
    },
  },
};
</script>

7.2 Vue2 注意事项 ​

  • 老项目如果 axios 版本较旧,取消请求可能使用 CancelToken;新项目推荐 AbortController。
  • 不要把所有 chunk Blob 放进 Vue 响应式 data,避免内存和性能问题。
  • 大文件 hash 放 Worker。
  • 上传状态可以存在 Vuex,但 Blob 和 AbortController 不建议放全局 store。

8. React 项目实现 ​

8.1 hook ​

tsx
import { useRef, useState } from "react";
import { createFileChunks, uploadChunksWithLimit } from "./chunkUpload";

export function useChunkUpload() {
  const [status, setStatus] = useState<"idle" | "uploading" | "paused" | "done" | "error">("idle");
  const [progress, setProgress] = useState(0);
  const fileRef = useRef<File | null>(null);
  const uploadIdRef = useRef<string | null>(null);
  const controllersRef = useRef(new Set<AbortController>());
  const statusRef = useRef(status);

  function updateStatus(nextStatus: typeof status) {
    statusRef.current = nextStatus;
    setStatus(nextStatus);
  }

  async function start(file: File) {
    fileRef.current = file;
    updateStatus("uploading");

    const chunkSize = 5 * 1024 * 1024;
    const chunks = createFileChunks(file, chunkSize);
    const fileHash = `${file.name}-${file.size}-${file.lastModified}`;

    const initResponse = await fetch("/uploads/init", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        fileName: file.name,
        fileSize: file.size,
        contentType: file.type,
        fileHash,
        chunkSize,
        totalChunks: chunks.length,
      }),
    });

    const initResult = await initResponse.json();

    if (!initResult.shouldUpload) {
      setProgress(100);
      updateStatus("done");
      return initResult.fileUrl;
    }

    uploadIdRef.current = initResult.uploadId;

    await uploadChunksWithLimit({
      uploadId: initResult.uploadId,
      chunks,
      uploadedIndexes: new Set(initResult.uploadedChunks),
      concurrency: 4,
      onProgress(uploaded, total) {
        setProgress(Math.round((uploaded / total) * 100));
      },
      isPaused: () => statusRef.current === "paused",
      addController: (controller) => controllersRef.current.add(controller),
      removeController: (controller) => controllersRef.current.delete(controller),
    });

    if (statusRef.current === "paused") return null;

    const completeResponse = await fetch(
      `/uploads/${initResult.uploadId}/complete`,
      { method: "POST" }
    );

    updateStatus("done");
    setProgress(100);
    return completeResponse.json();
  }

  function pause() {
    updateStatus("paused");
    controllersRef.current.forEach((controller) => controller.abort());
    controllersRef.current.clear();
  }

  async function resume() {
    if (fileRef.current) {
      await start(fileRef.current);
    }
  }

  return {
    status,
    progress,
    start,
    pause,
    resume,
  };
}

8.2 组件使用 ​

tsx
import { useChunkUpload } from "./useChunkUpload";

export function UploadPanel() {
  const { status, progress, start, pause, resume } = useChunkUpload();

  return (
    <section>
      <input
        type="file"
        onChange={(event) => {
          const file = event.target.files?.[0];
          if (file) start(file);
        }}
      />
      <progress value={progress} max={100} />
      {status === "uploading" && <button onClick={pause}>暂停</button>}
      {status === "paused" && <button onClick={resume}>继续</button>}
      <span>{progress}%</span>
    </section>
  );
}

9. Nuxt 项目实现 ​

9.1 前端页面 ​

Nuxt 前端与 Vue3 类似,可以使用 composable。

app/pages/upload.vue:

vue
<script setup lang="ts">
const progress = ref(0);
const status = ref("idle");

async function onFileChange(event: Event) {
  const input = event.target as HTMLInputElement;
  const file = input.files?.[0];
  if (!file) return;

  status.value = "uploading";
  // 调用通用 chunk upload service
}
</script>

<template>
  <input type="file" @change="onFileChange" />
  <progress :value="progress" max="100" />
</template>

9.2 Nuxt server/api 普通 chunk 接口 ​

server/api/uploads/init.post.ts:

ts
const sessions = new Map<string, any>();

export default defineEventHandler(async (event) => {
  const body = await readBody<{
    fileName: string;
    fileSize: number;
    fileHash: string;
    chunkSize: number;
    totalChunks: number;
  }>(event);

  const uploadId = body.fileHash;

  if (!sessions.has(uploadId)) {
    sessions.set(uploadId, {
      ...body,
      uploadedChunks: new Set<number>(),
    });
  }

  const session = sessions.get(uploadId);

  return {
    uploadId,
    shouldUpload: true,
    uploadedChunks: Array.from(session.uploadedChunks),
  };
});

server/api/uploads/[uploadId]/chunks/[index].post.ts:

ts
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";

export default defineEventHandler(async (event) => {
  const uploadId = getRouterParam(event, "uploadId")!;
  const index = getRouterParam(event, "index")!;
  const parts = await readMultipartFormData(event);
  const chunk = parts?.find((part) => part.name === "chunk");

  if (!chunk?.data) {
    throw createError({ statusCode: 400, statusMessage: "chunk is required" });
  }

  const dir = join(process.cwd(), ".uploads", uploadId);
  await mkdir(dir, { recursive: true });
  await writeFile(join(dir, index), chunk.data);

  return { ok: true };
});

注意:

  • readMultipartFormData 会把当前 chunk 读到内存中,所以 chunk 不要太大。
  • 生产大文件更推荐对象存储直传,或在 Node 服务中使用流式 multipart parser。
  • Nuxt serverless/edge 部署环境不一定适合本地磁盘合并文件。

10. 小程序项目实现 ​

10.1 小程序上传限制 ​

小程序的 wx.uploadFile 适合上传一个文件路径。

它返回 UploadTask,可以:

  • 监听进度。
  • abort 中断。

但它不直接提供完整的大文件暂停续传协议。断点续传需要自己做分片和服务端状态记录。

10.2 小文件上传 ​

ts
const task = wx.uploadFile({
  url: "https://api.example.com/upload",
  filePath,
  name: "file",
  success(res) {
    console.log(res);
  },
  fail(error) {
    console.error(error);
  },
});

task.onProgressUpdate((progress) => {
  console.log(progress.progress);
});

// 暂停等价于中断
task.abort();

10.3 小程序分片上传思路 ​

由于 wx.uploadFile 需要 filePath,分片有两种做法:

  1. 使用 FileSystemManager.readFile 读取指定 position/length,得到 ArrayBuffer,用 wx.request 上传 raw binary。
  2. 读取分片后写入临时 chunk 文件,再用 wx.uploadFile 上传该 chunk 文件。

第一种更直接,但要求后端支持 application/octet-stream。

10.4 小程序 raw chunk 上传 ​

ts
function uploadChunk(options: {
  uploadId: string;
  index: number;
  data: ArrayBuffer;
}) {
  return new Promise<void>((resolve, reject) => {
    const task = wx.request({
      url: `https://api.example.com/uploads/${options.uploadId}/chunks/${options.index}`,
      method: "PUT",
      header: {
        "Content-Type": "application/octet-stream",
      },
      data: options.data,
      success(res) {
        if (res.statusCode >= 200 && res.statusCode < 300) {
          resolve();
        } else {
          reject(res);
        }
      },
      fail: reject,
    });

    // 保存 task,用于暂停时 abort
    currentTasks.add(task);
  });
}

读取分片:

ts
function readChunk(filePath: string, position: number, length: number) {
  const fs = wx.getFileSystemManager();

  return new Promise<ArrayBuffer>((resolve, reject) => {
    fs.readFile({
      filePath,
      position,
      length,
      success(res) {
        resolve(res.data as ArrayBuffer);
      },
      fail: reject,
    });
  });
}

调度:

ts
async function uploadMiniProgramFile(filePath: string, fileSize: number) {
  const chunkSize = 2 * 1024 * 1024;
  const totalChunks = Math.ceil(fileSize / chunkSize);

  const initResult = await requestInitUpload({
    fileName: filePath.split("/").pop() || "file",
    fileSize,
    chunkSize,
    totalChunks,
  });

  for (let index = 0; index < totalChunks; index += 1) {
    if (paused) break;
    if (initResult.uploadedChunks.includes(index)) continue;

    const position = index * chunkSize;
    const length = Math.min(chunkSize, fileSize - position);
    const data = await readChunk(filePath, position, length);

    await uploadChunk({
      uploadId: initResult.uploadId,
      index,
      data,
    });
  }
}

暂停:

ts
function pauseMiniProgramUpload() {
  paused = true;
  currentTasks.forEach((task) => task.abort());
  currentTasks.clear();
}

继续:

ts
async function resumeMiniProgramUpload(filePath: string, fileSize: number) {
  paused = false;
  const status = await getUploadStatus(uploadId);
  // 根据 status.uploadedChunks 继续上传缺失 chunk
  await uploadMiniProgramFile(filePath, fileSize);
}

10.5 小程序注意事项 ​

  • 临时文件路径可能失效,真正断点续传通常需要用户重新选择文件,或把文件保存到本地持久路径。
  • chunk 不宜太大,避免内存压力。
  • 小程序网络并发有限,建议并发 1-3。
  • 上传失败要记录错误码和网络状态。
  • 后端 raw endpoint 要限制 chunk 大小。

11. Express 服务端实现 ​

11.1 安装 ​

bash
pnpm add express multer cors
pnpm add -D typescript @types/express @types/multer

11.2 目录 ​

text
src/
  app.ts
  upload/
    upload.routes.ts
    upload.service.ts
tmp/
  uploads/
storage/

11.3 示例数据结构 ​

演示用 Map,生产应使用数据库或 Redis:

ts
interface UploadSession {
  uploadId: string;
  fileName: string;
  fileSize: number;
  fileHash: string;
  chunkSize: number;
  totalChunks: number;
  uploadedChunks: Set<number>;
  status: "uploading" | "completed" | "cancelled";
}

const sessions = new Map<string, UploadSession>();
const completedFiles = new Map<string, string>();

11.4 init 接口 ​

ts
import express from "express";
import { randomUUID } from "node:crypto";

const router = express.Router();

router.post("/uploads/init", express.json(), (req, res) => {
  const {
    fileName,
    fileSize,
    fileHash,
    chunkSize,
    totalChunks,
  } = req.body;

  const existingUrl = completedFiles.get(fileHash);
  if (existingUrl) {
    res.json({
      shouldUpload: false,
      fileUrl: existingUrl,
    });
    return;
  }

  const uploadId = fileHash || randomUUID();

  if (!sessions.has(uploadId)) {
    sessions.set(uploadId, {
      uploadId,
      fileName,
      fileSize,
      fileHash,
      chunkSize,
      totalChunks,
      uploadedChunks: new Set(),
      status: "uploading",
    });
  }

  const session = sessions.get(uploadId)!;

  res.json({
    uploadId,
    shouldUpload: true,
    uploadedChunks: Array.from(session.uploadedChunks),
  });
});

11.5 multipart chunk 上传 ​

ts
import multer from "multer";
import { mkdir, rename } from "node:fs/promises";
import { join } from "node:path";

const upload = multer({
  dest: "tmp/chunks",
  limits: {
    fileSize: 10 * 1024 * 1024,
  },
});

router.post(
  "/uploads/:uploadId/chunks/:index",
  upload.single("chunk"),
  async (req, res, next) => {
    try {
      const { uploadId, index } = req.params;
      const session = sessions.get(uploadId);

      if (!session) {
        res.status(404).json({ message: "upload session not found" });
        return;
      }

      if (!req.file) {
        res.status(400).json({ message: "chunk is required" });
        return;
      }

      const chunkDir = join("tmp/uploads", uploadId);
      await mkdir(chunkDir, { recursive: true });
      await rename(req.file.path, join(chunkDir, index));

      session.uploadedChunks.add(Number(index));
      res.json({ ok: true });
    } catch (error) {
      next(error);
    }
  }
);

11.6 raw binary chunk 上传 ​

适合小程序 wx.request 上传 ArrayBuffer:

ts
router.put(
  "/uploads/:uploadId/chunks/:index",
  express.raw({
    type: "application/octet-stream",
    limit: "10mb",
  }),
  async (req, res, next) => {
    try {
      const { uploadId, index } = req.params;
      const session = sessions.get(uploadId);

      if (!session) {
        res.status(404).json({ message: "upload session not found" });
        return;
      }

      const chunkDir = join("tmp/uploads", uploadId);
      await mkdir(chunkDir, { recursive: true });
      await writeFile(join(chunkDir, index), req.body);

      session.uploadedChunks.add(Number(index));
      res.json({ ok: true });
    } catch (error) {
      next(error);
    }
  }
);

11.7 status 接口 ​

ts
router.get("/uploads/:uploadId/status", (req, res) => {
  const session = sessions.get(req.params.uploadId);

  if (!session) {
    res.status(404).json({ message: "upload session not found" });
    return;
  }

  res.json({
    uploadId: session.uploadId,
    status: session.status,
    uploadedChunks: Array.from(session.uploadedChunks),
    totalChunks: session.totalChunks,
  });
});

11.8 complete 合并 ​

ts
import { createReadStream, createWriteStream } from "node:fs";
import { mkdir, rm } from "node:fs/promises";
import { once } from "node:events";

async function pipeChunkToWriteStream(chunkPath: string, writeStream: NodeJS.WritableStream) {
  await new Promise<void>((resolve, reject) => {
    const readStream = createReadStream(chunkPath);
    readStream.on("error", reject);
    readStream.on("end", resolve);
    readStream.pipe(writeStream, { end: false });
  });
}

router.post("/uploads/:uploadId/complete", async (req, res, next) => {
  try {
    const session = sessions.get(req.params.uploadId);

    if (!session) {
      res.status(404).json({ message: "upload session not found" });
      return;
    }

    if (session.uploadedChunks.size !== session.totalChunks) {
      res.status(400).json({ message: "chunks are incomplete" });
      return;
    }

    await mkdir("storage", { recursive: true });

    const outputPath = join("storage", `${session.fileHash}-${session.fileName}`);
    const writeStream = createWriteStream(outputPath);

    for (let index = 0; index < session.totalChunks; index += 1) {
      await pipeChunkToWriteStream(join("tmp/uploads", session.uploadId, String(index)), writeStream);
    }

    writeStream.end();
    await once(writeStream, "finish");

    session.status = "completed";
    const fileUrl = `/files/${session.fileHash}-${session.fileName}`;
    completedFiles.set(session.fileHash, fileUrl);

    await rm(join("tmp/uploads", session.uploadId), { recursive: true, force: true });

    res.json({
      fileUrl,
      fileHash: session.fileHash,
      size: session.fileSize,
    });
  } catch (error) {
    next(error);
  }
});

11.9 Express 生产注意事项 ​

  • Map 要换成 Redis / MySQL / PostgreSQL。
  • 多实例部署不能只写本地磁盘,除非有共享存储。
  • complete 要加锁,避免两个请求同时合并。
  • chunk 文件名只能用数字 index,不能信任客户端传路径。
  • 定时清理过期 upload session。

12. NestJS 服务端实现 ​

12.1 Controller ​

ts
import {
  Body,
  Controller,
  Get,
  Param,
  Post,
  UploadedFile,
  UseInterceptors,
} from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import { UploadService } from "./upload.service";

@Controller("uploads")
export class UploadController {
  constructor(private readonly uploadService: UploadService) {}

  @Post("init")
  init(@Body() body: InitUploadDto) {
    return this.uploadService.init(body);
  }

  @Post(":uploadId/chunks/:index")
  @UseInterceptors(FileInterceptor("chunk", { dest: "tmp/chunks" }))
  uploadChunk(
    @Param("uploadId") uploadId: string,
    @Param("index") index: string,
    @UploadedFile() file: Express.Multer.File
  ) {
    return this.uploadService.saveChunk(uploadId, Number(index), file);
  }

  @Get(":uploadId/status")
  status(@Param("uploadId") uploadId: string) {
    return this.uploadService.status(uploadId);
  }

  @Post(":uploadId/complete")
  complete(@Param("uploadId") uploadId: string) {
    return this.uploadService.complete(uploadId);
  }
}

12.2 DTO ​

ts
import { IsInt, IsNumber, IsString, Min } from "class-validator";

export class InitUploadDto {
  @IsString()
  fileName!: string;

  @IsNumber()
  @Min(1)
  fileSize!: number;

  @IsString()
  fileHash!: string;

  @IsInt()
  @Min(1)
  chunkSize!: number;

  @IsInt()
  @Min(1)
  totalChunks!: number;
}

12.3 Service 设计 ​

ts
import { Injectable, NotFoundException } from "@nestjs/common";

@Injectable()
export class UploadService {
  async init(dto: InitUploadDto) {
    // 1. 查 fileHash 是否已存在,实现秒传
    // 2. 查 upload session 是否存在,实现断点续传
    // 3. 不存在则创建 session
    return {
      uploadId: dto.fileHash,
      shouldUpload: true,
      uploadedChunks: [],
    };
  }

  async saveChunk(uploadId: string, index: number, file: Express.Multer.File) {
    // 1. 校验 session
    // 2. 移动临时 chunk 到 tmp/uploads/{uploadId}/{index}
    // 3. DB/Redis 记录 index 已上传
    return { ok: true };
  }

  async status(uploadId: string) {
    // 返回 uploadedChunks
    throw new NotFoundException(uploadId);
  }

  async complete(uploadId: string) {
    // 1. 校验所有 chunk 都存在
    // 2. 加锁
    // 3. 顺序合并
    // 4. 校验完整 hash
    // 5. 写入文件表
    // 6. 清理临时文件
    return { fileUrl: "" };
  }
}

12.4 NestJS 生产增强 ​

建议加入:

  • ValidationPipe。
  • 鉴权 Guard。
  • 上传配额。
  • Redis 记录会话。
  • 定时任务清理临时文件。
  • Swagger 文档。
  • Interceptor 记录上传耗时。
  • Exception Filter 统一错误。

13. 对象存储 Multipart Upload 方案 ​

13.1 为什么生产更推荐对象存储 ​

如果文件很大、用户很多,让文件先经过应用服务器会带来巨大带宽和磁盘压力。

更好的方式是:

text
浏览器/小程序 -> 对象存储
业务服务器 -> 只负责签名、鉴权、记录元数据、完成确认

13.2 S3 Multipart 关键限制 ​

Amazon S3 Multipart Upload 常见规则:

  • 每个 part 通常至少 5 MiB,最后一个 part 例外。
  • 单个 multipart upload 最多 10,000 个 part。
  • 上传后 complete 时需要提交每个 part 的 PartNumber 和 ETag。
  • 如果取消上传,要 abort multipart upload 清理未完成 part。

不同云厂商 OSS/COS/OBS API 名称不同,但思想类似。

13.2.1 内部机制:Multipart ETag 不是全文 MD5 ​

一个必须知道的事实:对象存储返回的 ETag 不能当作“整文件 MD5”来校验。规则:

text
单次 PUT 上传:  ETag = MD5(整个对象)                    (未加密时)
Multipart 上传: ETag = MD5( part1的MD5原始字节
                         ‖ part2的MD5原始字节
                         ‖ ... ‖ partN的MD5原始字节 ) + "-N"
例如: ceb8853ddc5086cc4ab9e149f8f09c88-5  → 5 个 part

推论:

  • 校验直传完整性要在前端按同样的分片大小重算聚合 MD5(对每片 MD5 的二进制摘要拼接再取 MD5,不是拼接十六进制字符串),得到 <hex>-<N> 与服务端比对;KMS/SSE 加密对象的 ETag 甚至不是任何 MD5,不能用于内容校验。
  • 更稳的做法是不依赖 ETag:init 时约定业务 fileHash,服务端合并/complete 后自算全文 hash 落库(与 3.5 节第四层复核同一思想)。
  • 未完成的 multipart 会持续计费已传 part 的存储,所以生命周期规则里必须配 AbortIncompleteMultipartUpload(按天清理),这是账单层面的“清理策略”,与 15.5 节的临时文件清理对应。
  • part 上限 10,000 决定了 chunkSize 下限:chunkSize ≥ fileSize / 10000(单对象最大 5TB)。1GB 文件按 5MB 分片是 200 片,余量充足;10GB 文件就不能再用 1MB 分片。

13.3 接口设计 ​

创建:

http
POST /uploads/multipart/init

响应:

json
{
  "uploadId": "s3-upload-id",
  "objectKey": "videos/xxx.mp4",
  "parts": [
    {
      "partNumber": 1,
      "url": "https://signed-url"
    }
  ]
}

前端直传:

ts
const response = await fetch(part.url, {
  method: "PUT",
  body: chunk,
});

const etag = response.headers.get("ETag");

完成:

http
POST /uploads/multipart/complete

请求:

json
{
  "objectKey": "videos/xxx.mp4",
  "uploadId": "s3-upload-id",
  "parts": [
    { "partNumber": 1, "etag": "\"abc\"" },
    { "partNumber": 2, "etag": "\"def\"" }
  ]
}

13.4 暂停和续传 ​

暂停:

  • abort 当前 PUT 请求。
  • 保存已完成 part 的 ETag。
  • 不调用 AbortMultipartUpload,除非用户取消。

续传:

  • 查询后端或对象存储已上传 part。
  • 获取缺失 part 的新签名 URL。
  • 继续上传缺失 part。

13.5 什么时候不使用对象存储直传 ​

不适合:

  • 文件必须先经过应用服务器实时处理。
  • 安全策略不允许客户端直连对象存储。
  • 文件很小。
  • 云存储接入成本高于收益。

14. 暂停、继续、断点重传的真实处理逻辑 ​

14.1 暂停按钮做什么 ​

前端:

  1. 状态改为 paused。
  2. 终止当前请求。
  3. 停止调度新 chunk。
  4. 保留 uploadId、fileHash、uploadedChunks。

服务端:

  1. 不删除已上传 chunk。
  2. session 保持 uploading。
  3. 等待后续继续或过期清理。

14.2 继续按钮做什么 ​

前端:

  1. 如果 File 对象还在内存,直接继续。
  2. 如果页面刷新过,要求用户重新选择同一个文件。
  3. 重新计算 fileHash 或 fingerprint。
  4. 调用 init/status。
  5. 上传缺失 chunk。

服务端:

  1. 根据 fileHash 找到 session。
  2. 返回 uploadedChunks。
  3. complete 时校验 chunk 完整性。

14.3 浏览器刷新后的断点续传 ​

浏览器不能永久保存用户选择的 File 对象。

所以刷新后通常需要用户重新选择同一个文件。

可以持久化:

json
{
  "fileName": "video.mp4",
  "fileSize": 1048576000,
  "lastModified": 1710000000000,
  "fileHash": "xxx",
  "uploadId": "u_123"
}

存在 localStorage 或 IndexedDB。

用户重新选择文件后,对比:

  • name。
  • size。
  • lastModified。
  • hash 或 fingerprint。

一致才继续。

14.4 失败重试 ​

单个 chunk 失败不应导致整个上传失败。

建议:

  • 每个 chunk 最多重试 3 次。
  • 指数退避。
  • 网络断开时暂停。
  • 401 时刷新 token 后重试。
  • 4xx 参数错误不要重试。

示例:

ts
async function retry<T>(fn: () => Promise<T>, times = 3) {
  let lastError: unknown;

  for (let i = 0; i < times; i += 1) {
    try {
      return await fn();
    } catch (error) {
      lastError = error;
      await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** i));
    }
  }

  throw lastError;
}

14.5 进度计算 ​

按 chunk 数计算简单,但不精确:

ts
progress = uploadedChunks / totalChunks;

更准确是按字节:

ts
progress = uploadedBytes / file.size;

如果要显示当前 chunk 内部进度,使用 XMLHttpRequest upload.onprogress 比 fetch 更方便,因为 fetch 标准上传进度支持仍不如 XHR 普遍。

按字节累计的完整写法(把每个 chunk 的已传字节也计入):

ts
export function uploadChunkWithProgress(options: {
  uploadId: string;
  index: number;
  chunk: Blob;
  signal: AbortSignal;
  onBytes: (loadedInChunk: number) => void;
}) {
  return new Promise<void>((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    const fd = new FormData();
    fd.append("chunk", options.chunk);

    xhr.upload.addEventListener("progress", (e) => {
      if (e.lengthComputable) options.onBytes(e.loaded);
    });
    xhr.addEventListener("load", () =>
      xhr.status >= 200 && xhr.status < 300
        ? resolve()
        : reject(Object.assign(new Error(`chunk ${options.index} failed`), { status: xhr.status })));
    xhr.addEventListener("error", () => reject(new Error("network error")));
    xhr.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")));

    xhr.open("POST", `/uploads/${options.uploadId}/chunks/${options.index}`);
    xhr.send(fd);

    // fetch 的 signal 不能直接给 XHR 用,桥接一下
    options.signal.addEventListener("abort", () => xhr.abort(), { once: true });
  });
}

全局进度 = (已完成 chunk 的字节总数 + Σ各在传 chunk 的 loaded) / file.size。⚠️ upload.onprogress 的 loaded 是“已交给操作系统的字节”,受 TCP 发送缓冲影响,断网瞬间可能短暂停在非零值;重传后要按服务端 status 校正,不要只信本地计数。

14.6 注意事项:续传时必须重新校验 chunk 内容 ​

“服务端说 chunk 3 已存在”有一个隐藏前提:它存在且内容正确。断点续传恢复时如果只信 index 列表,可能复用一个写坏/被其他会话覆盖的分片。低成本防御:

text
init 响应扩展:
{
  "uploadedChunks": [
    { "index": 0, "size": 5242880, "hash": "md5-of-chunk-0" },
    { "index": 1, "size": 5242880, "hash": "md5-of-chunk-1" }
  ]
}

前端恢复时对本地重切出的同名分片算一次快速 hash(Worker 里按 5MB 算 MD5 只需几毫秒/片),与服务端返回的 per-chunk hash 比对;不一致的分片当作未上传重传。这一步把“断点续传后 complete 才发现全文 hash 不对、整个文件重传”的最坏情况,变成“只重传坏片”。


15. 安全、可靠性与生产注意事项 ​

15.1 安全 ​

必须做:

  • 鉴权。
  • 上传权限校验。
  • 文件大小限制。
  • chunk 大小限制。
  • 文件类型白名单。
  • 文件后缀和 MIME 双重检查。
  • 不信任客户端文件名。
  • 防路径穿越。
  • 病毒扫描或异步审核。
  • 私有文件访问鉴权。
  • 上传频率限制。

15.2 完整性校验 ​

建议:

  • chunk hash 可选,用于单片校验。
  • 完整 fileHash 必须在 complete 后校验。
  • fileSize 必须一致。
  • totalChunks 必须一致。

15.3 幂等 ​

chunk 上传要幂等:

  • 同一个 uploadId + index 重复上传,应覆盖或直接返回成功。
  • complete 重复调用,应返回同一个 fileUrl。
  • init 重复调用,应返回已有 session。

15.4 多实例部署 ​

不能把 chunk 只写在某台机器本地磁盘上,除非有粘性会话和清晰回滚策略。

生产推荐:

  • 共享存储。
  • 对象存储。
  • Redis 记录 session。
  • 数据库记录文件元数据。

15.5 清理策略 ​

必须清理:

  • 过期 upload session。
  • 已取消上传。
  • complete 后临时 chunk。
  • 对象存储未完成 multipart。

建议定时任务:

text
每小时扫描超过 24 小时未完成上传
删除临时 chunk
标记 session expired

15.6 Nginx 和网关配置 ​

如果 chunk 是 5MB,网关 body limit 要大于 chunk size。

示例:

nginx
client_max_body_size 10m;
proxy_read_timeout 300s;
proxy_send_timeout 300s;

如果使用对象存储直传,业务网关压力会小很多。


16. 测试清单 ​

16.1 基础功能 ​

  • 小文件上传成功。
  • 大文件分片上传成功。
  • complete 后文件可访问。
  • 文件大小一致。
  • 文件 hash 一致。

16.2 暂停继续 ​

  • 上传中点击暂停。
  • 暂停后没有继续上传新 chunk。
  • 点击继续后只上传缺失 chunk。
  • 多次暂停继续。
  • 暂停后刷新页面,重新选择同文件继续。

16.3 失败重试 ​

  • 单个 chunk 失败自动重试。
  • 网络断开后暂停。
  • token 过期后刷新再继续。
  • 服务端 400 不重试。

16.4 并发和边界 ​

  • 文件小于 chunkSize。
  • 文件刚好等于 chunkSize。
  • 最后一个 chunk 小于 chunkSize。
  • 并发 1、3、5 都正常。
  • 同一文件重复上传秒传。
  • 两个用户上传同名不同文件。
  • 两个用户上传同 hash 文件权限隔离正确。

16.5 安全 ​

  • 超大 chunk 被拒绝。
  • 非法文件类型被拒绝。
  • 路径穿越文件名被处理。
  • 未登录不能上传。
  • 无权限不能访问私有文件。

17. 官方参考资料 ​

基于 VitePress 构建的个人学习文档知识库