权限管理系统笔记

权限管理系统笔记

本文档结合 elder 养老管理后台的完整代码,讲解「用户 → 角色 → 权限(菜单/按钮)」这一套基于 RBAC(Role-Based Access Control,基于角色的访问控制)模型的权限管理系统实现原理,覆盖从数据库表设计、后端权限查询、JWT 登录鉴权,到前端动态菜单渲染与按钮级权限控制的完整链路。


一、系统概述

本项目采用经典的 RBAC 权限模型,核心思想是:

给用户分配角色,给角色分配权限。 用户不直接与权限挂钩,而是通过「角色」这个中间层间接获得权限。

这样一来好处很明显:当一群用户的权限需要整体调整时,只需改动角色所绑定的权限,而不是逐个用户去配置。比如公司来了一批新护理主管,管理员只要把这些主管的账号都挂到「护理主管」这个角色下即可,角色配什么权限,他们就自动拥有什么权限。

权限被细分为三类(type 字段):

type 名称 含义 例子
0 目录 顶级分组,侧边栏里的一级菜单 「系统功能」「护理管理」
1 菜单 一个具体的功能页面 「用户管理」「角色管理」
2 按钮 页面里的一个操作按钮 新增用户、删除角色(对应 user:add、role:deleteById)

系统最终实现两级权限控制:

  1. 菜单级:根据当前用户拥有的「目录 + 菜单」权限,动态渲染左侧导航栏——没有权限的页面,入口根本不会出现在菜单里。
  2. 按钮级:根据当前用户拥有的「按钮」权限值(如 user:add),控制页面里增删改查等按钮的可用/禁用。

二、数据库表设计(5 张核心表)

整个权限系统围绕 5 张表展开:用户、角色、权限,以及连接它们的 2 张关联表。

┌──────────┐   N          N ┌──────────┐
│  user    │──────────────▶│  role    │
│ 用户表   │  user_role     │ 角色表   │
└──────────┘                └──────────┘
┌──────────┐   N          N ┌──────────────┐
│  role    │──────────────▶│  permission  │
│ 角色表   │ role_permission │ 权限表       │
└──────────┘                └──────────────┘

2.1 权限表 permission

对应实体 Permission.java(src/main/java/com/situ/elder/pojo/entity/Permission.java):

@Data
@EqualsAndHashCode(callSuper = false)
public class Permission implements Serializable {
    /** 编号 */
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    /** 所属上级:顶级为 0 */
    @TableField("parent_id")
    private Long parentId;

    /** 名称 */
    private String name;

    /** 类型(0:目录,1:菜单,2:按钮) —— 这是权限分级的核心字段 */
    private Integer type;

    /** 路由地址:仅菜单类型使用,如 /role */
    private String path;

    /** 权限值:仅按钮类型使用,如 user:add */
    @TableField("permission_value")
    private String permissionValue;

    /** 图标:菜单/目录显示的图标名,如 User、Crop */
    private String icon;

    /** 排序 */
    private Integer sort;

    /** 状态(1:正常,0:禁止) */
    private Integer status;

    /** 逻辑删除 0未删除,1已删除 */
    @TableLogic
    private Integer deleted;

    /** 创建时间(自动填充) */
    @TableField(value = "create_time", fill = FieldFill.INSERT)
    private Date createTime;

    /** 更新时间(自动填充) */
    @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
    private Date updateTime;
}

说明:每一条记录用 parent_id 指向上一级,parent_id = 0 表示顶级。这样 permission 表本身就是一棵树:目录(type=0)→ 菜单(type=1)→ 按钮(type=2)。

2.2 角色表 role

对应实体 Role.java:包含 id、name(角色名称)、code(角色编码,表单里校验只能是大写字母或下划线)、description(描述),以及逻辑删除字段和时间自动填充字段。Role 与 Permission 两个实体结构高度相似,就不再重复粘贴完整代码。

2.3 用户表 user

对应实体 User.java,包含用户的账号、密码、手机号、邮箱、头像、状态(status,0 禁用/1 正常)、逻辑删除等字段。用户是 user——角色(user_role)关联的另一端。

2.4 角色-权限关联表 role_permission

对应实体 RolePermission.java:一端关联 role_id,另一端关联 permission_id,记录「某个角色拥有哪些权限」:

@Data
@EqualsAndHashCode(callSuper = false)
public class RolePermission implements Serializable {
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    @TableField("role_id")
    private Long roleId;          // 角色 id

    @TableField("permission_id")
    private Long permissionId;    // 权限 id

    /** 逻辑删除 */
    @TableLogic
    private Integer deleted;

    @TableField(value = "create_time", fill = FieldFill.INSERT)
    private Date createTime;

    @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
    private Date updateTime;
}

2.5 用户-角色关联表 user_role

对应实体 UserRole.java:一端 user_id,另一端 role_id,记录「某个用户拥有哪些角色」:

@Data
@EqualsAndHashCode(callSuper = false)
public class UserRole implements Serializable {
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    @TableField("role_id")
    private Long roleId;   // 角色 id

    @TableField("user_id")
    private Long userId;   // 用户 id

    @TableField(value = "create_time", fill = FieldFill.INSERT)
    private Date createTime;

