前端分块上传实现说明.md 8.9 KB

微信小程序/UniApp 分块上传实现说明

📋 概述

分块上传不是固定的"三次请求",而是 1 + N + 1 次请求:

  • 1次 初始化请求 - 获取 uploadId
  • N次 上传分块请求 - 每个分块一次请求(N = 文件大小 ÷ 分块大小)
  • 1次 完成请求 - 合并所有分块并保存到数据库

示例: 上传一个 10MB 的文件,分块大小为 2MB

  • 1次初始化
  • 5次上传分块(10MB ÷ 2MB = 5个分块)
  • 1次完成
  • 总共 7 次请求

🔄 完整流程

1. 用户选择文件
   ↓
2. 调用初始化接口 → 获取 uploadId 和 fileName
   ↓
3. 将文件分成 N 个分块
   ↓
4. 循环上传每个分块 → 收集 PartETag
   ├─ 分块1 → PartETag1
   ├─ 分块2 → PartETag2
   ├─ ...
   └─ 分块N → PartETagN
   ↓
5. 调用完成接口 → 传入所有 PartETag → 合并文件并保存
   ↓
6. 上传完成

📱 微信小程序特殊处理

微信小程序的 wx.uploadFile API 只能上传文件路径,不能直接上传 ArrayBuffer 或 Blob。因此需要:

  1. 读取文件 → 使用 wx.getFileSystemManager().readFile() 读取整个文件
  2. 切分文件 → 使用 ArrayBuffer.slice() 切分成多个分块
  3. 保存临时文件 → 将每个分块保存为临时文件(wx.env.USER_DATA_PATH
  4. 上传分块 → 使用 wx.uploadFile() 上传临时文件
  5. 清理临时文件 → 上传完成后删除临时文件

🚀 核心实现代码

1. 分块上传工具函数

创建文件:utils/multipartUpload.js

/**
 * 微信小程序/UniApp 分块上传实现
 */

const CHUNK_SIZE = 2 * 1024 * 1024; // 每个分块大小 2MB
const BASE_URL = 'https://your-api-domain.com'; // 替换为你的后端API地址

/**
 * 分块上传主函数
 */
async function multipartUpload(filePathOrFile, caseId, fileType, originalName, isDelete = {}, onProgress) {
  try {
    // 1. 获取文件信息
    let fileSize, filePath, fileObject;
    
    // #ifdef H5
    if (filePathOrFile instanceof File) {
      fileObject = filePathOrFile;
      fileSize = fileObject.size;
      filePath = null;
    } else {
      const fileInfo = await getFileInfo(filePathOrFile);
      fileSize = fileInfo.size;
      filePath = filePathOrFile;
    }
    // #endif
    
    // #ifdef MP-WEIXIN || APP-PLUS
    const fileInfo = await getFileInfo(filePathOrFile);
    fileSize = fileInfo.size;
    filePath = filePathOrFile;
    fileObject = null;
    // #endif
    
    const totalChunks = Math.ceil(fileSize / CHUNK_SIZE);
    
    // 2. 初始化分块上传
    const initResult = await initMultipartUpload(caseId, fileType, originalName);
    if (!initResult.success) {
      throw new Error('初始化分块上传失败: ' + initResult.message);
    }
    
    const { uploadId, fileName, randomUUID, ext } = initResult.data;
    
    // 3. 上传所有分块
    const partETags = [];
    let uploadedSize = 0;
    const tempFiles = [];
    
    for (let partNumber = 1; partNumber <= totalChunks; partNumber++) {
      const start = (partNumber - 1) * CHUNK_SIZE;
      const end = Math.min(start + CHUNK_SIZE, fileSize);
      const chunkSize = end - start;
      
      // 读取分块数据
      let chunkData;
      // #ifdef H5
      if (fileObject) {
        chunkData = await readFileChunkFromFile(fileObject, start, end);
      } else {
        chunkData = await readFileChunkToTempFile(filePath, start, end, partNumber);
      }
      // #endif
      // #ifdef MP-WEIXIN || APP-PLUS
      chunkData = await readFileChunkToTempFile(filePath, start, end, partNumber);
      // #endif
      
      if (chunkData.tempFilePath) {
        tempFiles.push(chunkData.tempFilePath);
      }
      
      // 上传分块(支持重试)
      const partETag = await uploadPartWithRetry(
        fileName, uploadId, partNumber, chunkData, chunkSize, 3
      );
      
      partETags.push({
        partNumber: partETag.partNumber,
        eTag: partETag.eTag
      });
      
      // 清理临时文件
      if (chunkData.tempFilePath) {
        try {
          // #ifdef MP-WEIXIN
          wx.getFileSystemManager().unlinkSync(chunkData.tempFilePath);
          // #endif
          // #ifdef APP-PLUS
          uni.getFileSystemManager().unlinkSync(chunkData.tempFilePath);
          // #endif
        } catch (e) {
          console.warn('清理临时文件失败:', e);
        }
      }
      
      // 更新进度
      uploadedSize += chunkSize;
      const progress = Math.round((uploadedSize / fileSize) * 100);
      if (onProgress) {
        onProgress({
          progress: progress,
          uploaded: uploadedSize,
          total: fileSize,
          currentChunk: partNumber,
          totalChunks: totalChunks
        });
      }
    }
    
    // 4. 完成分块上传
    const completeResult = await completeMultipartUpload(
      caseId, fileType, fileName, uploadId, randomUUID, ext,
      originalName, fileSize, isDelete, partETags
    );
    
    if (!completeResult.success) {
      await abortMultipartUpload(fileName, uploadId);
      throw new Error('完成分块上传失败: ' + completeResult.message);
    }
    
    return { success: true, data: completeResult.data };
    
  } catch (error) {
    console.error('分块上传失败:', error);
    return { success: false, message: error.message || '上传失败' };
  }
}

// 辅助函数实现...
// (完整代码请参考项目中的实现)

export default { multipartUpload, CHUNK_SIZE };

2. 页面使用示例

<template>
  <view class="upload-container">
    <button @click="chooseFile">选择文件</button>
    <button @click="startUpload" :disabled="uploading">开始上传</button>
    
    <view v-if="uploading" class="progress">
      <view class="progress-bar">
        <view class="progress-fill" :style="{ width: progress + '%' }"></view>
      </view>
      <text>{{ progress }}%</text>
    </view>
  </view>
</template>

<script>
import multipartUpload from '@/utils/multipartUpload.js';

export default {
  data() {
    return {
      selectedFile: null,
      uploading: false,
      progress: 0
    };
  },
  methods: {
    chooseFile() {
      // #ifdef MP-WEIXIN
      wx.chooseMedia({
        count: 1,
        success: (res) => {
          this.selectedFile = {
            path: res.tempFiles[0].tempFilePath,
            name: res.tempFiles[0].name,
            size: res.tempFiles[0].size
          };
        }
      });
      // #endif
      
      // #ifdef H5
      const input = document.createElement('input');
      input.type = 'file';
      input.onchange = (e) => {
        const file = e.target.files[0];
        if (file) {
          this.selectedFile = {
            path: file,
            name: file.name,
            size: file.size,
            file: file
          };
        }
      };
      input.click();
      // #endif
    },
    
    async startUpload() {
      if (!this.selectedFile) return;
      
      this.uploading = true;
      this.progress = 0;
      
      const fileParam = this.selectedFile.file || this.selectedFile.path;
      const result = await multipartUpload.multipartUpload(
        fileParam,
        123, // caseId
        'document', // fileType
        this.selectedFile.name,
        {}, // isDelete
        (progressInfo) => {
          this.progress = progressInfo.progress;
        }
      );
      
      this.uploading = false;
      
      if (result.success) {
        uni.showToast({ title: '上传成功', icon: 'success' });
      } else {
        uni.showToast({ title: result.message, icon: 'none' });
      }
    }
  }
};
</script>

🔧 后端接口说明

1. 初始化分块上传

接口: POST /wechat/file/multipart/init/{caseId}/{type}

参数: originalName (String)

返回:

{
  "code": 200,
  "data": {
    "uploadId": "xxx",
    "fileName": "123/document/uuid.ext",
    "randomUUID": "uuid",
    "ext": "jpg"
  }
}

2. 上传分块

接口: POST /wechat/file/multipart/upload

参数(FormData):

  • fileName (String)
  • uploadId (String)
  • partNumber (Integer)
  • chunk (File)

返回:

{
  "code": 200,
  "data": {
    "partNumber": 1,
    "eTag": "xxx"
  }
}

3. 完成分块上传

接口: POST /wechat/file/multipart/complete/{caseId}/{type}

请求体(JSON):

{
  "fileName": "123/document/uuid.ext",
  "uploadId": "xxx",
  "randomUUID": "uuid",
  "ext": "jpg",
  "originalName": "原始文件名.jpg",
  "fileSize": 1024000,
  "isDelete": {},
  "partETagsList": [
    {"partNumber": 1, "eTag": "xxx"},
    {"partNumber": 2, "eTag": "yyy"}
  ]
}

⚙️ 配置参数

  • 分块大小: 默认 2MB,可根据需要调整
  • 重试次数: 默认每个分块最多重试 3 次

⚠️ 注意事项

  1. 微信小程序需要将分块保存为临时文件
  2. 上传完成后记得清理临时文件
  3. 建议添加错误处理和重试机制
  4. 大文件建议使用更大的分块大小