核心設計:接口 + 實現分離

1. 定義業務接口

public interface CloudStorageService {
    /**
     * 上傳文件並返回可訪問 URL
     * @param bucket 存儲桶
     * @param objectName 對象名(含路徑)
     * @param inputStream 文件流
     * @param contentType MIME 類型
     * @return 公開訪問 URL(或內部路徑)
     */
    String upload(String bucket, String objectName, InputStream inputStream, String contentType)
        throws StorageException;
}

2. 自定義異常(便於統一處理)

public class StorageException extends RuntimeException {
    public StorageException(String message, Throwable cause) {
        super(message, cause);
    }
}

生產級實現(含熔斷、指標、日誌)

import io.minio.*;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.io.InputStream;
import java.util.concurrent.CompletableFuture;

@Service
public class MinioStorageService implements CloudStorageService {

    private static final Logger log = LoggerFactory.getLogger(MinioStorageService.class);

    private final MinioClient minioClient;
    private final MinioProperties properties;
    private final MeterRegistry meterRegistry;

    // 指標:上傳成功/失敗計數、耗時分佈
    private final Timer uploadTimer;

    public MinioStorageService(
            MinioClient minioClient,
            MinioProperties properties,
            MeterRegistry meterRegistry) {
        this.minioClient = minioClient;
        this.properties = properties;
        this.meterRegistry = meterRegistry;
        this.uploadTimer = Timer.builder("storage.upload.duration")
                .description("MinIO upload latency")
                .register(meterRegistry);
    }

    @Override
    public String upload(String bucket, String objectName, InputStream inputStream, String contentType) {
        return uploadTimer.recordCallable(() -> {
            try {
                // 確保存儲桶存在(冪等操作)
                if (!minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build())) {
                    minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
                    log.info("Created bucket: {}", bucket);
                }

                // 執行上傳
                minioClient.putObject(PutObjectArgs.builder()
                        .bucket(bucket)
                        .object(objectName)
                        .stream(inputStream, -1, 10485760) // 10MB part size
                        .contentType(contentType)
                        .build());

                String url = generatePublicUrl(bucket, objectName);
                log.debug("File uploaded: {} -> {}", objectName, url);
                meterRegistry.counter("storage.upload.success").increment();
                return url;

            } catch (Exception e) {
                meterRegistry.counter("storage.upload.failure").increment();
                log.error("MinIO upload failed for {}/{}", bucket, objectName, e);
                throw new StorageException("文件上傳存儲失敗", e);
            }
        });
    }

    private String generatePublicUrl(String bucket, String objectName) {
        // 注意:生產環境應使用預簽名 URL 或 CDN,而非直接暴露 MinIO
        return String.format("%s/%s/%s", properties.getPublicUrl(), bucket, objectName);
    }
}