【OSS【进阶篇】--手把手教你实现 OSS 大文件分片上传】


在实际开发中,大文件(如视频、压缩包等)直接上传容易出现超时、内存溢出等问题,分片上传是解决这一问题的主流方案。本文将以阿里云 OSS 为例,详解大文件分片上传的实现思路,并提供完整的前端(JavaScript)和后端(Java)代码示例。

一、分片上传核心原理

分片上传是将大文件切割成多个小分片(Part),分别上传到对象存储服务(如 OSS),最后再通知服务端将所有分片合并成完整文件的过程。核心流程如下:

初始化分片上传: 向 OSS 申请一个唯一标识(uploadId),用于关联后续所有分片。

切割文件并上传分片: 将本地文件按固定大小(如 5MB)切割成多个分片,并发上传,每个分片需携带uploadId和分片编号(partNumber)。

合并分片: 所有分片上传完成后,通知 OSS 将指定uploadId下的所有分片按编号合并成完整文件。

优势:

支持断点续传(可查询已上传分片,避免重复上传)。

降低单次请求压力,减少超时风险。

支持并发上传,提高上传效率。

二、核心概念说明

uploadId: 分片上传的唯一标识,初始化时由 OSS 返回,贯穿整个分片上传流程。

objectKey: OSS 中文件的唯一标识(类似文件路径 + 文件名,如uploads/202407/file.zip),用于定位最终合并后的文件。

partNumber: 分片编号(从 1 开始),用于确保分片合并时的顺序。

ETag: 每个分片上传成功后,OSS 返回的唯一标识,合并时需提交所有分片的ETag列表。

官方参考文档:https://help.aliyun.com/product/31815.html

三、完整实现代码

(一)前端实现(JavaScript)

前端负责文件切割、分片上传、进度展示等逻辑,需配合后端接口完成交互。

// OSS智能上传JavaScript逻辑
class OSSUploader {
  constructor() {
    this.selectedFile = null;
    this.uploadId = null;
    this.objectKey = null;
    this.totalParts = 0;
    this.uploadedParts = 0;
    this.partSize = 5 * 1024 * 1024; // 5MB per part
    this.isUploading = false;
    this.uploadStartTime = null;
    this.uploadedBytes = 0;
    this.cancelUpload = false;
    this.isPartUpload = false;

    this.apiBase = 'http://localhost:8080/api/upload';
    this.endpoints = {
      preCheck: `${this.apiBase}/preCheckUpload`,
      smart: `${this.apiBase}/ossMultipartUpload`,
      uploadPart: `${this.apiBase}/uploadPart`,
      complete: `${this.apiBase}/completeMultipartUpload`,
      abort: `${this.apiBase}/abortMultipartUpload`,
    };

    this.fileSizeThreshold = 100 * 1024 * 1024;

    this.init();
  }

  init() {
    this.bindEvents();
  }

  bindEvents() {
    document.getElementById('fileInput').addEventListener('change', (e) => {
      this.handleFileSelect(e.target.files[0]);
    });

    const dropArea = document.getElementById('dropArea');
    dropArea.addEventListener('dragover', (e) => {
      e.preventDefault();
      dropArea.classList.add('dragover');
    });

    dropArea.addEventListener('dragleave', () => {
      dropArea.classList.remove('dragover');
    });

    dropArea.addEventListener('drop', (e) => {
      e.preventDefault();
      dropArea.classList.remove('dragover');
      this.handleFileSelect(e.dataTransfer.files[0]);
    });

    document.getElementById('uploadBtn').addEventListener('click', () => {
      this.startUpload();
    });

    document.getElementById('cancelBtn').addEventListener('click', () => {
      this.cancelCurrentUpload();
    });
  }

  handleFileSelect(file) {
    if (!file) return;

    this.selectedFile = file;
    this.resetUploadState();
    this.updateFileInfo();
    this.showUploadControls();
  }

