feat(minio): 集成 MinIO 工具类并优化对象存储客户端构建

- 新增 MinioUtil 工具类,统一封装 MinIO SDK 的常用操作
- 规范化 endpoint 处理,避免签名校验时因路径错误失败
- 统一使用 MinioUtil.buildClient 创建 MinIO 客户端,避免自动探测 region 导致错误
- 实现自动创建 bucket 及设置匿名只读策略,兼容裸桶场景
- 进程内缓存已准备好的 bucket,减少重复请求
- 修正 AliOssController 和 FileRecordServiceImpl 的 MinIO 客户端初始化逻辑
This commit is contained in:
2026-08-07 09:23:18 +08:00
parent e487f021dc
commit e77353b478
3 changed files with 159 additions and 16 deletions

View 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();
}
}

View File

@@ -24,6 +24,7 @@ import com.aliyuncs.profile.IClientProfile;
import com.gxwebsoft.common.core.annotation.OperationLog;
import com.gxwebsoft.common.core.config.ConfigProperties;
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.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController;
@@ -103,9 +104,7 @@ public class AliOssController extends BaseController {
String uploadMethod = settingInfo.getString("uploadMethod");
// 规范化 endpoint去掉结尾斜杠避免 minio-java 拼接出双斜杠导致签名不匹配
if (StrUtil.isNotBlank(endpoint) && endpoint.endsWith("/")) {
endpoint = endpoint.substring(0, endpoint.length() - 1);
}
endpoint = MinioUtil.normalizeEndpoint(endpoint);
// 判断是否登录
String authorization = getAuthorization();
@@ -130,11 +129,9 @@ public class AliOssController extends BaseController {
if (isMinio) {
// 显式指定 region=us-east-1跳过 minio-java 的 getBucketLocation 自动探测
// (该探测 GET 在 MinIO + 反代下会触发 SignatureDoesNotMatch
minioClient = MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKeyId, accessKeySecret)
.region("us-east-1")
.build();
minioClient = MinioUtil.buildClient(endpoint, accessKeyId, accessKeySecret);
// bucket 不存在则自动创建,并套上匿名只读策略(存量裸桶也会自动补策略)
MinioUtil.ensureBucketReady(minioClient, endpoint, bucketName);
} else {
CredentialsProvider credentialsProvider = new DefaultCredentialProvider(accessKeyId, accessKeySecret);
ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);

View File

@@ -13,6 +13,7 @@ import com.aliyun.oss.common.auth.DefaultCredentialProvider;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
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.PageResult;
import com.gxwebsoft.common.system.entity.Company;
@@ -99,20 +100,14 @@ public class FileRecordServiceImpl extends ServiceImpl<FileRecordMapper, FileRec
String accessKeySecret = uploadConfig.getString("accessKeySecret");
String uploadMethod = uploadConfig.getString("uploadMethod");
// 规范化 endpoint去掉结尾斜杠避免 minio-java 拼接出双斜杠导致签名不匹配
if (StrUtil.isNotBlank(endpoint) && endpoint.endsWith("/")) {
endpoint = endpoint.substring(0, endpoint.length() - 1);
}
endpoint = MinioUtil.normalizeEndpoint(endpoint);
boolean isMinio = "minio".equals(uploadMethod);
OSS ossClient = null;
MinioClient minioClient = null;
if (isMinio) {
// MinIO 用官方 minio-java SDK原生 AWS4 + path-stylealiyun OSS SDK 无法对接 MinIO
// 显式指定 region=us-east-1跳过 getBucketLocation 自动探测(该探测 GET 会触发 SignatureDoesNotMatch
minioClient = MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKeyId, accessKeySecret)
.region("us-east-1")
.build();
minioClient = MinioUtil.buildClient(endpoint, accessKeyId, accessKeySecret);
} else {
CredentialsProvider credentialsProvider = new DefaultCredentialProvider(accessKeyId, accessKeySecret);
ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);