1. 新增分页接口聚合查询注解支持

2. 优化 databus api 日志记录的字段缺失问题
3. 新增 eplat sso 页面登录校验
4. 用户、部门编辑新增 seata 事务支持
5. 新增 iwork 流程发起接口
6. 新增 eban 同步用户时的岗位处理逻辑
7. 新增无 skywalking 时的 traceId 支持
This commit is contained in:
chenbowen
2025-11-18 10:03:34 +08:00
parent af7f103a38
commit 266eb45e00
74 changed files with 5001 additions and 102 deletions

581
sql/dm/bpm.sql Normal file

File diff suppressed because one or more lines are too long

View File

@@ -26,7 +26,7 @@
<url>https://github.com/YunaiV/ruoyi-vue-pro</url>
<properties>
<revision>3.0.40</revision>
<revision>3.0.43</revision>
<flatten-maven-plugin.version>1.6.0</flatten-maven-plugin.version>
<!-- 统一依赖管理 -->
<spring.boot.version>3.4.5</spring.boot.version>

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

@@ -5,6 +5,7 @@ import com.zt.plat.framework.common.pojo.PageParam;
import com.zt.plat.framework.common.pojo.PageResult;
import com.zt.plat.framework.common.pojo.SortablePageParam;
import com.zt.plat.framework.common.pojo.SortingField;
import com.zt.plat.framework.mybatis.core.sum.PageSumSupport;
import com.zt.plat.framework.mybatis.core.util.JdbcUtils;
import com.zt.plat.framework.mybatis.core.util.MyBatisUtils;
import com.baomidou.mybatisplus.annotation.DbType;
@@ -43,14 +44,18 @@ public interface BaseMapperX<T> extends MPJBaseMapper<T> {
// 特殊:不分页,直接查询全部
if (PageParam.PAGE_SIZE_NONE.equals(pageParam.getPageSize())) {
List<T> list = selectList(queryWrapper);
return new PageResult<>(list, (long) list.size());
PageResult<T> pageResult = new PageResult<>(list, (long) list.size());
PageSumSupport.tryAttachSummary(this, queryWrapper, pageResult);
return pageResult;
}
// MyBatis Plus 查询
IPage<T> mpPage = MyBatisUtils.buildPage(pageParam, sortingFields);
selectPage(mpPage, queryWrapper);
// 转换返回
return new PageResult<>(mpPage.getRecords(), mpPage.getTotal());
PageResult<T> pageResult = new PageResult<>(mpPage.getRecords(), mpPage.getTotal());
PageSumSupport.tryAttachSummary(this, queryWrapper, pageResult);
return pageResult;
}
default <D> PageResult<D> selectJoinPage(PageParam pageParam, Class<D> clazz, MPJLambdaWrapper<T> lambdaWrapper) {

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

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

@@ -1,11 +1,11 @@
package com.zt.plat.module.system.dal.dataobject.user;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.zt.plat.framework.common.enums.CommonStatusEnum;
import com.zt.plat.framework.common.pojo.CompanyDeptInfo;
import com.zt.plat.framework.tenant.core.db.TenantBaseDO;
import com.zt.plat.module.system.enums.common.SexEnum;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.*;

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

@@ -4,6 +4,10 @@ import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import com.google.common.annotations.VisibleForTesting;
import com.mzt.logapi.context.LogRecordContext;
import com.mzt.logapi.service.impl.DiffParseFunction;
import com.mzt.logapi.starter.annotation.LogRecord;
import com.zt.plat.framework.common.enums.CommonStatusEnum;
import com.zt.plat.framework.common.pojo.CompanyDeptInfo;
import com.zt.plat.framework.common.pojo.PageResult;
@@ -23,16 +27,13 @@ import com.zt.plat.module.system.dal.dataobject.user.AdminUserDO;
import com.zt.plat.module.system.dal.dataobject.userdept.UserDeptDO;
import com.zt.plat.module.system.dal.mysql.dept.UserPostMapper;
import com.zt.plat.module.system.dal.mysql.user.AdminUserMapper;
import com.zt.plat.module.system.enums.user.UserSourceEnum;
import com.zt.plat.module.system.service.dept.DeptService;
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 com.zt.plat.module.system.enums.user.UserSourceEnum;
import com.google.common.annotations.VisibleForTesting;
import com.mzt.logapi.context.LogRecordContext;
import com.mzt.logapi.service.impl.DiffParseFunction;
import com.mzt.logapi.starter.annotation.LogRecord;
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

@@ -1,6 +1,8 @@
package com.zt.plat.module.system.service.auth;
import cn.hutool.core.util.ReflectUtil;
import com.anji.captcha.model.common.ResponseModel;
import com.anji.captcha.service.CaptchaService;
import com.zt.plat.framework.common.enums.CommonStatusEnum;
import com.zt.plat.framework.common.enums.UserTypeEnum;
import com.zt.plat.framework.test.core.ut.BaseDbUnitTest;
@@ -15,13 +17,9 @@ 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;
import com.anji.captcha.model.common.ResponseModel;
import com.anji.captcha.service.CaptchaService;
import jakarta.annotation.Resource;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
@@ -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: