diff --git a/crm-auth/src/main/java/com/crm/auth/controller/SystemController.java b/crm-auth/src/main/java/com/crm/auth/controller/SystemController.java index 0668d9a..b54d7e7 100644 --- a/crm-auth/src/main/java/com/crm/auth/controller/SystemController.java +++ b/crm-auth/src/main/java/com/crm/auth/controller/SystemController.java @@ -14,7 +14,7 @@ import com.crm.auth.service.IAuthUserService; import com.crm.auth.service.ISysDeptService; import com.crm.auth.service.ISysMenuService; import com.crm.auth.service.ISysRoleService; -import com.crm.auth.service.impl.PermissionServiceImpl; +import com.crm.auth.security.PermissionResolver; import com.crm.base.domain.result.PageResult; import com.crm.base.domain.result.Result; import com.crm.base.security.SecurityUtils; @@ -35,7 +35,7 @@ public class SystemController { private final ISysMenuService sysMenuService; private final ISysDeptService sysDeptService; private final IAuthUserService authUserService; - private final PermissionServiceImpl permissionService; + private final PermissionResolver permissionResolver; private final SysRoleMenuMapper sysRoleMenuMapper; private final SysUserRoleMapper sysUserRoleMapper; @@ -44,7 +44,7 @@ public class SystemController { @GetMapping("/menus/tree") public Result> menuTree() { Long userId = Long.valueOf(SecurityUtils.getRequiredUserId()); - return Result.success(permissionService.getMenuTree(userId)); + return Result.success(permissionResolver.visibleMenuTree(userId)); } @PostMapping("/menus/save") diff --git a/crm-auth/src/main/java/com/crm/auth/security/JwtAuthenticationFilter.java b/crm-auth/src/main/java/com/crm/auth/security/JwtAuthenticationFilter.java index 5275557..e7e6e6e 100644 --- a/crm-auth/src/main/java/com/crm/auth/security/JwtAuthenticationFilter.java +++ b/crm-auth/src/main/java/com/crm/auth/security/JwtAuthenticationFilter.java @@ -2,7 +2,6 @@ package com.crm.auth.security; import cn.hutool.core.util.StrUtil; import com.crm.auth.constant.AuthConstants; -import com.crm.auth.service.impl.PermissionServiceImpl; import com.crm.base.security.DataVisibilityContext; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -11,14 +10,12 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; -import java.util.List; /** * JWT 认证过滤器:从 Authorization: Bearer {token} 中解析登录态, @@ -30,7 +27,7 @@ import java.util.List; public class JwtAuthenticationFilter extends OncePerRequestFilter { private final TokenService tokenService; - private final PermissionServiceImpl permissionService; + private final PermissionResolver permissionResolver; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) @@ -40,14 +37,12 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { if (token != null && SecurityContextHolder.getContext().getAuthentication() == null) { AuthLoginUser loginUser = tokenService.verifyToken(token); if (loginUser != null) { - // 装入数据可见性范围并取角色编码集合:装不上就让请求失败(ADR 0006) - List roleCodes = permissionService.initDataScopeContext(Long.valueOf(loginUser.getUserId())); - // 角色编码 → Spring Security authority,使 @PreAuthorize(hasRole(...)) 生效(ADR-0008) - List authorities = roleCodes.stream() - .map(SimpleGrantedAuthority::new) - .toList(); + // 权限解析引擎一次算清:装不上可见性就让请求失败(fail-closed,ADR-0006) + PermissionGrant grant = permissionResolver.resolve(Long.valueOf(loginUser.getUserId())); + DataVisibilityContext.load(grant.getVisibility()); + // 权限码并集 + 角色编码 → Spring Security authority(ADR-0008 / ADR-0011) UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(loginUser, null, authorities); + new UsernamePasswordAuthenticationToken(loginUser, null, grant.asAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } else { diff --git a/crm-auth/src/main/java/com/crm/auth/security/PermissionGrant.java b/crm-auth/src/main/java/com/crm/auth/security/PermissionGrant.java new file mode 100644 index 0000000..30f51e0 --- /dev/null +++ b/crm-auth/src/main/java/com/crm/auth/security/PermissionGrant.java @@ -0,0 +1,67 @@ +package com.crm.auth.security; + +import com.crm.base.security.DataVisibility; +import com.crm.base.security.DataVisibilityContext; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * 权限解析结果:一次 resolve 拿齐一个用户的三份授权事实(ADR-0011)。 + * + *

不可变。纯取值对象——装载 {@link DataVisibilityContext} 是请求生命周期所有者 + * (JWT 过滤器)的职责,本对象不产生副作用。

+ * + *
    + *
  • {@link #getVisibility()} 数据可见性(部门集合 + 档位 + 子树展开)
  • + *
  • {@link #getPermCodes()} 权限码并集(停用权限点已全局断路,不进并集)
  • + *
  • {@link #getRoleCodes()} 角色编码集合
  • + *
+ */ +public final class PermissionGrant { + + /** Spring Security 角色 authority 前缀(hasRole('X') 实际匹配 ROLE_X) */ + private static final String ROLE_PREFIX = "ROLE_"; + + private final DataVisibility visibility; + private final Set permCodes; + private final Set roleCodes; + + public PermissionGrant(DataVisibility visibility, Set permCodes, Set roleCodes) { + this.visibility = visibility; + this.permCodes = permCodes == null ? Set.of() : Set.copyOf(permCodes); + this.roleCodes = roleCodes == null ? Set.of() : Set.copyOf(roleCodes); + } + + public DataVisibility getVisibility() { + return visibility; + } + + /** 权限码并集:button 节点 perms 的去重集合,已排除停用权限点 */ + public Set getPermCodes() { + return permCodes; + } + + /** 角色编码集合(原样,不带 ROLE_ 前缀约定) */ + public Set getRoleCodes() { + return roleCodes; + } + + /** + * 合成 Spring Security authorities:权限码原样注入;角色编码幂等归一到 ROLE_ 前缀 + * (已带前缀的不重复加),使 hasAuthority / hasRole 各自按约定匹配。 + * 前缀约定由本模块保证,调用方与 DB 数据无需理解它。 + */ + public List asAuthorities() { + List authorities = new ArrayList<>(permCodes.size() + roleCodes.size()); + permCodes.forEach(perm -> authorities.add(new SimpleGrantedAuthority(perm))); + roleCodes.forEach(role -> authorities.add(new SimpleGrantedAuthority(normalizeRole(role)))); + return List.copyOf(authorities); + } + + private static String normalizeRole(String roleCode) { + return roleCode.startsWith(ROLE_PREFIX) ? roleCode : ROLE_PREFIX + roleCode; + } +} diff --git a/crm-auth/src/main/java/com/crm/auth/security/PermissionResolver.java b/crm-auth/src/main/java/com/crm/auth/security/PermissionResolver.java new file mode 100644 index 0000000..5d9600f --- /dev/null +++ b/crm-auth/src/main/java/com/crm/auth/security/PermissionResolver.java @@ -0,0 +1,35 @@ +package com.crm.auth.security; + +import com.crm.auth.domain.entity.SysMenu; + +import java.util.List; + +/** + * 权限解析引擎:每请求把一个用户的全部授权事实一次算清(ADR-0011)。 + * + *

深模块:调用方只见两个方法,「用户 → 角色 → 授权 → 资源」的查询链、 + * 档位取舍、子树展开、权限码并集的断路规则全部藏在实现里。

+ * + *

fail-closed(ADR-0006):解析失败即抛异常,由请求入口拒绝该请求; + * 不存在「部分成功」的第三状态。

+ */ +public interface PermissionResolver { + + /** + * 解析用户的数据可见性与全部授权码。纯取值——不装载任何线程上下文, + * 装载 {@link com.crm.base.security.DataVisibilityContext} 由调用方按请求生命周期负责。 + * + * @param userId 本地用户 ID + * @return 可见性 + 权限码并集 + 角色编码;无角色用户返回最窄档可见性与空授权集合 + */ + PermissionGrant resolve(Long userId); + + /** + * 用户可见菜单树(catalog/menu 节点,按角色授权过滤、排除 button、按 sort 排序)。 + * 登录后渲染侧边栏用,不参与每请求解析。 + * + * @param userId 本地用户 ID + * @return 组好父子关系的菜单树;无任何授权时返回空列表 + */ + List visibleMenuTree(Long userId); +} diff --git a/crm-auth/src/main/java/com/crm/auth/security/PermissionResolverImpl.java b/crm-auth/src/main/java/com/crm/auth/security/PermissionResolverImpl.java new file mode 100644 index 0000000..1915437 --- /dev/null +++ b/crm-auth/src/main/java/com/crm/auth/security/PermissionResolverImpl.java @@ -0,0 +1,139 @@ +package com.crm.auth.security; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.crm.auth.domain.entity.AuthUser; +import com.crm.auth.domain.entity.SysMenu; +import com.crm.auth.domain.entity.SysRole; +import com.crm.auth.domain.entity.SysRoleMenu; +import com.crm.auth.domain.entity.SysUserDept; +import com.crm.auth.domain.entity.SysUserRole; +import com.crm.auth.domain.enums.MenuType; +import com.crm.auth.mapper.AuthUserMapper; +import com.crm.auth.mapper.SysMenuMapper; +import com.crm.auth.mapper.SysRoleMapper; +import com.crm.auth.mapper.SysRoleMenuMapper; +import com.crm.auth.mapper.SysUserDeptMapper; +import com.crm.auth.mapper.SysUserRoleMapper; +import com.crm.auth.service.DeptTreeCache; +import com.crm.base.security.DataScopeLevel; +import com.crm.base.security.DataVisibility; +import com.crm.base.utils.TreeUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 权限解析引擎实现:数据可见性、权限码并集、可见菜单树共用同一条 + * 「用户 → 角色 → 授权 → 资源」查询链(ADR-0011)。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PermissionResolverImpl implements PermissionResolver { + + private final SysUserRoleMapper sysUserRoleMapper; + private final SysRoleMapper sysRoleMapper; + private final SysRoleMenuMapper sysRoleMenuMapper; + private final SysMenuMapper sysMenuMapper; + private final AuthUserMapper authUserMapper; + private final DeptTreeCache deptTreeCache; + private final SysUserDeptMapper sysUserDeptMapper; + + @Override + public PermissionGrant resolve(Long userId) { + // ── 数据可见性:无角色取最窄档(仅本人),多角色取最宽档(ADR-0008)── + List userRoles = userRolesOf(userId); + DataScopeLevel widest = DataScopeLevel.SELF; + List roles = Collections.emptyList(); + if (CollUtil.isNotEmpty(userRoles)) { + roles = sysRoleMapper.selectBatchIds(roleIdsOf(userRoles)); + for (SysRole role : roles) { + // 未配档位的角色按最窄档处理,不放宽任何人的可见范围 + if (role.getDataScope() == null) continue; + DataScopeLevel level = DataScopeLevel.fromCode(role.getDataScope()); + if (level.getCode() > widest.getCode()) { + widest = level; + } + } + } + AuthUser user = authUserMapper.selectById(userId); + Long primaryDeptId = user != null ? user.getDeptId() : null; + // 部门集合 = 主部门 + 兼职部门并集,集合内地位平等(ADR-0005);兼职关系每请求实时查 + Set deptIds = new LinkedHashSet<>(); + if (primaryDeptId != null) { + deptIds.add(primaryDeptId); + } + sysUserDeptMapper.selectList( + new LambdaQueryWrapper().eq(SysUserDept::getUserId, userId)) + .forEach(ud -> deptIds.add(ud.getDeptId())); + List expandedDeptIds = null; + if (widest == DataScopeLevel.DEPT_AND_CHILDREN) { + // 子树展开在缓存树上完成,多部门共享同一棵树;无部门可展开时为空集合,即一律不可见 + expandedDeptIds = deptIds.isEmpty() ? List.of() : deptTreeCache.expandWithChildren(deptIds); + } + DataVisibility visibility = new DataVisibility( + userId, primaryDeptId, List.copyOf(deptIds), widest, expandedDeptIds); + + // ── 权限码并集(ADR-0011):button 且 status=enabled 的 perms,去重 ── + Set permCodes = CollUtil.isEmpty(userRoles) + ? Set.of() + : authorizedMenus(roleIdsOf(userRoles)).stream() + .filter(m -> m.getMenuType() != null && m.getMenuType() == MenuType.BUTTON.getCode()) + .filter(m -> "enabled".equals(m.getStatus())) + .map(SysMenu::getPerms) + .filter(StrUtil::isNotBlank) + .collect(Collectors.toCollection(LinkedHashSet::new)); + + // ── 角色编码(原样持有,ROLE_ 前缀归一由 PermissionGrant 负责)── + Set roleCodes = roles.stream() + .map(SysRole::getRoleCode) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + + return new PermissionGrant(visibility, permCodes, roleCodes); + } + + @Override + public List visibleMenuTree(Long userId) { + List userRoles = userRolesOf(userId); + if (CollUtil.isEmpty(userRoles)) return Collections.emptyList(); + List visibleMenus = authorizedMenus(roleIdsOf(userRoles)).stream() + .filter(m -> Boolean.TRUE.equals(m.getVisible())) + .filter(m -> m.getMenuType() == null || m.getMenuType() != MenuType.BUTTON.getCode()) + .sorted(Comparator.comparingInt(m -> m.getSort() != null ? m.getSort() : 0)) + .collect(Collectors.toList()); + return TreeUtils.buildTree(visibleMenus, SysMenu::getId, SysMenu::getParentId, SysMenu::setChildren); + } + + // ==================== 共享查询链 ==================== + + private List userRolesOf(Long userId) { + return sysUserRoleMapper.selectList( + new LambdaQueryWrapper().eq(SysUserRole::getUserId, userId)); + } + + private static List roleIdsOf(List userRoles) { + return userRoles.stream().map(SysUserRole::getRoleId).distinct().collect(Collectors.toList()); + } + + /** 角色授权的资源节点全集(含 button / menu / catalog),过滤规则由调用方决定 */ + private List authorizedMenus(List roleIds) { + List roleMenus = sysRoleMenuMapper.selectList( + new LambdaQueryWrapper().in(SysRoleMenu::getRoleId, roleIds)); + if (CollUtil.isEmpty(roleMenus)) { + return Collections.emptyList(); + } + Set menuIds = roleMenus.stream().map(SysRoleMenu::getMenuId).collect(Collectors.toSet()); + return sysMenuMapper.selectBatchIds(menuIds); + } +} diff --git a/crm-auth/src/main/java/com/crm/auth/service/impl/PermissionServiceImpl.java b/crm-auth/src/main/java/com/crm/auth/service/impl/PermissionServiceImpl.java deleted file mode 100644 index 4d5673a..0000000 --- a/crm-auth/src/main/java/com/crm/auth/service/impl/PermissionServiceImpl.java +++ /dev/null @@ -1,123 +0,0 @@ -package com.crm.auth.service.impl; - -import cn.hutool.core.collection.CollUtil; -import cn.hutool.core.util.StrUtil; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.crm.auth.domain.entity.*; -import com.crm.auth.domain.enums.MenuType; -import com.crm.auth.mapper.*; -import com.crm.auth.service.DeptTreeCache; -import com.crm.base.security.DataScopeLevel; -import com.crm.base.security.DataVisibility; -import com.crm.base.security.DataVisibilityContext; -import com.crm.base.utils.TreeUtils; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import java.util.*; -import java.util.stream.Collectors; - -@Slf4j -@Service -@RequiredArgsConstructor -public class PermissionServiceImpl { - - private final SysUserRoleMapper sysUserRoleMapper; - private final SysRoleMapper sysRoleMapper; - private final SysRoleMenuMapper sysRoleMenuMapper; - private final SysMenuMapper sysMenuMapper; - private final AuthUserMapper authUserMapper; - private final DeptTreeCache deptTreeCache; - private final SysUserDeptMapper sysUserDeptMapper; - - /** - * 装入当前用户的数据可见性范围,并返回其角色编码集合。 - *

角色编码由 JWT 过滤器填充为 Spring Security authority,使 {@code @PreAuthorize(hasRole(...))} - * 生效(ADR-0008);无角色返回空集。

- */ - public List initDataScopeContext(Long userId) { - List userRoles = sysUserRoleMapper.selectList( - new LambdaQueryWrapper().eq(SysUserRole::getUserId, userId)); - // 初值取最窄档(仅本人),无角色用户不会被放行全部数据;多角色取最宽档 - DataScopeLevel widest = DataScopeLevel.SELF; - List roles = Collections.emptyList(); - if (CollUtil.isNotEmpty(userRoles)) { - List roleIds = userRoles.stream().map(SysUserRole::getRoleId).collect(Collectors.toList()); - roles = sysRoleMapper.selectBatchIds(roleIds); - for (SysRole role : roles) { - // 未配档位的角色按最窄档处理,不放宽任何人的可见范围 - if (role.getDataScope() == null) continue; - DataScopeLevel level = DataScopeLevel.fromCode(role.getDataScope()); - if (level.getCode() > widest.getCode()) { - widest = level; - } - } - } - AuthUser user = authUserMapper.selectById(userId); - Long primaryDeptId = user != null ? user.getDeptId() : null; - // 部门集合 = 主部门 + 兼职部门并集,集合内地位平等(ADR 0005);兼职关系每请求实时查 - Set deptIds = new LinkedHashSet<>(); - if (primaryDeptId != null) { - deptIds.add(primaryDeptId); - } - sysUserDeptMapper.selectList( - new LambdaQueryWrapper().eq(SysUserDept::getUserId, userId)) - .forEach(ud -> deptIds.add(ud.getDeptId())); - List expandedDeptIds = null; - if (widest == DataScopeLevel.DEPT_AND_CHILDREN) { - // 子树展开在缓存树上完成,多部门共享同一棵树(工单 05);无部门可展开时为空集合,即一律不可见 - expandedDeptIds = deptIds.isEmpty() ? List.of() : deptTreeCache.expandWithChildren(deptIds); - } - DataVisibilityContext.load( - new DataVisibility(userId, primaryDeptId, List.copyOf(deptIds), widest, expandedDeptIds)); - - // ── 权限码并集(ADR-0011):收集用户所有角色授权的 button 节点 perms ── - List authorities = new ArrayList<>(); - if (CollUtil.isNotEmpty(userRoles)) { - try { - Set roleIds = userRoles.stream().map(SysUserRole::getRoleId).collect(Collectors.toSet()); - List allRoleMenus = sysRoleMenuMapper.selectList( - new LambdaQueryWrapper().in(SysRoleMenu::getRoleId, roleIds)); - if (CollUtil.isNotEmpty(allRoleMenus)) { - Set menuIds = allRoleMenus.stream() - .map(SysRoleMenu::getMenuId).collect(Collectors.toSet()); - List menus = sysMenuMapper.selectBatchIds(menuIds); - menus.stream() - .filter(m -> m.getMenuType() != null && m.getMenuType() == MenuType.BUTTON.getCode()) - .filter(m -> "enabled".equals(m.getStatus())) - .map(SysMenu::getPerms) - .filter(StrUtil::isNotBlank) - .distinct() - .forEach(authorities::add); - } - } catch (Exception e) { - log.debug("权限码并集查询失败(可能 sys_menu/sys_role_menu 表尚未创建),跳过", e); - } - } - // 角色编码追加在权限码之后(两者同为 Spring Security authority,hasRole/hasAuthority 各自匹配) - roles.stream() - .map(SysRole::getRoleCode) - .filter(Objects::nonNull) - .forEach(authorities::add); - return authorities; - } - - public List getMenuTree(Long userId) { - List userRoles = sysUserRoleMapper.selectList( - new LambdaQueryWrapper().eq(SysUserRole::getUserId, userId)); - if (CollUtil.isEmpty(userRoles)) return Collections.emptyList(); - List roleIds = userRoles.stream().map(SysUserRole::getRoleId).collect(Collectors.toList()); - List roleMenus = sysRoleMenuMapper.selectList( - new LambdaQueryWrapper().in(SysRoleMenu::getRoleId, roleIds)); - if (CollUtil.isEmpty(roleMenus)) return Collections.emptyList(); - Set menuIds = roleMenus.stream().map(SysRoleMenu::getMenuId).collect(Collectors.toSet()); - List allMenus = sysMenuMapper.selectBatchIds(menuIds); - List visibleMenus = allMenus.stream() - .filter(m -> Boolean.TRUE.equals(m.getVisible())) - .filter(m -> m.getMenuType() != MenuType.BUTTON.getCode()) - .sorted(Comparator.comparingInt(m -> m.getSort() != null ? m.getSort() : 0)) - .collect(Collectors.toList()); - return TreeUtils.buildTree(visibleMenus, SysMenu::getId, SysMenu::getParentId, SysMenu::setChildren); - } -} diff --git a/crm-auth/src/test/java/com/crm/auth/security/scope/DataScopeIntegrationTest.java b/crm-auth/src/test/java/com/crm/auth/security/scope/DataScopeIntegrationTest.java index d9b49d4..565ced7 100644 --- a/crm-auth/src/test/java/com/crm/auth/security/scope/DataScopeIntegrationTest.java +++ b/crm-auth/src/test/java/com/crm/auth/security/scope/DataScopeIntegrationTest.java @@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator; import com.baomidou.mybatisplus.core.toolkit.GlobalConfigUtils; import com.crm.auth.domain.entity.AuthUser; import com.crm.auth.domain.entity.SysDept; +import com.crm.auth.domain.entity.SysMenu; import com.crm.auth.domain.entity.SysUserDept; import com.crm.auth.mapper.AuthUserMapper; import com.crm.auth.mapper.SysDeptMapper; @@ -15,8 +16,10 @@ import com.crm.auth.mapper.SysUserDeptMapper; import com.crm.auth.mapper.SysUserRoleMapper; import com.crm.auth.security.DataScopeInterceptor; import com.crm.auth.security.DataScopeTables; +import com.crm.auth.security.PermissionGrant; +import com.crm.auth.security.PermissionResolver; +import com.crm.auth.security.PermissionResolverImpl; import com.crm.auth.service.DeptTreeCache; -import com.crm.auth.service.impl.PermissionServiceImpl; import com.crm.base.annotation.DataScope; import com.crm.base.config.MetaObjectFillHandler; import com.crm.base.security.DataVisibilityContext; @@ -37,6 +40,7 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import java.sql.Connection; import java.sql.Statement; @@ -50,9 +54,9 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; /** * 「权限上下文 → 可见行」集成缝(工单 01 主缝)。 * - *

给定「用户 + 角色档位 + 主部门 + 部门树」,经真实的 {@link PermissionServiceImpl#initDataScopeContext} + *

给定「用户 + 角色档位 + 主部门 + 部门树」,经真实的 {@link PermissionResolver#resolve} * 与真实的 Mapper 查询(H2 MySQL 模式 + {@link DataScopeInterceptor}),断言测试专用受控实体 - * {@link TestOwnedData} 的可见行集合。不断言生成的 SQL 字符串。

+ * {@link TestOwnedData} 的可见行集合与权限码并集。不断言生成的 SQL 字符串。

* *

固定数据集:

*
@@ -69,7 +73,7 @@ class DataScopeIntegrationTest {
     private static SqlSessionFactory sqlSessionFactory;
 
     private SqlSession session;
-    private PermissionServiceImpl permissionService;
+    private PermissionResolver resolver;
     private DeptTreeCache deptTreeCache;
     private TestOwnedDataMapper testDataMapper;
     private SysDeptMapper deptMapper;
@@ -136,7 +140,7 @@ class DataScopeIntegrationTest {
         // 非自动提交 + AfterEach 回滚:插入类用例不污染共享的静态数据集
         session = sqlSessionFactory.openSession();
         deptTreeCache = new DeptTreeCache(session.getMapper(SysDeptMapper.class));
-        permissionService = new PermissionServiceImpl(
+        resolver = new PermissionResolverImpl(
                 session.getMapper(SysUserRoleMapper.class),
                 session.getMapper(SysRoleMapper.class),
                 session.getMapper(SysRoleMenuMapper.class),
@@ -159,10 +163,15 @@ class DataScopeIntegrationTest {
     }
 
     private List visibleRowIds(long userId) {
-        permissionService.initDataScopeContext(userId);
+        loadVisibility(userId);
         return testDataMapper.selectList(null).stream().map(TestOwnedData::getId).toList();
     }
 
+    /** 走新缝:resolve 是纯函数,装载上下文由调用方(测试模拟过滤器)负责 */
+    private void loadVisibility(long userId) {
+        DataVisibilityContext.load(resolver.resolve(userId).getVisibility());
+    }
+
     @Test
     @DisplayName("档位1 仅本人:只可见归属人是自己的行")
     void scopeOne_seesOnlyOwnRows() {
@@ -214,7 +223,7 @@ class DataScopeIntegrationTest {
     @Test
     @DisplayName("未标注 @DataScope 的表不受过滤影响")
     void unannotatedTable_isNotFiltered() {
-        permissionService.initDataScopeContext(2L); // 档位2 的用户
+        loadVisibility(2L); // 档位2 的用户
         assertThat(deptMapper.selectList(null)).hasSize(5);
     }
 
@@ -277,9 +286,9 @@ class DataScopeIntegrationTest {
     @Test
     @DisplayName("部门树缓存:第二次权限计算不再触发部门全表查询(多部门共享同一棵树)")
     void deptTree_isCached_noRepeatedFullTableQuery() {
-        permissionService.initDataScopeContext(3L); // 档位3,首次建树
+        loadVisibility(3L); // 档位3,首次建树
         DeptQueryCounter.COUNT.set(0);
-        permissionService.initDataScopeContext(22L); // 另一档位3用户(多部门),命中缓存树
+        loadVisibility(22L); // 另一档位3用户(多部门),命中缓存树
         assertThat(DeptQueryCounter.COUNT.get())
                 .as("第二次权限计算的 sys_dept 全表查询次数")
                 .isZero();
@@ -333,7 +342,7 @@ class DataScopeIntegrationTest {
     @Test
     @DisplayName("插入自动归属:owner 填当前用户,dept 填主部门(取值源是数据权限上下文)")
     void insert_autoFillsOwnerAndDeptFromContext() {
-        permissionService.initDataScopeContext(1L); // 用户1:主部门 10
+        loadVisibility(1L); // 用户1:主部门 10
         TestOwnedData row = new TestOwnedData();
         row.setId(3001L);
         row.setTitle("自动归属");
@@ -345,7 +354,7 @@ class DataScopeIntegrationTest {
     @Test
     @DisplayName("插入显式赋值:业务代码已赋 owner/dept 时不被自动填充覆盖")
     void insert_explicitValues_notOverridden() {
-        permissionService.initDataScopeContext(1L);
+        loadVisibility(1L);
         TestOwnedData row = new TestOwnedData();
         row.setId(3002L);
         row.setTitle("显式归属");
@@ -359,7 +368,7 @@ class DataScopeIntegrationTest {
     @Test
     @DisplayName("无主部门用户插入:owner 照填,dept 保持 null(不从兼职集合里挑)")
     void insert_withoutPrimaryDept_leavesDeptNull() {
-        permissionService.initDataScopeContext(23L); // 用户23:无主部门,仅兼职 11
+        loadVisibility(23L); // 用户23:无主部门,仅兼职 11
         TestOwnedData row = new TestOwnedData();
         row.setId(3003L);
         row.setTitle("无主部门");
@@ -379,6 +388,63 @@ class DataScopeIntegrationTest {
         assertThat(row.getDeptId()).isNull();
     }
 
+    // ==================== 权限码并集(ADR-0011) ====================
+
+    @Test
+    @DisplayName("权限码并集:多角色取并集,停用权限点全局断路,menu 节点不进并集")
+    void permUnion_multiRole_enabledButtonsOnly() {
+        PermissionGrant grant = resolver.resolve(5L); // 角色1+2:授权了 301,302,303,304
+        assertThat(grant.getPermCodes())
+                .as("302 停用断路、303 是 menu 节点,均不进并集")
+                .containsExactlyInAnyOrder("crm:user:list", "crm:order:list");
+        assertThat(grant.getRoleCodes()).containsExactlyInAnyOrder("SELF", "DEPT");
+    }
+
+    @Test
+    @DisplayName("停用权限点断路:已授权但 status=disabled 的 perms 不进并集")
+    void permUnion_disabledButton_cutOff() {
+        PermissionGrant grant = resolver.resolve(1L); // 角色1:授权 301(enabled) + 302(disabled)
+        assertThat(grant.getPermCodes()).containsExactly("crm:user:list");
+    }
+
+    @Test
+    @DisplayName("无角色用户:权限码与角色码均为空集,可见性取最窄档")
+    void permUnion_noRole_emptySets() {
+        PermissionGrant grant = resolver.resolve(9L);
+        assertThat(grant.getPermCodes()).isEmpty();
+        assertThat(grant.getRoleCodes()).isEmpty();
+        assertThat(grant.getVisibility()).isNotNull();
+    }
+
+    @Test
+    @DisplayName("ROLE_ 前缀归一幂等:已带前缀的 roleCode 不得双前缀")
+    void rolePrefix_normalization_isIdempotent() {
+        PermissionGrant grant = resolver.resolve(25L); // 角色5:roleCode='ROLE_ADMIN'
+        assertThat(grant.getRoleCodes()).containsExactly("ROLE_ADMIN");
+        assertThat(grant.asAuthorities())
+                .extracting(SimpleGrantedAuthority::getAuthority)
+                .contains("ROLE_ADMIN")
+                .doesNotContain("ROLE_ROLE_ADMIN");
+    }
+
+    @Test
+    @DisplayName("ROLE_ 前缀归一:无前缀的 roleCode 由模块补前缀")
+    void rolePrefix_normalization_addsWhenMissing() {
+        PermissionGrant grant = resolver.resolve(1L); // 角色1:roleCode='SELF'
+        assertThat(grant.asAuthorities())
+                .extracting(SimpleGrantedAuthority::getAuthority)
+                .contains("ROLE_SELF", "crm:user:list");
+    }
+
+    @Test
+    @DisplayName("可见菜单树:含授权的 menu 节点,排除 button,无授权返回空")
+    void visibleMenuTree_menusOnly() {
+        List tree = resolver.visibleMenuTree(5L);
+        assertThat(tree).extracting(SysMenu::getId).contains(303L);
+        assertThat(tree).extracting(SysMenu::getId).doesNotContain(301L, 302L, 304L);
+        assertThat(resolver.visibleMenuTree(9L)).isEmpty();
+    }
+
     /** 建表 + 固定数据集(BaseEntity 审计列一律允许 NULL,deleted 显式置 0) */
     private static final String[] SCHEMA_AND_SEED = {
             """
@@ -417,6 +483,20 @@ class DataScopeIntegrationTest {
                 updater_id varchar(50), update_time datetime, deleted tinyint not null default 0,
                 dept_id bigint, owner_id bigint, title varchar(50))
             """,
+            """
+            create table sys_menu (
+                id bigint primary key, creator_id varchar(50), create_time datetime,
+                updater_id varchar(50), update_time datetime, deleted tinyint not null default 0,
+                parent_id bigint not null default 0, menu_name varchar(50) not null, menu_type tinyint not null,
+                path varchar(200), component varchar(200), icon varchar(50), sort int default 0,
+                visible tinyint default 1, description varchar(100), perms varchar(100),
+                deny_behavior varchar(16), status varchar(16) default 'enabled', api_url varchar(200))
+            """,
+            """
+            create table sys_role_menu (
+                id bigint primary key, role_id bigint not null, menu_id bigint not null,
+                constraint uk_role_menu unique (role_id, menu_id))
+            """,
             // 部门树
             "insert into sys_dept (id, deleted, parent_id, dept_name) values (10, 0, 0, '销售部')",
             "insert into sys_dept (id, deleted, parent_id, dept_name) values (11, 0, 10, '华东组')",
@@ -428,6 +508,8 @@ class DataScopeIntegrationTest {
             "insert into sys_role (id, deleted, role_name, role_code, data_scope) values (2, 0, '本部门', 'DEPT', 2)",
             "insert into sys_role (id, deleted, role_name, role_code, data_scope) values (3, 0, '含子部门', 'DEPT_CHILD', 3)",
             "insert into sys_role (id, deleted, role_name, role_code, data_scope) values (4, 0, '全部', 'ALL', 4)",
+            // 角色5:roleCode 自带 ROLE_ 前缀(验证归一幂等,不得双前缀)
+            "insert into sys_role (id, deleted, role_name, role_code, data_scope) values (5, 0, '管理员', 'ROLE_ADMIN', 1)",
             // 用户
             "insert into crm_auth_user (id, deleted, username, dept_id) values (1, 0, '仅本人-销售部', 10)",
             "insert into crm_auth_user (id, deleted, username, dept_id) values (2, 0, '本部门-销售部', 10)",
@@ -441,6 +523,7 @@ class DataScopeIntegrationTest {
             "insert into crm_auth_user (id, deleted, username, dept_id) values (22, 0, '兼职-主市场兼华北', 20)",
             "insert into crm_auth_user (id, deleted, username, dept_id) values (23, 0, '兼职-无主部门', null)",
             "insert into crm_auth_user (id, deleted, username, dept_id) values (24, 0, '兼职-层级重叠', 10)",
+            "insert into crm_auth_user (id, deleted, username, dept_id) values (25, 0, '带前缀角色', 10)",
             // 用户-角色
             "insert into sys_user_role (id, user_id, role_id) values (1001, 1, 1)",
             "insert into sys_user_role (id, user_id, role_id) values (1002, 2, 2)",
@@ -454,6 +537,7 @@ class DataScopeIntegrationTest {
             "insert into sys_user_role (id, user_id, role_id) values (1010, 22, 3)",
             "insert into sys_user_role (id, user_id, role_id) values (1011, 23, 2)",
             "insert into sys_user_role (id, user_id, role_id) values (1012, 24, 3)",
+            "insert into sys_user_role (id, user_id, role_id) values (1013, 25, 5)",
             // 兼职挂靠
             "insert into sys_user_dept (id, user_id, dept_id) values (2001, 21, 20)",
             "insert into sys_user_dept (id, user_id, dept_id) values (2002, 22, 12)",
@@ -466,5 +550,16 @@ class DataScopeIntegrationTest {
             "insert into test_owned_data (id, deleted, dept_id, owner_id, title) values (104, 0, 20, 4, '市场部-用户4')",
             "insert into test_owned_data (id, deleted, dept_id, owner_id, title) values (105, 0, 20, 1, '市场部-用户1')",
             "insert into test_owned_data (id, deleted, dept_id, owner_id, title) values (106, 0, 121, 8, '华北一队-用户8')",
+            // 权限资源树:302 是停用权限点(全局断路);303 是 menu 节点(不进权限码并集)
+            "insert into sys_menu (id, deleted, parent_id, menu_name, menu_type, perms, status, visible, sort) values (301, 0, 0, '用户列表按钮', 3, 'crm:user:list', 'enabled', 1, 0)",
+            "insert into sys_menu (id, deleted, parent_id, menu_name, menu_type, perms, status, visible, sort) values (302, 0, 0, '用户删除按钮', 3, 'crm:user:delete', 'disabled', 1, 0)",
+            "insert into sys_menu (id, deleted, parent_id, menu_name, menu_type, path, visible, sort) values (303, 0, 0, '订单菜单', 2, '/order', 1, 0)",
+            "insert into sys_menu (id, deleted, parent_id, menu_name, menu_type, perms, status, visible, sort) values (304, 0, 303, '订单列表按钮', 3, 'crm:order:list', 'enabled', 1, 0)",
+            // 角色授权:角色1→301,302;角色2→302,304,303
+            "insert into sys_role_menu (id, role_id, menu_id) values (5001, 1, 301)",
+            "insert into sys_role_menu (id, role_id, menu_id) values (5002, 1, 302)",
+            "insert into sys_role_menu (id, role_id, menu_id) values (5003, 2, 302)",
+            "insert into sys_role_menu (id, role_id, menu_id) values (5004, 2, 304)",
+            "insert into sys_role_menu (id, role_id, menu_id) values (5005, 2, 303)",
     };
 }