Skip to content
第 66 / 250 章后端⏱ 10 分钟阅读

第 66 章:RBAC 权限模型

学习目标

  • 深入理解 RBAC0/1/2/3 四种模型
  • 设计完整的用户-角色-权限数据库结构
  • 实现按钮级、菜单级、数据级三种粒度的权限控制

一、什么是 RBAC?

RBAC = Role-Based Access Control,基于角色的访问控制

核心思想:不直接给用户赋权限,而是给用户分配角色,给角色赋权限。 改一次角色权限,所有该角色的用户都生效。

二、RBAC 四层模型

模型含义实战价值
RBAC0用户-角色-权限基本关系90% 系统的核心
RBAC1角色继承(主管继承员工)大型组织
RBAC2角色互斥(财务不能兼出纳)合规要求
RBAC3RBAC1 + RBAC2复杂权限系统

三、数据库设计

基础五张表

sql
-- ① 用户表
CREATE TABLE sys_user (
    id BIGINT PRIMARY KEY,
    username VARCHAR(50) UNIQUE NOT NULL,
    nickname VARCHAR(50),
    password VARCHAR(100) NOT NULL,
    status TINYINT DEFAULT 1 COMMENT '0=禁用 1=正常',
    dept_id BIGINT COMMENT '部门 ID',
    tenant_id BIGINT NOT NULL,
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    is_deleted TINYINT DEFAULT 0,
    KEY idx_dept (dept_id)
);

-- ② 角色表
CREATE TABLE sys_role (
    id BIGINT PRIMARY KEY,
    name VARCHAR(50) NOT NULL COMMENT '角色名称',
    code VARCHAR(50) UNIQUE NOT NULL COMMENT '角色编码:ROLE_ADMIN',
    sort INT DEFAULT 0,
    status TINYINT DEFAULT 1,
    data_scope TINYINT DEFAULT 4 COMMENT '数据权限范围',
    tenant_id BIGINT NOT NULL,
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    is_deleted TINYINT DEFAULT 0
);

-- ③ 菜单/权限表(合一)
CREATE TABLE sys_menu (
    id BIGINT PRIMARY KEY,
    parent_id BIGINT DEFAULT 0,
    name VARCHAR(50) NOT NULL COMMENT '菜单名',
    type TINYINT NOT NULL COMMENT '1=目录 2=菜单 3=按钮',
    permission VARCHAR(100) COMMENT '权限标识:user:create',
    path VARCHAR(200) COMMENT '前端路径',
    component VARCHAR(200) COMMENT '前端组件',
    icon VARCHAR(50),
    sort INT DEFAULT 0,
    status TINYINT DEFAULT 1,
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    is_deleted TINYINT DEFAULT 0
);

-- ④ 用户-角色关系
CREATE TABLE sys_user_role (
    user_id BIGINT NOT NULL,
    role_id BIGINT NOT NULL,
    PRIMARY KEY (user_id, role_id),
    KEY idx_role (role_id)
);

-- ⑤ 角色-菜单/权限关系
CREATE TABLE sys_role_menu (
    role_id BIGINT NOT NULL,
    menu_id BIGINT NOT NULL,
    PRIMARY KEY (role_id, menu_id)
);

角色继承(RBAC1)

sql
-- 父角色继承子角色
CREATE TABLE sys_role_relation (
    parent_role_id BIGINT NOT NULL,
    child_role_id BIGINT NOT NULL,
    PRIMARY KEY (parent_role_id, child_role_id)
);
-- 主管继承组员的所有权限

角色互斥(RBAC2)

sql
-- 不能同时拥有的角色组合
CREATE TABLE sys_role_conflict (
    role_id_1 BIGINT NOT NULL,
    role_id_2 BIGINT NOT NULL,
    PRIMARY KEY (role_id_1, role_id_2)
);
-- 财务和出纳不能同时分配

四、菜单权限设计

sql
INSERT INTO sys_menu (id, parent_id, name, type, permission, path) VALUES
-- 一级目录
(1, 0, '系统管理', 1, NULL, '/system'),

-- 二级菜单
(10, 1, '用户管理', 2, NULL, '/system/user'),
(11, 1, '角色管理', 2, NULL, '/system/role'),
(12, 1, '菜单管理', 2, NULL, '/system/menu'),

-- 三级按钮
(100, 10, '用户新增', 3, 'user:create', NULL),
(101, 10, '用户编辑', 3, 'user:update', NULL),
(102, 10, '用户删除', 3, 'user:delete', NULL),
(103, 10, '用户查询', 3, 'user:query', NULL);

type 字段是精髓

  • 1 = 目录(左侧菜单分组)
  • 2 = 菜单(左侧菜单项 + 路由)
  • 3 = 按钮(页面内的按钮)

五、查询当前用户的权限

java
@Service
@RequiredArgsConstructor
public class PermissionService {

    private final RedisTemplate<String, Object> redis;
    private final UserMapper userMapper;
    private final RoleMapper roleMapper;
    private final MenuMapper menuMapper;

    // ① 获取用户的所有权限(从 Redis 缓存)
    public Set<String> getUserPermissions(Long userId) {
        Set<String> cached = (Set<String>) redis.opsForValue()
                .get("user:perms:" + userId);
        if (cached != null) return cached;

        // ② 从 DB 查询
        Set<String> perms = new HashSet<>();
        List<Role> roles = roleMapper.selectByUserId(userId);
        for (Role role : roles) {
            List<Menu> menus = menuMapper.selectByRoleId(role.getId());
            for (Menu menu : menus) {
                if (menu.getType() == 3 && menu.getPermission() != null) {
                    perms.add(menu.getPermission());     // user:create 等
                }
            }
        }

        // ③ 写缓存(30 分钟)
        redis.opsForValue().set(
                "user:perms:" + userId, perms, 30, TimeUnit.MINUTES);
        return perms;
    }

    // ④ 获取用户的菜单树(前端用)
    public List<MenuTreeVO> getUserMenuTree(Long userId) {
        List<Menu> all = menuMapper.selectByUserId(userId);
        return buildTree(all, 0L);
    }

    private List<MenuTreeVO> buildTree(List<Menu> all, Long parentId) {
        return all.stream()
                .filter(m -> m.getParentId().equals(parentId))
                .map(m -> {
                    MenuTreeVO vo = new MenuTreeVO();
                    BeanUtils.copyProperties(m, vo);
                    vo.setChildren(buildTree(all, m.getId()));
                    return vo;
                })
                .sorted(Comparator.comparing(MenuTreeVO::getSort))
                .toList();
    }

    // ⑤ 权限变更时清理缓存
    public void clearCache(Long userId) {
        redis.delete("user:perms:" + userId);
    }
}

六、权限注解

java
// ① 按钮级权限(细粒度)
@Service
public class UserService {

    @PreAuthorize("hasAuthority('user:create')")
    public void create(UserDTO dto) { ... }

    @PreAuthorize("hasAuthority('user:update')")
    public void update(UserDTO dto) { ... }

    @PreAuthorize("hasAuthority('user:delete')")
    public void delete(Long id) { ... }
}

// ② 自定义注解(更可读)
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasAuthority('{value}')")
public @interface RequiresPermission {
    String value();    // "user:create"
}

// 用法
@RequiresPermission("user:create")
public void create(UserDTO dto) { ... }
java
// ③ 自定义注解(角色)
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('{value}')")
public @interface RequiresRole {
    String value();
}

@RequiresRole("ADMIN")
@DeleteMapping("/{id}")
public void delete(@PathVariable Long id) { ... }

七、数据权限

场景:销售只能看自己部门的订单,主管能看整个大区,总裁看全部。

sql
-- 数据权限范围
sys_role.data_scope:
1   -- 全部
2   -- 本部门及下级
3   -- 本部门
4   -- 仅本人
5   -- 自定义(指定部门)
java
@Service
@RequiredArgsConstructor
public class DataScopeService {

    private final SecurityUtils securityUtils;
    private final UserMapper userMapper;

    // ① 数据权限过滤
    public void applyDataScope(Wrapper<Order> wrapper) {
        User currentUser = securityUtils.getCurrentUser();
        Role currentRole = currentUser.getRoles().get(0);   // 简化:取第一个角色
        Integer scope = currentRole.getDataScope();

        switch (scope) {
            case 1:  // 全部:不加条件
                break;
            case 2:  // 本部门及下级
                List<Long> deptIds = getDeptAndChildren(currentUser.getDeptId());
                wrapper.in("dept_id", deptIds);
                break;
            case 3:  // 本部门
                wrapper.eq("dept_id", currentUser.getDeptId());
                break;
            case 4:  // 仅本人
                wrapper.eq("create_by", currentUser.getId());
                break;
            case 5:  // 自定义
                List<Long> customDeptIds = customDeptMapper
                        .selectByRoleId(currentRole.getId());
                wrapper.in("dept_id", customDeptIds);
                break;
        }
    }
}

用 MyBatis-Plus 拦截器实现(更优雅)

java
public class DataScopeInnerInterceptor implements InnerInterceptor {

    @Override
    public void beforeQuery(Executor executor, MappedStatement ms,
                             Object parameter, RowBounds rowBounds,
                             ResultHandler resultHandler, BoundSql boundSql) {
        // ① 拿到当前 SQL
        String sql = boundSql.getSql();

        // ② 拿到当前用户的数据权限范围
        DataScope dataScope = SecurityUtils.getCurrentDataScope();

        // ③ 拼接条件
        String newSql = appendCondition(sql, dataScope);

        // ④ 用反射改写 BoundSql 的 SQL(略)
        // 实际项目建议重写 MetaObject
    }
}

八、菜单权限的 Vue 集成

js
// src/store/modules/permission.js
export const usePermissionStore = defineStore('permission', {
    state: () => ({
        menus: [],          // 当前用户的菜单树
        permissions: [],    // 当前用户的按钮权限
    }),

    actions: {
        async loadPermissions() {
            // ① 调接口获取
            const { data } = await api.getUserMenus()
            this.menus = data.menus
            this.permissions = data.permissions
        },

        hasPermission(code) {
            return this.permissions.includes(code)
        },
    },
})

// ② 在按钮上用 v-permission 指令
<el-button v-permission="['user:create']">新增用户</el-button>
<el-button v-permission="['user:delete']">删除</el-button>

// ③ 自定义指令
app.directive('permission', {
    mounted(el, binding) {
        const allowed = binding.value
        const has = usePermissionStore().hasPermission(allowed)
        if (!has) el.parentNode?.removeChild(el)
    },
})

九、权限设计的反模式

反模式 1:权限颗粒度过细

sql
-- ❌ 每个按钮都建一个权限
'user:create', 'user:update', 'user:delete', 'user:export',
'user:import', 'user:reset-password', 'user:enable', 'user:disable',
'user:avatar-upload', 'user:profile-update', ...
-- 几十个按钮 → 维护噩梦

-- ✅ 按业务场景分组
'user:manage'        -- 用户管理(增删改查)
'user:export'        -- 导出(如果业务需要单独控制)

反模式 2:权限耦合到 URL

sql
-- ❌ 用 URL 当权限标识
'GET /api/user/list'
'POST /api/user'

-- ✅ 用业务标识
'user:query'
'user:create'

反模式 3:权限检查散落在代码各处

java
// ❌ 每个方法都写 if 判断
if (currentUser.getId() != order.getUserId()) {
    throw new BusinessException("无权查看");
}

// ✅ 集中在拦截器 / 注解
@PreAuthorize("hasAuthority('order:view') or #order.userId == authentication.principal.id")
public Order getOrder(Long id) { ... }

十、本章小结

要点关键
模型RBAC0 用户-角色-权限(核心)
表设计5 张基础表(user / role / menu / user_role / role_menu)
菜单 type1=目录 2=菜单 3=按钮
按钮权限@PreAuthorize("hasAuthority('user:create')")
菜单权限返回菜单树 + 前端动态生成路由
数据权限data_scope:全部 / 本部门 / 仅本人 / 自定义
性能权限列表 Redis 缓存(30 分钟)+ 权限变更主动清理
颗粒度按业务场景分组,不要太细

动手练习

练习 1:基础题

设计并实现完整 RBAC:5 张表 + 用户管理 CRUD 接口 + 给角色分配菜单/权限的接口。

练习 2:进阶题

实现"数据权限":销售部员工只能看自己部门的订单,主管能看整个销售部,总裁看所有部门。用 MyBatis-Plus 拦截器自动拼接条件。

练习 3:思考题

你的 SaaS 系统支持多租户,多个租户共用一套代码。如何让每个租户独立管理自己的角色和权限?


下一章第 67 章:接口文档 OpenAPI

本站基于 VitePress 构建 · 由 Codebook 团队维护