随写——记录一次s3 分片 兼容 小于 5m

44 阅读2分钟
import React, { useState } from 'react';
import {
  S3Client,
  InitiateMultipartUploadCommand,
  UploadPartCommand,
  CompleteMultipartUploadCommand,
  AbortMultipartUploadCommand,
} from '@aws-sdk/client-s3';

interface FileUploadProps {
  bucketName: string;
  region: string;
  accessKeyId: string;
  secretAccessKey: string;
}

const FileUploadComponent: React.FC<FileUploadProps> = ({
  bucketName,
  region,
  accessKeyId,
  secretAccessKey,
}) => {
  const [selectedFile, setSelectedFile] = useState<File | null>(null);
  const [uploadProgress, setUploadProgress] = useState(0);
  const [isUploading, setIsUploading] = useState(false);
  const [uploadError, setUploadError] = useState<string | null>(null);
  const uploadIdRef = React.useRef<string | null>(null); // 存储UploadId

  // 初始化 S3 客户端
  const s3Client = new S3Client({
    region,
    credentials: {
      accessKeyId,
      secretAccessKey,
    },
  });

  // 处理文件选择
  const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (file) {
      setSelectedFile(file);
      setUploadError(null);
    }
  };

  // 主上传逻辑
  const handleUpload = async () => {
    if (!selectedFile) return;
    setIsUploading(true);
    setUploadProgress(0);
    setUploadError(null);

    try {
      const objectKey = selectedFile.name;
      const FILE_SIZE = selectedFile.size;
      const PART_SIZE = 5 * 1024 * 1024; // 5MB
      const isSmallFile = FILE_SIZE < PART_SIZE;

      // 初始化分段上传
      const initCommand = new InitiateMultipartUploadCommand({
        Bucket: bucketName,
        Key: objectKey,
      });
      const initResponse = await s3Client.send(initCommand);
      uploadIdRef.current = initResponse.UploadId;
      if (!uploadIdRef.current) throw new Error('初始化分段上传失败');

      if (isSmallFile) {
        // 处理小文件:单分片上传,标记为最后一块
        await uploadSinglePartAsLast(objectKey, selectedFile);
      } else {
        // 处理大文件:多分片上传
        await uploadMultipleParts(objectKey, selectedFile, PART_SIZE);
      }

      // 完成上传
      await completeUpload(objectKey);
      setUploadProgress(100);
    } catch (error: any) {
      setUploadError(error.message || '上传失败');
      // 异常时终止上传
      if (uploadIdRef.current) {
        await abortUpload(uploadIdRef.current, selectedFile?.name || '');
      }
    } finally {
      setIsUploading(false);
    }
  };

  // 上传单分片(小文件,标记为最后一块)
  const uploadSinglePartAsLast = async (
    objectKey: string,
    file: File
  ) => {
    const uploadCommand = new UploadPartCommand({
      Bucket: bucketName,
      Key: objectKey,
      UploadId: uploadIdRef.current!,
      PartNumber: 1, // 唯一分片
      Body: file,
      ContentLength: file.size,
      // 关键:显式标记为最后一块(AWS SDK v3 中通过ContentLength判断最后一块)
      // 当分片大小等于文件剩余大小时,S3自动识别为最后一块,无需额外参数
    });
    await s3Client.send(uploadCommand);
    setUploadProgress(100); // 直接完成进度
  };

  // 上传多分片(大文件)
  const uploadMultipleParts = async (
    objectKey: string,
    file: File,
    partSize: number
  ) => {
    const partCount = Math.ceil(file.size / partSize);
    const uploadedParts: { ETag: string; PartNumber: number }[] = [];

    for (let i = 0; i < partCount; i++) {
      const partNumber = i + 1;
      const start = i * partSize;
      const end = Math.min(start + partSize, file.size);
      const chunk = file.slice(start, end);

      const uploadCommand = new UploadPartCommand({
        Bucket: bucketName,
        Key: objectKey,
        UploadId: uploadIdRef.current!,
        PartNumber: partNumber,
        Body: chunk,
        ContentLength: chunk.size,
      });

      const response = await s3Client.send(uploadCommand);
      uploadedParts.push({
        ETag: response.ETag!,
        PartNumber: partNumber,
      });

      // 更新进度
      setUploadProgress((prev) => prev + (100 / partCount));
    }
  };

  // 完成分段上传
  const completeUpload = async (objectKey: string) => {
    if (!uploadIdRef.current) return;

    const completeCommand = new CompleteMultipartUploadCommand({
      Bucket: bucketName,
      Key: objectKey,
      UploadId: uploadIdRef.current!,
      // 小文件场景无Parts,但SDK允许空列表(S3会自动处理单分片)
      MultipartUpload: { Parts: [] }, // 单分片时Parts可选
    });

    await s3Client.send(completeCommand);
  };

  // 终止上传
  const abortUpload = async (uploadId: string, objectKey: string) => {
    const abortCommand = new AbortMultipartUploadCommand({
      Bucket: bucketName,
      Key: objectKey,
      UploadId: uploadId,
    });
    await s3Client.send(abortCommand);
  };

  return (
    <div className="p-6 max-w-md mx-auto bg-white rounded-lg shadow-lg">
      <h2 className="text-xl font-bold mb-4">S3 文件上传</h2>
      
      <div className="mb-4">
        <input 
          type="file" 
          onChange={handleFileSelect} 
          className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 cursor-pointer"
        />
      </div>
      
      {selectedFile && (
        <div className="mb-4">
          <p>文件名: {selectedFile.name}</p>
          <p>文件大小: {formatSize(selectedFile.size)}</p>
        </div>
      )}
      
      <button
        onClick={handleUpload}
        disabled={isUploading || !selectedFile}
        className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-lg disabled:opacity-50"
      >
        {isUploading ? '上传中...' : '开始上传'}
      </button>
      
      {uploadProgress > 0 && (
        <div className="progress mt-4">
          <div
            className="progress-bar"
            style={{ width: `${uploadProgress}%` }}
          ></div>
        </div>
      )}
      
      {uploadError && (
        <div className="text-red-500 mt-4">{uploadError}</div>
      )}
    </div>
  );
};

// 格式化文件大小
const formatSize = (bytes: number) => {
  if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
  if (bytes >= 1024) return `${(bytes / 1024).toFixed(2)} KB`;
  return `${bytes} B`;
};

export default FileUploadComponent;