feat(minio): 集成 MinIO 工具类并优化对象存储客户端构建
- 新增 MinioUtil 工具类,统一封装 MinIO SDK 的常用操作 - 规范化 endpoint 处理,避免签名校验时因路径错误失败 - 统一使用 MinioUtil.buildClient 创建 MinIO 客户端,避免自动探测 region 导致错误 - 实现自动创建 bucket 及设置匿名只读策略,兼容裸桶场景 - 进程内缓存已准备好的 bucket,减少重复请求 - 修正 AliOssController 和 FileRecordServiceImpl 的 MinIO 客户端初始化逻辑
This commit is contained in:
151
src/main/java/com/gxwebsoft/common/core/utils/MinioUtil.java
Normal file
151
src/main/java/com/gxwebsoft/common/core/utils/MinioUtil.java
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package com.gxwebsoft.common.core.utils;
|
||||||
|
|
||||||
|
import io.minio.BucketExistsArgs;
|
||||||
|
import io.minio.GetBucketPolicyArgs;
|
||||||
|
import io.minio.MakeBucketArgs;
|
||||||
|
import io.minio.MinioClient;
|
||||||
|
import io.minio.SetBucketPolicyArgs;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MinIO 对象存储工具类
|
||||||
|
*
|
||||||
|
* <p>说明:aliyun-sdk-oss 只实现了阿里云自家的 OSS 签名(OSSV1/V2/V4Signer),
|
||||||
|
* 不含 AWS4-HMAC-SHA256,因此无法对接只接受 AWS4 的 MinIO。
|
||||||
|
* 所有 MinIO 操作统一走官方 minio-java SDK。
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
* @since 2026-08-07
|
||||||
|
*/
|
||||||
|
public class MinioUtil {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MinIO 默认 region。
|
||||||
|
* 必须显式指定,否则 minio-java 会先发 {@code GET /{bucket}?location=} 做 region 自动探测,
|
||||||
|
* 该探测请求在 MinIO + 反向代理场景下容易触发 SignatureDoesNotMatch。
|
||||||
|
*/
|
||||||
|
public static final String DEFAULT_REGION = "us-east-1";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已确保「存在 + 已配置匿名只读策略」的 bucket 缓存(endpoint|bucket)。
|
||||||
|
* 进程内缓存,避免每次上传都多发两次 HTTP 请求。
|
||||||
|
*/
|
||||||
|
private static final Set<String> READY_BUCKETS = ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
|
private MinioUtil() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化 endpoint:去掉结尾斜杠,避免 minio-java 拼接出双斜杠导致签名不匹配
|
||||||
|
*/
|
||||||
|
public static String normalizeEndpoint(String endpoint) {
|
||||||
|
if (endpoint == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String result = endpoint.trim();
|
||||||
|
while (result.endsWith("/")) {
|
||||||
|
result = result.substring(0, result.length() - 1);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 MinioClient(统一带上 region,跳过 getBucketLocation 探测)
|
||||||
|
*
|
||||||
|
* @param endpoint S3 API 地址,如 https://minio.websoft.top
|
||||||
|
* @param accessKeyId AK
|
||||||
|
* @param accessKeySecret SK
|
||||||
|
*/
|
||||||
|
public static MinioClient buildClient(String endpoint, String accessKeyId, String accessKeySecret) {
|
||||||
|
return MinioClient.builder()
|
||||||
|
.endpoint(normalizeEndpoint(endpoint))
|
||||||
|
.credentials(accessKeyId, accessKeySecret)
|
||||||
|
.region(DEFAULT_REGION)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 bucket 的匿名只读策略。
|
||||||
|
* 只放开读取对象,<b>故意不给 s3:ListBucket</b>,避免匿名用户列出桶内全部文件清单。
|
||||||
|
*/
|
||||||
|
public static String buildPublicReadPolicy(String bucketName) {
|
||||||
|
return "{"
|
||||||
|
+ "\"Version\":\"2012-10-17\","
|
||||||
|
+ "\"Statement\":["
|
||||||
|
+ "{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":[\"*\"]},"
|
||||||
|
+ "\"Action\":[\"s3:GetBucketLocation\"],"
|
||||||
|
+ "\"Resource\":[\"arn:aws:s3:::" + bucketName + "\"]},"
|
||||||
|
+ "{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":[\"*\"]},"
|
||||||
|
+ "\"Action\":[\"s3:GetObject\"],"
|
||||||
|
+ "\"Resource\":[\"arn:aws:s3:::" + bucketName + "/*\"]}"
|
||||||
|
+ "]}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保 bucket 可用:
|
||||||
|
* <ol>
|
||||||
|
* <li>bucket 不存在 → 自动创建,并套上匿名只读策略</li>
|
||||||
|
* <li>bucket 已存在但没有任何策略 → 补上匿名只读策略(兼容历史手动建的裸桶)</li>
|
||||||
|
* <li>bucket 已有自定义策略 → 不覆盖,尊重人工配置</li>
|
||||||
|
* </ol>
|
||||||
|
* 结果会缓存在进程内,同一个 endpoint+bucket 只检查一次。
|
||||||
|
*
|
||||||
|
* <p>本方法不会抛异常:即使当前 AK 没有建桶/改策略权限,也只打日志,
|
||||||
|
* 后续的上传动作照常执行(真正失败会由上传自身报错)。
|
||||||
|
*
|
||||||
|
* @return true 表示 bucket 已就绪
|
||||||
|
*/
|
||||||
|
public static boolean ensureBucketReady(MinioClient client, String endpoint, String bucketName) {
|
||||||
|
if (client == null || bucketName == null || bucketName.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String cacheKey = normalizeEndpoint(endpoint) + "|" + bucketName;
|
||||||
|
if (READY_BUCKETS.contains(cacheKey)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
boolean exists = client.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
|
||||||
|
if (!exists) {
|
||||||
|
client.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
|
||||||
|
client.setBucketPolicy(SetBucketPolicyArgs.builder()
|
||||||
|
.bucket(bucketName)
|
||||||
|
.config(buildPublicReadPolicy(bucketName))
|
||||||
|
.build());
|
||||||
|
System.out.println("[MinIO] 自动创建 bucket 并配置匿名只读策略: " + bucketName);
|
||||||
|
} else {
|
||||||
|
// minio-java 在无策略时返回空串而非抛错
|
||||||
|
String policy = client.getBucketPolicy(GetBucketPolicyArgs.builder().bucket(bucketName).build());
|
||||||
|
if (policy == null || policy.trim().isEmpty()) {
|
||||||
|
client.setBucketPolicy(SetBucketPolicyArgs.builder()
|
||||||
|
.bucket(bucketName)
|
||||||
|
.config(buildPublicReadPolicy(bucketName))
|
||||||
|
.build());
|
||||||
|
System.out.println("[MinIO] 已为存量 bucket 补充匿名只读策略: " + bucketName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
READY_BUCKETS.add(cacheKey);
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 不阻断上传:权限不足或网络异常时仅告警
|
||||||
|
System.out.println("[MinIO] ensureBucketReady 失败(不影响上传), bucket=" + bucketName
|
||||||
|
+ ", reason=" + e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动失效缓存(例如后台修改了存储配置后调用)
|
||||||
|
*/
|
||||||
|
public static void evictBucketCache(String endpoint, String bucketName) {
|
||||||
|
READY_BUCKETS.remove(normalizeEndpoint(endpoint) + "|" + bucketName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空全部缓存
|
||||||
|
*/
|
||||||
|
public static void clearBucketCache() {
|
||||||
|
READY_BUCKETS.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import com.aliyuncs.profile.IClientProfile;
|
|||||||
import com.gxwebsoft.common.core.annotation.OperationLog;
|
import com.gxwebsoft.common.core.annotation.OperationLog;
|
||||||
import com.gxwebsoft.common.core.config.ConfigProperties;
|
import com.gxwebsoft.common.core.config.ConfigProperties;
|
||||||
import com.gxwebsoft.common.core.utils.FileServerUtil;
|
import com.gxwebsoft.common.core.utils.FileServerUtil;
|
||||||
|
import com.gxwebsoft.common.core.utils.MinioUtil;
|
||||||
import com.gxwebsoft.common.core.utils.RedisUtil;
|
import com.gxwebsoft.common.core.utils.RedisUtil;
|
||||||
import com.gxwebsoft.common.core.web.ApiResult;
|
import com.gxwebsoft.common.core.web.ApiResult;
|
||||||
import com.gxwebsoft.common.core.web.BaseController;
|
import com.gxwebsoft.common.core.web.BaseController;
|
||||||
@@ -103,9 +104,7 @@ public class AliOssController extends BaseController {
|
|||||||
String uploadMethod = settingInfo.getString("uploadMethod");
|
String uploadMethod = settingInfo.getString("uploadMethod");
|
||||||
|
|
||||||
// 规范化 endpoint:去掉结尾斜杠,避免 minio-java 拼接出双斜杠导致签名不匹配
|
// 规范化 endpoint:去掉结尾斜杠,避免 minio-java 拼接出双斜杠导致签名不匹配
|
||||||
if (StrUtil.isNotBlank(endpoint) && endpoint.endsWith("/")) {
|
endpoint = MinioUtil.normalizeEndpoint(endpoint);
|
||||||
endpoint = endpoint.substring(0, endpoint.length() - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 判断是否登录
|
// 判断是否登录
|
||||||
String authorization = getAuthorization();
|
String authorization = getAuthorization();
|
||||||
@@ -130,11 +129,9 @@ public class AliOssController extends BaseController {
|
|||||||
if (isMinio) {
|
if (isMinio) {
|
||||||
// 显式指定 region=us-east-1,跳过 minio-java 的 getBucketLocation 自动探测
|
// 显式指定 region=us-east-1,跳过 minio-java 的 getBucketLocation 自动探测
|
||||||
// (该探测 GET 在 MinIO + 反代下会触发 SignatureDoesNotMatch)
|
// (该探测 GET 在 MinIO + 反代下会触发 SignatureDoesNotMatch)
|
||||||
minioClient = MinioClient.builder()
|
minioClient = MinioUtil.buildClient(endpoint, accessKeyId, accessKeySecret);
|
||||||
.endpoint(endpoint)
|
// bucket 不存在则自动创建,并套上匿名只读策略(存量裸桶也会自动补策略)
|
||||||
.credentials(accessKeyId, accessKeySecret)
|
MinioUtil.ensureBucketReady(minioClient, endpoint, bucketName);
|
||||||
.region("us-east-1")
|
|
||||||
.build();
|
|
||||||
} else {
|
} else {
|
||||||
CredentialsProvider credentialsProvider = new DefaultCredentialProvider(accessKeyId, accessKeySecret);
|
CredentialsProvider credentialsProvider = new DefaultCredentialProvider(accessKeyId, accessKeySecret);
|
||||||
ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);
|
ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import com.aliyun.oss.common.auth.DefaultCredentialProvider;
|
|||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
import com.gxwebsoft.common.core.utils.CommonUtil;
|
import com.gxwebsoft.common.core.utils.CommonUtil;
|
||||||
|
import com.gxwebsoft.common.core.utils.MinioUtil;
|
||||||
import com.gxwebsoft.common.core.web.PageParam;
|
import com.gxwebsoft.common.core.web.PageParam;
|
||||||
import com.gxwebsoft.common.core.web.PageResult;
|
import com.gxwebsoft.common.core.web.PageResult;
|
||||||
import com.gxwebsoft.common.system.entity.Company;
|
import com.gxwebsoft.common.system.entity.Company;
|
||||||
@@ -99,20 +100,14 @@ public class FileRecordServiceImpl extends ServiceImpl<FileRecordMapper, FileRec
|
|||||||
String accessKeySecret = uploadConfig.getString("accessKeySecret");
|
String accessKeySecret = uploadConfig.getString("accessKeySecret");
|
||||||
String uploadMethod = uploadConfig.getString("uploadMethod");
|
String uploadMethod = uploadConfig.getString("uploadMethod");
|
||||||
// 规范化 endpoint:去掉结尾斜杠,避免 minio-java 拼接出双斜杠导致签名不匹配
|
// 规范化 endpoint:去掉结尾斜杠,避免 minio-java 拼接出双斜杠导致签名不匹配
|
||||||
if (StrUtil.isNotBlank(endpoint) && endpoint.endsWith("/")) {
|
endpoint = MinioUtil.normalizeEndpoint(endpoint);
|
||||||
endpoint = endpoint.substring(0, endpoint.length() - 1);
|
|
||||||
}
|
|
||||||
boolean isMinio = "minio".equals(uploadMethod);
|
boolean isMinio = "minio".equals(uploadMethod);
|
||||||
OSS ossClient = null;
|
OSS ossClient = null;
|
||||||
MinioClient minioClient = null;
|
MinioClient minioClient = null;
|
||||||
if (isMinio) {
|
if (isMinio) {
|
||||||
// MinIO 用官方 minio-java SDK(原生 AWS4 + path-style),aliyun OSS SDK 无法对接 MinIO
|
// MinIO 用官方 minio-java SDK(原生 AWS4 + path-style),aliyun OSS SDK 无法对接 MinIO
|
||||||
// 显式指定 region=us-east-1,跳过 getBucketLocation 自动探测(该探测 GET 会触发 SignatureDoesNotMatch)
|
// 显式指定 region=us-east-1,跳过 getBucketLocation 自动探测(该探测 GET 会触发 SignatureDoesNotMatch)
|
||||||
minioClient = MinioClient.builder()
|
minioClient = MinioUtil.buildClient(endpoint, accessKeyId, accessKeySecret);
|
||||||
.endpoint(endpoint)
|
|
||||||
.credentials(accessKeyId, accessKeySecret)
|
|
||||||
.region("us-east-1")
|
|
||||||
.build();
|
|
||||||
} else {
|
} else {
|
||||||
CredentialsProvider credentialsProvider = new DefaultCredentialProvider(accessKeyId, accessKeySecret);
|
CredentialsProvider credentialsProvider = new DefaultCredentialProvider(accessKeyId, accessKeySecret);
|
||||||
ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);
|
ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);
|
||||||
|
|||||||
Reference in New Issue
Block a user