Elder 开发手册 · 权限功能

Elder 开发手册 · 权限功能

本手册整理养老项目的完整权限体系:JWT 登录鉴权 → RBAC 权限模型 → 动态菜单 → 按钮级权限 → 路由级鉴权 → 服务端数据权限,覆盖从数据库表设计到前端渲染的全链路,代码均来自项目实际实现。

更偏原理推导的版本见《权限管理系统笔记.md》;模块业务见《elder开发手册模块功能.md》;通用组件见《elder开发手册组件功能.md》。

目录

章节 内容 关键词
1 RBAC 权限模型与表设计 五张表 / type 三级权限
2 JWT 原理与工具类 Session 弊端 / 结构 / JwtUtil
3 Token 完整传输流程 登录 → 存储 → 携带 → 校验
4 三道防线对比 路由守卫 / axios / 后端拦截器
5 登录接口与四重校验 双端登录 / 白名单
6 权限查询与 userInfo 接口 三表联查 / buildTree / roleCodes
7 前端:动态菜单 el-menu / 路径级鉴权
8 前端:按钮级权限 hasBtnPermission / btnList
9 服务端数据权限(护工) hasRole / 越权校验 ★
10 配置端:权限树 / 角色分配 / 用户分配角色 先删后插 / 叶子回显
11 已知短板与后续强化 接口级鉴权 / BCrypt

1. RBAC 权限模型与表设计

本项目采用经典 RBAC(Role-Based Access Control,基于角色的访问控制)模型:

给用户分配角色,给角色分配权限。 用户不直接与权限挂钩,而是通过「角色」中间层间接获得权限——一批人的权限整体调整时只改角色,不用逐个用户配置。

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

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

系统最终实现两级前端控制 + 一级后端控制:

  1. 菜单级:根据用户拥有的「目录+菜单」动态渲染左侧导航——没权限的页面入口根本不出现;
  2. 按钮级:根据「按钮」权限值(如 user:add)控制页面按钮可用/禁用;
  3. 数据级(后端强制):护工角色(HUGONG)登录时服务端强制只查/只允许操作自己的任务(见第 9 节)。

五张核心表

user ──(user_role)── role ──(role_permission)── permission
用户                角色                       权限(parent_id 自关联成树)
表 关键字段
user name / password / phone / avatar / status(0停用 1正常) / deleted
role name / code(角色编码,如 HUGONG) / description
permission parent_id(0=顶级) / name / type(0目录 1菜单 2按钮) / path(菜单路由) / permission_value(按钮权限值) / icon / sort / status
user_role user_id + role_id
role_permission role_id + permission_id

Permission.java 实体(核心字段):

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

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

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

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

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

    /** 图标名:菜单/目录显示的图标(配合前端全局注册使用) */
    private String icon;

    /** 排序 */
    private Integer sort;
    // status / deleted(@TableLogic) / create_time、update_time(自动填充)
}

2. JWT 原理与工具类

2.1 传统 Session 认证的弊端

  1. 每个用户的登录信息保存在服务器 session 中,用户增多服务器开销明显增大;
  2. session 存在物理内存中,分布式系统失效(虽然可用 Redis 统一存放,但增加复杂性);
  3. 对非浏览器客户端(移动端)不适用——session 依赖 cookie;
  4. 基于 cookie,容易被截获造成 CSRF 攻击;浏览器禁用 cookie 即失效;
  5. 前后端分离系统请求经过多个中间件,session 信息转发多次;
  6. cookie 无法跨域 → session 认证无法跨域,单点登录不适用。

Token 方案:按约定规则生成字符串并加密处理(如 userid#username#timestamp#random → 加密)。优点:支持跨域(token 放请求头不依赖 cookie)、无状态(服务端不存 session)、适用于移动端。

2.2 JWT 结构

JWT(JSON Web Token)是 token 的一种具体实现。本质是字符串:把用户信息保存到 JSON 字符串,编码后带签名,可校验是否被篡改。

Header(头)  .  Payload(有效载荷)  .  Signature(签名)
  • Header:描述元数据,alg 签名算法(默认 HMAC SHA256 写作 HS256)、typ: JWT,Base64URL 编码;
  • Payload:主体内容,包含需要传递的数据(本项目放在统一的 claims 字段里:{id, name});
  • Signature:对前两部分的签名,需要服务器保存的密钥(secret),确保数据不被篡改。

三部分分别 Base64URL 编码后用 . 连接构成最终 token。

验证流程:客户端用账号密码请求登录 → 服务端验证 → 签发 token 返回 → 客户端存起来(一般 localStorage)→ 之后每次请求把 token 放请求头携带 → 服务端验证 token 合法则返回数据。

2.3 JwtUtil 工具类(utils/JwtUtil.java)

public class JwtUtil {

    private static final String KEY = "dxk";   // ⚠️ 签名密钥硬编码,学习项目简化;生产应放配置/环境变量

    //接收业务数据,生成token并返回
    public static String createToken(Map<String, Object> map) {
        return JWT.create()
                .withClaim("claims", map)                                   // 有效载荷
                .withExpiresAt(new Date(System.currentTimeMillis() + 1000 * 60 * 60))  // 失效时间:1小时
                .sign(Algorithm.HMAC256(KEY));                              // HMAC256签名
    }

    //接收token,验证token,并返回业务数据
    //token 非法、或过期,verify 都会抛异常 → 调用方据此判定 401
    public static Map<String, Object> parseToken(String token) {
        return JWT.require(Algorithm.HMAC256(KEY))
                .build()
                .verify(token)
                .getClaim("claims")
                .asMap();
    }
}

依赖:com.auth0:java-jwt:4.4.0。
注意:parseToken 还原的 claims 里 id 取出来是 Integer 类型——与各控制器 Integer id = (Integer) map.get("id") 对应。


3. Token 完整传输流程

3.1 登录阶段(获取 token)

  1. 前端 Login.vue 调 login() → userApi.login(user) → request.post("/users/login", {name, password});
  2. axios 请求拦截器:此时 tokenStore.token 为空,不添加 Authorization 头;
  3. Vite 代理:/api/users/login → 剥掉 /api 前缀转发到 http://localhost:8080/users/login;
  4. 后端校验通过(见 5.1)→ 组装载荷生成 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);

3.2 前端存储(Pinia 持久化)

//Login.vue
if (result.code == 1) {
    tokenStore.setToken(result.data)   // token 字符串存入 pinia
    router.push('/')
}
//store/token.js:persist 开启后自动同步 localStorage
persist: { enabled: true, key: 'elder-admin-token' }
  • 内存:Pinia state(页面刷新丢失);
  • localStorage:持久化,刷新后 pinia 插件自动读取恢复 → 登录态保持。

3.3 后续请求(携带)

request.js 请求拦截器统一注入:

if (tokenStore.token) {
    config.headers.Authorization = tokenStore.token
}

只要 pinia 中存在 token,每一次 axios 请求都自动带上 Authorization: <token>(原始值,不加 Bearer 前缀,前后端约定一致即可)。

3.4 后端校验与过期处理

每个请求进 Controller 前先过 LoginInterceptor(见 4.1);token 过期(1 小时)后后端返回 401 → 前端响应拦截器捕获 → 清 token → 跳登录页。


4. 三道防线对比

4.1 总览:它们分别在哪个环节守着

【用户点开网站】
     │
     ▼
┌─────────────────────────────┐
│ ① 前端路由守卫 beforeEach     │  还没进页面就查 token
│    没有token → 踢去 /login   │
└─────────────────────────────┘
     │ 进入页面,发请求
     ▼
┌─────────────────────────────┐
│ ② request 请求拦截器         │  每次发请求前自动加 Authorization 头
│    有token → 帮你带上         │
└─────────────────────────────┘
     │ HTTP 请求发到后端
     ▼
┌─────────────────────────────┐
│ ③ 后端 LoginInterceptor      │  每个请求进 Controller 前先验 token
│    没token/非法 → 401 拦截   │
└─────────────────────────────┘
     │ 通过
     ▼
  Controller 处理,返回数据

4.2 核心对比表

对比维度 ① 前端路由守卫 ② request(axios 拦截器) ③ 后端 LoginInterceptor
运行位置 浏览器(Vue Router) 浏览器(axios) 服务器(Spring MVC)
作用 没登录就不让进页面 有 token 自动带头 + 收到 401 跳登录 没 token / 非法 token 直接 401 拦截
原理 beforeEach 检查 tokenStore.token 存不存在 请求拦截加头;响应拦截 error.response.status === 401 preHandle 取 Authorization 头 → JwtUtil.parseToken 验签 → return false 则请求终止
管的范围 页面能不能打开 请求发不发、头带不带、错误怎么提示 请求能不能进 Controller
判断什么 token 存不存在(不验真假) token 存不存在(不验真假) token 合法不合法(验签 + 过期)
能验 token 真假吗 ❌ 不能 ❌ 不能 ✅ 能(密钥 HMAC256 验签)
被绕过的难度 改 localStorage 就绕过 curl / Postman 直接绕过 几乎不可能(除非攻破服务器)
失效后果 未登录也能打开页面 请求不带头 / 401 提示失灵 数据裸奔,任何人可调接口
本质 体验层 体验层 + 便利层 安全边界

4.3 按「能不能被绕过」再分一层

                       ┌── ① 路由守卫  → 改 localStorage 就能绕过
 前端(跑在用户浏览器)─┤
                       └── ② request    → curl / Postman 直接绕过
 后端(跑在服务器)─── ③ LoginInterceptor → 绕不过(这是唯一的安全防线)

关键点:①② 管的是”你网站里的用户”;③ 管的是”所有来敲你门的人”。 攻击者永远不会走你的浏览器,所以①②对他无效,只有③能挡住。

一句话总结:位置不同(浏览器 vs 服务器)、能力不同(有没有 vs 真不真)、可信度不同(可改可绕 vs 可信)。

但 LoginInterceptor 也只验「登录没登录」,不验「有没有权调这个接口」——接口级鉴权是第 11 节的扩展方向。


5. 登录接口与白名单

5.1 管理端登录(POST /admin/users/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("密码错误"); }  // 明文比对(见11.2)
    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);
}

校验顺序:存在 → 密码 → 注销(逻辑删除)→ 禁用(status)。

5.2 手机端老人登录(POST /app/elders/login)——同构的第二套账号体系

//Elder 实体也有 password/status/deleted,与 User 完全同构:
//按 name 查 Elder → 四重校验 → JwtUtil 签发 {id, name} 的 token
//注意:admin 用户和 elder 老人的 token 结构相同(都只有 id/name),
//两套接口按 /admin 与 /app 前缀物理隔离,各自的 token 只在各自端使用

5.3 LoginInterceptor 与 WebConfig 白名单

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;
        }
    }
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //拦截所有请求,公开接口逐条列入白名单(否则没 token 没法登录/浏览)
        registry.addInterceptor(new LoginInterceptor())
                .addPathPatterns("/**")
                .excludePathPatterns(
                        "/admin/users/login", "/app/elders/login",
                        "/admin/users/register", "/users/forgetPassword",
                        //预约模块公开接口:免登录自助提交预约、预约项目下拉
                        "/admin/appointment-plan/submit", "/admin/appointment-item/public-list",
                        //体检模块公开接口:免登录查看上架套餐及详情
                        "/app/exam-package", "/app/exam-package/*");
    }
}

免登录接口的安全姿势:submit/register 一律强制覆盖 id/status/source/userId 等敏感字段——白名单接口等于把写库能力暴露给陌生人,必须在 Service 里不信任前端(见模块手册 6.2)。

配套:/users/forgetPassword、/resetPassword、手机端 /app/elders/profile|password 等身份相关接口都要从 token 解析用户,不能信前端传的 id。


