前提在自己服务器安装好minio,创建好自己的桶,密钥,域名,ssl证书,nginx配置。我目前是在docker上面部署(记得做好数据卷数据备份)
目前情况是 nginx,后端,minio,都在docker容器里
java 代码部分
yml 配置

 app:
  minio:
    # http://**.***.**.**:9000  http://127.0.0.1:9000  自己图片的 用户名:anchiteAgriculture  密码:Agriculture
    endpoint: ${MINIO_ENDPOINT:http://**.***.**.**:9000}
    public-endpoint: ${MINIO_PUBLIC_ENDPOINT:http://114.55.114.197:9000}
    # 密钥仅通过环境变量或 profile 注入,勿在此写空默认值(会覆盖其它配置)
    bucket: ${MINIO_BUCKET:zgb}
    accessKey: ${MINIO_ACCESS_KEY:rx4jUm52IreKXkaKgt}
    secretKey: ${MINIO_SECRET_KEY:6NiPknXUGIvOoBQp3OZGhSm1sPkuKhPhHU59}
    presign-expire-hours: ${MINIO_PRESIGN_EXPIRE_HOURS:24}

配置bean注入



import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

/**
 * MinIO 对象存储配置,绑定前缀 {@code app.minio}。
 * <p>访问凭证可通过环境变量或配置文件注入;未配置时文件上传功能不可用。
 */
@Data
@Component
@ConfigurationProperties(prefix = "app.minio")
public class MinioProperties {

    /** SDK 连接 MinIO 的内网/本机地址(上传、bucket 检查),如 http://127.0.0.1:9000 */
    private String endpoint = "http://**.***.**.**:9000";
    /** 预签名 URL 对外地址(经 Nginx HTTPS 反代),如 https://www.amn.cc/minio;空则与 endpoint 相同 */
    private String publicEndpoint;
    /** 访问密钥 ID */
    private String accessKey ="rx4jUm52IreKXkaKgt";
    /** 访问密钥 Secret */
    private String secretKey ="6NiPknXUGIvOoBQp3OZGhSm1sPkuKhPhHU59";
    /** 默认存储桶名称 */
    private String bucket = "zgb";
    /** 预签名下载链接有效时长(小时) */
    private int presignExpireHours = 24;

    /**
     * 返回去空白后的端点地址,空串视为未配置。
     */
    public String getEndpoint() {
        return trimToNull(endpoint);
    }

    public String getPublicEndpoint() {
        return trimToNull(publicEndpoint);
    }

    /**
     * 返回去空白后的访问密钥 ID,空串视为未配置。
     */
    public String getAccessKey() {
        return trimToNull(accessKey);
    }

    /**
     * 返回去空白后的访问密钥 Secret,空串视为未配置。
     */
    public String getSecretKey() {
        return trimToNull(secretKey);
    }

    /**
     * 是否已配置可用的访问凭证(密钥 ID 与 Secret 均非空)。
     */
    public boolean isConfigured() {
        return getAccessKey() != null && getSecretKey() != null;
    }

    private static String trimToNull(String value) {
        if (value == null) {
            return null;
        }
        String trimmed = value.trim();
        return trimmed.isEmpty() ? null : trimmed;
    }
}

基于 MinIO 的对象存储服务:负责桶初始化、文件上传、预签名访问链接生成及文件元数据持久化。

import com.gk.minicursor.common.BusinessException;
import com.gk.minicursor.config.MinioProperties;
import com.gk.minicursor.modules.file.dto.FileAccessVO;
import com.gk.minicursor.modules.file.entity.FileObject;
import com.gk.minicursor.modules.file.repository.FileObjectRepository;
import io.minio.BucketExistsArgs;
import io.minio.GetPresignedObjectUrlArgs;
import io.minio.MakeBucketArgs;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import io.minio.http.Method;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.InputStream;
import java.net.URI;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

/**
 * 基于 MinIO 的对象存储服务:负责桶初始化、文件上传、预签名访问链接生成及文件元数据持久化。
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class MinioStorageService {

    private final MinioProperties minioProperties;
    private final FileObjectRepository fileObjectRepository;
    /** 内网上传、bucket 检查 */
    private MinioClient minioClient;
    /** 生成浏览器可访问的预签名链接(可与 endpoint 不同,如 HTTPS 反代地址) */
    private MinioClient presignClient;

    /**
     * 应用启动后初始化 MinIO 客户端;若未配置或连接失败则记录警告并保持上传不可用。
     */
    @PostConstruct
    public void init() {
        if (!minioProperties.isConfigured()) {
            log.warn("MinIO 未配置(请设置 MINIO_ACCESS_KEY / MINIO_SECRET_KEY 或 dev 配置),文件上传暂不可用");
            return;
        }
        try {
            minioClient = buildClient(minioProperties.getEndpoint());
            ensureBucket(minioClient);
            presignClient = resolvePresignClient();
            log.info("MinIO 已连接 endpoint={} presign={}",
                    minioProperties.getEndpoint(), presignEndpointLabel());
        } catch (Exception e) {
            minioClient = null;
            presignClient = null;
            log.warn("MinIO 连接失败,文件上传暂不可用: {}", e.getMessage());
        }
    }

    private MinioClient resolvePresignClient() {
        String pub = minioProperties.getPublicEndpoint();
        String internal = minioProperties.getEndpoint();
        if (pub == null || pub.equalsIgnoreCase(internal)) {
            return minioClient;
        }
        try {
            // 公网地址仅用于生成预签名 URL,不重复 bucketExists(经 Nginx 可能 403)
            return buildClient(pub);
        } catch (Exception e) {
            log.warn("MinIO 公网预签名端点不可用 publicEndpoint={},回退内网 endpoint: {}",
                    pub, e.getMessage());
            return minioClient;
        }
    }

    /**
     * 构建 MinIO 客户端。统一使用完整 URL(如 {@code http://127.0.0.1:9000}、{@code https://minio.example.com}),
     * 勿使用带路径的 endpoint(SDK 会报 no path allowed)。
     */
    private MinioClient buildClient(String endpoint) {
        String normalized = normalizeEndpointUrl(endpoint);
        URI uri = URI.create(normalized);
        if (uri.getHost() == null || uri.getHost().isBlank()) {
            throw new IllegalArgumentException("MinIO endpoint 无效: " + endpoint);
        }
        if (hasUrlPathPrefix(normalized)) {
            throw new IllegalArgumentException(
                    "MinIO endpoint 不能包含路径");
        }
        return MinioClient.builder()
                .endpoint(normalized)
                .credentials(minioProperties.getAccessKey(), minioProperties.getSecretKey())
                .build();
    }

    private static String normalizeEndpointUrl(String endpoint) {
        String s = endpoint.trim();
        while (s.endsWith("/")) {
            s = s.substring(0, s.length() - 1);
        }
        if (!s.contains("://")) {
            s = "http://" + s;
        }
        return s;
    }

    private static boolean hasUrlPathPrefix(String normalizedUrl) {
        URI uri = URI.create(normalizedUrl);
        String path = uri.getPath();
        return path != null && path.length() > 1;
    }

    private void ensureBucket(MinioClient client) throws Exception {
        boolean exists = client.bucketExists(
                BucketExistsArgs.builder().bucket(minioProperties.getBucket()).build());
        if (!exists) {
            client.makeBucket(MakeBucketArgs.builder().bucket(minioProperties.getBucket()).build());
        }
    }

    private String presignEndpointLabel() {
        String pub = minioProperties.getPublicEndpoint();
        return pub != null ? pub : minioProperties.getEndpoint();
    }

    /**
     * 上传文件至 MinIO 并写入数据库,返回带预签名访问地址的视图对象。
     *
     * @param file 待上传的 multipart 文件
     * @return 文件访问信息(含 fileId、objectKey、预签名 URL 等)
     */
    public FileAccessVO upload(MultipartFile file) {
        FileObject saved = uploadAndPersist(file);
        return toAccessVo(saved);
    }

    /**
     * 根据已持久化的文件主键生成预签名下载链接。
     *
     * @param fileId 文件记录 ID
     * @return 文件访问信息
     */
    public FileAccessVO getAccessUrl(Long fileId) {
        FileObject file = fileObjectRepository.findById(fileId)
                .orElseThrow(() -> new BusinessException(404, "文件不存在"));
        return toAccessVo(file);
    }

    /**
     * 根据库中保存的引用解析预签名访问地址。
     * <p>支持:纯数字 fileId、{@code bucket/objectKey} 存储引用、objectKey 或历史 endpoint 直链格式。
     *
     * @param reference 文件引用(fileId、objectKey 或历史 URL)
     * @return 文件访问信息
     */
    public FileAccessVO resolveAccessUrl(String reference) {
        if (reference == null || reference.isBlank()) {
            throw new BusinessException("文件引用不能为空");
        }
        String ref = reference.trim();
        if (ref.matches("\\d+")) {
            return getAccessUrl(Long.parseLong(ref));
        }
        FileObject byKey = fileObjectRepository.findByBucketAndObjectKey(minioProperties.getBucket(), extractObjectKey(ref))
                .orElse(null);
        if (byKey != null) {
            return toAccessVo(byKey);
        }
        String objectKey = extractObjectKey(ref);
        return FileAccessVO.builder()
                .bucket(minioProperties.getBucket())
                .objectKey(objectKey)
                .accessUrl(presignGetUrl(objectKey))
                .expiresInSeconds(expireSeconds())
                .build();
    }

    private FileObject uploadAndPersist(MultipartFile file) {
        if (minioClient == null) {
            throw new BusinessException("文件服务未就绪,请检查 MinIO 配置");
        }
        if (file == null || file.isEmpty()) {
            throw new BusinessException("文件不能为空");
        }
        String objectKey = UUID.randomUUID() + "_" + sanitizeFilename(file.getOriginalFilename());
        try (InputStream in = file.getInputStream()) {
            minioClient.putObject(PutObjectArgs.builder()
                    .bucket(minioProperties.getBucket())
                    .object(objectKey)
                    .stream(in, file.getSize(), -1)
                    .contentType(file.getContentType())
                    .build());
            FileObject fo = new FileObject();
            fo.setBucket(minioProperties.getBucket());
            fo.setObjectKey(objectKey);
            fo.setOriginalName(file.getOriginalFilename());
            fo.setContentType(file.getContentType());
            fo.setSizeBytes(file.getSize());
            // 库内仅存对象定位信息,不作为浏览器直链
            fo.setUrl(buildStorageRef(minioProperties.getBucket(), objectKey));
            return fileObjectRepository.save(fo);
        } catch (Exception e) {
            throw new BusinessException("文件上传失败: " + e.getMessage());
        }
    }

    private FileAccessVO toAccessVo(FileObject file) {
        return FileAccessVO.builder()
                .id(file.getId())
                .bucket(file.getBucket())
                .objectKey(file.getObjectKey())
                .accessUrl(presignGetUrl(file.getObjectKey()))
                .expiresInSeconds(expireSeconds())
                .build();
    }

    private String presignGetUrl(String objectKey) {
        if (minioClient == null || presignClient == null) {
            throw new BusinessException("文件服务未就绪,请检查 MinIO 配置");
        }
        GetPresignedObjectUrlArgs args = GetPresignedObjectUrlArgs.builder()
                .method(Method.GET)
                .bucket(minioProperties.getBucket())
                .object(objectKey)
                .expiry(minioProperties.getPresignExpireHours(), TimeUnit.HOURS)
                .build();
        if (presignClient != minioClient) {
            try {
                return presignClient.getPresignedObjectUrl(args);
            } catch (Exception ex) {
                log.warn("公网预签名失败 publicEndpoint={}:{}",
                        minioProperties.getPublicEndpoint(), ex.toString());
            }
        }
        try {
            String url = minioClient.getPresignedObjectUrl(args);
            if (presignClient != minioClient) {
                log.warn("已回退内网预签名 URL,HTTPS 页面可能无法加载图片;请确认 DNS 已解析 {}",
                        minioProperties.getPublicEndpoint());
            }
            return url;
        } catch (Exception e) {
            throw new BusinessException("生成访问链接失败: " + formatPresignError(e));
        }
    }

    private String formatPresignError(Exception e) {
        String msg = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
        if (!msg.contains(" ") && msg.contains(".")) {
            return msg + "(无法解析该域名,请在 DNS 增加 minio 子域名 A 记录,或本机 hosts 绑定服务器 IP)";
        }
        return msg;
    }

    private long expireSeconds() {
        return minioProperties.getPresignExpireHours() * 3600L;
    }

    private static String buildStorageRef(String bucket, String objectKey) {
        return bucket + "/" + objectKey;
    }

    /**
     * 从 storageRef、objectKey 或历史 endpoint/bucket/key 直链中解析 MinIO 对象键。
     *
     * @param ref 存储引用或 URL 片段
     * @return 解析后的 objectKey
     */
    static String extractObjectKey(String ref) {
        if (ref.contains("://")) {
            try {
                URI uri = URI.create(ref.split("\\?")[0]);
                String path = uri.getPath();
                if (path == null || path.isBlank()) {
                    return ref;
                }
                String normalized = path.startsWith("/") ? path.substring(1) : path;
                int slash = normalized.indexOf('/');
                if (slash > 0) {
                    return normalized.substring(slash + 1);
                }
                return normalized;
            } catch (Exception ignored) {
                // fall through
            }
        }
        int slash = ref.indexOf('/');
        if (slash > 0 && !ref.substring(0, slash).contains(".")) {
            return ref.substring(slash + 1);
        }
        return ref;
    }

    private static String sanitizeFilename(String name) {
        if (name == null || name.isBlank()) {
            return "file";
        }
        return name.replaceAll("[\\\\/:*?\"<>|]", "_");
    }
}

import io.swagger.v3.oas.annotations.media.Schema;

import lombok.AllArgsConstructor;

import lombok.Data;

import lombok.NoArgsConstructor;



/**

 * 统一 API 响应包装。

 */

@Data

@NoArgsConstructor

@AllArgsConstructor

@Schema(description = "统一响应体")

public class ApiResponse<T> {



    @Schema(description = "业务码,0 表示成功")

    private int code;

    @Schema(description = "提示信息")

    private String message;

    @Schema(description = "业务数据")

    private T data;



    public static <T> ApiResponse<T> ok(T data) {

        return new ApiResponse<>(0, "success", data);

    }



    public static <T> ApiResponse<T> ok() {

        return ok(null);

    }



    public static <T> ApiResponse<T> fail(int code, String message) {

        return new ApiResponse<>(code, message, null);

    }

}


import com.gk.minicursor.common.ApiResponse;

import com.gk.minicursor.config.OpenApiConfig;

import com.gk.minicursor.infrastructure.storage.MinioStorageService;

import com.gk.minicursor.modules.file.dto.FileAccessVO;

import io.swagger.v3.oas.annotations.Operation;

import io.swagger.v3.oas.annotations.Parameter;

import io.swagger.v3.oas.annotations.security.SecurityRequirement;

import io.swagger.v3.oas.annotations.tags.Tag;

import lombok.RequiredArgsConstructor;

import org.springframework.web.bind.annotation.*;

import org.springframework.web.multipart.MultipartFile;



/** 管理端文件(MinIO 私有桶)。 */

@Tag(name = "管理端-文件", description = "上传与预签名访问")

@RestController

@RequestMapping("/api/admin/files")

@RequiredArgsConstructor

@SecurityRequirement(name = OpenApiConfig.BEARER_AUTH)

public class AdminFileController {



    private final MinioStorageService minioStorageService;



    @Operation(summary = "上传文件", description = "返回 fileId 与临时 accessUrl;业务表请存 id,勿长期存 accessUrl")

    @PostMapping("/upload")

    public ApiResponse<FileAccessVO> upload(

            @Parameter(description = "图片或文件") @RequestParam("file") MultipartFile file) {

        return ApiResponse.ok(minioStorageService.upload(file));

    }



    @Operation(summary = "按文件 ID 获取预签名链接", description = "链接过期后重新调用")

    @GetMapping("/{id}/access-url")

    public ApiResponse<FileAccessVO> accessUrl(@Parameter(description = "文件 ID") @PathVariable Long id) {

        return ApiResponse.ok(minioStorageService.getAccessUrl(id));

    }



    @Operation(summary = "按引用解析预签名链接", description = "ref 支持 fileId、bucket/objectKey、历史 URL")

    @GetMapping("/presign")

    public ApiResponse<FileAccessVO> presign(@Parameter(description = "文件引用") @RequestParam String ref) {

        return ApiResponse.ok(minioStorageService.resolveAccessUrl(ref));

    }

}


| 角色 | 地址 | 说明 | (docker容器名字 minio)
| 内网上传 | http://minio:9000 | 仅容器间,后端 MINIO_ENDPOINT |
| 浏览器访问 | https://**.***.** | 后端 MINIO_PUBLIC_ENDPOINT无路径前缀 |
| Console | http://IP:9001 | 与 API 子域名分离,避免重定向循环 |

Docker 网络(必做)

问题根因之一:后端只在 bridge,MinIO 在 app-net,导致 MINIO_ENDPOINT=http://minio:9000 解析失败。

docker network create app-net 2>/dev/null || true

docker network connect app-net minio 2>/dev/null || true
docker network connect app-net nginx 2>/dev/null || true
docker network connect app-net mini-cursor-backend

检查:

docker ps --format "table {{.Names}}\t{{.Networks}}" | grep -E "minio|mini-cursor|nginx"

期望:minionginxmini-cursor-backend 均包含 app-net

docker network inspect app-net | grep -E '"Name": "(minio|mini-cursor-backend|nginx)"'

## 5. MinIO 启动命令

### 5.1 推荐命令

- 密码含 `!` 时 **必须用单引号**,否则 bash 会吃掉 `MINIO_ROOT_PASSWORD`  
- **不要**设置 `MINIO_SERVER_URL``MINIO_BROWSER_REDIRECT_URL` 为同一 HTTPS 域名且仅反代 9000(易 **重定向过多**、Console 登录失败)

```bash
docker stop minio 2>/dev/null
docker rm minio 2>/dev/null

docker run -d \
  --name minio \
  --restart always \
  --network app-net \
  -p 9000:9000 \
  -p 9001:9001 \
  -e 'MINIO_ROOT_USER=admin' \
  -e 'MINIO_ROOT_PASSWORD=你的强密码' \
  -v /data/minio:/data \
  minio/minio:RELEASE.2024-02-26T09-33-48Z server /data --console-address ":9001"

nginx 新加一个ssl模块

server {
    listen 443 ssl;
    server_name **.***.**;
    ssl_certificate     /usr/local/nginx-docker/key/**.***.**.pem;
    ssl_certificate_key /usr/local/nginx-docker/key/**.***.**.key;
    client_max_body_size 20m;
    location / {
        proxy_pass http://minio:9000;
        proxy_http_version 1.1;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host $host;
        proxy_connect_timeout 300;
        proxy_send_timeout 300;
        proxy_read_timeout 300;
        proxy_redirect off;
        proxy_buffering off;
    }
}
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