后台管理系统的部门树怎么设计?从 ShiyuAdmin 拆解组织架构、递归树与数据权限

在这里插入图片描述

几乎所有后台管理系统做到后面,都会出现一个非常典型的数据结构:

部门树。

比如一家公司可能有这样的组织架构:

仕宇科技
├── 技术中心
│   ├── 后端开发部
│   │   ├── Go 开发组
│   │   └── Java 开发组
│   └── 前端开发部
│       ├── React 开发组
│       └── Vue 开发组
├── 产品中心
│   ├── 产品一部
│   └── 产品二部
└── 市场中心
    ├── 国内市场部
    └── 海外市场部

前端看到的是一棵树。

但数据库显然不可能直接存:

{
  "name": "仕宇科技",
  "children": [
    {
      "name": "技术中心",
      "children": [...]
    }
  ]
}

真正需要解决的问题其实是:

数据库怎么存?

父子部门怎么关联?

Go 后端怎么查询?

平铺数据怎么变成树?

新增部门怎么校验?

修改父部门怎么防止死循环?

删除部门时子部门怎么办?

部门树和 RBAC、数据权限是什么关系?

这篇文章就结合我的开源后台项目 ShiyuAdmin,把部门树这件事情完整拆开。

ShiyuAdmin 是一个基于:

Go
Gin
GORM
React
Ant Design Pro
JWT
RBAC

构建的通用后台管理系统,目前已经包含用户、角色、菜单、部门、动态权限、数据权限等基础模块。

项目地址:

https://github.com/Rodert/ShiyuAdmin

一、部门树本质上是什么?

我们先不要看代码。

部门树从数据结构上看,其实就是:

一棵多叉树。

每一个部门都可以拥有:

0 个
1 个
N 个

子部门。

例如:

技术中心
├── 后端部
├── 前端部
└── 测试部

而:

后端部

下面又可以继续存在:

后端部
├── Go 组
├── Java 组
└── Python 组

因此理论上:

Department
    ↓
Children []
    ↓
Department
    ↓
Children []

是一个天然的递归结构。

如果直接使用 Go 表达,大概是:

type Department struct {
    ID       int64
    Name     string
    Children []*Department
}

但是有一个问题。

数据库不擅长直接保存这种:

Children []Department

所以通常需要把树“打平”保存。


二、ShiyuAdmin 如何保存部门树?

ShiyuAdmin 当前使用的是非常经典的一种设计:

邻接表(Adjacency List)。

核心思想特别简单:

每一个部门只记录:

我是谁
+
我的爸爸是谁

也就是:

dept_code
parent_code

ShiyuAdmin 当前的 Dept 实体核心字段就是:

type Dept struct {
    ID int64

    DeptCode string

    ParentCode string

    DeptName string

    Status int

    CreatedAt time.Time

    UpdatedAt time.Time

    DeletedAt gorm.DeletedAt
}

其中 DeptCode 是业务唯一标识,而 ParentCode 用来指向父部门;根部门的 ParentCode 为空。

项目中的实体定义实际表达的关系就是:

type Dept struct {
    ID int64 `gorm:"primaryKey;autoIncrement"`

    DeptCode string `gorm:"size:32;uniqueIndex"`

    ParentCode string `gorm:"size:32"`

    DeptName string `gorm:"size:128"`

    Status int

    CreatedAt time.Time

    UpdatedAt time.Time

    DeletedAt gorm.DeletedAt
}

对应数据库表:

sys_depts

三、数据库里的部门其实是“平的”

假设我们有这样一棵组织架构:

仕宇科技
├── 技术中心
│   ├── 后端开发部
│   └── 前端开发部
└── 市场中心
    └── 海外市场部

数据库不需要存树。

只需要这样:

+-----------+-------------+--------------+
| dept_code | parent_code | dept_name    |
+-----------+-------------+--------------+
| D001      |             | 仕宇科技      |
| D002      | D001        | 技术中心      |
| D003      | D002        | 后端开发部    |
| D004      | D002        | 前端开发部    |
| D005      | D001        | 市场中心      |
| D006      | D005        | 海外市场部    |
+-----------+-------------+--------------+

你会发现:

数据库完全不知道:

什么叫 children。

它只知道:

D003.parent_code = D002

于是我们就知道:

D003
是
D002
的子部门。

而:

D002.parent_code = D001

所以:

D002
又是
D001
的子部门。

关系就这样建立起来了。


四、为什么 ShiyuAdmin 用 dept_code,而不是数据库 ID?

这是 ShiyuAdmin 里我比较推荐的一种设计。

数据库虽然有:

ID int64

但业务关联没有主要依赖:

1
2
3
4

而是使用:

DeptCode string

比如:

DEPT001
DEPT002
DEPT003

于是:

ParentCode

保存的也是:

DeptCode

而不是数据库自增主键。

例如:

dept_code = TECH_BACKEND
parent_code = TECH

这样做最大的好处是:

数据库主键
和
业务标识
分离。

ID 是数据库关心的。

id = 17

DeptCode 是业务系统关心的。

dept_code = backend

这和 ShiyuAdmin 用户设计中的:

ID
+
UserCode

以及角色里的:

ID
+
RoleCode

是同一种思路。ShiyuAdmin 当前用户、角色、菜单、部门等核心模型均保留数据库 ID,同时使用独立业务 Code 进行业务关联。


五、部门表完整 SQL 可以怎么设计?

如果使用 PostgreSQL,可以设计成:

CREATE TABLE sys_depts (
    id BIGSERIAL PRIMARY KEY,

    dept_code VARCHAR(32) NOT NULL,

    parent_code VARCHAR(32) DEFAULT '',

    dept_name VARCHAR(128) NOT NULL,

    status INTEGER NOT NULL DEFAULT 1,

    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,

    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,

    deleted_at TIMESTAMP NULL,

    CONSTRAINT uk_sys_depts_code
        UNIQUE (dept_code)
);

索引建议至少加:

CREATE INDEX idx_sys_depts_parent_code
ON sys_depts(parent_code);

为什么?

因为以后非常常见的一条 SQL 就是:

SELECT *
FROM sys_depts
WHERE parent_code = 'D001';

也就是:

查询 D001 的直接子部门。

如果部门数量变大:

100
1000
10000
100000

parent_code 没索引的话就会不断全表扫描。


六、ShiyuAdmin 的 Repository 怎么查部门?

ShiyuAdmin 没有把 GORM 查询直接写到 Handler 里面,而是放在 Repository 层。

当前部门 Repository 提供了:

GetByCode
List
ListByParent
Create
Update
DeleteByCode

这样的数据库能力。

我们把核心代码稍微整理一下:

type DeptRepository struct {
    db *gorm.DB
}

构造:

func NewDeptRepository(
    db *gorm.DB,
) *DeptRepository {

    return &DeptRepository{
        db: db,
    }
}

七、根据部门编码查询

func (r *DeptRepository) GetByCode(
    ctx context.Context,
    deptCode string,
) (*Dept, error) {

    var dept Dept

    err := r.db.
        WithContext(ctx).
        Where(
            "dept_code = ?",
            deptCode,
        ).
        First(&dept).
        Error

    if errors.Is(
        err,
        gorm.ErrRecordNotFound,
    ) {
        return nil, nil
    }

    if err != nil {
        return nil, err
    }

    return &dept, nil
}

例如:

dept, err :=
    repo.GetByCode(
        ctx,
        "D002",
    )

返回:

{
  "dept_code": "D002",
  "parent_code": "D001",
  "dept_name": "技术中心"
}

八、查询全部部门

ShiyuAdmin 当前部门树的实现有一个很重要的设计:

先一次性查询全部部门,再在内存里组树。

Repository:

func (r *DeptRepository) List(
    ctx context.Context,
) ([]*Dept, error) {

    var depts []*Dept

    err := r.db.
        WithContext(ctx).
        Order("id ASC").
        Find(&depts).
        Error

    if err != nil {
        return nil, err
    }

    return depts, nil
}

此时返回的仍然只是:

[
  {
    "dept_code": "D001",
    "parent_code": "",
    "dept_name": "仕宇科技"
  },
  {
    "dept_code": "D002",
    "parent_code": "D001",
    "dept_name": "技术中心"
  },
  {
    "dept_code": "D003",
    "parent_code": "D002",
    "dept_name": "后端开发部"
  }
]

注意:

还没有:

"children": []

九、Service 为什么不负责递归组树?

ShiyuAdmin 当前 Service 中:

func (s *Service) ListTree(
    ctx context.Context,
) ([]*entity.Dept, error) {

    // Return flat list,
    // tree building is done in VO layer

    return s.repo.List(ctx)
}

也就是说:

数据库
↓
Repository
↓
Service

这一段始终处理:

平铺部门数据

真正构建:

children

是在:

VO Layer

完成。

我认为这个设计挺有意思。

因为:

Entity

描述的是数据库对象。

而:

Tree

其实是 API 对前端的一种展示结构。

所以:

Entity 不强行塞 Children

而是在:

DeptVO

中增加:

Children []*DeptVO

职责会更清晰。


十、ShiyuAdmin 的 DeptVO

ShiyuAdmin 当前的部门 VO 类似:

type DeptVO struct {
    DeptCode string `json:"dept_code"`

    ParentCode string `json:"parent_code"`

    DeptName string `json:"dept_name"`

    Status int `json:"status"`

    Children []*DeptVO `json:"children,omitempty"`
}

这里最核心的就是:

Children []*DeptVO

于是 API 可以输出:

{
  "dept_code": "D001",
  "dept_name": "仕宇科技",
  "children": [
    {
      "dept_code": "D002",
      "dept_name": "技术中心",
      "children": []
    }
  ]
}

而数据库 Entity 完全不需要知道:

Children

的存在。


十一、最关键的代码:怎么把平铺列表变成树?

ShiyuAdmin 当前使用的是:

Map + 两次遍历。

这也是这篇文章最值得理解的代码。

简化一下:

func BuildDeptTree(
    depts []*Dept,
) []*DeptVO {

    deptMap :=
        make(map[string]*DeptVO)

    var roots []*DeptVO

    // 第一遍
    // 创建所有节点
    for _, dept := range depts {

        node := BuildDeptVO(dept)

        deptMap[dept.DeptCode] =
            node
    }

    // 第二遍
    // 建立父子关系
    for _, dept := range depts {

        node :=
            deptMap[dept.DeptCode]

        // 根节点
        if dept.ParentCode == "" {

            roots =
                append(
                    roots,
                    node,
                )

            continue
        }

        // 找父节点
        parent :=
            deptMap[dept.ParentCode]

        if parent != nil {

            parent.Children =
                append(
                    parent.Children,
                    node,
                )
        }
    }

    return roots
}

ShiyuAdmin 当前 BuildDeptTree 的真实实现就是这种“两遍扫描”的结构。


十二、为什么第一遍不能直接组树?

很多人第一次写会这么想:

for _, dept := range depts {

    if dept.ParentCode != "" {

        parent := deptMap[
            dept.ParentCode
        ]

        parent.Children =
            append(...)
    }
}

问题是:

父部门不一定已经创建。

比如查询结果是:

D003 后端开发部
D002 技术中心
D001 仕宇科技

当遍历 D003 时:

deptMap["D002"]

还不存在。

所以最稳妥的方法是:

第一遍
↓
所有节点全部放入 Map

第二遍
↓
再建立 Parent → Children

这样顺序就完全不重要。


十三、BuildDeptTree 的时间复杂度是多少?

假设有:

N

个部门。

第一遍:

O(N)

第二遍:

O(N)

Map 查询父部门平均:

O(1)

最终:

O(N)
+
O(N)

=

O(N)

也就是:

O(N)

空间复杂度:

O(N)

因为需要:

map[string]*DeptVO

保存所有节点。

对于普通后台:

几十个部门
几百个部门
几千个部门

完全足够。


十四、为什么不要每个节点都查一次数据库?

还有一种非常容易出现的实现:

先查根部门:

SELECT *
FROM sys_depts
WHERE parent_code = '';

拿到:

技术中心
市场中心
产品中心

然后针对每一个部门:

SELECT *
FROM sys_depts
WHERE parent_code = ?;

再继续递归。

伪代码:

func BuildTree(
    parentCode string,
) []*Dept {

    children :=
        repo.ListByParent(
            parentCode,
        )

    for _, child :=
        range children {

        child.Children =
            BuildTree(
                child.DeptCode,
            )
    }

    return children
}

看起来特别优雅。

实际上很危险。

如果部门树有:

1000

个节点。

最极端可能产生接近:

1000 次 SQL

这就是经典的:

N + 1 Query

问题。

而 ShiyuAdmin 当前的方式:

SELECT 所有部门
↓
Go 内存组树

通常只需要:

1 次数据库查询。

这也是为什么中小规模后台部门树,我更推荐:

一次查询
+
Map 组树

而不是数据库递归 N 次。


十五、ShiyuAdmin 的部门树接口怎么设计?

ShiyuAdmin 当前部门模块提供:

GET    /depts
GET    /depts/tree
GET    /depts/:code

POST   /depts
PUT    /depts/:code
DELETE /depts/:code

并分别绑定:

system:dept:list
system:dept:create
system:dept:update
system:dept:delete

等权限标识。

例如部门树:

rg.GET(
    "/depts/tree",

    middleware.RequirePermission(
        permissionSvc,
        "system:dept:list",
    ),

    func(c *gin.Context) {
        listDeptTree(
            c,
            deptSvc,
        )
    },
)

Handler:

func listDeptTree(
    c *gin.Context,
    svc DeptService,
) {

    depts, err :=
        svc.ListTree(c)

    if err != nil {

        response.Error(
            c,
            http.StatusInternalServerError,
            err.Error(),
        )

        return
    }

    tree :=
        vo.BuildDeptTree(depts)

    response.Success(
        c,
        tree,
    )
}

整个过程非常清楚:

GET /depts/tree
↓
Permission Middleware
↓
DeptService.ListTree
↓
DeptRepository.List
↓
SELECT sys_depts
↓
BuildDeptTree
↓
JSON

十六、最终 API 返回什么样?

最终前端收到的应该类似:

