From 56d8abcdc32769f006317c8dd075f5cb6df7f156 Mon Sep 17 00:00:00 2001 From: luoweijian <1329394916@qq.com> Date: Mon, 17 Aug 2026 21:13:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9D=83=E9=99=90=E7=A0=81=E5=85=A8?= =?UTF-8?q?=E9=9B=86=E4=B8=8B=E5=8F=91=E2=80=94=E2=80=94/me=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=20permCodes=20=E4=B8=8E=20perms=E2=86=92denyBehavior?= =?UTF-8?q?=20=E6=98=A0=E5=B0=84=EF=BC=88auth-perms-01=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ticket-01-perms-fullset-endpoint.md | 11 +++- .../com/crm/auth/domain/dto/UserInfoDTO.java | 8 +++ .../crm/auth/security/PermissionGrant.java | 17 +++++ .../auth/security/PermissionResolverImpl.java | 24 +++++-- .../auth/service/impl/AuthServiceImpl.java | 14 ++-- .../scope/DataScopeIntegrationTest.java | 64 +++++++++++++++++-- .../service/impl/AuthServiceImplTest.java | 46 +++++++++++++ 7 files changed, 164 insertions(+), 20 deletions(-) diff --git a/.scratch/auth-perms/ticket-01-perms-fullset-endpoint.md b/.scratch/auth-perms/ticket-01-perms-fullset-endpoint.md index caa700c..d7281fa 100644 --- a/.scratch/auth-perms/ticket-01-perms-fullset-endpoint.md +++ b/.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)。 diff --git a/crm-auth/src/main/java/com/crm/auth/domain/dto/UserInfoDTO.java b/crm-auth/src/main/java/com/crm/auth/domain/dto/UserInfoDTO.java index 55ced37..958819a 100644 --- a/crm-auth/src/main/java/com/crm/auth/domain/dto/UserInfoDTO.java +++ b/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 permCodes; + + @Schema(description = "权限码 → 无权限时前端表现(hide 隐藏 / disable 禁用,仅 /me 时填充)") + private Map denyBehaviors; } 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 index 30f51e0..02b239f 100644 --- a/crm-auth/src/main/java/com/crm/auth/security/PermissionGrant.java +++ b/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; * */ @@ -27,11 +29,18 @@ public final class PermissionGrant { private final DataVisibility visibility; private final Set permCodes; + private final Map denyBehaviors; private final Set roleCodes; public PermissionGrant(DataVisibility visibility, Set permCodes, Set roleCodes) { + this(visibility, permCodes, Map.of(), roleCodes); + } + + public PermissionGrant(DataVisibility visibility, Set permCodes, + Map denyBehaviors, Set 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 getDenyBehaviors() { + return denyBehaviors; + } + /** 角色编码集合(原样,不带 ROLE_ 前缀约定) */ public Set getRoleCodes() { return roleCodes; 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 index c0eb17c..616f802 100644 --- a/crm-auth/src/main/java/com/crm/auth/security/PermissionResolverImpl.java +++ b/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 permCodes = CollUtil.isEmpty(userRoles) - ? Set.of() + List 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 permCodes = enabledButtons.stream() + .map(SysMenu::getPerms) + .collect(Collectors.toCollection(LinkedHashSet::new)); + // ── 权限码 → 无权限时前端表现(hide/disable),与并集同源同断路;未配置按 hide 兜底 ── + // 同一 perms 被多角色授权且行为不同时取 disable(更保守:看得见但不可点), + // 显式裁决不依赖 DB 返回顺序 + Map 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 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 diff --git a/crm-auth/src/main/java/com/crm/auth/service/impl/AuthServiceImpl.java b/crm-auth/src/main/java/com/crm/auth/service/impl/AuthServiceImpl.java index 8318684..6ac0c61 100644 --- a/crm-auth/src/main/java/com/crm/auth/service/impl/AuthServiceImpl.java +++ b/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 避免循环依赖 - // 此处预留扩展点 - } } 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 eb7fc5a..134b462 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 @@ -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)", }; } diff --git a/crm-auth/src/test/java/com/crm/auth/service/impl/AuthServiceImplTest.java b/crm-auth/src/test/java/com/crm/auth/service/impl/AuthServiceImplTest.java index 7eafe96..97f4c65 100644 --- a/crm-auth/src/test/java/com/crm/auth/service/impl/AuthServiceImplTest.java +++ b/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 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 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() {