Merge branch 'dev' into test
This commit is contained in:
581
sql/dm/bpm.sql
Normal file
581
sql/dm/bpm.sql
Normal file
File diff suppressed because one or more lines are too long
@@ -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());
|
||||
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
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
package com.zt.plat.framework.mybatis.core.mapper;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
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;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
@@ -43,14 +50,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;
|
||||
}
|
||||
}
|
||||
@@ -7,17 +7,23 @@ import com.zt.plat.module.databus.controller.admin.gateway.vo.accesslog.ApiAcces
|
||||
import com.zt.plat.module.databus.controller.admin.gateway.vo.accesslog.ApiAccessLogRespVO;
|
||||
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiAccessLogDO;
|
||||
import com.zt.plat.module.databus.service.gateway.ApiAccessLogService;
|
||||
import com.zt.plat.module.databus.service.gateway.ApiDefinitionService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static com.zt.plat.framework.common.pojo.CommonResult.success;
|
||||
|
||||
@@ -33,13 +39,18 @@ public class ApiAccessLogController {
|
||||
@Resource
|
||||
private ApiAccessLogService apiAccessLogService;
|
||||
|
||||
@Resource
|
||||
private ApiDefinitionService apiDefinitionService;
|
||||
|
||||
@GetMapping("/get")
|
||||
@Operation(summary = "获取访问日志详情")
|
||||
@Parameter(name = "id", description = "日志编号", required = true, example = "1024")
|
||||
@PreAuthorize("@ss.hasPermission('databus:gateway:access-log:query')")
|
||||
public CommonResult<ApiAccessLogRespVO> get(@RequestParam("id") Long id) {
|
||||
ApiAccessLogDO logDO = apiAccessLogService.get(id);
|
||||
return success(ApiAccessLogConvert.INSTANCE.convert(logDO));
|
||||
ApiAccessLogRespVO respVO = ApiAccessLogConvert.INSTANCE.convert(logDO);
|
||||
enrichDefinitionInfo(respVO);
|
||||
return success(respVO);
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
@@ -47,6 +58,51 @@ public class ApiAccessLogController {
|
||||
@PreAuthorize("@ss.hasPermission('databus:gateway:access-log:query')")
|
||||
public CommonResult<PageResult<ApiAccessLogRespVO>> page(@Valid ApiAccessLogPageReqVO pageReqVO) {
|
||||
PageResult<ApiAccessLogDO> pageResult = apiAccessLogService.getPage(pageReqVO);
|
||||
return success(ApiAccessLogConvert.INSTANCE.convertPage(pageResult));
|
||||
PageResult<ApiAccessLogRespVO> result = ApiAccessLogConvert.INSTANCE.convertPage(pageResult);
|
||||
enrichDefinitionInfo(result.getList());
|
||||
return success(result);
|
||||
}
|
||||
|
||||
private void enrichDefinitionInfo(List<ApiAccessLogRespVO> list) {
|
||||
// 对分页结果批量补充 API 描述,使用本地缓存减少重复查询
|
||||
if (CollectionUtils.isEmpty(list)) {
|
||||
return;
|
||||
}
|
||||
Map<String, String> cache = new HashMap<>(list.size());
|
||||
list.forEach(item -> {
|
||||
if (item == null) {
|
||||
return;
|
||||
}
|
||||
String cacheKey = buildCacheKey(item.getApiCode(), item.getApiVersion());
|
||||
if (!cache.containsKey(cacheKey)) {
|
||||
cache.put(cacheKey, resolveApiDescription(item.getApiCode(), item.getApiVersion()));
|
||||
}
|
||||
item.setApiDescription(cache.get(cacheKey));
|
||||
});
|
||||
}
|
||||
|
||||
private void enrichDefinitionInfo(ApiAccessLogRespVO item) {
|
||||
// 单条数据同样需要补全描述信息
|
||||
if (item == null) {
|
||||
return;
|
||||
}
|
||||
item.setApiDescription(resolveApiDescription(item.getApiCode(), item.getApiVersion()));
|
||||
}
|
||||
|
||||
private String resolveApiDescription(String apiCode, String apiVersion) {
|
||||
if (!StringUtils.hasText(apiCode)) {
|
||||
return null;
|
||||
}
|
||||
String normalizedVersion = StringUtils.hasText(apiVersion) ? apiVersion.trim() : apiVersion;
|
||||
// 通过网关定义服务补全 API 描述,提升页面可读性
|
||||
return apiDefinitionService.findByCodeAndVersionIncludingInactive(apiCode, normalizedVersion)
|
||||
.map(aggregate -> aggregate.getDefinition() != null ? aggregate.getDefinition().getDescription() : null)
|
||||
.filter(StringUtils::hasText)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private String buildCacheKey(String apiCode, String apiVersion) {
|
||||
// 组合唯一键,避免重复查询相同的 API 描述
|
||||
return (apiCode == null ? "" : apiCode) + "#" + (apiVersion == null ? "" : apiVersion);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import com.zt.plat.framework.common.pojo.PageResult;
|
||||
import com.zt.plat.module.databus.controller.admin.gateway.vo.accesslog.ApiAccessLogRespVO;
|
||||
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiAccessLogDO;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
import org.mapstruct.factory.Mappers;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -13,6 +15,8 @@ public interface ApiAccessLogConvert {
|
||||
|
||||
ApiAccessLogConvert INSTANCE = Mappers.getMapper(ApiAccessLogConvert.class);
|
||||
|
||||
@Mapping(target = "statusDesc", expression = "java(statusDesc(bean.getStatus()))")
|
||||
@Mapping(target = "responseStatusText", expression = "java(resolveHttpStatusText(bean.getResponseStatus()))")
|
||||
ApiAccessLogRespVO convert(ApiAccessLogDO bean);
|
||||
|
||||
List<ApiAccessLogRespVO> convertList(List<ApiAccessLogDO> list);
|
||||
@@ -26,4 +30,26 @@ public interface ApiAccessLogConvert {
|
||||
result.setTotal(page.getTotal());
|
||||
return result;
|
||||
}
|
||||
|
||||
default String statusDesc(Integer status) {
|
||||
// 将数字状态码转换为中文描述,方便前端直接展示
|
||||
if (status == null) {
|
||||
return "未知";
|
||||
}
|
||||
return switch (status) {
|
||||
case 0 -> "成功";
|
||||
case 1 -> "客户端错误";
|
||||
case 2 -> "服务端错误";
|
||||
default -> "未知";
|
||||
};
|
||||
}
|
||||
|
||||
default String resolveHttpStatusText(Integer status) {
|
||||
// 统一使用 Spring 的 HttpStatus 解析出标准文案
|
||||
if (status == null) {
|
||||
return null;
|
||||
}
|
||||
HttpStatus resolved = HttpStatus.resolve(status);
|
||||
return resolved != null ? resolved.getReasonPhrase() : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ public class ApiAccessLogRespVO {
|
||||
@Schema(description = "API 编码", example = "user.query")
|
||||
private String apiCode;
|
||||
|
||||
@Schema(description = "API 描述", example = "用户查询服务")
|
||||
private String apiDescription;
|
||||
|
||||
@Schema(description = "API 版本", example = "v1")
|
||||
private String apiVersion;
|
||||
|
||||
@@ -42,6 +45,9 @@ public class ApiAccessLogRespVO {
|
||||
@Schema(description = "响应 HTTP 状态", example = "200")
|
||||
private Integer responseStatus;
|
||||
|
||||
@Schema(description = "响应 HTTP 状态说明", example = "OK")
|
||||
private String responseStatusText;
|
||||
|
||||
@Schema(description = "响应提示", example = "OK")
|
||||
private String responseMessage;
|
||||
|
||||
@@ -51,6 +57,9 @@ public class ApiAccessLogRespVO {
|
||||
@Schema(description = "访问状态", example = "0")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "访问状态展示文案", example = "成功")
|
||||
private String statusDesc;
|
||||
|
||||
@Schema(description = "错误码", example = "DAT-001")
|
||||
private String errorCode;
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ public class ApiAccessLogDO extends TenantBaseDO {
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 请求追踪标识,对应 {@link com.zt.plat.module.databus.framework.integration.gateway.model.ApiInvocationContext#getRequestId()}
|
||||
* 请求追踪标识,对应 {@link com.zt.plat.framework.common.util.monitor.TracerUtils#getTraceId()}
|
||||
*/
|
||||
private String traceId;
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@ package com.zt.plat.module.databus.framework.integration.gateway.core;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.zt.plat.framework.common.util.monitor.TracerUtils;
|
||||
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiAccessLogDO;
|
||||
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiInvocationContext;
|
||||
import com.zt.plat.module.databus.service.gateway.ApiAccessLogService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -42,8 +44,9 @@ public class ApiGatewayAccessLogger {
|
||||
*/
|
||||
public void onRequest(ApiInvocationContext context) {
|
||||
try {
|
||||
String traceId = TracerUtils.getTraceId();
|
||||
ApiAccessLogDO logDO = new ApiAccessLogDO();
|
||||
logDO.setTraceId(context.getRequestId());
|
||||
logDO.setTraceId(traceId);
|
||||
logDO.setApiCode(context.getApiCode());
|
||||
logDO.setApiVersion(context.getApiVersion());
|
||||
logDO.setRequestMethod(context.getHttpMethod());
|
||||
@@ -60,7 +63,7 @@ public class ApiGatewayAccessLogger {
|
||||
Long logId = apiAccessLogService.create(logDO);
|
||||
context.getAttributes().put(ATTR_LOG_ID, logId);
|
||||
} catch (Exception ex) {
|
||||
log.warn("记录 API 访问日志开始阶段失败, traceId={}", context.getRequestId(), ex);
|
||||
log.warn("记录 API 访问日志开始阶段失败, traceId={}", TracerUtils.getTraceId(), ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,12 +88,18 @@ public class ApiGatewayAccessLogger {
|
||||
try {
|
||||
ApiAccessLogDO update = new ApiAccessLogDO();
|
||||
update.setId(logId);
|
||||
update.setResponseStatus(context.getResponseStatus());
|
||||
update.setResponseMessage(context.getResponseMessage());
|
||||
int responseStatus = resolveHttpStatus(context);
|
||||
context.setResponseStatus(responseStatus);
|
||||
update.setResponseStatus(responseStatus);
|
||||
String responseMessage = resolveResponseMessage(context, responseStatus);
|
||||
update.setResponseMessage(responseMessage);
|
||||
if (!StringUtils.hasText(context.getResponseMessage()) && StringUtils.hasText(responseMessage)) {
|
||||
context.setResponseMessage(responseMessage);
|
||||
}
|
||||
update.setResponseBody(toJson(context.getResponseBody()));
|
||||
update.setStatus(resolveStatus(context.getResponseStatus()));
|
||||
update.setErrorCode(extractErrorCode(context.getResponseBody()));
|
||||
update.setErrorMessage(resolveErrorMessage(context));
|
||||
update.setStatus(resolveStatus(responseStatus));
|
||||
update.setErrorCode(extractErrorCode(context.getResponseBody(), responseStatus));
|
||||
update.setErrorMessage(resolveErrorMessage(context, responseStatus));
|
||||
update.setExceptionStack((String) context.getAttributes().get(ATTR_EXCEPTION_STACK));
|
||||
update.setStepResults(toJson(context.getStepResults()));
|
||||
update.setExtra(toJson(buildExtra(context)));
|
||||
@@ -98,7 +107,7 @@ public class ApiGatewayAccessLogger {
|
||||
update.setDuration(calculateDuration(context));
|
||||
apiAccessLogService.update(update);
|
||||
} catch (Exception ex) {
|
||||
log.warn("记录 API 访问日志结束阶段失败, traceId={}, logId={}", context.getRequestId(), logId, ex);
|
||||
log.warn("记录 API 访问日志结束阶段失败, traceId={}, logId={}", TracerUtils.getTraceId(), logId, ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +146,10 @@ public class ApiGatewayAccessLogger {
|
||||
return 3;
|
||||
}
|
||||
|
||||
private String resolveErrorMessage(ApiInvocationContext context) {
|
||||
private String resolveErrorMessage(ApiInvocationContext context, int responseStatus) {
|
||||
if (!isErrorStatus(responseStatus)) {
|
||||
return null;
|
||||
}
|
||||
if (StringUtils.hasText(context.getResponseMessage())) {
|
||||
return truncate(context.getResponseMessage());
|
||||
}
|
||||
@@ -151,7 +163,10 @@ public class ApiGatewayAccessLogger {
|
||||
return null;
|
||||
}
|
||||
|
||||
private String extractErrorCode(Object responseBody) {
|
||||
private String extractErrorCode(Object responseBody, int responseStatus) {
|
||||
if (!isErrorStatus(responseStatus)) {
|
||||
return null;
|
||||
}
|
||||
if (responseBody instanceof Map<?, ?> map) {
|
||||
Object errorCode = firstNonNull(map.get("errorCode"), map.get("code"));
|
||||
return errorCode == null ? null : truncate(String.valueOf(errorCode));
|
||||
@@ -159,6 +174,27 @@ public class ApiGatewayAccessLogger {
|
||||
return null;
|
||||
}
|
||||
|
||||
private int resolveHttpStatus(ApiInvocationContext context) {
|
||||
Integer status = context.getResponseStatus();
|
||||
if (status != null) {
|
||||
return status;
|
||||
}
|
||||
// 默认兜底为 200,避免日志中出现空的 HTTP 状态码
|
||||
return HttpStatus.OK.value();
|
||||
}
|
||||
|
||||
private String resolveResponseMessage(ApiInvocationContext context, int responseStatus) {
|
||||
if (StringUtils.hasText(context.getResponseMessage())) {
|
||||
return truncate(context.getResponseMessage());
|
||||
}
|
||||
HttpStatus resolved = HttpStatus.resolve(responseStatus);
|
||||
return resolved != null ? resolved.getReasonPhrase() : null;
|
||||
}
|
||||
|
||||
private boolean isErrorStatus(int responseStatus) {
|
||||
return responseStatus >= 400;
|
||||
}
|
||||
|
||||
private Map<String, Object> buildExtra(ApiInvocationContext context) {
|
||||
Map<String, Object> extra = new HashMap<>();
|
||||
if (!CollectionUtils.isEmpty(context.getVariables())) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.zt.plat.framework.common.exception.ServiceException;
|
||||
import com.zt.plat.framework.common.exception.util.ServiceExceptionUtil;
|
||||
import com.zt.plat.framework.common.util.monitor.TracerUtils;
|
||||
import com.zt.plat.module.databus.controller.admin.gateway.vo.ApiGatewayInvokeReqVO;
|
||||
import com.zt.plat.module.databus.framework.integration.config.ApiGatewayProperties;
|
||||
import com.zt.plat.module.databus.framework.integration.gateway.domain.ApiDefinitionAggregate;
|
||||
@@ -240,7 +241,7 @@ public class ApiGatewayExecutionService {
|
||||
.code(status)
|
||||
.message(message)
|
||||
.response(context.getResponseBody())
|
||||
.traceId(context.getRequestId())
|
||||
.traceId(TracerUtils.getTraceId())
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.zt.plat.framework.common.util.json.JsonUtils;
|
||||
import com.zt.plat.framework.common.util.monitor.TracerUtils;
|
||||
import com.zt.plat.framework.common.util.security.CryptoSignatureUtils;
|
||||
import com.zt.plat.framework.common.util.servlet.ServletUtils;
|
||||
import com.zt.plat.framework.security.core.LoginUser;
|
||||
@@ -464,11 +465,12 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
|
||||
response.resetBuffer();
|
||||
response.setStatus(status.value());
|
||||
String resolvedMessage = StringUtils.hasText(message) ? message : status.getReasonPhrase();
|
||||
String traceId = TracerUtils.getTraceId();
|
||||
ApiGatewayResponse envelope = ApiGatewayResponse.builder()
|
||||
.code(status.value())
|
||||
.message(resolvedMessage)
|
||||
.response(null)
|
||||
.traceId(null)
|
||||
.traceId(traceId)
|
||||
.build();
|
||||
if (shouldEncryptErrorResponse(security, credential)) {
|
||||
String encryptionKey = credential.getEncryptionKey();
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
package com.zt.plat.module.databus.framework.integration.gateway.step.impl;
|
||||
|
||||
import com.zt.plat.framework.common.exception.ServiceException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.netty.http.client.HttpClient;
|
||||
import reactor.netty.http.client.PrematureCloseException;
|
||||
import reactor.netty.resources.ConnectionProvider;
|
||||
import reactor.util.retry.Retry;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.net.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Demonstrates the stale-connection scenario using the legacy vs the deferred retry pipeline.
|
||||
*/
|
||||
class HttpStepHandlerConnectionResetScenarioTest {
|
||||
|
||||
private static final Duration RETRY_DELAY = Duration.ofMillis(200);
|
||||
private static final int RETRY_ATTEMPTS = 3;
|
||||
private static final Duration BLOCK_TIMEOUT = Duration.ofSeconds(5);
|
||||
private static final Duration RESET_WAIT = Duration.ofMillis(300);
|
||||
|
||||
@Test
|
||||
void legacyPipelineLosesSuccessfulRetry() throws Exception {
|
||||
try (ResetOnceHttpServer server = new ResetOnceHttpServer()) {
|
||||
WebClient webClient = createWebClient();
|
||||
URI uri = server.uri("/demo");
|
||||
|
||||
warmUp(server, webClient, uri);
|
||||
server.awaitWarmupConnectionReset(RESET_WAIT);
|
||||
|
||||
legacyInvoke(webClient, uri, Map.of("mode", "legacy"));
|
||||
|
||||
server.awaitFreshResponses(1, Duration.ofSeconds(2));
|
||||
assertThat(server.getFreshResponseCount()).isEqualTo(1);
|
||||
assertThat(server.getServedBodies()).contains("reset", "fresh");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void deferredPipelinePropagatesSuccessfulRetry() throws Exception {
|
||||
try (ResetOnceHttpServer server = new ResetOnceHttpServer()) {
|
||||
WebClient webClient = createWebClient();
|
||||
URI uri = server.uri("/demo");
|
||||
|
||||
warmUp(server, webClient, uri);
|
||||
server.awaitWarmupConnectionReset(RESET_WAIT);
|
||||
|
||||
Object result = deferredInvoke(webClient, uri, Map.of("mode", "defer"));
|
||||
assertThat(result).isInstanceOf(Map.class);
|
||||
Map<?, ?> resultMap = (Map<?, ?>) result;
|
||||
assertThat(resultMap.get("stage")).isEqualTo("fresh");
|
||||
|
||||
server.awaitFreshResponses(1, Duration.ofSeconds(2));
|
||||
assertThat(server.getFreshResponseCount()).isEqualTo(1);
|
||||
assertThat(server.getServedBodies()).contains("reset", "fresh");
|
||||
}
|
||||
}
|
||||
|
||||
private WebClient createWebClient() {
|
||||
ConnectionProvider provider = ConnectionProvider.builder("http-step-handler-demo")
|
||||
.maxConnections(1)
|
||||
.pendingAcquireMaxCount(-1)
|
||||
.maxIdleTime(Duration.ofSeconds(5))
|
||||
.build();
|
||||
HttpClient httpClient = HttpClient.create(provider).compress(true);
|
||||
return WebClient.builder()
|
||||
.clientConnector(new ReactorClientHttpConnector(httpClient))
|
||||
.build();
|
||||
}
|
||||
|
||||
private void warmUp(ResetOnceHttpServer server, WebClient webClient, URI uri) {
|
||||
webClient.post()
|
||||
.uri(uri)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(Map.of("warm", true))
|
||||
.retrieve()
|
||||
.bodyToMono(Object.class)
|
||||
.block(BLOCK_TIMEOUT);
|
||||
server.awaitWarmupResponse(Duration.ofSeconds(2));
|
||||
}
|
||||
|
||||
private Object legacyInvoke(WebClient webClient, URI uri, Object body) {
|
||||
WebClient.RequestHeadersSpec<?> spec = webClient.post()
|
||||
.uri(uri)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(body);
|
||||
Mono<Object> responseMono = spec.retrieve()
|
||||
.bodyToMono(Object.class)
|
||||
// 模拟业务中首次订阅后缓存失败结果的场景
|
||||
.cache();
|
||||
return responseMono.retryWhen(Retry.fixedDelay(RETRY_ATTEMPTS, RETRY_DELAY)
|
||||
.filter(this::isRetryableException)
|
||||
.onRetryExhaustedThrow((specification, signal) -> signal.failure()))
|
||||
.block(BLOCK_TIMEOUT);
|
||||
}
|
||||
|
||||
private Object deferredInvoke(WebClient webClient, URI uri, Object body) {
|
||||
Mono<Object> responseMono = Mono.defer(() -> webClient.post()
|
||||
.uri(uri)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(body)
|
||||
.retrieve()
|
||||
.bodyToMono(Object.class)
|
||||
// 通过 defer,每次重试都会重新创建带缓存的响应 Mono
|
||||
.cache());
|
||||
return responseMono.retryWhen(Retry.fixedDelay(RETRY_ATTEMPTS, RETRY_DELAY)
|
||||
.filter(this::isRetryableException))
|
||||
.block(BLOCK_TIMEOUT);
|
||||
}
|
||||
|
||||
private boolean isRetryableException(Throwable throwable) {
|
||||
if (throwable == null) {
|
||||
return false;
|
||||
}
|
||||
Throwable cursor = throwable;
|
||||
while (cursor != null) {
|
||||
if (cursor instanceof ServiceException) {
|
||||
return false;
|
||||
}
|
||||
if (cursor instanceof PrematureCloseException) {
|
||||
return true;
|
||||
}
|
||||
if (cursor instanceof IOException) {
|
||||
return true;
|
||||
}
|
||||
cursor = cursor.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static final class ResetOnceHttpServer implements AutoCloseable {
|
||||
|
||||
private static final Duration RESET_DELAY = Duration.ofMillis(250);
|
||||
|
||||
private final ServerSocket serverSocket;
|
||||
private final ExecutorService acceptExecutor;
|
||||
private final ScheduledExecutorService scheduler;
|
||||
private final AtomicInteger connectionCount = new AtomicInteger();
|
||||
private final AtomicInteger freshResponses = new AtomicInteger();
|
||||
private final CountDownLatch warmupResponseSent = new CountDownLatch(1);
|
||||
private final CountDownLatch warmupReset = new CountDownLatch(1);
|
||||
private final List<String> servedBodies = new CopyOnWriteArrayList<>();
|
||||
private volatile boolean running = true;
|
||||
private volatile Socket warmupSocket;
|
||||
|
||||
ResetOnceHttpServer() throws IOException {
|
||||
this.serverSocket = new ServerSocket(0, 50, InetAddress.getByName("127.0.0.1"));
|
||||
this.serverSocket.setReuseAddress(true);
|
||||
this.acceptExecutor = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread t = new Thread(r, "reset-once-http-accept");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "reset-once-http-scheduler");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
acceptExecutor.submit(this::acceptLoop);
|
||||
}
|
||||
|
||||
URI uri(String path) {
|
||||
Objects.requireNonNull(path, "path");
|
||||
if (!path.startsWith("/")) {
|
||||
path = "/" + path;
|
||||
}
|
||||
return URI.create("http://127.0.0.1:" + serverSocket.getLocalPort() + path);
|
||||
}
|
||||
|
||||
List<String> getServedBodies() {
|
||||
return new ArrayList<>(servedBodies);
|
||||
}
|
||||
|
||||
int getFreshResponseCount() {
|
||||
return freshResponses.get();
|
||||
}
|
||||
|
||||
void awaitWarmupResponse(Duration timeout) {
|
||||
awaitLatch(warmupResponseSent, timeout);
|
||||
}
|
||||
|
||||
void awaitWarmupConnectionReset(Duration timeout) {
|
||||
awaitLatch(warmupReset, timeout);
|
||||
}
|
||||
|
||||
void awaitFreshResponses(int expected, Duration timeout) {
|
||||
long deadline = System.nanoTime() + timeout.toNanos();
|
||||
while (freshResponses.get() < expected && System.nanoTime() < deadline) {
|
||||
try {
|
||||
Thread.sleep(10);
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void awaitLatch(CountDownLatch latch, Duration timeout) {
|
||||
try {
|
||||
if (!latch.await(timeout.toMillis(), TimeUnit.MILLISECONDS)) {
|
||||
throw new IllegalStateException("Timed out waiting for latch");
|
||||
}
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void acceptLoop() {
|
||||
try {
|
||||
while (running) {
|
||||
Socket socket = serverSocket.accept();
|
||||
int index = connectionCount.incrementAndGet();
|
||||
handle(socket, index);
|
||||
}
|
||||
} catch (SocketException ex) {
|
||||
if (running) {
|
||||
throw new IllegalStateException("Unexpected server socket error", ex);
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
if (running) {
|
||||
throw new IllegalStateException("I/O error in server", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handle(Socket socket, int index) {
|
||||
try {
|
||||
socket.setTcpNoDelay(true);
|
||||
RequestMetadata metadata = readRequest(socket);
|
||||
if (index == 1) {
|
||||
warmupSocket = socket;
|
||||
String body = "{\"stage\":\"warmup\",\"path\":\"" + metadata.path + "\"}";
|
||||
writeResponse(socket, body, true);
|
||||
servedBodies.add("warmup");
|
||||
warmupResponseSent.countDown();
|
||||
scheduler.schedule(() -> forceReset(socket), RESET_DELAY.toMillis(), TimeUnit.MILLISECONDS);
|
||||
} else if (index == 2) {
|
||||
// 模拟客户端复用到仍在连接池中的旧连接,但服务端已在请求到达后立即复位。
|
||||
servedBodies.add("reset");
|
||||
scheduler.schedule(() -> closeWithReset(socket), 10, TimeUnit.MILLISECONDS);
|
||||
} else {
|
||||
String body = "{\"stage\":\"fresh\",\"attempt\":" + index + "}";
|
||||
writeResponse(socket, body, false);
|
||||
servedBodies.add("fresh");
|
||||
freshResponses.incrementAndGet();
|
||||
socket.close();
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
// ignore for the purpose of the test
|
||||
}
|
||||
}
|
||||
|
||||
private void forceReset(Socket socket) {
|
||||
try {
|
||||
if (!socket.isClosed()) {
|
||||
servedBodies.add("reset");
|
||||
closeWithReset(socket);
|
||||
}
|
||||
} finally {
|
||||
warmupReset.countDown();
|
||||
}
|
||||
}
|
||||
|
||||
private void closeWithReset(Socket socket) {
|
||||
try {
|
||||
if (!socket.isClosed()) {
|
||||
socket.setSoLinger(true, 0);
|
||||
socket.close();
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private RequestMetadata readRequest(Socket socket) throws IOException {
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.US_ASCII));
|
||||
String requestLine = reader.readLine();
|
||||
if (requestLine == null) {
|
||||
return new RequestMetadata("unknown", 0);
|
||||
}
|
||||
String path = requestLine.split(" ", 3)[1];
|
||||
int contentLength = 0;
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null && !line.isEmpty()) {
|
||||
if (line.toLowerCase(Locale.ROOT).startsWith("content-length:")) {
|
||||
contentLength = Integer.parseInt(line.substring(line.indexOf(':') + 1).trim());
|
||||
}
|
||||
}
|
||||
if (contentLength > 0) {
|
||||
char[] buffer = new char[contentLength];
|
||||
int read = 0;
|
||||
while (read < contentLength) {
|
||||
int r = reader.read(buffer, read, contentLength - read);
|
||||
if (r < 0) {
|
||||
break;
|
||||
}
|
||||
read += r;
|
||||
}
|
||||
}
|
||||
return new RequestMetadata(path, contentLength);
|
||||
}
|
||||
|
||||
private void writeResponse(Socket socket, String body, boolean keepAlive) throws IOException {
|
||||
byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
StringBuilder builder = new StringBuilder()
|
||||
.append("HTTP/1.1 200 OK\r\n")
|
||||
.append("Content-Type: application/json\r\n")
|
||||
.append("Content-Length: ").append(bodyBytes.length).append("\r\n");
|
||||
if (keepAlive) {
|
||||
builder.append("Connection: keep-alive\r\n");
|
||||
} else {
|
||||
builder.append("Connection: close\r\n");
|
||||
}
|
||||
builder.append("\r\n");
|
||||
OutputStream outputStream = socket.getOutputStream();
|
||||
outputStream.write(builder.toString().getBytes(StandardCharsets.US_ASCII));
|
||||
outputStream.write(bodyBytes);
|
||||
outputStream.flush();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
running = false;
|
||||
try {
|
||||
serverSocket.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
if (warmupSocket != null && !warmupSocket.isClosed()) {
|
||||
try {
|
||||
warmupSocket.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
scheduler.shutdownNow();
|
||||
acceptExecutor.shutdownNow();
|
||||
}
|
||||
|
||||
private record RequestMetadata(String path, int contentLength) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ public class BusinessFileController {
|
||||
|
||||
@GetMapping("/page")
|
||||
@Operation(summary = "获得业务附件关联分页")
|
||||
@PreAuthorize("@ss.hasPermission('infra:business-file:query')")
|
||||
@PreAuthorize("@ss.hasAnyPermissions({'infra:business-file:query','PurchaseCreditGrantingFormTemplate:query','PurchaseAmountRequestFormTemplate:query'})")
|
||||
public CommonResult<PageResult<BusinessFileRespVO>> getBusinessFilePage(@Valid BusinessFilePageReqVO pageReqVO) {
|
||||
PageResult<BusinessFileDO> pageResult = businessFileService.getBusinessFilePage(pageReqVO);
|
||||
return success(BeanUtils.toBean(pageResult, BusinessFileRespVO.class));
|
||||
|
||||
@@ -22,6 +22,12 @@ public interface ErrorCodeConstants {
|
||||
ErrorCode AUTH_LOGIN_INTERNAL_USER_PASSWORD_NOT_ALLOWED = new ErrorCode(1_002_000_011, "内部用户不允许使用账号密码登录,请通过e办进行统一登录");
|
||||
ErrorCode AUTH_LOGIN_EBAN_TOKEN_INVALID = new ErrorCode(1_002_000_012, "token 无效");
|
||||
ErrorCode AUTH_LOGIN_EBAN_USER_NOT_SYNC = new ErrorCode(1_002_000_013, "用户未同步到此应用,请联系管理员进行同步");
|
||||
ErrorCode EXTERNAL_SSO_DISABLED = new ErrorCode(1_002_000_050, "外部单点登录功能已关闭");
|
||||
ErrorCode EXTERNAL_SSO_TOKEN_MISSING = new ErrorCode(1_002_000_051, "token 不能为空");
|
||||
ErrorCode EXTERNAL_SSO_REMOTE_ERROR = new ErrorCode(1_002_000_055, "获取外部用户信息失败:{}");
|
||||
ErrorCode EXTERNAL_SSO_USER_NOT_FOUND = new ErrorCode(1_002_000_056, "未找到匹配的本地用户");
|
||||
ErrorCode EXTERNAL_SSO_USER_DISABLED = new ErrorCode(1_002_000_057, "匹配的本地用户已被禁用");
|
||||
ErrorCode EXTERNAL_SSO_SOURCE_UNSUPPORTED = new ErrorCode(1_002_000_058, "来源系统({})暂不支持");
|
||||
|
||||
// ========== 菜单模块 1-002-001-000 ==========
|
||||
ErrorCode MENU_NAME_DUPLICATE = new ErrorCode(1_002_001_000, "已经存在该名字的菜单");
|
||||
|
||||
@@ -14,6 +14,7 @@ public enum LoginLogTypeEnum {
|
||||
LOGIN_SOCIAL(101), // 使用社交登录
|
||||
LOGIN_MOBILE(103), // 使用手机登陆
|
||||
LOGIN_SMS(104), // 使用短信登陆
|
||||
LOGIN_EXTERNAL_SSO(105), // 外部系统单点登录
|
||||
|
||||
LOGOUT_SELF(200), // 自己主动登出
|
||||
LOGOUT_DELETE(202), // 强制退出
|
||||
|
||||
@@ -15,6 +15,11 @@ public enum LoginResultEnum {
|
||||
USER_DISABLED(20), // 用户被禁用
|
||||
CAPTCHA_NOT_FOUND(30), // 图片验证码不存在
|
||||
CAPTCHA_CODE_ERROR(31), // 图片验证码不正确
|
||||
TOKEN_INVALID(40), // SSO token 无效
|
||||
TOKEN_EXPIRED(41), // SSO token 已过期
|
||||
TOKEN_REPLAY(42), // SSO token 被重复使用
|
||||
REMOTE_SERVICE_ERROR(43), // 拉取外部用户失败
|
||||
USER_NOT_FOUND(44), // 未找到匹配的本地用户
|
||||
|
||||
;
|
||||
|
||||
|
||||
@@ -67,6 +67,17 @@
|
||||
<artifactId>zt-spring-boot-starter-redis</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Reactive HTTP client for iWork integration -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- RPC 远程调用相关 -->
|
||||
<dependency>
|
||||
<groupId>com.zt.plat</groupId>
|
||||
@@ -127,6 +138,18 @@
|
||||
<artifactId>zt-spring-boot-starter-monitor</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 分布式事务 -->
|
||||
<dependency>
|
||||
<groupId>org.apache.seata</groupId>
|
||||
<artifactId>seata-spring-boot-starter</artifactId>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>com.alibaba</groupId>
|
||||
<artifactId>druid</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
<!-- 三方云服务相关 -->
|
||||
<dependency>
|
||||
<groupId>me.zhyd.oauth</groupId>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.zt.plat.module.system.controller.admin.integration.iwork;
|
||||
|
||||
import com.zt.plat.framework.common.pojo.CommonResult;
|
||||
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkOperationRespVO;
|
||||
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkUserInfoReqVO;
|
||||
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkUserInfoRespVO;
|
||||
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkWorkflowCreateReqVO;
|
||||
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkWorkflowVoidReqVO;
|
||||
import com.zt.plat.module.system.service.integration.iwork.IWorkIntegrationService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import static com.zt.plat.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
* 提供统一 iWork 流程能力的管理端接口。
|
||||
*/
|
||||
@Tag(name = "管理后台 - iWork 集成")
|
||||
@RestController
|
||||
@RequestMapping("/system/integration/iwork")
|
||||
@RequiredArgsConstructor
|
||||
@Validated
|
||||
public class IWorkIntegrationController {
|
||||
|
||||
private final IWorkIntegrationService integrationService;
|
||||
|
||||
@PostMapping("/user/resolve")
|
||||
@Operation(summary = "根据外部标识获取 iWork 用户编号")
|
||||
public CommonResult<IWorkUserInfoRespVO> resolveUser(@Valid @RequestBody IWorkUserInfoReqVO reqVO) {
|
||||
return success(integrationService.resolveUserId(reqVO));
|
||||
}
|
||||
|
||||
@PostMapping("/workflow/create")
|
||||
@Operation(summary = "发起 iWork 流程")
|
||||
public CommonResult<IWorkOperationRespVO> createWorkflow(@Valid @RequestBody IWorkWorkflowCreateReqVO reqVO) {
|
||||
return success(integrationService.createWorkflow(reqVO));
|
||||
}
|
||||
|
||||
@PostMapping("/workflow/void")
|
||||
@Operation(summary = "作废 / 干预 iWork 流程")
|
||||
public CommonResult<IWorkOperationRespVO> voidWorkflow(@Valid @RequestBody IWorkWorkflowVoidReqVO reqVO) {
|
||||
return success(integrationService.voidWorkflow(reqVO));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* iWork 集成接口公用的请求字段。
|
||||
*/
|
||||
@Data
|
||||
public class IWorkBaseReqVO {
|
||||
|
||||
@Schema(description = "配置的 iWork 凭证 appId;为空时使用默认凭证", example = "iwork-app")
|
||||
private String appId;
|
||||
|
||||
@Schema(description = "iWork 操作人用户编号", example = "1")
|
||||
private String operatorUserId;
|
||||
|
||||
@Schema(description = "是否强制刷新 token", example = "false")
|
||||
private Boolean forceRefreshToken;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 传递给 iWork 的单条明细记录。
|
||||
*/
|
||||
@Data
|
||||
public class IWorkDetailRecordVO {
|
||||
|
||||
@Schema(description = "记录序号,从 0 开始", example = "0")
|
||||
private Integer recordOrder;
|
||||
|
||||
@Schema(description = "明细字段列表")
|
||||
@NotEmpty(message = "明细字段不能为空")
|
||||
@Valid
|
||||
private List<IWorkFormFieldVO> fields;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* iWork 流程请求中的明细表定义。
|
||||
*/
|
||||
@Data
|
||||
public class IWorkDetailTableVO {
|
||||
|
||||
@Schema(description = "表名", example = "formtable_main_26_dt1")
|
||||
@NotBlank(message = "明细表名不能为空")
|
||||
private String tableDBName;
|
||||
|
||||
@Schema(description = "明细记录集合")
|
||||
@NotEmpty(message = "明细记录不能为空")
|
||||
@Valid
|
||||
private List<IWorkDetailRecordVO> records;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 提交给 iWork 的单个表单字段。
|
||||
*/
|
||||
@Data
|
||||
public class IWorkFormFieldVO {
|
||||
|
||||
@Schema(description = "字段名", example = "sqr")
|
||||
@NotBlank(message = "字段名不能为空")
|
||||
private String fieldName;
|
||||
|
||||
@Schema(description = "字段值", example = "张三")
|
||||
@NotBlank(message = "字段值不能为空")
|
||||
private String fieldValue;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* iWork 流程操作的通用响应封装。
|
||||
*/
|
||||
@Data
|
||||
public class IWorkOperationRespVO {
|
||||
|
||||
@Schema(description = "iWork 返回的原始数据")
|
||||
private Map<String, Object> payload;
|
||||
|
||||
@Schema(description = "iWork 返回的原始字符串")
|
||||
private String rawBody;
|
||||
|
||||
@Schema(description = "是否判断为成功")
|
||||
private boolean success;
|
||||
|
||||
@Schema(description = "返回提示信息")
|
||||
private String message;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 获取 iWork 会话令牌的请求载荷。
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class IWorkTokenApplyReqVO extends IWorkBaseReqVO {
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 用于解析 iWork 用户编号的请求体。
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class IWorkUserInfoReqVO extends IWorkBaseReqVO {
|
||||
|
||||
@Schema(description = "用户识别字段", example = "loginid")
|
||||
@NotBlank(message = "用户识别字段不能为空")
|
||||
private String identifierKey;
|
||||
|
||||
@Schema(description = "用户识别值", example = "zhangsan")
|
||||
@NotBlank(message = "用户识别值不能为空")
|
||||
private String identifierValue;
|
||||
|
||||
@Schema(description = "额外的请求载荷,会与识别字段合并后提交")
|
||||
private Map<String, Object> payload;
|
||||
|
||||
@Schema(description = "额外的查询参数")
|
||||
private Map<String, Object> queryParams;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* iWork 用户信息查询结果。
|
||||
*/
|
||||
@Data
|
||||
public class IWorkUserInfoRespVO {
|
||||
|
||||
@Schema(description = "iWork 返回的原始数据")
|
||||
private Map<String, Object> payload;
|
||||
|
||||
@Schema(description = "iWork 返回的原始字符串")
|
||||
private String rawBody;
|
||||
|
||||
@Schema(description = "是否判断为成功")
|
||||
private boolean success;
|
||||
|
||||
@Schema(description = "返回提示信息")
|
||||
private String message;
|
||||
|
||||
@Schema(description = "解析出的 iWork 用户编号")
|
||||
private String userId;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 发起 iWork 流程的请求体。
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class IWorkWorkflowCreateReqVO extends IWorkBaseReqVO {
|
||||
|
||||
@Schema(description = "流程标题", example = "测试流程")
|
||||
@NotBlank(message = "流程标题不能为空")
|
||||
private String requestName;
|
||||
|
||||
@Schema(description = "流程模板编号,可为空使用默认配置", example = "54")
|
||||
private Long workflowId;
|
||||
|
||||
@Schema(description = "主表字段")
|
||||
@NotEmpty(message = "主表字段不能为空")
|
||||
@Valid
|
||||
private List<IWorkFormFieldVO> mainFields;
|
||||
|
||||
@Schema(description = "明细表数据")
|
||||
@Valid
|
||||
private List<IWorkDetailTableVO> detailTables;
|
||||
|
||||
@Schema(description = "额外参数")
|
||||
private Map<String, Object> otherParams;
|
||||
|
||||
@Schema(description = "额外 Form 数据")
|
||||
private Map<String, String> formExtras;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 作废 / 干预 iWork 流程的请求体。
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class IWorkWorkflowVoidReqVO extends IWorkBaseReqVO {
|
||||
|
||||
@Schema(description = "流程请求编号", example = "REQ-001")
|
||||
@NotBlank(message = "流程请求编号不能为空")
|
||||
private String requestId;
|
||||
|
||||
@Schema(description = "作废原因")
|
||||
private String reason;
|
||||
|
||||
@Schema(description = "额外参数")
|
||||
private Map<String, Object> extraParams;
|
||||
|
||||
@Schema(description = "额外 Form 数据")
|
||||
private Map<String, String> formExtras;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.zt.plat.module.system.controller.admin.sso;
|
||||
|
||||
import com.zt.plat.framework.common.pojo.CommonResult;
|
||||
import com.zt.plat.framework.tenant.core.aop.TenantIgnore;
|
||||
import com.zt.plat.module.system.controller.admin.auth.vo.AuthLoginRespVO;
|
||||
import com.zt.plat.module.system.controller.admin.sso.vo.ExternalSsoVerifyReqVO;
|
||||
import com.zt.plat.module.system.service.sso.ExternalSsoService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.security.PermitAll;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import static com.zt.plat.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
* 管理后台 - 外部单点登录接口。
|
||||
*/
|
||||
@Tag(name = "管理后台 - 外部单点登录")
|
||||
@RestController
|
||||
@RequestMapping("/system/sso")
|
||||
@Validated
|
||||
@RequiredArgsConstructor
|
||||
public class ExternalSsoController {
|
||||
|
||||
private final ExternalSsoService externalSsoService;
|
||||
|
||||
@PostMapping("/verify")
|
||||
@PermitAll
|
||||
@TenantIgnore
|
||||
@Operation(summary = "校验外部单点登录令牌")
|
||||
public CommonResult<AuthLoginRespVO> verify(@Valid @RequestBody ExternalSsoVerifyReqVO reqVO) {
|
||||
return success(externalSsoService.verifyToken(reqVO));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.zt.plat.module.system.controller.admin.sso.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 外部 SSO token 校验请求 VO。
|
||||
*/
|
||||
@Data
|
||||
public class ExternalSsoVerifyReqVO {
|
||||
|
||||
@Schema(description = "外部系统下发的单点登录 token", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank(message = "token 不能为空")
|
||||
private String token;
|
||||
|
||||
@Schema(description = "外部系统跳转时的原始地址", example = "/#/dashboard")
|
||||
private String targetUri;
|
||||
|
||||
@Schema(description = "来源系统标识", example = "partner-a")
|
||||
private String sourceSystem;
|
||||
|
||||
}
|
||||
@@ -17,6 +17,8 @@ public class UserCreateRequestVO {
|
||||
private String bimRequestId;
|
||||
@Schema(description = "用户归属部门(多个为逗号分割)", required = true)
|
||||
private String deptIds;
|
||||
@Schema(description = "所属岗位名称")
|
||||
private String postName;
|
||||
@Schema(description = "用户名")
|
||||
private String username;
|
||||
@Schema(description = "密码")
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.zt.plat.module.system.framework.integration.iwork.config;
|
||||
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* 负责加载 {@link IWorkProperties} 的自动配置类。
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(IWorkProperties.class)
|
||||
public class IWorkIntegrationConfiguration {
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.zt.plat.module.system.framework.integration.iwork.config;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
import lombok.Getter;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* iWork 集成所需的配置项。
|
||||
*/
|
||||
@Data
|
||||
@Validated
|
||||
@ConfigurationProperties(prefix = "iwork")
|
||||
public class IWorkProperties {
|
||||
|
||||
/**
|
||||
* 是否开启 iWork 集成能力。
|
||||
*/
|
||||
private boolean enabled = false;
|
||||
|
||||
/**
|
||||
* iWork 网关的基础地址。
|
||||
*/
|
||||
private String baseUrl;
|
||||
|
||||
/**
|
||||
* 当调用方未传入时使用的默认 appId。
|
||||
*/
|
||||
private String appId;
|
||||
|
||||
/**
|
||||
* 与 iWork 侧预先约定的 RSA 公钥(Base64 编码)。
|
||||
*/
|
||||
private String clientPublicKey;
|
||||
|
||||
/**
|
||||
* 当调用方未指定流程编号时使用的默认流程模板编号。
|
||||
*/
|
||||
private Long workflowId;
|
||||
|
||||
/**
|
||||
* 当请求未指定操作人时使用的默认用户编号。
|
||||
*/
|
||||
private String userId;
|
||||
|
||||
@Valid
|
||||
private final Paths paths = new Paths();
|
||||
private final Headers headers = new Headers();
|
||||
@Valid
|
||||
private final Token token = new Token();
|
||||
@Valid
|
||||
private final Client client = new Client();
|
||||
|
||||
@Data
|
||||
public static class Paths {
|
||||
/**
|
||||
* 负责交换公钥和密钥的注册接口路径。
|
||||
*/
|
||||
@NotBlank(message = "iWork 注册接口路径不能为空")
|
||||
private String register;
|
||||
/**
|
||||
* 申请访问令牌的接口路径。
|
||||
*/
|
||||
@NotBlank(message = "iWork 申请 Token 接口路径不能为空")
|
||||
private String applyToken;
|
||||
/**
|
||||
* 查询用户信息的接口路径。
|
||||
*/
|
||||
@NotBlank(message = "iWork 用户信息接口路径不能为空")
|
||||
private String userInfo;
|
||||
/**
|
||||
* 发起流程的接口路径。
|
||||
*/
|
||||
@NotBlank(message = "iWork 发起流程接口路径不能为空")
|
||||
private String createWorkflow;
|
||||
/**
|
||||
* 干预或作废流程的接口路径。
|
||||
*/
|
||||
@NotBlank(message = "iWork 作废流程接口路径不能为空")
|
||||
private String voidWorkflow;
|
||||
}
|
||||
|
||||
@Getter
|
||||
public static class Headers {
|
||||
private final String appId = "app-id";
|
||||
private final String clientPublicKey = "client-public-key";
|
||||
private final String secret = "secret";
|
||||
private final String token = "token";
|
||||
private final String time = "time";
|
||||
private final String userId = "user-id";
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Token {
|
||||
/**
|
||||
* 向 iWork 申请的 Token 有效期(单位秒)。
|
||||
*/
|
||||
@Min(value = 1, message = "iWork Token 有效期必须大于 0")
|
||||
private long ttlSeconds;
|
||||
/**
|
||||
* Token 过期前提前刷新的秒数。
|
||||
*/
|
||||
@Min(value = 0, message = "iWork Token 提前刷新秒数不能为负数")
|
||||
private long refreshAheadSeconds;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Client {
|
||||
/**
|
||||
* Reactor Netty 连接超时时间。
|
||||
*/
|
||||
@NotNull(message = "iWork 客户端连接超时时间不能为空")
|
||||
private Duration connectTimeout;
|
||||
/**
|
||||
* Reactor Netty 响应超时时间。
|
||||
*/
|
||||
@NotNull(message = "iWork 客户端响应超时时间不能为空")
|
||||
private Duration responseTimeout;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package com.zt.plat.module.system.framework.sso.config;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.cloud.context.config.annotation.RefreshScope;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 外部 SSO 相关的可配置属性。
|
||||
*
|
||||
* <p>该配置支持通过配置中心(如 Nacos)在运行期动态刷新。</p>
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "external-sso")
|
||||
@Validated
|
||||
@RefreshScope
|
||||
@Data
|
||||
public class ExternalSsoProperties {
|
||||
|
||||
/**
|
||||
* 是否开启外部 SSO 集成。
|
||||
*/
|
||||
private boolean enabled = true;
|
||||
|
||||
/**
|
||||
* 用于区分不同上游系统的业务标识。
|
||||
*/
|
||||
private String systemCode = "default";
|
||||
|
||||
@NotNull
|
||||
private TokenProperties token = new TokenProperties();
|
||||
|
||||
@NotNull
|
||||
private RemoteProperties remote = new RemoteProperties();
|
||||
|
||||
@NotNull
|
||||
private MappingProperties mapping = new MappingProperties();
|
||||
|
||||
@NotNull
|
||||
private CorsProperties cors = new CorsProperties();
|
||||
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public static class TokenProperties {
|
||||
|
||||
/**
|
||||
* 用于解密外部令牌的共享密钥。
|
||||
*/
|
||||
@NotBlank(message = "external-sso.token.secret 不能为空")
|
||||
private String secret;
|
||||
|
||||
/**
|
||||
* 加密算法,默认使用 AES。
|
||||
*/
|
||||
@NotBlank
|
||||
private String algorithm = "AES";
|
||||
|
||||
/**
|
||||
* 校验签发时间允许的最大时间偏移(秒)。
|
||||
*/
|
||||
@Min(0)
|
||||
private long allowedClockSkewSeconds = 60;
|
||||
|
||||
/**
|
||||
* 当令牌未包含过期时间时允许的最大生存时间(秒)。
|
||||
*/
|
||||
@Min(1)
|
||||
private long maxAgeSeconds = Duration.ofMinutes(5).getSeconds();
|
||||
|
||||
/**
|
||||
* 是否要求令牌中必须包含一次性随机数(nonce)。
|
||||
*/
|
||||
private boolean requireNonce = true;
|
||||
|
||||
/**
|
||||
* 是否启用基于 nonce 的重放校验。
|
||||
*/
|
||||
private boolean replayProtectionEnabled = true;
|
||||
|
||||
/**
|
||||
* nonce 在缓存中的有效期(秒)。
|
||||
*/
|
||||
@Min(1)
|
||||
private long nonceTtlSeconds = Duration.ofMinutes(10).getSeconds();
|
||||
|
||||
/**
|
||||
* 可选的签名密钥,用于验证解密后的载荷签名。
|
||||
*/
|
||||
private String signatureSecret;
|
||||
|
||||
/**
|
||||
* 签名算法,例如 HMAC-SHA256。为空表示不进行签名校验。
|
||||
*/
|
||||
private String signatureAlgorithm;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public static class RemoteProperties {
|
||||
|
||||
/**
|
||||
* 上游接口的基础地址,例如 https://partner.example.com。
|
||||
*/
|
||||
@NotBlank(message = "external-sso.remote.base-url 不能为空")
|
||||
private String baseUrl = "http://10.1.7.110";
|
||||
|
||||
/**
|
||||
* 用户信息接口路径,例如 /api/user/info。
|
||||
*/
|
||||
@NotBlank
|
||||
private String userInfoPath = "/service/S_BF_CS_01";
|
||||
|
||||
/**
|
||||
* 调用上游接口所使用的 HTTP 方法。
|
||||
*/
|
||||
@NotNull
|
||||
private HttpMethod method = HttpMethod.POST;
|
||||
|
||||
/**
|
||||
* 发送请求时附加的静态请求头。
|
||||
*/
|
||||
@NotNull
|
||||
private Map<String, String> headers = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 发送请求时附加的静态查询参数。
|
||||
*/
|
||||
@NotNull
|
||||
private Map<String, String> queryParams = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* POST/PUT 请求使用的请求体模板,支持简单占位符替换。
|
||||
*/
|
||||
@NotNull
|
||||
private Map<String, String> body = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 连接超时时间,单位毫秒。
|
||||
*/
|
||||
@Min(1)
|
||||
private int connectTimeoutMillis = (int) Duration.ofSeconds(5).toMillis();
|
||||
|
||||
/**
|
||||
* 读取超时时间,单位毫秒。
|
||||
*/
|
||||
@Min(1)
|
||||
private int readTimeoutMillis = (int) Duration.ofSeconds(10).toMillis();
|
||||
|
||||
|
||||
/**
|
||||
* 响应中表示业务状态码的字段路径。
|
||||
*/
|
||||
private String codeField = "__sys__.status";
|
||||
|
||||
/**
|
||||
* 上游系统约定的成功状态码。
|
||||
*/
|
||||
private String successCode = "1";
|
||||
|
||||
/**
|
||||
* 上游返回的提示信息字段路径。
|
||||
*/
|
||||
private String messageField = "message";
|
||||
|
||||
/**
|
||||
* 包裹实际数据载荷的字段路径。
|
||||
*/
|
||||
private String dataField = "data";
|
||||
|
||||
/** 外部用户唯一标识所在的字段路径。 */
|
||||
private String userIdField = "sub";
|
||||
}
|
||||
|
||||
public enum MatchField {
|
||||
USERNAME,
|
||||
MOBILE
|
||||
}
|
||||
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public static class MappingProperties {
|
||||
|
||||
/** 匹配策略的执行顺序。 */
|
||||
@NotEmpty
|
||||
private List<MatchField> order = new LinkedList<>(List.of(MatchField.USERNAME, MatchField.MOBILE));
|
||||
/** 当上游未提供租户信息时使用的默认租户。 */
|
||||
private Long defaultTenantId;
|
||||
/** 字符串字段比较时是否忽略大小写。 */
|
||||
private boolean ignoreCase = true;
|
||||
/** 是否在每次登录成功后同步昵称、邮箱、手机号等资料。 */
|
||||
private boolean updateProfileOnLogin = true;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public static class CorsProperties {
|
||||
/** 允许访问 SSO 校验接口的来源域名。 */
|
||||
@NotNull
|
||||
private List<String> allowedOrigins = new ArrayList<>();
|
||||
/** 允许的 HTTP 方法。 */
|
||||
@NotNull
|
||||
private List<String> allowedMethods = new ArrayList<>(List.of("OPTIONS", "GET", "POST"));
|
||||
/** 允许携带的请求头。 */
|
||||
@NotNull
|
||||
private List<String> allowedHeaders = new ArrayList<>(List.of("Authorization", "Content-Type", "X-Requested-With"));
|
||||
/** 是否允许携带凭证信息(Cookie、授权头等)。 */
|
||||
private boolean allowCredentials = true;
|
||||
/** 预检请求的缓存时长。 */
|
||||
@Min(0)
|
||||
private long maxAge = Duration.ofMinutes(30).getSeconds();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import com.zt.plat.module.system.dal.mysql.dept.DeptMapper;
|
||||
import com.zt.plat.module.system.dal.mysql.userdept.UserDeptMapper;
|
||||
import com.zt.plat.module.system.dal.redis.RedisKeyConstants;
|
||||
import com.zt.plat.module.system.enums.dept.DeptSourceEnum;
|
||||
import org.apache.seata.spring.annotation.GlobalTransactional;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
@@ -58,6 +59,8 @@ public class DeptServiceImpl implements DeptService {
|
||||
.thenComparing(DeptDO::getId, Comparator.nullsLast(Comparator.naturalOrder()));
|
||||
|
||||
@Override
|
||||
@GlobalTransactional(rollbackFor = Exception.class)
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@CacheEvict(cacheNames = RedisKeyConstants.DEPT_CHILDREN_ID_LIST,
|
||||
allEntries = true) // allEntries 清空所有缓存,因为操作一个部门,涉及到多个缓存
|
||||
public Long createDept(DeptSaveReqVO createReqVO) {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.zt.plat.module.system.service.integration.iwork;
|
||||
|
||||
import com.zt.plat.framework.common.exception.ErrorCode;
|
||||
|
||||
/**
|
||||
* iWork 集成相关的错误码常量。
|
||||
*/
|
||||
public interface IWorkIntegrationErrorCodeConstants {
|
||||
|
||||
ErrorCode IWORK_DISABLED = new ErrorCode(1_010_200_001, "iWork 集成未启用,请先完成配置");
|
||||
ErrorCode IWORK_BASE_URL_MISSING = new ErrorCode(1_010_200_002, "iWork 集成未配置网关地址");
|
||||
ErrorCode IWORK_CONFIGURATION_INVALID = new ErrorCode(1_010_200_003,
|
||||
"iWork 集成缺少必填配置(appId/clientPublicKey/userId/workflowId)");
|
||||
ErrorCode IWORK_REGISTER_FAILED = new ErrorCode(1_010_200_004, "iWork 注册授权失败");
|
||||
ErrorCode IWORK_APPLY_TOKEN_FAILED = new ErrorCode(1_010_200_005, "iWork 令牌申请失败");
|
||||
ErrorCode IWORK_REMOTE_REQUEST_FAILED = new ErrorCode(1_010_200_006, "iWork 接口请求失败");
|
||||
ErrorCode IWORK_USER_IDENTIFIER_MISSING = new ErrorCode(1_010_200_007, "缺少用户识别信息,无法调用 iWork 接口");
|
||||
ErrorCode IWORK_OPERATOR_USER_MISSING = new ErrorCode(1_010_200_008, "缺少 iWork 操作人用户编号");
|
||||
ErrorCode IWORK_WORKFLOW_ID_MISSING = new ErrorCode(1_010_200_009, "缺少 iWork 流程模板编号");
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.zt.plat.module.system.service.integration.iwork;
|
||||
|
||||
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkOperationRespVO;
|
||||
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkUserInfoReqVO;
|
||||
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkUserInfoRespVO;
|
||||
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkWorkflowCreateReqVO;
|
||||
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkWorkflowVoidReqVO;
|
||||
|
||||
/**
|
||||
* 对外暴露统一 iWork 流程能力的门面接口。
|
||||
*/
|
||||
public interface IWorkIntegrationService {
|
||||
|
||||
/**
|
||||
* 根据外部标识解析 iWork 内部用户编号。
|
||||
*/
|
||||
IWorkUserInfoRespVO resolveUserId(IWorkUserInfoReqVO reqVO);
|
||||
|
||||
/**
|
||||
* 在 iWork 中发起新流程。
|
||||
*/
|
||||
IWorkOperationRespVO createWorkflow(IWorkWorkflowCreateReqVO reqVO);
|
||||
|
||||
/**
|
||||
* 在 iWork 中对已有流程执行作废或干预。
|
||||
*/
|
||||
IWorkOperationRespVO voidWorkflow(IWorkWorkflowVoidReqVO reqVO);
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
package com.zt.plat.module.system.service.integration.iwork.impl;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import com.zt.plat.framework.common.exception.ErrorCode;
|
||||
import com.zt.plat.framework.common.exception.ServiceException;
|
||||
import com.zt.plat.framework.common.exception.util.ServiceExceptionUtil;
|
||||
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkDetailRecordVO;
|
||||
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkDetailTableVO;
|
||||
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkFormFieldVO;
|
||||
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkOperationRespVO;
|
||||
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkUserInfoReqVO;
|
||||
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkUserInfoRespVO;
|
||||
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkWorkflowCreateReqVO;
|
||||
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkWorkflowVoidReqVO;
|
||||
import com.zt.plat.module.system.framework.integration.iwork.config.IWorkProperties;
|
||||
import com.zt.plat.module.system.service.integration.iwork.IWorkIntegrationService;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.ToString;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.reactive.function.BodyInserters;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PublicKey;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import static com.zt.plat.module.system.service.integration.iwork.IWorkIntegrationErrorCodeConstants.*;
|
||||
|
||||
/**
|
||||
* {@link IWorkIntegrationService} 的默认实现,负责编排远程 iWork 调用。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class IWorkIntegrationServiceImpl implements IWorkIntegrationService {
|
||||
|
||||
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {
|
||||
};
|
||||
|
||||
private final IWorkProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final WebClient.Builder webClientBuilder;
|
||||
|
||||
private final Cache<SessionKey, IWorkSession> sessionCache = Caffeine.newBuilder()
|
||||
.maximumSize(256)
|
||||
.build();
|
||||
|
||||
private final Cache<String, PublicKey> publicKeyCache = Caffeine.newBuilder()
|
||||
.maximumSize(64)
|
||||
.build();
|
||||
|
||||
private volatile WebClient cachedWebClient;
|
||||
|
||||
@Override
|
||||
public IWorkUserInfoRespVO resolveUserId(IWorkUserInfoReqVO reqVO) {
|
||||
assertEnabled();
|
||||
String appId = resolveAppId();
|
||||
String clientPublicKey = resolveClientPublicKey();
|
||||
String operatorUserId = resolveOperatorUserId(reqVO.getOperatorUserId());
|
||||
ensureIdentifier(reqVO.getIdentifierKey(), reqVO.getIdentifierValue());
|
||||
|
||||
IWorkSession session = ensureSession(appId, clientPublicKey, operatorUserId, Boolean.TRUE.equals(reqVO.getForceRefreshToken()));
|
||||
Map<String, Object> payload = buildUserPayload(reqVO);
|
||||
String responseBody = executeJsonRequest(properties.getPaths().getUserInfo(), reqVO.getQueryParams(), appId, session, payload);
|
||||
|
||||
return buildUserInfoResponse(responseBody);
|
||||
}
|
||||
|
||||
@Override
|
||||
public IWorkOperationRespVO createWorkflow(IWorkWorkflowCreateReqVO reqVO) {
|
||||
assertEnabled();
|
||||
String appId = resolveAppId();
|
||||
String clientPublicKey = resolveClientPublicKey();
|
||||
String operatorUserId = resolveOperatorUserId(reqVO.getOperatorUserId());
|
||||
IWorkSession session = ensureSession(appId, clientPublicKey, operatorUserId, Boolean.TRUE.equals(reqVO.getForceRefreshToken()));
|
||||
|
||||
MultiValueMap<String, String> formData = buildCreateForm(reqVO);
|
||||
appendFormExtras(formData, reqVO.getFormExtras());
|
||||
String responseBody = executeFormRequest(properties.getPaths().getCreateWorkflow(), appId, session, formData);
|
||||
return buildOperationResponse(responseBody);
|
||||
}
|
||||
|
||||
@Override
|
||||
public IWorkOperationRespVO voidWorkflow(IWorkWorkflowVoidReqVO reqVO) {
|
||||
assertEnabled();
|
||||
String appId = resolveAppId();
|
||||
String clientPublicKey = resolveClientPublicKey();
|
||||
String operatorUserId = resolveOperatorUserId(reqVO.getOperatorUserId());
|
||||
if (!StringUtils.hasText(reqVO.getRequestId())) {
|
||||
throw ServiceExceptionUtil.exception(IWORK_USER_IDENTIFIER_MISSING);
|
||||
}
|
||||
IWorkSession session = ensureSession(appId, clientPublicKey, operatorUserId, Boolean.TRUE.equals(reqVO.getForceRefreshToken()));
|
||||
|
||||
MultiValueMap<String, String> formData = buildVoidForm(reqVO);
|
||||
appendFormExtras(formData, reqVO.getFormExtras());
|
||||
String responseBody = executeFormRequest(properties.getPaths().getVoidWorkflow(), appId, session, formData);
|
||||
return buildOperationResponse(responseBody);
|
||||
}
|
||||
|
||||
private void assertEnabled() {
|
||||
if (!properties.isEnabled()) {
|
||||
throw ServiceExceptionUtil.exception(IWORK_DISABLED);
|
||||
}
|
||||
if (!StringUtils.hasText(properties.getBaseUrl())) {
|
||||
throw ServiceExceptionUtil.exception(IWORK_BASE_URL_MISSING);
|
||||
}
|
||||
if (!StringUtils.hasText(properties.getAppId()) || !StringUtils.hasText(properties.getClientPublicKey())) {
|
||||
throw ServiceExceptionUtil.exception(IWORK_CONFIGURATION_INVALID);
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveOperatorUserId(String requestValue) {
|
||||
if (StringUtils.hasText(requestValue)) {
|
||||
return requestValue.trim();
|
||||
}
|
||||
if (StringUtils.hasText(properties.getUserId())) {
|
||||
return properties.getUserId().trim();
|
||||
}
|
||||
throw ServiceExceptionUtil.exception(IWORK_OPERATOR_USER_MISSING);
|
||||
}
|
||||
|
||||
private String resolveAppId() {
|
||||
String value = properties.getAppId();
|
||||
if (!StringUtils.hasText(value)) {
|
||||
throw ServiceExceptionUtil.exception(IWORK_CONFIGURATION_INVALID);
|
||||
}
|
||||
return StringUtils.trimWhitespace(value);
|
||||
}
|
||||
|
||||
private String resolveClientPublicKey() {
|
||||
String value = properties.getClientPublicKey();
|
||||
if (!StringUtils.hasText(value)) {
|
||||
throw ServiceExceptionUtil.exception(IWORK_CONFIGURATION_INVALID);
|
||||
}
|
||||
return StringUtils.trimWhitespace(value);
|
||||
}
|
||||
|
||||
private void ensureIdentifier(String identifierKey, String identifierValue) {
|
||||
if (!StringUtils.hasText(identifierKey) || !StringUtils.hasText(identifierValue)) {
|
||||
throw ServiceExceptionUtil.exception(IWORK_USER_IDENTIFIER_MISSING);
|
||||
}
|
||||
}
|
||||
|
||||
private IWorkSession ensureSession(String appId, String clientPublicKey, String operatorUserId, boolean forceRefresh) {
|
||||
SessionKey key = new SessionKey(appId, operatorUserId);
|
||||
Instant now = Instant.now();
|
||||
if (!forceRefresh) {
|
||||
IWorkSession cached = sessionCache.getIfPresent(key);
|
||||
if (cached != null && cached.isValid(now, properties.getToken().getRefreshAheadSeconds())) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
synchronized (key.intern()) {
|
||||
IWorkSession cached = sessionCache.getIfPresent(key);
|
||||
if (!forceRefresh && cached != null && cached.isValid(now, properties.getToken().getRefreshAheadSeconds())) {
|
||||
return cached;
|
||||
}
|
||||
IWorkSession session = createSession(appId, clientPublicKey, operatorUserId);
|
||||
sessionCache.put(key, session);
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
private IWorkSession createSession(String appId, String clientPublicKey, String operatorUserId) {
|
||||
RegistrationResult registration = register(appId, clientPublicKey);
|
||||
String encryptedSecret = encryptWithPublicKey(registration.secret(), registration.spk());
|
||||
String encryptedUserId = encryptWithPublicKey(operatorUserId, registration.spk());
|
||||
String token = applyToken(appId, encryptedSecret);
|
||||
Instant expiresAt = Instant.now().plusSeconds(Math.max(1L, properties.getToken().getTtlSeconds()));
|
||||
return new IWorkSession(token, encryptedUserId, expiresAt, registration.spk());
|
||||
}
|
||||
|
||||
private RegistrationResult register(String appId, String clientPublicKey) {
|
||||
String responseBody;
|
||||
try {
|
||||
responseBody = webClient()
|
||||
.post()
|
||||
.uri(properties.getPaths().getRegister())
|
||||
.headers(headers -> {
|
||||
headers.set(properties.getHeaders().getAppId(), appId);
|
||||
headers.set(properties.getHeaders().getClientPublicKey(), clientPublicKey);
|
||||
})
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block();
|
||||
} catch (WebClientResponseException ex) {
|
||||
log.error("[iWork] register failed. status={}, body={}", ex.getStatusCode(), ex.getResponseBodyAsString(), ex);
|
||||
throw ServiceExceptionUtil.exception(IWORK_REGISTER_FAILED, ex.getStatusCode().value(), ex.getResponseBodyAsString());
|
||||
} catch (Exception ex) {
|
||||
log.error("[iWork] register failed", ex);
|
||||
throw ServiceExceptionUtil.exception(IWORK_REGISTER_FAILED, ex.getMessage());
|
||||
}
|
||||
JsonNode node = parseJson(responseBody, IWORK_REGISTER_FAILED);
|
||||
String secret = textValue(node, "secret");
|
||||
String spk = textValue(node, "spk");
|
||||
if (!StringUtils.hasText(secret) || !StringUtils.hasText(spk)) {
|
||||
throw ServiceExceptionUtil.exception(IWORK_REGISTER_FAILED, "返回缺少 secret 或 spk");
|
||||
}
|
||||
return new RegistrationResult(secret, spk);
|
||||
}
|
||||
|
||||
private String applyToken(String appId, String encryptedSecret) {
|
||||
String responseBody;
|
||||
try {
|
||||
responseBody = webClient()
|
||||
.post()
|
||||
.uri(properties.getPaths().getApplyToken())
|
||||
.headers(headers -> {
|
||||
headers.set(properties.getHeaders().getAppId(), appId);
|
||||
headers.set(properties.getHeaders().getSecret(), encryptedSecret);
|
||||
headers.set(properties.getHeaders().getTime(), String.valueOf(properties.getToken().getTtlSeconds()));
|
||||
})
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block();
|
||||
} catch (WebClientResponseException ex) {
|
||||
log.error("[iWork] apply token failed. status={}, body={}", ex.getStatusCode(), ex.getResponseBodyAsString(), ex);
|
||||
throw ServiceExceptionUtil.exception(IWORK_APPLY_TOKEN_FAILED, ex.getStatusCode().value(), ex.getResponseBodyAsString());
|
||||
} catch (Exception ex) {
|
||||
log.error("[iWork] apply token failed", ex);
|
||||
throw ServiceExceptionUtil.exception(IWORK_APPLY_TOKEN_FAILED, ex.getMessage());
|
||||
}
|
||||
JsonNode node = parseJson(responseBody, IWORK_APPLY_TOKEN_FAILED);
|
||||
String token = textValue(node, "token");
|
||||
if (!StringUtils.hasText(token)) {
|
||||
throw ServiceExceptionUtil.exception(IWORK_APPLY_TOKEN_FAILED, "返回缺少 token");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
private String executeJsonRequest(String path,
|
||||
Map<String, Object> queryParams,
|
||||
String appId,
|
||||
IWorkSession session,
|
||||
Map<String, Object> payload) {
|
||||
try {
|
||||
return webClient()
|
||||
.post()
|
||||
.uri(uriBuilder -> {
|
||||
uriBuilder.path(path);
|
||||
if (queryParams != null) {
|
||||
queryParams.forEach((key, value) -> {
|
||||
if (value != null) {
|
||||
uriBuilder.queryParam(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
return uriBuilder.build();
|
||||
})
|
||||
.headers(headers -> setAuthHeaders(headers, appId, session))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(payload == null ? Collections.emptyMap() : payload)
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block();
|
||||
} catch (WebClientResponseException ex) {
|
||||
log.error("[iWork] request {} failed. status={}, body={}", path, ex.getStatusCode(), ex.getResponseBodyAsString(), ex);
|
||||
throw ServiceExceptionUtil.exception(IWORK_REMOTE_REQUEST_FAILED, ex.getStatusCode().value(), ex.getResponseBodyAsString());
|
||||
} catch (Exception ex) {
|
||||
log.error("[iWork] request {} failed", path, ex);
|
||||
throw ServiceExceptionUtil.exception(IWORK_REMOTE_REQUEST_FAILED, ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String executeFormRequest(String path,
|
||||
String appId,
|
||||
IWorkSession session,
|
||||
MultiValueMap<String, String> formData) {
|
||||
try {
|
||||
return webClient()
|
||||
.post()
|
||||
.uri(path)
|
||||
.headers(headers -> setAuthHeaders(headers, appId, session))
|
||||
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
.body(BodyInserters.fromFormData(formData))
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block();
|
||||
} catch (WebClientResponseException ex) {
|
||||
log.error("[iWork] form request {} failed. status={}, body={}", path, ex.getStatusCode(), ex.getResponseBodyAsString(), ex);
|
||||
throw ServiceExceptionUtil.exception(IWORK_REMOTE_REQUEST_FAILED, ex.getStatusCode().value(), ex.getResponseBodyAsString());
|
||||
} catch (Exception ex) {
|
||||
log.error("[iWork] form request {} failed", path, ex);
|
||||
throw ServiceExceptionUtil.exception(IWORK_REMOTE_REQUEST_FAILED, ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void setAuthHeaders(org.springframework.http.HttpHeaders headers,
|
||||
String appId,
|
||||
IWorkSession session) {
|
||||
headers.set(properties.getHeaders().getAppId(), appId);
|
||||
headers.set(properties.getHeaders().getToken(), session.getToken());
|
||||
headers.set(properties.getHeaders().getUserId(), session.getEncryptedUserId());
|
||||
}
|
||||
|
||||
private Map<String, Object> buildUserPayload(IWorkUserInfoReqVO reqVO) {
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
if (reqVO.getPayload() != null) {
|
||||
payload.putAll(reqVO.getPayload());
|
||||
}
|
||||
payload.put(reqVO.getIdentifierKey(), reqVO.getIdentifierValue());
|
||||
return payload;
|
||||
}
|
||||
|
||||
private MultiValueMap<String, String> buildCreateForm(IWorkWorkflowCreateReqVO reqVO) {
|
||||
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
|
||||
formData.add("requestName", reqVO.getRequestName());
|
||||
formData.add("workflowId", String.valueOf(resolveWorkflowId(reqVO.getWorkflowId())));
|
||||
formData.add("mainData", toJsonString(convertFormFields(reqVO.getMainFields())));
|
||||
if (reqVO.getDetailTables() != null && !reqVO.getDetailTables().isEmpty()) {
|
||||
formData.add("detailData", toJsonString(convertDetailTables(reqVO.getDetailTables())));
|
||||
}
|
||||
if (reqVO.getOtherParams() != null && !reqVO.getOtherParams().isEmpty()) {
|
||||
formData.add("otherParams", toJsonString(reqVO.getOtherParams()));
|
||||
}
|
||||
return formData;
|
||||
}
|
||||
|
||||
private long resolveWorkflowId(Long requestWorkflowId) {
|
||||
if (requestWorkflowId != null) {
|
||||
return requestWorkflowId;
|
||||
}
|
||||
if (properties.getWorkflowId() != null) {
|
||||
return properties.getWorkflowId();
|
||||
}
|
||||
throw ServiceExceptionUtil.exception(IWORK_WORKFLOW_ID_MISSING);
|
||||
}
|
||||
|
||||
private MultiValueMap<String, String> buildVoidForm(IWorkWorkflowVoidReqVO reqVO) {
|
||||
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
|
||||
formData.add("requestId", reqVO.getRequestId());
|
||||
if (StringUtils.hasText(reqVO.getReason())) {
|
||||
formData.add("remark", reqVO.getReason());
|
||||
}
|
||||
if (reqVO.getExtraParams() != null && !reqVO.getExtraParams().isEmpty()) {
|
||||
reqVO.getExtraParams().forEach((key, value) -> {
|
||||
if (value != null) {
|
||||
formData.add(key, String.valueOf(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
return formData;
|
||||
}
|
||||
|
||||
private void appendFormExtras(MultiValueMap<String, String> formData, Map<String, String> extras) {
|
||||
if (extras == null || extras.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
extras.forEach((key, value) -> {
|
||||
if (StringUtils.hasText(key) && value != null) {
|
||||
formData.add(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> convertFormFields(List<IWorkFormFieldVO> fields) {
|
||||
return fields.stream().map(field -> {
|
||||
Map<String, Object> map = new HashMap<>(2);
|
||||
map.put("fieldName", field.getFieldName());
|
||||
map.put("fieldValue", field.getFieldValue());
|
||||
return map;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> convertDetailTables(List<IWorkDetailTableVO> tables) {
|
||||
return tables.stream().map(table -> {
|
||||
Map<String, Object> tableMap = new HashMap<>(2);
|
||||
tableMap.put("tableDBName", table.getTableDBName());
|
||||
List<Map<String, Object>> records = table.getRecords().stream().map(record -> {
|
||||
Map<String, Object> recordMap = new HashMap<>(2);
|
||||
if (record.getRecordOrder() != null) {
|
||||
recordMap.put("recordOrder", record.getRecordOrder());
|
||||
}
|
||||
recordMap.put("workflowRequestTableFields", convertFormFields(record.getFields()));
|
||||
return recordMap;
|
||||
}).toList();
|
||||
tableMap.put("workflowRequestTableRecords", records);
|
||||
return tableMap;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
private IWorkUserInfoRespVO buildUserInfoResponse(String responseBody) {
|
||||
IWorkUserInfoRespVO respVO = new IWorkUserInfoRespVO();
|
||||
respVO.setRawBody(responseBody);
|
||||
if (!StringUtils.hasText(responseBody)) {
|
||||
return respVO;
|
||||
}
|
||||
JsonNode node = parseJson(responseBody, IWORK_REMOTE_REQUEST_FAILED);
|
||||
Map<String, Object> payload = objectMapper.convertValue(node, MAP_TYPE);
|
||||
respVO.setPayload(payload);
|
||||
respVO.setSuccess(isSuccess(node));
|
||||
respVO.setMessage(resolveMessage(node));
|
||||
respVO.setUserId(extractUserId(node));
|
||||
return respVO;
|
||||
}
|
||||
|
||||
private IWorkOperationRespVO buildOperationResponse(String responseBody) {
|
||||
IWorkOperationRespVO respVO = new IWorkOperationRespVO();
|
||||
respVO.setRawBody(responseBody);
|
||||
if (!StringUtils.hasText(responseBody)) {
|
||||
return respVO;
|
||||
}
|
||||
JsonNode node = parseJson(responseBody, IWORK_REMOTE_REQUEST_FAILED);
|
||||
respVO.setPayload(objectMapper.convertValue(node, MAP_TYPE));
|
||||
respVO.setSuccess(isSuccess(node));
|
||||
respVO.setMessage(resolveMessage(node));
|
||||
return respVO;
|
||||
}
|
||||
|
||||
private boolean isSuccess(JsonNode node) {
|
||||
if (node == null) {
|
||||
return false;
|
||||
}
|
||||
return checkSuccessByField(node, "code")
|
||||
|| checkSuccessByField(node, "status")
|
||||
|| checkSuccessByField(node, "success")
|
||||
|| checkSuccessByField(node, "errno");
|
||||
}
|
||||
|
||||
private boolean checkSuccessByField(JsonNode node, String field) {
|
||||
if (!node.has(field)) {
|
||||
return false;
|
||||
}
|
||||
JsonNode value = node.get(field);
|
||||
if (value.isBoolean()) {
|
||||
return value.booleanValue();
|
||||
}
|
||||
String text = value.asText();
|
||||
return Objects.equals("0", text) || Objects.equals("1", text) || Objects.equals("success", text);
|
||||
}
|
||||
|
||||
private String resolveMessage(JsonNode node) {
|
||||
if (node == null) {
|
||||
return null;
|
||||
}
|
||||
if (node.has("msg")) {
|
||||
return node.get("msg").asText();
|
||||
}
|
||||
if (node.has("message")) {
|
||||
return node.get("message").asText();
|
||||
}
|
||||
if (node.has("errmsg")) {
|
||||
return node.get("errmsg").asText();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String extractUserId(JsonNode node) {
|
||||
if (node == null) {
|
||||
return null;
|
||||
}
|
||||
if (node.has("userid")) {
|
||||
return node.get("userid").asText();
|
||||
}
|
||||
if (node.has("userId")) {
|
||||
return node.get("userId").asText();
|
||||
}
|
||||
if (node.has("data")) {
|
||||
JsonNode data = node.get("data");
|
||||
if (data.has("userid")) {
|
||||
return data.get("userid").asText();
|
||||
}
|
||||
if (data.has("userId")) {
|
||||
return data.get("userId").asText();
|
||||
}
|
||||
if (data.isArray() && data.size() > 0) {
|
||||
JsonNode first = data.get(0);
|
||||
if (first.has("userid")) {
|
||||
return first.get("userid").asText();
|
||||
}
|
||||
if (first.has("userId")) {
|
||||
return first.get("userId").asText();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private JsonNode parseJson(String responseBody, ErrorCode errorCode) {
|
||||
try {
|
||||
return objectMapper.readTree(responseBody);
|
||||
} catch (JsonProcessingException ex) {
|
||||
log.error("[iWork] failed to parse JSON body: {}", responseBody, ex);
|
||||
throw ServiceExceptionUtil.exception(errorCode, "响应不是合法 JSON");
|
||||
}
|
||||
}
|
||||
|
||||
private String textValue(JsonNode node, String fieldName) {
|
||||
return node != null && node.has(fieldName) ? node.get(fieldName).asText() : null;
|
||||
}
|
||||
|
||||
private String toJsonString(Object payload) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(payload);
|
||||
} catch (JsonProcessingException ex) {
|
||||
throw new ServiceException(IWORK_REMOTE_REQUEST_FAILED.getCode(), "序列化 JSON 失败: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String encryptWithPublicKey(String plainText, String base64PublicKey) {
|
||||
if (!StringUtils.hasText(plainText)) {
|
||||
return plainText;
|
||||
}
|
||||
try {
|
||||
PublicKey publicKey = publicKeyCache.get(base64PublicKey, this::loadPublicKey);
|
||||
Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
|
||||
byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString(encrypted);
|
||||
} catch (Exception ex) {
|
||||
log.error("[iWork] RSA encryption failed", ex);
|
||||
throw new ServiceException(IWORK_REMOTE_REQUEST_FAILED.getCode(), "RSA 加密失败: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private PublicKey loadPublicKey(String base64PublicKey) {
|
||||
try {
|
||||
byte[] decoded = Base64.getDecoder().decode(base64PublicKey);
|
||||
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(decoded);
|
||||
return KeyFactory.getInstance("RSA").generatePublic(keySpec);
|
||||
} catch (Exception ex) {
|
||||
throw new ServiceException(IWORK_REMOTE_REQUEST_FAILED.getCode(), "加载公钥失败: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private WebClient webClient() {
|
||||
WebClient client = cachedWebClient;
|
||||
if (client != null) {
|
||||
return client;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (cachedWebClient == null) {
|
||||
cachedWebClient = buildWebClient();
|
||||
}
|
||||
return cachedWebClient;
|
||||
}
|
||||
}
|
||||
|
||||
private WebClient buildWebClient() {
|
||||
WebClient.Builder builder = cloneBuilder();
|
||||
builder.baseUrl(properties.getBaseUrl());
|
||||
IWorkProperties.Client clientProps = properties.getClient();
|
||||
if (clientProps != null) {
|
||||
Duration responseTimeout = clientProps.getResponseTimeout();
|
||||
if (responseTimeout != null) {
|
||||
builder.filter((request, next) -> next.exchange(request).timeout(responseTimeout));
|
||||
}
|
||||
// 连接超时时间由全局的 HttpClient 自定义器统一配置(若存在)。
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private WebClient.Builder cloneBuilder() {
|
||||
try {
|
||||
return webClientBuilder.clone();
|
||||
} catch (UnsupportedOperationException ex) {
|
||||
return WebClient.builder();
|
||||
}
|
||||
}
|
||||
|
||||
private record RegistrationResult(String secret, String spk) {
|
||||
}
|
||||
|
||||
@Getter
|
||||
private static final class IWorkSession {
|
||||
private final String token;
|
||||
private final String encryptedUserId;
|
||||
private final Instant expiresAt;
|
||||
private final String spk;
|
||||
|
||||
private IWorkSession(String token, String encryptedUserId, Instant expiresAt, String spk) {
|
||||
this.token = token;
|
||||
this.encryptedUserId = encryptedUserId;
|
||||
this.expiresAt = expiresAt;
|
||||
this.spk = spk;
|
||||
}
|
||||
|
||||
private boolean isValid(Instant now, long refreshAheadSeconds) {
|
||||
Instant refreshThreshold = expiresAt.minusSeconds(Math.max(0L, refreshAheadSeconds));
|
||||
return refreshThreshold.isAfter(now) && StringUtils.hasText(token) && StringUtils.hasText(encryptedUserId);
|
||||
}
|
||||
}
|
||||
|
||||
@ToString
|
||||
private static final class SessionKey {
|
||||
private final String appId;
|
||||
private final String operatorUserId;
|
||||
|
||||
private SessionKey(String appId, String operatorUserId) {
|
||||
this.appId = appId;
|
||||
this.operatorUserId = operatorUserId;
|
||||
}
|
||||
|
||||
private String cacheKey() {
|
||||
return appId + "::" + operatorUserId;
|
||||
}
|
||||
|
||||
private String intern() {
|
||||
return cacheKey().intern();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (!(o instanceof SessionKey that)) {
|
||||
return false;
|
||||
}
|
||||
return Objects.equals(appId, that.appId)
|
||||
&& Objects.equals(operatorUserId, that.operatorUserId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(appId, operatorUserId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.collection.CollectionUtil;
|
||||
import cn.hutool.core.util.ArrayUtil;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import com.baomidou.dynamic.datasource.annotation.DSTransactional;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.common.base.Suppliers;
|
||||
import com.google.common.collect.Sets;
|
||||
import com.zt.plat.framework.common.biz.system.permission.dto.DeptDataPermissionRespDTO;
|
||||
import com.zt.plat.framework.common.enums.CommonStatusEnum;
|
||||
import com.zt.plat.framework.common.util.collection.CollectionUtils;
|
||||
@@ -23,16 +27,13 @@ import com.zt.plat.module.system.enums.permission.RoleTypeEnum;
|
||||
import com.zt.plat.module.system.service.dept.DeptService;
|
||||
import com.zt.plat.module.system.service.user.AdminUserService;
|
||||
import com.zt.plat.module.system.service.userdept.UserDeptService;
|
||||
import com.baomidou.dynamic.datasource.annotation.DSTransactional;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.common.base.Suppliers;
|
||||
import com.google.common.collect.Sets;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.cache.annotation.Caching;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -61,7 +62,6 @@ public class PermissionServiceImpl implements PermissionService {
|
||||
@Resource
|
||||
private UserRoleMapper userRoleMapper;
|
||||
|
||||
@Resource
|
||||
private RoleService roleService;
|
||||
@Resource
|
||||
private MenuService menuService;
|
||||
@@ -73,8 +73,11 @@ public class PermissionServiceImpl implements PermissionService {
|
||||
private RoleMenuExclusionMapper roleMenuExclusionMapper;
|
||||
@Resource
|
||||
private UserDeptService userDeptService;
|
||||
|
||||
@Autowired
|
||||
private PermissionService permissionService;
|
||||
public void setRoleService(@Lazy RoleService roleService) {
|
||||
this.roleService = roleService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasAnyPermissions(Long userId, String... permissions) {
|
||||
@@ -320,7 +323,7 @@ public class PermissionServiceImpl implements PermissionService {
|
||||
@Override
|
||||
public void assignRoleDataScope(Long roleId, Integer dataScope, Set<Long> dataScopeDeptIds) {
|
||||
RoleDO role = roleService.getRole(roleId);
|
||||
Set<Long> userRoleIdListByUserId = permissionService.getUserRoleIdListByUserId(getLoginUserId());
|
||||
Set<Long> userRoleIdListByUserId = getSelf().getUserRoleIdListByUserId(getLoginUserId());
|
||||
// 如果为标准角色,只允许管理员修改数据权限
|
||||
if (RoleTypeEnum.NORMAL.getType().equals(role.getType()) && !roleService.hasAnySuperAdmin(userRoleIdListByUserId)) {
|
||||
throw exception(ROLE_CAN_NOT_UPDATE_NORMAL_TYPE_ROLE);
|
||||
|
||||
@@ -29,6 +29,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -52,7 +53,6 @@ import static com.zt.plat.module.system.enums.LogRecordConstants.*;
|
||||
@Slf4j
|
||||
public class RoleServiceImpl implements RoleService {
|
||||
|
||||
@Resource
|
||||
private PermissionService permissionService;
|
||||
|
||||
@Resource
|
||||
@@ -60,6 +60,11 @@ public class RoleServiceImpl implements RoleService {
|
||||
@Autowired
|
||||
private UserRoleMapper userRoleMapper;
|
||||
|
||||
@Autowired
|
||||
public void setPermissionService(@Lazy PermissionService permissionService) {
|
||||
this.permissionService = permissionService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@LogRecord(type = SYSTEM_ROLE_TYPE, subType = SYSTEM_ROLE_CREATE_SUB_TYPE, bizNo = "{{#role.id}}",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.zt.plat.module.system.service.sso;
|
||||
|
||||
import com.zt.plat.module.system.controller.admin.auth.vo.AuthLoginRespVO;
|
||||
import com.zt.plat.module.system.controller.admin.sso.vo.ExternalSsoVerifyReqVO;
|
||||
|
||||
/**
|
||||
* 处理外部单点登录校验的业务接口。
|
||||
*/
|
||||
public interface ExternalSsoService {
|
||||
|
||||
/**
|
||||
* 校验外部单点登录令牌并返回本地登录凭证。
|
||||
*
|
||||
* @param reqVO 校验请求参数
|
||||
* @return 登录凭证信息
|
||||
*/
|
||||
AuthLoginRespVO verifyToken(ExternalSsoVerifyReqVO reqVO);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package com.zt.plat.module.system.service.sso;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.zt.plat.framework.common.biz.system.logger.dto.OperateLogCreateReqDTO;
|
||||
import com.zt.plat.framework.common.enums.CommonStatusEnum;
|
||||
import com.zt.plat.framework.common.enums.UserTypeEnum;
|
||||
import com.zt.plat.framework.common.exception.ErrorCode;
|
||||
import com.zt.plat.framework.common.exception.ServiceException;
|
||||
import com.zt.plat.framework.common.util.json.JsonUtils;
|
||||
import com.zt.plat.framework.common.util.monitor.TracerUtils;
|
||||
import com.zt.plat.framework.common.util.servlet.ServletUtils;
|
||||
import com.zt.plat.framework.tenant.core.util.TenantUtils;
|
||||
import com.zt.plat.module.system.api.logger.dto.LoginLogCreateReqDTO;
|
||||
import com.zt.plat.module.system.controller.admin.auth.vo.AuthLoginRespVO;
|
||||
import com.zt.plat.module.system.controller.admin.sso.vo.ExternalSsoVerifyReqVO;
|
||||
import com.zt.plat.module.system.convert.auth.AuthConvert;
|
||||
import com.zt.plat.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
|
||||
import com.zt.plat.module.system.dal.dataobject.user.AdminUserDO;
|
||||
import com.zt.plat.module.system.enums.logger.LoginLogTypeEnum;
|
||||
import com.zt.plat.module.system.enums.logger.LoginResultEnum;
|
||||
import com.zt.plat.module.system.enums.oauth2.OAuth2ClientConstants;
|
||||
import com.zt.plat.module.system.framework.sso.config.ExternalSsoProperties;
|
||||
import com.zt.plat.module.system.service.logger.LoginLogService;
|
||||
import com.zt.plat.module.system.service.logger.OperateLogService;
|
||||
import com.zt.plat.module.system.service.oauth2.OAuth2TokenService;
|
||||
import com.zt.plat.module.system.service.sso.client.ExternalSsoClientException;
|
||||
import com.zt.plat.module.system.service.sso.dto.ExternalSsoUserInfo;
|
||||
import com.zt.plat.module.system.service.sso.strategy.ExternalSsoStrategy;
|
||||
import com.zt.plat.module.system.service.user.AdminUserService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static com.zt.plat.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static com.zt.plat.module.system.enums.ErrorCodeConstants.*;
|
||||
|
||||
/**
|
||||
* {@link ExternalSsoService} 的默认实现。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ExternalSsoServiceImpl implements ExternalSsoService {
|
||||
|
||||
private final ExternalSsoProperties properties;
|
||||
private final List<ExternalSsoStrategy> strategies;
|
||||
private final AdminUserService adminUserService;
|
||||
private final LoginLogService loginLogService;
|
||||
private final OAuth2TokenService oauth2TokenService;
|
||||
private final OperateLogService operateLogService;
|
||||
|
||||
@Override
|
||||
public AuthLoginRespVO verifyToken(ExternalSsoVerifyReqVO reqVO) {
|
||||
// 步骤一:检查开关并校验令牌参数
|
||||
if (!properties.isEnabled()) {
|
||||
throw fail(EXTERNAL_SSO_DISABLED, LoginResultEnum.TOKEN_INVALID, null, null);
|
||||
}
|
||||
if (!StringUtils.hasText(reqVO.getToken())) {
|
||||
throw fail(EXTERNAL_SSO_TOKEN_MISSING, LoginResultEnum.TOKEN_INVALID, null, null);
|
||||
}
|
||||
String normalizedSourceSystem = normalizeSourceSystem(reqVO.getSourceSystem());
|
||||
reqVO.setSourceSystem(normalizedSourceSystem);
|
||||
|
||||
ExternalSsoStrategy strategy = selectStrategy(normalizedSourceSystem);
|
||||
if (strategy == null) {
|
||||
throw fail(EXTERNAL_SSO_SOURCE_UNSUPPORTED, LoginResultEnum.REMOTE_SERVICE_ERROR,
|
||||
null, null, normalizedSourceSystem);
|
||||
}
|
||||
|
||||
// 步骤二:调用外部接口查询用户资料
|
||||
ExternalSsoUserInfo externalUser = fetchExternalUser(strategy, reqVO);
|
||||
// 步骤三:匹配本地账号
|
||||
AdminUserDO user = resolveLocalUser(strategy, externalUser, reqVO);
|
||||
ensureUserEnabled(user);
|
||||
// 步骤四:发放本地登录凭证并记审计日志
|
||||
AuthLoginRespVO respVO = issueLoginToken(user);
|
||||
recordAuditLog(user, reqVO, externalUser, reqVO.getToken());
|
||||
return respVO;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 调用外部系统获取用户信息,并兜底补充外部用户标识。
|
||||
*/
|
||||
private ExternalSsoUserInfo fetchExternalUser(ExternalSsoStrategy strategy, ExternalSsoVerifyReqVO reqVO) {
|
||||
try {
|
||||
return strategy.fetchExternalUser(reqVO);
|
||||
} catch (ExternalSsoClientException ex) {
|
||||
log.warn("拉取外部用户信息失败: {}", ex.getMessage());
|
||||
throw fail(EXTERNAL_SSO_REMOTE_ERROR, LoginResultEnum.REMOTE_SERVICE_ERROR, null,
|
||||
ex, ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private AdminUserDO resolveLocalUser(ExternalSsoStrategy strategy, ExternalSsoUserInfo externalUser,
|
||||
ExternalSsoVerifyReqVO reqVO) {
|
||||
AdminUserDO user = strategy.resolveLocalUser(externalUser, reqVO);
|
||||
if (user != null) {
|
||||
return user;
|
||||
}
|
||||
return handleMissingUser();
|
||||
}
|
||||
|
||||
/**
|
||||
* 当无法匹配到本地账号时直接返回错误,不再自动建号。
|
||||
*/
|
||||
private AdminUserDO handleMissingUser() {
|
||||
// 明确禁止自动建号,统一返回用户不存在
|
||||
throw fail(EXTERNAL_SSO_USER_NOT_FOUND, LoginResultEnum.USER_NOT_FOUND, null,
|
||||
null);
|
||||
}
|
||||
|
||||
private void ensureUserEnabled(AdminUserDO user) {
|
||||
if (user == null) {
|
||||
throw fail(EXTERNAL_SSO_USER_NOT_FOUND, LoginResultEnum.USER_NOT_FOUND, null,
|
||||
null);
|
||||
}
|
||||
if (CommonStatusEnum.isDisable(user.getStatus())) {
|
||||
throw fail(EXTERNAL_SSO_USER_DISABLED, LoginResultEnum.USER_DISABLED, user,
|
||||
null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为已通过校验的账号创建访问令牌并返回。
|
||||
*/
|
||||
private AuthLoginRespVO issueLoginToken(AdminUserDO user) {
|
||||
recordLoginLog(user.getId(), user.getUsername(), LoginResultEnum.SUCCESS);
|
||||
OAuth2AccessTokenDO token = TenantUtils.execute(user.getTenantId(),
|
||||
() -> oauth2TokenService.createAccessToken(user.getId(), UserTypeEnum.ADMIN.getValue(),
|
||||
OAuth2ClientConstants.CLIENT_ID_DEFAULT, null));
|
||||
return AuthConvert.INSTANCE.convert(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一次外部单点登录的审计日志,便于后续追踪。
|
||||
*/
|
||||
private void recordAuditLog(AdminUserDO user, ExternalSsoVerifyReqVO request,
|
||||
ExternalSsoUserInfo externalUser, String token) {
|
||||
try {
|
||||
Map<String, Object> extra = new LinkedHashMap<>();
|
||||
extra.put("externalUsername", externalUser.getUsername());
|
||||
extra.put("externalNickname", externalUser.getNickname());
|
||||
// extra.put("externalAttributes", attributes);
|
||||
extra.put("localUserId", user.getId());
|
||||
extra.put("localUsername", user.getUsername());
|
||||
// extra.put("tenantId", user.getTenantId());
|
||||
extra.put("targetUri", request.getTargetUri());
|
||||
extra.put("sourceSystem", request.getSourceSystem());
|
||||
|
||||
OperateLogCreateReqDTO dto = new OperateLogCreateReqDTO();
|
||||
dto.setTraceId(TracerUtils.getTraceId());
|
||||
dto.setUserId(user.getId());
|
||||
dto.setUserType(UserTypeEnum.ADMIN.getValue());
|
||||
dto.setType("EXTERNAL_SSO");
|
||||
dto.setSubType("VERIFY");
|
||||
dto.setBizId(user.getId());
|
||||
String externalPrincipal = StringUtils.hasText(externalUser.getUsername())
|
||||
? externalUser.getUsername()
|
||||
: "";
|
||||
dto.setAction(StrUtil.format("外部单点登录成功: {} -> {}", externalPrincipal, user.getUsername()));
|
||||
dto.setExtra(JsonUtils.toJsonString(extra));
|
||||
dto.setRequestMethod("POST");
|
||||
dto.setRequestUrl("/system/sso/verify");
|
||||
dto.setUserIp(ServletUtils.getClientIP());
|
||||
dto.setUserAgent(ServletUtils.getUserAgent());
|
||||
operateLogService.createOperateLog(dto);
|
||||
} catch (Exception ex) {
|
||||
log.warn("记录外部 SSO 审计日志失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void recordLoginLog(Long userId, String username, LoginResultEnum result) {
|
||||
LoginLogCreateReqDTO dto = new LoginLogCreateReqDTO();
|
||||
dto.setLogType(LoginLogTypeEnum.LOGIN_EXTERNAL_SSO.getType());
|
||||
dto.setTraceId(TracerUtils.getTraceId());
|
||||
dto.setUserId(userId);
|
||||
dto.setUserType(UserTypeEnum.ADMIN.getValue());
|
||||
dto.setUsername(username);
|
||||
dto.setResult(result.getResult());
|
||||
dto.setUserIp(ServletUtils.getClientIP());
|
||||
dto.setUserAgent(ServletUtils.getUserAgent());
|
||||
loginLogService.createLoginLog(dto);
|
||||
if (userId != null && result == LoginResultEnum.SUCCESS) {
|
||||
adminUserService.updateUserLogin(userId, ServletUtils.getClientIP());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造业务异常并同步记录登录日志。
|
||||
*/
|
||||
private ServiceException fail(ErrorCode errorCode, LoginResultEnum result, AdminUserDO user,
|
||||
Throwable cause, Object... args) {
|
||||
Long userId = user != null ? user.getId() : null;
|
||||
String username = user != null ? user.getUsername() : "登录失败";
|
||||
recordLoginLog(userId, username, result);
|
||||
ServiceException ex = exception(errorCode, args);
|
||||
if (cause != null) {
|
||||
ex.initCause(cause);
|
||||
}
|
||||
return ex;
|
||||
}
|
||||
|
||||
private String normalizeSourceSystem(String sourceSystem) {
|
||||
String trimmed = StringUtils.hasText(sourceSystem) ? sourceSystem.trim() : null;
|
||||
if (StringUtils.hasText(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
return properties.getSystemCode();
|
||||
}
|
||||
|
||||
private ExternalSsoStrategy selectStrategy(String sourceSystem) {
|
||||
if (strategies == null || strategies.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return strategies.stream()
|
||||
.filter(strategy -> {
|
||||
try {
|
||||
return strategy.supports(sourceSystem);
|
||||
} catch (Exception ex) {
|
||||
log.warn("判定 SSO 策略是否支持来源系统时出现异常: {}", ex.getMessage());
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package com.zt.plat.module.system.service.sso.client;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.MissingNode;
|
||||
import com.zt.plat.framework.common.util.integration.ShareServiceProperties;
|
||||
import com.zt.plat.framework.common.util.integration.ShareServiceUtils;
|
||||
import com.zt.plat.module.system.controller.admin.sso.vo.ExternalSsoVerifyReqVO;
|
||||
import com.zt.plat.module.system.framework.sso.config.ExternalSsoProperties;
|
||||
import com.zt.plat.module.system.framework.sso.config.ExternalSsoProperties.RemoteProperties;
|
||||
import com.zt.plat.module.system.service.sso.dto.ExternalSsoUserInfo;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.client.RestClientResponseException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 通过 HTTP 调用外部接口获取用户信息的默认实现。
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class DefaultExternalSsoClient implements ExternalSsoClient {
|
||||
|
||||
private static final String RESPONSE_CODE_FIELD = "__sys__.status";
|
||||
private static final String RESPONSE_SUCCESS_CODE = "1";
|
||||
private static final String RESPONSE_MESSAGE_FIELD = "__sys__.msg";
|
||||
private static final String RESPONSE_USERNAME_FIELD = "sub";
|
||||
private static final String RESPONSE_NICKNAME_FIELD = "ucn";
|
||||
|
||||
private final ExternalSsoProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final RestTemplateBuilder restTemplateBuilder;
|
||||
private final ShareServiceProperties shareServiceProperties;
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
private volatile RestTemplate restTemplate;
|
||||
private volatile RestTemplate shareServiceRestTemplate;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
this.restTemplate = buildRestTemplate();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ExternalSsoUserInfo fetchUserInfo(String token, ExternalSsoVerifyReqVO request) {
|
||||
RemoteProperties remote = properties.getRemote();
|
||||
RestTemplate template = getUserInfoRestTemplate();
|
||||
String shareToken = obtainShareServiceToken();
|
||||
|
||||
// 构造访问外部用户信息接口的完整地址
|
||||
UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromHttpUrl(normalizeBaseUrl(remote.getBaseUrl()))
|
||||
.path(normalizePath(remote.getUserInfoPath()));
|
||||
|
||||
remote.getQueryParams().forEach((key, value) -> {
|
||||
if (value != null) {
|
||||
uriBuilder.queryParam(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
// 组装请求头
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setAccept(List.of(MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN, MediaType.ALL));
|
||||
remote.getHeaders().forEach((key, value) -> {
|
||||
if (value != null) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
});
|
||||
if (StringUtils.hasText(shareToken)) {
|
||||
headers.set(shareServiceProperties.getTokenHeaderName(), shareToken);
|
||||
}
|
||||
|
||||
// 组装请求体
|
||||
HttpEntity<?> entity;
|
||||
if (remote.getMethod() == HttpMethod.GET) {
|
||||
entity = new HttpEntity<>(headers);
|
||||
} else {
|
||||
Map<String, Object> body = buildRequestBody(remote, token);
|
||||
if (headers.getContentType() == null) {
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
}
|
||||
entity = new HttpEntity<>(body, headers);
|
||||
}
|
||||
|
||||
// 调用外部接口并处理重试
|
||||
String responseBody = null;
|
||||
int attempts = Math.max(1, 0) + 1;
|
||||
// 简单重试机制
|
||||
for (int current = 1; current <= attempts; current++) {
|
||||
try {
|
||||
ResponseEntity<String> response = template.exchange(uriBuilder.build(true).toUri(), remote.getMethod(), entity, String.class);
|
||||
responseBody = response.getBody();
|
||||
break;
|
||||
} catch (RestClientResponseException ex) {
|
||||
responseBody = ex.getResponseBodyAsString();
|
||||
if (current == attempts) {
|
||||
throw new ExternalSsoClientException("调用外部用户信息接口返回异常: " + ex.getRawStatusCode(), ex, responseBody);
|
||||
}
|
||||
log.warn("调用外部 SSO 接口失败({}/{}): status={}, body={}", current, attempts, ex.getRawStatusCode(), StrUtil.maxLength(responseBody, 200));
|
||||
} catch (Exception ex) {
|
||||
if (current == attempts) {
|
||||
throw new ExternalSsoClientException("调用外部用户信息接口失败", ex);
|
||||
}
|
||||
log.warn("调用外部 SSO 接口异常({}/{}): {}", current, attempts, ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (!StringUtils.hasText(responseBody)) {
|
||||
throw new ExternalSsoClientException("外部用户信息接口返回空响应");
|
||||
}
|
||||
|
||||
try {
|
||||
// 解析外部接口返回结果并抽取关键字段
|
||||
JsonNode root = objectMapper.readTree(responseBody);
|
||||
validateResponse(root);
|
||||
String username = textValue(extractNode(root, RESPONSE_USERNAME_FIELD));
|
||||
String nickname = textValue(extractNode(root, RESPONSE_NICKNAME_FIELD));
|
||||
|
||||
if (!StringUtils.hasText(username)) {
|
||||
username = token;
|
||||
}
|
||||
if (!StringUtils.hasText(nickname)) {
|
||||
nickname = username;
|
||||
}
|
||||
|
||||
ExternalSsoUserInfo info = new ExternalSsoUserInfo()
|
||||
.setUsername(username)
|
||||
.setNickname(nickname);
|
||||
info.addAttribute("rawResponse", responseBody);
|
||||
return info;
|
||||
} catch (ExternalSsoClientException ex) {
|
||||
throw ex;
|
||||
} catch (Exception ex) {
|
||||
throw new ExternalSsoClientException("解析外部用户信息失败", ex, responseBody);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验外部接口的业务状态码,只有成功码才允许继续解析。
|
||||
*/
|
||||
private void validateResponse(JsonNode root) {
|
||||
JsonNode codeNode = extractNode(root, RESPONSE_CODE_FIELD);
|
||||
String code = codeNode != null && !codeNode.isNull() ? codeNode.asText() : null;
|
||||
if (code != null) {
|
||||
if (!StrUtil.equals(code, RESPONSE_SUCCESS_CODE)) {
|
||||
String message = textValue(extractNode(root, RESPONSE_MESSAGE_FIELD));
|
||||
throw new ExternalSsoClientException(StrUtil.format("外部接口返回失败, code={}, message={}", code, message), root.toString());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果最终既没有配置的 code 字段,则不再强制认为失败,后续解析将尽量从返回体中抽取数据。
|
||||
}
|
||||
|
||||
/**
|
||||
* 按“a.b.c”路径提取嵌套节点,缺失时返回 MissingNode,便于后续统一判空。
|
||||
*/
|
||||
private JsonNode extractNode(JsonNode root, String path) {
|
||||
if (!StringUtils.hasText(path)) {
|
||||
return root;
|
||||
}
|
||||
if (root == null || root.isMissingNode()) {
|
||||
return MissingNode.getInstance();
|
||||
}
|
||||
JsonNode current = root;
|
||||
for (String segment : path.split("\\.")) {
|
||||
if (!StringUtils.hasText(segment) || current == null) {
|
||||
return MissingNode.getInstance();
|
||||
}
|
||||
current = current.get(segment);
|
||||
if (current == null) {
|
||||
return MissingNode.getInstance();
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字符串值并做去空白处理。
|
||||
*/
|
||||
private String textValue(JsonNode node) {
|
||||
if (node == null || node.isMissingNode() || node.isNull()) {
|
||||
return null;
|
||||
}
|
||||
String value = node.asText();
|
||||
return StringUtils.hasText(value) ? value.trim() : null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 调用共享服务获取访问 token,失败时包装成客户端异常。
|
||||
*/
|
||||
private String obtainShareServiceToken() {
|
||||
try {
|
||||
RestTemplate shareTemplate = getShareServiceRestTemplate();
|
||||
String token = ShareServiceUtils.getAccessToken(shareTemplate, stringRedisTemplate, shareServiceProperties);
|
||||
if (!StringUtils.hasText(token)) {
|
||||
throw new ExternalSsoClientException("获取共享服务访问 token 为空");
|
||||
}
|
||||
return token;
|
||||
} catch (ExternalSsoClientException ex) {
|
||||
throw ex;
|
||||
} catch (Exception ex) {
|
||||
throw new ExternalSsoClientException("获取共享服务访问 token 失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 懒加载共享服务使用的 RestTemplate,减少重复构造。
|
||||
*/
|
||||
private RestTemplate getShareServiceRestTemplate() {
|
||||
RestTemplate existing = shareServiceRestTemplate;
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (shareServiceRestTemplate == null) {
|
||||
shareServiceRestTemplate = restTemplateBuilder.build();
|
||||
}
|
||||
return shareServiceRestTemplate;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建具备超时与代理能力的 RestTemplate。
|
||||
*/
|
||||
private RestTemplate buildRestTemplate() {
|
||||
RemoteProperties remote = properties.getRemote();
|
||||
return restTemplateBuilder.requestFactory(() -> createRequestFactory(remote)).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 懒加载外部用户接口使用的 RestTemplate,确保多线程安全。
|
||||
*/
|
||||
private RestTemplate getUserInfoRestTemplate() {
|
||||
RestTemplate existing = restTemplate;
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (restTemplate == null) {
|
||||
restTemplate = buildRestTemplate();
|
||||
}
|
||||
return restTemplate;
|
||||
}
|
||||
}
|
||||
|
||||
private SimpleClientHttpRequestFactory createRequestFactory(RemoteProperties remote) {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(remote.getConnectTimeoutMillis());
|
||||
factory.setReadTimeout(remote.getReadTimeoutMillis());
|
||||
return factory;
|
||||
}
|
||||
|
||||
private String normalizeBaseUrl(String baseUrl) {
|
||||
if (!StringUtils.hasText(baseUrl)) {
|
||||
return baseUrl;
|
||||
}
|
||||
return baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
||||
}
|
||||
|
||||
private String normalizePath(String path) {
|
||||
if (!StringUtils.hasText(path)) {
|
||||
return "";
|
||||
}
|
||||
return path.startsWith("/") ? path : "/" + path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据配置与动态上下文构造请求体,并附加必需的 x-token 结构。
|
||||
*/
|
||||
private Map<String, Object> buildRequestBody(RemoteProperties remote,
|
||||
String token) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
remote.getBody().forEach((key, value) -> {
|
||||
if (value != null) {
|
||||
body.put(key, value);
|
||||
}
|
||||
});
|
||||
body.put("x-token", token);
|
||||
return body;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.zt.plat.module.system.service.sso.client;
|
||||
|
||||
import com.zt.plat.module.system.controller.admin.sso.vo.ExternalSsoVerifyReqVO;
|
||||
import com.zt.plat.module.system.service.sso.dto.ExternalSsoUserInfo;
|
||||
|
||||
/**
|
||||
* 定义外部身份源拉取用户信息的能力。
|
||||
*/
|
||||
public interface ExternalSsoClient {
|
||||
|
||||
/**
|
||||
* 根据外部令牌获取用户详情。
|
||||
*
|
||||
* @param token 原始令牌
|
||||
* @param request 请求参数
|
||||
* @return 外部用户信息
|
||||
*/
|
||||
ExternalSsoUserInfo fetchUserInfo(String token, ExternalSsoVerifyReqVO request);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.zt.plat.module.system.service.sso.client;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.zt.plat.framework.common.util.integration.ShareServiceProperties;
|
||||
import com.zt.plat.module.system.framework.sso.config.ExternalSsoProperties;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
/**
|
||||
* 注册外部 SSO 客户端默认实现的配置类,允许业务自行覆盖。
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties(ShareServiceProperties.class)
|
||||
public class ExternalSsoClientConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(ExternalSsoClient.class)
|
||||
public ExternalSsoClient externalSsoClient(ExternalSsoProperties properties,
|
||||
ObjectMapper objectMapper,
|
||||
RestTemplateBuilder restTemplateBuilder,
|
||||
ShareServiceProperties shareServiceProperties,
|
||||
StringRedisTemplate stringRedisTemplate) {
|
||||
return new DefaultExternalSsoClient(properties, objectMapper, restTemplateBuilder, shareServiceProperties, stringRedisTemplate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.zt.plat.module.system.service.sso.client;
|
||||
|
||||
/**
|
||||
* 外部 SSO 客户端在获取用户信息失败时抛出的异常。
|
||||
*/
|
||||
public class ExternalSsoClientException extends RuntimeException {
|
||||
|
||||
private final String responseBody;
|
||||
|
||||
public ExternalSsoClientException(String message) {
|
||||
super(message);
|
||||
this.responseBody = null;
|
||||
}
|
||||
|
||||
public ExternalSsoClientException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.responseBody = null;
|
||||
}
|
||||
|
||||
public ExternalSsoClientException(String message, String responseBody) {
|
||||
super(message);
|
||||
this.responseBody = responseBody;
|
||||
}
|
||||
|
||||
public ExternalSsoClientException(String message, Throwable cause, String responseBody) {
|
||||
super(message, cause);
|
||||
this.responseBody = responseBody;
|
||||
}
|
||||
|
||||
public String getResponseBody() {
|
||||
return responseBody;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.zt.plat.module.system.service.sso.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 外部系统返回的用户信息标准化模型。
|
||||
*/
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public class ExternalSsoUserInfo {
|
||||
|
||||
private String username;
|
||||
private String nickname;
|
||||
private String email;
|
||||
private String mobile;
|
||||
private Long tenantId;
|
||||
private Map<String, Object> attributes = new HashMap<>();
|
||||
|
||||
public ExternalSsoUserInfo addAttribute(String key, Object value) {
|
||||
if (attributes == null) {
|
||||
attributes = new HashMap<>();
|
||||
}
|
||||
attributes.put(key, value);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.zt.plat.module.system.service.sso.strategy;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.zt.plat.framework.tenant.core.util.TenantUtils;
|
||||
import com.zt.plat.module.system.controller.admin.sso.vo.ExternalSsoVerifyReqVO;
|
||||
import com.zt.plat.module.system.dal.dataobject.user.AdminUserDO;
|
||||
import com.zt.plat.module.system.framework.sso.config.ExternalSsoProperties;
|
||||
import com.zt.plat.module.system.framework.sso.config.ExternalSsoProperties.MatchField;
|
||||
import com.zt.plat.module.system.framework.sso.config.ExternalSsoProperties.MappingProperties;
|
||||
import com.zt.plat.module.system.service.sso.client.ExternalSsoClient;
|
||||
import com.zt.plat.module.system.service.sso.client.ExternalSsoClientException;
|
||||
import com.zt.plat.module.system.service.sso.dto.ExternalSsoUserInfo;
|
||||
import com.zt.plat.module.system.service.user.AdminUserService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 默认的外部单点登录策略,基于 {@link ExternalSsoProperties} 配置实现。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@Order(Ordered.LOWEST_PRECEDENCE)
|
||||
@RequiredArgsConstructor
|
||||
public class DefaultExternalSsoStrategy implements ExternalSsoStrategy {
|
||||
|
||||
private final ExternalSsoProperties properties;
|
||||
private final ExternalSsoClient externalSsoClient;
|
||||
private final AdminUserService adminUserService;
|
||||
|
||||
@Override
|
||||
public boolean supports(String sourceSystem) {
|
||||
String expected = properties.getSystemCode();
|
||||
if (!StringUtils.hasText(sourceSystem)) {
|
||||
return true;
|
||||
}
|
||||
if (!StringUtils.hasText(expected)) {
|
||||
return true;
|
||||
}
|
||||
return StrUtil.equalsIgnoreCase(sourceSystem.trim(), expected);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ExternalSsoUserInfo fetchExternalUser(ExternalSsoVerifyReqVO reqVO) {
|
||||
ExternalSsoUserInfo info = externalSsoClient.fetchUserInfo(reqVO.getToken(), reqVO);
|
||||
if (info == null) {
|
||||
throw new ExternalSsoClientException("外部接口未返回用户信息");
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AdminUserDO resolveLocalUser(ExternalSsoUserInfo externalUser, ExternalSsoVerifyReqVO reqVO) {
|
||||
MappingProperties mapping = properties.getMapping();
|
||||
List<MatchField> fields = mapping.getOrder();
|
||||
if (CollectionUtils.isEmpty(fields)) {
|
||||
fields = List.of(MatchField.USERNAME, MatchField.MOBILE);
|
||||
}
|
||||
AdminUserDO user = null;
|
||||
for (MatchField field : fields) {
|
||||
switch (field) {
|
||||
case USERNAME -> user = findUserByUsername(externalUser.getUsername(), mapping.isIgnoreCase());
|
||||
case MOBILE -> user = findUserByMobile(externalUser.getMobile());
|
||||
default -> {
|
||||
}
|
||||
}
|
||||
if (user != null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
private AdminUserDO findUserByUsername(String username, boolean ignoreCase) {
|
||||
if (!StringUtils.hasText(username)) {
|
||||
return null;
|
||||
}
|
||||
AdminUserDO user = adminUserService.getUserByUsername(username);
|
||||
if (user == null && ignoreCase) {
|
||||
AdminUserDO candidate = TenantUtils.executeIgnore(() -> {
|
||||
List<AdminUserDO> list = adminUserService.getUserListByNickname(username);
|
||||
return list.stream().filter(item -> StrUtil.equalsIgnoreCase(item.getUsername(), username)).findFirst().orElse(null);
|
||||
});
|
||||
if (candidate != null) {
|
||||
user = candidate;
|
||||
}
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
private AdminUserDO findUserByMobile(String mobile) {
|
||||
if (!StringUtils.hasText(mobile)) {
|
||||
return null;
|
||||
}
|
||||
return adminUserService.getUserByMobile(mobile);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.zt.plat.module.system.service.sso.strategy;
|
||||
|
||||
import com.zt.plat.module.system.controller.admin.sso.vo.ExternalSsoVerifyReqVO;
|
||||
import com.zt.plat.module.system.dal.dataobject.user.AdminUserDO;
|
||||
import com.zt.plat.module.system.service.sso.dto.ExternalSsoUserInfo;
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
/**
|
||||
* 定义外部单点登录在不同来源系统下的处理策略。
|
||||
*/
|
||||
public interface ExternalSsoStrategy {
|
||||
|
||||
/**
|
||||
* 判断当前策略是否适用于指定来源系统。
|
||||
*
|
||||
* @param sourceSystem 来源系统标识,可能为空
|
||||
* @return 是否支持
|
||||
*/
|
||||
boolean supports(@Nullable String sourceSystem);
|
||||
|
||||
/**
|
||||
* 拉取并构造外部用户信息。
|
||||
*
|
||||
* @param reqVO 请求参数
|
||||
* @return 外部用户信息,不能为空
|
||||
*/
|
||||
ExternalSsoUserInfo fetchExternalUser(ExternalSsoVerifyReqVO reqVO);
|
||||
|
||||
/**
|
||||
* 根据外部用户信息匹配本地账号。
|
||||
*
|
||||
* @param externalUser 外部用户信息
|
||||
* @param reqVO 请求参数
|
||||
* @return 匹配到的用户,找不到时返回 {@code null}
|
||||
*/
|
||||
AdminUserDO resolveLocalUser(ExternalSsoUserInfo externalUser, ExternalSsoVerifyReqVO reqVO);
|
||||
}
|
||||
@@ -58,6 +58,15 @@ public class UserSyncServiceImpl implements UserSyncService {
|
||||
saveReqVO.setPassword("Zgty@9527");
|
||||
// 设置为同步用户
|
||||
saveReqVO.setUserSource(UserSourceEnum.SYNC.getSource());
|
||||
// 处理岗位名称字段
|
||||
if (StrUtil.isNotBlank(requestVO.getPostName())) {
|
||||
Long postId = postService.getOrCreatePostByName(requestVO.getPostName());
|
||||
if (postId != null) {
|
||||
Set<Long> postIds = saveReqVO.getPostIds() != null ? new HashSet<>(saveReqVO.getPostIds()) : new HashSet<>();
|
||||
postIds.add(postId);
|
||||
saveReqVO.setPostIds(postIds);
|
||||
}
|
||||
}
|
||||
Long userId = adminUserService.createUser(saveReqVO);
|
||||
UserCreateResponseVO resp = new UserCreateResponseVO();
|
||||
resp.setUid(String.valueOf(userId));
|
||||
|
||||
@@ -33,6 +33,7 @@ import com.zt.plat.module.system.service.dept.PostService;
|
||||
import com.zt.plat.module.system.service.permission.PermissionService;
|
||||
import com.zt.plat.module.system.service.tenant.TenantService;
|
||||
import com.zt.plat.module.system.service.userdept.UserDeptService;
|
||||
import org.apache.seata.spring.annotation.GlobalTransactional;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
@@ -93,6 +94,7 @@ public class AdminUserServiceImpl implements AdminUserService {
|
||||
private UserDeptService userDeptService;
|
||||
|
||||
@Override
|
||||
@GlobalTransactional(rollbackFor = Exception.class)
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@LogRecord(type = SYSTEM_USER_TYPE, subType = SYSTEM_USER_CREATE_SUB_TYPE, bizNo = "{{#user.id}}",
|
||||
success = SYSTEM_USER_CREATE_SUCCESS)
|
||||
@@ -323,12 +325,18 @@ public class AdminUserServiceImpl implements AdminUserService {
|
||||
@Override
|
||||
public AdminUserDO getUserByUsername(String username) {
|
||||
AdminUserDO user = userMapper.selectByUsername(username);
|
||||
if (user != null) {
|
||||
fillUserDeptInfo(Collections.singletonList(user));
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AdminUserDO getUserByMobile(String mobile) {
|
||||
AdminUserDO user = userMapper.selectByMobile(mobile);
|
||||
if (user != null) {
|
||||
fillUserDeptInfo(Collections.singletonList(user));
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -386,7 +394,9 @@ public class AdminUserServiceImpl implements AdminUserService {
|
||||
}
|
||||
// 查询用户信息
|
||||
Set<Long> userIds = convertSet(validUserDeptListByDeptIds, UserDeptDO::getUserId);
|
||||
return userMapper.selectList("id", userIds);
|
||||
List<AdminUserDO> users = userMapper.selectList("id", userIds);
|
||||
fillUserDeptInfo(users);
|
||||
return users;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -399,6 +409,7 @@ public class AdminUserServiceImpl implements AdminUserService {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<AdminUserDO> users = userMapper.selectBatchIds(userIds);
|
||||
fillUserDeptInfo(users);
|
||||
return users;
|
||||
}
|
||||
|
||||
@@ -408,6 +419,7 @@ public class AdminUserServiceImpl implements AdminUserService {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<AdminUserDO> users = userMapper.selectListByIds(ids);
|
||||
fillUserDeptInfo(users);
|
||||
return users;
|
||||
}
|
||||
|
||||
@@ -434,6 +446,7 @@ public class AdminUserServiceImpl implements AdminUserService {
|
||||
@Override
|
||||
public List<AdminUserDO> getUserListByNickname(String nickname) {
|
||||
List<AdminUserDO> users = userMapper.selectListByNickname(nickname);
|
||||
fillUserDeptInfo(users);
|
||||
return users;
|
||||
}
|
||||
|
||||
@@ -603,6 +616,7 @@ public class AdminUserServiceImpl implements AdminUserService {
|
||||
@Override
|
||||
public List<AdminUserDO> getUserListByStatus(Integer status) {
|
||||
List<AdminUserDO> users = userMapper.selectListByStatus(status);
|
||||
fillUserDeptInfo(users);
|
||||
return users;
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,28 @@ spring:
|
||||
easy-trans:
|
||||
is-enable-global: true # 启用全局翻译(拦截所有 SpringMVC ResponseBody 进行自动翻译 )。如果对于性能要求很高可关闭此配置,或通过 @IgnoreTrans 忽略某个接口
|
||||
|
||||
--- #################### iWork 集成配置 ####################
|
||||
|
||||
iwork:
|
||||
enabled: true
|
||||
base-url: http://172.16.36.233:8080
|
||||
app-id:
|
||||
client-public-key:
|
||||
user-id:
|
||||
workflow-id:
|
||||
paths:
|
||||
register: /api/ec/dev/auth/regist
|
||||
apply-token: /api/ec/dev/auth/applytoken
|
||||
user-info: /api/workflow/paService/getUserInfo
|
||||
create-workflow: /api/workflow/paService/doCreateRequest
|
||||
void-workflow: /api/workflow/paService/doCancelRequest
|
||||
token:
|
||||
ttl-seconds: 3600
|
||||
refresh-ahead-seconds: 60
|
||||
client:
|
||||
connect-timeout: 5s
|
||||
response-timeout: 30s
|
||||
|
||||
--- #################### RPC 远程调用相关配置 ####################
|
||||
|
||||
--- #################### 消息队列相关 ####################
|
||||
@@ -236,7 +258,7 @@ sync:
|
||||
|
||||
eplat:
|
||||
share:
|
||||
url-prefix: https://10.1.7.110
|
||||
url-prefix: http://10.1.7.110
|
||||
client-id: ztjgj5gsJ2uU20900h9j
|
||||
client-secret: DC82AD38EA764719B6DC7D71AAB4856C
|
||||
scope: read
|
||||
|
||||
@@ -17,8 +17,6 @@ import com.zt.plat.module.system.enums.logger.LoginResultEnum;
|
||||
import com.zt.plat.module.system.enums.sms.SmsSceneEnum;
|
||||
import com.zt.plat.module.system.enums.social.SocialTypeEnum;
|
||||
import com.zt.plat.module.system.service.logger.LoginLogService;
|
||||
import com.zt.plat.module.system.service.member.MemberService;
|
||||
import com.zt.plat.module.system.service.oauth2.EbanOAuth2Service;
|
||||
import com.zt.plat.module.system.service.oauth2.OAuth2TokenService;
|
||||
import com.zt.plat.module.system.service.social.SocialUserService;
|
||||
import com.zt.plat.module.system.service.user.AdminUserService;
|
||||
@@ -60,10 +58,6 @@ public class AdminAuthServiceImplTest extends BaseDbUnitTest {
|
||||
@MockBean
|
||||
private OAuth2TokenService oauth2TokenService;
|
||||
@MockBean
|
||||
private MemberService memberService;
|
||||
@MockBean
|
||||
private EbanOAuth2Service ebanOAuth2Service;
|
||||
@MockBean
|
||||
private Validator validator;
|
||||
|
||||
@BeforeEach
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
package com.zt.plat.module.system.service.integration.iwork.impl;
|
||||
|
||||
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkDetailRecordVO;
|
||||
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkDetailTableVO;
|
||||
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkFormFieldVO;
|
||||
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkOperationRespVO;
|
||||
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkUserInfoReqVO;
|
||||
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkUserInfoRespVO;
|
||||
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkWorkflowCreateReqVO;
|
||||
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkWorkflowVoidReqVO;
|
||||
import com.zt.plat.module.system.framework.integration.iwork.config.IWorkProperties;
|
||||
import com.zt.plat.module.system.service.integration.iwork.IWorkIntegrationService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import okhttp3.mockwebserver.MockResponse;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
import okhttp3.mockwebserver.RecordedRequest;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class IWorkIntegrationServiceImplTest {
|
||||
|
||||
private static KeyPair serverKeyPair;
|
||||
private static String serverPublicKeyBase64;
|
||||
private static String clientPublicKeyBase64;
|
||||
|
||||
private MockWebServer mockWebServer;
|
||||
private IWorkIntegrationService integrationService;
|
||||
private IWorkProperties properties;
|
||||
|
||||
@BeforeAll
|
||||
static void initKeys() throws Exception {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(1024);
|
||||
serverKeyPair = generator.generateKeyPair();
|
||||
serverPublicKeyBase64 = Base64.getEncoder().encodeToString(serverKeyPair.getPublic().getEncoded());
|
||||
|
||||
KeyPair clientKeyPair = generator.generateKeyPair();
|
||||
clientPublicKeyBase64 = Base64.getEncoder().encodeToString(clientKeyPair.getPublic().getEncoded());
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
mockWebServer = new MockWebServer();
|
||||
mockWebServer.start();
|
||||
|
||||
properties = buildProperties();
|
||||
WebClient.Builder builder = WebClient.builder();
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
integrationService = new IWorkIntegrationServiceImpl(properties, objectMapper, builder);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws Exception {
|
||||
mockWebServer.shutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testWorkflowLifecycle() throws Exception {
|
||||
enqueueRegisterResponse();
|
||||
enqueueApplyTokenResponse();
|
||||
enqueueJsonResponse("{\"code\":1,\"userid\":\"1001\",\"msg\":\"OK\"}");
|
||||
enqueueJsonResponse("{\"code\":\"1\",\"requestid\":\"REQ-001\",\"msg\":\"created\"}");
|
||||
enqueueJsonResponse("{\"code\":\"1\",\"msg\":\"voided\"}");
|
||||
|
||||
IWorkUserInfoReqVO userReq = new IWorkUserInfoReqVO();
|
||||
userReq.setIdentifierKey("loginid");
|
||||
userReq.setIdentifierValue("zhangsan");
|
||||
|
||||
IWorkUserInfoRespVO userResp = integrationService.resolveUserId(userReq);
|
||||
assertThat(userResp.isSuccess()).isTrue();
|
||||
assertThat(userResp.getUserId()).isEqualTo("1001");
|
||||
|
||||
IWorkWorkflowCreateReqVO createReq = buildCreateRequest();
|
||||
IWorkOperationRespVO createResp = integrationService.createWorkflow(createReq);
|
||||
assertThat(createResp.isSuccess()).isTrue();
|
||||
assertThat(createResp.getPayload().get("requestid")).isEqualTo("REQ-001");
|
||||
|
||||
IWorkWorkflowVoidReqVO voidReq = new IWorkWorkflowVoidReqVO();
|
||||
voidReq.setRequestId("REQ-001");
|
||||
voidReq.setReason("testing void");
|
||||
IWorkOperationRespVO voidResp = integrationService.voidWorkflow(voidReq);
|
||||
assertThat(voidResp.isSuccess()).isTrue();
|
||||
|
||||
verifyRegisterRequest(mockWebServer.takeRequest());
|
||||
verifyApplyTokenRequest(mockWebServer.takeRequest());
|
||||
verifyUserInfoRequest(mockWebServer.takeRequest());
|
||||
verifyCreateRequest(mockWebServer.takeRequest());
|
||||
verifyVoidRequest(mockWebServer.takeRequest());
|
||||
|
||||
assertThat(mockWebServer.getRequestCount()).isEqualTo(5);
|
||||
}
|
||||
|
||||
private IWorkProperties buildProperties() {
|
||||
IWorkProperties properties = new IWorkProperties();
|
||||
properties.setEnabled(true);
|
||||
properties.setBaseUrl(mockWebServer.url("/").toString());
|
||||
properties.setAppId("test-app");
|
||||
properties.setClientPublicKey(clientPublicKeyBase64);
|
||||
properties.setUserId("1");
|
||||
properties.setWorkflowId(54L);
|
||||
properties.getToken().setTtlSeconds(3600L);
|
||||
properties.getToken().setRefreshAheadSeconds(30L);
|
||||
properties.getClient().setResponseTimeout(Duration.ofSeconds(5));
|
||||
|
||||
properties.getPaths().setRegister("/api/ec/dev/auth/regist");
|
||||
properties.getPaths().setApplyToken("/api/ec/dev/auth/applytoken");
|
||||
properties.getPaths().setUserInfo("/api/workflow/paService/getUserInfo");
|
||||
properties.getPaths().setCreateWorkflow("/api/workflow/paService/doCreateRequest");
|
||||
properties.getPaths().setVoidWorkflow("/api/workflow/paService/doCancelRequest");
|
||||
|
||||
properties.getClient().setConnectTimeout(Duration.ofSeconds(5));
|
||||
return properties;
|
||||
}
|
||||
|
||||
private void verifyRegisterRequest(RecordedRequest request) {
|
||||
assertThat(request.getPath()).isEqualTo("/api/ec/dev/auth/regist");
|
||||
assertThat(request.getHeader(properties.getHeaders().getAppId())).isEqualTo("test-app");
|
||||
assertThat(request.getHeader(properties.getHeaders().getClientPublicKey())).isEqualTo(clientPublicKeyBase64);
|
||||
}
|
||||
|
||||
private void verifyApplyTokenRequest(RecordedRequest request) throws Exception {
|
||||
assertThat(request.getPath()).isEqualTo("/api/ec/dev/auth/applytoken");
|
||||
assertThat(request.getHeader(properties.getHeaders().getAppId())).isEqualTo("test-app");
|
||||
assertThat(request.getHeader(properties.getHeaders().getTime())).isEqualTo("3600");
|
||||
String decryptedSecret = decryptHeader(request.getHeader(properties.getHeaders().getSecret()));
|
||||
assertThat(decryptedSecret).isEqualTo("plain-secret");
|
||||
}
|
||||
|
||||
private void verifyUserInfoRequest(RecordedRequest request) throws Exception {
|
||||
assertThat(request.getPath()).isEqualTo("/api/workflow/paService/getUserInfo");
|
||||
assertThat(request.getHeader(properties.getHeaders().getToken())).isEqualTo("token-123");
|
||||
String decryptedUserId = decryptHeader(request.getHeader(properties.getHeaders().getUserId()));
|
||||
assertThat(decryptedUserId).isEqualTo("1");
|
||||
String body = request.getBody().readUtf8();
|
||||
assertThat(body).contains("loginid");
|
||||
assertThat(body).contains("zhangsan");
|
||||
}
|
||||
|
||||
private void verifyCreateRequest(RecordedRequest request) throws Exception {
|
||||
assertThat(request.getPath()).isEqualTo("/api/workflow/paService/doCreateRequest");
|
||||
assertThat(request.getHeader(properties.getHeaders().getToken())).isEqualTo("token-123");
|
||||
String decryptedUserId = decryptHeader(request.getHeader(properties.getHeaders().getUserId()));
|
||||
assertThat(decryptedUserId).isEqualTo("1");
|
||||
String body = request.getBody().readUtf8();
|
||||
assertThat(body).contains("requestName=测试流程");
|
||||
assertThat(body).contains("workflowId=54");
|
||||
assertThat(body).contains("mainData=%5B");
|
||||
}
|
||||
|
||||
private void verifyVoidRequest(RecordedRequest request) throws Exception {
|
||||
assertThat(request.getPath()).isEqualTo("/api/workflow/paService/doCancelRequest");
|
||||
assertThat(request.getHeader(properties.getHeaders().getToken())).isEqualTo("token-123");
|
||||
String decryptedUserId = decryptHeader(request.getHeader(properties.getHeaders().getUserId()));
|
||||
assertThat(decryptedUserId).isEqualTo("1");
|
||||
String body = request.getBody().readUtf8();
|
||||
assertThat(body).contains("requestId=REQ-001");
|
||||
assertThat(body).contains("remark=testing+void");
|
||||
}
|
||||
|
||||
private void enqueueRegisterResponse() {
|
||||
enqueueJsonResponse("{" +
|
||||
"\"secret\":\"plain-secret\"," +
|
||||
"\"spk\":\"" + serverPublicKeyBase64 + "\"}");
|
||||
}
|
||||
|
||||
private void enqueueApplyTokenResponse() {
|
||||
enqueueJsonResponse("{\"token\":\"token-123\",\"expire\":3600}");
|
||||
}
|
||||
|
||||
private void enqueueJsonResponse(String body) {
|
||||
mockWebServer.enqueue(new MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body));
|
||||
}
|
||||
|
||||
private IWorkWorkflowCreateReqVO buildCreateRequest() {
|
||||
IWorkFormFieldVO field1 = new IWorkFormFieldVO();
|
||||
field1.setFieldName("sqr");
|
||||
field1.setFieldValue("张三");
|
||||
|
||||
IWorkFormFieldVO field2 = new IWorkFormFieldVO();
|
||||
field2.setFieldName("sqrq");
|
||||
field2.setFieldValue("2023-11-02");
|
||||
|
||||
IWorkDetailRecordVO detailRecord = new IWorkDetailRecordVO();
|
||||
detailRecord.setRecordOrder(0);
|
||||
IWorkFormFieldVO detailField = new IWorkFormFieldVO();
|
||||
detailField.setFieldName("ddh");
|
||||
detailField.setFieldValue("100010");
|
||||
detailRecord.setFields(List.of(detailField));
|
||||
|
||||
IWorkDetailTableVO detailTable = new IWorkDetailTableVO();
|
||||
detailTable.setTableDBName("formtable_main_26_dt1");
|
||||
detailTable.setRecords(List.of(detailRecord));
|
||||
|
||||
IWorkWorkflowCreateReqVO req = new IWorkWorkflowCreateReqVO();
|
||||
req.setRequestName("测试流程");
|
||||
req.setMainFields(List.of(field1, field2));
|
||||
req.setDetailTables(List.of(detailTable));
|
||||
req.setOtherParams(Map.of("isnextflow", "0"));
|
||||
return req;
|
||||
}
|
||||
|
||||
private String decryptHeader(String headerValue) throws Exception {
|
||||
Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, serverKeyPair.getPrivate());
|
||||
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(headerValue));
|
||||
return new String(decrypted, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.zt.plat.module.system.service.sso.client;
|
||||
|
||||
import com.zt.plat.module.system.framework.sso.config.ExternalSsoProperties;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* 仅加载 ExternalSsoClientConfiguration,校验上下文能成功创建 ExternalSsoClient Bean。
|
||||
* 使用 Mock 的 RedisTemplate,避免外部依赖。
|
||||
*/
|
||||
@SpringBootTest(classes = { ExternalSsoClientConfiguration.class, ExternalSsoClientConfigurationLoadTest.TestBeans.class })
|
||||
@Import(ExternalSsoProperties.class)
|
||||
class ExternalSsoClientConfigurationLoadTest {
|
||||
|
||||
@TestConfiguration
|
||||
static class TestBeans {
|
||||
@Bean
|
||||
public StringRedisTemplate stringRedisTemplate() {
|
||||
return mock(StringRedisTemplate.class);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ExternalSsoProperties externalSsoProperties() {
|
||||
ExternalSsoProperties props = new ExternalSsoProperties();
|
||||
// 提供必要的基础配置,避免初始化过程出现 NPE
|
||||
props.getRemote().setBaseUrl("http://localhost");
|
||||
return props;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void contextLoads(org.springframework.context.ApplicationContext context) {
|
||||
Object bean = context.getBean(ExternalSsoClient.class);
|
||||
assertThat(bean).isNotNull();
|
||||
assertThat(bean).isInstanceOf(DefaultExternalSsoClient.class);
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,7 @@ import com.zt.plat.module.system.service.userdept.UserDeptService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
@@ -57,25 +54,60 @@ public class UserSyncServiceImplTest extends BaseMockitoUnitTest {
|
||||
securityFrameworkUtilsMock.close();
|
||||
}
|
||||
|
||||
// @Test
|
||||
// void testCreateUser() {
|
||||
// // Arrange
|
||||
// UserCreateRequestVO requestVO = randomPojo(UserCreateRequestVO.class);
|
||||
// Long newUserId = randomLongId();
|
||||
// when(adminUserService.createUser(any(UserSaveReqVO.class))).thenReturn(newUserId);
|
||||
//
|
||||
// // Act
|
||||
// UserCreateResponseVO response = userSyncService.createUser(requestVO);
|
||||
//
|
||||
// // Assert
|
||||
// assertNotNull(response);
|
||||
// assertEquals("0", response.getResultCode());
|
||||
// assertEquals("success", response.getMessage());
|
||||
// assertEquals(String.valueOf(newUserId), response.getUid());
|
||||
// assertEquals(requestVO.getBimRequestId(), response.getBimRequestId());
|
||||
//
|
||||
// verify(adminUserService).createUser(any(UserSaveReqVO.class));
|
||||
// }
|
||||
@Test
|
||||
void testCreateUser_WithPostName() {
|
||||
// Arrange
|
||||
UserCreateRequestVO requestVO = randomPojo(UserCreateRequestVO.class, vo -> {
|
||||
vo.setPostName("岗位A");
|
||||
vo.setDeptIds(null);
|
||||
});
|
||||
Long newUserId = randomLongId();
|
||||
Long postId = randomLongId();
|
||||
when(postService.getOrCreatePostByName(requestVO.getPostName())).thenReturn(postId);
|
||||
when(adminUserService.createUser(any(UserSaveReqVO.class))).thenReturn(newUserId);
|
||||
|
||||
// Act
|
||||
UserCreateResponseVO response = userSyncService.createUser(requestVO);
|
||||
|
||||
// Assert
|
||||
assertNotNull(response);
|
||||
assertEquals("0", response.getResultCode());
|
||||
assertEquals("success", response.getMessage());
|
||||
assertEquals(String.valueOf(newUserId), response.getUid());
|
||||
assertEquals(requestVO.getBimRequestId(), response.getBimRequestId());
|
||||
|
||||
ArgumentCaptor<UserSaveReqVO> captor = ArgumentCaptor.forClass(UserSaveReqVO.class);
|
||||
verify(adminUserService).createUser(captor.capture());
|
||||
assertNotNull(captor.getValue().getPostIds());
|
||||
assertTrue(captor.getValue().getPostIds().contains(postId));
|
||||
verify(postService).getOrCreatePostByName(requestVO.getPostName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateUser_WithoutPostName() {
|
||||
// Arrange
|
||||
UserCreateRequestVO requestVO = randomPojo(UserCreateRequestVO.class, vo -> {
|
||||
vo.setPostName(null);
|
||||
vo.setDeptIds(null);
|
||||
});
|
||||
Long newUserId = randomLongId();
|
||||
when(adminUserService.createUser(any(UserSaveReqVO.class))).thenReturn(newUserId);
|
||||
|
||||
// Act
|
||||
UserCreateResponseVO response = userSyncService.createUser(requestVO);
|
||||
|
||||
// Assert
|
||||
assertNotNull(response);
|
||||
assertEquals("0", response.getResultCode());
|
||||
assertEquals("success", response.getMessage());
|
||||
assertEquals(String.valueOf(newUserId), response.getUid());
|
||||
assertEquals(requestVO.getBimRequestId(), response.getBimRequestId());
|
||||
|
||||
ArgumentCaptor<UserSaveReqVO> captor = ArgumentCaptor.forClass(UserSaveReqVO.class);
|
||||
verify(adminUserService).createUser(captor.capture());
|
||||
assertTrue(captor.getValue().getPostIds() == null || captor.getValue().getPostIds().isEmpty());
|
||||
verify(postService, never()).getOrCreatePostByName(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteUser_Success() {
|
||||
|
||||
@@ -649,6 +649,7 @@ public class AdminUserServiceImplTest extends BaseDbUnitTest {
|
||||
o.setStatus(randomEle(CommonStatusEnum.values()).getStatus()); // 保证 status 的范围
|
||||
o.setSex(randomEle(SexEnum.values()).getSex());
|
||||
o.setDeptIds(new HashSet<>(asSet(1L, 2L)));
|
||||
o.setDeptNames("-");
|
||||
o.setCompanyDeptInfos(null);// 保证 deptIds 的范围
|
||||
o.setUserSource(null);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,16 @@ spring:
|
||||
main:
|
||||
lazy-initialization: true # 开启懒加载,加快速度
|
||||
banner-mode: off # 单元测试,禁用 Banner
|
||||
config:
|
||||
import: optional:classpath:application-unit-test-config.yaml # 覆盖主配置中的 Nacos 导入,避免单测加载远程配置
|
||||
cloud:
|
||||
nacos:
|
||||
config:
|
||||
enabled: false
|
||||
import-check:
|
||||
enabled: false
|
||||
discovery:
|
||||
enabled: false
|
||||
|
||||
--- #################### 数据库相关配置 ####################
|
||||
|
||||
@@ -37,6 +47,16 @@ mybatis-plus:
|
||||
configuration:
|
||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||
|
||||
config:
|
||||
server-addr: 127.0.0.1:8848
|
||||
namespace: unit-test
|
||||
group: DEFAULT_GROUP
|
||||
username:
|
||||
password:
|
||||
|
||||
env:
|
||||
name: unit-test
|
||||
|
||||
--- #################### 定时任务相关配置 ####################
|
||||
|
||||
--- #################### 配置中心相关配置 ####################
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
spring:
|
||||
config:
|
||||
import: optional:classpath:application-unit-test-config.yaml
|
||||
application:
|
||||
name: system-server
|
||||
profiles:
|
||||
active: unit-test
|
||||
cloud:
|
||||
nacos:
|
||||
config:
|
||||
enabled: false
|
||||
import-check:
|
||||
enabled: false
|
||||
discovery:
|
||||
enabled: false
|
||||
|
||||
config:
|
||||
server-addr: 127.0.0.1:8848
|
||||
namespace: unit-test
|
||||
group: DEFAULT_GROUP
|
||||
username:
|
||||
password:
|
||||
|
||||
env:
|
||||
name: unit-test
|
||||
@@ -1,15 +1,12 @@
|
||||
package com.zt.plat.module.template.dal.dataobject.contract;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import com.zt.plat.framework.common.annotation.PageSum;
|
||||
import com.zt.plat.framework.mybatis.core.dataobject.BusinessBaseDO;
|
||||
import lombok.*;
|
||||
import java.util.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalDateTime;
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import com.zt.plat.framework.mybatis.core.dataobject.BusinessBaseDO;
|
||||
/**
|
||||
* 合同 DO
|
||||
*
|
||||
@@ -75,6 +72,10 @@ public class DemoContractDO extends BusinessBaseDO {
|
||||
*/
|
||||
@TableField("AMOUNT")
|
||||
private BigDecimal amount;
|
||||
|
||||
@PageSum(column = "AMOUNT")
|
||||
private BigDecimal amountTotal;
|
||||
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
|
||||
@@ -50,7 +50,7 @@ spring:
|
||||
time-to-live: 1h # 设置过期时间为 1 小时
|
||||
|
||||
server:
|
||||
port: 48100
|
||||
port: 49100
|
||||
|
||||
logging:
|
||||
file:
|
||||
|
||||
Reference in New Issue
Block a user