    @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
    private Date updateTime;
}

三、整体数据流(先看全貌)

【管理员后台配置阶段】
  权限管理页 ──▶ 维护 permission 权限树(目录/菜单/按钮)
  角色管理页 ──▶ 维护角色 + 给角色勾选权限(写入 role_permission)
  用户管理页 ──▶ 维护用户 + 给用户勾选角色(写入 user_role)

【普通用户登录使用阶段】
  ① 前端提交账号密码  POST /users/login
  ② 后端校验通过 → JwtUtil 生成 token 返回前端(前端存到 pinia 并持久化)
  ③ 前端每次请求在 Header 带 Authorization: token
  ④ Index.vue 挂载时  GET /users/userInfo
       后端按 token 解析出用户 id
       → 三次表关联查出该用户所有权限
       → 拆成 routerList(目录+菜单,拼成树) + btnList(按钮权限值数组)
       → 前端用它动态渲染左侧菜单,并控制按钮可用性
  ⑤ LoginInterceptor 拦截所有接口,token 非法/过期 → 401    (后端接口层校验登录态)

关键设计:后端在接口权限层面主要保证「用户是否登录」(JWT);而「这个登录用户能不能访问某个菜单、能不能点某个按钮」相对地由前端配合菜单/按钮渲染 + 路由守卫路径级鉴权(见 5.2)共同决定。这是本项目偏重前端侧实现权限的一种典型做法(更严格的方案还会在后端每个接口上加角色判定,属于可扩展点,见第六节)。


四、后端实现详解

4.1 登录接口:生成 token(入口)

文件:UserController.java 的 login 方法

@PostMapping("/login")
public Result<String> login(@RequestBody User user) {
    User dbUser = iUserService.getOne(new QueryWrapper<User>().eq("name", user.getName()));

    if (dbUser == null) {
        return Result.error("用户名不存在");
    }
    if (!dbUser.getPassword().equals(user.getPassword())) {
        return Result.error("密码错误");
    }
    if (dbUser.getDeleted() == 1) {
        return Result.error("用户已经注销");
    }
    if (dbUser.getStatus() == 0) {
        return Result.error("用户被禁用");
    }

    //登录成功生成token
    Map<String, Object> map = new HashMap<>();
    map.put("id", dbUser.getId());
    map.put("name", dbUser.getName());
    String token = JwtUtil.createToken(map);
    return Result.ok("登录成功", token);
}

要点:

  • 按 name 查库,依次校验 密码、是否注销(逻辑删除)、是否禁用(status)。
  • 登录成功把用户 id、name 放进 map,交给 JwtUtil.createToken 生成 token。
  • 密码用的是明文比对(学习项目简化,生产应使用 BCrypt 加密),前端登录时直接明文提交。

4.2 JWT 工具类 JwtUtil

文件:utils/JwtUtil.java

public class JwtUtil {
    private static final String KEY = "dxk";  // 签名秘钥

    // 把业务数据放进 token 的 claims(有效载荷)里,用 HMAC256 签名,有效期 1 小时
    public static String createToken(Map<String, Object> map) {
        return JWT.create()
                .withClaim("claims", map)
                .withExpiresAt(new Date(System.currentTimeMillis() + 1000 * 60 * 60))
                .sign(Algorithm.HMAC256(KEY));
    }

    // 验证 token 签名与有效期,并把 claims 还原为 Map;token 非法或过期这里会抛异常
    public static Map<String, Object> parseToken(String token) {
        return JWT.require(Algorithm.HMAC256(KEY))
                .build()
                .verify(token)
                .getClaim("claims")
                .asMap();
    }
}

注意:parseToken 得到的 claims 是 Map<String,Object>,其中 id 取出来时类型是 Integer(这也和后面的 Integer id = (Integer) map.get("id") 对应上了)。

4.3 登录态拦截器 LoginInterceptor

文件:interceptor/LoginInterceptor.java

public class LoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String token = request.getHeader("Authorization");
        if (ObjectUtils.isEmpty(token)) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401
            return false;
        }
        try {
            // token 非法、或者过期,解析都会抛出异常
            JwtUtil.parseToken(token);
            return true; // token 合法,放行
        } catch (Exception e) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return false;
        }
    }
}

注册于 config/WebConfig.java(同时这里还注册了一个字符串→Date 的类型转换器,解决前端 ISO 时间字符串的转换):

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 拦截所有请求,登录与忘记密码接口例外(否则没 token 没法登录)
        registry.addInterceptor(new LoginInterceptor())
                .addPathPatterns("/**")
                .excludePathPatterns("/users/login", "/users/forgetPassword");
    }
    // ... addFormatters 处理 yyyy-MM-dd 等日期字符串转 Date
}

前端 request.js 如何配合:请求拦截器把 token 放到 Authorization 头;响应拦截器里如果后端返回 401,就清除 token 并跳回登录页:

// 请求拦截器:带上 token
request.interceptors.request.use((config) => {
    const tokenStore = useTokenStore();
    if (tokenStore.token) {
        config.headers.Authorization = tokenStore.token;
    }
    return config;
});

// 响应拦截器:401 说明未登录/过期
if (error.response.status === 401) {
    ElMessage.error('请先登录');
    tokenStore.removeToken();
    router.push('/login');
}

