TradeId:controller改造返回值中加上traceId

This commit is contained in:
zhugongping 2026-09-23 15:34:40 +08:00
parent 7039f43964
commit 58632033b5
15 changed files with 364 additions and 82 deletions

View File

@ -1,6 +1,7 @@
package com.fastbee.common.core.domain;
import com.fastbee.common.constant.HttpStatus;
import com.fastbee.common.trace.TraceContext;
import com.fastbee.common.utils.StringUtils;
import java.util.HashMap;
@ -196,4 +197,20 @@ public class AjaxResult extends HashMap<String, Object> {
super.put(key, value);
return this;
}
/**
* 附带当前链路 TraceId
*
* <p>用于绕过 ResponseBodyAdvice 直接写响应的场景(Security 认证失败、重复提交拦截等),
* 这类响应不经过 TraceIdResponseAdvice,需要手动携带 TraceId 供前端排查。
*
* @return 当前对象,便于链式调用
*/
public AjaxResult withTraceId() {
String traceId = TraceContext.getTraceId();
if (traceId != null) {
super.put(TraceContext.TRACE_ID_KEY, traceId);
}
return this;
}
}

View File

@ -34,6 +34,13 @@ public class CommonResult<T> implements Serializable {
* @see ErrorCode#getMsg() ()
*/
private String msg;
/**
* 链路追踪ID,由 TraceIdResponseAdvice 统一回填,用于前后端联查日志
*
* <p>属性名必须与 {@link com.fastbee.common.trace.TraceContext#TRACE_ID_KEY} 保持一致,
* 否则本类序列化出的 JSON 字段名会与 AjaxResult 注入的字段名不一致。
*/
private String traceId;
/**
* 将传入的 result 对象,转换成另外一个泛型结果的对象

View File

@ -25,6 +25,14 @@ public class R<T> implements Serializable {
private T data;
/**
* 链路追踪ID,由 TraceIdResponseAdvice 统一回填,用于前后端联查日志
*
* <p>属性名必须与 {@link com.fastbee.common.trace.TraceContext#TRACE_ID_KEY} 保持一致,
* 否则本类序列化出的 JSON 字段名会与 AjaxResult 注入的字段名不一致。
*/
private String traceId;
public static <T> R<T> ok() {
return restResult(null, SUCCESS, "操作成功");
}
@ -96,4 +104,12 @@ public class R<T> implements Serializable {
public void setData(T data) {
this.data = data;
}
public String getTraceId() {
return traceId;
}
public void setTraceId(String traceId) {
this.traceId = traceId;
}
}

View File

@ -31,6 +31,14 @@ public class TableDataInfo implements Serializable {
*/
private String msg;
/**
* 链路追踪ID,由 TraceIdResponseAdvice 统一回填,用于前后端联查日志
*
* <p>属性名必须与 {@link com.fastbee.common.trace.TraceContext#TRACE_ID_KEY} 保持一致,
* 否则本类序列化出的 JSON 字段名会与 AjaxResult 注入的字段名不一致。
*/
private String traceId;
/**
* 表格数据对象
*/
@ -79,4 +87,12 @@ public class TableDataInfo implements Serializable {
public void setMsg(String msg) {
this.msg = msg;
}
public String getTraceId() {
return traceId;
}
public void setTraceId(String traceId) {
this.traceId = traceId;
}
}

View File

@ -44,10 +44,25 @@ import java.io.Serializable;
public class TraceContext {
/**
* TraceId 在 MDC 中的 key
* TraceId 的名称,同时用于日志 MDC 键名与 HTTP 响应体 JSON 字段名
*
* <p>两者刻意共用同一个名称:日志里 {@code grep 'traceId=xxx'} 与前端看到的
* {@code traceId: xxx} 是同一个值,避免排障时再做一次名称换算。
*/
public static final String TRACE_ID_KEY = "traceId";
/**
* TraceId 在 HTTP Header / MQ Header 中的名称
* HTTP 传播沿用业界惯例的连字符风格,因此与 MDC / JSON 的字段名不同
*/
public static final String TRACE_ID_HEADER = "X-Trace-Id";
/**
* TraceId 的合法字符集与长度上限
* 用于校验上游传入的 TraceId,防止伪造或包含换行符导致日志注入
*/
public static final String TRACE_ID_PATTERN = "^[A-Za-z0-9_-]{1,64}$";
/**
* TenantId 在 MDC 中的 key
* 预留字段,暂未使用

View File

@ -2,12 +2,15 @@ package com.fastbee.framework.config;
import com.fastbee.common.config.RuoYiConfig;
import com.fastbee.common.constant.Constants;
import com.fastbee.common.trace.TraceContext;
import com.fastbee.framework.filter.TraceIdFilter;
import com.fastbee.framework.interceptor.LanguageInterceptor;
import com.fastbee.framework.interceptor.RepeatSubmitInterceptor;
import com.fastbee.framework.interceptor.TraceIdInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.http.CacheControl;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@ -30,8 +33,20 @@ public class ResourcesConfig implements WebMvcConfigurer {
private RepeatSubmitInterceptor repeatSubmitInterceptor;
@Resource
private LanguageInterceptor languageInterceptor;
@Autowired
private TraceIdInterceptor traceIdInterceptor;
/**
* TraceId 过滤器注册
* 必须排在 Spring Security 过滤器链(-100)之前,保证 token 校验、鉴权失败等早期日志
* 与 Controller 返回的 traceId 属于同一条链路
*/
@Bean
public FilterRegistrationBean<TraceIdFilter> traceIdFilterRegistration() {
FilterRegistrationBean<TraceIdFilter> registration = new FilterRegistrationBean<>(new TraceIdFilter());
registration.addUrlPatterns("/*");
registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
registration.setName("traceIdFilter");
return registration;
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
@ -51,8 +66,7 @@ public class ResourcesConfig implements WebMvcConfigurer {
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
// TraceId拦截器放在最前面,确保所有请求都有TraceId
registry.addInterceptor(traceIdInterceptor).addPathPatterns("/**").order(0);
// TraceId 统一由 TraceIdFilter 在 Servlet 过滤器最前端生成,此处不再重复处理
registry.addInterceptor(repeatSubmitInterceptor).addPathPatterns("/**");
//这里配置国际化拦截器的白名单
registry.addInterceptor(languageInterceptor).addPathPatterns("/**").excludePathPatterns("/v2/api-docs",
@ -70,6 +84,8 @@ public class ResourcesConfig implements WebMvcConfigurer {
config.addAllowedOriginPattern("*");
// 设置访问源请求头
config.addAllowedHeader("*");
// 暴露 TraceId 响应头,跨域场景下前端 JS 才能读取
config.addExposedHeader(TraceContext.TRACE_ID_HEADER);
// 设置访问源请求方法
config.addAllowedMethod("*");
// 有效期 1800秒

View File

@ -0,0 +1,80 @@
package com.fastbee.framework.filter;
import com.fastbee.common.trace.TraceContext;
import com.fastbee.common.trace.TraceIdGenerator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import java.util.regex.Pattern;
/**
* TraceId 入口过滤器
*
* <p>放在 Servlet 过滤器链最前端(早于 Spring Security 的 JwtAuthenticationTokenFilter),
* 保证 token 校验、鉴权失败等早期日志也带有 TraceId,与 Controller 返回的 TraceId 一致。
*
* <p>职责:
* <ul>
* <li>请求头携带合法 X-Trace-Id 则继承,否则生成新的</li>
* <li>写入 TraceContext(自动同步 MDC)并回写响应头</li>
* <li>请求结束后清理 TraceContext,避免 Tomcat 线程复用导致 TraceId 串链</li>
* </ul>
*
* @author FastBee
*/
public class TraceIdFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(TraceIdFilter.class);
private static final Pattern TRACE_ID_MATCHER = Pattern.compile(TraceContext.TRACE_ID_PATTERN);
/**
* 不打请求日志的路径前缀
* 过滤器作用域是 /*,静态资源与监控端点数量大且无排查价值,避免淹没业务日志
*/
private static final Set<String> NO_LOG_PATH_PREFIX = new HashSet<>(Arrays.asList(
"/profile", "/statics", "/webjars", "/actuator", "/druid", "/favicon.ico", "/ueditor"));
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String traceId = request.getHeader(TraceContext.TRACE_ID_HEADER);
// 上游 TraceId 不合法(含换行符等)时重新生成,避免日志注入
if (traceId == null || traceId.isEmpty() || !TRACE_ID_MATCHER.matcher(traceId).matches()) {
traceId = TraceIdGenerator.generate();
}
TraceContext.setTraceId(traceId);
response.setHeader(TraceContext.TRACE_ID_HEADER, traceId);
String uri = request.getRequestURI();
try {
if (isNeedLog(uri)) {
log.info("请求入口: {} {}", request.getMethod(), uri);
}
chain.doFilter(request, response);
} finally {
TraceContext.clear();
}
}
private boolean isNeedLog(String uri) {
if (uri == null) {
return false;
}
for (String prefix : NO_LOG_PATH_PREFIX) {
if (uri.startsWith(prefix)) {
return false;
}
}
return true;
}
}

View File

@ -27,7 +27,7 @@ public abstract class RepeatSubmitInterceptor implements HandlerInterceptor {
RepeatSubmit annotation = method.getAnnotation(RepeatSubmit.class);
if (annotation != null) {
if (this.isRepeatSubmit(request, annotation)) {
AjaxResult ajaxResult = AjaxResult.error(annotation.message());
AjaxResult ajaxResult = AjaxResult.error(annotation.message()).withTraceId();
ServletUtils.renderString(response, JSON.toJSONString(ajaxResult));
return false;
}

View File

@ -1,53 +0,0 @@
package com.fastbee.framework.interceptor;
import com.fastbee.common.trace.TraceContext;
import com.fastbee.common.trace.TraceIdGenerator;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 日志追踪ID拦截器
* 用于生成和传递TraceId,方便同一个链路调用追查问题
*
* @author Sumuxi
*/
@Component
public class TraceIdInterceptor implements HandlerInterceptor {
private static final String TRACE_ID_HEADER = "X-Trace-Id";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 获取请求URI
String requestURI = request.getRequestURI();
// 尝试从请求头中获取TraceId(支持分布式追踪)
String traceId = request.getHeader(TRACE_ID_HEADER);
// 如果请求头中没有TraceId,则生成一个新的
if (traceId == null || traceId.isEmpty()) {
traceId = TraceIdGenerator.generate();
}
// 设置到 TraceContext(自动同步到 MDC)
TraceContext.setTraceId(traceId);
// 将TraceId放入响应头,方便前端获取
response.setHeader(TRACE_ID_HEADER, traceId);
// 打印请求路径日志
org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(TraceIdInterceptor.class);
log.info("拦截请求路径: {}", requestURI);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 请求完成后清除 TraceContext,避免内存泄漏
TraceContext.clear();
}
}

View File

@ -23,20 +23,10 @@ import java.io.Serializable;
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint, Serializable {
private static final long serialVersionUID = -8970718410437077606L;
public static boolean isAjaxRequest(HttpServletRequest request) {
String ajaxFlag = request.getHeader("X-Requested-With");
return ajaxFlag != null && "XMLHttpRequest".equals(ajaxFlag);
}
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e)
throws IOException {
// if (isAjaxRequest(request)){
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) {
int code = HttpStatus.UNAUTHORIZED;
String msg = StringUtils.format("请求访问:{},认证失败,无法访问系统资源", request.getRequestURI());
ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg)));
// }else {
// response.sendRedirect("/oauth/login");
// }
ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg).withTraceId()));
}
}

