huangxz
2026-03-05 c06808d044501d558db65520bcd5dc06edf96aed
feat: 添加请求/响应日志过滤器和Feign调用日志切面
8个文件已添加
888 ■■■■■ 已修改文件
xp-robotaxi-itinerary-boot/src/main/java/com/xiaopeng/itinerary/config/log/FeignLoggingAspect.java 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-itinerary-boot/src/main/java/com/xiaopeng/itinerary/config/log/RequestResponseLoggingFilter.java 139 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-order-boot/src/main/java/com/xiaopeng/order/config/log/FeignLoggingAspect.java 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-order-boot/src/main/java/com/xiaopeng/order/config/log/RequestResponseLoggingFilter.java 139 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-payment-boot/src/main/java/com/xiaopeng/payment/config/log/FeignLoggingAspect.java 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-payment-boot/src/main/java/com/xiaopeng/payment/config/log/RequestResponseLoggingFilter.java 139 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-push-message-boot/src/main/java/com/xiaopeng/pushMessage/config/log/FeignLoggingAspect.java 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-push-message-boot/src/main/java/com/xiaopeng/pushMessage/config/log/RequestResponseLoggingFilter.java 139 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-itinerary-boot/src/main/java/com/xiaopeng/itinerary/config/log/FeignLoggingAspect.java
New file
@@ -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() + "]";
        }
    }
}
xp-robotaxi-itinerary-boot/src/main/java/com/xiaopeng/itinerary/config/log/RequestResponseLoggingFilter.java
New file
@@ -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);
    }
}
xp-robotaxi-order-boot/src/main/java/com/xiaopeng/order/config/log/FeignLoggingAspect.java
New file
@@ -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() + "]";
        }
    }
}
xp-robotaxi-order-boot/src/main/java/com/xiaopeng/order/config/log/RequestResponseLoggingFilter.java
New file
@@ -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);
    }
}
xp-robotaxi-payment-boot/src/main/java/com/xiaopeng/payment/config/log/FeignLoggingAspect.java
New file
@@ -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() + "]";
        }
    }
}
xp-robotaxi-payment-boot/src/main/java/com/xiaopeng/payment/config/log/RequestResponseLoggingFilter.java
New file
@@ -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);
    }
}
xp-robotaxi-push-message-boot/src/main/java/com/xiaopeng/pushMessage/config/log/FeignLoggingAspect.java
New file
@@ -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() + "]";
        }
    }
}
xp-robotaxi-push-message-boot/src/main/java/com/xiaopeng/pushMessage/config/log/RequestResponseLoggingFilter.java
New file
@@ -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);
    }
}