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
        }
    

感觉维持当前的让用户一个个删稳妥一点, 万一搞错了咋办, 全部干掉就不好复原了.

至此, 菜单权限的增删改查接口也就处理好了, 比较通用, 尤其是菜单树处理还是有点东西的, 值得记录一下.

posted @ 2025-05-10 23:36  致于数据科学家的小陈  阅读(33)  评论(0)    收藏  举报