View File

@ -45,6 +45,6 @@ public class LogoutSuccessHandlerImpl implements LogoutSuccessHandler {
// 记录用户退出日志
AsyncManager.me().execute(AsyncFactory.recordLogininfor(userName, Constants.LOGOUT, "退出成功"));
}
ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.success("退出成功")));
ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.success("退出成功").withTraceId()));
}
}

View File

@ -0,0 +1,140 @@
package com.fastbee.framework.web.advice;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fastbee.common.core.domain.CommonResult;
import com.fastbee.common.core.domain.R;
import com.fastbee.common.core.page.TableDataInfo;
import com.fastbee.common.trace.TraceContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.MethodParameter;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
/**
* TraceId 响应体注入
*
* <p>所有经 JSON 序列化的 Controller 返回值统一带上 traceId 字段,前端报错时可直接把
* traceId 报给后端,用 {@code grep traceId=xxx logs/sys-all.log} 定位整条链路。
*
* <p>覆盖范围:AjaxResult、R、CommonResult、TableDataInfo 以及直接返回 VO 的接口,
* 同时包含 {@code @RestControllerAdvice} 异常处理返回的 AjaxResult。
*
* <p>处理策略:
* <ul>
* <li>AjaxResult(Map 子类):直接 put,零额外开销</li>
* <li>R / CommonResult / TableDataInfo:调用新增的 setTraceId,避免 JSON 树转换开销</li>
* <li>其余 POJO:转成 JSON 对象节点后追加字段,复用容器中的 ObjectMapper 保证序列化配置一致</li>
* <li>响应体为 null / JSON 数组 / 标量:无法追加字段,保持原样,由 X-Trace-Id 响应头兜底</li>
* <li>非 JSON 转换器(String、byte[]、文件、流):不介入,同样由响应头兜底</li>
* </ul>
*
* @author FastBee
*/
@RestControllerAdvice
// 最后执行:若有其它 ResponseBodyAdvice(如统一结果包装)先执行,本 advice 再在其结果上补 traceId
@Order()
public class TraceIdResponseAdvice implements ResponseBodyAdvice<Object> {
/**
* 不注入响应体的路径前缀
* Swagger/OpenAPI 文档是既定结构的元数据,注入业务字段会污染契约
*/
private static final List<String> EXCLUDE_PATH_PREFIX = Arrays.asList(
"/v2/api-docs", "/v3/api-docs", "/swagger-resources", "/webjars", "/actuator");
private static final Logger log = LoggerFactory.getLogger(TraceIdResponseAdvice.class);
private final ObjectMapper objectMapper;
public TraceIdResponseAdvice(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return MappingJackson2HttpMessageConverter.class.isAssignableFrom(converterType);
}
@Override
@SuppressWarnings("unchecked")
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response) {
String traceId = TraceContext.getTraceId();
if (traceId == null) {
return body;
}
// 响应头始终写入:即使响应体无法追加字段,前端仍可从 header 取到 TraceId
response.getHeaders().set(TraceContext.TRACE_ID_HEADER, traceId);
if (body == null || isExcludePath(request.getURI().getPath())) {
return body;
}
// Map 子类无需 JSON 树转换,直接进入零开销路径
if (body instanceof Map) {
writeToMap((Map<String, Object>) body, traceId, request.getURI().getPath());
return body;
}
if (body instanceof R) {
((R<?>) body).setTraceId(traceId);
return body;
}
if (body instanceof CommonResult) {
((CommonResult<?>) body).setTraceId(traceId);
return body;
}
if (body instanceof TableDataInfo) {
((TableDataInfo) body).setTraceId(traceId);
return body;
}
// 直接返回 VO 的场景:复用容器 ObjectMapper 转成 JSON 对象节点,保证序列化配置一致
try {
JsonNode node = objectMapper.valueToTree(body);
if (node instanceof ObjectNode) {
((ObjectNode) node).put(TraceContext.TRACE_ID_KEY, traceId);
return node;
}
} catch (RuntimeException e) {
// JSON 树转换失败(如存在循环引用)时保持原样,由响应头兜底,绝不让旁路功能影响业务响应
log.warn("响应体注入 traceId 失败,已降级到响应头,uri={}", request.getURI().getPath(), e);
}
return body;
}
/**
* 向 Map 响应体写入 traceId
* 不可变 Map(如 Collections.unmodifiableMap)会拒绝写入,此时由响应头兜底
*/
private void writeToMap(Map<String, Object> body, String traceId, String uri) {
try {
body.put(TraceContext.TRACE_ID_KEY, traceId);
} catch (UnsupportedOperationException e) {
log.debug("响应体为不可变 Map,traceId 仅输出到响应头,uri={}", uri, e);
}
}
private boolean isExcludePath(String path) {
if (path == null) {
return false;
}
for (String prefix : EXCLUDE_PATH_PREFIX) {
if (path.startsWith(prefix)) {
return true;
}
}
return false;
}
}

View File

@ -7,6 +7,17 @@ const codeMessage = {
500: '服务器内部错误,无法完成请求',
};
/**
* 给错误提示附加 traceId,便于用户报错后到后端日志里检索整条链路
* 优先取响应体 traceId,其次取 X-Trace-Id 响应头
*/
const withTraceId = (msg, res) => {
// 小程序平台 response header 字段名不统一,两个都取
const header = res.header || res.headers || {};
const traceId = (res.data && res.data.traceId) || header['X-Trace-Id'] || header['x-trace-id'] || '';
return traceId ? `${msg} [traceId:${traceId}]` : msg;
};
const install = (Vue, vm) => {
// 这个配置是一次配置,全局通用的,具体参数见 https://www.uviewui.com/js/http.html
uni.$u.http.setConfig((config) => {
@ -115,10 +126,10 @@ const install = (Vue, vm) => {
return result
} else {
console.log(result);
vm.$u.toast(result.msg);
vm.$u.toast(withTraceId(result.msg, res));
}
} else {
vm.$u.toast(res.data.msg);
vm.$u.toast(withTraceId((res.data && res.data.msg) || '', res));
}
return false;
});

View File

@ -71,8 +71,10 @@ axiosInstance.interceptors.response.use(
return Promise.resolve(res.data)
}
// 提示错误
window['$message'].error(window['$t']((res.data as any).msg))
// 提示错误(附带 traceId,便于用户报错后按此 ID 检索后端整条链路日志)
const { msg, traceId } = res.data as { msg: string; traceId?: string }
const message = window['$t'](msg)
window['$message'].error(traceId ? `${message} [traceId:${traceId}]` : message)
return Promise.resolve(res.data)
},
(err: AxiosError) => {

View File

@ -11,6 +11,31 @@ let downloadLoadingInstance;
// 是否显示重新登录
export let isRelogin = { show: false };
/**
* 提取响应中的链路追踪ID
* 优先取响应体 traceId 字段,其次取 X-Trace-Id 响应头(二进制/网络异常场景)
*
* @param {Object} res axios 响应对象
* @returns {String} traceId,取不到时返回空串
*/
function getTraceId(res) {
if (!res) {
return '';
}
return (res.data && res.data.traceId) || (res.headers && res.headers['x-trace-id']) || '';
}
/**
* 给错误提示附加 traceId,便于用户报错后直接到日志里检索整条链路
*
* @param {String} message 原始提示
* @param {String} traceId 链路追踪ID
* @returns {String} 拼接后的提示
*/
function appendTraceId(message, traceId) {
return traceId ? `${message} [traceId:${traceId}]` : message;
}
axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8';
// 创建axios实例
const service = axios.create({
@ -102,13 +127,13 @@ service.interceptors.response.use(
}
return Promise.reject('无效的会话,或者会话已过期,请重新登录。');
} else if (code === 500) {
Message({ message: msg, type: 'error' });
Message({ message: appendTraceId(msg, getTraceId(res)), type: 'error', duration: 5 * 1000 });
return Promise.reject('error');
} else if (code === 601) {
Message({ message: msg, type: 'warning' });
Message({ message: appendTraceId(msg, getTraceId(res)), type: 'warning', duration: 5 * 1000 });
return Promise.reject('error');
} else if (code !== 200) {
Notification.error({ title: msg });
Notification.error({ title: msg, message: getTraceId(res) ? 'traceId:' + getTraceId(res) : '', duration: 5 * 1000 });
return Promise.reject('error');
} else {
return res.data;
@ -124,7 +149,7 @@ service.interceptors.response.use(
} else if (message.includes('Request failed with status code')) {
message = '系统接口' + message.substr(message.length - 3) + '异常';
}
Message({ message: message, type: 'error', duration: 5 * 1000 });
Message({ message: appendTraceId(message, getTraceId(error.response)), type: 'error', duration: 5 * 1000 });
return Promise.reject(error);
}
);
@ -152,7 +177,7 @@ export function download(url, params, filename, config) {
const resText = await data.text();
const rspObj = JSON.parse(resText);
const errMsg = errorCode[rspObj.code] || rspObj.msg || errorCode['default'];
Message.error(errMsg);
Message.error({ message: appendTraceId(errMsg, rspObj.traceId), duration: 5 * 1000 });
}
downloadLoadingInstance.close();
})