From c06808d044501d558db65520bcd5dc06edf96aed Mon Sep 17 00:00:00 2001
From: huangxz <huangxz@xiaopeng.com>
Date: 星期六, 07 三月 2026 21:01:24 +0800
Subject: [PATCH] feat: 添加请求/响应日志过滤器和Feign调用日志切面
---
xp-robotaxi-order-boot/src/main/java/com/xiaopeng/order/config/log/RequestResponseLoggingFilter.java | 139 +++++++++
xp-robotaxi-push-message-boot/src/main/java/com/xiaopeng/pushMessage/config/log/FeignLoggingAspect.java | 83 +++++
xp-robotaxi-itinerary-boot/src/main/java/com/xiaopeng/itinerary/config/log/FeignLoggingAspect.java | 83 +++++
xp-robotaxi-itinerary-boot/src/main/java/com/xiaopeng/itinerary/config/log/RequestResponseLoggingFilter.java | 139 +++++++++
xp-robotaxi-payment-boot/src/main/java/com/xiaopeng/payment/config/log/FeignLoggingAspect.java | 83 +++++
xp-robotaxi-order-boot/src/main/java/com/xiaopeng/order/config/log/FeignLoggingAspect.java | 83 +++++
xp-robotaxi-payment-boot/src/main/java/com/xiaopeng/payment/config/log/RequestResponseLoggingFilter.java | 139 +++++++++
xp-robotaxi-push-message-boot/src/main/java/com/xiaopeng/pushMessage/config/log/RequestResponseLoggingFilter.java | 139 +++++++++
8 files changed, 888 insertions(+), 0 deletions(-)
diff --git a/xp-robotaxi-itinerary-boot/src/main/java/com/xiaopeng/itinerary/config/log/FeignLoggingAspect.java b/xp-robotaxi-itinerary-boot/src/main/java/com/xiaopeng/itinerary/config/log/FeignLoggingAspect.java
new file mode 100644
index 0000000..5af8b0f
--- /dev/null
+++ b/xp-robotaxi-itinerary-boot/src/main/java/com/xiaopeng/itinerary/config/log/FeignLoggingAspect.java
@@ -0,0 +1,83 @@
+package com.xiaopeng.itinerary.config.log;
+
+import com.alibaba.fastjson.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 java.lang.reflect.Method;
+import java.util.Arrays;
+
+/**
+ * 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);
+
+ 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() + "]";
+ }
+ }
+}
diff --git a/xp-robotaxi-itinerary-boot/src/main/java/com/xiaopeng/itinerary/config/log/RequestResponseLoggingFilter.java b/xp-robotaxi-itinerary-boot/src/main/java/com/xiaopeng/itinerary/config/log/RequestResponseLoggingFilter.java
new file mode 100644
index 0000000..a68088b
--- /dev/null
+++ b/xp-robotaxi-itinerary-boot/src/main/java/com/xiaopeng/itinerary/config/log/RequestResponseLoggingFilter.java
@@ -0,0 +1,139 @@
+package com.xiaopeng.itinerary.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.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * 请求/响应日志过滤器
+ * 打印: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 = java.util.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());
+
+ log.info(">>> [{}] {} {}{} | body={}",
+ traceId,
+ method,
+ uri,
+ query != null ? "?" + query : "",
+ body);
+ }
+
+ 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);
+ }
+}
diff --git a/xp-robotaxi-order-boot/src/main/java/com/xiaopeng/order/config/log/FeignLoggingAspect.java b/xp-robotaxi-order-boot/src/main/java/com/xiaopeng/order/config/log/FeignLoggingAspect.java
new file mode 100644
index 0000000..b62264e
--- /dev/null
+++ b/xp-robotaxi-order-boot/src/main/java/com/xiaopeng/order/config/log/FeignLoggingAspect.java
@@ -0,0 +1,83 @@
+package com.xiaopeng.order.config.log;
+
+import com.alibaba.fastjson.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 java.lang.reflect.Method;
+import java.util.Arrays;
+
+/**
+ * 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);
+
+ 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() + "]";
+ }
+ }
+}
diff --git a/xp-robotaxi-order-boot/src/main/java/com/xiaopeng/order/config/log/RequestResponseLoggingFilter.java b/xp-robotaxi-order-boot/src/main/java/com/xiaopeng/order/config/log/RequestResponseLoggingFilter.java
new file mode 100644
index 0000000..ff1a80b
--- /dev/null
+++ b/xp-robotaxi-order-boot/src/main/java/com/xiaopeng/order/config/log/RequestResponseLoggingFilter.java
@@ -0,0 +1,139 @@
+package com.xiaopeng.order.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.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * 请求/响应日志过滤器
+ * 打印: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 = java.util.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());
+
+ log.info(">>> [{}] {} {}{} | body={}",
+ traceId,
+ method,
+ uri,
+ query != null ? "?" + query : "",
+ body);
+ }
+
+ 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);
+ }
+}
diff --git a/xp-robotaxi-payment-boot/src/main/java/com/xiaopeng/payment/config/log/FeignLoggingAspect.java b/xp-robotaxi-payment-boot/src/main/java/com/xiaopeng/payment/config/log/FeignLoggingAspect.java
new file mode 100644
index 0000000..f919fe1
--- /dev/null
+++ b/xp-robotaxi-payment-boot/src/main/java/com/xiaopeng/payment/config/log/FeignLoggingAspect.java
@@ -0,0 +1,83 @@
+package com.xiaopeng.payment.config.log;
+
+import com.alibaba.fastjson.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 java.lang.reflect.Method;
+import java.util.Arrays;
+
+/**
+ * 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);
+
+ 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() + "]";
+ }
+ }
+}
diff --git a/xp-robotaxi-payment-boot/src/main/java/com/xiaopeng/payment/config/log/RequestResponseLoggingFilter.java b/xp-robotaxi-payment-boot/src/main/java/com/xiaopeng/payment/config/log/RequestResponseLoggingFilter.java
new file mode 100644
index 0000000..cd31b1a
--- /dev/null
+++ b/xp-robotaxi-payment-boot/src/main/java/com/xiaopeng/payment/config/log/RequestResponseLoggingFilter.java
@@ -0,0 +1,139 @@
+package com.xiaopeng.payment.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.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * 请求/响应日志过滤器
+ * 打印: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 = java.util.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());
+
+ log.info(">>> [{}] {} {}{} | body={}",
+ traceId,
+ method,
+ uri,
+ query != null ? "?" + query : "",
+ body);
+ }
+
+ 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);
+ }
+}
diff --git a/xp-robotaxi-push-message-boot/src/main/java/com/xiaopeng/pushMessage/config/log/FeignLoggingAspect.java b/xp-robotaxi-push-message-boot/src/main/java/com/xiaopeng/pushMessage/config/log/FeignLoggingAspect.java
new file mode 100644
index 0000000..53b7414
--- /dev/null
+++ b/xp-robotaxi-push-message-boot/src/main/java/com/xiaopeng/pushMessage/config/log/FeignLoggingAspect.java
@@ -0,0 +1,83 @@
+package com.xiaopeng.pushMessage.config.log;
+
+import com.alibaba.fastjson.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 java.lang.reflect.Method;
+import java.util.Arrays;
+
+/**
+ * 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);
+
+ 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() + "]";
+ }
+ }
+}
diff --git a/xp-robotaxi-push-message-boot/src/main/java/com/xiaopeng/pushMessage/config/log/RequestResponseLoggingFilter.java b/xp-robotaxi-push-message-boot/src/main/java/com/xiaopeng/pushMessage/config/log/RequestResponseLoggingFilter.java
new file mode 100644
index 0000000..b5ef595
--- /dev/null
+++ b/xp-robotaxi-push-message-boot/src/main/java/com/xiaopeng/pushMessage/config/log/RequestResponseLoggingFilter.java
@@ -0,0 +1,139 @@
+package com.xiaopeng.pushMessage.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.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * 请求/响应日志过滤器
+ * 打印: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 = java.util.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());
+
+ log.info(">>> [{}] {} {}{} | body={}",
+ traceId,
+ method,
+ uri,
+ query != null ? "?" + query : "",
+ body);
+ }
+
+ 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);
+ }
+}
--
Gitblit v1.7.1