6. 权限查询与 userInfo 接口

6.1 ★ 三表联查:按用户查权限

<select id="selectPermissionByUserId" resultType="com.situ.elder.pojo.entity.permission.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 ur
             INNER JOIN role_permission AS rp ON ur.role_id = rp.role_id
             INNER JOIN permission AS p       ON rp.permission_id = p.id
    WHERE ur.user_id = #{id}
      AND rp.deleted = 0
      AND p.deleted = 0
    ORDER BY p.sort
</select>
public interface PermissionMapper extends BaseMapper<Permission> {
    List<Permission> selectPermissionByUserId(Long id);
}

理解这条 SQL:

  • user_role 按 ur.user_id = #{id} 定位用户 → 拿到所有角色 id;
  • role_permission 用 ur.role_id = rp.role_id 关联 → 拿到这些角色绑定的所有权限 id;
  • permission 用 rp.permission_id = p.id 关联 → 还原权限完整字段;
  • DISTINCT 去重:用户多角色/权限被多角色重复绑定时每个权限只出现一次;
  • deleted=0 过滤逻辑删除;ORDER BY sort 保证菜单顺序一致。

6.2 ★ 把权限拆成「菜单树」和「按钮列表」

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

    //2. 按类型拆分:
    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);
}

递归拼树:

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 parent, List<PermissionVo> all) {
    //递归为每个节点挂上所有子节点,直到没有子节点
    return all.stream()
            .filter(permissionVo -> permissionVo.getParentId().equals(parent.getId()))
            .map(permissionVo -> {
                permissionVo.setChildren(buildChildrenTree(permissionVo, all));
                return permissionVo;
            }).toList();
}

PermissionVo extends Permission 加 List<PermissionVo> children 存子树——扁平的权限列表被整理成嵌套 JSON 树。

6.3 userInfo 接口(一个接口喂饱前端三样数据)

@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"));
    //返回角色编码列表,前端据此区分身份(如护工 HUGONG)
    resultMap.put("roleCodes", iUserService.getRoleCodes(user.getId()));
    return Result.ok(resultMap);
}

7. 前端:动态菜单与路由级鉴权

7.1 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(供 hasBtnPermission 使用)
      UserInfoStore.setBtnList(result.data.btnList);
      //4. 菜单树也存入 store,供路由守卫做"路径级鉴权"
      UserInfoStore.setRouterList(result.data.routerList);
      //5. 角色编码 → pinia(页面身份判断)
      UserInfoStore.setRoleCodes(result.data.roleCodes);
    }
  })
}

菜单渲染用 el-menu 的 v-for 双形态(目录走 el-sub-menu,菜单走 el-menu-item),图标的字符串渲染原理见组件手册 12.1。

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

let whiteList = ['/login', '/register', '/forget'];  // 白名单:未登录也能访问
router.beforeEach(async (to) => {
  //已登录访问登录/注册/忘记密码页 → 直接回首页
  if (whiteList.includes(to.path) && tokenStore.token) {
    ElMessage.success("已成功登录")
    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)
        userInfoStore.setRoleCodes(result.data.roleCodes)
      }
    } catch (e) {
      //拉取失败保持空权限,未授权路径一律被拦截
    }
  }
  //★ 路径级鉴权:目标路由不在"允许路径集合"内 → 拦截回首页
  if (!userInfoStore.allowedPaths.includes(to.path)) {
    ElMessage.warning('无权访问该页面')
    return '/chart'   // 首页在 allowedPaths 里固定放行,不会二次拦截
  }
});

说明:路由仍然是静态注册的(所有页面 Component 都 import 写死),没有改成”按 routerList 动态生成路由、无权限即 404″。但守卫里多了一层路径级鉴权,解决”登录后直接敲 URL 绕过权限”的问题。

7.3 allowedPaths getter(权限树扁平化 + 固定放行)

//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
    }
}

7.4 store 的防越权设计

actions: {
    setRouterList(routerList) {
        this.routerList = routerList || []
        this.routesLoaded = true     // 加载标记:守卫据此避免重复请求
    },
    //退出登录时清空用户及权限数据,防止下一账号沿用上一账号的权限(越权)
    resetAll() {
        this.user = {}; this.btnList = []; this.routerList = []
        this.roleCodes = []; this.routesLoaded = false
    }
},
persist: { enabled: true, key: 'elder-admin-user' }

由于 routerList/btnList 都被持久化,刷新页面后菜单和按钮权限不丢;routesLoaded 标记避免每次跳转都请求 userInfo;退出登录必须 resetAll()。


8. 前端:按钮级权限

8.1 工具函数(utils/btnPermission.js)

import {useUserInfoStore} from '@/store/userInfo.js'
//判断当前用户是否具有这个按钮权限 user:add
export default function hasBtnPermission(permission) {
    const userInfoStore = useUserInfoStore()
    const btnList = userInfoStore.btnList;   // 例如 ['user:add','user:deleteById',...]
    return btnList.indexOf(permission) !== -1;   // 用户拥有该权限值 → true
}

8.2 页面用法:配 :disabled

<el-button type="success" @click="showAddDialog()"
           :disabled="!hasBtnPermission('permission:add')">添加顶级菜单</el-button>

项目使用的权限值一览(命名约定 模块:动作):

权限值 所在页面
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
elder:add / elder:deleteAll / elder:update / elder:deleteById / elder:assignTag / elder:exportExcel / elder:importExcel Elder.vue
care:、exam:、appointment: 等 各业务页面

由于 btnList 持久化,刷新后 hasBtnPermission 依然有效。


9. ★ 服务端数据权限(护工只能看自己的任务)

问题场景:护工 A 登录后,如果接口只按前端传的 userId 查任务,护工 A 手工改参数(或直接 curl)就能看到护工 B 的任务。前端的路由/按钮拦截只是”看不见”,数据安全必须在服务端强制。

9.1 列表查询:强制覆盖 userId 条件

//CareTaskController(AppointmentTaskController 同构)
//条件分页查询(护工角色登录时,服务端强制只查分配给自己的任务)
@GetMapping
public Result<IPage<CareTaskVo>> selectAll(CareTaskQuery careTaskQuery,
                                           @RequestHeader(name = "Authorization") String token) {
    //数据权限必须在服务端强制:解析token拿到当前用户,若是护工则覆盖userId条件,
    //前端无论传什么userId都无效,防止手工调接口越权查看他人任务
    Integer id = (Integer) JwtUtil.parseToken(token).get("id");
    Long currentUserId = id.longValue();
    if (iUserService.hasRole(currentUserId, "HUGONG")) {
        careTaskQuery.setUserId(currentUserId);
    }
    return Result.ok(iCareTaskService.list(careTaskQuery));
}
//UserServiceImpl:角色判断的底层
public boolean hasRole(Long userId, String roleCode) {
    return getRoleCodes(userId).contains(roleCode);
}

9.2 详情与执行:二次校验属主

//详情:护工只能查看自己的任务详情
CareTaskDetailVo detailVo = iCareTaskService.selectDetail(id);
if (isCaregiver(token) && !detailVo.getTask().getUserId().equals(currentUserId(token))) {
    throw new ServiceException("无权查看他人任务");
}

//执行任务:护工只能执行分配给自己的任务
CareTask task = iCareTaskService.getById(id);
if (isCaregiver(token) && !task.getUserId().equals(currentUserId(token))) {
    throw new ServiceException("无权执行他人任务");
}

9.3 同一思想在其它模块的应用

场景 防线
老人看合同附件 AppContractController 先校验 contract.elderId == 当前token老人
老人看体检报告 elderReport(elderId, id) 内部校验”本人 + 已完成”双重条件
老人取消预约 SERVICE 分支只允许取消本人的、status==0 待确认的计划
免登录提交预约 强制覆盖 elderId/userId/status/source,不信任前端
修改个人资料 PUT /app/elders/profile 的 id 强制取自 token

四层权限体系总结:

① 菜单级(前端渲染,体验层)
② 按钮级(前端 disabled,体验层)
③ 路由级(前端守卫 allowedPaths,体验层)
④ 数据级(后端 hasRole 强制覆盖 + 属主校验,★ 唯一安全边界)

10. 配置端:权限树 / 角色分配 / 用户分配角色

10.1 权限管理页(维护 permission 树)

  • 数据加载:permissionApi.selectPermissionTree() 拿整棵树,el-table row-key="id" 树形渲染(组件手册 11);
  • 类型限制:目录(0)下只能加菜单(1)、菜单(1)下只能加按钮(2)、按钮是叶子不能再加(:disabled="row.type===2",曾经的坑是写成 ===3 永远不生效);
  • 条件字段:图标只有目录/菜单显示(IconPicker 选择);「路由地址」只有 type=1 显示且必填;「按钮权限值」只有 type=2 显示且必填(条件校验器写法见组件手册 7.5)。

10.2 角色分配权限(role_permission,先删后插)

后端:

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

为什么用”先删后插”:前端给的是这次勾选的完整集合,整删整插逻辑最简单、天然支持取消勾选,避免复杂的两两比较。代价是频繁整删整插,但权限是低频变更场景,完全够用。

前端(Role.vue):

//① 打开弹窗:一次性拿到全部权限树 + 已分配 id,递归找出【叶子节点】回显
const showAssignedPermissionDialog = (row) => {
  role.value = row;
  dialogPermissionVisible.value = true;
  roleApi.selectAssignedPermission(row.id).then((result) => {
    treeData.value = result.data.permissionVOList;
    let checkedLeafIdList = [];
    getCheckedLeafIdList(result.data.permissionVOList,
        result.data.assignedPermissionIdList, checkedLeafIdList);
    treeRef.value.setCheckedKeys(checkedLeafIdList);
  });
};

//递归找出"该角色拥有且为叶子节点"的权限 id
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);
      }
    })
  });
};

//② 保存:getCheckedNodes(false, true) —— includeHalfChecked=true 把半选父节点也算入
const assignPermission = () => {
  let checkedNodes = treeRef.value.getCheckedNodes(false, true);
  let permissionIds = checkedNodes.map(node => node.id).join(',');
  roleApi.assignPermission(role.value.id, permissionIds).then((result) => {
    ElMessage({message: result.msg, type: 'success'});
    dialogPermissionVisible.value = false;
  });
};
<el-tree :data="treeData" ref="treeRef" show-checkbox node-key="id"
         default-expand-all :props="{ children: 'children', label: 'name' }"/>

两个关键细节:

  1. 为什么回显只勾”叶子节点”? el-tree 是父子联动的:父节点选中会自动带上所有子节点。后端 role_permission 里通常存的是最细粒度权限,只回显叶子可避免”父半选被当全选”的重复/歧义;
  2. 为什么提交要带半选父节点? getCheckedNodes(false, true) 的第二参数把”部分子节点被勾选”的父节点(半选态)也收进来,保证目录权限(menu 入口)也被写入 role_permission,否则前端 allowedPaths 扁平化时拿不到目录级 path。

10.3 用户分配角色(user_role,同构)

与 10.2 完全同构,只是对象换成「用户-角色」,控件从 el-tree 换成 el-checkbox-group:

