diff --git a/zt-framework/zt-spring-boot-starter-biz-data-permission/src/main/java/com/zt/plat/framework/datapermission/core/rule/dept/DeptDataPermissionRule.java b/zt-framework/zt-spring-boot-starter-biz-data-permission/src/main/java/com/zt/plat/framework/datapermission/core/rule/dept/DeptDataPermissionRule.java index 7ff3fe02..e54a7f54 100644 --- a/zt-framework/zt-spring-boot-starter-biz-data-permission/src/main/java/com/zt/plat/framework/datapermission/core/rule/dept/DeptDataPermissionRule.java +++ b/zt-framework/zt-spring-boot-starter-biz-data-permission/src/main/java/com/zt/plat/framework/datapermission/core/rule/dept/DeptDataPermissionRule.java @@ -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; diff --git a/zt-framework/zt-spring-boot-starter-biz-data-permission/src/test/java/com/zt/plat/framework/datapermission/core/rule/dept/DeptDataPermissionRuleTest.java b/zt-framework/zt-spring-boot-starter-biz-data-permission/src/test/java/com/zt/plat/framework/datapermission/core/rule/dept/DeptDataPermissionRuleTest.java index 2177695b..77f194ce 100644 --- a/zt-framework/zt-spring-boot-starter-biz-data-permission/src/test/java/com/zt/plat/framework/datapermission/core/rule/dept/DeptDataPermissionRuleTest.java +++ b/zt-framework/zt-spring-boot-starter-biz-data-permission/src/test/java/com/zt/plat/framework/datapermission/core/rule/dept/DeptDataPermissionRuleTest.java @@ -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) ReflectUtil.getFieldValue(rule, "deptColumns")).clear(); - ((Map) ReflectUtil.getFieldValue(rule, "deptColumns")).clear(); + ((Map) 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 secMock = mockStatic(SecurityFrameworkUtils.class); + MockedStatic 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 secMock = mockStatic(SecurityFrameworkUtils.class); + MockedStatic deptCtxMock = mockStatic(DeptContextHolder.class); + MockedStatic 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 secMock = mockStatic(SecurityFrameworkUtils.class); + MockedStatic deptCtxMock = mockStatic(DeptContextHolder.class); + MockedStatic 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()); + } + } + } diff --git a/zt-framework/zt-spring-boot-starter-biz-tenant/src/main/java/com/zt/plat/framework/tenant/core/context/DeptContextHolder.java b/zt-framework/zt-spring-boot-starter-biz-tenant/src/main/java/com/zt/plat/framework/tenant/core/context/DeptContextHolder.java new file mode 100644 index 00000000..e463ae50 --- /dev/null +++ b/zt-framework/zt-spring-boot-starter-biz-tenant/src/main/java/com/zt/plat/framework/tenant/core/context/DeptContextHolder.java @@ -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 DEPT_ID = new TransmittableThreadLocal<>(); + /** 当前部门所属公司编号(用于一致性校验) */ + private static final ThreadLocal COMPANY_ID = new TransmittableThreadLocal<>(); + /** 是否忽略部门数据权限 */ + private static final ThreadLocal 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(); + } +} diff --git a/zt-framework/zt-spring-boot-starter-biz-tenant/src/main/java/com/zt/plat/framework/tenant/core/web/CompanyVisitContextInterceptor.java b/zt-framework/zt-spring-boot-starter-biz-tenant/src/main/java/com/zt/plat/framework/tenant/core/web/CompanyVisitContextInterceptor.java index 538f6d4f..f4688a21 100644 --- a/zt-framework/zt-spring-boot-starter-biz-tenant/src/main/java/com/zt/plat/framework/tenant/core/web/CompanyVisitContextInterceptor.java +++ b/zt-framework/zt-spring-boot-starter-biz-tenant/src/main/java/com/zt/plat/framework/tenant/core/web/CompanyVisitContextInterceptor.java @@ -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) { diff --git a/zt-framework/zt-spring-boot-starter-biz-tenant/src/test/java/com/zt/plat/framework/tenant/core/web/CompanyVisitContextInterceptorTest.java b/zt-framework/zt-spring-boot-starter-biz-tenant/src/test/java/com/zt/plat/framework/tenant/core/web/CompanyVisitContextInterceptorTest.java new file mode 100644 index 00000000..6fbe99ff --- /dev/null +++ b/zt-framework/zt-spring-boot-starter-biz-tenant/src/test/java/com/zt/plat/framework/tenant/core/web/CompanyVisitContextInterceptorTest.java @@ -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()); + } +}