03 · RBAC 权限模型与数据库设计
03 · RBAC 权限模型与数据库设计
本篇讲后端权限系统的经典设计:RBAC(Role-Based Access Control,基于角色的访问控制)。本项目就是一套标准的 RBAC,代码全部真实可查。读完本篇,你应该能回答三个问题:权限存在哪、用户有哪些权限是怎么算出来的、一次请求是怎么被拦下来的。
3.1 为什么需要 RBAC(概念)
假设直接给用户分配「能否删除用户」这类权限:
用户A ── 能删除用户、能创建用户
用户B ── 能删除用户
用户C ── 能创建用户用户一多就无法维护:想收回「删除用户」权限,要逐个用户改;想新增一类权限,又要给每个用户重新配置一遍。
RBAC 的核心思想:在「用户」和「权限」之间插入一层 角色(Role),权限只挂在角色上,用户只挂角色。
用户 ──多对多── 角色 ──多对多── 权限- 给用户分配角色(如
admin)。 - 给角色分配权限(如
user:delete)。 - 判断「某用户能否做 X」= 看他的某个角色是否拥有权限 X。
为什么这一层有价值?
- 批量管理:给 100 个运营同学统一加
operator角色,比逐个分配权限简单得多。 - 职责分离:权限的语义由角色表达("管理员""操作员"),产品/运营能看懂,而不是一堆
user:delete。 - 最小权限原则(Least Privilege):每个角色只授予完成工作所必需的最小权限集合。本项目里
operator就没有user:delete和role:manage,这正是该原则的体现——操作员能管用户但不能删用户、不能动角色。
最小权限原则:默认不给,按需授予。宁可多问一次"这个角色真的需要这个权限吗",也不要图省事把所有权限都给
operator。
3.2 五张核心表与 ER 图
| 表 | 作用 | 关键字段 |
|---|---|---|
sys_user | 用户 | username(唯一)、password_hash、enabled |
sys_role | 角色 | code(唯一)、name |
sys_permission | 权限 | code(唯一)、name |
sys_user_role | 用户-角色关联 | user_id、role_id |
sys_role_permission | 角色-权限关联 | role_id、permission_id |
实体类位置:
backend/src/main/java/com/yang/backend/entity/SysUser.javabackend/src/main/java/com/yang/backend/entity/SysRole.javabackend/src/main/java/com/yang/backend/entity/SysPermission.javabackend/src/main/java/com/yang/backend/entity/SysUserRole.javabackend/src/main/java/com/yang/backend/entity/SysRolePermission.java
sys_user 与 sys_role 是「多对多」,必须有一张中间表 sys_user_role 把它们拆成两个「一对多」;
同理 sys_role 与 sys_permission 是「多对多」,中间表是 sys_role_permission。
这两张中间表就是 RBAC 的骨架。
┌──────────────────────┐
│ sys_permission │
│ id (PK) │
│ code UNIQUE │◄──┐
│ name │ │ 多对多
└──────────┬───────────┘ │ (role_id, permission_id)
│ │
role_id ◄────────────┼───────────────┘
┌──────────▼───────────┐
│ sys_role_permission │ (中间表,role:permission)
└──────────┬───────────┘
│ role_id
▼
┌──────────────────────┐
│ sys_role │
│ id (PK) │◄──┐
│ code UNIQUE │ │ 多对多
│ name │ │ (user_id, role_id)
└──────────┬───────────┘ │
│ │
user_id ◄───────────┼───────────────┘
┌──────────▼───────────┐
│ sys_user_role │ (中间表,user:role)
└──────────┬───────────┘
│ user_id
▼
┌──────────────────────┐
│ sys_user │
│ id (PK) │
│ username UNIQUE │
│ password_hash │
│ enabled │
└──────────────────────┘
实线箭头 = 外键 REFERENCES,指向被引用方(被引用方是"一",引用方是"多")
两个中间表自身没有单独的主键列,而是用 (用户,角色) / (角色,权限) 做联合主键记忆口诀:用户和角色"多对多",靠 sys_user_role 拆;角色和权限"多对多",靠 sys_role_permission 拆。
3.3 DDL 逐字段解读(见 schema.sql)
建表脚本在 backend/src/main/resources/db/schema.sql。下面逐一解释为什么这么设计。
3.3.1 三张主表
CREATE TABLE IF NOT EXISTS sys_user (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
display_name VARCHAR(100) NOT NULL,
email VARCHAR(160) UNIQUE,
password_hash VARCHAR(128) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);要点:
id BIGSERIAL PRIMARY KEY:主键用数据库自增(BIGSERIAL,对应 Java 的Long,见SysUser.java:13的@TableId(type = IdType.AUTO))。用数字主键而不是用username当主键,是因为用户名以后可能允许修改,而主键不应变。username VARCHAR(64) NOT NULL UNIQUE:登录账号必须唯一,否则无法区分谁是谁;UNIQUE约束由数据库兜底,比只在代码里判断更可靠。email VARCHAR(160) UNIQUE:邮箱可选(NULL允许)但一旦填了就唯一。注意:PostgreSQL 中多个NULL不违反 UNIQUE,所以允许"都没填邮箱"的多个用户共存。password_hash VARCHAR(128) NOT NULL:只存哈希值,绝不存明文密码(本项目用PasswordHasher,见UserService.java:100)。enabled BOOLEAN NOT NULL DEFAULT TRUE:账号是否启用,禁用后不应再能登录,是权限之外的"开关"。created_at/updated_at:审计字段,默认当前时间。
sys_role、sys_permission 结构类似,关键是 code VARCHAR(...) NOT NULL UNIQUE:
角色的 code(如 admin)和权限的 code(如 user:delete)都是程序内部引用,必须唯一稳定,不能随便改名。前端、后端、SQL 到处都靠这个 code 字符串,所以比起会变的 id,更常用 code 做业务标识(assignDefaultRole 就用 code = 'operator' 去找角色,见 UserService.java:195)。
3.3.2 两张关联表(多对多的实现)
CREATE TABLE IF NOT EXISTS sys_user_role (
user_id BIGINT NOT NULL REFERENCES sys_user(id) ON DELETE CASCADE,
role_id BIGINT NOT NULL REFERENCES sys_role(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
CREATE TABLE IF NOT EXISTS sys_role_permission (
role_id BIGINT NOT NULL REFERENCES sys_role(id) ON DELETE CASCADE,
permission_id BIGINT NOT NULL REFERENCES sys_permission(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);要点:
- 联合主键
PRIMARY KEY (user_id, role_id):保证同一个用户不会重复绑定同一个角色(天然去重)。sys_role_permission同理。注意它们没有单独的id列——多对多中间表不需要自己主键,复合主键就够了。 - 外键
REFERENCES ... ON DELETE CASCADE:这是 RBAC 数据完整性的关键。ON DELETE CASCADE表示——删用户时,他在sys_user_role里的关联行自动删除;删角色时,它在sys_user_role和sys_role_permission里的关联行也全部自动删除;删权限时,它在sys_role_permission里的关联行自动删除。- 没有它:删一个角色会留下一堆指向已不存在角色的孤儿记录,下次连表查询就可能出错或查到脏数据。
- 有了它:删角色"一删到底",不用写额外
DELETE FROM sys_user_role WHERE role_id = ?之类的清理代码(虽然UserService.replaceRoles/RoleService.replacePermissions仍然自己先deleteByXxxId再插入,那是"整体替换"语义,见 3.6)。
3.3.3 索引(schema.sql:49-51)
CREATE INDEX IF NOT EXISTS idx_sys_user_username ON sys_user(username);
CREATE INDEX IF NOT EXISTS idx_sys_user_role_user_id ON sys_user_role(user_id);
CREATE INDEX IF NOT EXISTS idx_sys_role_permission_role_id ON sys_role_permission(role_id);为什么需要这些索引?
idx_sys_user_username:登录时按username查用户是最高频操作(UserService.findByUsername的eq(SysUser::getUsername, ...),见UserService.java:81)。虽然username已经是UNIQUE,会隐式建唯一索引,这里再显式建一个只是保险/可读;真正起作用的是下面两个。idx_sys_user_role_user_id:查"某用户有哪些角色"(selectRolesByUserId的WHERE ur.user_id = ?,见SysUserRoleMapper.java:18)走这个索引,避免全表扫描。idx_sys_role_permission_role_id:查"某角色有哪些权限"(selectPermissionsByRoleId的WHERE rp.role_id = ?,见SysRolePermissionMapper.java:18)走这个索引。
经验:外键列几乎总是要建索引。因为
ON DELETE CASCADE删除父表一行时,数据库要去子表找到所有引用它的行来级联删除;没有索引就会全表扫描子表。本项目正好为两个关联表的外键列建了索引。
(注:schema.sql 还有一张 sys_session_setting,是会话超时策略表,与 RBAC 主体无关,但 session:manage 权限用来保护它的写接口,见 3.7。)
3.4 预置数据(见 data.sql)
初始化脚本 backend/src/main/resources/db/data.sql 预置了全部权限、角色、以及 admin 用户。真实内容如下。
3.4.1 14 个权限
原 RBAC 只有 6 个权限;项目后续扩展了「商品 / 购物车 / 订单 / 钱包」电商模块,新增 8 个权限码(详见 Doc/08-messaging.md)。完整清单:
INSERT INTO sys_permission (code, name, description)
VALUES
('user:view', '查看用户', '查看用户列表和详情'),
('user:create', '创建用户', '创建新用户'),
('user:update', '更新用户', '更新用户资料和角色'),
('user:delete', '删除用户', '删除用户'),
('role:manage', '管理角色', '维护角色和权限'),
('session:manage', '管理会话策略', '维护登录会话有效期和空闲超时时间'),
('product:view', '浏览商品', '查看商品列表与详情'),
('product:manage', '管理商品', '创建、更新、删除商品'),
('cart:manage', '管理购物车', '添加、修改、删除购物车商品'),
('order:create', '下单购买', '将购物车结算为订单'),
('order:view', '查看订单', '查看自己或全部的订单'),
('wallet:view', '查看余额', '查看自己的账户余额与流水'),
('wallet:transfer','余额转账', '向其他用户转账'),
('wallet:deposit', '账户充值', '为任意用户账户充值')
ON CONFLICT (code) DO UPDATE
SET name = EXCLUDED.name, description = EXCLUDED.description;3.4.2 两个角色
INSERT INTO sys_role (code, name, description)
VALUES
('admin', '管理员', '系统管理员,拥有全部权限'),
('operator','操作员', '日常用户管理角色')
ON CONFLICT (code) DO UPDATE
SET name = EXCLUDED.name, description = EXCLUDED.description;3.4.3 给 admin 授予"全部"权限(用 CROSS JOIN)
INSERT INTO sys_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM sys_role r
CROSS JOIN sys_permission p
WHERE r.code = 'admin'
ON CONFLICT DO NOTHING;这里用了 CROSS JOIN:把 sys_role(只有 admin 行) 与 sys_permission(全部 14 行) 做笛卡尔积,得到 (admin, 每个权限) 的 14 行,一次性插入。这正是"管理员拥有全部权限"的简洁写法——以后往 sys_permission 加新权限,这段脚本下次执行会自动把新权限也 JOIN 给 admin,不用手改(本项目新增的 8 个电商权限就是这样被 admin 自动持有的)。
3.4.4 给 operator 授予 9 个权限
INSERT INTO sys_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM sys_role r
JOIN sys_permission p ON p.code IN (
'user:view', 'user:create', 'user:update',
'product:view', 'cart:manage', 'order:create', 'order:view',
'wallet:view', 'wallet:transfer'
)
WHERE r.code = 'operator'
ON CONFLICT DO NOTHING;操作员拿到「用户管理(查看/创建/更新)+ 电商用户侧」共 9 个权限:user:view / user:create / user:update、product:view(浏览商品)、cart:manage(购物车)、order:create+order:view(下单与看自己的订单)、wallet:view+wallet:transfer(查余额、向他人转账)。没有 user:delete、role:manage、session:manage(管理类),也没有 product:manage(商品增删改归管理员)和 wallet:deposit(充值归管理员)——这就是 3.1 说的"最小权限"。
由于新注册用户默认分配
operator角色,因此普通用户天然可以「浏览商品 → 加购 → 下单 → 转账 → 查余额」,但无法管理商品、无法给其他人充值。管理员(admin)则通过上述CROSS JOIN持有全部 14 个权限。
3.4.5 预置 admin 用户并绑定 admin 角色
INSERT INTO sys_user (username, display_name, email, password_hash, enabled)
VALUES ('admin', '系统管理员', 'admin@example.com',
'240be518fabd2724ddb6f04eeb1da5967448d7e831c08c8fa822809f74c720a9', TRUE)
ON CONFLICT (username) DO UPDATE
SET display_name = EXCLUDED.display_name, email = EXCLUDED.email, enabled = EXCLUDED.enabled;
INSERT INTO sys_user_role (user_id, role_id)
SELECT u.id, r.id
FROM sys_user u
JOIN sys_role r ON r.code = 'admin'
WHERE u.username = 'admin'
ON CONFLICT DO NOTHING;预置 admin 的密码哈希是固定值(注意:真实部署请改密码)。sys_user_role 的插入用子查询 JOIN 出 id,避免写死数字主键。
3.4.6 幂等性:ON CONFLICT 是什么
注意每行 INSERT 后面都带了 ON CONFLICT:
ON CONFLICT (code) DO UPDATE ...:如果code已存在,就改为"更新"那一行的 name/description,不报错。适合权限、角色这种"定义稳定、可重复执行"的数据。ON CONFLICT DO NOTHING:如果主键/唯一约束冲突就忽略,不报错。
为什么要幂等? 因为 data.sql 可能在数据库已经初始化过的情况下被重复执行(比如重启时再次灌数据)。如果直接 INSERT 而不处理冲突,第二次执行就会因为 UNIQUE/PRIMARY KEY 冲突直接报错、中断启动。加上 ON CONFLICT 后,脚本可以"随便跑几次都安全",这叫幂等初始化。这是后端工程里非常重要的习惯。
3.5 用户的权限是怎么算出来的(端到端)
从"一个用户"到"一串权限编码",要跨三张表。完整链路:
sys_user (id=1, admin)
└─ sys_user_role (user_id=1, role_id=1)
└─ sys_role (id=1, admin)
└─ sys_role_permission (role_id=1, permission_id=1..6)
└─ sys_permission (code = user:view, user:create, ...)代码里有两条真实的 SQL,逐级 JOIN:
第一步:用户 → 角色(SysUserRoleMapper.java:14)
@Select("""
SELECT r.*
FROM sys_role r
JOIN sys_user_role ur ON ur.role_id = r.id
WHERE ur.user_id = #{userId}
ORDER BY r.id
""")
List<SysRole> selectRolesByUserId(@Param("userId") Long userId);第二步:角色 → 权限(SysRolePermissionMapper.java:14)
@Select("""
SELECT p.*
FROM sys_permission p
JOIN sys_role_permission rp ON rp.permission_id = p.id
WHERE rp.role_id = #{roleId}
ORDER BY p.id
""")
List<SysPermission> selectPermissionsByRoleId(@Param("roleId") Long roleId);第三步:汇总成权限编码列表(UserService.java:84)
public List<String> listPermissionCodes(Long userId) {
return userRoleMapper.selectRolesByUserId(userId).stream()
.flatMap(role -> rolePermissionMapper.selectPermissionsByRoleId(role.getId()).stream())
.map(permission -> permission.getCode())
.distinct()
.sorted()
.toList();
}解析:
selectRolesByUserId拿到该用户所有角色(支持"一个用户多个角色")。- 对每个角色
flatMap出它拥有的权限。 map(...getCode())只取权限编码字符串。distinct()很重要:若一个用户同时有admin和operator角色,某些权限会被重复计算,distinct去掉重复。这也意味着本项目天然支持"多角色、权限取并集"。sorted()让结果稳定有序,方便前端比较、测试断言。
对 admin 用户来说,最终得到全部 14 个权限编码(排序后):[cart:manage, order:create, order:view, product:manage, product:view, role:manage, session:manage, user:create, user:delete, user:update, user:view, wallet:deposit, wallet:transfer, wallet:view]。
这套权限列表在登录时就算好并塞进会话(见
AuthService.java:41/53的userService.listPermissionCodes(...),以及LoginSessionService.java:34/39构造LoginSession)。之后每次请求不再重算,直接读会话里的这份列表——这是性能关键,见 3.7。
3.6 两层授权校验
本项目的权限校验分两层:接口级(声明"需要什么权限") 和 会话级(实际比对权限列表)。
3.6.1 第一层:用 PermissionGuard.require 声明接口所需权限
PermissionGuard 是校验核心(backend/src/main/java/com/yang/backend/service/PermissionGuard.java):
@Component
public class PermissionGuard {
public void require(String permission) {
LoginSession session = SessionContext.current();
if (session == null || !session.permissions().contains(permission)) {
throw new BadRequestException("无权执行该操作");
}
}
public void requireAny(String... permissions) {
LoginSession session = SessionContext.current();
if (session == null || Arrays.stream(permissions).noneMatch(session.permissions()::contains)) {
throw new BadRequestException("无权执行该操作");
}
}
}require("user:delete"):当前会话必须包含该权限,否则抛异常(HTTP 400)。requireAny("role:manage", "user:view"):只要满足其中任意一个即可("或"的关系)。
各 Controller 在每个接口开头调用它,把"这个接口需要什么权限"写成了声明式代码:
UserController.java(每个接口一个精确权限)
| 接口 | 行号 | 所需权限 |
|---|---|---|
GET /api/users | UserController.java:38 | user:view |
GET /api/users/{id} | UserController.java:45 | user:view |
POST /api/users | UserController.java:52 | user:create |
PUT /api/users/{id} | UserController.java:59 | user:update |
DELETE /api/users/{id} | UserController.java:66 | user:delete |
@DeleteMapping("/{id}")
public void deleteUser(@PathVariable Long id) {
permissionGuard.require("user:delete"); // 删除用户必须拥有 user:delete
userService.deleteUser(id);
}RoleController.java(查询用 requireAny,写操作用 require)
| 接口 | 行号 | 所需权限 |
|---|---|---|
GET /api/roles | RoleController.java:35 | requireAny("role:manage", "user:view") |
GET /api/roles/{id} | RoleController.java:42 | requireAny("role:manage", "user:view") |
POST /api/roles | RoleController.java:49 | role:manage |
PUT /api/roles/{id} | RoleController.java:56 | role:manage |
DELETE /api/roles/{id} | RoleController.java:63 | role:manage |
设计细节:查角色列表允许"有 role:manage 的管理员"或"只是能看用户的操作员"访问(requireAny);但创建/修改/删除角色只能 role:manage——因为改角色会间接改很多人的权限,必须更严格。
PermissionController.java
@GetMapping
public List<PermissionResponse> listPermissions() {
permissionGuard.requireAny("role:manage", "user:view"); // PermissionController.java:28
return permissionService.listPermissions();
}
permissionGuard在三个 Controller 里都是通过构造函数注入的(UserController.java:28、RoleController.java:27、PermissionController.java:20),这是 Spring 推荐的构造器注入。
3.6.2 第二层:会话级检查(从内存读,不查库)
PermissionGuard 实际比对的是 SessionContext.current().permissions()——这是一个 ThreadLocal<LoginSession>(SessionContext.java:5)。LoginSession 里的 permissions 列表是登录时就算好、随会话一起存起来的(见 3.5 末尾)。
SessionSettingController 是另一个例子,它没注入 PermissionGuard,而是自己写了一个同款 requirePermission:
// SessionSettingController.java:40
private void requirePermission(String permission) {
LoginSession session = SessionContext.current();
if (session == null || !session.permissions().contains(permission)) {
throw new BadRequestException("无权执行该操作");
}
}为什么从内存里的会话读、而不每次重新查库?
- 每次请求都跑 3.5 那两段 JOIN SQL(用户→角色→权限)要打 2 次以上数据库,高频接口会被拖慢。
- 权限在两次登录之间基本不变,那么"登录时算一次、之后直接从会话对象里读"就足够正确,又极快——只读内存里的
List<String>.contains(...),是 O(n) 本地操作,没有网络/SQL 开销。 - 请求进来时,
BearerSessionFilter会根据 token 把会话放进SessionContext(BearerSessionFilter.java:58的SessionContext.set(session)),所以同一个请求的处理线程里随时能拿到完整权限列表。
代价:如果管理员在用户登录期间改了他的角色/权限,要等他下次登录(会话重建)才会生效。本项目用"登录即算、会话期内有效"换性能,是常见且合理的取舍。
3.6.3 写关联表时的"整体替换"事务
给用户换角色、给角色换权限,都是"先删旧的、再插新的",并且必须在一个事务里,否则中途失败会留下半成品:
UserService.replaceRoles(UserService.java:158):
private void replaceRoles(Long userId, List<Long> roleIds) {
userRoleMapper.deleteByUserId(userId); // 先清掉旧关联
if (roleIds == null || roleIds.isEmpty()) return;
List<Long> distinctRoleIds = roleIds.stream().distinct().toList(); // 先去重
long count = roleMapper.selectByIds(distinctRoleIds).size();
if (count != distinctRoleIds.size()) {
throw new BadRequestException("存在无效角色 ID"); // 再校验 ID 都真实存在
}
distinctRoleIds.forEach(roleId -> userRoleMapper.insert(new SysUserRole(userId, roleId)));
}RoleService.replacePermissions(RoleService.java:87)逻辑完全一样,只是作用在 sys_role_permission 上。两个方法都被 @Transactional 标注(外层 createUser/updateRole 等也是),保证主表+关联表要么一起成功、要么一起回滚。先 distinct 是为了不插入重复联合主键;先校验"ID 都存在"是为了防止塞进指向不存在角色/权限的脏关联。
3.7 「资源:动作」命名约定
本项目的权限编码统一用 资源:动作:
user:view user:create user:update user:delete
role:manage
session:manage
product:view product:manage
cart:manage
order:create order:view
wallet:view wallet:transfer wallet:deposit为什么这样命名、而且能扩展:
- 可读性强:看到
user:delete就知道是"对用户做删除"。 - 结构清晰、好分组:冒号左边是资源(user / role / session / product / cart / order / wallet),右边是动作(view / create / update / delete / manage / transfer / deposit)。电商模块就是直接套用这套命名:
product:*管商品、cart:*管购物车、order:*管订单、wallet:*管余额。 - 方便前端按编码控制按钮显示:前端只要拿到权限列表,判断
permissions.includes('user:delete')就能决定"删除"按钮显不显示,无需后端再暴露结构。 - 动作可枚举:常见动作就 view/create/update/delete/manage 几种,新资源直接套用,命名不会乱。
小提示:
manage是一类"广义"动作(管理角色的增删改查),而user:*拆得更细。实际项目里常把"菜单可见"和"具体操作"分开——见 3.9 扩展了解。
3.8 设计要点小结
- 权限应当「预置、稳定」,角色只引用既有权限(前端不开放创建权限,正是这个思路)。
- 给用户分配角色、给角色分配权限,都用
@Transactional保证主表与关联表在同一事务内完成(见UserService/RoleService)。 - 新增权限时记得同时更新
data.sql,否则重新初始化数据库后新权限会丢失。 code是权限/角色的业务主键(程序到处引用),比自增id更稳定,别轻易改名。- 权限列表在登录时算一次并缓存进会话,接口校验只读内存,性能好。
- 本项目 RBAC 主体用 5 张表(
sys_user / sys_role / sys_permission / sys_user_role / sys_role_permission)。电商扩展在不改动 RBAC 表的前提下,复用同一套PermissionGuard做了「最小权限」拆分:admin通过CROSS JOIN自动获得全部 14 个权限,operator(新注册用户默认角色)只获得用户侧 9 个权限。资金变动(充值/转账/购买)的权限校验与异步处理见Doc/08-messaging.md。
3.9 扩展了解:真实项目的 RBAC 还会怎么做
以下本项目未实现,但你在工作或面试中常会碰到,作为知识延伸。
权限树 / 菜单权限:大型系统把权限组织成树(模块 → 页面 → 按钮),并区分「菜单权限」(控制左侧导航是否显示) 和「接口/数据权限」(控制后端是否放行)。本项目只有扁平的 6 个权限,没有树结构。
数据级 / 行级权限(Row-Level):RBAC 解决"能不能做某类操作",但解决不了"只能看自己部门的数据"。例如两个
operator都拥有user:view,但 A 只能看华东区用户、B 只能看华北区。这需要在查询时额外带department_id之类的过滤条件,常称为"数据权限",是 RBAC 之上的另一层。菜单权限 vs API 权限分离:很多项目把"前端菜单/路由可见性"和"后端 API 是否放行"拆成两套权限码,甚至前端菜单来自后端下发的"权限树"。本项目
permission:*只下发给前端判断是否显示按钮,后端用require兜底,已经体现了"前后端双重校验"的思想。角色继承 / 角色层级:有的系统允许
super_admin继承admin的所有权限。本项目靠"给用户挂多个角色 + 权限取并集"达到类似效果,没有显式的角色继承。动态权限 vs 静态权限:本项目的权限码写死在
data.sql与PermissionGuard.require("...")里,属于"静态声明",最直观也最安全。更复杂的场景会用注解@PreAuthorize("hasPermission(...)")或规则引擎,但本质一样。
3.10 常见坑
忘给 admin 授权 → 403/400:若
data.sql里漏了admin的CROSS JOIN那段,admin 登录后权限列表为空,调用任何接口都会被PermissionGuard拦下报"无权执行该操作"。admin 必须拥有全部权限,否则整套管理功能自己都进不去。给 operator 多加了权限:手滑在
operator的INSERT里加了user:delete,操作员就能删用户,违背最小权限原则,还可能造成越权操作。加权限前先问"这个角色真的需要吗"。改了
code但没全局替换:权限code是字符串,散落在data.sql、PermissionGuard.require(...)、UserController等处。改一个名字只改一处,别处就匹配不上,权限形同虚设。用常量或枚举管理code可避免拼写错误。M:N 级联删除误伤:
ON DELETE CASCADE很方便,但删除一个被很多用户使用的角色,会瞬间清掉所有用户与该角色的关联。如果某个用户只剩这一个角色,删除后他就没有任何角色、权限列表变空,可能再也无法登录管理后台。删除角色前最好先确认还有哪些用户依赖它。N+1 查询:
listPermissionCodes先查角色(1 次),再对每个角色查权限(N 次),这就是典型的 N+1。本项目用户角色数很少,性能没问题;但如果"一个用户几百个角色",就会打几百次 SQL。优化办法是用一条JOIN三张表的 SQL 一次查出(SELECT p.code FROM ... WHERE ur.user_id = ?)。本项目的写法胜在清晰、好懂,适合学习。email唯一但允许为 NULL:PostgreSQL 允许多个NULL共存于 UNIQUE 列,所以"都不填邮箱"的多个用户不会冲突;但一旦两个人填了同一个邮箱就会报唯一约束错误。代码里trimToNull(UserService.java:201)把空字符串转成NULL正是为了利用这个特性。会话内权限不实时刷新:因为权限在登录时算好存进会话(3.6.2),管理员改了某人的角色,对方要重新登录才生效。排查"我明明给了权限为什么还 403"时,先确认他有没有重新登录。
3.11 练习
- 画链路图:画一张图,标出
admin用户到user:delete权限之间的所有表与关联(提示:sys_user → sys_user_role → sys_role → sys_role_permission → sys_permission)。 - 读源码:在
RoleService.java中找出「整体替换角色权限」的代码(replacePermissions,RoleService.java:87),思考为什么先distinct再去重、再校验权限是否存在,最后才插入。 - 并集支持:如果产品要求"一个用户可有多个角色、权限取并集",本项目现在的查询是否已经支持?提示:看
listPermissionCodes里selectRolesByUserId之后如何flatMap并用distinct()汇总(UserService.java:84-91)。 - 加一个新权限:给系统加一个
user:export(导出用户)权限。请一步步想清楚要在哪些地方改:data.sql的sys_permission插入里加一行;- (若希望 admin 也有)
admin的CROSS JOIN会自动带上,无需手改; - 在
UserController里新增导出接口,并调用permissionGuard.require("user:export"); - 思考:operator 要不要有?若要,在
operator那段IN (...)里补上user:export。
- 加一个新角色:新增一个
auditor(审计员)角色,只拥有user:view和role:manage(只看用户、不管角色数据)。写出对应的data.sql插入语句,并说明它和operator的权限差异。 - 断点验证:在
PermissionGuard.require里打一个断点,用 admin / operator 两个账号分别调用DELETE /api/users/{id},观察为什么 operator 会被拦下(operator 的权限列表里没有user:delete)。
3.12 一句话回顾
RBAC = 用户挂角色、角色挂权限;本项目用 5 张表(sys_user / sys_role / sys_permission / sys_user_role / sys_role_permission)落地,登录时把权限算好存进会话,PermissionGuard.require 在接口边界做声明式校验,会话级比对走内存、不查库,又快又清晰。电商模块在 RBAC 之上新增 product:* / cart:* / order:* / wallet:* 共 8 个权限码,沿用同一套声明式校验,把「管理 vs 普通用户」的边界划分清楚(admin 全有,operator 仅有用户侧权限)。