Parcourir la source

fix(系统): 角色详情带上已授权菜单/部门,权限抽屉此前全不勾且保存会清空授权

RoleService#toVO 自 Java 平移起就把 menus / depts 写死成空数组,而前端权限
分配抽屉一直读 detail.menus[].id —— 那个形状不是随便定的,Python 版
RoleOutSchema.menus 就是 list[MenuOutSchema](lazy="selectin")。

后果不止是「显示全未勾选」:抽屉保存走的是先删光再插入
(RoleMapper.deleteRoleMenusByRoleIds → insertRoleMenus),所以
「打开抽屉、什么都没改、点确定」会把该角色的授权**清空**。

改法:

- RoleMapper 新增 selectMenusByRoleId / selectDeptsByRoleId,条件里带
  status='0'。抽屉那棵树来自 MenuService#getMenuTree(MenuEntity.status 挂
  @TableLogic,树里只有活着的菜单),这里不跟着过滤就会回勾一批树里没有的
  id(Element 静默忽略),保存时又被删掉:读到的与写回去的对不上。
- RoleVO.menus 由 id 列表改成 List<MenuVO> —— 前端取的是 .id,纯 id 数组上
  取 .id 全是 undefined,树同样全不勾。depts 用 List<Map>,键名由 SQL 别名
  钉成 id / name / parent_id(Map 的键不会被 Jackson 的 SNAKE_CASE 改写)。
- 两个字段只填在 getDetail。列表/分页不查:那是每行两次查询的 N+1,
  而抽屉只用得到 detail,由 RoleServiceTest#getList_doesNotLoadMenusPerRole 钉住。

测试 RoleServiceTest 两条,断言走**线上那支 ObjectMapper**(JacksonConfig,
SNAKE_CASE)序列化后的 JSON —— 前端读的是键名(parent_id / data_scope),
不是 Java 字段名。

两向读数:getDetail 里去掉那两行赋值 ⇒ 红(menus=[] ,expected 1 but was 0);
按 sha256 3eca6f83… 还原 ⇒ 全量 888 绿(原 886 + 本类 2 条)。

管理端角色页无需重新登录:JwtAuthFilter 每请求重载权限,本次也没改权限串。
alphaH il y a 2 semaines
Parent
commit
1bdaa59d5c

+ 20 - 5
java/src/main/java/com/payment/platform/module/system/role/dto/RoleVO.java

@@ -1,10 +1,12 @@
 package com.payment.platform.module.system.role.dto;
 
+import com.payment.platform.module.system.menu.dto.MenuVO;
 import io.swagger.v3.oas.annotations.media.Schema;
 import lombok.Data;
 
 import java.time.OffsetDateTime;
 import java.util.List;
