第二篇:SeaPack 权限体系:从"谁都能看"到"该看什么看什么"

写在前面

上一篇聊了项目初始化和工程规范,这篇来聊一个让所有后台系统都绕不开的话题------权限控制

为什么要做权限?因为我的项目不是一个单用户的 demo,而是一个有完整用户体系的平台。系统管理模块里有用户管理、角色管理、菜单权限管理,股票基金模块有监控阈值配置,AI 模块有 Token 配额管理。这些功能不是所有用户都应该看到的------不同的用户看到的内容是可以动态设置的。

所以权限控制不是"锦上添花",而是必须做的事

一个人做权限,和团队做有什么区别? 团队做权限,通常有现成的权限框架(如 Sa-Token、Shiro),配置一下就能用。但我想从零实现,能根据自己的需求定制。

最后我实现了一套前后端分离的完整权限链路------后端管数据,前端管展示,两层互不依赖但紧密配合。

访问地址http://124.222.194.201/

前端代码github.com/seapack-hub...

后端代码github.com/seapack-hub...

一、权限模型:五张表撑起整个体系

1.1 经典 RBAC 模型

我采用了经典的 RBAC(Role-Based Access Control) 模型------基于角色的访问控制。核心思想很简单:

用户 → 角色 → 权限

用户不直接拥有权限,而是通过角色间接获得。一个用户可以有多个角色,一个角色可以包含多个权限。这样管理起来很灵活:给一个角色加了权限,这个角色下的所有用户自动生效。

1.2 数据库设计:五张表

plain 复制代码
┌──────────┐      ┌──────────────┐     ┌──────────┐      ┌──────────────────┐      ┌──────────────┐
│ sys_user │────▶│ sys_user_role│◀────│ sys_role │────▶│sys_role_permission│◀────│sys_permission│
└──────────┘      └──────────────┘     └──────────┘      └──────────────────┘      └──────────────┘
   用户表          用户-角色中间表        角色表             角色-权限中间表               权限/菜单表

用户表

plain 复制代码
CREATE TABLE `sys_user`  (
     `id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户ID',
     `user_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '用户名',
     `mobile` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '手机号',
     `nick_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '昵称',
     `gender` tinyint NULL DEFAULT NULL COMMENT '性别 (1男 0女)',
     `email` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '邮箱',
     `status` tinyint NULL DEFAULT NULL COMMENT '状态 (1正常 0停用)',
     `dept_id` bigint NULL DEFAULT NULL COMMENT '部门ID',
     `dept_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '部门名称',
     `create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
     `password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '密码',
     PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 6 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '用户表' ROW_FORMAT = Dynamic;

用户角色关联表

plain 复制代码
CREATE TABLE `sys_user_role` (
 `user_id` bigint NOT NULL COMMENT '用户ID',
 `role_id` bigint NOT NULL COMMENT '角色ID',
 PRIMARY KEY (`user_id`, `role_id`) USING BTREE,
 KEY `idx_role_id` (`role_id`) USING BTREE,
 -- 【重点】直接在建表时声明外键并设置级联删除
 CONSTRAINT `fk_user_role_user_id` FOREIGN KEY (`user_id`) REFERENCES `sys_user` (`id`) ON DELETE CASCADE,
 CONSTRAINT `fk_user_role_role_id` FOREIGN KEY (`role_id`) REFERENCES `sys_role` (`id`) ON DELETE CASCADE

) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户与角色关联表';

角色表

plain 复制代码
CREATE TABLE `sys_role` (
    `id` bigint NOT NULL AUTO_INCREMENT COMMENT '角色ID',
    `role_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色名称',
    `role_code` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色编码',
    `description` varchar(255) DEFAULT NULL COMMENT '角色描述',
    `status` tinyint DEFAULT 1 COMMENT '状态 (1正常 0停用)',
    `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    PRIMARY KEY (`id`) USING BTREE,
    UNIQUE KEY `uk_role_code` (`role_code`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统角色表';

角色权限关联表

plain 复制代码
CREATE TABLE `sys_role_permission` (
  `role_id` bigint NOT NULL COMMENT '角色ID',
  `permission_id` bigint NOT NULL COMMENT '权限ID',
   PRIMARY KEY (`role_id`, `permission_id`) USING BTREE,
   KEY `idx_permission_id` (`permission_id`) USING BTREE,
   CONSTRAINT `fk_role_perm_role_id` FOREIGN KEY (`role_id`) REFERENCES `sys_role` (`id`) ON DELETE CASCADE,
   CONSTRAINT `fk_role_perm_perm_id` FOREIGN KEY (`permission_id`) REFERENCES `sys_permission` (`id`) ON DELETE CASCADE

) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色与权限关联表';

权限/菜单表

plain 复制代码
CREATE TABLE sys_permission (
  id         BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '权限ID',
  parent_id  BIGINT DEFAULT 0 COMMENT '父级权限ID (0表示顶级)',
  name       VARCHAR(100) COMMENT '权限/菜单名称',
  perm_key   VARCHAR(100) COMMENT '权限标识符',
  type       INT COMMENT '资源类型 (1-目录, 2-菜单, 3-按钮)',
  path       VARCHAR(200) COMMENT '前端路由路径',
  component  VARCHAR(200) COMMENT '前端组件路径',
  sort_order INT DEFAULT 0 COMMENT '显示排序',
  status     INT DEFAULT 1 COMMENT '状态 (1正常 0停用)'
);

五张表的关系:

表名 作用 关键字段
sys_user 存储用户基本信息 id, user_name, password, nick_name
sys_role 存储角色定义 id, role_name, role_code, description
sys_permission 核心表------存储目录、菜单、按钮三种资源 id, parent_id, name, perm_key, type, path, component
sys_user_role 用户-角色多对多关联 user_id, role_id(联合主键)
sys_role_permission 角色-权限多对多关联 role_id, permission_id(联合主键)

重点说说 sys_permission ------它是整个权限体系的核心。这张表用 parent_id 构建树形结构,通过 type 字段区分三种资源类型:

java 复制代码
// SysPermission.java
@Entity
@Table(name = "sys_permission")
public class SysPermission {
    @Id
    private Long id;
    private Long parentId;       // 父级权限ID (0表示顶级)
    private String name;          // 权限/菜单名称
    private String permKey;       // 权限标识符(如 systemManagement、user、add)
    private Integer type;         // 资源类型 (1-目录, 2-菜单, 3-按钮)
    private String path;          // 前端路由路径
    private String component;     // 前端组件路径
    private Integer sortOrder;    // 显示排序
    private Integer status;       // 状态 (1正常 0停用)
}

type 的三种取值对应了权限的三个层级:

plain 复制代码
type=1 目录(如"系统管理")
  └── type=2 菜单(如"用户管理")
        └── type=3 按钮(如"新增用户")

举个例子,数据库里可能长这样:

plain 复制代码
[目录] systemManagement  →  type=1, perm_key=systemManagement
  [菜单] user             →  type=2, perm_key=user
    [按钮] add            →  type=3, perm_key=add
    [按钮] edit           →  type=3, perm_key=edit
    [按钮] delete         →  type=3, perm_key=delete
  [菜单] role             →  type=2, perm_key=role
    [按钮] add            → type=3, perm_key=add

注意按钮的 permKey 只存了 addedit 这样的短名,不是 sys:user:add 这样的完整路径。完整路径是在后端查询时动态拼接出来的,后面会详细讲。

1.3 权限标识符的设计:短名 + 动态拼接

为什么要这样设计?直接在按钮上存 sys:user:add 不是更简单?

确实更简单,但有两个问题:

  1. 冗余 :如果 add 这个按钮被多个菜单复用(比如用户管理和角色管理都有"新增"按钮),存完整路径会导致重复
  2. 维护成本:如果目录的 permKey 改了,所有子节点的完整路径都要改

所以我选择了只存短名,查询时拼接 的策略。后端 AuthService 中有一个递归方法负责这件事:

java 复制代码
// AuthService.java
private String buildPermKeyPath(SysPermission p, List<SysPermission> allPerms) {
    // 递归向上查找父节点,拼接完整路径
    // 例如:按钮 permKey="add",父菜单 permKey="user",祖父目录 permKey="system"
    // 返回 "system:user:add"
    if (p.getParentId() == null || p.getParentId() == 0) {
        return p.getPermKey();
    }
    SysPermission parent = allPerms.stream()
            .filter(a -> a.getId().equals(p.getParentId()))
            .findFirst().orElse(null);
    String parentPath = buildPermKeyPath(parent, allPerms);
    return parentPath + ":" + p.getPermKey();
}

效果 :前端拿到的按钮权限标识符是 ["system:user:add", "system:role:edit"] 这样的完整路径,一眼就能看出这个按钮属于哪个模块的哪个菜单。

二、后端:三个接口撑起权限数据

后端权限相关的接口只有三个,设计原则是每个接口职责单一,返回的数据前端直接能用

2.1 接口一:GET /auth/user-info ------ 用户信息 + 菜单权限

java 复制代码
@GetMapping("/user-info")
public ResponseEntity<UserInfoVO> userInfo() {
    Long userId = SecurityUtils.getCurrentUserId();  // 从 JWT Token 自动解析
    return ResponseEntity.ok(authService.getUserInfo(userId));
}

返回的 UserInfoVO 包含:

java 复制代码
public class UserInfoVO {
    private Long userId;
    private String userName;
    private List<String> roles;       // 角色编码列表,如 ["admin", "user"]
    private List<String> permissions; // 菜单权限标识符,如 ["systemManagement", "user"]
}

关键细节permissions 只包含 type=1(目录)和 type=2(菜单)的 permKey,不包含按钮权限 。为什么分开?因为菜单权限和按钮权限的使用场景不同------菜单权限用于路由守卫和侧边栏渲染按钮权限用于页面内按钮显隐。混在一起反而增加复杂度。

java 复制代码
// AuthService.java ------ getUserInfo
List<SysPermission> menuPerms = permSet.stream()
        .filter(p -> (p.getType() == 1 || p.getType() == 2))  // 只要目录和菜单
        .collect(Collectors.toList());
return UserInfoVO.of(userId, user.getUserName(), roles, menuPerms);

2.2 接口二:GET /auth/menus ------ 动态菜单树

java 复制代码
@GetMapping("/menus")
public ResponseEntity<List<PermissionTreeNode>> menus() {
    Long userId = SecurityUtils.getCurrentUserId();
    return ResponseEntity.ok(authService.getUserMenus(userId));
}

返回的是一个树形结构,前端拿到后用于渲染侧边栏和提取 permKey:

java 复制代码
// PermissionTreeNode.java
public class PermissionTreeNode {
    private Long id;
    private Long parentId;
    private String name;
    private String permKey;
    private Integer type;
    private String path;
    private String component;
    private Integer sortOrder;
    private List<PermissionTreeNode> children = new ArrayList<>();
}

树形构建的核心逻辑

  1. sys_role_permission 查出用户所有角色拥有的权限 ID
  2. sys_permission 查出所有权限记录(扁平列表)
  3. 过滤出用户有权限的 type=1/2 节点
  4. 补全父节点链------如果用户只有"用户管理"菜单的权限,但没有"基础信息"目录的权限,也要把"基础信息"目录加进来,否则树形结构会断
  5. 递归构建树形结构
java 复制代码
// 补全父节点链,确保树形结构完整
private void addAncestors(SysPermission p, List<SysPermission> allPerms, Set<Long> visibleIds) {
    if (p.getParentId() == null || p.getParentId() == 0) return;
    visibleIds.add(p.getParentId());
    allPerms.stream()
            .filter(a -> a.getId().equals(p.getParentId()))
            .findFirst()
            .ifPresent(parent -> addAncestors(parent, allPerms, visibleIds));
}

为什么要补全父节点? 想象一下:用户只有"用户管理"(type=2)的权限,没有"基础信息"(type=1)的权限。如果不补全父节点,侧边栏会渲染出一个没有父级的孤立菜单项,样式和层级都会出问题。补全之后,侧边栏能正确渲染出"基础信息 > 用户管理"的层级结构。

2.3 接口三:GET /auth/buttons ------ 按钮权限

java 复制代码
@GetMapping("/buttons")
public ResponseEntity<List<String>> buttons() {
    Long userId = SecurityUtils.getCurrentUserId();
    return ResponseEntity.ok(authService.getUserButtonPerms(userId));
}

返回的是完整路径格式的按钮权限列表:

json 复制代码
["system:user:add", "system:user:edit", "system:role:add", "system:role:delete"]

核心逻辑

  1. 查出用户所有角色的权限 ID
  2. 从所有权限记录中筛选 type=3(按钮)的节点
  3. 对每个按钮调用 buildPermKeyPath() 拼接完整路径
java 复制代码
// AuthService.java ------ getUserButtonPerms
for (SysPermission p : allPerms) {
    if (permIds.contains(p.getId()) && p.getType() == 3 && p.getPermKey() != null) {
        String fullPath = buildPermKeyPath(p, allPerms);  // 递归拼接路径
        if (fullPath != null && !fullPath.isEmpty()) {
            buttonPerms.add(fullPath);
        }
    }
}

2.4 安全层:Spring Security + JWT

所有权限接口都需要携带有效的 JWT Token。后端通过 Spring Security 的过滤器链实现:

java 复制代码
// SecurityConfig.java
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/auth/login", "/auth/captcha/**", "/auth/rsa/**").permitAll()
    .anyRequest().authenticated()  // 其他接口都要认证
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

JwtAuthenticationFilter 在每个请求到达 Controller 之前执行,从 Authorization 头提取 Token,解析出 userId,写入 SecurityContext。后续 Controller 可以直接通过 SecurityUtils.getCurrentUserId() 获取当前用户 ID:

java 复制代码
// SecurityUtils.java
public static Long getCurrentUserId() {
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    if (auth != null && auth.getPrincipal() instanceof Long) {
        return (Long) auth.getPrincipal();
    }
    return null;
}

这里有一个重要的设计决策JwtAuthenticationFilter 只做 Token 校验和用户身份注入不做权限校验。权限校验交给前端的路由守卫和按钮指令。为什么?因为前后端分离架构下,后端只负责提供数据,前端负责展示控制。后端做权限校验(如 URL 级别的访问控制)是"防君子不防小人"------真正要保护数据安全,应该在业务逻辑层校验,而不是在网关层。


三、前端:路由守卫的完整链路

3.1 登录后的第一次请求:权限数据加载

用户登录成功后,前端会拿到 Token 存入 localStorage。但权限数据不在登录时加载,而是在用户第一次访问需要权限的页面时,由路由守卫触发加载。

这个设计的原因是:登录接口只返回 Token 和基本信息,不返回权限数据。权限数据的获取需要有效的 Token,所以必须先登录、拿到 Token、再请求权限接口

路由守卫 permissionPlugin 的核心逻辑:

typescript 复制代码
// permissionPlugin.ts
async beforeEach({ to }) {
  const userStore = useUserStore()

  // 1. 白名单直接放行
  if (WHITE_LIST.includes(to.path)) return undefined

  // 2. Token 校验与恢复
  if (!userStore.token) {
    const isRestored = userStore.restoreLoginState()  // 从 localStorage 恢复
    if (!isRestored) {
      return `/login?redirect=${to.path}`  // 未登录 → 跳登录页
    }

    // 2a. 权限数据已加载则跳过
    if (!userStore.authLoaded) {
      // 2b. 优先从 sessionStorage 恢复(页面刷新后避免重复 API 调用)
      const cached = userStore.restoreAuthFromCache()
      if (!cached) {
        // 2c. 缓存未命中 → 从后端拉取
        try {
          await userStore.fetchAuthPerms(String(userStore.userId))
        } catch {
          userStore.clearAuth()
          userStore.clearToken()
          return `/login?redirect=${to.path}`  // Token 过期 → 跳登录页
        }
      } else {
        // 从缓存恢复后需要重建路由表
        usePermissionStore().collectRoutes()
      }
    }
  }

  // 3. 权限标识检查
  const permKey = getValidPermKey(to.meta)
  if (permKey) {
    if (!userStore.menuPermKeys.includes(permKey)) {
      // 无权限 → 在同模块内找第一个有权限的子路由
      const moduleName = to.matched.find(
        r => r.name && MODULE_ROUTE_NAMES.includes(r.name as string)
      )?.name
      if (moduleName) {
        const modDef = MODULE_DEFS.find(m => m.key === moduleName)
        const fallback = modDef?.entryRoutes?.find(
          name => userStore.menuPermKeys.includes(name)
        )
        if (fallback) return { name: fallback }
      }
      return '/errorPage/403'  // 找不到 → 跳 403
    }
  }
}

三层缓存策略,减少不必要的 API 请求:

plain 复制代码
第 1 层:内存(userStore.authLoaded)
  ↓ 未命中
第 2 层:sessionStorage(页面刷新后恢复)
  ↓ 未命中
第 3 层:后端 API(fetchAuthPerms)

为什么要这样设计?因为权限数据很少变化(只有管理员修改权限时才会变),如果每次都请求后端接口,既浪费带宽又增加延迟。sessionStorage 在标签页关闭时自动清除,保证了权限数据的时效性。

3.2 权限数据的三层结构

fetchAuthPerms 一次请求三个接口,拿到三类数据:

typescript 复制代码
// userStore.fetchAuthPerms
async fetchAuthPerms(userId: string) {
  // 1. 用户信息 + 菜单权限标识
  const authInfo = await AuthAPI.getUserInfo(userId)
  this.roles = authInfo.roles
  this.perms = authInfo.perms

  // 2. 按钮权限标识(完整路径)
  const buttonPerms = await AuthAPI.getButtons()
  this.buttonPerms = buttonPerms

  // 3. 动态菜单树
  const menu = await AuthAPI.getMenus(userId)
  this.menuTree = menu

  // 缓存到 sessionStorage
  this.authLoaded = true
  this.saveAuthToCache()

  // 重建路由表,刷新侧边栏
  usePermissionStore().collectRoutes()
}

三个数据各司其职

数据 来源接口 用途 存储位置
perms /auth/user-info usePermission 组合式函数的权限/角色判断 userStore
buttonPerms /auth/buttons v-permission 指令和 useButtonPermission 的按钮显隐 userStore
menuTree /auth/menus 提取menuPermKeys 供路由守卫校验,渲染侧边栏 userStore

menuPermKeys** 是一个计算属性**,从 menuTree 中递归提取所有非空的 permKey

typescript 复制代码
// userStore.menuPermKeys
menuPermKeys(state): string[] {
  const keys = new Set<string>()
  function walk(nodes: MenuTree[]) {
    for (const node of nodes) {
      const k = node.permKey?.trim()
      if (k) keys.add(k)
      if (node.children?.length) walk(node.children)
    }
  }
  walk(state.menuTree)
  return Array.from(keys)
}

这个属性是路由守卫判断用户是否有权访问某个页面的唯一数据源 。为什么不用 perms?因为 perms 只包含 permKey 列表,而 menuPermKeys 是从实际的菜单树中提取的------如果后端菜单树里没有某个节点,即使 perms 里有对应的 permKey,路由守卫也不会放行。这样保证了权限数据的一致性

3.3 路由定义中的 permKey

每个路由都可以在 meta.permKey 中声明自己的权限标识:

typescript 复制代码
// systemManagement.ts
{
  path: '/systemManagement',
  name: 'systemManagement',
  meta: {
    title: 'systemManagement',
    description: '系统管理',
    icon: 'system',
    permKey: 'systemManagement',  // 目录级权限标识
  },
  children: [
    {
      path: 'user',
      name: 'user',
      meta: {
        title: 'user',
        description: '用户管理',
        icon: 'user',
        permKey: 'user',  // 菜单级权限标识
      },
    },
    {
      path: 'role',
      name: 'role',
      meta: {
        title: 'role',
        description: '角色管理',
        permKey: 'role',
      },
    },
  ]
}

路由守卫的校验流程

plain 复制代码
用户访问 /systemManagement/user
  ↓
permissionPlugin 检查 meta.permKey = 'user'
  ↓
检查 userStore.menuPermKeys 是否包含 'user'
  ↓ 包含 → 放行
  ↓ 不包含
    ↓ 在 systemManagement 模块内找 entryRoutes 中第一个有权限的
    ↓ 找到 → 重定向
    ↓ 找不到 → 跳 403

兜底重定向 是一个很实用的设计。假设用户没有"用户管理"的权限,但有"角色管理"的权限。当用户访问 /systemManagement/user 时,路由守卫不会直接跳 403,而是先在 systemManagement 模块的 entryRoutes 中找第一个用户有权限的路由:

typescript 复制代码
// config/modules.ts
{
  key: 'systemManagement',
  entryRoutes: [
    'Dashboard', 'baseInfo', 'dept', 'user', 'industryManagement',
    'industryClassification', 'dictSetting', 'permission', 'role', 'menu',
  ],
}

如果用户有 role 的权限,就重定向到角色管理页面。只有所有 entryRoutes 都没权限,才跳 403。这样用户体验好很多------不会因为点错一个菜单就看到冷冰冰的 403 页面。

3.4 权限数据的缓存与恢复

页面刷新是权限系统最容易出 bug 的场景。用户登录后刷新页面,Pinia 状态会丢失,需要重新加载权限数据。但如果每次都请求后端接口,体验会很差(刷新后要等几秒才能看到页面)。

我的解决方案是三层缓存

typescript 复制代码
// 缓存结构
interface AuthCache {
  roles: string[]
  perms: string[]
  buttonPerms: string[]
  menuTree: MenuTree[]
}

// 保存到 sessionStorage
saveAuthToCache() {
  const cache: AuthCache = {
    roles: this.roles,
    perms: this.perms,
    buttonPerms: this.buttonPerms,
    menuTree: this.menuTree,
  }
  sessionStorage.setItem(CacheKey.AUTH_CACHE, JSON.stringify(cache))
}

// 从 sessionStorage 恢复
restoreAuthFromCache(): boolean {
  const raw = sessionStorage.getItem(CacheKey.AUTH_CACHE)
  if (!raw) return false
  const cache: AuthCache = JSON.parse(raw)
  this.roles = cache.roles
  this.perms = cache.perms
  this.buttonPerms = cache.buttonPerms
  this.menuTree = cache.menuTree
  this.authLoaded = true
  return true
}

路由守卫中的恢复逻辑:

typescript 复制代码
// permissionPlugin.ts
if (!userStore.authLoaded) {
  const cached = userStore.restoreAuthFromCache()
  if (cached) {
    // 从缓存恢复 → 重建路由表(路由注册状态也需要恢复)
    usePermissionStore().collectRoutes()
  } else {
    // 缓存未命中 → 请求后端
    await userStore.fetchAuthPerms(String(userStore.userId))
  }
}

为什么用 sessionStorage 而不是 localStorage? 因为**权限数据应该跟随标签页的生命周期**。用户打开多个标签页时,每个标签页有独立的 sessionStorage,互不干扰。标签页关闭后自动清除,保证下次打开时重新获取最新权限数据。


四、前端:按钮级权限的三种实现方式

菜单权限控制的是"用户能看到哪些页面",按钮权限控制的是"用户在页面里能做哪些操作"。 我提供了三种方式来控制按钮显隐,覆盖不同的使用场景。

4.1 方式一:v-permission 指令(模板声明式)

最简单直接的方式,在模板中用自定义指令控制按钮显隐:

vue 复制代码
<!-- 单个权限标识 -->
<el-button v-permission="'system:user:add'">新增用户</el-button>
<!-- 多个权限标识(OR 逻辑) -->
<el-button v-permission="['system:user:add', 'system:user:edit']">批量操作</el-button>
<!-- 无参数:始终显示 -->
<el-button>公开功能</el-button>

实现原理:

typescript 复制代码
// directives/permission.ts
function checkPermission(perm?: string | string[]): boolean {
  const userStore = useUserStore()
  const perms = userStore.buttonPerms ?? []

  if (!perm) return true
  if (perms.includes('*:*:*') || perms.includes('*')) return true  // 通配符
  if (typeof perm === 'string') return perms.includes(perm)
  if (Array.isArray(perm)) return perm.some(p => perms.includes(p))
  return false
}

app.directive('permission', {
  mounted(el, binding) {
    if (!checkPermission(binding.value)) {
      el.parentNode?.removeChild(el)  // 无权限 → 从 DOM 移除(不是隐藏)
    }
  },
  updated(el, binding) {
    if (!checkPermission(binding.value)) {
      el.parentNode?.removeChild(el)
    }
  },
})

两个关键设计

  1. 移除而非隐藏 :无权限时直接从 DOM 移除元素,而不是 display: none。这样用户即使打开开发者工具也看不到被隐藏的按钮,安全性更高
  2. 支持通配符 :管理员角色通常需要所有权限,*:*:* 通配符避免了给管理员配置几百个权限的麻烦

4.2 方式二:useButtonPermission Hook(脚本式)

当按钮需要在 JavaScript 逻辑中判断权限时(比如操作列的按钮组),用组合式函数更合适:

typescript 复制代码
// hooks/useButtonPermission.ts
export default () => {
  const userStore = useUserStore()

  const buttonHasPermission = (buttonPermission: any) => {
    const permKey = getPermKey(buttonPermission)
    if (!permKey) return true

    // 新系统:从 userStore.buttonPerms 查找
    const { buttonPerms } = userStore
    if (buttonPerms.length > 0) {
      if (buttonPerms.includes('*:*:*') || buttonPerms.includes('*')) return true
      return buttonPerms.includes(permKey)
    }

    // 旧系统:从 route.meta.buttonList 查找(兼容)
    // ...
  }

  const buttonsHasPermission = (buttons: any[]) => {
    return buttons.filter(item => {
      if (item.buttonPermission) return buttonHasPermission(item.buttonPermission)
      return true
    })
  }

  return { buttonHasPermission, buttonsHasPermission }
}

使用示例:

vue 复制代码
<script setup>
const { buttonsHasPermission } = useButtonPermission()

const operationButtons = computed(() => {
  const buttons = [
    { label: '编辑', type: 'primary', buttonPermission: 'system:user:edit' },
    { label: '删除', type: 'danger', buttonPermission: 'system:user:delete' },
  ]
  return buttonsHasPermission(buttons)  // 过滤掉无权限的按钮
})
</script>
<template>
  <el-button v-for="btn in operationButtons" :key="btn.label" :type="btn.type">
    {{ btn.label }}
  </el-button>
</template>

4.3 方式三:usePermission Hook(通用权限/角色判断)

当需要在 JavaScript 中同时判断权限和角色时,用这个 hook:

typescript 复制代码
// hooks/usePermission.ts
export function usePermission() {
  const userStore = useUserStore()

  // 校验权限标识(OR 逻辑)
  function hasPermission(perm?: string | string[]): boolean {
    const perms = userStore.perms ?? []
    if (!perm) return true
    if (typeof perm === 'string') return perms.includes(perm)
    if (Array.isArray(perm)) return perm.some(p => perms.includes(p))
    return false
  }

  // 校验角色编码
  function hasRole(role: string | string[]): boolean {
    const roles = userStore.roles ?? []
    if (typeof role === 'string') return roles.includes(role)
    if (Array.isArray(role)) return role.some(r => roles.includes(r))
    return false
  }

  // 校验所有权限(AND 逻辑)
  function hasAllPerms(perms: string[]): boolean {
    const userPerms = userStore.perms ?? []
    return perms.every(p => userPerms.includes(p))
  }

  return { hasPermission, hasRole, hasAllPerms }
}

三种 hook 的适用场景

Hook 数据源 适用场景
v-permission userStore.buttonPerms 模板中控制按钮显隐
useButtonPermission userStore.buttonPerms 操作列按钮组过滤
usePermission userStore.perms + userStore.roles 逻辑分支判断、角色级别功能开关

4.4 页面级权限校验

除了路由守卫的拦截,我还在页面组件内部加了一层兜底校验

typescript 复制代码
// hooks/usePagePermission.ts
export function usePagePermission(permKey: string, pageName: string) {
  const router = useRouter()
  const userStore = useUserStore()

  onMounted(() => {
    if (!userStore.menuPermKeys.includes(permKey)) {
      ElMessage.warning(`暂无「${pageName}」的访问权限,请联系管理员开通`)
      router.replace({ path: '/stockFund/workbench' })
    }
  })
}

为什么路由守卫已经拦截了,还要在页面里再校验一次? 因为路由守卫的拦截有"时间差"------用户直接通过 URL 访问页面时,路由守卫会拦截。但如果用户是通过侧边栏点击进入的,路由守卫的 permKey 检查可能不会触发(因为有些路由的 meta.permKey 是空的)。页面级校验是一个安全兜底,确保即使路由守卫漏掉了,页面自己也能拦截。

五、工作台:按权限过滤模块入口

工作台是用户登录后看到的第一个页面,展示了所有可用的模块卡片。这里的权限过滤逻辑很直观------用户没有权限的模块不显示

vue 复制代码
<!-- workbench/index.vue -->
<script setup>
const accessibleModules = computed(() => {
  return MODULE_DEFS.filter(m => !m.permKey || userStore.menuPermKeys.includes(m.permKey))
})
</script>

点击模块卡片时,通过 useRoutePermission hook 做带权限校验的跳转:

typescript 复制代码
const { navigateWithPermission, hasRoutePermission } = useRoutePermission()

function enterModule(mod) {
  // 有 entryRoutes 时,找第一个用户有权限的路由跳转
  if (mod.entryRoutes?.length) {
    const target = mod.entryRoutes.find(name => hasRoutePermission(name))
    if (target) {
      navigateWithPermission(target)
      return
    }
    router.push('/errorPage/403')
    return
  }
  router.push({ path: mod.path })
}

模块入口路由的优先级entryRoutes 数组中的路由按优先级排列。比如 systemManagement 模块的 entryRoutes 是 ['Dashboard', 'baseInfo', 'dept', 'user', ...],用户点击"系统管理"卡片时,会依次检查这些路由的权限,跳转到第一个有权限的页面。


六、数据流全景图

把前后端串起来看,整个权限体系的数据流是这样的:

plain 复制代码
┌────────────────────────────────────────────────────────────------─┐
│                        后端 (Spring Boot)                     │
│                                                               │
│  sys_user ──▶ sys_user_role ──▶ sys_role                     │
│                                      │                        │
│                              sys_role_permission              │
│                                      │                        │
│                              sys_permission                   │
│                               (type=1/2/3)                    │
│                                     │                         │
│             ┌───────────────────────┼───────────────────┐     │
│             ▼                       ▼                   ▼      │
│      /auth/user-info          /auth/menus         /auth/buttons│
│      (roles + perms)         (菜单树)           (按钮权限)      │
└─────────────────────────────────────────────────────────────---------┘
                          │
                          │  HTTP (Bearer Token)
                          ▼
┌─────────────────────────────────────────────────────────────┐
│                        前端 (Vue 3)                          │
│                                                             │
│  fetchAuthPerms() ──▶ userStore                              │
│    ├── roles: ['admin', 'user']                              │
│    ├── perms: ['systemManagement', 'user']                   │
│    ├── buttonPerms: ['system:user:add', 'system:user:edit']  │
│    └── menuTree: [...]                                       │
│         │                                                    │
│         ├──▶ menuPermKeys (计算属性)                          │
│         │      │                                             │
│         │      ├──▶ permissionPlugin (路由守卫)               │
│         │      │      检查 meta.permKey 是否在 menuPermKeys 中│
│         │      │                                             │
│         │      └──▶ workbench (工作台)                        │
│         │             过滤 accessibleModules                  │
│         │                                                    │
│         ├──▶ buttonPerms                                     │
│         │      │                                             │
│         │      ├──▶ v-permission 指令 (模板显隐)              │
│         │      └──▶ useButtonPermission (操作列按钮)         │
│         │                                                    │
│         └──▶ roles + perms                                   │
│                │                                             │
│                └──▶ usePermission (角色/权限逻辑判断)         │
└─────────────────────────────────────────────────────────────┘

七、踩过的坑和设计取舍

7.1 为什么菜单权限和按钮权限分开获取?

最开始我把菜单权限和按钮权限放在一个接口里返回,后来发现有问题:

  • 菜单权限用于路由守卫和侧边栏渲染,在页面加载时就需要
  • 按钮权限用于页面内按钮显隐,在具体页面组件里才需要

如果混在一起,每次刷新页面都要加载全部权限数据(包括几百个按钮权限),浪费带宽。分开之后,按钮权限可以延迟加载(虽然目前是和菜单权限一起加载的,但架构上已经解耦了)。

7.2 为什么不用动态路由注册?

很多权限系统会根据后端返回的菜单树动态注册路由(router.addRoute()),只注册用户有权限的路由。但我选择了全量注册 + 守卫拦截的方案。

原因:

  1. 简单:全量注册不需要处理路由的动态添加/移除逻辑,减少 bug
  2. 侧边栏渲染 :全量注册后,侧边栏的数据来源是 permissionStore.dynamicRoutes,不需要额外处理
  3. 权限校验 :路由守卫通过 meta.permKeymenuPermKeys 做权限判断,效果和动态路由一样------无权限的页面进不去
  4. 开发体验好 :在工作的开发中,公司使用的是动态注册路由,每次开发新功能界面,需要先注册路由然后才能显示导航栏菜单,本地开发调试时很不方便,所以我想让菜单路由由前后端一起控制,后端控制有权限的界面显示与隐藏,前端可以配置这个界面是否有权限

当然这个方案也有缺点:用户在浏览器的地址栏手动输入无权限的 URL,能看到路由存在(虽然进不去)。对于个人项目来说,这个缺点可以接受。

7.3 sessionStorage vs localStorage

权限数据用 sessionStorage 而不是 localStorage,Token 用 localStorage。这个区别很重要:

  • Token 用 localStorage因为 Token 是长期有效的(24 小时),用户关闭浏览器再打开应该还是登录状态
  • 权限数据用 sessionStorage因为权限数据应该跟随标签页生命周期。如果用 localStorage,用户在 A 标签页登出后,B 标签页的权限数据还在,可能导致权限不一致

7.4 permKey 的命名规范

权限标识符的命名没有强制规范(比如必须用冒号分隔),但我在实践中形成了一套约定:

  • 目录级:systemManagementstockFundaiModule
  • 菜单级:userrolestockQuoterag
  • 按钮级:后端自动拼接为 system:user:addsystem:role:edit

前端路由定义中的 permKey 和后端数据库中的 permKey 必须一一对应 。这是最容易出错的地方------如果前端写了 permKey: 'user' 但数据库里存的是 sys_user,权限校验就会失败。

八、总结

这套权限体系的复杂度比我预期的要高。它涉及五张数据库表、三个后端接口、一个路由守卫插件、三种前端权限判断方式、三层缓存策略。但拆开来看,每一部分的逻辑都不复杂------复杂的是它们之间的协作关系。

维度 实现方式 说明
数据模型 RBAC 五张表 ---
权限标识 短名存储 + 动态拼接 冗余低,维护方便
后端接口 /user-info + /menus + /buttons 三个接口各司其职,职责清晰
路由守卫 permissionPlugin 插件 和进度条等守卫解耦,好维护
菜单权限 meta.permKey + menuPermKeys 数据源统一,不会出现"权限和菜单不一致"
按钮权限 v-permission + useButtonPermission 声明式 + 脚本式,覆盖所有场景
缓存策略 内存 → sessionStorage → 后端 API 刷新页面不卡顿,标签页隔离
兜底重定向 同模块 entryRoutes 优先 用户体验好,不会直接看到 403

权限控制的本质是"最小权限原则"------用户只能看到和操作他被授权的资源。实现这个原则不难,难的是在保证安全的同时不影响用户体验。路由守卫的兜底重定向、sessionStorage 的缓存恢复、通配符的支持,这些都是在"安全"和"体验"之间找平衡。

相关推荐
Z_Quintaz1 小时前
关于导航栏颜色透明这件事
前端·debug
默_笙1 小时前
🚅 地铁的"回头路、存档点与人工闸机"(下):LangGraph 的循环、持久化与中断
前端·javascript
JoyT1 小时前
Spring AI 2.0 Agent 进阶:Memory、State 与 Context Engineering 常见技术全景
后端
ClouGence1 小时前
Chrome Recorder 能用于长期回归测试吗?
前端·chrome·测试
拖孩1 小时前
这个小程序是 AI 帮我写的,可它里面一个 AI 功能都没有
前端·后端·微信小程序
许彰午2 小时前
50-18个表单控件
java·低代码·架构
leeyi3 小时前
Agent 要用 API key,但明文一次都不能进模型——Secret Runtime 落地实录(第110篇)
后端·aigc·agent
Nayana3 小时前
《Web 到 HarmonyOS》-- 业务分析:消息推送业务方法
前端
GreenTea3 小时前
发布 3 天登顶 HN:不生成一个字的模型 Jev,我把它的源码和黑料都扒了一遍
前端·后端·算法