  updateFileInfo() {
    if (!this.selectedFile) return;

    const fileInfo = document.getElementById('fileInfo');
    const fileName = document.getElementById('fileName');
    const fileSize = document.getElementById('fileSize');

    fileName.textContent = this.selectedFile.name;
    fileSize.textContent = this.formatFileSize(this.selectedFile.size);

    fileInfo.style.display = 'block';
  }

  showUploadControls() {
    document.getElementById('uploadControls').style.display = 'block';
    document.getElementById('uploadBtn').disabled = false;
  }

  resetUploadState() {
    this.uploadId = null;
    this.objectKey = null;
    this.totalParts = 0;
    this.uploadedParts = 0;
    this.uploadedBytes = 0;
    this.isUploading = false;
    this.cancelUpload = false;
    this.isPartUpload = false;

    document.getElementById('progressContainer').style.display = 'none';
    document.getElementById('uploadResult').style.display = 'none';
    document.getElementById('uploadStatus').textContent = '待上传';
  }

  async startUpload() {
    if (!this.selectedFile || this.isUploading) return;

    this.isUploading = true;
    this.cancelUpload = false;
    this.uploadStartTime = Date.now();

    document.getElementById('uploadBtn').disabled = true;
    document.getElementById('cancelBtn').disabled = false;
    document.getElementById('uploadStatus').textContent = '上传中...';

    try {
      const result = await this.smartUpload();

      if (!this.cancelUpload) {
        this.showUploadSuccess(result);
      }
    } catch (error) {
      if (!this.cancelUpload) {
        this.showUploadError(error);
      }
    } finally {
      this.isUploading = false;
      document.getElementById('uploadBtn').disabled = false;
      document.getElementById('cancelBtn').disabled = true;
    }
  }

  async smartUpload() {
    const prefix = 'smart-upload';

    if (this.selectedFile.size > this.fileSizeThreshold) {
      return await this.preCheckAndUpload(prefix);
    } else {
      return await this.directUpload(prefix);
    }
  }

  async preCheckAndUpload(prefix) {
    const checkData = new FormData();
    checkData.append('fileName', this.selectedFile.name);
    checkData.append('fileSize', this.selectedFile.size);
    checkData.append('subBucketPrefix', prefix);

    const checkResponse = await fetch(this.endpoints.preCheck, {
      method: 'POST',
      body: checkData,
    });

    const checkResult = await checkResponse.json();

    if (checkResult.code !== 200) {
      throw new Error(checkResult.message || '预检查失败');
    }

    if (checkResult.data.isPartUpload) {
      this.isPartUpload = true;
      this.uploadId = checkResult.data.uploadId;
      this.objectKey = checkResult.data.objectKey;
      this.totalParts = checkResult.data.totalParts;
      document.getElementById('progressContainer').style.display = 'block';
      document.getElementById(
        'partInfo'
      ).textContent = `0/${this.totalParts} 分片`;

      return await this.performMultipartUpload();
    } else {
      this.isPartUpload = false;
      return await this.directUpload(prefix);
    }
  }

  async directUpload(prefix) {
    const formData = new FormData();
    formData.append('file', this.selectedFile);
    formData.append('subBucketPrefix', prefix);

    const response = await fetch(this.endpoints.smart, {
      method: 'POST',
      body: formData,
    });

    const result = await response.json();

    if (result.code !== 200) {
      throw new Error(result.message || '上传失败');
    }

    return result.data;
  }

  async performMultipartUpload() {
    const partETags = [];
    const fileSize = this.selectedFile.size;

    for (let partNumber = 1; partNumber <= this.totalParts; partNumber++) {
      if (this.cancelUpload) {
        await this.abortMultipartUpload();
        throw new Error('上传已取消');
      }

      const start = (partNumber - 1) * this.partSize;
      const end = Math.min(start + this.partSize, fileSize);
      const partData = this.selectedFile.slice(start, end);

      const etag = await this.uploadPart(partNumber, partData);
      partETags.push(etag);

      this.uploadedParts = partNumber;
      this.uploadedBytes = end;

      const progress = (this.uploadedBytes / fileSize) * 100;
      this.updateProgress(progress);
      this.updatePartInfo();
      this.updateSpeed();
    }

    return await this.completeMultipartUpload(partETags);
  }

