diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/config/FileConfig.java b/blade-service/blade-file/src/main/java/org/springblade/file/config/FileConfig.java index 3e578fef..07748388 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/config/FileConfig.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/config/FileConfig.java @@ -11,6 +11,7 @@ import org.springblade.file.service.impl.LocalFileServiceImpl; import org.springblade.file.service.impl.MinioFileServiceImpl; import org.springblade.file.service.impl.OssFileServiceImpl; import org.springblade.file.service.impl.ObsFileServiceImpl; +import org.springblade.file.util.MinioClientFactory; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -65,19 +66,22 @@ public class FileConfig { private IFileService buildMinioFileService(FileStorageProperties.StoreProperties properties) { validateStoreProperties(STORAGE_MINIO, properties); + String endpointOrigin = MinioClientFactory.resolveOrigin(properties.getEndpoint()); + String endpointPathPrefix = MinioClientFactory.resolvePathPrefix(properties.getEndpoint()); + if (StringUtil.isNotBlank(endpointPathPrefix)) { + log.warn("MinIO endpoint 含 path,将按 origin={} pathPrefix={} 访问;建议 endpoint 使用内网地址,transform-endpoint 使用外网 HTTPS", + endpointOrigin, endpointPathPrefix); + } if (StringUtil.isBlank(properties.getTransformEndpoint()) - && StringUtil.isNotBlank(properties.getEndpoint()) && properties.getEndpoint().trim().toLowerCase().startsWith("http://")) { log.warn("MinIO transform-endpoint 未配置,endpoint={}。HTTPS 页面分片直传会被浏览器 Mixed Content 拦截,将回退为服务端代传", properties.getEndpoint()); } else { - log.info("MinIO 文件存储已启用 endpoint={} transformEndpoint={}", - properties.getEndpoint(), properties.getTransformEndpoint()); + log.info("MinIO 文件存储已启用 endpoint={} pathPrefix={} transformEndpoint={}", + endpointOrigin, endpointPathPrefix, properties.getTransformEndpoint()); } - MinioClient minioClient = MinioClient.builder() - .endpoint(properties.getEndpoint()) - .credentials(properties.getAccessKeyId(), properties.getAccessKeySecret()) - .build(); + MinioClient minioClient = MinioClientFactory.create( + properties.getEndpoint(), properties.getAccessKeyId(), properties.getAccessKeySecret()); return new MinioFileServiceImpl(properties, minioClient); } diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java b/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java index 45741733..c7d2ed4e 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java @@ -40,6 +40,7 @@ import lombok.extern.slf4j.Slf4j; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.tool.utils.StringUtil; import org.springblade.file.config.FileStorageProperties; +import org.springblade.file.util.MinioClientFactory; import org.springblade.file.pojo.vo.PartEtagVO; import java.io.File; @@ -65,8 +66,7 @@ import java.util.UUID; public class MinioFileServiceImpl extends AbstractFileService { private static final long PART_SIZE = 10 * 1024 * 1024L; - /** MinIO 默认 region;固定后预签名无需再请求外网域名做 GetBucketLocation */ - private static final String DEFAULT_REGION = "us-east-1"; + private static final String DEFAULT_REGION = MinioClientFactory.DEFAULT_REGION; private final FileStorageProperties.StoreProperties properties; private final MinioClient minioClient; @@ -216,10 +216,7 @@ public class MinioFileServiceImpl extends AbstractFileService { /** * 浏览器侧预签名优先使用外网地址,避免 HTTPS 页面访问 HTTP MinIO 被 Mixed Content 拦截。 - *

* MinIO SDK 不允许 endpoint 带 path,因此外网地址中的 /oss 等前缀在签名后单独拼回。 - * 外网域名通常是业务网关而非直连 MinIO,必须固定 region,否则 SDK 会请求 GetBucketLocation - * 并拿到 HTML 页面导致 XmlParserException。 */ private MinioClient resolvePresignClient() { String transformEndpoint = properties.getTransformEndpoint(); @@ -232,29 +229,13 @@ public class MinioFileServiceImpl extends AbstractFileService { } synchronized (this) { if (transformPresignClient == null) { - transformPresignClient = MinioClient.builder() - .endpoint(resolveTransformOrigin(transformEndpoint)) - .credentials(properties.getAccessKeyId(), properties.getAccessKeySecret()) - .region(DEFAULT_REGION) - .build(); + transformPresignClient = MinioClientFactory.create( + transformEndpoint, properties.getAccessKeyId(), properties.getAccessKeySecret()); } return transformPresignClient; } } - private String resolveTransformOrigin(String transformEndpoint) { - URI transformUri = URI.create(transformEndpoint.trim()); - if (StringUtil.isBlank(transformUri.getScheme()) || StringUtil.isBlank(transformUri.getHost())) { - throw new ServiceException("MinIO外网地址配置不正确"); - } - StringBuilder origin = new StringBuilder(); - origin.append(transformUri.getScheme()).append("://").append(transformUri.getHost()); - if (transformUri.getPort() > 0) { - origin.append(':').append(transformUri.getPort()); - } - return origin.toString(); - } - private String applyTransformPathPrefix(String presignedUrl) { String transformEndpoint = properties.getTransformEndpoint(); if (StringUtil.isBlank(transformEndpoint) || StringUtil.isBlank(presignedUrl)) { diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/util/MinioClientFactory.java b/blade-service/blade-file/src/main/java/org/springblade/file/util/MinioClientFactory.java new file mode 100644 index 00000000..384dc61e --- /dev/null +++ b/blade-service/blade-file/src/main/java/org/springblade/file/util/MinioClientFactory.java @@ -0,0 +1,121 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.file.util; + +import io.minio.MinioClient; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.tool.utils.StringUtil; + +import java.net.URI; +import java.util.concurrent.TimeUnit; + +/** + * MinIO 客户端构建。 + *

+ * SDK 不允许 endpoint 带 path;若配置为 {@code https://host/oss},则: + *

+ */ +public final class MinioClientFactory { + + public static final String DEFAULT_REGION = "us-east-1"; + + private MinioClientFactory() { + } + + public static MinioClient create(String endpoint, String accessKey, String secretKey) { + EndpointParts parts = parseEndpoint(endpoint); + MinioClient.Builder builder = MinioClient.builder() + .endpoint(parts.origin()) + .credentials(accessKey, secretKey) + .region(DEFAULT_REGION); + if (StringUtil.isNotBlank(parts.pathPrefix())) { + builder.httpClient(buildHttpClient(parts.pathPrefix()), true); + } + return builder.build(); + } + + public static String resolveOrigin(String endpoint) { + return parseEndpoint(endpoint).origin(); + } + + public static String resolvePathPrefix(String endpoint) { + return parseEndpoint(endpoint).pathPrefix(); + } + + private static EndpointParts parseEndpoint(String endpoint) { + if (StringUtil.isBlank(endpoint)) { + throw new ServiceException("MinIO endpoint 不能为空"); + } + URI uri = URI.create(endpoint.trim()); + if (StringUtil.isBlank(uri.getScheme()) || StringUtil.isBlank(uri.getHost())) { + throw new ServiceException("MinIO endpoint 配置不正确: " + endpoint); + } + StringBuilder origin = new StringBuilder(); + origin.append(uri.getScheme()).append("://").append(uri.getHost()); + if (uri.getPort() > 0) { + origin.append(':').append(uri.getPort()); + } + return new EndpointParts(origin.toString(), normalizePathPrefix(uri.getPath())); + } + + private static String normalizePathPrefix(String path) { + if (StringUtil.isBlank(path) || "/".equals(path)) { + return ""; + } + String normalized = path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + return normalized.startsWith("/") ? normalized : "/" + normalized; + } + + private static OkHttpClient buildHttpClient(String pathPrefix) { + return new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .addInterceptor(chain -> { + Request original = chain.request(); + HttpUrl originalUrl = original.url(); + String encodedPath = originalUrl.encodedPath(); + if (encodedPath.startsWith(pathPrefix + "/") || encodedPath.equals(pathPrefix)) { + return chain.proceed(original); + } + HttpUrl newUrl = originalUrl.newBuilder() + .encodedPath(pathPrefix + encodedPath) + .build(); + return chain.proceed(original.newBuilder().url(newUrl).build()); + }) + .build(); + } + + private record EndpointParts(String origin, String pathPrefix) { + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/config/VoucherMinioConfig.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/config/VoucherMinioConfig.java index 5c0db187..269eb1a5 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/config/VoucherMinioConfig.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/config/VoucherMinioConfig.java @@ -6,6 +6,9 @@ package org.springblade.transport.config; import io.minio.MinioClient; import lombok.extern.slf4j.Slf4j; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.tool.utils.StringUtil; import org.springframework.beans.factory.annotation.Value; @@ -14,34 +17,49 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.net.URI; +import java.util.concurrent.TimeUnit; /** * 凭证图片 MinIO 客户端配置。 *

* 连接参数由 Nacos 的 file.storage.minio 配置提供。 *

- * 未配置 {@code file.storage.minio.endpoint} 时不注册该客户端, - * 以免凭证上传功能缺失配置导致整个 blade-transport 服务无法启动。 */ @Slf4j @Configuration @ConditionalOnProperty(prefix = "file.storage.minio", name = "endpoint") public class VoucherMinioConfig { + private static final String DEFAULT_REGION = "us-east-1"; + @Bean public MinioClient voucherMinioClient( @Value("${file.storage.minio.endpoint:${minio.endpoint:}}") String endpoint, @Value("${file.storage.minio.access-key-id:${minio.access-key:}}") String accessKey, @Value("${file.storage.minio.access-key-secret:${minio.secret-key:}}") String secretKey) { - String clientEndpoint = resolveClientEndpoint(endpoint); - if (!clientEndpoint.equals(endpoint == null ? null : endpoint.trim())) { - log.warn("MinIO endpoint 含 path,已剥离为 {}(原值={})。服务端读写请配置内网 endpoint,外网请用 transform-endpoint", - clientEndpoint, endpoint); + String origin = resolveClientEndpoint(endpoint); + String pathPrefix = resolvePathPrefix(endpoint); + if (StringUtil.isNotBlank(pathPrefix)) { + log.warn("MinIO endpoint 含 path,将按 origin={} pathPrefix={} 访问;建议 endpoint 使用内网地址", + origin, pathPrefix); } - return MinioClient.builder().endpoint(clientEndpoint).credentials(accessKey, secretKey).build(); + return create(endpoint, accessKey, secretKey); + } + + public static MinioClient create(String endpoint, String accessKey, String secretKey) { + String origin = resolveClientEndpoint(endpoint); + String pathPrefix = resolvePathPrefix(endpoint); + MinioClient.Builder builder = MinioClient.builder() + .endpoint(origin) + .credentials(accessKey, secretKey) + .region(DEFAULT_REGION); + if (StringUtil.isNotBlank(pathPrefix)) { + builder.httpClient(buildHttpClient(pathPrefix), true); + } + return builder.build(); } /** @@ -62,4 +80,34 @@ public class VoucherMinioConfig { } return origin.toString(); } + + private static String resolvePathPrefix(String endpoint) { + URI uri = URI.create(endpoint.trim()); + String path = uri.getPath(); + if (StringUtil.isBlank(path) || "/".equals(path)) { + return ""; + } + String normalized = path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + return normalized.startsWith("/") ? normalized : "/" + normalized; + } + + private static OkHttpClient buildHttpClient(String pathPrefix) { + return new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .addInterceptor(chain -> { + Request original = chain.request(); + HttpUrl originalUrl = original.url(); + String encodedPath = originalUrl.encodedPath(); + if (encodedPath.startsWith(pathPrefix + "/") || encodedPath.equals(pathPrefix)) { + return chain.proceed(original); + } + HttpUrl newUrl = originalUrl.newBuilder() + .encodedPath(pathPrefix + encodedPath) + .build(); + return chain.proceed(original.newBuilder().url(newUrl).build()); + }) + .build(); + } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/VoucherManageServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/VoucherManageServiceImpl.java index 6f6b7d4e..e01c3fc7 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/VoucherManageServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/VoucherManageServiceImpl.java @@ -1525,11 +1525,8 @@ public class VoucherManageServiceImpl extends BaseServiceImpl