ruoyi-auth/src/main/java/com/ruoyi/auth/config/log/FeignLoggingAspect.java
New file @@ -0,0 +1,117 @@ package com.ruoyi.auth.config.log; import com.alibaba.fastjson2.JSON; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.reflect.MethodSignature; import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import javax.servlet.http.HttpServletRequest; import java.lang.reflect.Method; import java.util.Arrays; import java.util.Enumeration; /** * Feign 调用日志切面 * * <p>拦截所有带 @FeignClient 注解的接口方法,记录: * <ul> * <li>调用的接口类名 + 方法名</li> * <li>入参(JSON 序列化)</li> * <li>出参(JSON 序列化)</li> * <li>耗时(ms)</li> * <li>异常信息(含完整堆栈)</li> * </ul> */ @Slf4j @Aspect @Component public class FeignLoggingAspect { /** * 拦截所有带 @FeignClient 注解的接口的所有方法 */ @Around("within(@org.springframework.cloud.openfeign.FeignClient *)") public Object logFeignCall(ProceedingJoinPoint pjp) throws Throwable { return doLog(pjp); } private Object doLog(ProceedingJoinPoint pjp) throws Throwable { MethodSignature signature = (MethodSignature) pjp.getSignature(); Method method = signature.getMethod(); // 使用接口声明类名,避免打印动态代理类名(如 $Proxy270) String className = signature.getDeclaringType().getSimpleName(); String methodName = method.getName(); String fullMethod = className + "#" + methodName; Object[] args = pjp.getArgs(); String argsJson = safeToJson(args); String headerStr = extractCurrentHeaders(); if (headerStr != null) { log.info("[Feign] >>> 调用开始 | method={} | headers={} | args={}", fullMethod, headerStr, argsJson); } else { log.info("[Feign] >>> 调用开始 | method={} | args={}", fullMethod, argsJson); } long start = System.currentTimeMillis(); try { Object result = pjp.proceed(); long elapsed = System.currentTimeMillis() - start; String resultJson = safeToJson(result); log.info("[Feign] <<< 调用成功 | method={} | elapsed={}ms | result={}", fullMethod, elapsed, resultJson); return result; } catch (Throwable ex) { long elapsed = System.currentTimeMillis() - start; log.error("[Feign] <<< 调用异常 | method={} | elapsed={}ms | error={} | args={}", fullMethod, elapsed, ex.getMessage(), argsJson, ex); throw ex; } } /** * 安全序列化为 JSON,避免序列化失败导致日志切面本身抛异常 */ private String safeToJson(Object obj) { if (obj == null) { return "null"; } try { if (obj instanceof Object[]) { return JSON.toJSONString(Arrays.asList((Object[]) obj)); } return JSON.toJSONString(obj); } catch (Exception e) { return "[序列化失败: " + obj.getClass().getSimpleName() + "]"; } } /** * 从当前请求上下文中提取请求头信息 */ private String extractCurrentHeaders() { try { ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attrs == null) return "{}"; HttpServletRequest request = attrs.getRequest(); StringBuilder sb = new StringBuilder("{"); boolean first = true; Enumeration<String> headerNames = request.getHeaderNames(); while (headerNames != null && headerNames.hasMoreElements()) { String name = headerNames.nextElement(); String value = request.getHeader(name); if (!first) sb.append(", "); first = false; sb.append(name).append("=").append(value); } return sb.append("}").toString(); } catch (Exception e) { return "{error: " + e.getMessage() + "}"; } } } ruoyi-auth/src/main/java/com/ruoyi/auth/config/log/RequestResponseLoggingFilter.java
New file @@ -0,0 +1,152 @@ package com.ruoyi.auth.config.log; import lombok.extern.slf4j.Slf4j; import org.slf4j.MDC; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.util.ContentCachingRequestWrapper; import org.springframework.web.util.ContentCachingResponseWrapper; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.*; /** * 请求/响应日志过滤器 * 打印:traceId、method、uri、queryString、请求体、响应状态、耗时、响应体 */ @Slf4j @Component @Order(Ordered.HIGHEST_PRECEDENCE + 10) public class RequestResponseLoggingFilter extends OncePerRequestFilter { /** 不打印 body 的路径前缀(健康检查、actuator 等) */ private static final Set<String> EXCLUDE_PATHS = new HashSet<>(Arrays.asList( "/actuator", "/healthcheck", "/health", "/prometheus" )); /** 只对这些 Content-Type 打印 body(避免打印二进制流) */ private static final Set<String> LOGGABLE_CONTENT_TYPES = new HashSet<>(Arrays.asList( MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_JSON_UTF8_VALUE, MediaType.TEXT_PLAIN_VALUE, MediaType.TEXT_XML_VALUE, MediaType.APPLICATION_XML_VALUE, "application/x-www-form-urlencoded" )); private static final int MAX_BODY_LENGTH = 2000; @Override protected boolean shouldNotFilter(HttpServletRequest request) { String uri = request.getRequestURI(); return EXCLUDE_PATHS.stream().anyMatch(uri::startsWith); } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { // 初始化 MDC(Filter 先于 Interceptor 执行,在此统一设置) String traceId = request.getHeader("X-Trace-Id"); if (traceId == null || traceId.isEmpty()) { traceId = UUID.randomUUID().toString().replace("-", ""); } MDC.put("traceId", traceId); MDC.put("clientIp", getClientIp(request)); MDC.put("requestUri", request.getRequestURI()); response.setHeader("X-Trace-Id", traceId); ContentCachingRequestWrapper reqWrapper = new ContentCachingRequestWrapper(request); ContentCachingResponseWrapper respWrapper = new ContentCachingResponseWrapper(response); long start = System.currentTimeMillis(); try { chain.doFilter(reqWrapper, respWrapper); } finally { long cost = System.currentTimeMillis() - start; logRequest(reqWrapper); logResponse(respWrapper, cost); // 必须把缓存的响应体写回原始响应,否则客户端收不到数据 respWrapper.copyBodyToResponse(); MDC.clear(); } } private String getClientIp(HttpServletRequest request) { String ip = request.getHeader("X-Forwarded-For"); if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { return ip.split(",")[0].trim(); } ip = request.getHeader("X-Real-IP"); if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { return ip; } return request.getRemoteAddr(); } private void logRequest(ContentCachingRequestWrapper request) { String traceId = MDC.get("traceId"); String uri = request.getRequestURI(); String query = request.getQueryString(); String method = request.getMethod(); String body = extractBody(request.getContentAsByteArray(), request.getContentType()); String headers = extractHeaders(request); log.info(">>> [{}] {} {}{} | headers={} | body={}", traceId, method, uri, query != null ? "?" + query : "", headers, body); } private String extractHeaders(HttpServletRequest request) { StringBuilder sb = new StringBuilder("{"); boolean first = true; // 未配置则打印全部请求头 Enumeration<String> headerNames = request.getHeaderNames(); while (headerNames != null && headerNames.hasMoreElements()) { String name = headerNames.nextElement(); String value = request.getHeader(name); if (!first) sb.append(", "); first = false; sb.append(name).append("=").append(value); } return sb.append("}").toString(); } private void logResponse(ContentCachingResponseWrapper response, long cost) { String traceId = MDC.get("traceId"); int status = response.getStatus(); String body = extractBody(response.getContentAsByteArray(), response.getContentType()); log.info("<<< [{}] status={} cost={}ms | body={}", traceId, status, cost, body); } private String extractBody(byte[] bodyBytes, String contentType) { if (bodyBytes == null || bodyBytes.length == 0) { return ""; } if (!isLoggableContentType(contentType)) { return "[binary, " + bodyBytes.length + " bytes]"; } String body = new String(bodyBytes, StandardCharsets.UTF_8); // if (body.length() > MAX_BODY_LENGTH) { // body = body.substring(0, MAX_BODY_LENGTH) + "...(truncated)"; // } return body; } private boolean isLoggableContentType(String contentType) { if (contentType == null) return true; String ct = contentType.toLowerCase(); return LOGGABLE_CONTENT_TYPES.stream().anyMatch(ct::contains); } } ruoyi-modules/ruoyi-system/src/main/java/com/ruoyi/system/config/log/FeignLoggingAspect.java
New file @@ -0,0 +1,117 @@ package com.ruoyi.system.config.log; import com.alibaba.fastjson2.JSON; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.reflect.MethodSignature; import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import javax.servlet.http.HttpServletRequest; import java.lang.reflect.Method; import java.util.Arrays; import java.util.Enumeration; /** * Feign 调用日志切面 * * <p>拦截所有带 @FeignClient 注解的接口方法,记录: * <ul> * <li>调用的接口类名 + 方法名</li> * <li>入参(JSON 序列化)</li> * <li>出参(JSON 序列化)</li> * <li>耗时(ms)</li> * <li>异常信息(含完整堆栈)</li> * </ul> */ @Slf4j @Aspect @Component public class FeignLoggingAspect { /** * 拦截所有带 @FeignClient 注解的接口的所有方法 */ @Around("within(@org.springframework.cloud.openfeign.FeignClient *)") public Object logFeignCall(ProceedingJoinPoint pjp) throws Throwable { return doLog(pjp); } private Object doLog(ProceedingJoinPoint pjp) throws Throwable { MethodSignature signature = (MethodSignature) pjp.getSignature(); Method method = signature.getMethod(); // 使用接口声明类名,避免打印动态代理类名(如 $Proxy270) String className = signature.getDeclaringType().getSimpleName(); String methodName = method.getName(); String fullMethod = className + "#" + methodName; Object[] args = pjp.getArgs(); String argsJson = safeToJson(args); String headerStr = extractCurrentHeaders(); if (headerStr != null) { log.info("[Feign] >>> 调用开始 | method={} | headers={} | args={}", fullMethod, headerStr, argsJson); } else { log.info("[Feign] >>> 调用开始 | method={} | args={}", fullMethod, argsJson); } long start = System.currentTimeMillis(); try { Object result = pjp.proceed(); long elapsed = System.currentTimeMillis() - start; String resultJson = safeToJson(result); log.info("[Feign] <<< 调用成功 | method={} | elapsed={}ms | result={}", fullMethod, elapsed, resultJson); return result; } catch (Throwable ex) { long elapsed = System.currentTimeMillis() - start; log.error("[Feign] <<< 调用异常 | method={} | elapsed={}ms | error={} | args={}", fullMethod, elapsed, ex.getMessage(), argsJson, ex); throw ex; } } /** * 安全序列化为 JSON,避免序列化失败导致日志切面本身抛异常 */ private String safeToJson(Object obj) { if (obj == null) { return "null"; } try { if (obj instanceof Object[]) { return JSON.toJSONString(Arrays.asList((Object[]) obj)); } return JSON.toJSONString(obj); } catch (Exception e) { return "[序列化失败: " + obj.getClass().getSimpleName() + "]"; } } /** * 从当前请求上下文中提取请求头信息 */ private String extractCurrentHeaders() { try { ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attrs == null) return "{}"; HttpServletRequest request = attrs.getRequest(); StringBuilder sb = new StringBuilder("{"); boolean first = true; Enumeration<String> headerNames = request.getHeaderNames(); while (headerNames != null && headerNames.hasMoreElements()) { String name = headerNames.nextElement(); String value = request.getHeader(name); if (!first) sb.append(", "); first = false; sb.append(name).append("=").append(value); } return sb.append("}").toString(); } catch (Exception e) { return "{error: " + e.getMessage() + "}"; } } } ruoyi-modules/ruoyi-system/src/main/java/com/ruoyi/system/config/log/RequestResponseLoggingFilter.java
New file @@ -0,0 +1,152 @@ package com.ruoyi.system.config.log; import lombok.extern.slf4j.Slf4j; import org.slf4j.MDC; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.util.ContentCachingRequestWrapper; import org.springframework.web.util.ContentCachingResponseWrapper; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.*; /** * 请求/响应日志过滤器 * 打印:traceId、method、uri、queryString、请求体、响应状态、耗时、响应体 */ @Slf4j @Component @Order(Ordered.HIGHEST_PRECEDENCE + 10) public class RequestResponseLoggingFilter extends OncePerRequestFilter { /** 不打印 body 的路径前缀(健康检查、actuator 等) */ private static final Set<String> EXCLUDE_PATHS = new HashSet<>(Arrays.asList( "/actuator", "/healthcheck", "/health", "/prometheus" )); /** 只对这些 Content-Type 打印 body(避免打印二进制流) */ private static final Set<String> LOGGABLE_CONTENT_TYPES = new HashSet<>(Arrays.asList( MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_JSON_UTF8_VALUE, MediaType.TEXT_PLAIN_VALUE, MediaType.TEXT_XML_VALUE, MediaType.APPLICATION_XML_VALUE, "application/x-www-form-urlencoded" )); private static final int MAX_BODY_LENGTH = 2000; @Override protected boolean shouldNotFilter(HttpServletRequest request) { String uri = request.getRequestURI(); return EXCLUDE_PATHS.stream().anyMatch(uri::startsWith); } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { // 初始化 MDC(Filter 先于 Interceptor 执行,在此统一设置) String traceId = request.getHeader("X-Trace-Id"); if (traceId == null || traceId.isEmpty()) { traceId = UUID.randomUUID().toString().replace("-", ""); } MDC.put("traceId", traceId); MDC.put("clientIp", getClientIp(request)); MDC.put("requestUri", request.getRequestURI()); response.setHeader("X-Trace-Id", traceId); ContentCachingRequestWrapper reqWrapper = new ContentCachingRequestWrapper(request); ContentCachingResponseWrapper respWrapper = new ContentCachingResponseWrapper(response); long start = System.currentTimeMillis(); try { chain.doFilter(reqWrapper, respWrapper); } finally { long cost = System.currentTimeMillis() - start; logRequest(reqWrapper); logResponse(respWrapper, cost); // 必须把缓存的响应体写回原始响应,否则客户端收不到数据 respWrapper.copyBodyToResponse(); MDC.clear(); } } private String getClientIp(HttpServletRequest request) { String ip = request.getHeader("X-Forwarded-For"); if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { return ip.split(",")[0].trim(); } ip = request.getHeader("X-Real-IP"); if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { return ip; } return request.getRemoteAddr(); } private void logRequest(ContentCachingRequestWrapper request) { String traceId = MDC.get("traceId"); String uri = request.getRequestURI(); String query = request.getQueryString(); String method = request.getMethod(); String body = extractBody(request.getContentAsByteArray(), request.getContentType()); String headers = extractHeaders(request); log.info(">>> [{}] {} {}{} | headers={} | body={}", traceId, method, uri, query != null ? "?" + query : "", headers, body); } private String extractHeaders(HttpServletRequest request) { StringBuilder sb = new StringBuilder("{"); boolean first = true; // 未配置则打印全部请求头 Enumeration<String> headerNames = request.getHeaderNames(); while (headerNames != null && headerNames.hasMoreElements()) { String name = headerNames.nextElement(); String value = request.getHeader(name); if (!first) sb.append(", "); first = false; sb.append(name).append("=").append(value); } return sb.append("}").toString(); } private void logResponse(ContentCachingResponseWrapper response, long cost) { String traceId = MDC.get("traceId"); int status = response.getStatus(); String body = extractBody(response.getContentAsByteArray(), response.getContentType()); log.info("<<< [{}] status={} cost={}ms | body={}", traceId, status, cost, body); } private String extractBody(byte[] bodyBytes, String contentType) { if (bodyBytes == null || bodyBytes.length == 0) { return ""; } if (!isLoggableContentType(contentType)) { return "[binary, " + bodyBytes.length + " bytes]"; } String body = new String(bodyBytes, StandardCharsets.UTF_8); // if (body.length() > MAX_BODY_LENGTH) { // body = body.substring(0, MAX_BODY_LENGTH) + "...(truncated)"; // } return body; } private boolean isLoggableContentType(String contentType) { if (contentType == null) return true; String ct = contentType.toLowerCase(); return LOGGABLE_CONTENT_TYPES.stream().anyMatch(ct::contains); } } ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/config/log/RequestResponseLoggingFilter.java
@@ -138,9 +138,9 @@ return "[binary, " + bodyBytes.length + " bytes]"; } String body = new String(bodyBytes, StandardCharsets.UTF_8); if (body.length() > MAX_BODY_LENGTH) { body = body.substring(0, MAX_BODY_LENGTH) + "...(truncated)"; } // if (body.length() > MAX_BODY_LENGTH) { // body = body.substring(0, MAX_BODY_LENGTH) + "...(truncated)"; // } return body; } ruoyi-service/ruoyi-chargingPile/src/main/java/com/ruoyi/chargingPile/config/log/RequestResponseLoggingFilter.java
@@ -138,9 +138,9 @@ return "[binary, " + bodyBytes.length + " bytes]"; } String body = new String(bodyBytes, StandardCharsets.UTF_8); if (body.length() > MAX_BODY_LENGTH) { body = body.substring(0, MAX_BODY_LENGTH) + "...(truncated)"; } // if (body.length() > MAX_BODY_LENGTH) { // body = body.substring(0, MAX_BODY_LENGTH) + "...(truncated)"; // } return body; } ruoyi-service/ruoyi-integration/src/main/java/com/ruoyi/integration/config/log/RequestResponseLoggingFilter.java
@@ -138,9 +138,9 @@ return "[binary, " + bodyBytes.length + " bytes]"; } String body = new String(bodyBytes, StandardCharsets.UTF_8); if (body.length() > MAX_BODY_LENGTH) { body = body.substring(0, MAX_BODY_LENGTH) + "...(truncated)"; } // if (body.length() > MAX_BODY_LENGTH) { // body = body.substring(0, MAX_BODY_LENGTH) + "...(truncated)"; // } return body; } ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/config/log/RequestResponseLoggingFilter.java
@@ -138,9 +138,9 @@ return "[binary, " + bodyBytes.length + " bytes]"; } String body = new String(bodyBytes, StandardCharsets.UTF_8); if (body.length() > MAX_BODY_LENGTH) { body = body.substring(0, MAX_BODY_LENGTH) + "...(truncated)"; } // if (body.length() > MAX_BODY_LENGTH) { // body = body.substring(0, MAX_BODY_LENGTH) + "...(truncated)"; // } return body; } ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/config/log/RequestResponseLoggingFilter.java
@@ -138,9 +138,9 @@ return "[binary, " + bodyBytes.length + " bytes]"; } String body = new String(bodyBytes, StandardCharsets.UTF_8); if (body.length() > MAX_BODY_LENGTH) { body = body.substring(0, MAX_BODY_LENGTH) + "...(truncated)"; } // if (body.length() > MAX_BODY_LENGTH) { // body = body.substring(0, MAX_BODY_LENGTH) + "...(truncated)"; // } return body; } ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/config/log/RequestResponseLoggingFilter.java
@@ -138,9 +138,9 @@ return "[binary, " + bodyBytes.length + " bytes]"; } String body = new String(bodyBytes, StandardCharsets.UTF_8); if (body.length() > MAX_BODY_LENGTH) { body = body.substring(0, MAX_BODY_LENGTH) + "...(truncated)"; } // if (body.length() > MAX_BODY_LENGTH) { // body = body.substring(0, MAX_BODY_LENGTH) + "...(truncated)"; // } return body; }