跳转到内容
123xiao | 无名键客

《Spring Boot + MyBatis 在 Java Web 开发中的实战:基于 RBAC 的后台权限系统设计与接口安全落地》

字数: 0 阅读时长: 1 分钟

背景与问题

后台管理系统做得越久,越容易遇到一个“看起来简单、实际上很容易烂尾”的问题:权限系统

很多项目一开始只有“管理员”和“普通用户”两个角色,接口里直接写:

if (!user.isAdmin()) {
    throw new RuntimeException("无权限");
}

早期跑得飞快,需求一复杂就开始失控:

  • 新增“运营主管”“财务审核员”“只读审计员”时,代码里到处都是 if-else
  • 菜单权限、按钮权限、接口权限三套逻辑各自为政
  • 前端隐藏了按钮,但后端接口没拦,导致“会抓包就能越权”
  • 开发环境一切正常,生产环境因为缓存、事务、权限刷新不及时,出现“刚授权却访问不了”或“已撤权还能调用”的问题

我自己做后台系统时,最深的感受是:权限不是页面功能,而是系统边界。真正难的不是把表建出来,而是让权限模型、接口鉴权、数据访问和运维行为一致。

这篇文章从 Java Web 实战角度,讲一个比较稳妥的方案:Spring Boot + MyBatis + RBAC,重点放在两个问题上:

  1. 权限模型怎么设计,后续扩展不痛苦
  2. 接口安全怎么真正落地,而不是只停留在菜单展示层

方案目标与设计边界

先把目标说清楚,不然后面很容易“边做边加,最后四不像”。

本文的 RBAC 方案适合这类后台系统:

  • 中小型到中大型管理后台
  • 权限以“角色授权”为主,用户可挂多个角色
  • 需要同时控制:
    • 菜单可见性
    • 按钮/操作权限
    • 后端接口访问权限
  • 技术栈:
    • Spring Boot 3.x
    • MyBatis
    • MySQL
    • Spring Web + 拦截器/过滤器

不重点展开的边界:

  • 不深入讲 OAuth2 / SSO / 微服务统一认证中心
  • 不展开复杂 ABAC(属性权限)和 PBAC(策略权限)
  • 不处理超细粒度“按字段脱敏、按行过滤”的完整实现,只给扩展思路

一句话概括本文的架构取舍:

认证尽量简单清晰,授权尽量统一收口,菜单权限和接口权限统一建模。


核心原理

1. RBAC 的本质

RBAC(Role-Based Access Control)不是“用户直接有什么权限”,而是:

用户 -> 角色 -> 权限

这样做的好处是权限变更更稳定。你不需要给 500 个用户逐个授权,只要调整角色绑定即可。

一个常见且够用的模型是:

  • sys_user:用户
  • sys_role:角色
  • sys_permission:权限
  • sys_user_role:用户-角色关联
  • sys_role_permission:角色-权限关联

其中 permission 不仅可以表示菜单,也可以表示按钮和接口资源。


2. 菜单权限和接口权限要不要分开?

我的建议是:逻辑上区分,模型上统一。

比如在 sys_permission 中增加 type 字段:

  • MENU:菜单
  • BUTTON:按钮
  • API:接口

这样有几个好处:

  1. 后台权限模型统一
  2. 前端拿菜单树时按 MENU/BUTTON 过滤
  3. 后端鉴权时按 API 或统一 perm_code 校验
  4. 后续加“导出”“审核”“发布”等按钮,不需要再造一套表

3. 认证与授权分层

很多项目把认证和授权搅在一起,后面排查问题非常难。

建议分成两层:

认证:你是谁

典型做法:

  • 用户登录成功
  • 服务端生成 token(可以是 JWT,也可以是自定义 token)
  • 请求头带上 token
  • 服务端解析 token,识别当前用户身份

授权:你能做什么

典型做法:

  • 根据用户 ID 查询其角色、权限码集合
  • 当前接口标注需要的权限码
  • 拦截器比对是否具备权限

也就是说:

  • 认证失败:401 未登录/登录失效
  • 授权失败:403 无权限

这两个状态要分清,前端处理才不会混乱。


4. 一个够用的权限码设计

权限码建议有层次感,便于管理,例如:

system:user:list
system:user:add
system:user:update
system:user:delete
system:role:assign
order:refund:audit

它的特点是:

  • 便于命名和搜索
  • 能直接映射业务资源
  • 可用于前端按钮控制
  • 可用于后端接口拦截

如果接口比较多,不建议直接按 URL 作为权限标识。URL 更适合作为资源属性,但真正稳定的授权主键应该是权限码


架构设计

整体调用链

flowchart LR
    A[前端请求] --> B[认证过滤器]
    B --> C{Token 是否有效}
    C -- 否 --> X[返回 401]
    C -- 是 --> D[解析用户身份]
    D --> E[权限拦截器]
    E --> F{是否具备接口权限}
    F -- 否 --> Y[返回 403]
    F -- 是 --> G[Controller]
    G --> H[Service]
    H --> I[MyBatis Mapper]
    I --> J[(MySQL)]

这个调用链里最关键的是:权限检查发生在进入业务之前
不要把权限校验散落到每个 Service 里,否则维护成本会越来越高。


权限模型关系图

classDiagram
    class SysUser {
      +Long id
      +String username
      +String password
      +Integer status
    }

    class SysRole {
      +Long id
      +String roleCode
      +String roleName
      +Integer status
    }

    class SysPermission {
      +Long id
      +String permCode
      +String permName
      +String type
      +String path
      +String method
      +Integer status
    }

    SysUser "1..*" --> "0..*" SysRole : user_role
    SysRole "1..*" --> "0..*" SysPermission : role_permission

数据库设计

表结构设计

这里给一套可以直接落地的 MySQL 表结构,做演示足够了。

