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 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); } } }