前提在自己服务器安装好minio,创建好自己的桶,密钥,域名,ssl证书,nginx配置。我目前是在docker上面部署(记得做好数据卷数据备份)
目前情况是 nginx,后端,minio,都在docker容器里
java 代码部分
yml 配置
java
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注入
java
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 的对象存储服务:负责桶初始化、文件上传、预签名访问链接生成及文件元数据持久化。
java
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("[\\\\/:*?\"<>|]", "_");
}
}
java
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);
}
}
java
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 解析失败。
bash
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
检查:
bash
docker ps --format "table {{.Names}}\t{{.Networks}}" | grep -E "minio|mini-cursor|nginx"
期望:minio、nginx、mini-cursor-backend 均包含 app-net。
bash
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模块
java
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;
}
}