1. 修复界面bug
2. 新增 api 可配置匿名访问固定用户配置 3. 新增密码弱口令校验规则 4. e 办使用 loginName 确认唯一用户逻辑
This commit is contained in:
@@ -8,6 +8,7 @@ import com.zt.plat.module.databus.controller.admin.gateway.vo.credential.ApiClie
|
||||
import com.zt.plat.module.databus.controller.admin.gateway.vo.credential.ApiClientCredentialSaveReqVO;
|
||||
import com.zt.plat.module.databus.controller.admin.gateway.vo.credential.ApiClientCredentialSimpleRespVO;
|
||||
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiClientCredentialDO;
|
||||
import com.zt.plat.module.databus.service.gateway.ApiAnonymousUserService;
|
||||
import com.zt.plat.module.databus.service.gateway.ApiClientCredentialService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -26,6 +27,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import java.util.List;
|
||||
|
||||
import static com.zt.plat.framework.common.pojo.CommonResult.success;
|
||||
import static org.springframework.util.CollectionUtils.isEmpty;
|
||||
|
||||
@Tag(name = "管理后台 - API 客户端凭证")
|
||||
@RestController
|
||||
@@ -35,19 +37,24 @@ import static com.zt.plat.framework.common.pojo.CommonResult.success;
|
||||
public class ApiClientCredentialController {
|
||||
|
||||
private final ApiClientCredentialService credentialService;
|
||||
private final ApiAnonymousUserService anonymousUserService;
|
||||
|
||||
@GetMapping("/page")
|
||||
@Operation(summary = "分页查询客户端凭证")
|
||||
public CommonResult<PageResult<ApiClientCredentialRespVO>> page(ApiClientCredentialPageReqVO reqVO) {
|
||||
PageResult<ApiClientCredentialDO> page = credentialService.getPage(reqVO);
|
||||
return success(ApiClientCredentialConvert.INSTANCE.convertPage(page));
|
||||
PageResult<ApiClientCredentialRespVO> respPage = ApiClientCredentialConvert.INSTANCE.convertPage(page);
|
||||
populateAnonymousInfo(respPage.getList());
|
||||
return success(respPage);
|
||||
}
|
||||
|
||||
@GetMapping("/get")
|
||||
@Operation(summary = "查询凭证详情")
|
||||
public CommonResult<ApiClientCredentialRespVO> get(@RequestParam("id") Long id) {
|
||||
ApiClientCredentialDO credential = credentialService.get(id);
|
||||
return success(ApiClientCredentialConvert.INSTANCE.convert(credential));
|
||||
ApiClientCredentialRespVO respVO = ApiClientCredentialConvert.INSTANCE.convert(credential);
|
||||
populateAnonymousInfo(List.of(respVO));
|
||||
return success(respVO);
|
||||
}
|
||||
|
||||
@PostMapping("/create")
|
||||
@@ -76,4 +83,14 @@ public class ApiClientCredentialController {
|
||||
List<ApiClientCredentialDO> list = credentialService.listEnabled();
|
||||
return success(ApiClientCredentialConvert.INSTANCE.convertSimpleList(list));
|
||||
}
|
||||
|
||||
private void populateAnonymousInfo(List<ApiClientCredentialRespVO> list) {
|
||||
if (isEmpty(list)) {
|
||||
return;
|
||||
}
|
||||
list.stream()
|
||||
.filter(item -> Boolean.TRUE.equals(item.getAllowAnonymous()) && item.getAnonymousUserId() != null)
|
||||
.forEach(item -> anonymousUserService.find(item.getAnonymousUserId())
|
||||
.ifPresent(details -> item.setAnonymousUserNickname(details.getNickname())));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.zt.plat.module.databus.controller.admin.gateway.convert.ApiDefinition
|
||||
import com.zt.plat.module.databus.controller.admin.gateway.vo.ApiGatewayInvokeReqVO;
|
||||
import com.zt.plat.module.databus.controller.admin.gateway.vo.definition.ApiDefinitionDetailRespVO;
|
||||
import com.zt.plat.module.databus.framework.integration.gateway.core.ApiGatewayExecutionService;
|
||||
import com.zt.plat.module.databus.framework.integration.gateway.core.IntegrationFlowManager;
|
||||
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiGatewayResponse;
|
||||
import com.zt.plat.module.databus.service.gateway.ApiDefinitionService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -27,6 +28,7 @@ public class ApiGatewayController {
|
||||
|
||||
private final ApiGatewayExecutionService executionService;
|
||||
private final ApiDefinitionService apiDefinitionService;
|
||||
private final IntegrationFlowManager integrationFlowManager;
|
||||
|
||||
@PostMapping(value = "/invoke", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@Operation(summary = "测试调用 API 编排")
|
||||
@@ -43,4 +45,12 @@ public class ApiGatewayController {
|
||||
return success(definitions);
|
||||
}
|
||||
|
||||
@PostMapping("/cache/refresh")
|
||||
@Operation(summary = "刷新 API 缓存")
|
||||
public CommonResult<Boolean> refreshCache() {
|
||||
apiDefinitionService.refreshAllCache();
|
||||
integrationFlowManager.refreshAll();
|
||||
return success(Boolean.TRUE);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -33,6 +33,15 @@ public class ApiClientCredentialRespVO {
|
||||
@Schema(description = "备注", example = "默认应用凭证")
|
||||
private String remark;
|
||||
|
||||
@Schema(description = "允许匿名访问", example = "false")
|
||||
private Boolean allowAnonymous;
|
||||
|
||||
@Schema(description = "匿名访问固定用户 ID", example = "1024")
|
||||
private Long anonymousUserId;
|
||||
|
||||
@Schema(description = "匿名访问固定用户昵称", example = "张三")
|
||||
private String anonymousUserNickname;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
|
||||
@@ -38,4 +38,11 @@ public class ApiClientCredentialSaveReqVO {
|
||||
@Schema(description = "备注", example = "默认应用凭证")
|
||||
private String remark;
|
||||
|
||||
@Schema(description = "允许匿名访问", example = "false")
|
||||
@NotNull(message = "匿名访问标识不能为空")
|
||||
private Boolean allowAnonymous;
|
||||
|
||||
@Schema(description = "匿名访问固定用户 ID", example = "1024")
|
||||
private Long anonymousUserId;
|
||||
|
||||
}
|
||||
|
||||
@@ -34,4 +34,8 @@ public class ApiClientCredentialDO extends BaseDO {
|
||||
|
||||
private String remark;
|
||||
|
||||
private Boolean allowAnonymous;
|
||||
|
||||
private Long anonymousUserId;
|
||||
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ import java.util.Set;
|
||||
@ConfigurationProperties(prefix = "databus.api-portal")
|
||||
public class ApiGatewayProperties {
|
||||
|
||||
public static String APP_ID_HEADER = "ZT-App-Id";
|
||||
public static String TIMESTAMP_HEADER = "ZT-Timestamp";
|
||||
public static String NONCE_HEADER = "ZT-Nonce";
|
||||
public static String SIGNATURE_HEADER = "ZT-Signature";
|
||||
public static final String DEFAULT_BASE_PATH = "/admin-api/databus/api/portal";
|
||||
public static final String LEGACY_BASE_PATH = "/databus/api/portal";
|
||||
|
||||
@@ -62,14 +66,6 @@ public class ApiGatewayProperties {
|
||||
|
||||
private boolean enabled = true;
|
||||
|
||||
private String appIdHeader = "ZT-App-Id";
|
||||
|
||||
private String timestampHeader = "ZT-Timestamp";
|
||||
|
||||
private String nonceHeader = "ZT-Nonce";
|
||||
|
||||
private String signatureHeader = "ZT-Signature";
|
||||
|
||||
private String signatureType = CryptoSignatureUtils.SIGNATURE_TYPE_MD5;
|
||||
|
||||
private String encryptionType = CryptoSignatureUtils.ENCRYPT_TYPE_AES;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.zt.plat.module.databus.framework.integration.gateway.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.web.reactive.function.client.WebClientCustomizer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
|
||||
@Configuration
|
||||
public class GatewayWebClientConfiguration {
|
||||
|
||||
private final int maxInMemorySize;
|
||||
|
||||
public GatewayWebClientConfiguration(
|
||||
@Value("${databus.gateway.web-client.max-in-memory-size:2097152}") int maxInMemorySize) {
|
||||
this.maxInMemorySize = maxInMemorySize;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public WebClientCustomizer gatewayWebClientCustomizer() {
|
||||
return builder -> builder.codecs(configurer ->
|
||||
configurer.defaultCodecs().maxInMemorySize(maxInMemorySize));
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,17 @@ package com.zt.plat.module.databus.framework.integration.gateway.security;
|
||||
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.security.CryptoSignatureUtils;
|
||||
import com.zt.plat.framework.common.util.servlet.ServletUtils;
|
||||
import com.zt.plat.framework.security.core.LoginUser;
|
||||
import com.zt.plat.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import com.zt.plat.framework.tenant.core.context.TenantContextHolder;
|
||||
import com.zt.plat.framework.web.core.util.WebFrameworkUtils;
|
||||
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiClientCredentialDO;
|
||||
import com.zt.plat.module.databus.framework.integration.config.ApiGatewayProperties;
|
||||
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiGatewayResponse;
|
||||
import com.zt.plat.module.databus.service.gateway.ApiAnonymousUserService;
|
||||
import com.zt.plat.module.databus.service.gateway.ApiClientCredentialService;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
@@ -25,6 +31,7 @@ import org.springframework.web.util.ContentCachingResponseWrapper;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
@@ -32,6 +39,9 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static com.zt.plat.framework.common.util.security.CryptoSignatureUtils.SIGNATURE_FIELD;
|
||||
import static com.zt.plat.module.databus.framework.integration.config.ApiGatewayProperties.*;
|
||||
|
||||
/**
|
||||
* 对进入网关的请求统一执行 IP 校验、报文签名、加解密与防重复校验。
|
||||
*/
|
||||
@@ -43,6 +53,7 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
|
||||
private final ApiGatewayProperties properties;
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
private final ApiClientCredentialService credentialService;
|
||||
private final ApiAnonymousUserService anonymousUserService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AntPathMatcher pathMatcher = new AntPathMatcher();
|
||||
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
|
||||
@@ -51,6 +62,7 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
String pathWithinApplication = pathWithinApplication(request);
|
||||
// 仅处理配置的 API 门户路径,不符合的请求直接放行
|
||||
boolean matchesPortalPath = properties.getAllBasePaths()
|
||||
.stream()
|
||||
.map(this::normalizeBasePath)
|
||||
@@ -59,6 +71,7 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
// 校验访问 IP 是否落在允许范围内
|
||||
if (!isIpAllowed(request)) {
|
||||
log.warn("[API-PORTAL] 拦截来自 IP {} 访问 {} 的请求", request.getRemoteAddr(), pathWithinApplication);
|
||||
response.sendError(HttpStatus.FORBIDDEN.value(), "IP 禁止访问");
|
||||
@@ -72,28 +85,46 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
|
||||
}
|
||||
try {
|
||||
Long tenantId = resolveTenantId(request);
|
||||
String appId = requireHeader(request, security.getAppIdHeader(), "缺少应用标识");
|
||||
// 从请求头解析 appId 并加载客户端凭证,包含匿名访问配置
|
||||
String appId = requireHeader(request, APP_ID_HEADER, "缺少应用标识");
|
||||
credential = credentialService.findActiveCredential(appId)
|
||||
.orElseThrow(() -> new SecurityValidationException(HttpStatus.UNAUTHORIZED, "应用凭证不存在或已禁用"));
|
||||
boolean allowAnonymous = Boolean.TRUE.equals(credential.getAllowAnonymous());
|
||||
ApiAnonymousUserService.AnonymousUserDetails anonymousDetails = null;
|
||||
if (allowAnonymous) {
|
||||
Long anonymousUserId = credential.getAnonymousUserId();
|
||||
if (anonymousUserId == null) {
|
||||
throw new SecurityValidationException(HttpStatus.UNAUTHORIZED, "匿名访问未配置固定用户");
|
||||
}
|
||||
anonymousDetails = anonymousUserService.find(anonymousUserId)
|
||||
.orElseThrow(() -> new SecurityValidationException(HttpStatus.UNAUTHORIZED, "匿名访问固定用户不可用"));
|
||||
}
|
||||
|
||||
String timestampHeader = requireHeader(request, security.getTimestampHeader(), "缺少时间戳");
|
||||
String timestampHeader = requireHeader(request, TIMESTAMP_HEADER, "缺少时间戳");
|
||||
// 校验时间戳与随机数,防止请求被重放
|
||||
validateTimestamp(timestampHeader, security);
|
||||
String nonce = requireHeader(request, security.getNonceHeader(), "缺少随机数");
|
||||
String nonce = requireHeader(request, NONCE_HEADER, "缺少随机数");
|
||||
if (nonce.length() < 8) {
|
||||
throw new SecurityValidationException(HttpStatus.BAD_REQUEST, "随机数长度不足");
|
||||
}
|
||||
String signature = requireHeader(request, security.getSignatureHeader(), "缺少签名");
|
||||
String signature = requireHeader(request, SIGNATURE_HEADER, "缺少签名");
|
||||
|
||||
byte[] originalBody = StreamUtils.copyToByteArray(request.getInputStream());
|
||||
// 尝试按凭证配置解密请求体,并构建签名载荷进行校验
|
||||
byte[] decryptedBody = decryptRequestBody(originalBody, credential, security);
|
||||
verifySignature(request, decryptedBody, signature, credential, security);
|
||||
verifySignature(request, decryptedBody, signature, credential, security, appId, timestampHeader);
|
||||
ensureNonce(tenantId, appId, nonce, security);
|
||||
|
||||
// 使用可重复读取的请求包装,供后续过滤器继续消费
|
||||
CachedBodyHttpServletRequest securedRequest = new CachedBodyHttpServletRequest(request, decryptedBody);
|
||||
if (StringUtils.hasText(request.getCharacterEncoding())) {
|
||||
securedRequest.setCharacterEncoding(request.getCharacterEncoding());
|
||||
}
|
||||
propagateJwtToken(request, securedRequest);
|
||||
if (allowAnonymous) {
|
||||
applyAnonymousLogin(securedRequest, anonymousDetails);
|
||||
} else {
|
||||
propagateJwtToken(request, securedRequest);
|
||||
}
|
||||
ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response);
|
||||
try {
|
||||
filterChain.doFilter(securedRequest, responseWrapper);
|
||||
@@ -213,11 +244,15 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
|
||||
byte[] decryptedBody,
|
||||
String signature,
|
||||
ApiClientCredentialDO credential,
|
||||
ApiGatewayProperties.Security security) {
|
||||
ApiGatewayProperties.Security security,
|
||||
String appId,
|
||||
String timestampHeader) {
|
||||
Map<String, Object> signaturePayload = new LinkedHashMap<>();
|
||||
mergeQueryParameters(signaturePayload, request);
|
||||
mergeBodyParameters(signaturePayload, decryptedBody);
|
||||
signaturePayload.put("signature", signature);
|
||||
signaturePayload.put(APP_ID_HEADER, appId);
|
||||
signaturePayload.put(TIMESTAMP_HEADER, timestampHeader);
|
||||
signaturePayload.put(SIGNATURE_FIELD, signature);
|
||||
|
||||
String signatureType = resolveSignatureType(credential, security);
|
||||
try {
|
||||
@@ -331,12 +366,16 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
|
||||
if (!security.isEncryptResponse()) {
|
||||
return;
|
||||
}
|
||||
byte[] plainBody = responseWrapper.getContentAsByteArray();
|
||||
if (plainBody.length == 0) {
|
||||
// 空响应无需加密,直接返回避免因密钥缺失导致异常
|
||||
return;
|
||||
}
|
||||
String encryptionKey = credential.getEncryptionKey();
|
||||
String encryptionType = resolveEncryptionType(credential, security);
|
||||
if (!StringUtils.hasText(encryptionKey) || !StringUtils.hasText(encryptionType)) {
|
||||
throw new SecurityValidationException(HttpStatus.INTERNAL_SERVER_ERROR, "应用未配置加密密钥");
|
||||
}
|
||||
byte[] plainBody = responseWrapper.getContentAsByteArray();
|
||||
String charsetName = responseWrapper.getCharacterEncoding();
|
||||
if (!StringUtils.hasText(charsetName)) {
|
||||
charsetName = StandardCharsets.UTF_8.name();
|
||||
@@ -372,6 +411,33 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
|
||||
securedRequest.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 匿名访问场景下,将固定用户写入安全上下文并同步到请求头,确保后续业务链路能够识别身份。
|
||||
*/
|
||||
private void applyAnonymousLogin(CachedBodyHttpServletRequest securedRequest,
|
||||
ApiAnonymousUserService.AnonymousUserDetails anonymousDetails) {
|
||||
LoginUser loginUser = anonymousDetails.getLoginUser();
|
||||
SecurityFrameworkUtils.setLoginUser(loginUser, securedRequest);
|
||||
Long tenantId = loginUser.getTenantId();
|
||||
// 设置租户标识到请求头与上下文
|
||||
securedRequest.setHeader(WebFrameworkUtils.HEADER_TENANT_ID, tenantId != null ? tenantId.toString() : null);
|
||||
TenantContextHolder.setTenantId(tenantId);
|
||||
try {
|
||||
String serialized = JsonUtils.toJsonString(loginUser);
|
||||
String encoded = URLEncoder.encode(serialized, StandardCharsets.UTF_8);
|
||||
securedRequest.setHeader(SecurityFrameworkUtils.LOGIN_USER_HEADER, encoded);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[API-PORTAL] 序列化匿名访问用户失败", ex);
|
||||
}
|
||||
securedRequest.removeHeader(GatewayJwtResolver.HEADER_ZT_AUTH_TOKEN);
|
||||
securedRequest.removeHeader(HttpHeaders.AUTHORIZATION);
|
||||
anonymousUserService.issueAccessToken(anonymousDetails)
|
||||
.ifPresent(token -> {
|
||||
securedRequest.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
|
||||
securedRequest.setHeader(GatewayJwtResolver.HEADER_ZT_AUTH_TOKEN, token);
|
||||
});
|
||||
}
|
||||
|
||||
private static final class SecurityValidationException extends RuntimeException {
|
||||
|
||||
private final HttpStatus status;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.zt.plat.module.databus.framework.rpc.config;
|
||||
|
||||
import com.zt.plat.framework.common.biz.system.oauth2.OAuth2TokenCommonApi;
|
||||
import com.zt.plat.module.system.api.user.AdminUserApi;
|
||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Databus 模块的 RPC 配置,开启所需的 Feign 客户端。
|
||||
*/
|
||||
@Configuration(value = "databusRpcConfiguration", proxyBeanMethods = false)
|
||||
@EnableFeignClients(clients = {AdminUserApi.class, OAuth2TokenCommonApi.class})
|
||||
public class RpcConfiguration {
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.zt.plat.module.databus.service.gateway;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import com.github.benmanes.caffeine.cache.LoadingCache;
|
||||
import com.zt.plat.framework.common.biz.system.oauth2.OAuth2TokenCommonApi;
|
||||
import com.zt.plat.framework.common.biz.system.oauth2.dto.OAuth2AccessTokenCreateReqDTO;
|
||||
import com.zt.plat.framework.common.biz.system.oauth2.dto.OAuth2AccessTokenRespDTO;
|
||||
import com.zt.plat.framework.common.enums.CommonStatusEnum;
|
||||
import com.zt.plat.framework.common.enums.UserTypeEnum;
|
||||
import com.zt.plat.framework.security.core.LoginUser;
|
||||
import com.zt.plat.framework.tenant.core.aop.TenantIgnore;
|
||||
import com.zt.plat.module.system.api.user.AdminUserApi;
|
||||
import com.zt.plat.module.system.api.user.dto.AdminUserRespDTO;
|
||||
import com.zt.plat.module.system.enums.oauth2.OAuth2ClientConstants;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static com.zt.plat.framework.security.core.LoginUser.INFO_KEY_NICKNAME;
|
||||
import static com.zt.plat.framework.security.core.LoginUser.INFO_KEY_TENANT_ID;
|
||||
|
||||
/**
|
||||
* 缓存并提供匿名访问所需的固定用户信息,包含对应的 LoginUser 快照。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ApiAnonymousUserService {
|
||||
|
||||
private final AdminUserApi adminUserApi;
|
||||
private final OAuth2TokenCommonApi oauth2TokenApi;
|
||||
|
||||
private LoadingCache<Long, Optional<AnonymousUserDetails>> cache;
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
cache = Caffeine.newBuilder()
|
||||
.maximumSize(256)
|
||||
.expireAfterWrite(Duration.ofMinutes(5))
|
||||
.build(this::loadUserDetails);
|
||||
}
|
||||
|
||||
public Optional<AnonymousUserDetails> find(Long userId) {
|
||||
if (userId == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return cache.get(userId);
|
||||
}
|
||||
|
||||
public AnonymousUserDetails getOrThrow(Long userId) {
|
||||
return find(userId).orElseThrow(() -> new IllegalStateException("匿名访问固定用户不存在或不可用"));
|
||||
}
|
||||
|
||||
public void invalidate(Long userId) {
|
||||
if (userId != null) {
|
||||
cache.invalidate(userId);
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<AnonymousUserDetails> loadUserDetails(Long userId) {
|
||||
AdminUserRespDTO user = adminUserApi.getUser(userId).getCheckedData();
|
||||
if (user == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (!CommonStatusEnum.isEnable(user.getStatus())) {
|
||||
return Optional.empty();
|
||||
}
|
||||
LoginUser loginUser = buildLoginUser(user);
|
||||
return Optional.of(new AnonymousUserDetails(user.getId(), user.getNickname(), loginUser));
|
||||
}
|
||||
|
||||
private LoginUser buildLoginUser(AdminUserRespDTO user) {
|
||||
LoginUser loginUser = new LoginUser();
|
||||
loginUser.setId(user.getId());
|
||||
loginUser.setUserType(UserTypeEnum.ADMIN.getValue());
|
||||
loginUser.setTenantId(user.getTenantId());
|
||||
loginUser.setVisitTenantId(user.getTenantId());
|
||||
if (!CollectionUtils.isEmpty(user.getDeptIds())) {
|
||||
loginUser.setVisitDeptId(user.getDeptIds().get(0));
|
||||
}
|
||||
Map<String, String> info = new HashMap<>(2);
|
||||
if (user.getNickname() != null) {
|
||||
info.put(INFO_KEY_NICKNAME, user.getNickname());
|
||||
}
|
||||
if (user.getTenantId() != null) {
|
||||
info.put(INFO_KEY_TENANT_ID, String.valueOf(user.getTenantId()));
|
||||
}
|
||||
loginUser.setInfo(info);
|
||||
loginUser.setScopes(Collections.emptyList());
|
||||
return loginUser;
|
||||
}
|
||||
|
||||
@TenantIgnore
|
||||
public Optional<String> issueAccessToken(AnonymousUserDetails details) {
|
||||
if (details == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
OAuth2AccessTokenCreateReqDTO req = buildAccessTokenRequest(details);
|
||||
OAuth2AccessTokenRespDTO resp = oauth2TokenApi.createAccessToken(req).getCheckedData();
|
||||
if (resp == null || !StringUtils.hasText(resp.getAccessToken())) {
|
||||
log.warn("[ANONYMOUS] 获取用户 {} 的访问令牌失败: 响应为空", details.getUserId());
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(resp.getAccessToken());
|
||||
} catch (Exception ex) {
|
||||
log.error("[ANONYMOUS] 获取用户 {} 的访问令牌时发生异常", details.getUserId(), ex);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private OAuth2AccessTokenCreateReqDTO buildAccessTokenRequest(AnonymousUserDetails details) {
|
||||
OAuth2AccessTokenCreateReqDTO req = new OAuth2AccessTokenCreateReqDTO();
|
||||
req.setUserId(details.getUserId());
|
||||
Integer userType = Optional.ofNullable(details.getLoginUser())
|
||||
.map(LoginUser::getUserType)
|
||||
.orElse(UserTypeEnum.ADMIN.getValue());
|
||||
req.setUserType(userType);
|
||||
req.setClientId(OAuth2ClientConstants.CLIENT_ID_DEFAULT);
|
||||
if (details.getLoginUser() != null && !CollectionUtils.isEmpty(details.getLoginUser().getScopes())) {
|
||||
req.setScopes(details.getLoginUser().getScopes());
|
||||
} else {
|
||||
req.setScopes(Collections.emptyList());
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
@Getter
|
||||
public static final class AnonymousUserDetails {
|
||||
private final Long userId;
|
||||
private final String nickname;
|
||||
private final LoginUser loginUser;
|
||||
|
||||
public AnonymousUserDetails(Long userId, String nickname, LoginUser loginUser) {
|
||||
this.userId = userId;
|
||||
this.nickname = nickname;
|
||||
this.loginUser = loginUser;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,11 @@ public interface ApiDefinitionService {
|
||||
*/
|
||||
Optional<ApiDefinitionAggregate> refresh(String apiCode, String version);
|
||||
|
||||
/**
|
||||
* Evict all cached API definitions, forcing rebuild on next access.
|
||||
*/
|
||||
void refreshAllCache();
|
||||
|
||||
/**
|
||||
* Lookup API definition aggregate by primary key.
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.zt.plat.module.databus.controller.admin.gateway.vo.credential.ApiClie
|
||||
import com.zt.plat.module.databus.controller.admin.gateway.vo.credential.ApiClientCredentialSaveReqVO;
|
||||
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiClientCredentialDO;
|
||||
import com.zt.plat.module.databus.dal.mysql.gateway.ApiClientCredentialMapper;
|
||||
import com.zt.plat.module.databus.service.gateway.ApiAnonymousUserService;
|
||||
import com.zt.plat.module.databus.service.gateway.ApiClientCredentialService;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -24,6 +25,8 @@ import java.util.Optional;
|
||||
|
||||
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_CREDENTIAL_DUPLICATE_APP;
|
||||
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_CREDENTIAL_NOT_FOUND;
|
||||
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_CREDENTIAL_ANONYMOUS_USER_INVALID;
|
||||
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_CREDENTIAL_ANONYMOUS_USER_REQUIRED;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -31,6 +34,7 @@ import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErro
|
||||
public class ApiClientCredentialServiceImpl implements ApiClientCredentialService {
|
||||
|
||||
private final ApiClientCredentialMapper credentialMapper;
|
||||
private final ApiAnonymousUserService anonymousUserService;
|
||||
|
||||
private LoadingCache<String, Optional<ApiClientCredentialDO>> credentialCache;
|
||||
|
||||
@@ -51,10 +55,17 @@ public class ApiClientCredentialServiceImpl implements ApiClientCredentialServic
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long create(ApiClientCredentialSaveReqVO reqVO) {
|
||||
ensureAppIdUnique(reqVO.getAppId(), null);
|
||||
normalizeAnonymousSettings(reqVO);
|
||||
|
||||
ApiClientCredentialDO credential = BeanUtils.toBean(reqVO, ApiClientCredentialDO.class);
|
||||
credential.setId(null);
|
||||
credential.setDeleted(Boolean.FALSE);
|
||||
if (credential.getAllowAnonymous() == null) {
|
||||
credential.setAllowAnonymous(Boolean.FALSE);
|
||||
}
|
||||
if (Boolean.FALSE.equals(credential.getAllowAnonymous())) {
|
||||
credential.setAnonymousUserId(null);
|
||||
}
|
||||
credentialMapper.insert(credential);
|
||||
invalidateCache(credential.getAppId());
|
||||
return credential.getId();
|
||||
@@ -65,11 +76,22 @@ public class ApiClientCredentialServiceImpl implements ApiClientCredentialServic
|
||||
public void update(ApiClientCredentialSaveReqVO reqVO) {
|
||||
ApiClientCredentialDO existing = ensureExists(reqVO.getId());
|
||||
ensureAppIdUnique(reqVO.getAppId(), existing.getId());
|
||||
normalizeAnonymousSettings(reqVO);
|
||||
|
||||
ApiClientCredentialDO updateObj = BeanUtils.toBean(reqVO, ApiClientCredentialDO.class);
|
||||
if (updateObj.getAllowAnonymous() == null) {
|
||||
updateObj.setAllowAnonymous(Boolean.FALSE);
|
||||
}
|
||||
if (Boolean.FALSE.equals(updateObj.getAllowAnonymous())) {
|
||||
updateObj.setAnonymousUserId(null);
|
||||
}
|
||||
credentialMapper.updateById(updateObj);
|
||||
invalidateCache(existing.getAppId());
|
||||
invalidateCache(updateObj.getAppId());
|
||||
if (!Objects.equals(existing.getAnonymousUserId(), updateObj.getAnonymousUserId())) {
|
||||
anonymousUserService.invalidate(existing.getAnonymousUserId());
|
||||
anonymousUserService.invalidate(updateObj.getAnonymousUserId());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -78,6 +100,7 @@ public class ApiClientCredentialServiceImpl implements ApiClientCredentialServic
|
||||
ApiClientCredentialDO existing = ensureExists(id);
|
||||
credentialMapper.deleteById(id);
|
||||
invalidateCache(existing.getAppId());
|
||||
anonymousUserService.invalidate(existing.getAnonymousUserId());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -130,4 +153,18 @@ public class ApiClientCredentialServiceImpl implements ApiClientCredentialServic
|
||||
}
|
||||
credentialCache.invalidate(appId.trim());
|
||||
}
|
||||
|
||||
private void normalizeAnonymousSettings(ApiClientCredentialSaveReqVO reqVO) {
|
||||
if (Boolean.TRUE.equals(reqVO.getAllowAnonymous())) {
|
||||
if (reqVO.getAnonymousUserId() == null) {
|
||||
throw ServiceExceptionUtil.exception(API_CREDENTIAL_ANONYMOUS_USER_REQUIRED);
|
||||
}
|
||||
if (anonymousUserService.find(reqVO.getAnonymousUserId()).isEmpty()) {
|
||||
throw ServiceExceptionUtil.exception(API_CREDENTIAL_ANONYMOUS_USER_INVALID);
|
||||
}
|
||||
} else {
|
||||
reqVO.setAllowAnonymous(Boolean.FALSE);
|
||||
reqVO.setAnonymousUserId(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.Duration;
|
||||
@@ -102,6 +103,12 @@ public class ApiDefinitionServiceImpl implements ApiDefinitionService {
|
||||
return findByCodeAndVersion(apiCode, version);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void refreshAllCache() {
|
||||
definitionCache.invalidateAll();
|
||||
clearRedisCacheForTenant(TenantContextHolder.getTenantId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ApiDefinitionAggregate> findById(Long id) {
|
||||
return Optional.ofNullable(apiDefinitionMapper.selectById(id))
|
||||
@@ -205,11 +212,28 @@ public class ApiDefinitionServiceImpl implements ApiDefinitionService {
|
||||
}
|
||||
}
|
||||
|
||||
private void clearRedisCacheForTenant(Long tenantId) {
|
||||
String tenantPart = tenantId == null ? "global" : tenantId.toString();
|
||||
String pattern = REDIS_CACHE_PREFIX + tenantPart + ":*";
|
||||
try {
|
||||
Set<String> keys = stringRedisTemplate.keys(pattern);
|
||||
if (CollectionUtils.isEmpty(keys)) {
|
||||
return;
|
||||
}
|
||||
stringRedisTemplate.delete(keys);
|
||||
} catch (DataAccessException ex) {
|
||||
log.warn("批量删除 Redis 中匹配 {} 的 API 定义聚合失败", pattern, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private String buildCacheKey(String apiCode, String version) {
|
||||
Long tenantId = TenantContextHolder.getTenantId();
|
||||
return buildCacheKeyForTenant(tenantId, apiCode, version);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建包含步骤、变换、策略等元数据的聚合对象,供缓存与运行时直接使用。
|
||||
*/
|
||||
private ApiDefinitionAggregate buildAggregate(ApiDefinitionDO definition) {
|
||||
List<ApiStepDO> stepDOS = apiStepMapper.selectByApiId(definition.getId());
|
||||
List<ApiStepDefinition> stepDefinitions = new ArrayList<>(stepDOS.size());
|
||||
@@ -313,6 +337,9 @@ public class ApiDefinitionServiceImpl implements ApiDefinitionService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 顺序持久化步骤定义,并针对开始/结束节点清理不必要配置避免脏数据。
|
||||
*/
|
||||
private void persistSteps(Long apiId, List<ApiDefinitionStepSaveReqVO> steps) {
|
||||
if (CollUtil.isEmpty(steps)) {
|
||||
return;
|
||||
@@ -323,14 +350,7 @@ public class ApiDefinitionServiceImpl implements ApiDefinitionService {
|
||||
ApiStepDO stepDO = BeanUtils.toBean(stepVO, ApiStepDO.class);
|
||||
stepDO.setId(null);
|
||||
stepDO.setApiId(apiId);
|
||||
if (isStartStep(stepVO)) {
|
||||
stepDO.setParallelGroup(null);
|
||||
stepDO.setTargetEndpoint(null);
|
||||
stepDO.setFallbackStrategy(null);
|
||||
stepDO.setConditionExpr(null);
|
||||
stepDO.setStopOnError(Boolean.FALSE);
|
||||
stepDO.setTimeout(null);
|
||||
} else if (isEndStep(stepVO)) {
|
||||
if (isStartStep(stepVO) || isEndStep(stepVO)) {
|
||||
stepDO.setParallelGroup(null);
|
||||
stepDO.setTargetEndpoint(null);
|
||||
stepDO.setFallbackStrategy(null);
|
||||
@@ -380,6 +400,9 @@ public class ApiDefinitionServiceImpl implements ApiDefinitionService {
|
||||
.ifPresent(definition -> { throw ServiceExceptionUtil.exception(API_DEFINITION_DUPLICATE); });
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验步骤编排的拓扑约束,确保开始/结束节点唯一且位置正确。
|
||||
*/
|
||||
private void validateStructure(ApiDefinitionSaveReqVO reqVO) {
|
||||
if (CollUtil.isEmpty(reqVO.getSteps())) {
|
||||
throw ServiceExceptionUtil.exception(API_DEFINITION_STEP_EMPTY);
|
||||
|
||||
@@ -52,5 +52,8 @@ public interface GatewayServiceErrorCodeConstants {
|
||||
ErrorCode API_SIGNATURE_CONFIG_INVALID = new ErrorCode(1_010_000_043, "签名策略配置异常");
|
||||
ErrorCode API_CREDENTIAL_NOT_FOUND = new ErrorCode(1_010_000_044, "应用凭证不存在或已删除");
|
||||
ErrorCode API_CREDENTIAL_DUPLICATE_APP = new ErrorCode(1_010_000_045, "应用标识已存在");
|
||||
ErrorCode API_STEP_MAPPING_CONFIG_INVALID = new ErrorCode(1_010_000_046, "步骤映射配置 JSON 非法");
|
||||
ErrorCode API_CREDENTIAL_ANONYMOUS_USER_REQUIRED = new ErrorCode(1_010_000_047, "启用匿名访问时必须指定固定用户");
|
||||
ErrorCode API_CREDENTIAL_ANONYMOUS_USER_INVALID = new ErrorCode(1_010_000_048, "匿名访问固定用户不存在或已被禁用");
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user