🐛 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.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);
}
@@ -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 拦截。
* <p>
* 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)) {
@@ -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 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 客户端配置。
* <p>
* 连接参数由 Nacos 的 file.storage.minio 配置提供。
* <ul>
* <li>{@code endpoint}:内网地址,供服务端 put/get,不能带 path</li>
* <li>{@code transform-endpoint}:外网地址(可带 /oss),仅用于浏览器预签名</li>
* <li>{@code endpoint}:建议内网地址;若误配为 https://host/oss,会自动剥离 path 并用拦截器补回</li>
* <li>{@code transform-endpoint}:外网地址(可带 /oss),用于浏览器预签名</li>
* </ul>
* 未配置 {@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();
}
}
@@ -1525,11 +1525,8 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
}
synchronized (this) {
if (transformPresignClient == null) {
transformPresignClient = MinioClient.builder()
.endpoint(org.springblade.transport.config.VoucherMinioConfig.resolveClientEndpoint(minioTransformEndpoint))
.credentials(minioAccessKey, minioSecretKey)
.region("us-east-1")
.build();
transformPresignClient = org.springblade.transport.config.VoucherMinioConfig.create(
minioTransformEndpoint, minioAccessKey, minioSecretKey);
}
return transformPresignClient;
}
+2 -2
View File
@@ -121,8 +121,8 @@ baidu:
token-refresh-advance: 1m
# 对象存储(blade-file 分片上传 + blade-transport 凭证读写共用)
# endpoint:内网地址,禁止带 path,供服务端 put/get(如 http://tms-prod-oss:9000 或 http://IP:19000)
# transform-endpoint:外网 HTTPS,可带 /oss,仅用于浏览器预签名;切勿把该值填到 endpoint
# endpoint:强烈建议内网 MinIO(http://IP:19000);若只能走网关可填 https://tms-prod.xinghy56.com/oss(代码会自动处理 /oss)
# transform-endpoint:浏览器预签名外网地址
file:
storage:
minio: