部门表设计

30 阅读9分钟

引言

部门表通常是一种树状结构,使用idparent_id两个字段就足以表达表中所有数据之间的层级关系,为了定义每个部门之间的顺序关系,通常还会添加一个seq字段。 虽然上述字段就足以体现整个公司各部门之间的层级关系,但在应用上还是有些费劲,比如要查找某个部门的所有子部门,就得遍历每个部门,看它的parent_id是不是此部门的id,然后还得递归查询下去以找到所有的下级。这样显然效率很低,所以还会有个字段充分体现每个部门的所有层级关系。

在若依项目中就使用了祖级列表ancestors表示每个部门的所有上级部门,如表中[0, 100, 101]就表示了该部门的上级部门分别若依科技-深圳总公司(表中并不存在id为0的部门,但是约定parent_id为0的部门就是根部门)。

若依表设计结构.png

若依的祖级列表ancestors

有了祖级列表之后,查找某个部门的所有子部门就相对简单些了,在项目中,使用了如下SQL语句用于查询若依科技及其所有子部门:

... d.deptId in (select dept_id from sys_dept where dept_id = 100 or find_in_set(100, ancestors));
  • SQL语句中使用了find_in_set函数,作用是查找以逗号分隔的字符串列表中是否存在要查找的字符串。这样就可以找到祖级列表中包含本id的所有部门。
  • 获取到了所有子部门的id后,再进行了一次查找,最后得到所有的子部门

使用find_in_set确实避免了递归查询,但是这个函数本身性能并不好,它不会利用索引,当数据里量大了后,查询速度就被拖慢了。

为了改善这个问题,我优化了若依中的部门表,改用字段level表示所有部门的层级关系。

我的层级字符串level

建表语句:

create table sys_dept
(
    id          bigint auto_increment comment '部门id'   primary key,
    name        varchar(32) default ''       null comment '部门名称',
    parent_id   bigint      default 0        null comment '父部门id',
    level       varchar(32)     null comment '部门级别字符串,以.分隔',
    seq         int                       null comment '部门本级排序',
)
    comment '部门表';

数据示例:

file-20260119093727020.png

字段level有如下特点:

  • 只表示层级关系,如没有分隔符.它就是一级部门,有1个它就是二级部门。
  • 父部门的level值会被其子部门全部继承。
  • 每一级部门的level值要保证唯一性。

⭐不要想着让字段level也具有排序的功能,这样会让增和改都变得非常麻烦。只要保证唯一性就可以了。

这样在查询大公司及其所有子部门时,只要使用like这个模糊查询功能就能做到:

... d.level like '1%'

要是查询大公司的所有子部门时(不包含大公司自己):

... d.level like '1.%'

是不是很灵活方便?

加上排序就是这样的:

order by length(level) - length(replace(level, '.', '')), seq is null, seq
  • length(level) - length(replace(level, '.', ''))表示点的个数
  • 这样就实现了先按层级排,再按seq字段排,并且seq为空值的会被排在后面

增改要考虑的

有了level字段后,查询起来是方便了,但是维护却很麻烦,颇有功在当代,利在千秋的感觉,毕竟对于部门表来说,肯定是查询量比增改多多了。

项目中用到的其它字段说明:

  • status:部门状态,为1是正常,为0是停用
  • del_flag:删除标记,为1是被删了,为0是没有

增和改要接收的对象:

public class SysDeptDTO {  
    /** 部门ID */  
    private Long id;  
  
    /** 部门名称 */  
    private String name;  
  
    /** 父部门ID */  
    private Long parentId;  
  
    /** 部门本级排序 */  
    private Integer seq;  
  
    /** 部门状态(1正常 0停用) */  
    @Max(value = 1, message = "dept.status.error")
    private Integer status;  
  
    /** 备注信息 */  
    private String remark;
}

只有status这个字段对增和改的要求都是一样的,其它的都不一样,所以要嘛是用分组校验,要嘛直接在Controller层添加校验规则。这里我选择第二种。

增对字段的要求:

  • id,可以没有,有也用不到
  • name,必须要有,还要满足一定规则(比如不能太长),还要求同一层级内的唯一性
  • parentId,必须要有,且对应的父部门不能是被删除、被停用的状态,要额外处理parentId为0的特殊情况
  • seq,可以没有
  • status,可以没有,有的话不能超过1,默认值为1
  • remark,可以没有

处理逻辑顺序:

  • name不能为空
  • parentId不能为空
  • 校验name是否满足规则,是否唯一
  • 检查部门权限
    • 判断用户是不是Admin,是就直接返回
    • 判断parentId是不是0,是就抛出异常,因为约定只有超级管理员才能添加最高级部门
    • 判断父部门在不在查看的权限内
  • parentId不是0时,从数据库中查找父部门,检查有没有父部门,有没有被删,是不是禁用
  • 获取父部门的所有子部门,也就是兄弟部门
  • 根据兄弟部门的最后一位level值,得到一个唯一的level值,再将父部门的level值和这个唯一的level值做拼接(中间以.分隔),得到这个新增部门的level值。
  • 设置创建人
  • 提交给mapper层执行添加
/**  
 * 新增部门  
 *  
 * @param dept 部门信息  
 * @return 结果  
 */  
@Override  
public AjaxResult insert(SysDeptDTO dept) {  
    SysDept sysDept = converter.convert(dept, SysDept.class);  
    Long parentId = sysDept.getParentId();  
    // 校验部门名称,父部门相同的情况下,部门名称不能重复  
    checkDeptNameUnique(null, parentId, sysDept.getName());  
    // 检查权限  
    checkDeptDataScope(parentId);  
    // 处理同级部门之间的关系  
    List<SysDept> siblingDepts = sysDeptMapper.selectDeptsByParentId(parentId);  
    int uniqueLevel = getUniqueLevel(siblingDepts);  
    String level = String.valueOf(uniqueLevel);  
    if (parentId != 0) {  
        SysDept parentDept = sysDeptMapper.selectDeptById(parentId);  
        checkParentDept(parentDept);  
  
        level = parentDept.getLevel() + "." + level;  
    }  
    sysDept.setLevel(level);  
    sysDept.setCreateBy(SecurityUtils.getUsername());  
    int rows = sysDeptMapper.insert(sysDept);  
    return AjaxResult.toAjaxResult(rows, "post.success", "post.failed");  
}
/**  
 * 获取唯一的部门层级数  
 *  
 * @param siblingDepts 同级部门列表  
 * @return 层级  
 */  
private int getUniqueLevel(List<SysDept> siblingDepts) {  
    if (CollectionUtils.isEmpty(siblingDepts)) {  
        return 1;  
    }  
    int[] levels = siblingDepts.stream()  
            .map(SysDept::getLevel)  // 获取level字段  
            .map(level -> level.split("\\."))  // 按.分割  
            .map(parts -> parts[parts.length - 1])  // 取最后一个部分  
            .mapToInt(Integer::parseInt)  // 转为整数  
            .toArray();  
    Arrays.sort(levels);  
  
    // 从2开始查找第一个不在数组中的数  
    int candidate = 2;  
    for (int level : levels) {  
        if (candidate < level) {  
            // 找到了不在数组中的最小数  
            break;  
        } else if (candidate == level) {  
            // 如果相等,候选数递增  
            candidate++;  
        }  
        // 如果candidate > level,继续循环  
    }  
  
    return candidate;  
}

改就更费劲了

增对字段的要求:

  • id,必须要有
  • name,可以没有,有就得满足规则,满足同一层级内的唯一性
  • parentId,可以没有,有的话,对应的父部门不能是被删除、被停用的状态,要额外处理parentId为0的特殊情况
  • seq,可以没有
  • status,可以没有,有的话不能超过1,默认值为1
  • remark,可以没有

