前端直连oss分片上传文件,断点续传

坑点:1、跨域问题:去 OSS 控制台 → 你的 bucket → 权限管理 → 跨域设置

2、权限问题403:后端凭证要给对

复制代码
   /**
     * 处理文件上传
     */
    async handleUploadForm(param) {
      this.multiUploadLoading = true;
      const file = param.file;
      const data = {
        model: this.modelName ? this.modelName : 'product',
        pid: this.tableData.pid ? this.tableData.pid : 0,
      };

      try {
        // 定义需要分片上传的文件类型
        const needsMultipartUpload = (file) => {
          // 视频文件
          if (file.type.startsWith('video/')) {
            return true;
          }
          // 文档文件 (doc, docx)
          const fileName = file.name.toLowerCase();
          return fileName.endsWith('.doc') || fileName.endsWith('.docx');
        };

        // 对于视频文件,使用阿里云OSS直传
        if (needsMultipartUpload(file)) {
          let bucketType = 0;
          if (file.type.startsWith('video/')) {
            bucketType = 1;
          }

          // 显示上传进度条
          this.progressText = '准备上传...';
          // 不要直接设置为0,保持之前的状态
          // this.multipartUploadProgress = 0;
          this.showProgressModal = true;

          // 获取STS凭证
          const creds = await credentials();

          // 创建上传器实例
          const uploader = new AliyunOSSUploader();

          // 直接从本地存储获取检查点,不依赖上传器实例
          const checkpointKey = `aliyun_oss_upload_checkpoint_${file.name}_${file.size}`;
          let checkpoint = null;

          try {
            const checkpointStr = localStorage.getItem(checkpointKey);
            if (checkpointStr) {
              checkpoint = JSON.parse(checkpointStr);
              // 检查是否过期(24小时)
              if (Date.now() - checkpoint.timestamp > 24 * 60 * 60 * 1000) {
                localStorage.removeItem(checkpointKey);
                checkpoint = null;
              } else {
                console.log('找到检查点:', checkpoint);
              }
            }
          } catch (error) {
            console.error('解析检查点失败:', error);
            checkpoint = null;
          }

          if (checkpoint) {
            // 计算初始进度
            const totalSize = file.size;
            const doneParts = checkpoint.doneParts || [];
            let uploadedSize = 0;

            if (doneParts.length > 0) {
              doneParts.forEach((part) => {
                if (part.size) {
                  uploadedSize += part.size;
                } else {
                  uploadedSize += checkpoint.partSize || 5 * 1024 * 1024;
                }
              });
            }

            const initialProgress = totalSize > 0 ? Math.round((uploadedSize / totalSize) * 100) : 0;
            this.multipartUploadProgress = initialProgress;
            this.progressText = `准备续传... ${initialProgress}%`;
          } else {
            // 新上传,设置为0
            this.multipartUploadProgress = 0;
          }

          // 使用阿里云OSS直传(直接调用阿里云OSS API,不经过后端)
          const result = await uploader.multipartUpload(file, creds, bucketType, (progress, key) => {
            this.multipartUploadProgress = progress;
            this.progressText = `上传中... ${progress}%`;
          });

          // 上传完成
          this.progressText = '上传成功';
          this.multipartUploadProgress = 100;
          this.showProgressModal = false;
          // 存储到接口
          try {
            await this.saveFileUrl(result, file, bucketType);
            this.$message.success('上传成功');
            this.multipartUploadProgress = 0;
          } catch (saveError) {
            console.error('存储文件链接失败:', saveError);
            this.$message.error('上传成功,但存储链接失败');
          }

          this.multiUploadLoading = false;

          // 刷新文件列表
          this.getFileList();
        } else {
          // 对于其他文件类型,使用原有上传方式
          const formData = new FormData();
          formData.append('multipart', file);
          this.uploadPic(formData, data);
        }
      } catch (error) {
        console.error('上传失败:', error);
        this.$message.error(error.message || '上传失败');
        this.showProgressModal = false;
        this.multipartUploadProgress = 0;
        this.multiUploadLoading = false;
      }
    },

