Browse Source

feat: 权限码全集下发——/me 增加 permCodes 与 perms→denyBehavior 映射(auth-perms-01)

master
luoweijian 3 weeks ago
parent
commit
56d8abcdc3
  1. 11
      .scratch/auth-perms/ticket-01-perms-fullset-endpoint.md
  2. 8
      crm-auth/src/main/java/com/crm/auth/domain/dto/UserInfoDTO.java
  3. 17
      crm-auth/src/main/java/com/crm/auth/security/PermissionGrant.java
  4. 24
      crm-auth/src/main/java/com/crm/auth/security/PermissionResolverImpl.java
  5. 14
      crm-auth/src/main/java/com/crm/auth/service/impl/AuthServiceImpl.java
  6. 64
      crm-auth/src/test/java/com/crm/auth/security/scope/DataScopeIntegrationTest.java
  7. 46
      crm-auth/src/test/java/com/crm/auth/service/impl/AuthServiceImplTest.java

11
.scratch/auth-perms/ticket-01-perms-fullset-endpoint.md

@ -1,6 +1,6 @@
# Ticket 01: 权限码全集下发接口(前端按钮显隐数据源)
状态: 待排期 · 优先级: 前端联调阻塞项 · 2026-08-14 用户决策:模式 2(全集下发),不采用逐次问询式
状态: 已完成(2026-08-17 实现)· 优先级: 前端联调阻塞项 · 2026-08-14 用户决策:模式 2(全集下发),不采用逐次问询式
## 背景
@ -12,7 +12,7 @@
- 登录后前端可一次性获取:`permCodes` 全集 + 每个 perms 的 `denyBehavior`(hide / disable)。
- 实现位置二选一(实现时定):`/me` 的 userInfo 增加字段(登录必调、零额外请求,推荐);或新端点 `GET /api/auth/perms`
- 数据源:`PermissionGrant`(随登录会话缓存);无角色用户返回空集合;停用权限点不得出现。
- 数据源:`PermissionGrant`(每请求实时 resolve,停用权限点全局断路);无角色用户返回空集合;停用权限点不得出现。
## 验收
@ -24,3 +24,10 @@
## 关联
ADR-0008(权限码体系延后)、ADR-0011(统一权限资源树)、ADR-0024(菜单粒度文档决策中「按钮显隐由前端权限点运行时控制」的前提)
## Comments
- 2026-08-17 实现:选 `/me` 增字段方案(工单推荐)。`UserInfoDTO` 新增 `permCodes` + `denyBehaviors`(perms→denyBehavior 映射),`AuthServiceImpl.getCurrentUserInfo()` 从 `PermissionResolver.resolve()` 填充;`PermissionGrant` 新增 `getDenyBehaviors()`,与权限码并集同源同断路。
- 前提更正:本仓库不存在 grant 会话缓存,`JwtAuthenticationFilter` 每请求实时 resolve;因此验收 3 无需重登即生效(更强保证)。
- 边界规则:denyBehavior 未配置按 hide 兜底;同一 perms 多按钮行为冲突时取 disable(更保守)。
- 验收 1–4 全部覆盖:`DataScopeIntegrationTest`(多角色合并/停用断路/无角色空/兜底/冲突裁决)+ `AuthServiceImplTest`(/me 下发、空集合非 null)。

8
crm-auth/src/main/java/com/crm/auth/domain/dto/UserInfoDTO.java

@ -5,6 +5,8 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Map;
import java.util.Set;
/**
* 当前用户信息对外展示用不含敏感字段
@ -38,4 +40,10 @@ public class UserInfoDTO implements Serializable {
@Schema(description = "当前用户菜单树(仅登录和 /me 时填充)")
private java.util.List<?> menus;
@Schema(description = "权限码全集(button 且启用,前端按钮显隐数据源,仅 /me 时填充)")
private Set<String> permCodes;
@Schema(description = "权限码 → 无权限时前端表现(hide 隐藏 / disable 禁用,仅 /me 时填充)")
private Map<String, String> denyBehaviors;
}

17
crm-auth/src/main/java/com/crm/auth/security/PermissionGrant.java

@ -6,6 +6,7 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
@ -17,6 +18,7 @@ import java.util.Set;
* <ul>
* <li>{@link #getVisibility()} 数据可见性部门集合 + 档位 + 子树展开</li>
* <li>{@link #getPermCodes()} 权限码并集停用权限点已全局断路不进并集</li>
* <li>{@link #getDenyBehaviors()} 权限码 无权限时前端表现hide/disable</li>
* <li>{@link #getRoleCodes()} 角色编码集合</li>
* </ul>
*/
@ -27,11 +29,18 @@ public final class PermissionGrant {
private final DataVisibility visibility;
private final Set<String> permCodes;
private final Map<String, String> denyBehaviors;
private final Set<String> roleCodes;
public PermissionGrant(DataVisibility visibility, Set<String> permCodes, Set<String> roleCodes) {
this(visibility, permCodes, Map.of(), roleCodes);
}
public PermissionGrant(DataVisibility visibility, Set<String> permCodes,
Map<String, String> denyBehaviors, Set<String> roleCodes) {
this.visibility = visibility;
this.permCodes = permCodes == null ? Set.of() : Set.copyOf(permCodes);
this.denyBehaviors = denyBehaviors == null ? Map.of() : Map.copyOf(denyBehaviors);
this.roleCodes = roleCodes == null ? Set.of() : Set.copyOf(roleCodes);
}
@ -44,6 +53,14 @@ public final class PermissionGrant {
return permCodes;
}
/**
* 权限码 无权限时前端表现hide 隐藏 / disable 禁用 {@link #getPermCodes()}
* 同源同断路规则前端按钮显隐据此渲染未配置的权限点按 hide 兜底
*/
public Map<String, String> getDenyBehaviors() {
return denyBehaviors;
}
/** 角色编码集合(原样,不带 ROLE_ 前缀约定) */
public Set<String> getRoleCodes() {
return roleCodes;

24
crm-auth/src/main/java/com/crm/auth/security/PermissionResolverImpl.java

@ -30,6 +30,7 @@ import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@ -105,14 +106,25 @@ public class PermissionResolverImpl implements PermissionResolver {
userId, primaryDeptId, List.copyOf(deptIds), moduleLevels, expandedDeptIds);
// ── 权限码并集(ADR-0011):button 且 status=enabled 的 perms,去重 ──
Set<String> permCodes = CollUtil.isEmpty(userRoles)
? Set.of()
List<SysMenu> enabledButtons = CollUtil.isEmpty(userRoles)
? Collections.emptyList()
: 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));
.filter(m -> StrUtil.isNotBlank(m.getPerms()))
.collect(Collectors.toList());
Set<String> permCodes = enabledButtons.stream()
.map(SysMenu::getPerms)
.collect(Collectors.toCollection(LinkedHashSet::new));
// ── 权限码 → 无权限时前端表现(hide/disable),与并集同源同断路;未配置按 hide 兜底 ──
// 同一 perms 被多角色授权且行为不同时取 disable(更保守:看得见但不可点),
// 显式裁决不依赖 DB 返回顺序
Map<String, String> denyBehaviors = new LinkedHashMap<>();
for (SysMenu button : enabledButtons) {
denyBehaviors.merge(button.getPerms(),
StrUtil.blankToDefault(button.getDenyBehavior(), "hide"),
(a, b) -> "disable".equals(a) || "disable".equals(b) ? "disable" : a);
}
// ── 角色编码(原样持有,ROLE_ 前缀归一由 PermissionGrant 负责)──
Set<String> roleCodes = roles.stream()
@ -120,7 +132,7 @@ public class PermissionResolverImpl implements PermissionResolver {
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
return new PermissionGrant(visibility, permCodes, roleCodes);
return new PermissionGrant(visibility, permCodes, denyBehaviors, roleCodes);
}
@Override

14
crm-auth/src/main/java/com/crm/auth/service/impl/AuthServiceImpl.java

