这是一个系列 Blog,作者将以一个 PHP 全栈工程师的身份,利用 AI 工具(TRAE、claude code、codex、deepseek、豆包等):从零开始学习 golang 语言,并最终完成 ai-go-admin(github | gitee)开源项目的制作,全程记录分享。
在上一期,我们进行了 “管理员权限检查中间件”,本期将完成:管理员日志管理和日志中间件
管理员日志中间件
一个全局中间件,对所有 /admin/ 开头的请求生效,之所以不作为分组中间件注册,因为我们路由注册时,可能会建立多个类似下方的分组:
group := r.Group("/admin/auth/admin")
group := r.Group("/admin/auth/group")
即,我们的路由都是子模块自行负责注册的,不是统一注册,所以使用分组级中间件不方便。
后续有改动:未来会在制作 “自定义后台入口” 功能时,统一注册 admin 路由的 group,并将日志中间件直接注册到该 group 上,就不再需要全局注册了。
日志标题读取
从权限规则表读取
用户请求 /admin/auth/admin/update 接口,日志中不仅要记录 请求用户、请求URL、请求体 等,更重要的是 日志标题 数据,最好能一眼就看出管理员干了啥。
我们刚好有 admin_rules 表,管理员的每个请求都经过了验权,能验权自然于该表有权限数据:
/auth/admin从规则表可查出标题为管理员账号管理的权限规则/auth/admin/update从规则表可查出标题为更新的权限规则
最终组装为 管理员账号管理 - 更新 作为日志标题,看起来很完美,但是:一个后台请求中,我们已经读了 tokens、验权读了 admin_rules,现在为了个日志标题,又加两个 SQL 查询,这非常不合适,所有这里让 AI 做了个在单条 SQL 查询中准备好标题的办法,核心代码是这样的:
fmt.Sprintf(
"SELECT c.title, COALESCE(p.title, '') AS parent_title FROM %s AS c LEFT JOIN %s AS p ON p.id = c.pid WHERE c.name = ?",
tableName, tableName,
)
控制器层、服务层指定标题
这一点非常有必要,比如管理员登录接口 login,这个接口压根不在权限规则表有记录,而这个接口的日志,又不得不记录,那么我们可以在控制器里边:
c.Set(middleware.CtxAdminLogTitleKey, "登录")
向请求上下文设定一个自定义的日志标题,日志中间件里边只需要先调用 c.Next(),让控制器的代码先跑,即可在写入日志时读取到自定义标题数据:
// 检查控制器层是否已设置自定义日志标题
customTitle, hasCustomTitle := c.Get(CtxAdminLogTitleKey)
// 异步写入日志
// 先在外面把变量提前捕获好,在开 goroutine 慢慢处理和入库,避免挂在请求上下文的数据随请求结束丢失等
go func() {
// ......
}
排除
日志也不用每条请求都做记录,有以下排除规则:
请求排除
- 仅记录管理后台请求
- 仅记录
POST请求 - 排除
chunked请求 - 额外排除
list请求(因为list请求也可能是POST,筛选、排序等数据GET不够,且GET直接发送json数据不方便)
敏感字段过滤或排除
我们可以利用 GORM 的模型的创建前钩子,在日志写入前,对 password、salt、token 等敏感字段进行过滤。
日志是异步写入的,在模型钩子中处理,可以避免解析请求体等动作拖慢用户侧响应速度。
非超管只能看自己的日志
在控制器和服务层直接通过拼接 Where 进行限制,避免任何可能的越权。
// 非超管则只查询自己的日志
if !super {
opts.Wheres = append(opts.Wheres, service.WhereGroup{
Wheres: []service.Where{{
Field: "admin_id",
Operator: "eq",
Value: extension.AdminSession.ID,
}},
})
}
管理员日志管理
前端请求体展示优化
到这里一般的后台就是直接在 日志详情 直接显示 JSON 了,比如:{"username": "admin", "password": "****", ......},不过我还是在这里用了个 el-tree,并写了工具函数将 JSON 构建为该组件可以合理使用的结构,最终效果如下:
双击打开详情
表格的默认设计是双击打开编辑,稍微改一下即可;另外直接将双击行的数据作为 详情弹窗 的数据,无需额外加载。
自定义一个表格行级操作按钮:
let optButtons: OptButton[] = [
{
render: 'tip',
name: 'info',
title: '详情',
text: '',
type: 'primary',
icon: 'lucide-info',
class: 'table-row-info',
click: (row: TableRow) => {
openInfo(row)
},
},
]
利用双击单元格前钩子重写双击操作:
tableManager.opts.before!.columnDblclick = ({ row }) => {
openInfo(row)
return false
}
接下来在 openInfo 函数打开详情弹窗,主要是 tableManager.form.operate = 'info',详情弹窗那边再 <Info v-if="tableManager.form.operate == 'info'" /> 就行了
/**
* 点击查看详情按钮响应
*/
const openInfo = (row: TableRow) => {
if (!row) return
// 数据来自表格数据,未重新请求 API,深克隆,不然可能会影响表格
let rowClone = cloneDeep(row)
rowClone.data = rowClone.data ? [{ label: '点击展开', children: buildJsonToElTreeData(JSON.parse(rowClone.data)) }] : []
tableManager.form.extend!['info'] = rowClone
tableManager.form.operate = 'info'
}