第一篇:【Go】基于GoFiber从零开始搭建一个GoWeb后台管理系统(一)搭建项目
第二篇:【Go】基于GoFiber从零开始搭建一个GoWeb后台管理系统(二)日志输出中间件、校验token中间件、配置路由、基础工具函数。
前两篇我们搭好了项目,并且配置好了中间件和路由,这篇开始我们可以正式写业务代码了。
首先我们先来实现日志管理这个模块,像登录、退出、操作数据库(增、删、改)都会记录日志保存到sys_log表中。
数据库表我在第一篇已经全部写出来了,这里就不再重复了。
先看看记录的数据:
日志管理这个模块,其实接口的话就一个接口:列表查询。
router.go
// 日志管理路由
func logRouter(app *fiber.App) {
log := app.Group("/sys/log")
{
log.Get("/list", api.LogController{}.GetPage) // 日志列表
}
}
package sys
import (
"github.com/gofiber/fiber/v2"
"go-web2/app/common/config"
"go-web2/app/model/sys"
"time"
)
type LogController struct{}
// 日志列表分页
func (LogController) GetPage(c *fiber.Ctx) error {
syslog := sys.SysLog{}
syslog.IP = c.Query("code")
name := c.Query("name")
createTime, _ := time.Parse("2006-01-02", c.Query("startDate"))
syslog.CreatorId = &name
syslog.CreateTime = createTime
pageSize := c.QueryInt("pageSize", 10)
pageNum := c.QueryInt("pageNum", 1)
return c.Status(200).JSON(config.Success(syslog.GetPage(pageSize, pageNum)))
}
package sys
import (
"fmt"
"github.com/google/uuid"
"go-web2/app/common/config"
"strings"
"time"
)
// 操作日志管理
type SysLog struct {
config.BaseModel
IP string `gorm:"ip" json:"ip"` // 用户请求IP
Title string `gorm:"title" json:"title"` // 用户请求的标题
Type string `gorm:"type" json:"title"` // 操作类型(其他 登录 退出 新增 修改 删除 上传 导入 设置状态 设置密码)
Method string `gorm:"method" json:"method"` // 用户请求的方法
Url string `gorm:"url" json:"url"` // 请求url
Info string `gorm:"info" json:"info"` // 详细信息
State string `gorm:"state" json:"state"` // 状态(操作成功 操作失败)
}
// 获取表名
func (SysLog) TableName() string {
return "sys_log"
}
// 列表
func (e *SysLog) GetPage(pageSize int, pageNum int) config.PageInfo {
var list []SysLog // 查询结果
var total int64 // 总数
query := config.DB.Table(e.TableName())
var creatorId string
if e.CreatorId != nil {
creatorId = *e.CreatorId
}
if creatorId != "" {
query.Where("creator_id like ?", fmt.Sprintf("%%%s%%", creatorId))
}
if e.IP != "" {
query.Where("ip like ?", fmt.Sprintf("%%%s%%", e.IP))
}
if !e.CreateTime.IsZero() {
query.Where("DATE_FORMAT(create_time,'%Y-%m-%d') = ?", e.CreateTime.Format("2006-01-02"))
}
offset := (pageNum - 1) * pageSize // 计算跳过的记录数
query.Debug().Order("create_time desc").Offset(offset).Limit(pageSize).Find(&list) // 分页查询,根据offset和limit来查询
query.Count(&total)
return config.PageInfo{list, total}
}
// 新增
func (e *SysLog) Insert() (err error) {
e.Id = strings.ReplaceAll(uuid.NewString(), "-", "")
e.CreateTime = time.Now()
config.DB.Create(e)
return
}
列表查询这个没什么,这个模块主要难在怎么获取到需要的数据,然后处理好添加到数据库中。
在Java中,我们可以用注解+aop的方式,设置和获取对应的信息,比如通过注解设置 title、type,通过aop切面获取 请求的接口、前端传给后端的参数、后端返回给前端的数据等等。
在go中,我们想实现类似的功能,需要用中间件,不过中间件只能实现类似Java中aop的功能,也就是说中间件只能获取请求到的信息和返回的信息。像设置 title、type 就只能自己定义标准,然后根据请求的接口来判断、设置 title、type。
title:就是接口名称,type:就是操作类型。
我的思路就是:每个接口按模块来划分,像注册路由的时候,我们也都是一个模块的接口全部放到一个路由组中。然后接口命名也保持统一风格。这样我们一个模块的所有接口都有一个共同的前缀。
比如用户管理模块,它的路由是这样的:
// 用户管理路由
func userRouter(app *fiber.App) {
controller := api.UserController{}
user := app.Group("/sys/user")
{
user.Get("/getLoginUser", controller.GetLoginUser) // 获取当前登录的用户
user.Get("/list", controller.GetPage) // 用户列表
user.Get("/getById/:id", controller.GetById) // 根据id获取用户
user.Post("/insert", controller.Insert) // 新增用户
user.Post("/update", controller.Update) // 修改用户
user.Delete("/delete", controller.Delete) // 删除用户
user.Post("/updatePassword", controller.UpdatePassword) // 修改密码
user.Post("/resetPassword", controller.ResetPassword) // 重置密码
user.Post("/upload", controller.Upload) // 上传头像
}
}
这里面的接口,都是 /sys/user
开头的;其他模块也是同理,所以我们定义一个标准就是接口以什么开头就属于哪个模块。以 /sys/user
开头的接口都是用户管理模块的,以 /sys/dept
开头的都是部门管理模块。
然后接口命名保持统一风格,增、删、改、查这些接口命名风格统一(主要是增、删、改)
这样我们判断 操作类型 时就好判断了。比如 /sys/user/insert
接口我们就可以判断 type = 新增,title = 用户新增
。
middleware.go
// 路由接口前缀(名称),用于在日志中间件中,获取请求接口的title
var RouteNames = map[string]string{
"/sys/logout": "用户退出",
"/sys/safe": "安全设置",
"/sys/user": "用户",
"/sys/dept": "部门",
"/sys/role": "角色",
"/sys/menu": "菜单",
"/sys/dict": "字典",
"/sys/dict/deleteType": "字典类型",
}
// 保存日志到数据库:操作日志
func SysLogInit(c *fiber.Ctx) error {
path := c.Path() // 获取当前请求的路径
// 跳过get请求
if c.Method() == fiber.MethodGet || strings.Contains(path, "/sys/login") {
return c.Next()
}
var entity model.SysLog
re := regexp.MustCompile(`^/(.*?)(?:\?.*)?$`) // 根据正则解析接口
match := re.FindStringSubmatch(path)
if len(match) > 1 {
api := match[1]
if !strings.HasPrefix(api, "/") {
api = "/" + api
}
method := ""
title := RouteNames[api] // 直接根据接口名获取接口名称
if title == "" { // 如果获取不到则只获取接口前缀,根据前缀拿到当前接口是属于哪个模块的
split := strings.Split(api, "/") // api根据 / 分割
str := ""
for i, s := range split {
if s == "" {
continue
}
if i > 0 {
str += "/"
}
str += s
title = RouteNames[str] // 获取到接口前缀
if title != "" {
method = split[i+1] // 如果拿到了接口前缀,那么当前索引+1就是具体的接口名
break
}
}
}
entity.State = "操作成功"
if strings.Contains(api, "/sys/logout") {
entity.Type = "退出"
entity.Info = "退出成功"
entity.Title = "用户退出"
} else if strings.Contains(path, "insert") {
entity.Type = "新增"
} else if strings.Contains(path, "update") && !strings.Contains(path, "updateState") && !strings.Contains(path, "updatePassword") {
entity.Type = "修改"
} else if strings.Contains(path, "delete") {
entity.Type = "删除"
} else if strings.Contains(path, "updateState") {
entity.Type = "设置状态"
entity.Title = "设置" + title + "状态"
} else if strings.Contains(path, "updatePassword") {
entity.Type = "修改密码"
entity.Title = "修改密码"
} else if strings.Contains(path, "resetPassword") {
entity.Type = "重置密码"
entity.Title = "重置密码"
} else if strings.Contains(path, "upload") {
entity.Type = "上传"
} else if strings.Contains(path, "imports") {
entity.Type = "导入"
} else {
entity.Type = "其他"
}
if entity.Title == "" {
// 新增用户、修改用户、删除用户、设置用户状态、上传用户、导入用户、字典类型删除......
entity.Title = entity.Type + title
}
entity.Info = entity.Title + "成功"
// 调用下一个中间件或路由处理程序,用来获取响应给前端的数据
c.Next()
code := c.Response().StatusCode()
if code != 200 {
entity.State = "操作失败"
entity.Info = "未知异常"
} else {
var result config.Result
json.Unmarshal(c.Response().Body(), &result)
if result.Code != 0 {
entity.State = "操作失败"
entity.Info = entity.Title + "失败:" + result.Message
}
}
if method == "" {
methods := strings.Split(api, "/") // 当前接口根据 / 分割
entity.Method = methods[len(methods)-1] // 获取当前请求的方法
} else {
entity.Method = method
}
entity.IP = c.IP() // 获取用户IP
entity.Url = api // 获取当前请求的路径
token := c.Get(config.TokenHeader)
if token != "" {
user := model.GetLoginUser(token)
entity.CreatorId = &user.UserName
entity.Info = user.UserName + " " + entity.Info
}
entity.Insert()
}
return c.Next()
}
// 省略其他代码.....
这样我们就可以获取到操作日志了。然后登录日志的话,我们在登录接口设置更好一点。
接下来我们来实现登录模块。
// 登录路由
func loginRouter(app *fiber.App) {
controller := api.LoginController{}
login := app.Group("/sys")
{
login.Get("/getKey", controller.GetKey) // 获取RSA公钥
login.Get("/getCode", controller.GetCode) // 获取验证码
login.Post("/login", controller.Login) // 用户登录
login.Delete("/logout", controller.Logout) // 用户退出
}
}
登录模块有上面这几个接口,除了登录退出,还有获取登录的验证码,还有登录的时候用户输入用户名、密码调用登录接口时,前端需要将用户名、密码根据RSA公钥进行加密传输,后端接收到参数时也需要根据RSA私钥进行解密,获取到真正的用户名和密码。
登录时校验密码,有错误次数限定超过了这个账号就会锁定15或30分钟,在这期间不允许再次登录。
这里因为我没有前端用的是apipost测试的,所以RSA加解密那块我注释掉了。
package sys
import (
"fmt"
"github.com/gofiber/fiber/v2"
"github.com/pkg/errors"
"go-web2/app/common/config"
"go-web2/app/common/util"
"go-web2/app/model/sys"
"time"
)
type LoginController struct{}
// 获取公钥
func (LoginController) GetKey(c *fiber.Ctx) error {
return c.Status(200).JSON(config.Success(util.GetPublicKey()))
}
// 获取验证码
func (LoginController) GetCode(c *fiber.Ctx) error {
id, base64 := util.GenerateCaptcha(4, 100, 42)
code := make(map[string]string)
code["codeId"] = id
code["code"] = base64
return c.Status(200).JSON(config.Success(code))
}
// 登录
func (LoginController) Login(c *fiber.Ctx) error {
//code := c.FormValue("code")
//codeId := c.FormValue("codeId")
userName := c.FormValue("userName")
password := c.FormValue("password")
// 解密
//userName = util.RSADecrypt(userName)
//password = util.RSADecrypt(password)
// 校验验证码是否正确
//b := util.CaptVerify(codeId, code)
//if !b {
// return c.Status(200).JSON(config.Error("验证码错误或已过期"))
//}
var syslog = sys.SysLog{IP: c.IP(), Title: "用户登录", Type: "登录", Method: "login", Url: "/sys/login", State: "登录成功"}
syslog.CreatorId = &userName
// 校验用户名和密码
safe := sys.SysSafe{}
safe.GetById()
user, result := passwordErrorNum(userName, password, safe)
if result.Code != 0 {
syslog.State = "登录失败"
syslog.Info = result.Message
syslog.Insert()
return c.Status(200).JSON(result)
}
i := safe.IdleTimeSetting //如果系统闲置时间为0,设置token和session永不过期
// 登录
token := ""
if i == 0 {
token = user.Login("", -1) // 永不过期
} else {
token = user.Login("", config.TokenExpire) // 默认保持登录为30分钟
}
syslog.Info = userName + "登录成功"
syslog.Insert()
return c.Status(200).JSON(config.Success(token))
}
// 退出
func (LoginController) Logout(c *fiber.Ctx) error {
token := c.Get(config.TokenHeader) // 获取请求头中的 Token
sys.Logout(token) // 退出登录
return c.Status(200).JSON(config.Success(nil))
}
/**
* 判断账号是否锁定
*/
func lockedUser(currentTime, errorCount int64, userName string) (error, bool) {
flag := false
exists, _ := config.RedisConn.Exists(config.ERROR_COUNT + userName).Result()
// 如果没有错误次数,直接返回
if exists == 0 {
return nil, flag
}
loginTime, _ := config.RedisConn.HGet(config.ERROR_COUNT+userName, "loginTime").Int64()
i, _ := config.RedisConn.HGet(config.ERROR_COUNT+userName, "errorNum").Int64()
if i >= errorCount && currentTime < loginTime {
diff := loginTime - currentTime // 计算时间差
minutes := int(diff / 60) // 将差值转换为分钟
msg := fmt.Sprintf("账号锁定中,还没到允许登录的时间,请%d分钟后再尝试", minutes)
return errors.New(msg), flag
} else {
flag = true
}
return nil, flag
}
// 校验用户名和密码
func passwordErrorNum(userName, password string, safe sys.SysSafe) (*sys.SysUser, config.Result) {
user := sys.SysUser{}
user.UserName = userName
//查询用户
err := user.GetUser()
if err != nil || user.Id == "" {
return nil, config.ErrorCode(1001, "用户不存在或密码错误")
}
//根据前端输入的密码(明文),和加密的密码、盐值进行比较,判断输入的密码是否正确
authenticate := util.AuthenticatePassword(password, user.Password)
if authenticate {
//密码正确错误次数清零
config.RedisConn.Del(config.ERROR_COUNT + userName)
} else {
// 获取当前时间的时间戳(单位:秒)
currentTime := time.Now().Unix()
flag := false
//错误3次,锁定15分钟后才可登陆 允许时间加上定义的登陆时间(毫秒)
str := "15"
var errorCount int64 = 3
timeStamp := currentTime + 900
//密码登录限制(0:连续错3次,锁定账号15分钟。1:连续错5次,锁定账号30分钟)
if safe.PwdLoginLimit == 1 {
errorCount = 5
str = "30"
timeStamp = currentTime + 1800
}
//判断账号是否锁定
err, flag = lockedUser(currentTime, errorCount, userName)
if err != nil {
return nil, config.ErrorCode(1004, err.Error())
}
exists, _ := config.RedisConn.Exists(config.ERROR_COUNT + userName).Result()
if exists == 0 { // 键不存在,第一次登录
loginMap := map[string]any{
"errorNum": 1,
"loginTime": timeStamp,
}
config.RedisConn.HMSet(config.ERROR_COUNT+userName, loginMap)
} else {
i, _ := config.RedisConn.HGet(config.ERROR_COUNT+userName, "errorNum").Int64()
if flag && i == errorCount {
config.RedisConn.HSet(config.ERROR_COUNT+userName, "errorNum", 1)
} else {
config.RedisConn.HIncrBy(config.ERROR_COUNT+userName, "errorNum", 1)
}
config.RedisConn.HSet(config.ERROR_COUNT+userName, "loginTime", timeStamp)
}
i, _ := config.RedisConn.HGet(config.ERROR_COUNT+userName, "errorNum").Int64()
if i == errorCount {
return nil, config.ErrorCode(1004, fmt.Sprintf("您的密码已错误%d次,现已被锁定,请%s分钟后再尝试", errorCount, str))
}
return nil, config.ErrorCode(1000, fmt.Sprintf("密码错误,总登录次数%d次,剩余次数: %d", errorCount, (errorCount-i)))
}
return &user, config.Success(nil)
}
其实这个文件的这几个登录、token相关的方法,本来是应该放在 sys_user.go 文件里的,然后因为 sys_user.go 代码有点多,我想了下最后还是决定单独建个 sys_login.go 文件分开来。
package sys
import (
"encoding/json"
"fmt"
"go-web2/app/common/config"
"go-web2/app/common/util"
"strconv"
"strings"
"time"
)
// ======================================= 登录相关 =======================================
// 用户登录:user 用户信息 loginType 登录类型 expire 有效期
func (user *SysUser) Login(loginType string, expire time.Duration) string {
str := util.MD5(user.UserName) // 用户名md5加密
// 删除所有以当前用户名开头的key
keys, _, _ := config.RedisConn.Scan(uint64(0), config.CachePrefix+str+"*", 1000).Result()
for i := range keys {
config.RedisConn.Del(keys[i])
}
// 设置登录类型前缀
if len(loginType) > 0 {
str = loginType + "_" + str
}
token := str + util.GenerateRandomToken(32) // 生成token
user.Token = token
userJson, _ := json.Marshal(user)
loginMap := map[string]any{
"token": token,
"createTime": time.Now().Unix(),
"user": string(userJson),
}
// 将用户信息map设置到redis中
config.RedisConn.HMSet(config.CachePrefix+token, loginMap)
// 设置有效期
if expire > 0 {
config.RedisConn.Expire(config.CachePrefix+token, expire)
}
// 判断当前用户部门是否存在数据权限设置
exists, _ := config.RedisConn.Exists(config.DATA_SCOPE + user.DeptId).Result()
if exists == 0 {
SetDataScope(user.DeptId) // 如果没有,则需要设置
}
return token
}
// 用户退出
func Logout(token string) {
config.RedisConn.Del(config.CachePrefix + token)
}
// 获取当前用户的剩余有效时长,返回秒数,返回 -2 时,key已过期
func GetTimeOut(token string) int {
// 使用 TTL 命令获取 key 的剩余有效时长,如果 key 不存在或已过期,TTL 将返回 -2
ttl, err := config.RedisConn.TTL(config.CachePrefix + token).Result()
if err != nil {
return -2
}
return int(ttl.Seconds())
}
// 获取当前用户
func GetLoginUser(token string) *SysUser {
val, _ := config.RedisConn.HGet(config.CachePrefix+token, "user").Result()
user := SysUser{}
json.Unmarshal([]byte(val), &user)
// 判断当前用户部门是否存在数据权限设置
exists, _ := config.RedisConn.Exists(config.DATA_SCOPE + user.DeptId).Result()
if exists == 0 {
SetDataScope(user.DeptId) // 如果没有,则需要设置
}
dataScope := config.RedisConn.HGetAll(config.DATA_SCOPE + user.DeptId).Val()
user.AncestorId = dataScope["ancestorId"]
user.AncestorName = dataScope["ancestorName"]
user.ChildId = dataScope["childId"]
user.ChildName = dataScope["childName"]
return &user
}
// 获取当前用户id
func GetLoginId(token string) *string {
user := GetLoginUser(token)
return &user.Id
}
// 获取当前用户token的创建时间
func GetCreateTime(token string) int64 {
val, _ := config.RedisConn.HGet(config.CachePrefix+token, "createTime").Result()
r, _ := strconv.ParseInt(val, 10, 64)
return r
}
// 刷新过期时间
func UpdateTimeOut(token string, expire time.Duration) {
if expire.Seconds() < 0 {
// -1 永不过期,Persist 将删除key的过期时间,使其永不过期
config.RedisConn.Persist(config.CachePrefix + token)
} else {
config.RedisConn.Expire(config.CachePrefix+token, expire)
}
}
// 更新用户信息
func (user *SysUserView) UpdateUser(token string) {
config.RedisConn.HSet(config.CachePrefix+token, "user", user)
}
// ======================================= 数据权限相关 =======================================
// 设置当前部门的数据范围
func SetDataScope(deptId string) {
if deptId != "" {
dept := SysDept{}
dept.Id = deptId
// 这里的数据权限条件存了部门id和名称,如果没有特殊要求的话,只用部门id也可以的。
// 但是因为我的项目的业务原因,需要用到部门名称来过滤数据(因为有的表的数据判断是哪个部门的数据,用的不是部门id而是部门名称)
childId, childName := GetDeptChild(deptId) // 当前部门及子部门id和名称
ancestorId, ancestorName := dept.GetAncestor() // 当前部门祖级id和名称
dataScope := map[string]any{
"ancestorId": ancestorId,
"ancestorName": ancestorName,
"childId": childId,
"childName": childName,
}
// 将数据范围信息map设置到redis中,并设置有效期为2小时
config.RedisConn.HMSet(config.DATA_SCOPE+dept.Id, dataScope)
config.RedisConn.Expire(config.DATA_SCOPE+dept.Id, time.Second*7200)
}
}
// 获取数据范围条件
func GetDataScope(token string, ignoreAdmin, isId bool) string {
if token == "" {
return ""
}
loginUser := GetLoginUser(token)
// ignoreAdmin=true 表示不管是不是管理员,都要过滤数据; ignoreAdmin=false 表示只有非管理员角色才需要过滤数据
if ignoreAdmin || (!ignoreAdmin && loginUser.RoleKey != "CJGLY") {
if isId {
return loginUser.ChildId
} else {
return loginUser.ChildName
}
}
return ""
}
// 统一的数据过滤 fieldName 要查询的字段,ignoreAdmin 是否忽略超级管理员(true 忽略 false 不忽略),isId 表示是用id还是用name查询
// dataScope 数据范围(1 所有数据 2 所在部门及子部门数据 3 所在部门数据 4 仅本人数据 5 自定义数据)
func AppendQueryDataScope(token, fieldName, dataScope string, ignoreAdmin, isId bool) string {
str := GetDataScope(token, ignoreAdmin, isId)
sql := ""
if str != "" {
// 根据 当前用户的数据范围 拼接查询条件语句 scope 数据范围、过滤条件、fieldName 查询的字段名
if dataScope == "5" {
// 自定义数据范围(暂不需要)
// 5 和其他数字的范围取并集,用 or 连接,并且它们的外层不要忘了用括号括起来
} else if dataScope == "2" {
// 所在部门及子部门数据(用FIND_IN_SET查询)
sql = fmt.Sprintf("FIND_IN_SET(%s,'%s')", fieldName, str)
} else if dataScope == "3" {
// 所在部门数据(用等于查询)
sql = fmt.Sprintf("%s = '%s'", fieldName, str)
} else if dataScope == "4" {
// 仅本人数据直接用等于查询
sql = fmt.Sprintf("%s = '%s'", fieldName, str)
}
}
return sql
}
// 校验是否有数据权限(新增、修改、删除数据时):verified 需要校验的值
func CheckDataScope(token, verified string, ignoreAdmin, isId bool) bool {
scope := GetDataScope(token, ignoreAdmin, isId)
// 当scope不是空值时,判断需要校验的值是否包含在scope中,不包含说明没有权限
if scope != "" && !util.IsContain(strings.Split(scope, ","), verified) {
return false
}
return true
}
数据权限这一块,仅作一个参考,我感觉我现在这个实现方式,也还是有点点问题,但是又想不到其他特别好的解决办法,就暂时先用这种方式吧。。。
我现在的实现方式:
上面的实现方式还存在一个问题:新增部门时要更新 parentId 的数据权限,但是修改部门要不要更新呢?修改的时候是只更新当前 id 的缓存,还是 parentId 的缓存也要一起更新?(暂时不知道要怎么做,就先这样吧)
ok,以上就是本篇文章的全部内容了,等我更完这个项目的全部文章,我会放出完整代码的地址,欢迎大家多多点赞支持下,最后可以关注我不迷路~