Merge branch 'dev' into test

This commit is contained in:
chenbowen
2025-11-18 10:59:55 +08:00
73 changed files with 4998 additions and 93 deletions

581
sql/dm/bpm.sql Normal file

File diff suppressed because one or more lines are too long

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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(), "/");
}
}

View File

@@ -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) {
}
}

View File

@@ -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(), "-", "");
}
}

View File

@@ -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) {

View File

@@ -24,10 +24,15 @@ public class TraceFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
// 设置响应 traceId
response.addHeader(HEADER_NAME_TRACE_ID, TracerUtils.getTraceId());
// 继续过滤
chain.doFilter(request, response);
String traceId = TracerUtils.getTraceId();
try {
// 设置响应 traceId便于客户端回溯
response.addHeader(HEADER_NAME_TRACE_ID, traceId);
// 继续过滤
chain.doFilter(request, response);
} finally {
TracerUtils.clear();
}
}
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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) {

View File

@@ -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 +
'}';
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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())) {

View File

@@ -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;
@@ -236,12 +237,12 @@ public class ApiGatewayExecutionService {
String message = StringUtils.hasText(context.getResponseMessage())
? context.getResponseMessage()
: HttpStatus.valueOf(status).getReasonPhrase();
return ApiGatewayResponse.builder()
return ApiGatewayResponse.builder()
.code(status)
.message(message)
.response(context.getResponseBody())
.traceId(context.getRequestId())
.build();
.message(message)
.response(context.getResponseBody())
.traceId(TracerUtils.getTraceId())
.build();
}
private String normalizeBasePath(String basePath) {

View File

@@ -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();
ApiGatewayResponse envelope = ApiGatewayResponse.builder()
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();

View File

@@ -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) {
}
}
}

View File

@@ -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));

View File

@@ -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, "已经存在该名字的菜单");

View File

@@ -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), // 强制退出

View File

@@ -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), // 未找到匹配的本地用户
;

View File

@@ -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>

View File

@@ -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));
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 {
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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));
}
}

View File

@@ -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;
}

View File

@@ -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 = "密码")

View File

@@ -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 {
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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) {

View File

@@ -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 流程模板编号");
}

View File

@@ -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);
}

View File

@@ -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);
}
}
}

View File

@@ -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);

View File

@@ -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}}",

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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));

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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() {

View File

@@ -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);
};

View File

@@ -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
--- #################### 定时任务相关配置 ####################
--- #################### 配置中心相关配置 ####################

View File

@@ -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

View File

@@ -1,15 +1,12 @@
package com.zt.plat.module.template.dal.dataobject.contract;
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.common.annotation.PageSum;
import com.zt.plat.framework.mybatis.core.dataobject.BusinessBaseDO;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 合同 DO
*
@@ -75,6 +72,10 @@ public class DemoContractDO extends BusinessBaseDO {
*/
@TableField("AMOUNT")
private BigDecimal amount;
@PageSum(column = "AMOUNT")
private BigDecimal amountTotal;
/**
* 备注
*/

View File

@@ -50,7 +50,7 @@ spring:
time-to-live: 1h # 设置过期时间为 1 小时
server:
port: 48100
port: 49100
logging:
file: