| | |
| | | import com.ard.oss.service.impl.OssServiceContext; |
| | | import com.ard.sample.media.Response.S3ErrorResponse; |
| | | import com.ard.sample.media.Response.S3PutObjectResponse; |
| | | import io.minio.BucketExistsArgs; |
| | | import io.minio.MinioClient; |
| | | import io.minio.PutObjectArgs; |
| | | import io.minio.credentials.StaticProvider; |
| | | import io.minio.errors.ErrorResponseException; |
| | | import io.minio.errors.MinioException; |
| | | import io.minio.messages.ErrorResponse; |
| | | import io.swagger.annotations.Api; |
| | | import io.swagger.annotations.ApiOperation; |
| | | import lombok.extern.slf4j.Slf4j; |
| | |
| | | import org.springframework.http.ResponseEntity; |
| | | import org.springframework.web.bind.annotation.*; |
| | | |
| | | import javax.servlet.http.HttpServletRequest; |
| | | import java.io.ByteArrayInputStream; |
| | | import java.io.IOException; |
| | | import java.net.URLDecoder; |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.security.InvalidKeyException; |
| | | import java.security.NoSuchAlgorithmException; |
| | | import java.time.OffsetDateTime; |
| | | import java.time.format.DateTimeFormatter; |
| | | import java.util.HashMap; |
| | | import java.util.Map; |
| | | |
| | | /** |
| | | * 适配 MinIO SDK 8.3.7 + 服务器 RELEASE.2024-08-03 |
| | |
| | | @Autowired |
| | | private OssServiceContext ossService; |
| | | |
| | | @ApiOperation("minio上传/创建目录") |
| | | // 关键修改:{objectName:.*} 支持路径中包含斜杠,适配多层级文件/目录 |
| | | @PutMapping(value = "/{bucketName}/{objectName:.*}", produces = MediaType.APPLICATION_XML_VALUE) |
| | | public ResponseEntity<?> proxyUpload( |
| | | // S3标准错误码 -> HTTP状态码 映射表(行业通用标准) |
| | | private static final Map<String, HttpStatus> S3_ERROR_CODE_MAP; |
| | | |
| | | // 初始化常见错误映射 |
| | | static { |
| | | S3_ERROR_CODE_MAP = new HashMap<>(); |
| | | S3_ERROR_CODE_MAP.put("NoSuchBucket", HttpStatus.NOT_FOUND); |
| | | S3_ERROR_CODE_MAP.put("NoSuchKey", HttpStatus.NOT_FOUND); |
| | | S3_ERROR_CODE_MAP.put("AccessDenied", HttpStatus.FORBIDDEN); |
| | | S3_ERROR_CODE_MAP.put("InvalidCredentials", HttpStatus.FORBIDDEN); |
| | | S3_ERROR_CODE_MAP.put("BucketAlreadyExists", HttpStatus.CONFLICT); |
| | | S3_ERROR_CODE_MAP.put("InvalidRequest", HttpStatus.BAD_REQUEST); |
| | | } |
| | | |
| | | /** |
| | | * 完全复刻 MinIO 原生 PUT 上传接口 |
| | | * 通配符匹配所有路径,保留原始URI格式 |
| | | */ |
| | | @ApiOperation("MinIO原生PUT接口代理(无感中转)") |
| | | @PutMapping(value = "/{bucketName}/**", consumes = MediaType.ALL_VALUE) |
| | | public ResponseEntity<?> proxyMinioPutObject( |
| | | @PathVariable String bucketName, |
| | | @PathVariable String objectName, |
| | | // 允许请求体为空(目录创建场景) |
| | | @RequestBody(required = false) byte[] fileContent, |
| | | @RequestHeader(value = "Content-Type", defaultValue = "application/octet-stream") String contentType |
| | | // 透传大疆设备原始请求体,不修改、不封装 |
| | | @RequestBody(required = false) byte[] rawRequestBody, |
| | | HttpServletRequest request |
| | | ) { |
| | | // 基础参数校验 |
| | | if (bucketName.isBlank() || objectName.isBlank()) { |
| | | return buildS3Error("InvalidRequest", "存储桶/对象名称不能为空", "/" + bucketName + "/" + objectName); |
| | | } |
| | | log.info("接收参数bucketName:{}",bucketName); |
| | | // ===================== 1. 解析原始路径:保留所有斜杠/末尾分隔符,完全对齐原生MinIO ===================== |
| | | String rawUri = request.getRequestURI(); |
| | | String requestUri = URLDecoder.decode(rawUri, StandardCharsets.UTF_8); |
| | | String bucketPrefix = "/" + bucketName; |
| | | // 提取原始ObjectKey,无任何修改 |
| | | String objectKey = requestUri.substring(bucketPrefix.length()); |
| | | |
| | | // 标识:是否为目录创建请求(对象名以 / 结尾) |
| | | boolean isCreateDirectory = objectName.endsWith("/"); |
| | | // 处理空数据:目录创建/空请求体时,使用空字节数组 |
| | | byte[] actualContent = (fileContent == null) ? new byte[0] : fileContent; |
| | | log.info("中转MinIO PUT请求 | 桶名:{} | 原始对象路径:{} | 请求体大小:{}byte", |
| | | bucketName, objectKey, rawRequestBody == null ? 0 : rawRequestBody.length); |
| | | |
| | | log.info("开始代理操作 | 存储桶:{} | 对象路径:{} | 操作类型:{}", |
| | | bucketName, objectName, isCreateDirectory ? "创建目录" : "上传文件"); |
| | | |
| | | try (ByteArrayInputStream inputStream = new ByteArrayInputStream(actualContent)) { |
| | | // ===================== 保留你原有逻辑:获取临时凭证 ===================== |
| | | CredentialsDTO credentials = ossService.getCredentials(); |
| | | try { |
| | | // ===================== 2. 复用原有逻辑:获取临时凭证 ===================== |
| | | var credentials = ossService.getCredentials(); |
| | | if (credentials == null || credentials.getAccessKeyId() == null || credentials.getSecurityToken() == null) { |
| | | log.error("MinIO临时凭证获取失败"); |
| | | return buildS3Error("InvalidCredentials", "临时凭证无效", "/" + bucketName + "/" + objectName); |
| | | log.error("临时凭证获取失败"); |
| | | return buildNativeS3Error("InvalidCredentials", "临时凭证无效", requestUri, HttpStatus.FORBIDDEN); |
| | | } |
| | | |
| | | // ===================== 保留你原有逻辑:构建MinioClient ===================== |
| | | // ===================== 3. 复用原有逻辑:构建MinIO客户端 ===================== |
| | | MinioClient minioClient = MinioClient.builder() |
| | | .endpoint(OssConfiguration.endpoint) |
| | | .credentialsProvider(new StaticProvider( |
| | |
| | | )) |
| | | .build(); |
| | | |
| | | // ===================== 核心上传逻辑:兼容文件/目录/多层级路径 ===================== |
| | | minioClient.putObject( |
| | | PutObjectArgs.builder() |
| | | .bucket(bucketName) |
| | | .object(objectName) |
| | | .stream(inputStream, actualContent.length, -1) |
| | | .contentType(contentType) |
| | | .build() |
| | | ); |
| | | // 校验桶存在性(兼容原生接口行为,桶不存在返回404) |
| | | boolean bucketExists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build()); |
| | | if (!bucketExists) { |
| | | return buildNativeS3Error("NoSuchBucket", "指定存储桶不存在", requestUri, HttpStatus.NOT_FOUND); |
| | | } |
| | | |
| | | // 生成标准响应参数 |
| | | String eTag = "\"" + DigestUtils.md5Hex(actualContent) + "\""; |
| | | String lastModified = OffsetDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); |
| | | S3PutObjectResponse successResponse = new S3PutObjectResponse(eTag, lastModified); |
| | | // ===================== 4. 透传请求:原样转发大疆原始数据到MinIO,无业务篡改 ===================== |
| | | // 空请求体=大疆原生创建目录请求,直接透传;带请求体=上传文件,原生处理 |
| | | byte[] requestBody = rawRequestBody == null ? new byte[0] : rawRequestBody; |
| | | try (ByteArrayInputStream inputStream = new ByteArrayInputStream(requestBody)) { |
| | | minioClient.putObject( |
| | | PutObjectArgs.builder() |
| | | .bucket(bucketName) |
| | | .object(objectKey) |
| | | // 透传原始流、原始长度,完全复刻原生请求 |
| | | .stream(inputStream, requestBody.length, -1) |
| | | // 透传原生Content-Type,无修改 |
| | | .contentType(request.getContentType() == null ? "application/octet-stream" : request.getContentType()) |
| | | .build() |
| | | ); |
| | | } |
| | | |
| | | log.info("操作成功 | 路径:{} | ETag:{}", objectName, eTag); |
| | | // ===================== 5. 原生接口成功响应:200 OK + 空响应体(核心对齐!)===================== |
| | | log.info("中转上传成功 | 路径:{}", objectKey); |
| | | return ResponseEntity.ok() |
| | | .contentType(MediaType.APPLICATION_XML) |
| | | .body(successResponse); |
| | | // 无任何响应体,与MinIO原生行为完全一致 |
| | | .body(null); |
| | | |
| | | } catch (MinioException e) { |
| | | log.error("MinIO服务异常: {}", e.getMessage(), e); |
| | | return buildS3Error("ClientError", e.getMessage(), "/" + bucketName + "/" + objectName); |
| | | } catch (InvalidKeyException | NoSuchAlgorithmException e) { |
| | | log.error("凭证/加密异常: {}", e.getMessage(), e); |
| | | return buildS3Error("CredentialError", "凭证校验失败", "/" + bucketName + "/" + objectName); |
| | | } catch (IOException e) { |
| | | log.error("文件流处理异常: {}", e.getMessage(), e); |
| | | return buildS3Error("IOError", "文件数据流处理失败", "/" + bucketName + "/" + objectName); |
| | | } catch (ErrorResponseException e) { |
| | | // ===================== 最终修正:无任何无效方法调用 ===================== |
| | | ErrorResponse nativeError = e.errorResponse(); |
| | | String errorCode = nativeError.code(); |
| | | // 映射状态码,无匹配则默认500 |
| | | HttpStatus status = S3_ERROR_CODE_MAP.getOrDefault(errorCode, HttpStatus.INTERNAL_SERVER_ERROR); |
| | | |
| | | log.error("MinIO服务错误 | 错误码:{} | 信息:{}", errorCode, nativeError.message()); |
| | | // 透传MinIO原生XML错误体,大疆设备完美兼容 |
| | | return ResponseEntity.status(status) |
| | | .contentType(MediaType.APPLICATION_XML) |
| | | .body(nativeError); |
| | | } catch (MinioException e) { |
| | | log.error("MinIO客户端异常:{}", e.getMessage()); |
| | | return buildNativeS3Error("ClientError", e.getMessage(), requestUri, HttpStatus.INTERNAL_SERVER_ERROR); |
| | | } catch (Exception e) { |
| | | log.error("通用服务异常: {}", e.getMessage(), e); |
| | | return buildS3Error("InternalError", "服务器内部异常", "/" + bucketName + "/" + objectName); |
| | | log.error("代理中转通用异常:{}", e.getMessage(), e); |
| | | return buildNativeS3Error("InternalError", "服务器代理异常", requestUri, HttpStatus.INTERNAL_SERVER_ERROR); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 构造S3标准XML错误响应 |
| | | * 兜底构造S3标准XML错误响应,兼容大疆设备解析 |
| | | */ |
| | | private ResponseEntity<S3ErrorResponse> buildS3Error(String code, String message, String resource) { |
| | | S3ErrorResponse errorResponse = new S3ErrorResponse(code, message, resource); |
| | | return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) |
| | | private ResponseEntity<S3ErrorResponse> buildNativeS3Error(String code, String message, String resource, HttpStatus status) { |
| | | S3ErrorResponse error = new S3ErrorResponse(code, message, resource); |
| | | return ResponseEntity.status(status) |
| | | .contentType(MediaType.APPLICATION_XML) |
| | | .body(errorResponse); |
| | | .body(error); |
| | | } |
| | | } |