🐛 MinIO 客户端支持 /oss 路径前缀并固定 region,修复合并分片失败
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+4
-23
@@ -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)) {
|
||||
|
||||
+121
@@ -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) {
|
||||
}
|
||||
}
|
||||
+57
-9
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+2
-5
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user