//UserServiceImpl
public UserRoleVo selectAssinedRole(Long userId) {
    List<Role> roleList = roleMapper.selectList(null);   // 所有角色
    List<Long> assignedRoleIdList = userRoleMapper.selectList(
            new LambdaQueryWrapper<UserRole>().eq(UserRole::getUserId, userId))
            .stream().map(UserRole::getRoleId).collect(Collectors.toList());
    UserRoleVo vo = new UserRoleVo();
    vo.setRoleList(roleList);
    vo.setAssignedRoleIdList(assignedRoleIdList);
    return vo;
}

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.setUserId(userId);
        userRole.setRoleId(roleId);
        userRoleMapper.insert(userRole);
    }
}
//User.vue
const showAssignedRoleDialog = (row) => {
  userApi.selectAssingnedRole(row.id).then((result) => {
    roleList.value = result.data.roleList;
    assignedRoleIdList.value = result.data.assignedRoleIdList;
    dialogRoleVisible.value = true;
  });
};
const assignRole = () => {
  let roleIds = assignedRoleIdList.value.join(',');
  userApi.assignRole(user.value.id, roleIds).then((result) => {
    ElMessage.success(result.msg);
    loadData();
    dialogRoleVisible.value = false;
  });
};

老人-标签(elder_tag)的分配与这一模一样——全项目所有”分配类”操作都是同一套:回显接口(全部+已选)+ 先删后插保存接口 + join(‘,’) 传参。


11. 已知短板与后续强化

11.1 当前实现的取舍

短板 现状 风险
密码明文比对/存储 equals() 直接比 数据库泄露即全量泄密
后端只验登录不验权限 LoginInterceptor 只验 token 合法 拿自己 token 可调任何接口
路由静态注册 页面 Component 全部写死 打包体积包含无权限页面代码
JWT 签名密钥硬编码 KEY = "dxk" 写在源码 源码泄露即可伪造任意用户 token
JWT 有效期 1 小时不可续期 过期只能重新登录 体验差;无刷新机制

11.2 后端接口级鉴权(最值得先做的一步)

给接口加 @RequirePermission("user:deleteById") 注解 + PermissionInterceptor 拦截器,权限值与前端按钮权限同源同值(三表联查出的 btnList),配置一次两端生效。完整代码见《elder开发手册模块功能.md》第 11.4 节,核心思路:

请求 → LoginInterceptor(验登录,order=0)
     → PermissionInterceptor(验权限,order=1)
        读 HandlerMethod 上的 @RequirePermission
        → selectPermissionByUserId(当前用户) 取权限值集合
        → 不包含 → 403

11.3 BCrypt 密码加密

spring-security-crypto 单包 + BCryptPasswordEncoder:注册/新增时 encode(),登录时 matches(明文, 密文),存量数据用 ApplicationRunner 一次性迁移。完整代码见《elder开发手册模块功能.md》第 11.5 节。

11.4 更彻底的方案(面试加分项)

  • 动态路由:登录后 router.addRoute() 按 routerList 注册路由,无权限页面根本不存在于路由表,打包也可配合路由懒加载减小体积;
  • JWT 刷新:双 token(access 短效 + refresh 长效),或滑动过期;
  • 密钥外置:KEY 移入 application.yml + 环境变量(同 OSS 密钥的做法);
  • Spring Security:完整过滤器链 + 方法级 @PreAuthorize,替代手写拦截器(学习成本高,本项目规模下手写拦截器更直观)。

附:权限实现链路一图流

以”给新用户配好系统,让他登录后只能看到能操作的东西”为例:

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

【使用阶段 — 张三】
4. 张三登录:POST /users/login → 四重校验通过 → 返回 JWT token
5. 前端存 token 到 pinia(localStorage),跳转首页
6. Index.vue 挂载 → GET /users/userInfo(带 Authorization)
   后端:解析 token 拿 userId → 三表 INNER JOIN 查出所有权限
        → type=2 归入 btnList;type=0/1 buildTree 成 routerList
        → 附带 roleCodes 一并返回
7. 前端:routerList 渲染左侧导航(只显示有权限的菜单)
        btnList → hasBtnPermission() 控制按钮禁用/可用
        roleCodes → 页面身份判断
8. 路由守卫 allowedPaths:直接敲无权限 URL → 拦回首页
9. LoginInterceptor:token 缺失/过期 → 401 → 前端清 token 跳登录
10. 护工角色(HUGONG)调任务接口:服务端强制覆盖 userId,只看自己的任务
上一篇
下一篇