4.4 ★ 核心:按用户查权限 SQL(三次表关联)

有了 userId,怎么得到这个用户的所有权限?——通过 user_role → role_permission → permission 三张表 INNER JOIN。

文件:mapper/PermissionMapper.xml

<select id="selectPermissionByUserId" resultType="com.situ.elder.pojo.entity.Permission">
    SELECT DISTINCT p.id,
                    p.parent_id,
                    p.name,
                    p.type,
                    p.path,
                    p.permission_value,
                    p.icon,
                    p.sort
    FROM user_role AS ar
             INNER JOIN role_permission AS rp ON ar.role_id = rp.role_id
             INNER JOIN permission AS p        ON rp.permission_id = p.id
    WHERE ar.user_id = #{id}
      AND rp.deleted = 0
      AND p.deleted = 0
    ORDER BY p.sort
</select>

对应 Mapper 接口 PermissionMapper.java:

public interface PermissionMapper extends BaseMapper<Permission> {
    List<Permission> selectPermissionByUserId(Long id);
}

理解这条 SQL:

  • user_role 按 ar.user_id = #{id} 定位用户 → 拿到该用户的所有角色 id。
  • role_permission 用 ar.role_id = rp.role_id 关联 → 拿到这些角色绑定过的所有权限 id。
  • permission 用 rp.permission_id = p.id 关联 → 还原出权限的完整字段。
  • DISTINCT 去重:考虑用户可能同时有多个角色、或一个权限被多个角色重复绑定,最终每个权限只出现一次。
  • deleted=0 过滤逻辑删除的记录。
  • 按 sort 排序,保证菜单顺序一致。

4.5 ★ 核心:把权限拆成「路由菜单」和「按钮列表」

文件:PermissionServiceImpl.java

@Service
public class PermissionServiceImpl extends ServiceImpl<PermissionMapper, Permission> implements IPermissionService {

    @Override
    public Map<String, Object> selectUserPermissionInfoById(Long id) {
        // 1. 根据用户 id 查出他所有的权限
        List<Permission> permissionList = permissionMapper.selectPermissionByUserId(id);

        // 2. 把 List<Permission> 拆分成两类:
        List<String> btnList = new ArrayList<>();        // 按钮权限值集合
        List<PermissionVo> permissionVoList = new ArrayList<>(); // 目录+菜单集合

        permissionList.forEach(permission -> {
            if (permission.getType() == 2) {
                // 按钮类型:把"权限值"(如 user:add) 塞进 btnList
                btnList.add(permission.getPermissionValue());
            } else {
                // 目录(0)或菜单(1):复制成 PermissionVo,用于后面拼树
                PermissionVo permissionVo = new PermissionVo();
                BeanUtils.copyProperties(permission, permissionVo);
                permissionVoList.add(permissionVo);
            }
        });

        return Map.of("routerList", buildTree(permissionVoList), "btnList", btnList);
    }
    // ... 其余代码见下
}

它返回一个 Map,里面有两个 key,正好对应前端 Index.vue 里需要的两份数据:

  • routerList:目录+菜单组成的树 → 用于渲染左侧导航。
  • btnList:权限值数组,如 ["user:add","user:deleteById",...] → 用于按钮级控制。

4.6 权限树构建 buildTree(递归)

文件:PermissionServiceImpl.java

private List<PermissionVo> buildTree(List<PermissionVo> permissionVoList) {
    // 先找所有顶级(parentId == 0)
    return permissionVoList.stream()
            .filter(permissionVo -> permissionVo.getParentId() == 0)
            .map(permissionVo -> {
                permissionVo.setChildren(buildChildrenTree(permissionVo, permissionVoList));
                return permissionVo;
            }).toList();
}

private List<PermissionVo> buildChildrenTree(PermissionVo parentPermissionVo, List<PermissionVo> permissionVoList) {
    // 递归找当前父节点的所有子节点
    return permissionVoList.stream()
            .filter(permissionVo -> permissionVo.getParentId().equals(parentPermissionVo.getId()))
            .map(permissionVo -> {
                permissionVo.setChildren(buildChildrenTree(permissionVo, permissionVoList));
                return permissionVo;
            }).toList();
}
  • buildTree 找到所有 parentId == 0 的顶级节点。
  • buildChildrenTree 递归地为每个节点挂上它的所有子节点,直到没有子节点为止。
  • 用 PermissionVo extends Permission 并在其上扩展了一个 children 字段存放子树:
@Data
public class PermissionVo extends Permission {
    private List<PermissionVo> children;   // 子权限
}

这样”扁平的权限列表”就被整理成了一条嵌套的 JSON 树,前端可以直接交给 el-tree / 菜单组件渲染。

同样,管理员配置权限时,selectPermissionTree() 接口也走同一个 selectPermissionTree() + buildTree(见 4.7),只是它查的是所有权限(orderByAsc(Permission::getSort)),而不限定某个用户。

4.7 完整的 userInfo 接口(返回菜单 + 按钮)

文件:UserController.java

@GetMapping("userInfo")
public Result userInfo(@RequestHeader(name = "Authorization") String token) {
    // 从 token 解析出用户 id
    Map<String, Object> map = JwtUtil.parseToken(token);
    Integer id = (Integer) map.get("id");
    // 查用户信息,密码置空(不返回给前端)
    User user = iUserService.getById(id);
    user.setPassword(null);

    // 查该用户的权限,拆成 routerList(菜单树) + btnList(按钮数组)
    Map<String, Object> PermissionMap = iPermissionService.selectUserPermissionInfoById(user.getId());

    Map<String, Object> resultMap = new HashMap<>();
    resultMap.put("user", user);
    resultMap.put("routerList", PermissionMap.get("routerList"));
    resultMap.put("btnList", PermissionMap.get("btnList"));
    return Result.ok(resultMap);
}

前端 Index.vue 拿到这个结果后,routerList → 动态菜单,btnList → 按钮控制(详见第五节)。

4.8 权限管理页后端(Permission 表 CRUD)

文件:PermissionController.java。这一层就是非常标准的 MyBatis-Plus 增删改查,注意几个接口约定:

接口 方法 作用
GET /permissions/selectPermissionTree selectPermissionTree() 返回完整权限树(用于权限管理页 / 角色分配页)
GET /permissions selectAll(permissionQuery) 条件分页查询
GET /permissions/{id} selectById 按 id 查
POST /permissions insert 新增权限
PUT /permissions/{id} updateById 修改权限
DELETE /permissions deleteBatch(@RequestBody Long[] ids) 批量删除
DELETE /permissions/{id} deleteById 单个删除

条件分页 list(PermissionQuery) 实现(PermissionServiceImpl):

@Override
public IPage<Permission> list(PermissionQuery permissionQuery) {
    IPage<Permission> page = new Page<>(permissionQuery.getPage(), permissionQuery.getLimit());
    LambdaQueryWrapper<Permission> wrapper = new LambdaQueryWrapper<>();
    wrapper.like(!ObjectUtils.isEmpty(permissionQuery.getName()), Permission::getName, permissionQuery.getName())
            .eq(!ObjectUtils.isEmpty(permissionQuery.getType()), Permission::getType, permissionQuery.getType())
            .between(!ObjectUtils.isEmpty(permissionQuery.getBegintime()) && !ObjectUtils.isEmpty(permissionQuery.getEndtime()),
                    Permission::getCreateTime, permissionQuery.getBegintime(), permissionQuery.getEndtime())
            .orderByDesc(Permission::getCreateTime);
    return permissionMapper.selectPage(page, wrapper);
}

PermissionQuery 继承分页基类,提供 name、type、begintime、endtime 等搜索条件。

4.9 ★ 角色管理:查看/保存角色的权限(role_permission)

文件:RoleServiceImpl.java

@Override
public Map<String, Object> selectAssignedPermission(Long roleId) {
    // 1. 所有权限的树形结构(给前端 el-tree 用)
    List<PermissionVo> permissionVOList = permissionService.selectPermissionTree();
    // 2. 查这个角色已经在 role_permission 里绑定了哪些权限 id
    LambdaQueryWrapper<RolePermission> wrapper = new LambdaQueryWrapper<>();
    wrapper.eq(RolePermission::getRoleId, roleId);
    List<RolePermission> rolePermissionList = rolePermissionMapper.selectList(wrapper);
    List<Long> assignedPermissionIdList = rolePermissionList.stream()
            .map(RolePermission::getPermissionId).toList();

    Map<String, Object> map = new HashMap<>();
    map.put("permissionVOList", permissionVOList);      // 全部权限树
    map.put("assignedPermissionIdList", assignedPermissionIdList); // 已勾选的权限 id
    return map;
}

@Override
public void assignPermission(Long roleId, Long[] permissionIds) {
    // 1. 先删掉该角色原有的所有权限关联
    rolePermissionMapper.delete(new LambdaQueryWrapper<RolePermission>().eq(RolePermission::getRoleId, roleId));
    // 2. 再逐个插入新的关联
    for (Long permissionId : permissionIds) {
        RolePermission rolePermission = new RolePermission();
        rolePermission.setRoleId(roleId);
        rolePermission.setPermissionId(permissionId);
        rolePermissionMapper.insert(rolePermission);
    }
}

这里采用「先删后插」的策略:因为前端给的是这次勾选的完整集合,直接整表删除该角色的旧关联、再写入新集合,逻辑最简单,也天然支持取消勾选。代价是频繁整删整插,但对权限这种低频变更的场景完全够用,而且避免了复杂的两两比较。

Controller 入口 RoleController.java:

@GetMapping("/selectAssignedPermission/{roleId}")
public Result<Map<String, Object>> selectAssignedPermission(@PathVariable("roleId") Long roleId) {
    return Result.ok(iRoleService.selectAssignedPermission(roleId));
}

@PostMapping("/assignPermission")
public Result assignPermission(Long roleId, Long[] permissionIds) {
    iRoleService.assignPermission(roleId, permissionIds);
    return Result.ok("分配完成");
}

4.10 用户分配角色(user_role)

与 4.9 完全同构,只是对象从「角色-权限」换成了「用户-角色」。文件:UserServiceImpl.java

@Override
public UserRoleVo selectAssinedRole(Long userId) {
    // 1. 所有角色
    List<Role> roleList = roleMapper.selectList(null);
    // 2. 该用户已经分配的角色 id
    LambdaQueryWrapper<UserRole> wrapper = new LambdaQueryWrapper<>();
    wrapper.eq(UserRole::getUserId, userId);
    List<Long> assignedRoleIdList = userRoleMapper.selectList(wrapper).stream()
            .map(UserRole::getRoleId).collect(Collectors.toList());

    UserRoleVo vo = new UserRoleVo();
    vo.setAssignedRoleIdList(assignedRoleIdList);
    vo.setRoleList(roleList);
    return vo;
}

@Override
public void assignRole(Long userId, Long[] roleIds) {
    // 先删该用户旧角色,再写入新的
    userRoleMapper.delete(new LambdaQueryWrapper<UserRole>().eq(UserRole::getUserId, userId));
    for (Long roleId : roleIds) {
        UserRole userRole = new UserRole();
        userRole.setRoleId(roleId);
        userRole.setUserId(userId);
        userRoleMapper.insert(userRole);
    }
}

UserRoleVo.java:

@Data
public class UserRoleVo {
    private List<Role> roleList;          // 所有角色
    private List<Long> assignedRoleIdList; // 该用户已选角色 id
}

Controller 入口 UserController.java:

@GetMapping("/selectAssingnedRole/{userId}")
public Result<UserRoleVo> selectAssingnedRole(@PathVariable("userId") Long userId) {
    return Result.ok(iUserService.selectAssinedRole(userId));
}

@PostMapping("/assignRole")
public Result assignRole(Long userId, Long[] roleIds) {
    iUserService.assignRole(userId, roleIds);
    return Result.ok("分配成功");
}

五、前端实现详解

5.1 登录保存 token

文件:views/Login.vue → 调用 userApi.login,成功后把 token 存进 pinia(持久化到 localStorage),跳转首页:

const login = () => {
    userApi.login(user.value).then(result => {
        if (result.code == 1) {
            ElMessage.success(result.msg)
            tokenStore.setToken(result.data)   // 保存 token
            router.push('/')
        } else {
            ElMessage.error(result.msg)
        }
    })
}

token 的 pinia store(store/token.js)开启了 persist: {enabled: true},所以刷新页面 token 不丢,登录态得以保持。

5.2 路由守卫(登录校验 + 路径级鉴权)

文件:router/index.js

import {useTokenStore} from '@/store/token.js'
import {useUserInfoStore} from '@/store/userInfo.js'
import userApi from '@/api/user.js'
import {ElMessage} from "element-plus"

let whiteList = ['/login', '/forget'];  // 白名单:未登录也能访问
router.beforeEach(async (to) => {
    const tokenStore = useTokenStore();
    const userInfoStore = useUserInfoStore();
    const token = tokenStore.token;

    // 已登录访问白名单 → 跳首页
    if (whiteList.includes(to.path) && token) return '/';
    // 未登录访问非白名单 → 跳登录页
    if (!whiteList.includes(to.path) && !token) return '/login';
    // 白名单页直接放行,不参与鉴权(防止访问 /login 时被后面的鉴权拦截,形成死循环)
    if (whiteList.includes(to.path)) return true;

    // ——已登录进入受保护区域:确保权限已加载——
    // 直接刷新 /user 时 Pinia 里可能还是空的,先从后端拉一次菜单,才能判断有无权限
    if (!userInfoStore.routesLoaded) {
        try {
            const result = await userApi.userInfo();
            if (result.code === 1) {
                userInfoStore.setUser(result.data.user);
                userInfoStore.setBtnList(result.data.btnList);
                userInfoStore.setRouterList(result.data.routerList);
            }
        } catch (e) {
            // 拉取失败保持空权限,未授权路径一律被拦截
        }
    }
    // ★ 路径级鉴权:目标路由不在“允许路径集合”内 → 拦截回首页
    if (!userInfoStore.allowedPaths.includes(to.path)) {
        ElMessage.warning('无权访问该页面');
        return '/chart';   // 首页 /chart 在 allowedPaths 里固定放行,不会二次拦截
    }
});

说明:

  • 路由仍然是静态注册的(/user、/role、/permission…都在 router 里写死、Component 已 import),并没有改成”按 routerList 动态生成路由、无权限即 404″。
  • 但守卫里多了一层路径级鉴权——用 userInfoStore.allowedPaths(见下)判断目标路径是否在用户权限内,不在就拦截并跳回首页。这就解决了”登录后直接敲 http://.../user 绕过权限”的问题:即使知道 URL,没有权限也会被守卫拦回首页。
  • allowedPaths 是 userInfo store 里的 getter,把权限树 routerList 扁平化成的”允许访问的路径数组”,并固定放行一些不属于权限菜单的页面(如首页 /chart、个人中心 /userCenter、/resetPassword),保证任何登录用户都能进这些页面:
// store/userInfo.js
getters: {
    allowedPaths(state) {
        const acc = ['/chart', '/userCenter', '/resetPassword']; // 非权限菜单,固定放行
        const walk = (list) => {
            (list || []).forEach(item => {
                if (item.path) acc.push(item.path);
                if (item.children?.length) walk(item.children);
            });
        };
        walk(state.routerList);
        return acc;
    }
}
  • 该 store 开启了 persist: { enabled: true },所以 routerList/routesLoaded 会持久化到 localStorage:有权限的用户刷新 /user 能直接进入(routesLoaded 为真、跳过重新拉取)。退出登录时用 resetAll() 清空权限,避免换账号后沿用上一账号的权限(越权)。

5.3 ★ 核心:Index.vue 挂载时拉取用户权限 → 动态菜单 + 按钮数组

文件:views/Index.vue(布局页,/ 的根组件)

const getUserInfo = () => {
    userApi.userInfo().then(result => {
        if (result.code == 1) {
            // 1. 用户基本信息 → pinia
            UserInfoStore.setUser(result.data.user);
            // 2. 目录+菜单列表 → 动态菜单数据(首页固定拼在最前,不依赖角色权限)
            menuData.value = [
                {name: '首页', icon: 'TrendCharts', path: '/chart'},
                ...result.data.routerList,
            ];
            // 3. 按钮权限数组 → pinia
            UserInfoStore.setBtnList(result.data.btnList);
            // 4. 菜单树也存入 store,供路由守卫做“路径级鉴权”(见 5.2)
            UserInfoStore.setRouterList(result.data.routerList);
        }
    })
}
getUserInfo()   // 挂载后立即执行
  • 动态菜单:menuData.value 被后端返回的 routerList(权限树)覆盖,<el-menu> 用 v-for 渲染它:
<el-menu ... router :collapse="isCollapse" :default-active="route.path">
  <template v-for="menu in menuData" :key="menu.name">
    <!-- 有子节点 → 子菜单(目录) -->
    <el-sub-menu v-if="menu.children?.length > 0" :index="menu.name">
      <template #title>
        <component class="icons" :is="menu.icon"></component>
        <span>{{ menu.name }}</span>
      </template>
      <el-menu-item v-for="child in menu.children" :index="child.path">
        <el-icon><component :is="child.icon"></component></el-icon>
        <span>{{ child.name }}</span>
      </el-menu-item>
    </el-sub-menu>
    <!-- 无子节点 → 普通菜单项(菜单) -->
    <el-menu-item v-else :index="menu.path">
      <el-icon><component :is="menu.icon"></component></el-icon>
      <span>{{ menu.name }}</span>
    </el-menu-item>
  </template>
</el-menu>

关键点:菜单里 <component :is="menu.icon"> 中的 icon 是后端 permission 表存的图标字符串(如 User、Crop)。之所以字符串能直接解析成组件,是因为 main.js 里把所有 Element Plus 图标全局注册了:

import * as ElementPlusIconsVue from '@element-plus/icons-vue'
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
    app.component(key, component);   // 全局注册,可用字符串 :is 使用
}
  • 按钮数组:btnList 存入 userInfo store,供 hasBtnPermission 使用。

5.4 ★ 核心:按钮级权限控制 btnPermission.js

文件:utils/btnPermission.js

export default function hasBtnPermission(permission) {
    const userInfoStore = useUserInfoStore();
    const btnList = userInfoStore.btnList;   // 例如 ['user:add','user:deleteById',...]
    return btnList.indexOf(permission) !== -1; // 用户拥有该权限值 → true
}

在页面里配合 :disabled 使用。例如 Permission.vue:

<el-button type="success" @click="showAddDialog()"
           :disabled="!hasBtnPermission('permission:add')">添加顶级菜单</el-button>
  • 当用户拥有 permission:add 这个权限值时,按钮可点;否则 disabled 置灰不可点。
  • 每个”危险/新增”按钮都绑一个权限值,就实现了按钮级权限。各页面用到的权限值举例如下:
权限值 含义 所在页面
user:add / user:deleteAll / user:update / user:deleteById / user:assignRole / user:exportExcel / user:importExcel 用户管理的各类按钮 User.vue
role:add / role:deleteAll / role:update / role:deleteById / role:assignPermission 角色管理的各类按钮 Role.vue
permission:add / permission:update / permission:deleteById 权限管理的各类按钮 Permission.vue

userInfo 的 pinia store(store/userInfo.js)同样开启持久化:

export const useUserInfoStore = defineStore('userInfo', {
    state() {
        return { user: {}, btnList: [] }
    },
    actions: {
        setUser(user) { this.user = user },
        setBtnList(btnList) { this.btnList = btnList },
        removeBtnList() { this.btnList = [] }
    },
    persist: { enabled: true }
})

由于 btnList 被持久化,刷新页面后 hasBtnPermission 依然有效,按钮权限不会因刷新而丢失。

5.5 权限管理页 Permission.vue(维护权限树)

  • 数据加载:permissionApi.selectPermissionTree() 拿整棵权限树,用 el-table 的 row-key="id" 配合树形子行展示。
  • 权限类型限制:打开”添加”弹窗时,根据父节点类型限定可选的 type —— 在一个目录(type=0)下只能加菜单(type=1);在一个菜单(type=1)下只能加按钮(type=2):
const showAddDialog = (row) => {
    dialogFormVisible.value = true;
    permission.value = {status: 1};
    title.value = "添加下级菜单";
    if (row) {
        permission.value.parentId = row.id;
        permission.value.parentName = row.name;
        if (row.type == 0) {
            // 目录下 → 只能加菜单
            permission.value.type = 1;
            type0Disabled.value = true;  // 目录被禁选
            type2Disabled.value = true;  // 按钮被禁选
        } else if (row.type == 1) {
            // 菜单下 → 只能加按钮
            permission.value.type = 2;
            type0Disabled.value = true;
            type1Disabled.value = true;
        }
    } else {
        // 顶级 → 只能加目录
        title.value = "添加顶级菜单";
        permission.value.type = 0;
        permission.value.parentId = 0;
        type0Disabled.value = false;
        type1Disabled.value = false;
        type2Disabled.value = true;
    }
}
  • 表单条件显示:图标只有目录/菜单才显示;「按钮权限值」只有 type=2 才显示并必填;「路由地址」只有 type=1 才显示并必填:
const validatePath = (rule, value, callback) => {
    if (permission.value.type === 1 && !value) return callback(new Error('请输入路由地址'));
    callback();
};
const validatePermissionValue = (rule, value, callback) => {
    if (permission.value.type === 2 && !value) return callback(new Error('请输入按钮权限值'));
    callback();
};
  • 启用/禁用:表格里每行一个 el-switch,切换时调 update 接口只更新 status。

5.6 ★ 角色管理页 Role.vue(角色 CRUD + 权限分配)

除了角色的增删改查(和用户/权限页套路一致),重点是分配权限弹窗,用 el-tree 勾选权限:

① 打开弹窗,回显已分配的权限:

const showAssignedPermissionDialog = (row) => {
    role.value = row;
    dialogPermissionVisible.value = true;
    treeData.value = [];
    // 一次性拿到:全部权限树 + 该角色已分配的权限 id
    roleApi.selectAssignedPermission(row.id).then((result) => {
        treeData.value = result.data.permissionVOList;               // el-tree 数据
        let checkedLeafIdList = [];
        // 递归找出"该角色拥有且为叶子节点"的权限 id
        getCheckedLeafIdList(result.data.permissionVOList,
            result.data.assignedPermissionIdList, checkedLeafIdList);
        treeRef.value.setCheckedKeys(checkedLeafIdList);             // 回显勾选
    });
};

const getCheckedLeafIdList = (permissionVOList, assignedPermissionIdList, checkedLeafIdList) => {
    permissionVOList.forEach(permissionVO => {
        assignedPermissionIdList.forEach(id => {
            if (permissionVO.id == id && permissionVO.children.length == 0) {
                checkedLeafIdList.push(id);   // 是叶子节点 → 加入勾选
            } else if (permissionVO.id == id && permissionVO.children.length != 0) {
                getCheckedLeafIdList(permissionVO.children, assignedPermissionIdList, checkedLeafIdList);
            }
        });
    });
};

为什么只勾”叶子节点”?因为 el-tree 是父子联动的:父节点选中后会自动带上所有子节点。后端 role_permission 里通常只存最细粒度的权限。这里只回显叶子节点,避免父子同时 setChecked 造成重复/歧义。

② 保存分配的权限:

const assignPermission = () => {
    // getCheckedNodes(false, true):只取被完整勾选的叶子节点
    let checkedNodes = treeRef.value.getCheckedNodes(false, true);
    let permissionIds = checkedNodes.map(node => node.id);
    permissionIds = permissionIds.join(',');
    roleApi.assignPermission(role.value.id, permissionIds).then((result) => {
        ElMessage({message: result.msg, type: 'success'});
        dialogPermissionVisible.value = false;
    });
};

提交时把勾选的叶子权限 id 用逗号拼成字符串 "1,2,5",Spring 会自动把它解析成 Long[] permissionIds 数组传给后端 assignPermission(roleId, permissionIds)(见 4.9)。

③ 分配权限的树形控件:

<el-tree :data="treeData" ref="treeRef" show-checkbox node-key="id"
         default-expand-all :props="{ children: 'children', label: 'name' }">
</el-tree>

5.7 用户管理页 User.vue(用户 CRUD + 分配角色)

与 5.6 完全对称,只是从”角色分配权限”换成”用户分配角色”(代码见 4.10)。前端:

const showAssignedRoleDialog = (row) => {
    user.value = row;
    // 拿到:所有角色 + 该用户已分配的角色 id
    userApi.selectAssingnedRole(row.id).then((result) => {
        roleList.value = result.data.roleList;
        assignedRoleIdList.value = result.data.assignedRoleIdList;
        dialogRoleVisible.value = true;
    });
};
// 保存:把勾选的角色 id 拼成字符串提交
const assignRole = () => {
    let roleIds = assignedRoleIdList.value.join(',');
    userApi.assignRole(user.value.id, roleIds).then((result) => {
        ElMessage.success(result.msg);
        loadData();
        dialogRoleVisible.value = false;
    });
};

六、完整实现链路(一图流)

以”给一个新用户把系统配好,并让他登录后只能看到他能操作的东西”为例串联全部:

【配置阶段 — 管理员】
1. 权限管理页(_permission_):录入目录(系统功能)→菜单(用户管理/角色管理…)→按钮(user:add, role:deleteById…)
   落库到 permission 表,parent_id 形成树。