[
  {
    "dept_code": "D001",
    "parent_code": "",
    "dept_name": "仕宇科技",
    "status": 1,
    "children": [
      {
        "dept_code": "D002",
        "parent_code": "D001",
        "dept_name": "技术中心",
        "status": 1,
        "children": [
          {
            "dept_code": "D003",
            "parent_code": "D002",
            "dept_name": "后端开发部",
            "status": 1
          },
          {
            "dept_code": "D004",
            "parent_code": "D002",
            "dept_name": "前端开发部",
            "status": 1
          }
        ]
      },
      {
        "dept_code": "D005",
        "parent_code": "D001",
        "dept_name": "市场中心",
        "status": 1,
        "children": [
          {
            "dept_code": "D006",
            "parent_code": "D005",
            "dept_name": "海外市场部",
            "status": 1
          }
        ]
      }
    ]
  }
]

这样 React、Ant Design Tree、TreeSelect 等组件都可以直接消费。


十七、新增部门怎么设计?

ShiyuAdmin 当前新增部门 DTO:

type CreateDeptRequest struct {
    DeptCode string `json:"dept_code" binding:"required"`

    ParentCode string `json:"parent_code"`

    DeptName string `json:"dept_name" binding:"required"`

    Status int `json:"status"`
}

更新 DTO 则使用指针字段,让后端能够区分“没有提交”与“主动修改”。

例如创建:

{
  "dept_code": "D003",
  "parent_code": "D002",
  "dept_name": "后端开发部",
  "status": 1
}

Service 当前会先检查:

dept_code

是否重复。

简化以后:

func (s *Service) Create(
    ctx context.Context,
    req *CreateDeptRequest,
) (*Dept, error) {

    deptCode :=
        strings.TrimSpace(
            req.DeptCode,
        )

    existing, err :=
        s.repo.GetByCode(
            ctx,
            deptCode,
        )

    if err != nil {
        return nil, err
    }

    if existing != nil {

        return nil,
            NewConflict(
                "duplicate_department_code",
                "部门编码已存在",
            )
    }

    dept := &Dept{
        DeptCode: deptCode,

        ParentCode:
            strings.TrimSpace(
                req.ParentCode,
            ),

        DeptName:
            strings.TrimSpace(
                req.DeptName,
            ),

        Status: req.Status,
    }

    if err :=
        s.repo.Create(
            ctx,
            dept,
        );
        err != nil {

        return nil, err
    }

    return dept, nil
}

ShiyuAdmin 当前创建部门时已经同时进行了业务层编码重复检查,并对数据库唯一约束冲突进行统一转换。


十八、这里其实还能进一步加强:父部门必须存在

假设用户提交:

{
  "dept_code": "D100",
  "parent_code": "D999999",
  "dept_name": "神秘部门"
}

但是:

D999999

根本不存在。

如果不校验,就会产生:

孤儿节点。

数据库:

D100.parent_code = D999999

但树构建的时候:

parent :=
    deptMap["D999999"]

结果:

parent == nil

这个部门最终可能不会进入任何树。

所以生产级实现最好加:

if req.ParentCode != "" {

    parent, err :=
        s.repo.GetByCode(
            ctx,
            req.ParentCode,
        )

    if err != nil {
        return nil, err
    }

    if parent == nil {

        return nil,
            apperrors.NewBadRequest(
                "parent_department_not_found",
                "上级部门不存在",
            )
    }
}

这样组织结构不会悄悄产生脏数据。


十九、修改部门最危险的问题:循环引用

这个问题特别值得讲。

假设:

技术中心 D001
└── 后端部 D002
    └── Go 开发组 D003

关系:

D001.parent = ""

D002.parent = D001

D003.parent = D002

现在管理员修改:

D001.parent = D003

结果变成:

D001
↓
D002
↓
D003
↓
D001
↓
D002
↓
D003
...

形成:

环。

这已经不是树了。


二十、第一条规则:不能把自己设为父部门

最简单的一条:

if req.ParentCode != nil {

    parentCode :=
        strings.TrimSpace(
            *req.ParentCode,
        )

    if parentCode == deptCode {

        return nil,
            apperrors.NewBadRequest(
                "invalid_parent_department",
                "不能将当前部门设置为自己的上级部门",
            )
    }
}

可以挡住:

D001.parent = D001

但是还不够。

因为:

D001.parent = D003

仍然可能形成环。


二十一、第二条规则:不能把自己的子孙设成父节点

需要判断:

新 Parent

是不是当前节点的:

子节点
孙节点
曾孙节点
...

一种简单做法:

先加载所有部门:

depts, err :=
    s.repo.List(ctx)

构建:

childrenMap :=
    make(
        map[string][]string,
    )

for _, dept :=
    range depts {

    childrenMap[
        dept.ParentCode
    ] =
        append(
            childrenMap[
                dept.ParentCode
            ],
            dept.DeptCode,
        )
}

然后判断目标部门是不是当前部门的后代:

func isDescendant(
    childrenMap map[string][]string,
    rootCode string,
    targetCode string,
) bool {

    queue :=
        []string{
            rootCode,
        }

    visited :=
        make(
            map[string]bool,
        )

    for len(queue) > 0 {

        current :=
            queue[0]

        queue =
            queue[1:]

        if visited[current] {
            continue
        }

        visited[current] =
            true

        for _, child :=
            range childrenMap[current] {

            if child ==
                targetCode {

                return true
            }

            queue =
                append(
                    queue,
                    child,
                )
        }
    }

    return false
}

修改前:

if req.ParentCode != nil {

    newParent :=
        strings.TrimSpace(
            *req.ParentCode,
        )

    if newParent ==
        deptCode {

        return nil,
            errors.New(
                "不能选择自己作为父部门",
            )
    }

    if isDescendant(
        childrenMap,
        deptCode,
        newParent,
    ) {

        return nil,
            errors.New(
                "不能选择当前部门的下级部门作为上级部门",
            )
    }
}

这样组织树就不会出现循环。


二十二、为什么这个校验很重要?

因为很多后台前端确实会做限制:

编辑技术中心
↓
父部门下拉框
↓
自动禁用技术中心及其子节点

但注意:

前端校验永远不能代替后端校验。

攻击者完全可以跳过 React:

curl -X PUT \
  "http://localhost:8080/api/v1/system/depts/D001" \
  -H "Authorization: Bearer xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "parent_code": "D003"
  }'

所以最终规则必须由:

Service

守住。


二十三、更新 DTO 为什么使用指针?

ShiyuAdmin 当前:

type UpdateDeptRequest struct {
    ParentCode *string `json:"parent_code"`

    DeptName *string `json:"dept_name"`

    Status *int `json:"status"`
}

为什么不直接:

type UpdateDeptRequest struct {
    ParentCode string
    DeptName string
    Status int
}

因为:

{}

和:

{
  "parent_code": ""
}

语义不同。

前者:

不要修改 ParentCode

后者:

我要把它变成根部门

使用:

*string

以后:

nil
=
没有提交

&""
=
明确设置为空

所以:

if req.ParentCode != nil {

    dept.ParentCode =
        strings.TrimSpace(
            *req.ParentCode,
        )
}

这也是比较标准的 Update API 设计。


二十四、删除部门不是 DELETE 一条记录那么简单

当前 ShiyuAdmin Repository 删除逻辑比较直接:

func (r *DeptRepository) DeleteByCode(
    ctx context.Context,
    deptCode string,
) error {

    return r.db.
        WithContext(ctx).
        Where(
            "dept_code = ?",
            deptCode,
        ).
        Delete(
            &Dept{},
        ).
        Error
}

当前 Service 也主要是直接调用 DeleteByCode。

作为基础脚手架这样很清楚。

但如果用于真正的企业后台,我建议继续加三层检查。


二十五、第一层:有子部门不能直接删除

例如:

技术中心
├── 后端部
└── 前端部

如果删除:

技术中心

那么:

后端部.parent_code = 技术中心
前端部.parent_code = 技术中心

马上变成孤儿节点。

所以应该:

children, err :=
    s.repo.ListByParent(
        ctx,
        deptCode,
    )

if err != nil {
    return err
}

if len(children) > 0 {

    return apperrors.NewConflict(
        "department_has_children",
        "当前部门存在下级部门,请先处理下级部门",
    )
}

ShiyuAdmin 当前 Repository 已经提供了 ListByParent,所以非常适合直接用来完成这项校验。


二十六、第二层:部门里还有用户不能删除

ShiyuAdmin 的 User 本身有:

DeptCode string

用来关联部门。

所以:

后端开发部

可能还有:

张三
李四
王五

此时删除部门以后:

user.dept_code

就会引用一个不存在的组织。

因此可以增加:

count, err :=
    userRepo.CountByDeptCode(
        ctx,
        deptCode,
    )

if err != nil {
    return err
}

if count > 0 {

    return apperrors.NewConflict(
        "department_has_users",
        "当前部门仍存在用户,无法删除",
    )
}

Repository:

func (r *UserRepository) CountByDeptCode(
    ctx context.Context,
    deptCode string,
) (int64, error) {

    var count int64

    err := r.db.
        WithContext(ctx).
        Model(&User{}).
        Where(
            "dept_code = ?",
            deptCode,
        ).
        Count(&count).
        Error

    return count, err
}

二十七、第三层:数据权限里正在使用的部门怎么办?

这里就进入 ShiyuAdmin 更有意思的地方了。

ShiyuAdmin 不是单纯做了:

部门管理

项目中还有:

Role
RoleDept
User

Role 里存在:

DataScope string

当前语义包括:

all
dept
deptAndChild
self

同时还定义了:

type RoleDept struct {
    RoleCode string

    DeptCode string
}

也就是:

角色
↔
部门

之间可以建立数据权限关联。

所以部门树实际上不是一个孤立模块。

它会直接成为:

数据权限系统的组织基础设施。


二十八、部门树和数据权限是什么关系?

假设:

仕宇科技
├── 技术中心
│   ├── 后端部
│   └── 前端部
└── 市场中心

现在有一个角色:

技术中心经理

DataScope:

deptAndChild

意思就是:

能看自己部门以及下面所有子部门的数据。

如果当前用户:

dept_code = TECH

那么系统需要计算:

TECH
BACKEND
FRONTEND

最终查询用户时:

SELECT *
FROM sys_users
WHERE dept_code IN (
    'TECH',
    'BACKEND',
    'FRONTEND'
);

你会发现:

部门树已经从“页面展示问题”变成了“数据库权限问题”。


二十九、如何计算一个部门的所有后代?

可以先加载全部部门:

depts, err :=
    deptRepo.List(ctx)

构建:

childrenMap :=
    make(
        map[string][]string,
    )

for _, dept :=
    range depts {

    childrenMap[
        dept.ParentCode
    ] =
        append(
            childrenMap[
                dept.ParentCode
            ],
            dept.DeptCode,
        )
}

然后 BFS:

func collectDeptCodes(
    childrenMap map[string][]string,
    rootCode string,
) []string {

    result :=
        make([]string, 0)

    queue :=
        []string{
            rootCode,
        }

    visited :=
        make(
            map[string]bool,
        )

    for len(queue) > 0 {

        current :=
            queue[0]

        queue =
            queue[1:]

        if visited[current] {
            continue
        }

        visited[current] =
            true

        result =
            append(
                result,
                current,
            )

        queue =
            append(
                queue,
                childrenMap[current]...,
            )
    }

    return result
}

调用:

deptCodes :=
    collectDeptCodes(
        childrenMap,
        "TECH",
    )

得到:

[]string{
    "TECH",
    "BACKEND",
    "FRONTEND",
}

然后 GORM:

query :=
    db.Where(
        "dept_code IN ?",
        deptCodes,
    )

这就是:

部门树
+
数据权限

真正结合起来的地方。


三十、DataScope 可以怎么设计?

ShiyuAdmin 当前角色已经存在 DataScope 字段。

可以把它扩展成:

const (
    DataScopeAll =
        "all"

    DataScopeDept =
        "dept"

    DataScopeDeptAndChild =
        "deptAndChild"

    DataScopeSelf =
        "self"

    DataScopeCustom =
        "custom"
)

不同范围:

all
全部数据

dept
当前部门

deptAndChild
当前部门及下级部门

self
只看自己

custom
指定部门

查询用户时:

switch role.DataScope {

case DataScopeAll:

    // 不增加部门条件

case DataScopeDept:

    query =
        query.Where(
            "dept_code = ?",
            currentUser.DeptCode,
        )

case DataScopeDeptAndChild:

    codes :=
        collectDeptCodes(
            childrenMap,
            currentUser.DeptCode,
        )

    query =
        query.Where(
            "dept_code IN ?",
            codes,
        )

case DataScopeSelf:

    query =
        query.Where(
            "user_code = ?",
            currentUser.UserCode,
        )
}

这样:

RBAC

解决:

你有没有资格调用接口?

部门 DataScope 解决:

调用接口以后你能看到哪些数据?

这是两个完全不同的权限层次。


三十一、部门树为什么不建议直接用递归 SQL?

PostgreSQL 确实支持:

WITH RECURSIVE

比如:

WITH RECURSIVE dept_tree AS (

    SELECT
        dept_code,
        parent_code,
        dept_name

    FROM sys_depts

    WHERE dept_code = 'TECH'

    UNION ALL

    SELECT
        d.dept_code,
        d.parent_code,
        d.dept_name

    FROM sys_depts d

    INNER JOIN dept_tree dt
        ON d.parent_code =
           dt.dept_code
)

SELECT *
FROM dept_tree;

这可以一次查询:

TECH
+
所有后代

非常强。

但 ShiyuAdmin 同时支持:

PostgreSQL
MySQL
SQLite
SQL Server

多数据库场景。

不同数据库的:

递归 CTE
方言
版本要求
行为

存在差异。

所以对于这种通用后台脚手架:

普通部门数量
+
多数据库兼容

使用:

简单 SQL
+
Go 内存组树

反而是一种非常务实的方案。


三十二、什么时候应该升级部门树存储方案?

邻接表非常适合:

部门管理
菜单管理
分类管理
地区管理
评论关系

但是如果数据特别大:

100 万节点

而且经常执行:

查询所有祖先
查询所有后代
计算层级
计算路径

就可以考虑其他模型。

比如:

Materialized Path

保存:

/

路径。

例如:

仕宇科技
code = D001
path = /D001/

技术中心
code = D002
path = /D001/D002/

后端部
code = D003
path = /D001/D002/D003/

查询技术中心所有后代:

SELECT *
FROM sys_depts
WHERE path LIKE '/D001/D002/%';

非常方便。


三十三、还可以保存 ancestors

另一种:

ancestors

字段。

例如:

D001
ancestors = ""

D002
ancestors = "D001"

D003
ancestors = "D001,D002"

那么:

D003

可以非常快地知道:

它属于
D001
↓
D002

但是缺点也明显。

一旦:

D002

换父部门,

那么:

D002
下面所有后代

的 ancestors 都要更新。

所以对于 ShiyuAdmin 这种通用后台,我反而认为当前:

DeptCode
+
ParentCode

就已经足够合理。


三十四、部门排序怎么做?

ShiyuAdmin 当前部门实体比较精简,目前没有独立的 sort_order 字段,Repository 列表主要按数据库 ID 排序。

如果准备继续增强部门模块,我建议加入:

SortOrder int

Entity:

type Dept struct {
    ID int64

    DeptCode string

    ParentCode string

    DeptName string

    SortOrder int

    Status int
}

SQL:

ALTER TABLE sys_depts
ADD COLUMN sort_order INT
NOT NULL DEFAULT 0;

查询:

db.
    Order("parent_code ASC").
    Order("sort_order ASC").
    Order("id ASC").
    Find(&depts)

这样:

技术中心
市场中心
产品中心

的顺序就可以人为控制。

而不是取决于:

谁先插入数据库。

三十五、状态字段怎么处理?

部门通常有:

Status int

例如:

1 = 启用
0 = 禁用

但是这里有一个需要提前定义的业务规则:

如果:

技术中心

被禁用。

那么:

后端部
前端部
测试部

怎么办?

一般有三种策略。

策略一

只禁用当前部门。

父部门禁用
不影响子部门

实现简单。

策略二

父部门禁用时:

所有子部门全部禁用

更符合组织结构逻辑。

策略三

数据不修改,但是权限计算时:

只要祖先有一个禁用
↓
节点就视为不可用

业务最严谨,但是计算稍复杂。

对于通用后台,我更倾向:

数据库保持独立状态

业务使用时
检查组织链路是否有效

这样数据不会因为一次误操作被大面积修改。


三十六、树里出现脏数据怎么办?

生产环境里你最终一定会遇到:

孤儿节点
循环节点
重复编码

所以最好提供一个检查工具。

例如:

type DeptCheckResult struct {
    Orphans []string

    SelfCycles []string

    Cycles [][]string
}

检查孤儿:

func findOrphans(
    depts []*Dept,
) []string {

    deptMap :=
        make(
            map[string]bool,
        )

    for _, dept :=
        range depts {

        deptMap[
            dept.DeptCode
        ] = true
    }

    var result []string

    for _, dept :=
        range depts {

        if dept.ParentCode ==
            "" {

            continue
        }

        if !deptMap[
            dept.ParentCode
        ] {

            result =
                append(
                    result,
                    dept.DeptCode,
                )
        }
    }

    return result
}

这样至少能快速发现:

parent_code 指向不存在部门

的问题。


三十七、BuildDeptTree 还可以增强容错

ShiyuAdmin 当前逻辑中,如果一个部门:

ParentCode != ""

但父部门不存在,那么它不会进入 roots。

生产级场景可以考虑:

if parent == nil {

    // 脏数据容错
    roots =
        append(
            roots,
            node,
        )

    continue
}

同时记录日志:

logger.Warn(
    "orphan department",
    "dept_code",
    dept.DeptCode,
    "parent_code",
    dept.ParentCode,
)

这样至少不会让数据:

凭空消失在 API 返回结果里。

三十八、完整的增强版 BuildDeptTree

最终可以写成:

func BuildDeptTree(
    depts []*Dept,
) []*DeptVO {

    nodes :=
        make(
            map[string]*DeptVO,
            len(depts),
        )

    roots :=
        make(
            []*DeptVO,
            0,
        )

    // ----------------
    // 第一步:创建节点
    // ----------------

    for _, dept :=
        range depts {

        if dept == nil {
            continue
        }

        nodes[
            dept.DeptCode
        ] =
            &DeptVO{
                DeptCode:
                    dept.DeptCode,

                ParentCode:
                    dept.ParentCode,

                DeptName:
                    dept.DeptName,

                Status:
                    dept.Status,

                Children:
                    make(
                        []*DeptVO,
                        0,
                    ),
            }
    }

    // ----------------
    // 第二步:建立关系
    // ----------------

    for _, dept :=
        range depts {

        if dept == nil {
            continue
        }

        node :=
            nodes[
                dept.DeptCode
            ]

        if dept.ParentCode ==
            "" {

            roots =
                append(
                    roots,
                    node,
                )

            continue
        }

        parent,
        exists :=
            nodes[
                dept.ParentCode
            ]

        if !exists {

            // 孤儿节点容错
            roots =
                append(
                    roots,
                    node,
                )

            continue
        }

        parent.Children =
            append(
                parent.Children,
                node,
            )
    }

    return roots
}

核心依旧没变:

一次查询
+
两次遍历
+
一个 Map

三十九、整个 ShiyuAdmin 部门模块可以怎么理解?

现在我们重新看一次完整链路。

前端:

部门管理

请求:

GET /api/v1/system/depts/tree

进入:

Gin Router

权限:

system:dept:list

经过:

RequirePermission

然后:

Dept Handler

调用:

DeptService.ListTree

Service 调用:

DeptRepository.List

GORM:

SELECT *
FROM sys_depts
ORDER BY id ASC;

然后:

[]Dept

进入:

BuildDeptTree

最终:

[]DeptVO

返回:

[
  {
    "dept_name": "仕宇科技",
    "children": []
  }
]

整个请求链路:

React
↓
Gin Router
↓
JWT
↓
RBAC Permission
↓
Dept Handler
↓
Dept Service
↓
Dept Repository
↓
GORM
↓
sys_depts
↓
[]Dept
↓
BuildDeptTree
↓
[]DeptVO
↓
JSON
↓
Ant Design Tree

这基本就是 ShiyuAdmin 部门树的核心结构。


四十、真正值得理解的不是“递归”

很多人第一次看到部门树会觉得:

树
=
递归

但实际写后台以后你会发现:

部门树真正麻烦的地方根本不是:

func recursive() {}

真正难的是:

数据怎么保存
↓
怎么保证父节点存在
↓
怎么防止形成环
↓
怎么安全删除
↓
怎么关联用户
↓
怎么关联角色
↓
怎么计算数据权限
↓
怎么兼容多个数据库
↓
怎么应对脏数据

这才是后台组织架构设计真正需要解决的问题。


四十一、ShiyuAdmin 当前方案的优点

回过头来看 ShiyuAdmin 当前的实现。

它采用:

DeptCode
+
ParentCode

建立部门父子关系。

Repository:

只负责数据库 CRUD。

Service:

负责业务。

VO:

负责把平铺 Entity
转换成 Tree。

API:

负责 HTTP 和权限。

最终形成:

Database
↓
Repository
↓
Service
↓
VO
↓
Tree Response

这套设计最大的优势就是:

简单。

没有:

复杂递归 SQL
复杂 Closure Table
复杂树结构存储

对于绝大多数:

ERP
CRM
OA
CMS
SaaS 后台
运营后台
企业内部管理系统

这样的方案已经足够用了。


四十二、如果继续升级 ShiyuAdmin,我会补哪些东西?

如果我要继续增强 ShiyuAdmin 的部门模块,我认为可以按照下面这个优先级完善。

第一优先级:

父部门存在校验

避免:

孤儿部门。

第二优先级:

禁止自己作为自己的父部门。

第三优先级:

禁止把子孙部门设置成自己的父部门。

彻底避免:

循环引用。

第四优先级:

有子部门禁止删除。

第五优先级:

有用户的部门禁止删除。

第六优先级:

增加 sort_order。

第七优先级:

进一步完善
deptAndChild
数据权限。

这样整个部门模块就能从:

基础部门 CRUD

升级成:

真正可以长期用于企业后台
的组织架构系统。

总结

后台管理系统的部门树,表面看起来只是:

一个 Tree 组件。

实际上背后连接的是:

组织架构
用户体系
角色体系
RBAC
数据权限
业务数据隔离

而 ShiyuAdmin 当前选择的核心数据模型非常经典:

dept_code
+
parent_code

也就是:

邻接表模型。

数据库保存平铺结构:

Dept
Dept
Dept
Dept

然后 Go 后端:

一次查询
↓
Map
↓
两次遍历
↓
Children
↓
部门树

时间复杂度:

O(N)

数据库查询:

通常只需要 1 次。

相比对每一个节点递归查询数据库,它简单很多,也避免了明显的 N+1 Query 问题。

更重要的是,在 ShiyuAdmin 中,部门并不是孤立存在的。

用户拥有:

DeptCode

角色拥有:

DataScope

系统还有:

RoleDept

角色部门关联。

因此最终可以形成:

用户
↓
部门
↓
部门树
↓
角色
↓
DataScope
↓
允许访问哪些部门
↓
最终允许访问哪些数据

这时候:

部门树就不再只是一个前端展示结构,而是整个后台数据权限体系的一部分。

如果正在设计一个真正准备长期维护的后台管理系统,我比较推荐从 ShiyuAdmin 现在这种:

Adjacency List
+
DeptCode
+
ParentCode
+
Map Build Tree

的方案开始。

不要一上来就设计特别复杂的树模型。

先把:

父子关系
循环校验
删除规则
用户关联
数据权限

这些真正影响业务正确性的地方做好。

因为对于后台系统来说:

一棵部门树真正重要的,不是它能不能显示出来,而是这棵树能不能成为用户、角色和数据权限之间稳定可靠的组织基础。

posted @ 2026-09-01 10:14  JavaPub  阅读(18)  评论(0)    收藏  举报