Skip to content
第 8 章 ⏱ 10 分钟阅读

第 8 章:角色模块 ​

学习目标 ​

  • 实现角色的 CRUD 接口
  • 实现"角色绑定菜单"功能
  • 实现"角色绑定用户"功能

一、角色实体 ​

java
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("sys_role")
public class Role extends BaseEntity {

    private String name;          // 角色名:超级管理员
    private String code;          // 编码:ROLE_ADMIN
    private String description;
    private Integer sort;
    private Integer status;       // 0=禁用 1=正常
    private Integer dataScope;    // 1-5 数据权限范围
}

⚠️ 坑 1:角色编码 code 用大写常量约定 (ROLE_ADMIN)。前端可以通过 v-if="hasRole('ADMIN')" 隐藏菜单。命名约定保持前后端一致,别一会 ADMIN 一会 SuperAdmin。

二、DTO ​

java
@Data
public class RoleCreateDTO {

    @NotBlank(message = "角色名不能为空")
    @Length(max = 50)
    private String name;

    @NotBlank(message = "角色编码不能为空")
    @Pattern(regexp = "^ROLE_[A-Z_]+$",
             message = "编码格式:ROLE_后跟大写字母和下划线")
    private String code;

    private String description;

    @Min(0)
    private Integer sort = 0;

    private Integer dataScope = 4;   // 默认仅本人

    private List<Long> menuIds;      // 创建时直接分配菜单
}

dataScope 五个值:

值含义
1全部数据
2本部门及下级
3本部门
4仅本人
5自定义(配合 sys_role_dept)

三、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 RoleConvert roleConvert;

    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createRole(RoleCreateDTO dto) {
        // ① 编码唯一性
        if (baseMapper.selectByCode(dto.getCode()) != null) {
            throw new BusinessException(ErrorCode.ROLE_EXISTS);
        }

        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(rollbackFor = Exception.class)
    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(rollbackFor = Exception.class)
    public void deleteRole(Long id) {
        // ① 保护超管
        Assert.isTrue(!id.equals(1L), "超级管理员角色不可删除");

        // ② 检查是否被用户使用
        Long userCount = userRoleMapper.countByRoleId(id);
        if (userCount > 0) {
            throw new BusinessException(ErrorCode.ROLE_IN_USE);
        }

        baseMapper.deleteById(id);
        roleMenuMapper.deleteByRoleId(id);
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void assignMenus(Long roleId, List<Long> menuIds) {
        // 先删后插(简单可靠)
        roleMenuMapper.deleteByRoleId(roleId);
        if (menuIds != null && !menuIds.isEmpty()) {
            for (Long menuId : menuIds) {
                roleMenuMapper.insert(new RoleMenu(roleId, menuId));
            }
        }

        // ① 失效该角色所有用户的权限缓存
        List<Long> userIds = userRoleMapper.selectUserIdsByRoleId(roleId);
        for (Long userId : userIds) {
            permissionService.clearCache(userId);
        }
    }

    @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, roleConvert::toVO);
    }

    @Override
    public List<RoleVO> listAll() {
        return baseMapper.selectList(
                new LambdaQueryWrapper<Role>()
                        .eq(Role::getStatus, 1)
                        .orderByAsc(Role::getSort))
                .stream().map(roleConvert::toVO).toList();
    }
}

⚠️ 坑 2:改完角色菜单必须清权限缓存,否则用户继续用旧权限。生产事故常见原因:开发改完功能自测一切正常,运营同事反馈"我没改动怎么没生效",就是缓存清错了。

四、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}")
    @Operation(summary = "角色详情(含菜单ID)")
    public Result<RoleVO> getById(@PathVariable Long id) {
        return Result.ok(roleService.getVOById(id));
    }

    @PostMapping
    @PreAuthorize("hasAuthority('role:create')")
    @Operation(summary = "创建角色")
    public Result<Long> create(@RequestBody @Valid RoleCreateDTO dto) {
        return Result.ok(roleService.createRole(dto));
    }

    @PutMapping("/{id}")
    @PreAuthorize("hasAuthority('role:update')")
    public Result<Void> update(@PathVariable Long id,
                               @RequestBody @Valid RoleUpdateDTO dto) {
        roleService.updateRole(id, dto);
        return Result.ok();
    }

    @DeleteMapping("/{id}")
    @PreAuthorize("hasAuthority('role:delete')")
    public Result<Void> delete(@PathVariable Long id) {
        roleService.deleteRole(id);
        return Result.ok();
    }

    @PutMapping("/{id}/menus")
    @PreAuthorize("hasAuthority('role:assign')")
    @Operation(summary = "分配菜单")
    public Result<Void> assignMenus(@PathVariable Long id,
                                    @RequestBody List<Long> menuIds) {
        roleService.assignMenus(id, menuIds);
        return Result.ok();
    }
}

五、用户分配角色 ​

角色管理之外,还需要"给用户分配角色"接口(在用户模块里):

java
@PutMapping("/{id}/roles")
@PreAuthorize("hasAuthority('user:assign-role')")
@Operation(summary = "分配角色")
public Result<Void> assignRoles(@PathVariable Long id,
                                @RequestBody List<Long> roleIds) {
    userService.assignRoles(id, roleIds);
    return Result.ok();
}
java
@Override
@Transactional
public void assignRoles(Long userId, List<Long> roleIds) {
    userRoleMapper.deleteByUserId(userId);
    if (roleIds != null) {
        for (Long roleId : roleIds) {
            userRoleMapper.insert(new UserRole(userId, roleId));
        }
    }
    // 失效该用户的权限缓存
    permissionService.clearCache(userId);
}

六、批量校验:一个用户多个角色 ​

角色数据合并时,典型的坑:

java
// 用户 A 有两个角色:ROLE_USER + ROLE_VIEWER
// 当前用户的所有权限是这两个角色菜单的并集

public Set<String> loadFromDB(Long userId) {
    Set<String> permissions = 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())) {
                permissions.add(menu.getPermission());
            }
        }
    }
    return permissions;
}

⚠️ 坑 3:权限合并必须用 Set,不能用 List!否则重复的 permission 会导致 hasAuthority 重复判定,本身没问题但浪费内存。同一个用户的多个角色有重复菜单很常见。

七、前端展示(分配菜单对话框) ​

用 Element Plus 的 <el-tree> 和 <el-checkbox>:

vue
<template>
  <el-dialog v-model="visible" title="分配菜单" width="600px">
    <el-tree
        ref="treeRef"
        :data="menuTree"
        :props="{ label: 'name', children: 'children' }"
        show-checkbox
        node-key="id"
        :default-checked-keys="checkedIds"
    />
    <template #footer>
      <el-button @click="visible = false">取消</el-button>
      <el-button type="primary" @click="submit">确定</el-button>
    </template>
  </el-dialog>
</template>

<script setup>
import { ref, watch } from 'vue'
import api from '@/api'

const props = defineProps({ roleId: Number })

const visible = ref(false)
const menuTree = ref([])
const checkedIds = ref([])

const treeRef = ref()

watch(visible, async (val) => {
  if (val) {
    const { data } = await api.getMenuTree()
    menuTree.value = data

    const detail = await api.getRoleById(props.roleId)
    checkedIds.value = detail.data.menuIds
  }
})

const submit = async () => {
  const ids = treeRef.value.getCheckedKeys()
    .concat(treeRef.value.getHalfCheckedKeys())  // 父节点也带上
  await api.assignMenus(props.roleId, ids)
  visible.value = false
}
</script>

八、本章小结 ​

要点关键
角色编码ROLE_ 前缀,大写字母下划线
数据权限dataScope 1-5,5 配合 sys_role_dept
删除保护校验是否被用户使用
菜单绑定先删后插,失效权限缓存
多角色并集用 Set 收集 permission

动手练习 ​

  1. 创建角色:实现新增/修改/删除角色接口,带菜单分配
  2. 测权限失效:创建角色 → 分配菜单 → 用该角色用户登录 → 看权限生效
  3. 删除保护:测试删除"超级管理员"被拦,删除"被用户引用的角色"被拦

下一章:第 9 章:菜单模块 →

本站基于 VitePress 构建 · 由 StackHub 团队维护