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) |
系统最终实现两级前端控制 + 一级后端控制:
- 菜单级:根据用户拥有的「目录+菜单」动态渲染左侧导航——没权限的页面入口根本不出现;
- 按钮级:根据「按钮」权限值(如
user:add)控制页面按钮可用/禁用; - 数据级(后端强制):护工角色(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 认证的弊端
- 每个用户的登录信息保存在服务器 session 中,用户增多服务器开销明显增大;
- session 存在物理内存中,分布式系统失效(虽然可用 Redis 统一存放,但增加复杂性);
- 对非浏览器客户端(移动端)不适用——session 依赖 cookie;
- 基于 cookie,容易被截获造成 CSRF 攻击;浏览器禁用 cookie 即失效;
- 前后端分离系统请求经过多个中间件,session 信息转发多次;
- 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)
- 前端
Login.vue调login()→userApi.login(user)→request.post("/users/login", {name, password}); - axios 请求拦截器:此时
tokenStore.token为空,不添加 Authorization 头; - Vite 代理:
/api/users/login→ 剥掉/api前缀转发到http://localhost:8080/users/login; - 后端校验通过(见 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' }"/>
两个关键细节:
- 为什么回显只勾”叶子节点”?
el-tree是父子联动的:父节点选中会自动带上所有子节点。后端role_permission里通常存的是最细粒度权限,只回显叶子可避免”父半选被当全选”的重复/歧义; - 为什么提交要带半选父节点?
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,只看自己的任务