第八章 · 系统模块(RBAC 权限模型与标准分层模板)
本章讲 system 模块(387 个 Java 文件,已启用模块中体量最大)。任务有三:①看懂它内部的分层模板(Controller→Service→Convert→DO→Mapper),这**是全工程 80% 业务代码的”标准长相”**;②吃透 RBAC 权限模型(用户↔角色↔菜单);③理解”权限判定”背后 Redis 缓存与
PermissionApi边界如何协作。
1. 模块概述
yudao-module-system 承载通用能力:用户/角色/菜单/部门/岗位/字典/租户/短信/邮件/操作日志/登录日志/错误码/通知公告/OAuth2……
模块内部按 controller / service / dal / convert / framework / enums / mq 子包组织,其中 dal 又细分 dataobject(DO)、mysql(Mapper)。一条标准 CRUD 的代码形态如下:
1 | controller/admin/user/UserController → 端点 + @PreAuthorize 权限 |
flowchart LR
C[UserController] --> SVC[AdminUserServiceImpl]
SVC --> CV[UserConvert]
SVC --> DP[DataPermissionUtils 数据权限]
SVC --> MAP[AdminUserMapper]
MAP --> DO[SysUserDO extends BaseDO/TenantBaseDO]
SVC -.权限缓存.-> PERM[PermissionService @Cacheable]
C -.注解权限.-> SS[SecurityFrameworkService]
2. 命名体系与易混淆对象对比
2.1 命名规律拆解(三层 VO + 实体)
| 前缀/命名 | 对象 | 含义 |
|---|---|---|
XxxSaveReqVO |
入参 | 新增/更新共用的请求体 |
XxxPageReqVO / XxxListReqVO |
入参 | 分页/列表查询条件 |
XxxRespVO |
出参 | 展示给前端的响应体 |
XxxSimpleRespVO |
出参 | 下拉/简化版,用于”字典/部门联动” |
XxxDO |
数据实体 | 映射数据库表的领域对象(Data Object) |
命名规律总结:请求(Req)、响应(Resp)、实体(DO)三类命名语义无歧义;SimpleRespVO 是”只给下拉用”的裁剪版,避免返回多余字段。VO 转换用 MapStruct 生成器(XxxConvert.INSTANCE),见源码 convert 包。
2.2 易混淆对象对比:SysUserDO的 @TableName("system_users") 与一般约
值得留意的是,AdminUserDO 用 @TableName(value = "system_users", autoResultMap = true)——源码注释说明:”由于 SQL Server 的 system_user 是关键字,所以使用 system_users“。这是”为兼容多数据库而改表名”的典型。autoResultMap = true 是为了让 @TableField(typeHandler = JacksonTypeHandler.class) 的自定义类型(如 postIds: Set<Long>)在 CRUD 时能正确序列化/反序列化。
再对比 **MenuDO vs RoleDO vs Permission**:
| 对比维度 | system_menu |
system_role |
权限串 permission |
|---|---|---|---|
| 本质 | 菜单/按钮资源(含 permission 字段) |
角色,通过 system_role_menu 绑定菜单 |
菜单上配置的权限标识字符串 |
| 关联 | 角色↔菜单多对多 | 用户↔角色(system_user_role) |
归属某个菜单 |
| 判定入口 | 取菜单→取 permission 串 | 取角色 | @ss.hasPermission 比对 |
| 一句话区分 | 是”资源清单”;是”角色包”;是”资源的权限代号”。 |
3. API Signatures
3.1 用户 CRUD(UserController / AdminUserService)
1 | // UserController 端点(@PreAuthorize 注解式权限) |
1 | // AdminUserService 接口(节选) |
3.2 RBAC 查询(PermissionService)
1 | public class PermissionServiceImpl { |
4. 数据结构深度解析
4.a 结构体存在的理由:SysUserDO
用户是 RBAC 的起点。
SysUserDO除基础身份字段外,还承担:①关联部门deptId(用于部门数据权限);②岗位集合postIds: Set<Long>(用JacksonTypeHandler把集合序列化进单列);③密码password(BCrypt 密文);④租户tenantId(继承TenantBaseDO,多租户表)。选它做”三层字段分析”示例,能覆盖实体最常见的四种字段用法。
4.b 代码:AdminUserDO 字段关键部分
📍 源码:AdminUserDO.java
1 |
|
4.c 字段三层分析表(SysUserDO 代表性字段)
| 字段 | 设计动机 | 反事实 | 替代方案 |
|---|---|---|---|
deptId |
归属部门 → 部门数据权限(”只看本部门”) | 无法做组织隔离 | 存 dept 名,丢失 id 关联 |
postIds: Set<Long> |
一人多岗,避免拆关联表 | 只能单岗,表达能力下降 | 拆 system_user_post 关联表,多一条查询 |
password |
BCrypt 密文,防明文泄露 | 明文存库,一旦泄露全量暴露 | 用 MD5,但无盐/可彩虹表,BCrypt 更优 |
tenantId(继承) |
多租户归属 | 无法租户隔离 | 见 第六章 |
4.d 生命周期状态图(用户)
stateDiagram-v2
[*] --> 正常: createUser(默认 ENABLE)
正常 --> 停用: updateUserStatus(DISABLE)
停用 --> 正常: 重新启用
正常 --> 已删: deleteUser(逻辑删除 deleted=1)
已删 --> [*]
5. 函数逐行精讲
5.a 场景卡片
**函数:
AdminUserServiceImpl.createUser**(源码:87-116)
- 调用时机:管理员新增用户。
- 典型调用者:
UserController.create。- 前置条件:
@PreAuthorize("system:user:create")已通过;Controller@Valid校验已过。- 目的:在租户额度内创建用户,密码加密,写入用户与岗位关联。
5.b 逐行注释式精讲
1 |
|
🔍 连贯细节:
validateUserForCreateOrUpdate之所以要DataPermissionUtils.executeIgnore,是因为 校验唯一性时不能受部门数据权限过滤(例如部门管理员看不到总部用户,但新增时仍需校验用户名唯一)。这是”通用校验需穿透数据权限”的典型场景。
5.c 场景卡片:getPermissionInfo(登录后回显用户权限)
**函数:
AuthController.getPermissionInfo**(源码:92-116)
- 调用时机:登录成功后前端拉取”我的权限/菜单”。
- 典型调用者:前端路由初始化。
- 前置条件:已登录。
- 目的:把 用户 + 启用角色 + 启用菜单 拼给前端,用于动态路由与按钮权限渲染。
1 | public CommonResult<AuthPermissionInfoRespVO> getPermissionInfo() { |
6. 关键机制剖析:RBAC 判定 + Redis 缓存 + API 边界
6.1 权限判定数据流
sequenceDiagram
participant C as AuthController(登录后)
participant P as PermissionService
participant R as Redis(@Cacheable)
participant DB as MySQL
C->>P: getUserRoleIdListByUserId(userId)
P->>R: 命中? user_role_ids:{userId}
alt 未命中
P->>DB: user_role 表查 roleId 集合
DB-->>P: Set
P->>R: 回填缓存
end
P-->>C: Set
C->>P: getRoleMenuListByRoleId(roleIds)
P->>R: 盲查 menu_role_ids / role 缓存
P-->>C: 菜单集合(超管直接全量)
- 写操作失效:
assignRoleMenu/assignUserRole时用@CacheEvict(..., allEntries=true)批量清空,保证”改完立刻生效”。 - 超管特判:
getRoleMenuListByRoleId对超管角色直接返回全部菜单,不查关联表。
6.2 PermissionApi 边界(framework ↔ system 的契约)
| 接口方法 | 实现委托 | 说明 |
|---|---|---|
hasAnyPermissions(userId, permissions) |
permissionService.hasAnyPermissions |
供 @ss 注解判定 |
hasAnyRoles(userId, roles) |
permissionService.hasAnyRoles |
角色判定 |
getUserRoleIdListByRoleIds(roleIds) |
permissionService.getUserRoleIdListByRoleId |
反查拥有这些角色的用户 |
getDeptDataPermission(userId) |
permissionService.getDeptDataPermission |
部门数据权限 DTO |
🔑 注意区面 vs 实现:
PermissionApi与PermissionService的并发维度不同——PermissionApi是对内暴露给 framework 的稳定契约;PermissionService是 module 内部实现(含getUserRoleIdListByUserId这类自用方法),framework 只依赖前者。
7. 设计决策分析
- 为什么 RBAC 用”用户→角色→菜单”三张关联表? 经典 RBAC:用户与角色、角色与菜单各一张关联表,中间保留角色这一层,使”批量授权 + 组织权限”成为可能;角色抽象了”一组权限”,比用户直接绑权限可维护得多。
- 为什么权限数据走 Redis 缓存而非每次查库? 权限判定在每次请求都可能发生(
@PreAuthorize),直接查库会在高并发下打爆 DB。用@Cacheable按 userId/menuId 缓存,且写操作@CacheEvict保证一致性,是”读多写少”负载的经典解。 - 为什么
PermissionApi只暴露少量方法? 稳定契约应小而准——只暴露真正被 framework 需要的判定能力,避免把内部实现细节(如逐个查询)泄露出去,也降低跨模块重构的摩擦。
8. 学习检查点
📝 本章小结
- system 模块分 Controller/Service/Convert/DO/Mapper 五层,DO 继承
TenantBaseDO(多租户)与BaseDO(审计字段)。 - RBAC = 用户↔角色↔菜单三张关联表 + 权限串
permission,配合@PreAuthorize("@ss.hasPermission(...)")落校验。 - 权限查询走 Redis
@Cacheable,写操作@CacheEvict失效;超管特判免查询。 - 唯一性校验用
DataPermissionUtils.executeIgnore穿透部门权限,避免”看不到就别给我校验”的坑。 PermissionApi是 framework 依赖的稳定契约,PermissionService是 module 内部实现。
🤔 思考题
postIds用Set<Long>+JacksonTypeHandler单列存 JSON,和拆成user_post关联表比,各自的取舍是什么?(提示:查询 / 一致 / 反范式)参考答案
单列 JSON:读用户时一次查回全部岗位,写时一次更新,实现简单;但在”按岗位过滤用户”场景需要 LIKE/JSON 函数,无法走索引,且多实例并发更新列有覆盖风险。关联表:支持
JOIN/索引高效按岗位查用户,事务下多行写入更规范;代价是增删一次用户要额外维护关联、多一条查询。yudao 的AdminUserDO选择单列(postIds),节省查询次数,是靠”岗位维度反查用户”的低频业务假设来接受的。getRoleMenuListByRoleId对超管直接返回全部菜单——如果让超管也走关联表,会有什么问题?(提示:系统内置菜单可能不与任何角色关联、性能)参考答案
①正确性:系统固有菜单/管理后台入口可能不与任何普通角色关联,若超管也”只能看到其角色绑定的菜单”,会出现超管看不到管理后台的情形。②性能:超管是高频主体,走关联表多一次 join;直接返回全量省去查询。因此”超管免查全量”既是语义需要,也是性能优化。
@CacheEvict(value = USER_ROLE_ID_LIST, key = "#userId")只清当前用户,而assignRoleMenu用allEntries=true清全部,为什么策略不同?(提示:回收范围决定一致性成本)参考答案
assignUserRole只改变了一个用户的角色集合,精确清空该USER_ROLE_ID_LIST:{userId}缓存即可,代价小;而assignRoleMenu改的是一个角色→菜单的映射,这个角色可能被很多用户引用(每位用户缓存里都存了角色→菜单的结果),得清空MENU_ROLE_ID_LIST与ROLE相关全部条目(allEntries=true)才能保证任一用户下次拿到最新权限。策略上”精确到入参影响面,扩大则全清”,在一致性与维护成本间取平衡。