🐛 MinIO 客户端支持 /oss 路径前缀并固定 region,修复合并分片失败

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-14 00:50:27 +08:00
parent 64b4ad99fc
commit fe78152515
6 changed files with 197 additions and 46 deletions
@@ -11,6 +11,7 @@ import org.springblade.file.service.impl.LocalFileServiceImpl;
import org.springblade.file.service.impl.MinioFileServiceImpl; import org.springblade.file.service.impl.MinioFileServiceImpl;
import org.springblade.file.service.impl.OssFileServiceImpl; import org.springblade.file.service.impl.OssFileServiceImpl;
import org.springblade.file.service.impl.ObsFileServiceImpl; import org.springblade.file.service.impl.ObsFileServiceImpl;
import org.springblade.file.util.MinioClientFactory;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@@ -65,19 +66,22 @@ public class FileConfig {
private IFileService buildMinioFileService(FileStorageProperties.StoreProperties properties) { private IFileService buildMinioFileService(FileStorageProperties.StoreProperties properties) {
validateStoreProperties(STORAGE_MINIO, 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()) if (StringUtil.isBlank(properties.getTransformEndpoint())
&& StringUtil.isNotBlank(properties.getEndpoint())
&& properties.getEndpoint().trim().toLowerCase().startsWith("http://")) { && properties.getEndpoint().trim().toLowerCase().startsWith("http://")) {
log.warn("MinIO transform-endpoint 未配置,endpoint={}。HTTPS 页面分片直传会被浏览器 Mixed Content 拦截,将回退为服务端代传", log.warn("MinIO transform-endpoint 未配置,endpoint={}。HTTPS 页面分片直传会被浏览器 Mixed Content 拦截,将回退为服务端代传",
properties.getEndpoint()); properties.getEndpoint());
} else { } else {
log.info("MinIO 文件存储已启用 endpoint={} transformEndpoint={}", log.info("MinIO 文件存储已启用 endpoint={} pathPrefix={} transformEndpoint={}",
properties.getEndpoint(), properties.getTransformEndpoint()); endpointOrigin, endpointPathPrefix, properties.getTransformEndpoint());
} }
MinioClient minioClient = MinioClient.builder() MinioClient minioClient = MinioClientFactory.create(
.endpoint(properties.getEndpoint()) properties.getEndpoint(), properties.getAccessKeyId(), properties.getAccessKeySecret());
.credentials(properties.getAccessKeyId(), properties.getAccessKeySecret())
.build();
return new MinioFileServiceImpl(properties, minioClient); return new MinioFileServiceImpl(properties, minioClient);
} }
@@ -40,6 +40,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springblade.core.log.exception.ServiceException; import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.tool.utils.StringUtil; import org.springblade.core.tool.utils.StringUtil;
import org.springblade.file.config.FileStorageProperties; import org.springblade.file.config.FileStorageProperties;
import org.springblade.file.util.MinioClientFactory;
import org.springblade.file.pojo.vo.PartEtagVO; import org.springblade.file.pojo.vo.PartEtagVO;
import java.io.File; import java.io.File;
@@ -65,8 +66,7 @@ import java.util.UUID;
public class MinioFileServiceImpl extends AbstractFileService { public class MinioFileServiceImpl extends AbstractFileService {
private static final long PART_SIZE = 10 * 1024 * 1024L; private static final long PART_SIZE = 10 * 1024 * 1024L;
/** MinIO 默认 region;固定后预签名无需再请求外网域名做 GetBucketLocation */ private static final String DEFAULT_REGION = MinioClientFactory.DEFAULT_REGION;
private static final String DEFAULT_REGION = "us-east-1";
private final FileStorageProperties.StoreProperties properties; private final FileStorageProperties.StoreProperties properties;
private final MinioClient minioClient; private final MinioClient minioClient;
@@ -216,10 +216,7 @@ public class MinioFileServiceImpl extends AbstractFileService {
/** /**
* 浏览器侧预签名优先使用外网地址,避免 HTTPS 页面访问 HTTP MinIO 被 Mixed Content 拦截。 * 浏览器侧预签名优先使用外网地址,避免 HTTPS 页面访问 HTTP MinIO 被 Mixed Content 拦截。
* <p>
* MinIO SDK 不允许 endpoint 带 path,因此外网地址中的 /oss 等前缀在签名后单独拼回。 * MinIO SDK 不允许 endpoint 带 path,因此外网地址中的 /oss 等前缀在签名后单独拼回。
* 外网域名通常是业务网关而非直连 MinIO,必须固定 region,否则 SDK 会请求 GetBucketLocation
* 并拿到 HTML 页面导致 XmlParserException。
*/ */
private MinioClient resolvePresignClient() { private MinioClient resolvePresignClient() {
String transformEndpoint = properties.getTransformEndpoint(); String transformEndpoint = properties.getTransformEndpoint();
@@ -232,29 +229,13 @@ public class MinioFileServiceImpl extends AbstractFileService {
} }
synchronized (this) { synchronized (this) {
if (transformPresignClient == null) { if (transformPresignClient == null) {
transformPresignClient = MinioClient.builder() transformPresignClient = MinioClientFactory.create(
.endpoint(resolveTransformOrigin(transformEndpoint)) transformEndpoint, properties.getAccessKeyId(), properties.getAccessKeySecret());
.credentials(properties.getAccessKeyId(), properties.getAccessKeySecret())
.region(DEFAULT_REGION)
.build();
} }
return transformPresignClient; 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) { private String applyTransformPathPrefix(String presignedUrl) {
String transformEndpoint = properties.getTransformEndpoint(); String transformEndpoint = properties.getTransformEndpoint();
if (StringUtil.isBlank(transformEndpoint) || StringUtil.isBlank(presignedUrl)) { if (StringUtil.isBlank(transformEndpoint) || StringUtil.isBlank(presignedUrl)) {
@@ -0,0 +1,121 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* 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 客户端构建。
* <p>
* SDK 不允许 endpoint 带 path;若配置为 {@code https://host/oss},则:
* <ul>
* <li>endpoint 使用 origin</li>
* <li>通过 OkHttp 拦截器把 /oss 拼回每个 API 请求</li>
* <li>固定 region,避免 GetBucketLocation 打到业务网关拿到 HTML</li>
* </ul>
*/
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) {
}
}
@@ -6,6 +6,9 @@ package org.springblade.transport.config;
import io.minio.MinioClient; import io.minio.MinioClient;
import lombok.extern.slf4j.Slf4j; 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.log.exception.ServiceException;
import org.springblade.core.tool.utils.StringUtil; import org.springblade.core.tool.utils.StringUtil;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -14,34 +17,49 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import java.net.URI; import java.net.URI;
import java.util.concurrent.TimeUnit;
/** /**
* 凭证图片 MinIO 客户端配置。 * 凭证图片 MinIO 客户端配置。
* <p> * <p>
* 连接参数由 Nacos 的 file.storage.minio 配置提供。 * 连接参数由 Nacos 的 file.storage.minio 配置提供。
* <ul> * <ul>
* <li>{@code endpoint}:内网地址,供服务端 put/get,不能带 path</li> * <li>{@code endpoint}:建议内网地址;若误配为 https://host/oss,会自动剥离 path 并用拦截器补回</li>
* <li>{@code transform-endpoint}:外网地址(可带 /oss),仅用于浏览器预签名</li> * <li>{@code transform-endpoint}:外网地址(可带 /oss),用于浏览器预签名</li>
* </ul> * </ul>
* 未配置 {@code file.storage.minio.endpoint} 时不注册该客户端,
* 以免凭证上传功能缺失配置导致整个 blade-transport 服务无法启动。
*/ */
@Slf4j @Slf4j
@Configuration @Configuration
@ConditionalOnProperty(prefix = "file.storage.minio", name = "endpoint") @ConditionalOnProperty(prefix = "file.storage.minio", name = "endpoint")
public class VoucherMinioConfig { public class VoucherMinioConfig {
private static final String DEFAULT_REGION = "us-east-1";
@Bean @Bean
public MinioClient voucherMinioClient( public MinioClient voucherMinioClient(
@Value("${file.storage.minio.endpoint:${minio.endpoint:}}") String endpoint, @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-id:${minio.access-key:}}") String accessKey,
@Value("${file.storage.minio.access-key-secret:${minio.secret-key:}}") String secretKey) { @Value("${file.storage.minio.access-key-secret:${minio.secret-key:}}") String secretKey) {
String clientEndpoint = resolveClientEndpoint(endpoint); String origin = resolveClientEndpoint(endpoint);
if (!clientEndpoint.equals(endpoint == null ? null : endpoint.trim())) { String pathPrefix = resolvePathPrefix(endpoint);
log.warn("MinIO endpoint 含 path,已剥离为 {}(原值={})。服务端读写请配置内网 endpoint,外网请用 transform-endpoint", if (StringUtil.isNotBlank(pathPrefix)) {
clientEndpoint, endpoint); 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(); 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();
}
} }
@@ -1525,11 +1525,8 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
} }
synchronized (this) { synchronized (this) {
if (transformPresignClient == null) { if (transformPresignClient == null) {
transformPresignClient = MinioClient.builder() transformPresignClient = org.springblade.transport.config.VoucherMinioConfig.create(
.endpoint(org.springblade.transport.config.VoucherMinioConfig.resolveClientEndpoint(minioTransformEndpoint)) minioTransformEndpoint, minioAccessKey, minioSecretKey);
.credentials(minioAccessKey, minioSecretKey)
.region("us-east-1")
.build();
} }
return transformPresignClient; return transformPresignClient;
} }
+2 -2
View File
@@ -121,8 +121,8 @@ baidu:
token-refresh-advance: 1m token-refresh-advance: 1m
# 对象存储(blade-file 分片上传 + blade-transport 凭证读写共用) # 对象存储(blade-file 分片上传 + blade-transport 凭证读写共用)
# endpoint:内网地址,禁止带 path,供服务端 put/get(如 http://tms-prod-oss:9000 或 http://IP:19000) # endpoint:强烈建议内网 MinIO(http://IP:19000);若只能走网关可填 https://tms-prod.xinghy56.com/oss(代码会自动处理 /oss)
# transform-endpoint:外网 HTTPS,可带 /oss,仅用于浏览器预签名;切勿把该值填到 endpoint # transform-endpoint:浏览器预签名外网地址
file: file:
storage: storage:
minio: minio: