后台管理系统的部门树怎么设计?从 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
的方案开始。
不要一上来就设计特别复杂的树模型。
先把:
父子关系
循环校验
删除规则
用户关联
数据权限
这些真正影响业务正确性的地方做好。
因为对于后台系统来说:
一棵部门树真正重要的,不是它能不能显示出来,而是这棵树能不能成为用户、角色和数据权限之间稳定可靠的组织基础。

浙公网安备 33010602011771号