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 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 的匿名只读策略。 + * 只放开读取对象,故意不给 s3:ListBucket,避免匿名用户列出桶内全部文件清单。 + */ + 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 可用: + *

    + *
  1. bucket 不存在 → 自动创建,并套上匿名只读策略
  2. + *
  3. bucket 已存在但没有任何策略 → 补上匿名只读策略(兼容历史手动建的裸桶)
  4. + *
  5. bucket 已有自定义策略 → 不覆盖,尊重人工配置
  6. + *
+ * 结果会缓存在进程内,同一个 endpoint+bucket 只检查一次。 + * + *

本方法不会抛异常:即使当前 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