Commit c4f3408f by Lizh

http/https封装工具增加文件上传逻辑的处理,并且低调工具的目录到integrate目录下

parent c43128f2
...@@ -46,17 +46,5 @@ ...@@ -46,17 +46,5 @@
<artifactId>spring-boot-starter-aop</artifactId> <artifactId>spring-boot-starter-aop</artifactId>
<version>4.0.0-M2</version> <version>4.0.0-M2</version>
</dependency> </dependency>
<!-- WebClient for HTTP calls -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- Resilience4j for circuit breaker and retry -->
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>2.2.0</version>
</dependency>
</dependencies> </dependencies>
</project> </project>
...@@ -25,5 +25,21 @@ ...@@ -25,5 +25,21 @@
<artifactId>commons-collections</artifactId> <artifactId>commons-collections</artifactId>
<version>3.2.2</version> <version>3.2.2</version>
</dependency> </dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- WebClient for HTTP calls -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- Resilience4j for circuit breaker and retry -->
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>2.2.0</version>
</dependency>
</dependencies> </dependencies>
</project> </project>
package com.jomalls.custom.app.client; package com.jomalls.custom.integrate.client;
import com.jomalls.custom.app.exception.RemoteServiceException; import com.jomalls.custom.integrate.exception.RemoteServiceException;
import io.github.resilience4j.retry.annotation.Retry; import io.github.resilience4j.retry.annotation.Retry;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException; import org.springframework.web.reactive.function.client.WebClientResponseException;
...@@ -16,24 +24,27 @@ import java.util.Map; ...@@ -16,24 +24,27 @@ import java.util.Map;
/** /**
* 通用 REST 客户端 * 通用 REST 客户端
* 基于 WebClient,集成 Resilience4j 重试能力。 * 基于 WebClient,集成 Resilience4j 重试能力,支持 JSON 请求和文件上传
* <p> * <p>
* 使用方式: * 使用方式:
* <pre> * <pre>
* @Autowired * @Autowired
* private RemoteApiClient remoteApiClient; * private RemoteApiClient remoteApiClient;
* <p> * <p>
* // GET 简单对象 * // GET
* ResponseEntity<UserDTO> resp = remoteApiClient.get(url, UserDTO.class, headers); * ResponseEntity<T> resp = remoteApiClient.get(url, UserDTO.class, headers);
* <p> * <p>
* // GET 泛型列表 * // POST JSON
* ResponseEntity<List<UserDTO>> resp = remoteApiClient.get(url, * ResponseEntity<T> resp = remoteApiClient.post(url, body, UserDTO.class, headers);
* new ParameterizedTypeReference<List<UserDTO>>() {}, headers); * <p>
* // 文件上传
* ResponseEntity<UploadResult> resp = remoteApiClient.upload(url, file, UploadResult.class, headers);
* <p>
* // 文件上传(带额外表单字段)
* ResponseEntity<UploadResult> resp = remoteApiClient.upload(url, file, Map.of("dir", "images"), UploadResult.class, headers);
* </pre> * </pre>
* *
* @author Lizh * @author Lizh
* @Date: 2026/6/4 16:50
* @Version: 1.0
*/ */
@Slf4j @Slf4j
@Component @Component
...@@ -69,7 +80,7 @@ public class RemoteApiClient { ...@@ -69,7 +80,7 @@ public class RemoteApiClient {
} }
/** /**
* POST 请求(简单类型响应) * POST 请求(JSON 请求体,简单类型响应)
*/ */
@Retry(name = RETRY_NAME) @Retry(name = RETRY_NAME)
public <T, R> ResponseEntity<T> post(String url, R body, Class<T> responseType, public <T, R> ResponseEntity<T> post(String url, R body, Class<T> responseType,
...@@ -83,7 +94,7 @@ public class RemoteApiClient { ...@@ -83,7 +94,7 @@ public class RemoteApiClient {
} }
/** /**
* POST 请求(泛型响应) * POST 请求(JSON 请求体,泛型响应)
*/ */
@Retry(name = RETRY_NAME) @Retry(name = RETRY_NAME)
public <T, R> ResponseEntity<T> post(String url, R body, ParameterizedTypeReference<T> typeReference, public <T, R> ResponseEntity<T> post(String url, R body, ParameterizedTypeReference<T> typeReference,
...@@ -97,7 +108,7 @@ public class RemoteApiClient { ...@@ -97,7 +108,7 @@ public class RemoteApiClient {
} }
/** /**
* PUT 请求 * PUT 请求(JSON 请求体)
*/ */
@Retry(name = RETRY_NAME) @Retry(name = RETRY_NAME)
public <T, R> ResponseEntity<T> put(String url, R body, Class<T> responseType, public <T, R> ResponseEntity<T> put(String url, R body, Class<T> responseType,
...@@ -121,22 +132,201 @@ public class RemoteApiClient { ...@@ -121,22 +132,201 @@ public class RemoteApiClient {
} }
/** /**
* 通用执行方法(Class<T> 响应类型 * 上传单个文件(简单类型响应
* <p> * <p>
* 异常处理策略: * 注意:文件上传的重试需谨慎。如果远程服务已接收文件但响应超时,
* - 4xx 客户端错误 → 包装为 RemoteServiceException(不触发重试) * 重试将导致重复上传。建议对上传接口设置合理的超时时间。
* - 5xx / 超时 / 网络异常 → 原样抛出,由 @Retry 捕获后重试 *
* @param url 上传地址
* @param file 上传的文件
* @param fieldName 表单字段名(默认 "file")
* @param responseType 响应类型
* @param headers 自定义请求头
* @param <T> 响应类型泛型
* @return 响应实体
*/
@Retry(name = RETRY_NAME)
public <T> ResponseEntity<T> upload(String url, MultipartFile file, String fieldName,
Class<T> responseType, Map<String, String> headers) {
MultiValueMap<String, HttpEntity<?>> parts = buildMultipartParts(fieldName, file, null);
return doUpload(url, parts, responseType, headers);
}
/**
* 上传单个文件(泛型响应)
*/
@Retry(name = RETRY_NAME)
public <T> ResponseEntity<T> upload(String url, MultipartFile file, String fieldName,
ParameterizedTypeReference<T> typeReference,
Map<String, String> headers) {
MultiValueMap<String, HttpEntity<?>> parts = buildMultipartParts(fieldName, file, null);
return doUpload(url, parts, typeReference, headers);
}
/**
* 上传文件并附带额外表单字段
* <p>
* 示例:上传文件同时指定存储目录
* <pre>
* Map<String, Object> fields = Map.of("dir", "images", "overwrite", true);
* remoteApiClient.upload(url, file, "file", fields, UploadResult.class, headers);
* </pre>
*
* @param url 上传地址
* @param file 上传的文件
* @param fieldName 文件字段名
* @param formFields 额外的表单字段(可为 null)
* @param responseType 响应类型
* @param headers 自定义请求头
* @param <T> 响应类型泛型
* @return 响应实体
*/
@Retry(name = RETRY_NAME)
public <T> ResponseEntity<T> upload(String url, MultipartFile file, String fieldName,
Map<String, Object> formFields,
Class<T> responseType, Map<String, String> headers) {
MultiValueMap<String, HttpEntity<?>> parts = buildMultipartParts(fieldName, file, formFields);
return doUpload(url, parts, responseType, headers);
}
/**
* 上传字节数组
*
* @param url 上传地址
* @param fileBytes 文件字节数组
* @param filename 文件名
* @param fieldName 表单字段名(默认 "file")
* @param responseType 响应类型
* @param headers 自定义请求头
* @param <T> 响应类型泛型
* @return 响应实体
*/
@Retry(name = RETRY_NAME)
public <T> ResponseEntity<T> upload(String url, byte[] fileBytes, String filename, String fieldName,
Class<T> responseType, Map<String, String> headers) {
MultipartBodyBuilder builder = new MultipartBodyBuilder();
builder.part(fieldName, new ByteArrayResource(fileBytes) {
@Override
public String getFilename() {
return filename;
}
});
return doUpload(url, builder.build(), responseType, headers);
}
/**
* 提交表单数据(application/x-www-form-urlencoded)
*
* @param url 请求地址
* @param formData 表单键值对
* @param responseType 响应类型
* @param headers 自定义请求头
* @param <T> 响应类型泛型
* @return 响应实体
*/ */
private <T> ResponseEntity<T> doExecute(String url, WebClient.ResponseSpec responseSpec, Class<T> responseType) { @Retry(name = RETRY_NAME)
if (log.isDebugEnabled()) { public <T> ResponseEntity<T> postForm(String url, Map<String, String> formData,
log.debug("HTTP请求: {}", url); Class<T> responseType, Map<String, String> headers) {
log.debug("表单提交: {}", url);
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
if (formData != null) {
formData.forEach(form::add);
} }
try { try {
WebClient.RequestHeadersSpec<?> spec = webClient.post().uri(url)
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.bodyValue(form);
addHeaders(spec, headers);
ResponseEntity<T> response = spec.retrieve()
.toEntity(responseType)
.block(Duration.ofMillis(readTimeout));
log.debug("表单响应: {} - {}", url,
response != null ? response.getStatusCode() : "null");
return response;
} catch (WebClientResponseException e) {
return handleWebClientException(url, e);
} catch (Exception e) {
log.error("表单提交异常: {}", url, e);
throw e;
}
}
/**
* 构建 multipart 请求体
*/
private MultiValueMap<String, HttpEntity<?>> buildMultipartParts(
String fieldName, MultipartFile file, Map<String, Object> formFields) {
MultipartBodyBuilder builder = new MultipartBodyBuilder();
// 添加文件
String originalFilename = file.getOriginalFilename();
if (originalFilename != null) {
builder.part(fieldName, file.getResource()).filename(originalFilename);
} else {
builder.part(fieldName, file.getResource());
}
// 添加额外表单字段
if (formFields != null && !formFields.isEmpty()) {
formFields.forEach(builder::part);
}
return builder.build();
}
/**
* 执行文件上传(Class<T> 响应类型)
*/
private <T> ResponseEntity<T> doUpload(String url, MultiValueMap<String, HttpEntity<?>> parts,
Class<T> responseType, Map<String, String> headers) {
log.debug("文件上传: {}", url);
try {
WebClient.RequestHeadersSpec<?> spec = webClient.post().uri(url)
.body(BodyInserters.fromMultipartData(parts));
addHeaders(spec, headers);
ResponseEntity<T> response = spec.retrieve().toEntity(responseType)
.block(Duration.ofMillis(readTimeout));
log.debug("上传响应: {} - {}", url, response != null ? response.getStatusCode() : "null");
return response;
} catch (WebClientResponseException e) {
return handleWebClientException(url, e);
} catch (Exception e) {
log.error("文件上传异常: {}", url, e);
throw e;
}
}
/**
* 执行文件上传(ParameterizedTypeReference<T> 泛型响应类型)
*/
private <T> ResponseEntity<T> doUpload(String url, MultiValueMap<String, HttpEntity<?>> parts,
ParameterizedTypeReference<T> typeReference,
Map<String, String> headers) {
log.debug("文件上传(泛型): {}", url);
try {
WebClient.RequestHeadersSpec<?> spec = webClient.post().uri(url)
.body(BodyInserters.fromMultipartData(parts));
addHeaders(spec, headers);
ResponseEntity<T> response = spec.retrieve().toEntity(typeReference)
.block(Duration.ofMillis(readTimeout));
log.debug("上传响应(泛型): {} - {}", url, response != null ? response.getStatusCode() : "null");
return response;
} catch (WebClientResponseException e) {
return handleWebClientException(url, e);
} catch (Exception e) {
log.error("文件上传异常(泛型): {}", url, e);
throw e;
}
}
/**
* 通用执行方法(Class<T> 响应类型)
*/
private <T> ResponseEntity<T> doExecute(String url, WebClient.ResponseSpec responseSpec,
Class<T> responseType) {
log.debug("HTTP请求: {}", url);
try {
ResponseEntity<T> response = responseSpec.toEntity(responseType) ResponseEntity<T> response = responseSpec.toEntity(responseType)
.block(Duration.ofMillis(readTimeout)); .block(Duration.ofMillis(readTimeout));
if (log.isDebugEnabled()) { log.debug("HTTP响应: {} - {}", url, response != null ? response.getStatusCode() : "null");
log.debug("HTTP响应: {} - {}", url, response != null ? response.getStatusCode() : "null");
}
return response; return response;
} catch (WebClientResponseException e) { } catch (WebClientResponseException e) {
return handleWebClientException(url, e); return handleWebClientException(url, e);
...@@ -147,19 +337,15 @@ public class RemoteApiClient { ...@@ -147,19 +337,15 @@ public class RemoteApiClient {
} }
/** /**
* 通用执行方法(ParameterizedTypeReference<T> 泛型响应类型) * 通用执行方法(ParameterizedTypeReference<T>; 泛型响应类型)
*/ */
private <T> ResponseEntity<T> doExecute(String url, WebClient.ResponseSpec responseSpec, private <T> ResponseEntity<T> doExecute(String url, WebClient.ResponseSpec responseSpec,
ParameterizedTypeReference<T> typeReference) { ParameterizedTypeReference<T> typeReference) {
if (log.isDebugEnabled()) {
log.debug("HTTP请求(泛型): {}", url); log.debug("HTTP请求(泛型): {}", url);
}
try { try {
ResponseEntity<T> response = responseSpec.toEntity(typeReference) ResponseEntity<T> response = responseSpec.toEntity(typeReference)
.block(Duration.ofMillis(readTimeout)); .block(Duration.ofMillis(readTimeout));
if (log.isDebugEnabled()) { log.debug("HTTP响应(泛型): {} - {}", url, response != null ? response.getStatusCode() : "null");
log.debug("HTTP响应(泛型): {} - {}", url, response != null ? response.getStatusCode() : "null");
}
return response; return response;
} catch (WebClientResponseException e) { } catch (WebClientResponseException e) {
return handleWebClientException(url, e); return handleWebClientException(url, e);
......
package com.jomalls.custom.app.client; package com.jomalls.custom.integrate.client;
import io.github.resilience4j.retry.event.RetryOnErrorEvent; import io.github.resilience4j.retry.event.RetryOnErrorEvent;
import io.github.resilience4j.retry.event.RetryOnRetryEvent; import io.github.resilience4j.retry.event.RetryOnRetryEvent;
......
package com.jomalls.custom.config; package com.jomalls.custom.integrate.configuration;
import io.netty.channel.ChannelOption; import io.netty.channel.ChannelOption;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
......
package com.jomalls.custom.app.exception; package com.jomalls.custom.integrate.exception;
import lombok.Getter; import lombok.Getter;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
......
...@@ -3,7 +3,7 @@ package com.jomalls.custom.config; ...@@ -3,7 +3,7 @@ package com.jomalls.custom.config;
import com.jomalls.custom.app.enums.CodeEnum; import com.jomalls.custom.app.enums.CodeEnum;
import com.jomalls.custom.app.exception.InvalidTokenException; import com.jomalls.custom.app.exception.InvalidTokenException;
import com.jomalls.custom.app.exception.PermissionDeniedException; import com.jomalls.custom.app.exception.PermissionDeniedException;
import com.jomalls.custom.app.exception.RemoteServiceException; import com.jomalls.custom.integrate.exception.RemoteServiceException;
import com.jomalls.custom.app.exception.ServiceException; import com.jomalls.custom.app.exception.ServiceException;
import com.jomalls.custom.app.utils.R; import com.jomalls.custom.app.utils.R;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
......
...@@ -65,6 +65,6 @@ resilience4j.retry.configs.default.exponential-backoff-multiplier=2 ...@@ -65,6 +65,6 @@ resilience4j.retry.configs.default.exponential-backoff-multiplier=2
# 以下异常触发重试(网络异常 + 服务端5xx错误) # 以下异常触发重试(网络异常 + 服务端5xx错误)
resilience4j.retry.configs.default.retry-exceptions=java.util.concurrent.TimeoutException,java.io.IOException,java.net.ConnectException,org.springframework.web.reactive.function.client.WebClientResponseException resilience4j.retry.configs.default.retry-exceptions=java.util.concurrent.TimeoutException,java.io.IOException,java.net.ConnectException,org.springframework.web.reactive.function.client.WebClientResponseException
# 以下异常不触发重试(客户端4xx错误已包装为RemoteServiceException,重试无意义) # 以下异常不触发重试(客户端4xx错误已包装为RemoteServiceException,重试无意义)
resilience4j.retry.configs.default.ignore-exceptions=com.jomalls.custom.app.exception.RemoteServiceException resilience4j.retry.configs.default.ignore-exceptions=com.jomalls.custom.integrate.exception.RemoteServiceException
# 远程 API 重试实例使用默认配置 # 远程 API 重试实例使用默认配置
resilience4j.retry.instances.remoteApi.base-config=default resilience4j.retry.instances.remoteApi.base-config=default
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment