diff --git a/src/main/java/com/gxwebsoft/common/core/utils/MinioUtil.java b/src/main/java/com/gxwebsoft/common/core/utils/MinioUtil.java new file mode 100644 index 0000000..eb3829a --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/utils/MinioUtil.java @@ -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 对象存储工具类 + * + *
说明: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 本方法不会抛异常:即使当前 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();
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/common/system/controller/AliOssController.java b/src/main/java/com/gxwebsoft/common/system/controller/AliOssController.java
index 832ccf3..d5d5c29 100644
--- a/src/main/java/com/gxwebsoft/common/system/controller/AliOssController.java
+++ b/src/main/java/com/gxwebsoft/common/system/controller/AliOssController.java
@@ -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);
diff --git a/src/main/java/com/gxwebsoft/common/system/service/impl/FileRecordServiceImpl.java b/src/main/java/com/gxwebsoft/common/system/service/impl/FileRecordServiceImpl.java
index 1600daf..df0d087 100644
--- a/src/main/java/com/gxwebsoft/common/system/service/impl/FileRecordServiceImpl.java
+++ b/src/main/java/com/gxwebsoft/common/system/service/impl/FileRecordServiceImpl.java
@@ -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
+ *
+ * 结果会缓存在进程内,同一个 endpoint+bucket 只检查一次。
+ *
+ *