CREATE TABLE sys_user (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(64) NOT NULL UNIQUE,
    password VARCHAR(128) NOT NULL,
    nickname VARCHAR(64) DEFAULT NULL,
    status TINYINT NOT NULL DEFAULT 1,
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE sys_role (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    role_code VARCHAR(64) NOT NULL UNIQUE,
    role_name VARCHAR(64) NOT NULL,
    status TINYINT NOT NULL DEFAULT 1,
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE sys_permission (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    perm_code VARCHAR(128) NOT NULL UNIQUE,
    perm_name VARCHAR(128) NOT NULL,
    type VARCHAR(16) NOT NULL,
    path VARCHAR(255) DEFAULT NULL,
    method VARCHAR(16) DEFAULT NULL,
    status TINYINT NOT NULL DEFAULT 1,
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE sys_user_role (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    role_id BIGINT NOT NULL,
    UNIQUE KEY uk_user_role (user_id, role_id)
);

CREATE TABLE sys_role_permission (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    role_id BIGINT NOT NULL,
    permission_id BIGINT NOT NULL,
    UNIQUE KEY uk_role_perm (role_id, permission_id)
);

初始化数据

INSERT INTO sys_user (username, password, nickname) VALUES
('admin', '123456', '超级管理员'),
('operator', '123456', '运营人员');

INSERT INTO sys_role (role_code, role_name) VALUES
('ADMIN', '管理员'),
('OPERATOR', '运营');

INSERT INTO sys_permission (perm_code, perm_name, type, path, method) VALUES
('system:user:list', '用户列表', 'API', '/api/users', 'GET'),
('system:user:add', '新增用户', 'API', '/api/users', 'POST'),
('system:role:assign', '角色分配', 'API', '/api/roles/assign', 'POST'),
('dashboard:view', '首页查看', 'MENU', '/dashboard', 'GET');

INSERT INTO sys_user_role (user_id, role_id) VALUES
(1, 1),
(2, 2);

INSERT INTO sys_role_permission (role_id, permission_id) VALUES
(1, 1), (1, 2), (1, 3), (1, 4),
(2, 1), (2, 4);

实际项目里密码一定要加密存储,这里为了让代码可运行、结构清晰,先用明文演示,后文会讲安全实践。


实战代码(可运行)

下面给一个精简但能跑通思路的实现。为了突出 RBAC 核心,登录 token 先用内存模拟,不引入 JWT 依赖。

1. Maven 依赖

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.example</groupId>
    <artifactId>rbac-demo</artifactId>
    <version>1.0.0</version>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.2</version>
    </parent>

    <properties>
        <java.version>17</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>3.0.3</version>
        </dependency>

        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>
</project>

2. 配置文件

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/rbac_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: root
    password: root

mybatis:
  mapper-locations: classpath:mapper/*.xml
  type-aliases-package: com.example.rbacdemo.domain

server:
  port: 8080

3. 启动类

package com.example.rbacdemo;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@MapperScan("com.example.rbacdemo.mapper")
@SpringBootApplication
public class RbacDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(RbacDemoApplication.class, args);
    }
}

4. 实体类

User.java

package com.example.rbacdemo.domain;

import lombok.Data;

@Data
public class User {
    private Long id;
    private String username;
    private String password;
    private String nickname;
    private Integer status;
}

LoginRequest.java

package com.example.rbacdemo.dto;

import lombok.Data;

@Data
public class LoginRequest {
    private String username;
    private String password;
}

5. Mapper 接口与 XML

UserMapper.java

package com.example.rbacdemo.mapper;

import com.example.rbacdemo.domain.User;
import org.apache.ibatis.annotations.Param;

import java.util.Set;

public interface UserMapper {
    User findByUsername(@Param("username") String username);
    Set<String> findPermissionsByUserId(@Param("userId") Long userId);
}

UserMapper.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.rbacdemo.mapper.UserMapper">

    <select id="findByUsername" resultType="com.example.rbacdemo.domain.User">
        SELECT id, username, password, nickname, status
        FROM sys_user
        WHERE username = #{username}
        LIMIT 1
    </select>

    <select id="findPermissionsByUserId" resultType="string">
        SELECT DISTINCT p.perm_code
        FROM sys_user_role ur
        JOIN sys_role r ON ur.role_id = r.id AND r.status = 1
        JOIN sys_role_permission rp ON rp.role_id = r.id
        JOIN sys_permission p ON rp.permission_id = p.id AND p.status = 1
        WHERE ur.user_id = #{userId}
    </select>

</mapper>

6. 当前用户上下文

UserContext.java

package com.example.rbacdemo.security;

public class UserContext {
    private static final ThreadLocal<Long> USER_HOLDER = new ThreadLocal<>();

    public static void setUserId(Long userId) {
        USER_HOLDER.set(userId);
    }

    public static Long getUserId() {
        return USER_HOLDER.get();
    }

    public static void clear() {
        USER_HOLDER.remove();
    }
}

7. 自定义权限注解

RequirePermission.java

package com.example.rbacdemo.security;

import java.lang.annotation.*;

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RequirePermission {
    String value();
}

8. 认证服务

AuthService.java

package com.example.rbacdemo.service;

import com.example.rbacdemo.domain.User;
import com.example.rbacdemo.mapper.UserMapper;
import org.springframework.stereotype.Service;

import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

@Service
public class AuthService {

    private final UserMapper userMapper;

    private final Map<String, Long> tokenStore = new ConcurrentHashMap<>();
    private final Map<Long, Set<String>> permissionCache = new ConcurrentHashMap<>();

    public AuthService(UserMapper userMapper) {
        this.userMapper = userMapper;
    }

    public String login(String username, String password) {
        User user = userMapper.findByUsername(username);
        if (user == null || !user.getPassword().equals(password)) {
            throw new RuntimeException("用户名或密码错误");
        }
        if (user.getStatus() == null || user.getStatus() != 1) {
            throw new RuntimeException("用户已禁用");
        }
        String token = UUID.randomUUID().toString();
        tokenStore.put(token, user.getId());
        permissionCache.put(user.getId(), userMapper.findPermissionsByUserId(user.getId()));
        return token;
    }

    public Long getUserIdByToken(String token) {
        return tokenStore.get(token);
    }

    public boolean hasPermission(Long userId, String permCode) {
        Set<String> permissions = permissionCache.computeIfAbsent(userId,
                id -> userMapper.findPermissionsByUserId(id));
        return permissions.contains(permCode);
    }

    public void refreshPermission(Long userId) {
        permissionCache.put(userId, userMapper.findPermissionsByUserId(userId));
    }
}

9. 认证拦截器

AuthInterceptor.java

package com.example.rbacdemo.security;

import com.example.rbacdemo.service.AuthService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

@Component
public class AuthInterceptor implements HandlerInterceptor {

    private final AuthService authService;

    public AuthInterceptor(AuthService authService) {
        this.authService = authService;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        if (!(handler instanceof HandlerMethod)) {
            return true;
        }

        String uri = request.getRequestURI();
        if ("/api/auth/login".equals(uri)) {
            return true;
        }

        String token = request.getHeader("Authorization");
        if (token == null || token.isBlank()) {
            response.setStatus(401);
            response.getWriter().write("未登录");
            return false;
        }

        Long userId = authService.getUserIdByToken(token);
        if (userId == null) {
            response.setStatus(401);
            response.getWriter().write("登录已失效");
            return false;
        }

        UserContext.setUserId(userId);

        HandlerMethod handlerMethod = (HandlerMethod) handler;
        RequirePermission requirePermission = handlerMethod.getMethodAnnotation(RequirePermission.class);
        if (requirePermission != null) {
            boolean pass = authService.hasPermission(userId, requirePermission.value());
            if (!pass) {
                response.setStatus(403);
                response.getWriter().write("无权限");
                return false;
            }
        }

        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        UserContext.clear();
    }
}

10. WebMvc 配置

WebConfig.java

package com.example.rbacdemo.config;

import com.example.rbacdemo.security.AuthInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    private final AuthInterceptor authInterceptor;

    public WebConfig(AuthInterceptor authInterceptor) {
        this.authInterceptor = authInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authInterceptor).addPathPatterns("/api/**");
    }
}

11. 控制器

AuthController.java

package com.example.rbacdemo.controller;

import com.example.rbacdemo.dto.LoginRequest;
import com.example.rbacdemo.service.AuthService;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final AuthService authService;

    public AuthController(AuthService authService) {
        this.authService = authService;
    }

    @PostMapping("/login")
    public Map<String, Object> login(@RequestBody LoginRequest request) {
        String token = authService.login(request.getUsername(), request.getPassword());
        return Map.of("token", token);
    }
}

UserController.java

package com.example.rbacdemo.controller;

import com.example.rbacdemo.security.RequirePermission;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping
    @RequirePermission("system:user:list")
    public List<Map<String, Object>> list() {
        return List.of(
                Map.of("id", 1, "username", "admin"),
                Map.of("id", 2, "username", "operator")
        );
    }

    @PostMapping
    @RequirePermission("system:user:add")
    public Map<String, Object> add(@RequestBody Map<String, Object> body) {
        return Map.of("message", "用户创建成功", "data", body);
    }
}

RoleController.java

package com.example.rbacdemo.controller;

import com.example.rbacdemo.security.RequirePermission;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/api/roles")
public class RoleController {

    @PostMapping("/assign")
    @RequirePermission("system:role:assign")
    public Map<String, Object> assign(@RequestBody Map<String, Object> body) {
        return Map.of("message", "角色分配成功", "data", body);
    }
}

12. 调用验证

1)登录 admin

curl -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"123456"}'

返回:

{"token":"xxxxxx"}

2)访问用户列表

curl http://localhost:8080/api/users \
  -H "Authorization: xxxxxx"

3)operator 尝试新增用户

curl -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"operator","password":"123456"}'
curl -X POST http://localhost:8080/api/users \
  -H "Authorization: operator-token" \
  -H "Content-Type: application/json" \
  -d '{"username":"test"}'

预期结果:403 无权限


授权执行时序

sequenceDiagram
    participant C as Client
    participant I as AuthInterceptor
    participant A as AuthService
    participant M as MyBatis
    participant D as MySQL

    C->>I: 请求 /api/users + token
    I->>A: 根据 token 获取 userId
    A-->>I: userId
    I->>A: 校验 permission(system:user:list)
    A->>M: 查询/读取权限缓存
    M->>D: select perm_code ...
    D-->>M: 权限集合
    M-->>A: Set<String>
    A-->>I: true/false
    I-->>C: 放行或返回 403

方案对比与取舍分析

权限系统没有银弹,关键在于你愿意为“灵活性”付出多少复杂度。

方案一:接口里手写 if-else

优点:

  • 上手最快
  • 小项目初期改动少

缺点:

  • 逻辑分散
  • 难统一审计
  • 极易漏校验
  • 随角色增长迅速失控

适合:一次性内部工具、小团队短期系统


方案二:RBAC + 注解拦截(本文方案)

优点:

  • 结构清晰
  • 接口权限统一
  • 菜单/按钮/接口可共用权限码
  • 开发体验比较稳定

缺点:

  • 需要维护权限元数据
  • 动态变更权限要处理缓存刷新
  • 超细粒度数据权限还需额外扩展

适合:大多数企业后台管理系统


方案三:RBAC + 数据权限 + 策略引擎

优点:

  • 灵活
  • 可以支持部门隔离、行级控制、字段脱敏

缺点:

  • 实现和维护成本高
  • 排障复杂度大幅上升
  • 对团队工程能力要求高

适合:多租户、组织层级复杂、合规要求强的系统


常见坑与排查

这部分我建议你认真看,很多问题不是“不会写”,而是“上线后才暴露”。

1. 前端隐藏按钮了,接口还是能调

现象:

  • 页面上看不到“删除”按钮
  • 但用 Postman 直接请求删除接口居然成功

原因:

  • 只做了前端展示控制
  • 后端没有做接口级授权

排查:

  1. 检查对应 Controller 方法是否标注权限
  2. 检查拦截器是否只拦菜单接口、没拦业务接口
  3. 检查权限码配置是否与注解值一致

建议:

前端权限只负责“体验”,后端权限才负责“边界”。


2. 刚授权的角色不生效

现象:

  • 管理员刚给用户加了权限
  • 用户刷新页面还是 403

原因:

  • 权限缓存没刷新
  • token 中固化了旧权限
  • 多节点部署时缓存不一致

排查:

  1. 看授权操作后是否触发 refreshPermission
  2. 如果用了 Redis/JWT,检查权限是不是写死在 token 里
  3. 多实例场景检查是否有统一缓存失效机制

建议:

  • 小型单体:本地缓存 + 修改后主动刷新
  • 多实例:Redis 缓存 + 发布订阅通知失效
  • 如果权限变更频繁,不建议把全量权限长期固化在 JWT 中

3. ThreadLocal 用户串数据

现象:

  • 某些请求出现“拿到了别人的 userId”
  • 压测时偶发

原因:

  • ThreadLocal 使用后没清理
  • 线程池复用导致脏数据遗留

排查:

  • 看拦截器 afterCompletion 是否执行了 UserContext.clear()

建议:

这个坑我踩过,问题不一定高频,但一旦出现很难复现。
ThreadLocal 一定要在 finally/afterCompletion 里清理。


4. 权限码命名混乱,后面没人敢改

现象:

  • 有的权限码是 user_add
  • 有的是 /api/user/add
  • 有的是 addUserPermission
  • 最后谁都不知道该用哪个

建议:

统一格式,例如:

模块:资源:动作
system:user:list
system:user:add
system:role:assign

并且在系统里建立“权限字典”,不要让每个开发各写各的。


5. MyBatis 查询权限慢

现象:

  • 登录慢
  • 每次请求都查五张表
  • 高并发下数据库压力明显

排查:

  1. sys_user_role.user_id
  2. sys_role_permission.role_id
  3. sys_permission.id / perm_code
  4. 是否每个请求都查权限,没有缓存

建议:

  • 权限集合优先缓存
  • 联表字段必须建索引
  • 避免在每个请求里做全量权限树装配

安全/性能最佳实践

这一节是“从能跑到能上线”的关键。

1. 密码必须加密存储

演示里用了明文密码,是为了便于理解。真实项目中必须使用哈希算法,例如:

  • BCrypt
  • Argon2

示例:

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

public class PasswordUtil {
    private static final BCryptPasswordEncoder ENCODER = new BCryptPasswordEncoder();

    public static String encode(String rawPassword) {
        return ENCODER.encode(rawPassword);
    }

    public static boolean matches(String rawPassword, String encodedPassword) {
        return ENCODER.matches(rawPassword, encodedPassword);
    }
}

不要自己写 MD5/SHA1 直接存,这已经不够安全了。


2. 认证与授权失败要标准化返回

建议统一返回结构,例如:

{
  "code": 401,
  "message": "登录已失效",
  "data": null
}
{
  "code": 403,
  "message": "无权限访问该资源",
  "data": null
}

这样前端可以明确区分:

  • 401:跳登录页
  • 403:展示无权限页或提示

3. 权限缓存要有失效策略

权限查询天然适合缓存,但不能只缓存、不失效。

建议策略:

  • 用户登录后加载权限到缓存
  • 角色变更/授权变更时,主动删除相关用户缓存
  • 设置合理 TTL,避免脏数据长期存在

如果是单机系统,本地缓存就够用;如果是集群系统,建议 Redis。


4. 菜单权限与接口权限分开消费

虽然模型统一,但消费方式不要混淆:

  • 前端路由/菜单树:拿 MENUBUTTON
  • 后端接口鉴权:校验 perm_code
  • 不建议完全依赖 path + method 动态匹配做唯一授权判断

因为 URL 改动比权限码改动更频繁,耦合太深后维护很痛苦。


5. 管理员超级权限要谨慎

很多系统会设计一个“超级管理员绕过所有校验”的逻辑。
这不是不能做,但要加边界:

  • 只允许内置角色生效
  • 不要靠前端传参标记 admin
  • 最好在数据库有明确角色标识
  • 审计日志必须记录

否则后面很容易出现“假超级管理员”漏洞。


6. 审计日志别省

真正出了问题,你最想知道的是:

  • 谁在什么时间
  • 用哪个账号
  • 调了哪个接口
  • 是否鉴权通过
  • 改了什么数据

至少对这些操作记日志:

  • 登录/退出
  • 用户创建、禁用
  • 角色分配
  • 权限变更
  • 导出、删除、审核等高风险动作

7. 容量与性能估算建议

对于一般后台系统,可以做一个粗略估算:

  • 用户数:1 万以内
  • 角色数:几十到几百
  • 权限点:几百到几千
  • 并发:中低并发

这种规模下:

  • MySQL + Redis 足够
  • 权限集合按用户缓存完全可行
  • 不必一开始就上复杂策略引擎

如果出现以下情况,再考虑升级架构:

  • 权限实时变更极频繁
  • 多租户隔离复杂
  • 数据权限跨部门跨组织联动
  • 多服务统一鉴权与审计要求强

可扩展方向

如果你准备把这套方案继续做深,我建议按这个顺序扩展,而不是一口气全加:

1. 菜单树接口

sys_permission 增加:

  • parent_id
  • sort
  • icon
  • component
  • visible

然后前端按树形展示菜单。

2. 按钮权限联动前端

接口返回当前用户权限码集合,前端做:

hasPerm('system:user:add')

用于控制按钮显隐。

3. 数据权限

比如“运营只能看自己部门的数据”。
这时 RBAC 不够,需要在查询层增加数据范围控制:

  • 仅本人
  • 本部门
  • 本部门及子部门
  • 全部

通常做法是角色上挂一个 data_scope,在 SQL 层或服务层拼接过滤条件。

4. 分布式会话与缓存

如果系统多实例部署:

  • token 存 Redis
  • 权限缓存存 Redis
  • 授权变更时发布失效消息

这样才能避免某个节点拿到旧权限。


总结

如果你想把后台权限系统做得稳一点,我建议记住这几条:

  1. 认证和授权分层

    • 认证解决“你是谁”
    • 授权解决“你能做什么”
  2. 权限码统一管理

    • 用稳定的 perm_code 做授权核心
    • 不要把 URL 当成唯一权限主键
  3. 菜单、按钮、接口统一建模

    • 类型可以分开
    • 模型尽量统一,减少重复设计
  4. 后端接口必须强制鉴权

    • 前端隐藏按钮不是安全
    • 真正的边界在服务端
  5. 缓存、日志、失效机制要提前考虑

    • 这决定了系统能不能从“开发能用”走到“线上可靠”

对于大多数 Java Web 后台项目,Spring Boot + MyBatis + RBAC + 注解式接口鉴权 是一个性价比很高的组合。它不算最炫,但足够稳、足够清晰,也足够适合团队协作。

如果你现在正在做一个后台系统,我的可执行建议是:

  • 第一阶段:先把用户、角色、权限、接口鉴权跑通
  • 第二阶段:补上菜单树、按钮权限、缓存刷新
  • 第三阶段:再评估是否真的需要数据权限和更复杂的策略引擎

不要一开始就追求“万能权限平台”,那通常会把自己拖进复杂度泥潭。
先做对,再做全,最后再做深。


分享到:

上一篇
《从单体到集群:中级工程师落地高可用微服务集群架构的设计与扩容实践》
下一篇
《从浏览器指纹到请求签名:Web逆向中前端加密参数定位与复现实战》