+import java.util.Map;
 
 @Data
 public class RoleVO {
@@ -39,9 +41,22 @@ public class RoleVO {
     @Schema(description = "更新时间")
     private OffsetDateTime updatedTime;
 
-    @Schema(description = "关联菜单ID列表")
-    private List<Long> menus;
-
-    @Schema(description = "关联部门ID列表")
-    private List<Long> depts;
+    /**
+     * 角色**已授权**的菜单,只在详情里填(列表/分页留空,避免每行两次查询的 N+1)。
+     *
+     * 不是 Long 列表:权限分配抽屉读的是 `menus[].id`(PermissonDrawer.vue 的
+     * `menus?.map(menu => menu.id)`,对应 Python 版 RoleOutSchema.menus: list[MenuOutSchema])。
+     * 这里返回纯 id 数组的话,`[1,2]` 上取 `.id` 全是 undefined ⇒ 树同样全不勾。
+     */
+    @Schema(description = "关联菜单列表")
+    private List<MenuVO> menus;
+
+    /**
+     * 角色**已授权**的部门,只在详情里填,data_scope=5(自定义数据权限)时抽屉靠它回勾。
+     *
+     * 用 Map 而不是实体:dept 模块自己的返回就是 Map(DeptService#getDeptTree 等),
+     * 且键名由 SQL 别名钉死成前端 permissionDeptType 要的 id / name / parent_id。
+     */
+    @Schema(description = "关联部门列表")
+    private List<Map<String, Object>> depts;
 }

+ 30 - 0
java/src/main/java/com/payment/platform/module/system/role/mapper/RoleMapper.java

@@ -2,13 +2,16 @@ package com.payment.platform.module.system.role.mapper;
 
 import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.payment.platform.module.system.menu.dto.MenuVO;
 import com.payment.platform.module.system.role.entity.RoleEntity;
 import org.apache.ibatis.annotations.Delete;
 import org.apache.ibatis.annotations.Insert;
 import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
 
 import java.util.List;
+import java.util.Map;
 
 @Mapper
 public interface RoleMapper extends BaseMapper<RoleEntity> {
@@ -18,6 +21,33 @@ public interface RoleMapper extends BaseMapper<RoleEntity> {
     @Select("SELECT r.* FROM sys_role r INNER JOIN sys_user_roles ur ON ur.role_id = r.id WHERE ur.user_id = #{userId}")
     List<RoleEntity> selectByUserId(Long userId);
 
+    /**
+     * 查询角色**已授权**的菜单 —— 权限分配抽屉回勾用(前端读 menus[].id)。
+     *
+     * 只认 status='0':抽屉那棵树来自 MenuService#getMenuTree(MenuMapper.selectList),
+     * 而 MenuEntity.status 挂了 @TableLogic ⇒ 树里只有活着的菜单。这里不跟着过滤的话,
+     * 会回勾一批树里根本没有的 id(Element 静默忽略),保存时又被删掉 ——
+     * 读到的和写回去的对不上,事后极难查。
+     */
+    @Select("SELECT m.id, m.name, m.type, m.permission, m.parent_id, m.status "
+            + "FROM sys_role_menus rm INNER JOIN sys_menu m ON m.id = rm.menu_id "
+            + "WHERE rm.role_id = #{roleId} AND m.status = '0' "
+            + "ORDER BY m.\"order\", m.id")
+    List<MenuVO> selectMenusByRoleId(@Param("roleId") Long roleId);
+
+    /**
+     * 查询角色**已授权**的部门 —— data_scope=5(自定义数据权限)时抽屉回勾用。
+     *
+     * 返回 Map 与 dept 模块自己的返回风格一致;键名由别名钉死成前端 permissionDeptType
+     * 要的 id / name / parent_id(Map 的键不会被 Jackson 的 SNAKE_CASE 策略改写,
+     * 别名写成什么就是什么)。过滤 status='0' 的理由同上,对齐部门树。
+     */
+    @Select("SELECT d.id AS id, d.name AS name, d.parent_id AS parent_id "
+            + "FROM sys_role_depts rd INNER JOIN sys_dept d ON d.id = rd.dept_id "
+            + "WHERE rd.role_id = #{roleId} AND d.status = '0' "
+            + "ORDER BY d.\"order\", d.id")
+    List<Map<String, Object>> selectDeptsByRoleId(@Param("roleId") Long roleId);
+
     /** 删除指定角色的菜单关联 */
     @Delete("<script>DELETE FROM sys_role_menus WHERE role_id IN <foreach collection='roleIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
     void deleteRoleMenusByRoleIds(List<Long> roleIds);

+ 15 - 1
java/src/main/java/com/payment/platform/module/system/role/service/RoleService.java

@@ -27,8 +27,17 @@ public class RoleService {
 
     private final RoleMapper roleMapper;
 
+    /**
+     * 角色详情。**授权明细只在这一条路径上查** —— 权限分配抽屉靠它回勾
+     * (前端读 menus[].id / depts[].id,契约由 RoleServiceTest 钉住)。
+     *
+     * 列表/分页不查:那是每行两次查询的 N+1,而抽屉只用得到 detail。
+     */
     public RoleVO getDetail(Long id) {
-        return toVO(requireRole(id));
+        RoleVO vo = toVO(requireRole(id));
+        vo.setMenus(roleMapper.selectMenusByRoleId(id));
+        vo.setDepts(roleMapper.selectDeptsByRoleId(id));
+        return vo;
     }
 
     public List<RoleVO> getList(String name) {
@@ -144,6 +153,11 @@ public class RoleService {
         return e;
     }
 
+    /**
+     * 基础映射(列表 / 分页 / 创建 / 更新都走这里):授权明细默认是**空数组**,
+     * 只有 {@link #getDetail} 会去查真值 —— 那两个字段各要一次额外查询,
+     * 放进这里就是每行两次的 N+1(由 RoleServiceTest#getList_doesNotLoadMenusPerRole 钉住)。
+     */
     private RoleVO toVO(RoleEntity r) {
         RoleVO vo = BeanUtil.copyProperties(r, RoleVO.class);
         vo.setMenus(List.of());

+ 90 - 0
java/src/test/java/com/payment/platform/module/system/role/service/RoleServiceTest.java

@@ -0,0 +1,90 @@
+package com.payment.platform.module.system.role.service;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.payment.platform.common.config.JacksonConfig;
+import com.payment.platform.module.system.menu.dto.MenuVO;
+import com.payment.platform.module.system.role.dto.RoleVO;
+import com.payment.platform.module.system.role.entity.RoleEntity;
+import com.payment.platform.module.system.role.mapper.RoleMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * 角色详情要带上「这个角色已经授了哪些菜单/部门」。
+ *
+ * 消费方是权限分配抽屉(frontend PermissonDrawer.vue 的 init):它读
+ * `detail.menus[].id` 与 `detail.depts[].id`,再 setCheckedKeys 回勾。
+ * 详情不返回这两个数组时,树**全未勾选**,而保存走的是「先删光再插入」
+ * (RoleMapper.deleteRoleMenusByRoleIds → insertRoleMenus)——
+ * 于是「打开抽屉、什么都没改、点确定」会把该角色的授权**清空**。
+ * 所以这两个字段空了不是显示问题,是数据丢失。
+ *
+ * 断言一律走**线上那支 ObjectMapper**(JacksonConfig,SNAKE_CASE)序列化后的 JSON:
+ * 前端读的是键名(parent_id / data_scope),不是 Java 字段名。
+ */
+@ExtendWith(MockitoExtension.class)
+class RoleServiceTest {
+
+    @Mock
+    private RoleMapper roleMapper;
+
+    @InjectMocks
+    private RoleService roleService;
+
+    @Test
+    void getDetail_carriesTheRolesOwnMenusAndDepts() throws Exception {
+        RoleEntity role = new RoleEntity();
+        role.setId(2L);
+        role.setName("租户管理员");
+        when(roleMapper.selectById(2L)).thenReturn(role);
+
+        MenuVO shopPage = new MenuVO();
+        shopPage.setId(31L);
+        shopPage.setName("门店管理");
+        shopPage.setParentId(9L);
+        when(roleMapper.selectMenusByRoleId(2L)).thenReturn(List.of(shopPage));
+        when(roleMapper.selectDeptsByRoleId(2L))
+                .thenReturn(List.of(Map.of("id", 7L, "name", "总部", "parent_id", 3L)));
+
+        JsonNode json = readJson(roleService.getDetail(2L));
+
+        JsonNode menus = json.get("menus");
+        assertNotNull(menus, "detail 少了 menus,抽屉只能全不勾。实测: " + json);
+        assertEquals(1, menus.size(), "menus 应当是**已授权**的那一条,实测: " + json);
+        assertEquals(31L, menus.get(0).get("id").asLong(), "抽屉按 menus[].id 回勾,实测: " + json);
+
+        JsonNode depts = json.get("depts");
+        assertNotNull(depts, "detail 少了 depts,实测: " + json);
+        assertEquals(7L, depts.get(0).get("id").asLong(), "抽屉按 depts[].id 回勾,实测: " + json);
+    }
+
+    /** 列表不带授权明细:那是每行两次查询的 N+1,抽屉只用 detail。 */
+    @Test
+    void getList_doesNotLoadMenusPerRole() {
+        when(roleMapper.selectList(any(Wrapper.class))).thenReturn(List.of(new RoleEntity()));
+
+        List<RoleVO> list = roleService.getList(null);
+
+        assertEquals(1, list.size());
+        assertTrue(list.get(0).getMenus().isEmpty(), "列表不该带授权明细");
+        verify(roleMapper, never()).selectMenusByRoleId(any());
+        verify(roleMapper, never()).selectDeptsByRoleId(any());
+    }
+
+    private JsonNode readJson(Object value) throws Exception {
+        ObjectMapper mapper = new JacksonConfig().objectMapper();
+        return mapper.readTree(mapper.writeValueAsString(value));
+    }
+}