  async uploadPart(partNumber, partData) {
    const formData = new FormData();
    formData.append('uploadId', this.uploadId);
    formData.append('objectKey', this.objectKey);
    formData.append('partNumber', partNumber);
    formData.append('partData', new Blob([partData]));

    const response = await fetch(this.endpoints.uploadPart, {
      method: 'POST',
      body: formData,
    });

    const result = await response.json();

    if (result.code !== 200) {
      throw new Error(`分片${partNumber}上传失败: ${result.message}`);
    }

    return result.data;
  }

  async completeMultipartUpload(partETags) {
    const requestData = {
      uploadId: this.uploadId,
      objectKey: this.objectKey,
      partsList: partETags,
    };

    const response = await fetch(this.endpoints.complete, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(requestData),
    });

    const result = await response.json();

    if (result.code !== 200) {
      throw new Error(result.message || '完成分片上传失败');
    }

    return result.data;
  }

  async abortMultipartUpload() {
    if (!this.uploadId || !this.objectKey) return;

    const formData = new FormData();
    formData.append('uploadId', this.uploadId);
    formData.append('objectKey', this.objectKey);

    try {
      await fetch(this.endpoints.abort, {
        method: 'POST',
        body: formData,
      });
    } catch (error) {
      console.error('取消上传失败:', error);
    }
  }

  async cancelCurrentUpload() {
    this.cancelUpload = true;

    if (this.isPartUpload && this.uploadId && this.objectKey) {
      await this.abortMultipartUpload();
    }

    document.getElementById('uploadStatus').textContent = '已取消';
  }

  updateProgress(percentage) {
    const progressBar = document.getElementById('progressBar');
    const progressText = document.getElementById('progressText');

    progressBar.style.width = `${percentage}%`;
    progressText.textContent = `${Math.round(percentage)}%`;
  }

  updatePartInfo() {
    document.getElementById(
      'partInfo'
    ).textContent = `${this.uploadedParts}/${this.totalParts} 分片`;
  }

  updateSpeed() {
    if (!this.uploadStartTime) return;

    const elapsed = (Date.now() - this.uploadStartTime) / 1000;
    const speed = this.uploadedBytes / elapsed;
    const remaining = (this.selectedFile.size - this.uploadedBytes) / speed;

    document.getElementById('uploadSpeed').textContent =
      this.formatSpeed(speed);
    document.getElementById('timeRemaining').textContent =
      this.formatTime(remaining);
  }

  showUploadSuccess(result) {
    const uploadResult = document.getElementById('uploadResult');
    const resultContent = document.getElementById('resultContent');

    uploadResult.className = 'upload-result result-success';
    uploadResult.style.display = 'block';

    resultContent.innerHTML = `
            <h6><i class="bi bi-check-circle-fill"></i> 上传成功!</h6>
            <p class="mb-1"><strong>文件URL:</strong> <a href="${
              result.fileUrl
            }" target="_blank">${result.fileUrl}</a></p>
            ${
              result.cdnUrl
                ? `<p class="mb-0"><strong>CDN URL:</strong> <a href="${result.cdnUrl}" target="_blank">${result.cdnUrl}</a></p>`
                : ''
            }
        `;

    document.getElementById('uploadStatus').innerHTML =
      '<span class="status-indicator status-success"></span>上传完成';
  }

  showUploadError(error) {
    const uploadResult = document.getElementById('uploadResult');
    const resultContent = document.getElementById('resultContent');

    uploadResult.className = 'upload-result result-error';
    uploadResult.style.display = 'block';

    resultContent.innerHTML = `
            <h6><i class="bi bi-exclamation-triangle-fill"></i> 上传失败</h6>
            <p class="mb-0">${error.message}</p>
        `;

    document.getElementById('uploadStatus').innerHTML =
      '<span class="status-indicator status-error"></span>上传失败';
  }

  formatFileSize(bytes) {
    if (bytes === 0) return '0 Bytes';
    const k = 1024;
    const sizes = ['Bytes', 'KB', 'MB', 'GB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
  }

  formatSpeed(bytesPerSecond) {
    return this.formatFileSize(bytesPerSecond) + '/s';
  }

  formatTime(seconds) {
    if (!isFinite(seconds) || seconds < 0) return '计算中...';

    const minutes = Math.floor(seconds / 60);
    const remainingSeconds = Math.floor(seconds % 60);

    if (minutes > 0) {
      return `${minutes}${remainingSeconds}`;
    } else {
      return `${remainingSeconds}`;
    }
  }
}

// 初始化上传器
document.addEventListener('DOMContentLoaded', () => {
  new OSSUploader();
});


(二)后端实现(Java + Spring Boot)

后端负责与阿里云 OSS 交互,处理初始化分片、上传分片、合并分片等核心逻辑,提供前端所需的 API 接口。

1. 依赖配置(pom.xml)
<!-- 阿里云OSS SDK -->
<dependency>
  <groupId>com.aliyun.oss</groupId>
  <artifactId>aliyun-sdk-oss</artifactId>
  <version>3.17.4</version>
</dependency>
<!-- Spring Boot Web -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Redis(用于存储分片上传状态,可选) -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2. OSS 配置类(读取阿里云密钥)
@RefreshScope
@Data
@ConfigurationProperties(prefix = "aliyun")
@Component
public class AliYunConfig {

    private String accessKey; // 阿里云AccessKeyId

    private String accessKeySecret; // 阿里云AccessKeySecret

    private String ossBucket; // 存储桶名称

    private String ossEndpoint; // OSS地域节点(如oss-cn-beijing.aliyuncs.com)

    private String multipartPartSize; // 分片大小

    private String multipartThreshold; // 文件超过阈值即需要分片上传


    @Bean
    public OSS oSSClient() {
        return new OSSClient(ossEndpoint, accessKey, accessKeySecret);
    }
}

3. 核心业务接口(Controller)
    /**
     * 预检查文件上传方式(大文件用这个接口)
     *
     * @param fileName        文件名
     * @param fileSize        文件大小
     * @param subBucketPrefix 前置路径
     * @return 上传方式信息
     */
    @PostMapping("/preCheckUpload")
    public CommonResponse<OssUploadResponse> preCheckUpload(
            @RequestParam("fileName") String fileName,
            @RequestParam("fileSize") Long fileSize,
            @RequestParam(value = "subBucketPrefix", required = false) String subBucketPrefix) {
        return ossMultipartService.preCheckUpload(fileName, fileSize, subBucketPrefix);
    }

    /**
     * 根据文件大小自动选择上传方式(小文件用这个接口)
     *
     * @param file            上传的文件
     * @param subBucketPrefix 前置路径
     * @return 上传结果
     */
    @PostMapping("/ossMultipartUpload")
    public CommonResponse<OssUploadResponse> smartUpload(
            @RequestParam("file") MultipartFile file,
            @RequestParam(value = "subBucketPrefix", required = false) String subBucketPrefix) {
        return ossMultipartService.smartUpload(file, subBucketPrefix);
    }

    /**
     * 查询成功上传分片
     *
     * @param uploadId
     * @return
     */
    @GetMapping("/checkParts")
    public CommonResponse<List<Integer>> checkUploadedParts(@RequestParam("uploadId") String uploadId) {
        return ossMultipartService.getUploadedParts(uploadId);
    }

    /**
     * 上传文件分片
     *
     * @param uploadId   上传ID
     * @param objectKey  对象key
     * @param partNumber 分片编号
     * @param partData   分片数据
     * @return 分片ETag
     */
    @PostMapping("/uploadPart")
    public CommonResponse<String> uploadPart(
            @RequestParam("uploadId") String uploadId,
            @RequestParam("objectKey") String objectKey,
            @RequestParam("partNumber") int partNumber,
            @RequestParam("partData") MultipartFile partData) throws IOException {
        Assert.isFalse(partData.isEmpty(), "分片数据不能为空");
        log.debug("上传分片: uploadId={}, partNumber={}, size={}", uploadId, partNumber, partData.getSize());
        return ossMultipartService.uploadPart(uploadId, objectKey, partNumber, partData.getBytes());
    }

    /**
     * 完成对分片的合并
     *
     * @param request 完成分片上传请求
     * @return 上传结果
     */
    @PostMapping("/completeMultipartUpload")
    public CommonResponse<MultipartUploadResponse> completeMultipartUpload(@RequestBody @Validated CompleteMultipartRequest request) {
        Assert.isFalse(request.getPartsList() == null || request.getPartsList().isEmpty(), "分片ETag列表不能为空");
        log.info("完成分片上传: uploadId={}, objectKey={}, partCount={}", request.getUploadId(), request.getObjectKey(), request.getPartsList().size());
        return ossMultipartService.completeMultipartUpload(request.getUploadId(), request.getObjectKey(), request.getPartsList());

    }

    /**
     * 取消分片上传
     *
     * @param uploadId  上传ID
     * @param objectKey 对象key
     * @return 取消结果
     */
    @PostMapping("/abortMultipartUpload")
    public CommonResponse<String> abortMultipartUpload(
            @RequestParam("uploadId") String uploadId,
            @RequestParam("objectKey") String objectKey) {
        log.info("取消分片上传: uploadId={}, objectKey={}", uploadId, objectKey);
        return ossMultipartService.abortMultipartUpload(uploadId, objectKey);
    }

}

