Merge remote-tracking branch 'base-version/main' into dev
# Conflicts: # zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/tenant/vo/tenant/TenantSaveReqVO.java
This commit is contained in:
@@ -7,6 +7,12 @@ ALTER TABLE infra_file ADD aes_iv VARCHAR(128);
|
||||
|
||||
COMMENT ON COLUMN infra_file.aes_iv IS 'AES加密时的随机IV(Base64编码)';
|
||||
|
||||
-- 3. Databus 客户端凭证新增匿名访问配置(DM8)
|
||||
ALTER TABLE databus_api_client_credential ADD allow_anonymous BIT DEFAULT '0' NOT NULL;
|
||||
ALTER TABLE databus_api_client_credential ADD anonymous_user_id BIGINT;
|
||||
COMMENT ON COLUMN databus_api_client_credential.allow_anonymous IS '是否允许匿名访问';
|
||||
COMMENT ON COLUMN databus_api_client_credential.anonymous_user_id IS '匿名访问固定用户';
|
||||
|
||||
-- 3 业务附件统一管理
|
||||
DROP TABLE IF EXISTS infra_bsn_file;
|
||||
CREATE TABLE infra_bsn_file (
|
||||
|
||||
@@ -656,6 +656,7 @@ INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_t
|
||||
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (36, 3, '本部门数据权限', '3', 'system_data_scope', 0, '', '', '本部门数据权限', 'admin', '2021-01-05 17:03:48', '', '2022-02-01 16:47:16', '0');
|
||||
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (37, 4, '本部门及以下数据权限', '4', 'system_data_scope', 0, '', '', '本部门及以下数据权限', 'admin', '2021-01-05 17:03:48', '', '2022-02-01 16:47:21', '0');
|
||||
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (38, 5, '仅本人数据权限', '5', 'system_data_scope', 0, '', '', '仅本人数据权限', 'admin', '2021-01-05 17:03:48', '', '2022-02-01 16:47:23', '0');
|
||||
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (200010, 6, '公司及所属部门数据权限', '6', 'system_data_scope', 0, '', '', '公司及所属部门数据权限', 'admin', '2021-01-05 17:03:48', '', '2025-10-24 00:00:00', '0');
|
||||
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (39, 0, '成功', '0', 'system_login_result', 0, 'success', '', '登陆结果 - 成功', '', '2021-01-18 06:17:36', '1', '2022-02-16 13:23:49', '0');
|
||||
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (40, 10, '账号或密码不正确', '10', 'system_login_result', 0, 'primary', '', '登陆结果 - 账号或密码不正确', '', '2021-01-18 06:17:54', '1', '2022-02-16 13:24:27', '0');
|
||||
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (41, 20, '用户被禁用', '20', 'system_login_result', 0, 'warning', '', '登陆结果 - 用户被禁用', '', '2021-01-18 06:17:54', '1', '2022-02-16 13:23:57', '0');
|
||||
|
||||
@@ -198,7 +198,7 @@ COMMENT ON COLUMN databus_api_transform.update_time IS '更新时间';
|
||||
COMMENT ON COLUMN databus_api_transform.deleted IS '逻辑删除标记';
|
||||
|
||||
|
||||
-- 统一外部网关 - 客户端凭证表(DM8)
|
||||
-- 统一外部网关 - 客户端凭证表(DM8)
|
||||
-- 可重复执行的建表脚本,执行前请备份历史数据
|
||||
|
||||
DROP TABLE IF EXISTS databus_api_client_credential;
|
||||
@@ -212,6 +212,8 @@ CREATE TABLE databus_api_client_credential (
|
||||
signature_type VARCHAR(32) NOT NULL,
|
||||
enabled BIT DEFAULT '1' NOT NULL,
|
||||
remark VARCHAR(255),
|
||||
allow_anonymous BIT DEFAULT '0' NOT NULL,
|
||||
anonymous_user_id BIGINT,
|
||||
creator VARCHAR(64) DEFAULT '' NOT NULL,
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updater VARCHAR(64) DEFAULT '' NOT NULL,
|
||||
@@ -231,6 +233,8 @@ COMMENT ON COLUMN databus_api_client_credential.encryption_type IS '加密算法
|
||||
COMMENT ON COLUMN databus_api_client_credential.signature_type IS '签名算法';
|
||||
COMMENT ON COLUMN databus_api_client_credential.enabled IS '是否启用';
|
||||
COMMENT ON COLUMN databus_api_client_credential.remark IS '备注';
|
||||
COMMENT ON COLUMN databus_api_client_credential.allow_anonymous IS '是否允许匿名访问';
|
||||
COMMENT ON COLUMN databus_api_client_credential.anonymous_user_id IS '匿名访问固定用户';
|
||||
COMMENT ON COLUMN databus_api_client_credential.creator IS '创建者';
|
||||
COMMENT ON COLUMN databus_api_client_credential.create_time IS '创建时间';
|
||||
COMMENT ON COLUMN databus_api_client_credential.updater IS '更新者';
|
||||
|
||||
@@ -9,11 +9,7 @@ import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 通用的签名、加解密工具类
|
||||
@@ -26,7 +22,7 @@ public final class CryptoSignatureUtils {
|
||||
public static final String SIGNATURE_TYPE_SHA256 = "SHA256";
|
||||
|
||||
private static final String AES_TRANSFORMATION = "AES/ECB/PKCS5Padding";
|
||||
private static final String SIGNATURE_FIELD = "signature";
|
||||
public static final String SIGNATURE_FIELD = "signature";
|
||||
|
||||
private CryptoSignatureUtils() {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.zt.plat.framework.common.validation;
|
||||
|
||||
import jakarta.validation.Constraint;
|
||||
import jakarta.validation.Payload;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
/**
|
||||
* 密码复杂度校验注解,要求至少包含大小写字母、数字、特殊字符中的三种。
|
||||
* @author chenbowen
|
||||
*/
|
||||
@Target({
|
||||
ElementType.METHOD,
|
||||
ElementType.FIELD,
|
||||
ElementType.ANNOTATION_TYPE,
|
||||
ElementType.CONSTRUCTOR,
|
||||
ElementType.PARAMETER,
|
||||
ElementType.TYPE_USE
|
||||
})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
@Constraint(validatedBy = PasswordValidator.class)
|
||||
public @interface Password {
|
||||
|
||||
String message() default "密码必须包含大写字母、小写字母、数字、特殊字符中的至少三种";
|
||||
|
||||
Class<?>[] groups() default {};
|
||||
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.zt.plat.framework.common.validation;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import jakarta.validation.ConstraintValidator;
|
||||
import jakarta.validation.ConstraintValidatorContext;
|
||||
|
||||
/**
|
||||
* 密码复杂度校验:至少命中以下类别中的三类:大写字母、小写字母、数字、特殊字符。
|
||||
*/
|
||||
public class PasswordValidator implements ConstraintValidator<Password, String> {
|
||||
|
||||
@Override
|
||||
public void initialize(Password constraintAnnotation) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid(String value, ConstraintValidatorContext context) {
|
||||
if (StrUtil.isBlank(value)) {
|
||||
// 空值交由 @NotEmpty 等注解处理;在无需修改密码时视为空密码通过
|
||||
return true;
|
||||
}
|
||||
int categories = 0;
|
||||
if (value.matches(".*[A-Z].*")) {
|
||||
categories++;
|
||||
}
|
||||
if (value.matches(".*[a-z].*")) {
|
||||
categories++;
|
||||
}
|
||||
if (value.matches(".*[0-9].*")) {
|
||||
categories++;
|
||||
}
|
||||
if (value.matches(".*[^A-Za-z0-9].*")) {
|
||||
categories++;
|
||||
}
|
||||
return categories >= 3;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.zt.plat.framework.common.validation;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class PasswordValidatorTest {
|
||||
|
||||
private final PasswordValidator validator = new PasswordValidator();
|
||||
|
||||
@Test
|
||||
void shouldAcceptBlankPassword() {
|
||||
assertTrue(validator.isValid(null, null));
|
||||
assertTrue(validator.isValid("", null));
|
||||
assertTrue(validator.isValid(" ", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectInsufficientComplexity() {
|
||||
assertFalse(validator.isValid("abcdef", null));
|
||||
assertFalse(validator.isValid("ABCDEF", null));
|
||||
assertFalse(validator.isValid("ABC123", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAcceptComplexPassword() {
|
||||
assertTrue(validator.isValid("Abc123!", null));
|
||||
assertTrue(validator.isValid("1a#BCdef", null));
|
||||
}
|
||||
}
|
||||
@@ -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, "匿名访问固定用户不存在或已被禁用");
|
||||
|
||||
}
|
||||
|
||||
@@ -3,12 +3,15 @@ package com.zt.plat.module.databus.controller.admin.gateway;
|
||||
import com.zt.plat.module.databus.controller.admin.gateway.vo.ApiGatewayInvokeReqVO;
|
||||
import com.zt.plat.module.databus.framework.integration.gateway.core.ApiGatewayExecutionService;
|
||||
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiGatewayResponse;
|
||||
import com.zt.plat.module.databus.framework.integration.config.ApiGatewayProperties;
|
||||
import com.zt.plat.module.databus.service.gateway.ApiClientCredentialService;
|
||||
import com.zt.plat.module.databus.service.gateway.ApiDefinitionService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
@@ -25,7 +28,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
@WebMvcTest(ApiGatewayController.class)
|
||||
@AutoConfigureMockMvc(addFilters = false)
|
||||
@TestPropertySource(properties = {
|
||||
"spring.config.import=optional:",
|
||||
"spring.cloud.nacos.config.enabled=false",
|
||||
"spring.cloud.nacos.discovery.enabled=false"
|
||||
})
|
||||
@@ -40,6 +42,15 @@ class ApiGatewayControllerTest {
|
||||
@MockBean
|
||||
private ApiDefinitionService apiDefinitionService;
|
||||
|
||||
@MockBean
|
||||
private ApiGatewayProperties apiGatewayProperties;
|
||||
|
||||
@MockBean
|
||||
private StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
@MockBean
|
||||
private ApiClientCredentialService apiClientCredentialService;
|
||||
|
||||
@Test
|
||||
void invokeShouldReturnGatewayEnvelope() throws Exception {
|
||||
ApiGatewayResponse response = ApiGatewayResponse.builder()
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
package com.zt.plat.module.databus.framework.integration.gateway.sample;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.zt.plat.framework.common.util.security.CryptoSignatureUtils;
|
||||
|
||||
import java.io.PrintStream;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 可直接运行的示例,演示如何使用 appId=test 与对应密钥调用本地 Databus API。
|
||||
*/
|
||||
public final class DatabusApiInvocationExample {
|
||||
|
||||
public static final String TIMESTAMP = Long.toString(System.currentTimeMillis());
|
||||
// private static final String APP_ID = "ztmy";
|
||||
// private static final String APP_SECRET = "zFre/nTRGi7LpoFjN7oQkKeOT09x1fWTyIswrc702QQ=";
|
||||
private static final String APP_ID = "test";
|
||||
private static final String APP_SECRET = "RSYtKXrXPLMy3oeh0cOro6QCioRUgqfnKCkDkNq78sI=";
|
||||
private static final String ENCRYPTION_TYPE = CryptoSignatureUtils.ENCRYPT_TYPE_AES;
|
||||
// private static final String TARGET_API = "http://127.0.0.1:48080/admin-api/databus/api/portal/lgstOpenApi/v1";
|
||||
private static final String TARGET_API = "http://127.0.0.1:48080/admin-api/databus/api/portal/test11111/233";
|
||||
private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(5))
|
||||
.build();
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
private static final PrintStream OUT = buildConsolePrintStream();
|
||||
public static final String ZT_APP_ID = "ZT-App-Id";
|
||||
public static final String ZT_TIMESTAMP = "ZT-Timestamp";
|
||||
public static final String ZT_NONCE = "ZT-Nonce";
|
||||
public static final String ZT_SIGNATURE = "ZT-Signature";
|
||||
public static final String ZT_AUTH_TOKEN = "ZT-Auth-Token";
|
||||
public static final String CONTENT_TYPE = "Content-Type";
|
||||
|
||||
private DatabusApiInvocationExample() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
OUT.println("=== GET 请求示例 ===");
|
||||
executeGetExample();
|
||||
// OUT.println();
|
||||
// OUT.println("=== POST 请求示例 ===");
|
||||
// executePostExample();
|
||||
}
|
||||
|
||||
private static void executeGetExample() throws Exception {
|
||||
Map<String, Object> queryParams = new LinkedHashMap<>();
|
||||
queryParams.put("businessCode", "waybillUnLoadingImage");
|
||||
queryParams.put("fileId", "1979463299195412481");
|
||||
String signature = generateSignature(queryParams, Map.of());
|
||||
URI requestUri = buildUri(TARGET_API, queryParams);
|
||||
String nonce = randomNonce();
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder(requestUri)
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.header(ZT_APP_ID, APP_ID)
|
||||
.header(ZT_TIMESTAMP, TIMESTAMP)
|
||||
.header(ZT_NONCE, nonce)
|
||||
.header(ZT_SIGNATURE, signature)
|
||||
// .header("ZT-Auth-Token", "a75c0ea94c7f4a88b86b60bbc0b432c3")
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
printResponse(response);
|
||||
}
|
||||
|
||||
private static void executePostExample() throws Exception {
|
||||
Map<String, Object> queryParams = new LinkedHashMap<>();
|
||||
|
||||
LinkedHashMap<String, Object> bodyParams = new LinkedHashMap<>();
|
||||
bodyParams.put("businessCode", "waybillUnLoadingImage");
|
||||
bodyParams.put("fileId", "1979463299195412481");
|
||||
|
||||
LinkedHashMap<String, Object> extra = new LinkedHashMap<>();
|
||||
extra.put("remark", "demo invocation");
|
||||
extra.put("timestamp", System.currentTimeMillis());
|
||||
bodyParams.put("extra", extra);
|
||||
|
||||
String signature = generateSignature(queryParams, bodyParams);
|
||||
URI requestUri = buildUri(TARGET_API, queryParams);
|
||||
String nonce = randomNonce();
|
||||
String bodyJson = OBJECT_MAPPER.writeValueAsString(bodyParams);
|
||||
String cipherBody = encryptPayload(bodyJson);
|
||||
OUT.println("原始 Request Body: " + bodyJson);
|
||||
OUT.println("加密 Request Body: " + cipherBody);
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder(requestUri)
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.header(ZT_APP_ID, APP_ID)
|
||||
.header(ZT_TIMESTAMP, TIMESTAMP)
|
||||
.header(ZT_NONCE, nonce)
|
||||
.header(ZT_SIGNATURE, signature)
|
||||
.header(ZT_AUTH_TOKEN, "a5d7cf609c0b47038ea405c660726ee9")
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(cipherBody, StandardCharsets.UTF_8))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
printResponse(response);
|
||||
}
|
||||
|
||||
private static String encryptPayload(String plaintext) {
|
||||
try {
|
||||
return CryptoSignatureUtils.encrypt(plaintext, APP_SECRET, ENCRYPTION_TYPE);
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("Failed to encrypt request body", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void printResponse(HttpResponse<String> response) {
|
||||
OUT.println("HTTP Status: " + response.statusCode());
|
||||
String cipherText = response.body();
|
||||
OUT.println("加密 Response: " + cipherText);
|
||||
String plain = tryDecrypt(cipherText);
|
||||
OUT.println("原始 Response: " + normalizePotentialMojibake(plain));
|
||||
}
|
||||
|
||||
private static String randomNonce() {
|
||||
return UUID.randomUUID().toString().replace("-", "");
|
||||
}
|
||||
|
||||
private static URI buildUri(String baseUrl, Map<String, Object> queryParams) {
|
||||
if (queryParams == null || queryParams.isEmpty()) {
|
||||
return URI.create(baseUrl);
|
||||
}
|
||||
StringBuilder builder = new StringBuilder(baseUrl);
|
||||
builder.append(baseUrl.contains("?") ? '&' : '?');
|
||||
boolean first = true;
|
||||
for (Map.Entry<String, Object> entry : queryParams.entrySet()) {
|
||||
if (!first) {
|
||||
builder.append('&');
|
||||
}
|
||||
first = false;
|
||||
builder.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8));
|
||||
builder.append('=');
|
||||
builder.append(URLEncoder.encode(String.valueOf(entry.getValue()), StandardCharsets.UTF_8));
|
||||
}
|
||||
return URI.create(builder.toString());
|
||||
}
|
||||
|
||||
private static String generateSignature(Map<String, Object> queryParams, Map<String, Object> bodyParams) {
|
||||
TreeMap<String, Object> sorted = new TreeMap<>();
|
||||
if (queryParams != null) {
|
||||
queryParams.forEach((key, value) -> sorted.put(key, normalizeValue(value)));
|
||||
}
|
||||
if (bodyParams != null) {
|
||||
bodyParams.forEach((key, value) -> sorted.put(key, normalizeValue(value)));
|
||||
}
|
||||
sorted.put(ZT_APP_ID, APP_ID);
|
||||
sorted.put(ZT_TIMESTAMP, TIMESTAMP);
|
||||
StringBuilder canonical = new StringBuilder();
|
||||
sorted.forEach((key, value) -> {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
if (canonical.length() > 0) {
|
||||
canonical.append('&');
|
||||
}
|
||||
canonical.append(key).append('=').append(value);
|
||||
});
|
||||
return md5Hex(canonical.toString());
|
||||
}
|
||||
|
||||
private static Object normalizeValue(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Map || value instanceof Iterable) {
|
||||
try {
|
||||
return OBJECT_MAPPER.writeValueAsString(value);
|
||||
} catch (JsonProcessingException ignored) {
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String md5Hex(String input) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("MD5");
|
||||
byte[] bytes = digest.digest(input.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder hex = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes) {
|
||||
String segment = Integer.toHexString(b & 0xFF);
|
||||
if (segment.length() == 1) {
|
||||
hex.append('0');
|
||||
}
|
||||
hex.append(segment);
|
||||
}
|
||||
return hex.toString();
|
||||
} catch (NoSuchAlgorithmException ex) {
|
||||
throw new IllegalStateException("MD5 algorithm not available", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static String tryDecrypt(String cipherText) {
|
||||
if (cipherText == null || cipherText.isBlank()) {
|
||||
return cipherText;
|
||||
}
|
||||
try {
|
||||
// Databus 会在凭证开启加密时返回密文,这里做一次解密展示真实响应。
|
||||
return CryptoSignatureUtils.decrypt(cipherText, APP_SECRET, ENCRYPTION_TYPE);
|
||||
} catch (Exception ex) {
|
||||
return "<unable to decrypt> " + ex.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// 解决控制台打印 乱码问题
|
||||
private static String normalizePotentialMojibake(String value) {
|
||||
if (value == null || value.isEmpty()) {
|
||||
return value;
|
||||
}
|
||||
long suspectCount = value.chars().filter(ch -> ch >= 0x80 && ch <= 0xFF).count();
|
||||
long highCount = value.chars().filter(ch -> ch > 0xFF).count();
|
||||
if (suspectCount > 0 && highCount == 0) {
|
||||
try {
|
||||
byte[] decoded = value.getBytes(StandardCharsets.ISO_8859_1);
|
||||
String converted = new String(decoded, StandardCharsets.UTF_8);
|
||||
if (converted.chars().anyMatch(ch -> ch > 0xFF)) {
|
||||
return converted;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出流编码与当前控制台保持一致,避免中文字符再次出现编码差异。
|
||||
*/
|
||||
private static PrintStream buildConsolePrintStream() {
|
||||
try {
|
||||
String consoleEncoding = System.getProperty("sun.stdout.encoding");
|
||||
if (consoleEncoding != null && !consoleEncoding.isBlank()) {
|
||||
return new PrintStream(System.out, true, Charset.forName(consoleEncoding));
|
||||
}
|
||||
return new PrintStream(System.out, true, Charset.defaultCharset());
|
||||
} catch (Exception ignored) {
|
||||
return System.out;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,24 +1,34 @@
|
||||
package com.zt.plat.module.databus.framework.integration.gateway.security;
|
||||
|
||||
import cn.hutool.crypto.SecureUtil;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.zt.plat.framework.common.util.security.CryptoSignatureUtils;
|
||||
import com.zt.plat.framework.security.core.LoginUser;
|
||||
import com.zt.plat.framework.security.core.util.SecurityFrameworkUtils;
|
||||
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.service.gateway.ApiAnonymousUserService;
|
||||
import com.zt.plat.module.databus.service.gateway.ApiClientCredentialService;
|
||||
import com.zt.plat.framework.common.util.security.CryptoSignatureUtils;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mock.web.MockFilterChain;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static com.zt.plat.module.databus.framework.integration.config.ApiGatewayProperties.*;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
@@ -27,13 +37,21 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
class GatewaySecurityFilterTest {
|
||||
|
||||
@AfterEach
|
||||
void clearSecurityContext() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAllowRequestWhenIpPermitted() throws Exception {
|
||||
ApiGatewayProperties properties = createProperties();
|
||||
properties.getSecurity().setEnabled(false);
|
||||
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
||||
ApiClientCredentialService credentialService = mock(ApiClientCredentialService.class);
|
||||
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, new ObjectMapper());
|
||||
ApiClientCredentialService credentialService = mock(ApiClientCredentialService.class);
|
||||
ApiAnonymousUserService anonymousUserService = mock(ApiAnonymousUserService.class);
|
||||
when(anonymousUserService.issueAccessToken(any())).thenReturn(Optional.empty());
|
||||
when(anonymousUserService.issueAccessToken(any())).thenReturn(Optional.empty());
|
||||
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, anonymousUserService, new ObjectMapper());
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin-api/databus/api/portal/demo/v1");
|
||||
request.setRemoteAddr("127.0.0.1");
|
||||
@@ -54,7 +72,9 @@ class GatewaySecurityFilterTest {
|
||||
properties.getSecurity().setEnabled(false);
|
||||
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
||||
ApiClientCredentialService credentialService = mock(ApiClientCredentialService.class);
|
||||
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, new ObjectMapper());
|
||||
ApiAnonymousUserService anonymousUserService = mock(ApiAnonymousUserService.class);
|
||||
when(anonymousUserService.issueAccessToken(any())).thenReturn(Optional.empty());
|
||||
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, anonymousUserService, new ObjectMapper());
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin-api/databus/api/portal/demo/v1");
|
||||
request.setRemoteAddr("10.0.0.1");
|
||||
@@ -76,6 +96,8 @@ class GatewaySecurityFilterTest {
|
||||
when(valueOperations.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(Boolean.TRUE);
|
||||
|
||||
ApiClientCredentialService credentialService = mock(ApiClientCredentialService.class);
|
||||
ApiAnonymousUserService anonymousUserService = mock(ApiAnonymousUserService.class);
|
||||
when(anonymousUserService.issueAccessToken(any())).thenReturn(Optional.empty());
|
||||
ApiClientCredentialDO credential = new ApiClientCredentialDO();
|
||||
credential.setAppId("demo-app");
|
||||
credential.setSignatureType(null);
|
||||
@@ -86,17 +108,17 @@ class GatewaySecurityFilterTest {
|
||||
properties.getSecurity().setRequireBodyEncryption(false);
|
||||
properties.getSecurity().setEncryptResponse(false);
|
||||
|
||||
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, new ObjectMapper());
|
||||
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, anonymousUserService, new ObjectMapper());
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin-api/databus/api/portal/demo/v1");
|
||||
request.setRemoteAddr("127.0.0.1");
|
||||
long timestamp = System.currentTimeMillis();
|
||||
String nonce = UUID.randomUUID().toString().replaceAll("-", "");
|
||||
String signature = "d41d8cd98f00b204e9800998ecf8427e";
|
||||
request.addHeader(properties.getSecurity().getAppIdHeader(), "demo-app");
|
||||
request.addHeader(properties.getSecurity().getTimestampHeader(), String.valueOf(timestamp));
|
||||
request.addHeader(properties.getSecurity().getNonceHeader(), nonce);
|
||||
request.addHeader(properties.getSecurity().getSignatureHeader(), signature);
|
||||
String signature = signatureForApp("demo-app");
|
||||
request.addHeader(APP_ID_HEADER, "demo-app");
|
||||
request.addHeader(TIMESTAMP_HEADER, String.valueOf(timestamp));
|
||||
request.addHeader(NONCE_HEADER, nonce);
|
||||
request.addHeader(SIGNATURE_HEADER, signature);
|
||||
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
@@ -119,22 +141,23 @@ class GatewaySecurityFilterTest {
|
||||
when(valueOperations.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(Boolean.TRUE);
|
||||
|
||||
ApiClientCredentialService credentialService = mock(ApiClientCredentialService.class);
|
||||
ApiAnonymousUserService anonymousUserService = mock(ApiAnonymousUserService.class);
|
||||
ApiClientCredentialDO credential = new ApiClientCredentialDO();
|
||||
credential.setAppId("demo-app");
|
||||
credential.setEncryptionKey("demo-secret-key");
|
||||
credential.setEncryptionType(CryptoSignatureUtils.ENCRYPT_TYPE_AES);
|
||||
when(credentialService.findActiveCredential("demo-app")).thenReturn(Optional.of(credential));
|
||||
|
||||
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, new ObjectMapper());
|
||||
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, anonymousUserService, new ObjectMapper());
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin-api/databus/api/portal/demo/v1");
|
||||
request.setRemoteAddr("127.0.0.1");
|
||||
long timestamp = System.currentTimeMillis();
|
||||
String nonce = UUID.randomUUID().toString().replaceAll("-", "");
|
||||
request.addHeader(properties.getSecurity().getAppIdHeader(), "demo-app");
|
||||
request.addHeader(properties.getSecurity().getTimestampHeader(), String.valueOf(timestamp));
|
||||
request.addHeader(properties.getSecurity().getNonceHeader(), nonce);
|
||||
request.addHeader(properties.getSecurity().getSignatureHeader(), "invalid-signature");
|
||||
request.addHeader(APP_ID_HEADER, "demo-app");
|
||||
request.addHeader(TIMESTAMP_HEADER, String.valueOf(timestamp));
|
||||
request.addHeader(NONCE_HEADER, nonce);
|
||||
request.addHeader(SIGNATURE_HEADER, "invalid-signature");
|
||||
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
filter.doFilter(request, response, new MockFilterChain());
|
||||
@@ -150,10 +173,65 @@ class GatewaySecurityFilterTest {
|
||||
assertThat(node.get("message").asText()).isEqualTo("签名校验失败");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAuthenticateWithAnonymousUserWhenConfigured() throws Exception {
|
||||
ApiGatewayProperties properties = createProperties();
|
||||
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
||||
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
|
||||
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
when(valueOperations.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(Boolean.TRUE);
|
||||
|
||||
ApiClientCredentialService credentialService = mock(ApiClientCredentialService.class);
|
||||
ApiAnonymousUserService anonymousUserService = mock(ApiAnonymousUserService.class);
|
||||
ApiClientCredentialDO credential = new ApiClientCredentialDO();
|
||||
credential.setAppId("demo-app");
|
||||
credential.setSignatureType(null);
|
||||
credential.setEncryptionKey(null);
|
||||
credential.setEncryptionType(null);
|
||||
credential.setAllowAnonymous(Boolean.TRUE);
|
||||
credential.setAnonymousUserId(99L);
|
||||
when(credentialService.findActiveCredential("demo-app")).thenReturn(Optional.of(credential));
|
||||
|
||||
LoginUser loginUser = new LoginUser();
|
||||
loginUser.setId(999L);
|
||||
loginUser.setUserType(2);
|
||||
loginUser.setTenantId(123L);
|
||||
ApiAnonymousUserService.AnonymousUserDetails details = new ApiAnonymousUserService.AnonymousUserDetails(99L, "匿名", loginUser);
|
||||
when(anonymousUserService.find(99L)).thenReturn(Optional.of(details));
|
||||
when(anonymousUserService.issueAccessToken(details)).thenReturn(Optional.of("mock-token"));
|
||||
|
||||
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, anonymousUserService, new ObjectMapper());
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin-api/databus/api/portal/demo/v1");
|
||||
request.setRemoteAddr("127.0.0.1");
|
||||
long timestamp = System.currentTimeMillis();
|
||||
String nonce = UUID.randomUUID().toString().replaceAll("-", "");
|
||||
request.addHeader(APP_ID_HEADER, "demo-app");
|
||||
request.addHeader(TIMESTAMP_HEADER, String.valueOf(timestamp));
|
||||
request.addHeader(NONCE_HEADER, nonce);
|
||||
request.addHeader(SIGNATURE_HEADER, signatureForApp("demo-app"));
|
||||
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
assertThat(SecurityFrameworkUtils.getLoginUser()).isNotNull();
|
||||
assertThat(SecurityFrameworkUtils.getLoginUser().getId()).isEqualTo(999L);
|
||||
assertThat(((HttpServletRequest) chain.getRequest()).getHeader(WebFrameworkUtils.HEADER_TENANT_ID)).isEqualTo("123");
|
||||
assertThat(((HttpServletRequest) chain.getRequest()).getHeader(GatewayJwtResolver.HEADER_ZT_AUTH_TOKEN)).isEqualTo("mock-token");
|
||||
assertThat(((HttpServletRequest) chain.getRequest()).getHeader(HttpHeaders.AUTHORIZATION)).isEqualTo("Bearer mock-token");
|
||||
}
|
||||
|
||||
private ApiGatewayProperties createProperties() {
|
||||
ApiGatewayProperties properties = new ApiGatewayProperties();
|
||||
properties.setBasePath("/admin-api/databus/api/portal");
|
||||
properties.setAllowedIps(Collections.singletonList("127.0.0.1"));
|
||||
return properties;
|
||||
}
|
||||
|
||||
private String signatureForApp(String appId) {
|
||||
return SecureUtil.md5("appId=" + appId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ public class AdminUserRespDTO implements VO {
|
||||
@Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "小王")
|
||||
private String nickname;
|
||||
|
||||
@Schema(description = "租户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Long tenantId;
|
||||
|
||||
@Schema(description = "帐号状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Integer status; // 参见 CommonStatusEnum 枚举
|
||||
|
||||
|
||||
@@ -22,8 +22,9 @@ public enum DataScopeEnum implements ArrayValuable<Integer> {
|
||||
DEPT_CUSTOM(2), // 指定部门数据权限
|
||||
DEPT_ONLY(3), // 部门数据权限
|
||||
DEPT_AND_CHILD(4), // 部门及以下数据权限
|
||||
SELF(5), // 仅本人数据权限
|
||||
|
||||
SELF(5); // 仅本人数据权限
|
||||
COMPANY_AND_DEPT(6); // 公司及所属部门数据权限
|
||||
|
||||
/**
|
||||
* 范围
|
||||
|
||||
@@ -5,6 +5,7 @@ import cn.hutool.core.util.ObjUtil;
|
||||
import com.zt.plat.framework.common.pojo.CommonResult;
|
||||
import com.zt.plat.framework.common.util.object.BeanUtils;
|
||||
import com.zt.plat.framework.datapermission.core.util.DataPermissionUtils;
|
||||
import com.zt.plat.framework.tenant.core.aop.TenantIgnore;
|
||||
import com.zt.plat.module.system.api.user.dto.AdminUserRespDTO;
|
||||
import com.zt.plat.module.system.api.user.dto.AdminUserSaveReqDTO;
|
||||
import com.zt.plat.module.system.api.user.dto.AdminUserUpdatePasswordReqDTO;
|
||||
@@ -70,6 +71,7 @@ public class AdminUserApiImpl implements AdminUserApi {
|
||||
}
|
||||
|
||||
@Override
|
||||
@TenantIgnore
|
||||
public CommonResult<AdminUserRespDTO> getUser(Long id) {
|
||||
AdminUserDO user = userService.getUser(id);
|
||||
return success(BeanUtils.toBean(user, AdminUserRespDTO.class));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.zt.plat.module.system.controller.admin.auth.vo;
|
||||
|
||||
|
||||
import com.zt.plat.framework.common.validation.Password;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
@@ -26,6 +27,7 @@ public class AuthRegisterReqVO extends CaptchaVerificationReqVO {
|
||||
|
||||
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
|
||||
@NotEmpty(message = "密码不能为空")
|
||||
@Length(min = 4, max = 16, message = "密码长度为 4-16 位")
|
||||
@Length(min = 6, max = 20, message = "密码长度为 6-20 位")
|
||||
@Password
|
||||
private String password;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.zt.plat.module.system.controller.admin.auth.vo;
|
||||
|
||||
import com.zt.plat.framework.common.validation.Mobile;
|
||||
import com.zt.plat.framework.common.validation.Password;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.AllArgsConstructor;
|
||||
@@ -18,7 +19,8 @@ public class AuthResetPasswordReqVO {
|
||||
|
||||
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "1234")
|
||||
@NotEmpty(message = "密码不能为空")
|
||||
@Length(min = 4, max = 16, message = "密码长度为 4-16 位")
|
||||
@Length(min = 6, max = 20, message = "密码长度为 6-20 位")
|
||||
@Password
|
||||
private String password;
|
||||
|
||||
@Schema(description = "手机号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13312341234")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.zt.plat.module.system.controller.admin.auth.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
@@ -13,9 +12,8 @@ import org.hibernate.validator.constraints.Length;
|
||||
@AllArgsConstructor
|
||||
public class AuthVerifyPasswordReqVO {
|
||||
|
||||
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "buzhidao")
|
||||
@NotEmpty(message = "密码不能为空")
|
||||
@Length(min = 4, max = 16, message = "密码长度为 4-16 位")
|
||||
@Schema(description = "密码", example = "buzhidao")
|
||||
@Length(max = 16, message = "密码长度不能超过 16 位")
|
||||
private String password;
|
||||
|
||||
}
|
||||
|
||||
@@ -136,6 +136,15 @@ public class DeptController {
|
||||
return success(BeanUtils.toBean(list, DeptRespVO.class));
|
||||
}
|
||||
|
||||
@GetMapping("/search")
|
||||
@Operation(summary = "根据关键字搜索部门树", description = "返回匹配节点及其上级节点,便于前端展示搜索结果")
|
||||
@Parameter(name = "name", description = "部门名称关键字", required = true, example = "研发")
|
||||
@PreAuthorize("@ss.hasPermission('system:dept:query')")
|
||||
public CommonResult<List<DeptRespVO>> searchDeptTree(@RequestParam("name") String name) {
|
||||
List<DeptDO> list = deptService.searchDeptTree(name);
|
||||
return success(BeanUtils.toBean(list, DeptRespVO.class));
|
||||
}
|
||||
|
||||
@GetMapping("/get")
|
||||
@Operation(summary = "获得部门信息")
|
||||
@Parameter(name = "id", description = "编号", required = true, example = "1024")
|
||||
|
||||
@@ -10,6 +10,9 @@ public class DeptListReqVO {
|
||||
@Schema(description = "部门名称,模糊匹配", example = "芋道")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "部门编码,精确匹配", example = "ZT001")
|
||||
private String code;
|
||||
|
||||
@Schema(description = "展示状态,参见 CommonStatusEnum 枚举类", example = "1")
|
||||
private Integer status;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.zt.plat.module.system.controller.admin.tenant.vo.tenant;
|
||||
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.zt.plat.framework.common.validation.Password;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
@@ -9,7 +10,6 @@ import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
import org.hibernate.validator.constraints.Length;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Schema(description = "管理后台 - 租户创建/修改 Request VO")
|
||||
@@ -57,7 +57,8 @@ public class TenantSaveReqVO {
|
||||
private String username;
|
||||
|
||||
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
|
||||
@Length(min = 4, max = 16, message = "密码长度为 4-16 位")
|
||||
@Length(min = 6, max = 20, message = "密码长度为 6-20 位")
|
||||
@Password
|
||||
private String password;
|
||||
|
||||
@AssertTrue(message = "用户账号、密码不能为空")
|
||||
|
||||
@@ -11,7 +11,6 @@ import com.zt.plat.module.system.controller.admin.user.vo.user.*;
|
||||
import com.zt.plat.module.system.convert.user.UserConvert;
|
||||
import com.zt.plat.module.system.dal.dataobject.user.AdminUserDO;
|
||||
import com.zt.plat.module.system.enums.common.SexEnum;
|
||||
import com.zt.plat.module.system.service.dept.DeptService;
|
||||
import com.zt.plat.module.system.service.user.AdminUserService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
@@ -38,8 +37,6 @@ public class UserController {
|
||||
|
||||
@Resource
|
||||
private AdminUserService userService;
|
||||
@Resource
|
||||
private DeptService deptService;
|
||||
|
||||
@PostMapping("/create")
|
||||
@Operation(summary = "新增用户")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.zt.plat.module.system.controller.admin.user.vo.profile;
|
||||
|
||||
import com.zt.plat.framework.common.validation.Password;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
@@ -16,7 +17,8 @@ public class UserProfileUpdatePasswordReqVO {
|
||||
|
||||
@Schema(description = "新密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "654321")
|
||||
@NotEmpty(message = "新密码不能为空")
|
||||
@Length(min = 4, max = 16, message = "密码长度为 4-16 位")
|
||||
@Length(min = 6, max = 20, message = "密码长度为 6-20 位")
|
||||
@Password
|
||||
private String newPassword;
|
||||
|
||||
}
|
||||
|
||||
@@ -34,9 +34,9 @@ public class UserRespVO{
|
||||
|
||||
@Schema(description = "部门ID列表", example = "我是一个部门Id列表")
|
||||
private List<Long> deptIds;
|
||||
// @Schema(description = "部门名称", example = "IT 部")
|
||||
// @ExcelProperty("部门名称")
|
||||
// private String deptName;
|
||||
|
||||
@Schema(description = "部门名称", example = "总部研发部、平台组")
|
||||
private String deptNames;
|
||||
|
||||
@Schema(description = "岗位编号数组", example = "1")
|
||||
private Set<Long> postIds;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package com.zt.plat.module.system.controller.admin.user.vo.user;
|
||||
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.mzt.logapi.starter.annotation.DiffLogField;
|
||||
import com.zt.plat.framework.common.validation.Mobile;
|
||||
import com.zt.plat.framework.common.validation.Password;
|
||||
import com.zt.plat.module.system.framework.operatelog.core.DeptParseFunction;
|
||||
import com.zt.plat.module.system.framework.operatelog.core.PostParseFunction;
|
||||
import com.zt.plat.module.system.framework.operatelog.core.SexParseFunction;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.mzt.logapi.starter.annotation.DiffLogField;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.*;
|
||||
import lombok.Data;
|
||||
@@ -76,7 +77,8 @@ public class UserSaveReqVO {
|
||||
// ========== 仅【创建】时,需要传递的字段 ==========
|
||||
|
||||
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
|
||||
@Length(min = 4, max = 16, message = "密码长度为 4-16 位")
|
||||
@Length(min = 6, max = 20, message = "密码长度为 6-20 位")
|
||||
@Password
|
||||
private String password;
|
||||
|
||||
@AssertTrue(message = "密码不能为空")
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.zt.plat.module.system.controller.admin.user.vo.user;
|
||||
|
||||
import com.zt.plat.framework.common.pojo.PageParam;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Schema(description = "管理后台 - 用户选择器分页 Request VO")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class UserSelectorPageReqVO extends PageParam {
|
||||
|
||||
@Schema(description = "关键字(支持姓名/账号/手机号模糊匹配)", example = "张三")
|
||||
private String keyword;
|
||||
|
||||
@Schema(description = "部门编号,同时筛选子部门", example = "1024")
|
||||
private Long deptId;
|
||||
|
||||
@Schema(description = "展示状态,参见 CommonStatusEnum 枚举类", example = "0")
|
||||
private Integer status;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.zt.plat.module.system.controller.admin.user.vo.user;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Schema(description = "管理后台 - 用户选择器 Response VO")
|
||||
@Data
|
||||
public class UserSelectorRespVO {
|
||||
|
||||
@Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "王小明")
|
||||
private String nickname;
|
||||
|
||||
@Schema(description = "用户账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "wangxm")
|
||||
private String username;
|
||||
|
||||
@Schema(description = "手机号码", example = "13800000000")
|
||||
private String mobile;
|
||||
|
||||
@Schema(description = "帐号状态,参见 CommonStatusEnum 枚举类", example = "0")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "所属部门名称(多个部门以、分隔)", example = "总部研发部、平台组")
|
||||
private String deptNames;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.zt.plat.module.system.controller.admin.user.vo.user;
|
||||
|
||||
import com.zt.plat.framework.common.validation.Password;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
@@ -16,7 +17,8 @@ public class UserUpdatePasswordReqVO {
|
||||
|
||||
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
|
||||
@NotEmpty(message = "密码不能为空")
|
||||
@Length(min = 4, max = 16, message = "密码长度为 4-16 位")
|
||||
@Length(min = 6, max = 20, message = "密码长度为 6-20 位")
|
||||
@Password
|
||||
private String password;
|
||||
|
||||
}
|
||||
|
||||
@@ -30,7 +30,11 @@ public interface UserConvert {
|
||||
}
|
||||
|
||||
default UserRespVO convert(AdminUserDO user) {
|
||||
return BeanUtils.toBean(user, UserRespVO.class);
|
||||
UserRespVO vo = BeanUtils.toBean(user, UserRespVO.class);
|
||||
if (user.getDeptIds() != null) {
|
||||
vo.setDeptIds(CollectionUtils.convertList(user.getDeptIds(), Long::longValue));
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
default List<UserSimpleRespVO> convertSimpleList(List<AdminUserDO> list) {
|
||||
|
||||
@@ -60,6 +60,9 @@ public class AdminUserDO extends TenantBaseDO {
|
||||
@TableField(exist = false, typeHandler = JacksonTypeHandler.class )
|
||||
@NotEmpty
|
||||
private Set<Long> deptIds;
|
||||
|
||||
@TableField(exist = false)
|
||||
private String deptNames;
|
||||
/**
|
||||
* 公司 ID 列表
|
||||
*/
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package com.zt.plat.module.system.dal.mysql.dept;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
||||
import com.zt.plat.framework.common.enums.CommonStatusEnum;
|
||||
import com.zt.plat.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import com.zt.plat.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import com.zt.plat.module.system.controller.admin.dept.vo.dept.DeptListReqVO;
|
||||
import com.zt.plat.module.system.dal.dataobject.dept.DeptDO;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.util.Collection;
|
||||
@@ -20,6 +21,7 @@ public interface DeptMapper extends BaseMapperX<DeptDO> {
|
||||
default List<DeptDO> selectList(DeptListReqVO reqVO) {
|
||||
return selectList(new LambdaQueryWrapperX<DeptDO>()
|
||||
.likeIfPresent(DeptDO::getName, reqVO.getName())
|
||||
.eqIfPresent(DeptDO::getCode, reqVO.getCode())
|
||||
.eqIfPresent(DeptDO::getStatus, reqVO.getStatus())
|
||||
.eqIfPresent(DeptDO::getIsCompany, reqVO.getIsCompany())
|
||||
);
|
||||
@@ -127,4 +129,17 @@ public interface DeptMapper extends BaseMapperX<DeptDO> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据部门名称模糊查询启用状态的部门列表
|
||||
*
|
||||
* @param name 部门名称关键字
|
||||
* @return 部门列表
|
||||
*/
|
||||
default List<DeptDO> selectListByName(String name) {
|
||||
return selectList(new LambdaQueryWrapperX<DeptDO>()
|
||||
.likeIfPresent(DeptDO::getName, name)
|
||||
.eq(DeptDO::getStatus, CommonStatusEnum.ENABLE.getStatus())
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package com.zt.plat.module.system.service.auth;
|
||||
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import com.anji.captcha.model.common.ResponseModel;
|
||||
import com.anji.captcha.model.vo.CaptchaVO;
|
||||
import com.anji.captcha.service.CaptchaService;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.zt.plat.framework.common.enums.CommonStatusEnum;
|
||||
import com.zt.plat.framework.common.enums.UserTypeEnum;
|
||||
import com.zt.plat.framework.common.util.monitor.TracerUtils;
|
||||
@@ -19,17 +23,13 @@ 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.enums.sms.SmsSceneEnum;
|
||||
import com.zt.plat.module.system.enums.user.UserSourceEnum;
|
||||
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.OAuth2TokenService;
|
||||
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.zt.plat.module.system.enums.user.UserSourceEnum;
|
||||
import com.anji.captcha.model.common.ResponseModel;
|
||||
import com.anji.captcha.model.vo.CaptchaVO;
|
||||
import com.anji.captcha.service.CaptchaService;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Validator;
|
||||
import lombok.Setter;
|
||||
@@ -286,6 +286,16 @@ public class AdminAuthServiceImpl implements AdminAuthService {
|
||||
if (user == null) {
|
||||
throw exception(USER_NOT_EXISTS);
|
||||
}
|
||||
if (isInternalUser(user)) {
|
||||
return;
|
||||
}
|
||||
if (StringUtils.isBlank(password)) {
|
||||
throw exception(AUTH_LOGIN_BAD_CREDENTIALS);
|
||||
}
|
||||
int length = password.length();
|
||||
if (length < 4 || length > 16) {
|
||||
throw exception(AUTH_LOGIN_BAD_CREDENTIALS);
|
||||
}
|
||||
if (!userService.isPasswordMatch(password, user.getPassword())) {
|
||||
throw exception(AUTH_LOGIN_BAD_CREDENTIALS);
|
||||
}
|
||||
|
||||
@@ -128,6 +128,15 @@ public interface DeptService {
|
||||
|
||||
Set<CompanyDeptInfo> getCompanyDeptInfoListByUserId(Long userId);
|
||||
|
||||
/**
|
||||
* 计算公司及其直属部门数据权限范围
|
||||
* 在给定部门集合的场景下,按所属公司维度汇总公司本级及其直属部门(不含其他子公司)
|
||||
*
|
||||
* @param deptIds 部门编号集合
|
||||
* @return 公司及其直属部门编号集合
|
||||
*/
|
||||
Set<Long> computeCompanyScopeDeptIds(Set<Long> deptIds);
|
||||
|
||||
/**
|
||||
* 获取当前用户可访问的顶级部门列表
|
||||
* 用于懒加载,返回当前用户所属部门的最顶层祖先部门
|
||||
@@ -157,4 +166,12 @@ public interface DeptService {
|
||||
* 按照新的编码规则初始化全部部门编码
|
||||
*/
|
||||
void initializeDeptCodes();
|
||||
|
||||
/**
|
||||
* 根据关键字搜索部门树,包含匹配节点及其上级节点
|
||||
*
|
||||
* @param keyword 关键字
|
||||
* @return 部门列表
|
||||
*/
|
||||
List<DeptDO> searchDeptTree(String keyword);
|
||||
}
|
||||
|
||||
@@ -400,19 +400,11 @@ public class DeptServiceImpl implements DeptService {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Set<Long> companyIds = new HashSet<>();
|
||||
Map<Long, DeptDO> deptCache = new HashMap<>();
|
||||
for (Long deptId : deptIds) {
|
||||
DeptDO dept = getDept(deptId);
|
||||
while (dept != null) {
|
||||
// 如果当前部门是公司,加入结果并结束本次递归
|
||||
if (Boolean.TRUE.equals(dept.getIsCompany())) {
|
||||
companyIds.add(dept.getId());
|
||||
break;
|
||||
}
|
||||
// 到达根节点或无上级,结束递归
|
||||
if (dept.getParentId() == null || DeptDO.PARENT_ID_ROOT.equals(dept.getParentId())) {
|
||||
break;
|
||||
}
|
||||
dept = getDept(dept.getParentId());
|
||||
Long companyId = resolveNearestCompanyId(deptId, deptCache);
|
||||
if (companyId != null) {
|
||||
companyIds.add(companyId);
|
||||
}
|
||||
}
|
||||
return getDeptList(companyIds);
|
||||
@@ -440,43 +432,15 @@ public class DeptServiceImpl implements DeptService {
|
||||
|
||||
// 如果指定了公司ID,则进一步过滤属于该公司的部门
|
||||
if (companyId != null) {
|
||||
Map<Long, DeptDO> deptCache = new HashMap<>();
|
||||
return deptList.stream()
|
||||
.filter(dept -> isUnderCompany(dept, companyId))
|
||||
.filter(dept -> Objects.equals(resolveNearestCompanyId(dept.getId(), deptCache), companyId))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
return deptList;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断部门是否属于指定公司
|
||||
*
|
||||
* @param dept 部门
|
||||
* @param companyId 公司ID
|
||||
* @return 是否属于指定公司
|
||||
*/
|
||||
private boolean isUnderCompany(DeptDO dept, Long companyId) {
|
||||
if (dept == null || companyId == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果部门本身就是指定的公司
|
||||
if (dept.getId().equals(companyId) && Boolean.TRUE.equals(dept.getIsCompany())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 向上递归查找,看是否有祖先部门是指定的公司
|
||||
DeptDO current = dept;
|
||||
while (current != null && current.getParentId() != null && !DeptDO.PARENT_ID_ROOT.equals(current.getParentId())) {
|
||||
current = getDept(current.getParentId());
|
||||
if (current != null && current.getId().equals(companyId) && Boolean.TRUE.equals(current.getIsCompany())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户ID查询其归属公司及直属部门关系列表(不递归下级公司)
|
||||
*/
|
||||
@@ -493,18 +457,13 @@ public class DeptServiceImpl implements DeptService {
|
||||
// 查询所有部门信息
|
||||
Map<Long, DeptDO> deptMap = getDeptList(deptIds).stream()
|
||||
.collect(Collectors.toMap(DeptDO::getId, d -> d));
|
||||
Map<Long, DeptDO> deptCache = new HashMap<>(deptMap);
|
||||
Set<CompanyDeptInfo> result = new HashSet<>();
|
||||
for (Long deptId : deptIds) {
|
||||
DeptDO dept = deptMap.get(deptId);
|
||||
DeptDO dept = loadDept(deptId, deptCache);
|
||||
if (dept == null) continue;
|
||||
// 向上查找公司,如果到达顶层(parentId为PARENT_ID_ROOT)还没找到公司,则用顶层部门作为公司
|
||||
DeptDO company = dept;
|
||||
while (company != null && !Boolean.TRUE.equals(company.getIsCompany())) {
|
||||
if (company.getParentId() == null || DeptDO.PARENT_ID_ROOT.equals(company.getParentId())) {
|
||||
break;
|
||||
}
|
||||
company = getDept(company.getParentId());
|
||||
}
|
||||
Long companyId = resolveNearestCompanyId(deptId, deptCache);
|
||||
DeptDO company = companyId != null ? loadDept(companyId, deptCache) : findTopLevelAncestor(dept);
|
||||
if (company == null) continue;
|
||||
CompanyDeptInfo info = new CompanyDeptInfo();
|
||||
info.setCompanyId(company.getId());
|
||||
@@ -518,6 +477,26 @@ public class DeptServiceImpl implements DeptService {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<Long> computeCompanyScopeDeptIds(Set<Long> deptIds) {
|
||||
if (CollUtil.isEmpty(deptIds)) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
Map<Long, DeptDO> deptCache = new HashMap<>();
|
||||
Map<Long, Set<Long>> companyDeptCache = new HashMap<>();
|
||||
Set<Long> result = new HashSet<>();
|
||||
for (Long deptId : deptIds) {
|
||||
Long companyId = resolveNearestCompanyId(deptId, deptCache);
|
||||
if (companyId == null) {
|
||||
continue;
|
||||
}
|
||||
Set<Long> scopedDeptIds = companyDeptCache.computeIfAbsent(companyId,
|
||||
id -> resolveCompanyDeptIds(id, deptCache));
|
||||
result.addAll(scopedDeptIds);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<DeptDO> getTopLevelDeptList() {
|
||||
// 获取当前用户所属的部门列表
|
||||
@@ -614,4 +593,93 @@ public class DeptServiceImpl implements DeptService {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<DeptDO> searchDeptTree(String keyword) {
|
||||
if (StrUtil.isBlank(keyword)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<DeptDO> matchedDepts = deptMapper.selectListByName(keyword.trim());
|
||||
if (CollUtil.isEmpty(matchedDepts)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
Map<Long, DeptDO> cache = new HashMap<>();
|
||||
LinkedHashMap<Long, DeptDO> resultMap = new LinkedHashMap<>();
|
||||
for (DeptDO dept : matchedDepts) {
|
||||
appendDeptWithAncestors(dept, resultMap, cache);
|
||||
}
|
||||
return new ArrayList<>(resultMap.values());
|
||||
}
|
||||
|
||||
private void appendDeptWithAncestors(DeptDO dept, Map<Long, DeptDO> resultMap, Map<Long, DeptDO> cache) {
|
||||
DeptDO current = dept;
|
||||
while (current != null) {
|
||||
if (!resultMap.containsKey(current.getId())) {
|
||||
resultMap.put(current.getId(), current);
|
||||
}
|
||||
Long parentId = current.getParentId();
|
||||
if (parentId == null || DeptDO.PARENT_ID_ROOT.equals(parentId)) {
|
||||
break;
|
||||
}
|
||||
if (Objects.equals(parentId, current.getId())) {
|
||||
break;
|
||||
}
|
||||
current = cache.computeIfAbsent(parentId, deptMapper::selectById);
|
||||
}
|
||||
}
|
||||
|
||||
private Set<Long> resolveCompanyDeptIds(Long companyId, Map<Long, DeptDO> deptCache) {
|
||||
DeptDO company = loadDept(companyId, deptCache);
|
||||
if (company == null) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
Set<Long> deptIds = new HashSet<>();
|
||||
deptIds.add(company.getId());
|
||||
List<DeptDO> descendants = getChildDeptList(companyId);
|
||||
if (CollUtil.isEmpty(descendants)) {
|
||||
return deptIds;
|
||||
}
|
||||
descendants.forEach(dept -> deptCache.putIfAbsent(dept.getId(), dept));
|
||||
for (DeptDO dept : descendants) {
|
||||
if (Boolean.TRUE.equals(dept.getIsCompany())) {
|
||||
continue;
|
||||
}
|
||||
Long anchorCompanyId = resolveNearestCompanyId(dept.getId(), deptCache);
|
||||
if (Objects.equals(anchorCompanyId, companyId)) {
|
||||
deptIds.add(dept.getId());
|
||||
}
|
||||
}
|
||||
return deptIds;
|
||||
}
|
||||
|
||||
private Long resolveNearestCompanyId(Long deptId, Map<Long, DeptDO> deptCache) {
|
||||
DeptDO current = loadDept(deptId, deptCache);
|
||||
while (current != null) {
|
||||
if (Boolean.TRUE.equals(current.getIsCompany())) {
|
||||
return current.getId();
|
||||
}
|
||||
Long parentId = current.getParentId();
|
||||
if (parentId == null || DeptDO.PARENT_ID_ROOT.equals(parentId)) {
|
||||
return null;
|
||||
}
|
||||
current = loadDept(parentId, deptCache);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private DeptDO loadDept(Long deptId, Map<Long, DeptDO> deptCache) {
|
||||
if (deptId == null) {
|
||||
return null;
|
||||
}
|
||||
DeptDO cached = deptCache.get(deptId);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
DeptDO dept = getDept(deptId);
|
||||
if (dept != null) {
|
||||
deptCache.put(deptId, dept);
|
||||
}
|
||||
return dept;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package com.zt.plat.module.system.service.oauth2;
|
||||
|
||||
import com.zt.plat.module.system.controller.admin.auth.vo.AuthOAuth2CallbackReqVO;
|
||||
import com.zt.plat.module.system.controller.admin.auth.vo.AuthLoginRespVO;
|
||||
import com.zt.plat.module.system.controller.admin.auth.vo.AuthOAuth2CallbackReqVO;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* E办OAuth2服务接口
|
||||
@@ -30,6 +31,7 @@ public interface EbanOAuth2Service {
|
||||
/**
|
||||
* E办用户信息
|
||||
*/
|
||||
@Data
|
||||
class EbanUserInfo {
|
||||
private String username;
|
||||
private String realName;
|
||||
@@ -37,47 +39,12 @@ public interface EbanOAuth2Service {
|
||||
private String mobile;
|
||||
private String deptName;
|
||||
private String uid;
|
||||
private String displayName;
|
||||
private String displayName;
|
||||
private String loginName;
|
||||
private String rawUserInfoJson;
|
||||
private EbanOAuth2ServiceImpl.EbanTokenInfo tokenInfo; // 添加Token信息
|
||||
|
||||
// 构造函数
|
||||
public EbanUserInfo() {}
|
||||
|
||||
public EbanUserInfo(String username, String realName, String email, String mobile, String deptName) {
|
||||
this.username = username;
|
||||
this.realName = realName;
|
||||
this.email = email;
|
||||
this.mobile = mobile;
|
||||
this.deptName = deptName;
|
||||
}
|
||||
|
||||
// getter和setter方法
|
||||
public String getUsername() { return username; }
|
||||
public void setUsername(String username) { this.username = username; }
|
||||
|
||||
public String getRealName() { return realName; }
|
||||
public void setRealName(String realName) { this.realName = realName; }
|
||||
|
||||
public String getEmail() { return email; }
|
||||
public void setEmail(String email) { this.email = email; }
|
||||
|
||||
public String getMobile() { return mobile; }
|
||||
public void setMobile(String mobile) { this.mobile = mobile; }
|
||||
|
||||
public String getDeptName() { return deptName; }
|
||||
public void setDeptName(String deptName) { this.deptName = deptName; }
|
||||
|
||||
public String getUid() { return uid; }
|
||||
public void setUid(String uid) { this.uid = uid; }
|
||||
|
||||
public String getDisplayName() { return displayName; }
|
||||
public void setDisplayName(String displayName) { this.displayName = displayName; }
|
||||
|
||||
public String getRawUserInfoJson() { return rawUserInfoJson; }
|
||||
public void setRawUserInfoJson(String rawUserInfoJson) { this.rawUserInfoJson = rawUserInfoJson; }
|
||||
|
||||
public EbanOAuth2ServiceImpl.EbanTokenInfo getTokenInfo() { return tokenInfo; }
|
||||
public void setTokenInfo(EbanOAuth2ServiceImpl.EbanTokenInfo tokenInfo) { this.tokenInfo = tokenInfo; }
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ 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.service.logger.LoginLogService;
|
||||
import com.zt.plat.module.system.service.user.AdminUserService;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -26,7 +25,6 @@ import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static com.zt.plat.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static com.zt.plat.framework.common.util.servlet.ServletUtils.getClientIP;
|
||||
import static com.zt.plat.module.system.enums.ErrorCodeConstants.*;
|
||||
|
||||
/**
|
||||
@@ -46,9 +44,6 @@ public class EbanOAuth2ServiceImpl implements EbanOAuth2Service {
|
||||
@Resource
|
||||
private LoginLogService loginLogService;
|
||||
|
||||
@Resource
|
||||
private OAuth2TokenService oauth2TokenService;
|
||||
|
||||
@Resource
|
||||
private EbanTokenService ebanTokenService;
|
||||
|
||||
@@ -77,16 +72,16 @@ public class EbanOAuth2ServiceImpl implements EbanOAuth2Service {
|
||||
throw exception(AUTH_LOGIN_EBAN_TOKEN_INVALID);
|
||||
}
|
||||
|
||||
String displayName = StrUtil.trim(StrUtil.blankToDefault(userInfo.getDisplayName(), userInfo.getUsername()));
|
||||
if (StrUtil.isBlank(displayName)) {
|
||||
log.error("E办OAuth2用户信息缺少displayName与loginName,无法匹配账号: {}", JSONUtil.toJsonStr(userInfo));
|
||||
String username = StrUtil.trim(StrUtil.blankToDefault(userInfo.getLoginName(), userInfo.getUsername()));
|
||||
if (StrUtil.isBlank(username)) {
|
||||
log.error("E办OAuth2用户信息缺少 username 与 loginName,无法匹配账号: {}", JSONUtil.toJsonStr(userInfo));
|
||||
throw exception(AUTH_LOGIN_EBAN_TOKEN_INVALID);
|
||||
}
|
||||
|
||||
AdminUserDO user = userService.getUserByUsername(displayName);
|
||||
AdminUserDO user = userService.getUserByUsername(username);
|
||||
if (user == null) {
|
||||
createLoginLog(null, displayName, LoginLogTypeEnum.LOGIN_SOCIAL, LoginResultEnum.BAD_CREDENTIALS);
|
||||
log.warn("E办OAuth2用户displayName未在系统中找到对应账号: {}", displayName);
|
||||
createLoginLog(null, username, LoginLogTypeEnum.LOGIN_SOCIAL, LoginResultEnum.BAD_CREDENTIALS);
|
||||
log.warn("E办OAuth2用户displayName未在系统中找到对应账号: {}", username);
|
||||
throw exception(AUTH_LOGIN_EBAN_USER_NOT_SYNC);
|
||||
}
|
||||
|
||||
@@ -95,27 +90,24 @@ public class EbanOAuth2ServiceImpl implements EbanOAuth2Service {
|
||||
throw exception(AUTH_LOGIN_USER_DISABLED);
|
||||
}
|
||||
|
||||
try {
|
||||
EbanTokenInfo tokenInfo = userInfo.getTokenInfo();
|
||||
if (tokenInfo != null && StrUtil.isNotBlank(tokenInfo.getAccessToken())) {
|
||||
String userInfoJson = StrUtil.blankToDefault(userInfo.getRawUserInfoJson(), buildBasicUserInfoJson(userInfo));
|
||||
Long tenantId = user.getTenantId() != null ? user.getTenantId() : 0L;
|
||||
ebanTokenService.createEbanToken(
|
||||
user.getId(),
|
||||
tenantId,
|
||||
tokenInfo.getAccessToken(),
|
||||
tokenInfo.getRefreshToken(),
|
||||
tokenInfo.getExpiresIn(),
|
||||
userInfo.getUid(),
|
||||
userInfoJson
|
||||
);
|
||||
log.info("成功保存E办token,userId={}, uid={}, displayName={}", user.getId(), userInfo.getUid(), displayName);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("保存E办token失败,userId={}, uid={}, displayName={}", user.getId(), userInfo.getUid(), displayName, e);
|
||||
EbanTokenInfo tokenInfo = userInfo.getTokenInfo();
|
||||
if (tokenInfo == null || StrUtil.isBlank(tokenInfo.getAccessToken())) {
|
||||
log.error("E办OAuth2回调缺少有效的token信息,uid={}, username={}", userInfo.getUid(), username);
|
||||
throw exception(AUTH_LOGIN_EBAN_TOKEN_INVALID);
|
||||
}
|
||||
|
||||
return createTokenAfterLoginSuccess(user.getId(), user.getUsername(), LoginLogTypeEnum.LOGIN_SOCIAL);
|
||||
Long tenantId = user.getTenantId() != null ? user.getTenantId() : 0L;
|
||||
OAuth2AccessTokenDO ebanAccessTokenDO = ebanTokenService.createEbanToken(
|
||||
user.getId(),
|
||||
tenantId,
|
||||
tokenInfo.getAccessToken(),
|
||||
tokenInfo.getRefreshToken(),
|
||||
tokenInfo.getExpiresIn(),
|
||||
userInfo
|
||||
);
|
||||
log.info("成功保存E办token,userId={}, uid={}, username={}", user.getId(), userInfo.getUid(), username);
|
||||
|
||||
return createTokenAfterLoginSuccess(user.getId(), user.getUsername(), LoginLogTypeEnum.LOGIN_SOCIAL, ebanAccessTokenDO);
|
||||
|
||||
} catch (ServiceException e) {
|
||||
throw e;
|
||||
@@ -139,18 +131,6 @@ public class EbanOAuth2ServiceImpl implements EbanOAuth2Service {
|
||||
return userInfo;
|
||||
}
|
||||
|
||||
private String buildBasicUserInfoJson(EbanUserInfo userInfo) {
|
||||
JSONObject jsonObject = new JSONObject();
|
||||
jsonObject.put("uid", userInfo.getUid());
|
||||
jsonObject.put("displayName", userInfo.getDisplayName());
|
||||
jsonObject.put("loginName", userInfo.getUsername());
|
||||
jsonObject.put("realName", userInfo.getRealName());
|
||||
jsonObject.put("email", userInfo.getEmail());
|
||||
jsonObject.put("mobile", userInfo.getMobile());
|
||||
jsonObject.put("deptName", userInfo.getDeptName());
|
||||
return jsonObject.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* E办Token信息
|
||||
*/
|
||||
@@ -340,18 +320,15 @@ public class EbanOAuth2ServiceImpl implements EbanOAuth2Service {
|
||||
/**
|
||||
* 登录成功后创建Token
|
||||
*/
|
||||
private AuthLoginRespVO createTokenAfterLoginSuccess(Long userId, String username, LoginLogTypeEnum logType) {
|
||||
private AuthLoginRespVO createTokenAfterLoginSuccess(Long userId, String username, LoginLogTypeEnum logType,
|
||||
OAuth2AccessTokenDO accessTokenDO) {
|
||||
// 插入登陆日志
|
||||
createLoginLog(userId, username, logType, LoginResultEnum.SUCCESS);
|
||||
|
||||
// 创建访问令牌
|
||||
OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.createAccessToken(
|
||||
userId,
|
||||
com.zt.plat.framework.common.enums.UserTypeEnum.ADMIN.getValue(),
|
||||
OAuth2ClientConstants.CLIENT_ID_DEFAULT,
|
||||
null
|
||||
);
|
||||
|
||||
|
||||
if (accessTokenDO == null) {
|
||||
throw exception(AUTH_LOGIN_EBAN_TOKEN_INVALID);
|
||||
}
|
||||
|
||||
// 构建返回结果
|
||||
return AuthConvert.INSTANCE.convert(accessTokenDO);
|
||||
}
|
||||
|
||||
@@ -17,12 +17,11 @@ public interface EbanTokenService {
|
||||
* @param accessToken E办访问令牌
|
||||
* @param refreshToken E办刷新令牌
|
||||
* @param expiresIn 过期时间(秒)
|
||||
* @param uid E办用户唯一标识
|
||||
* @param userInfo E办用户信息(JSON格式)
|
||||
* @param ebanUserInfo e办用户信息
|
||||
* @return OAuth2AccessTokenDO
|
||||
*/
|
||||
OAuth2AccessTokenDO createEbanToken(Long userId, Long tenantId, String accessToken, String refreshToken,
|
||||
Integer expiresIn, String uid, String userInfo);
|
||||
Integer expiresIn, EbanOAuth2Service.EbanUserInfo ebanUserInfo);
|
||||
|
||||
/**
|
||||
* 根据用户ID获取E办Token
|
||||
@@ -54,7 +53,7 @@ public interface EbanTokenService {
|
||||
* @return 是否有效
|
||||
*/
|
||||
boolean isEbanTokenValid(Long userId);
|
||||
|
||||
|
||||
/**
|
||||
* 根据access_token获取E办Token信息
|
||||
*
|
||||
|
||||
@@ -53,19 +53,26 @@ public class EbanTokenServiceImpl implements EbanTokenService {
|
||||
|
||||
@Override
|
||||
public OAuth2AccessTokenDO createEbanToken(Long userId, Long tenantId, String accessToken, String refreshToken,
|
||||
Integer expiresIn, String uid, String userInfo) {
|
||||
Integer expiresIn, EbanOAuth2Service.EbanUserInfo userInfo) {
|
||||
if (StrUtil.isBlank(accessToken)) {
|
||||
throw ServiceExceptionUtil.exception(AUTH_LOGIN_EBAN_TOKEN_INVALID);
|
||||
}
|
||||
if (userInfo == null) {
|
||||
throw ServiceExceptionUtil.exception(AUTH_LOGIN_EBAN_TOKEN_INVALID);
|
||||
}
|
||||
|
||||
LocalDateTime expiresTime = calculateExpiresTime(expiresIn);
|
||||
Map<String, String> userInfoMap = MapUtil.newHashMap(2, false);
|
||||
if (StrUtil.isNotBlank(uid)) {
|
||||
userInfoMap.put("uid", uid);
|
||||
Map<String, String> userInfoMap = MapUtil.newHashMap(6, false);
|
||||
if (StrUtil.isNotBlank(userInfo.getLoginName())) {
|
||||
userInfoMap.put("username", userInfo.getLoginName());
|
||||
}
|
||||
if (StrUtil.isNotBlank(userInfo)) {
|
||||
userInfoMap.put("rawUserInfo", userInfo);
|
||||
if (StrUtil.isNotBlank(userInfo.getDisplayName())) {
|
||||
userInfoMap.put("nickname", userInfo.getDisplayName());
|
||||
}
|
||||
if (StrUtil.isNotBlank(userInfo.getUid())) {
|
||||
userInfoMap.put("uid", userInfo.getUid());
|
||||
}
|
||||
userInfoMap.put("tenantId", String.valueOf(tenantId != null ? tenantId : 0L));
|
||||
|
||||
OAuth2AccessTokenDO tokenDO = oauth2AccessTokenMapper.selectByUserIdAndClientId(userId, EBAN_CLIENT_ID);
|
||||
if (tokenDO == null) {
|
||||
@@ -87,7 +94,7 @@ public class EbanTokenServiceImpl implements EbanTokenService {
|
||||
oauth2AccessTokenMapper.updateById(tokenDO);
|
||||
}
|
||||
|
||||
log.info("保存E办Token成功,userId={}, uid={}, expiresTime={}", userId, uid, expiresTime);
|
||||
log.info("保存E办Token成功,userId={}, expiresTime={}", userId, expiresTime);
|
||||
return tokenDO;
|
||||
}
|
||||
|
||||
|
||||
@@ -372,7 +372,7 @@ public class PermissionServiceImpl implements PermissionService {
|
||||
CollUtil.addAll(result.getDeptIds(), userDeptIds.get());
|
||||
continue;
|
||||
}
|
||||
// 情况四,DEPT_DEPT_AND_CHILD
|
||||
// 情况四,DEPT_AND_CHILD
|
||||
if (Objects.equals(role.getDataScope(), DataScopeEnum.DEPT_AND_CHILD.getScope())) {
|
||||
Set<Long> deptIds = Optional.ofNullable(userDeptIds.get()).orElseGet(Collections::emptySet);
|
||||
for (Long userDeptId : deptIds) {
|
||||
@@ -382,7 +382,15 @@ public class PermissionServiceImpl implements PermissionService {
|
||||
CollUtil.addAll(result.getDeptIds(), deptIds);
|
||||
continue;
|
||||
}
|
||||
// 情况五,SELF
|
||||
// 情况五,COMPANY_AND_DEPT
|
||||
if (Objects.equals(role.getDataScope(), DataScopeEnum.COMPANY_AND_DEPT.getScope())) {
|
||||
// 公司及所属部门数据范围:由部门服务汇总最近所属公司及其直属部门
|
||||
Set<Long> deptIds = deptService.computeCompanyScopeDeptIds(Optional.ofNullable(userDeptIds.get())
|
||||
.orElse(Collections.emptySet()));
|
||||
CollUtil.addAll(result.getDeptIds(), deptIds);
|
||||
continue;
|
||||
}
|
||||
// 情况六,SELF
|
||||
if (Objects.equals(role.getDataScope(), DataScopeEnum.SELF.getScope())) {
|
||||
result.setSelf(true);
|
||||
continue;
|
||||
|
||||
@@ -10,11 +10,11 @@ import com.zt.plat.module.system.dal.dataobject.dept.UserPostDO;
|
||||
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.enums.user.UserSourceEnum;
|
||||
import com.zt.plat.module.system.service.dept.PostService;
|
||||
import com.zt.plat.module.system.service.user.AdminUserService;
|
||||
import com.zt.plat.module.system.service.userdept.UserDeptService;
|
||||
import com.zt.plat.module.system.util.sync.SyncVerifyUtil;
|
||||
import com.zt.plat.module.system.enums.user.UserSourceEnum;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -55,7 +55,7 @@ public class UserSyncServiceImpl implements UserSyncService {
|
||||
Long tenantId = Optional.ofNullable(loginUser).orElse(new LoginUser()).getTenantId();
|
||||
TenantContextHolder.setTenantId(tenantId);
|
||||
// 中铝 e 办不会设置密码,设置默认密码
|
||||
saveReqVO.setPassword("ZLEB");
|
||||
saveReqVO.setPassword("Zgty@9527");
|
||||
// 设置为同步用户
|
||||
saveReqVO.setUserSource(UserSourceEnum.SYNC.getSource());
|
||||
Long userId = adminUserService.createUser(saveReqVO);
|
||||
|
||||
@@ -328,7 +328,9 @@ public class AdminUserServiceImpl implements AdminUserService {
|
||||
permissionService.getUserRoleIdListByRoleId(singleton(reqVO.getRoleId())) : null;
|
||||
|
||||
// 分页查询
|
||||
return userMapper.selectPage(reqVO, getDeptCondition(reqVO.getDeptId()), userIds);
|
||||
PageResult<AdminUserDO> pageResult = userMapper.selectPage(reqVO, getDeptCondition(reqVO.getDeptId()), userIds);
|
||||
fillUserDeptInfo(pageResult.getList());
|
||||
return pageResult;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -338,7 +340,15 @@ public class AdminUserServiceImpl implements AdminUserService {
|
||||
adminUserDO.setDeptIds(companyDeptInfoListByUserId.stream().map(CompanyDeptInfo::getDeptId).collect(Collectors.toSet()));
|
||||
adminUserDO.setCompanyIds(companyDeptInfoListByUserId.stream().map(CompanyDeptInfo::getCompanyId).collect(Collectors.toSet()));
|
||||
adminUserDO.setCompanyDeptInfos(companyDeptInfoListByUserId);
|
||||
// 设置用户的部门名称集合
|
||||
String deptNames = companyDeptInfoListByUserId.stream()
|
||||
.map(CompanyDeptInfo::getDeptName)
|
||||
.filter(StrUtil::isNotBlank)
|
||||
.distinct()
|
||||
.collect(Collectors.joining("、"));
|
||||
adminUserDO.setDeptNames(StrUtil.blankToDefault(deptNames, "-"));
|
||||
if (CollUtil.isEmpty(adminUserDO.getDeptIds())) {
|
||||
adminUserDO.setDeptIds(Collections.emptySet());
|
||||
}
|
||||
return adminUserDO;
|
||||
}
|
||||
|
||||
@@ -416,6 +426,48 @@ public class AdminUserServiceImpl implements AdminUserService {
|
||||
return deptIds;
|
||||
}
|
||||
|
||||
private void fillUserDeptInfo(List<AdminUserDO> users) {
|
||||
if (CollUtil.isEmpty(users)) {
|
||||
return;
|
||||
}
|
||||
Map<Long, AdminUserDO> userMap = CollectionUtils.convertMap(users, AdminUserDO::getId);
|
||||
userMap.values().forEach(user -> {
|
||||
user.setDeptIds(Collections.emptySet());
|
||||
user.setDeptNames("-");
|
||||
});
|
||||
|
||||
List<UserDeptDO> relations = userDeptService.getValidUserDeptListByUserIds(userMap.keySet());
|
||||
if (CollUtil.isEmpty(relations)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Map<Long, LinkedHashSet<Long>> userDeptIdsMap = new HashMap<>();
|
||||
relations.forEach(relation ->
|
||||
userDeptIdsMap.computeIfAbsent(relation.getUserId(), key -> new LinkedHashSet<>())
|
||||
.add(relation.getDeptId()));
|
||||
|
||||
Set<Long> allDeptIds = userDeptIdsMap.values().stream()
|
||||
.flatMap(Collection::stream)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
Map<Long, DeptDO> deptMap = allDeptIds.isEmpty() ? Collections.emptyMap() : deptService.getDeptMap(allDeptIds);
|
||||
|
||||
userDeptIdsMap.forEach((userId, deptIds) -> {
|
||||
AdminUserDO user = userMap.get(userId);
|
||||
if (user == null) {
|
||||
return;
|
||||
}
|
||||
user.setDeptIds(deptIds);
|
||||
String deptNames = deptIds.stream()
|
||||
.map(deptMap::get)
|
||||
.filter(Objects::nonNull)
|
||||
.map(DeptDO::getName)
|
||||
.filter(StrUtil::isNotBlank)
|
||||
.distinct()
|
||||
.collect(Collectors.joining("、"));
|
||||
user.setDeptNames(StrUtil.blankToDefault(deptNames, "-"));
|
||||
});
|
||||
}
|
||||
|
||||
private AdminUserDO validateUserForCreateOrUpdate(Long id, String username, String mobile, String email,
|
||||
Set<Long> deptIds, Set<Long> postIds) {
|
||||
// 关闭数据权限,避免因为没有数据权限,查询不到数据,进而导致唯一校验不正确
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.zt.plat.module.system.enums.sms.SmsSceneEnum;
|
||||
import com.zt.plat.module.system.enums.social.SocialTypeEnum;
|
||||
import com.zt.plat.module.system.service.logger.LoginLogService;
|
||||
import com.zt.plat.module.system.service.member.MemberService;
|
||||
import com.zt.plat.module.system.service.oauth2.EbanOAuth2Service;
|
||||
import com.zt.plat.module.system.service.oauth2.OAuth2TokenService;
|
||||
import com.zt.plat.module.system.service.social.SocialUserService;
|
||||
import com.zt.plat.module.system.service.user.AdminUserService;
|
||||
@@ -60,6 +61,8 @@ public class AdminAuthServiceImplTest extends BaseDbUnitTest {
|
||||
private OAuth2TokenService oauth2TokenService;
|
||||
@MockBean
|
||||
private MemberService memberService;
|
||||
@MockBean
|
||||
private EbanOAuth2Service ebanOAuth2Service;
|
||||
@MockBean
|
||||
private Validator validator;
|
||||
|
||||
|
||||
@@ -457,7 +457,7 @@ public class SocialClientServiceImplTest extends BaseDbUnitTest {
|
||||
reqVO.setName("芋");
|
||||
reqVO.setSocialType(SocialTypeEnum.GITEE.getType());
|
||||
reqVO.setUserType(UserTypeEnum.ADMIN.getValue());
|
||||
reqVO.setClientId("yu");
|
||||
reqVO.setClientId("z");
|
||||
reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus());
|
||||
|
||||
// 调用
|
||||
|
||||
@@ -12,7 +12,9 @@ create table IF NOT EXISTS system_user_dept (
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS "system_dept" (
|
||||
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
|
||||
"code" varchar(64) NOT NULL DEFAULT '',
|
||||
"name" varchar(30) NOT NULL DEFAULT '',
|
||||
"short_name" varchar(30) DEFAULT '',
|
||||
"parent_id" bigint NOT NULL DEFAULT '0',
|
||||
"sort" int NOT NULL DEFAULT '0',
|
||||
"leader_user_id" bigint DEFAULT NULL,
|
||||
|
||||
Reference in New Issue
Block a user