diff --git a/daxpay-platform/daxpay-platform-common/common-request-context/src/main/java/cn/daxpay/open/platform/common/request/context/filter/TraceIdFilter.java b/daxpay-platform/daxpay-platform-common/common-request-context/src/main/java/cn/daxpay/open/platform/common/request/context/filter/TraceIdFilter.java
index 2154b672d..69d3877d7 100644
--- a/daxpay-platform/daxpay-platform-common/common-request-context/src/main/java/cn/daxpay/open/platform/common/request/context/filter/TraceIdFilter.java
+++ b/daxpay-platform/daxpay-platform-common/common-request-context/src/main/java/cn/daxpay/open/platform/common/request/context/filter/TraceIdFilter.java
@@ -3,8 +3,6 @@ package cn.daxpay.open.platform.common.request.context.filter;
import cn.daxpay.open.platform.common.request.context.local.RequestContextStorage;
import cn.daxpay.open.platform.core.code.CommonCode;
import cn.daxpay.open.platform.core.code.WebHeaderCode;
-import cn.hutool.core.lang.Validator;
-import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
@@ -21,13 +19,16 @@ import java.io.IOException;
/// # 追踪ID过滤器
///
+/// 基于 OpenTelemetry 自动注入的 MDC traceId, 将其透传到 ThreadLocal 与响应 header。
+/// 本过滤器不再自行生成 traceId, 完全依赖 OTel ServerHttpObservationFilter 的注入。
+/// 执行顺序晚于 OTel Filter(HIGHEST_PRECEDENCE + 1), 以确保 MDC 中已有 traceId。
@Slf4j
@Component
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
public class TraceIdFilter extends OncePerRequestFilter implements Ordered {
- /// 过滤器优先级
- public static final int ORDER = Ordered.HIGHEST_PRECEDENCE + 1;
+ /// 过滤器优先级: 晚于 OTel ServerHttpObservationFilter, 确保 MDC 已注入 traceId
+ public static final int ORDER = Ordered.HIGHEST_PRECEDENCE + 10;
@Override
public int getOrder() {
@@ -38,34 +39,18 @@ public class TraceIdFilter extends OncePerRequestFilter implements Ordered {
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
try {
- String traceId = extractOrGenerateTraceId(request);
- RequestContextStorage.put(WebHeaderCode.X_TRACE_ID, traceId);
- MDC.put(CommonCode.TRACE_ID, traceId);
- response.setHeader(WebHeaderCode.X_TRACE_ID, traceId);
+ // 读取 OTel ServerHttpObservationFilter 已注入的 traceId
+ String traceId = MDC.get(CommonCode.TRACE_ID);
+ if (StrUtil.isNotBlank(traceId)) {
+ // 透传到 ThreadLocal, 供业务代码通过 RequestContextHolder 访问
+ RequestContextStorage.put(WebHeaderCode.X_TRACE_ID, traceId);
+ // 写入响应 header, 便于前端/调用方关联
+ response.setHeader(WebHeaderCode.X_TRACE_ID, traceId);
+ }
chain.doFilter(request, response);
} finally {
- MDC.clear();
+ // 注意: 不要调用 MDC.clear(), OTel 自行管理 MDC 生命周期
+ RequestContextStorage.clear();
}
}
-
- /// 从请求头提取或生成追踪ID
- private String extractOrGenerateTraceId(HttpServletRequest request) {
- String traceId = request.getHeader(WebHeaderCode.X_TRACE_ID);
- if (isValidTraceId(traceId)) {
- return traceId;
- }
- return String.valueOf(IdUtil.getSnowflakeNextId());
- }
-
- /// 验证追踪ID的有效性
- private boolean isValidTraceId(String traceId) {
- if (StrUtil.isBlank(traceId)) {
- return false;
- }
- if (!Validator.isNumber(traceId)) {
- return false;
- }
- int length = traceId.length();
- return length >= 10 && length <= 20;
- }
}
diff --git a/daxpay-platform/daxpay-platform-common/common-spring/src/main/java/cn/daxpay/open/platform/common/spring/configuration/RestClientConfiguration.java b/daxpay-platform/daxpay-platform-common/common-spring/src/main/java/cn/daxpay/open/platform/common/spring/configuration/RestClientConfiguration.java
index 4d056c2ae..99c24ff76 100644
--- a/daxpay-platform/daxpay-platform-common/common-spring/src/main/java/cn/daxpay/open/platform/common/spring/configuration/RestClientConfiguration.java
+++ b/daxpay-platform/daxpay-platform-common/common-spring/src/main/java/cn/daxpay/open/platform/common/spring/configuration/RestClientConfiguration.java
@@ -1,6 +1,7 @@
package cn.daxpay.open.platform.common.spring.configuration;
import cn.daxpay.open.platform.common.config.properties.PlatformCommonProperties;
+import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import org.apache.hc.client5.http.config.ConnectionConfig;
import org.apache.hc.client5.http.config.RequestConfig;
@@ -12,16 +13,32 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
+import org.springframework.http.client.ClientHttpRequestExecution;
+import org.springframework.http.client.ClientHttpRequestInterceptor;
+import org.springframework.http.client.ClientHttpResponse;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
+import org.springframework.web.context.request.RequestAttributes;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import jakarta.servlet.http.HttpServletRequest;
+import java.io.IOException;
/// # RestClient 配置
///
+/// 使用 Spring Boot 自动配置的 [RestClient.Builder], 以便 OpenTelemetry 的
+/// ClientRequestObservation 拦截器自动追加(W3C traceparent 头自动透传)。
+/// 另通过 BusinessContextInterceptor 透传业务上下文(国际化语言、终端编码)。
@Configuration
@RequiredArgsConstructor
public class RestClientConfiguration {
private final PlatformCommonProperties platformCommonProperties;
+ /// 业务上下文 header 名(与 WebHeaderCode 保持一致, 此处为避免底层模块依赖循环而硬编码)
+ private static final String HEADER_ACCEPT_LANGUAGE = "accept-language";
+ private static final String HEADER_X_CLIENT_CODE = "x-client-code";
+
@Bean
public CloseableHttpClient httpClient() {
var rest = platformCommonProperties.getSpring().getRest();
@@ -55,18 +72,58 @@ public class RestClientConfiguration {
.build();
}
- // 4. 将 HttpClient 适配为 Spring 的 ClientHttpRequestFactory
+ // 将 HttpClient 适配为 Spring 的 ClientHttpRequestFactory
@Bean
public HttpComponentsClientHttpRequestFactory httpRequestFactory(CloseableHttpClient httpClient) {
return new HttpComponentsClientHttpRequestFactory(httpClient);
}
- // 5. 创建 RestClient Bean
+ /// 创建 RestClient Bean
+ ///
+ /// 使用自动配置的 RestClient.Builder 而非静态 [RestClient.builder],
+ /// 以便 OTel 拦截器自动注入(W3C traceparent 头自动透传)。
@Bean
- public RestClient restClient(HttpComponentsClientHttpRequestFactory httpRequestFactory) {
- return RestClient.builder()
+ public RestClient restClient(RestClient.Builder restClientBuilder,
+ HttpComponentsClientHttpRequestFactory httpRequestFactory) {
+ return restClientBuilder
.requestFactory(httpRequestFactory)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
+ .requestInterceptor(new BusinessContextInterceptor())
.build();
}
+
+ /// 业务上下文透传拦截器
+ ///
+ /// OTel 仅自动透传 W3C traceparent, 业务上下文(国际化语言、终端编码)需手动透传。
+ /// 通过 Spring 原生 [RequestContextHolder] 获取当前请求, 避免模块循环依赖。
+ static class BusinessContextInterceptor implements ClientHttpRequestInterceptor {
+ @Override
+ public ClientHttpResponse intercept(org.springframework.http.HttpRequest request,
+ byte[] body,
+ ClientHttpRequestExecution execution) throws IOException {
+ HttpServletRequest currentRequest = getCurrentRequest();
+ if (currentRequest != null) {
+ // 透传国际化语言(子应用异常消息按语言返回)
+ String language = currentRequest.getHeader(HEADER_ACCEPT_LANGUAGE);
+ if (StrUtil.isNotBlank(language)) {
+ request.getHeaders().set(HttpHeaders.ACCEPT_LANGUAGE, language);
+ }
+ // 透传终端编码(运营端/H5/小程序/API)
+ String clientCode = currentRequest.getHeader(HEADER_X_CLIENT_CODE);
+ if (StrUtil.isNotBlank(clientCode)) {
+ request.getHeaders().set(HEADER_X_CLIENT_CODE, clientCode);
+ }
+ }
+ return execution.execute(request, body);
+ }
+
+ /// 获取当前线程绑定的 HttpServletRequest(若存在)
+ private static HttpServletRequest getCurrentRequest() {
+ RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
+ if (attrs instanceof ServletRequestAttributes servletAttrs) {
+ return servletAttrs.getRequest();
+ }
+ return null;
+ }
+ }
}
diff --git a/daxpay-start/pom.xml b/daxpay-start/pom.xml
index 05a441f2d..3bea82aeb 100644
--- a/daxpay-start/pom.xml
+++ b/daxpay-start/pom.xml
@@ -34,6 +34,16 @@
org.springframework.boot
spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+
+ org.springframework.boot
+ spring-boot-starter-opentelemetry
+
org.projectlombok
diff --git a/daxpay-start/src/main/resources/application.yml b/daxpay-start/src/main/resources/application.yml
index 4a50ab4c1..2c22495b9 100644
--- a/daxpay-start/src/main/resources/application.yml
+++ b/daxpay-start/src/main/resources/application.yml
@@ -13,14 +13,24 @@ spring:
multipart:
# 上传文件大小限制为100M
max-file-size: 100MB
- management:
- endpoints:
- web:
- exposure:
- include: health,info
- endpoint:
- health:
- show-details: always
+# 监控端点与链路追踪(actuator 配置必须放在顶层, 不能放在 spring: 下)
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health,info,httpexchanges
+ endpoint:
+ health:
+ show-details: always
+ # 链路追踪配置(仅日志关联, 不导出到后端)
+ tracing:
+ sampling:
+ probability: 1.0
+ opentelemetry:
+ tracing:
+ export:
+ otlp:
+ enabled: false
# ORM
mybatis-plus:
mapper-locations: classpath*:mapper/**/*Mapper.xml
diff --git a/daxpay-start/src/main/resources/logback-spring.xml b/daxpay-start/src/main/resources/logback-spring.xml
index f20686f9c..a7182395c 100644
--- a/daxpay-start/src/main/resources/logback-spring.xml
+++ b/daxpay-start/src/main/resources/logback-spring.xml
@@ -14,7 +14,7 @@
- %yellow(%date{yyyy-MM-dd HH:mm:ssS})| %highlight(%-5level[%X{traceId:-}]) %boldYellow(%thread) - %boldGreen(%logger{36})| %msg%n
+ %yellow(%date{yyyy-MM-dd HH:mm:ssS})| %highlight(%-5level[%X{traceId:-},%X{spanId:-}]) %boldYellow(%thread) - %boldGreen(%logger{36})| %msg%n
@@ -27,7 +27,7 @@
UTF-8
- %date{yyyy-MM-dd HH:mm:ssS} | %-5level[%X{traceId:-}] %thread - %logger{36}| %msg%n
+ %date{yyyy-MM-dd HH:mm:ssS} | %-5level[%X{traceId:-},%X{spanId:-}] %thread - %logger{36}| %msg%n