4. 业务实现(Service)
/**
 * OSS分片上传服务
 */
@Service
@Slf4j
public class OssMultipartService {

    private static final String UPLOAD_PARTS_KEY_PREFIX = "oss:upload_parts:";
    private static final long UPLOAD_STATUS_EXPIRE_TIME = 7 * 24 * 60 * 60; // 上传状态保留7天

    private static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");


    @Autowired
    private OSS ossClient;

    @Autowired
    private AliYunConfig aliYunConfig;


    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 初始化分片上传(内部方法)
     *
     * @param file            文件
     * @param subBucketPrefix 前置路径
     * @return 分片大小和总共的分片数
     */
    private InitiateMultipartResponse initiateMultipartUpload(MultipartFile file, String subBucketPrefix) {
        try {
            String objectKey = generateObjectKey(file.getOriginalFilename(), subBucketPrefix);
            long fileSize = file.getSize();
            long partSize = Long.parseLong(aliYunConfig.getMultipartPartSize());
            int totalParts = (int) Math.ceil((double) fileSize / partSize);

            InitiateMultipartUploadRequest request = new InitiateMultipartUploadRequest(
                    aliYunConfig.getOssBucket(), objectKey);

            InitiateMultipartUploadResult result = ossClient.initiateMultipartUpload(request);

            log.info("初始化分片上传成功,UploadId: {}, ObjectKey: {}, 总分片数: {}",
                    result.getUploadId(), objectKey, totalParts);

            return new InitiateMultipartResponse(
                    result.getUploadId(),
                    file.getOriginalFilename(),
                    objectKey,
                    fileSize,
                    totalParts,
                    partSize);

        } catch (Exception e) {
            log.error("初始化分片上传失败", e);
            throw new RuntimeException("初始化分片上传失败: " + e.getMessage());
        }
    }

    /**
     * 上传文件分片
     *
     * @param uploadId   上传ID
     * @param objectKey  对象key
     * @param partNumber 分片编号
     * @param partData   分片数据
     * @return 分片ETag
     */
    public CommonResponse<String> uploadPart(String uploadId, String objectKey, int partNumber, byte[] partData) {
        try {
            InputStream inputStream = new ByteArrayInputStream(partData);

            UploadPartRequest uploadPartRequest = new UploadPartRequest();
            uploadPartRequest.setBucketName(aliYunConfig.getOssBucket());
            uploadPartRequest.setKey(objectKey);
            uploadPartRequest.setUploadId(uploadId);
            uploadPartRequest.setPartNumber(partNumber);
            uploadPartRequest.setInputStream(inputStream);
            uploadPartRequest.setPartSize(partData.length);

            UploadPartResult uploadPartResult = ossClient.uploadPart(uploadPartRequest);
            log.debug("上传分片成功,PartNumber: {}, ETag: {}", partNumber, uploadPartResult.getETag());
            log.debug("缓存开始更新成功上传分片信息");
            addPartNumber(uploadId, partNumber);
            return CommonResponse.succeed(uploadPartResult.getETag());

        } catch (Exception e) {
            log.error("上传分片失败,PartNumber: {}", partNumber, e);
            return CommonResponse.fail("上传分片失败: " + e.getMessage());
        }
    }

    /**
     * 完成分片上传
     *
     * @param uploadId  上传ID
     * @param objectKey 对象key
     * @param partETags 分片ETag列表
     * @return 上传结果
     */
    public CommonResponse<MultipartUploadResponse> completeMultipartUpload(String uploadId, String objectKey,
                                                                           List<String> partETags) {
        try {
            List<PartETag> partETagList = new ArrayList<>();
            for (int i = 0; i < partETags.size(); i++) {
                partETagList.add(new PartETag(i + 1, partETags.get(i)));
            }

            CompleteMultipartUploadRequest completeRequest = new CompleteMultipartUploadRequest(
                    aliYunConfig.getOssBucket(), objectKey, uploadId, partETagList);

            ossClient.completeMultipartUpload(completeRequest);
            log.info("完成分片上传成功,ObjectKey: {}", objectKey);

            return CommonResponse.succeed(new MultipartUploadResponse(
                    uploadId,
                    extractFileName(objectKey),
                    objectKey,
                    0L, // 文件大小需要单独获取
                    partETags.size(),
                    "SUCCESS"));

        } catch (Exception e) {
            log.error("完成分片上传失败", e);
            return CommonResponse.fail("完成分片上传失败: " + e.getMessage());
        }
    }

    /**
     * 取消分片上传
     *
     * @param uploadId  上传ID
     * @param objectKey 对象key
     */
    public CommonResponse<String> abortMultipartUpload(String uploadId, String objectKey) {
        try {
            AbortMultipartUploadRequest abortRequest = new AbortMultipartUploadRequest(
                    aliYunConfig.getOssBucket(), objectKey, uploadId);

            ossClient.abortMultipartUpload(abortRequest);

            log.info("取消分片上传成功,UploadId: {}, ObjectKey: {}", uploadId, objectKey);
            return CommonResponse.succeed("取消成功");
        } catch (Exception e) {
            log.error("取消分片上传失败", e);
            return CommonResponse.fail("取消分片上传失败: " + e.getMessage());
        }
    }

    /**
     * 简单上传(小文件,内部方法)
     *
     * @param file            文件
     * @param subBucketPrefix 前置路径
     * @return 上传结果
     */
    private MultipartUploadResponse simpleUpload(MultipartFile file, String subBucketPrefix) {
        try {
            String objectKey = generateObjectKey(file.getOriginalFilename(), subBucketPrefix);

            ObjectMetadata metadata = new ObjectMetadata();
            metadata.setContentLength(file.getSize());
            metadata.setContentType(file.getContentType());

            ossClient.putObject(
                    aliYunConfig.getOssBucket(),
                    objectKey,
                    file.getInputStream(),
                    metadata);

            log.info("简单上传成功,ObjectKey: {}", objectKey);

            return new MultipartUploadResponse(
                    null,
                    file.getOriginalFilename(),
                    objectKey,
                    file.getSize(),
                    1,
                    "SUCCESS");

        } catch (IOException e) {
            log.error("简单上传失败", e);
            throw new RuntimeException("简单上传失败: " + e.getMessage());
        }
    }

    /**
     * 预检查上传方式(只检查不上传文件)
     *
     * @param fileName        文件名
     * @param fileSize        文件大小
     * @param subBucketPrefix 前置路径
     * @return 上传方式信息
     */
    public CommonResponse<OssUploadResponse> preCheckUpload(String fileName, Long fileSize, String subBucketPrefix) {
        try {
            Assert.isFalse(fileName == null || fileName.trim().isEmpty(), "文件名不能为空");
            Assert.isFalse(fileSize == null || fileSize <= 0, "文件大小不能为空或小于等于0");
            log.info("预检查文件上传: {}, 大小: {} bytes, 前置路径: {}", fileName, fileSize, subBucketPrefix);
            long threshold = Long.parseLong(aliYunConfig.getMultipartThreshold());

            if (fileSize > threshold) {
                // 大文件, 需要切片上传
                String objectKey = generateObjectKey(fileName, subBucketPrefix);
                long partSize = Long.parseLong(aliYunConfig.getMultipartPartSize());
                int totalParts = (int) Math.ceil((double) fileSize / partSize);

                // 初始化分片上传
                InitiateMultipartUploadRequest request = new InitiateMultipartUploadRequest(
                        aliYunConfig.getOssBucket(), objectKey);

                InitiateMultipartUploadResult result = ossClient.initiateMultipartUpload(request);

                log.info("预检查-初始化分片上传成功,UploadId: {}, ObjectKey: {}, 总分片数: {}",
                        result.getUploadId(), objectKey, totalParts);

                OssUploadResponse response = OssUploadResponse.createMultipartUploadResponse(result.getUploadId());
                response.setFileName(fileName);
                response.setTotalParts(totalParts);
                response.setFileSize(fileSize);
                response.setObjectKey(objectKey);
                return CommonResponse.succeed(response);
            } else {
                // 小文件可以直接上传
                OssUploadResponse response = new OssUploadResponse();
                response.setIsPartUpload(false);
                response.setFileName(fileName);
                response.setFileSize(fileSize);
                return CommonResponse.succeed(response);
            }
        } catch (Exception e) {
            log.error("预检查失败", e);
            return CommonResponse.fail("预检查失败: " + e.getMessage());
        }
    }

    /**
     * 智能上传(根据文件大小选择上传方式)
     *
     * @param file            文件
     * @param subBucketPrefix 前置路径
     * @return 上传结果
     */
    public CommonResponse<OssUploadResponse> smartUpload(MultipartFile file, String subBucketPrefix) {

        try {
            Assert.isFalse(file.isEmpty(), "文件不能为空");
            log.info("开始上传文件: {}, 大小: {} bytes, 前置路径: {}", file.getOriginalFilename(), file.getSize(), subBucketPrefix);

            long fileSize = file.getSize();
            long threshold = Long.parseLong(aliYunConfig.getMultipartThreshold());

            if (fileSize > threshold) {
                // 大文件, 需要切片上传
                InitiateMultipartResponse initResponse = initiateMultipartUpload(file, subBucketPrefix);
                OssUploadResponse response = OssUploadResponse.createMultipartUploadResponse(initResponse.getUploadId());

                // 设置分片上传的必要信息
                response.setFileName(initResponse.getFileName());
                response.setTotalParts(initResponse.getTotalParts());
                response.setFileSize(initResponse.getFileSize());
                response.setObjectKey(initResponse.getObjectKey());
                return CommonResponse.succeed(response);
            } else {
                // 小文件直接上传
                MultipartUploadResponse simpleResult = simpleUpload(file, subBucketPrefix);
                return CommonResponse.succeed(OssUploadResponse.createSimpleUploadResponse(simpleResult.getFileSize(), simpleResult.getFileUrl()));
            }
        } catch (Exception e) {
            log.error("上传失败", e);
            return CommonResponse.fail("上传失败: " + e.getMessage());
        }
    }

