Merge remote-tracking branch 'ztcloud/main' into main-ztcloud

# Conflicts:
#	zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/dept/DeptService.java
This commit is contained in:
hewencai
2025-12-24 11:15:23 +08:00
46 changed files with 1539 additions and 1266 deletions

View File

@@ -1,6 +1,7 @@
package com.zt.plat.framework.common.util.security;
import cn.hutool.crypto.SecureUtil;
import com.zt.plat.framework.common.util.json.JsonUtils;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
@@ -126,7 +127,11 @@ public final class CryptoSignatureUtils {
continue;
}
sb.append(key).append('=');
sb.append(value);
if (value instanceof String || value instanceof Number || value instanceof Boolean) {
sb.append(value);
} else {
sb.append(JsonUtils.toJsonString(value));
}
sb.append('&');
}
if (sb.length() > 0) {

View File

@@ -7,6 +7,8 @@ import org.springframework.boot.autoconfigure.AutoConfigurationPackages;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.util.StringUtils;
import java.util.LinkedHashSet;
import java.util.Set;
@@ -18,14 +20,12 @@ import java.util.Set;
public class BusinessDataPermissionConfiguration {
@Bean
public BusinessDataPermissionEntityScanner businessDataPermissionEntityScanner(BeanFactory beanFactory, ApplicationContext applicationContext) {
public BusinessDataPermissionEntityScanner businessDataPermissionEntityScanner(BeanFactory beanFactory, ApplicationContext applicationContext, Environment environment) {
Set<String> basePackages = new LinkedHashSet<>();
addConfiguredBasePackages(environment, basePackages);
if (AutoConfigurationPackages.has(beanFactory)) {
basePackages.addAll(AutoConfigurationPackages.get(beanFactory));
}
if (basePackages.isEmpty()) {
basePackages.add("com.zt");
}
ClassLoader classLoader = applicationContext != null
? applicationContext.getClassLoader()
: Thread.currentThread().getContextClassLoader();
@@ -35,6 +35,21 @@ public class BusinessDataPermissionConfiguration {
return new BusinessDataPermissionEntityScanner(basePackages, classLoader);
}
private void addConfiguredBasePackages(Environment environment, Set<String> basePackages) {
if (environment == null) {
return;
}
String configured = environment.getProperty("zt.info.base-package");
if (!StringUtils.hasText(configured)) {
return;
}
for (String pkg : configured.split("[,;\\s]+")) {
if (StringUtils.hasText(pkg)) {
basePackages.add(pkg.trim());
}
}
}
@Bean
public CompanyDataPermissionRuleCustomizer autoCompanyDataPermissionRuleCustomizer(BusinessDataPermissionEntityScanner scanner) {
return rule -> scanner.getEntityMetadata().forEach(metadata -> {

View File

@@ -28,6 +28,14 @@ import java.util.*;
@Slf4j
public class BusinessDataPermissionEntityScanner {
/**
* 临时排除的包前缀(物流模块 DO不参与数据权限扫描
*/
private static final Set<String> EXCLUDED_PACKAGE_PREFIXES = Set.of(
"com.zt.plat.module.backendlogistics",
"com.zt.plat.module.erp",
"com.zt.plat.framework.mybatis.core.dataobject.BusinessBaseDO");
private final Set<String> basePackages;
private final ClassLoader classLoader;
@@ -70,6 +78,9 @@ public class BusinessDataPermissionEntityScanner {
if (!StringUtils.hasText(className)) {
continue;
}
if (isExcludedPackage(className)) {
continue;
}
try {
Class<?> clazz = ClassUtils.forName(className, classLoader);
if (clazz == BusinessBaseDO.class || !BusinessBaseDO.class.isAssignableFrom(clazz)) {
@@ -92,6 +103,15 @@ public class BusinessDataPermissionEntityScanner {
return new ArrayList<>(metadataMap.values());
}
private boolean isExcludedPackage(String className) {
for (String prefix : EXCLUDED_PACKAGE_PREFIXES) {
if (className.startsWith(prefix)) {
return true;
}
}
return false;
}
private EntityMetadata buildMetadata(Class<? extends BusinessBaseDO> entityClass) {
String tableName = resolveTableName(entityClass);
if (!StringUtils.hasText(tableName)) {

View File

@@ -3,6 +3,7 @@ package com.zt.plat.framework.datapermission.core.rule.dept;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.zt.plat.framework.common.biz.system.permission.PermissionCommonApi;
import com.zt.plat.framework.common.biz.system.permission.dto.DeptDataPermissionRespDTO;
import com.zt.plat.framework.common.enums.UserTypeEnum;
@@ -14,7 +15,7 @@ import com.zt.plat.framework.mybatis.core.util.MyBatisUtils;
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.CompanyContextHolder;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.zt.plat.framework.tenant.core.context.DeptContextHolder;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.sf.jsqlparser.expression.Alias;
@@ -108,6 +109,11 @@ public class DeptDataPermissionRule implements DataPermissionRule {
return null;
}
// 显式忽略部门数据权限时直接放行
if (DeptContextHolder.shouldIgnore()) {
return null;
}
// 获得数据权限
DeptDataPermissionRespDTO deptDataPermission = loginUser.getContext(CONTEXT_KEY, DeptDataPermissionRespDTO.class);
// 从上下文中拿不到,则调用逻辑进行获取
@@ -136,6 +142,20 @@ public class DeptDataPermissionRule implements DataPermissionRule {
}
}
// 若存在部门上下文,优先使用上下文中的单一部门,必要时校验公司一致性
Long ctxDeptId = DeptContextHolder.getDeptId();
if (ctxDeptId != null && ctxDeptId > 0L) {
Long currentCompanyId = CompanyContextHolder.getCompanyId();
Long ctxCompanyId = DeptContextHolder.getCompanyId();
Long compareCompanyId = ctxCompanyId != null ? ctxCompanyId : currentCompanyId;
if (currentCompanyId != null && currentCompanyId > 0L
&& compareCompanyId != null && !currentCompanyId.equals(compareCompanyId)) {
log.warn("[getExpression][LoginUser({}) Table({}/{}) DeptContextHolder company mismatch: currentCompanyId={}, ctxCompanyId={}, ctxDeptId={}, source=DeptContextHolder]",
JsonUtils.toJsonString(loginUser), tableName, tableAlias == null ? null : tableAlias.getName(),
currentCompanyId, compareCompanyId, ctxDeptId);
}
}
// 情况一,如果是 ALL 可查看全部,则无需拼接条件
if (deptDataPermission.getAll()) {
return null;

View File

@@ -7,10 +7,13 @@ import com.zt.plat.framework.common.enums.UserTypeEnum;
import com.zt.plat.framework.common.util.collection.SetUtils;
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.CompanyContextHolder;
import com.zt.plat.framework.tenant.core.context.DeptContextHolder;
import com.zt.plat.framework.test.core.ut.BaseMockitoUnitTest;
import com.zt.plat.framework.common.biz.system.permission.dto.DeptDataPermissionRespDTO;
import net.sf.jsqlparser.expression.Alias;
import net.sf.jsqlparser.expression.Expression;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
@@ -27,6 +30,7 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
@@ -48,7 +52,13 @@ class DeptDataPermissionRuleTest extends BaseMockitoUnitTest {
// 清空 rule
rule.getTableNames().clear();
((Map<String, String>) ReflectUtil.getFieldValue(rule, "deptColumns")).clear();
((Map<String, String>) ReflectUtil.getFieldValue(rule, "deptColumns")).clear();
((Map<String, String>) ReflectUtil.getFieldValue(rule, "userColumns")).clear();
}
@AfterEach
void tearDown() {
DeptContextHolder.clear();
CompanyContextHolder.clear();
}
@Test // 无 LoginUser
@@ -236,4 +246,88 @@ class DeptDataPermissionRuleTest extends BaseMockitoUnitTest {
}
}
@Test // 忽略部门数据权限,直接放行
void testGetExpression_ignoreDeptContext() {
try (MockedStatic<SecurityFrameworkUtils> secMock = mockStatic(SecurityFrameworkUtils.class);
MockedStatic<DeptContextHolder> deptCtxMock = mockStatic(DeptContextHolder.class)) {
String tableName = "t_order";
Alias alias = new Alias("o");
LoginUser loginUser = randomPojo(LoginUser.class, o -> o.setId(1L)
.setUserType(UserTypeEnum.ADMIN.getValue()));
secMock.when(SecurityFrameworkUtils::getLoginUser).thenReturn(loginUser);
deptCtxMock.when(DeptContextHolder::shouldIgnore).thenReturn(true);
Expression expression = rule.getExpression(tableName, alias);
assertNull(expression);
verifyNoInteractions(permissionApi);
}
}
@Test // 上下文部门存在且公司一致时,清空原集合并覆盖为单一 deptId
void testGetExpression_deptContextOverride_companyMatch() {
try (MockedStatic<SecurityFrameworkUtils> secMock = mockStatic(SecurityFrameworkUtils.class);
MockedStatic<DeptContextHolder> deptCtxMock = mockStatic(DeptContextHolder.class);
MockedStatic<CompanyContextHolder> companyCtxMock = mockStatic(CompanyContextHolder.class)) {
String tableName = "t_user";
Alias tableAlias = new Alias("u");
LoginUser loginUser = randomPojo(LoginUser.class, o -> o.setId(1L)
.setUserType(UserTypeEnum.ADMIN.getValue()));
secMock.when(SecurityFrameworkUtils::getLoginUser).thenReturn(loginUser);
DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO()
.setDeptIds(CollUtil.newLinkedHashSet(10L, 20L))
.setCompanyId(1L);
when(permissionApi.getDeptDataPermission(same(1L))).thenReturn(success(deptDataPermission));
deptCtxMock.when(DeptContextHolder::shouldIgnore).thenReturn(false);
deptCtxMock.when(DeptContextHolder::getDeptId).thenReturn(99L);
deptCtxMock.when(DeptContextHolder::getCompanyId).thenReturn(1L);
companyCtxMock.when(CompanyContextHolder::getCompanyId).thenReturn(1L);
companyCtxMock.when(CompanyContextHolder::isIgnore).thenReturn(false);
rule.addDeptColumn(tableName, "dept_id");
Expression expression = rule.getExpression(tableName, tableAlias);
assertEquals("u.dept_id IN (99)", expression.toString());
assertEquals(CollUtil.newLinkedHashSet(99L), deptDataPermission.getDeptIds());
assertEquals(1L, deptDataPermission.getCompanyId());
}
}
@Test // 上下文部门存在但公司不一致时,记录告警并保持原逻辑(不覆盖)
void testGetExpression_deptContextOverride_companyMismatch() {
try (MockedStatic<SecurityFrameworkUtils> secMock = mockStatic(SecurityFrameworkUtils.class);
MockedStatic<DeptContextHolder> deptCtxMock = mockStatic(DeptContextHolder.class);
MockedStatic<CompanyContextHolder> companyCtxMock = mockStatic(CompanyContextHolder.class)) {
String tableName = "t_user";
Alias tableAlias = new Alias("u");
LoginUser loginUser = randomPojo(LoginUser.class, o -> o.setId(1L)
.setUserType(UserTypeEnum.ADMIN.getValue()));
secMock.when(SecurityFrameworkUtils::getLoginUser).thenReturn(loginUser);
DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO()
.setDeptIds(CollUtil.newLinkedHashSet(10L))
.setCompanyId(1L);
when(permissionApi.getDeptDataPermission(same(1L))).thenReturn(success(deptDataPermission));
deptCtxMock.when(DeptContextHolder::shouldIgnore).thenReturn(false);
deptCtxMock.when(DeptContextHolder::getDeptId).thenReturn(99L);
deptCtxMock.when(DeptContextHolder::getCompanyId).thenReturn(2L);
companyCtxMock.when(CompanyContextHolder::getCompanyId).thenReturn(1L);
companyCtxMock.when(CompanyContextHolder::isIgnore).thenReturn(false);
rule.addDeptColumn(tableName, "dept_id");
Expression expression = rule.getExpression(tableName, tableAlias);
assertEquals("u.dept_id IN (10)", expression.toString());
assertEquals(CollUtil.newLinkedHashSet(10L), deptDataPermission.getDeptIds());
assertEquals(1L, deptDataPermission.getCompanyId());
}
}
}

View File

@@ -0,0 +1,61 @@
package com.zt.plat.framework.tenant.core.context;
import com.alibaba.ttl.TransmittableThreadLocal;
/**
* 部门上下文 Holder使用 {@link TransmittableThreadLocal} 支持在线程池/异步场景下的上下文传递。
*
* 包含当前部门编号、所属公司编号以及是否忽略部门数据权限的标识。
*/
public class DeptContextHolder {
/** 当前部门编号 */
private static final ThreadLocal<Long> DEPT_ID = new TransmittableThreadLocal<>();
/** 当前部门所属公司编号(用于一致性校验) */
private static final ThreadLocal<Long> COMPANY_ID = new TransmittableThreadLocal<>();
/** 是否忽略部门数据权限 */
private static final ThreadLocal<Boolean> IGNORE = new TransmittableThreadLocal<>();
public static Long getDeptId() {
return DEPT_ID.get();
}
public static Long getCompanyId() {
return COMPANY_ID.get();
}
/**
* 设置部门与所属公司编号。
*/
public static void setContext(Long deptId, Long companyId) {
DEPT_ID.set(deptId);
COMPANY_ID.set(companyId);
}
public static void setDeptId(Long deptId) {
DEPT_ID.set(deptId);
}
public static void setCompanyId(Long companyId) {
COMPANY_ID.set(companyId);
}
public static boolean hasDeptId() {
Long deptId = DEPT_ID.get();
return deptId != null && deptId > 0L;
}
public static void setIgnore(Boolean ignore) {
IGNORE.set(ignore);
}
public static boolean shouldIgnore() {
return Boolean.TRUE.equals(IGNORE.get());
}
public static void clear() {
DEPT_ID.remove();
COMPANY_ID.remove();
IGNORE.remove();
}
}

View File

@@ -3,6 +3,7 @@ package com.zt.plat.framework.tenant.core.web;
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.CompanyContextHolder;
import com.zt.plat.framework.tenant.core.context.DeptContextHolder;
import com.zt.plat.framework.web.core.util.WebFrameworkUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -66,11 +67,19 @@ public class CompanyVisitContextInterceptor implements HandlerInterceptor {
if (companyId == null || companyId <= 0L) {
CompanyContextHolder.setIgnore(true);
DeptContextHolder.clear();
return true;
}
CompanyContextHolder.setIgnore(false);
CompanyContextHolder.setCompanyId(companyId);
// 默认不忽略部门数据权限;如果有有效部门则写入上下文
DeptContextHolder.setIgnore(false);
if (deptId != null && deptId > 0L) {
DeptContextHolder.setContext(deptId, companyId);
} else {
DeptContextHolder.clear();
}
if (loginUser == null) {
return true;
}
@@ -91,7 +100,9 @@ public class CompanyVisitContextInterceptor implements HandlerInterceptor {
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
if (loginUser != null) {
loginUser.setVisitCompanyId(0L);
loginUser.setVisitDeptId(0L);
}
DeptContextHolder.clear();
}
private Long resolveLong(Object value) {

View File

@@ -0,0 +1,88 @@
package com.zt.plat.framework.tenant.core.web;
import com.zt.plat.framework.security.core.LoginUser;
import com.zt.plat.framework.tenant.core.context.CompanyContextHolder;
import com.zt.plat.framework.tenant.core.context.DeptContextHolder;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import static org.junit.jupiter.api.Assertions.*;
/**
* CompanyVisitContextInterceptor 单测,覆盖公司/部门上下文写入及清理。
*/
class CompanyVisitContextInterceptorTest {
private final HandlerInterceptor interceptor = new CompanyVisitContextInterceptor();
@AfterEach
void tearDown() {
CompanyContextHolder.clear();
DeptContextHolder.clear();
SecurityContextHolder.clearContext();
}
@Test // 无公司 id应 ignore公司/部门上下文清空
void testPreHandle_noCompanyId_ignore() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletResponse response = new MockHttpServletResponse();
boolean result = interceptor.preHandle(request, response, new Object());
assertTrue(result);
assertTrue(CompanyContextHolder.isIgnore());
assertNull(CompanyContextHolder.getCompanyId());
assertNull(DeptContextHolder.getDeptId());
}
@Test // 有公司无部门:写入公司,部门清空
void testPreHandle_companyOnly() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletResponse response = new MockHttpServletResponse();
LoginUser loginUser = new LoginUser();
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(loginUser, null));
request.addHeader("visit-company-id", "11");
boolean result = interceptor.preHandle(request, response, new Object());
assertTrue(result);
assertFalse(CompanyContextHolder.isIgnore());
assertEquals(11L, CompanyContextHolder.getCompanyId());
assertFalse(DeptContextHolder.shouldIgnore());
assertNull(DeptContextHolder.getDeptId());
assertEquals(11L, loginUser.getVisitCompanyId());
assertNull(loginUser.getVisitDeptId());
}
@Test // 有公司+部门写入公司、部门上下文afterCompletion 清理 visitDeptId & holder
void testPreHandle_withCompanyAndDept_andAfterCompletionClear() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletResponse response = new MockHttpServletResponse();
LoginUser loginUser = new LoginUser();
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(loginUser, null));
request.addHeader("visit-company-id", "22");
request.addHeader("visit-dept-id", "33");
boolean result = interceptor.preHandle(request, response, new Object());
assertTrue(result);
assertFalse(CompanyContextHolder.isIgnore());
assertEquals(22L, CompanyContextHolder.getCompanyId());
assertEquals(33L, DeptContextHolder.getDeptId());
assertEquals(22L, DeptContextHolder.getCompanyId());
assertEquals(22L, loginUser.getVisitCompanyId());
assertEquals(33L, loginUser.getVisitDeptId());
// afterCompletion: 清理 visitCompanyId/visitDeptId 与 holder
interceptor.afterCompletion(request, response, new Object(), null);
assertEquals(0L, loginUser.getVisitCompanyId());
assertEquals(0L, loginUser.getVisitDeptId());
assertNull(DeptContextHolder.getDeptId());
assertNull(DeptContextHolder.getCompanyId());
}
}