zhaowenyu
2026-02-11 a12665923c5ec933e2c92ea74135680cc8b3af4a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
package com.ard.web.controller.sample.media;
 
import com.ard.oss.model.CredentialsDTO;
import com.ard.oss.service.impl.OssServiceContext;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import okhttp3.*;
import okio.BufferedSink;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
 
import javax.net.ssl.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.util.Enumeration;
import java.util.concurrent.TimeUnit;
 
/**
 * MinIO STS代理控制器(适配分块上传全流程:初始化/传块/合并)
 * 解决SSL连接重置、鉴权透传、分块合并XML透传问题
 */
@Api(tags = "机场上传minio")
@Slf4j
@RestController
@RequestMapping("/minioTransfer")
public class MinioStsProxyController {
 
    // ==================== 常量配置 ====================
    private static final String PROXY_PREFIX = "/minioTransfer";
    private static final int BUFFER_SIZE = 65536; // 64K缓冲区
    private static final long MAX_MEMORY_SIZE = 1024 * 1024 * 100; // 100MB(小文件阈值)
    private static final String X_AMZ_SECURITY_TOKEN = "X-Amz-Security-Token";
    private static final String TRANSFER_ENCODING = "Transfer-Encoding";
    private static final String HOST = "Host";
 
    // 配置注入(支持yml配置,避免硬编码)
    @Value("${oss.endpoint}")
    private String minioEndpoint;
    private static final Integer connectTimeout = 30;
    private static final Integer readWriteTimeout = 1800;
 
    @Autowired
    private OssServiceContext ossService;
 
    // OkHttp客户端(全局单例,优化SSL/TCP)
    private static OkHttpClient httpClient;
 
    // 静态初始化:SSL/TCP优化(适配MinIO内网环境)
    static {
        try {
            // 1. 全局信任所有SSL证书(内网专用,生产需替换合法证书)
            TrustManager[] trustAllCerts = new TrustManager[]{
                    new X509TrustManager() {
                        @Override
                        public void checkClientTrusted(X509Certificate[] chain, String authType) {}
                        @Override
                        public void checkServerTrusted(X509Certificate[] chain, String authType) {}
                        @Override
                        public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
                    }
            };
 
            // 2. 强制TLS 1.2(解决MinIO SSL版本兼容问题)
            SSLContext sslContext = SSLContext.getInstance("TLSv1.2");
            sslContext.init(null, trustAllCerts, new SecureRandom());
 
            // 3. 构建高兼容性OkHttp客户端
            httpClient = new OkHttpClient.Builder()
                    .sslSocketFactory(sslContext.getSocketFactory(), (X509TrustManager) trustAllCerts[0])
                    .hostnameVerifier((hostname, session) -> true) // 禁用主机名校验
                    .connectTimeout(30, TimeUnit.SECONDS)
                    .readTimeout(1800, TimeUnit.SECONDS) // 30分钟读超时(适配大文件)
                    .writeTimeout(1800, TimeUnit.SECONDS) // 30分钟写超时
                    .connectionPool(new ConnectionPool(30, 10, TimeUnit.MINUTES)) // 扩大连接池
                    .retryOnConnectionFailure(true) // 开启连接重试
                    .pingInterval(10, TimeUnit.SECONDS) // TCP保活(防止连接被重置)
                    .callTimeout(60, TimeUnit.SECONDS)
                    .build();
 
            log.info("OkHttp客户端初始化完成(适配MinIO分块上传)");
        } catch (Exception e) {
            log.error("OkHttp初始化失败,降级为默认客户端", e);
            httpClient = new OkHttpClient();
        }
    }
 