    /**
     * 生成对象key
     *
     * @param originalFilename 原始文件名
     * @param subBucketPrefix  前置路径
     * @return 对象key
     */
    private String generateObjectKey(String originalFilename, String subBucketPrefix) {
        String dateStr = new SimpleDateFormat("yyyy/MM/dd").format(new Date());
        String uuid = UUID.randomUUID().toString().replace("-", "");
        String extension = "";

        if (originalFilename != null && originalFilename.contains(".")) {
            extension = originalFilename.substring(originalFilename.lastIndexOf("."));
        }

        // 如果有前置路径,则使用前置路径,否则使用默认的uploads路径
        String basePath = (subBucketPrefix != null && !subBucketPrefix.trim().isEmpty())
                ? subBucketPrefix.trim()
                : "uploads";

        // 确保路径格式正确,移除开头和结尾的斜杠
        if (basePath.startsWith("/")) {
            basePath = basePath.substring(1);
        }
        if (basePath.endsWith("/")) {
            basePath = basePath.substring(0, basePath.length() - 1);
        }

        return String.format("%s/%s/%s%s", basePath, dateStr, uuid, extension);
    }

    /**
     * 从对象key中提取文件名
     *
     * @param objectKey 对象key
     * @return 文件名
     */
    private String extractFileName(String objectKey) {
        if (objectKey == null) {
            return null;
        }
        int lastSlash = objectKey.lastIndexOf("/");
        return lastSlash >= 0 ? objectKey.substring(lastSlash + 1) : objectKey;
    }

    /**
     * 添加分片编号到字符串(格式:1,2,3,4)
     */
    private void addPartNumber(String uploadId, int partNumber) {

        String key = UPLOAD_PARTS_KEY_PREFIX + uploadId;

        // 使用 SADD 命令添加到集合(自动去重)
        stringRedisTemplate.opsForSet().add(key, String.valueOf(partNumber));

        // 设置过期时间
        stringRedisTemplate.expire(key, UPLOAD_STATUS_EXPIRE_TIME, TimeUnit.SECONDS);
    }

    /**
     * 检查已上传的分片
     *
     * @param uploadId
     * @return
     */
    public CommonResponse<List<Integer>> getUploadedParts(String uploadId) {
        String key = UPLOAD_PARTS_KEY_PREFIX + uploadId;
        Set<String> parts = stringRedisTemplate.opsForSet().members(key);

        return CommonResponse.succeed(parts != null
                ? parts.stream()
                .map(Integer::valueOf)
                .sorted()
                .collect(Collectors.toList())
                : Collections.emptyList());
    }
}

四、关键优化与注意事项

分片大小选择:

分片不宜过大(否则单次上传仍可能超时)或过小(增加请求次数和合并压力),建议设置为 5MB~10MB(需与 OSS 的分片大小限制匹配)。

并发控制:

前端上传分片时需限制并发数(如同时上传 3~5 个),避免浏览器或服务器资源耗尽。

断点续传实现:
通过checkParts接口查询已上传分片,仅上传未完成的部分,核心是用 Redis 的 Set 结构记录已上传的partNumber。

错误重试机制:
分片上传失败时,可重试该分片(需保证partNumber和uploadId不变)。

跨域问题:
后端需配置@CrossOrigin或通过网关处理跨域请求,避免前端请求被拦截。

资源清理:
取消上传或上传失败后,需调用abortMultipartUpload接口通知 OSS 删除已上传的分片,避免占用存储空间。

五、总结

通过分片上传,可有效解决大文件上传的稳定性和效率问题。本文提供的方案涵盖了从前端文件切割、并发上传到后端 OSS 交互、断点续传的完整流程,可直接应用于生产环境(需根据实际业务调整参数和安全配置)。
如需进一步优化,可考虑添加分片上传进度实时同步、大文件 MD5 校验(避免文件损坏)等功能。

完结!!!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值