1. 引言
权限管理是几乎所有多用户系统的核心难题。很多团队在初期只做"登录 + 角色",上线后才发现:用户 A 能看用户 B 的私有数据、两个部门共用了同一份资源却互相覆盖、离职员工的权限迟迟没有回收......这些问题的根源,往往在于没有把"用户资格"和"资源空间"这两个维度分开设计。
本文从你提出的两个关键诉求出发------每个用户具有自己使用权限资源的资格 、多个用户共同占有同一个空间------梳理一套可落地的权限模型,并给出数据库设计与代码示例。
2. 先厘清两个核心概念:资格(Entitlement)与空间(Space)
在动手设计之前,先把需求拆成两个正交的维度:
- 资格(Entitlement) :描述"某个用户能不能做某件事"。例如"张三可以编辑文档""李四可以管理成员"。它回答的是 Who can do What。
- 空间(Space) :描述"资源归属于哪个容器"。例如"项目 A 的文档库""团队 B 的共享文件夹"。它回答的是 Where the resource lives。
你提到的"每个用户具有自己使用权限资源的资格",本质是资格要落到用户个体 ;"共同占有同样的空间",本质是多个用户共享同一个资源容器。
把这两个维度分开,就能避免最常见的错误:把"谁能访问"和"资源在哪"混在一张表里,导致共享空间里每个人的权限无法独立控制。
3. 推荐模型:RBAC + 资源空间隔离
业界最稳妥的组合是 RBAC(基于角色的访问控制)+ 资源空间(Space)隔离:
- 用 RBAC 管理"资格":用户 → 角色 → 权限点。
- 用 Space 管理"空间":资源挂在某个 Space 下,Space 再与用户/角色建立授权关系。
这样既保留了角色的复用性,又能做到"同一个空间里,不同用户拥有不同资格"。
3.1 核心实体
| 实体 | 说明 | 示例 |
|---|---|---|
| User | 用户 | 张三 |
| Role | 角色(一组权限的集合) | 管理员、编辑者、访客 |
| Permission | 权限点(最小操作单元) | doc:edit、doc:view |
| Space | 资源空间容器 | 项目 A、团队 B |
| SpaceMember | 空间成员(用户在某空间内的角色) | 张三在项目 A 中是编辑者 |
3.2 关键设计:空间成员表
"共同占有同一个空间"的核心落点,就是 SpaceMember 这张表。它把"用户"和"空间"关联起来,并记录该用户在这个空间内的角色:
sql
CREATE TABLE space_member (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
space_id BIGINT NOT NULL COMMENT '空间ID',
user_id BIGINT NOT NULL COMMENT '用户ID',
role_id BIGINT NOT NULL COMMENT '角色ID(在该空间内生效)',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_space_user (space_id, user_id)
) COMMENT '空间成员表:用户在某空间内的资格';
通过这张表,你可以回答两个关键问题:
- "张三在项目 A 里能做什么?" → 查
space_member得到角色,再映射到权限点。 - "项目 A 里有哪些成员?" → 按
space_id查space_member。
4. 权限判断的核心流程
当用户发起一次操作(比如"编辑项目 A 的文档")时,判断流程如下:
- 找到资源所属的 Space。
- 查
space_member,得到该用户在这个 Space 内的 Role。 - 由 Role 映射出 Permission 集合。
- 判断所需权限点是否在集合中。
java
public class PermissionService {
private final SpaceMemberMapper spaceMemberMapper;
private final RolePermissionMapper rolePermissionMapper;
public boolean checkPermission(Long userId, Long spaceId, String permissionCode) {
// 1. 用户在空间内的角色
SpaceMember member = spaceMemberMapper.findBySpaceIdAndUserId(spaceId, userId);
if (member == null) {
return false; // 不是空间成员,默认无权限
}
// 2. 角色对应的权限点
List<String> codes = rolePermissionMapper.findPermissionCodesByRoleId(member.getRoleId());
return codes.contains(permissionCode);
}
}
5. 如何兼顾"个人资格"与"共享空间"
你提到的两个诉求,在模型里分别对应两种授权方式:
5.1 个人专属资格
当某个用户拥有独立于任何空间的资格时(例如"全局管理员""仅查看自己的数据"),可以:
- 在
user_role表中记录用户的全局角色; - 或在
space_member中为每个用户创建一个私有空间(Personal Space),把个人资源挂进去。
sql
CREATE TABLE 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)
) COMMENT '用户全局角色表:与空间无关的资格';
5.2 共享空间内的差异化资格
多个用户共同占有同一个 Space,但各自资格不同:
| 用户 | 空间 | 角色 | 可执行操作 |
|---|---|---|---|
| 张三 | 项目 A | 编辑者 | 编辑、评论 |
| 李四 | 项目 A | 访客 | 仅查看 |
| 王五 | 项目 A | 管理员 | 编辑、删除、管理成员 |
这正是 space_member 表的价值:同一个 space_id,不同 user_id,不同 role_id。
6. 权限判断的完整时序
数据库 PermissionService 业务接口 用户 数据库 PermissionService 业务接口 用户 #mermaid-svg-G3L1k9IhwGFvzQNE{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-G3L1k9IhwGFvzQNE .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-G3L1k9IhwGFvzQNE .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-G3L1k9IhwGFvzQNE .error-icon{fill:#552222;}#mermaid-svg-G3L1k9IhwGFvzQNE .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-G3L1k9IhwGFvzQNE .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-G3L1k9IhwGFvzQNE .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-G3L1k9IhwGFvzQNE .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-G3L1k9IhwGFvzQNE .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-G3L1k9IhwGFvzQNE .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-G3L1k9IhwGFvzQNE .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-G3L1k9IhwGFvzQNE .marker{fill:#333333;stroke:#333333;}#mermaid-svg-G3L1k9IhwGFvzQNE .marker.cross{stroke:#333333;}#mermaid-svg-G3L1k9IhwGFvzQNE svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-G3L1k9IhwGFvzQNE p{margin:0;}#mermaid-svg-G3L1k9IhwGFvzQNE .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-G3L1k9IhwGFvzQNE text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-G3L1k9IhwGFvzQNE .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-G3L1k9IhwGFvzQNE .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-G3L1k9IhwGFvzQNE .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-G3L1k9IhwGFvzQNE .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-G3L1k9IhwGFvzQNE #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-G3L1k9IhwGFvzQNE .sequenceNumber{fill:white;}#mermaid-svg-G3L1k9IhwGFvzQNE #sequencenumber{fill:#333;}#mermaid-svg-G3L1k9IhwGFvzQNE #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-G3L1k9IhwGFvzQNE .messageText{fill:#333;stroke:none;}#mermaid-svg-G3L1k9IhwGFvzQNE .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-G3L1k9IhwGFvzQNE .labelText,#mermaid-svg-G3L1k9IhwGFvzQNE .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-G3L1k9IhwGFvzQNE .loopText,#mermaid-svg-G3L1k9IhwGFvzQNE .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-G3L1k9IhwGFvzQNE .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-G3L1k9IhwGFvzQNE .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-G3L1k9IhwGFvzQNE .noteText,#mermaid-svg-G3L1k9IhwGFvzQNE .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-G3L1k9IhwGFvzQNE .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-G3L1k9IhwGFvzQNE .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-G3L1k9IhwGFvzQNE .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-G3L1k9IhwGFvzQNE .actorPopupMenu{position:absolute;}#mermaid-svg-G3L1k9IhwGFvzQNE .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-G3L1k9IhwGFvzQNE .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-G3L1k9IhwGFvzQNE .actor-man circle,#mermaid-svg-G3L1k9IhwGFvzQNE line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-G3L1k9IhwGFvzQNE :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 请求编辑项目A的文档checkPermission(userId, spaceId, "doc:edit")查询 space_member返回角色 roleId查询 role_permission返回权限点集合是否包含 "doc:edit"允许 / 拒绝
7. 常见坑与建议
- 不要把权限写死在业务代码里 :用
if (user.isAdmin())这种散落判断,后期会失控。统一走权限点校验。 - 空间删除前先清理成员关系:避免产生孤儿数据。
- 权限点要足够细:至少拆到"查看 / 编辑 / 删除 / 管理成员"级别,方便后续做细粒度控制。
- 缓存角色-权限映射 :权限判断是高频操作,建议把
role_permission缓存到 Redis,减少数据库压力。 - 超管与空间管理员分开 :全局超管走
user_role,空间管理员走space_member,两者互不干扰。
8. 总结
权限管理的核心,是把"资格"和"空间"两个维度解耦:
- 资格用 RBAC 管理,落到用户个体;
- 空间 用 Space 容器管理,多个用户通过
space_member共享同一空间,并在空间内拥有各自独立的角色。
这样既满足了"每个用户具有自己使用权限资源的资格",也实现了"共同占有同样的空间"。按这个模型落地,后续无论是加新空间、调成员角色,还是做细粒度权限,都能平滑扩展。