Gin 实现菜单权限模块接口
前面实现了一个简单的公告模块的 CRUD. 主要是为了形成一些习惯的套路而已, 参数验证 + SQL 操作.
本篇来实现这个 菜单权限 的增删改查, 这里涉及的技能点主要是对 层级树结构的表处理, 对应的 SQL 技术就是 自关联, 子查询 还是有点绕的.
项目目录
我是在 mac 终端下, 用命令行来模拟一下 tree 的功能, 体现目录结构.
find . -path './.git' -prune -o -print | sed -e 's;[^/]*/;|____;g;s;____|; |;g'
.
|____go.mod
|____internal
| |____middleware
| | |____auth.go
| |____db
| | |____db.go
|____go.sum
|____api
| |____routers.go
| |____handlers
| | |____auth
| | | |____views.go
| | |____user
| | | |____menu.go
| | | |____views.go
| | |____notice
| | | |____views.go
| | |____rule
| | | |____views.go
|____.vscode
| |____extensions.json
|____tmp
| |____runner-build
|____main.go
|____pkg
| |____utils
| | |____response.go
| | |____tools.go
| |____jwtt
| | |____jwt.go
cj@mini gin-shop-admin %
接下来就开始正式的接口编写吧.
数据库表
主要是 rule 表, 字段如下:
-- 管理员-权限表
drop table if exists rule;
create table rule(
id int not null auto_increment primary key comment '自增id'
, parent_id int default 0 comment '父级id'
, status tinyint default 1 comment '0关闭, 1启用'
, name varchar(50) default null comment '权限菜单名称'
, descr varchar(50) default null comment '前端路由名称'
, frontpath varchar(50) default null comment '前端路由注册路径'
, cond varchar(50) default null comment '菜单下的操作权限描述'
, menu tinyint default 1 comment '菜单或权限, 1菜单0权限'
, orders int default 50 comment '排序'
, icon varchar(100) default null comment '图标路径'
, method varchar(50) not null default 'GET' comment '请求方式'
, create_time datetime default current_timestamp
, update_time datetime default current_timestamp on update current_timestamp
);
模块路由
api/routers.go
package api
import (
"github.com/gin-gonic/gin"
"youge.com/api/handlers/rule"
"youge.com/internal/middleware"
)
// 统一注册入口
func RegisterAllRouters(r *gin.Engine) {
// 登录认证模块
// 用户管理模块
// 公告模块
// 菜单权限模块
ruleGroup := r.Group("api/rule")
// ruleGroup.Use(middleware.JWT()) // 也是需要 token 认证
{
// get
ruleGroup.GET("/rule", rule.GetRuleList)
// add
ruleGroup.POST("/rule", rule.CreateRule)
// update
ruleGroup.PUT("/rule/:id", rule.UpdateRule)
// update_status
ruleGroup.PUT("/rule/:id/update_status", rule.UpdateRuleStatus)
// delete
ruleGroup.DELETE("/rule/:id/delete", rule.DleteRule)
}
// ... 更多模块
}
对应的接口逻辑实现都放在 /api/handlers/rule/views.go 里面, 不做分层了哈, 直接干.
GET 查询菜单和权限接口
前端: http://localhost:8000/api/rule/rule
后端: /api/handlers/rule/views.go => GetRuleList
就不搞分页查询了, 因为就超管可以用设置而已, 直接查询原始数据,
然后转为
层级树 json给到前端即可
package rule
import (
"log"
"sort"
"strconv"
"github.com/gin-gonic/gin"
"youge.com/internal/db"
"youge.com/pkg/utils"
)
// 菜单权限表
type Menu struct {
ID int `db:"id" json:"id"`
PID int `db:"parent_id" json:"-"`
Name string `db:"name" json:"name"`
Descr string `db:"descr" json:"descr"`
Status int `db:"status" json:"status"`
Icon string `db:"icon" json:"icon"`
FrontPath string `db:"frontpath" json:"frontpath"`
Cond string `db:"cond" json:"cond"`
Menu int `db:"menu" json:"menu"`
Orders int `db:"orders" json:"orders"`
Method string `db:"method" json:"method"`
Children []*Menu `json:"children,omitempty"`
}
// 构建树函数
func BuildMenuTree(items []*Menu) []*Menu {
// 排序可选
sort.Slice(items, func(i, j int) bool {
if items[i].PID == items[j].PID {
return items[i].ID < items[j].ID // 确保父子相邻
}
return items[i].PID < items[j].PID // 父节点在前
})
// 创建节点索引(类似固定绳结)
nodeMap := make(map[int]*Menu)
for _, node := range items {
nodeMap[node.ID] = node
}
// 多级挂载
var tree []*Menu
for _, node := range items {
// 已约定 pid = 0 则为顶层节点
if node.PID == 0 {
tree = append(tree, node)
} else {
// 找到父节点,将其挂载到其 children 中
if parent, exist := nodeMap[node.PID]; exist {
parent.Children = append(parent.Children, node)
}
}
}
return tree
}
// 接口: 获取菜单权限列表
func GetRuleList(c *gin.Context) {
mysql := `
select
id
, parent_id
, status
, name
, coalesce(descr, '') as descr
, coalesce(frontpath, '') as frontpath
, coalesce(cond, '') as cond
, menu
, coalesce(orders, 0) as orders
, coalesce(icon, '') as icon
, method
from rule
order by parent_id
`
var ruleList []*Menu
if err := db.Query(&ruleList, mysql); err != nil {
utils.BadRequest(c, "获取数据失败")
return
}
// 过滤出菜单节点(menu=1)
var filterMenu func(nodes []*Menu) []*Menu
filterMenu = func(nodes []*Menu) []*Menu {
var filtered []*Menu
for _, n := range nodes {
if n.Menu == 1 {
clone := *n
clone.Children = filterMenu(n.Children)
filtered = append(filtered, &clone)
}
}
return filtered
}
// 构建菜单+权限的树
fullTree := BuildMenuTree(ruleList)
menuTree := filterMenu(fullTree)
// 查询总条数
// var total int
// db.Get(&total, "select count(*) as total from rule;")
total := len(ruleList)
// 返回结果
utils.Success(c, gin.H{
"list": fullTree,
"menus": menuTree,
"totalCount": total,
})
}
这里有2个已经优化的点:
- 过滤菜单节点, 直接从程序内存中过滤效率高, 而之前是要从数据库重新查询处理
- 查询总记录数, 直接从程序结果集计算, 之前也是要查询一遍数据库
POST 新增菜单或权限接口
前端: http://localhost:8000/api/rule/rule
前端请求体 json :
{
"rule_id": 0,
"status": 1,
"name": "test123",
"cond": "",
"menu": 1,
"orders": 50,
"method":"GET",
"frontpath": "/",
"icon": ""
}
后端: /api/handlers/rule/views.go => CreateRule
// 接口: 新增菜单权限
func CreateRule(c *gin.Context) {
// 参数验证, 这里的 json 是对应前端传递的参数哈
var req struct {
PID *int `json:"rule_id" validate:"required"`
Name string `json:"name" validate:"required"`
Status int `json:"status" validate:"oneof=0 1"` // 0 or 1
Icon string `json:"icon" validate:"omitempty"` // 可选填
FrontPath string `json:"frontpath" validate:"omitempty"`
Cond string `json:"cond" validate:"omitempty"`
Menu int `json:"menu" validate:"oneof=0 1"`
Orders int `json:"orders" validate:"min=10,max=1000"`
Method string `json:"method" validate:"omitempty,oneof=GET POST PUT DELETE"`
}
if err := utils.BindAndValidate(c, &req); err != nil {
utils.Error(c, 400, "参数错误", err)
return
}
// 不用校验其他, 直接插入就行了
mysql := `
insert into rule (parent_id, name, status, icon, frontpath, cond, menu, orders, method)
values (?, ?, ?, ?, ?, ?, ?, ?, ?);
`
rows, err := db.Exec(mysql, req.PID, req.Name, req.Status, req.Icon, req.FrontPath, req.Cond, req.Menu, req.Orders, req.Method)
if err != nil {
utils.Error(c, 500, "", err)
return
}
utils.Success(c, gin.H{
"affectedRows": rows,
})
}
新增的接口是最没有技术含量的, 就是 参数验证 + 拼接sql 语句执行.
PUT 修改菜单或权限接口
前端: http://localhost:8000/api/rule/rule/10
后端: /api/handlers/rule/views.go => UpdateRule
// 接口: 修改菜单权限数据
func UpdateRule(c *gin.Context) {
// 获取并校验路径参数
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
utils.BadRequest(c, "请求参数错误")
}
// 获取请求体, 和新增一样的参数验证
var req struct {
PID int `json:"rule_id" validate:"omitempty"`
Name string `json:"name" validate:"required"`
Status int `json:"status" validate:"oneof=0 1"` // 0 or 1
Icon string `json:"icon" validate:"omitempty"` // 可选填
FrontPath string `json:"frontpath" validate:"omitempty"`
Cond string `json:"cond" validate:"omitempty"`
Menu int `json:"menu" validate:"oneof=0 1"`
Orders int `json:"orders" validate:"min=10,max=1000"`
Method string `json:"method" validate:"omitempty,oneof=GET POST PUT DELETE"`
}
if err := utils.BindAndValidate(c, &req); err != nil {
log.Println(err)
utils.BadRequest(c, "请求体参数错误")
return
}
// 根据id, 更新数据
mysql := `
update rule set parent_id = ?, name = ?, status = ?, icon = ?, frontpath = ?, cond = ?, menu = ?, orders = ?, method = ?
where id = ?;
`
rows, err := db.Exec(mysql, req.PID, req.Name, req.Status, req.Icon, req.FrontPath, req.Cond, req.Menu, req.Orders, req.Method, id)
if err != nil {
utils.Error(c, 500, "", err)
return
}
utils.Success(c, gin.H{
"affectedRows": rows,
})
}
PUT 修改菜单或权限状态接口
前端: http://localhost:8000/api/rule/rule/10/update_status
后端: /api/handlers/rule/views.go => UpdateRuleStatus
// 接口: 修改菜单权限状态
func UpdateRuleStatus(c *gin.Context) {
// 获取并校验路径参数
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
utils.BadRequest(c, "请求参数错误")
return
}
// 请求体参数验证
var req struct {
Status int `json:"status" validate:"oneof=0 1"`
}
if err := utils.BindAndValidate(c, &req); err != nil {
utils.Error(c, 400, "参数错误", err)
return
}
// 修改状态
sql := "update rule set status = ? where id = ?"
rows, err2 := db.Exec(sql, req.Status, id)
if err2 != nil {
utils.Error(c, 500, "", err2)
return
}
utils.Success(c, gin.H{
"affectedRows": rows,
})
}
DELETE 删除菜单或权限状态接口
前端: http://localhost:8000/api/rule/rule/10/delete
后端: /api/handlers/rule/views.go => DeleteRule
// 接口: 删除一条菜单权限
func DleteRule(c *gin.Context) {
// 获取并校验路径参数
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
utils.BadRequest(c, "请求参数错误")
return
}
// 删除一条数据根据id, 如果它还有子节点则不让删
// id.pid in ids ? ; id 是不可能为 0 的
var hasChild int
db.Get(&hasChild, `
select exists (
select 1
from rule
where parent_id = ?
and parent_id != 0
limit 1
) as hasChild
`, id)
if hasChild == 1 {
utils.BadRequest(c, "请先删除子菜单或者权限, 再操作哈")
return
}
// 真正删除
rows, err := db.Exec("delete from rule where id = ?", id)
if err != nil {
utils.Error(c, 500, "删除失败", err)
return
}
utils.Success(c, gin.H{
"affectedRows": rows,
})
}
这里的优化在于:
-
当前: 不能直接删除尚有子节点的菜单, 防止出现
孤儿节点的脏数据 -
未来: 后续考虑支持递归删除其子节点树的实现
// 使用递归CTE进行删除 result, err := db.Exec(` WITH RECURSIVE to_delete AS ( SELECT id FROM rule WHERE id = ? UNION ALL SELECT r.id FROM rule r INNER JOIN to_delete td ON r.parent_id = td.id ) DELETE FROM rule WHERE id IN (SELECT id FROM to_delete) `, id) if err != nil { utils.Error(c, 500, "删除失败", err) return }
感觉维持当前的让用户一个个删稳妥一点, 万一搞错了咋办, 全部干掉就不好复原了.
至此, 菜单权限的增删改查接口也就处理好了, 比较通用, 尤其是菜单树处理还是有点东西的, 值得记录一下.

浙公网安备 33010602011771号