1. 新增分页接口聚合查询注解支持
2. 优化 databus api 日志记录的字段缺失问题 3. 新增 eplat sso 页面登录校验 4. 用户、部门编辑新增 seata 事务支持 5. 新增 iwork 流程发起接口 6. 新增 eban 同步用户时的岗位处理逻辑 7. 新增无 skywalking 时的 traceId 支持
This commit is contained in:
@@ -52,6 +52,12 @@
|
||||
<scope>provided</scope> <!-- 设置为 provided,只有工具类需要使用到 -->
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.data</groupId>
|
||||
<artifactId>spring-data-redis</artifactId>
|
||||
<scope>provided</scope> <!-- 设置为 provided,只有工具类需要使用到 -->
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>jakarta.servlet</groupId>
|
||||
<artifactId>jakarta.servlet-api</artifactId>
|
||||
@@ -151,6 +157,12 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.zt.plat.framework.common.annotation;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
/**
|
||||
* 标记分页结果中需要求和的字段。
|
||||
* <p>
|
||||
* 未显式指定列名时,会默认使用实体字段对应的数据库列。
|
||||
* <p>
|
||||
* {@link #exist()} 可以用于声明该字段并不存在于表结构中,相当于为字段添加
|
||||
* {@code @TableField(exist = false)},方便在 DO 中声明专用于汇总结果的临时字段。
|
||||
*/
|
||||
@Documented
|
||||
@Target(ElementType.FIELD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface PageSum {
|
||||
|
||||
/**
|
||||
* 自定义求和的数据库列名或表达式,未设置时默认使用实体字段对应的列。
|
||||
*/
|
||||
String column() default "";
|
||||
|
||||
/**
|
||||
* 是否在实体字段上声明真实存在的数据库列。
|
||||
* <p>
|
||||
* 设为 {@code false} 时,框架会自动为该字段提供 {@code @TableField(exist = false)} 的能力,
|
||||
* 适用于只在分页响应中返回的临时统计字段。
|
||||
*/
|
||||
boolean exist() default false;
|
||||
|
||||
}
|
||||
@@ -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<T> implements Serializable {
|
||||
private List<T> list;
|
||||
|
||||
@Schema(description = "总量", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@JsonProperty("total")
|
||||
@JsonAlias({"totalCount"})
|
||||
private Long total;
|
||||
|
||||
@Schema(description = "汇总信息(字段需使用 @PageSum 标注)")
|
||||
@JsonProperty("summary")
|
||||
private Map<String, BigDecimal> summary;
|
||||
|
||||
public PageResult() {
|
||||
this.list = new ArrayList<>();
|
||||
this.summary = Collections.emptyMap();
|
||||
}
|
||||
|
||||
public PageResult(List<T> list, Long total) {
|
||||
this(list, total, null);
|
||||
}
|
||||
|
||||
public PageResult(List<T> list, Long total, Map<String, BigDecimal> 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 <T> PageResult<T> empty() {
|
||||
@@ -38,4 +57,30 @@ public final class PageResult<T> implements Serializable {
|
||||
return new PageResult<>(total);
|
||||
}
|
||||
|
||||
public void setSummary(Map<String, BigDecimal> summary) {
|
||||
setSummaryInternal(summary);
|
||||
}
|
||||
|
||||
private void setSummaryInternal(Map<String, BigDecimal> summary) {
|
||||
if (summary == null || summary.isEmpty()) {
|
||||
this.summary = Collections.emptyMap();
|
||||
return;
|
||||
}
|
||||
this.summary = new LinkedHashMap<>(summary);
|
||||
}
|
||||
|
||||
public <R> PageResult<R> convert(List<R> newList) {
|
||||
return new PageResult<>(newList, total, summary);
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Long getTotalCount() {
|
||||
return total;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public void setTotalCount(Long totalCount) {
|
||||
this.total = totalCount;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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(), "/");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<String, Lock> 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<String> entity = new HttpEntity<>(payload, headers);
|
||||
ResponseEntity<String> 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<String, String> 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<String, String> 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<String, String> buildClientCredentialsParams(ShareServiceProperties properties) {
|
||||
MultiValueMap<String, String> params = baseTokenParams(properties);
|
||||
params.add("grant_type", "client_credentials");
|
||||
if (StrUtil.isNotBlank(properties.getScope())) {
|
||||
params.add("scope", properties.getScope());
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
private static MultiValueMap<String, String> buildRefreshTokenParams(ShareServiceProperties properties,
|
||||
String refreshToken) {
|
||||
MultiValueMap<String, String> params = baseTokenParams(properties);
|
||||
params.add("grant_type", "refresh_token");
|
||||
params.add("refresh_token", refreshToken);
|
||||
return params;
|
||||
}
|
||||
|
||||
private static MultiValueMap<String, String> baseTokenParams(ShareServiceProperties properties) {
|
||||
MultiValueMap<String, String> 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<String, String> body) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
|
||||
HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(body, headers);
|
||||
String tokenUrl = properties.buildTokenUrl();
|
||||
log.info("共享服务获取 token 地址:[{}],授权方式:[{}]", tokenUrl, body.getFirst("grant_type"));
|
||||
ResponseEntity<String> 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<String, String> 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) {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<String> FALLBACK_TRACE_ID = new InheritableThreadLocal<>();
|
||||
|
||||
/**
|
||||
* 私有化构造方法
|
||||
@@ -18,13 +52,121 @@ public class TracerUtils {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得链路追踪编号,直接返回 SkyWalking 的 TraceId。
|
||||
* 如果不存在的话为空字符串!!!
|
||||
* 获得链路追踪编号。
|
||||
* <p>
|
||||
* 优先返回 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(), "-", "");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<T> 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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -107,6 +107,12 @@
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-openfeign</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<T> extends MPJBaseMapper<T> {
|
||||
// 特殊:不分页,直接查询全部
|
||||
if (PageParam.PAGE_SIZE_NONE.equals(pageParam.getPageSize())) {
|
||||
List<T> list = selectList(queryWrapper);
|
||||
return new PageResult<>(list, (long) list.size());
|
||||
PageResult<T> pageResult = new PageResult<>(list, (long) list.size());
|
||||
PageSumSupport.tryAttachSummary(this, queryWrapper, pageResult);
|
||||
return pageResult;
|
||||
}
|
||||
|
||||
// MyBatis Plus 查询
|
||||
IPage<T> mpPage = MyBatisUtils.buildPage(pageParam, sortingFields);
|
||||
selectPage(mpPage, queryWrapper);
|
||||
// 转换返回
|
||||
return new PageResult<>(mpPage.getRecords(), mpPage.getTotal());
|
||||
PageResult<T> pageResult = new PageResult<>(mpPage.getRecords(), mpPage.getTotal());
|
||||
PageSumSupport.tryAttachSummary(this, queryWrapper, pageResult);
|
||||
return pageResult;
|
||||
}
|
||||
|
||||
default <D> PageResult<D> selectJoinPage(PageParam pageParam, Class<D> clazz, MPJLambdaWrapper<T> lambdaWrapper) {
|
||||
|
||||
@@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -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<Class<?>, Optional<Class<?>>> ENTITY_CLASS_CACHE = new ConcurrentHashMap<>();
|
||||
private static final ConcurrentMap<Class<?>, List<PageSumFieldMeta>> FIELD_META_CACHE = new ConcurrentHashMap<>();
|
||||
private static final ConcurrentMap<Class<?>, Optional<Field>> SQL_SELECT_FIELD_CACHE = new ConcurrentHashMap<>();
|
||||
|
||||
private PageSumSupport() {
|
||||
}
|
||||
|
||||
public static <T> void tryAttachSummary(Object mapperProxy, Wrapper<T> wrapper, PageResult<?> pageResult) {
|
||||
if (mapperProxy == null || pageResult == null) {
|
||||
return;
|
||||
}
|
||||
Class<?> entityClass = resolveEntityClass(mapperProxy.getClass());
|
||||
if (entityClass == null) {
|
||||
return;
|
||||
}
|
||||
List<PageSumFieldMeta> fieldMetas = resolveFieldMetas(entityClass);
|
||||
if (fieldMetas.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<String, BigDecimal> summary = executeSum((BaseMapper<T>) 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<Class<?>> 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<PageSumFieldMeta> resolveFieldMetas(Class<?> entityClass) {
|
||||
return FIELD_META_CACHE.computeIfAbsent(entityClass, PageSumSupport::scanFieldMetas);
|
||||
}
|
||||
|
||||
private static List<PageSumFieldMeta> 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<String, String> propertyColumnMap = tableInfo != null
|
||||
? buildPropertyColumnMap(tableInfo)
|
||||
: Collections.emptyMap();
|
||||
List<PageSumFieldMeta> 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<String, String> buildPropertyColumnMap(TableInfo tableInfo) {
|
||||
Map<String, String> 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<String, String> 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 <T> Map<String, BigDecimal> executeSum(BaseMapper<T> mapper, Wrapper<T> wrapper, List<PageSumFieldMeta> metas) {
|
||||
if (metas.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
Wrapper<T> workingWrapper = cloneWrapper(wrapper);
|
||||
applySelect(workingWrapper, metas);
|
||||
List<Map<String, Object>> rows = mapper.selectMaps(workingWrapper);
|
||||
Map<String, BigDecimal> result = new LinkedHashMap<>(metas.size());
|
||||
Map<String, Object> row = rows.isEmpty() ? Collections.emptyMap() : rows.get(0);
|
||||
for (PageSumFieldMeta meta : metas) {
|
||||
Object value = extractValue(row, meta.getSelectAlias());
|
||||
result.put(meta.getPropertyName(), toBigDecimal(value));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static <T> Wrapper<T> cloneWrapper(Wrapper<T> wrapper) {
|
||||
if (wrapper == null) {
|
||||
return new QueryWrapper<>();
|
||||
}
|
||||
if (wrapper instanceof com.baomidou.mybatisplus.core.conditions.AbstractWrapper<?, ?, ?> abstractWrapper) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Wrapper<T> clone = (Wrapper<T>) abstractWrapper.clone();
|
||||
return clone;
|
||||
}
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
private static void applySelect(Wrapper<?> wrapper, List<PageSumFieldMeta> metas) {
|
||||
String selectSql = buildSelectSql(metas);
|
||||
if (wrapper instanceof QueryWrapper<?> queryWrapper) {
|
||||
queryWrapper.select(selectSql);
|
||||
return;
|
||||
}
|
||||
if (wrapper instanceof LambdaQueryWrapper<?> lambdaQueryWrapper) {
|
||||
setSqlSelect(lambdaQueryWrapper, selectSql);
|
||||
return;
|
||||
}
|
||||
// attempt reflective fallback for other wrapper implementations extending LambdaQueryWrapper
|
||||
setSqlSelect(wrapper, selectSql);
|
||||
}
|
||||
|
||||
private static String buildSelectSql(List<PageSumFieldMeta> metas) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int i = 0; i < metas.size(); i++) {
|
||||
if (i > 0) {
|
||||
builder.append(',');
|
||||
}
|
||||
builder.append(metas.get(i).buildSelectSegment());
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private static void setSqlSelect(Object wrapper, String selectSql) {
|
||||
Field field = SQL_SELECT_FIELD_CACHE.computeIfAbsent(wrapper.getClass(), PageSumSupport::locateSqlSelectField)
|
||||
.orElse(null);
|
||||
if (field == null) {
|
||||
LOGGER.debug("Unable to locate sqlSelect field on wrapper {}, summary aggregation skipped.",
|
||||
wrapper.getClass().getName());
|
||||
return;
|
||||
}
|
||||
try {
|
||||
com.baomidou.mybatisplus.core.conditions.SharedString shared = (com.baomidou.mybatisplus.core.conditions.SharedString) field.get(wrapper);
|
||||
if (shared == null) {
|
||||
shared = com.baomidou.mybatisplus.core.conditions.SharedString.emptyString();
|
||||
field.set(wrapper, shared);
|
||||
}
|
||||
shared.setStringValue(selectSql);
|
||||
} catch (IllegalAccessException ex) {
|
||||
LOGGER.warn("Failed to set sqlSelect on wrapper {}: {}", wrapper.getClass().getName(), ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static Optional<Field> locateSqlSelectField(Class<?> wrapperClass) {
|
||||
Class<?> current = wrapperClass;
|
||||
while (current != null && current != Object.class) {
|
||||
try {
|
||||
Field field = current.getDeclaredField("sqlSelect");
|
||||
field.setAccessible(true);
|
||||
return Optional.of(field);
|
||||
} catch (NoSuchFieldException ignored) {
|
||||
current = current.getSuperclass();
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private static Object extractValue(Map<String, Object> row, String alias) {
|
||||
if (row == null || row.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (row.containsKey(alias)) {
|
||||
return row.get(alias);
|
||||
}
|
||||
for (Map.Entry<String, Object> entry : row.entrySet()) {
|
||||
if (alias.equalsIgnoreCase(entry.getKey())) {
|
||||
return entry.getValue();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static BigDecimal toBigDecimal(Object value) {
|
||||
if (value == null) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
if (value instanceof BigDecimal decimal) {
|
||||
return decimal;
|
||||
}
|
||||
if (value instanceof BigInteger bigInteger) {
|
||||
return new BigDecimal(bigInteger);
|
||||
}
|
||||
if (value instanceof Number number) {
|
||||
return new BigDecimal(number.toString());
|
||||
}
|
||||
if (value instanceof CharSequence sequence) {
|
||||
String text = sequence.toString().trim();
|
||||
if (text.isEmpty()) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
try {
|
||||
return new BigDecimal(text);
|
||||
} catch (NumberFormatException ex) {
|
||||
LOGGER.warn("Unable to parse numeric summary value '{}': {}", text, ex.getMessage());
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
}
|
||||
LOGGER.warn("Unsupported summary value type: {}", value.getClass().getName());
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.zt.plat.framework.mybatis.core.sum;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.core.handlers.AnnotationHandler;
|
||||
import com.zt.plat.framework.common.annotation.PageSum;
|
||||
import org.springframework.core.annotation.AnnotationUtils;
|
||||
|
||||
import java.lang.annotation.Annotation;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 让 {@link PageSum#exist()} 能够自动生成 {@link TableField#exist()} = false 的能力,
|
||||
* 这样 DO 层无需重复编写 {@code @TableField(exist = false)}。
|
||||
*/
|
||||
public class PageSumTableFieldAnnotationHandler implements AnnotationHandler {
|
||||
|
||||
private static final AnnotationHandler DEFAULT_HANDLER = new AnnotationHandler() { };
|
||||
/** 预构建 @TableField(exist = false) 的属性集合,避免重复创建 Map 对象 */
|
||||
private static final Map<String, Object> TABLE_FIELD_EXIST_FALSE_ATTRIBUTES =
|
||||
Collections.singletonMap("exist", Boolean.FALSE);
|
||||
|
||||
private final AnnotationHandler delegate;
|
||||
|
||||
public PageSumTableFieldAnnotationHandler(AnnotationHandler delegate) {
|
||||
this.delegate = delegate != null ? delegate : DEFAULT_HANDLER;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T extends Annotation> T getAnnotation(Class<?> target, Class<T> annotationClass) {
|
||||
return delegate.getAnnotation(target, annotationClass);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T extends Annotation> boolean isAnnotationPresent(Class<?> target, Class<T> annotationClass) {
|
||||
return delegate.isAnnotationPresent(target, annotationClass);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T extends Annotation> T getAnnotation(java.lang.reflect.Method method, Class<T> annotationClass) {
|
||||
return delegate.getAnnotation(method, annotationClass);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T extends Annotation> boolean isAnnotationPresent(java.lang.reflect.Method method, Class<T> annotationClass) {
|
||||
return delegate.isAnnotationPresent(method, annotationClass);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T extends Annotation> T getAnnotation(Field field, Class<T> annotationClass) {
|
||||
T annotation = delegate.getAnnotation(field, annotationClass);
|
||||
if (annotation != null || annotationClass != TableField.class) {
|
||||
return annotation;
|
||||
}
|
||||
PageSum pageSum = delegate.getAnnotation(field, PageSum.class);
|
||||
if (pageSum != null && !pageSum.exist()) {
|
||||
// 当字段只用于分页汇总时,动态合成一个 exist = false 的 TableField 注解
|
||||
return annotationClass.cast(synthesizeTableField(field));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T extends Annotation> boolean isAnnotationPresent(Field field, Class<T> annotationClass) {
|
||||
if (delegate.isAnnotationPresent(field, annotationClass)) {
|
||||
return true;
|
||||
}
|
||||
if (annotationClass != TableField.class) {
|
||||
return false;
|
||||
}
|
||||
PageSum pageSum = delegate.getAnnotation(field, PageSum.class);
|
||||
return pageSum != null && !pageSum.exist();
|
||||
}
|
||||
|
||||
private static TableField synthesizeTableField(Field field) {
|
||||
return AnnotationUtils.synthesizeAnnotation(TABLE_FIELD_EXIST_FALSE_ATTRIBUTES, TableField.class, field);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.zt.plat.framework.mybatis.core.sum;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.zt.plat.framework.common.annotation.PageSum;
|
||||
import com.zt.plat.framework.common.pojo.PageResult;
|
||||
import com.zt.plat.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
class PageSumSupportTest {
|
||||
|
||||
@Test
|
||||
void shouldAttachSummaryWhenAnnotationPresent() {
|
||||
TestMapper mapper = createMapperProxy();
|
||||
PageResult<TestEntity> pageResult = new PageResult<>(Collections.emptyList(), 0L);
|
||||
QueryWrapper<TestEntity> wrapper = new QueryWrapper<>();
|
||||
|
||||
PageSumSupport.tryAttachSummary(mapper, wrapper, pageResult);
|
||||
|
||||
assertFalse(pageResult.getSummary().isEmpty());
|
||||
assertEquals(new BigDecimal("123.45"), pageResult.getSummary().get("amount"));
|
||||
assertEquals(new BigDecimal("50"), pageResult.getSummary().get("virtualAmount"));
|
||||
}
|
||||
|
||||
private TestMapper createMapperProxy() {
|
||||
InvocationHandler handler = new InvocationHandler() {
|
||||
@Override
|
||||
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
|
||||
if (method.getDeclaringClass() == Object.class) {
|
||||
return method.invoke(this, args);
|
||||
}
|
||||
if ("selectMaps".equals(method.getName())) {
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("amount", new BigDecimal("123.45"));
|
||||
row.put("virtualAmount", new BigDecimal("50"));
|
||||
return List.of(row);
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
};
|
||||
return (TestMapper) Proxy.newProxyInstance(
|
||||
TestMapper.class.getClassLoader(),
|
||||
new Class[]{TestMapper.class},
|
||||
handler);
|
||||
}
|
||||
|
||||
interface TestMapper extends BaseMapperX<TestEntity> {
|
||||
}
|
||||
|
||||
static class TestEntity {
|
||||
@PageSum(column = "amount")
|
||||
private BigDecimal amount;
|
||||
|
||||
@PageSum(column = "virtual_column", exist = false)
|
||||
private BigDecimal virtualAmount;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user