- 3.0.40
+ 3.0.43
1.6.0
3.4.5
diff --git a/zt-framework/zt-common/pom.xml b/zt-framework/zt-common/pom.xml
index 79049dff..db7d9ceb 100644
--- a/zt-framework/zt-common/pom.xml
+++ b/zt-framework/zt-common/pom.xml
@@ -52,6 +52,12 @@
provided
+
+ org.springframework.data
+ spring-data-redis
+ provided
+
+
jakarta.servlet
jakarta.servlet-api
@@ -151,6 +157,12 @@
spring-boot-starter-test
test
+
+
+ org.mockito
+ mockito-core
+ test
+
diff --git a/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/annotation/PageSum.java b/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/annotation/PageSum.java
new file mode 100644
index 00000000..0130ea7c
--- /dev/null
+++ b/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/annotation/PageSum.java
@@ -0,0 +1,31 @@
+package com.zt.plat.framework.common.annotation;
+
+import java.lang.annotation.*;
+
+/**
+ * 标记分页结果中需要求和的字段。
+ *
+ * 未显式指定列名时,会默认使用实体字段对应的数据库列。
+ *
+ * {@link #exist()} 可以用于声明该字段并不存在于表结构中,相当于为字段添加
+ * {@code @TableField(exist = false)},方便在 DO 中声明专用于汇总结果的临时字段。
+ */
+@Documented
+@Target(ElementType.FIELD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface PageSum {
+
+ /**
+ * 自定义求和的数据库列名或表达式,未设置时默认使用实体字段对应的列。
+ */
+ String column() default "";
+
+ /**
+ * 是否在实体字段上声明真实存在的数据库列。
+ *
+ * 设为 {@code false} 时,框架会自动为该字段提供 {@code @TableField(exist = false)} 的能力,
+ * 适用于只在分页响应中返回的临时统计字段。
+ */
+ boolean exist() default false;
+
+}
diff --git a/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/pojo/PageResult.java b/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/pojo/PageResult.java
index 6d4aa9bb..103230f5 100644
--- a/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/pojo/PageResult.java
+++ b/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/pojo/PageResult.java
@@ -1,11 +1,18 @@
package com.zt.plat.framework.common.pojo;
+import com.fasterxml.jackson.annotation.JsonAlias;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
+import java.math.BigDecimal;
import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
@Schema(description = "分页结果")
@Data
@@ -15,19 +22,31 @@ public final class PageResult implements Serializable {
private List list;
@Schema(description = "总量", requiredMode = Schema.RequiredMode.REQUIRED)
+ @JsonProperty("total")
+ @JsonAlias({"totalCount"})
private Long total;
+ @Schema(description = "汇总信息(字段需使用 @PageSum 标注)")
+ @JsonProperty("summary")
+ private Map summary;
+
public PageResult() {
+ this.list = new ArrayList<>();
+ this.summary = Collections.emptyMap();
}
public PageResult(List list, Long total) {
+ this(list, total, null);
+ }
+
+ public PageResult(List list, Long total, Map summary) {
this.list = list;
this.total = total;
+ setSummaryInternal(summary);
}
public PageResult(Long total) {
- this.list = new ArrayList<>();
- this.total = total;
+ this(new ArrayList<>(), total, null);
}
public static PageResult empty() {
@@ -38,4 +57,30 @@ public final class PageResult implements Serializable {
return new PageResult<>(total);
}
+ public void setSummary(Map summary) {
+ setSummaryInternal(summary);
+ }
+
+ private void setSummaryInternal(Map summary) {
+ if (summary == null || summary.isEmpty()) {
+ this.summary = Collections.emptyMap();
+ return;
+ }
+ this.summary = new LinkedHashMap<>(summary);
+ }
+
+ public PageResult convert(List newList) {
+ return new PageResult<>(newList, total, summary);
+ }
+
+ @JsonIgnore
+ public Long getTotalCount() {
+ return total;
+ }
+
+ @JsonIgnore
+ public void setTotalCount(Long totalCount) {
+ this.total = totalCount;
+ }
+
}
diff --git a/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/util/integration/ShareServiceProperties.java b/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/util/integration/ShareServiceProperties.java
new file mode 100644
index 00000000..c5daff37
--- /dev/null
+++ b/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/util/integration/ShareServiceProperties.java
@@ -0,0 +1,112 @@
+package com.zt.plat.framework.common.util.integration;
+
+import cn.hutool.core.util.StrUtil;
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.time.Duration;
+
+/**
+ * 配置参数,控制对接 ePlat 共享服务的请求行为。
+ */
+@Getter
+@Setter
+@Component
+@ConfigurationProperties(prefix = "eplat.share")
+public class ShareServiceProperties {
+
+ private static final String DEFAULT_TOKEN_ENDPOINT_PATH = "/eplat/oauth/token";
+
+ /**
+ * 共享服务基础地址,例如:https://example.com/share。
+ */
+ private String urlPrefix;
+
+ /**
+ * OAuth 客户端标识。
+ */
+ private String clientId;
+
+ /**
+ * OAuth 客户端密钥。
+ */
+ private String clientSecret;
+
+ /**
+ * OAuth scope,默认 read。
+ */
+ private String scope = "read";
+
+ /**
+ * 访问 token 在 Redis 中的缓存 key。
+ */
+ private String tokenCacheKey = "eplat:cache:shareToken";
+
+ /**
+ * 刷新 token 在 Redis 中的缓存 key。
+ */
+ private String refreshTokenCacheKey = "eplat:cache:shareRefreshToken";
+
+ /**
+ * 调用共享服务时携带 token 的请求头名称。
+ */
+ private String tokenHeaderName = "Xplat-Token";
+
+ /**
+ * 获取 token 的接口路径,默认 /eplat/oauth/token。
+ */
+ private String tokenEndpointPath = DEFAULT_TOKEN_ENDPOINT_PATH;
+
+ /**
+ * 访问 token 默认有效期,默认 5000 秒,建议略小于服务端实际过期时间。
+ */
+ private Duration tokenTtl = Duration.ofSeconds(5000);
+
+ /**
+ * 刷新 token 默认有效期,若未设置则取访问 token 的 2 倍。
+ */
+ private Duration refreshTokenTtl;
+
+ /**
+ * 构造具体服务的请求地址。
+ *
+ * @param serviceNo 服务号
+ * @return 完整请求地址
+ */
+ public String buildServiceUrl(String serviceNo) {
+ return normalizeBaseUrl(urlPrefix) + "/service/" + serviceNo;
+ }
+
+ /**
+ * 构造获取 token 的请求地址。
+ *
+ * @return token 请求地址
+ */
+ public String buildTokenUrl() {
+ String base = normalizeBaseUrl(urlPrefix);
+ String path = StrUtil.prependIfMissing(tokenEndpointPath, "/");
+ return base + path;
+ }
+
+ /**
+ * 刷新 token 的缓存有效期。
+ *
+ * @return 刷新 token 有效期
+ */
+ public Duration getRefreshTokenTtl() {
+ if (refreshTokenTtl != null) {
+ return refreshTokenTtl;
+ }
+ return tokenTtl.multipliedBy(2);
+ }
+
+ private static String normalizeBaseUrl(String url) {
+ if (StrUtil.isBlank(url)) {
+ throw new IllegalArgumentException("共享服务地址不能为空");
+ }
+ return StrUtil.removeSuffix(url.trim(), "/");
+ }
+
+}
diff --git a/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/util/integration/ShareServiceUtils.java b/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/util/integration/ShareServiceUtils.java
new file mode 100644
index 00000000..cf11959c
--- /dev/null
+++ b/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/util/integration/ShareServiceUtils.java
@@ -0,0 +1,237 @@
+package com.zt.plat.framework.common.util.integration;
+
+import cn.hutool.core.util.StrUtil;
+import com.zt.plat.framework.common.util.json.JsonUtils;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.util.Assert;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.client.RestClientException;
+import org.springframework.web.client.RestTemplate;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * ePlat 共享服务调用工具,负责发送请求与自动刷新访问 token。
+ */
+@Slf4j
+public final class ShareServiceUtils {
+
+ private static final Duration MIN_CACHE_TTL = Duration.ofSeconds(1);
+ private static final ConcurrentMap TOKEN_REFRESH_LOCKS = new ConcurrentHashMap<>();
+
+ private ShareServiceUtils() {
+ }
+
+ public static String callShareService(RestTemplate restTemplate,
+ StringRedisTemplate redisTemplate,
+ ShareServiceProperties properties,
+ String serviceNo,
+ String requestBody) {
+ return callShareService(restTemplate, redisTemplate, properties, serviceNo, (Object) requestBody);
+ }
+
+ public static String callShareService(RestTemplate restTemplate,
+ StringRedisTemplate redisTemplate,
+ ShareServiceProperties properties,
+ String serviceNo,
+ Object requestBody) {
+ Assert.notNull(restTemplate, "RestTemplate 不能为空");
+ Assert.notNull(redisTemplate, "StringRedisTemplate 不能为空");
+ Assert.notNull(properties, "ShareServiceProperties 不能为空");
+ Assert.hasText(serviceNo, "服务号不能为空");
+
+ String url = properties.buildServiceUrl(serviceNo);
+ String payload = convertRequestBody(requestBody);
+ log.info("共享服务调用地址:[{}],请求体:[{}]", url, payload);
+
+ String token = obtainAccessToken(restTemplate, redisTemplate, properties);
+ log.debug("共享服务服务号 [{}] 使用的 token 已获取", serviceNo);
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ headers.set(properties.getTokenHeaderName(), token);
+
+ HttpEntity entity = new HttpEntity<>(payload, headers);
+ ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
+ return Objects.requireNonNullElse(response.getBody(), "");
+ }
+
+ /**
+ * 获取共享服务的访问 token,可复用于自定义调用场景。
+ *
+ * @param restTemplate 用于请求共享服务的 {@link RestTemplate}
+ * @param redisTemplate 缓存 token 的 {@link StringRedisTemplate}
+ * @param properties 共享服务配置
+ * @return 访问共享服务的 token
+ */
+ public static String getAccessToken(RestTemplate restTemplate,
+ StringRedisTemplate redisTemplate,
+ ShareServiceProperties properties) {
+ Assert.notNull(restTemplate, "RestTemplate 不能为空");
+ Assert.notNull(redisTemplate, "StringRedisTemplate 不能为空");
+ Assert.notNull(properties, "ShareServiceProperties 不能为空");
+ return obtainAccessToken(restTemplate, redisTemplate, properties);
+ }
+
+ private static String convertRequestBody(Object requestBody) {
+ if (requestBody == null) {
+ return "";
+ }
+ if (requestBody instanceof String str) {
+ return str;
+ }
+ if (requestBody instanceof byte[] bytes) {
+ return new String(bytes, StandardCharsets.UTF_8);
+ }
+ return JsonUtils.toJsonString(requestBody);
+ }
+
+ private static String obtainAccessToken(RestTemplate restTemplate,
+ StringRedisTemplate redisTemplate,
+ ShareServiceProperties properties) {
+ // 直接从 Redis 读取可复用的 token
+ ValueOperations valueOps = redisTemplate.opsForValue();
+ String token = valueOps.get(properties.getTokenCacheKey());
+ if (StrUtil.isNotBlank(token)) {
+ return token;
+ }
+ // 针对同一个缓存 key 做细粒度加锁,避免并发刷新问题
+ Lock lock = TOKEN_REFRESH_LOCKS.computeIfAbsent(properties.getTokenCacheKey(), key -> new ReentrantLock());
+ lock.lock();
+ try {
+ token = valueOps.get(properties.getTokenCacheKey());
+ if (StrUtil.isNotBlank(token)) {
+ return token;
+ }
+ return refreshAccessToken(restTemplate, redisTemplate, properties, valueOps);
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ private static String refreshAccessToken(RestTemplate restTemplate,
+ StringRedisTemplate redisTemplate,
+ ShareServiceProperties properties,
+ ValueOperations valueOps) {
+ String refreshToken = valueOps.get(properties.getRefreshTokenCacheKey());
+ if (StrUtil.isNotBlank(refreshToken)) {
+ try {
+ return requestToken(restTemplate, redisTemplate, properties,
+ buildRefreshTokenParams(properties, refreshToken));
+ } catch (RuntimeException ex) {
+ log.warn("刷新共享服务 token 失败,准备回退为 client_credentials 模式", ex);
+ redisTemplate.delete(properties.getRefreshTokenCacheKey());
+ }
+ }
+ return requestToken(restTemplate, redisTemplate, properties,
+ buildClientCredentialsParams(properties));
+ }
+
+ private static MultiValueMap buildClientCredentialsParams(ShareServiceProperties properties) {
+ MultiValueMap params = baseTokenParams(properties);
+ params.add("grant_type", "client_credentials");
+ if (StrUtil.isNotBlank(properties.getScope())) {
+ params.add("scope", properties.getScope());
+ }
+ return params;
+ }
+
+ private static MultiValueMap buildRefreshTokenParams(ShareServiceProperties properties,
+ String refreshToken) {
+ MultiValueMap params = baseTokenParams(properties);
+ params.add("grant_type", "refresh_token");
+ params.add("refresh_token", refreshToken);
+ return params;
+ }
+
+ private static MultiValueMap baseTokenParams(ShareServiceProperties properties) {
+ MultiValueMap params = new LinkedMultiValueMap<>();
+ Assert.hasText(properties.getClientId(), "clientId 不能为空");
+ Assert.hasText(properties.getClientSecret(), "clientSecret 不能为空");
+ params.add("client_id", properties.getClientId());
+ params.add("client_secret", properties.getClientSecret());
+ return params;
+ }
+
+ private static String requestToken(RestTemplate restTemplate,
+ StringRedisTemplate redisTemplate,
+ ShareServiceProperties properties,
+ MultiValueMap body) {
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+ HttpEntity> entity = new HttpEntity<>(body, headers);
+ String tokenUrl = properties.buildTokenUrl();
+ log.info("共享服务获取 token 地址:[{}],授权方式:[{}]", tokenUrl, body.getFirst("grant_type"));
+ ResponseEntity response;
+ try {
+ response = restTemplate.postForEntity(tokenUrl, entity, String.class);
+ } catch (RestClientException ex) {
+ throw new IllegalStateException("请求共享服务 token 失败", ex);
+ }
+ String responseBody = response.getBody();
+ if (StrUtil.isBlank(responseBody)) {
+ throw new IllegalStateException("共享服务返回的 token 内容为空");
+ }
+ TokenResponse tokenResponse = parseTokenResponse(responseBody);
+ cacheTokens(redisTemplate, properties, tokenResponse);
+ return tokenResponse.accessToken();
+ }
+
+ private static TokenResponse parseTokenResponse(String body) {
+ var node = JsonUtils.parseTree(body);
+ String accessToken = node.path("access_token").asText(null);
+ if (StrUtil.isBlank(accessToken)) {
+ throw new IllegalStateException("共享服务返回结果缺少 access_token 字段");
+ }
+ String refreshToken = node.path("refresh_token").asText(null);
+ long expiresIn = node.path("expires_in").asLong(-1);
+ long refreshExpiresIn = node.path("refresh_expires_in").asLong(-1);
+ return new TokenResponse(accessToken, refreshToken, expiresIn, refreshExpiresIn);
+ }
+
+ private static void cacheTokens(StringRedisTemplate redisTemplate,
+ ShareServiceProperties properties,
+ TokenResponse tokenResponse) {
+ // 将最新的 token 与刷新 token 写回缓存
+ ValueOperations valueOps = redisTemplate.opsForValue();
+ Duration tokenTtl = resolveTtl(tokenResponse.expiresIn(), properties.getTokenTtl());
+ valueOps.set(properties.getTokenCacheKey(), tokenResponse.accessToken(), tokenTtl);
+ if (StrUtil.isNotBlank(tokenResponse.refreshToken())) {
+ Duration refreshTtl = resolveTtl(tokenResponse.refreshExpiresIn(), properties.getRefreshTokenTtl());
+ valueOps.set(properties.getRefreshTokenCacheKey(), tokenResponse.refreshToken(), refreshTtl);
+ }
+ }
+
+ private static Duration resolveTtl(long expiresInSeconds, Duration fallback) {
+ Duration effectiveFallback = fallback;
+ if (effectiveFallback == null || effectiveFallback.compareTo(MIN_CACHE_TTL) < 0) {
+ effectiveFallback = Duration.ofMinutes(5);
+ }
+ if (expiresInSeconds > 0) {
+ Duration candidate = Duration.ofSeconds(expiresInSeconds);
+ if (candidate.compareTo(MIN_CACHE_TTL) < 0) {
+ candidate = MIN_CACHE_TTL;
+ }
+ return candidate.compareTo(effectiveFallback) < 0 ? candidate : effectiveFallback;
+ }
+ return effectiveFallback;
+ }
+
+ private record TokenResponse(String accessToken, String refreshToken, long expiresIn, long refreshExpiresIn) {
+ }
+
+}
diff --git a/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/util/monitor/TracerUtils.java b/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/util/monitor/TracerUtils.java
index a098d6cd..98d1063b 100644
--- a/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/util/monitor/TracerUtils.java
+++ b/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/util/monitor/TracerUtils.java
@@ -1,6 +1,14 @@
package com.zt.plat.framework.common.util.monitor;
+import jakarta.servlet.http.HttpServletRequest;
+import org.apache.commons.lang3.StringUtils;
import org.apache.skywalking.apm.toolkit.trace.TraceContext;
+import org.slf4j.MDC;
+import org.springframework.web.context.request.RequestAttributes;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import java.util.UUID;
/**
* 链路追踪工具类
@@ -9,7 +17,33 @@ import org.apache.skywalking.apm.toolkit.trace.TraceContext;
*
* @author ZT
*/
-public class TracerUtils {
+public final class TracerUtils {
+
+ /**
+ * SkyWalking 在未接入 Agent 时返回的默认占位值
+ */
+ private static final String SKY_WALKING_PLACEHOLDER = "N/A";
+
+ /**
+ * SkyWalking 在忽略追踪时返回的占位值
+ */
+ private static final String SKY_WALKING_IGNORED = "Ignored_Trace";
+
+ private static final String MDC_TRACE_ID_KEY = "traceId";
+ private static final String REQUEST_ATTRIBUTE_KEY = TracerUtils.class.getName() + ".TRACE_ID";
+ private static final String[] HEADER_CANDIDATES = {
+ "trace-id",
+ "Trace-Id",
+ "x-trace-id",
+ "X-Trace-Id",
+ "x-request-id",
+ "X-Request-Id"
+ };
+
+ /**
+ * 兜底的 traceId,保证在未接入链路追踪时依旧具备追踪能力
+ */
+ private static final InheritableThreadLocal FALLBACK_TRACE_ID = new InheritableThreadLocal<>();
/**
* 私有化构造方法
@@ -18,13 +52,121 @@ public class TracerUtils {
}
/**
- * 获得链路追踪编号,直接返回 SkyWalking 的 TraceId。
- * 如果不存在的话为空字符串!!!
+ * 获得链路追踪编号。
+ *
+ * 优先返回 SkyWalking 的 TraceId;在缺少链路上下文或者未接入 SkyWalking 时,会优先复用来自请求上下文的 TraceId,
+ * 否则生成一个新的兜底 TraceId,并在当前线程、请求上下文与日志 MDC 中缓存,确保后续组件能够复用。
*
* @return 链路追踪编号
*/
public static String getTraceId() {
- return TraceContext.traceId();
+ String traceId = TraceContext.traceId();
+ if (isValidTraceId(traceId)) {
+ cacheTraceId(traceId);
+ return traceId;
+ }
+ String cached = resolveCachedTraceId();
+ if (StringUtils.isNotBlank(cached)) {
+ return cached;
+ }
+ String generated = generateFallbackTraceId();
+ cacheTraceId(generated);
+ return generated;
+ }
+
+ /**
+ * 手动绑定外部传入的 TraceId,例如消费消息、处理异步任务时。
+ *
+ * @param traceId 链路编号
+ */
+ public static void bindTraceId(String traceId) {
+ if (StringUtils.isBlank(traceId)) {
+ return;
+ }
+ cacheTraceId(traceId.trim());
+ }
+
+ /**
+ * 清理当前线程关联的兜底 traceId,避免线程复用导致污染。
+ */
+ public static void clear() {
+ FALLBACK_TRACE_ID.remove();
+ MDC.remove(MDC_TRACE_ID_KEY);
+ HttpServletRequest request = currentRequest();
+ if (request != null) {
+ request.removeAttribute(REQUEST_ATTRIBUTE_KEY);
+ }
+ }
+
+ private static boolean isValidTraceId(String traceId) {
+ if (StringUtils.isBlank(traceId)) {
+ return false;
+ }
+ if (StringUtils.equalsIgnoreCase(traceId, SKY_WALKING_PLACEHOLDER)) {
+ return false;
+ }
+ return !StringUtils.equalsIgnoreCase(traceId, SKY_WALKING_IGNORED);
+ }
+
+ private static String resolveCachedTraceId() {
+ String cached = FALLBACK_TRACE_ID.get();
+ if (StringUtils.isNotBlank(cached)) {
+ return cached;
+ }
+ HttpServletRequest request = currentRequest();
+ if (request != null) {
+ Object attribute = request.getAttribute(REQUEST_ATTRIBUTE_KEY);
+ if (attribute instanceof String attrValue && StringUtils.isNotBlank(attrValue)) {
+ cacheTraceId(attrValue);
+ return attrValue;
+ }
+ String headerValue = resolveTraceIdFromHeader(request);
+ if (StringUtils.isNotBlank(headerValue)) {
+ cacheTraceId(headerValue);
+ return headerValue;
+ }
+ }
+ String mdcTraceId = MDC.get(MDC_TRACE_ID_KEY);
+ if (StringUtils.isNotBlank(mdcTraceId)) {
+ cacheTraceId(mdcTraceId);
+ return mdcTraceId;
+ }
+ return null;
+ }
+
+ private static void cacheTraceId(String traceId) {
+ if (StringUtils.isBlank(traceId)) {
+ return;
+ }
+ String trimmed = traceId.trim();
+ FALLBACK_TRACE_ID.set(trimmed);
+ MDC.put(MDC_TRACE_ID_KEY, trimmed);
+ HttpServletRequest request = currentRequest();
+ if (request != null) {
+ request.setAttribute(REQUEST_ATTRIBUTE_KEY, trimmed);
+ }
+ }
+
+ private static HttpServletRequest currentRequest() {
+ RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
+ if (requestAttributes instanceof ServletRequestAttributes servletRequestAttributes) {
+ return servletRequestAttributes.getRequest();
+ }
+ return null;
+ }
+
+ private static String resolveTraceIdFromHeader(HttpServletRequest request) {
+ for (String header : HEADER_CANDIDATES) {
+ String value = request.getHeader(header);
+ if (StringUtils.isNotBlank(value)) {
+ return value.trim();
+ }
+ }
+ return null;
+ }
+
+ private static String generateFallbackTraceId() {
+ return StringUtils.replace(UUID.randomUUID().toString(), "-", "");
}
}
diff --git a/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/util/object/BeanUtils.java b/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/util/object/BeanUtils.java
index be773fe0..346e6627 100644
--- a/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/util/object/BeanUtils.java
+++ b/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/util/object/BeanUtils.java
@@ -4,6 +4,7 @@ import cn.hutool.core.bean.BeanUtil;
import com.zt.plat.framework.common.pojo.PageResult;
import com.zt.plat.framework.common.util.collection.CollectionUtils;
+import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;
@@ -69,10 +70,13 @@ public class BeanUtils {
return null;
}
List list = toBean(source.getList(), targetType);
+ if (list == null) {
+ list = Collections.emptyList();
+ }
if (peek != null) {
list.forEach(peek);
}
- return new PageResult<>(list, source.getTotal());
+ return new PageResult<>(list, source.getTotal(), source.getSummary());
}
public static void copyProperties(Object source, Object target) {
diff --git a/zt-framework/zt-spring-boot-starter-monitor/src/main/java/com/zt/plat/framework/tracer/core/filter/TraceFilter.java b/zt-framework/zt-spring-boot-starter-monitor/src/main/java/com/zt/plat/framework/tracer/core/filter/TraceFilter.java
index d9cbc1cb..1651f7f7 100644
--- a/zt-framework/zt-spring-boot-starter-monitor/src/main/java/com/zt/plat/framework/tracer/core/filter/TraceFilter.java
+++ b/zt-framework/zt-spring-boot-starter-monitor/src/main/java/com/zt/plat/framework/tracer/core/filter/TraceFilter.java
@@ -24,10 +24,15 @@ public class TraceFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
- // 设置响应 traceId
- response.addHeader(HEADER_NAME_TRACE_ID, TracerUtils.getTraceId());
- // 继续过滤
- chain.doFilter(request, response);
+ String traceId = TracerUtils.getTraceId();
+ try {
+ // 设置响应 traceId,便于客户端回溯
+ response.addHeader(HEADER_NAME_TRACE_ID, traceId);
+ // 继续过滤
+ chain.doFilter(request, response);
+ } finally {
+ TracerUtils.clear();
+ }
}
}
diff --git a/zt-framework/zt-spring-boot-starter-mybatis/pom.xml b/zt-framework/zt-spring-boot-starter-mybatis/pom.xml
index 510b74c2..66f0ec5f 100644
--- a/zt-framework/zt-spring-boot-starter-mybatis/pom.xml
+++ b/zt-framework/zt-spring-boot-starter-mybatis/pom.xml
@@ -107,6 +107,12 @@
org.springframework.cloud
spring-cloud-starter-openfeign
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
diff --git a/zt-framework/zt-spring-boot-starter-mybatis/src/main/java/com/zt/plat/framework/mybatis/config/ZtMybatisAutoConfiguration.java b/zt-framework/zt-spring-boot-starter-mybatis/src/main/java/com/zt/plat/framework/mybatis/config/ZtMybatisAutoConfiguration.java
index f83cd5f2..896aae53 100644
--- a/zt-framework/zt-spring-boot-starter-mybatis/src/main/java/com/zt/plat/framework/mybatis/config/ZtMybatisAutoConfiguration.java
+++ b/zt-framework/zt-spring-boot-starter-mybatis/src/main/java/com/zt/plat/framework/mybatis/config/ZtMybatisAutoConfiguration.java
@@ -1,9 +1,9 @@
package com.zt.plat.framework.mybatis.config;
import cn.hutool.core.util.StrUtil;
-import com.zt.plat.framework.mybatis.core.handler.DefaultDBFieldHandler;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration;
+import com.baomidou.mybatisplus.autoconfigure.MybatisPlusPropertiesCustomizer;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.baomidou.mybatisplus.core.incrementer.IKeyGenerator;
import com.baomidou.mybatisplus.extension.incrementer.*;
@@ -11,6 +11,8 @@ import com.baomidou.mybatisplus.extension.parser.JsqlParserGlobal;
import com.baomidou.mybatisplus.extension.parser.cache.JdkSerialCaffeineJsqlParseCache;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
+import com.zt.plat.framework.mybatis.core.handler.DefaultDBFieldHandler;
+import com.zt.plat.framework.mybatis.core.sum.PageSumTableFieldAnnotationHandler;
import org.apache.ibatis.annotations.Mapper;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.autoconfigure.AutoConfiguration;
@@ -25,29 +27,40 @@ import java.util.concurrent.TimeUnit;
*
* @author ZT
*/
-@AutoConfiguration(before = MybatisPlusAutoConfiguration.class) // 目的:先于 MyBatis Plus 自动配置,避免 @MapperScan 可能扫描不到 Mapper 打印 warn 日志
+@AutoConfiguration(before = MybatisPlusAutoConfiguration.class) // 先于官方自动配置,避免 Mapper 未扫描完成
@MapperScan(value = "${zt.info.base-package}", annotationClass = Mapper.class,
- lazyInitialization = "${mybatis.lazy-initialization:false}") // Mapper 懒加载,目前仅用于单元测试
+ lazyInitialization = "${mybatis.lazy-initialization:false}") // Mapper 懒加载,目前仅单测需要
public class ZtMybatisAutoConfiguration {
static {
- // 动态 SQL 智能优化支持本地缓存加速解析,更完善的租户复杂 XML 动态 SQL 支持,静态注入缓存
+ // 使用本地缓存加速 JsqlParser 解析,复杂动态 SQL 性能更稳定
JsqlParserGlobal.setJsqlParseCache(new JdkSerialCaffeineJsqlParseCache(
- (cache) -> cache.maximumSize(1024)
- .expireAfterWrite(5, TimeUnit.SECONDS))
- );
+ cache -> cache.maximumSize(1024).expireAfterWrite(5, TimeUnit.SECONDS)));
}
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
- MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor();
- mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor()); // 分页插件
- return mybatisPlusInterceptor;
+ MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
+ interceptor.addInnerInterceptor(new PaginationInnerInterceptor()); // 分页插件
+ return interceptor;
}
@Bean
public MetaObjectHandler defaultMetaObjectHandler() {
- return new DefaultDBFieldHandler(); // 自动填充参数类
+ return new DefaultDBFieldHandler(); // 统一的公共字段填充
+ }
+
+ @Bean
+ public MybatisPlusPropertiesCustomizer pageSumAnnotationCustomizer() {
+ // 通过官方扩展点为 @PageSum 字段自动注入 exist = false 的 TableField 注解
+ return properties -> {
+ var globalConfig = properties.getGlobalConfig();
+ if (globalConfig == null) {
+ return;
+ }
+ globalConfig.setAnnotationHandler(
+ new PageSumTableFieldAnnotationHandler(globalConfig.getAnnotationHandler()));
+ };
}
@Bean
diff --git a/zt-framework/zt-spring-boot-starter-mybatis/src/main/java/com/zt/plat/framework/mybatis/core/mapper/BaseMapperX.java b/zt-framework/zt-spring-boot-starter-mybatis/src/main/java/com/zt/plat/framework/mybatis/core/mapper/BaseMapperX.java
index eed089ad..701d322e 100644
--- a/zt-framework/zt-spring-boot-starter-mybatis/src/main/java/com/zt/plat/framework/mybatis/core/mapper/BaseMapperX.java
+++ b/zt-framework/zt-spring-boot-starter-mybatis/src/main/java/com/zt/plat/framework/mybatis/core/mapper/BaseMapperX.java
@@ -5,6 +5,7 @@ import com.zt.plat.framework.common.pojo.PageParam;
import com.zt.plat.framework.common.pojo.PageResult;
import com.zt.plat.framework.common.pojo.SortablePageParam;
import com.zt.plat.framework.common.pojo.SortingField;
+import com.zt.plat.framework.mybatis.core.sum.PageSumSupport;
import com.zt.plat.framework.mybatis.core.util.JdbcUtils;
import com.zt.plat.framework.mybatis.core.util.MyBatisUtils;
import com.baomidou.mybatisplus.annotation.DbType;
@@ -43,14 +44,18 @@ public interface BaseMapperX extends MPJBaseMapper {
// 特殊:不分页,直接查询全部
if (PageParam.PAGE_SIZE_NONE.equals(pageParam.getPageSize())) {
List list = selectList(queryWrapper);
- return new PageResult<>(list, (long) list.size());
+ PageResult pageResult = new PageResult<>(list, (long) list.size());
+ PageSumSupport.tryAttachSummary(this, queryWrapper, pageResult);
+ return pageResult;
}
// MyBatis Plus 查询
IPage mpPage = MyBatisUtils.buildPage(pageParam, sortingFields);
selectPage(mpPage, queryWrapper);
// 转换返回
- return new PageResult<>(mpPage.getRecords(), mpPage.getTotal());
+ PageResult pageResult = new PageResult<>(mpPage.getRecords(), mpPage.getTotal());
+ PageSumSupport.tryAttachSummary(this, queryWrapper, pageResult);
+ return pageResult;
}
default PageResult selectJoinPage(PageParam pageParam, Class clazz, MPJLambdaWrapper lambdaWrapper) {
diff --git a/zt-framework/zt-spring-boot-starter-mybatis/src/main/java/com/zt/plat/framework/mybatis/core/sum/PageSumFieldMeta.java b/zt-framework/zt-spring-boot-starter-mybatis/src/main/java/com/zt/plat/framework/mybatis/core/sum/PageSumFieldMeta.java
new file mode 100644
index 00000000..8357b00d
--- /dev/null
+++ b/zt-framework/zt-spring-boot-starter-mybatis/src/main/java/com/zt/plat/framework/mybatis/core/sum/PageSumFieldMeta.java
@@ -0,0 +1,76 @@
+package com.zt.plat.framework.mybatis.core.sum;
+
+import java.lang.reflect.Field;
+import java.util.Objects;
+
+/**
+ * Metadata describing a field participating in page-level SUM aggregation.
+ */
+final class PageSumFieldMeta {
+
+ private final String propertyName;
+ private final String columnExpression;
+ private final String selectAlias;
+ private final Class> fieldType;
+
+ PageSumFieldMeta(String propertyName, String columnExpression, String selectAlias, Class> fieldType) {
+ this.propertyName = propertyName;
+ this.columnExpression = columnExpression;
+ this.selectAlias = selectAlias;
+ this.fieldType = fieldType;
+ }
+
+ static PageSumFieldMeta of(Field field, String columnExpression) {
+ String property = field.getName();
+ return new PageSumFieldMeta(property, columnExpression, property, field.getType());
+ }
+
+ String getPropertyName() {
+ return propertyName;
+ }
+
+ String getColumnExpression() {
+ return columnExpression;
+ }
+
+ String getSelectAlias() {
+ return selectAlias;
+ }
+
+ Class> getFieldType() {
+ return fieldType;
+ }
+
+ String buildSelectSegment() {
+ return "SUM(" + columnExpression + ") AS " + selectAlias;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(propertyName, columnExpression, selectAlias, fieldType);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (!(obj instanceof PageSumFieldMeta other)) {
+ return false;
+ }
+ return Objects.equals(propertyName, other.propertyName)
+ && Objects.equals(columnExpression, other.columnExpression)
+ && Objects.equals(selectAlias, other.selectAlias)
+ && Objects.equals(fieldType, other.fieldType);
+ }
+
+ @Override
+ public String toString() {
+ return "PageSumFieldMeta{" +
+ "propertyName='" + propertyName + '\'' +
+ ", columnExpression='" + columnExpression + '\'' +
+ ", selectAlias='" + selectAlias + '\'' +
+ ", fieldType=" + fieldType +
+ '}';
+ }
+}
diff --git a/zt-framework/zt-spring-boot-starter-mybatis/src/main/java/com/zt/plat/framework/mybatis/core/sum/PageSumSupport.java b/zt-framework/zt-spring-boot-starter-mybatis/src/main/java/com/zt/plat/framework/mybatis/core/sum/PageSumSupport.java
new file mode 100644
index 00000000..a2ad25f8
--- /dev/null
+++ b/zt-framework/zt-spring-boot-starter-mybatis/src/main/java/com/zt/plat/framework/mybatis/core/sum/PageSumSupport.java
@@ -0,0 +1,341 @@
+package com.zt.plat.framework.mybatis.core.sum;
+
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.core.metadata.TableFieldInfo;
+import com.baomidou.mybatisplus.core.metadata.TableInfo;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.zt.plat.framework.common.annotation.PageSum;
+import com.zt.plat.framework.common.pojo.PageResult;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+/**
+ * Utility that inspects {@link PageSum} annotations and attaches aggregated SUM results to {@link PageResult}.
+ */
+public final class PageSumSupport {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(PageSumSupport.class);
+
+ private static final ConcurrentMap, Optional>> ENTITY_CLASS_CACHE = new ConcurrentHashMap<>();
+ private static final ConcurrentMap, List> FIELD_META_CACHE = new ConcurrentHashMap<>();
+ private static final ConcurrentMap, Optional> SQL_SELECT_FIELD_CACHE = new ConcurrentHashMap<>();
+
+ private PageSumSupport() {
+ }
+
+ public static void tryAttachSummary(Object mapperProxy, Wrapper wrapper, PageResult> pageResult) {
+ if (mapperProxy == null || pageResult == null) {
+ return;
+ }
+ Class> entityClass = resolveEntityClass(mapperProxy.getClass());
+ if (entityClass == null) {
+ return;
+ }
+ List fieldMetas = resolveFieldMetas(entityClass);
+ if (fieldMetas.isEmpty()) {
+ return;
+ }
+ Map summary = executeSum((BaseMapper) mapperProxy, wrapper, fieldMetas);
+ if (!summary.isEmpty()) {
+ pageResult.setSummary(summary);
+ }
+ }
+
+ private static Class> resolveEntityClass(Class> mapperProxyClass) {
+ return ENTITY_CLASS_CACHE.computeIfAbsent(mapperProxyClass, PageSumSupport::extractEntityClass)
+ .orElse(null);
+ }
+
+ private static Optional> extractEntityClass(Class> mapperProxyClass) {
+ Class>[] interfaces = mapperProxyClass.getInterfaces();
+ for (Class> iface : interfaces) {
+ Class> entityClass = extractEntityClassFromInterface(iface);
+ if (entityClass != null) {
+ return Optional.of(entityClass);
+ }
+ }
+ return Optional.empty();
+ }
+
+ private static Class> extractEntityClassFromInterface(Class> interfaceClass) {
+ if (interfaceClass == null || interfaceClass == Object.class) {
+ return null;
+ }
+ // inspect direct generic interfaces
+ for (Type type : interfaceClass.getGenericInterfaces()) {
+ Class> resolved = resolveFromType(type);
+ if (resolved != null) {
+ return resolved;
+ }
+ }
+ // fallback to parent interfaces recursively
+ for (Class> parent : interfaceClass.getInterfaces()) {
+ Class> resolved = extractEntityClassFromInterface(parent);
+ if (resolved != null) {
+ return resolved;
+ }
+ }
+ // handle generic super class (rare for interfaces but keep for completeness)
+ return resolveFromType(interfaceClass.getGenericSuperclass());
+ }
+
+ private static Class> resolveFromType(Type type) {
+ if (type == null) {
+ return null;
+ }
+ if (type instanceof ParameterizedType parameterizedType) {
+ Type raw = parameterizedType.getRawType();
+ if (raw instanceof Class> rawClass) {
+ if (BaseMapper.class.isAssignableFrom(rawClass)) {
+ Type[] actualTypes = parameterizedType.getActualTypeArguments();
+ if (actualTypes.length > 0) {
+ Type actual = actualTypes[0];
+ return toClass(actual);
+ }
+ }
+ Class> resolved = extractEntityClassFromInterface(rawClass);
+ if (resolved != null) {
+ return resolved;
+ }
+ }
+ for (Type actual : parameterizedType.getActualTypeArguments()) {
+ Class> resolved = resolveFromType(actual);
+ if (resolved != null) {
+ return resolved;
+ }
+ }
+ } else if (type instanceof Class> clazz) {
+ return extractEntityClassFromInterface(clazz);
+ }
+ return null;
+ }
+
+ private static Class> toClass(Type type) {
+ if (type instanceof Class> clazz) {
+ return clazz;
+ }
+ if (type instanceof ParameterizedType parameterizedType) {
+ Type raw = parameterizedType.getRawType();
+ if (raw instanceof Class>) {
+ return (Class>) raw;
+ }
+ }
+ return null;
+ }
+
+ private static List resolveFieldMetas(Class> entityClass) {
+ return FIELD_META_CACHE.computeIfAbsent(entityClass, PageSumSupport::scanFieldMetas);
+ }
+
+ private static List scanFieldMetas(Class> entityClass) {
+ TableInfo tableInfo = TableInfoHelper.getTableInfo(entityClass);
+ if (tableInfo == null) {
+ LOGGER.debug("No TableInfo found for entity {}, falling back to annotation provided column expressions.",
+ entityClass.getName());
+ }
+ Map propertyColumnMap = tableInfo != null
+ ? buildPropertyColumnMap(tableInfo)
+ : Collections.emptyMap();
+ List metas = new ArrayList<>();
+ Class> current = entityClass;
+ while (current != null && current != Object.class) {
+ Field[] fields = current.getDeclaredFields();
+ for (Field field : fields) {
+ PageSum annotation = field.getAnnotation(PageSum.class);
+ if (annotation == null) {
+ continue;
+ }
+ if (!isNumeric(field.getType())) {
+ LOGGER.warn("Field {}.{} annotated with @PageSum is not numeric and will be ignored.",
+ entityClass.getSimpleName(), field.getName());
+ continue;
+ }
+ String columnExpression = resolveColumnExpression(annotation, field, propertyColumnMap);
+ if (StrUtil.isBlank(columnExpression)) {
+ LOGGER.warn("Unable to resolve column for field {}.{} with @PageSum, skipping.",
+ entityClass.getSimpleName(), field.getName());
+ continue;
+ }
+ metas.add(PageSumFieldMeta.of(field, columnExpression));
+ }
+ current = current.getSuperclass();
+ }
+ return metas.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(metas);
+ }
+
+ private static Map buildPropertyColumnMap(TableInfo tableInfo) {
+ Map mapping = new LinkedHashMap<>();
+ if (StrUtil.isNotBlank(tableInfo.getKeyProperty()) && StrUtil.isNotBlank(tableInfo.getKeyColumn())) {
+ mapping.put(tableInfo.getKeyProperty(), tableInfo.getKeyColumn());
+ }
+ for (TableFieldInfo fieldInfo : tableInfo.getFieldList()) {
+ mapping.put(fieldInfo.getProperty(), fieldInfo.getColumn());
+ }
+ return mapping;
+ }
+
+ private static String resolveColumnExpression(PageSum annotation, Field field, Map propertyColumnMap) {
+ if (StrUtil.isNotBlank(annotation.column())) {
+ return annotation.column();
+ }
+ return propertyColumnMap.get(field.getName());
+ }
+
+ private static boolean isNumeric(Class> type) {
+ if (type.isPrimitive()) {
+ return type == int.class || type == long.class || type == double.class
+ || type == float.class || type == short.class || type == byte.class;
+ }
+ return Number.class.isAssignableFrom(type) || BigDecimal.class.isAssignableFrom(type)
+ || BigInteger.class.isAssignableFrom(type);
+ }
+
+ private static Map executeSum(BaseMapper mapper, Wrapper wrapper, List metas) {
+ if (metas.isEmpty()) {
+ return Collections.emptyMap();
+ }
+ Wrapper workingWrapper = cloneWrapper(wrapper);
+ applySelect(workingWrapper, metas);
+ List