从零手写数智人事系统(十二):部门树、角色授权与菜单维护

从零手写数智人事系统(十二):部门树、角色授权与菜单维护

第 9 期验证权限链路时,是手写 SQL 往 per_role_menu 里添数据。部门要能维护、菜单要能维护、角色要能配权限、员工要能分角色------这一期相当于把这部分功能实现出来。


动手前提

需完成第 9 期(RBAC 四张表与 @PreAuthorize 校验链)与第 11 期(CRUD 实现)。要看前端交互细节,可补第 10 期(动态路由与 v-permission)。


本期目标

主要实现三块功能:

  • 部门管理 :两级部门用 parent_id 表示父子关系;列表按根部门分页,按需查回关联子部门后挂到父级下;
  • 菜单管理 :目录、菜单、权限点三级用 level 区分;新增时目录下挂菜单、菜单下挂权限点;权限点可以启用、停用;
  • 角色授权与员工分角色:授权对话框怎么回显、怎么提交;两张关系表都用"先删后插"更新。

页面对应的数据库表

都是 MySQL hrm 库里的表:

  • 部门管理(views/system/department)→ sys_dept;
  • 菜单管理(views/permission/menu)→ per_menu;
  • 角色管理(views/permission/role)→ per_role + per_role_menu;
  • 员工页的"分配角色"(views/system/staff)→ per_staff_role。

部门管理:借助 parent_id 构建两级部门

sys_dept 表的 parent_id 字段默认值为 0,表示根部门。

flowchart TD HR[&#34;人事部门<br/>parent_id = 0 · 根部门&#34;] HR --> HR1[&#34;人事1部<br/>07:00-11:00 · 13:30-17:30<br/>total_work_time = 8.0&#34;] HR --> HR2[&#34;人事2部<br/>06:10-10:40 · 13:40-17:10<br/>total_work_time = 8.0&#34;] HR --> HR3[&#34;人事3部<br/>06:10-10:20 · 13:30-17:00<br/>total_work_time = 7.7&#34;]

部门只做两层。根部门只做分组,四个时间列都是空的;子部门填好这些时间,写入时换算成总工时。根部门 parent_id = 0,子部门的 parent_id 指向根部门 id。

这里没有给 parent_id 建二级索引:

  • 部门表的数据量通常很小(一般几十到上百行);
  • 查询子部门时需要返回全部字段,走 parent_id 二级索引定位主键后再回表,效率未必比直接全表扫描高。

分页查询与两阶段装配

分页查询(DeptService.java:138):

java 复制代码
public ResponseDTO list(Integer current, Integer size, String name) {
    IPage<Dept> config = new Page<>(current, size);
    String searchName = (name == null) ? "" : name;
    IPage<Dept> page = this.deptMapper.listParentDept(config, searchName);
    List<Dept> parentList = page.getRecords();

    if (CollectionUtils.isEmpty(parentList)) {
        Map<String, Object> map = new HashMap<>();
        map.put("pages", page.getPages());
        map.put("total", page.getTotal());
        map.put("list", Collections.emptyList());
        return Response.success(map);
    }

    // 仅提取当前页父部门的 ID 列表
    List<Integer> parentIds = parentList.stream()
            .map(Dept::getId)
            .collect(Collectors.toList());

    // 按需仅查询当前页父部门下属的子部门
    List<Dept> subList = this.list(new LambdaQueryWrapper<Dept>()
            .in(Dept::getParentId, parentIds));

    // 按 parentId 分组
    Map<Integer, List<Dept>> subMap = subList.stream()
            .collect(Collectors.groupingBy(Dept::getParentId));

    // 组装子部门列表并防空
    for (Dept parentDept : parentList) {
        parentDept.setChildren(subMap.getOrDefault(parentDept.getId(), Collections.emptyList()));
    }

    Map<String, Object> map = new HashMap<>();
    map.put("pages", page.getPages());
    map.put("total", page.getTotal());
    map.put("list", parentList);
    return Response.success(map);
}

这里采用两阶段查询:先查当前页的根部门,再拿根部门 id 列表去查下属子部门,最后在 service 层拼装。

为什么不用一条 SQL 查询一步到位?

初看可能会想:既然父子部门都在 sys_dept 表里,为什么不直接写一条自连接 SQL(sys_dept p LEFT JOIN sys_dept s ON p.id = s.parent_id)一次查出来?

主要原因在于分页冲突:

  • 记录行数被子部门撑开:一对多自连接后,结果集的每一行对应一个子部门。一个根部门下如果有 3 个子部门,自连接后就会产生 3 行记录;没有子部门的根部门则是 1 行。查询结果集的总行数,实际上变成了展开后的子部门数量,而不是根部门数量;
  • LIMIT 截断错乱 :分页插件会在 SQL 外层追加 LIMIT。如果每页查 10 条,截断的是展开后的 10 行平铺数据,而不是 10 个根部门。这会导致一页实际返回的根部门数量不足 10 个,甚至某个根部门的子部门被拦腰截断、后半截被挤到了下一页;
  • COUNT 统计失真 :分页插件生成的 COUNT(*) 统计出的也是平铺后的总行数,前端分页条展示的"总记录数"和"总页数"会彻底对不上。

因此,分页树形数据最稳妥的写法是两阶段查询:

  • 第一步让分页只作用于根部门,保证 total 和 pages 准确;
  • 第二步用 WHERE parent_id IN (...) 仅检索当前页相关的子部门,数据取回后在 service 层用 Map 分组装配。

菜单管理:三级结构与权限点开关

per_menu 表里的节点分三级:level=0 一级目录、level=1 二级菜单、level=2 权限点。

新增节点的 parent_id 和 level 怎么定(views/permission/menu/index.vue:263):

javascript 复制代码
if (row.level === 0) {
  this.dialogForm.formData = { parentId: row.id, level: 1 }
} else if (row.level === 1) {
  this.dialogForm.formData = { parentId: row.id, level: 2 }
}

在目录行上点"新增",得到的是菜单;在菜单行上点"新增",得到的是权限点------新行的 parent_id 取当前行 id,level 在当前行基础上加一。

权限点开关。 表格里只有 level=2 的行带"正常/禁用"开关。这个开关影响两处:授权对话框的权限树只装配 status=1 的节点(MenuService.java:82),鉴权查询也过滤 status=1(MenuMapper.java:32)。所以停用一个权限点后,它不能再授权给新角色;已授权角色的这个权限,续期时不会再进新 token,相关接口随即返回 1300。


分配权限:勾选树的回显与提交

角色管理页的"分配权限"对话框(views/permission/role/index.vue:39)是一棵三级权限树,勾选结果直接决定 per_role_menu 的数据。el-tree 的节点有勾选、未勾选、半选三种状态(子节点只选了一部分时,父节点处于半选);默认配置下,勾选父节点会级联勾上它下面的全部子节点。

flowchart TD Tree[&#34;授权对话框的权限树<br/>目录 → 菜单 → 权限点&#34;] Tree --> Echo[&#34;回显方向<br/>只勾叶子节点<br/>父级半选由 el-tree 推导&#34;] Tree --> Submit[&#34;提交方向<br/>勾选 + 半选合并落库<br/>目录行与菜单行也要存&#34;]

回显方向:只勾叶子

打开对话框时,先把该角色已授权的节点点亮。接口返回的是 per_role_menu 的数据(GET /role/menu/{id}),前端逐一判断,只把叶子节点交给 setCheckedKeys(views/permission/role/index.vue:340):

javascript 复制代码
response.data.forEach(item => {
  if (this.$refs.tree.getNode(item.menuId)) {
    if (this.$refs.tree.getNode(item.menuId).isLeaf) {
      leafKeys.push(item.menuId)
    }
  }
})
this.$refs.tree.setCheckedKeys(leafKeys)

为什么只勾叶子?因为库里存的是"勾选 + 半选"的并集,既有权限点也有目录、菜单行(为什么这样存,下一小节讲)。如果把这些行原样全部回填,el-tree 会把每个父节点当成"被勾选",再级联勾上它下面的所有子节点------本来没授权的权限点全被点亮,管理员点一下"确定",就多授了一批权限。只勾叶子,父级的半选状态由组件自己推导,能还原出当时的勾选状态。

另外,被停用的权限点不在树数据里(queryAll 只返回 status=1 的节点),getNode 拿不到节点时跳过即可。

提交方向:叶子与半选一起落库

"确定"按钮提交的是两个集合的并集(views/permission/role/index.vue:362):

javascript 复制代码
setMenu(this.roleId,
  this.$refs.tree.getCheckedKeys().concat(this.$refs.tree.getHalfCheckedKeys()))

getCheckedKeys() 是勾选的节点(叶子,加上被级联勾满的父节点),getHalfCheckedKeys() 是半选的祖先。半选为什么也必须提交?看第 9 期那两条查询就明白了:菜单查询按 level < 2 取数据、权限查询按 level > 0 取数据,两者都从 per_role_menu JOIN 出来。假设只给某角色勾了"新增员工"这一个权限点:若只提交叶子,库里就只有权限点那一行,level < 2 的目录与菜单数据一行都没有------该员工登录后菜单查询为空,侧边栏整片空白,连"员工管理"这个页面自己都进不去。所以提交时要带上半选节点,补的就是目录行和菜单行这两级祖先。

落库:先删后插

setMenu 的落库写法(RoleMenuService.java:36):

java 复制代码
@Transactional
public ResponseDTO setMenu(Integer roleId, List<Integer> menuIds) {
    remove(new QueryWrapper<RoleMenu>().eq("role_id", roleId));
    for (Integer menuId : menuIds) {
        save(new RoleMenu().setRoleId(roleId).setMenuId(menuId));
    }
    return Response.success();
}

先把该角色的旧行删干净,再把本次提交的全部插入。先删后插天然幂等:同一份勾选提交多少次,结果都一样,前端也不用比对差异;撤销某个节点的授权,就是提交时不再勾它,落库后旧行消失。事务包住两步,中途失败不会留下"删了一半"的状态。

另一个前提是关系表没有 is_deleted------第 9 期说过,关系表要么存在要么不存在,删除就是物理删除。撤销授权后旧行直接消失,不用清理逻辑删除的记录。


员工分配角色:同一套先删后插

员工页的"分配角色"(views/system/staff/index.vue:477)没有树,只有一组勾选框:打开时 GET /staff/role/{id} 回显已绑角色,确定时 POST /staff/set/{id}。后端 StaffRoleService.setRole(StaffRoleService.java:26)与 setMenu 是同一套写法------先删后插、@Transactional。这里没有层级,勾选框的值就是角色 id,回显时直接勾上就行,不存在半选的问题。

分配完什么时候生效?回到第 9 期的结论:权限在登录时查库装进 JWT、每次续期重查,所以改动最迟 15 分钟(一次续期周期)生效,不需要用户重新登录。


效果验证

沿用第 9 期造的只读角色(role_id=10)和测试员工(staff_id=9),这次不写 SQL,全部走接口。

用 admin 登录后,给角色分配权限------提交的 [5,1,23] 正是一个目录、一个菜单、一个权限点,即"勾选 + 半选"落库后的形态:

bash 复制代码
curl -X POST "http://localhost:8888/role/set/10" \
  -H "Content-Type: application/json" \
  -d "[5,1,23]" \
  -b cookies.txt
sql 复制代码
SELECT role_id, menu_id FROM per_role_menu WHERE role_id = 10;
-- 10 | 5   目录:系统管理
-- 10 | 1   菜单:员工管理(permission = system:staff:list)
-- 10 | 23  权限点:新增员工(permission = system:staff:add)

把角色分配给员工:

bash 复制代码
curl -X POST "http://localhost:8888/staff/set/9" \
  -H "Content-Type: application/json" \
  -d "[10]" \
  -b cookies.txt

用该员工的账号重新登录:

bash 复制代码
curl -b viewer-cookies.txt "http://localhost:8888/menu/staff/9"
# 系统管理 → 员工管理

此时该员工能查询员工列表、能新增员工(system:staff:list 与 system:staff:add 都在 token 里),导出接口仍然返回 1300。

最后验证权限点开关:把"新增员工"(menu_id=23)停用------

bash 复制代码
curl -X PUT "http://localhost:8888/menu" \
  -H "Content-Type: application/json" \
  -d '{"id":23,"status":0}' \
  -b cookies.txt

该员工重新登录后,新增员工接口返回 1300------授权行还在 per_role_menu 里,但鉴权查询按 status=1 过滤后,system:staff:add 已经不在 token 里了。


小坑提醒

  • 部门拼树的 == 比较依赖 Integer 缓存 :早期写法若写成 dept.getParentId() == parentDept.getId(),两个 Integer 用 == 比较的是引用。它表面能工作,是因为部门 id 落在 -128~127 时命中 Java 的 Integer 缓存,两边是同一个对象;id 一旦超过 127,比较结果变成 false,子部门会静默地挂不上去。重构后改用 Map 分组(按 parentId 映射),或比对时使用 Objects.equals,更稳妥安全。
  • 部门列表的分页与搜索都只作用于根部门 :total/pages 统计的是根部门;名称搜索也只匹配根部门名称,直接搜"人事1部"这类子部门名不会命中,按根部门名搜即可看到整棵子树。
  • 停用权限点后授权行会残留 :per_role_menu 里的旧行不会被清理,也不需要清理------鉴权侧同样过滤 status=1,停用即失效;重新启用后旧行恢复生效(停用期间若重新保存过该角色的授权,这个节点不在树里、提交不上去,旧行会被顺带删掉)。排查"权限突然没了"时,先看 per_menu.status。

小结与下期预告

这一期补了三块管理界面:

  • 部门管理 :parent_id 一列表示两级组织树,根部门分页、子部门按需取回后在 service 层分组,总工时由四列时间在写入时算出;
  • 菜单管理 :三级结构由 level 与 parent_id 表达,权限点开关停用后,授权与校验两边同时失效;
  • 授权对话框:提交时合并半选节点、回显时只勾叶子;两处写反,要么丢菜单、要么多授权限;落库用先删后插,重复提交结果一致。

菜单管理定义权限点,角色管理给角色勾权限,员工页把角色分配给人------第 9 期手写 SQL 插的那几张表,现在都有界面可维护了。

下一期进入考勤与打卡:一天两次打卡怎么落成一行记录,考勤日历与统计图表怎么呈现,节假日配置又如何支撑出勤判定。下期见!

GitHub :github.com/quuuuj/hrm

相关推荐
打工仔折腾 AI3 小时前
FaceFusion本地换脸实战:Windows整合包、模型选择与遮罩调参记录
人工智能·windows·后端·python·深度学习·性能优化·ai agent 实战
Nebula_g4 小时前
JavaSE拓展:工具类Executors
java·开发语言·后端·spring·基础·javase
弈栈录4 小时前
Java 后端高并发设计:线程池、限流、熔断与降级
后端·架构
PC2005_cloud4 小时前
AI 汉字漂移实录:它为什么总把「学习」写成日语「学習」
前端·后端
码剑客4 小时前
告别逐张排查DICOM:6维度本地批量校验方案,数据零上传更合规
后端
RaaS1004 小时前
AI网关能做Prompt注入防护吗?AI网关核心功能详解
前端·后端
newerp4 小时前
Go 性能分析工具 pprof:CPU、Heap 与 Goroutine 实战
后端·程序员·go
Zelman4 小时前
第06章-超节点
人工智能·后端
Raas1004 小时前
AI网关能做语义缓存吗?MAI Gateway(魔芋企业级AI网关)实战能力深度解读
java·后端·spring