处理逻辑顺序:

  • 判断id是不是空
  • 判断parentIdid是否相同
  • 判断name是不是空的,是空的直接赋值为null
  • 校验数据权限:
    • 判断是不是Admin,是就直接返回
    • 判断要修改的部门是否在查看的权限范围内
  • 根据id从数据库中获取原始部门信息
    • 原始部门如果是null,或者被删、被禁用,就拒绝更新
  • 校验父部门
    • parentId如果为null,或者parentId和原始部门的parentId相同,就说明没有改变层级结构,因此可以不处理level值,直接更新。
    • 如果父部门变了
      • parentId不为0时,要检查新的父部门是否存在,是否被删,是否被禁用,还要检查新的父部门是不是当前部门的子部门
      • 校验部门名称
      • 根据parentId获取同级部门,也就是兄弟部门
      • 根据兄弟部门的最后一位level值,得到一个唯一的level值,再将父部门的level值和这个唯一的level值做拼接(中间以.分隔),得到这个新增部门的level值。
      • 获取该部门的所有子部门,根据原始信息的level值长度,把所有子部门的level都裁掉(因为这些都是从父部门完完整整继承过来的),再拼接上新的level。不能把这个过程当作替换,可能会不小心把后面的也都给替了。
      • 批量更新所有子部门(只更新level值)
      • 更新新的部门信息
/**  
 * 更新部门  
 *  
 * @param dept 部门信息  
 * @return 结果  
 */  
@Override  
public AjaxResult update(SysDeptDTO dept) {  
    Long deptId = dept.getId();  
  
    // 校验数据权限  
    checkDeptDataScope(deptId);  
    // 获取数据库中存储的原始部门信息  
    SysDept oldDept = sysDeptMapper.selectDeptById(deptId);  
    if (oldDept == null || Constants.DELETED.equals(oldDept.getDelFlag())) {  
        throw new BaseException(ModuleConstants.SYSTEM, HttpStatus.FORBIDDEN, "找不到要更新的部门!");  
    }  
    // 校验父部门  
    if (dept.getParentId() == null || Objects.equals(dept.getParentId(), oldDept.getParentId())) {  
        // 父部门不变,直接更新  
        // 校验部门名称,父部门相同的情况下,部门名称不能重复  
        checkDeptNameUnique(deptId, oldDept.getParentId(), dept.getName());  
        SysDept sysDept = converter.convert(dept, SysDept.class);  
        return update(sysDept);  
    }  
  
    // 父部门变了  
    // 获取父部门信息  
    SysDept parentDept = null;  
    List<SysDept> children = sysDeptMapper.selectDeptsByLevel(oldDept.getLevel() + ".");  
    // 判断父部门id不为0的情况  
    if (dept.getParentId() != 0L) {  
        parentDept = sysDeptMapper.selectDeptById(dept.getParentId());  
        // 校验父部门  
        checkParentDept(parentDept);  
        // 父部门不能是子部门  
        if (children.stream().anyMatch(child -> child.getId().equals(dept.getParentId()))) {  
            throw new BaseException(ModuleConstants.SYSTEM, HttpStatus.PARA_ERROR, "父部门不能是当前部门的子部门!");  
        }  
    }  
    // 校验部门名称,父部门相同的情况下,部门名称不能重复  
    checkDeptNameUnique(deptId, dept.getParentId(), dept.getName());  
  
    SysDept sysDept = converter.convert(dept, SysDept.class);  
    // 处理这个要更新的部门和同级之间的关系  
    List<SysDept> siblingDepts = sysDeptMapper.selectDeptsByParentId(sysDept.getParentId());  
    int uniqueLevel = getUniqueLevel(siblingDepts);  
    String oldLevel = oldDept.getLevel();  
    String newLevel = parentDept == null ? String.valueOf(uniqueLevel) : parentDept.getLevel() + "." + uniqueLevel;  
    children.forEach(child -> {  
        String level = newLevel + child.getLevel().substring(oldLevel.length());  
        child.setLevel(level);  
        child.setUpdateBy(SecurityUtils.getUsername());  
    });  
    // 批量更新所有子部门  
    sysDeptMapper.updateLevelInBatch(children);  
    // 更新自己  
    sysDept.setLevel(newLevel);  
    return update(sysDept);  
}

总结

level字段有效地帮助了查询,提高了查询效率,但是维护起来也相对复杂。文中重点梳理了增、改的处理逻辑,它们各有不同,相应的代码只是提供了大致的处理思路,还有一些其它不太相关的细节可以在我的项目中找到。我目前主要计划就是复现若依,抱着学习的态度学,作为一个初学者、入门者,遇到的问题很多,若依本身的问题也很多,希望在不断解决问题的过程中能不断提升自己的coding能力。文章里有说的不到位的,欢迎各位大佬批评指正!