手把手教你搞定菜單權限設計,精確到按鈕級別

一、介紹

在實際的項目開發過程中,菜單權限功能可以說是後端管理系統中必不可少的一個環節,根據業務的複雜度,設計的時候可深可淺,但無論怎麼變化,設計的思路基本都是圍繞着用戶、角色、菜單進行相應的擴展

今天小編就和大家一起來討論一下,怎麼設計一套可以精確到按鈕級別的菜單權限功能,廢話不多說,直接開擼!

二、數據庫設計

先來看一下,用戶、角色、菜單表對應的ER圖,如下:

其中,用戶和角色是多對多的關係角色與菜單也是多對多的關係用戶通過角色來關聯到菜單,當然也有的業務系統菜單權限模型,是可以直接通過用戶關聯到菜單,對菜單權限可以直接控制到用戶級別,不過這個都不是問題,這個也可以進行擴展。

對於用戶、角色表比較簡單,下面,我們重點來看看菜單表的設計,如下:

可以看到,整個菜單表就是一個樹型結構,關鍵字段說明

  • menu_code:菜單編碼,用於後端權限控制
  • parent_id:菜單父節點ID,方便遞歸遍歷菜單
  • node_type:節點類型,可以是文件夾、頁面或者按鈕類型
  • link_url:頁面對應的地址,如果是文件夾或者按鈕類型,可以爲空
  • level:菜單樹的層次,以便於查詢指定層級的菜單
  • path:樹id的路徑,主要用於存放從根節點到當前樹的父節點的路徑,逗號分隔,想要找父節點會特別快

爲了後面方便開發,我們先創建一個名爲menu_auth_db的數據庫,初始腳本如下:

CREATE DATABASE IF NOT EXISTS menu_auth_db default charset utf8mb4 COLLATE utf8mb4_unicode_ci;

CREATE TABLE menu_auth_db.tb_user (
  id bigint(20) unsigned NOT NULL COMMENT '消息給過來的ID',
  mobile varchar(20) NOT NULL DEFAULT '' COMMENT '手機號',
  name varchar(100) NOT NULL DEFAULT '' COMMENT '姓名',
  password varchar(128) NOT NULL DEFAULT '' COMMENT '密碼',
  is_delete tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否刪除 1:已刪除;0:未刪除',
  PRIMARY KEY (id),
  KEY idx_name (name) USING BTREE,
  KEY idx_mobile (mobile) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用戶表';

CREATE TABLE menu_auth_db.tb_user_role (
  id bigint(20) unsigned NOT NULL COMMENT '主鍵',
  user_id bigint(20) NOT NULL COMMENT '用戶ID',
  role_id bigint(20) NOT NULL COMMENT '角色ID',
  PRIMARY KEY (id),
  KEY idx_user_id (user_id) USING BTREE,
  KEY idx_role_id (role_id) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用戶角色表';

CREATE TABLE menu_auth_db.tb_role (
  id bigint(20) unsigned NOT NULL COMMENT '主鍵',
  code varchar(100) NOT NULL DEFAULT '' COMMENT '編碼',
  name varchar(100) NOT NULL DEFAULT '' COMMENT '名稱',
  is_delete tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否刪除 1:已刪除;0:未刪除',
  PRIMARY KEY (id),
  KEY idx_code (code) USING BTREE,
  KEY idx_name (name) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色表';


CREATE TABLE menu_auth_db.tb_role_menu (
  id bigint(20) unsigned NOT NULL COMMENT '主鍵',
  role_id bigint(20) NOT NULL COMMENT '角色ID',
  menu_id bigint(20) NOT NULL COMMENT '菜單ID',
  PRIMARY KEY (id),
  KEY idx_role_id (role_id) USING BTREE,
  KEY idx_menu_id (menu_id) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色菜單關係表';


CREATE TABLE menu_auth_db.tb_menu (
  id bigint(20) NOT NULL COMMENT '主鍵',
  name varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '名稱',
  menu_code varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '菜單編碼',
  parent_id bigint(20) DEFAULT NULL COMMENT '父節點',
  node_type tinyint(4) NOT NULL DEFAULT '1' COMMENT '節點類型,1文件夾,2頁面,3按鈕',
  icon_url varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '圖標地址',
  sort int(11) NOT NULL DEFAULT '1' COMMENT '排序號',
  link_url varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '頁面對應的地址',
  level int(11) NOT NULL DEFAULT '0' COMMENT '層次',
  path varchar(2500) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '樹id的路徑 整個層次上的路徑id,逗號分隔,想要找父節點特別快',
  is_delete tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否刪除 1:已刪除;0:未刪除',
  PRIMARY KEY (id) USING BTREE,
  KEY idx_parent_id (parent_id) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='菜單表';

三、後端開發

菜單權限模塊的數據庫設計,一般5張表就可以搞定,真正有點複雜的地方在於數據的寫入和渲染,當然如果老闆突然讓你來開發一套菜單權限系統,我們也沒必要慌張,下面,我們一起來看看後端應該如何開發。

3.1、創建項目

爲了方便快捷,小編我採用的是springboot+mybatisPlus組件來快速開發,直接利用mybatisPlus官方提供的快速生成代碼的demo,一鍵生成所需的daoserviceweb層的代碼,結果如下:

3.2、編寫菜單添加服務

@Override
public void addMenu(Menu menu) {
    //如果插入的當前節點爲根節點,parentId指定爲0
    if(menu.getParentId().longValue() == 0){
        menu.setLevel(1);//根節點層級爲1
        menu.setPath(null);//根節點路徑爲空
    }else{
        Menu parentMenu = baseMapper.selectById(menu.getParentId());
        if(parentMenu == null){
            throw new CommonException("未查詢到對應的父節點");
        }
        menu.setLevel(parentMenu.getLevel().intValue() + 1);
        if(StringUtils.isNotEmpty(parentMenu.getPath())){
            menu.setPath(parentMenu.getPath() + "," + parentMenu.getId());
        }else{
            menu.setPath(parentMenu.getId().toString());
        }
    }
    //可以使用雪花算法,生成ID
    menu.setId(System.currentTimeMillis());
    super.save(menu);
}

新增菜單比較簡單,直接將數據插入即可,需要注意的地方是parent_idlevelpath,這三個字段的寫入,如果新建的是根節點,默認parent_id0,方便後續遞歸遍歷。

3.3、編寫菜單後端查詢服務

  • 新建一個菜單視圖實體類
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
public class MenuVo implements Serializable {

    private static final long serialVersionUID = -4559267810907997111L;

    /**
     * 主鍵
     */
    private Long id;

    /**
     * 名稱
     */
    private String name;

    /**
     * 菜單編碼
     */
    private String menuCode;

    /**
     * 父節點
     */
    private Long parentId;

    /**
     * 節點類型,1文件夾,2頁面,3按鈕
     */
    private Integer nodeType;

    /**
     * 圖標地址
     */
    private String iconUrl;

    /**
     * 排序號
     */
    private Integer sort;

    /**
     * 頁面對應的地址
     */
    private String linkUrl;

    /**
     * 層次
     */
    private Integer level;

    /**
     * 樹id的路徑 整個層次上的路徑id,逗號分隔,想要找父節點特別快
     */
    private String path;

    /**
     * 子菜單集合
     */
    List<MenuVo> childMenu;
}
  • 編寫菜單查詢服務,使用遞歸重新封裝菜單視圖
@Override
public List<MenuVo> queryMenuTree() {
    Wrapper queryObj = new QueryWrapper<>().orderByAsc("level","sort");
    List<Menu> allMenu = super.list(queryObj);
    // 0L:表示根節點的父ID
    List<MenuVo> resultList = transferMenuVo(allMenu, 0L);
    return resultList;
}
/**
 * 封裝菜單視圖
 * @param allMenu
 * @param parentId
 * @return
 */
private List<MenuVo> transferMenuVo(List<Menu> allMenu, Long parentId){
    List<MenuVo> resultList = new ArrayList<>();
    if(!CollectionUtils.isEmpty(allMenu)){
        for (Menu source : allMenu) {
            if(parentId.longValue() == source.getParentId().longValue()){
                MenuVo menuVo = new MenuVo();
                BeanUtils.copyProperties(source, menuVo);
                //遞歸查詢子菜單,並封裝信息
                List<MenuVo> childList = transferMenuVo(allMenu, source.getId());
                if(!CollectionUtils.isEmpty(childList)){
                    menuVo.setChildMenu(childList);
                }
                resultList.add(menuVo);
            }
        }
    }
    return resultList;
}
  • 編寫一個菜單樹查詢接口,如下:
@RestController
@RequestMapping("/menu")
public class MenuController {

    @Autowired
    private MenuService menuService;

    @PostMapping(value = "/queryMenuTree")
    public List<MenuVo> queryTreeMenu(){
        return menuService.queryMenuTree();
    }
}

爲了便於演示,我們先初始化7條數據,如下圖:

其中最後三條是按鈕類型,等下會用於後端權限控制,接口查詢結果如下:

這個服務是針對後端管理界面查詢的,會將所有的菜單全部查詢出來以便於進行管理,展示結果類似如下圖:

這個圖片截圖於小編正在開發的一個項目,內容可能不一致,但是數據結構基本都是一致的

3.4、編寫用戶菜單權限查詢服務

在上面,我們介紹到了用戶通過角色來關聯菜單,因此,很容易想到,流程如下:

  • 第一步:先通過用戶查詢到對應的角色;
  • 第二步:然後再通過角色查詢到對應的菜單;
  • 第三步:最後將菜單查詢出來之後進行渲染;

實現過程相比菜單查詢服務多了前2個步驟,過程如下:

@Override
public List<MenuVo> queryMenus(Long userId) {
    //1、先查詢當前用戶對應的角色
    Wrapper queryUserRoleObj = new QueryWrapper<>().eq("user_id", userId);
    List<UserRole> userRoles = userRoleService.list(queryUserRoleObj);
    if(!CollectionUtils.isEmpty(userRoles)){
        //2、通過角色查詢菜單(默認取第一個角色)
        Wrapper queryRoleMenuObj = new QueryWrapper<>().eq("role_id", userRoles.get(0).getRoleId());
        List<RoleMenu> roleMenus = roleMenuService.list(queryRoleMenuObj);
        if(!CollectionUtils.isEmpty(roleMenus)){
            Set<Long> menuIds = new HashSet<>();
            for (RoleMenu roleMenu : roleMenus) {
                menuIds.add(roleMenu.getMenuId());
            }
            //查詢對應的菜單
            Wrapper queryMenuObj = new QueryWrapper<>().in("id", new ArrayList<>(menuIds));
            List<Menu> menus = super.list(queryMenuObj);
            if(!CollectionUtils.isEmpty(menus)){
                //將菜單下對應的父節點也一併全部查詢出來
                Set<Long> allMenuIds = new HashSet<>();
                for (Menu menu : menus) {
                    allMenuIds.add(menu.getId());
                    if(StringUtils.isNotEmpty(menu.getPath())){
                        String[] pathIds = StringUtils.split(",", menu.getPath());
                        for (String pathId : pathIds) {
                            allMenuIds.add(Long.valueOf(pathId));
                        }
                    }
                }
                //3、查詢對應的所有菜單,並進行封裝展示
                List<Menu> allMenus = super.list(new QueryWrapper<Menu>().in("id", new ArrayList<>(allMenuIds)));
                List<MenuVo> resultList = transferMenuVo(allMenus, 0L);
                return resultList;
            }
        }

    }
    return null;
}
  • 編寫一個用戶菜單查詢接口,如下:
@PostMapping(value = "/queryMenus")
public List<MenuVo> queryMenus(Long userId){
    //查詢當前用戶下的菜單權限
    return menuService.queryMenus(userId);
}

有的同學,可能覺得沒必要存放path這個字段,的確在某些場景下不需要。

爲什麼要存放這個字段呢?

小編在跟前端進行對接的時候,發現這麼一個問題,有些前端的樹型組件,在勾選子集的時候,不會將對應的父ID傳給後端,例如,我在勾選【列表查詢】的時候,前端無法將父節點【菜單管理】ID也傳給後端,所有後端實際存放的是一個尾節點,需要一個字段path,來存放節點對應的父節點路徑。

其實,前端也可以傳,只不過需要修改組件的屬性,前端修改完成之後,樹型組件就無法全選,不滿足業務需求。

所以,有些時候得根據實際得情況來進行取捨。

3.5、編寫後端權限控制

後端進行權限控制目標,主要是爲了防止無權限的用戶,進行接口請求查詢。

其中菜單編碼menuCode就是一個前、後端聯繫的橋樑,細心的你會發現,所有後端的接口,與前端對應的都是按鈕操作,所以我們可以以按鈕爲基準,實現前後端雙向控制

以【角色管理-查詢】這個爲例,前端可以通過菜單編碼實現是否展示這個查詢按鈕,後端可以通過菜單編碼來判斷,當前用戶是否具備請求接口的權限。

以後端爲例,我們只需編寫一個權限註解和代理攔截器即可!

  • 編寫一個權限註解
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckPermissions {

    String value() default "";
}
  • 編寫一個代理攔截器,攔截有@CheckPermissions註解的方法
@Aspect
@Component
public class CheckPermissionsAspect {

    @Autowired
    private MenuMapper menuMapper;

    @Pointcut("@annotation(com.company.project.core.annotation.CheckPermissions)")
    public void checkPermissions() {}

    @Before("checkPermissions()")
    public void doBefore(JoinPoint joinPoint) throws Throwable {
        Long userId = null;
        Object[] args = joinPoint.getArgs();
        Object parobj = args[0];
        //用戶請求參數實體類中的用戶ID
        if(!Objects.isNull(parobj)){
            Class userCla = parobj.getClass();
            Field field = userCla.getDeclaredField("userId");
            field.setAccessible(true);
            userId = (Long) field.get(parobj);
        }
        if(!Objects.isNull(userId)){
            //獲取方法上有CheckPermissions註解的參數
            Class clazz = joinPoint.getTarget().getClass();
            String methodName = joinPoint.getSignature().getName();
            Class[] parameterTypes = ((MethodSignature)joinPoint.getSignature()).getMethod().getParameterTypes();
            Method method = clazz.getMethod(methodName, parameterTypes);
            if(method.getAnnotation(CheckPermissions.class) != null){
                CheckPermissions annotation = method.getAnnotation(CheckPermissions.class);
                String menuCode = annotation.value();
                if (StringUtils.isNotBlank(menuCode)) {
                    //通過用戶ID、菜單編碼查詢是否有關聯
                    int count = menuMapper.selectAuthByUserIdAndMenuCode(userId, menuCode);
                    if(count == 0){
                        throw new CommonException("接口無訪問權限");
                    }
                }
            }
        }
    }
}
  • 我們以【角色管理-查詢】爲例,先新建一個請求實體類RoleDto,添加用戶ID屬性
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
public class RoleDto extends Role {

    //添加用戶ID
    private Long userId;
}
  • 在需要的接口上,添加@CheckPermissions註解,增加權限控制
@RestController
@RequestMapping("/role")
public class RoleController {

    private RoleService roleService;

    @CheckPermissions(value="roleMgr:list")
    @PostMapping(value = "/queryRole")
    public List<Role> queryRole(RoleDto roleDto){
        return roleService.list();
    }

    @CheckPermissions(value="roleMgr:add")
    @PostMapping(value = "/addRole")
    public void addRole(RoleDto roleDto){
        roleService.add(roleDto);
    }

    @CheckPermissions(value="roleMgr:delete")
    @PostMapping(value = "/deleteRole")
    public void deleteRole(RoleDto roleDto){
        roleService.delete(roleDto);
    }
}

依次類推,當我們想對某個接口進行權限控制的時候,只需要添加一個註解@CheckPermissions,並填寫對應的菜單編碼即可!

四、用戶權限測試

我們先初始化一個用戶【張三】,然後給他分配一個角色【訪客人員】,同時給這個角色分配一下2個菜單權限【系統配置】、【用戶管理】,等會用於權限測試。

初始內容如下:

數據初始化完成之後,我們來啓動項目,傳入用戶【張三】的ID,查詢用戶具備的菜單權限,結果如下:

查詢結果,用戶【張三】有兩個菜單權限!

接着,我們來驗證一下,用戶【張三】是否有角色查詢權限,請求角色查詢接口如下:

因爲沒有配置角色查詢接口,所以無權訪問!

五、總結

整片內容,只介紹了後端關鍵的服務實現過程,可能也有遺漏的地方,歡迎網友點評、吐槽!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章