@ -9,6 +9,8 @@ import com.crm.auth.domain.enums.ClientTypeEnum;
import com.crm.auth.domain.enums.IdentityTypeEnum;
import com.crm.auth.constant.AuthConstants;
import com.crm.auth.security.AuthLoginUser;
import com.crm.auth.security.PermissionGrant;
import com.crm.auth.security.PermissionResolver;
import com.crm.auth.security.PreLoginSession;
import com.crm.auth.security.TokenService;
import com.crm.auth.service.IAuthIdentityService;
@ -52,6 +54,7 @@ public class AuthServiceImpl implements IAuthService {
private final IAuthUserService authUserService;
private final IAuthIdentityService authIdentityService;
private final TokenService tokenService;
private final PermissionResolver permissionResolver;
/** self-injection:让 login() 内部调用 doLoginInTx() 能走 Spring AOP 事务代理 */
@Lazy
@ -261,13 +264,10 @@ public class AuthServiceImpl implements IAuthService {
String userId = SecurityUtils.getRequiredUserId();
AuthUser user = authUserService.getByIdOrThrow(userId);
UserInfoDTO dto = BeanCopyUtils.copy(user, UserInfoDTO::new);
// 填充部门名称和菜单
fillExtraInfo(dto, Long.valueOf(userId));
// 权限码全集 + perms→denyBehavior:前端按钮显隐数据源,与鉴权同源同断路规则
PermissionGrant grant = permissionResolver.resolve(Long.valueOf(userId));
dto.setPermCodes(grant.getPermCodes());
dto.setDenyBehaviors(grant.getDenyBehaviors());
return dto;
}
private void fillExtraInfo(UserInfoDTO dto, Long userId) {
// 部门名称由 controller/filter 层查询填充,Service 层不直接引用 PermissionService 避免循环依赖
// 此处预留扩展点
}
}

64
crm-auth/src/test/java/com/crm/auth/security/scope/DataScopeIntegrationTest.java

