├── config ├── .gitignore ├── autoload │ ├── redis.go │ ├── jwt.go │ ├── logger.go │ ├── mysql.go │ └── app.go ├── config.yaml.example └── config.go ├── cmd ├── .gitignore ├── version │ └── version.go ├── command │ └── command.go ├── service │ └── service.go ├── root.go └── cron │ └── cron.go ├── internal ├── interfaces │ ├── model │ │ └── model.go │ └── common.go ├── resources │ ├── common.go │ ├── role.go │ ├── dept.go │ ├── api.go │ ├── admin_user.go │ ├── request_log.go │ └── login_log.go ├── pkg │ ├── errors │ │ ├── code_test.go │ │ ├── zh-cn.go │ │ ├── en-us.go │ │ ├── code.go │ │ └── error.go │ ├── utils │ │ ├── token │ │ │ ├── jwt_test.go │ │ │ └── jwt.go │ │ ├── format_time.go │ │ ├── utils_test.go │ │ ├── desensitize.go │ │ └── utils.go │ ├── func_make │ │ ├── func_make_test.go │ │ └── func_make.go │ ├── request │ │ └── request.go │ ├── logger │ │ └── logger.go │ └── response │ │ └── response.go ├── global │ ├── auth.go │ └── common.go ├── model │ ├── modelDict │ │ └── base.go │ ├── a_menu_api_map.go │ ├── a_role_menu_map.go │ ├── a_admin_user_dept_map.go │ ├── a_admin_user_role_map.go │ ├── a_dept_role_map.go │ ├── a_file_upload.go │ ├── dept.go │ ├── a_request_logs.go │ ├── a_role.go │ ├── a_admin_users.go │ ├── a_menu.go │ └── a_admin_login_logs.go ├── console │ ├── demo │ │ └── demo.go │ ├── system_init │ │ └── system_init.go │ └── init │ │ └── init.go ├── validator │ └── form │ │ ├── auth.go │ │ ├── login_log.go │ │ ├── dept.go │ │ ├── common.go │ │ ├── role.go │ │ ├── request_log.go │ │ ├── permission.go │ │ ├── menu.go │ │ └── admin_user.go ├── service │ ├── sys_base.go │ ├── permission │ │ ├── request_log.go │ │ ├── api.go │ │ └── login_log.go │ └── system │ │ └── init.go ├── controller │ ├── sys_demo.go │ ├── admin_v1 │ │ ├── auth_request_log.go │ │ ├── auth_login_log.go │ │ ├── auth_api.go │ │ ├── sys_common.go │ │ ├── auth_role.go │ │ ├── auth_menu.go │ │ ├── auth.go │ │ └── auth_dept.go │ └── sys_base.go ├── middleware │ ├── request_cost.go │ ├── parse_token.go │ ├── admin_auth.go │ └── recovery.go └── routers │ └── router.go ├── main.go ├── rbac_model.conf ├── data ├── migrations │ ├── 000002_init_data.down.sql │ └── 000001_init_table.down.sql ├── data.go ├── redis.go └── mysql.go ├── .gitignore ├── pkg ├── utils │ ├── crypto │ │ ├── types.go │ │ ├── crypto.go │ │ ├── crypto_aes.go │ │ └── README.md │ ├── helpers_test.go │ ├── http_test.go │ ├── utils_test.go │ ├── helpers.go │ ├── http.go │ └── utils.go └── convert │ └── convert.go ├── policy.csv ├── .github └── workflows │ ├── go.yml │ └── codeql.yml ├── .air.toml ├── LICENSE ├── tests ├── admin_test │ ├── permission_test.go │ ├── admin_test.go │ └── admin_user_test.go └── test.go └── .golangci.yml /config/.gitignore: -------------------------------------------------------------------------------- 1 | *.yaml 2 | *.ini -------------------------------------------------------------------------------- /cmd/.gitignore: -------------------------------------------------------------------------------- 1 | !.gitignore 2 | go-layout -------------------------------------------------------------------------------- /internal/interfaces/model/model.go: -------------------------------------------------------------------------------- 1 | package model 2 | -------------------------------------------------------------------------------- /internal/interfaces/common.go: -------------------------------------------------------------------------------- 1 | package interfaces 2 | 3 | type GetInterface interface { 4 | GetId() uint 5 | } 6 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/wannanbigpig/gin-layout/cmd" 5 | ) 6 | 7 | func main() { 8 | cmd.Execute() 9 | } 10 | -------------------------------------------------------------------------------- /internal/resources/common.go: -------------------------------------------------------------------------------- 1 | package resources 2 | 3 | type department struct { 4 | ID uint `json:"id"` 5 | Name string `json:"name"` 6 | Pid uint `json:"pid"` 7 | } 8 | -------------------------------------------------------------------------------- /rbac_model.conf: -------------------------------------------------------------------------------- 1 | [request_definition] 2 | r = sub, obj, act 3 | 4 | [policy_definition] 5 | p = sub, obj, act 6 | 7 | [role_definition] 8 | g = _, _ 9 | 10 | [policy_effect] 11 | e = some(where (p.eft == allow)) 12 | 13 | [matchers] 14 | m = (g(r.sub, p.sub) && keyMatch3(r.obj, p.obj) && regexMatch(r.act, p.act)) -------------------------------------------------------------------------------- /internal/pkg/errors/code_test.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestText(t *testing.T) { 8 | var errorText = NewErrorText("zh_CN") 9 | if "OK" != errorText.Text(0) { 10 | t.Error("text 返回 msg 不是预期的") 11 | } 12 | 13 | if "unknown error" != errorText.Text(1202389) { 14 | t.Error("text 返回 msg 不是预期的") 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /data/migrations/000002_init_data.down.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | -- 回滚系统数据 4 | 5 | -- 删除casbin_rule表数据 6 | DELETE FROM `casbin_rule` WHERE `id` BETWEEN 1 AND 40; 7 | 8 | -- 删除菜单数据 9 | DELETE FROM `a_menu` WHERE `id` BETWEEN 1 AND 41; 10 | 11 | -- 删除权限分组数据 12 | DELETE FROM `a_api_group` WHERE `id` BETWEEN 1 AND 9; 13 | 14 | -- 删除管理员用户数据 15 | DELETE FROM `a_admin_user` WHERE `id` = 1; 16 | 17 | COMMIT; -------------------------------------------------------------------------------- /internal/global/auth.go: -------------------------------------------------------------------------------- 1 | package global 2 | 3 | const ( 4 | SuperAdminId uint = 1 5 | Issuer = "go-layout" 6 | PcAdminSubject = "pc-admin-token" 7 | CasbinAdminUserPrefix = "adminUser" 8 | CasbinRolePrefix = "role" 9 | CasbinMenuPrefix = "menu" 10 | CasbinDeptPrefix = "dept" 11 | CasbinSeparator = ":" 12 | ) 13 | -------------------------------------------------------------------------------- /internal/pkg/errors/zh-cn.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | var zhCNText = map[int]string{ 4 | SUCCESS: "OK", 5 | FAILURE: "FAIL", 6 | NotFound: "资源不存在", 7 | InvalidParameter: "参数错误", 8 | ServerErr: "服务器内部错误", 9 | TooManyRequests: "请求过多", 10 | UserDoesNotExist: "用户不存在", 11 | UserDisable: "用户已被禁用", 12 | AuthorizationErr: "暂无权限", 13 | NotLogin: "请先登录", 14 | CaptchaErr: "验证码错误", 15 | } 16 | -------------------------------------------------------------------------------- /cmd/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/wannanbigpig/gin-layout/internal/global" 9 | ) 10 | 11 | var ( 12 | // Cmd 版本信息命令 13 | Cmd = &cobra.Command{ 14 | Use: "version", 15 | Short: "Get version info", 16 | Example: "go-layout version", 17 | Run: func(cmd *cobra.Command, args []string) { 18 | fmt.Println(global.Version) 19 | }, 20 | } 21 | ) 22 | -------------------------------------------------------------------------------- /config/autoload/redis.go: -------------------------------------------------------------------------------- 1 | package autoload 2 | 3 | type RedisConfig struct { 4 | Enable bool `mapstructure:"enable"` 5 | Host string `mapstructure:"host"` 6 | Port string `mapstructure:"port"` 7 | Password string `mapstructure:"password"` 8 | Database int `mapstructure:"database"` 9 | } 10 | 11 | var Redis = RedisConfig{ 12 | Enable: false, 13 | Host: "127.0.0.1", 14 | Password: "", 15 | Port: "6379", 16 | Database: 0, 17 | } 18 | -------------------------------------------------------------------------------- /internal/model/modelDict/base.go: -------------------------------------------------------------------------------- 1 | package modelDict 2 | 3 | import "github.com/wannanbigpig/gin-layout/internal/global" 4 | 5 | type Dict map[uint8]string 6 | 7 | func (d Dict) Map(k uint8) string { 8 | // 先判断 d 是否为 nil,防止 nil 指针解引用 9 | if d == nil { 10 | return "-" 11 | } 12 | 13 | if v, ok := d[k]; ok { 14 | return v 15 | } 16 | 17 | return "-" 18 | } 19 | 20 | var IsMap Dict = map[uint8]string{ 21 | global.No: "否", 22 | global.Yes: "是", 23 | } 24 | -------------------------------------------------------------------------------- /config/autoload/jwt.go: -------------------------------------------------------------------------------- 1 | package autoload 2 | 3 | import "time" 4 | 5 | type JwtConfig struct { 6 | TTL time.Duration `mapstructure:"ttl"` 7 | // 默认0,不主动刷新 Token 8 | // 刷新时间大于0,则判断剩余过期时间小于刷新时间时刷新 Token 并在 Response Header 中返回,推荐刷新时间设置为 TTL/2 9 | RefreshTTL time.Duration `mapstructure:"refresh_ttl"` 10 | SecretKey string `mapstructure:"secret_key"` 11 | } 12 | 13 | var Jwt = JwtConfig{ 14 | TTL: 7200, 15 | RefreshTTL: 0, 16 | SecretKey: "", 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | .idea 18 | *.DS_Store 19 | storage 20 | *.yaml 21 | *.ini 22 | gin-layout/ 23 | logs/ 24 | .vscode 25 | migrate 26 | tmp 27 | build/ 28 | go-layout -------------------------------------------------------------------------------- /pkg/utils/crypto/types.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | // Algorithm 加密算法类型 4 | type Algorithm string 5 | 6 | const ( 7 | // AlgorithmAES256GCM AES-256-GCM 加密算法(默认) 8 | AlgorithmAES256GCM Algorithm = "aes-256-gcm" 9 | ) 10 | 11 | // String 返回算法名称 12 | func (a Algorithm) String() string { 13 | return string(a) 14 | } 15 | 16 | // IsValid 检查算法是否有效 17 | func (a Algorithm) IsValid() bool { 18 | switch a { 19 | case AlgorithmAES256GCM: 20 | return true 21 | default: 22 | return false 23 | } 24 | } 25 | 26 | -------------------------------------------------------------------------------- /internal/global/common.go: -------------------------------------------------------------------------------- 1 | package global 2 | 3 | const ( 4 | // Version is the current gin-layout version. 5 | Version = "0.9.0" 6 | // PerPage is the default per page size 7 | PerPage = 10 8 | // Yes is the value of yes 9 | Yes uint8 = 1 10 | // No is the value of no 11 | No uint8 = 0 12 | // ChinaCountryCode is the value of China country code 13 | ChinaCountryCode = "86" 14 | // SUCCESS is the value of success 15 | SUCCESS = "SUCCESS" 16 | // ERROR is the value of error 17 | ERROR = "ERROR" 18 | ) 19 | -------------------------------------------------------------------------------- /internal/model/a_menu_api_map.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // MenuApiMap 权限路由表 4 | type MenuApiMap struct { 5 | BaseModel 6 | MenuId uint `json:"menu_id"` // 菜单ID 7 | ApiId uint `json:"api_id"` // API ID 8 | } 9 | 10 | func NewMenuApiMap() *MenuApiMap { 11 | return &MenuApiMap{} 12 | } 13 | 14 | // TableName 获取表名 15 | func (m *MenuApiMap) TableName() string { 16 | return "a_menu_api_map" 17 | } 18 | 19 | func (m *MenuApiMap) BatchCreate(menuApi []*MenuApiMap) error { 20 | return m.DB().Create(&menuApi).Error 21 | } 22 | -------------------------------------------------------------------------------- /internal/console/demo/demo.go: -------------------------------------------------------------------------------- 1 | package demo 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var ( 10 | Cmd = &cobra.Command{ 11 | Use: "demo", 12 | Short: "这是一个demo", 13 | Example: "go-layout command demo", 14 | Run: func(cmd *cobra.Command, args []string) { 15 | demo() 16 | }, 17 | } 18 | test string 19 | ) 20 | 21 | func init() { 22 | Cmd.Flags().StringVarP(&test, "test", "t", "test", "测试接收参数") 23 | } 24 | 25 | func demo() { 26 | fmt.Println("hello console!", test) 27 | } 28 | -------------------------------------------------------------------------------- /internal/model/a_role_menu_map.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // RoleMenuMap 角色菜单关联表 4 | type RoleMenuMap struct { 5 | BaseModel 6 | MenuId uint `json:"menu_id"` // 菜单ID 7 | RoleId uint `json:"role_id"` // RoleID 8 | } 9 | 10 | func NewRoleMenuMap() *RoleMenuMap { 11 | return &RoleMenuMap{} 12 | } 13 | 14 | // TableName 获取表名 15 | func (m *RoleMenuMap) TableName() string { 16 | return "a_role_menu_map" 17 | } 18 | 19 | func (m *RoleMenuMap) BatchCreate(roleMenu []*RoleMenuMap) error { 20 | return m.DB().Create(&roleMenu).Error 21 | } 22 | -------------------------------------------------------------------------------- /pkg/utils/helpers_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestMaskSensitiveInfo(t *testing.T) { 8 | mobile := "13200000000" 9 | m := MaskSensitiveInfo(mobile, 3, 4) 10 | if m != "132****0000" { 11 | t.Error("手机号脱敏失败") 12 | } 13 | m1 := MaskSensitiveInfo(mobile, -1, 15) 14 | if m1 != "***********" { 15 | t.Error("手机号脱敏失败") 16 | } 17 | idNumber := "110101199001010010" 18 | id := MaskSensitiveInfo(idNumber, 6, 8) 19 | if id != "110101********0010" { 20 | t.Error("身份证脱敏失败") 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /internal/pkg/errors/en-us.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | var enUSText = map[int]string{ 4 | SUCCESS: "OK", 5 | FAILURE: "FAIL", 6 | NotFound: "resources not found", 7 | ServerErr: "Internal server error", 8 | TooManyRequests: "Too many requests", 9 | InvalidParameter: "Parameter error", 10 | UserDoesNotExist: "user does not exist", 11 | UserDisable: "User is disabled", 12 | AuthorizationErr: "You have no permission", 13 | NotLogin: "Please login first", 14 | CaptchaErr: "Captcha error", 15 | } 16 | -------------------------------------------------------------------------------- /internal/validator/form/auth.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | type LoginAuth struct { 4 | UserName string `form:"username" json:"username" label:"用户名" binding:"required,min=3,max=16"` // 验证规则:必填,最小长度为3 5 | PassWord string `form:"password" json:"password" label:"密码" binding:"required,min=6,max=18"` // 验证规则:必填,最小长度为6 6 | Captcha string `form:"captcha" json:"captcha" label:"验证码" binding:"required"` 7 | CaptchaID string `form:"captcha_id" json:"captcha_id" binding:"required"` 8 | } 9 | 10 | func NewLoginForm() *LoginAuth { 11 | return &LoginAuth{} 12 | } 13 | -------------------------------------------------------------------------------- /data/data.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "sync" 5 | 6 | c "github.com/wannanbigpig/gin-layout/config" 7 | ) 8 | 9 | var once sync.Once 10 | 11 | func InitData() { 12 | once.Do(func() { 13 | if c.Config.Mysql.Enable { 14 | // 初始化 mysql 15 | err := initMysql() 16 | if err != nil { 17 | panic("mysql init error: " + err.Error()) 18 | } 19 | } 20 | 21 | if c.Config.Redis.Enable { 22 | // 初始化 redis 23 | err := initRedis() 24 | if err != nil { 25 | panic("redis init error: " + err.Error()) 26 | } 27 | } 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /internal/service/sys_base.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import "github.com/gin-gonic/gin" 4 | 5 | type Base struct { 6 | ctx *gin.Context 7 | adminUserId uint 8 | } 9 | 10 | // SetAdminUserId 设置管理员ID 11 | func (b *Base) SetAdminUserId(userId uint) { 12 | b.adminUserId = userId 13 | } 14 | 15 | // GetAdminUserId 获取管理员ID 16 | func (b *Base) GetAdminUserId() uint { 17 | return b.adminUserId 18 | } 19 | 20 | // SetCtx 设置上下文 21 | func (b *Base) SetCtx(c *gin.Context) { 22 | b.ctx = c 23 | } 24 | 25 | // GetCtx 获取上下文 26 | func (b *Base) GetCtx() *gin.Context { 27 | return b.ctx 28 | } 29 | -------------------------------------------------------------------------------- /internal/model/a_admin_user_dept_map.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // AdminUsesDeptMap 管理员用户部门关系表 4 | type AdminUsesDeptMap struct { 5 | BaseModel 6 | Uid uint `json:"uid"` // admin_user用户ID 7 | DeptId uint `json:"dept_id"` // RoleID 8 | } 9 | 10 | func NewAdminUsesDeptMap() *AdminUsesDeptMap { 11 | return &AdminUsesDeptMap{} 12 | } 13 | 14 | // TableName 获取表名 15 | func (m *AdminUsesDeptMap) TableName() string { 16 | return "a_admin_user_department_map" 17 | } 18 | 19 | func (m *AdminUsesDeptMap) BatchCreate(dept []*AdminUsesDeptMap) error { 20 | return m.DB().Create(&dept).Error 21 | } 22 | -------------------------------------------------------------------------------- /internal/model/a_admin_user_role_map.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // AdminUserRoleMap 管理员用户角色关系表 4 | type AdminUserRoleMap struct { 5 | BaseModel 6 | Uid uint `json:"uid"` // admin_user用户ID 7 | RoleId uint `json:"role_id"` // RoleID 8 | } 9 | 10 | func NewAdminUserRoleMap() *AdminUserRoleMap { 11 | return &AdminUserRoleMap{} 12 | } 13 | 14 | // TableName 获取表名 15 | func (m *AdminUserRoleMap) TableName() string { 16 | return "a_admin_user_role_map" 17 | } 18 | 19 | func (m *AdminUserRoleMap) BatchCreate(roleMenu []*AdminUserRoleMap) error { 20 | return m.DB().Create(&roleMenu).Error 21 | } 22 | -------------------------------------------------------------------------------- /internal/controller/sys_demo.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | // DemoController Demo控制器 10 | type DemoController struct { 11 | Api 12 | } 13 | 14 | // NewDemoController 创建Demo控制器实例 15 | func NewDemoController() *DemoController { 16 | return &DemoController{} 17 | } 18 | 19 | // HelloWorld Demo示例接口 20 | func (api DemoController) HelloWorld(c *gin.Context) { 21 | name, ok := c.GetQuery("name") 22 | if !ok { 23 | name = "gin-layout" 24 | } 25 | 26 | id := c.Param("id") 27 | result := fmt.Sprintf("hello %s %s", name, id) 28 | api.Success(c, result) 29 | } 30 | -------------------------------------------------------------------------------- /policy.csv: -------------------------------------------------------------------------------- 1 | # 用户-角色绑定 2 | g, user:1, dept:2 3 | 4 | # 角色继承 5 | g, role:1, role:2 6 | g, role:2, role:3 7 | 8 | # 部门-》角色绑定 9 | g, dept:1, role:1 10 | g, dept:2, role:2 11 | g, dept:2, role:4 12 | 13 | # 角色-》菜单绑定 14 | g, role:1, menu:1 15 | g, role:3, menu:3 16 | g, role:2, menu:2 17 | 18 | # 菜单-》权限绑定 19 | p, menu:1, /menu/*, GET 20 | p, menu:1, /api/hr/*, POST 21 | p, menu:2, /api/v1/menu/update, POST 22 | p, menu:2, /api/data, GET 23 | p, menu:3, /api/v1/menu/list, GET 24 | p, menu:3, /api/v1/menu/add, POST 25 | 26 | # 测试用例 27 | # user:1, /api/data, GET 28 | # user:1, /api/v1/menu/update, POST 29 | # user:1, /api/v1/menu/add1, POST 30 | -------------------------------------------------------------------------------- /internal/model/a_dept_role_map.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // DeptRoleMap 部门角色关联表 4 | type DeptRoleMap struct { 5 | BaseModel 6 | DeptId uint `json:"dept_id"` // 菜单ID 7 | RoleId uint `json:"role_id"` // RoleID 8 | } 9 | 10 | func NewDeptRoleMap() *DeptRoleMap { 11 | return &DeptRoleMap{} 12 | } 13 | 14 | // TableName 获取表名 15 | func (m *DeptRoleMap) TableName() string { 16 | return "a_department_role_map" 17 | } 18 | 19 | func (m *DeptRoleMap) DeleteByDeptId(deptId uint) error { 20 | return m.DB().Where("dept_id = ?", deptId).Delete(&DeptRoleMap{}).Error 21 | } 22 | 23 | func (m *DeptRoleMap) BatchCreate(deptRole []*DeptRoleMap) error { 24 | return m.DB().Create(&deptRole).Error 25 | } 26 | -------------------------------------------------------------------------------- /pkg/utils/http_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestGetRequest(t *testing.T) { 9 | http := HttpRequest{} 10 | _, err := http.Request("GET", "https://www.baidu.com", nil).ParseBytes() 11 | if err != nil { 12 | t.Error("请求失败") 13 | } 14 | 15 | } 16 | 17 | func ExampleRequest() { 18 | http := HttpRequest{} 19 | // You can define map directly or define a structure corresponding to the return value to receive data 20 | var resp map[string]any 21 | err := http.Request("GET", "http://127.0.0.1:9999/api/v1/hello-world?name=world", nil).ParseJson(&resp) 22 | if err != nil { 23 | panic(err) 24 | } 25 | fmt.Printf("%#v", resp) 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ master, x_l_admin ] 6 | pull_request: 7 | branches: [ master, x_l_admin ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | go-version: [ '1.24.x', '1.25.x' ] 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Setup Go ${{ matrix.go-version }} 20 | uses: actions/setup-go@v4 21 | with: 22 | go-version: ${{ matrix.go-version }} 23 | 24 | - name: Display Go version 25 | run: go version 26 | 27 | - name: Build 28 | run: go build -v ./... 29 | 30 | - name: Test 31 | run: go test $(go list ./... | grep -v /tests/) -------------------------------------------------------------------------------- /internal/validator/form/login_log.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | // LoginLogList 登录日志列表查询表单 4 | type AdminLoginLogList struct { 5 | Paginate 6 | Username string `form:"username" json:"username" binding:"omitempty"` // 登录账号 7 | LoginStatus *int8 `form:"login_status" json:"login_status" binding:"omitempty,oneof=0 1"` // 登录状态:1=成功, 0=失败 8 | IP string `form:"ip" json:"ip" binding:"omitempty"` // 登录IP 9 | StartTime string `form:"start_time" json:"start_time" binding:"omitempty"` // 开始时间 10 | EndTime string `form:"end_time" json:"end_time" binding:"omitempty"` // 结束时间 11 | } 12 | 13 | // NewAdminLoginLogListQuery 创建登录日志列表查询表单 14 | func NewAdminLoginLogListQuery() *AdminLoginLogList { 15 | return &AdminLoginLogList{} 16 | } 17 | -------------------------------------------------------------------------------- /internal/validator/form/dept.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | type EditDept struct { 4 | Id uint `form:"id" json:"id" binding:"omitempty"` 5 | Name string `form:"name" json:"name" label:"部门名称" binding:"required"` 6 | Pid uint `form:"pid" json:"pid" label:"上级部门" binding:"omitempty"` 7 | Description string `form:"description" json:"description" label:"描述" binding:"omitempty"` 8 | Sort uint `form:"sort" json:"sort" label:"排序" binding:"omitempty"` 9 | } 10 | 11 | func NewEditDeptForm() *EditDept { 12 | return &EditDept{} 13 | } 14 | 15 | type ListDept struct { 16 | Paginate 17 | Name string `form:"name" json:"name" label:"部门名称" binding:"omitempty"` // 关键字 18 | Pid *uint `form:"pid" json:"pid" label:"上级部门" binding:"omitempty"` 19 | } 20 | 21 | func NewDeptListQuery() *ListDept { 22 | return &ListDept{} 23 | } 24 | -------------------------------------------------------------------------------- /internal/validator/form/common.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | type Paginate struct { 4 | Page int `form:"page" json:"page" binding:"omitempty,gt=0"` // 必填,页面值>=1 5 | PerPage int `form:"per_page" json:"per_page" binding:"omitempty,gt=0"` // 必填,每页条数值>=1 6 | } 7 | 8 | // NewPaginate 创建一个新的分页查询 9 | func NewPaginate() *Paginate { 10 | return &Paginate{} 11 | } 12 | 13 | type ID struct { 14 | ID uint `form:"id" json:"id" binding:"required"` 15 | } 16 | 17 | // NewIdForm ID表单 18 | func NewIdForm() *ID { 19 | return &ID{} 20 | } 21 | 22 | type BindRole struct { 23 | Id uint `form:"id" json:"id" label:"用户ID" binding:"required"` // 验证规则:必填 24 | RoleIds []uint `form:"role_ids" json:"role_ids" label:"角色ID" binding:"required"` // 验证规则:必填 25 | } 26 | 27 | // NewBindRole 绑定角色 28 | func NewBindRole() *BindRole { 29 | return &BindRole{} 30 | } 31 | -------------------------------------------------------------------------------- /internal/pkg/errors/code.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | const ( 4 | SUCCESS = 0 5 | FAILURE = 1 6 | AuthorizationErr = 403 7 | NotFound = 404 8 | CaptchaErr = 400 9 | NotLogin = 401 10 | ServerErr = 500 11 | InvalidParameter = 10000 12 | UserDoesNotExist = 10001 13 | UserDisable = 10002 14 | TooManyRequests = 10102 15 | ) 16 | 17 | type ErrorText struct { 18 | Language string 19 | } 20 | 21 | func NewErrorText(language string) *ErrorText { 22 | return &ErrorText{ 23 | Language: language, 24 | } 25 | } 26 | 27 | func (et *ErrorText) Text(code int) (str string) { 28 | var ok bool 29 | switch et.Language { 30 | case "zh_CN": 31 | str, ok = zhCNText[code] 32 | case "en": 33 | str, ok = enUSText[code] 34 | default: 35 | str, ok = zhCNText[code] 36 | } 37 | if !ok { 38 | return "unknown error" 39 | } 40 | return 41 | } 42 | -------------------------------------------------------------------------------- /internal/pkg/utils/token/jwt_test.go: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/golang-jwt/jwt/v5" 7 | ) 8 | 9 | func TestGenerate(t *testing.T) { 10 | claims := jwt.MapClaims{ 11 | "Id": 1, 12 | } 13 | _, err := Generate(claims) 14 | if err != nil { 15 | t.Error("生成Token失败") 16 | } 17 | } 18 | 19 | func TestParse(t *testing.T) { 20 | tokenString := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJJZCI6MX0.JGVOAsonk7CoOaTS-b6dW86LLEOt8Z6kHhsFxIvqaCE" 21 | claims := jwt.MapClaims{} 22 | err := Parse(tokenString, claims) 23 | if err != nil { 24 | t.Error("解析Token失败") 25 | } 26 | } 27 | 28 | func TestGetAccessToken(t *testing.T) { 29 | authorization := "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJJZCI6MX0.JGVOAsonk7CoOaTS-b6dW86LLEOt8Z6kHhsFxIvqaCE" 30 | 31 | _, err := GetAccessToken(authorization) 32 | if err != nil { 33 | t.Error("获取Token失败") 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /internal/model/a_file_upload.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type UploadFiles struct { 4 | BaseModel 5 | UID uint `json:"uid"` // 用户ID 6 | OriginName string `json:"origin_name"` // 原始文件名 7 | Name string `json:"name"` // 存储的文件名(UUID+扩展名) 8 | Path string `json:"path"` // 文件相对路径(相对于storage/public或storage/private) 9 | Size uint `json:"size"` // 文件大小(字节) 10 | Ext string `json:"ext"` // 文件扩展名 11 | Hash string `json:"hash"` // 文件SHA256哈希值(用于去重) 12 | UUID string `json:"uuid"` // 文件UUID(用于URL访问,32位十六进制字符串,不带连字符) 13 | MimeType string `json:"mime_type"` // MIME类型(如:image/jpeg, application/pdf) 14 | IsPublic uint8 `json:"is_public"` // 是否公开访问:0否 1是 15 | } 16 | 17 | func NewUploadFiles() *UploadFiles { 18 | return &UploadFiles{} 19 | } 20 | 21 | // TableName 获取表名 22 | func (m *UploadFiles) TableName() string { 23 | return "a_upload_files" 24 | } 25 | -------------------------------------------------------------------------------- /pkg/utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestGetRunPath(t *testing.T) { 8 | path := GetRunPath() 9 | if path == "" { 10 | t.Error("获取运行路径失败") 11 | } 12 | } 13 | 14 | func TestGetCurrentPath(t *testing.T) { 15 | _, err := GetCurrentPath() 16 | if err != nil { 17 | t.Error("获取运行路径失败") 18 | } 19 | } 20 | 21 | func TestGetCurrentAbPathByExecutable(t *testing.T) { 22 | _, err := GetCurrentAbPathByExecutable() 23 | if err != nil { 24 | t.Error("获取路径失败") 25 | } 26 | } 27 | 28 | func TestGetCurrentFileDirectory(t *testing.T) { 29 | path, ok := GetFileDirectoryToCaller() 30 | if !ok { 31 | t.Error("获取路径失败", path) 32 | } 33 | 34 | path, ok = GetFileDirectoryToCaller(1) 35 | if !ok { 36 | t.Error("获取路径失败", path) 37 | } 38 | } 39 | 40 | func TestIf(t *testing.T) { 41 | if 3 != If(false, 1, 3) { 42 | t.Error("模拟三元操作失败") 43 | } 44 | 45 | if 1 != If(true, 1, 3) { 46 | t.Error("模拟三元操作失败") 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /internal/pkg/func_make/func_make_test.go: -------------------------------------------------------------------------------- 1 | package func_make 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | var ( 8 | funcMap = map[string]interface{}{ 9 | "test": func(str string) string { 10 | return str 11 | }, 12 | } 13 | funcMake = New() 14 | ) 15 | 16 | func TestRegisters(t *testing.T) { 17 | err := funcMake.Registers(funcMap) 18 | if err != nil { 19 | t.Errorf("绑定失败") 20 | } 21 | } 22 | 23 | func TestRegister(t *testing.T) { 24 | err := funcMake.Register("test1", func(str ...string) string { 25 | var res string 26 | for _, v := range str { 27 | res += v 28 | } 29 | return res 30 | }) 31 | if err != nil { 32 | t.Errorf("绑定失败") 33 | } 34 | } 35 | 36 | func TestCall(t *testing.T) { 37 | TestRegisters(t) 38 | TestRegister(t) 39 | if _, err := funcMake.Call("test", "1"); err != nil { 40 | t.Errorf("请求test方法失败:%s", err) 41 | } 42 | if _, err := funcMake.Call("test1", "2323", "ddd"); err != nil { 43 | t.Errorf("请求test1方法失败:%s", err) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /data/migrations/000001_init_table.down.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | -- 删除管理员表 4 | DROP TABLE IF EXISTS `a_admin_user`; 5 | -- 删除路由表 6 | DROP TABLE IF EXISTS `a_api`; 7 | -- 删除路由分组表 8 | DROP TABLE IF EXISTS `a_api_group`; 9 | -- 删除菜单表 10 | DROP TABLE IF EXISTS `a_menu`; 11 | -- 删除部门表 12 | DROP TABLE IF EXISTS `a_department`; 13 | -- 删除角色表 14 | DROP TABLE IF EXISTS `a_role`; 15 | -- 删除用户部门映射表 16 | DROP TABLE IF EXISTS `a_admin_user_department_map`; 17 | -- 删除部门角色映射表 18 | DROP TABLE IF EXISTS `a_department_role_map`; 19 | -- 删除用户菜单映射表 20 | DROP TABLE IF EXISTS `a_admin_user_menu_map`; 21 | -- 删除菜单权限映射表 22 | DROP TABLE IF EXISTS `a_menu_api_map`; 23 | -- 删除角色菜单映射表 24 | DROP TABLE IF EXISTS `a_role_menu_map`; 25 | -- 删除用户角色映射表 26 | DROP TABLE IF EXISTS `a_admin_user_role_map`; 27 | -- 删除请求日志表 28 | DROP TABLE IF EXISTS `a_request_logs`; 29 | -- 删除管理员登录日志表 30 | DROP TABLE IF EXISTS `a_admin_login_logs`; 31 | -- 删除casbin规则表 32 | DROP TABLE IF EXISTS `casbin_rule`; 33 | -- 删除文件上传表 34 | DROP TABLE IF EXISTS `a_upload_files`; 35 | 36 | COMMIT; -------------------------------------------------------------------------------- /internal/validator/form/role.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | type RoleList struct { 4 | Paginate 5 | Status *int8 `form:"status" json:"status" binding:"omitempty,oneof=0 1"` 6 | Name string `form:"name" json:"name" binding:"omitempty"` 7 | Pid *uint `form:"pid" json:"pid" binding:"omitempty"` 8 | } 9 | 10 | // NewRoleListQuery 初始化查询参数 11 | func NewRoleListQuery() *RoleList { 12 | return &RoleList{} 13 | } 14 | 15 | type EditRole struct { 16 | Id uint `form:"id" json:"id" binding:"omitempty"` 17 | Name string `form:"name" json:"name" binding:"required"` 18 | Description string `form:"description" json:"description" binding:"omitempty"` 19 | Status uint8 `form:"status" json:"status" binding:"omitempty,oneof=0 1"` 20 | Pid uint `form:"pid" json:"pid" binding:"omitempty"` 21 | Sort uint `form:"sort" json:"sort" binding:"omitempty"` 22 | MenuList []uint `form:"menu_ids" json:"menu_list" binding:"omitempty"` 23 | } 24 | 25 | func NewEditRoleForm() *EditRole { 26 | return &EditRole{} 27 | } 28 | -------------------------------------------------------------------------------- /cmd/command/command.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/wannanbigpig/gin-layout/data" 7 | "github.com/wannanbigpig/gin-layout/internal/console/demo" 8 | initconsole "github.com/wannanbigpig/gin-layout/internal/console/init" 9 | "github.com/wannanbigpig/gin-layout/internal/console/system_init" 10 | ) 11 | 12 | var ( 13 | Cmd = &cobra.Command{ 14 | Use: "command", 15 | Short: "The control head runs the command", 16 | Example: "go-layout command demo", 17 | PreRun: func(cmd *cobra.Command, args []string) { 18 | // 初始化数据库 19 | data.InitData() 20 | }, 21 | } 22 | ) 23 | 24 | func init() { 25 | registerSubCommands() 26 | } 27 | 28 | // registerSubCommands 注册子命令 29 | func registerSubCommands() { 30 | // 一次性运行脚本 31 | Cmd.AddCommand(demo.Cmd) 32 | Cmd.AddCommand(initconsole.ApiRouteCmd) // 初始化API路由表: go-layout command api-route 33 | Cmd.AddCommand(initconsole.MenuApiMapCmd) // 初始化菜单-API映射: go-layout command menu-api-map 34 | Cmd.AddCommand(system_init.InitSystemCmd) // 初始化系统: go-layout command init-system 35 | } 36 | -------------------------------------------------------------------------------- /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | args_bin = [] 7 | # 使用相对路径,或者不指定配置文件让程序自动查找 8 | bin = "./tmp/main service" 9 | cmd = "go build -o ./tmp/main" 10 | # 设置环境变量 11 | env = ["GO_ENV=development"] 12 | delay = 0 13 | exclude_dir = ["assets", "tmp", "vendor", "testdata", "logs", "tests"] 14 | exclude_file = [] 15 | exclude_regex = ["_test.go"] 16 | exclude_unchanged = false 17 | follow_symlink = false 18 | full_bin = "" 19 | include_dir = [] 20 | include_ext = ["go", "tpl", "tmpl", "html"] 21 | include_file = [] 22 | kill_delay = "0s" 23 | log = "./logs/build-errors.log" 24 | poll = false 25 | poll_interval = 0 26 | rerun = false 27 | rerun_delay = 500 28 | send_interrupt = false 29 | stop_on_error = false 30 | 31 | [color] 32 | app = "" 33 | build = "yellow" 34 | main = "magenta" 35 | runner = "green" 36 | watcher = "cyan" 37 | 38 | [log] 39 | main_only = false 40 | time = false 41 | 42 | [misc] 43 | clean_on_exit = false 44 | 45 | [screen] 46 | clear_on_rebuild = false 47 | keep_scroll = true 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 wannanbigpig 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /internal/middleware/request_cost.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/google/uuid" 8 | ) 9 | 10 | const ( 11 | // ContextKeyRequestStartTime 请求开始时间的上下文键 12 | ContextKeyRequestStartTime = "requestStartTime" 13 | // ContextKeyRequestID 请求ID的上下文键 14 | ContextKeyRequestID = "requestId" 15 | ) 16 | 17 | // RequestCostHandler 请求耗时和请求ID中间件 18 | // 功能: 19 | // 1. 记录请求开始时间,用于后续计算请求耗时 20 | // 2. 为每个请求生成唯一的请求ID,用于日志追踪 21 | func RequestCostHandler() gin.HandlerFunc { 22 | return func(c *gin.Context) { 23 | // 设置请求上下文信息 24 | setRequestContext(c) 25 | // 暂停1-5秒随机时间 26 | // randomSeconds := rand.Intn(5) + 1 // 生成1-5的随机数 27 | // time.Sleep(time.Duration(randomSeconds) * time.Second) 28 | c.Next() 29 | } 30 | } 31 | 32 | // setRequestContext 设置请求上下文信息 33 | func setRequestContext(c *gin.Context) { 34 | // 设置请求开始时间 35 | c.Set(ContextKeyRequestStartTime, time.Now()) 36 | 37 | // 生成并设置请求ID 38 | requestID := generateRequestID() 39 | c.Set(ContextKeyRequestID, requestID) 40 | } 41 | 42 | // generateRequestID 生成请求ID 43 | func generateRequestID() string { 44 | return uuid.New().String() 45 | } 46 | -------------------------------------------------------------------------------- /internal/pkg/request/request.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "io/ioutil" 7 | "net/http" 8 | 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | func GetQueryParams(c *gin.Context) map[string]any { 13 | query := c.Request.URL.Query() 14 | var queryMap = make(map[string]any, len(query)) 15 | for k := range query { 16 | queryMap[k] = c.Query(k) 17 | } 18 | return queryMap 19 | } 20 | 21 | func GetPostFormParams(c *gin.Context) (map[string]any, error) { 22 | if err := c.Request.ParseMultipartForm(32 << 20); err != nil { 23 | if !errors.Is(err, http.ErrNotMultipart) { 24 | return nil, err 25 | } 26 | } 27 | var postMap = make(map[string]any, len(c.Request.PostForm)) 28 | for k, v := range c.Request.PostForm { 29 | if len(v) > 1 { 30 | postMap[k] = v 31 | } else if len(v) == 1 { 32 | postMap[k] = v[0] 33 | } 34 | } 35 | 36 | return postMap, nil 37 | } 38 | 39 | func GetBody(c *gin.Context) []byte { 40 | // 读取body数据 41 | body, err := c.GetRawData() 42 | if err != nil { 43 | return nil 44 | } 45 | //把读过的字节流重新放到body 46 | c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(body)) 47 | return body 48 | } 49 | -------------------------------------------------------------------------------- /data/redis.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "time" 7 | 8 | "github.com/go-redis/redis/v8" 9 | 10 | c "github.com/wannanbigpig/gin-layout/config" 11 | ) 12 | 13 | var ( 14 | redisDb *redis.Client 15 | redisOnce sync.Once 16 | redisInitError error 17 | ) 18 | 19 | func initRedis() error { 20 | // 创建带超时的上下文 21 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 22 | defer cancel() 23 | 24 | redisDb = redis.NewClient(&redis.Options{ 25 | Addr: c.Config.Redis.Host + ":" + c.Config.Redis.Port, 26 | Password: c.Config.Redis.Password, 27 | DB: c.Config.Redis.Database, 28 | }) 29 | 30 | _, err := redisDb.Ping(ctx).Result() 31 | if err != nil { 32 | _ = redisDb.Close() // 忽略关闭错误,但确保资源释放 33 | redisDb = nil 34 | return err 35 | } 36 | return nil 37 | } 38 | 39 | // RedisClient 返回 Redis 客户端和初始化错误 40 | func RedisClient() *redis.Client { 41 | if redisDb == nil { 42 | redisOnce.Do(func() { 43 | redisInitError = initRedis() 44 | }) 45 | } 46 | return redisDb 47 | } 48 | 49 | // GetRedisInitError 返回 Redis 初始化错误,供外部检查 50 | func GetRedisInitError() error { 51 | return redisInitError 52 | } 53 | -------------------------------------------------------------------------------- /internal/pkg/func_make/func_make.go: -------------------------------------------------------------------------------- 1 | package func_make 2 | 3 | import ( 4 | "errors" 5 | "reflect" 6 | ) 7 | 8 | type FuncMap map[string]reflect.Value 9 | 10 | func New() FuncMap { 11 | return make(FuncMap, 2) 12 | } 13 | 14 | func (f FuncMap) Register(name string, fn any) error { 15 | v := reflect.ValueOf(fn) 16 | if v.Kind() != reflect.Func { 17 | return errors.New(name + " is not a function type.") 18 | } 19 | f[name] = v 20 | return nil 21 | } 22 | 23 | func (f FuncMap) Registers(funcMap map[string]any) (err error) { 24 | for k, v := range funcMap { 25 | err = f.Register(k, v) 26 | if err != nil { 27 | break 28 | } 29 | } 30 | return 31 | } 32 | 33 | func (f FuncMap) Call(name string, params ...any) (result []reflect.Value, err error) { 34 | if _, ok := f[name]; !ok { 35 | err = errors.New(name + " method does not exist.") 36 | return 37 | } 38 | in := make([]reflect.Value, len(params)) 39 | for k, param := range params { 40 | in[k] = reflect.ValueOf(param) 41 | } 42 | 43 | defer func() { 44 | if e := recover(); e != nil { 45 | err = errors.New("call " + name + " method fail. " + e.(string)) 46 | } 47 | }() 48 | 49 | result = f[name].Call(in) 50 | return 51 | } 52 | -------------------------------------------------------------------------------- /config/autoload/logger.go: -------------------------------------------------------------------------------- 1 | package autoload 2 | 3 | type DivisionTime struct { 4 | MaxAge int `mapstructure:"max_age"` // 保留旧文件的最大天数,单位天 5 | RotationTime int `mapstructure:"rotation_time"` // 多长时间切割一次文件,单位小时 6 | } 7 | 8 | type DivisionSize struct { 9 | MaxSize int `mapstructure:"max_size"` // 在进行切割之前,日志文件的最大大小(以MB为单位) 10 | MaxBackups int `mapstructure:"max_backups"` // 保留旧文件的最大个数 11 | MaxAge int `mapstructure:"max_age"` // 保留旧文件的最大天数 12 | Compress bool `mapstructure:"compress"` // 是否压缩/归档旧文件 13 | } 14 | 15 | type LoggerConfig struct { 16 | DefaultDivision string `mapstructure:"default_division"` 17 | Filename string `mapstructure:"file_name"` 18 | DivisionTime DivisionTime `mapstructure:"division_time"` 19 | DivisionSize DivisionSize `mapstructure:"division_size"` 20 | Output string `mapstructure:"output"` 21 | } 22 | 23 | var Logger = LoggerConfig{ 24 | DefaultDivision: "time", // time 按时间切割,默认一天, size 按文件大小切割 25 | Filename: "sys.log", 26 | DivisionTime: DivisionTime{ 27 | MaxAge: 15, 28 | RotationTime: 24, 29 | }, 30 | DivisionSize: DivisionSize{ 31 | MaxSize: 2, 32 | MaxBackups: 2, 33 | MaxAge: 15, 34 | Compress: false, 35 | }, 36 | } 37 | -------------------------------------------------------------------------------- /config/autoload/mysql.go: -------------------------------------------------------------------------------- 1 | package autoload 2 | 3 | import "time" 4 | 5 | type MysqlConfig struct { 6 | Enable bool `mapstructure:"enable"` 7 | Host string `mapstructure:"host"` 8 | Username string `mapstructure:"username"` 9 | Password string `mapstructure:"password"` 10 | Port uint16 `mapstructure:"port"` 11 | Database string `mapstructure:"database"` 12 | Charset string `mapstructure:"charset"` 13 | TablePrefix string `mapstructure:"table_prefix"` 14 | MaxIdleConns int `mapstructure:"max_idle_conns"` 15 | MaxOpenConns int `mapstructure:"max_open_conns"` 16 | MaxLifetime time.Duration `mapstructure:"max_lifetime"` 17 | LogLevel int `mapstructure:"log_level"` 18 | PrintSql bool `mapstructure:"print_sql"` 19 | } 20 | 21 | var Mysql = MysqlConfig{ 22 | Enable: false, 23 | Host: "127.0.0.1", 24 | Username: "root", 25 | Password: "root1234", 26 | Port: 3306, 27 | Database: "test", 28 | Charset: "utf8mb4", 29 | TablePrefix: "", 30 | MaxIdleConns: 10, 31 | MaxOpenConns: 100, 32 | MaxLifetime: time.Hour, 33 | LogLevel: 4, 34 | PrintSql: false, 35 | } 36 | -------------------------------------------------------------------------------- /internal/validator/form/request_log.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | // RequestLogList 请求日志列表查询表单 4 | type RequestLogList struct { 5 | Paginate 6 | OperatorID uint `form:"operator_id" json:"operator_id" binding:"omitempty"` // 操作ID(用户ID) 7 | OperatorAccount string `form:"operator_account" json:"operator_account" binding:"omitempty"` // 操作账号 8 | OperationStatus *int `form:"operation_status" json:"operation_status" binding:"omitempty,oneof=0 1"` // 操作状态:0=成功,1=失败 9 | Method string `form:"method" json:"method" binding:"omitempty"` // HTTP请求方法 10 | BaseURL string `form:"base_url" json:"base_url" binding:"omitempty"` // 请求基础URL 11 | OperationName string `form:"operation_name" json:"operation_name" binding:"omitempty"` // 操作接口 12 | IP string `form:"ip" json:"ip" binding:"omitempty"` // 操作IP 13 | StartTime string `form:"start_time" json:"start_time" binding:"omitempty"` // 开始时间 14 | EndTime string `form:"end_time" json:"end_time" binding:"omitempty"` // 结束时间 15 | } 16 | 17 | // NewRequestLogListQuery 创建请求日志列表查询表单 18 | func NewRequestLogListQuery() *RequestLogList { 19 | return &RequestLogList{} 20 | } 21 | -------------------------------------------------------------------------------- /internal/pkg/utils/format_time.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "database/sql/driver" 5 | "fmt" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | type FormatDate struct { 11 | time.Time 12 | } 13 | 14 | const ( 15 | timeFormat = "2006-01-02 15:04:05" 16 | ) 17 | 18 | func (t FormatDate) MarshalJSON() ([]byte, error) { 19 | if &t == nil || t.IsZero() { 20 | return []byte("null"), nil 21 | } 22 | return []byte(fmt.Sprintf("\"%s\"", t.Format(timeFormat))), nil 23 | } 24 | 25 | func (t FormatDate) Value() (driver.Value, error) { 26 | var zeroTime time.Time 27 | if t.Time.UnixNano() == zeroTime.UnixNano() { 28 | return nil, nil 29 | } 30 | return t.Time, nil 31 | } 32 | 33 | func (t *FormatDate) Scan(v interface{}) error { 34 | if value, ok := v.(time.Time); ok { 35 | *t = FormatDate{value} 36 | return nil 37 | } 38 | return fmt.Errorf("can not convert %v to timestamp", v) 39 | } 40 | 41 | func (t *FormatDate) String() string { 42 | if t == nil || t.IsZero() { 43 | return "" 44 | } 45 | return fmt.Sprintf("%s", t.Time.Format(timeFormat)) 46 | } 47 | 48 | func (t *FormatDate) UnmarshalJSON(data []byte) error { 49 | str := string(data) 50 | if str == "null" { 51 | return nil 52 | } 53 | t1, err := time.ParseInLocation(timeFormat, strings.Trim(str, "\""), time.Local) 54 | *t = FormatDate{t1} 55 | return err 56 | } 57 | -------------------------------------------------------------------------------- /internal/model/dept.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // Department 部门表 4 | type Department struct { 5 | ContainsDeleteBaseModel 6 | Pid uint `json:"pid" gorm:"column:pid;type:int unsigned;not null;default:0;comment:上级id"` 7 | Pids string `json:"pids" gorm:"column:pids;type:varchar(255);not null;default:'';comment:所有上级id"` 8 | Name string `json:"name" gorm:"column:name;type:varchar(60);not null;default:'';comment:部门名称"` 9 | Description string `json:"description" gorm:"column:description;type:varchar(255);not null;default:'';comment:描述"` 10 | Level uint8 `json:"level" gorm:"column:level;type:tinyint unsigned;not null;default:1;comment:层级"` 11 | Sort uint `json:"sort" gorm:"column:sort;type:mediumint unsigned;not null;default:0;comment:排序"` 12 | ChildrenNum uint `json:"children_num" gorm:"column:children_num;type:int unsigned;not null;default:0;comment:子集数量"` 13 | UserNumber uint `json:"user_number" gorm:"column:user_number;type:int unsigned;not null;default:0;comment:用户数量"` 14 | RoleList []DeptRoleMap `json:"role_list" gorm:"foreignKey:dept_id;references:id"` 15 | } 16 | 17 | func NewDepartment() *Department { 18 | return &Department{} 19 | } 20 | 21 | // TableName 获取表名 22 | func (m *Department) TableName() string { 23 | return "a_department" 24 | } 25 | -------------------------------------------------------------------------------- /internal/controller/admin_v1/auth_request_log.go: -------------------------------------------------------------------------------- 1 | package admin_v1 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | 6 | "github.com/wannanbigpig/gin-layout/internal/controller" 7 | "github.com/wannanbigpig/gin-layout/internal/service/permission" 8 | "github.com/wannanbigpig/gin-layout/internal/validator" 9 | "github.com/wannanbigpig/gin-layout/internal/validator/form" 10 | ) 11 | 12 | // RequestLogController 请求日志控制器 13 | type RequestLogController struct { 14 | controller.Api 15 | } 16 | 17 | // NewRequestLogController 创建请求日志控制器实例 18 | func NewRequestLogController() *RequestLogController { 19 | return &RequestLogController{} 20 | } 21 | 22 | // List 分页查询请求日志列表 23 | func (api RequestLogController) List(c *gin.Context) { 24 | params := form.NewRequestLogListQuery() 25 | if err := validator.CheckQueryParams(c, ¶ms); err != nil { 26 | return 27 | } 28 | 29 | result := permission.NewRequestLogService().List(params) 30 | api.Success(c, result) 31 | } 32 | 33 | // Detail 获取请求日志详情 34 | func (api RequestLogController) Detail(c *gin.Context) { 35 | query := form.NewIdForm() 36 | if err := validator.CheckQueryParams(c, &query); err != nil { 37 | return 38 | } 39 | 40 | detail, err := permission.NewRequestLogService().Detail(query.ID) 41 | if err != nil { 42 | api.Err(c, err) 43 | return 44 | } 45 | 46 | api.Success(c, detail) 47 | } 48 | -------------------------------------------------------------------------------- /cmd/service/service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/wannanbigpig/gin-layout/data" 9 | "github.com/wannanbigpig/gin-layout/internal/routers" 10 | "github.com/wannanbigpig/gin-layout/internal/validator" 11 | ) 12 | 13 | const ( 14 | defaultHost = "0.0.0.0" 15 | defaultPort = 9001 16 | ) 17 | 18 | var ( 19 | Cmd = &cobra.Command{ 20 | Use: "service", 21 | Short: "Start API service", 22 | Example: "go-layout service -c config.yml", 23 | PreRun: func(cmd *cobra.Command, args []string) { 24 | initializeService() 25 | }, 26 | RunE: func(cmd *cobra.Command, args []string) error { 27 | return run() 28 | }, 29 | } 30 | host string 31 | port int 32 | ) 33 | 34 | func init() { 35 | registerFlags() 36 | } 37 | 38 | // registerFlags 注册命令行标志 39 | func registerFlags() { 40 | Cmd.Flags().StringVarP(&host, "host", "H", defaultHost, "监听服务器地址") 41 | Cmd.Flags().IntVarP(&port, "port", "P", defaultPort, "监听服务器端口") 42 | } 43 | 44 | // initializeService 初始化服务器 45 | func initializeService() { 46 | // 初始化数据库 47 | data.InitData() 48 | 49 | // 初始化验证器 50 | validator.InitValidatorTrans("zh") 51 | } 52 | 53 | // run 运行服务器 54 | func run() error { 55 | engine, _ := routers.SetRouters(false) 56 | address := fmt.Sprintf("%s:%d", host, port) 57 | return engine.Run(address) 58 | } 59 | -------------------------------------------------------------------------------- /internal/controller/admin_v1/auth_login_log.go: -------------------------------------------------------------------------------- 1 | package admin_v1 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | 6 | "github.com/wannanbigpig/gin-layout/internal/controller" 7 | "github.com/wannanbigpig/gin-layout/internal/service/permission" 8 | "github.com/wannanbigpig/gin-layout/internal/validator" 9 | "github.com/wannanbigpig/gin-layout/internal/validator/form" 10 | ) 11 | 12 | // AdminLoginLogController 登录日志控制器 13 | type AdminLoginLogController struct { 14 | controller.Api 15 | } 16 | 17 | // NewAdminLoginLogController 创建登录日志控制器实例 18 | func NewAdminLoginLogController() AdminLoginLogController { 19 | return AdminLoginLogController{} 20 | } 21 | 22 | // List 分页查询管理员登录日志列表 23 | func (api AdminLoginLogController) List(c *gin.Context) { 24 | params := form.NewAdminLoginLogListQuery() 25 | if err := validator.CheckQueryParams(c, ¶ms); err != nil { 26 | return 27 | } 28 | 29 | result := permission.NewAdminLoginLogService().List(params) 30 | api.Success(c, result) 31 | } 32 | 33 | // Detail 获取管理员登录日志详情 34 | func (api AdminLoginLogController) Detail(c *gin.Context) { 35 | query := form.NewIdForm() 36 | if err := validator.CheckQueryParams(c, &query); err != nil { 37 | return 38 | } 39 | 40 | detail, err := permission.NewAdminLoginLogService().Detail(query.ID) 41 | if err != nil { 42 | api.Err(c, err) 43 | return 44 | } 45 | 46 | api.Success(c, detail) 47 | } 48 | -------------------------------------------------------------------------------- /internal/pkg/utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestCalculateChanges(t *testing.T) { 9 | existingIds := []int{1, 2, 3, 4, 5} 10 | ids := []int{2, 3, 6, 7} 11 | toDelete, toAdd, remainingList := CalculateChanges(existingIds, ids) 12 | fmt.Println("toDelete:", toDelete) 13 | fmt.Println("toAdd:", toAdd) 14 | fmt.Println("remainingList:", remainingList) 15 | } 16 | 17 | func TestRandString(t *testing.T) { 18 | s := RandString(12) 19 | if s == "" { 20 | t.Error("获取运行路径失败") 21 | } 22 | } 23 | 24 | func BenchmarkRandString(b *testing.B) { 25 | // 基准函数会运行目标代码b.N次。 26 | for i := 0; i < b.N; i++ { 27 | RandString(12) 28 | } 29 | } 30 | 31 | func TestDesensitizeRule(b *testing.T) { 32 | // 手机号脱敏 33 | phoneRule := &DesensitizeRule{KeepPrefixLen: 3, KeepSuffixLen: 4, MaskChar: '*'} 34 | if phoneRule.Apply("13812345678") != "138****5678" { 35 | b.Error("手机号码脱敏失败") 36 | } 37 | 38 | // 邮箱脱敏 39 | emailRule := &DesensitizeRule{KeepPrefixLen: 2, KeepSuffixLen: 0, MaskChar: '*', Separator: '@', FixedMaskLength: 3} 40 | if emailRule.Apply("test@example.com") != "te***@example.com" { 41 | b.Error("邮箱脱敏失败") 42 | } 43 | } 44 | func BenchmarkTrimPrefixAndSuffixAND(b *testing.B) { 45 | input := " AND AND name = 'Tom' AND age = 18 AND " 46 | for i := 0; i < b.N; i++ { 47 | _ = TrimPrefixAndSuffixAND(input) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /pkg/utils/helpers.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "strings" 5 | 6 | "golang.org/x/crypto/bcrypt" 7 | ) 8 | 9 | // MaskSensitiveInfo 对于字符串脱敏 10 | // s 需要脱敏的字符串 11 | // start 从第几位开始脱敏 12 | // maskNumber 需要脱敏长度 13 | // maskChars 掩饰字符串,替代需要脱敏处理的字符串 14 | func MaskSensitiveInfo(s string, start int, maskNumber int, maskChars ...string) string { 15 | // 将字符串s的[start, end)区间用maskChar替换,并返回替换后的结果。 16 | maskChar := "*" 17 | if maskChars != nil { 18 | maskChar = maskChars[0] 19 | } 20 | // 处理起始位置超出边界的情况 21 | if start < 0 { 22 | start = 0 23 | } 24 | // 处理结束位置超出边界的情况 25 | end := start + maskNumber 26 | if end > len(s) { 27 | end = len(s) 28 | } 29 | return s[:start] + strings.Repeat(maskChar, end-start) + s[end:] 30 | } 31 | 32 | // PasswordHash 密码hash并自动加盐 33 | func PasswordHash(pwd string) (string, error) { 34 | hash, err := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost) 35 | return string(hash), err 36 | } 37 | 38 | // ComparePasswords 比对用户密码是否正确 39 | func ComparePasswords(oldPassword, password string) bool { 40 | if err := bcrypt.CompareHashAndPassword([]byte(oldPassword), []byte(password)); err != nil { 41 | return false 42 | } 43 | return true 44 | } 45 | 46 | // SliceToAny 将切片转换为any类型切片 47 | func SliceToAny[T any](data []T) []any { 48 | anyData := make([]any, len(data)) 49 | for i, v := range data { 50 | anyData[i] = v 51 | } 52 | return anyData 53 | } 54 | -------------------------------------------------------------------------------- /tests/admin_test/permission_test.go: -------------------------------------------------------------------------------- 1 | package admin_test 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | 10 | e "github.com/wannanbigpig/gin-layout/internal/pkg/errors" 11 | "github.com/wannanbigpig/gin-layout/internal/pkg/response" 12 | ) 13 | 14 | func TestPermissionEdit(t *testing.T) { 15 | route := ts.URL + "/admin/v1/permission/edit" 16 | 17 | body := `{"id":10,"name":"ping","description":"服务心跳检测接口","method":"GET","route":"/ping","is_auth":0,"sort":100}` 18 | resp := postRequest(route, &body) 19 | 20 | assert.Nil(t, resp.Error) 21 | assert.Equal(t, http.StatusOK, resp.Response.StatusCode) 22 | result := new(response.Result) 23 | err := resp.ParseJson(result) 24 | assert.Nil(t, err) 25 | assert.Equal(t, e.SUCCESS, result.Code) 26 | } 27 | 28 | func TestPermissionList(t *testing.T) { 29 | route := ts.URL + "/admin/v1/permission/list" 30 | queryParams := &url.Values{} 31 | queryParams.Set("page", "1") 32 | queryParams.Set("per_page", "1") 33 | queryParams.Set("name", "ping") 34 | queryParams.Set("method", "GET") 35 | queryParams.Set("route", "/ping") 36 | queryParams.Set("is_auth", "1") 37 | resp := getRequest(route, queryParams) 38 | 39 | assert.Nil(t, resp.Error) 40 | assert.Equal(t, http.StatusOK, resp.Response.StatusCode) 41 | result := new(response.Result) 42 | err := resp.ParseJson(result) 43 | assert.Nil(t, err) 44 | assert.Equal(t, e.SUCCESS, result.Code) 45 | } 46 | -------------------------------------------------------------------------------- /tests/test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "io" 5 | "net/url" 6 | "strings" 7 | 8 | "github.com/gin-gonic/gin" 9 | 10 | "github.com/wannanbigpig/gin-layout/config" 11 | "github.com/wannanbigpig/gin-layout/data" 12 | "github.com/wannanbigpig/gin-layout/internal/pkg/logger" 13 | "github.com/wannanbigpig/gin-layout/internal/routers" 14 | "github.com/wannanbigpig/gin-layout/internal/validator" 15 | "github.com/wannanbigpig/gin-layout/pkg/utils" 16 | ) 17 | 18 | func SetupRouter() *gin.Engine { 19 | // 1、初始化配置 20 | config.InitConfig("") 21 | config.Config.Mysql.PrintSql = false 22 | // 2、初始化zap日志 23 | logger.InitLogger() 24 | // 初始化数据库 25 | data.InitData() 26 | // 初始化验证器 27 | validator.InitValidatorTrans("zh") 28 | 29 | gin.SetMode(gin.ReleaseMode) 30 | gin.DefaultWriter = io.Discard 31 | engine := gin.Default() 32 | register := &routers.RegisterRouter{ 33 | ApiMap: make(routers.ApiMap), 34 | InitApiTable: false, 35 | Engine: engine, 36 | } 37 | routers.SetAdminApiRoute(register) 38 | return engine 39 | } 40 | 41 | func Request(method, route string, body *string, args ...any) *utils.HttpRequest { 42 | h := utils.HttpRequest{} 43 | var params io.Reader 44 | if body != nil { 45 | params = strings.NewReader(*body) 46 | } 47 | 48 | return h.JsonRequest(method, route, params, args...) 49 | } 50 | 51 | func GetRequest(route string, queryParams *url.Values, args ...any) *utils.HttpRequest { 52 | h := utils.HttpRequest{} 53 | 54 | return h.GetRequest(route, queryParams, args...) 55 | } 56 | -------------------------------------------------------------------------------- /internal/pkg/errors/error.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | c "github.com/wannanbigpig/gin-layout/config" 8 | ) 9 | 10 | type BusinessError struct { 11 | code int 12 | message string 13 | contextErr []error 14 | } 15 | 16 | func (e *BusinessError) Error() string { 17 | return fmt.Sprintf("[Code]:%d [Msg]:%s, [context error] %s", e.code, e.message, e.contextErr) 18 | } 19 | 20 | func (e *BusinessError) GetCode() int { 21 | return e.code 22 | } 23 | 24 | func (e *BusinessError) GetMessage() string { 25 | return e.message 26 | } 27 | 28 | func (e *BusinessError) SetCode(code int) { 29 | e.code = code 30 | } 31 | 32 | func (e *BusinessError) SetMessage(message string) { 33 | e.message = message 34 | } 35 | 36 | func (e *BusinessError) SetContextErr(err error) { 37 | e.contextErr = append(e.contextErr, err) 38 | } 39 | 40 | func (e *BusinessError) GetContextErr() []error { 41 | return e.contextErr 42 | } 43 | 44 | // NewBusinessError Create a business error 45 | func NewBusinessError(code int, message ...string) *BusinessError { 46 | var msg string 47 | if message != nil { 48 | msg = message[0] 49 | } else { 50 | msg = NewErrorText(c.Config.Language).Text(code) 51 | } 52 | err := new(BusinessError) 53 | err.SetCode(code) 54 | err.SetMessage(msg) 55 | return err 56 | } 57 | 58 | type Error struct{} 59 | 60 | func (e *Error) AsBusinessError(err error) (*BusinessError, error) { 61 | var BusinessError = new(BusinessError) 62 | if errors.As(err, &BusinessError) { 63 | return BusinessError, nil 64 | } 65 | return nil, err 66 | } 67 | -------------------------------------------------------------------------------- /pkg/utils/crypto/crypto.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import "errors" 4 | 5 | // Encrypt 使用指定算法加密字符串(默认使用 AES-256-GCM) 6 | // key: 加密密钥(字符串,会通过 SHA256 派生为 32 字节密钥) 7 | // plaintext: 待加密的明文 8 | // algorithm: 加密算法(可选参数,不传则使用默认算法 AlgorithmAES256GCM) 9 | // 返回: base64 编码的密文 10 | func Encrypt(key, plaintext string, algorithm ...Algorithm) (string, error) { 11 | // 确定使用的算法 12 | var algo Algorithm 13 | if len(algorithm) > 0 && algorithm[0] != "" { 14 | algo = algorithm[0] 15 | } else { 16 | algo = AlgorithmAES256GCM 17 | } 18 | 19 | // 验证算法有效性 20 | if !algo.IsValid() { 21 | return "", errors.New("不支持的加密算法: " + algo.String()) 22 | } 23 | 24 | // 根据算法选择加密方法 25 | switch algo { 26 | case AlgorithmAES256GCM: 27 | return AESEncrypt(key, plaintext) 28 | default: 29 | return "", errors.New("不支持的加密算法: " + algo.String()) 30 | } 31 | } 32 | 33 | // Decrypt 使用指定算法解密字符串(默认使用 AES-256-GCM) 34 | // key: 解密密钥(字符串,会通过 SHA256 派生为 32 字节密钥) 35 | // ciphertext: base64 编码的密文 36 | // algorithm: 解密算法(可选参数,不传则使用默认算法 AlgorithmAES256GCM) 37 | // 返回: 解密后的明文 38 | func Decrypt(key, ciphertext string, algorithm ...Algorithm) (string, error) { 39 | // 确定使用的算法 40 | var algo Algorithm 41 | if len(algorithm) > 0 && algorithm[0] != "" { 42 | algo = algorithm[0] 43 | } else { 44 | algo = AlgorithmAES256GCM 45 | } 46 | 47 | // 验证算法有效性 48 | if !algo.IsValid() { 49 | return "", errors.New("不支持的解密算法: " + algo.String()) 50 | } 51 | 52 | // 根据算法选择解密方法 53 | switch algo { 54 | case AlgorithmAES256GCM: 55 | return AESDecrypt(key, ciphertext) 56 | default: 57 | return "", errors.New("不支持的解密算法: " + algo.String()) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /internal/model/a_request_logs.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // RequestLogs 请求日志表 4 | type RequestLogs struct { 5 | BaseModel 6 | RequestID string `json:"request_id"` // 请求唯一标识 7 | JwtID string `json:"jwt_id"` // 请求授权的jwtId 8 | OperatorID uint `json:"operator_id"` // 操作ID(用户ID) 9 | IP string `json:"ip"` // 客户端IP地址 10 | UserAgent string `json:"user_agent"` // 用户代理(浏览器/设备信息) 11 | OS string `json:"os"` // 操作系统 12 | Browser string `json:"browser"` // 浏览器 13 | Method string `json:"method"` // HTTP请求方法(GET/POST等) 14 | BaseURL string `json:"base_url"` // 请求基础URL 15 | OperationName string `json:"operation_name"` // 操作名称 16 | OperationStatus int `json:"operation_status"` // 操作状态码(响应返回的code,0=成功,其他=失败) 17 | OperatorAccount string `json:"operator_account"` // 操作账号 18 | OperatorName string `json:"operator_name"` // 操作人员 19 | RequestHeaders string `json:"request_headers"` // 请求头(JSON格式) 20 | RequestQuery string `json:"request_query"` // 请求参数 21 | RequestBody string `json:"request_body"` // 请求体 22 | ResponseStatus int `json:"response_status"` // 响应状态码 23 | ResponseBody string `json:"response_body"` // 响应体 24 | ResponseHeader string `json:"response_header"` // 响应头 25 | ExecutionTime float64 `json:"execution_time"` // 执行时间(毫秒,支持小数,最多4位) 26 | } 27 | 28 | func NewRequestLogs() *RequestLogs { 29 | return &RequestLogs{} 30 | } 31 | 32 | // TableName 获取表名 33 | func (m *RequestLogs) TableName() string { 34 | return "a_request_logs" 35 | } 36 | -------------------------------------------------------------------------------- /internal/model/a_role.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "github.com/wannanbigpig/gin-layout/internal/model/modelDict" 4 | 5 | // 角色状态字典 6 | var RoleStatusDict modelDict.Dict = map[uint8]string{ 7 | 1: "启用", 8 | 2: "禁用", 9 | } 10 | 11 | // Role 角色表 12 | type Role struct { 13 | ContainsDeleteBaseModel 14 | Pid uint `json:"pid" gorm:"column:pid;type:int unsigned;not null;default:0;comment:上级id"` 15 | Pids string `json:"pids" gorm:"column:pids;type:varchar(255);not null;default:'';comment:所有上级id"` 16 | Name string `json:"name" gorm:"column:name;type:varchar(60);not null;default:'';comment:角色名称"` 17 | Description string `json:"description" gorm:"column:description;type:varchar(255);not null;default:'';comment:描述"` 18 | Level uint8 `json:"level" gorm:"column:level;type:tinyint unsigned;not null;default:1;comment:层级"` 19 | Sort uint `json:"sort" gorm:"column:sort;type:mediumint unsigned;not null;default:0;comment:排序"` 20 | ChildrenNum uint `json:"children_num" gorm:"column:children_num;type:int unsigned;not null;default:0;comment:子集数量"` 21 | MenuList []RoleMenuMap `json:"menu_list,omitempty" gorm:"foreignkey:role_id;references:id;comment:菜单列表"` 22 | Status uint8 `json:"status" gorm:"column:status;type:tinyint unsigned;not null;default:1;comment:是否启用状态,1启用,2不启用"` 23 | } 24 | 25 | func NewRole() *Role { 26 | return &Role{} 27 | } 28 | 29 | // TableName 获取表名 30 | func (m *Role) TableName() string { 31 | return "a_role" 32 | } 33 | 34 | // StatusMap 状态映射 35 | func (m *Role) StatusMap() string { 36 | return RoleStatusDict.Map(m.Status) 37 | } 38 | -------------------------------------------------------------------------------- /internal/resources/role.go: -------------------------------------------------------------------------------- 1 | package resources 2 | 3 | import ( 4 | "github.com/samber/lo" 5 | 6 | "github.com/wannanbigpig/gin-layout/internal/model" 7 | "github.com/wannanbigpig/gin-layout/internal/pkg/utils" 8 | ) 9 | 10 | type RoleResources struct { 11 | ID uint `json:"id"` 12 | Pid uint `json:"pid"` 13 | Name string `json:"name"` 14 | Description string `json:"description"` 15 | Level uint8 `json:"level"` 16 | Sort uint16 `json:"sort"` 17 | ChildrenNum uint `json:"children_num"` 18 | Status uint8 `json:"status"` 19 | StatusName string `json:"status_name"` // 状态名称 20 | MenuList []uint `json:"menu_list"` 21 | CreatedAt utils.FormatDate `json:"created_at"` 22 | UpdatedAt utils.FormatDate `json:"updated_at"` 23 | } 24 | 25 | func (r *RoleResources) GetID() uint { 26 | return r.ID 27 | } 28 | func (r *RoleResources) GetPID() uint { 29 | return r.Pid 30 | } 31 | 32 | func (r *RoleResources) SetCustomFields(data *model.Role) { 33 | r.MenuList = []uint{} 34 | if data == nil { 35 | return 36 | } 37 | // 设置映射字段 38 | r.StatusName = data.StatusMap() 39 | r.MenuList = lo.Map(data.MenuList, func(m model.RoleMenuMap, _ int) uint { 40 | return m.MenuId 41 | }) 42 | } 43 | 44 | type RoleTransformer struct { 45 | BaseResources[*model.Role, *RoleResources] 46 | } 47 | 48 | func NewRoleTransformer() RoleTransformer { 49 | return RoleTransformer{ 50 | BaseResources: BaseResources[*model.Role, *RoleResources]{ 51 | NewResource: func() *RoleResources { 52 | return &RoleResources{} 53 | }, 54 | }, 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /pkg/convert/convert.go: -------------------------------------------------------------------------------- 1 | package convert 2 | 3 | import "time" 4 | 5 | func GetString(val interface{}) (s string) { 6 | s, _ = val.(string) 7 | return 8 | } 9 | 10 | // GetBool returns the value associated with the key as a boolean. 11 | func GetBool(val interface{}) (b bool) { 12 | b, _ = val.(bool) 13 | return 14 | } 15 | 16 | // GetInt returns the value associated with the key as an integer. 17 | func GetInt(val interface{}) (i int) { 18 | i, _ = val.(int) 19 | return 20 | } 21 | 22 | // GetInt64 returns the value associated with the key as an integer. 23 | func GetInt64(val interface{}) (i64 int64) { 24 | i64, _ = val.(int64) 25 | return 26 | } 27 | 28 | // GetUint returns the value associated with the key as an unsigned integer. 29 | func GetUint(val interface{}) (ui uint) { 30 | ui, _ = val.(uint) 31 | return 32 | } 33 | 34 | // GetUint8 returns the value associated with the key as an unsigned integer. 35 | func GetUint8(val interface{}) (ui uint8) { 36 | ui, _ = val.(uint8) 37 | return 38 | } 39 | 40 | // GetUint64 returns the value associated with the key as an unsigned integer. 41 | func GetUint64(val interface{}) (ui64 uint64) { 42 | ui64, _ = val.(uint64) 43 | return 44 | } 45 | 46 | // GetFloat64 returns the value associated with the key as a float64. 47 | func GetFloat64(val interface{}) (f64 float64) { 48 | f64, _ = val.(float64) 49 | return 50 | } 51 | 52 | // GetTime returns the value associated with the key as time. 53 | func GetTime(val interface{}) (t time.Time) { 54 | t, _ = val.(time.Time) 55 | return 56 | } 57 | 58 | // GetDuration returns the value associated with the key as a duration. 59 | func GetDuration(val interface{}) (d time.Duration) { 60 | d, _ = val.(time.Duration) 61 | return 62 | } 63 | -------------------------------------------------------------------------------- /internal/controller/sys_base.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "go.uber.org/zap" 6 | 7 | "github.com/wannanbigpig/gin-layout/internal/middleware" 8 | "github.com/wannanbigpig/gin-layout/internal/pkg/errors" 9 | log "github.com/wannanbigpig/gin-layout/internal/pkg/logger" 10 | r "github.com/wannanbigpig/gin-layout/internal/pkg/response" 11 | ) 12 | 13 | // Api 控制器基类 14 | type Api struct { 15 | errors.Error 16 | } 17 | 18 | // Success 业务成功响应 19 | func (api Api) Success(c *gin.Context, data ...any) { 20 | response := r.Resp() 21 | if len(data) > 0 && data[0] != nil { 22 | response.WithDataSuccess(c, data[0]) 23 | return 24 | } 25 | response.Success(c) 26 | } 27 | 28 | // FailCode 业务失败响应(使用错误码) 29 | func (api Api) FailCode(c *gin.Context, code int, data ...any) { 30 | response := r.Resp() 31 | if len(data) > 0 && data[0] != nil { 32 | response.WithData(data[0]).FailCode(c, code) 33 | return 34 | } 35 | response.FailCode(c, code) 36 | } 37 | 38 | // Fail 业务失败响应(自定义错误消息) 39 | func (api Api) Fail(c *gin.Context, code int, message string, data ...any) { 40 | response := r.Resp() 41 | if len(data) > 0 && data[0] != nil { 42 | response.WithData(data[0]).Fail(c, code, message) 43 | return 44 | } 45 | response.Fail(c, code, message) 46 | } 47 | 48 | // Err 统一错误处理 49 | // 判断错误类型是自定义类型则自动返回错误中携带的code和message,否则返回服务器错误 50 | func (api Api) Err(c *gin.Context, err error) { 51 | businessError, parseErr := api.AsBusinessError(err) 52 | if parseErr != nil { 53 | requestID := c.GetString(middleware.ContextKeyRequestID) 54 | log.Logger.Warn("Unknown error:", zap.String("requestId", requestID), zap.Error(parseErr)) 55 | api.FailCode(c, errors.ServerErr) 56 | return 57 | } 58 | 59 | api.Fail(c, businessError.GetCode(), businessError.GetMessage()) 60 | } 61 | -------------------------------------------------------------------------------- /tests/admin_test/admin_test.go: -------------------------------------------------------------------------------- 1 | package admin_test 2 | 3 | import ( 4 | "net/http/httptest" 5 | "net/url" 6 | "testing" 7 | "time" 8 | 9 | "github.com/golang-jwt/jwt/v5" 10 | 11 | c "github.com/wannanbigpig/gin-layout/config" 12 | "github.com/wannanbigpig/gin-layout/internal/global" 13 | "github.com/wannanbigpig/gin-layout/internal/pkg/utils/token" 14 | "github.com/wannanbigpig/gin-layout/pkg/utils" 15 | "github.com/wannanbigpig/gin-layout/tests" 16 | ) 17 | 18 | var ( 19 | ts *httptest.Server 20 | authorization string 21 | ) 22 | 23 | func TestMain(m *testing.M) { 24 | ts = httptest.NewServer(tests.SetupRouter()) 25 | now := time.Now() 26 | expiresAt := now.Add(time.Second * c.Config.Jwt.TTL) 27 | claims := token.AdminCustomClaims{ 28 | AdminUserInfo: token.AdminUserInfo{ 29 | UserID: 1, 30 | PhoneNumber: "13200000000", 31 | Nickname: "admin", 32 | }, 33 | RegisteredClaims: jwt.RegisteredClaims{ 34 | ExpiresAt: jwt.NewNumericDate(expiresAt), 35 | Issuer: global.Issuer, // 签发人 36 | // IssuedAt: jwt.NewNumericDate(now), // 签发时间 37 | Subject: global.PcAdminSubject, // 签发主体 38 | // NotBefore: jwt.NewNumericDate(now), // 生效时间 39 | }, 40 | } 41 | accessToken, err := token.Generate(claims) 42 | authorization = "Bearer " + accessToken 43 | if err != nil { 44 | panic("创建管理员Token失败") 45 | } 46 | m.Run() 47 | } 48 | 49 | func postRequest(route string, body *string) *utils.HttpRequest { 50 | options := map[string]string{ 51 | "Authorization": authorization, 52 | } 53 | return tests.Request("POST", route, body, options) 54 | } 55 | 56 | func getRequest(route string, queryParams *url.Values) *utils.HttpRequest { 57 | options := map[string]string{ 58 | "Authorization": authorization, 59 | } 60 | return tests.GetRequest(route, queryParams, options) 61 | } 62 | -------------------------------------------------------------------------------- /internal/resources/dept.go: -------------------------------------------------------------------------------- 1 | package resources 2 | 3 | import ( 4 | "github.com/samber/lo" 5 | 6 | "github.com/wannanbigpig/gin-layout/internal/model" 7 | "github.com/wannanbigpig/gin-layout/internal/pkg/utils" 8 | ) 9 | 10 | type DeptResources struct { 11 | ID uint `json:"id"` 12 | Pid uint `json:"pid"` 13 | Name string `json:"name"` 14 | Description string `json:"description"` 15 | Level uint8 `json:"level"` 16 | Sort uint16 `json:"sort"` 17 | ChildrenNum uint `json:"children_num"` 18 | Children []*DeptResources `json:"children,omitempty"` 19 | RoleList []uint `json:"role_list"` 20 | UserNumber uint `json:"user_number"` 21 | CreatedAt utils.FormatDate `json:"created_at"` 22 | UpdatedAt utils.FormatDate `json:"updated_at"` 23 | } 24 | 25 | func (r *DeptResources) SetChildren(children []*DeptResources) { 26 | r.Children = children 27 | } 28 | func (r *DeptResources) GetID() uint { 29 | return r.ID 30 | } 31 | func (r *DeptResources) GetPID() uint { 32 | return r.Pid 33 | } 34 | 35 | type DeptTreeTransformer struct { 36 | TreeResource[*model.Department, *DeptResources] 37 | } 38 | 39 | func (r *DeptResources) SetCustomFields(data *model.Department) { 40 | // 初始化 RoleList 为空切片,确保字段总是存在 41 | r.RoleList = []uint{} 42 | if data == nil { 43 | return 44 | } 45 | // 如果 RoleList 有数据,则提取 RoleId 46 | if len(data.RoleList) > 0 { 47 | r.RoleList = lo.Map(data.RoleList, func(m model.DeptRoleMap, _ int) uint { 48 | return m.RoleId 49 | }) 50 | } 51 | } 52 | 53 | func NewDeptTreeTransformer() DeptTreeTransformer { 54 | return DeptTreeTransformer{ 55 | TreeResource: TreeResource[*model.Department, *DeptResources]{ 56 | NewResource: func() *DeptResources { 57 | return &DeptResources{} 58 | }, 59 | }, 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/admin_test/admin_user_test.go: -------------------------------------------------------------------------------- 1 | package admin_test 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/url" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | 12 | e "github.com/wannanbigpig/gin-layout/internal/pkg/errors" 13 | "github.com/wannanbigpig/gin-layout/internal/pkg/response" 14 | "github.com/wannanbigpig/gin-layout/pkg/utils" 15 | ) 16 | 17 | func TestLogin(t *testing.T) { 18 | // 获取验证码 19 | captchaRoute := ts.URL + "/admin/v1/login-captcha" 20 | captchaResp := getRequest(captchaRoute, nil) 21 | assert.Nil(t, captchaResp.Error) 22 | assert.Equal(t, http.StatusOK, captchaResp.Response.StatusCode) 23 | captchaResult := new(response.Result) 24 | err := captchaResp.ParseJson(captchaResult) 25 | assert.Nil(t, err) 26 | assert.Equal(t, e.SUCCESS, captchaResult.Code) 27 | 28 | // 登录 29 | route := ts.URL + "/admin/v1/login" 30 | h := utils.HttpRequest{} 31 | captchaData, ok := captchaResult.Data.(map[string]any) 32 | assert.True(t, ok) 33 | loginData := map[string]any{ 34 | "username": "super_admin", 35 | "password": "123456", 36 | "captcha": captchaData["answer"], 37 | "captcha_id": captchaData["id"], 38 | } 39 | body, err := json.Marshal(loginData) 40 | assert.Nil(t, err) 41 | resp := h.JsonRequest("POST", route, strings.NewReader(string(body))) 42 | 43 | assert.Nil(t, resp.Error) 44 | assert.Equal(t, http.StatusOK, resp.Response.StatusCode) 45 | result := new(response.Result) 46 | err = resp.ParseJson(result) 47 | assert.Nil(t, err) 48 | assert.Equal(t, e.SUCCESS, result.Code) 49 | } 50 | 51 | func TestGetAdminUser(t *testing.T) { 52 | route := ts.URL + "/admin/v1/admin-user/get" 53 | queryParams := &url.Values{} 54 | queryParams.Set("id", "1") 55 | resp := getRequest(route, queryParams) 56 | 57 | assert.Nil(t, resp.Error) 58 | assert.Equal(t, http.StatusOK, resp.Response.StatusCode) 59 | result := new(response.Result) 60 | err := resp.ParseJson(result) 61 | assert.Nil(t, err) 62 | assert.Equal(t, e.SUCCESS, result.Code) 63 | } 64 | -------------------------------------------------------------------------------- /internal/pkg/utils/desensitize.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "strings" 5 | "unicode/utf8" 6 | ) 7 | 8 | type DesensitizeRule struct { 9 | KeepPrefixLen int // 保留前缀长度 10 | KeepSuffixLen int // 保留后缀长度 11 | MaskChar rune // 脱敏字符 12 | Separator rune // 特殊分隔符(如邮箱的@) 13 | FixedMaskLength int // 固定脱敏长度(0表示不固定) 14 | } 15 | 16 | // NewPhoneRule 构建手机号码脱敏规则 17 | func NewPhoneRule() *DesensitizeRule { 18 | return &DesensitizeRule{KeepPrefixLen: 3, KeepSuffixLen: 4, MaskChar: '*', FixedMaskLength: 4} 19 | } 20 | 21 | // NewEmailRule 构建邮箱脱敏规则 22 | func NewEmailRule() *DesensitizeRule { 23 | return &DesensitizeRule{KeepPrefixLen: 2, KeepSuffixLen: 0, MaskChar: '*', Separator: '@', FixedMaskLength: 3} 24 | } 25 | func (r *DesensitizeRule) Apply(s string) string { 26 | if utf8.RuneCountInString(s) == 0 { 27 | return s 28 | } 29 | 30 | // 处理带分隔符的情况(如邮箱) 31 | if r.Separator != 0 { 32 | parts := strings.Split(s, string(r.Separator)) 33 | if len(parts) == 2 { 34 | localPart := r.applyToPart(parts[0]) 35 | return localPart + string(r.Separator) + parts[1] 36 | } 37 | } 38 | 39 | return r.applyToPart(s) 40 | } 41 | 42 | func (r *DesensitizeRule) applyToPart(s string) string { 43 | runes := []rune(s) 44 | length := len(runes) 45 | 46 | // 计算需要保留的前后部分 47 | keepPrefix := r.min(r.KeepPrefixLen, length) 48 | keepSuffix := r.min(r.KeepSuffixLen, length-keepPrefix) 49 | 50 | // 计算脱敏部分长度 51 | var maskLength int 52 | if r.FixedMaskLength > 0 { 53 | maskLength = r.FixedMaskLength // 使用固定长度 54 | } else { 55 | maskLength = length - keepPrefix - keepSuffix // 使用可变长度 56 | } 57 | 58 | // 构建结果 59 | var result strings.Builder 60 | if keepPrefix > 0 { 61 | result.WriteString(string(runes[:keepPrefix])) 62 | } 63 | if maskLength > 0 { 64 | result.WriteString(strings.Repeat(string(r.MaskChar), maskLength)) 65 | } 66 | if keepSuffix > 0 { 67 | result.WriteString(string(runes[length-keepSuffix:])) 68 | } 69 | 70 | return result.String() 71 | } 72 | 73 | func (r *DesensitizeRule) min(a, b int) int { 74 | if a < b { 75 | return a 76 | } 77 | return b 78 | } 79 | -------------------------------------------------------------------------------- /config/config.yaml.example: -------------------------------------------------------------------------------- 1 | # 该文件为配置示例文件,请复制该文件改名为 config.yaml, 不要直接修改该文件,修改无意义 2 | app: 3 | app_env: local 4 | debug: true 5 | language: zh_CN 6 | # watch_config: false 7 | # base_path: "" 8 | # base_url: "https://example.com" # 文件访问的基础URL,用于拼接本地文件访问地址 9 | # # CORS 跨域配置 10 | # cors_origins: # CORS允许的源列表,空数组表示允许所有源(开发环境) 11 | # - "http://localhost:3000" 12 | # - "http://localhost:8080" 13 | # - "https://example.com" 14 | # # 支持通配符匹配,例如: 15 | # - "https://*.wannanbigpig.com" # 匹配所有 wannanbigpig.com 的子域名(如 https://x-l-admin.wannanbigpig.com) 16 | # # 或者明确指定: 17 | # - "https://x-l-admin.wannanbigpig.com" 18 | # cors_methods: # 允许的HTTP方法,空数组使用默认值(GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS) 19 | # - "GET" 20 | # - "POST" 21 | # - "PUT" 22 | # - "PATCH" 23 | # - "DELETE" 24 | # - "HEAD" 25 | # - "OPTIONS" 26 | # cors_headers: # 允许的请求头,空数组表示允许所有(使用 "*") 27 | # - "Content-Type" 28 | # - "Authorization" 29 | # - "X-Requested-With" 30 | # cors_expose_headers: # 暴露的响应头,空数组表示暴露所有(使用 "*") 31 | # - "Content-Length" 32 | # - "X-Request-Id" 33 | # cors_max_age: 43200 # 预检请求缓存时间(秒),默认 43200(12小时) 34 | # cors_credentials: false # 是否允许携带凭证(cookies等),默认 false 35 | jwt: 36 | ttl: 7200 37 | refresh_ttl: 3600 38 | secret_key: Wj7rctFCgio1UxDXQEQbr64S5Q4JNQQthee9PcAKxFnZXFcnUlMlj8uBTfSi9xGq 39 | mysql: 40 | enable: false 41 | host: 127.0.0.1 42 | port: 3306 43 | database: test 44 | username: root 45 | password: root1234 46 | charset: utf8mb4 47 | table_prefix: "" 48 | max_idle_conns: 10 49 | max_open_conns: 100 50 | max_lifetime: 3600s 51 | redis: 52 | enable: false 53 | host: 127.0.0.1 54 | port: 6379 55 | password: 56 | database: 0 57 | logger: 58 | # 日志输出默认为文件,stderr 可选 59 | output: file 60 | default_division: time 61 | file_name: gin-layout.sys.log 62 | division_time: 63 | max_age: 15 64 | rotation_time: 24 65 | division_size: 66 | max_size: 20 67 | max_backups: 15 68 | max_age: 15 69 | compress: false -------------------------------------------------------------------------------- /internal/console/system_init/system_init.go: -------------------------------------------------------------------------------- 1 | package system_init 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/spf13/cobra" 10 | "go.uber.org/zap" 11 | 12 | log "github.com/wannanbigpig/gin-layout/internal/pkg/logger" 13 | "github.com/wannanbigpig/gin-layout/internal/service/system" 14 | ) 15 | 16 | var ( 17 | // InitSystemCmd 手动执行初始化系统命令 18 | InitSystemCmd = &cobra.Command{ 19 | Use: "init-system", 20 | Short: "Initialize system data manually", 21 | Long: `This command manually initializes the system data, which includes: 22 | 1. Rollback all database migrations 23 | 2. Re-execute all migrations 24 | 3. Re-initialize API routes 25 | 4. Re-initialize menu-API mappings 26 | 27 | This is the same task that runs automatically at 2:00 AM daily.`, 28 | RunE: func(cmd *cobra.Command, args []string) error { 29 | return runInitSystem() 30 | }, 31 | } 32 | ) 33 | 34 | // runInitSystem 执行初始化系统 35 | func runInitSystem() error { 36 | // 用户确认 37 | if !confirmOperation("此命令将执行系统初始化,包括回滚迁移、重新执行迁移、重新初始化路由和路由映射。此操作会清空现有数据,确定要继续吗? [Y/N]: ") { 38 | fmt.Println("操作已取消。") 39 | return nil 40 | } 41 | 42 | fmt.Println("开始执行初始化系统任务...") 43 | log.Logger.Info("手动执行初始化系统任务") 44 | 45 | resetService := system.NewResetService() 46 | if err := resetService.ReinitializeSystemData(); err != nil { 47 | log.Logger.Error("初始化系统任务执行失败", zap.Error(err)) 48 | fmt.Printf("初始化系统失败: %v\n", err) 49 | return err 50 | } 51 | 52 | fmt.Println("初始化系统任务执行完成!") 53 | log.Logger.Info("手动执行初始化系统任务完成") 54 | return nil 55 | } 56 | 57 | // confirmOperation 确认操作,返回用户是否确认 58 | func confirmOperation(prompt string) bool { 59 | scanner := bufio.NewScanner(os.Stdin) 60 | fmt.Print(prompt) 61 | 62 | if !scanner.Scan() { 63 | if err := scanner.Err(); err != nil { 64 | log.Logger.Error("Failed to read user input", zap.Error(err)) 65 | _, err := fmt.Fprintln(os.Stderr, "reading standard input:", err) 66 | if err != nil { 67 | return false 68 | } 69 | } 70 | return false 71 | } 72 | 73 | input := strings.TrimSpace(strings.ToLower(scanner.Text())) 74 | return input == "y" || input == "yes" 75 | } 76 | -------------------------------------------------------------------------------- /internal/pkg/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "math/rand" 5 | "regexp" 6 | "strings" 7 | "time" 8 | 9 | "github.com/samber/lo" 10 | ) 11 | 12 | // CalculateChanges 计算差集 (一次性获取删除、新增和剩余列表) 13 | // 计算交集 14 | // 合并差集和交集 15 | // 示例: 16 | // 17 | // existingIds := []int{1, 2, 3, 4, 5} 18 | // ids := []int{2, 3, 6, 7} 19 | // toDelete, toAdd, remainingList := CalculateChanges(existingIds, ids) 20 | // fmt.Println("toDelete:", toDelete) 21 | // fmt.Println("toAdd:", toAdd) 22 | // fmt.Println("remainingList:", remainingList) 23 | // 24 | // 输出: 25 | // toDelete: [1 4 5] 26 | // toAdd: [6 7] 27 | // remainingList: [2 3 6 7] 28 | func CalculateChanges[T comparable](existingIds, ids []T) (toDelete, toAdd, remainingList []T) { 29 | // 2. 计算差集(一次性获取删除和新增列表) 30 | toDelete, toAdd = lo.Difference(existingIds, lo.Uniq(ids)) 31 | 32 | // 2. 计算交集 33 | intersection := lo.Intersect(ids, existingIds) 34 | 35 | // 3. 合并差集和交集 36 | remainingList = lo.Union(intersection, toAdd) 37 | return 38 | } 39 | 40 | // RandString 生成随机字符串 41 | func RandString(n int) string { 42 | letterBytes := []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") 43 | var src = rand.NewSource(time.Now().UnixNano()) 44 | 45 | const ( 46 | letterIdxBits = 6 47 | letterIdxMask = 1<= 0; { 52 | if remain == 0 { 53 | cache, remain = src.Int63(), letterIdxMax 54 | } 55 | if idx := int(cache & letterIdxMask); idx < len(letterBytes) { 56 | b[i] = letterBytes[idx] 57 | i-- 58 | } 59 | cache >>= letterIdxBits 60 | remain-- 61 | } 62 | return string(b) 63 | } 64 | 65 | // TrimPrefixAndSuffixAND 去除字符串前后的 AND(不区分大小写,忽略多余空白) 66 | func TrimPrefixAndSuffixAND(s string) string { 67 | s = strings.TrimSpace(s) 68 | 69 | // 正则匹配开头或结尾的 AND(忽略大小写和空白) 70 | re := regexp.MustCompile(`(?i)^(AND\s+)|(\s+AND)$`) 71 | for { 72 | trimmed := re.ReplaceAllString(s, "") 73 | if trimmed == s { 74 | break 75 | } 76 | s = strings.TrimSpace(trimmed) 77 | } 78 | 79 | return s 80 | } 81 | -------------------------------------------------------------------------------- /config/autoload/app.go: -------------------------------------------------------------------------------- 1 | package autoload 2 | 3 | import ( 4 | "github.com/wannanbigpig/gin-layout/pkg/utils" 5 | ) 6 | 7 | type AppConfig struct { 8 | AppEnv string `mapstructure:"app_env"` 9 | Debug bool `mapstructure:"debug"` 10 | Language string `mapstructure:"language"` 11 | WatchConfig bool `mapstructure:"watch_config"` 12 | BasePath string `mapstructure:"base_path"` 13 | BaseURL string `mapstructure:"base_url"` // 文件访问的基础URL(如:https://example.com) 14 | Timezone *string `mapstructure:"timezone"` 15 | // CORS 配置 16 | CorsOrigins []string `mapstructure:"cors_origins"` // CORS允许的源列表(如:["http://localhost:3000", "https://example.com"]),空数组表示允许所有源 17 | CorsMethods []string `mapstructure:"cors_methods"` // 允许的HTTP方法(如:["GET", "POST", "PUT", "DELETE"]),空数组使用默认值 18 | CorsHeaders []string `mapstructure:"cors_headers"` // 允许的请求头(如:["Content-Type", "Authorization"]),空数组表示允许所有(使用 "*") 19 | CorsExposeHeaders []string `mapstructure:"cors_expose_headers"` // 暴露的响应头(如:["Content-Length", "X-Request-Id"]),空数组表示暴露所有(使用 "*") 20 | CorsMaxAge int `mapstructure:"cors_max_age"` // 预检请求缓存时间(秒),默认 43200(12小时) 21 | CorsCredentials bool `mapstructure:"cors_credentials"` // 是否允许携带凭证(cookies等),默认 false 22 | } 23 | 24 | var App = AppConfig{ 25 | AppEnv: "local", 26 | Debug: true, 27 | Language: "zh_CN", 28 | WatchConfig: false, 29 | BasePath: getDefaultPath(), 30 | BaseURL: "", // 默认空,需要配置 31 | Timezone: nil, 32 | CorsOrigins: []string{}, // 默认空数组,表示允许所有源(开发环境) 33 | CorsMethods: []string{}, // 默认空数组,使用默认方法列表 34 | CorsHeaders: []string{}, // 默认空数组,表示允许所有请求头(使用 "*") 35 | CorsExposeHeaders: []string{}, // 默认空数组,表示暴露所有响应头(使用 "*") 36 | CorsMaxAge: 43200, // 默认 12 小时(43200 秒) 37 | CorsCredentials: false, // 默认不允许携带凭证 38 | } 39 | 40 | func getDefaultPath() (path string) { 41 | // 始终使用二进制文件所在目录作为 BasePath 42 | // 如果获取失败,使用 /tmp 作为后备方案(仅用于初始化,实际不会发生) 43 | path, err := utils.GetDefaultPath() 44 | if err != nil || path == "" { 45 | // 如果获取失败,使用临时目录(这种情况不应该发生) 46 | path = "/tmp" 47 | } 48 | return 49 | } 50 | -------------------------------------------------------------------------------- /internal/controller/admin_v1/auth_api.go: -------------------------------------------------------------------------------- 1 | package admin_v1 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | 6 | "github.com/wannanbigpig/gin-layout/internal/controller" 7 | e "github.com/wannanbigpig/gin-layout/internal/pkg/errors" 8 | "github.com/wannanbigpig/gin-layout/internal/service/permission" 9 | "github.com/wannanbigpig/gin-layout/internal/validator" 10 | "github.com/wannanbigpig/gin-layout/internal/validator/form" 11 | ) 12 | 13 | // ApiController API权限控制器 14 | type ApiController struct { 15 | controller.Api 16 | } 17 | 18 | // NewApiController 创建API控制器实例 19 | func NewApiController() *ApiController { 20 | return &ApiController{} 21 | } 22 | 23 | // Edit 编辑API权限 24 | func (api ApiController) Edit(c *gin.Context) { 25 | params := form.NewEditApiForm() 26 | if err := validator.CheckPostParams(c, ¶ms); err != nil { 27 | return 28 | } 29 | 30 | if err := permission.NewApiService().Edit(params); err != nil { 31 | api.Err(c, err) 32 | return 33 | } 34 | 35 | api.Success(c, nil) 36 | } 37 | 38 | // Create 新增API权限 39 | func (api ApiController) Create(c *gin.Context) { 40 | params := form.NewEditApiForm() 41 | if err := validator.CheckPostParams(c, ¶ms); err != nil { 42 | return 43 | } 44 | 45 | // 确保 ID 为空,表示新增 46 | params.Id = 0 47 | 48 | if err := permission.NewApiService().Edit(params); err != nil { 49 | api.Err(c, err) 50 | return 51 | } 52 | 53 | api.Success(c, nil) 54 | } 55 | 56 | // Update 更新API权限 57 | func (api ApiController) Update(c *gin.Context) { 58 | params := form.NewEditApiForm() 59 | if err := validator.CheckPostParams(c, ¶ms); err != nil { 60 | return 61 | } 62 | 63 | // 确保 ID 不为空,表示更新 64 | if params.Id == 0 { 65 | api.Err(c, e.NewBusinessError(1, "更新API权限时ID不能为空")) 66 | return 67 | } 68 | 69 | if err := permission.NewApiService().Edit(params); err != nil { 70 | api.Err(c, err) 71 | return 72 | } 73 | 74 | api.Success(c, nil) 75 | } 76 | 77 | // List 分页查询API权限列表 78 | func (api ApiController) List(c *gin.Context) { 79 | params := form.NewListApiQuery() 80 | if err := validator.CheckQueryParams(c, ¶ms); err != nil { 81 | return 82 | } 83 | 84 | result := permission.NewApiService().ListPage(params) 85 | api.Success(c, result) 86 | } 87 | -------------------------------------------------------------------------------- /internal/resources/api.go: -------------------------------------------------------------------------------- 1 | package resources 2 | 3 | import ( 4 | "github.com/wannanbigpig/gin-layout/internal/model" 5 | ) 6 | 7 | type ApiResources struct { 8 | ID uint `json:"id"` 9 | Name string `json:"name"` // 权限名称 10 | Code string `json:"code"` // 权限名称 11 | Description string `json:"description"` // 描述 12 | Method string `json:"method"` // 接口请求方法 13 | Route string `json:"route"` // 接口路由 14 | Func string `json:"func"` // 接口方法 15 | FuncPath string `json:"func_path"` // 接口方法 16 | IsAuth uint8 `json:"is_auth"` // 是否授权 17 | IsEffective uint8 `json:"is_effective"` // 是否有效 18 | IsAuthName *string `json:"is_auth_name"` // 是否有效 19 | IsEffectiveName *string `json:"is_effective_name"` // 是否有效 20 | Sort int `json:"sort"` // 排序 21 | } 22 | 23 | // ApiTransformer 权限资源转换 24 | type ApiTransformer struct { 25 | BaseResources[*model.Api, *ApiResources] 26 | } 27 | 28 | // NewApiTransformer 实例化权限资源转换器 29 | func NewApiTransformer() ApiTransformer { 30 | return ApiTransformer{ 31 | BaseResources: BaseResources[*model.Api, *ApiResources]{ 32 | NewResource: func() *ApiResources { 33 | return &ApiResources{} 34 | }, 35 | }, 36 | } 37 | } 38 | 39 | func (ApiTransformer) ToStruct(data *model.Api) *ApiResources { 40 | isAuthName := data.IsAuthMap() 41 | isEffectiveName := data.IsEffectiveMap() 42 | return &ApiResources{ 43 | ID: data.ID, 44 | Name: data.Name, 45 | Description: data.Description, 46 | Method: data.Method, 47 | Route: data.Route, 48 | Func: data.Func, 49 | FuncPath: data.FuncPath, 50 | IsAuth: data.IsAuth, 51 | IsAuthName: &isAuthName, 52 | Sort: data.Sort, 53 | Code: data.Code, 54 | IsEffective: data.IsEffective, 55 | IsEffectiveName: &isEffectiveName, 56 | } 57 | } 58 | 59 | func (ApiTransformer) ToCollection(page, perPage int, total int64, data []*model.Api) *Collection { 60 | response := make([]any, 0, len(data)) 61 | for _, v := range data { 62 | response = append(response, ApiTransformer{}.ToStruct(v)) 63 | } 64 | return NewCollection().SetPaginate(page, perPage, total).ToCollection(response) 65 | } 66 | -------------------------------------------------------------------------------- /pkg/utils/http.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | "net/url" 8 | ) 9 | 10 | type HttpRequest struct { 11 | http.Client 12 | Response *http.Response 13 | Error error 14 | } 15 | 16 | // JsonRequest 默认 Content-Type:application/json 类型请求 17 | func (hr *HttpRequest) JsonRequest(method string, url string, body io.Reader, args ...any) *HttpRequest { 18 | var options map[string]string 19 | if args != nil { 20 | var ok bool 21 | if options, ok = args[0].(map[string]string); ok { 22 | options["Content-Type"] = "application/json" 23 | } 24 | } else { 25 | options = map[string]string{ 26 | "Content-Type": "application/json", 27 | } 28 | } 29 | return hr.Request(method, url, body, options) 30 | } 31 | 32 | // GetRequest 发起 GET 请求 33 | func (hr *HttpRequest) GetRequest(url string, params *url.Values, args ...any) *HttpRequest { 34 | r := url 35 | if params != nil { 36 | r = url + "?" + params.Encode() 37 | } 38 | 39 | return hr.Request("GET", r, nil, args...) 40 | } 41 | 42 | // Request make a request 43 | func (hr *HttpRequest) Request(method string, url string, body io.Reader, args ...any) *HttpRequest { 44 | req, err := http.NewRequest(method, url, body) 45 | if err != nil { 46 | hr.Error = err 47 | } 48 | 49 | if args != nil { 50 | if options, ok := args[0].(map[string]string); ok { 51 | for k, v := range options { 52 | req.Header.Set(k, v) 53 | } 54 | } 55 | } 56 | 57 | hr.Response, hr.Error = hr.Do(req) 58 | 59 | return hr 60 | } 61 | 62 | // ParseJson Parse the return value into json format 63 | func (hr *HttpRequest) ParseJson(payload any) error { 64 | bytes, err := hr.ParseBytes() 65 | if err != nil { 66 | return err 67 | } 68 | return json.Unmarshal(bytes, &payload) 69 | } 70 | 71 | // ParseBytes Parse the return value into []byte format 72 | func (hr *HttpRequest) ParseBytes() ([]byte, error) { 73 | if hr.Error != nil { 74 | return nil, hr.Error 75 | } 76 | 77 | defer func(Body io.ReadCloser) { 78 | err := Body.Close() 79 | if err != nil { 80 | panic(err.Error()) 81 | } 82 | }(hr.Response.Body) 83 | 84 | return io.ReadAll(hr.Response.Body) 85 | } 86 | 87 | // Raw Return the raw response data as a string 88 | func (hr *HttpRequest) Raw() (string, error) { 89 | str, err := hr.ParseBytes() 90 | if err != nil { 91 | return "", err 92 | } 93 | return string(str), nil 94 | } 95 | -------------------------------------------------------------------------------- /internal/validator/form/permission.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | type EditPermission struct { 4 | Id uint `form:"id" json:"id" binding:"required"` // id 5 | Name string `form:"name" json:"name" binding:"required,max=60"` // 权限名称 6 | Description string `form:"description" json:"desc" binding:"omitempty"` // 描述 7 | Method string `form:"method" json:"method" binding:"omitempty,oneof=GET POST PUT DELETE OPTIONS HEAD PATCH" label:"接口请求方法"` // 接口请求方法 8 | Route string `form:"route" json:"route" binding:"omitempty"` // 接口路由 9 | Func string `form:"func" json:"func" binding:"omitempty"` // 接口方法 10 | FuncPath string `form:"func_path" json:"func_path" binding:"omitempty"` // 接口方法 11 | IsAuth *int8 `form:"is_auth" json:"is_auth" binding:"required,oneof=0 1"` // 接口方法 12 | Sort int32 `form:"sort" json:"sort" binding:"required"` // 排序 13 | } 14 | 15 | func NewEditApiForm() *EditPermission { 16 | return &EditPermission{} 17 | } 18 | 19 | type ListPermission struct { 20 | Paginate 21 | Name string `form:"name" json:"name" binding:"omitempty,max=60"` // 权限名称 22 | Method string `form:"method" json:"method" binding:"omitempty,oneof=GET POST PUT DELETE OPTIONS HEAD PATCH" label:"接口请求方法"` // 接口请求方法 23 | Route string `form:"route" json:"route" binding:"omitempty"` // 接口路由 24 | Keyword string `form:"keyword" json:"keyword" binding:"omitempty"` // 关键字 25 | IsAuth *int8 `form:"is_auth" json:"is_auth" binding:"omitempty,oneof=0 1"` // 是否授权 26 | IsEffective *int8 `form:"is_effective" json:"is_effective" binding:"omitempty,oneof=0 1"` // 是否授权 27 | } 28 | 29 | func NewListApiQuery() *ListPermission { 30 | return &ListPermission{} 31 | } 32 | -------------------------------------------------------------------------------- /internal/controller/admin_v1/sys_common.go: -------------------------------------------------------------------------------- 1 | package admin_v1 2 | 3 | import ( 4 | "mime/multipart" 5 | "os" 6 | 7 | "github.com/gin-gonic/gin" 8 | 9 | "github.com/wannanbigpig/gin-layout/internal/controller" 10 | "github.com/wannanbigpig/gin-layout/internal/pkg/errors" 11 | "github.com/wannanbigpig/gin-layout/internal/service" 12 | ) 13 | 14 | const ( 15 | defaultUploadPath = "default" 16 | ) 17 | 18 | // CommonController 通用控制器 19 | type CommonController struct { 20 | controller.Api 21 | } 22 | 23 | // NewCommonController 创建通用控制器实例 24 | func NewCommonController() *CommonController { 25 | return &CommonController{} 26 | } 27 | 28 | // Upload 上传文件 29 | func (api CommonController) Upload(c *gin.Context) { 30 | form, err := c.MultipartForm() 31 | if err != nil { 32 | api.FailCode(c, errors.InvalidParameter, "参数错误") 33 | return 34 | } 35 | 36 | // 获取用户ID 37 | uid := c.GetUint("uid") 38 | commonService := service.NewCommonService() 39 | commonService.SetAdminUserId(uid) 40 | 41 | // 获取上传路径参数 42 | path := getUploadPath(form) 43 | 44 | // 执行文件上传 45 | result, err := commonService.UploadFiles(form.File["files"], path) 46 | if err != nil { 47 | api.Err(c, err) 48 | return 49 | } 50 | 51 | api.Success(c, result) 52 | } 53 | 54 | // GetFile 获取文件(支持公开和私有文件访问) 55 | // 公开文件:无需认证,直接访问 56 | // 私有文件:需要认证,只能由文件所有者访问 57 | // 路由: GET /admin/v1/file/:uuid 58 | // 参数: uuid - 文件的UUID(32位十六进制字符串,不带连字符) 59 | func (api CommonController) GetFile(c *gin.Context) { 60 | fileUUID := c.Param("uuid") 61 | if fileUUID == "" { 62 | api.FailCode(c, errors.InvalidParameter, "文件UUID不能为空") 63 | return 64 | } 65 | 66 | commonService := service.NewCommonService() 67 | 68 | // 获取当前用户ID(如果已登录) 69 | var currentUID uint 70 | var checkAuth bool 71 | if uid, exists := c.Get("uid"); exists { 72 | currentUID = uid.(uint) 73 | checkAuth = true 74 | commonService.SetAdminUserId(currentUID) 75 | } else { 76 | checkAuth = false 77 | } 78 | 79 | // 获取文件路径(会自动检查权限) 80 | filePath, err := commonService.GetFileAccessPath(fileUUID, checkAuth, currentUID) 81 | if err != nil { 82 | api.Err(c, err) 83 | return 84 | } 85 | 86 | // 检查文件是否存在 87 | if _, err := os.Stat(filePath); os.IsNotExist(err) { 88 | api.FailCode(c, errors.NotFound, "文件不存在") 89 | return 90 | } 91 | 92 | // 返回文件 93 | c.File(filePath) 94 | } 95 | 96 | // getUploadPath 获取上传路径 97 | func getUploadPath(form *multipart.Form) string { 98 | if len(form.Value["path"]) > 0 && form.Value["path"][0] != "" { 99 | return form.Value["path"][0] 100 | } 101 | return defaultUploadPath 102 | } 103 | -------------------------------------------------------------------------------- /internal/validator/form/menu.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | type EditMenu struct { 4 | Id uint `form:"id" json:"id" binding:"omitempty"` 5 | Icon string `form:"icon" json:"icon" label:"图标" binding:"omitempty,max=255"` 6 | Title string `form:"title" json:"title" label:"标题" binding:"required,max=60"` 7 | Code string `form:"code" json:"code" label:"前端按钮权限标识" binding:"required_if=Type 3"` 8 | Path string `form:"path" json:"path" label:"路由地址" binding:"omitempty"` 9 | Name string `form:"name" json:"name" label:"前端路由名称" binding:"required_if_exist=Type 2"` 10 | AnimateEnter string `form:"animate_enter" json:"animate_enter" label:"进入动画,动画类参考URL_ADDRESS" binding:"omitempty"` 11 | AnimateLeave string `form:"animate_leave" json:"animate_leave" label:"离开动画,动画类参考URL_ADDRESS" binding:"omitempty"` 12 | AnimateDuration float32 `form:"animate_duration" json:"animate_duration" label:"动画持续时间" binding:"omitempty"` 13 | IsShow uint8 `form:"is_show" json:"is_show" label:"是否显示" binding:"omitempty,oneof=0 1"` // 0 否 1 是 14 | IsAuth uint8 `form:"is_auth" json:"is_auth" label:"是否需要授权" binding:"omitempty,oneof=0 1"` // 0 否 1 是 15 | IsNewWindow uint8 `form:"is_new_window" json:"is_new_window" label:"新窗口打开" binding:"omitempty,oneof=0 1"` // 0 否 1 是 16 | Sort uint `form:"sort" json:"sort" label:"排序" binding:"required"` 17 | Type uint8 `form:"type" json:"type" label:"菜单类型" binding:"required,oneof=1 2 3"` // 1 目录 2 菜单 3 按钮 18 | Pid uint `form:"pid" json:"pid" label:"上级菜单" binding:"omitempty"` 19 | Description string `form:"description" json:"description" label:"描述" binding:"omitempty"` 20 | ApiList []uint `form:"api_list" json:"api_list" label:"接口列表" binding:"omitempty"` 21 | Component string `form:"component" json:"component" label:"前端组件路径"` 22 | Status uint8 `form:"status" json:"status" label:"状态" binding:"omitempty,oneof=0 1"` // 0 禁用 1 启用 23 | Redirect string `form:"redirect" json:"redirect" label:"重定向地址" binding:"omitempty"` 24 | IsExternalLinks uint8 `form:"is_external_links" json:"is_external_links" label:"是否外链" binding:"omitempty,oneof=0 1"` 25 | } 26 | 27 | func NewEditMenuForm() *EditMenu { 28 | return &EditMenu{} 29 | } 30 | 31 | type ListMenu struct { 32 | Paginate 33 | Keyword string `form:"keyword" json:"keyword" binding:"omitempty"` // 关键字 34 | IsAuth *int8 `form:"is_auth" json:"is_auth" binding:"omitempty"` // 是否授权 35 | Status *int8 `form:"status" json:"status" binding:"omitempty"` // 状态 36 | } 37 | 38 | func NewMenuListQuery() *ListMenu { 39 | return &ListMenu{} 40 | } 41 | -------------------------------------------------------------------------------- /pkg/utils/crypto/crypto_aes.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/rand" 7 | "crypto/sha256" 8 | "encoding/base64" 9 | "errors" 10 | "io" 11 | ) 12 | 13 | // AESEncrypt 使用 AES-256-GCM 加密字符串 14 | // key: 加密密钥(字符串,会通过 SHA256 派生为 32 字节密钥) 15 | // plaintext: 待加密的明文 16 | // 返回: base64 编码的密文 17 | func AESEncrypt(key, plaintext string) (string, error) { 18 | if plaintext == "" { 19 | return "", nil 20 | } 21 | 22 | if key == "" { 23 | return "", errors.New("加密密钥不能为空") 24 | } 25 | 26 | // 从字符串密钥派生 32 字节密钥(AES-256 需要 32 字节) 27 | derivedKey := deriveKey256(key) 28 | 29 | // 创建 AES cipher 30 | block, err := aes.NewCipher(derivedKey) 31 | if err != nil { 32 | return "", err 33 | } 34 | 35 | // 创建 GCM 36 | gcm, err := cipher.NewGCM(block) 37 | if err != nil { 38 | return "", err 39 | } 40 | 41 | // 生成随机 nonce(12 字节,GCM 推荐大小) 42 | nonce := make([]byte, gcm.NonceSize()) 43 | if _, err := io.ReadFull(rand.Reader, nonce); err != nil { 44 | return "", err 45 | } 46 | 47 | // 加密 48 | ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) 49 | 50 | // 返回 base64 编码的密文 51 | return base64.StdEncoding.EncodeToString(ciphertext), nil 52 | } 53 | 54 | // AESDecrypt 使用 AES-256-GCM 解密字符串 55 | // key: 解密密钥(字符串,会通过 SHA256 派生为 32 字节密钥) 56 | // ciphertext: base64 编码的密文 57 | // 返回: 解密后的明文 58 | func AESDecrypt(key, ciphertext string) (string, error) { 59 | if ciphertext == "" { 60 | return "", nil 61 | } 62 | 63 | if key == "" { 64 | return "", errors.New("解密密钥不能为空") 65 | } 66 | 67 | // 从字符串密钥派生 32 字节密钥 68 | derivedKey := deriveKey256(key) 69 | 70 | // 解码 base64 71 | ciphertextBytes, err := base64.StdEncoding.DecodeString(ciphertext) 72 | if err != nil { 73 | return "", err 74 | } 75 | 76 | // 创建 AES cipher 77 | block, err := aes.NewCipher(derivedKey) 78 | if err != nil { 79 | return "", err 80 | } 81 | 82 | // 创建 GCM 83 | gcm, err := cipher.NewGCM(block) 84 | if err != nil { 85 | return "", err 86 | } 87 | 88 | // 检查密文长度 89 | nonceSize := gcm.NonceSize() 90 | if len(ciphertextBytes) < nonceSize { 91 | return "", errors.New("密文长度不足") 92 | } 93 | 94 | // 提取 nonce 和密文 95 | nonce, ciphertextBytes := ciphertextBytes[:nonceSize], ciphertextBytes[nonceSize:] 96 | 97 | // 解密 98 | plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil) 99 | if err != nil { 100 | return "", err 101 | } 102 | 103 | return string(plaintext), nil 104 | } 105 | 106 | // deriveKey256 从字符串密钥派生 32 字节密钥(用于 AES-256) 107 | func deriveKey256(key string) []byte { 108 | hash := sha256.Sum256([]byte(key)) 109 | return hash[:] 110 | } 111 | -------------------------------------------------------------------------------- /internal/validator/form/admin_user.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | type EditAdminUser struct { 4 | Id uint `form:"id" json:"id" label:"用户ID" binding:"omitempty"` 5 | Username *string `form:"username" json:"username" label:"用户名" binding:"omitempty,min=3,max=20,regexp=^[a-zA-Z0-9_]+$"` // 编辑时可选,创建时必填(在Service层判断) 6 | Nickname *string `form:"nickname" json:"nickname" label:"昵称" binding:"omitempty"` // 编辑时可选,创建时必填(在Service层判断) 7 | Password *string `form:"password" json:"password" label:"密码" binding:"omitempty,min=6,max=32"` 8 | PhoneNumber *string `form:"phone_number" json:"phone_number" label:"手机号" binding:"omitempty,phone_number"` 9 | CountryCode *string `form:"country_code" json:"country_code" label:"国家代码" binding:"omitempty"` 10 | Email *string `form:"email" json:"email" label:"邮箱" binding:"omitempty,email"` 11 | Status *int8 `form:"status" json:"status" label:"状态" binding:"omitempty,oneof=0 1"` 12 | Avatar *string `form:"avatar" json:"avatar" label:"头像" binding:"omitempty"` 13 | DeptIds []uint `form:"dept_ids" json:"dept_ids" label:"部门ID" binding:"omitempty"` 14 | } 15 | 16 | // NewEditAdminUser 创建一个新的管理员用户编辑器 17 | func NewEditAdminUser() *EditAdminUser { 18 | return &EditAdminUser{} 19 | } 20 | 21 | type AdminUserList struct { 22 | Paginate 23 | Email string `form:"email" json:"email" binding:"omitempty,email"` 24 | UserName string `form:"username" json:"username" binding:"omitempty"` 25 | Status *int8 `form:"status" json:"status" binding:"omitempty,oneof=0 1"` 26 | PhoneNumber string `form:"phone_number" json:"phone_number" binding:"omitempty,phone_number"` 27 | NickName string `form:"nickname" json:"nickname" binding:"omitempty"` 28 | ID uint `form:"id" json:"id" binding:"omitempty"` 29 | DeptId uint `form:"dept_id" json:"dept_id" binding:"omitempty"` 30 | } 31 | 32 | // NewAdminUserListQuery 创建一个新的管理员用户列表查询 33 | func NewAdminUserListQuery() *AdminUserList { 34 | return &AdminUserList{} 35 | } 36 | 37 | // UpdateProfile 更新个人资料表单 38 | type UpdateProfile struct { 39 | Nickname *string `form:"nickname" json:"nickname" label:"昵称" binding:"omitempty"` 40 | Password *string `form:"password" json:"password" label:"密码" binding:"omitempty,min=6,max=32"` 41 | PhoneNumber *string `form:"phone_number" json:"phone_number" label:"手机号" binding:"omitempty,phone_number"` 42 | CountryCode *string `form:"country_code" json:"country_code" label:"国家代码" binding:"omitempty"` 43 | Email *string `form:"email" json:"email" label:"邮箱" binding:"omitempty,email"` 44 | Avatar *string `form:"avatar" json:"avatar" label:"头像" binding:"omitempty"` // 头像:文件ID或外部链接(https开头) 45 | } 46 | 47 | // NewUpdateProfile 创建一个新的更新个人资料表单 48 | func NewUpdateProfile() *UpdateProfile { 49 | return &UpdateProfile{} 50 | } 51 | -------------------------------------------------------------------------------- /pkg/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/hex" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "runtime" 10 | "strings" 11 | ) 12 | 13 | // If 模拟简单的三元操作 14 | func If[T any](condition bool, trueVal, falseVal T) T { 15 | if condition { 16 | return trueVal 17 | } 18 | return falseVal 19 | } 20 | 21 | // WouldCauseCycle 检查新的父节点是否是当前节点的子节点,防止循环引用 22 | func WouldCauseCycle(id, parentPid uint, parentPids string) bool { 23 | if id == 0 { 24 | return false 25 | } 26 | if parentPid == id { 27 | return true 28 | } 29 | // 检测循环引用 30 | idStr := fmt.Sprintf("%d", id) 31 | pidsSlice := strings.Split(parentPids, ",") 32 | for _, pid := range pidsSlice { 33 | if pid == idStr { 34 | return true 35 | } 36 | } 37 | return false 38 | } 39 | 40 | // GetRunPath 获取执行目录作为默认目录 41 | func GetRunPath() string { 42 | currentPath, err := os.Getwd() 43 | if err != nil { 44 | return "" 45 | } 46 | return currentPath 47 | } 48 | 49 | // GetFileDirectoryToCaller 根据运行堆栈信息获取文件目录,skip 默认1 50 | func GetFileDirectoryToCaller(opts ...int) (directory string, ok bool) { 51 | var filename string 52 | directory = "" 53 | skip := 1 54 | if opts != nil { 55 | skip = opts[0] 56 | } 57 | if _, filename, _, ok = runtime.Caller(skip); ok { 58 | directory = filepath.Dir(filename) 59 | } 60 | return 61 | } 62 | 63 | // GetCurrentAbPathByExecutable 获取当前执行文件所在目录的绝对路径 64 | // 这是最可靠的获取二进制文件所在目录的方法,适用于所有环境 65 | func GetCurrentAbPathByExecutable() (string, error) { 66 | exePath, err := os.Executable() 67 | if err != nil { 68 | return "", fmt.Errorf("获取执行文件路径失败: %w", err) 69 | } 70 | 71 | // 解析符号链接,获取真实路径 72 | realPath, err := filepath.EvalSymlinks(exePath) 73 | if err != nil { 74 | // 如果解析符号链接失败,使用原始路径 75 | realPath = exePath 76 | } 77 | 78 | // 获取目录路径并转换为绝对路径 79 | dir := filepath.Dir(realPath) 80 | absDir, err := filepath.Abs(dir) 81 | if err != nil { 82 | return "", fmt.Errorf("获取绝对路径失败: %w", err) 83 | } 84 | 85 | return absDir, nil 86 | } 87 | 88 | // GetCurrentPath 获取当前执行文件路径(始终使用二进制文件所在目录) 89 | // 这是统一的路径获取方法,确保所有环境行为一致 90 | func GetCurrentPath() (dir string, err error) { 91 | return GetCurrentAbPathByExecutable() 92 | } 93 | 94 | // GetDefaultPath 获取当前执行文件路径,如果是临时目录则获取运行命令的工作目录 95 | func GetDefaultPath() (dir string, err error) { 96 | if os.Getenv("GO_ENV") != "development" { 97 | dir, err = GetCurrentAbPathByExecutable() 98 | if err != nil { 99 | return "", err 100 | } 101 | } else { 102 | dir = GetRunPath() 103 | } 104 | 105 | return dir, nil 106 | } 107 | 108 | // MD5 计算字符串的 MD5 值 109 | func MD5(str string) string { 110 | // 计算 MD5 哈希 111 | hash := md5.Sum([]byte(str)) 112 | 113 | // 将哈希值转换为十六进制字符串 114 | return hex.EncodeToString(hash[:]) 115 | } 116 | -------------------------------------------------------------------------------- /internal/model/a_admin_users.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "github.com/wannanbigpig/gin-layout/internal/model/modelDict" 5 | "github.com/wannanbigpig/gin-layout/internal/pkg/utils" 6 | utils2 "github.com/wannanbigpig/gin-layout/pkg/utils" 7 | ) 8 | 9 | // 管理员状态常量 10 | const ( 11 | AdminUserStatusEnabled uint8 = 1 // 启用 12 | AdminUserStatusDisabled uint8 = 0 // 禁用(数据库定义:1启用 0禁用) 13 | ) 14 | 15 | // 管理员状态字典 16 | var AdminUserStatusDict modelDict.Dict = map[uint8]string{ 17 | AdminUserStatusEnabled: "启用", 18 | AdminUserStatusDisabled: "禁用", 19 | } 20 | 21 | // AdminUser 总管理员表 22 | type AdminUser struct { 23 | ContainsDeleteBaseModel 24 | IsSuperAdmin uint8 `json:"is_super_admin"` // 是否是总管理员 25 | Nickname string `json:"nickname"` // 用户昵称 26 | Username string `json:"username"` // 用户名 27 | Password string `json:"password"` // 密码 28 | PhoneNumber string `json:"phone_number"` // 手机号 29 | FullPhoneNumber string `json:"full_phone_number"` // 完整手机号 30 | CountryCode string `json:"country_code"` // 国际区号 31 | Email string `json:"email"` // 邮箱 32 | Avatar string `json:"avatar"` // 头像 33 | Status int8 `json:"status"` // 状态 1启用 2禁用 34 | LastLogin utils.FormatDate `json:"last_login"` // 最后登录时间 35 | LastIp string `json:"last_ip"` // 最后登录IP 36 | Department []Department `json:"department" gorm:"many2many:a_admin_user_department_map;foreignKey:ID;joinForeignKey:Uid;References:ID;joinReferences:DeptId"` 37 | RoleList []AdminUserRoleMap `json:"role_list" gorm:"foreignKey:uid;references:id"` 38 | } 39 | 40 | func NewAdminUsers() *AdminUser { 41 | return &AdminUser{} 42 | } 43 | 44 | // TableName 获取表名 45 | func (m *AdminUser) TableName() string { 46 | return "a_admin_user" 47 | } 48 | 49 | // Register 用户注册,写入到DB 50 | func (m *AdminUser) Register() error { 51 | m.Password, _ = utils2.PasswordHash(m.Password) 52 | result := m.DB().Create(m) 53 | return result.Error 54 | } 55 | 56 | // ChangePassword 修改密码 57 | func (m *AdminUser) ChangePassword() error { 58 | m.Password, _ = utils2.PasswordHash(m.Password) 59 | return m.DB(m).Update("password", m.Password).Error 60 | } 61 | 62 | // GetUserInfo 根据名称获取用户信息 63 | func (m *AdminUser) GetUserInfo(username string) error { 64 | if err := m.DB().Where("username", username).First(m).Error; err != nil { 65 | return err 66 | } 67 | return nil 68 | } 69 | 70 | // IsSuperAdminMap 是否为超级管理员映射 71 | func (m *AdminUser) IsSuperAdminMap() string { 72 | return modelDict.IsMap.Map(m.IsSuperAdmin) 73 | } 74 | 75 | // StatusMap 状态映射 76 | func (m *AdminUser) StatusMap() string { 77 | return AdminUserStatusDict.Map(uint8(m.Status)) 78 | } 79 | -------------------------------------------------------------------------------- /internal/middleware/parse_token.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/golang-jwt/jwt/v5" 6 | 7 | "github.com/wannanbigpig/gin-layout/internal/global" 8 | "github.com/wannanbigpig/gin-layout/internal/model" 9 | "github.com/wannanbigpig/gin-layout/internal/pkg/utils/token" 10 | "github.com/wannanbigpig/gin-layout/internal/service/permission" 11 | ) 12 | 13 | // ParseTokenHandler 全局token解析中间件(所有路由都走) 14 | // 功能: 15 | // - 尝试从请求头提取token(不强制要求) 16 | // - 如果token存在且有效,解析并设置用户信息到context 17 | // - 如果token不存在或无效,静默继续执行(用于可选认证的路由) 18 | // 19 | // 注意:此中间件不会阻止请求,即使token无效也会继续执行 20 | func ParseTokenHandler() gin.HandlerFunc { 21 | return func(c *gin.Context) { 22 | // 提前返回:如果没有token,直接继续执行 23 | accessToken, err := extractAccessToken(c) 24 | if err != nil || accessToken == "" { 25 | c.Next() 26 | return 27 | } 28 | 29 | // 验证token并获取用户信息 30 | adminUser, jwtID := validateToken(c, accessToken) 31 | if adminUser == nil { 32 | // token无效,静默继续(可选认证) 33 | c.Next() 34 | return 35 | } 36 | 37 | // token有效,设置用户信息和jwt_id到上下文 38 | setUserContext(c, adminUser, jwtID) 39 | c.Next() 40 | } 41 | } 42 | 43 | // extractAccessToken 从请求头中提取访问令牌 44 | // 返回:token字符串和错误信息(如果token不存在或格式错误) 45 | func extractAccessToken(c *gin.Context) (string, error) { 46 | authorization := c.GetHeader("Authorization") 47 | return token.GetAccessToken(authorization) 48 | } 49 | 50 | // validateToken 验证Token并返回用户信息和JWT ID 51 | // 优化:先解析token获取claims(包括jwt_id),再验证token有效性 52 | // 返回:用户信息对象和JWT ID(如果验证成功),否则返回nil和空字符串 53 | func validateToken(c *gin.Context, accessToken string) (*model.AdminUser, string) { 54 | // 先解析token获取claims(包括jwt_id),避免后续重复解析 55 | claims := &token.AdminCustomClaims{} 56 | if err := token.Parse(accessToken, claims, jwt.WithSubject(global.PcAdminSubject), jwt.WithIssuer(global.Issuer)); err != nil { 57 | return nil, "" 58 | } 59 | 60 | // 使用CheckToken进行完整验证(包括过期检查、用户状态、黑名单等) 61 | loginService := permission.NewLoginService() 62 | loginService.SetCtx(c) 63 | adminUser, ok := loginService.CheckToken(accessToken) 64 | if !ok { 65 | return nil, "" 66 | } 67 | 68 | // 验证成功,返回用户信息和jwt_id 69 | return adminUser, claims.ID 70 | } 71 | 72 | // setUserContext 设置用户信息到上下文 73 | // 将用户的基本信息和完整对象都存储到context,供后续中间件和控制器使用 74 | // 参数: 75 | // - c: gin上下文 76 | // - adminUser: 管理员用户对象 77 | // - jwtID: JWT唯一标识(用于token撤销等操作) 78 | func setUserContext(c *gin.Context, adminUser *model.AdminUser, jwtID string) { 79 | // 设置用户基本信息(供日志、权限验证等使用) 80 | c.Set("uid", adminUser.ID) 81 | c.Set("username", adminUser.Username) 82 | c.Set("full_phone_number", adminUser.FullPhoneNumber) 83 | c.Set("nickname", adminUser.Nickname) 84 | c.Set("email", adminUser.Email) 85 | 86 | // 设置JWT ID(用于token撤销、黑名单等操作) 87 | if jwtID != "" { 88 | c.Set("jwt_id", jwtID) 89 | } 90 | 91 | // 将完整的用户对象也存储到context,避免后续中间件重复查询数据库 92 | c.Set("admin_user", adminUser) 93 | } 94 | -------------------------------------------------------------------------------- /internal/pkg/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "io" 5 | "path/filepath" 6 | "sync" 7 | "time" 8 | 9 | rotatelogs "github.com/lestrrat-go/file-rotatelogs" 10 | "go.uber.org/zap" 11 | "go.uber.org/zap/zapcore" 12 | "gopkg.in/natefinch/lumberjack.v2" 13 | 14 | "github.com/wannanbigpig/gin-layout/config" 15 | ) 16 | 17 | var Logger *zap.Logger 18 | var once sync.Once 19 | 20 | func InitLogger() { 21 | once.Do(func() { Logger = createZapLog() }) 22 | } 23 | 24 | func Info(msg string, fields ...zap.Field) { 25 | Logger.Info(msg, fields...) 26 | } 27 | 28 | func Error(msg string, fields ...zap.Field) { 29 | Logger.Error(msg, fields...) 30 | } 31 | 32 | // initZapLog 初始化 zap 日志 33 | func createZapLog() *zap.Logger { 34 | // 开启 debug 35 | if config.Config.Logger.Output == "stderr" { 36 | if Logger, err := zap.NewDevelopment(); err == nil { 37 | return Logger 38 | } else { 39 | panic("创建zap日志包失败,详情:" + err.Error()) 40 | } 41 | } 42 | 43 | encoderConfig := zap.NewProductionEncoderConfig() 44 | encoderConfig.EncodeTime = func(t time.Time, enc zapcore.PrimitiveArrayEncoder) { 45 | enc.AppendString(t.Format("2006-01-02 15:04:05.000")) 46 | } 47 | 48 | // 在日志文件中使用大写字母记录日志级别 49 | encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder 50 | encoder := zapcore.NewConsoleEncoder(encoderConfig) 51 | filename := filepath.Join(config.Config.BasePath, "logs", config.Config.Logger.Filename) 52 | var writer zapcore.WriteSyncer 53 | if config.Config.Logger.DefaultDivision == "size" { 54 | // 按文件大小切割日志 55 | writer = zapcore.AddSync(getLumberJackWriter(filename)) 56 | } else { 57 | // 按天切割日志 58 | writer = zapcore.AddSync(getRotateWriter(filename)) 59 | } 60 | 61 | zapCore := zapcore.NewCore(encoder, writer, zap.InfoLevel) 62 | // zap.AddStacktrace(zap.WarnLevel) 63 | return zap.New(zapCore, zap.AddCaller()) 64 | } 65 | 66 | // getRotateWriter 按日期切割日志 67 | func getRotateWriter(filename string) io.Writer { 68 | maxAge := time.Duration(config.Config.Logger.DivisionTime.MaxAge) 69 | rotationTime := time.Duration(config.Config.Logger.DivisionTime.RotationTime) 70 | hook, err := rotatelogs.New( 71 | filename+".%Y%m%d", 72 | rotatelogs.WithLinkName(filename), 73 | rotatelogs.WithMaxAge(time.Hour*24*maxAge), 74 | rotatelogs.WithRotationTime(time.Hour*rotationTime), // 默认一天 75 | ) 76 | 77 | if err != nil { 78 | panic(err) 79 | } 80 | 81 | return hook 82 | } 83 | 84 | // getLumberJackWriter 按文件切割日志 85 | func getLumberJackWriter(filename string) io.Writer { 86 | // 日志切割配置 87 | return &lumberjack.Logger{ 88 | Filename: filename, // 日志文件的位置 89 | MaxSize: config.Config.Logger.DivisionSize.MaxSize, // 在进行切割之前,日志文件的最大大小(以MB为单位) 90 | MaxBackups: config.Config.Logger.DivisionSize.MaxBackups, // 保留旧文件的最大个数 91 | MaxAge: config.Config.Logger.DivisionSize.MaxAge, // 保留旧文件的最大天数 92 | Compress: config.Config.Logger.DivisionSize.Compress, // 是否压缩/归档旧文件 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master, x_l_admin ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master, x_l_admin ] 20 | schedule: 21 | - cron: '27 0 * * 6' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'go' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 -------------------------------------------------------------------------------- /internal/controller/admin_v1/auth_role.go: -------------------------------------------------------------------------------- 1 | package admin_v1 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | 6 | "github.com/wannanbigpig/gin-layout/internal/controller" 7 | e "github.com/wannanbigpig/gin-layout/internal/pkg/errors" 8 | "github.com/wannanbigpig/gin-layout/internal/service/permission" 9 | "github.com/wannanbigpig/gin-layout/internal/validator" 10 | "github.com/wannanbigpig/gin-layout/internal/validator/form" 11 | ) 12 | 13 | // RoleController 角色控制器 14 | type RoleController struct { 15 | controller.Api 16 | } 17 | 18 | // NewRoleController 创建角色控制器实例 19 | func NewRoleController() *RoleController { 20 | return &RoleController{} 21 | } 22 | 23 | // List 分页查询角色列表 24 | func (api RoleController) List(c *gin.Context) { 25 | params := form.NewRoleListQuery() 26 | if err := validator.CheckQueryParams(c, ¶ms); err != nil { 27 | return 28 | } 29 | 30 | result := permission.NewRoleService().List(params) 31 | api.Success(c, result) 32 | } 33 | 34 | // Edit 编辑角色 35 | func (api RoleController) Edit(c *gin.Context) { 36 | params := form.NewEditRoleForm() 37 | if err := validator.CheckPostParams(c, ¶ms); err != nil { 38 | return 39 | } 40 | 41 | if err := permission.NewRoleService().Edit(params); err != nil { 42 | api.Err(c, err) 43 | return 44 | } 45 | 46 | api.Success(c, nil) 47 | } 48 | 49 | // Create 新增角色 50 | func (api RoleController) Create(c *gin.Context) { 51 | params := form.NewEditRoleForm() 52 | if err := validator.CheckPostParams(c, ¶ms); err != nil { 53 | return 54 | } 55 | 56 | // 确保 ID 为空,表示新增 57 | params.Id = 0 58 | 59 | if err := permission.NewRoleService().Edit(params); err != nil { 60 | api.Err(c, err) 61 | return 62 | } 63 | 64 | api.Success(c, nil) 65 | } 66 | 67 | // Update 更新角色 68 | func (api RoleController) Update(c *gin.Context) { 69 | params := form.NewEditRoleForm() 70 | if err := validator.CheckPostParams(c, ¶ms); err != nil { 71 | return 72 | } 73 | 74 | // 确保 ID 不为空,表示更新 75 | if params.Id == 0 { 76 | api.Err(c, e.NewBusinessError(1, "更新角色时ID不能为空")) 77 | return 78 | } 79 | 80 | if err := permission.NewRoleService().Edit(params); err != nil { 81 | api.Err(c, err) 82 | return 83 | } 84 | 85 | api.Success(c, nil) 86 | } 87 | 88 | // Delete 删除角色 89 | func (api RoleController) Delete(c *gin.Context) { 90 | params := form.NewIdForm() 91 | if err := validator.CheckPostParams(c, ¶ms); err != nil { 92 | return 93 | } 94 | 95 | if err := permission.NewRoleService().Delete(params.ID); err != nil { 96 | api.Err(c, err) 97 | return 98 | } 99 | 100 | api.Success(c, nil) 101 | } 102 | 103 | // Detail 获取角色详情 104 | func (api RoleController) Detail(c *gin.Context) { 105 | query := form.NewIdForm() 106 | if err := validator.CheckQueryParams(c, &query); err != nil { 107 | return 108 | } 109 | 110 | detail, err := permission.NewRoleService().Detail(query.ID) 111 | if err != nil { 112 | api.Err(c, err) 113 | return 114 | } 115 | 116 | api.Success(c, detail) 117 | } 118 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "time" 7 | 8 | "github.com/spf13/cobra" 9 | "go.uber.org/zap" 10 | 11 | "github.com/wannanbigpig/gin-layout/cmd/command" 12 | "github.com/wannanbigpig/gin-layout/cmd/cron" 13 | "github.com/wannanbigpig/gin-layout/cmd/service" 14 | "github.com/wannanbigpig/gin-layout/cmd/version" 15 | "github.com/wannanbigpig/gin-layout/config" 16 | "github.com/wannanbigpig/gin-layout/internal/global" 17 | log "github.com/wannanbigpig/gin-layout/internal/pkg/logger" 18 | ) 19 | 20 | const ( 21 | welcomeMessage = "Welcome to go-layout. Use -h to see more commands" 22 | errorLoadingLocation = "Error loading location: %v" 23 | ) 24 | 25 | var ( 26 | rootCmd = &cobra.Command{ 27 | Use: "go-layout", 28 | Short: "go-layout", 29 | SilenceUsage: true, 30 | Long: `Gin framework is used as the core of this project to build a scaffold, 31 | based on the project can be quickly completed business development, out of the box 📦`, 32 | PersistentPreRun: func(cmd *cobra.Command, args []string) { 33 | initializeConfig() 34 | initializeTimezone() 35 | initializeLogger() 36 | }, 37 | Run: func(cmd *cobra.Command, args []string) { 38 | if printVersion { 39 | fmt.Println(global.Version) 40 | return 41 | } 42 | fmt.Printf("%s\n", welcomeMessage) 43 | }, 44 | } 45 | configPath string 46 | printVersion bool 47 | ) 48 | 49 | func init() { 50 | registerFlags() 51 | registerCommands() 52 | } 53 | 54 | // registerFlags 注册命令行标志 55 | func registerFlags() { 56 | rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "The absolute path of the configuration file") 57 | rootCmd.Flags().BoolVarP(&printVersion, "version", "v", false, "Get version info") 58 | } 59 | 60 | // registerCommands 注册子命令 61 | func registerCommands() { 62 | rootCmd.AddCommand(version.Cmd) // 查看版本: go-layout version 63 | rootCmd.AddCommand(service.Cmd) // 启动服务: go-layout service 64 | rootCmd.AddCommand(command.Cmd) // 运行命令: go-layout command demo / go-layout command init api-route 65 | rootCmd.AddCommand(cron.Cmd) // 启动计划任务: go-layout cron 66 | } 67 | 68 | // initializeConfig 初始化配置 69 | func initializeConfig() { 70 | config.InitConfig(configPath) 71 | } 72 | 73 | // initializeTimezone 初始化时区 74 | func initializeTimezone() { 75 | if config.Config.Timezone == nil { 76 | return 77 | } 78 | 79 | location, err := time.LoadLocation(*config.Config.Timezone) 80 | if err != nil { 81 | log.Logger.Error(fmt.Sprintf(errorLoadingLocation, err), zap.Error(err)) 82 | fmt.Printf(errorLoadingLocation+"\n", err) 83 | return 84 | } 85 | time.Local = location 86 | } 87 | 88 | // initializeLogger 初始化日志 89 | func initializeLogger() { 90 | log.InitLogger() 91 | } 92 | 93 | // Execute 执行命令 94 | func Execute() { 95 | if err := rootCmd.Execute(); err != nil { 96 | log.Logger.Error("Command execution failed", zap.Error(err)) 97 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 98 | os.Exit(1) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /internal/model/a_menu.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "github.com/wannanbigpig/gin-layout/internal/model/modelDict" 5 | ) 6 | 7 | // Menu 权限路由表 8 | type Menu struct { 9 | ContainsDeleteBaseModel 10 | Icon string `json:"icon"` // 图标 11 | Title string `json:"title"` // 中文标题 12 | Code string `json:"code"` // 前端权限标识 13 | Path string `json:"path"` // 前端路由 14 | FullPath string `json:"full_path"` // 完整前端路由 15 | IsShow uint8 `json:"is_show"` // 是否显示,1是 0否 16 | IsNewWindow uint8 `json:"is_new_window"` // 是否新窗口打开, 1是 0否 17 | Sort uint `json:"sort"` // 排序,数字越大,排名越靠前 18 | Type uint8 `json:"type"` // 菜单类型,1目录,2菜单,3按钮 19 | Pid uint `json:"pid"` // 上级菜单id 20 | Level uint8 `json:"level"` // 层级 21 | Pids string `json:"pids"` // 层级序列,多个用英文逗号隔开 22 | ChildrenNum uint `json:"children_num"` // 子集数量 23 | Description string `json:"description"` // 描述 24 | IsAuth uint8 `json:"is_auth"` // 是否鉴权 0:否 1:是 25 | IsExternalLinks uint8 `json:"is_external_links"` // 是否外链 0:否 1:是 26 | Name string `json:"name"` // 路由名称 27 | Component string `json:"component"` // 组件路径 28 | AnimateEnter string `json:"animate_enter"` // 进入动画 29 | AnimateLeave string `json:"animate_leave"` // 离开动画 30 | AnimateDuration float32 `json:"animate_duration"` // 动画时长 31 | ApiList []MenuApiMap `json:"api_list" gorm:"foreignkey:menu_id;references:id"` 32 | Status uint8 `json:"status"` // 状态,0禁用,1启用 33 | Redirect string `json:"redirect"` // 重定向路由名称 34 | } 35 | 36 | const CATALOGUE uint8 = 1 37 | const MENU uint8 = 2 38 | const BUTTON uint8 = 3 39 | 40 | var MenuType modelDict.Dict = map[uint8]string{ 41 | CATALOGUE: "目录", 42 | MENU: "菜单", 43 | BUTTON: "按钮", 44 | } 45 | 46 | func (m *Menu) MenuTypeMap() string { 47 | return MenuType.Map(m.Type) 48 | } 49 | 50 | func (m *Menu) IsExternalLinksMap() string { 51 | return modelDict.IsMap.Map(m.IsExternalLinks) 52 | } 53 | 54 | func (m *Menu) IsAuthMap() string { 55 | return modelDict.IsMap.Map(m.IsAuth) 56 | } 57 | 58 | func (m *Menu) IsShowMap() string { 59 | return modelDict.IsMap.Map(m.IsShow) 60 | } 61 | 62 | func (m *Menu) IsNewWindowMap() string { 63 | return modelDict.IsMap.Map(m.IsNewWindow) 64 | } 65 | 66 | // StatusMap 状态映射 67 | func (m *Menu) StatusMap() string { 68 | return modelDict.IsMap.Map(m.Status) 69 | } 70 | 71 | func (m *Menu) GetApiIds() []uint { 72 | // 如果 ApiList 为空,直接返回空切片 73 | if len(m.ApiList) == 0 { 74 | return []uint{} 75 | } 76 | 77 | // 预分配切片容量,避免多次内存分配 78 | apiIds := make([]uint, 0, len(m.ApiList)) 79 | for _, v := range m.ApiList { 80 | apiIds = append(apiIds, v.ApiId) 81 | } 82 | return apiIds 83 | } 84 | 85 | func NewMenu() *Menu { 86 | return &Menu{} 87 | } 88 | 89 | // TableName 获取表名 90 | func (m *Menu) TableName() string { 91 | return "a_menu" 92 | } 93 | -------------------------------------------------------------------------------- /internal/controller/admin_v1/auth_menu.go: -------------------------------------------------------------------------------- 1 | package admin_v1 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | 6 | "github.com/wannanbigpig/gin-layout/internal/controller" 7 | e "github.com/wannanbigpig/gin-layout/internal/pkg/errors" 8 | "github.com/wannanbigpig/gin-layout/internal/service/permission" 9 | "github.com/wannanbigpig/gin-layout/internal/validator" 10 | "github.com/wannanbigpig/gin-layout/internal/validator/form" 11 | ) 12 | 13 | // MenuController 菜单控制器 14 | type MenuController struct { 15 | controller.Api 16 | } 17 | 18 | // NewMenuController 创建菜单控制器实例 19 | func NewMenuController() *MenuController { 20 | return &MenuController{} 21 | } 22 | 23 | // Edit 编辑菜单 24 | func (api MenuController) Edit(c *gin.Context) { 25 | params := form.NewEditMenuForm() 26 | if err := validator.CheckPostParams(c, ¶ms); err != nil { 27 | return 28 | } 29 | 30 | if err := permission.NewMenuService().Edit(params); err != nil { 31 | api.Err(c, err) 32 | return 33 | } 34 | 35 | api.Success(c, nil) 36 | } 37 | 38 | // Create 新增菜单 39 | func (api MenuController) Create(c *gin.Context) { 40 | params := form.NewEditMenuForm() 41 | if err := validator.CheckPostParams(c, ¶ms); err != nil { 42 | return 43 | } 44 | 45 | // 确保 ID 为空,表示新增 46 | params.Id = 0 47 | 48 | if err := permission.NewMenuService().Edit(params); err != nil { 49 | api.Err(c, err) 50 | return 51 | } 52 | 53 | api.Success(c, nil) 54 | } 55 | 56 | // Update 更新菜单 57 | func (api MenuController) Update(c *gin.Context) { 58 | params := form.NewEditMenuForm() 59 | if err := validator.CheckPostParams(c, ¶ms); err != nil { 60 | return 61 | } 62 | 63 | // 确保 ID 不为空,表示更新 64 | if params.Id == 0 { 65 | api.Err(c, e.NewBusinessError(1, "更新菜单时ID不能为空")) 66 | return 67 | } 68 | 69 | if err := permission.NewMenuService().Edit(params); err != nil { 70 | api.Err(c, err) 71 | return 72 | } 73 | 74 | api.Success(c, nil) 75 | } 76 | 77 | // UpdateAllMenuPermissions 批量更新菜单权限到casbin 78 | func (api MenuController) UpdateAllMenuPermissions(c *gin.Context) { 79 | if err := permission.NewMenuService().UpdateAllMenuPermissions(); err != nil { 80 | api.Err(c, err) 81 | return 82 | } 83 | 84 | api.Success(c, nil) 85 | } 86 | 87 | // Detail 获取菜单详情 88 | func (api MenuController) Detail(c *gin.Context) { 89 | query := form.NewIdForm() 90 | if err := validator.CheckQueryParams(c, &query); err != nil { 91 | return 92 | } 93 | 94 | detail, err := permission.NewMenuService().Detail(query.ID) 95 | if err != nil { 96 | api.Err(c, err) 97 | return 98 | } 99 | 100 | api.Success(c, detail) 101 | } 102 | 103 | // List 查询菜单列表 104 | func (api MenuController) List(c *gin.Context) { 105 | params := form.NewMenuListQuery() 106 | if err := validator.CheckQueryParams(c, ¶ms); err != nil { 107 | return 108 | } 109 | result := permission.NewMenuService().List(params) 110 | api.Success(c, result) 111 | } 112 | 113 | // Delete 删除菜单 114 | func (api MenuController) Delete(c *gin.Context) { 115 | params := form.NewIdForm() 116 | if err := validator.CheckPostParams(c, ¶ms); err != nil { 117 | return 118 | } 119 | 120 | if err := permission.NewMenuService().Delete(params.ID); err != nil { 121 | api.Err(c, err) 122 | return 123 | } 124 | 125 | api.Success(c, nil) 126 | } 127 | -------------------------------------------------------------------------------- /internal/controller/admin_v1/auth.go: -------------------------------------------------------------------------------- 1 | package admin_v1 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/mssola/useragent" 6 | 7 | "github.com/wannanbigpig/gin-layout/internal/controller" 8 | "github.com/wannanbigpig/gin-layout/internal/pkg/errors" 9 | "github.com/wannanbigpig/gin-layout/internal/pkg/utils/token" 10 | "github.com/wannanbigpig/gin-layout/internal/service/permission" 11 | "github.com/wannanbigpig/gin-layout/internal/validator" 12 | "github.com/wannanbigpig/gin-layout/internal/validator/form" 13 | "github.com/wannanbigpig/gin-layout/pkg/utils/captcha" 14 | ) 15 | 16 | // LoginController 登录控制器 17 | type LoginController struct { 18 | controller.Api 19 | } 20 | 21 | // NewLoginController 创建登录控制器实例 22 | func NewLoginController() *LoginController { 23 | return &LoginController{} 24 | } 25 | 26 | // Login 管理员用户登录 27 | func (api LoginController) Login(c *gin.Context) { 28 | params := form.NewLoginForm() 29 | if err := validator.CheckPostParams(c, ¶ms); err != nil { 30 | return 31 | } 32 | 33 | // 构建登录日志信息 34 | logInfo := buildLoginLogInfo(c) 35 | 36 | // 校验验证码 37 | if !captcha.Verify(params.CaptchaID, params.Captcha) { 38 | // 记录验证码错误日志 39 | loginService := permission.NewLoginService() 40 | loginService.RecordLoginFailLog(params.UserName, "验证码错误", logInfo) 41 | api.FailCode(c, errors.CaptchaErr) 42 | return 43 | } 44 | 45 | // 执行登录 46 | result, err := permission.NewLoginService().Login(params.UserName, params.PassWord, logInfo) 47 | if err != nil { 48 | api.Err(c, err) 49 | return 50 | } 51 | 52 | api.Success(c, result) 53 | } 54 | 55 | // buildLoginLogInfo 构建登录日志信息 56 | func buildLoginLogInfo(c *gin.Context) permission.LoginLogInfo { 57 | userAgentStr := c.Request.UserAgent() 58 | 59 | // 解析 user_agent 获取 OS 和 Browser 信息 60 | ua := useragent.New(userAgentStr) 61 | os := ua.OS() 62 | browser, _ := ua.Browser() 63 | 64 | return permission.LoginLogInfo{ 65 | IP: c.ClientIP(), 66 | UserAgent: userAgentStr, 67 | OS: os, 68 | Browser: browser, 69 | } 70 | } 71 | 72 | // LoginCaptcha 生成登录验证码 73 | func (api LoginController) LoginCaptcha(c *gin.Context) { 74 | result, err := captcha.Generate() 75 | if err != nil { 76 | api.Err(c, err) 77 | return 78 | } 79 | 80 | api.Success(c, result) 81 | } 82 | 83 | // Logout 管理员用户退出登录 84 | func (api LoginController) Logout(c *gin.Context) { 85 | accessToken, err := extractAccessToken(c) 86 | if err != nil { 87 | // Token提取失败,视为已退出 88 | api.Success(c, nil) 89 | return 90 | } 91 | 92 | if err := permission.NewLoginService().Logout(accessToken); err != nil { 93 | api.Err(c, err) 94 | return 95 | } 96 | 97 | api.Success(c, nil) 98 | } 99 | 100 | // CheckToken 检查Token是否有效 101 | func (api LoginController) CheckToken(c *gin.Context) { 102 | accessToken, err := extractAccessToken(c) 103 | if err != nil { 104 | api.Err(c, err) 105 | return 106 | } 107 | 108 | loginService := permission.NewLoginService() 109 | loginService.SetCtx(c) 110 | _, ok := loginService.CheckToken(accessToken) 111 | 112 | api.Success(c, ok) 113 | } 114 | 115 | // extractAccessToken 从请求头中提取访问令牌 116 | func extractAccessToken(c *gin.Context) (string, error) { 117 | authorization := c.GetHeader("Authorization") 118 | return token.GetAccessToken(authorization) 119 | } 120 | -------------------------------------------------------------------------------- /internal/middleware/admin_auth.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gin-gonic/gin" 7 | "go.uber.org/zap" 8 | 9 | "github.com/wannanbigpig/gin-layout/internal/global" 10 | "github.com/wannanbigpig/gin-layout/internal/model" 11 | e "github.com/wannanbigpig/gin-layout/internal/pkg/errors" 12 | log "github.com/wannanbigpig/gin-layout/internal/pkg/logger" 13 | "github.com/wannanbigpig/gin-layout/internal/pkg/response" 14 | casbinx "github.com/wannanbigpig/gin-layout/internal/pkg/utils/casbin" 15 | ) 16 | 17 | // AdminAuthHandler 管理员权限验证中间件 18 | // 注意:此中间件需要在ParseTokenHandler之后使用,因为需要从context获取用户信息 19 | func AdminAuthHandler() gin.HandlerFunc { 20 | return func(c *gin.Context) { 21 | // 从context获取用户信息(由ParseTokenHandler设置) 22 | uid := c.GetUint("uid") 23 | if uid == 0 { 24 | response.Fail(c, e.NotLogin, "请先登录") 25 | c.Abort() 26 | return 27 | } 28 | 29 | // 获取用户信息(从context获取,如果不存在则查询数据库) 30 | adminUser := getUserFromContext(c) 31 | if adminUser == nil { 32 | response.Fail(c, e.NotLogin, "登录已失效,请重新登录") 33 | c.Abort() 34 | return 35 | } 36 | 37 | // 权限验证(非超级管理员需要检查接口权限) 38 | if !isSuperAdmin(adminUser) { 39 | if err := checkPermission(c, adminUser); err != nil { 40 | if businessErr, ok := err.(*e.BusinessError); ok { 41 | response.Fail(c, businessErr.GetCode(), businessErr.GetMessage()) 42 | } else { 43 | response.Fail(c, e.ServerErr, "权限验证失败") 44 | } 45 | c.Abort() 46 | return 47 | } 48 | } 49 | 50 | c.Next() 51 | } 52 | } 53 | 54 | // isSuperAdmin 判断是否为超级管理员 55 | func isSuperAdmin(adminUser *model.AdminUser) bool { 56 | return adminUser.IsSuperAdmin == global.Yes || adminUser.ID == global.SuperAdminId 57 | } 58 | 59 | // checkPermission 检查接口权限 60 | func checkPermission(c *gin.Context, adminUser *model.AdminUser) error { 61 | enforcer := casbinx.GetEnforcer() 62 | if enforcer.Error() != nil { 63 | log.Logger.Error("权限验证初始化失败", zap.Error(enforcer.Error())) 64 | return e.NewBusinessError(e.ServerErr, "权限验证初始化失败") 65 | } 66 | 67 | // 构建权限检查的key 68 | userKey := fmt.Sprintf("%s%s%d", global.CasbinAdminUserPrefix, global.CasbinSeparator, adminUser.ID) 69 | path := c.Request.URL.Path 70 | method := c.Request.Method 71 | 72 | // 检查权限 73 | ok, err := enforcer.Enforce(userKey, path, method) 74 | if err != nil { 75 | log.Logger.Error("权限验证失败", zap.Error(err)) 76 | return e.NewBusinessError(e.ServerErr, "权限验证失败") 77 | } 78 | 79 | // 如果没有权限,检查接口是否需要授权 80 | if !ok { 81 | if model.NewApi().CheckoutRouteIsAuth(path, method) { 82 | return e.NewBusinessError(e.AuthorizationErr, "暂无接口操作权限") 83 | } 84 | } 85 | 86 | return nil 87 | } 88 | 89 | // getUserFromContext 从context获取用户信息 90 | func getUserFromContext(c *gin.Context) *model.AdminUser { 91 | // 优先从context获取完整的用户对象 92 | if user, exists := c.Get("admin_user"); exists { 93 | if adminUser, ok := user.(*model.AdminUser); ok { 94 | return adminUser 95 | } 96 | } 97 | 98 | // 如果context中没有完整对象,但有uid,则查询数据库 99 | uid := c.GetUint("uid") 100 | if uid == 0 { 101 | return nil 102 | } 103 | 104 | adminUser := model.NewAdminUsers() 105 | if err := adminUser.GetById(adminUser, uid); err != nil { 106 | return nil 107 | } 108 | 109 | // 将查询到的用户信息设置回context,避免重复查询 110 | // 注意:从数据库查询时没有jwtID,传入空字符串 111 | setUserContext(c, adminUser, "") 112 | return adminUser 113 | } 114 | -------------------------------------------------------------------------------- /internal/console/init/init.go: -------------------------------------------------------------------------------- 1 | package init 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/spf13/cobra" 10 | "go.uber.org/zap" 11 | 12 | log "github.com/wannanbigpig/gin-layout/internal/pkg/logger" 13 | "github.com/wannanbigpig/gin-layout/internal/service/system" 14 | ) 15 | 16 | const ( 17 | msgProcessingComplete = "Processing complete." 18 | msgFailedToSaveRoute = "Failed to save the initial route data to the routing table." 19 | msgMenuApiMapComplete = "Menu-API mapping initialization complete." 20 | msgFailedToInitMenuApiMap = "Failed to initialize menu-API mapping." 21 | ) 22 | 23 | var ( 24 | ApiRouteCmd = &cobra.Command{ 25 | Use: "api-route", 26 | Short: "Initialize API route table", 27 | Long: "This command scans all defined API routes in the system and stores them in the a_api table for permission management and API documentation.", 28 | RunE: func(cmd *cobra.Command, args []string) error { 29 | return runInitApiRoute() 30 | }, 31 | } 32 | 33 | MenuApiMapCmd = &cobra.Command{ 34 | Use: "menu-api-map", 35 | Short: "Initialize menu-API mapping table from casbin_rule table", 36 | Long: "This command initializes the a_menu_api_map table by extracting menu-API relationships from the casbin_rule table.", 37 | RunE: func(cmd *cobra.Command, args []string) error { 38 | return runInitMenuApiMap() 39 | }, 40 | } 41 | ) 42 | 43 | func init() { 44 | // 可以在这里注册其他初始化相关的子命令 45 | } 46 | 47 | // runInitApiRoute 执行API路由表初始化 48 | func runInitApiRoute() error { 49 | // 用户确认 50 | if !confirmOperation("This command is used to obtain the defined API in the system and store it in the a_api table. Are you sure to perform the operation? [Y/N]: ") { 51 | fmt.Println("Operation cancelled.") 52 | return nil 53 | } 54 | 55 | // 调用服务层方法 56 | initService := system.NewInitService() 57 | if err := initService.InitApiRoutes(); err != nil { 58 | log.Logger.Error(msgFailedToSaveRoute, zap.Error(err)) 59 | fmt.Println(msgFailedToSaveRoute) 60 | return err 61 | } 62 | 63 | fmt.Println(msgProcessingComplete) 64 | return nil 65 | } 66 | 67 | // runInitMenuApiMap 执行菜单API映射初始化 68 | func runInitMenuApiMap() error { 69 | // 用户确认 70 | if !confirmOperation("This command is used to initialize the menu-API mapping table from casbin_rule table. Are you sure to perform the operation? [Y/N]: ") { 71 | fmt.Println("Operation cancelled.") 72 | return nil 73 | } 74 | 75 | // 调用服务层方法 76 | initService := system.NewInitService() 77 | if err := initService.InitMenuApiMap(); err != nil { 78 | log.Logger.Error(msgFailedToInitMenuApiMap, zap.Error(err)) 79 | fmt.Println(msgFailedToInitMenuApiMap) 80 | return err 81 | } 82 | 83 | fmt.Println(msgMenuApiMapComplete) 84 | return nil 85 | } 86 | 87 | // confirmOperation 确认操作,返回用户是否确认 88 | func confirmOperation(prompt string) bool { 89 | scanner := bufio.NewScanner(os.Stdin) 90 | fmt.Print(prompt) 91 | 92 | if !scanner.Scan() { 93 | if err := scanner.Err(); err != nil { 94 | log.Logger.Error("Failed to read user input", zap.Error(err)) 95 | _, err := fmt.Fprintln(os.Stderr, "reading standard input:", err) 96 | if err != nil { 97 | return false 98 | } 99 | } 100 | return false 101 | } 102 | 103 | input := strings.TrimSpace(strings.ToLower(scanner.Text())) 104 | return input == "y" || input == "yes" 105 | } 106 | -------------------------------------------------------------------------------- /internal/controller/admin_v1/auth_dept.go: -------------------------------------------------------------------------------- 1 | package admin_v1 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | 6 | "github.com/wannanbigpig/gin-layout/internal/controller" 7 | e "github.com/wannanbigpig/gin-layout/internal/pkg/errors" 8 | "github.com/wannanbigpig/gin-layout/internal/service/permission" 9 | "github.com/wannanbigpig/gin-layout/internal/validator" 10 | "github.com/wannanbigpig/gin-layout/internal/validator/form" 11 | ) 12 | 13 | // DeptController 部门控制器 14 | type DeptController struct { 15 | controller.Api 16 | } 17 | 18 | // NewDeptController 创建部门控制器实例 19 | func NewDeptController() *DeptController { 20 | return &DeptController{} 21 | } 22 | 23 | // List 查询部门列表 24 | func (api DeptController) List(c *gin.Context) { 25 | params := form.NewDeptListQuery() 26 | if err := validator.CheckQueryParams(c, ¶ms); err != nil { 27 | return 28 | } 29 | 30 | result := permission.NewDeptService().List(params) 31 | api.Success(c, result) 32 | } 33 | 34 | // Edit 编辑部门 35 | func (api DeptController) Edit(c *gin.Context) { 36 | params := form.NewEditDeptForm() 37 | if err := validator.CheckPostParams(c, ¶ms); err != nil { 38 | return 39 | } 40 | 41 | if err := permission.NewDeptService().Edit(params); err != nil { 42 | api.Err(c, err) 43 | return 44 | } 45 | 46 | api.Success(c, nil) 47 | } 48 | 49 | // Create 新增部门 50 | func (api DeptController) Create(c *gin.Context) { 51 | params := form.NewEditDeptForm() 52 | if err := validator.CheckPostParams(c, ¶ms); err != nil { 53 | return 54 | } 55 | 56 | // 确保 ID 为空,表示新增 57 | params.Id = 0 58 | 59 | if err := permission.NewDeptService().Edit(params); err != nil { 60 | api.Err(c, err) 61 | return 62 | } 63 | 64 | api.Success(c, nil) 65 | } 66 | 67 | // Update 更新部门 68 | func (api DeptController) Update(c *gin.Context) { 69 | params := form.NewEditDeptForm() 70 | if err := validator.CheckPostParams(c, ¶ms); err != nil { 71 | return 72 | } 73 | 74 | // 确保 ID 不为空,表示更新 75 | if params.Id == 0 { 76 | api.Err(c, e.NewBusinessError(1, "更新部门时ID不能为空")) 77 | return 78 | } 79 | 80 | if err := permission.NewDeptService().Edit(params); err != nil { 81 | api.Err(c, err) 82 | return 83 | } 84 | 85 | api.Success(c, nil) 86 | } 87 | 88 | // Delete 删除部门 89 | func (api DeptController) Delete(c *gin.Context) { 90 | params := form.NewIdForm() 91 | if err := validator.CheckPostParams(c, ¶ms); err != nil { 92 | return 93 | } 94 | 95 | if err := permission.NewDeptService().Delete(params.ID); err != nil { 96 | api.Err(c, err) 97 | return 98 | } 99 | 100 | api.Success(c, nil) 101 | } 102 | 103 | // Detail 获取部门详情 104 | func (api DeptController) Detail(c *gin.Context) { 105 | query := form.NewIdForm() 106 | if err := validator.CheckQueryParams(c, &query); err != nil { 107 | return 108 | } 109 | 110 | detail, err := permission.NewDeptService().Detail(query.ID) 111 | if err != nil { 112 | api.Err(c, err) 113 | return 114 | } 115 | 116 | api.Success(c, detail) 117 | } 118 | 119 | // BindRole 绑定角色到部门 120 | func (api DeptController) BindRole(c *gin.Context) { 121 | params := form.NewBindRole() 122 | if err := validator.CheckPostParams(c, ¶ms); err != nil { 123 | return 124 | } 125 | 126 | if err := permission.NewDeptService().BindRole(params); err != nil { 127 | api.Err(c, err) 128 | return 129 | } 130 | 131 | api.Success(c, nil) 132 | } 133 | -------------------------------------------------------------------------------- /internal/middleware/recovery.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | "time" 9 | 10 | "github.com/gin-gonic/gin" 11 | "go.uber.org/zap" 12 | 13 | "github.com/wannanbigpig/gin-layout/config" 14 | e "github.com/wannanbigpig/gin-layout/internal/pkg/errors" 15 | "github.com/wannanbigpig/gin-layout/internal/pkg/logger" 16 | "github.com/wannanbigpig/gin-layout/internal/pkg/response" 17 | ) 18 | 19 | const ( 20 | // panicErrorPrefix 服务器内部错误前缀 21 | panicErrorPrefix = "An error occurred in the server's internal code: " 22 | // panicRecoveredMsg panic恢复日志消息 23 | panicRecoveredMsg = "panic recovered" 24 | ) 25 | 26 | // CustomRecovery 自定义错误 (panic) 拦截中间件 27 | // 对可能发生的 panic 进行拦截、统一记录并返回友好的错误响应 28 | func CustomRecovery() gin.HandlerFunc { 29 | errorWriter := &PanicExceptionRecord{} 30 | return gin.RecoveryWithWriter(errorWriter, handlePanic) 31 | } 32 | 33 | // handlePanic 处理 panic 恢复逻辑 34 | func handlePanic(c *gin.Context, err interface{}) { 35 | // 格式化错误信息 36 | errStr := formatError(err) 37 | 38 | // 记录错误日志 39 | logPanicError(c, errStr) 40 | 41 | // 返回错误响应 42 | sendErrorResponse(c, errStr) 43 | } 44 | 45 | // formatError 格式化错误信息 46 | func formatError(err interface{}) string { 47 | if !config.Config.Debug { 48 | return "" 49 | } 50 | return fmt.Sprintf("%v", err) 51 | } 52 | 53 | // logPanicError 记录 panic 错误日志 54 | func logPanicError(c *gin.Context, errStr string) { 55 | // 构建基础日志字段 56 | logFields := buildPanicLogFields(c, errStr) 57 | 58 | // 添加调试信息(非生产环境) 59 | if gin.Mode() != gin.ReleaseMode { 60 | logFields = appendPanicDebugFields(c, logFields) 61 | } 62 | 63 | // 记录错误日志 64 | logger.Error(panicRecoveredMsg, logFields...) 65 | } 66 | 67 | // buildPanicLogFields 构建 panic 日志字段 68 | func buildPanicLogFields(c *gin.Context, errStr string) []zap.Field { 69 | cost := time.Since(c.GetTime("requestStartTime")) 70 | requestID := c.GetString("requestId") 71 | 72 | return []zap.Field{ 73 | zap.String("requestId", requestID), 74 | zap.Int("status", c.Writer.Status()), 75 | zap.String("method", c.Request.Method), 76 | zap.String("path", c.Request.URL.Path), 77 | zap.String("query", c.Request.URL.RawQuery), 78 | zap.String("ip", c.ClientIP()), 79 | zap.String("user-agent", c.Request.UserAgent()), 80 | zap.String("errors", errStr), 81 | zap.Duration("cost", cost), 82 | } 83 | } 84 | 85 | // appendPanicDebugFields 添加 panic 调试字段(仅非生产环境) 86 | func appendPanicDebugFields(c *gin.Context, logFields []zap.Field) []zap.Field { 87 | // 读取请求体(如果存在且未被消耗) 88 | if requestBody := readRequestBody(c); requestBody != nil { 89 | logFields = append(logFields, zap.ByteString("body", requestBody)) 90 | } 91 | return logFields 92 | } 93 | 94 | // sendErrorResponse 发送错误响应 95 | func sendErrorResponse(c *gin.Context, errStr string) { 96 | response.Resp(). 97 | SetHttpCode(http.StatusInternalServerError). 98 | FailCode(c, e.ServerErr, errStr) 99 | } 100 | 101 | // PanicExceptionRecord panic 异常记录器 102 | // 实现 io.Writer 接口,用于记录 panic 的完整堆栈信息 103 | type PanicExceptionRecord struct{} 104 | 105 | // Write 写入 panic 异常信息 106 | func (p *PanicExceptionRecord) Write(b []byte) (n int, err error) { 107 | errStr := buildPanicErrorString(b) 108 | logger.Error(errStr) 109 | return len(errStr), errors.New(errStr) 110 | } 111 | 112 | // buildPanicErrorString 构建 panic 错误字符串 113 | func buildPanicErrorString(stackTrace []byte) string { 114 | var builder strings.Builder 115 | builder.Grow(len(panicErrorPrefix) + len(stackTrace)) 116 | builder.WriteString(panicErrorPrefix) 117 | builder.Write(stackTrace) 118 | return builder.String() 119 | } 120 | -------------------------------------------------------------------------------- /internal/pkg/utils/token/jwt.go: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | "time" 7 | 8 | "github.com/golang-jwt/jwt/v5" 9 | "github.com/google/uuid" 10 | 11 | c "github.com/wannanbigpig/gin-layout/config" 12 | "github.com/wannanbigpig/gin-layout/internal/global" 13 | "github.com/wannanbigpig/gin-layout/internal/model" 14 | e "github.com/wannanbigpig/gin-layout/internal/pkg/errors" 15 | ) 16 | 17 | type AdminUserInfo struct { 18 | // 可根据需要自行添加字段 19 | UserID uint `json:"user_id"` 20 | FullPhoneNumber string `json:"full_phone_number"` 21 | Email string `json:"email"` 22 | Nickname string `json:"nickname"` 23 | PhoneNumber string `json:"phone_number"` 24 | CountryCode string `json:"country_code"` 25 | IsSuperAdmin uint8 `json:"is_super_admin"` 26 | } 27 | 28 | // Generate 生成JWT Token 29 | func Generate(claims jwt.Claims) (string, error) { 30 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 31 | 32 | // 生成签名字符串 33 | tokenStr, err := token.SignedString([]byte(c.Config.Jwt.SecretKey)) 34 | if err != nil { 35 | return "", err 36 | } 37 | return tokenStr, nil 38 | } 39 | 40 | // Refresh 刷新JWT Token 41 | func Refresh(claims jwt.Claims) (string, error) { 42 | return Generate(claims) 43 | } 44 | 45 | // Parse 解析token 46 | func Parse(accessToken string, claims jwt.Claims, options ...jwt.ParserOption) error { 47 | token, err := jwt.ParseWithClaims(accessToken, claims, func(token *jwt.Token) (i interface{}, err error) { 48 | return []byte(c.Config.Jwt.SecretKey), err 49 | }, options...) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | // 对token对象中的Claim进行类型断言 55 | if token.Valid { // 校验token 56 | return nil 57 | } 58 | 59 | return e.NewBusinessError(1, "invalid token") 60 | } 61 | 62 | // GetAccessToken 获取jwt的Token 63 | func GetAccessToken(authorization string) (accessToken string, err error) { 64 | if authorization == "" { 65 | return "", errors.New("authorization header is missing") 66 | } 67 | 68 | // 检查 Authorization 头的格式 69 | if !strings.HasPrefix(authorization, "Bearer ") { 70 | return "", errors.New("invalid Authorization header format") 71 | } 72 | 73 | // 提取 Token 的值 74 | accessToken = strings.TrimPrefix(authorization, "Bearer ") 75 | return 76 | } 77 | 78 | // AdminCustomClaims 自定义格式内容 79 | type AdminCustomClaims struct { 80 | AdminUserInfo 81 | jwt.RegisteredClaims // 内嵌标准的声明 82 | } 83 | 84 | // NewAdminCustomClaims 初始化AdminCustomClaims 85 | func NewAdminCustomClaims(user *model.AdminUser) AdminCustomClaims { 86 | now := time.Now().UTC() 87 | expiresAt := now.Add(time.Second * c.Config.Jwt.TTL) 88 | // phoneRule := &utils.DesensitizeRule{KeepPrefixLen: 3, KeepSuffixLen: 4, MaskChar: '*'} 89 | // emailRule := &utils.DesensitizeRule{KeepPrefixLen: 2, KeepSuffixLen: 0, MaskChar: '*', Separator: '@', FixedMaskLength: 3} 90 | return AdminCustomClaims{ 91 | AdminUserInfo: AdminUserInfo{ 92 | UserID: user.ID, 93 | FullPhoneNumber: user.FullPhoneNumber, // phoneRule.Apply(user.Mobile), 94 | PhoneNumber: user.PhoneNumber, // phoneRule.Apply(user.Mobile), 95 | CountryCode: user.CountryCode, // phoneRule.Apply(user.Mobile), 96 | Email: user.Email, // emailRule.Apply(user.Email), 97 | Nickname: user.Nickname, 98 | IsSuperAdmin: user.IsSuperAdmin, 99 | }, 100 | RegisteredClaims: jwt.RegisteredClaims{ 101 | ExpiresAt: jwt.NewNumericDate(expiresAt), // 定义过期时间 102 | Issuer: global.Issuer, // 签发人 103 | IssuedAt: jwt.NewNumericDate(now), // 签发时间 104 | Subject: global.PcAdminSubject, // 签发主题 105 | NotBefore: jwt.NewNumericDate(now), // 生效时间 106 | ID: uuid.New().String(), // 唯一标识 107 | }, 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /internal/pkg/response/response.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/gin-gonic/gin" 8 | 9 | "github.com/wannanbigpig/gin-layout/config" 10 | "github.com/wannanbigpig/gin-layout/internal/pkg/errors" 11 | ) 12 | 13 | // Result API响应结果结构 14 | type Result struct { 15 | Code int `json:"code"` 16 | Msg string `json:"msg"` 17 | Data any `json:"data"` 18 | Cost string `json:"cost"` 19 | RequestId string `json:"request_id"` 20 | } 21 | 22 | // NewResult 创建新的响应结果 23 | func NewResult() *Result { 24 | return &Result{ 25 | Code: 0, 26 | Msg: "", 27 | Data: nil, 28 | Cost: "", 29 | RequestId: "", 30 | } 31 | } 32 | 33 | // Response 响应处理器 34 | type Response struct { 35 | httpCode int 36 | result *Result 37 | } 38 | 39 | // Resp 创建响应处理器实例 40 | func Resp() *Response { 41 | return &Response{ 42 | httpCode: http.StatusOK, 43 | result: NewResult(), 44 | } 45 | } 46 | 47 | // Fail 错误返回 48 | func (r *Response) Fail(c *gin.Context, code int, msg string, data ...any) { 49 | r.SetCode(code) 50 | r.SetMessage(msg) 51 | if len(data) > 0 && data[0] != nil { 52 | r.WithData(data[0]) 53 | } 54 | r.json(c) 55 | } 56 | 57 | // FailCode 自定义错误码返回 58 | func (r *Response) FailCode(c *gin.Context, code int, msg ...string) { 59 | r.SetCode(code) 60 | if len(msg) > 0 && msg[0] != "" { 61 | r.SetMessage(msg[0]) 62 | } 63 | r.json(c) 64 | } 65 | 66 | // Success 正确返回 67 | func (r *Response) Success(c *gin.Context) { 68 | r.SetCode(errors.SUCCESS) 69 | r.json(c) 70 | } 71 | 72 | // WithDataSuccess 成功后需要返回值 73 | func (r *Response) WithDataSuccess(c *gin.Context, data interface{}) { 74 | r.SetCode(errors.SUCCESS) 75 | r.WithData(data) 76 | r.json(c) 77 | } 78 | 79 | // SetCode 设置返回code码 80 | func (r *Response) SetCode(code int) *Response { 81 | r.result.Code = code 82 | return r 83 | } 84 | 85 | // SetHttpCode 设置http状态码 86 | func (r *Response) SetHttpCode(code int) *Response { 87 | r.httpCode = code 88 | return r 89 | } 90 | 91 | // defaultRes 默认响应数据结构 92 | type defaultRes struct { 93 | Result any `json:"result"` 94 | } 95 | 96 | // WithData 设置返回data数据 97 | func (r *Response) WithData(data any) *Response { 98 | switch v := data.(type) { 99 | case string, int, bool: 100 | r.result.Data = &defaultRes{Result: v} 101 | default: 102 | r.result.Data = data 103 | } 104 | return r 105 | } 106 | 107 | // SetMessage 设置返回自定义错误消息 108 | func (r *Response) SetMessage(message string) *Response { 109 | r.result.Msg = message 110 | return r 111 | } 112 | 113 | var ErrorText = errors.NewErrorText(config.Config.Language) 114 | 115 | // json 返回 gin 框架的 JSON 响应 116 | func (r *Response) json(c *gin.Context) { 117 | // 如果消息为空,使用错误码对应的默认消息 118 | if r.result.Msg == "" { 119 | r.result.Msg = ErrorText.Text(r.result.Code) 120 | } 121 | 122 | // 计算请求耗时 123 | r.result.Cost = time.Since(c.GetTime("requestStartTime")).String() 124 | r.result.RequestId = c.GetString("requestId") 125 | c.AbortWithStatusJSON(r.httpCode, r.result) 126 | } 127 | 128 | // Success 业务成功响应(便捷方法) 129 | func Success(c *gin.Context, data ...any) { 130 | if len(data) > 0 && data[0] != nil { 131 | Resp().WithDataSuccess(c, data[0]) 132 | return 133 | } 134 | Resp().Success(c) 135 | } 136 | 137 | // FailCode 业务失败响应(便捷方法) 138 | func FailCode(c *gin.Context, code int, data ...any) { 139 | if len(data) > 0 && data[0] != nil { 140 | Resp().WithData(data[0]).FailCode(c, code) 141 | return 142 | } 143 | Resp().FailCode(c, code) 144 | } 145 | 146 | // Fail 业务失败响应(便捷方法) 147 | func Fail(c *gin.Context, code int, message string, data ...any) { 148 | if len(data) > 0 && data[0] != nil { 149 | Resp().WithData(data[0]).Fail(c, code, message) 150 | return 151 | } 152 | Resp().Fail(c, code, message) 153 | } 154 | -------------------------------------------------------------------------------- /internal/service/permission/request_log.go: -------------------------------------------------------------------------------- 1 | package permission 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/wannanbigpig/gin-layout/internal/model" 7 | e "github.com/wannanbigpig/gin-layout/internal/pkg/errors" 8 | "github.com/wannanbigpig/gin-layout/internal/resources" 9 | "github.com/wannanbigpig/gin-layout/internal/service" 10 | "github.com/wannanbigpig/gin-layout/internal/validator/form" 11 | ) 12 | 13 | // RequestLogService 请求日志服务 14 | type RequestLogService struct { 15 | service.Base 16 | } 17 | 18 | // NewRequestLogService 创建请求日志服务实例 19 | func NewRequestLogService() *RequestLogService { 20 | return &RequestLogService{} 21 | } 22 | 23 | // List 分页查询请求日志列表 24 | func (s *RequestLogService) List(params *form.RequestLogList) *resources.Collection { 25 | var conditions []string 26 | var args []any 27 | 28 | // 操作ID(用户ID) 29 | if params.OperatorID != 0 { 30 | conditions = append(conditions, "operator_id = ?") 31 | args = append(args, params.OperatorID) 32 | } 33 | 34 | // 操作账号(模糊查询) 35 | if params.OperatorAccount != "" { 36 | conditions = append(conditions, "operator_account LIKE ?") 37 | args = append(args, "%"+params.OperatorAccount+"%") 38 | } 39 | 40 | // 操作状态:0=成功(operation_status=0),1=失败(operation_status!=0) 41 | if params.OperationStatus != nil { 42 | switch *params.OperationStatus { 43 | case 0: 44 | // 查询成功的记录 45 | conditions = append(conditions, "operation_status = ?") 46 | args = append(args, 0) 47 | case 1: 48 | // 查询失败的记录 49 | conditions = append(conditions, "operation_status != ?") 50 | args = append(args, 0) 51 | } 52 | } 53 | 54 | if params.BaseURL != "" { 55 | conditions = append(conditions, "base_url = ?") 56 | args = append(args, params.BaseURL) 57 | } 58 | 59 | // HTTP请求方法 60 | if params.Method != "" { 61 | conditions = append(conditions, "method = ?") 62 | args = append(args, params.Method) 63 | } 64 | 65 | // 操作接口(模糊查询) 66 | if params.OperationName != "" { 67 | conditions = append(conditions, "operation_name LIKE ?") 68 | args = append(args, "%"+params.OperationName+"%") 69 | } 70 | 71 | // 操作IP(模糊查询) 72 | if params.IP != "" { 73 | conditions = append(conditions, "ip LIKE ?") 74 | args = append(args, "%"+params.IP+"%") 75 | } 76 | 77 | // 开始时间 78 | if params.StartTime != "" { 79 | conditions = append(conditions, "created_at >= ?") 80 | args = append(args, params.StartTime) 81 | } 82 | 83 | // 结束时间 84 | if params.EndTime != "" { 85 | conditions = append(conditions, "created_at <= ?") 86 | args = append(args, params.EndTime) 87 | } 88 | 89 | conditionStr := strings.Join(conditions, " AND ") 90 | requestLogModel := model.NewRequestLogs() 91 | 92 | // 构建查询参数,只查询列表需要的字段,排除大字段 93 | listOptionalParams := model.ListOptionalParams{ 94 | SelectFields: []string{ 95 | "id", 96 | "request_id", 97 | "operator_id", 98 | "ip", 99 | "method", 100 | "base_url", 101 | "operation_name", 102 | "operation_status", 103 | "operator_account", 104 | "operator_name", 105 | "response_status", 106 | "execution_time", 107 | "created_at", 108 | }, 109 | OrderBy: "created_at DESC, id DESC", 110 | } 111 | 112 | // 分页查询(只查询列表需要的字段) 113 | total, collection := model.ListPage(requestLogModel, params.Page, params.PerPage, conditionStr, args, listOptionalParams) 114 | 115 | // 使用资源类转换,列表不包含大字段 116 | transformer := resources.NewRequestLogTransformer() 117 | return transformer.ToCollection(params.Page, params.PerPage, total, collection) 118 | } 119 | 120 | // Detail 获取请求日志详情 121 | func (s *RequestLogService) Detail(id uint) (any, error) { 122 | requestLog := model.NewRequestLogs() 123 | if err := requestLog.GetById(requestLog, id); err != nil || requestLog.ID == 0 { 124 | return nil, e.NewBusinessError(1, "请求日志不存在") 125 | } 126 | // 使用资源类转换,详情包含所有字段 127 | transformer := resources.NewRequestLogTransformer() 128 | return transformer.ToStruct(requestLog), nil 129 | } 130 | -------------------------------------------------------------------------------- /internal/service/permission/api.go: -------------------------------------------------------------------------------- 1 | package permission 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/wannanbigpig/gin-layout/internal/model" 7 | e "github.com/wannanbigpig/gin-layout/internal/pkg/errors" 8 | "github.com/wannanbigpig/gin-layout/internal/resources" 9 | "github.com/wannanbigpig/gin-layout/internal/service" 10 | "github.com/wannanbigpig/gin-layout/internal/validator/form" 11 | "github.com/wannanbigpig/gin-layout/pkg/utils" 12 | ) 13 | 14 | // ApiService API权限服务 15 | type ApiService struct { 16 | service.Base 17 | } 18 | 19 | // NewApiService 创建API服务实例 20 | func NewApiService() *ApiService { 21 | return &ApiService{} 22 | } 23 | 24 | // Edit 编辑API权限(新增或更新) 25 | func (s *ApiService) Edit(params *form.EditPermission) error { 26 | apiModel := model.NewApi() 27 | 28 | // 编辑模式:验证API是否存在 29 | if params.Id > 0 { 30 | if !apiModel.ExistsById(apiModel, params.Id) { 31 | return e.NewBusinessError(1, "编辑的权限不存在") 32 | } 33 | return s.updateApi(apiModel, params) 34 | } 35 | 36 | // 新增模式:验证路由唯一性并创建 37 | return s.createApi(apiModel, params) 38 | } 39 | 40 | // updateApi 更新API权限 41 | func (s *ApiService) updateApi(apiModel *model.Api, params *form.EditPermission) error { 42 | data := map[string]any{ 43 | "name": params.Name, 44 | "description": params.Description, 45 | "is_auth": params.IsAuth, 46 | "sort": params.Sort, 47 | } 48 | return apiModel.Update(apiModel, params.Id, data) 49 | } 50 | 51 | // createApi 创建新API权限 52 | func (s *ApiService) createApi(apiModel *model.Api, params *form.EditPermission) error { 53 | // 验证路由唯一性 54 | if apiModel.Exists(apiModel, "route =?", params.Route) { 55 | return e.NewBusinessError(1, "权限路由已存在") 56 | } 57 | 58 | data := map[string]any{ 59 | "name": params.Name, 60 | "description": params.Description, 61 | "is_auth": params.IsAuth, 62 | "sort": params.Sort, 63 | "func": params.Func, 64 | "func_path": params.FuncPath, 65 | "method": params.Method, 66 | "code": utils.MD5(params.Method + "_" + params.Route), 67 | "route": params.Route, 68 | } 69 | 70 | return apiModel.Create(apiModel, data) 71 | } 72 | 73 | // ListPage 分页查询API权限列表 74 | func (s *ApiService) ListPage(params *form.ListPermission) *resources.Collection { 75 | condition, args := s.buildListCondition(params) 76 | 77 | apiModel := model.NewApi() 78 | total, collection := model.ListPage( 79 | apiModel, 80 | params.Page, 81 | params.PerPage, 82 | condition, 83 | args, 84 | model.ListOptionalParams{ 85 | OrderBy: "sort desc, id desc", 86 | }, 87 | ) 88 | 89 | return resources.NewApiTransformer().ToCollection(params.Page, params.PerPage, total, collection) 90 | } 91 | 92 | // buildListCondition 构建列表查询条件 93 | func (s *ApiService) buildListCondition(params *form.ListPermission) (string, []any) { 94 | var condition strings.Builder 95 | var args []any 96 | 97 | // 关键词搜索 98 | if params.Keyword != "" { 99 | condition.WriteString("(name like ? OR route like ? OR code = ?) AND ") 100 | args = append(args, "%"+params.Keyword+"%", "%"+params.Keyword+"%", params.Keyword) 101 | } 102 | 103 | // 名称过滤 104 | if params.Name != "" { 105 | condition.WriteString("name like ? AND ") 106 | args = append(args, "%"+params.Name+"%") 107 | } 108 | 109 | // 请求方法过滤 110 | if params.Method != "" { 111 | condition.WriteString("method = ? AND ") 112 | args = append(args, params.Method) 113 | } 114 | 115 | // 路由过滤 116 | if params.Route != "" { 117 | condition.WriteString("route like ? AND ") 118 | args = append(args, "%"+params.Route+"%") 119 | } 120 | 121 | // 鉴权状态过滤 122 | if params.IsAuth != nil { 123 | condition.WriteString("is_auth = ? AND ") 124 | args = append(args, params.IsAuth) 125 | } 126 | 127 | // 有效性过滤 128 | if params.IsEffective != nil { 129 | condition.WriteString("is_effective = ? AND ") 130 | args = append(args, params.IsEffective) 131 | } 132 | 133 | return condition.String(), args 134 | } 135 | -------------------------------------------------------------------------------- /internal/service/permission/login_log.go: -------------------------------------------------------------------------------- 1 | package permission 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/wannanbigpig/gin-layout/config" 7 | "github.com/wannanbigpig/gin-layout/internal/model" 8 | e "github.com/wannanbigpig/gin-layout/internal/pkg/errors" 9 | log "github.com/wannanbigpig/gin-layout/internal/pkg/logger" 10 | "github.com/wannanbigpig/gin-layout/internal/resources" 11 | "github.com/wannanbigpig/gin-layout/internal/service" 12 | "github.com/wannanbigpig/gin-layout/internal/validator/form" 13 | "github.com/wannanbigpig/gin-layout/pkg/utils/crypto" 14 | "go.uber.org/zap" 15 | ) 16 | 17 | // LoginLogService 登录日志服务 18 | type AdminLoginLogService struct { 19 | service.Base 20 | } 21 | 22 | // NewAdminLoginLogService 创建登录日志服务实例 23 | func NewAdminLoginLogService() *AdminLoginLogService { 24 | return &AdminLoginLogService{} 25 | } 26 | 27 | // List 分页查询登录日志列表 28 | func (s *AdminLoginLogService) List(params *form.AdminLoginLogList) *resources.Collection { 29 | var conditions []string 30 | var args []any 31 | 32 | // 登录账号(模糊查询) 33 | if params.Username != "" { 34 | conditions = append(conditions, "username LIKE ?") 35 | args = append(args, "%"+params.Username+"%") 36 | } 37 | 38 | // 登录状态 39 | if params.LoginStatus != nil { 40 | conditions = append(conditions, "login_status = ?") 41 | args = append(args, *params.LoginStatus) 42 | } 43 | 44 | // 登录IP(模糊查询) 45 | if params.IP != "" { 46 | conditions = append(conditions, "ip LIKE ?") 47 | args = append(args, "%"+params.IP+"%") 48 | } 49 | 50 | // 开始时间 51 | if params.StartTime != "" { 52 | conditions = append(conditions, "created_at >= ?") 53 | args = append(args, params.StartTime) 54 | } 55 | 56 | // 结束时间 57 | if params.EndTime != "" { 58 | conditions = append(conditions, "created_at <= ?") 59 | args = append(args, params.EndTime) 60 | } 61 | 62 | conditionStr := strings.Join(conditions, " AND ") 63 | loginLogModel := model.NewAdminLoginLogs() 64 | 65 | // 构建查询参数,只查询列表需要的字段,排除大字段 66 | listOptionalParams := model.ListOptionalParams{ 67 | SelectFields: []string{ 68 | "id", 69 | "uid", 70 | "username", 71 | "ip", 72 | "os", 73 | "browser", 74 | "execution_time", 75 | "login_status", 76 | "login_fail_reason", 77 | "type", 78 | "is_revoked", 79 | "revoked_code", 80 | "revoked_reason", 81 | "revoked_at", 82 | "created_at", 83 | }, 84 | OrderBy: "created_at DESC, id DESC", 85 | } 86 | 87 | // 分页查询(只查询列表需要的字段) 88 | total, collection := model.ListPage(loginLogModel, params.Page, params.PerPage, conditionStr, args, listOptionalParams) 89 | 90 | // 使用资源类转换,列表不包含大字段 91 | transformer := resources.NewAdminLoginLogTransformer() 92 | return transformer.ToCollection(params.Page, params.PerPage, total, collection) 93 | } 94 | 95 | // Detail 获取登录日志详情 96 | func (s *AdminLoginLogService) Detail(id uint) (any, error) { 97 | loginLog := model.NewAdminLoginLogs() 98 | if err := loginLog.GetById(loginLog, id); err != nil || loginLog.ID == 0 { 99 | return nil, e.NewBusinessError(1, "登录日志不存在") 100 | } 101 | 102 | // 解密 access_token 和 refresh_token 103 | s.decryptTokens(loginLog) 104 | 105 | // 使用资源类转换,详情包含所有字段 106 | transformer := resources.NewAdminLoginLogTransformer() 107 | return transformer.ToStruct(loginLog), nil 108 | } 109 | 110 | // decryptTokens 解密登录日志中的 token 111 | func (s *AdminLoginLogService) decryptTokens(loginLog *model.AdminLoginLogs) { 112 | encryptKey := config.Config.Jwt.SecretKey 113 | 114 | // 解密 access_token 115 | if loginLog.AccessToken != "" { 116 | decrypted, err := crypto.Decrypt(encryptKey, loginLog.AccessToken) 117 | if err != nil { 118 | log.Logger.Warn("解密 access_token 失败", 119 | zap.Error(err), 120 | zap.Uint("log_id", loginLog.ID), 121 | zap.Uint("user_id", loginLog.UID)) 122 | loginLog.AccessToken = "" // 解密失败时返回空字符串 123 | } else { 124 | loginLog.AccessToken = decrypted 125 | } 126 | } 127 | 128 | // 解密 refresh_token 129 | if loginLog.RefreshToken != "" { 130 | decrypted, err := crypto.Decrypt(encryptKey, loginLog.RefreshToken) 131 | if err != nil { 132 | log.Logger.Warn("解密 refresh_token 失败", 133 | zap.Error(err), 134 | zap.Uint("log_id", loginLog.ID), 135 | zap.Uint("user_id", loginLog.UID)) 136 | loginLog.RefreshToken = "" // 解密失败时返回空字符串 137 | } else { 138 | loginLog.RefreshToken = decrypted 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /pkg/utils/crypto/README.md: -------------------------------------------------------------------------------- 1 | # 加密工具使用说明 2 | 3 | 本包提供 AES-256-GCM 加密算法,用于字符串的加密和解密。支持通过参数选择加密算法。 4 | 5 | ## 快速开始 6 | 7 | ```go 8 | import "github.com/wannanbigpig/gin-layout/pkg/utils/crypto" 9 | 10 | // 使用默认算法加密(推荐,不传算法参数) 11 | encrypted, err := crypto.Encrypt("your-secret-key", "plaintext") 12 | if err != nil { 13 | // 处理错误 14 | } 15 | 16 | // 使用默认算法解密 17 | decrypted, err := crypto.Decrypt("your-secret-key", encrypted) 18 | if err != nil { 19 | // 处理错误 20 | } 21 | 22 | // 使用指定算法加密(可选) 23 | encrypted, err := crypto.Encrypt("your-secret-key", "plaintext", crypto.AlgorithmAES256GCM) 24 | 25 | // 使用指定算法解密(可选) 26 | decrypted, err := crypto.Decrypt("your-secret-key", encrypted, crypto.AlgorithmAES256GCM) 27 | ``` 28 | 29 | ## 算法说明 30 | 31 | ### AES-256-GCM 32 | 33 | **特点:** 34 | - 使用 AES-256(高级加密标准,256 位密钥) 35 | - GCM 模式(Galois/Counter Mode),提供认证加密(AEAD) 36 | - 国际标准,广泛使用 37 | - 性能优秀,硬件加速支持好 38 | - 兼容性好,所有平台支持 39 | 40 | **密钥处理:** 41 | - 输入密钥为字符串,通过 SHA256 哈希派生为 32 字节密钥(AES-256 需要 32 字节) 42 | - 每次加密使用随机 nonce(12 字节),确保相同明文产生不同密文 43 | 44 | **密文格式:** 45 | - 密文格式:`nonce + encrypted_data` 46 | - 最终以 base64 编码返回 47 | 48 | ## 支持的加密算法 49 | 50 | ### AlgorithmAES256GCM 51 | 52 | AES-256-GCM 加密算法(默认算法) 53 | 54 | ```go 55 | crypto.AlgorithmAES256GCM 56 | ``` 57 | 58 | ## API 文档 59 | 60 | ### Encrypt 61 | 62 | ```go 63 | func Encrypt(key, plaintext string, algorithm ...Algorithm) (string, error) 64 | ``` 65 | 66 | **参数:** 67 | - `key`: 加密密钥(字符串) 68 | - `plaintext`: 待加密的明文 69 | - `algorithm`: 加密算法(可选参数,可变参数,不传则使用默认算法 `AlgorithmAES256GCM`) 70 | 71 | **返回:** 72 | - `string`: base64 编码的密文 73 | - `error`: 错误信息(如果算法不支持、密钥为空或加密失败) 74 | 75 | **示例:** 76 | ```go 77 | // 使用默认算法(推荐) 78 | encrypted, err := crypto.Encrypt("key", "plaintext") 79 | 80 | // 使用指定算法 81 | encrypted, err := crypto.Encrypt("key", "plaintext", crypto.AlgorithmAES256GCM) 82 | ``` 83 | 84 | ### Decrypt 85 | 86 | ```go 87 | func Decrypt(key, ciphertext string, algorithm ...Algorithm) (string, error) 88 | ``` 89 | 90 | **参数:** 91 | - `key`: 解密密钥(字符串,必须与加密时使用的密钥相同) 92 | - `ciphertext`: base64 编码的密文 93 | - `algorithm`: 解密算法(可选参数,可变参数,不传则使用默认算法 `AlgorithmAES256GCM`) 94 | 95 | **返回:** 96 | - `string`: 解密后的明文 97 | - `error`: 错误信息(如果算法不支持、密钥为空、密文格式错误或解密失败) 98 | 99 | **示例:** 100 | ```go 101 | // 使用默认算法(推荐) 102 | decrypted, err := crypto.Decrypt("key", encrypted) 103 | 104 | // 使用指定算法 105 | decrypted, err := crypto.Decrypt("key", encrypted, crypto.AlgorithmAES256GCM) 106 | ``` 107 | 108 | ## 使用示例 109 | 110 | ### 示例 1:使用默认算法(推荐) 111 | 112 | ```go 113 | package main 114 | 115 | import ( 116 | "fmt" 117 | "github.com/wannanbigpig/gin-layout/pkg/utils/crypto" 118 | ) 119 | 120 | func main() { 121 | key := "my-secret-key-12345" 122 | plaintext := "Hello, World!" 123 | 124 | // 使用默认算法加密(不传算法参数) 125 | encrypted, err := crypto.Encrypt(key, plaintext) 126 | if err != nil { 127 | fmt.Printf("加密失败: %v\n", err) 128 | return 129 | } 130 | fmt.Printf("密文: %s\n", encrypted) 131 | 132 | // 使用默认算法解密(不传算法参数) 133 | decrypted, err := crypto.Decrypt(key, encrypted) 134 | if err != nil { 135 | fmt.Printf("解密失败: %v\n", err) 136 | return 137 | } 138 | fmt.Printf("明文: %s\n", decrypted) 139 | } 140 | ``` 141 | 142 | ### 示例 2:使用指定算法 143 | 144 | ```go 145 | package main 146 | 147 | import ( 148 | "fmt" 149 | "github.com/wannanbigpig/gin-layout/pkg/utils/crypto" 150 | ) 151 | 152 | func main() { 153 | key := "my-secret-key-12345" 154 | plaintext := "Hello, World!" 155 | 156 | // 使用指定算法加密 157 | encrypted, err := crypto.Encrypt(key, plaintext, crypto.AlgorithmAES256GCM) 158 | if err != nil { 159 | fmt.Printf("加密失败: %v\n", err) 160 | return 161 | } 162 | fmt.Printf("密文: %s\n", encrypted) 163 | 164 | // 使用指定算法解密 165 | decrypted, err := crypto.Decrypt(key, encrypted, crypto.AlgorithmAES256GCM) 166 | if err != nil { 167 | fmt.Printf("解密失败: %v\n", err) 168 | return 169 | } 170 | fmt.Printf("明文: %s\n", decrypted) 171 | } 172 | ``` 173 | 174 | ## 注意事项 175 | 176 | 1. **密钥管理**:请妥善保管加密密钥,建议使用环境变量或密钥管理服务 177 | 2. **密钥长度**:密钥通过 SHA256 派生为 32 字节,建议使用足够长的密钥字符串 178 | 3. **安全性**:每次加密使用随机 nonce,相同明文会产生不同密文,提高安全性 179 | 4. **错误处理**:请务必检查返回的错误,确保加密/解密操作成功 180 | 5. **空值处理**:空字符串会直接返回空字符串,不会进行加密操作 181 | 182 | ## 性能 183 | 184 | - **加密速度**:快(有硬件加速支持) 185 | - **解密速度**:快(有硬件加速支持) 186 | - **CPU 占用**:低 187 | - **内存占用**:低 188 | 189 | ## 适用场景 190 | 191 | - 敏感数据加密存储(如 token、密码等) 192 | - 配置文件加密 193 | - 数据库字段加密 194 | - 日志敏感信息加密 195 | -------------------------------------------------------------------------------- /internal/model/a_admin_login_logs.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "github.com/wannanbigpig/gin-layout/internal/global" 5 | "github.com/wannanbigpig/gin-layout/internal/model/modelDict" 6 | "github.com/wannanbigpig/gin-layout/internal/pkg/utils" 7 | ) 8 | 9 | // 登录操作类型常量 10 | const ( 11 | LoginTypeLogin uint8 = 1 // 登录操作 12 | LoginTypeRefresh uint8 = 2 // 刷新token 13 | ) 14 | 15 | // 登录状态常量 16 | const ( 17 | LoginStatusSuccess uint8 = 1 // 登录成功 18 | LoginStatusFail uint8 = 0 // 登录失败 19 | ) 20 | 21 | // 登录状态字典 22 | var LoginStatusDict modelDict.Dict = map[uint8]string{ 23 | LoginStatusFail: "失败", 24 | LoginStatusSuccess: "成功", 25 | } 26 | 27 | // 登录操作类型字典 28 | var LoginTypeDict modelDict.Dict = map[uint8]string{ 29 | LoginTypeLogin: "登录操作", 30 | LoginTypeRefresh: "刷新token", 31 | } 32 | 33 | // 是否被撤销常量(使用 global.Yes/No,这里定义别名以便使用) 34 | const ( 35 | IsRevokedNo = global.No // 否 36 | IsRevokedYes = global.Yes // 是 37 | ) 38 | 39 | // 撤销原因码常量 40 | const ( 41 | RevokedCodeUserLogout uint8 = 1 // 用户主动登出(退出登录) 42 | RevokedCodeSystemForce uint8 = 2 // 系统强制登出(账号被封) 43 | RevokedCodeTokenRefresh uint8 = 3 // 系统刷新token 44 | RevokedCodeUserDisable uint8 = 4 // 用户禁用(针对某个设备下线操作) 45 | RevokedCodeOther uint8 = 5 // 其他原因 46 | RevokedCodePasswordChangeSelf uint8 = 6 // 用户自己修改密码 47 | RevokedCodePasswordChangeAdmin uint8 = 7 // 管理员修改密码 48 | ) 49 | 50 | // RevokedCodeDict 撤销原因码字典 51 | var RevokedCodeDict modelDict.Dict = map[uint8]string{ 52 | RevokedCodeUserLogout: "用户主动登出(退出登录)", 53 | RevokedCodeSystemForce: "系统强制登出(账号被封)", 54 | RevokedCodeTokenRefresh: "系统刷新token", 55 | RevokedCodeUserDisable: "用户禁用(针对某个设备下线操作)", 56 | RevokedCodeOther: "其他原因", 57 | RevokedCodePasswordChangeSelf: "用户自己修改密码", 58 | RevokedCodePasswordChangeAdmin: "管理员修改密码", 59 | } 60 | 61 | // AdminLoginLogs 登录日志表 62 | type AdminLoginLogs struct { 63 | ContainsDeleteBaseModel 64 | UID uint `json:"uid"` // 用户ID(登录失败时为0) 65 | Username string `json:"username"` // 登录账号 66 | JwtID string `json:"jwt_id"` // JWT唯一标识(jti claim) 67 | AccessToken string `json:"access_token"` // 访问令牌 68 | RefreshToken string `json:"refresh_token"` // 刷新令牌 69 | TokenHash string `json:"token_hash"` // Token的SHA256哈希值 70 | RefreshTokenHash string `json:"refresh_token_hash"` // Refresh Token的哈希值 71 | IP string `json:"ip"` // 登录IP(支持IPv6) 72 | UserAgent string `json:"user_agent"` // 用户代理(浏览器/设备信息) 73 | OS string `json:"os"` // 操作系统 74 | Browser string `json:"browser"` // 浏览器 75 | ExecutionTime int `json:"execution_time"` // 登录耗时(毫秒) 76 | LoginStatus uint8 `json:"login_status"` // 登录状态:1=成功, 0=失败 77 | LoginFailReason string `json:"login_fail_reason"` // 登录失败原因 78 | Type uint8 `json:"type"` // 操作类型:1=登录操作, 2=刷新token 79 | IsRevoked uint8 `json:"is_revoked"` // 是否被撤销:0=否, 1=是 80 | RevokedCode uint8 `json:"revoked_code"` // 撤销原因码:1=用户主动登出(退出登录), 2=系统强制登出(账号被封), 3=系统刷新token, 4=用户禁用(针对某个设备下线操作) 5=其他原因 81 | RevokedReason string `json:"revoked_reason"` // 撤销原因 82 | RevokedAt *utils.FormatDate `json:"revoked_at"` // 撤销时间 83 | TokenExpires *utils.FormatDate `json:"token_expires"` // Token过期时间 84 | RefreshExpires *utils.FormatDate `json:"refresh_expires"` // Refresh Token过期时间 85 | } 86 | 87 | func NewAdminLoginLogs() *AdminLoginLogs { 88 | return &AdminLoginLogs{} 89 | } 90 | 91 | // TableName 获取表名 92 | func (m *AdminLoginLogs) TableName() string { 93 | return "a_admin_login_logs" 94 | } 95 | 96 | // LoginStatusMap 登录状态映射 97 | func (m *AdminLoginLogs) LoginStatusMap() string { 98 | return LoginStatusDict.Map(m.LoginStatus) 99 | } 100 | 101 | // TypeMap 操作类型映射 102 | func (m *AdminLoginLogs) TypeMap() string { 103 | return LoginTypeDict.Map(m.Type) 104 | } 105 | 106 | // IsRevokedMap 是否被撤销映射 107 | func (m *AdminLoginLogs) IsRevokedMap() string { 108 | return modelDict.IsMap.Map(m.IsRevoked) 109 | } 110 | 111 | // RevokedCodeMap 撤销原因码映射 112 | func (m *AdminLoginLogs) RevokedCodeMap() string { 113 | return RevokedCodeDict.Map(m.RevokedCode) 114 | } 115 | -------------------------------------------------------------------------------- /internal/resources/admin_user.go: -------------------------------------------------------------------------------- 1 | package resources 2 | 3 | import ( 4 | "github.com/samber/lo" 5 | "github.com/wannanbigpig/gin-layout/internal/model" 6 | "github.com/wannanbigpig/gin-layout/internal/pkg/utils" 7 | ) 8 | 9 | // AdminUserResources 是后台管理员用户的响应资源结构体 10 | // 用于对外暴露字段,避免直接返回数据库模型结构体 11 | // 可配合脱敏规则处理敏感信息 12 | type AdminUserResources struct { 13 | ID uint `json:"id"` // 管理员ID 14 | Nickname string `json:"nickname"` // 昵称 15 | Username string `json:"username"` // 用户名 16 | IsSuperAdmin uint8 `json:"is_super_admin"` // 是否为超级管理员 17 | IsSuperAdminName string `json:"is_super_admin_name"` // 是否为超级管理员名称 18 | PhoneNumber string `json:"phone_number"` // 手机号(可脱敏) 19 | CountryCode string `json:"country_code"` // 国家区号 20 | Email string `json:"email"` // 邮箱(可脱敏) 21 | Avatar string `json:"avatar"` // 头像链接 22 | CreatedAt utils.FormatDate `json:"created_at"` // 创建时间 23 | UpdatedAt utils.FormatDate `json:"updated_at"` // 更新时间 24 | Status int8 `json:"status"` // 状态(1启用/2禁用) 25 | StatusName string `json:"status_name"` // 状态名称 26 | LastIp string `json:"last_ip"` // 上次登录 IP 27 | LastLogin utils.FormatDate `json:"last_login"` // 上次登录时间 28 | Departments []department `json:"departments"` // 部门信息D 29 | RoleList []uint `json:"role_list"` // 角色信息 30 | } 31 | 32 | // AdminUserTransformer 是 AdminUser 的资源转换器,实现 Resources 接口 33 | // 内部嵌入 BaseResources 实现结构复用 34 | type AdminUserTransformer struct { 35 | BaseResources[*model.AdminUser, *AdminUserResources] 36 | } 37 | 38 | func (r *AdminUserResources) SetCustomFields(data *model.AdminUser) { 39 | // 初始化 RoleList 和 Departments 为空切片,确保字段总是存在 40 | r.RoleList = []uint{} 41 | r.Departments = []department{} 42 | if data == nil { 43 | return 44 | } 45 | // 设置映射字段 46 | r.IsSuperAdminName = data.IsSuperAdminMap() 47 | r.StatusName = data.StatusMap() 48 | // 头像URL原样返回 49 | r.Avatar = data.Avatar 50 | // 如果 RoleList 有数据,则提取 RoleId 51 | if len(data.RoleList) > 0 { 52 | r.RoleList = lo.Map(data.RoleList, func(m model.AdminUserRoleMap, _ int) uint { 53 | return m.RoleId 54 | }) 55 | } 56 | // 如果 Department 有数据,则转换为 department 结构 57 | if len(data.Department) > 0 { 58 | r.Departments = lo.Map(data.Department, func(d model.Department, _ int) department { 59 | return department{ 60 | ID: d.ID, 61 | Name: d.Name, 62 | Pid: d.Pid, 63 | } 64 | }) 65 | } 66 | } 67 | 68 | // NewAdminUserTransformer 返回 AdminUserTransformer 实例,绑定资源创建函数 69 | func NewAdminUserTransformer() AdminUserTransformer { 70 | return AdminUserTransformer{ 71 | BaseResources: BaseResources[*model.AdminUser, *AdminUserResources]{ 72 | NewResource: func() *AdminUserResources { 73 | return &AdminUserResources{} 74 | }, 75 | }, 76 | } 77 | } 78 | 79 | // ToCollection 覆盖默认实现,支持手机号、邮箱等字段的自定义脱敏逻辑 80 | // 若无特殊处理需求,可不实现该方法,默认继承 BaseResources 的逻辑 81 | func (AdminUserTransformer) ToCollection(page, perPage int, total int64, data []*model.AdminUser) *Collection { 82 | response := make([]any, 0, len(data)) 83 | phoneRule := utils.NewPhoneRule() // 手机号脱敏规则 84 | emailRule := utils.NewEmailRule() // 邮箱脱敏规则 85 | 86 | for _, v := range data { 87 | deptSlice := make([]department, 0, len(data)) 88 | for _, d := range v.Department { 89 | deptSlice = append(deptSlice, department{ 90 | ID: d.ID, 91 | Name: d.Name, 92 | Pid: d.Pid, 93 | }) 94 | } 95 | 96 | response = append(response, &AdminUserResources{ 97 | ID: v.ID, 98 | Nickname: v.Nickname, 99 | Username: v.Username, 100 | IsSuperAdmin: v.IsSuperAdmin, 101 | IsSuperAdminName: v.IsSuperAdminMap(), 102 | PhoneNumber: phoneRule.Apply(v.PhoneNumber), 103 | CountryCode: v.CountryCode, 104 | Email: emailRule.Apply(v.Email), 105 | Avatar: v.Avatar, 106 | Status: v.Status, 107 | StatusName: v.StatusMap(), 108 | LastIp: v.LastIp, 109 | LastLogin: v.LastLogin, 110 | CreatedAt: v.CreatedAt, 111 | UpdatedAt: v.UpdatedAt, 112 | Departments: deptSlice, 113 | }) 114 | } 115 | 116 | return NewCollection().SetPaginate(page, perPage, total).ToCollection(response) 117 | } 118 | -------------------------------------------------------------------------------- /data/mysql.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | "sync" 8 | 9 | "gorm.io/driver/mysql" 10 | "gorm.io/gorm" 11 | "gorm.io/gorm/logger" 12 | "gorm.io/gorm/schema" 13 | 14 | c "github.com/wannanbigpig/gin-layout/config" 15 | log "github.com/wannanbigpig/gin-layout/internal/pkg/logger" 16 | ) 17 | 18 | var ( 19 | mysqlDB *gorm.DB 20 | mysqlOnce sync.Once 21 | mysqlInitError error 22 | ) 23 | 24 | // Writer interface for custom logger 25 | type Writer interface { 26 | Printf(string, ...interface{}) 27 | } 28 | 29 | // WriterLog Custom logger implementation 30 | type WriterLog struct{} 31 | 32 | func (w WriterLog) Printf(format string, args ...interface{}) { 33 | if c.Config.Mysql.PrintSql { 34 | log.Logger.Sugar().Infof(format, args...) 35 | } 36 | } 37 | 38 | // GenerateDSN generates the MySQL DSN string with proper encoding 39 | func GenerateDSN() string { 40 | // 防御性编码 41 | if c.Config.Mysql.Host == "" || c.Config.Mysql.Database == "" { 42 | return "" 43 | } 44 | 45 | // 特殊字符处理 46 | username := strings.Replace(url.QueryEscape(c.Config.Mysql.Username), "%", "%25", -1) 47 | password := strings.Replace(url.QueryEscape(c.Config.Mysql.Password), "%", "%25", -1) 48 | 49 | // IPv6处理 50 | host := c.Config.Mysql.Host 51 | if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") { 52 | host = "[" + host + "]" 53 | } 54 | 55 | // 强制关键参数 56 | charset := "utf8mb4" 57 | dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s", 58 | username, 59 | password, 60 | host, 61 | c.Config.Mysql.Port, 62 | c.Config.Mysql.Database, 63 | ) 64 | 65 | // 参数显式排序 66 | params := url.Values{ 67 | "charset": []string{charset}, 68 | "parseTime": []string{"true"}, 69 | "loc": []string{"Local"}, 70 | "timeout": []string{"5s"}, 71 | "readTimeout": []string{"30s"}, 72 | "writeTimeout": []string{"60s"}, 73 | } 74 | 75 | return dsn + "?" + params.Encode() 76 | } 77 | 78 | // initMysql initializes the MySQL database connection 79 | func initMysql() error { 80 | // Validate configuration parameters 81 | if c.Config.Mysql.MaxIdleConns < 0 || c.Config.Mysql.MaxOpenConns < 0 || c.Config.Mysql.MaxLifetime < 0 { 82 | return fmt.Errorf("invalid MySQL configuration: MaxIdleConns, MaxOpenConns, and MaxLifetime must be non-negative") 83 | } 84 | 85 | // Initialize logger 86 | logConfig := logger.New( 87 | WriterLog{}, 88 | logger.Config{ 89 | SlowThreshold: 0, // Slow SQL threshold 90 | LogLevel: logger.LogLevel(c.Config.Mysql.LogLevel), // Log level 91 | IgnoreRecordNotFoundError: false, // Ignore ErrRecordNotFound 92 | Colorful: false, // Disable colorful logs 93 | }, 94 | ) 95 | 96 | // Configure GORM settings 97 | configs := &gorm.Config{ 98 | NamingStrategy: schema.NamingStrategy{ 99 | TablePrefix: c.Config.Mysql.TablePrefix, // Table prefix 100 | }, 101 | Logger: logConfig, 102 | SkipDefaultTransaction: true, 103 | } 104 | 105 | // Open database connection 106 | dsn := GenerateDSN() 107 | var err error 108 | mysqlDB, err = gorm.Open(mysql.Open(dsn), configs) 109 | if err != nil { 110 | return fmt.Errorf("failed to connect to MySQL: %s", err.Error()) 111 | } 112 | 113 | // Get underlying sql.DB and configure connection pool 114 | sqlDB, err := mysqlDB.DB() 115 | if err != nil { 116 | return fmt.Errorf("failed to get sql.DB: %s", err.Error()) 117 | } 118 | 119 | sqlDB.SetMaxIdleConns(c.Config.Mysql.MaxIdleConns) 120 | sqlDB.SetMaxOpenConns(c.Config.Mysql.MaxOpenConns) 121 | sqlDB.SetConnMaxLifetime(c.Config.Mysql.MaxLifetime) 122 | 123 | // Register callbacks if needed 124 | registerCallbacks(mysqlDB) 125 | return nil 126 | } 127 | 128 | // registerCallbacks registers GORM callbacks for logging operations 129 | func registerCallbacks(db *gorm.DB) { 130 | // Uncomment and implement these functions if needed 131 | // db.Callback().Create().After("gorm:create").Register("log_create_operation", logCreateOperation) 132 | // db.Callback().Update().After("gorm:update").Register("log_update_operation", logUpdateOperation) 133 | // db.Callback().Delete().After("gorm:delete").Register("log_delete_operation", logDeleteOperation) 134 | } 135 | 136 | // MysqlDB returns the singleton instance of gorm.DB 137 | func MysqlDB() *gorm.DB { 138 | if mysqlDB == nil { 139 | mysqlOnce.Do(func() { 140 | mysqlInitError = initMysql() 141 | }) 142 | } 143 | return mysqlDB 144 | } 145 | 146 | func MysqlInitError() error { 147 | return mysqlInitError 148 | } 149 | -------------------------------------------------------------------------------- /cmd/cron/cron.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | "time" 10 | 11 | "github.com/robfig/cron/v3" 12 | "github.com/spf13/cobra" 13 | "go.uber.org/zap" 14 | 15 | "github.com/wannanbigpig/gin-layout/data" 16 | log "github.com/wannanbigpig/gin-layout/internal/pkg/logger" 17 | "github.com/wannanbigpig/gin-layout/internal/service/system" 18 | ) 19 | 20 | const ( 21 | // cronSchedule 定时任务执行计划(每5秒执行一次,用于测试) 22 | cronSchedule = "0/5 * * * * *" 23 | // resetSystemDataSchedule 重置系统数据任务执行计划(每天凌晨2点执行) 24 | resetSystemDataSchedule = "0 0 2 * * *" 25 | // timeFormat 时间格式 26 | timeFormat = "2006-01-02 15:04:05" 27 | ) 28 | 29 | var ( 30 | Cmd = &cobra.Command{ 31 | Use: "cron", 32 | Short: "Starting a scheduled task", 33 | Example: "go-layout cron", 34 | PreRun: func(cmd *cobra.Command, args []string) { 35 | // 计划任务中使用数据请先初始化数据库链接 36 | data.InitData() 37 | }, 38 | Run: func(cmd *cobra.Command, args []string) { 39 | Start() 40 | }, 41 | } 42 | ) 43 | 44 | // Start 启动定时任务服务 45 | func Start() { 46 | // 初始化定时器 47 | crontab := createCronScheduler() 48 | if crontab == nil { 49 | errMsg := "创建定时任务调度器失败" 50 | log.Logger.Error(errMsg) 51 | fmt.Fprintf(os.Stderr, "错误: %s\n", errMsg) 52 | os.Exit(1) 53 | } 54 | 55 | // 添加任务 56 | if err := addCronJob(crontab); err != nil { 57 | errMsg := fmt.Sprintf("定时任务启动失败: %v", err) 58 | log.Logger.Error(errMsg, zap.Error(err)) 59 | fmt.Fprintf(os.Stderr, "错误: %s\n", errMsg) 60 | os.Exit(1) 61 | } 62 | 63 | // 启动定时器 64 | crontab.Start() 65 | defer crontab.Stop() 66 | 67 | log.Logger.Info("Cron service started successfully") 68 | 69 | // 优雅关闭 70 | waitForShutdown() 71 | log.Logger.Info("Cron service stopped gracefully") 72 | } 73 | 74 | // createCronScheduler 创建定时任务调度器 75 | func createCronScheduler() *cron.Cron { 76 | myLog := &cronLogger{} 77 | return cron.New( 78 | cron.WithSeconds(), 79 | cron.WithChain(cron.Recover(myLog)), 80 | ) 81 | } 82 | 83 | // addCronJob 添加定时任务 84 | func addCronJob(crontab *cron.Cron) error { 85 | myLog := &cronLogger{} 86 | 87 | // 1. 添加测试任务(每5秒执行一次,用于测试) 88 | testJob := cron.NewChain( 89 | cron.SkipIfStillRunning(myLog), 90 | cron.Recover(myLog), 91 | ).Then(cron.FuncJob(runTask)) 92 | _, err := crontab.AddJob(cronSchedule, testJob) 93 | if err != nil { 94 | return fmt.Errorf("添加测试任务失败 (schedule: %s): %w", cronSchedule, err) 95 | } 96 | 97 | // 2. 添加重置系统数据任务(每天凌晨2点执行) 98 | resetJob := cron.NewChain( 99 | cron.SkipIfStillRunning(myLog), 100 | cron.Recover(myLog), 101 | ).Then(cron.FuncJob(resetSystemDataTask)) 102 | _, err = crontab.AddJob(resetSystemDataSchedule, resetJob) 103 | if err != nil { 104 | return fmt.Errorf("添加重置系统数据任务失败 (schedule: %s): %w", resetSystemDataSchedule, err) 105 | } 106 | 107 | log.Logger.Info("定时任务添加成功", 108 | zap.String("test_task", cronSchedule), 109 | zap.String("reset_task", resetSystemDataSchedule), 110 | ) 111 | 112 | return nil 113 | } 114 | 115 | // waitForShutdown 等待关闭信号,实现优雅关闭 116 | func waitForShutdown() { 117 | ctx, cancel := context.WithCancel(context.Background()) 118 | defer cancel() 119 | 120 | go handleSignals(cancel) 121 | <-ctx.Done() 122 | } 123 | 124 | // runTask 执行定时任务(测试任务) 125 | func runTask() { 126 | log.Logger.Info("计划任务 demo 执行:", zap.String("time", time.Now().Format(timeFormat))) 127 | } 128 | 129 | // resetSystemDataTask 重置系统数据任务 130 | // 每天凌晨2点执行,重新初始化系统数据(回滚迁移、重新执行迁移、重新初始化路由和路由映射) 131 | func resetSystemDataTask() { 132 | log.Logger.Info("开始执行重置系统数据任务", zap.String("time", time.Now().Format(timeFormat))) 133 | 134 | resetService := system.NewResetService() 135 | if err := resetService.ReinitializeSystemData(); err != nil { 136 | log.Logger.Error("重置系统数据任务执行失败", zap.Error(err)) 137 | return 138 | } 139 | 140 | log.Logger.Info("重置系统数据任务执行完成", zap.String("time", time.Now().Format(timeFormat))) 141 | } 142 | 143 | // handleSignals 处理系统信号(SIGINT、SIGTERM) 144 | func handleSignals(cancel context.CancelFunc) { 145 | sigChan := make(chan os.Signal, 1) 146 | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) 147 | 148 | sig := <-sigChan 149 | log.Logger.Warn("Received shutdown signal", zap.String("signal", sig.String())) 150 | cancel() 151 | } 152 | 153 | // cronLogger 定时任务日志记录器 154 | type cronLogger struct{} 155 | 156 | // Info 记录信息日志 157 | func (cl *cronLogger) Info(msg string, keysAndValues ...interface{}) { 158 | if len(keysAndValues) > 0 { 159 | log.Logger.Info(fmt.Sprintf(msg, keysAndValues...)) 160 | } else { 161 | log.Logger.Info(msg) 162 | } 163 | } 164 | 165 | // Error 记录错误日志 166 | func (cl *cronLogger) Error(err error, msg string, keysAndValues ...interface{}) { 167 | errorMsg := err.Error() 168 | if len(keysAndValues) > 0 { 169 | errorMsg += " " + fmt.Sprintf(msg, keysAndValues...) 170 | } else if msg != "" { 171 | errorMsg += " " + msg 172 | } 173 | log.Logger.Error(errorMsg, zap.Error(err)) 174 | } 175 | -------------------------------------------------------------------------------- /internal/service/system/init.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/gin-gonic/gin" 9 | "go.uber.org/zap" 10 | 11 | "github.com/wannanbigpig/gin-layout/data" 12 | "github.com/wannanbigpig/gin-layout/internal/model" 13 | log "github.com/wannanbigpig/gin-layout/internal/pkg/logger" 14 | "github.com/wannanbigpig/gin-layout/internal/routers" 15 | "github.com/wannanbigpig/gin-layout/internal/validator" 16 | "github.com/wannanbigpig/gin-layout/pkg/utils" 17 | ) 18 | 19 | const ( 20 | defaultSort = 100 21 | defaultIsAuth = 0 22 | defaultGroupCode = "other" 23 | ) 24 | 25 | // InitService 初始化服务 26 | type InitService struct{} 27 | 28 | // NewInitService 创建初始化服务实例 29 | func NewInitService() *InitService { 30 | return &InitService{} 31 | } 32 | 33 | // InitApiRoutes 初始化API路由 34 | func (s *InitService) InitApiRoutes() error { 35 | // 检查数据库连接 36 | if err := s.checkDatabaseConnection(); err != nil { 37 | return err 38 | } 39 | 40 | // 初始化验证器 41 | validator.InitValidatorTrans("zh") 42 | 43 | // 设置路由(需要获取路由信息) 44 | engine, apiMap := routers.SetRouters(true) 45 | 46 | // 构建API数据 47 | apiData := s.buildApiData(engine, apiMap) 48 | 49 | // 保存API数据 50 | if err := s.saveApiData(apiData); err != nil { 51 | return fmt.Errorf("保存API数据失败: %w", err) 52 | } 53 | 54 | return nil 55 | } 56 | 57 | // InitMenuApiMap 初始化菜单-API映射 58 | func (s *InitService) InitMenuApiMap() error { 59 | // 检查数据库连接 60 | if err := s.checkDatabaseConnection(); err != nil { 61 | return err 62 | } 63 | 64 | // 执行初始化 65 | return s.buildMenuApiMap() 66 | } 67 | 68 | // checkDatabaseConnection 检查数据库连接 69 | func (s *InitService) checkDatabaseConnection() error { 70 | db := data.MysqlDB() 71 | if db == nil { 72 | return fmt.Errorf("数据库连接未初始化,请检查配置") 73 | } 74 | 75 | // 测试数据库连接 76 | sqlDB, err := db.DB() 77 | if err != nil { 78 | return fmt.Errorf("获取数据库连接失败: %w", err) 79 | } 80 | 81 | if err := sqlDB.Ping(); err != nil { 82 | return fmt.Errorf("数据库连接测试失败: %w", err) 83 | } 84 | 85 | return nil 86 | } 87 | 88 | // buildApiData 构建API数据 89 | func (s *InitService) buildApiData(engine *gin.Engine, apiMap routers.ApiMap) []map[string]any { 90 | date := time.Now().Format(time.DateTime) 91 | apiData := make([]map[string]any, 0, len(engine.Routes())) 92 | 93 | for _, route := range engine.Routes() { 94 | apiInfo := s.extractApiInfo(route, apiMap, date) 95 | apiData = append(apiData, apiInfo) 96 | } 97 | 98 | return apiData 99 | } 100 | 101 | // extractApiInfo 提取API信息 102 | func (s *InitService) extractApiInfo(route gin.RouteInfo, apiMap routers.ApiMap, date string) map[string]any { 103 | code := utils.MD5(route.Method + "_" + route.Path) 104 | name := route.Path 105 | isAuth := defaultIsAuth 106 | desc := "" 107 | groupCode := defaultGroupCode 108 | 109 | // 从 apiMap 中获取路由元信息 110 | if val, ok := apiMap[code]; ok { 111 | name = val.Title 112 | isAuth = int(val.Auth) 113 | desc = val.Desc 114 | groupCode = val.GroupCode 115 | } 116 | 117 | return map[string]any{ 118 | "code": code, 119 | "name": name, 120 | "route": route.Path, 121 | "method": route.Method, 122 | "func": s.extractHandlerName(route.Handler), 123 | "func_path": route.Handler, 124 | "is_auth": isAuth, 125 | "description": desc, 126 | "sort": defaultSort, 127 | "is_effective": 1, 128 | "created_at": date, 129 | "updated_at": date, 130 | "group_code": groupCode, 131 | } 132 | } 133 | 134 | // extractHandlerName 提取处理器名称 135 | func (s *InitService) extractHandlerName(handler string) string { 136 | parts := strings.Split(handler, ".") 137 | if len(parts) == 0 { 138 | return handler 139 | } 140 | handlerName := parts[len(parts)-1] 141 | // 移除方法接收器的后缀 "-fm" 142 | return strings.TrimSuffix(handlerName, "-fm") 143 | } 144 | 145 | // saveApiData 保存API数据到数据库 146 | func (s *InitService) saveApiData(apiData []map[string]any) error { 147 | apiModel := model.NewApi() 148 | date := time.Now().Format(time.DateTime) 149 | return apiModel.InitRegisters(apiData, date) 150 | } 151 | 152 | // buildMenuApiMap 构建菜单API关联表,根据casbin_rule表自动生成关联 153 | func (s *InitService) buildMenuApiMap() error { 154 | db := data.MysqlDB() 155 | 156 | // 执行 SQL:从 casbin_rule 表中提取菜单ID和API的route+method来关联 157 | sql := `INSERT INTO a_menu_api_map (menu_id, api_id, created_at, updated_at) 158 | SELECT 159 | CAST(SUBSTRING_INDEX(c.v0, ':', -1) AS UNSIGNED) AS menu_id, 160 | a.id AS api_id, 161 | NOW() AS created_at, 162 | NOW() AS updated_at 163 | FROM casbin_rule c 164 | INNER JOIN a_api a ON a.route = c.v1 AND a.method = c.v2 AND a.deleted_at = 0 165 | INNER JOIN a_menu m ON m.id = CAST(SUBSTRING_INDEX(c.v0, ':', -1) AS UNSIGNED) AND m.deleted_at = 0 166 | WHERE c.ptype = 'p' 167 | AND c.v0 LIKE 'menu:%' 168 | AND c.v1 != '' 169 | AND c.v2 != '' 170 | ON DUPLICATE KEY UPDATE updated_at = NOW()` 171 | 172 | if err := db.Exec(sql).Error; err != nil { 173 | log.Logger.Error("构建菜单API映射失败", zap.Error(err)) 174 | return err 175 | } 176 | 177 | return nil 178 | } 179 | -------------------------------------------------------------------------------- /internal/resources/request_log.go: -------------------------------------------------------------------------------- 1 | package resources 2 | 3 | import ( 4 | "github.com/wannanbigpig/gin-layout/internal/model" 5 | "github.com/wannanbigpig/gin-layout/internal/pkg/utils" 6 | ) 7 | 8 | // RequestLogBaseResources 请求日志基础资源(公共字段) 9 | type RequestLogBaseResources struct { 10 | ID uint `json:"id"` 11 | RequestID string `json:"request_id"` // 请求唯一标识 12 | OperatorID uint `json:"operator_id"` // 操作ID(用户ID) 13 | IP string `json:"ip"` // 客户端IP地址 14 | Method string `json:"method"` // HTTP请求方法(GET/POST等) 15 | BaseURL string `json:"base_url"` // 请求基础URL 16 | OperationName string `json:"operation_name"` // 操作名称 17 | OperationStatus int `json:"operation_status"` // 操作状态码(响应返回的code,0=成功,其他=失败) 18 | OperationStatusName string `json:"operation_status_name"` // 操作状态名称 19 | OperatorAccount string `json:"operator_account"` // 操作账号 20 | OperatorName string `json:"operator_name"` // 操作人员 21 | ResponseStatus int `json:"response_status"` // 响应状态码 22 | ExecutionTime float64 `json:"execution_time"` // 执行时间(毫秒,支持小数,最多4位) 23 | CreatedAt utils.FormatDate `json:"created_at"` // 创建时间 24 | } 25 | 26 | // RequestLogListResources 请求日志列表资源(简化版,不包含大字段) 27 | type RequestLogListResources struct { 28 | RequestLogBaseResources 29 | } 30 | 31 | // RequestLogResources 请求日志详情资源 32 | type RequestLogResources struct { 33 | RequestLogBaseResources 34 | JwtID string `json:"jwt_id"` // 请求授权的jwtId 35 | UserAgent string `json:"user_agent"` // 用户代理(浏览器/设备信息) 36 | OS string `json:"os"` // 操作系统 37 | Browser string `json:"browser"` // 浏览器 38 | RequestHeaders string `json:"request_headers"` // 请求头(JSON格式) 39 | RequestQuery string `json:"request_query"` // 请求参数 40 | RequestBody string `json:"request_body"` // 请求体 41 | ResponseBody string `json:"response_body"` // 响应体 42 | ResponseHeader string `json:"response_header"` // 响应头 43 | UpdatedAt utils.FormatDate `json:"updated_at"` // 更新时间 44 | } 45 | 46 | // RequestLogTransformer 请求日志资源转换器 47 | type RequestLogTransformer struct { 48 | BaseResources[*model.RequestLogs, *RequestLogResources] 49 | } 50 | 51 | // NewRequestLogTransformer 实例化请求日志资源转换器 52 | func NewRequestLogTransformer() RequestLogTransformer { 53 | return RequestLogTransformer{ 54 | BaseResources: BaseResources[*model.RequestLogs, *RequestLogResources]{ 55 | NewResource: func() *RequestLogResources { 56 | return &RequestLogResources{} 57 | }, 58 | }, 59 | } 60 | } 61 | 62 | // buildRequestLogBaseResources 构建基础资源(公共字段) 63 | func buildRequestLogBaseResources(data *model.RequestLogs) RequestLogBaseResources { 64 | return RequestLogBaseResources{ 65 | ID: data.ID, 66 | RequestID: data.RequestID, 67 | OperatorID: data.OperatorID, 68 | IP: data.IP, 69 | Method: data.Method, 70 | BaseURL: data.BaseURL, 71 | OperationName: data.OperationName, 72 | OperationStatus: data.OperationStatus, 73 | OperationStatusName: getOperationStatusName(data.OperationStatus), 74 | OperatorAccount: data.OperatorAccount, 75 | OperatorName: data.OperatorName, 76 | ResponseStatus: data.ResponseStatus, 77 | ExecutionTime: data.ExecutionTime, 78 | CreatedAt: data.CreatedAt, 79 | } 80 | } 81 | 82 | // ToStruct 转换为单个资源(详情) 83 | func (r RequestLogTransformer) ToStruct(data *model.RequestLogs) *RequestLogResources { 84 | base := buildRequestLogBaseResources(data) 85 | return &RequestLogResources{ 86 | RequestLogBaseResources: base, 87 | JwtID: data.JwtID, 88 | UserAgent: data.UserAgent, 89 | OS: data.OS, 90 | Browser: data.Browser, 91 | RequestHeaders: data.RequestHeaders, 92 | RequestQuery: data.RequestQuery, 93 | RequestBody: data.RequestBody, 94 | ResponseBody: data.ResponseBody, 95 | ResponseHeader: data.ResponseHeader, 96 | UpdatedAt: data.UpdatedAt, 97 | } 98 | } 99 | 100 | // ToCollection 转换为集合资源(列表,不包含大字段) 101 | func (r RequestLogTransformer) ToCollection(page, perPage int, total int64, data []*model.RequestLogs) *Collection { 102 | response := make([]any, 0, len(data)) 103 | for _, v := range data { 104 | base := buildRequestLogBaseResources(v) 105 | response = append(response, &RequestLogListResources{ 106 | RequestLogBaseResources: base, 107 | }) 108 | } 109 | return NewCollection().SetPaginate(page, perPage, total).ToCollection(response) 110 | } 111 | 112 | // getOperationStatusName 获取操作状态名称 113 | func getOperationStatusName(code int) string { 114 | if code == 0 { 115 | return "成功" 116 | } 117 | return "失败" 118 | } 119 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # golangci-lint 配置文件 2 | # 参考: https://golangci-lint.run/usage/configuration/ 3 | 4 | run: 5 | # 超时时间 6 | timeout: 5m 7 | # 并发数 8 | concurrency: 4 9 | # 包含的测试文件 10 | tests: true 11 | # 跳过目录 12 | skip-dirs: 13 | - vendor 14 | - build 15 | - dist 16 | - logs 17 | - storage 18 | - tmp 19 | # 跳过文件 20 | skip-files: 21 | - ".*\\.pb\\.go$" 22 | - ".*\\.gen\\.go$" 23 | 24 | # 输出配置 25 | output: 26 | # 输出格式: colored-line-number, line-number, json, tab, checkstyle, code-climate, html, junit-xml, github-actions 27 | format: colored-line-number 28 | # 打印问题数量 29 | print-issued-lines: true 30 | # 打印 linter 名称 31 | print-linter-name: true 32 | # 不打印欢迎信息 33 | uniq-by-line: false 34 | # 打印源代码行 35 | print-welcome: false 36 | 37 | # 启用的 linter 38 | linters: 39 | enable: 40 | # 代码质量 41 | - errcheck # 检查错误处理 42 | - gosimple # 简化代码建议 43 | - govet # go vet 检查 44 | - ineffassign # 检查未使用的赋值 45 | - staticcheck # 静态分析 46 | - unused # 检查未使用的代码 47 | - varcheck # 检查未使用的变量 48 | 49 | # 代码风格 50 | - gofmt # 代码格式检查 51 | - goimports # import 语句检查 52 | - misspell # 拼写检查 53 | - revive # Go 代码检查工具(替代 golint) 54 | - stylecheck # 风格检查 55 | 56 | # 性能 57 | - prealloc # 预分配切片检查 58 | 59 | # 复杂度 60 | - gocyclo # 圈复杂度检查 61 | - gocognit # 认知复杂度检查 62 | 63 | # 其他 64 | - exportloopref # 循环变量引用检查 65 | - gocritic # 代码审查建议 66 | - gosec # 安全检查 67 | - nakedret # 检查裸返回 68 | - noctx # 检查 context 传递 69 | - rowserrcheck # 检查 rows.Err() 70 | - sqlclosecheck # 检查 SQL 连接关闭 71 | 72 | linters-settings: 73 | # errcheck 配置 74 | errcheck: 75 | check-type-assertions: true 76 | check-blank: true 77 | 78 | # gocyclo 配置 - 圈复杂度阈值 79 | gocyclo: 80 | min-complexity: 15 81 | 82 | # gocognit 配置 - 认知复杂度阈值 83 | gocognit: 84 | min-complexity: 15 85 | 86 | # revive 配置 87 | revive: 88 | rules: 89 | - name: exported 90 | severity: warning 91 | - name: var-naming 92 | severity: warning 93 | - name: package-comments 94 | severity: warning 95 | - name: range 96 | severity: warning 97 | - name: increment-decrement 98 | severity: warning 99 | - name: error-return 100 | severity: warning 101 | - name: error-strings 102 | severity: warning 103 | - name: error-naming 104 | severity: warning 105 | - name: receiver-naming 106 | severity: warning 107 | - name: unexported-return 108 | severity: warning 109 | - name: time-equal 110 | severity: warning 111 | - name: banned-characters 112 | severity: warning 113 | - name: context-keys-type 114 | severity: warning 115 | - name: context-as-argument 116 | severity: warning 117 | - name: if-return 118 | severity: warning 119 | - name: increment-decrement 120 | severity: warning 121 | - name: var-declaration 122 | severity: warning 123 | - name: range-val-in-closure 124 | severity: warning 125 | - name: range-val-address 126 | severity: warning 127 | - name: waitgroup-by-value 128 | severity: warning 129 | - name: atomic 130 | severity: warning 131 | - name: empty-lines 132 | severity: warning 133 | - name: line-length-limit 134 | severity: warning 135 | arguments: 136 | - 120 137 | 138 | # gocritic 配置 139 | gocritic: 140 | enabled-tags: 141 | - diagnostic 142 | - experimental 143 | - opinionated 144 | - performance 145 | - style 146 | disabled-checks: 147 | - dupImport # 允许重复导入(某些情况下需要) 148 | - ifElseChain # 允许 if-else 链 149 | - octalLiteral # 允许八进制字面量 150 | 151 | # gosec 配置 152 | gosec: 153 | # 严重程度: low, medium, high 154 | severity: medium 155 | # 置信度: low, medium, high 156 | confidence: medium 157 | # 排除的规则 158 | excludes: 159 | - G104 # 审计错误未检查(某些情况下可以接受) 160 | - G401 # 弱随机数生成(某些场景可以接受) 161 | - G501 # 导入黑名单(某些依赖是必需的) 162 | 163 | issues: 164 | # 排除的问题 165 | exclude-rules: 166 | # 排除测试文件中的某些检查 167 | - path: _test\.go 168 | linters: 169 | - errcheck 170 | - gosec 171 | - gocritic 172 | - gocyclo 173 | - gocognit 174 | 175 | # 最大问题数(0 表示不限制) 176 | max-issues-per-linter: 0 177 | max-same-issues: 0 178 | 179 | # 排除的问题模式 180 | exclude: 181 | # 排除 "Error return value of .((os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*printf?|os\.(Un)?Setenv). is not checked" 182 | - 'Error return value of .((os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*printf?|os\.(Un)?Setenv). is not checked' 183 | # 排除 "exported function .* should have comment" 184 | - 'exported function .* should have comment' 185 | # 排除 "comment on exported .* should be of the form" 186 | - 'comment on exported .* should be of the form' 187 | 188 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "path/filepath" 7 | "sync" 8 | 9 | "github.com/fsnotify/fsnotify" 10 | "github.com/spf13/viper" 11 | 12 | . "github.com/wannanbigpig/gin-layout/config/autoload" 13 | utils2 "github.com/wannanbigpig/gin-layout/internal/pkg/utils" 14 | "github.com/wannanbigpig/gin-layout/pkg/utils" 15 | ) 16 | 17 | // Conf 配置项主结构体 18 | type Conf struct { 19 | AppConfig `mapstructure:"app"` 20 | Mysql MysqlConfig `mapstructure:"mysql"` 21 | Redis RedisConfig `mapstructure:"redis"` 22 | Logger LoggerConfig `mapstructure:"logger"` 23 | Jwt JwtConfig `mapstructure:"jwt"` 24 | } 25 | 26 | var ( 27 | Config = &Conf{ 28 | AppConfig: App, 29 | Mysql: Mysql, 30 | Redis: Redis, 31 | Logger: Logger, 32 | Jwt: Jwt, 33 | } 34 | once sync.Once 35 | V *viper.Viper 36 | ) 37 | 38 | func InitConfig(configPath string) { 39 | once.Do(func() { 40 | // 加载 .yaml 配置 41 | load(configPath) 42 | 43 | // 检查jwtSecretKey 44 | checkJwtSecretKey() 45 | }) 46 | } 47 | 48 | // checkJwtSecretKey 检查jwtSecretKey 49 | func checkJwtSecretKey() { 50 | // 自动生成JWT secretKey 51 | if Config.Jwt.SecretKey == "" { 52 | Config.Jwt.SecretKey = utils2.RandString(64) 53 | V.Set("jwt.secret_key", Config.Jwt.SecretKey) 54 | err := V.WriteConfig() 55 | if err != nil { 56 | panic("自动生成JWT secretKey失败: " + err.Error()) 57 | } 58 | } 59 | } 60 | 61 | func load(configPath string) { 62 | var filePath string 63 | if configPath == "" { 64 | // 判断是否为开发模式 65 | isDevelopment := os.Getenv("GO_ENV") == "development" 66 | 67 | var exampleConfig, targetConfig string 68 | 69 | if isDevelopment { 70 | // 开发模式:从当前工作目录查找配置文件 71 | workDir, err := os.Getwd() 72 | if err != nil { 73 | panic("获取工作目录失败: " + err.Error()) 74 | } 75 | // 先尝试项目根目录下的 config/ 目录 76 | exampleConfig = filepath.Join(workDir, "config", "config.yaml.example") 77 | if _, err := os.Stat(exampleConfig); os.IsNotExist(err) { 78 | // 再尝试项目根目录 79 | exampleConfig = filepath.Join(workDir, "config.yaml.example") 80 | } 81 | targetConfig = filepath.Join(workDir, "config.yaml") 82 | } else { 83 | // 生产模式:从执行文件目录查找配置文件 84 | runDirectory, err := utils.GetCurrentPath() 85 | if err != nil { 86 | panic("获取执行文件目录失败: " + err.Error()) 87 | } 88 | exampleConfig = filepath.Join(runDirectory, "config.yaml.example") 89 | targetConfig = filepath.Join(runDirectory, "config.yaml") 90 | } 91 | 92 | filePath = targetConfig 93 | copyConf(exampleConfig, filePath) 94 | } else { 95 | filePath = configPath 96 | } 97 | V = viper.New() 98 | // 路径必须要写相对路径,相对于项目的路径 99 | V.SetConfigFile(filePath) 100 | 101 | if err := V.ReadInConfig(); err != nil { 102 | if _, ok := err.(viper.ConfigFileNotFoundError); ok { 103 | panic("未找到配置: " + err.Error()) 104 | } else { 105 | panic("读取配置出错:" + err.Error()) 106 | } 107 | } 108 | 109 | // 映射到结构体 110 | if err := V.Unmarshal(&Config); err != nil { 111 | panic(err) 112 | } 113 | 114 | // 确保 CORS 配置字段有默认值(防止 nil 指针) 115 | ensureCorsDefaults() 116 | 117 | // 默认不监听配置变化,有些配置例如端口,数据库连接等即时配置变化不重启也不会变更。会导致配置文件与实际监听端口不一致混淆 118 | if Config.WatchConfig { 119 | // 监听配置文件变化 120 | V.WatchConfig() 121 | V.OnConfigChange(func(in fsnotify.Event) { 122 | if err := V.ReadInConfig(); err != nil { 123 | panic(err) 124 | } 125 | if err := V.Unmarshal(&Config); err != nil { 126 | panic(err) 127 | } 128 | // 确保 CORS 配置字段有默认值 129 | ensureCorsDefaults() 130 | }) 131 | } 132 | } 133 | 134 | // ensureCorsDefaults 确保 CORS 配置字段有默认值 135 | func ensureCorsDefaults() { 136 | // 如果切片为 nil,初始化为空切片 137 | if Config.CorsOrigins == nil { 138 | Config.CorsOrigins = []string{} 139 | } 140 | if Config.CorsMethods == nil { 141 | Config.CorsMethods = []string{} 142 | } 143 | if Config.CorsHeaders == nil { 144 | Config.CorsHeaders = []string{} 145 | } 146 | if Config.CorsExposeHeaders == nil { 147 | Config.CorsExposeHeaders = []string{} 148 | } 149 | // CorsMaxAge 和 CorsCredentials 是基本类型,不需要检查 nil 150 | // 但如果为 0,使用默认值 151 | if Config.CorsMaxAge == 0 { 152 | Config.CorsMaxAge = 43200 // 默认 12 小时 153 | } 154 | } 155 | 156 | // copyConf 复制配置示例文件 157 | func copyConf(exampleConfig, config string) { 158 | fileInfo, err := os.Stat(config) 159 | 160 | if err == nil { 161 | // 路径存在, 判断 config 文件是否目录,不是目录则代表文件存在直接 return 162 | if !fileInfo.IsDir() { 163 | return 164 | } 165 | panic("配置文件目录存在同名的文件夹,无法创建配置文件") 166 | } 167 | 168 | // 打开文件失败,并且返回的错误不是文件未找到 169 | if !os.IsNotExist(err) { 170 | panic("初始化失败: " + err.Error()) 171 | } 172 | 173 | // 自动复制一份示例文件 174 | source, err := os.Open(exampleConfig) 175 | if err != nil { 176 | panic("创建配置文件失败,配置示例文件不存在: " + err.Error()) 177 | } 178 | defer func(source *os.File) { 179 | err := source.Close() 180 | if err != nil { 181 | panic("关闭示例资源失败: " + err.Error()) 182 | } 183 | }(source) 184 | 185 | // 创建空文件 186 | dst, err := os.Create(config) 187 | if err != nil { 188 | panic("生成配置文件失败: " + err.Error()) 189 | } 190 | defer func(dst *os.File) { 191 | err := dst.Close() 192 | if err != nil { 193 | panic("关闭资源失败: " + err.Error()) 194 | } 195 | }(dst) 196 | 197 | // 复制内容 198 | _, err = io.Copy(dst, source) 199 | if err != nil { 200 | panic("写入配置文件失败: " + err.Error()) 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /internal/resources/login_log.go: -------------------------------------------------------------------------------- 1 | package resources 2 | 3 | import ( 4 | "github.com/wannanbigpig/gin-layout/internal/model" 5 | "github.com/wannanbigpig/gin-layout/internal/pkg/utils" 6 | ) 7 | 8 | // AdminLoginLogBaseResources 管理员登录日志基础资源(公共字段) 9 | type AdminLoginLogBaseResources struct { 10 | ID uint `json:"id"` 11 | UID uint `json:"uid"` // 用户ID(登录失败时为0) 12 | Username string `json:"username"` // 登录账号 13 | IP string `json:"ip"` // 登录IP(支持IPv6) 14 | OS string `json:"os"` // 操作系统 15 | Browser string `json:"browser"` // 浏览器 16 | ExecutionTime int `json:"execution_time"` // 登录耗时(毫秒) 17 | LoginStatus uint8 `json:"login_status"` // 登录状态:1=成功, 0=失败 18 | LoginStatusName string `json:"login_status_name"` // 登录状态名称 19 | LoginFailReason string `json:"login_fail_reason"` // 登录失败原因 20 | Type uint8 `json:"type"` // 操作类型:1=登录操作, 2=刷新token 21 | TypeName string `json:"type_name"` // 操作类型名称 22 | IsRevoked uint8 `json:"is_revoked"` // 是否被撤销:0=否, 1=是 23 | IsRevokedName string `json:"is_revoked_name"` // 是否被撤销名称 24 | RevokedCode uint8 `json:"revoked_code"` // 撤销原因码 25 | RevokedCodeName string `json:"revoked_code_name"` // 撤销原因码名称 26 | RevokedReason string `json:"revoked_reason"` // 撤销原因 27 | RevokedAt *utils.FormatDate `json:"revoked_at"` // 撤销时间 28 | CreatedAt utils.FormatDate `json:"created_at"` // 创建时间 29 | } 30 | 31 | // AdminLoginLogListResources 管理员登录日志列表资源(简化版,不包含大字段) 32 | type AdminLoginLogListResources struct { 33 | AdminLoginLogBaseResources 34 | } 35 | 36 | // AdminLoginLogResources 管理员登录日志详情资源 37 | type AdminLoginLogResources struct { 38 | AdminLoginLogBaseResources 39 | JwtID string `json:"jwt_id"` // JWT唯一标识(jti claim) 40 | UserAgent string `json:"user_agent"` // 用户代理(浏览器/设备信息) 41 | AccessToken string `json:"access_token"` // 访问令牌(解密后) 42 | RefreshToken string `json:"refresh_token"` // 刷新令牌(解密后) 43 | TokenHash string `json:"token_hash"` // Token的SHA256哈希值 44 | RefreshTokenHash string `json:"refresh_token_hash"` // Refresh Token的哈希值 45 | TokenExpires *utils.FormatDate `json:"token_expires"` // Token过期时间 46 | RefreshExpires *utils.FormatDate `json:"refresh_expires"` // Refresh Token过期时间 47 | UpdatedAt utils.FormatDate `json:"updated_at"` // 更新时间 48 | } 49 | 50 | // AdminLoginLogTransformer 管理员登录日志资源转换器 51 | type AdminLoginLogTransformer struct { 52 | BaseResources[*model.AdminLoginLogs, *AdminLoginLogResources] 53 | } 54 | 55 | // NewAdminLoginLogTransformer 实例化管理员登录日志资源转换器 56 | func NewAdminLoginLogTransformer() AdminLoginLogTransformer { 57 | return AdminLoginLogTransformer{ 58 | BaseResources: BaseResources[*model.AdminLoginLogs, *AdminLoginLogResources]{ 59 | NewResource: func() *AdminLoginLogResources { 60 | return &AdminLoginLogResources{} 61 | }, 62 | }, 63 | } 64 | } 65 | 66 | // buildAdminLoginLogBaseResources 构建基础资源(公共字段) 67 | func buildAdminLoginLogBaseResources(data *model.AdminLoginLogs) AdminLoginLogBaseResources { 68 | return AdminLoginLogBaseResources{ 69 | ID: data.ID, 70 | UID: data.UID, 71 | Username: data.Username, 72 | IP: data.IP, 73 | OS: data.OS, 74 | Browser: data.Browser, 75 | ExecutionTime: data.ExecutionTime, 76 | LoginStatus: data.LoginStatus, 77 | LoginStatusName: data.LoginStatusMap(), 78 | LoginFailReason: data.LoginFailReason, 79 | Type: data.Type, 80 | TypeName: data.TypeMap(), 81 | IsRevoked: data.IsRevoked, 82 | IsRevokedName: data.IsRevokedMap(), 83 | RevokedCode: data.RevokedCode, 84 | RevokedCodeName: data.RevokedCodeMap(), 85 | RevokedReason: data.RevokedReason, 86 | RevokedAt: data.RevokedAt, 87 | CreatedAt: data.CreatedAt, 88 | } 89 | } 90 | 91 | // ToStruct 转换为单个资源(详情) 92 | func (r AdminLoginLogTransformer) ToStruct(data *model.AdminLoginLogs) *AdminLoginLogResources { 93 | base := buildAdminLoginLogBaseResources(data) 94 | return &AdminLoginLogResources{ 95 | AdminLoginLogBaseResources: base, 96 | JwtID: data.JwtID, 97 | UserAgent: data.UserAgent, 98 | AccessToken: data.AccessToken, // 已在 service 层解密 99 | RefreshToken: data.RefreshToken, // 已在 service 层解密 100 | TokenHash: data.TokenHash, 101 | RefreshTokenHash: data.RefreshTokenHash, 102 | TokenExpires: data.TokenExpires, 103 | RefreshExpires: data.RefreshExpires, 104 | UpdatedAt: data.UpdatedAt, 105 | } 106 | } 107 | 108 | // ToCollection 转换为集合资源(列表,不包含大字段) 109 | func (r AdminLoginLogTransformer) ToCollection(page, perPage int, total int64, data []*model.AdminLoginLogs) *Collection { 110 | response := make([]any, 0, len(data)) 111 | for _, v := range data { 112 | base := buildAdminLoginLogBaseResources(v) 113 | response = append(response, &AdminLoginLogListResources{ 114 | AdminLoginLogBaseResources: base, 115 | }) 116 | } 117 | return NewCollection().SetPaginate(page, perPage, total).ToCollection(response) 118 | } 119 | -------------------------------------------------------------------------------- /internal/routers/router.go: -------------------------------------------------------------------------------- 1 | package routers 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/gin-gonic/gin" 9 | 10 | "github.com/wannanbigpig/gin-layout/config" 11 | "github.com/wannanbigpig/gin-layout/internal/middleware" 12 | "github.com/wannanbigpig/gin-layout/internal/pkg/errors" 13 | response2 "github.com/wannanbigpig/gin-layout/internal/pkg/response" 14 | "github.com/wannanbigpig/gin-layout/pkg/utils" 15 | ) 16 | 17 | func SetRouters(initApiTable bool) (*gin.Engine, ApiMap) { 18 | engine := createEngine() 19 | 20 | register := &RegisterRouter{ 21 | InitApiTable: initApiTable, 22 | Engine: engine, 23 | } 24 | 25 | // ping 26 | register.route(engine, http.MethodGet, "/ping", func(c *gin.Context) { 27 | c.String(http.StatusOK, "pong") 28 | }).setTitle("ping").setAuth(0).setDesc("服务心跳检测接口") 29 | 30 | // 设置 API 路由 31 | SetAdminApiRoute(register) 32 | 33 | // 统一处理 404 34 | engine.NoRoute(func(c *gin.Context) { 35 | response2.Resp().SetHttpCode(http.StatusNotFound).FailCode(c, errors.NotFound) 36 | }) 37 | 38 | if initApiTable { 39 | return engine, apiMap 40 | } 41 | 42 | return engine, nil 43 | } 44 | 45 | // createEngine 创建 gin 引擎并设置相关中间件 46 | func createEngine() *gin.Engine { 47 | var engine *gin.Engine 48 | 49 | if config.Config.Debug { 50 | // 开发调试模式 51 | engine = gin.New() 52 | engine.Use( 53 | middleware.CorsHandler(), 54 | middleware.RequestCostHandler(), // 请求耗时统计 55 | middleware.ParseTokenHandler(), // 全局token解析(所有路由都走) 56 | gin.Logger(), 57 | middleware.CustomRecovery(), 58 | middleware.CustomLogger(), 59 | ) 60 | 61 | } else { 62 | // 生产模式 63 | engine = ReleaseRouter() 64 | engine.Use( 65 | middleware.CorsHandler(), 66 | middleware.RequestCostHandler(), // 请求耗时统计 67 | middleware.ParseTokenHandler(), // 全局token解析(所有路由都走) 68 | middleware.CustomRecovery(), 69 | middleware.CustomLogger(), 70 | ) 71 | } 72 | // set up trusted agents 73 | if err := engine.SetTrustedProxies([]string{"127.0.0.1"}); err != nil { 74 | panic(err) 75 | } 76 | 77 | return engine 78 | } 79 | 80 | // ReleaseRouter 生产模式使用官方建议设置为 release 模式 81 | func ReleaseRouter() *gin.Engine { 82 | // 切换到生产模式 83 | gin.SetMode(gin.ReleaseMode) 84 | // 禁用 gin 输出接口访问日志 85 | gin.DefaultWriter = io.Discard 86 | 87 | engine := gin.New() 88 | 89 | return engine 90 | } 91 | 92 | type ApiMap map[string]*api 93 | 94 | var apiMap = make(ApiMap) 95 | 96 | // RegisterRouter 注册路由 97 | type RegisterRouter struct { 98 | ApiMap ApiMap 99 | Engine *gin.Engine 100 | InitApiTable bool 101 | } 102 | 103 | // route 注册路由信息 104 | func (r *RegisterRouter) route(e gin.IRoutes, method string, path string, handler ...gin.HandlerFunc) *api { 105 | api := newApi() 106 | if r.InitApiTable { 107 | api.Method = method 108 | api.Path = path 109 | api.Auth = 1 // 默认需要鉴权 110 | api.GroupCode = "" 111 | code := utils.MD5(method + "_" + api.Path) 112 | // 初始化 api 信息 113 | apiMap[code] = api 114 | } 115 | 116 | e.Handle(method, path, handler...) 117 | return api 118 | } 119 | 120 | func (r *RegisterRouter) group(relativePath string, handler ...gin.HandlerFunc) *GroupHandler { 121 | return &GroupHandler{RouterGroup: r.Engine.Group(relativePath, handler...), initApiTable: r.InitApiTable} 122 | } 123 | 124 | type GroupHandler struct { 125 | *gin.RouterGroup 126 | initApiTable bool 127 | GroupCode string 128 | } 129 | 130 | // setGroupCode 设置分组code(用于api权限管理,分组code取之于a_api_group表的code字段,需要提前在a_api_group表中添加对应的分组) 131 | func (g *GroupHandler) setGroupCode(code string) *GroupHandler { 132 | g.GroupCode = code 133 | return g 134 | } 135 | 136 | func (g *GroupHandler) group(relativePath string, handler ...gin.HandlerFunc) *GroupHandler { 137 | // 创建新的分组,默认继承父分组的 GroupCode 138 | // 如果子分组需要不同的 GroupCode,可以显式调用 setGroupCode() 来覆盖 139 | groupHandLer := &GroupHandler{ 140 | RouterGroup: g.RouterGroup.Group(relativePath, handler...), 141 | initApiTable: g.initApiTable, 142 | GroupCode: g.GroupCode, // 继承父分组的 GroupCode 143 | } 144 | return groupHandLer 145 | } 146 | 147 | // registerRoute 通用的路由注册函数 148 | func (g *GroupHandler) registerRoute(method string, relativePath string, handler ...gin.HandlerFunc) *api { 149 | api := newApi() 150 | if g.initApiTable { 151 | api.Method = method 152 | api.Path = strings.TrimSuffix(g.BasePath(), "/") + "/" + strings.Trim(relativePath, "/") 153 | api.Auth = 1 // 默认需要鉴 154 | api.GroupCode = g.GroupCode 155 | code := utils.MD5(method + "_" + api.Path) 156 | // 初始化 api 信息 157 | apiMap[code] = api 158 | } 159 | 160 | g.Handle(method, relativePath, handler...) 161 | return api 162 | } 163 | 164 | // post 注册 POST 路由 165 | func (g *GroupHandler) post(relativePath string, handler ...gin.HandlerFunc) *api { 166 | return g.registerRoute(http.MethodPost, relativePath, handler...) 167 | } 168 | 169 | // get 注册 GET 路由 170 | func (g *GroupHandler) get(relativePath string, handler ...gin.HandlerFunc) *api { 171 | return g.registerRoute(http.MethodGet, relativePath, handler...) 172 | } 173 | 174 | // api 结构体表示route 175 | type api struct { 176 | Title string 177 | Desc string 178 | Method string 179 | Path string 180 | Auth uint8 181 | GroupCode string 182 | } 183 | 184 | func newApi() *api { 185 | return &api{} 186 | } 187 | 188 | func (r *api) setTitle(title string) *api { 189 | r.Title = title 190 | return r 191 | } 192 | 193 | func (r *api) setDesc(desc string) *api { 194 | r.Desc = desc 195 | return r 196 | } 197 | 198 | func (r *api) setAuth(auth uint8) *api { 199 | r.Auth = auth 200 | return r 201 | } 202 | --------------------------------------------------------------------------------