背景与问题
后台管理系统做得越久,越容易遇到一个“看起来简单、实际上很容易烂尾”的问题:权限系统。
很多项目一开始只有“管理员”和“普通用户”两个角色,接口里直接写:
if (!user.isAdmin()) {
throw new RuntimeException("无权限");
}
早期跑得飞快,需求一复杂就开始失控:
- 新增“运营主管”“财务审核员”“只读审计员”时,代码里到处都是
if-else - 菜单权限、按钮权限、接口权限三套逻辑各自为政
- 前端隐藏了按钮,但后端接口没拦,导致“会抓包就能越权”
- 开发环境一切正常,生产环境因为缓存、事务、权限刷新不及时,出现“刚授权却访问不了”或“已撤权还能调用”的问题
我自己做后台系统时,最深的感受是:权限不是页面功能,而是系统边界。真正难的不是把表建出来,而是让权限模型、接口鉴权、数据访问和运维行为一致。
这篇文章从 Java Web 实战角度,讲一个比较稳妥的方案:Spring Boot + MyBatis + RBAC,重点放在两个问题上:
- 权限模型怎么设计,后续扩展不痛苦
- 接口安全怎么真正落地,而不是只停留在菜单展示层
方案目标与设计边界
先把目标说清楚,不然后面很容易“边做边加,最后四不像”。
本文的 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:接口
这样有几个好处:
- 后台权限模型统一
- 前端拿菜单树时按
MENU/BUTTON过滤 - 后端鉴权时按
API或统一perm_code校验 - 后续加“导出”“审核”“发布”等按钮,不需要再造一套表
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 直接请求删除接口居然成功
原因:
- 只做了前端展示控制
- 后端没有做接口级授权
排查:
- 检查对应 Controller 方法是否标注权限
- 检查拦截器是否只拦菜单接口、没拦业务接口
- 检查权限码配置是否与注解值一致
建议:
前端权限只负责“体验”,后端权限才负责“边界”。
2. 刚授权的角色不生效
现象:
- 管理员刚给用户加了权限
- 用户刷新页面还是 403
原因:
- 权限缓存没刷新
- token 中固化了旧权限
- 多节点部署时缓存不一致
排查:
- 看授权操作后是否触发
refreshPermission - 如果用了 Redis/JWT,检查权限是不是写死在 token 里
- 多实例场景检查是否有统一缓存失效机制
建议:
- 小型单体:本地缓存 + 修改后主动刷新
- 多实例: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 查询权限慢
现象:
- 登录慢
- 每次请求都查五张表
- 高并发下数据库压力明显
排查:
sys_user_role.user_idsys_role_permission.role_idsys_permission.id / perm_code- 是否每个请求都查权限,没有缓存
建议:
- 权限集合优先缓存
- 联表字段必须建索引
- 避免在每个请求里做全量权限树装配
安全/性能最佳实践
这一节是“从能跑到能上线”的关键。
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. 菜单权限与接口权限分开消费
虽然模型统一,但消费方式不要混淆:
- 前端路由/菜单树:拿
MENU、BUTTON - 后端接口鉴权:校验
perm_code - 不建议完全依赖
path + method动态匹配做唯一授权判断
因为 URL 改动比权限码改动更频繁,耦合太深后维护很痛苦。
5. 管理员超级权限要谨慎
很多系统会设计一个“超级管理员绕过所有校验”的逻辑。
这不是不能做,但要加边界:
- 只允许内置角色生效
- 不要靠前端传参标记 admin
- 最好在数据库有明确角色标识
- 审计日志必须记录
否则后面很容易出现“假超级管理员”漏洞。
6. 审计日志别省
真正出了问题,你最想知道的是:
- 谁在什么时间
- 用哪个账号
- 调了哪个接口
- 是否鉴权通过
- 改了什么数据
至少对这些操作记日志:
- 登录/退出
- 用户创建、禁用
- 角色分配
- 权限变更
- 导出、删除、审核等高风险动作
7. 容量与性能估算建议
对于一般后台系统,可以做一个粗略估算:
- 用户数:1 万以内
- 角色数:几十到几百
- 权限点:几百到几千
- 并发:中低并发
这种规模下:
- MySQL + Redis 足够
- 权限集合按用户缓存完全可行
- 不必一开始就上复杂策略引擎
如果出现以下情况,再考虑升级架构:
- 权限实时变更极频繁
- 多租户隔离复杂
- 数据权限跨部门跨组织联动
- 多服务统一鉴权与审计要求强
可扩展方向
如果你准备把这套方案继续做深,我建议按这个顺序扩展,而不是一口气全加:
1. 菜单树接口
在 sys_permission 增加:
parent_idsorticoncomponentvisible
然后前端按树形展示菜单。
2. 按钮权限联动前端
接口返回当前用户权限码集合,前端做:
hasPerm('system:user:add')
用于控制按钮显隐。
3. 数据权限
比如“运营只能看自己部门的数据”。
这时 RBAC 不够,需要在查询层增加数据范围控制:
- 仅本人
- 本部门
- 本部门及子部门
- 全部
通常做法是角色上挂一个 data_scope,在 SQL 层或服务层拼接过滤条件。
4. 分布式会话与缓存
如果系统多实例部署:
- token 存 Redis
- 权限缓存存 Redis
- 授权变更时发布失效消息
这样才能避免某个节点拿到旧权限。
总结
如果你想把后台权限系统做得稳一点,我建议记住这几条:
-
认证和授权分层
- 认证解决“你是谁”
- 授权解决“你能做什么”
-
权限码统一管理
- 用稳定的
perm_code做授权核心 - 不要把 URL 当成唯一权限主键
- 用稳定的
-
菜单、按钮、接口统一建模
- 类型可以分开
- 模型尽量统一,减少重复设计
-
后端接口必须强制鉴权
- 前端隐藏按钮不是安全
- 真正的边界在服务端
-
缓存、日志、失效机制要提前考虑
- 这决定了系统能不能从“开发能用”走到“线上可靠”
对于大多数 Java Web 后台项目,Spring Boot + MyBatis + RBAC + 注解式接口鉴权 是一个性价比很高的组合。它不算最炫,但足够稳、足够清晰,也足够适合团队协作。
如果你现在正在做一个后台系统,我的可执行建议是:
- 第一阶段:先把用户、角色、权限、接口鉴权跑通
- 第二阶段:补上菜单树、按钮权限、缓存刷新
- 第三阶段:再评估是否真的需要数据权限和更复杂的策略引擎
不要一开始就追求“万能权限平台”,那通常会把自己拖进复杂度泥潭。
先做对,再做全,最后再做深。