2. 角色管理页(_role_):新增角色"护理主管";点[权限]弹出权限树,
   勾选该角色需要的目录/菜单/按钮 → POST /role/assignPermission
   后端"先删后插"写入 role_permission 表。
3. 用户管理页(_user_):新增用户"张三",点[角色]弹出角色勾选框,
   勾选"护理主管" → POST /users/assignRole
   后端"先删后插"写入 user_role 表。
       至此:user_role: 张三→护理主管;role_permission: 护理主管→一堆权限

【使用阶段 — 张三】
4. 张三登录:POST /users/login → 校验通过 → 返回 JWT token
5. 前端存 token 到 pinia(localStorage),跳转首页
6. Index.vue 挂载 → GET /users/userInfo(带 Authorization: token)
   后端:解析 token 拿 userId → 三表 INNER JOIN 查出所有权限
        → type=2 归入 btnList;type=0/1 构建成树 routerList → 一并返回
7. 前端:routerList 渲染左侧导航(只显示张三有权限的菜单)
        btnList 存入 pinia → 各页面 hasBtnPermission() 控制按钮禁用/可用
8. 整站所有接口都经过 LoginInterceptor:token 缺失/过期 → 401
   前端响应拦截器收到 401 → 清 token → 跳登录页

七、关键点与注意事项

  1. RBAC 核心:用户↔角色↔权限三层解耦。改权限只动 role_permission,改归属只动 user_role,互不影响。
  1. 权限分级:permission.type(0 目录 / 1 菜单 / 2 按钮)是前端”菜单树 / 按钮数组”分流的关键判据。
  1. 树形结构:permission.parent_id 自引用构成树,后端用递归 buildTree 组装成嵌套 JSON;PermissionVo extends Permission 加了 children 存子树。
  1. 三表联查:selectPermissionByUserId 通过 user_role→role_permission→permission 三次 INNER JOIN,DISTINCT 去重、deleted=0 过滤、ORDER BY sort 排序。
  1. 先删后插:分配权限/角色都采用”清空旧的 + 逐个插入新的”,逻辑简单、天然支持取消勾选。低频配置场景下够用。
  1. 只回显叶子节点:el-tree 父子联动,分配权限弹窗回显时只 setCheckedKeys 叶子节点 id,避免父子重复勾选。
  1. 按钮权限实现:hasBtnPermission(权限值) 判断 btnList 是否包含该值,配 :disabled 控制。权限值命名约定 模块:动作(如 user:add)。
  1. 图标字符串渲染:菜单 <component :is="菜单图标字符串"> 能生效,靠的是 main.js 全局注册了所有 Element Plus 图标组件。
  1. token 生命周期:登录生成、前端持久化、LoginInterceptor 校验(过期 1 小时)、401 时前端拦截器清 token 跳登录。
  1. 未做/可扩展的地方(本项目取舍):
  • 登录密码为明文比对,生产应使用 BCrypt 加密(用 BCryptPasswordEncoder)。
  • 前端已在路由守卫里做了路径级鉴权(见 5.2):把权限树 routerList 扁平化成 allowedPaths,直接敲无权限的 URL 会被拦截并跳回首页。但路由仍是静态注册,未做”按 routerList 动态生成路由 / 无权限即 404″;更彻底的方案是动态添加路由 + 后端接口级鉴权(Spring Security 或自定义注解 + AOP)。
  • 后端 LoginInterceptor 只验证”是否登录”,未验证”该用户是否有权调用此接口”。前端的路由守卫拦截只是一道”看不见/进不去”的体验层防线,真正的数据安全必须靠后端;如需后端强约束,可给每个接口绑定所需权限值并二次校验。

八、如果要在新项目里复刻这套权限系统

  1. 建表:复制 user、role、permission、user_role、role_permission 五张表结构与实体类(Permission 需带 parentId/type/path/permissionValue/icon/sort)。
  2. 后端:
  • 复制 JwtUtil、LoginInterceptor、WebConfig(拦截器注册);
  • 复制 selectPermissionByUserId 三表联查 SQL + buildTree 递归拼树;
  • 复制角色权限(assignPermission/selectAssignedPermission)和用户角色(assignRole/selectAssinedRole)两套”先删后插”逻辑。
  1. 前端:
  • 复制 btnPermission.js、userInfo/token 两个 pinia store(含持久化);
  • 在 Index.vue 挂载时调 userInfo 接口填充 menuData 和 btnList,菜单用 el-menu 动态渲染;
  • 页面按钮统一用 :disabled="!hasBtnPermission('xxx:add')" 包裹。
  1. 接入新页面:在 permission 表加一条目录/菜单记录(parent_id=0 目录 → 子菜单),并把需要的按钮(type=2)挂到该菜单下;角色的权限分配里勾选即可;页面组件里对应按钮写上 permission:xxx 权限值。

本文档基于 elder 养老管理后台的代码现场整理,核心逻辑集中在后端 PermissionServiceImpl、RoleServiceImpl、UserServiceImpl、PermissionMapper.xml,以及前端 Index.vue、Role.vue、Permission.vue、btnPermission.js。如需在其它项目落地,按第八节步骤即可。

上一篇
下一篇