    /**
     * 核心代理方法:适配MinIO分块上传全流程(POST初始化/PUT传块/POST合并)
     */
    @ApiOperation("MinIO STS临时凭证通用代理(支持分块上传)")
    @RequestMapping("/**")
    public void proxyRequest(HttpServletRequest request, HttpServletResponse response) {
        String method = request.getMethod();
        String requestUri = request.getRequestURI();
        String queryString = request.getQueryString();
        long contentLength = request.getContentLengthLong();
 
        // 日志增强:区分分块上传阶段
        String uploadStage = getUploadStage(queryString);
        log.info("STS代理请求开始 | 阶段:{} | 方法:{} | URI:{} | 参数:{} | 内容长度:{}B",
                uploadStage, method, requestUri, queryString, contentLength);
 
        // 1. 构造MinIO目标地址
        String targetUrl = buildTargetUrl(requestUri, queryString);
        if (targetUrl == null) {
            sendError(response, HttpServletResponse.SC_BAD_REQUEST, "请求路径非法(缺失MinIO前缀)");
            return;
        }
 
        // 2. 获取并校验STS凭证(鉴权核心)
        CredentialsDTO credentials = getCredentials();
        if (credentials == null) {
            sendError(response, HttpServletResponse.SC_FORBIDDEN, "STS凭证无效/缺失");
            return;
        }
 
        // 3. 构建请求+转发响应(核心逻辑)
        try {
            Request okRequest = buildRequest(request, method, targetUrl, credentials);
            forwardResponse(okRequest, response);
            log.info("STS代理请求成功 | 阶段:{} | 目标地址:{}", uploadStage, targetUrl);
        } catch (Exception e) {
            log.error("STS代理请求执行异常 | 阶段:{}", uploadStage, e);
            // 友好错误提示:区分SSL/分块/鉴权错误
            String errorMsg = e.getMessage().contains("SSL") ? "SSL连接异常(请检查MinIO HTTPS配置)" :
                    e.getMessage().contains("Credentials") ? "STS凭证异常" : "代理服务异常";
            sendError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, errorMsg);
        }
    }
 
    /**
     * 识别分块上传阶段(日志优化)
     */
    private String getUploadStage(String queryString) {
        if (queryString == null) return "普通请求";
        if (queryString.contains("uploads") && !queryString.contains("uploadId")) return "分块初始化";
        if (queryString.contains("partNumber") && queryString.contains("uploadId")) return "分块上传";
        if (queryString.contains("uploadId") && !queryString.contains("partNumber")) return "分块合并";
        return "普通请求";
    }
 
    /**
     * 构造MinIO目标地址(兼容分块参数透传)
     */
    private String buildTargetUrl(String requestUri, String queryString) {
        try {
            // 截取代理前缀后的路径(/minioTransfer/xxx → xxx)
            String targetPath = requestUri.substring(PROXY_PREFIX.length());
            StringBuilder urlBuilder = new StringBuilder(minioEndpoint).append(targetPath);
            // 透传分块参数(uploads/uploadId/partNumber)
            if (queryString != null && !queryString.isBlank()) {
                urlBuilder.append("?").append(queryString);
            }
            return urlBuilder.toString();
        } catch (Exception e) {
            log.error("构造MinIO目标地址失败", e);
            return null;
        }
    }
 
    /**
     * 获取并校验STS凭证(确保AK/SK/Token完整)
     */
    private CredentialsDTO getCredentials() {
        try {
            CredentialsDTO dto = ossService.getCredentials();
            // 严格校验核心字段(避免鉴权失败)
            if (dto == null || dto.getAccessKeyId() == null || dto.getAccessKeySecret() == null || dto.getSecurityToken() == null) {
                log.error("STS凭证核心字段缺失:AK/SK/Token不能为空");
                return null;
            }
            log.info("STS凭证获取成功 | AK前缀:{}", dto.getAccessKeyId().substring(0, 4) + "***");
            return dto;
        } catch (Exception e) {
            log.error("获取STS凭证异常", e);
            return null;
        }
    }
 
    /**
     * 构建OkHttp请求(适配分块上传的请求体/头透传)
     */
    private Request buildRequest(HttpServletRequest request, String method, String url, CredentialsDTO credentials) throws IOException {
        Request.Builder builder = new Request.Builder().url(url);
 
        // 1. 透传所有请求头(排除Host,OkHttp自动处理)
        Enumeration<String> headerNames = request.getHeaderNames();
        while (headerNames.hasMoreElements()) {
            String name = headerNames.nextElement();
            if (!name.equalsIgnoreCase(HOST)) {
                builder.addHeader(name, request.getHeader(name));
            }
        }
 
        // 2. 添加STS鉴权头(MinIO分块上传必须)
        builder.addHeader(X_AMZ_SECURITY_TOKEN, credentials.getSecurityToken());
        // 补充MinIO鉴权必要的头(防止签名失败)
        builder.addHeader("X-Amz-Date", String.valueOf(System.currentTimeMillis()));
 
        // 3. 构建请求体(区分小文件/大文件/分块合并XML)
        RequestBody body = null;
        if (!"GET".equalsIgnoreCase(method) && !"HEAD".equalsIgnoreCase(method)) {
            InputStream inputStream = request.getInputStream();
            long contentLength = request.getContentLengthLong();
 
            // 小文件:预读取到内存(稳定,适配分块上传的小文件块)
            if (contentLength > 0 && contentLength <= MAX_MEMORY_SIZE) {
                log.info("小文件/分块上传:预读取到内存,大小:{:.2f}MB", contentLength / 1024.0 / 1024.0);
                byte[] content = readInputStreamToBytes(inputStream);
                MediaType mediaType = MediaType.parse(request.getContentType() == null ? "application/xml" : request.getContentType());
                body = RequestBody.create(content, mediaType);
            }
            // 大文件/未知长度:流式上传(省内存,适配大文件分块)
            else {
                log.info("大文件/未知长度上传:使用流式传输(适配分块上传)");
                body = new RequestBody() {
                    @Override
                    public MediaType contentType() {
                        return MediaType.parse(request.getContentType() == null ? "application/xml" : request.getContentType());
                    }
 
                    @Override
                    public long contentLength() {
                        return contentLength; // 避免chunked编码,适配MinIO分块
                    }
 
                    @Override
                    public void writeTo(BufferedSink sink) throws IOException {
                        byte[] buffer = new byte[BUFFER_SIZE];
                        int len;
                        long totalWritten = 0;
                        try (InputStream in = inputStream) {
                            while ((len = in.read(buffer)) != -1) {
                                sink.write(buffer, 0, len);
                                totalWritten += len;
                                // 每100MB刷新一次(防止缓冲区溢出)
                                if (totalWritten % (1024 * 1024 * 100) == 0) {
                                    sink.flush();
                                    log.info("分块上传进度:已写入{:.2f}MB", totalWritten / 1024.0 / 1024.0);
                                }
                            }
                            sink.flush();
                            log.info("流式上传完成,总写入:{:.2f}MB", totalWritten / 1024.0 / 1024.0);
                        }
                    }
                };
            }
        }
 
        return builder.method(method, body).build();
    }
 
    /**
     * 读取InputStream到字节数组(小文件/分块专用)
     */
    private byte[] readInputStreamToBytes(InputStream inputStream) throws IOException {
        try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
            byte[] buffer = new byte[BUFFER_SIZE];
            int len;
            while ((len = inputStream.read(buffer)) != -1) {
                bos.write(buffer, 0, len);
            }
            bos.flush();
            return bos.toByteArray();
        }
    }
 
    /**
     * 转发MinIO响应到客户端(关键:透传ETag/UploadId等分块参数)
     */
    private void forwardResponse(Request request, HttpServletResponse response) throws IOException {
        try (Response okResponse = httpClient.newCall(request).execute()) {
            // 1. 基础响应配置(确保分块合并的XML响应正常透传)
            response.setStatus(okResponse.code());
            response.setCharacterEncoding("UTF-8");
            String contentType = okResponse.header("Content-Type");
            if (contentType != null) {
                response.setContentType(contentType); // 透传XML/JSON类型
            }
 
            // 2. 透传所有响应头(重点:ETag/UploadId等分块核心参数)
            Headers headers = okResponse.headers();
            for (String name : headers.names()) {
                // 排除Transfer-Encoding(防止Tomcat重复编码)
                if (!name.equalsIgnoreCase(TRANSFER_ENCODING)) {
                    response.setHeader(name, headers.get(name));
                }
            }
 
            // 3. 透传响应体(分块初始化的UploadId、合并的成功响应等)
            ResponseBody responseBody = okResponse.body();
            if (responseBody != null) {
                try (InputStream in = responseBody.byteStream()) {
                    byte[] buffer = new byte[BUFFER_SIZE];
                    int len;
                    while ((len = in.read(buffer)) != -1) {
                        response.getOutputStream().write(buffer, 0, len);
                    }
                    response.getOutputStream().flush();
                }
            }
        }
    }
 
    /**
     * 统一返回错误响应
     */
    private void sendError(HttpServletResponse response, int code, String msg) {
        try {
            response.sendError(code, msg);
        } catch (IOException e) {
            log.error("发送错误响应失败", e);
        }
    }
}