1. 修复界面bug

2. 新增 api 可配置匿名访问固定用户配置
3. 新增密码弱口令校验规则
4. e 办使用 loginName 确认唯一用户逻辑
This commit is contained in:
chenbowen
2025-10-24 17:02:10 +08:00
parent 27796ff67d
commit 6e4cc4d55e
56 changed files with 1268 additions and 246 deletions

View File

@@ -7,6 +7,12 @@ ALTER TABLE infra_file ADD aes_iv VARCHAR(128);
COMMENT ON COLUMN infra_file.aes_iv IS 'AES加密时的随机IVBase64编码'; COMMENT ON COLUMN infra_file.aes_iv IS 'AES加密时的随机IVBase64编码';
-- 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 业务附件统一管理 -- 3 业务附件统一管理
DROP TABLE IF EXISTS infra_bsn_file; DROP TABLE IF EXISTS infra_bsn_file;
CREATE TABLE infra_bsn_file ( CREATE TABLE infra_bsn_file (

View 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 (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 (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 (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 (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 (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'); 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');

View File

@@ -198,7 +198,7 @@ COMMENT ON COLUMN databus_api_transform.update_time IS '更新时间';
COMMENT ON COLUMN databus_api_transform.deleted IS '逻辑删除标记'; COMMENT ON COLUMN databus_api_transform.deleted IS '逻辑删除标记';
-- 统一外部网关 - 客户端凭证表DM8 -- 统一外部网关 - 客户端凭证表DM8
-- 可重复执行的建表脚本,执行前请备份历史数据 -- 可重复执行的建表脚本,执行前请备份历史数据
DROP TABLE IF EXISTS databus_api_client_credential; DROP TABLE IF EXISTS databus_api_client_credential;
@@ -212,6 +212,8 @@ CREATE TABLE databus_api_client_credential (
signature_type VARCHAR(32) NOT NULL, signature_type VARCHAR(32) NOT NULL,
enabled BIT DEFAULT '1' NOT NULL, enabled BIT DEFAULT '1' NOT NULL,
remark VARCHAR(255), remark VARCHAR(255),
allow_anonymous BIT DEFAULT '0' NOT NULL,
anonymous_user_id BIGINT,
creator VARCHAR(64) DEFAULT '' NOT NULL, creator VARCHAR(64) DEFAULT '' NOT NULL,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updater VARCHAR(64) DEFAULT '' 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.signature_type IS '签名算法';
COMMENT ON COLUMN databus_api_client_credential.enabled 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.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.creator IS '创建者';
COMMENT ON COLUMN databus_api_client_credential.create_time IS '创建时间'; COMMENT ON COLUMN databus_api_client_credential.create_time IS '创建时间';
COMMENT ON COLUMN databus_api_client_credential.updater IS '更新者'; COMMENT ON COLUMN databus_api_client_credential.updater IS '更新者';

View File

@@ -9,11 +9,7 @@ import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Base64; import java.util.*;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
/** /**
* 通用的签名、加解密工具类 * 通用的签名、加解密工具类
@@ -26,7 +22,7 @@ public final class CryptoSignatureUtils {
public static final String SIGNATURE_TYPE_SHA256 = "SHA256"; public static final String SIGNATURE_TYPE_SHA256 = "SHA256";
private static final String AES_TRANSFORMATION = "AES/ECB/PKCS5Padding"; 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() { private CryptoSignatureUtils() {
} }

View File

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

View File

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

View File

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

View File

@@ -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.ApiClientCredentialSaveReqVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.credential.ApiClientCredentialSimpleRespVO; 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.dal.dataobject.gateway.ApiClientCredentialDO;
import com.zt.plat.module.databus.service.gateway.ApiAnonymousUserService;
import com.zt.plat.module.databus.service.gateway.ApiClientCredentialService; import com.zt.plat.module.databus.service.gateway.ApiClientCredentialService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
@@ -26,6 +27,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
import static com.zt.plat.framework.common.pojo.CommonResult.success; import static com.zt.plat.framework.common.pojo.CommonResult.success;
import static org.springframework.util.CollectionUtils.isEmpty;
@Tag(name = "管理后台 - API 客户端凭证") @Tag(name = "管理后台 - API 客户端凭证")
@RestController @RestController
@@ -35,19 +37,24 @@ import static com.zt.plat.framework.common.pojo.CommonResult.success;
public class ApiClientCredentialController { public class ApiClientCredentialController {
private final ApiClientCredentialService credentialService; private final ApiClientCredentialService credentialService;
private final ApiAnonymousUserService anonymousUserService;
@GetMapping("/page") @GetMapping("/page")
@Operation(summary = "分页查询客户端凭证") @Operation(summary = "分页查询客户端凭证")
public CommonResult<PageResult<ApiClientCredentialRespVO>> page(ApiClientCredentialPageReqVO reqVO) { public CommonResult<PageResult<ApiClientCredentialRespVO>> page(ApiClientCredentialPageReqVO reqVO) {
PageResult<ApiClientCredentialDO> page = credentialService.getPage(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") @GetMapping("/get")
@Operation(summary = "查询凭证详情") @Operation(summary = "查询凭证详情")
public CommonResult<ApiClientCredentialRespVO> get(@RequestParam("id") Long id) { public CommonResult<ApiClientCredentialRespVO> get(@RequestParam("id") Long id) {
ApiClientCredentialDO credential = credentialService.get(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") @PostMapping("/create")
@@ -76,4 +83,14 @@ public class ApiClientCredentialController {
List<ApiClientCredentialDO> list = credentialService.listEnabled(); List<ApiClientCredentialDO> list = credentialService.listEnabled();
return success(ApiClientCredentialConvert.INSTANCE.convertSimpleList(list)); 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())));
}
} }

View File

@@ -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.ApiGatewayInvokeReqVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.definition.ApiDefinitionDetailRespVO; 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.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.framework.integration.gateway.model.ApiGatewayResponse;
import com.zt.plat.module.databus.service.gateway.ApiDefinitionService; import com.zt.plat.module.databus.service.gateway.ApiDefinitionService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -27,6 +28,7 @@ public class ApiGatewayController {
private final ApiGatewayExecutionService executionService; private final ApiGatewayExecutionService executionService;
private final ApiDefinitionService apiDefinitionService; private final ApiDefinitionService apiDefinitionService;
private final IntegrationFlowManager integrationFlowManager;
@PostMapping(value = "/invoke", consumes = MediaType.APPLICATION_JSON_VALUE) @PostMapping(value = "/invoke", consumes = MediaType.APPLICATION_JSON_VALUE)
@Operation(summary = "测试调用 API 编排") @Operation(summary = "测试调用 API 编排")
@@ -43,4 +45,12 @@ public class ApiGatewayController {
return success(definitions); return success(definitions);
} }
@PostMapping("/cache/refresh")
@Operation(summary = "刷新 API 缓存")
public CommonResult<Boolean> refreshCache() {
apiDefinitionService.refreshAllCache();
integrationFlowManager.refreshAll();
return success(Boolean.TRUE);
}
} }

View File

@@ -33,6 +33,15 @@ public class ApiClientCredentialRespVO {
@Schema(description = "备注", example = "默认应用凭证") @Schema(description = "备注", example = "默认应用凭证")
private String remark; 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 = "创建时间") @Schema(description = "创建时间")
private LocalDateTime createTime; private LocalDateTime createTime;

View File

@@ -38,4 +38,11 @@ public class ApiClientCredentialSaveReqVO {
@Schema(description = "备注", example = "默认应用凭证") @Schema(description = "备注", example = "默认应用凭证")
private String remark; private String remark;
@Schema(description = "允许匿名访问", example = "false")
@NotNull(message = "匿名访问标识不能为空")
private Boolean allowAnonymous;
@Schema(description = "匿名访问固定用户 ID", example = "1024")
private Long anonymousUserId;
} }

View File

@@ -34,4 +34,8 @@ public class ApiClientCredentialDO extends BaseDO {
private String remark; private String remark;
private Boolean allowAnonymous;
private Long anonymousUserId;
} }

View File

@@ -17,6 +17,10 @@ import java.util.Set;
@ConfigurationProperties(prefix = "databus.api-portal") @ConfigurationProperties(prefix = "databus.api-portal")
public class ApiGatewayProperties { 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 DEFAULT_BASE_PATH = "/admin-api/databus/api/portal";
public static final String LEGACY_BASE_PATH = "/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 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 signatureType = CryptoSignatureUtils.SIGNATURE_TYPE_MD5;
private String encryptionType = CryptoSignatureUtils.ENCRYPT_TYPE_AES; private String encryptionType = CryptoSignatureUtils.ENCRYPT_TYPE_AES;

View File

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

View File

@@ -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.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; 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.security.CryptoSignatureUtils;
import com.zt.plat.framework.common.util.servlet.ServletUtils; 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.dal.dataobject.gateway.ApiClientCredentialDO;
import com.zt.plat.module.databus.framework.integration.config.ApiGatewayProperties; 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.framework.integration.gateway.model.ApiGatewayResponse;
import com.zt.plat.module.databus.service.gateway.ApiAnonymousUserService;
import com.zt.plat.module.databus.service.gateway.ApiClientCredentialService; import com.zt.plat.module.databus.service.gateway.ApiClientCredentialService;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
@@ -25,6 +31,7 @@ import org.springframework.web.util.ContentCachingResponseWrapper;
import org.springframework.web.util.UriComponentsBuilder; import org.springframework.web.util.UriComponentsBuilder;
import java.io.IOException; import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.Charset; import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
@@ -32,6 +39,9 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; 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 校验、报文签名、加解密与防重复校验。 * 对进入网关的请求统一执行 IP 校验、报文签名、加解密与防重复校验。
*/ */
@@ -43,6 +53,7 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
private final ApiGatewayProperties properties; private final ApiGatewayProperties properties;
private final StringRedisTemplate stringRedisTemplate; private final StringRedisTemplate stringRedisTemplate;
private final ApiClientCredentialService credentialService; private final ApiClientCredentialService credentialService;
private final ApiAnonymousUserService anonymousUserService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final AntPathMatcher pathMatcher = new AntPathMatcher(); private final AntPathMatcher pathMatcher = new AntPathMatcher();
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {}; 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) protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException { throws ServletException, IOException {
String pathWithinApplication = pathWithinApplication(request); String pathWithinApplication = pathWithinApplication(request);
// 仅处理配置的 API 门户路径,不符合的请求直接放行
boolean matchesPortalPath = properties.getAllBasePaths() boolean matchesPortalPath = properties.getAllBasePaths()
.stream() .stream()
.map(this::normalizeBasePath) .map(this::normalizeBasePath)
@@ -59,6 +71,7 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
return; return;
} }
// 校验访问 IP 是否落在允许范围内
if (!isIpAllowed(request)) { if (!isIpAllowed(request)) {
log.warn("[API-PORTAL] 拦截来自 IP {} 访问 {} 的请求", request.getRemoteAddr(), pathWithinApplication); log.warn("[API-PORTAL] 拦截来自 IP {} 访问 {} 的请求", request.getRemoteAddr(), pathWithinApplication);
response.sendError(HttpStatus.FORBIDDEN.value(), "IP 禁止访问"); response.sendError(HttpStatus.FORBIDDEN.value(), "IP 禁止访问");
@@ -72,28 +85,46 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
} }
try { try {
Long tenantId = resolveTenantId(request); Long tenantId = resolveTenantId(request);
String appId = requireHeader(request, security.getAppIdHeader(), "缺少应用标识"); // 从请求头解析 appId 并加载客户端凭证,包含匿名访问配置
String appId = requireHeader(request, APP_ID_HEADER, "缺少应用标识");
credential = credentialService.findActiveCredential(appId) credential = credentialService.findActiveCredential(appId)
.orElseThrow(() -> new SecurityValidationException(HttpStatus.UNAUTHORIZED, "应用凭证不存在或已禁用")); .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); validateTimestamp(timestampHeader, security);
String nonce = requireHeader(request, security.getNonceHeader(), "缺少随机数"); String nonce = requireHeader(request, NONCE_HEADER, "缺少随机数");
if (nonce.length() < 8) { if (nonce.length() < 8) {
throw new SecurityValidationException(HttpStatus.BAD_REQUEST, "随机数长度不足"); 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[] originalBody = StreamUtils.copyToByteArray(request.getInputStream());
// 尝试按凭证配置解密请求体,并构建签名载荷进行校验
byte[] decryptedBody = decryptRequestBody(originalBody, credential, security); 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); ensureNonce(tenantId, appId, nonce, security);
// 使用可重复读取的请求包装,供后续过滤器继续消费
CachedBodyHttpServletRequest securedRequest = new CachedBodyHttpServletRequest(request, decryptedBody); CachedBodyHttpServletRequest securedRequest = new CachedBodyHttpServletRequest(request, decryptedBody);
if (StringUtils.hasText(request.getCharacterEncoding())) { if (StringUtils.hasText(request.getCharacterEncoding())) {
securedRequest.setCharacterEncoding(request.getCharacterEncoding()); securedRequest.setCharacterEncoding(request.getCharacterEncoding());
} }
propagateJwtToken(request, securedRequest); if (allowAnonymous) {
applyAnonymousLogin(securedRequest, anonymousDetails);
} else {
propagateJwtToken(request, securedRequest);
}
ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response); ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response);
try { try {
filterChain.doFilter(securedRequest, responseWrapper); filterChain.doFilter(securedRequest, responseWrapper);
@@ -213,11 +244,15 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
byte[] decryptedBody, byte[] decryptedBody,
String signature, String signature,
ApiClientCredentialDO credential, ApiClientCredentialDO credential,
ApiGatewayProperties.Security security) { ApiGatewayProperties.Security security,
String appId,
String timestampHeader) {
Map<String, Object> signaturePayload = new LinkedHashMap<>(); Map<String, Object> signaturePayload = new LinkedHashMap<>();
mergeQueryParameters(signaturePayload, request); mergeQueryParameters(signaturePayload, request);
mergeBodyParameters(signaturePayload, decryptedBody); 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); String signatureType = resolveSignatureType(credential, security);
try { try {
@@ -331,12 +366,16 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
if (!security.isEncryptResponse()) { if (!security.isEncryptResponse()) {
return; return;
} }
byte[] plainBody = responseWrapper.getContentAsByteArray();
if (plainBody.length == 0) {
// 空响应无需加密,直接返回避免因密钥缺失导致异常
return;
}
String encryptionKey = credential.getEncryptionKey(); String encryptionKey = credential.getEncryptionKey();
String encryptionType = resolveEncryptionType(credential, security); String encryptionType = resolveEncryptionType(credential, security);
if (!StringUtils.hasText(encryptionKey) || !StringUtils.hasText(encryptionType)) { if (!StringUtils.hasText(encryptionKey) || !StringUtils.hasText(encryptionType)) {
throw new SecurityValidationException(HttpStatus.INTERNAL_SERVER_ERROR, "应用未配置加密密钥"); throw new SecurityValidationException(HttpStatus.INTERNAL_SERVER_ERROR, "应用未配置加密密钥");
} }
byte[] plainBody = responseWrapper.getContentAsByteArray();
String charsetName = responseWrapper.getCharacterEncoding(); String charsetName = responseWrapper.getCharacterEncoding();
if (!StringUtils.hasText(charsetName)) { if (!StringUtils.hasText(charsetName)) {
charsetName = StandardCharsets.UTF_8.name(); charsetName = StandardCharsets.UTF_8.name();
@@ -372,6 +411,33 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
securedRequest.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); 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 static final class SecurityValidationException extends RuntimeException {
private final HttpStatus status; private final HttpStatus status;

View File

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

View File

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

View File

@@ -29,6 +29,11 @@ public interface ApiDefinitionService {
*/ */
Optional<ApiDefinitionAggregate> refresh(String apiCode, String version); 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. * Lookup API definition aggregate by primary key.
*/ */

View File

@@ -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.controller.admin.gateway.vo.credential.ApiClientCredentialSaveReqVO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiClientCredentialDO; 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.dal.mysql.gateway.ApiClientCredentialMapper;
import com.zt.plat.module.databus.service.gateway.ApiAnonymousUserService;
import com.zt.plat.module.databus.service.gateway.ApiClientCredentialService; import com.zt.plat.module.databus.service.gateway.ApiClientCredentialService;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor; 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_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_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 @Slf4j
@Service @Service
@@ -31,6 +34,7 @@ import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErro
public class ApiClientCredentialServiceImpl implements ApiClientCredentialService { public class ApiClientCredentialServiceImpl implements ApiClientCredentialService {
private final ApiClientCredentialMapper credentialMapper; private final ApiClientCredentialMapper credentialMapper;
private final ApiAnonymousUserService anonymousUserService;
private LoadingCache<String, Optional<ApiClientCredentialDO>> credentialCache; private LoadingCache<String, Optional<ApiClientCredentialDO>> credentialCache;
@@ -51,10 +55,17 @@ public class ApiClientCredentialServiceImpl implements ApiClientCredentialServic
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public Long create(ApiClientCredentialSaveReqVO reqVO) { public Long create(ApiClientCredentialSaveReqVO reqVO) {
ensureAppIdUnique(reqVO.getAppId(), null); ensureAppIdUnique(reqVO.getAppId(), null);
normalizeAnonymousSettings(reqVO);
ApiClientCredentialDO credential = BeanUtils.toBean(reqVO, ApiClientCredentialDO.class); ApiClientCredentialDO credential = BeanUtils.toBean(reqVO, ApiClientCredentialDO.class);
credential.setId(null); credential.setId(null);
credential.setDeleted(Boolean.FALSE); credential.setDeleted(Boolean.FALSE);
if (credential.getAllowAnonymous() == null) {
credential.setAllowAnonymous(Boolean.FALSE);
}
if (Boolean.FALSE.equals(credential.getAllowAnonymous())) {
credential.setAnonymousUserId(null);
}
credentialMapper.insert(credential); credentialMapper.insert(credential);
invalidateCache(credential.getAppId()); invalidateCache(credential.getAppId());
return credential.getId(); return credential.getId();
@@ -65,11 +76,22 @@ public class ApiClientCredentialServiceImpl implements ApiClientCredentialServic
public void update(ApiClientCredentialSaveReqVO reqVO) { public void update(ApiClientCredentialSaveReqVO reqVO) {
ApiClientCredentialDO existing = ensureExists(reqVO.getId()); ApiClientCredentialDO existing = ensureExists(reqVO.getId());
ensureAppIdUnique(reqVO.getAppId(), existing.getId()); ensureAppIdUnique(reqVO.getAppId(), existing.getId());
normalizeAnonymousSettings(reqVO);
ApiClientCredentialDO updateObj = BeanUtils.toBean(reqVO, ApiClientCredentialDO.class); 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); credentialMapper.updateById(updateObj);
invalidateCache(existing.getAppId()); invalidateCache(existing.getAppId());
invalidateCache(updateObj.getAppId()); invalidateCache(updateObj.getAppId());
if (!Objects.equals(existing.getAnonymousUserId(), updateObj.getAnonymousUserId())) {
anonymousUserService.invalidate(existing.getAnonymousUserId());
anonymousUserService.invalidate(updateObj.getAnonymousUserId());
}
} }
@Override @Override
@@ -78,6 +100,7 @@ public class ApiClientCredentialServiceImpl implements ApiClientCredentialServic
ApiClientCredentialDO existing = ensureExists(id); ApiClientCredentialDO existing = ensureExists(id);
credentialMapper.deleteById(id); credentialMapper.deleteById(id);
invalidateCache(existing.getAppId()); invalidateCache(existing.getAppId());
anonymousUserService.invalidate(existing.getAnonymousUserId());
} }
@Override @Override
@@ -130,4 +153,18 @@ public class ApiClientCredentialServiceImpl implements ApiClientCredentialServic
} }
credentialCache.invalidate(appId.trim()); 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);
}
}
} }

View File

@@ -38,6 +38,7 @@ import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.time.Duration; import java.time.Duration;
@@ -102,6 +103,12 @@ public class ApiDefinitionServiceImpl implements ApiDefinitionService {
return findByCodeAndVersion(apiCode, version); return findByCodeAndVersion(apiCode, version);
} }
@Override
public void refreshAllCache() {
definitionCache.invalidateAll();
clearRedisCacheForTenant(TenantContextHolder.getTenantId());
}
@Override @Override
public Optional<ApiDefinitionAggregate> findById(Long id) { public Optional<ApiDefinitionAggregate> findById(Long id) {
return Optional.ofNullable(apiDefinitionMapper.selectById(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) { private String buildCacheKey(String apiCode, String version) {
Long tenantId = TenantContextHolder.getTenantId(); Long tenantId = TenantContextHolder.getTenantId();
return buildCacheKeyForTenant(tenantId, apiCode, version); return buildCacheKeyForTenant(tenantId, apiCode, version);
} }
/**
* 构建包含步骤、变换、策略等元数据的聚合对象,供缓存与运行时直接使用。
*/
private ApiDefinitionAggregate buildAggregate(ApiDefinitionDO definition) { private ApiDefinitionAggregate buildAggregate(ApiDefinitionDO definition) {
List<ApiStepDO> stepDOS = apiStepMapper.selectByApiId(definition.getId()); List<ApiStepDO> stepDOS = apiStepMapper.selectByApiId(definition.getId());
List<ApiStepDefinition> stepDefinitions = new ArrayList<>(stepDOS.size()); List<ApiStepDefinition> stepDefinitions = new ArrayList<>(stepDOS.size());
@@ -313,6 +337,9 @@ public class ApiDefinitionServiceImpl implements ApiDefinitionService {
} }
} }
/**
* 顺序持久化步骤定义,并针对开始/结束节点清理不必要配置避免脏数据。
*/
private void persistSteps(Long apiId, List<ApiDefinitionStepSaveReqVO> steps) { private void persistSteps(Long apiId, List<ApiDefinitionStepSaveReqVO> steps) {
if (CollUtil.isEmpty(steps)) { if (CollUtil.isEmpty(steps)) {
return; return;
@@ -323,14 +350,7 @@ public class ApiDefinitionServiceImpl implements ApiDefinitionService {
ApiStepDO stepDO = BeanUtils.toBean(stepVO, ApiStepDO.class); ApiStepDO stepDO = BeanUtils.toBean(stepVO, ApiStepDO.class);
stepDO.setId(null); stepDO.setId(null);
stepDO.setApiId(apiId); stepDO.setApiId(apiId);
if (isStartStep(stepVO)) { if (isStartStep(stepVO) || isEndStep(stepVO)) {
stepDO.setParallelGroup(null);
stepDO.setTargetEndpoint(null);
stepDO.setFallbackStrategy(null);
stepDO.setConditionExpr(null);
stepDO.setStopOnError(Boolean.FALSE);
stepDO.setTimeout(null);
} else if (isEndStep(stepVO)) {
stepDO.setParallelGroup(null); stepDO.setParallelGroup(null);
stepDO.setTargetEndpoint(null); stepDO.setTargetEndpoint(null);
stepDO.setFallbackStrategy(null); stepDO.setFallbackStrategy(null);
@@ -380,6 +400,9 @@ public class ApiDefinitionServiceImpl implements ApiDefinitionService {
.ifPresent(definition -> { throw ServiceExceptionUtil.exception(API_DEFINITION_DUPLICATE); }); .ifPresent(definition -> { throw ServiceExceptionUtil.exception(API_DEFINITION_DUPLICATE); });
} }
/**
* 校验步骤编排的拓扑约束,确保开始/结束节点唯一且位置正确。
*/
private void validateStructure(ApiDefinitionSaveReqVO reqVO) { private void validateStructure(ApiDefinitionSaveReqVO reqVO) {
if (CollUtil.isEmpty(reqVO.getSteps())) { if (CollUtil.isEmpty(reqVO.getSteps())) {
throw ServiceExceptionUtil.exception(API_DEFINITION_STEP_EMPTY); throw ServiceExceptionUtil.exception(API_DEFINITION_STEP_EMPTY);

View File

@@ -52,5 +52,8 @@ public interface GatewayServiceErrorCodeConstants {
ErrorCode API_SIGNATURE_CONFIG_INVALID = new ErrorCode(1_010_000_043, "签名策略配置异常"); 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_NOT_FOUND = new ErrorCode(1_010_000_044, "应用凭证不存在或已删除");
ErrorCode API_CREDENTIAL_DUPLICATE_APP = new ErrorCode(1_010_000_045, "应用标识已存在"); 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, "匿名访问固定用户不存在或已被禁用");
} }

View File

@@ -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.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.core.ApiGatewayExecutionService;
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiGatewayResponse; 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 com.zt.plat.module.databus.service.gateway.ApiDefinitionService;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.TestPropertySource;
@@ -25,7 +28,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@WebMvcTest(ApiGatewayController.class) @WebMvcTest(ApiGatewayController.class)
@AutoConfigureMockMvc(addFilters = false) @AutoConfigureMockMvc(addFilters = false)
@TestPropertySource(properties = { @TestPropertySource(properties = {
"spring.config.import=optional:",
"spring.cloud.nacos.config.enabled=false", "spring.cloud.nacos.config.enabled=false",
"spring.cloud.nacos.discovery.enabled=false" "spring.cloud.nacos.discovery.enabled=false"
}) })
@@ -40,6 +42,15 @@ class ApiGatewayControllerTest {
@MockBean @MockBean
private ApiDefinitionService apiDefinitionService; private ApiDefinitionService apiDefinitionService;
@MockBean
private ApiGatewayProperties apiGatewayProperties;
@MockBean
private StringRedisTemplate stringRedisTemplate;
@MockBean
private ApiClientCredentialService apiClientCredentialService;
@Test @Test
void invokeShouldReturnGatewayEnvelope() throws Exception { void invokeShouldReturnGatewayEnvelope() throws Exception {
ApiGatewayResponse response = ApiGatewayResponse.builder() ApiGatewayResponse response = ApiGatewayResponse.builder()

View File

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

View File

@@ -1,24 +1,34 @@
package com.zt.plat.module.databus.framework.integration.gateway.security; 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.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; 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.dal.dataobject.gateway.ApiClientCredentialDO;
import com.zt.plat.module.databus.framework.integration.config.ApiGatewayProperties; 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.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.junit.jupiter.api.Test;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations; 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.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.http.HttpStatus; import org.springframework.security.core.context.SecurityContextHolder;
import java.time.Duration; import java.time.Duration;
import java.util.Collections; import java.util.Collections;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; 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.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
@@ -27,13 +37,21 @@ import static org.mockito.Mockito.when;
class GatewaySecurityFilterTest { class GatewaySecurityFilterTest {
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Test @Test
void shouldAllowRequestWhenIpPermitted() throws Exception { void shouldAllowRequestWhenIpPermitted() throws Exception {
ApiGatewayProperties properties = createProperties(); ApiGatewayProperties properties = createProperties();
properties.getSecurity().setEnabled(false); properties.getSecurity().setEnabled(false);
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
ApiClientCredentialService credentialService = mock(ApiClientCredentialService.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());
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"); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin-api/databus/api/portal/demo/v1");
request.setRemoteAddr("127.0.0.1"); request.setRemoteAddr("127.0.0.1");
@@ -54,7 +72,9 @@ class GatewaySecurityFilterTest {
properties.getSecurity().setEnabled(false); properties.getSecurity().setEnabled(false);
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
ApiClientCredentialService credentialService = mock(ApiClientCredentialService.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"); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin-api/databus/api/portal/demo/v1");
request.setRemoteAddr("10.0.0.1"); request.setRemoteAddr("10.0.0.1");
@@ -76,6 +96,8 @@ class GatewaySecurityFilterTest {
when(valueOperations.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(Boolean.TRUE); when(valueOperations.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(Boolean.TRUE);
ApiClientCredentialService credentialService = mock(ApiClientCredentialService.class); ApiClientCredentialService credentialService = mock(ApiClientCredentialService.class);
ApiAnonymousUserService anonymousUserService = mock(ApiAnonymousUserService.class);
when(anonymousUserService.issueAccessToken(any())).thenReturn(Optional.empty());
ApiClientCredentialDO credential = new ApiClientCredentialDO(); ApiClientCredentialDO credential = new ApiClientCredentialDO();
credential.setAppId("demo-app"); credential.setAppId("demo-app");
credential.setSignatureType(null); credential.setSignatureType(null);
@@ -86,17 +108,17 @@ class GatewaySecurityFilterTest {
properties.getSecurity().setRequireBodyEncryption(false); properties.getSecurity().setRequireBodyEncryption(false);
properties.getSecurity().setEncryptResponse(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"); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin-api/databus/api/portal/demo/v1");
request.setRemoteAddr("127.0.0.1"); request.setRemoteAddr("127.0.0.1");
long timestamp = System.currentTimeMillis(); long timestamp = System.currentTimeMillis();
String nonce = UUID.randomUUID().toString().replaceAll("-", ""); String nonce = UUID.randomUUID().toString().replaceAll("-", "");
String signature = "d41d8cd98f00b204e9800998ecf8427e"; String signature = signatureForApp("demo-app");
request.addHeader(properties.getSecurity().getAppIdHeader(), "demo-app"); request.addHeader(APP_ID_HEADER, "demo-app");
request.addHeader(properties.getSecurity().getTimestampHeader(), String.valueOf(timestamp)); request.addHeader(TIMESTAMP_HEADER, String.valueOf(timestamp));
request.addHeader(properties.getSecurity().getNonceHeader(), nonce); request.addHeader(NONCE_HEADER, nonce);
request.addHeader(properties.getSecurity().getSignatureHeader(), signature); request.addHeader(SIGNATURE_HEADER, signature);
MockHttpServletResponse response = new MockHttpServletResponse(); MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain(); MockFilterChain chain = new MockFilterChain();
@@ -119,22 +141,23 @@ class GatewaySecurityFilterTest {
when(valueOperations.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(Boolean.TRUE); when(valueOperations.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(Boolean.TRUE);
ApiClientCredentialService credentialService = mock(ApiClientCredentialService.class); ApiClientCredentialService credentialService = mock(ApiClientCredentialService.class);
ApiAnonymousUserService anonymousUserService = mock(ApiAnonymousUserService.class);
ApiClientCredentialDO credential = new ApiClientCredentialDO(); ApiClientCredentialDO credential = new ApiClientCredentialDO();
credential.setAppId("demo-app"); credential.setAppId("demo-app");
credential.setEncryptionKey("demo-secret-key"); credential.setEncryptionKey("demo-secret-key");
credential.setEncryptionType(CryptoSignatureUtils.ENCRYPT_TYPE_AES); credential.setEncryptionType(CryptoSignatureUtils.ENCRYPT_TYPE_AES);
when(credentialService.findActiveCredential("demo-app")).thenReturn(Optional.of(credential)); 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"); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin-api/databus/api/portal/demo/v1");
request.setRemoteAddr("127.0.0.1"); request.setRemoteAddr("127.0.0.1");
long timestamp = System.currentTimeMillis(); long timestamp = System.currentTimeMillis();
String nonce = UUID.randomUUID().toString().replaceAll("-", ""); String nonce = UUID.randomUUID().toString().replaceAll("-", "");
request.addHeader(properties.getSecurity().getAppIdHeader(), "demo-app"); request.addHeader(APP_ID_HEADER, "demo-app");
request.addHeader(properties.getSecurity().getTimestampHeader(), String.valueOf(timestamp)); request.addHeader(TIMESTAMP_HEADER, String.valueOf(timestamp));
request.addHeader(properties.getSecurity().getNonceHeader(), nonce); request.addHeader(NONCE_HEADER, nonce);
request.addHeader(properties.getSecurity().getSignatureHeader(), "invalid-signature"); request.addHeader(SIGNATURE_HEADER, "invalid-signature");
MockHttpServletResponse response = new MockHttpServletResponse(); MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(request, response, new MockFilterChain()); filter.doFilter(request, response, new MockFilterChain());
@@ -150,10 +173,65 @@ class GatewaySecurityFilterTest {
assertThat(node.get("message").asText()).isEqualTo("签名校验失败"); 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() { private ApiGatewayProperties createProperties() {
ApiGatewayProperties properties = new ApiGatewayProperties(); ApiGatewayProperties properties = new ApiGatewayProperties();
properties.setBasePath("/admin-api/databus/api/portal"); properties.setBasePath("/admin-api/databus/api/portal");
properties.setAllowedIps(Collections.singletonList("127.0.0.1")); properties.setAllowedIps(Collections.singletonList("127.0.0.1"));
return properties; return properties;
} }
private String signatureForApp(String appId) {
return SecureUtil.md5("appId=" + appId);
}
} }

View File

@@ -17,6 +17,9 @@ public class AdminUserRespDTO implements VO {
@Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "小王") @Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "小王")
private String nickname; private String nickname;
@Schema(description = "租户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Long tenantId;
@Schema(description = "帐号状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") @Schema(description = "帐号状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Integer status; // 参见 CommonStatusEnum 枚举 private Integer status; // 参见 CommonStatusEnum 枚举

View File

@@ -22,8 +22,9 @@ public enum DataScopeEnum implements ArrayValuable<Integer> {
DEPT_CUSTOM(2), // 指定部门数据权限 DEPT_CUSTOM(2), // 指定部门数据权限
DEPT_ONLY(3), // 部门数据权限 DEPT_ONLY(3), // 部门数据权限
DEPT_AND_CHILD(4), // 部门及以下数据权限 DEPT_AND_CHILD(4), // 部门及以下数据权限
SELF(5), // 仅本人数据权限
SELF(5); // 仅本人数据权限 COMPANY_AND_DEPT(6); // 公司及所属部门数据权限
/** /**
* 范围 * 范围

View File

@@ -5,6 +5,7 @@ import cn.hutool.core.util.ObjUtil;
import com.zt.plat.framework.common.pojo.CommonResult; import com.zt.plat.framework.common.pojo.CommonResult;
import com.zt.plat.framework.common.util.object.BeanUtils; import com.zt.plat.framework.common.util.object.BeanUtils;
import com.zt.plat.framework.datapermission.core.util.DataPermissionUtils; 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.AdminUserRespDTO;
import com.zt.plat.module.system.api.user.dto.AdminUserSaveReqDTO; import com.zt.plat.module.system.api.user.dto.AdminUserSaveReqDTO;
import com.zt.plat.module.system.api.user.dto.AdminUserUpdatePasswordReqDTO; import com.zt.plat.module.system.api.user.dto.AdminUserUpdatePasswordReqDTO;
@@ -70,6 +71,7 @@ public class AdminUserApiImpl implements AdminUserApi {
} }
@Override @Override
@TenantIgnore
public CommonResult<AdminUserRespDTO> getUser(Long id) { public CommonResult<AdminUserRespDTO> getUser(Long id) {
AdminUserDO user = userService.getUser(id); AdminUserDO user = userService.getUser(id);
return success(BeanUtils.toBean(user, AdminUserRespDTO.class)); return success(BeanUtils.toBean(user, AdminUserRespDTO.class));

View File

@@ -1,6 +1,7 @@
package com.zt.plat.module.system.controller.admin.auth.vo; 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 io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotEmpty;
@@ -26,6 +27,7 @@ public class AuthRegisterReqVO extends CaptchaVerificationReqVO {
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456") @Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
@NotEmpty(message = "密码不能为空") @NotEmpty(message = "密码不能为空")
@Length(min = 4, max = 16, message = "密码长度为 4-16") @Length(min = 6, max = 20, message = "密码长度为 6-20")
@Password
private String password; private String password;
} }

View File

@@ -1,6 +1,7 @@
package com.zt.plat.module.system.controller.admin.auth.vo; 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.Mobile;
import com.zt.plat.framework.common.validation.Password;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotEmpty;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
@@ -18,7 +19,8 @@ public class AuthResetPasswordReqVO {
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "1234") @Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "1234")
@NotEmpty(message = "密码不能为空") @NotEmpty(message = "密码不能为空")
@Length(min = 4, max = 16, message = "密码长度为 4-16") @Length(min = 6, max = 20, message = "密码长度为 6-20")
@Password
private String password; private String password;
@Schema(description = "手机号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13312341234") @Schema(description = "手机号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13312341234")

View File

@@ -1,7 +1,6 @@
package com.zt.plat.module.system.controller.admin.auth.vo; package com.zt.plat.module.system.controller.admin.auth.vo;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
@@ -13,9 +12,8 @@ import org.hibernate.validator.constraints.Length;
@AllArgsConstructor @AllArgsConstructor
public class AuthVerifyPasswordReqVO { public class AuthVerifyPasswordReqVO {
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "buzhidao") @Schema(description = "密码", example = "buzhidao")
@NotEmpty(message = "密码不能为空") @Length(max = 16, message = "密码长度不能超过 16 位")
@Length(min = 4, max = 16, message = "密码长度为 4-16 位")
private String password; private String password;
} }

View File

@@ -136,6 +136,15 @@ public class DeptController {
return success(BeanUtils.toBean(list, DeptRespVO.class)); 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") @GetMapping("/get")
@Operation(summary = "获得部门信息") @Operation(summary = "获得部门信息")
@Parameter(name = "id", description = "编号", required = true, example = "1024") @Parameter(name = "id", description = "编号", required = true, example = "1024")

View File

@@ -10,6 +10,9 @@ public class DeptListReqVO {
@Schema(description = "部门名称,模糊匹配", example = "芋道") @Schema(description = "部门名称,模糊匹配", example = "芋道")
private String name; private String name;
@Schema(description = "部门编码,精确匹配", example = "ZT001")
private String code;
@Schema(description = "展示状态,参见 CommonStatusEnum 枚举类", example = "1") @Schema(description = "展示状态,参见 CommonStatusEnum 枚举类", example = "1")
private Integer status; private Integer status;

View File

@@ -2,14 +2,14 @@ package com.zt.plat.module.system.controller.admin.tenant.vo.tenant;
import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.ObjectUtil;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import com.zt.plat.framework.common.validation.Password;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@Schema(description = "管理后台 - 租户创建/修改 Request VO") @Schema(description = "管理后台 - 租户创建/修改 Request VO")
@@ -57,7 +57,8 @@ public class TenantSaveReqVO {
private String username; private String username;
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456") @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; private String password;
@AssertTrue(message = "用户账号、密码不能为空") @AssertTrue(message = "用户账号、密码不能为空")

View File

@@ -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.convert.user.UserConvert;
import com.zt.plat.module.system.dal.dataobject.user.AdminUserDO; 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.enums.common.SexEnum;
import com.zt.plat.module.system.service.dept.DeptService;
import com.zt.plat.module.system.service.user.AdminUserService; import com.zt.plat.module.system.service.user.AdminUserService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
@@ -38,8 +37,6 @@ public class UserController {
@Resource @Resource
private AdminUserService userService; private AdminUserService userService;
@Resource
private DeptService deptService;
@PostMapping("/create") @PostMapping("/create")
@Operation(summary = "新增用户") @Operation(summary = "新增用户")

View File

@@ -1,11 +1,11 @@
package com.zt.plat.module.system.controller.admin.user.vo.profile; 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 io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data; import lombok.Data;
import org.hibernate.validator.constraints.Length; import org.hibernate.validator.constraints.Length;
import jakarta.validation.constraints.NotEmpty;
@Schema(description = "管理后台 - 用户个人中心更新密码 Request VO") @Schema(description = "管理后台 - 用户个人中心更新密码 Request VO")
@Data @Data
public class UserProfileUpdatePasswordReqVO { public class UserProfileUpdatePasswordReqVO {
@@ -17,7 +17,8 @@ public class UserProfileUpdatePasswordReqVO {
@Schema(description = "新密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "654321") @Schema(description = "新密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "654321")
@NotEmpty(message = "新密码不能为空") @NotEmpty(message = "新密码不能为空")
@Length(min = 4, max = 16, message = "密码长度为 4-16") @Length(min = 6, max = 20, message = "密码长度为 6-20")
@Password
private String newPassword; private String newPassword;
} }

View File

@@ -34,9 +34,9 @@ public class UserRespVO{
@Schema(description = "部门ID列表", example = "我是一个部门Id列表") @Schema(description = "部门ID列表", example = "我是一个部门Id列表")
private List<Long> deptIds; private List<Long> deptIds;
// @Schema(description = "部门名称", example = "IT 部")
// @ExcelProperty("部门名称") @Schema(description = "部门名称", example = "总部研发部、平台组")
// private String deptName; private String deptNames;
@Schema(description = "岗位编号数组", example = "1") @Schema(description = "岗位编号数组", example = "1")
private Set<Long> postIds; private Set<Long> postIds;

View File

@@ -1,12 +1,13 @@
package com.zt.plat.module.system.controller.admin.user.vo.user; package com.zt.plat.module.system.controller.admin.user.vo.user;
import cn.hutool.core.util.ObjectUtil; 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.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.DeptParseFunction;
import com.zt.plat.module.system.framework.operatelog.core.PostParseFunction; import com.zt.plat.module.system.framework.operatelog.core.PostParseFunction;
import com.zt.plat.module.system.framework.operatelog.core.SexParseFunction; 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 io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.*;
import lombok.Data; import lombok.Data;
@@ -76,7 +77,8 @@ public class UserSaveReqVO {
// ========== 仅【创建】时,需要传递的字段 ========== // ========== 仅【创建】时,需要传递的字段 ==========
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456") @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; private String password;
@AssertTrue(message = "密码不能为空") @AssertTrue(message = "密码不能为空")

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
package com.zt.plat.module.system.controller.admin.user.vo.user; 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 io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
@Schema(description = "管理后台 - 用户更新密码 Request VO") @Schema(description = "管理后台 - 用户更新密码 Request VO")
@Data @Data
@@ -17,7 +17,8 @@ public class UserUpdatePasswordReqVO {
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456") @Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
@NotEmpty(message = "密码不能为空") @NotEmpty(message = "密码不能为空")
@Length(min = 4, max = 16, message = "密码长度为 4-16") @Length(min = 6, max = 20, message = "密码长度为 6-20")
@Password
private String password; private String password;
} }

View File

@@ -30,7 +30,11 @@ public interface UserConvert {
} }
default UserRespVO convert(AdminUserDO user) { 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) { default List<UserSimpleRespVO> convertSimpleList(List<AdminUserDO> list) {

View File

@@ -60,6 +60,9 @@ public class AdminUserDO extends TenantBaseDO {
@TableField(exist = false, typeHandler = JacksonTypeHandler.class ) @TableField(exist = false, typeHandler = JacksonTypeHandler.class )
@NotEmpty @NotEmpty
private Set<Long> deptIds; private Set<Long> deptIds;
@TableField(exist = false)
private String deptNames;
/** /**
* 公司 ID 列表 * 公司 ID 列表
*/ */

View File

@@ -1,11 +1,12 @@
package com.zt.plat.module.system.dal.mysql.dept; 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.mapper.BaseMapperX;
import com.zt.plat.framework.mybatis.core.query.LambdaQueryWrapperX; 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.controller.admin.dept.vo.dept.DeptListReqVO;
import com.zt.plat.module.system.dal.dataobject.dept.DeptDO; 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 org.apache.ibatis.annotations.Mapper;
import java.util.Collection; import java.util.Collection;
@@ -20,6 +21,7 @@ public interface DeptMapper extends BaseMapperX<DeptDO> {
default List<DeptDO> selectList(DeptListReqVO reqVO) { default List<DeptDO> selectList(DeptListReqVO reqVO) {
return selectList(new LambdaQueryWrapperX<DeptDO>() return selectList(new LambdaQueryWrapperX<DeptDO>()
.likeIfPresent(DeptDO::getName, reqVO.getName()) .likeIfPresent(DeptDO::getName, reqVO.getName())
.eqIfPresent(DeptDO::getCode, reqVO.getCode())
.eqIfPresent(DeptDO::getStatus, reqVO.getStatus()) .eqIfPresent(DeptDO::getStatus, reqVO.getStatus())
.eqIfPresent(DeptDO::getIsCompany, reqVO.getIsCompany()) .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())
);
}
} }

View File

@@ -1,6 +1,10 @@
package com.zt.plat.module.system.service.auth; package com.zt.plat.module.system.service.auth;
import cn.hutool.core.util.ObjectUtil; 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.CommonStatusEnum;
import com.zt.plat.framework.common.enums.UserTypeEnum; import com.zt.plat.framework.common.enums.UserTypeEnum;
import com.zt.plat.framework.common.util.monitor.TracerUtils; 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.logger.LoginResultEnum;
import com.zt.plat.module.system.enums.oauth2.OAuth2ClientConstants; 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.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.logger.LoginLogService;
import com.zt.plat.module.system.service.member.MemberService; 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.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.social.SocialUserService;
import com.zt.plat.module.system.service.user.AdminUserService; 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.annotation.Resource;
import jakarta.validation.Validator; import jakarta.validation.Validator;
import lombok.Setter; import lombok.Setter;
@@ -286,6 +286,16 @@ public class AdminAuthServiceImpl implements AdminAuthService {
if (user == null) { if (user == null) {
throw exception(USER_NOT_EXISTS); 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())) { if (!userService.isPasswordMatch(password, user.getPassword())) {
throw exception(AUTH_LOGIN_BAD_CREDENTIALS); throw exception(AUTH_LOGIN_BAD_CREDENTIALS);
} }

View File

@@ -128,6 +128,15 @@ public interface DeptService {
Set<CompanyDeptInfo> getCompanyDeptInfoListByUserId(Long userId); Set<CompanyDeptInfo> getCompanyDeptInfoListByUserId(Long userId);
/**
* 计算公司及其直属部门数据权限范围
* 在给定部门集合的场景下,按所属公司维度汇总公司本级及其直属部门(不含其他子公司)
*
* @param deptIds 部门编号集合
* @return 公司及其直属部门编号集合
*/
Set<Long> computeCompanyScopeDeptIds(Set<Long> deptIds);
/** /**
* 获取当前用户可访问的顶级部门列表 * 获取当前用户可访问的顶级部门列表
* 用于懒加载,返回当前用户所属部门的最顶层祖先部门 * 用于懒加载,返回当前用户所属部门的最顶层祖先部门
@@ -157,4 +166,12 @@ public interface DeptService {
* 按照新的编码规则初始化全部部门编码 * 按照新的编码规则初始化全部部门编码
*/ */
void initializeDeptCodes(); void initializeDeptCodes();
/**
* 根据关键字搜索部门树,包含匹配节点及其上级节点
*
* @param keyword 关键字
* @return 部门列表
*/
List<DeptDO> searchDeptTree(String keyword);
} }

View File

@@ -400,19 +400,11 @@ public class DeptServiceImpl implements DeptService {
return Collections.emptyList(); return Collections.emptyList();
} }
Set<Long> companyIds = new HashSet<>(); Set<Long> companyIds = new HashSet<>();
Map<Long, DeptDO> deptCache = new HashMap<>();
for (Long deptId : deptIds) { for (Long deptId : deptIds) {
DeptDO dept = getDept(deptId); Long companyId = resolveNearestCompanyId(deptId, deptCache);
while (dept != null) { if (companyId != null) {
// 如果当前部门是公司,加入结果并结束本次递归 companyIds.add(companyId);
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());
} }
} }
return getDeptList(companyIds); return getDeptList(companyIds);
@@ -440,43 +432,15 @@ public class DeptServiceImpl implements DeptService {
// 如果指定了公司ID则进一步过滤属于该公司的部门 // 如果指定了公司ID则进一步过滤属于该公司的部门
if (companyId != null) { if (companyId != null) {
Map<Long, DeptDO> deptCache = new HashMap<>();
return deptList.stream() return deptList.stream()
.filter(dept -> isUnderCompany(dept, companyId)) .filter(dept -> Objects.equals(resolveNearestCompanyId(dept.getId(), deptCache), companyId))
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
return deptList; 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查询其归属公司及直属部门关系列表不递归下级公司 * 根据用户ID查询其归属公司及直属部门关系列表不递归下级公司
*/ */
@@ -493,18 +457,13 @@ public class DeptServiceImpl implements DeptService {
// 查询所有部门信息 // 查询所有部门信息
Map<Long, DeptDO> deptMap = getDeptList(deptIds).stream() Map<Long, DeptDO> deptMap = getDeptList(deptIds).stream()
.collect(Collectors.toMap(DeptDO::getId, d -> d)); .collect(Collectors.toMap(DeptDO::getId, d -> d));
Map<Long, DeptDO> deptCache = new HashMap<>(deptMap);
Set<CompanyDeptInfo> result = new HashSet<>(); Set<CompanyDeptInfo> result = new HashSet<>();
for (Long deptId : deptIds) { for (Long deptId : deptIds) {
DeptDO dept = deptMap.get(deptId); DeptDO dept = loadDept(deptId, deptCache);
if (dept == null) continue; if (dept == null) continue;
// 向上查找公司如果到达顶层parentId为PARENT_ID_ROOT还没找到公司则用顶层部门作为公司 Long companyId = resolveNearestCompanyId(deptId, deptCache);
DeptDO company = dept; DeptDO company = companyId != null ? loadDept(companyId, deptCache) : findTopLevelAncestor(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());
}
if (company == null) continue; if (company == null) continue;
CompanyDeptInfo info = new CompanyDeptInfo(); CompanyDeptInfo info = new CompanyDeptInfo();
info.setCompanyId(company.getId()); info.setCompanyId(company.getId());
@@ -518,6 +477,26 @@ public class DeptServiceImpl implements DeptService {
return result; 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 @Override
public List<DeptDO> getTopLevelDeptList() { 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;
}
} }

View File

@@ -1,7 +1,8 @@
package com.zt.plat.module.system.service.oauth2; 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.AuthLoginRespVO;
import com.zt.plat.module.system.controller.admin.auth.vo.AuthOAuth2CallbackReqVO;
import lombok.Data;
/** /**
* E办OAuth2服务接口 * E办OAuth2服务接口
@@ -30,6 +31,7 @@ public interface EbanOAuth2Service {
/** /**
* E办用户信息 * E办用户信息
*/ */
@Data
class EbanUserInfo { class EbanUserInfo {
private String username; private String username;
private String realName; private String realName;
@@ -37,47 +39,12 @@ public interface EbanOAuth2Service {
private String mobile; private String mobile;
private String deptName; private String deptName;
private String uid; private String uid;
private String displayName; private String displayName;
private String loginName;
private String rawUserInfoJson; private String rawUserInfoJson;
private EbanOAuth2ServiceImpl.EbanTokenInfo tokenInfo; // 添加Token信息 private EbanOAuth2ServiceImpl.EbanTokenInfo tokenInfo; // 添加Token信息
// 构造函数 // 构造函数
public EbanUserInfo() {} 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; }
} }
} }

View File

@@ -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.dal.dataobject.user.AdminUserDO;
import com.zt.plat.module.system.enums.logger.LoginLogTypeEnum; 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.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.logger.LoginLogService;
import com.zt.plat.module.system.service.user.AdminUserService; import com.zt.plat.module.system.service.user.AdminUserService;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -26,7 +25,6 @@ import java.util.HashMap;
import java.util.Map; import java.util.Map;
import static com.zt.plat.framework.common.exception.util.ServiceExceptionUtil.exception; 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.*; import static com.zt.plat.module.system.enums.ErrorCodeConstants.*;
/** /**
@@ -46,9 +44,6 @@ public class EbanOAuth2ServiceImpl implements EbanOAuth2Service {
@Resource @Resource
private LoginLogService loginLogService; private LoginLogService loginLogService;
@Resource
private OAuth2TokenService oauth2TokenService;
@Resource @Resource
private EbanTokenService ebanTokenService; private EbanTokenService ebanTokenService;
@@ -77,16 +72,16 @@ public class EbanOAuth2ServiceImpl implements EbanOAuth2Service {
throw exception(AUTH_LOGIN_EBAN_TOKEN_INVALID); throw exception(AUTH_LOGIN_EBAN_TOKEN_INVALID);
} }
String displayName = StrUtil.trim(StrUtil.blankToDefault(userInfo.getDisplayName(), userInfo.getUsername())); String username = StrUtil.trim(StrUtil.blankToDefault(userInfo.getLoginName(), userInfo.getUsername()));
if (StrUtil.isBlank(displayName)) { if (StrUtil.isBlank(username)) {
log.error("E办OAuth2用户信息缺少displayName与loginName无法匹配账号: {}", JSONUtil.toJsonStr(userInfo)); log.error("E办OAuth2用户信息缺少 username 与 loginName无法匹配账号: {}", JSONUtil.toJsonStr(userInfo));
throw exception(AUTH_LOGIN_EBAN_TOKEN_INVALID); throw exception(AUTH_LOGIN_EBAN_TOKEN_INVALID);
} }
AdminUserDO user = userService.getUserByUsername(displayName); AdminUserDO user = userService.getUserByUsername(username);
if (user == null) { if (user == null) {
createLoginLog(null, displayName, LoginLogTypeEnum.LOGIN_SOCIAL, LoginResultEnum.BAD_CREDENTIALS); createLoginLog(null, username, LoginLogTypeEnum.LOGIN_SOCIAL, LoginResultEnum.BAD_CREDENTIALS);
log.warn("E办OAuth2用户displayName未在系统中找到对应账号: {}", displayName); log.warn("E办OAuth2用户displayName未在系统中找到对应账号: {}", username);
throw exception(AUTH_LOGIN_EBAN_USER_NOT_SYNC); throw exception(AUTH_LOGIN_EBAN_USER_NOT_SYNC);
} }
@@ -95,27 +90,24 @@ public class EbanOAuth2ServiceImpl implements EbanOAuth2Service {
throw exception(AUTH_LOGIN_USER_DISABLED); throw exception(AUTH_LOGIN_USER_DISABLED);
} }
try { EbanTokenInfo tokenInfo = userInfo.getTokenInfo();
EbanTokenInfo tokenInfo = userInfo.getTokenInfo(); if (tokenInfo == null || StrUtil.isBlank(tokenInfo.getAccessToken())) {
if (tokenInfo != null && StrUtil.isNotBlank(tokenInfo.getAccessToken())) { log.error("E办OAuth2回调缺少有效的token信息uid={}, username={}", userInfo.getUid(), username);
String userInfoJson = StrUtil.blankToDefault(userInfo.getRawUserInfoJson(), buildBasicUserInfoJson(userInfo)); throw exception(AUTH_LOGIN_EBAN_TOKEN_INVALID);
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办tokenuserId={}, uid={}, displayName={}", user.getId(), userInfo.getUid(), displayName);
}
} catch (Exception e) {
log.error("保存E办token失败userId={}, uid={}, displayName={}", user.getId(), userInfo.getUid(), displayName, e);
} }
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办tokenuserId={}, uid={}, username={}", user.getId(), userInfo.getUid(), username);
return createTokenAfterLoginSuccess(user.getId(), user.getUsername(), LoginLogTypeEnum.LOGIN_SOCIAL, ebanAccessTokenDO);
} catch (ServiceException e) { } catch (ServiceException e) {
throw e; throw e;
@@ -139,18 +131,6 @@ public class EbanOAuth2ServiceImpl implements EbanOAuth2Service {
return userInfo; 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信息 * E办Token信息
*/ */
@@ -340,18 +320,15 @@ public class EbanOAuth2ServiceImpl implements EbanOAuth2Service {
/** /**
* 登录成功后创建Token * 登录成功后创建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); createLoginLog(userId, username, logType, LoginResultEnum.SUCCESS);
// 创建访问令牌 if (accessTokenDO == null) {
OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.createAccessToken( throw exception(AUTH_LOGIN_EBAN_TOKEN_INVALID);
userId, }
com.zt.plat.framework.common.enums.UserTypeEnum.ADMIN.getValue(),
OAuth2ClientConstants.CLIENT_ID_DEFAULT,
null
);
// 构建返回结果 // 构建返回结果
return AuthConvert.INSTANCE.convert(accessTokenDO); return AuthConvert.INSTANCE.convert(accessTokenDO);
} }

View File

@@ -17,12 +17,11 @@ public interface EbanTokenService {
* @param accessToken E办访问令牌 * @param accessToken E办访问令牌
* @param refreshToken E办刷新令牌 * @param refreshToken E办刷新令牌
* @param expiresIn 过期时间(秒) * @param expiresIn 过期时间(秒)
* @param uid E办用户唯一标识 * @param ebanUserInfo e办用户信息
* @param userInfo E办用户信息JSON格式
* @return OAuth2AccessTokenDO * @return OAuth2AccessTokenDO
*/ */
OAuth2AccessTokenDO createEbanToken(Long userId, Long tenantId, String accessToken, String refreshToken, OAuth2AccessTokenDO createEbanToken(Long userId, Long tenantId, String accessToken, String refreshToken,
Integer expiresIn, String uid, String userInfo); Integer expiresIn, EbanOAuth2Service.EbanUserInfo ebanUserInfo);
/** /**
* 根据用户ID获取E办Token * 根据用户ID获取E办Token
@@ -54,7 +53,7 @@ public interface EbanTokenService {
* @return 是否有效 * @return 是否有效
*/ */
boolean isEbanTokenValid(Long userId); boolean isEbanTokenValid(Long userId);
/** /**
* 根据access_token获取E办Token信息 * 根据access_token获取E办Token信息
* *

View File

@@ -53,19 +53,26 @@ public class EbanTokenServiceImpl implements EbanTokenService {
@Override @Override
public OAuth2AccessTokenDO createEbanToken(Long userId, Long tenantId, String accessToken, String refreshToken, 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)) { if (StrUtil.isBlank(accessToken)) {
throw ServiceExceptionUtil.exception(AUTH_LOGIN_EBAN_TOKEN_INVALID); throw ServiceExceptionUtil.exception(AUTH_LOGIN_EBAN_TOKEN_INVALID);
} }
if (userInfo == null) {
throw ServiceExceptionUtil.exception(AUTH_LOGIN_EBAN_TOKEN_INVALID);
}
LocalDateTime expiresTime = calculateExpiresTime(expiresIn); LocalDateTime expiresTime = calculateExpiresTime(expiresIn);
Map<String, String> userInfoMap = MapUtil.newHashMap(2, false); Map<String, String> userInfoMap = MapUtil.newHashMap(6, false);
if (StrUtil.isNotBlank(uid)) { if (StrUtil.isNotBlank(userInfo.getLoginName())) {
userInfoMap.put("uid", uid); userInfoMap.put("username", userInfo.getLoginName());
} }
if (StrUtil.isNotBlank(userInfo)) { if (StrUtil.isNotBlank(userInfo.getDisplayName())) {
userInfoMap.put("rawUserInfo", userInfo); 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); OAuth2AccessTokenDO tokenDO = oauth2AccessTokenMapper.selectByUserIdAndClientId(userId, EBAN_CLIENT_ID);
if (tokenDO == null) { if (tokenDO == null) {
@@ -87,7 +94,7 @@ public class EbanTokenServiceImpl implements EbanTokenService {
oauth2AccessTokenMapper.updateById(tokenDO); oauth2AccessTokenMapper.updateById(tokenDO);
} }
log.info("保存E办Token成功userId={}, uid={}, expiresTime={}", userId, uid, expiresTime); log.info("保存E办Token成功userId={}, expiresTime={}", userId, expiresTime);
return tokenDO; return tokenDO;
} }

View File

@@ -372,7 +372,7 @@ public class PermissionServiceImpl implements PermissionService {
CollUtil.addAll(result.getDeptIds(), userDeptIds.get()); CollUtil.addAll(result.getDeptIds(), userDeptIds.get());
continue; continue;
} }
// 情况四DEPT_DEPT_AND_CHILD // 情况四DEPT_AND_CHILD
if (Objects.equals(role.getDataScope(), DataScopeEnum.DEPT_AND_CHILD.getScope())) { if (Objects.equals(role.getDataScope(), DataScopeEnum.DEPT_AND_CHILD.getScope())) {
Set<Long> deptIds = Optional.ofNullable(userDeptIds.get()).orElseGet(Collections::emptySet); Set<Long> deptIds = Optional.ofNullable(userDeptIds.get()).orElseGet(Collections::emptySet);
for (Long userDeptId : deptIds) { for (Long userDeptId : deptIds) {
@@ -382,7 +382,15 @@ public class PermissionServiceImpl implements PermissionService {
CollUtil.addAll(result.getDeptIds(), deptIds); CollUtil.addAll(result.getDeptIds(), deptIds);
continue; 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())) { if (Objects.equals(role.getDataScope(), DataScopeEnum.SELF.getScope())) {
result.setSelf(true); result.setSelf(true);
continue; continue;

View File

@@ -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.user.AdminUserDO;
import com.zt.plat.module.system.dal.dataobject.userdept.UserDeptDO; import com.zt.plat.module.system.dal.dataobject.userdept.UserDeptDO;
import com.zt.plat.module.system.dal.mysql.dept.UserPostMapper; import com.zt.plat.module.system.dal.mysql.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.dept.PostService;
import com.zt.plat.module.system.service.user.AdminUserService; import com.zt.plat.module.system.service.user.AdminUserService;
import com.zt.plat.module.system.service.userdept.UserDeptService; import com.zt.plat.module.system.service.userdept.UserDeptService;
import com.zt.plat.module.system.util.sync.SyncVerifyUtil; import com.zt.plat.module.system.util.sync.SyncVerifyUtil;
import com.zt.plat.module.system.enums.user.UserSourceEnum;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -55,7 +55,7 @@ public class UserSyncServiceImpl implements UserSyncService {
Long tenantId = Optional.ofNullable(loginUser).orElse(new LoginUser()).getTenantId(); Long tenantId = Optional.ofNullable(loginUser).orElse(new LoginUser()).getTenantId();
TenantContextHolder.setTenantId(tenantId); TenantContextHolder.setTenantId(tenantId);
// 中铝 e 办不会设置密码,设置默认密码 // 中铝 e 办不会设置密码,设置默认密码
saveReqVO.setPassword("ZLEB"); saveReqVO.setPassword("Zgty@9527");
// 设置为同步用户 // 设置为同步用户
saveReqVO.setUserSource(UserSourceEnum.SYNC.getSource()); saveReqVO.setUserSource(UserSourceEnum.SYNC.getSource());
Long userId = adminUserService.createUser(saveReqVO); Long userId = adminUserService.createUser(saveReqVO);

View File

@@ -328,7 +328,9 @@ public class AdminUserServiceImpl implements AdminUserService {
permissionService.getUserRoleIdListByRoleId(singleton(reqVO.getRoleId())) : null; 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 @Override
@@ -338,7 +340,15 @@ public class AdminUserServiceImpl implements AdminUserService {
adminUserDO.setDeptIds(companyDeptInfoListByUserId.stream().map(CompanyDeptInfo::getDeptId).collect(Collectors.toSet())); adminUserDO.setDeptIds(companyDeptInfoListByUserId.stream().map(CompanyDeptInfo::getDeptId).collect(Collectors.toSet()));
adminUserDO.setCompanyIds(companyDeptInfoListByUserId.stream().map(CompanyDeptInfo::getCompanyId).collect(Collectors.toSet())); adminUserDO.setCompanyIds(companyDeptInfoListByUserId.stream().map(CompanyDeptInfo::getCompanyId).collect(Collectors.toSet()));
adminUserDO.setCompanyDeptInfos(companyDeptInfoListByUserId); 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; return adminUserDO;
} }
@@ -416,6 +426,48 @@ public class AdminUserServiceImpl implements AdminUserService {
return deptIds; 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, private AdminUserDO validateUserForCreateOrUpdate(Long id, String username, String mobile, String email,
Set<Long> deptIds, Set<Long> postIds) { Set<Long> deptIds, Set<Long> postIds) {
// 关闭数据权限,避免因为没有数据权限,查询不到数据,进而导致唯一校验不正确 // 关闭数据权限,避免因为没有数据权限,查询不到数据,进而导致唯一校验不正确

View File

@@ -16,6 +16,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.enums.social.SocialTypeEnum;
import com.zt.plat.module.system.service.logger.LoginLogService; 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.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.oauth2.OAuth2TokenService;
import com.zt.plat.module.system.service.social.SocialUserService; import com.zt.plat.module.system.service.social.SocialUserService;
import com.zt.plat.module.system.service.user.AdminUserService; import com.zt.plat.module.system.service.user.AdminUserService;
@@ -60,6 +61,8 @@ public class AdminAuthServiceImplTest extends BaseDbUnitTest {
private OAuth2TokenService oauth2TokenService; private OAuth2TokenService oauth2TokenService;
@MockBean @MockBean
private MemberService memberService; private MemberService memberService;
@MockBean
private EbanOAuth2Service ebanOAuth2Service;
@MockBean @MockBean
private Validator validator; private Validator validator;

View File

@@ -457,7 +457,7 @@ public class SocialClientServiceImplTest extends BaseDbUnitTest {
reqVO.setName(""); reqVO.setName("");
reqVO.setSocialType(SocialTypeEnum.GITEE.getType()); reqVO.setSocialType(SocialTypeEnum.GITEE.getType());
reqVO.setUserType(UserTypeEnum.ADMIN.getValue()); reqVO.setUserType(UserTypeEnum.ADMIN.getValue());
reqVO.setClientId("yu"); reqVO.setClientId("z");
reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus()); reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus());
// 调用 // 调用

View File

@@ -12,7 +12,9 @@ create table IF NOT EXISTS system_user_dept (
); );
CREATE TABLE IF NOT EXISTS "system_dept" ( CREATE TABLE IF NOT EXISTS "system_dept" (
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
"code" varchar(64) NOT NULL DEFAULT '',
"name" varchar(30) NOT NULL DEFAULT '', "name" varchar(30) NOT NULL DEFAULT '',
"short_name" varchar(30) DEFAULT '',
"parent_id" bigint NOT NULL DEFAULT '0', "parent_id" bigint NOT NULL DEFAULT '0',
"sort" int NOT NULL DEFAULT '0', "sort" int NOT NULL DEFAULT '0',
"leader_user_id" bigint DEFAULT NULL, "leader_user_id" bigint DEFAULT NULL,