从零手写数智人事系统(十二):部门树、角色授权与菜单维护
第 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,表示根部门。
部门只做两层。根部门只做分组,四个时间列都是空的;子部门填好这些时间,写入时换算成总工时。根部门 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 的节点有勾选、未勾选、半选三种状态(子节点只选了一部分时,父节点处于半选);默认配置下,勾选父节点会级联勾上它下面的全部子节点。

回显方向:只勾叶子
打开对话框时,先把该角色已授权的节点点亮。接口返回的是 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