aliyunOSS.js

复制代码
import OSS from 'ali-oss';

const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB per chunk
const MAX_PARALLEL_UPLOADS = 3; // 并发上传数
const CHECKPOINT_KEY = 'aliyun_oss_upload_checkpoint';
const CHECKPOINT_EXPIRE_TIME = 24 * 60 * 60 * 1000; // 24小时过期

class AliyunOSSUploader {
  constructor() {
    this.credentials = null;
    this.file = null;
    this.fileName = null;
    this.bucketType = null;
    this.bucket = null;
    this.endpoint = null;
    this.key = null;
    this.client = null;
  }

  async init(credentials) {
    this.credentials = credentials;
    const { accessKeyId, accessKeySecret, securityToken, endpoint, bucketName, bucketNamePublic } = credentials;

    // 清理endpoint,提取region
    // endpoint格式: https://oss-cn-hangzhou.aliyuncs.com
    // 需要提取为: oss-cn-hangzhou
    const cleanEndpoint = endpoint.replace(/^https?:\/\//, '');
    this.endpoint = cleanEndpoint;
    const region = cleanEndpoint.split('.')[0]; // oss-cn-hangzhou

    this.bucket = this.bucketType === 0 ? bucketNamePublic : bucketName;
    // 使用STS凭证创建OSS客户端
    this.client = new OSS({
      region: region,
      accessKeyId: accessKeyId,
      accessKeySecret: accessKeySecret,
      stsToken: securityToken,
      bucket: this.bucket,
      endpoint: `https://${cleanEndpoint}`,
    });

    return true;
  }

  getCheckpointKey() {
    // 使用文件指纹作为检查点 key,确保同一个文件的检查点可以被复用
    if (!this.fileName) {
      throw new Error('文件名未设置,无法生成检查点键');
    }
    if (!this.file || !this.file.size) {
      throw new Error('文件大小未设置,无法生成检查点键');
    }
    const key = `${CHECKPOINT_KEY}_${this.fileName}_${this.file.size}`;
    console.debug('检查点键:', key);
    return key;
  }

  saveCheckpoint(key, uploadId, parts) {
    const checkpoint = {
      uploadId: uploadId,
      key: key,
      partSize: CHUNK_SIZE,
      doneParts: parts || [],
      fileName: this.fileName,
      fileSize: this.file.size,
      timestamp: Date.now(),
    };
    try {
      localStorage.setItem(this.getCheckpointKey(), JSON.stringify(checkpoint));
      console.debug('检查点保存:', checkpoint);
    } catch (error) {
      console.error('保存检查点失败:', error);
    }
  }

  getCheckpoint() {
    try {
      const checkpointStr = localStorage.getItem(this.getCheckpointKey());
      if (!checkpointStr) return null;

      const checkpoint = JSON.parse(checkpointStr);
      // 检查是否过期(24小时)
      if (Date.now() - checkpoint.timestamp > CHECKPOINT_EXPIRE_TIME) {
        localStorage.removeItem(this.getCheckpointKey());
        return null;
      }
      console.debug('获取到检查点:', checkpoint);
      return checkpoint;
    } catch (error) {
      console.error('获取检查点失败:', error);
      localStorage.removeItem(this.getCheckpointKey());
      return null;
    }
  }

  clearCheckpoint() {
    try {
      console.debug('清除检查点:', this.getCheckpointKey());
      localStorage.removeItem(this.getCheckpointKey());
    } catch (error) {
      console.error('清除检查点失败:', error);
    }
  }

  async multipartUpload(file, credentials, bucketType = 0, onProgress = () => { }) {
    this.file = file;
    this.fileName = file.name;
    this.bucketType = bucketType;

    // 初始化
    await this.init(credentials);

    // 生成唯一文件名
    let userInfo;
    try {
      const userInfoStr = localStorage.getItem('userInfo');
      if (!userInfoStr) {
        throw new Error('用户信息不存在');
      }
      userInfo = JSON.parse(userInfoStr);
      if (!userInfo || typeof userInfo !== 'object') {
        throw new Error('用户信息格式错误');
      }
    } catch (error) {
      console.error('解析用户信息失败:', error);
      throw new Error('无法获取用户信息,请重新登录');
    }

    const key = `${userInfo.userType || 2}-${userInfo.userId || 2}/${file.name}`;

    this.key = key;

    // 检查是否有断点记录
    const checkpoint = this.getCheckpoint();

    try {
      let result;

      if (checkpoint && checkpoint.key === key) {
        console.debug('检测到有效断点,准备续传:', {
          checkpoint: checkpoint,
          file: this.file.name,
          size: this.file.size,
          totalChunks: Math.ceil(this.file.size / CHUNK_SIZE)
        });

        // 计算初始进度(基于已完成的部分)并传递给回调
        const totalSize = this.file.size;
        const doneParts = checkpoint.doneParts || [];
        let uploadedSize = 0;

        if (doneParts.length > 0) {
          // 根据已完成的分片计算已上传大小
          doneParts.forEach(part => {
            // 如果part对象包含size信息则使用,否则按partSize计算
            if (part.size) {
              uploadedSize += part.size;
            } else {
              uploadedSize += checkpoint.partSize || CHUNK_SIZE;
            }
          });
        }

        const initialProgress = totalSize > 0 ? Math.round((uploadedSize / totalSize) * 100) : 0;
        console.debug('断点续传初始进度计算:', {
          uploadedSize: uploadedSize,
          totalSize: totalSize,
          initialProgress: initialProgress,
          doneParts: doneParts,
          partSize: checkpoint.partSize || CHUNK_SIZE
        });

        // 触发初始进度回调
        onProgress(initialProgress, key);

        // 断点续传
        result = await this.resumeUpload(key, onProgress);
      } else {
        console.debug('无有效断点,开始新上传:', {
          file: this.file.name,
          size: this.file.size,
          totalChunks: Math.ceil(this.file.size / CHUNK_SIZE)
        });
        // 新上传
        result = await this.newUpload(key, onProgress);
      }

      // 清除断点记录
      this.clearCheckpoint();

      return {
        key: result.name,
        url: `https://${this.bucket}.${this.endpoint}/${result.name}`,
        etag: result.etag,
      };
    } catch (error) {
      throw error;
    }
  }

  async newUpload(key, onProgress) {
    const checkpoint = this.getCheckpoint();

    const options = {
      partSize: CHUNK_SIZE,
      parallel: MAX_PARALLEL_UPLOADS,
      progress: (p, cpt) => {
        if (cpt) {
          this.saveCheckpoint(key, cpt.uploadId, cpt.doneParts || []);
        }
        const progress = Math.round(p * 100);
        onProgress(progress, key);
      },
    };

    if (checkpoint && checkpoint.key === key) {
      options.checkpoint = checkpoint;
    }

    const result = await this.client.multipartUpload(key, this.file, options);

    return result;
  }

  async resumeUpload(key, onProgress) {
    console.debug('开始断点续传:', {
      key: key,
      fileName: this.fileName,
      fileSize: this.file.size
    });

    // 参数校验
    if (!key) {
      throw new Error('Key参数不能为空');
    }
    if (typeof onProgress !== 'function') {
      throw new Error('onProgress必须是一个函数');
    }

    // 确保客户端已初始化
    if (!this.client) {
      throw new Error('OSS客户端未初始化');
    }

    // 确保文件存在
    if (!this.file) {
      throw new Error('文件未设置');
    }

    const checkpoint = this.getCheckpoint();

    // 检查断点是否存在
    if (!checkpoint) {
      throw new Error('没有找到有效的上传断点');
    }

    // 确保checkpoint中的必要字段存在
    if (!checkpoint.uploadId) {
      throw new Error('断点数据不完整:缺少uploadId');
    }

    // 计算初始进度(基于已完成的部分)
    const totalSize = this.file.size;
    const doneParts = checkpoint.doneParts || [];
    let uploadedSize = 0;

    if (doneParts.length > 0) {
      // 根据已完成的分片计算已上传大小
      doneParts.forEach(part => {
        // 如果part对象包含size信息则使用,否则按partSize计算
        if (part.size) {
          uploadedSize += part.size;
        } else {
          uploadedSize += checkpoint.partSize || CHUNK_SIZE;
        }
      });
    }

    const initialProgress = totalSize > 0 ? (uploadedSize / totalSize) : 0;
    console.debug('断点续传初始进度详细信息:', {
      totalSize: totalSize,
      uploadedSize: uploadedSize,
      donePartsCount: doneParts.length,
      doneParts: doneParts,
      initialProgress: initialProgress,
      percentage: Math.round(initialProgress * 100)
    });

    // 构造options对象
    const options = {
      partSize: CHUNK_SIZE,
      parallel: MAX_PARALLEL_UPLOADS,
      progress: (p, cpt) => {
        if (cpt) {
          // 统一安全地访问checkpoint对象的属性
          const updatedDoneParts = cpt.doneParts || cpt.uploadedParts || cpt.parts || [];
          this.saveCheckpoint(key, cpt.uploadId, updatedDoneParts);

          console.debug('上传进度更新:', {
            rawProgress: p,
            donePartsCount: updatedDoneParts.length,
            totalChunks: Math.ceil(totalSize / CHUNK_SIZE),
            uploadedChunks: updatedDoneParts.map(part => part.number || part.partNumber)
          });
        }

        // 计算实际进度:初始进度 + 当前新增进度的比例
        const actualProgress = Math.min(100, Math.round((initialProgress + p * (1 - initialProgress)) * 100));
        onProgress(actualProgress, key);
      },
    };

    // 添加checkpoint配置
    options.checkpoint = {
      file: this.file,           // 确保文件引用存在
      name: key,                 // 确保文件名存在
      uploadId: checkpoint.uploadId,
      doneParts: checkpoint.doneParts || [],
      partSize: checkpoint.partSize || CHUNK_SIZE
    };

    console.debug('断点续传参数配置:', {
      checkpointUploadId: checkpoint.uploadId,
      checkpointDoneParts: checkpoint.doneParts,
      checkpointPartSize: checkpoint.partSize || CHUNK_SIZE,
      options: options
    });

    let result;
    try {
      result = await this.client.multipartUpload(key, this.file, options);
      console.debug('断点续传成功:', result);
    } catch (error) {
      console.error('断点续传失败:', error);
      // 如果是checkpoint相关错误,清除无效的checkpoint
      if (error.message && (error.message.includes('checkpoint') || error.message.includes('replace'))) {
        this.clearCheckpoint();
      }
      throw error;
    }

    return result;
  }

  async cancelUpload() {
    if (this.client && this.key) {
      try {
        await this.client.abortMultipartUpload(this.key);
        this.clearCheckpoint();
      } catch (error) {
        console.error('取消上传失败:', error);
      }
    }
  }
}

export default AliyunOSSUploader;
相关推荐
Southern Wind2 小时前
Vue 3 + Socket.io 实时聊天项目完整开发文档
前端·javascript·vue.js
甄心爱学习2 小时前
【项目实训(个人4)】
前端·vue.js·python
轮子大叔2 小时前
HTML入门
前端·html
skilllite作者2 小时前
SkillLite 技术演进笔记:Workspace、沙箱与进化
java·开发语言·前端·笔记·安全·agentskills
qq_419854052 小时前
clip-path绘制倾斜角裁剪的矩形占比条;基于svg实现仪表盘弧线占比图。
前端·javascript·vue.js
m0_738120722 小时前
渗透基础知识ctfshow——Web应用安全与防护(完结:第八章)
前端·python·sql·安全·web安全·网络安全
克里斯蒂亚诺更新2 小时前
uniapp适配H5和Android-apk实现获取当前位置经纬度并调用接口
android·前端·uni-app
宁&沉沦2 小时前
前端开发专用的 Cursor 四大模式「快捷切换 + 指令模板」,直接复制就能用,覆盖 90% 日常场景
前端·编辑器
Cloud Traveler2 小时前
用Calibre-Web把NAS上的电子书管起来:部署、配置与远程访问实战
前端