Commit e257a716 by Lizh

增加http/https外部调用工具的封装,调整logback配置

parent 4a0d615d
...@@ -44,9 +44,19 @@ ...@@ -44,9 +44,19 @@
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId> <artifactId>spring-boot-starter-aop</artifactId>
<!--<version>${spring-boot.version}</version>-->
<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>
...@@ -16,7 +16,6 @@ import java.lang.annotation.*; ...@@ -16,7 +16,6 @@ import java.lang.annotation.*;
* @Date: 2026/6/2 10:58 * @Date: 2026/6/2 10:58
* @Version: 1.0 * @Version: 1.0
*/ */
@Inherited
@Target(ElementType.METHOD) @Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME) @Retention(RetentionPolicy.RUNTIME)
@Documented @Documented
...@@ -24,10 +23,10 @@ public @interface RepeatSubmit { ...@@ -24,10 +23,10 @@ public @interface RepeatSubmit {
/** /**
* 间隔时间(ms),小于此时间视为重复提交 * 间隔时间(ms),小于此时间视为重复提交
*/ */
public int interval() default 5000; int interval() default 5000;
/** /**
* 提示消息 * 提示消息
*/ */
public String message() default "不允许重复提交,请稍候再试"; String message() default "不允许重复提交,请稍候再试";
} }
...@@ -9,20 +9,21 @@ import java.lang.annotation.Target; ...@@ -9,20 +9,21 @@ import java.lang.annotation.Target;
/** /**
* 权限控制注解 * 权限控制注解
* 用于标注需要权限校验的 Controller 方法 * 用于标注需要权限校验的 Controller 方法
* 新老用法对照 * 增加@RequiresPermissions(value = "system:role:list", mode = RequiresPermissions.RequireMode.ANY)
* ┌─────────────────────────────────────────┬──────────────────────────────────────────────────────────────┐ * 新老用法对照 @PreAuthorize("@ss.hasPermi('system:role:list')")
* ├ custom-back │ custom-server(迁移后) │ * ┌────────────────────────────────────────────────────────┬──────────────────────────────────────────────────────────────┐
* ├─────────────────────────────────────────┼──────────────────────────────────────────────────────────────┼ * ├ custom-back │ custom-server(迁移后) │
* │ @PreAuthorize("@ss.hasPermi('x')") │ @RequiresPermissions("x") │ * ├────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────┼
* ├─────────────────────────────────────────┼──────────────────────────────────────────────────────────────┼ * │ @PreAuthorize("@ss.hasPermi('system:role:list')") │ @RequiresPermissions("system:role:list") │
* │ @PreAuthorize("@ss.hasAnyPermi('a,b')") │ @RequiresPermissions(value="a,b", mode=ANY) │ * ├────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────┼
* ├─────────────────────────────────────────┼──────────────────────────────────────────────────────────────┼ * │ @PreAuthorize("@ss.hasAnyPermi('a,b')") │ @RequiresPermissions(value="a,b", mode=ANY) │
* │ @PreAuthorize("@ss.hasRole('admin')") │ @RequiresRoles("admin") │ * ├────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────┼
* ├─────────────────────────────────────────┼──────────────────────────────────────────────────────────────┤ * │ @PreAuthorize("@ss.hasRole('admin')") │ @RequiresRoles("admin") │
* │ @PreAuthorize("@ss.hasAnyRoles('a,b')") │ @RequiresRoles(value="a,b", mode=ANY) │ * ├────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────┤
* ├─────────────────────────────────────────┼──────────────────────────────────────────────────────────────┤ * │ @PreAuthorize("@ss.hasAnyRoles('a,b')") │ @RequiresRoles(value="a,b", mode=ANY) │
* │ 权限+角色 AND 组合 │ @RequiresPermissions("x") + @RequiresRoles("admin") 同时使用 │ * ├────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────┤
* └─────────────────────────────────────────┴──────────────────────────────────────────────────────────────┴ * │ 权限+角色 AND 组合 │ @RequiresPermissions("x") + @RequiresRoles("admin") 同时使用 │
* └────────────────────────────────────────────────────────┴──────────────────────────────────────────────────────────────┴
* *
* @author Lizh * @author Lizh
* @version 0.01 * @version 0.01
......
...@@ -49,7 +49,7 @@ public class RepeatSubmitAspect { ...@@ -49,7 +49,7 @@ public class RepeatSubmitAspect {
// SET NX EX:成功返回true表示首次提交,false表示重复提交 // SET NX EX:成功返回true表示首次提交,false表示重复提交
Boolean isFirst = stringRedisTemplate.opsForValue() Boolean isFirst = stringRedisTemplate.opsForValue()
.setIfAbsent(redisKey, "1", interval, TimeUnit.MILLISECONDS); .setIfAbsent(redisKey, REPEAT_SUBMIT_VALUE, interval, TimeUnit.MILLISECONDS);
if (Boolean.TRUE.equals(isFirst)) { if (Boolean.TRUE.equals(isFirst)) {
log.debug("防重复提交检查通过,key: {}", redisKey); log.debug("防重复提交检查通过,key: {}", redisKey);
......
package com.jomalls.custom.app.client;
import com.jomalls.custom.app.exception.RemoteServiceException;
import io.github.resilience4j.retry.annotation.Retry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import java.time.Duration;
import java.util.Map;
/**
* 通用 REST 客户端
* 基于 WebClient,集成 Resilience4j 重试能力。
* <p>
* 使用方式:
* <pre>
* @Autowired
* private RemoteApiClient remoteApiClient;
* <p>
* // GET 简单对象
* ResponseEntity<UserDTO> resp = remoteApiClient.get(url, UserDTO.class, headers);
* <p>
* // GET 泛型列表
* ResponseEntity<List<UserDTO>> resp = remoteApiClient.get(url,
* new ParameterizedTypeReference<List<UserDTO>>() {}, headers);
* </pre>
*
* @author Lizh
* @Date: 2026/6/4 16:50
* @Version: 1.0
*/
@Slf4j
@Component
public class RemoteApiClient {
@Autowired
private WebClient webClient;
@Value("${http.client.read-timeout:30000}")
private int readTimeout;
private static final String RETRY_NAME = "remoteApi";
/**
* GET 请求(简单类型响应)
*/
@Retry(name = RETRY_NAME)
public <T> ResponseEntity<T> get(String url, Class<T> responseType, Map<String, String> headers) {
WebClient.RequestHeadersSpec<?> spec = webClient.get().uri(url);
addHeaders(spec, headers);
return doExecute(url, spec.retrieve(), responseType);
}
/**
* GET 请求(泛型响应,如 List<T>)
*/
@Retry(name = RETRY_NAME)
public <T> ResponseEntity<T> get(String url, ParameterizedTypeReference<T> typeReference,
Map<String, String> headers) {
WebClient.RequestHeadersSpec<?> spec = webClient.get().uri(url);
addHeaders(spec, headers);
return doExecute(url, spec.retrieve(), typeReference);
}
/**
* POST 请求(简单类型响应)
*/
@Retry(name = RETRY_NAME)
public <T, R> ResponseEntity<T> post(String url, R body, Class<T> responseType,
Map<String, String> headers) {
WebClient.RequestBodySpec spec = webClient.post().uri(url);
if (body != null) {
spec.bodyValue(body);
}
addHeaders(spec, headers);
return doExecute(url, spec.retrieve(), responseType);
}
/**
* POST 请求(泛型响应)
*/
@Retry(name = RETRY_NAME)
public <T, R> ResponseEntity<T> post(String url, R body, ParameterizedTypeReference<T> typeReference,
Map<String, String> headers) {
WebClient.RequestBodySpec spec = webClient.post().uri(url);
if (body != null) {
spec.bodyValue(body);
}
addHeaders(spec, headers);
return doExecute(url, spec.retrieve(), typeReference);
}
/**
* PUT 请求
*/
@Retry(name = RETRY_NAME)
public <T, R> ResponseEntity<T> put(String url, R body, Class<T> responseType,
Map<String, String> headers) {
WebClient.RequestBodySpec spec = webClient.put().uri(url);
if (body != null) {
spec.bodyValue(body);
}
addHeaders(spec, headers);
return doExecute(url, spec.retrieve(), responseType);
}
/**
* DELETE 请求
*/
@Retry(name = RETRY_NAME)
public <T> ResponseEntity<T> delete(String url, Class<T> responseType, Map<String, String> headers) {
WebClient.RequestHeadersSpec<?> spec = webClient.delete().uri(url);
addHeaders(spec, headers);
return doExecute(url, spec.retrieve(), responseType);
}
/**
* 通用执行方法(Class<T> 响应类型)
* <p>
* 异常处理策略:
* - 4xx 客户端错误 → 包装为 RemoteServiceException(不触发重试)
* - 5xx / 超时 / 网络异常 → 原样抛出,由 @Retry 捕获后重试
*/
private <T> ResponseEntity<T> doExecute(String url, WebClient.ResponseSpec responseSpec, Class<T> responseType) {
if (log.isDebugEnabled()) {
log.debug("HTTP请求: {}", url);
}
try {
ResponseEntity<T> response = responseSpec.toEntity(responseType)
.block(Duration.ofMillis(readTimeout));
if (log.isDebugEnabled()) {
log.debug("HTTP响应: {} - {}", url, response != null ? response.getStatusCode() : "null");
}
return response;
} catch (WebClientResponseException e) {
return handleWebClientException(url, e);
} catch (Exception e) {
log.error("HTTP请求异常: {}", url, e);
throw e;
}
}
/**
* 通用执行方法(ParameterizedTypeReference<T> 泛型响应类型)
*/
private <T> ResponseEntity<T> doExecute(String url, WebClient.ResponseSpec responseSpec,
ParameterizedTypeReference<T> typeReference) {
if (log.isDebugEnabled()) {
log.debug("HTTP请求(泛型): {}", url);
}
try {
ResponseEntity<T> response = responseSpec.toEntity(typeReference)
.block(Duration.ofMillis(readTimeout));
if (log.isDebugEnabled()) {
log.debug("HTTP响应(泛型): {} - {}", url, response != null ? response.getStatusCode() : "null");
}
return response;
} catch (WebClientResponseException e) {
return handleWebClientException(url, e);
} catch (Exception e) {
log.error("HTTP请求异常(泛型): {}", url, e);
throw e;
}
}
/**
* 处理 WebClient 响应异常
* <p>
* 4xx 客户端错误 → 不重试,直接包装为业务异常
* 5xx 服务端错误 → 原样抛出,交由 @Retry 重试
*/
private <T> T handleWebClientException(String url, WebClientResponseException e) {
if (e.getStatusCode().is4xxClientError()) {
log.warn("HTTP客户端错误(不重试): {} - {}", url, e.getStatusCode());
throw new RemoteServiceException(e.getStatusCode().value(), "远程服务客户端错误: " + e.getMessage(), e);
}
log.error("HTTP服务端错误(将重试): {} - {}", url, e.getStatusCode());
throw e;
}
/**
* 添加请求头
*/
private void addHeaders(WebClient.RequestHeadersSpec<?> requestSpec, Map<String, String> headers) {
if (headers != null && !headers.isEmpty()) {
headers.forEach(requestSpec::header);
}
}
}
package com.jomalls.custom.app.client;
import io.github.resilience4j.retry.event.RetryOnErrorEvent;
import io.github.resilience4j.retry.event.RetryOnRetryEvent;
import io.github.resilience4j.retry.event.RetryOnSuccessEvent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
/**
* Resilience4j 重试事件监听器
* <p>
* Retry 实例配置在 application.properties 中,由 resilience4j-spring-boot3 自动创建。
*
* @author Lizh
* @Date: 2026/6/4 16:46
* @Version: 1.0
*/
@Slf4j
@Component
public class ResilienceEventListener {
/**
* 重试执行事件
*/
@EventListener
public void onRetry(RetryOnRetryEvent event) {
String reason = event.getLastThrowable() != null ? event.getLastThrowable().getMessage() : "未知原因";
log.warn("重试器[{}]第{}次重试, 原因: {}", event.getName(), event.getNumberOfRetryAttempts(), reason);
}
/**
* 重试最终失败事件
*/
@EventListener
public void onRetryError(RetryOnErrorEvent event) {
log.error("重试器[{}]最终失败", event.getName());
}
/**
* 重试成功事件
*/
@EventListener
public void onRetrySuccess(RetryOnSuccessEvent event) {
if (log.isDebugEnabled()) {
log.debug("重试器[{}]调用成功, 在{}次尝试后成功", event.getName(), event.getNumberOfRetryAttempts());
}
}
}
package com.jomalls.custom.app.exception;
import lombok.Getter;
import org.springframework.http.HttpStatus;
import java.io.Serial;
/**
* 远程服务调用异常
* 用于封装调用外部 HTTP 服务时发生的错误
*
* @author Lizh
* @Date: 2026/6/4 16:27
* @Version: 1.0
*/
@Getter
public class RemoteServiceException extends RuntimeException {
@Serial
private static final long serialVersionUID = 1L;
/**
* HTTP 状态码
*/
private final int statusCode;
public RemoteServiceException(int statusCode, String message) {
super(message);
this.statusCode = statusCode;
}
public RemoteServiceException(String message) {
super(message);
this.statusCode = HttpStatus.INTERNAL_SERVER_ERROR.value();
}
public RemoteServiceException(String message, Throwable cause) {
super(message, cause);
this.statusCode = HttpStatus.INTERNAL_SERVER_ERROR.value();
}
public RemoteServiceException(int statusCode, String message, Throwable cause) {
super(message, cause);
this.statusCode = statusCode;
}
/**
* 使用 HttpStatus 枚举构造
*
* @param status HTTP 状态码枚举
* @param message 错误消息
*/
public RemoteServiceException(HttpStatus status, String message) {
super(message);
this.statusCode = status.value();
}
/**
* 使用 HttpStatus 枚举构造(含原始异常)
*
* @param status HTTP 状态码枚举
* @param message 错误消息
* @param cause 原始异常
*/
public RemoteServiceException(HttpStatus status, String message, Throwable cause) {
super(message, cause);
this.statusCode = status.value();
}
}
...@@ -34,6 +34,17 @@ ...@@ -34,6 +34,17 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>
</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>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId> <artifactId>spring-boot-starter-jdbc</artifactId>
......
...@@ -3,12 +3,19 @@ package com.jomalls.custom.config; ...@@ -3,12 +3,19 @@ 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.app.exception.ServiceException; import com.jomalls.custom.app.exception.ServiceException;
import com.jomalls.custom.app.utils.R;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 全局异常处理切面
*/
@Slf4j
@RestControllerAdvice @RestControllerAdvice
public class CommonExceptionHandlerAdvice { public class CommonExceptionHandlerAdvice {
...@@ -16,39 +23,53 @@ public class CommonExceptionHandlerAdvice { ...@@ -16,39 +23,53 @@ public class CommonExceptionHandlerAdvice {
* token验证失败(返回401 未登录或登录已过期) * token验证失败(返回401 未登录或登录已过期)
*/ */
@ExceptionHandler(InvalidTokenException.class) @ExceptionHandler(InvalidTokenException.class)
public ResponseEntity<com.jomalls.custom.app.utils.R<Object>> handleInvalidTokenException(InvalidTokenException e) { public ResponseEntity<R<Object>> handleInvalidTokenException(InvalidTokenException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED) return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(com.jomalls.custom.app.utils.R.fail(CodeEnum.UNAUTHORIZED.getCode(), e.getMessage())); .body(R.fail(CodeEnum.UNAUTHORIZED.getCode(), e.getMessage()));
} }
/** /**
* 权限异常处理(返回403 Forbidden) * 权限异常处理(返回403 Forbidden)
*/ */
@ExceptionHandler(PermissionDeniedException.class) @ExceptionHandler(PermissionDeniedException.class)
public ResponseEntity<com.jomalls.custom.app.utils.R<Object>> handlePermissionDeniedException(PermissionDeniedException e) { public ResponseEntity<R<Object>> handlePermissionDeniedException(PermissionDeniedException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN) return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(com.jomalls.custom.app.utils.R.fail(CodeEnum.FORBIDDEN.getCode(), e.getMessage())); .body(R.fail(CodeEnum.FORBIDDEN.getCode(), e.getMessage()));
} }
/** /**
* 业务异常处理 * 业务异常处理
*/ */
@ExceptionHandler(ServiceException.class) @ExceptionHandler(ServiceException.class)
public ResponseEntity<com.jomalls.custom.app.utils.R<Object>> handleServiceException(ServiceException e) { public ResponseEntity<R<Object>> handleServiceException(ServiceException e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(com.jomalls.custom.app.utils.R.fail(e.getCode(), e.getMessage())); .body(R.fail(e.getCode(), e.getMessage()));
}
/**
* 远程服务调用异常处理
*/
@ExceptionHandler(RemoteServiceException.class)
public ResponseEntity<R<Object>> handleRemoteServiceException(RemoteServiceException e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(R.fail(e.getStatusCode(), e.getMessage()));
} }
/**
* 运行时异常处理
*/
@ExceptionHandler(RuntimeException.class) @ExceptionHandler(RuntimeException.class)
public ResponseEntity<com.jomalls.custom.app.utils.R<Object>> handleRuntimeException(Exception e) { public ResponseEntity<R<Object>> handleRuntimeException(Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(com.jomalls.custom.app.utils.R.fail(e.getMessage())); .body(R.fail(e.getMessage()));
} }
/**
* 兜底异常处理
*/
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
public ResponseEntity<com.jomalls.custom.app.utils.R<Object>> handleException(Exception e) { public ResponseEntity<R<Object>> handleException(Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(com.jomalls.custom.app.utils.R.fail(CodeEnum.FAIL)); .body(R.fail(CodeEnum.FAIL.getCode(), e.getMessage()));
} }
} }
\ No newline at end of file
package com.jomalls.custom.config;
import io.netty.channel.ChannelOption;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import reactor.netty.http.client.HttpClient;
import reactor.netty.resources.ConnectionProvider;
import java.time.Duration;
/**
* WebClient 配置类
* HTTP 客户端配置,含连接池、超时、日志过滤器
*/
@Slf4j
@Configuration
public class WebClientConfig {
@Value("${http.client.connect-timeout:5000}")
private int connectTimeout;
@Value("${http.client.read-timeout:30000}")
private int readTimeout;
@Value("${http.client.pool-max-connections:50}")
private int poolMaxConnections;
@Value("${http.client.pool-acquire-timeout:2000}")
private int poolAcquireTimeout;
/**
* 连接池配置
* 使用固定大小连接池,避免默认的无限制连接导致资源耗尽
*/
@Bean
public ConnectionProvider connectionProvider() {
return ConnectionProvider.builder("custom-server-http-pool")
.maxConnections(poolMaxConnections)
.pendingAcquireMaxCount(-1)
.pendingAcquireTimeout(Duration.ofMillis(poolAcquireTimeout))
.maxIdleTime(Duration.ofSeconds(60))
.maxLifeTime(Duration.ofMinutes(5))
.evictInBackground(Duration.ofSeconds(30))
.lifo()
.metrics(true)
.build();
}
/**
* 创建 WebClient 实例
*
* @param connectionProvider 连接池
* @return WebClient bean
*/
@Bean
public WebClient webClient(ConnectionProvider connectionProvider) {
HttpClient httpClient = HttpClient.create(connectionProvider)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, connectTimeout)
.responseTimeout(Duration.ofMillis(readTimeout))
.option(ChannelOption.SO_KEEPALIVE, true)
.option(ChannelOption.TCP_NODELAY, true);
return WebClient.builder()
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.clientConnector(new ReactorClientHttpConnector(httpClient))
.codecs(configurer -> configurer.defaultCodecs()
.maxInMemorySize(16 * 1024 * 1024)) // 16MB
.filter(logRequest())
.filter(logResponse())
.build();
}
/**
* 请求日志过滤器
* 记录所有发出的 HTTP 请求的方法和 URL
*/
private ExchangeFilterFunction logRequest() {
return ExchangeFilterFunction.ofRequestProcessor(clientRequest -> {
if (log.isDebugEnabled()) {
log.debug("HTTP请求: {} {} headers={}", clientRequest.method(), clientRequest.url(),
clientRequest.headers());
}
return Mono.just(clientRequest);
});
}
/**
* 响应日志过滤器
* 记录所有收到的 HTTP 响应的状态码
*/
private ExchangeFilterFunction logResponse() {
return ExchangeFilterFunction.ofResponseProcessor(clientResponse -> {
if (log.isDebugEnabled()) {
log.debug("HTTP响应: status={} headers={}", clientResponse.statusCode(),
clientResponse.headers().asHttpHeaders());
}
return Mono.just(clientResponse);
});
}
}
...@@ -39,4 +39,32 @@ token.header=Authorization ...@@ -39,4 +39,32 @@ token.header=Authorization
# 令牌密钥(兼容旧版本) # 令牌密钥(兼容旧版本)
token.secret=custom token.secret=custom
# 令牌有效期(默认30分钟) # 令牌有效期(默认30分钟)
token.expireTime=720 token.expireTime=720
\ No newline at end of file
# ==================== HTTP Client Configuration ====================
# 连接超时(5000毫秒 = 5秒)
http.client.connect-timeout=5000
# 读取超时(30000毫秒 = 30秒)
http.client.read-timeout=30000
# 写入超时(30000毫秒 = 30秒)
http.client.write-timeout=30000
# 连接池最大连接数
http.client.pool-max-connections=50
# 连接池获取连接超时(毫秒)
http.client.pool-acquire-timeout=2000
# ==================== Resilience4j Retry Configuration ====================
# 最大重试次数:最多重试3次(首次 + 2次重试)
resilience4j.retry.configs.default.max-attempts=3
# 初始等待时间:第一次重试前等待500毫秒
resilience4j.retry.configs.default.wait-duration=500ms
# 启用指数退避:每次重试间隔翻倍(500ms → 1000ms → 2000ms)
resilience4j.retry.configs.default.enable-exponential-backoff=true
# 指数退避倍数:每次等待时间乘以2
resilience4j.retry.configs.default.exponential-backoff-multiplier=2
# 以下异常触发重试(网络异常 + 服务端5xx错误)
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,重试无意义)
resilience4j.retry.configs.default.ignore-exceptions=com.jomalls.custom.app.exception.RemoteServiceException
# 远程 API 重试实例使用默认配置
resilience4j.retry.instances.remoteApi.base-config=default
\ No newline at end of file
...@@ -8,18 +8,8 @@ spring: ...@@ -8,18 +8,8 @@ spring:
pathmatch: pathmatch:
matching-strategy: ant_path_matcher matching-strategy: ant_path_matcher
## 日志配置
logging:
level:
com.jomalls.custom: DEBUG
org.mybatis: DEBUG
org.springframework.web: DEBUG
## MyBatis-Plus配置 ## MyBatis-Plus配置
mybatis-plus: mybatis-plus:
configuration:
# 是否将SQL打印到控制台
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config: global-config:
db-config: db-config:
id-type: auto id-type: auto
......
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<configuration> <configuration>
<!-- 日志存放路径 --> <!-- 日志存放路径(开发环境用相对路径,生产环境通过 logging.file.path 覆盖) -->
<property name="log.path" value="/root/custom-v2/logs"/> <!--<property name="log.path" value="${LOG_PATH:-logs}"/>-->
<property name="log.path" value="./custom-v2/logs"/>
<!-- 日志输出格式 --> <!-- 日志输出格式 -->
<property name="log.pattern" value="%d{HH:mm:ss.SSS} [%thread] %-5level %logger{20} - [%method,%line] - %msg%n"/> <property name="log.pattern" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - [%method,%line] - %msg%n"/>
<!-- 控制台输出 --> <!-- ==================== 控制台输出 ==================== -->
<appender name="console" class="ch.qos.logback.core.ConsoleAppender"> <appender name="console" class="ch.qos.logback.core.ConsoleAppender">
<encoder> <encoder>
<pattern>${log.pattern}</pattern> <pattern>${log.pattern}</pattern>
<charset>UTF-8</charset>
</encoder> </encoder>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>INFO</level>
</filter>
</appender> </appender>
<!-- 系统日志输出 --> <!-- ==================== 文件输出 ==================== -->
<!-- 系统日志:INFO 及以上(含 WARN) -->
<appender name="file_info" class="ch.qos.logback.core.rolling.RollingFileAppender"> <appender name="file_info" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${log.path}/sys-info.log</file> <file>${log.path}/sys-info.log</file>
<!-- 循环政策:基于时间创建日志文件 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 日志文件名格式 -->
<fileNamePattern>${log.path}/sys-info.%d{yyyy-MM-dd}.log</fileNamePattern> <fileNamePattern>${log.path}/sys-info.%d{yyyy-MM-dd}.log</fileNamePattern>
<!-- 日志最大的历史 60天 -->
<maxHistory>60</maxHistory> <maxHistory>60</maxHistory>
</rollingPolicy> </rollingPolicy>
<encoder> <encoder>
<pattern>${log.pattern}</pattern> <pattern>${log.pattern}</pattern>
<charset>UTF-8</charset>
</encoder> </encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter"> <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<!-- 过滤的级别 -->
<level>INFO</level> <level>INFO</level>
<!-- 匹配时的操作:接收(记录) -->
<onMatch>ACCEPT</onMatch>
<!-- 不匹配时的操作:拒绝(不记录) -->
<onMismatch>DENY</onMismatch>
</filter> </filter>
</appender> </appender>
<!-- 错误日志:仅 ERROR -->
<appender name="file_error" class="ch.qos.logback.core.rolling.RollingFileAppender"> <appender name="file_error" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${log.path}/sys-error.log</file> <file>${log.path}/sys-error.log</file>
<!-- 循环政策:基于时间创建日志文件 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 日志文件名格式 -->
<fileNamePattern>${log.path}/sys-error.%d{yyyy-MM-dd}.log</fileNamePattern> <fileNamePattern>${log.path}/sys-error.%d{yyyy-MM-dd}.log</fileNamePattern>
<!-- 日志最大的历史 60天 -->
<maxHistory>60</maxHistory> <maxHistory>60</maxHistory>
</rollingPolicy> </rollingPolicy>
<encoder> <encoder>
<pattern>${log.pattern}</pattern> <pattern>${log.pattern}</pattern>
<charset>UTF-8</charset>
</encoder> </encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter"> <filter class="ch.qos.logback.classic.filter.LevelFilter">
<!-- 过滤的级别 -->
<level>ERROR</level> <level>ERROR</level>
<!-- 匹配时的操作:接收(记录) -->
<onMatch>ACCEPT</onMatch> <onMatch>ACCEPT</onMatch>
<!-- 不匹配时的操作:拒绝(不记录) -->
<onMismatch>DENY</onMismatch> <onMismatch>DENY</onMismatch>
</filter> </filter>
</appender> </appender>
<!-- 用户访问日志输出 --> <!-- 用户操作日志 -->
<appender name="sys-user" class="ch.qos.logback.core.rolling.RollingFileAppender"> <appender name="sys-user" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${log.path}/sys-user.log</file> <file>${log.path}/sys-user.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 按天回滚 daily -->
<fileNamePattern>${log.path}/sys-user.%d{yyyy-MM-dd}.log</fileNamePattern> <fileNamePattern>${log.path}/sys-user.%d{yyyy-MM-dd}.log</fileNamePattern>
<!-- 日志最大的历史 60天 -->
<maxHistory>60</maxHistory> <maxHistory>60</maxHistory>
</rollingPolicy> </rollingPolicy>
<encoder> <encoder>
<pattern>${log.pattern}</pattern> <pattern>${log.pattern}</pattern>
<charset>UTF-8</charset>
</encoder> </encoder>
</appender> </appender>
<!-- 系统模块日志级别控制 --> <!-- ==================== 框架日志级别控制 ==================== -->
<logger name="com.jomalls.custom" level="info"/>
<!-- Spring日志级别控制 -->
<logger name="org.springframework" level="warn"/>
<root level="info"> <!-- 项目代码 -->
<appender-ref ref="console"/> <logger name="com.jomalls.custom" level="INFO"/>
</root>
<!--系统操作日志--> <!-- Spring 框架 -->
<root level="info"> <logger name="org.springframework" level="WARN"/>
<logger name="org.springframework.web" level="WARN"/>
<!-- MyBatis / MyBatis-Plus -->
<logger name="org.mybatis" level="WARN"/>
<logger name="com.baomidou.mybatisplus" level="WARN"/>
<!-- 数据库连接池 -->
<logger name="com.zaxxer.hikari" level="WARN"/>
<!-- WebClient / Reactor Netty -->
<logger name="io.netty" level="WARN"/>
<logger name="reactor" level="WARN"/>
<logger name="reactor.netty" level="WARN"/>
<!-- Resilience4j -->
<logger name="io.github.resilience4j" level="INFO"/>
<!-- Redis / Lettuce -->
<logger name="io.lettuce" level="WARN"/>
<!-- API 文档 -->
<logger name="org.springdoc" level="WARN"/>
<!-- ==================== 根配置 ==================== -->
<root level="INFO">
<appender-ref ref="console"/>
<appender-ref ref="file_info"/> <appender-ref ref="file_info"/>
<appender-ref ref="file_error"/> <appender-ref ref="file_error"/>
</root> </root>
<!--系统用户操作日志--> <!-- 用户操作日志(独立 logger,不继承 root 的 appender,避免重复打印到控制台) -->
<logger name="sys-user" level="info"> <logger name="sys-user" level="INFO" additivity="false">
<appender-ref ref="sys-user"/> <appender-ref ref="sys-user"/>
</logger> </logger>
</configuration> </configuration>
\ No newline at end of file
package com.jomalls.custom.webapp.controller;
import com.jomalls.custom.app.client.RemoteApiClient;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 外部服务调用示例
*/
@Slf4j
@RestController
@Tag(name = "/external", description = "Controller")
@RequestMapping("/external")
public class ExternalServiceController {
@Autowired
private RemoteApiClient remoteApiClient;
/**
* 示例1:GET 请求(简单对象)
*/
@Operation(summary = "列表查询接口", description = "根据条件查询列表接口(不分页)")
@RequestMapping(value = "/getTest", method = RequestMethod.GET)
public void getTest() {
String url = "https://demo.jomalls.com/api/supply/productVariant/detail?id=4750";
Map<String, String> headers = new HashMap<>();
headers.put("jwt-token", "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzeXNVc2VyIjp7ImlkIjoyOTIsImVtcGxveWVlSWQiOjE4OCwibG9naW5OYW1lIjoibGkgemhvbmdob25nIiwicGFzc3dvcmQiOiI1MDIxNDIxZWM2YTZlZGQwNmNhNmQ2YTkwYTk3ZGIzMyIsInNhZmVQYXNzd29yZCI6ImUxMGFkYzM5NDliYTU5YWJiZTU2ZTA1N2YyMGY4ODNlIiwiZW5hYmxlIjp0cnVlLCJyZW1hcmsiOiIiLCJsYXN0SXAiOiIxMDMuMTE3Ljc4LjQ0IiwiZW1wTnVtYmVyIjoiMjYwNDIxMDEiLCJzeXNSb2xlTGlzdCI6W3siaWQiOjIsInJvbGVHcm91cCI6IueuoeeQhuWRmCIsIm5hbWUiOiLlip_og70t566h55CG5ZGYIiwidHlwZSI6IkZVTkNUSU9OX1JPTEUiLCJjcmVhdGVUaW1lIjoiMjAyMi0xMS0wNyAxODoyNzo1OCJ9LHsiaWQiOjMsInJvbGVHcm91cCI6IueuoeeQhuWRmCIsIm5hbWUiOiLmlbDmja4t566h55CG5ZGYIiwidHlwZSI6IkRBVEFfUk9MRSIsImNyZWF0ZVRpbWUiOiIyMDIyLTExLTA3IDE4OjI4OjQwIn1dLCJzeXNNZW51TGlzdCI6bnVsbCwiYXZhdGFyIjoiaHR0cHM6Ly9qb21hbGxzLm9zcy1jbi1oYW5nemhvdS5hbGl5dW5jcy5jb20vZGVtby9vdGhlci8yNjA0LzIxLzFsMDExMzEtdHB4MDl3ci1tbzgyN2Rrai5qcGciLCJlbXBsb3llZU5hbWUiOiLmnY7lv6DnuqIiLCJyb2xlSWRzIjoiMiwzIiwicm9sZU5hbWVzIjoi5Yqf6IO9LeeuoeeQhuWRmCzmlbDmja4t566h55CG5ZGYIiwiZGVwdElkIjpudWxsLCJkZXB0TmFtZSI6bnVsbCwid2FyZWhvdXNlSWQiOm51bGwsImxhc3RMb2dpblRpbWUiOjE3ODAwNTA4OTkwMDAsImNyZWF0ZVRpbWUiOjE3NzY3NDE4NTMwMDAsImF1dGhOdW0iOjAsImF1dGhBdWRpdEZsYWciOjAsImJpbmRTdGF0dXMiOmZhbHNlLCJwbGF0Rm9ybSI6bnVsbCwicXl2eElkIjpudWxsLCJhdXRob3JpemVkU2hvcHMiOlsxLDUsNiw5LDEwLDExLDEyLDEzLDE0LDE5LDQzLDQ2LDQ3LDUxLDUyLDUzLDU0LDU1LDU3LDU5LDYwLDYxLDYyLDYzLDY0LDY1LDY2LDY3LDY4LDY5LDcwLDcxLDcyLDczLDc1LDc5LDgwLDgxLDgyLDgzLDg0LDg1LDg2LDg3LDg4LDg5LDkwLDkxLDkzLDk3LDEwMV0sImNvc3QiOm51bGwsInR5cGUiOiJFTVBMT1lFRSIsImZ1bmN0aW9uTmFtZXMiOm51bGwsImRhdGFOYW1lcyI6bnVsbCwib3JnYW5pemF0aW9uSWQiOjIsIm9yZ2FuaXphdGlvbk5hbWUiOiLkuZ3njKvnp5HmioA-5oqA5pyv6YOo6ZeoIiwiZW1wU3RhdHVzIjpudWxsLCJtdWx0aURldmljZUxvZ2luIjowLCJzdXBlcmlvcnNJZCI6MzgsInBsYXRGb3JtVHh0IjpudWxsfSwiZXhwIjoxNzgwNjQ1NjczfQ.ZotyoTjgGRvptU55TAsfbh8KRENpY7NQmUCncrUIuJyD-laIRHdNXzf3ND1HXshul_abYHFzsDw1zH01NsECTg");
ResponseEntity<String> response = remoteApiClient.get(url, String.class, headers);
System.out.println("response 响应信息:");
System.out.println("response.getStatusCode:" + response.getStatusCode().value());
System.out.println("response.getBody:" + response.getBody());
}
/**
* 示例2:POST 请求(发送表单数据)
*/
@Operation(summary = "列表查询接口", description = "根据条件查询列表接口(不分页)")
@RequestMapping(value = "/postTest", method = RequestMethod.GET)
public void postTest() {
String url = "https://demo.jomalls.com/api/supply/productVariant/cardPage";
Map<String, String> headers = new HashMap<>();
headers.put("jwt-token", "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzeXNVc2VyIjp7ImlkIjoyOTIsImVtcGxveWVlSWQiOjE4OCwibG9naW5OYW1lIjoibGkgemhvbmdob25nIiwicGFzc3dvcmQiOiI1MDIxNDIxZWM2YTZlZGQwNmNhNmQ2YTkwYTk3ZGIzMyIsInNhZmVQYXNzd29yZCI6ImUxMGFkYzM5NDliYTU5YWJiZTU2ZTA1N2YyMGY4ODNlIiwiZW5hYmxlIjp0cnVlLCJyZW1hcmsiOiIiLCJsYXN0SXAiOiIxMDMuMTE3Ljc4LjQ0IiwiZW1wTnVtYmVyIjoiMjYwNDIxMDEiLCJzeXNSb2xlTGlzdCI6W3siaWQiOjIsInJvbGVHcm91cCI6IueuoeeQhuWRmCIsIm5hbWUiOiLlip_og70t566h55CG5ZGYIiwidHlwZSI6IkZVTkNUSU9OX1JPTEUiLCJjcmVhdGVUaW1lIjoiMjAyMi0xMS0wNyAxODoyNzo1OCJ9LHsiaWQiOjMsInJvbGVHcm91cCI6IueuoeeQhuWRmCIsIm5hbWUiOiLmlbDmja4t566h55CG5ZGYIiwidHlwZSI6IkRBVEFfUk9MRSIsImNyZWF0ZVRpbWUiOiIyMDIyLTExLTA3IDE4OjI4OjQwIn1dLCJzeXNNZW51TGlzdCI6bnVsbCwiYXZhdGFyIjoiaHR0cHM6Ly9qb21hbGxzLm9zcy1jbi1oYW5nemhvdS5hbGl5dW5jcy5jb20vZGVtby9vdGhlci8yNjA0LzIxLzFsMDExMzEtdHB4MDl3ci1tbzgyN2Rrai5qcGciLCJlbXBsb3llZU5hbWUiOiLmnY7lv6DnuqIiLCJyb2xlSWRzIjoiMiwzIiwicm9sZU5hbWVzIjoi5Yqf6IO9LeeuoeeQhuWRmCzmlbDmja4t566h55CG5ZGYIiwiZGVwdElkIjpudWxsLCJkZXB0TmFtZSI6bnVsbCwid2FyZWhvdXNlSWQiOm51bGwsImxhc3RMb2dpblRpbWUiOjE3ODAwNTA4OTkwMDAsImNyZWF0ZVRpbWUiOjE3NzY3NDE4NTMwMDAsImF1dGhOdW0iOjAsImF1dGhBdWRpdEZsYWciOjAsImJpbmRTdGF0dXMiOmZhbHNlLCJwbGF0Rm9ybSI6bnVsbCwicXl2eElkIjpudWxsLCJhdXRob3JpemVkU2hvcHMiOlsxLDUsNiw5LDEwLDExLDEyLDEzLDE0LDE5LDQzLDQ2LDQ3LDUxLDUyLDUzLDU0LDU1LDU3LDU5LDYwLDYxLDYyLDYzLDY0LDY1LDY2LDY3LDY4LDY5LDcwLDcxLDcyLDczLDc1LDc5LDgwLDgxLDgyLDgzLDg0LDg1LDg2LDg3LDg4LDg5LDkwLDkxLDkzLDk3LDEwMV0sImNvc3QiOm51bGwsInR5cGUiOiJFTVBMT1lFRSIsImZ1bmN0aW9uTmFtZXMiOm51bGwsImRhdGFOYW1lcyI6bnVsbCwib3JnYW5pemF0aW9uSWQiOjIsIm9yZ2FuaXphdGlvbk5hbWUiOiLkuZ3njKvnp5HmioA-5oqA5pyv6YOo6ZeoIiwiZW1wU3RhdHVzIjpudWxsLCJtdWx0aURldmljZUxvZ2luIjowLCJzdXBlcmlvcnNJZCI6MzgsInBsYXRGb3JtVHh0IjpudWxsfSwiZXhwIjoxNzgwNjQ1NjczfQ.ZotyoTjgGRvptU55TAsfbh8KRENpY7NQmUCncrUIuJyD-laIRHdNXzf3ND1HXshul_abYHFzsDw1zH01NsECTg");
Map<String, Object> data = new HashMap<>();
data.put("pageSize", 1);
data.put("currentPage", 1);
data.put("cateId", 84);
ResponseEntity<String> response = remoteApiClient.post(url, data, String.class, headers);
System.out.println("response 响应信息:");
System.out.println("response.getStatusCode:" + response.getStatusCode().value());
System.out.println("response.getBody:" + response.getBody());
}
/**
* 示例3:POST 请求(创建用户)
*/
public void createUser(Map<String, Object> data) {
String url = "https://api.example.com/users";
Map<String, String> headers = new HashMap<>();
headers.put("X-Api-Key", "your-api-key");
ResponseEntity<String> response = remoteApiClient.post(url, data, String.class, headers);
}
/**
* 示例4:POST 请求(发送表单数据)
*/
public void uploadData(Map<String, Object> data) {
String url = "https://api.example.com/data/upload";
Map<String, String> headers = new HashMap<>();
headers.put("Content-Type", "application/json");
ResponseEntity<String> response = remoteApiClient.post(url, data, String.class, headers);
}
}
\ 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