第 87 章:角色与权限模块
学习目标
- 实现角色 CRUD 与菜单分配
- 实现菜单树查询与按钮权限校验
- 完成 RBAC 权限闭环
一、模块结构
二、角色管理
实体
java
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("sys_role")
public class Role extends BaseEntity {
private String name;
private String code;
private String description;
private Integer sort;
private Integer status;
private Integer dataScope;
}DTO
java
@Data
public class RoleCreateDTO {
@NotBlank private String name;
@NotBlank @Pattern(regexp = "^ROLE_[A-Z_]+$")
private String code;
private String description;
private Integer sort;
private Integer dataScope = 4;
private List<Long> menuIds;
}
@Data
public class RoleUpdateDTO {
@NotBlank private String name;
private String description;
private Integer sort;
private Integer dataScope;
private List<Long> menuIds;
}
@Data
public class RolePageQuery {
@Min(1) private Long current = 1L;
@Min(1) @Max(200) private Long size = 10L;
private String keyword;
private Integer status;
}Service
java
public interface RoleService extends IService<Role> {
Long createRole(RoleCreateDTO dto);
void updateRole(Long id, RoleUpdateDTO dto);
void deleteRole(Long id);
void assignMenus(Long roleId, List<Long> menuIds);
PageResult<RoleVO> page(RolePageQuery query);
RoleVO getVOById(Long id);
List<RoleVO> listAll();
}java
@Service
@RequiredArgsConstructor
public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements RoleService {
private final RoleMenuMapper roleMenuMapper;
private final UserRoleMapper userRoleMapper;
private final MenuMapper menuMapper;
private final RoleConvert roleConvert;
@Override
@Transactional
public Long createRole(RoleCreateDTO dto) {
// ① 检查编码唯一
if (baseMapper.selectByCode(dto.getCode()) != null) {
throw new BusinessException("角色编码已存在");
}
Role role = roleConvert.toEntity(dto);
role.setStatus(1);
baseMapper.insert(role);
// ② 分配菜单
if (dto.getMenuIds() != null && !dto.getMenuIds().isEmpty()) {
assignMenus(role.getId(), dto.getMenuIds());
}
return role.getId();
}
@Override
@Transactional
public void updateRole(Long id, RoleUpdateDTO dto) {
Role role = baseMapper.selectById(id);
Assert.notNull(role, "角色不存在");
roleConvert.updateEntity(dto, role);
baseMapper.updateById(role);
if (dto.getMenuIds() != null) {
assignMenus(id, dto.getMenuIds());
}
}
@Override
@Transactional
public void deleteRole(Long id) {
Assert.isTrue(!id.equals(1L), "超级管理员角色不可删除");
// ① 检查是否有用户使用
if (userRoleMapper.countByRoleId(id) > 0) {
throw new BusinessException(ErrorCode.ROLE_IN_USE);
}
baseMapper.deleteById(id);
roleMenuMapper.deleteByRoleId(id);
}
@Override
@Transactional
public void assignMenus(Long roleId, List<Long> menuIds) {
roleMenuMapper.deleteByRoleId(roleId);
if (menuIds != null) {
for (Long menuId : menuIds) {
roleMenuMapper.insert(new RoleMenu(roleId, menuId));
}
}
// ① 清理所有该角色用户的权限缓存
userRoleMapper.selectUserIdsByRoleId(roleId)
.forEach(uid -> permissionService.clearCache(uid));
}
@Override
public PageResult<RoleVO> page(RolePageQuery query) {
Page<Role> page = new Page<>(query.getCurrent(), query.getSize());
LambdaQueryWrapper<Role> wrapper = new LambdaQueryWrapper<Role>()
.like(StringUtils.hasText(query.getKeyword()), Role::getName, query.getKeyword())
.eq(query.getStatus() != null, Role::getStatus, query.getStatus())
.orderByAsc(Role::getSort);
IPage<Role> result = baseMapper.selectPage(page, wrapper);
return PageResult.of(result, this::toVO);
}
@Override
public RoleVO getVOById(Long id) {
Role role = baseMapper.selectById(id);
Assert.notNull(role, "角色不存在");
RoleVO vo = roleConvert.toVO(role);
vo.setMenuIds(roleMenuMapper.selectMenuIdsByRoleId(id));
return vo;
}
@Override
public List<RoleVO> listAll() {
return baseMapper.selectList(
new LambdaQueryWrapper<Role>()
.eq(Role::getStatus, 1)
.orderByAsc(Role::getSort))
.stream().map(roleConvert::toVO).toList();
}
private RoleVO toVO(Role role) {
return roleConvert.toVO(role);
}
}三、菜单管理
实体
java
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("sys_menu")
public class Menu extends BaseEntity {
private Long parentId;
private String name;
private Integer type; // 1=目录 2=菜单 3=按钮
private String permission;
private String path;
private String component;
private String icon;
private Integer sort;
private Integer visible;
private Integer status;
}DTO / VO
java
@Data
public class MenuCreateDTO {
@NotNull private Long parentId;
@NotBlank private String name;
@NotNull @Min(1) @Max(3) private Integer type;
private String permission;
private String path;
private String component;
private String icon;
private Integer sort;
private Integer visible = 1;
}
@Data
public class MenuVO {
private Long id;
private Long parentId;
private String name;
private Integer type;
private String permission;
private String path;
private String component;
private String icon;
private Integer sort;
private List<MenuVO> children;
}Service
java
public interface MenuService extends IService<Menu> {
Long createMenu(MenuCreateDTO dto);
void updateMenu(Long id, MenuUpdateDTO dto);
void deleteMenu(Long id);
List<MenuVO> tree();
Set<String> currentUserPermissions();
List<MenuVO> currentUserMenuTree();
}java
@Service
@RequiredArgsConstructor
public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements MenuService {
private final RoleMenuMapper roleMenuMapper;
private final PermissionService permissionService;
private final RedisTemplate<String, Object> redis;
private final MenuConvert menuConvert;
@Override
@Transactional
public Long createMenu(MenuCreateDTO dto) {
Menu menu = menuConvert.toEntity(dto);
menu.setStatus(1);
baseMapper.insert(menu);
return menu.getId();
}
@Override
public void updateMenu(Long id, MenuUpdateDTO dto) {
Menu menu = baseMapper.selectById(id);
Assert.notNull(menu, "菜单不存在");
menuConvert.updateEntity(dto, menu);
baseMapper.updateById(menu);
// ① 清理所有权限缓存
permissionService.clearAllCache();
}
@Override
@Transactional
public void deleteMenu(Long id) {
// ① 检查是否有子菜单
Long childCount = baseMapper.selectCount(
new LambdaQueryWrapper<Menu>().eq(Menu::getParentId, id));
Assert.isTrue(childCount == 0, "请先删除子菜单");
// ② 检查是否被角色引用
Long refCount = roleMenuMapper.countByMenuId(id);
Assert.isTrue(refCount == 0, "菜单已被角色使用,无法删除");
baseMapper.deleteById(id);
permissionService.clearAllCache();
}
@Override
public List<MenuVO> tree() {
List<Menu> all = baseMapper.selectList(
new LambdaQueryWrapper<Menu>()
.orderByAsc(Menu::getSort));
return buildTree(all, 0L);
}
@Override
public Set<String> currentUserPermissions() {
return permissionService.getUserPermissions(SecurityUtils.getCurrentUserId());
}
@Override
public List<MenuVO> currentUserMenuTree() {
Long userId = SecurityUtils.getCurrentUserId();
List<Menu> menus = baseMapper.selectByUserId(userId);
return buildTree(menus, 0L);
}
private List<MenuVO> buildTree(List<Menu> all, Long parentId) {
return all.stream()
.filter(m -> m.getParentId().equals(parentId))
.map(m -> {
MenuVO vo = menuConvert.toVO(m);
vo.setChildren(buildTree(all, m.getId()));
return vo;
})
.toList();
}
}Controller
java
@RestController
@RequestMapping("/api/menu")
@RequiredArgsConstructor
@Tag(name = "菜单管理")
public class MenuController {
private final MenuService menuService;
@GetMapping("/tree")
@Operation(summary = "菜单树")
public Result<List<MenuVO>> tree() {
return Result.ok(menuService.tree());
}
@GetMapping("/current")
@Operation(summary = "当前用户菜单")
public Result<List<MenuVO>> currentUserMenu() {
return Result.ok(menuService.currentUserMenuTree());
}
@GetMapping("/permissions")
@Operation(summary = "当前用户按钮权限")
public Result<Set<String>> permissions() {
return Result.ok(menuService.currentUserPermissions());
}
@PostMapping
@RequiresPermission("menu:create")
public Result<Long> create(@RequestBody @Valid MenuCreateDTO dto) {
return Result.ok(menuService.createMenu(dto));
}
@PutMapping("/{id}")
@RequiresPermission("menu:update")
public Result<Void> update(@PathVariable Long id, @RequestBody MenuUpdateDTO dto) {
menuService.updateMenu(id, dto);
return Result.ok();
}
@DeleteMapping("/{id}")
@RequiresPermission("menu:delete")
public Result<Void> delete(@PathVariable Long id) {
menuService.deleteMenu(id);
return Result.ok();
}
}四、PermissionService(权限缓存核心)
java
@Service
@RequiredArgsConstructor
public class PermissionService {
private final RedisTemplate<String, Object> redis;
private final UserMapper userMapper;
private final RoleMapper roleMapper;
private final MenuMapper menuMapper;
private static final String PERM_KEY_PREFIX = "user:perms:";
private static final Duration CACHE_TTL = Duration.ofMinutes(30);
public Set<String> getUserPermissions(Long userId) {
String key = PERM_KEY_PREFIX + userId;
// ① 查缓存
Object cached = redis.opsForValue().get(key);
if (cached instanceof Set<?> set) {
return set.stream().map(Object::toString).collect(Collectors.toSet());
}
// ② 查 DB
Set<String> perms = new HashSet<>();
List<Role> roles = roleMapper.selectByUserId(userId);
for (Role role : roles) {
List<Menu> menus = menuMapper.selectByRoleId(role.getId());
for (Menu menu : menus) {
if (menu.getType() == 3 && StringUtils.hasText(menu.getPermission())) {
perms.add(menu.getPermission());
}
}
}
// ③ 写缓存
redis.opsForValue().set(key, perms, CACHE_TTL);
return perms;
}
public void clearCache(Long userId) {
redis.delete(PERM_KEY_PREFIX + userId);
}
public void clearAllCache() {
Set<String> keys = redis.keys(PERM_KEY_PREFIX + "*");
if (keys != null && !keys.isEmpty()) {
redis.delete(keys);
}
}
}五、权限注解
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasAuthority('{value}')")
public @interface RequiresPermission {
String value();
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('{value}')")
public @interface RequiresRole {
String value();
}java
// 用法
@RequiresPermission("user:create")
@PostMapping
public Result<Long> create(...) { ... }
@RequiresRole("ADMIN")
@DeleteMapping("/{id}")
public Result<Void> delete(...) { ... }六、Controller:角色管理
java
@RestController
@RequestMapping("/api/role")
@RequiredArgsConstructor
@Tag(name = "角色管理")
public class RoleController {
private final RoleService roleService;
@GetMapping("/page")
@Operation(summary = "角色分页")
public Result<PageResult<RoleVO>> page(RolePageQuery query) {
return Result.ok(roleService.page(query));
}
@GetMapping("/all")
@Operation(summary = "所有角色")
public Result<List<RoleVO>> all() {
return Result.ok(roleService.listAll());
}
@GetMapping("/{id}")
public Result<RoleVO> getById(@PathVariable Long id) {
return Result.ok(roleService.getVOById(id));
}
@PostMapping
@RequiresPermission("role:create")
public Result<Long> create(@RequestBody @Valid RoleCreateDTO dto) {
return Result.ok(roleService.createRole(dto));
}
@PutMapping("/{id}")
@RequiresPermission("role:update")
public Result<Void> update(@PathVariable Long id, @RequestBody @Valid RoleUpdateDTO dto) {
roleService.updateRole(id, dto);
return Result.ok();
}
@DeleteMapping("/{id}")
@RequiresPermission("role:delete")
public Result<Void> delete(@PathVariable Long id) {
roleService.deleteRole(id);
return Result.ok();
}
@PutMapping("/{id}/menus")
@RequiresPermission("role:assign")
public Result<Void> assignMenus(@PathVariable Long id, @RequestBody List<Long> menuIds) {
roleService.assignMenus(id, menuIds);
return Result.ok();
}
}七、完整 RBAC 闭环
八、本章小结
| 要点 | 关键 |
|---|---|
| Role | CRUD + 分配菜单 |
| Menu | 三级菜单(目录/菜单/按钮) |
| 权限 | 按钮级 + 菜单级 + 路径级 |
| 注解 | @RequiresPermission + @RequiresRole |
| 缓存 | Redis 缓存用户权限(30 分钟) |
| 失效 | 角色/菜单变更时主动清理 |
| 数据权限 | role.data_scope:1-5 |
动手练习
练习 1:基础题
实现角色管理 CRUD:新增角色、修改、删除、分页查询、分配菜单。
练习 2:进阶题
实现完整的权限校验:用 @RequiresPermission 控制接口访问,未登录返回 401,无权限返回 403。
练习 3:思考题
权限变更时如何让所有用户立即生效?除了 Redis 失效,还有什么方案?
下一章:第 88 章:部门与字典模块 →