@ -449,6 +449,44 @@ class DataScopeIntegrationTest {
assertThat(resolver.visibleMenuTree(9L)).isEmpty();
}
// ==================== perms→denyBehavior 下发(工单 auth-perms-01) ====================
@Test
@DisplayName("denyBehavior 映射:与权限码并集同源,多角色合并,停用权限点不出现")
void denyBehaviors_multiRole_cutOffDisabled() {
PermissionGrant grant = resolver.resolve(5L); // 角色1+2:授权 301,302,303,304
assertThat(grant.getDenyBehaviors())
.as("302 停用断路不进映射,303 是 menu 节点不进映射")
.containsExactlyInAnyOrderEntriesOf(Map.of(
"crm:user:list", "hide",
"crm:order:list", "disable"));
assertThat(grant.getDenyBehaviors().keySet())
.as("映射键与权限码并集完全一致")
.containsExactlyInAnyOrderElementsOf(grant.getPermCodes());
}
@Test
@DisplayName("denyBehavior 未配置 -> 按 hide 兜底")
void denyBehaviors_blank_defaultsToHide() {
PermissionGrant grant = resolver.resolve(26L); // 角色6:仅授权 305(deny_behavior 未配置)
assertThat(grant.getPermCodes()).containsExactly("crm:order:export");
assertThat(grant.getDenyBehaviors()).containsEntry("crm:order:export", "hide");
}
@Test
@DisplayName("同一 perms 多按钮冲突(hide vs disable)-> 取 disable,不依赖 DB 返回顺序")
void denyBehaviors_samePermConflict_disableWins() {
PermissionGrant grant = resolver.resolve(27L); // 角色7:同时授权 307(hide) 与 308(disable),同一 perms
assertThat(grant.getPermCodes()).containsExactly("crm:report:view");
assertThat(grant.getDenyBehaviors()).containsEntry("crm:report:view", "disable");
}
@Test
@DisplayName("无角色用户:denyBehavior 映射为空")
void denyBehaviors_noRole_emptyMap() {
assertThat(resolver.resolve(9L).getDenyBehaviors()).isEmpty();
}
/** 建表 + 固定数据集(BaseEntity 审计列一律允许 NULL,deleted 显式置 0) */
private static final String[] SCHEMA_AND_SEED = {
"""
@ -532,12 +570,18 @@ class DataScopeIntegrationTest {
"insert into sys_role (id, deleted, role_name, role_code) values (4, 0, '全部', 'ALL')",
// 角色5:roleCode 自带 ROLE_ 前缀(验证归一幂等,不得双前缀)
"insert into sys_role (id, deleted, role_name, role_code) values (5, 0, '管理员', 'ROLE_ADMIN')",
// 角色6:仅授权 deny_behavior 未配置的按钮(验证 hide 兜底)
"insert into sys_role (id, deleted, role_name, role_code) values (6, 0, '兜底行为', 'BLANK_DENY')",
// 角色7:同时授权同一 perms 的 hide/disable 两按钮(验证冲突取 disable)
"insert into sys_role (id, deleted, role_name, role_code) values (7, 0, '冲突裁决', 'DENY_CONFLICT')",
// 角色×模块档位:角色 id 即档位值
"insert into sys_role_data_scope (id, role_id, module_code, data_scope) values (1, 1, 'test', 1)",
"insert into sys_role_data_scope (id, role_id, module_code, data_scope) values (2, 2, 'test', 2)",
"insert into sys_role_data_scope (id, role_id, module_code, data_scope) values (3, 3, 'test', 3)",
"insert into sys_role_data_scope (id, role_id, module_code, data_scope) values (4, 4, 'test', 4)",
"insert into sys_role_data_scope (id, role_id, module_code, data_scope) values (5, 5, 'test', 1)",
"insert into sys_role_data_scope (id, role_id, module_code, data_scope) values (6, 6, 'test', 1)",
"insert into sys_role_data_scope (id, role_id, module_code, data_scope) values (7, 7, 'test', 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)",
@ -552,6 +596,8 @@ class DataScopeIntegrationTest {
"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 crm_auth_user (id, deleted, username, dept_id) values (26, 0, '兜底行为测试', 10)",
"insert into crm_auth_user (id, deleted, username, dept_id) values (27, 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)",
@ -566,6 +612,8 @@ class DataScopeIntegrationTest {
"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_role (id, user_id, role_id) values (1014, 26, 6)",
"insert into sys_user_role (id, user_id, role_id) values (1015, 27, 7)",
// 兼职挂靠
"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)",
@ -578,16 +626,22 @@ 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)",
// 权限资源树:302 是停用权限点(全局断路);303 是 menu 节点(不进权限码并集);305 deny_behavior 未配置(hide 兜底)
"insert into sys_menu (id, deleted, parent_id, menu_name, menu_type, perms, deny_behavior, status, visible, sort) values (301, 0, 0, '用户列表按钮', 3, 'crm:user:list', 'hide', 'enabled', 1, 0)",
"insert into sys_menu (id, deleted, parent_id, menu_name, menu_type, perms, deny_behavior, status, visible, sort) values (302, 0, 0, '用户删除按钮', 3, 'crm:user:delete', 'disable', '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_menu (id, deleted, parent_id, menu_name, menu_type, perms, deny_behavior, status, visible, sort) values (304, 0, 303, '订单列表按钮', 3, 'crm:order:list', 'disable', 'enabled', 1, 0)",
"insert into sys_menu (id, deleted, parent_id, menu_name, menu_type, perms, status, visible, sort) values (305, 0, 303, '订单导出按钮', 3, 'crm:order:export', 'enabled', 1, 0)",
"insert into sys_menu (id, deleted, parent_id, menu_name, menu_type, perms, deny_behavior, status, visible, sort) values (307, 0, 303, '报表查看按钮-hide', 3, 'crm:report:view', 'hide', 'enabled', 1, 0)",
"insert into sys_menu (id, deleted, parent_id, menu_name, menu_type, perms, deny_behavior, status, visible, sort) values (308, 0, 303, '报表查看按钮-disable', 3, 'crm:report:view', 'disable', 'enabled', 1, 0)",
// 角色授权:角色1→301,302;角色2→302,304,303;角色6→305(deny_behavior 未配置);角色7→307,308(同 perms 冲突)
"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)",
"insert into sys_role_menu (id, role_id, menu_id) values (5006, 6, 305)",
"insert into sys_role_menu (id, role_id, menu_id) values (5008, 7, 307)",
"insert into sys_role_menu (id, role_id, menu_id) values (5009, 7, 308)",
};
}

46
crm-auth/src/test/java/com/crm/auth/service/impl/AuthServiceImplTest.java

@ -8,6 +8,8 @@ import com.crm.auth.domain.entity.AuthUser;
import com.crm.auth.domain.enums.ClientTypeEnum;
import com.crm.auth.domain.enums.IdentityTypeEnum;
import com.crm.auth.security.AuthLoginUser;
import com.crm.auth.security.PermissionGrant;
import com.crm.auth.security.PermissionResolver;
import com.crm.auth.security.PreLoginSession;
import com.crm.auth.security.TokenService;
import com.crm.auth.service.IAuthIdentityService;
@ -31,6 +33,9 @@ import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
@ -61,6 +66,8 @@ class AuthServiceImplTest {
@Mock
private TokenService tokenService;
@Mock
private PermissionResolver permissionResolver;
@Mock
private ThirdPartyAuthClient thirdPartyClient;
@InjectMocks
@ -298,6 +305,7 @@ class AuthServiceImplTest {
AuthUser user = buildUser(7007L, "小明", "13900000000", true);
// getByIdOrThrow 是接口 default 方法,mock 下不会执行真实逻辑,需显式打桩
when(authUserService.getByIdOrThrow("7007")).thenReturn(user);
when(permissionResolver.resolve(7007L)).thenReturn(emptyGrant());
UserInfoDTO dto = authService.getCurrentUserInfo();
assertThat(dto).isNotNull();
@ -313,6 +321,44 @@ class AuthServiceImplTest {
}
}
@Test
@DisplayName("getCurrentUserInfo:下发权限码全集 + perms→denyBehavior(前端按钮显隐数据源)")
void getCurrentUserInfo_deliversPermCodesAndDenyBehaviors() {
try (MockedStatic<SecurityUtils> mocked = Mockito.mockStatic(SecurityUtils.class)) {
mocked.when(SecurityUtils::getRequiredUserId).thenReturn("7007");
when(authUserService.getByIdOrThrow("7007")).thenReturn(buildUser(7007L, "小明", "13900000000", true));
when(permissionResolver.resolve(7007L)).thenReturn(new PermissionGrant(null,
Set.of("crm:user:list", "crm:order:list"),
Map.of("crm:user:list", "hide", "crm:order:list", "disable"),
Set.of("SELF")));
UserInfoDTO dto = authService.getCurrentUserInfo();
assertThat(dto.getPermCodes()).containsExactlyInAnyOrder("crm:user:list", "crm:order:list");
assertThat(dto.getDenyBehaviors())
.containsExactlyInAnyOrderEntriesOf(Map.of("crm:user:list", "hide", "crm:order:list", "disable"));
}
}
@Test
@DisplayName("getCurrentUserInfo:无角色用户 -> permCodes 与 denyBehaviors 均为空集合而非 null")
void getCurrentUserInfo_noRole_emptyPermSets() {
try (MockedStatic<SecurityUtils> mocked = Mockito.mockStatic(SecurityUtils.class)) {
mocked.when(SecurityUtils::getRequiredUserId).thenReturn("7007");
when(authUserService.getByIdOrThrow("7007")).thenReturn(buildUser(7007L, "小明", "13900000000", true));
when(permissionResolver.resolve(7007L)).thenReturn(emptyGrant());
UserInfoDTO dto = authService.getCurrentUserInfo();
assertThat(dto.getPermCodes()).isEmpty();
assertThat(dto.getDenyBehaviors()).isEmpty();
}
}
private static PermissionGrant emptyGrant() {
return new PermissionGrant(null, Set.of(), Map.of(), Set.of());
}
@Test
@DisplayName("该 (用户, 端) 已有活会话 -> 不签正式 token,返回 DEVICE_ONLINE + preLoginToken(仍刷 lastLoginTime)")
void login_whenDeviceOnline_returnsDeviceOnlineWithPreLoginToken() {

Loading…
Cancel
Save