1. 新增业务数据查询,新增 部门 数据权限规则支持

2. 补全子角色排除父角色管理菜单测试用例
This commit is contained in:
chenbowen
2025-07-15 10:01:46 +08:00
parent 7f0957d9c4
commit eaea76e955
11 changed files with 360 additions and 26 deletions

View File

@@ -31,6 +31,12 @@
<artifactId>yudao-spring-boot-starter-biz-data-permission</artifactId>
<version>${revision}</version>
</dependency>
<!-- Test 测试相关 -->
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>yudao-spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -1,6 +1,7 @@
package cn.iocoder.yudao.framework.business.framework;
import cn.iocoder.yudao.framework.datapermission.core.rule.company.CompanyDataPermissionRuleCustomizer;
import cn.iocoder.yudao.framework.datapermission.core.rule.dept.DeptDataPermissionRuleCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -10,10 +11,18 @@ import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class BusinessDataPermissionConfiguration {
@Bean
public CompanyDataPermissionRuleCustomizer sysDeptDataPermissionRuleCustomizer() {
public CompanyDataPermissionRuleCustomizer sysCompanyDataPermissionRuleCustomizer() {
return rule -> {
// companyId
rule.addCompanyColumn("demo_contract", "company_id");
};
}
@Bean
public DeptDataPermissionRuleCustomizer sysDeptDataPermissionRuleCustomizer() {
return rule -> {
// dept
rule.addDeptColumn("demo_contract", "dept_id");
};
}
}

View File

@@ -1 +1,2 @@
cn.iocoder.yudao.framework.business.config.YudaoBusinessAutoConfiguration
cn.iocoder.yudao.framework.business.config.YudaoBusinessAutoConfiguration
cn.iocoder.yudao.framework.business.framework.BusinessDataPermissionConfiguration

View File

@@ -0,0 +1,238 @@
package cn.iocoder.yudao.framework.business.interceptor;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.framework.common.pojo.CompanyDeptInfo;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.web.method.HandlerMethod;
import java.io.PrintWriter;
import java.util.*;
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.springframework.security.core.context.SecurityContextHolder.getContext;
class BusinessHeaderInterceptorTest {
private BusinessHeaderInterceptor interceptor;
private HttpServletRequest request;
private HttpServletResponse response;
private HandlerMethod handlerMethod;
private PrintWriter writer;
@BeforeEach
void setUp() throws Exception {
interceptor = new BusinessHeaderInterceptor();
request = mock(HttpServletRequest.class);
response = mock(HttpServletResponse.class);
handlerMethod = mock(HandlerMethod.class);
writer = mock(PrintWriter.class);
when(response.getWriter()).thenReturn(writer);
}
/**
* 用例:传入的 handler 不是 HandlerMethod应该直接返回 true
*/
@Test
void testPreHandle_NotHandlerMethod() throws Exception {
boolean result = interceptor.preHandle(request, response, new Object());
assertTrue(result);
}
/**
* 用例handlerMethod.getBean() 不是 BusinessControllerMarker应该直接返回 true
*/
@Test
void testPreHandle_NotBusinessControllerMarker() throws Exception {
when(handlerMethod.getBean()).thenReturn(new Object());
boolean result = interceptor.preHandle(request, response, handlerMethod);
assertTrue(result);
}
/**
* 用例handlerMethod.getBean() 是普通 Controller未实现 marker 接口),应直接返回 true
*/
@Test
void testPreHandle_NormalController() throws Exception {
class NormalController {}
when(handlerMethod.getBean()).thenReturn(new NormalController());
boolean result = interceptor.preHandle(request, response, handlerMethod);
assertTrue(result);
}
/**
* 用例marker controller且 header 无 companyId/deptIdloginUser 有多个公司部门,应该返回 false 并提示 NEED_ADJUST
*/
@Test
void testPreHandle_NoCompanyId_MultiCompanyDept() throws Exception {
class TestBusinessController implements BusinessControllerMarker {}
when(handlerMethod.getBean()).thenReturn(new TestBusinessController());
when(request.getHeader("visit-company-id")).thenReturn(null);
when(request.getHeader("visit-dept-id")).thenReturn(null);
// 构造 loginUser包含多个公司部门
LoginUser loginUser = randomPojo(LoginUser.class, o -> o.setId(1L)
.setUserType(UserTypeEnum.ADMIN.getValue()));
Map<String, String> infoMap = new HashMap<>();
infoMap.put(LoginUser.INFO_KEY_COMPANY_DEPT_SET, "[{\"companyId\":1,\"deptId\":2},{\"companyId\":2,\"deptId\":3}]");
loginUser.setInfo(infoMap);
// 通过反射或包可见性设置 getLoginUser 返回
setLoginUserForTest(loginUser);
boolean result = interceptor.preHandle(request, response, handlerMethod);
assertFalse(result);
verify(writer).write(contains("400"));
}
/**
* 用例header 有 companyId/deptIdloginUser 有多个公司部门,应该正常通过
*/
@Test
void testPreHandle_WithCompanyIdDeptId_MultiCompanyDept() throws Exception {
class TestBusinessController implements BusinessControllerMarker {}
when(handlerMethod.getBean()).thenReturn(new TestBusinessController());
when(request.getHeader("visit-company-id")).thenReturn("1");
when(request.getHeader("visit-dept-id")).thenReturn("2");
// 构造 loginUser包含多个公司部门
CompanyDeptInfo deptInfo1 = new CompanyDeptInfo();
deptInfo1.setCompanyId(1L);
deptInfo1.setDeptId(2L);
CompanyDeptInfo deptInfo2 = new CompanyDeptInfo();
deptInfo2.setCompanyId(2L);
deptInfo2.setDeptId(3L);
Set<CompanyDeptInfo> deptSet = new HashSet<>();
deptSet.add(deptInfo1);
deptSet.add(deptInfo2);
LoginUser loginUser = randomPojo(LoginUser.class, o -> o.setId(1L)
.setUserType(UserTypeEnum.ADMIN.getValue()));
String deptSetJson = "[" +
"{\"companyId\":" + deptInfo1.getCompanyId() + ",\"deptId\":" + deptInfo1.getDeptId() + "}," +
"{\"companyId\":" + deptInfo2.getCompanyId() + ",\"deptId\":" + deptInfo2.getDeptId() + "}]";
Map<String, String> infoMap = new HashMap<>();
infoMap.put(LoginUser.INFO_KEY_COMPANY_DEPT_SET, deptSetJson);
loginUser.setInfo(infoMap);
setLoginUserForTest(loginUser);
boolean result = interceptor.preHandle(request, response, handlerMethod);
assertTrue(result);
}
/**
* 用例header 无 companyId/deptIdloginUser 只有一个公司部门,应该自动填充 header 并通过
*/
@Test
void testPreHandle_NoHeader_SingleCompanyDept() throws Exception {
class TestBusinessController implements BusinessControllerMarker {}
when(handlerMethod.getBean()).thenReturn(new TestBusinessController());
when(request.getHeader("visit-company-id")).thenReturn(null);
when(request.getHeader("visit-dept-id")).thenReturn(null);
// 构造 loginUser只有一个公司且公司下只有一个部门
CompanyDeptInfo deptInfo = new CompanyDeptInfo();
deptInfo.setCompanyId(100L);
deptInfo.setDeptId(200L);
Set<CompanyDeptInfo> deptSet = new HashSet<>();
deptSet.add(deptInfo);
LoginUser loginUser = randomPojo(LoginUser.class, o -> o.setId(1L)
.setUserType(UserTypeEnum.ADMIN.getValue()));
// 只放一个公司部门
String deptSetJson = "[{\"companyId\":" + deptInfo.getCompanyId() + ",\"deptId\":" + deptInfo.getDeptId() + "}]";
Map<String, String> infoMap = new HashMap<>();
infoMap.put(LoginUser.INFO_KEY_COMPANY_DEPT_SET, deptSetJson);
loginUser.setInfo(infoMap);
setLoginUserForTest(loginUser);
boolean result = interceptor.preHandle(request, response, handlerMethod);
assertFalse(result);
// 可选verify(request).setAttribute("visit-company-id", String.valueOf(deptInfo.getCompanyId()));
// 可选verify(request).setAttribute("visit-dept-id", String.valueOf(deptInfo.getDeptId()));
}
/**
* 用例header 无 companyId/deptIdloginUser 有多个公司部门,应该返回 false 并提示 400
*/
@Test
void testPreHandle_NoHeader_MultiCompanyDept() throws Exception {
class TestBusinessController implements BusinessControllerMarker {}
when(handlerMethod.getBean()).thenReturn(new TestBusinessController());
when(request.getHeader("visit-company-id")).thenReturn(null);
when(request.getHeader("visit-dept-id")).thenReturn(null);
// 构造 loginUser多个公司部门
CompanyDeptInfo deptInfo1 = new CompanyDeptInfo();
deptInfo1.setCompanyId(1L);
deptInfo1.setDeptId(2L);
CompanyDeptInfo deptInfo2 = new CompanyDeptInfo();
deptInfo2.setCompanyId(2L);
deptInfo2.setDeptId(3L);
Set<CompanyDeptInfo> deptSet = new HashSet<>();
deptSet.add(deptInfo1);
deptSet.add(deptInfo2);
LoginUser loginUser = randomPojo(LoginUser.class, o -> o.setId(1L)
.setUserType(UserTypeEnum.ADMIN.getValue()));
String deptSetJson = "[" +
"{\"companyId\":" + deptInfo1.getCompanyId() + ",\"deptId\":" + deptInfo1.getDeptId() + "}," +
"{\"companyId\":" + deptInfo2.getCompanyId() + ",\"deptId\":" + deptInfo2.getDeptId() + "}]";
Map<String, String> infoMap = new HashMap<>();
infoMap.put(LoginUser.INFO_KEY_COMPANY_DEPT_SET, deptSetJson);
loginUser.setInfo(infoMap);
setLoginUserForTest(loginUser);
boolean result = interceptor.preHandle(request, response, handlerMethod);
assertFalse(result);
verify(writer).write(contains("400"));
}
/**
* 用例header 有错误的 companyId/deptIdloginUser 不包含该公司部门,应该返回 false 并提示 400
*/
@Test
void testPreHandle_HeaderNotMatchUserCompanyDept() throws Exception {
class TestBusinessController implements BusinessControllerMarker {}
when(handlerMethod.getBean()).thenReturn(new TestBusinessController());
when(request.getHeader("visit-company-id")).thenReturn("999");
when(request.getHeader("visit-dept-id")).thenReturn("888");
// 构造 loginUser只有其他公司部门
CompanyDeptInfo deptInfo1 = new CompanyDeptInfo();
deptInfo1.setCompanyId(1L);
deptInfo1.setDeptId(2L);
CompanyDeptInfo deptInfo2 = new CompanyDeptInfo();
deptInfo2.setCompanyId(2L);
deptInfo2.setDeptId(3L);
Set<CompanyDeptInfo> deptSet = new HashSet<>();
deptSet.add(deptInfo1);
deptSet.add(deptInfo2);
LoginUser loginUser = randomPojo(LoginUser.class, o -> o.setId(1L)
.setUserType(UserTypeEnum.ADMIN.getValue()));
String deptSetJson = "[" +
"{\"companyId\":" + deptInfo1.getCompanyId() + ",\"deptId\":" + deptInfo1.getDeptId() + "}," +
"{\"companyId\":" + deptInfo2.getCompanyId() + ",\"deptId\":" + deptInfo2.getDeptId() + "}]";
Map<String, String> infoMap = new HashMap<>();
infoMap.put(LoginUser.INFO_KEY_COMPANY_DEPT_SET, deptSetJson);
loginUser.setInfo(infoMap);
setLoginUserForTest(loginUser);
boolean result = interceptor.preHandle(request, response, handlerMethod);
assertFalse(result);
verify(writer).write(contains("400"));
}
// 工具方法:通过 Spring Security 设置当前登录用户,仅测试环境使用
private void setLoginUserForTest(LoginUser loginUser) {
// 使用 Spring Security 的 SecurityContextHolder 设置 Authentication
getContext()
.setAuthentication(new UsernamePasswordAuthenticationToken(
loginUser, null, null
));
}
}

View File

@@ -21,8 +21,8 @@ import java.util.List;
*/
@AutoConfiguration
@ConditionalOnClass(LoginUser.class)
@ConditionalOnBean(value = {CompanyDataPermissionRuleCustomizer.class})
public class YudaoCompanyDataPermissionAutoConfiguration {
@ConditionalOnBean(value = {CompanyDataPermissionRuleCustomizer.class, DeptDataPermissionRuleCustomizer.class})
public class YudaoBusinessDataPermissionAutoConfiguration {
@Bean
public CompanyDataPermissionRule companyDataPermissionRule(List<CompanyDataPermissionRuleCustomizer> customizers) {
@@ -33,4 +33,22 @@ public class YudaoCompanyDataPermissionAutoConfiguration {
customizers.forEach(customizer -> customizer.customize(rule));
return rule;
}
@Bean
public DeptDataPermissionRule deptDataPermissionRule(PermissionCommonApi permissionApi, List<DeptDataPermissionRuleCustomizer> customizers) {
// Cloud 专属逻辑优先使用本地的 PermissionApi 实现类而不是 Feign 调用
// 原因在创建租户时租户还没创建好导致 Feign 调用获取数据权限时租户不存在的错误
try {
PermissionCommonApi permissionApiImpl = SpringUtil.getBean("permissionApiImpl", PermissionCommonApi.class);
if (permissionApiImpl != null) {
permissionApi = permissionApiImpl;
}
} catch (Exception ignored) {}
// 创建 DeptDataPermissionRule 对象
DeptDataPermissionRule rule = new DeptDataPermissionRule(permissionApi);
// 补全表配置
customizers.forEach(customizer -> customizer.customize(rule));
return rule;
}
}

View File

@@ -1,4 +1,4 @@
cn.iocoder.yudao.framework.datapermission.config.YudaoDataPermissionAutoConfiguration
cn.iocoder.yudao.framework.datapermission.config.YudaoDeptDataPermissionAutoConfiguration
cn.iocoder.yudao.framework.datapermission.config.YudaoCompanyDataPermissionAutoConfiguration
cn.iocoder.yudao.framework.datapermission.config.YudaoBusinessDataPermissionAutoConfiguration
cn.iocoder.yudao.framework.datapermission.config.YudaoDataPermissionRpcAutoConfiguration

View File

@@ -34,7 +34,7 @@ public interface ErrorCodeConstants {
ErrorCode ROLE_CAN_NOT_UPDATE_SYSTEM_TYPE_ROLE = new ErrorCode(1_002_002_003, "不能操作类型为系统内置的角色");
ErrorCode ROLE_IS_DISABLE = new ErrorCode(1_002_002_004, "名字为【{}】的角色已被禁用");
ErrorCode ROLE_ADMIN_CODE_ERROR = new ErrorCode(1_002_002_005, "标识【{}】不能使用");
ErrorCode ROLE_CAN_NOT_UPDATE_NORMAL_TYPE_ROLE = new ErrorCode(1_002_002_006, "不能操作类型为标准的角色,除非是管理员角色");
ErrorCode ROLE_CAN_NOT_UPDATE_NORMAL_TYPE_ROLE = new ErrorCode(1_002_002_006, "非管理员,不能操作类型为标准的角色");
ErrorCode ROLE_CAN_NOT_DELETE_HAS_CHILDREN = new ErrorCode(1_002_002_007, " 角色【{}】存在子角色,不允许删除");
ErrorCode ROLE_PARENT_IS_CHILD = new ErrorCode(1_002_002_008, "不能设置自己的子角色为父角色");

View File

@@ -157,29 +157,50 @@ public class PermissionServiceImpl implements PermissionService {
allEntries = true) // allEntries 清空所有缓存,主要一次更新涉及到的 menuIds 较多,反倒批量会更快
})
public void assignRoleMenu(Long roleId, Set<Long> menuIds) {
RoleDO role = roleService.getRole(roleId);
Set<Long> userRoleIdListByUserId = permissionService.getUserRoleIdListByUserId(getLoginUserId());
// 如果为标准角色,只允许管理员修改菜单权限
if (RoleTypeEnum.NORMAL.getType().equals(role.getType()) && !roleService.hasAnySuperAdmin(userRoleIdListByUserId)) {
throw exception(ROLE_CAN_NOT_UPDATE_NORMAL_TYPE_ROLE);
}
// 获得角色拥有菜单编号
Set<Long> dbMenuIds = convertSet(roleMenuMapper.selectListByRoleId(roleId), RoleMenuDO::getMenuId);
Set<Long> dbMenuIds = convertSet(getRoleMenuListByRoleId(roleId));
// 获取父级角色拥有的菜单编号
Set<Long> parentRoleIds = roleService.getAllParentAndSelfRoleIds(singleton(roleId));
// 移除自身角色编号
parentRoleIds.remove(roleId);
Set<Long> dbInheritedMenuIds = convertSet(roleMenuMapper.selectListByRoleId(parentRoleIds), RoleMenuDO::getMenuId);
// 计算新增和删除的菜单编号
Set<Long> menuIdList = CollUtil.emptyIfNull(menuIds);
Collection<Long> createMenuIds = CollUtil.subtract(menuIdList, dbMenuIds);
Collection<Long> deleteMenuIds = CollUtil.subtract(dbMenuIds, menuIdList);
// 执行新增和删除。对于已经授权的菜单,不用做任何处理
// 执行新增和删除。对于已经授权的菜单,不用进行新增和删除,处理排除关系即可
if (CollUtil.isNotEmpty(createMenuIds)) {
roleMenuMapper.insertBatch(CollectionUtils.convertList(createMenuIds, menuId -> {
RoleMenuDO entity = new RoleMenuDO();
entity.setRoleId(roleId);
entity.setMenuId(menuId);
return entity;
}));
Set<Long> inheritedCreateMenuIds = new HashSet<>(dbInheritedMenuIds);
inheritedCreateMenuIds.retainAll(createMenuIds);
if (CollUtil.isNotEmpty(inheritedCreateMenuIds)) {
// 不需要新增,只需要检查是否存在排除关系,如果存在,则标记排除关系失效
roleMenuExclusionMapper.deleteListByRoleIdAndMenuIds(roleId, inheritedCreateMenuIds);
createMenuIds.removeAll(inheritedCreateMenuIds);
}
if (CollUtil.isNotEmpty(createMenuIds)) {
roleMenuMapper.insertBatch(CollectionUtils.convertList(createMenuIds, menuId -> {
RoleMenuDO entity = new RoleMenuDO();
entity.setRoleId(roleId);
entity.setMenuId(menuId);
return entity;
}));
}
}
if (CollUtil.isNotEmpty(deleteMenuIds)) {
roleMenuMapper.deleteListByRoleIdAndMenuIds(roleId, deleteMenuIds);
Set<Long> inheritedDeleteMenuIds = new HashSet<>(dbInheritedMenuIds);
inheritedDeleteMenuIds.retainAll(deleteMenuIds);
if (CollUtil.isNotEmpty(inheritedDeleteMenuIds)) {
// 标记排除
roleMenuExclusionMapper.insertBatch(CollectionUtils.convertList(inheritedDeleteMenuIds, menuId -> {
RoleMenuExclusionDO entity = new RoleMenuExclusionDO();
entity.setRoleId(roleId);
entity.setMenuId(menuId);
return entity;
}));
}
if (CollUtil.isNotEmpty(deleteMenuIds)) {
roleMenuMapper.deleteListByRoleIdAndMenuIds(roleId, deleteMenuIds);
}
}
}
@@ -303,7 +324,7 @@ public class PermissionServiceImpl implements PermissionService {
Set<Long> userRoleIdListByUserId = permissionService.getUserRoleIdListByUserId(getLoginUserId());
// 如果为标准角色,只允许管理员修改数据权限
if (RoleTypeEnum.NORMAL.getType().equals(role.getType()) && !roleService.hasAnySuperAdmin(userRoleIdListByUserId)) {
throw exception(ROLE_CAN_NOT_UPDATE_SYSTEM_TYPE_ROLE);
throw exception(ROLE_CAN_NOT_UPDATE_NORMAL_TYPE_ROLE);
}
roleService.updateRoleDataScope(roleId, dataScope, dataScopeDeptIds);
}

View File

@@ -383,4 +383,29 @@ public class PermissionServiceTest extends BaseDbUnitTest {
assertTrue(menuIds2.contains(101L));
}
/**
* 测试子角色排除父角色菜单
* 通过 Service 方法排除,确保子角色不继承父角色的菜单
*/
@Test
public void testExcludeParentRoleMenu() {
// mock 父子关系 A->B
RoleDO parentRole = randomPojo(RoleDO.class, o -> o.setParentId(0L));
roleMapper.insert(parentRole);
RoleDO childRole = randomPojo(RoleDO.class, o -> o.setParentId(parentRole.getId()));
roleMapper.insert(childRole);
// 父角色分配菜单
RoleMenuDO parentMenu = randomPojo(RoleMenuDO.class).setRoleId(parentRole.getId()).setMenuId(101L);
roleMenuMapper.insert(parentMenu);
// 子角色排除父菜单(通过 Service 方法排除)
permissionService.assignRoleMenu(childRole.getId(), Collections.emptySet());
// 调用:获取子角色菜单(应不包含父菜单)
Set<Long> menuIds = permissionService.getRoleMenuListByRoleId(childRole.getId());
assertFalse(menuIds.contains(101L));
// 新增了子角色的排除菜单记录
List<RoleMenuExclusionDO> exclusionDOS = roleMenuExclusionMapper.selectMenuIdListByRoleId(Collections.singleton(childRole.getId()));
assertEquals(1, exclusionDOS.size());
assertEquals(101L, exclusionDOS.get(0).getMenuId());
}
}

View File

@@ -21,6 +21,26 @@
<groupId>cn.iocoder.cloud</groupId>
<artifactId>yudao-common</artifactId>
</dependency>
<!-- Web 相关 -->
<dependency>
<groupId>org.springdoc</groupId> <!-- 接口文档:使用最新版本的 Swagger 模型 -->
<artifactId>springdoc-openapi-starter-webmvc-api</artifactId>
<scope>provided</scope>
</dependency>
<!-- 参数校验 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<optional>true</optional>
</dependency>
<!-- RPC 远程调用相关 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>

View File

@@ -1,10 +1,7 @@
package cn.iocoder.yudao.module.template;
import cn.iocoder.yudao.framework.business.framework.BusinessDataPermissionConfiguration;
import cn.iocoder.yudao.framework.datapermission.config.YudaoCompanyDataPermissionAutoConfiguration;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Import;
/**
* 项目的启动类
@@ -12,7 +9,6 @@ import org.springframework.context.annotation.Import;
* @author 周迪
*/
@SpringBootApplication
@Import(BusinessDataPermissionConfiguration.class)
public class TemplateServerApplication {
public static void main(String[] args) {