手把手教你搞定菜單權(quán)限設(shè)計(jì),精確到按鈕級(jí)別,建議收藏
一、介紹
在實(shí)際的項(xiàng)目開(kāi)發(fā)過(guò)程中,菜單權(quán)限功能可以說(shuō)是后端管理系統(tǒng)中必不可少的一個(gè)環(huán)節(jié),根據(jù)業(yè)務(wù)的復(fù)雜度,設(shè)計(jì)的時(shí)候可深可淺,但無(wú)論怎么變化,設(shè)計(jì)的思路基本都是圍繞著用戶(hù)、角色、菜單進(jìn)行相應(yīng)的擴(kuò)展。
今天小編就和大家一起來(lái)討論一下,怎么設(shè)計(jì)一套可以精確到按鈕級(jí)別的菜單權(quán)限功能,廢話不多說(shuō),直接開(kāi)擼!
二、數(shù)據(jù)庫(kù)設(shè)計(jì)
先來(lái)看一下,用戶(hù)、角色、菜單表對(duì)應(yīng)的ER圖,如下:
其中,用戶(hù)和角色是多對(duì)多的關(guān)系,角色與菜單也是多對(duì)多的關(guān)系,用戶(hù)通過(guò)角色來(lái)關(guān)聯(lián)到菜單,當(dāng)然也有的業(yè)務(wù)系統(tǒng)菜單權(quán)限模型,是可以直接通過(guò)用戶(hù)關(guān)聯(lián)到菜單,對(duì)菜單權(quán)限可以直接控制到用戶(hù)級(jí)別,不過(guò)這個(gè)都不是問(wèn)題,這個(gè)也可以進(jìn)行擴(kuò)展。
對(duì)于用戶(hù)、角色表比較簡(jiǎn)單,下面,我們重點(diǎn)來(lái)看看菜單表的設(shè)計(jì),如下:
可以看到,整個(gè)菜單表就是一個(gè)樹(shù)型結(jié)構(gòu),關(guān)鍵字段說(shuō)明:
- menu_code:菜單編碼,用于后端權(quán)限控制
- parent_id:菜單父節(jié)點(diǎn)ID,方便遞歸遍歷菜單
- node_type:節(jié)點(diǎn)類(lèi)型,可以是文件夾、頁(yè)面或者按鈕類(lèi)型
- link_url:頁(yè)面對(duì)應(yīng)的地址,如果是文件夾或者按鈕類(lèi)型,可以為空
- level:菜單樹(shù)的層次,以便于查詢(xún)指定層級(jí)的菜單
- path:樹(shù)id的路徑,主要用于存放從根節(jié)點(diǎn)到當(dāng)前樹(shù)的父節(jié)點(diǎn)的路徑,逗號(hào)分隔,想要找父節(jié)點(diǎn)會(huì)特別快
為了后面方便開(kāi)發(fā),我們先創(chuàng)建一個(gè)名為menu_auth_db的數(shù)據(jù)庫(kù),初始腳本如下:
- 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 '消息給過(guò)來(lái)的ID',
- mobile varchar(20) NOT NULL DEFAULT '' COMMENT '手機(jī)號(hào)',
- 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='用戶(hù)表';
- CREATE TABLE menu_auth_db.tb_user_role (
- id bigint(20) unsigned NOT NULL COMMENT '主鍵',
- user_id bigint(20) NOT NULL COMMENT '用戶(hù)ID',
- role_id bigint(20) NOT NULL COMMENT '角色I(xiàn)D',
- 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='用戶(hù)角色表';
- 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 '名稱(chēng)',
- 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 '角色I(xiàn)D',
- 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='角色菜單關(guān)系表';
- CREATE TABLE menu_auth_db.tb_menu (
- id bigint(20) NOT NULL COMMENT '主鍵',
- name varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '名稱(chēng)',
- menu_code varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '菜單編碼',
- parent_id bigint(20) DEFAULT NULL COMMENT '父節(jié)點(diǎn)',
- node_type tinyint(4) NOT NULL DEFAULT '1' COMMENT '節(jié)點(diǎn)類(lèi)型,1文件夾,2頁(yè)面,3按鈕',
- icon_url varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '圖標(biāo)地址',
- sort int(11) NOT NULL DEFAULT '1' COMMENT '排序號(hào)',
- link_url varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '頁(yè)面對(duì)應(yīng)的地址',
- level int(11) NOT NULL DEFAULT '0' COMMENT '層次',
- path varchar(2500) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '樹(shù)id的路徑 整個(gè)層次上的路徑id,逗號(hào)分隔,想要找父節(jié)點(diǎn)特別快',
- 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='菜單表';
三、后端開(kāi)發(fā)
菜單權(quán)限模塊的數(shù)據(jù)庫(kù)設(shè)計(jì),一般5張表就可以搞定,真正有點(diǎn)復(fù)雜的地方在于數(shù)據(jù)的寫(xiě)入和渲染,當(dāng)然如果老板突然讓你來(lái)開(kāi)發(fā)一套菜單權(quán)限系統(tǒng),我們也沒(méi)必要慌張,下面,我們一起來(lái)看看后端應(yīng)該如何開(kāi)發(fā)。
3.1、創(chuàng)建項(xiàng)目
為了方便快捷,小編我采用的是springboot+mybatisPlus組件來(lái)快速開(kāi)發(fā),直接利用mybatisPlus官方提供的快速生成代碼的demo,一鍵生成所需的dao、service、web層的代碼,結(jié)果如下:
3.2、編寫(xiě)菜單添加服務(wù)
- @Override
- public void addMenu(Menu menu) {
- //如果插入的當(dāng)前節(jié)點(diǎn)為根節(jié)點(diǎn),parentId指定為0
- if(menu.getParentId().longValue() == 0){
- menu.setLevel(1);//根節(jié)點(diǎn)層級(jí)為1
- menu.setPath(null);//根節(jié)點(diǎn)路徑為空
- }else{
- Menu parentMenu = baseMapper.selectById(menu.getParentId());
- if(parentMenu == null){
- throw new CommonException("未查詢(xún)到對(duì)應(yīng)的父節(jié)點(diǎn)");
- }
- 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);
- }
新增菜單比較簡(jiǎn)單,直接將數(shù)據(jù)插入即可,需要注意的地方是parent_id、level、path,這三個(gè)字段的寫(xiě)入,如果新建的是根節(jié)點(diǎn),默認(rèn)parent_id為0,方便后續(xù)遞歸遍歷。
3.3、編寫(xiě)菜單后端查詢(xún)服務(wù)
- 新建一個(gè)菜單視圖實(shí)體類(lèi)
- @Data
- @EqualsAndHashCode(callSuper = false)
- @Accessors(chain = true)
- public class MenuVo implements Serializable {
- private static final long serialVersionUID = -4559267810907997111L;
- /**
- * 主鍵
- */
- private Long id;
- /**
- * 名稱(chēng)
- */
- private String name;
- /**
- * 菜單編碼
- */
- private String menuCode;
- /**
- * 父節(jié)點(diǎn)
- */
- private Long parentId;
- /**
- * 節(jié)點(diǎn)類(lèi)型,1文件夾,2頁(yè)面,3按鈕
- */
- private Integer nodeType;
- /**
- * 圖標(biāo)地址
- */
- private String iconUrl;
- /**
- * 排序號(hào)
- */
- private Integer sort;
- /**
- * 頁(yè)面對(duì)應(yīng)的地址
- */
- private String linkUrl;
- /**
- * 層次
- */
- private Integer level;
- /**
- * 樹(shù)id的路徑 整個(gè)層次上的路徑id,逗號(hào)分隔,想要找父節(jié)點(diǎn)特別快
- */
- private String path;
- /**
- * 子菜單集合
- */
- List<MenuVo> childMenu;
- }
- 編寫(xiě)菜單查詢(xún)服務(wù),使用遞歸重新封裝菜單視圖
- @Override
- public List<MenuVo> queryMenuTree() {
- Wrapper queryObj = new QueryWrapper<>().orderByAsc("level","sort");
- List<Menu> allMenu = super.list(queryObj);
- // 0L:表示根節(jié)點(diǎn)的父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);
- //遞歸查詢(xún)子菜單,并封裝信息
- List<MenuVo> childList = transferMenuVo(allMenu, source.getId());
- if(!CollectionUtils.isEmpty(childList)){
- menuVo.setChildMenu(childList);
- }
- resultList.add(menuVo);
- }
- }
- }
- return resultList;
- }
編寫(xiě)一個(gè)菜單樹(shù)查詢(xún)接口,如下:
- @RestController
- @RequestMapping("/menu")
- public class MenuController {
- @Autowired
- private MenuService menuService;
- @PostMapping(value = "/queryMenuTree")
- public List<MenuVo> queryTreeMenu(){
- return menuService.queryMenuTree();
- }
- }
為了便于演示,我們先初始化7條數(shù)據(jù),如下圖:
其中最后三條是按鈕類(lèi)型,等下會(huì)用于后端權(quán)限控制,接口查詢(xún)結(jié)果如下:
這個(gè)服務(wù)是針對(duì)后端管理界面查詢(xún)的,會(huì)將所有的菜單全部查詢(xún)出來(lái)以便于進(jìn)行管理,展示結(jié)果類(lèi)似如下圖:
這個(gè)圖片截圖于小編正在開(kāi)發(fā)的一個(gè)項(xiàng)目,內(nèi)容可能不一致,但是數(shù)據(jù)結(jié)構(gòu)基本都是一致的。
3.4、編寫(xiě)用戶(hù)菜單權(quán)限查詢(xún)服務(wù)
在上面,我們介紹到了用戶(hù)通過(guò)角色來(lái)關(guān)聯(lián)菜單,因此,很容易想到,流程如下:
- 第一步:先通過(guò)用戶(hù)查詢(xún)到對(duì)應(yīng)的角色;
- 第二步:然后再通過(guò)角色查詢(xún)到對(duì)應(yīng)的菜單;
- 第三步:最后將菜單查詢(xún)出來(lái)之后進(jìn)行渲染;
實(shí)現(xiàn)過(guò)程相比菜單查詢(xún)服務(wù)多了前2個(gè)步驟,過(guò)程如下:
- @Override
- public List<MenuVo> queryMenus(Long userId) {
- //1、先查詢(xún)當(dāng)前用戶(hù)對(duì)應(yīng)的角色
- Wrapper queryUserRoleObj = new QueryWrapper<>().eq("user_id", userId);
- List<UserRole> userRoles = userRoleService.list(queryUserRoleObj);
- if(!CollectionUtils.isEmpty(userRoles)){
- //2、通過(guò)角色查詢(xún)菜單(默認(rèn)取第一個(gè)角色)
- 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());
- }
- //查詢(xún)對(duì)應(yīng)的菜單
- Wrapper queryMenuObj = new QueryWrapper<>().in("id", new ArrayList<>(menuIds));
- List<Menu> menus = super.list(queryMenuObj);
- if(!CollectionUtils.isEmpty(menus)){
- //將菜單下對(duì)應(yīng)的父節(jié)點(diǎn)也一并全部查詢(xún)出來(lái)
- 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、查詢(xún)對(duì)應(yīng)的所有菜單,并進(jìn)行封裝展示
- List<Menu> allMenus = super.list(new QueryWrapper<Menu>().in("id", new ArrayList<>(allMenuIds)));
- List<MenuVo> resultList = transferMenuVo(allMenus, 0L);
- return resultList;
- }
- }
- }
- return null;
- }
- 編寫(xiě)一個(gè)用戶(hù)菜單查詢(xún)接口,如下:
- @PostMapping(value = "/queryMenus")
- public List<MenuVo> queryMenus(Long userId){
- //查詢(xún)當(dāng)前用戶(hù)下的菜單權(quán)限
- return menuService.queryMenus(userId);
- }
有的同學(xué),可能覺(jué)得沒(méi)必要存放path這個(gè)字段,的確在某些場(chǎng)景下不需要。
為什么要存放這個(gè)字段呢?
小編在跟前端進(jìn)行對(duì)接的時(shí)候,發(fā)現(xiàn)這么一個(gè)問(wèn)題,有些前端的樹(shù)型組件,在勾選子集的時(shí)候,不會(huì)將對(duì)應(yīng)的父ID傳給后端,例如,我在勾選【列表查詢(xún)】的時(shí)候,前端無(wú)法將父節(jié)點(diǎn)【菜單管理】ID也傳給后端,所有后端實(shí)際存放的是一個(gè)尾節(jié)點(diǎn),需要一個(gè)字段path,來(lái)存放節(jié)點(diǎn)對(duì)應(yīng)的父節(jié)點(diǎn)路徑。
其實(shí),前端也可以傳,只不過(guò)需要修改組件的屬性,前端修改完成之后,樹(shù)型組件就無(wú)法全選,不滿(mǎn)足業(yè)務(wù)需求。
所以,有些時(shí)候得根據(jù)實(shí)際得情況來(lái)進(jìn)行取舍。
3.5、編寫(xiě)后端權(quán)限控制
后端進(jìn)行權(quán)限控制目標(biāo),主要是為了防止無(wú)權(quán)限的用戶(hù),進(jìn)行接口請(qǐng)求查詢(xún)。
其中菜單編碼menuCode就是一個(gè)前、后端聯(lián)系的橋梁,細(xì)心的你會(huì)發(fā)現(xiàn),所有后端的接口,與前端對(duì)應(yīng)的都是按鈕操作,所以我們可以以按鈕為基準(zhǔn),實(shí)現(xiàn)前后端雙向控制。
以【角色管理-查詢(xún)】這個(gè)為例,前端可以通過(guò)菜單編碼實(shí)現(xiàn)是否展示這個(gè)查詢(xún)按鈕,后端可以通過(guò)菜單編碼來(lái)判斷,當(dāng)前用戶(hù)是否具備請(qǐng)求接口的權(quán)限。
以后端為例,我們只需編寫(xiě)一個(gè)權(quán)限注解和代理攔截器即可!
- 編寫(xiě)一個(gè)權(quán)限注解
- @Target({ElementType.TYPE, ElementType.METHOD})
- @Retention(RetentionPolicy.RUNTIME)
- public @interface CheckPermissions {
- String value() default "";
- }
- 編寫(xiě)一個(gè)代理攔截器,攔截有@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];
- //用戶(hù)請(qǐng)求參數(shù)實(shí)體類(lèi)中的用戶(hù)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注解的參數(shù)
- 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)) {
- //通過(guò)用戶(hù)ID、菜單編碼查詢(xún)是否有關(guān)聯(lián)
- int count = menuMapper.selectAuthByUserIdAndMenuCode(userId, menuCode);
- if(count == 0){
- throw new CommonException("接口無(wú)訪問(wèn)權(quán)限");
- }
- }
- }
- }
- }
- }
- 我們以【角色管理-查詢(xún)】為例,先新建一個(gè)請(qǐng)求實(shí)體類(lèi)RoleDto,添加用戶(hù)ID屬性
- @Data
- @EqualsAndHashCode(callSuper = false)
- @Accessors(chain = true)
- public class RoleDto extends Role {
- //添加用戶(hù)ID
- private Long userId;
- }
- 在需要的接口上,添加@CheckPermissions注解,增加權(quán)限控制
- @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);
- }
- }
依次類(lèi)推,當(dāng)我們想對(duì)某個(gè)接口進(jìn)行權(quán)限控制的時(shí)候,只需要添加一個(gè)注解@CheckPermissions,并填寫(xiě)對(duì)應(yīng)的菜單編碼即可!
四、用戶(hù)權(quán)限
測(cè)試我們先初始化一個(gè)用戶(hù)【張三】,然后給他分配一個(gè)角色【訪客人員】,同時(shí)給這個(gè)角色分配一下2個(gè)菜單權(quán)限【系統(tǒng)配置】、【用戶(hù)管理】,等會(huì)用于權(quán)限測(cè)試。
初始內(nèi)容如下:
數(shù)據(jù)初始化完成之后,我們來(lái)啟動(dòng)項(xiàng)目,傳入用戶(hù)【張三】的ID,查詢(xún)用戶(hù)具備的菜單權(quán)限,結(jié)果如下:

查詢(xún)結(jié)果,用戶(hù)【張三】有兩個(gè)菜單權(quán)限!
接著,我們來(lái)驗(yàn)證一下,用戶(hù)【張三】是否有角色查詢(xún)權(quán)限,請(qǐng)求角色查詢(xún)接口如下:
因?yàn)闆](méi)有配置角色查詢(xún)接口,所以無(wú)權(quán)訪問(wèn)!
五、總結(jié)
整片內(nèi)容,只介紹了后端關(guān)鍵的服務(wù)實(shí)現(xiàn)過(guò)程,可能也有遺漏的地方,歡迎網(wǎng)友點(diǎn)評(píng)、吐槽!