feat(admin): 后台登录/菜单/按钮级权限/RBAC管理与服务器日志监控

后端:
- 种子数据:超级管理员(admin/admin123)、super_admin 角色、22 条菜单(含按钮权限码)、角色绑定
- admin_menu 扩展 icon/component/hidden 字段(003_schema_ext.sql),手动补齐 entity/do/table
- auth 扩展:/auth/info、/auth/codes(仅登录),路由拆分公开/Profile/受保护三组
- 菜单路由:/menu/routes 返回 vben backend 动态路由树(按角色过滤、排序)
- RBAC 管理:/admins、/roles、/menus CRUD(含角色绑定、重置密码、菜单授权)
- 日志监控:/log/files、/log/tail(尾部读取+关键词过滤+路径穿越防护)
- 安全加固:接口鉴权改为「方法+路径→权限码」自动映射,杜绝任意权限码越权
- 修复:gf v2.10.2  不自动替换→cmd 注入;MySQL driver 需显式引入 contrib
- 全链路冒烟测试通过(含越权拒绝 30003)
This commit is contained in:
夏犀麟 2026-08-24 22:35:54 +08:00
parent 5cd8f0f551
commit 8fdb25e6bd
35 changed files with 1674 additions and 31 deletions

4
.gitignore vendored
View File

@ -19,4 +19,6 @@ bin
**/config/config.yaml
# WorkBuddy 本地记忆不入库跨账号上下文见 AGENTS.md docs/change-log/
.workbuddy/
.workbuddy/
# server runtime logs
log/

73
api/admin/v1/admin.go Normal file
View File

@ -0,0 +1,73 @@
package v1
import "github.com/gogf/gf/v2/frame/g"
// AdminItem is one row of the admin list.
type AdminItem struct {
Id uint64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Status int `json:"status"`
RoleIds []uint64 `json:"roleIds"`
RoleNames []string `json:"roleNames"`
CreatedAt string `json:"createdAt"`
}
// AdminListReq pages the administrators.
type AdminListReq struct {
g.Meta `path:"/admins" method:"get" tags:"Admin/Admin" summary:"Admin list"`
Page int `json:"page" d:"1" v:"min:1"`
Size int `json:"size" d:"10" v:"min:1|max:100"`
Keyword string `json:"keyword"` // matches username or nickname
}
// AdminListRes is the response of AdminListReq.
type AdminListRes struct {
List []*AdminItem `json:"list"`
Total int `json:"total"`
}
// AdminCreateReq creates an administrator.
type AdminCreateReq struct {
g.Meta `path:"/admins" method:"post" tags:"Admin/Admin" summary:"Create admin"`
Username string `json:"username" v:"required"`
Password string `json:"password" v:"required|min-length:6#password required|password too short"`
Nickname string `json:"nickname"`
RoleIds []uint64 `json:"roleIds"`
}
// AdminCreateRes is the response of AdminCreateReq.
type AdminCreateRes struct {
Id uint64 `json:"id"`
}
// AdminUpdateReq updates profile/status/roles of an administrator.
type AdminUpdateReq struct {
g.Meta `path:"/admins/{id}" method:"put" tags:"Admin/Admin" summary:"Update admin"`
Id uint64 `json:"id" in:"path" v:"required"`
Nickname string `json:"nickname"`
Status int `json:"status"`
RoleIds []uint64 `json:"roleIds"`
}
// AdminUpdateRes is the response of AdminUpdateReq.
type AdminUpdateRes struct{}
// AdminResetPwdReq resets an administrator's password.
type AdminResetPwdReq struct {
g.Meta `path:"/admins/{id}/password" method:"put" tags:"Admin/Admin" summary:"Reset admin password"`
Id uint64 `json:"id" in:"path" v:"required"`
Password string `json:"password" v:"required|min-length:6#password required|password too short"`
}
// AdminResetPwdRes is the response of AdminResetPwdReq.
type AdminResetPwdRes struct{}
// AdminDeleteReq deletes an administrator (soft delete).
type AdminDeleteReq struct {
g.Meta `path:"/admins/{id}" method:"delete" tags:"Admin/Admin" summary:"Delete admin"`
Id uint64 `json:"id" in:"path" v:"required"`
}
// AdminDeleteRes is the response of AdminDeleteReq.
type AdminDeleteRes struct{}

View File

@ -13,3 +13,27 @@ type LoginRes struct {
ExpiresIn int64 `json:"expiresIn"`
AdminID uint64 `json:"adminId"`
}
// InfoReq returns the current administrator profile (vben getUserInfo).
type InfoReq struct {
g.Meta `path:"/auth/info" method:"get" tags:"Admin/Auth" summary:"Current admin info"`
}
// InfoRes is the response of InfoReq. Roles carries role codes for vben authority.
type InfoRes struct {
AdminID uint64 `json:"adminId"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Roles []string `json:"roles"`
}
// CodesReq returns the button-level permission codes of the current admin
// (vben getAccessCodes, backend access mode).
type CodesReq struct {
g.Meta `path:"/auth/codes" method:"get" tags:"Admin/Auth" summary:"Current admin access codes"`
}
// CodesRes is the response of CodesReq.
type CodesRes struct {
Codes []string `json:"codes"`
}

35
api/admin/v1/log.go Normal file
View File

@ -0,0 +1,35 @@
package v1
import "github.com/gogf/gf/v2/frame/g"
// LogFile describes one server log file.
type LogFile struct {
Name string `json:"name"` // file name
Path string `json:"path"` // path relative to the log dir
Size int64 `json:"size"` // bytes
ModTime string `json:"modTime"` // 2006-01-02 15:04:05
}
// LogFilesReq lists the server log files.
type LogFilesReq struct {
g.Meta `path:"/log/files" method:"get" tags:"Admin/Log" summary:"List server log files"`
}
// LogFilesRes is the response of LogFilesReq.
type LogFilesRes struct {
Dir string `json:"dir"`
Files []*LogFile `json:"files"`
}
// LogTailReq reads the tail of a log file with optional keyword filter.
type LogTailReq struct {
g.Meta `path:"/log/tail" method:"get" tags:"Admin/Log" summary:"Tail a server log file"`
File string `json:"file" v:"required#file required"` // relative to the log dir; path traversal is rejected
Lines int `json:"lines" d:"200" v:"min:1|max:2000"`
Keyword string `json:"keyword"` // substring filter
}
// LogTailRes is the response of LogTailReq.
type LogTailRes struct {
Lines []string `json:"lines"`
}

33
api/admin/v1/menu.go Normal file
View File

@ -0,0 +1,33 @@
package v1
import "github.com/gogf/gf/v2/frame/g"
// RouteMeta mirrors vben admin dynamic route metadata.
type RouteMeta struct {
Title string `json:"title"`
Icon string `json:"icon"`
Order int `json:"order"`
Authority []string `json:"authority,omitempty"` // role codes
HideInMenu bool `json:"hideInMenu,omitempty"`
KeepAlive bool `json:"keepAlive,omitempty"`
}
// RouteItem is one node of the vben route tree.
type RouteItem struct {
Name string `json:"name"`
Path string `json:"path"`
Component string `json:"component,omitempty"`
Meta RouteMeta `json:"meta"`
Children []*RouteItem `json:"children,omitempty"`
}
// MenuRoutesReq returns the current admin's menu tree as vben routes
// (backend access mode, login-only endpoint).
type MenuRoutesReq struct {
g.Meta `path:"/menu/routes" method:"get" tags:"Admin/Menu" summary:"Current admin menu routes"`
}
// MenuRoutesRes is the response of MenuRoutesReq.
type MenuRoutesRes struct {
Routes []*RouteItem `json:"routes"`
}

View File

@ -0,0 +1,77 @@
package v1
import "github.com/gogf/gf/v2/frame/g"
// MenuItem is one node of the full menu tree (type 1 menu / 2 button).
type MenuItem struct {
Id uint64 `json:"id"`
ParentId uint64 `json:"parentId"`
Name string `json:"name"`
Icon string `json:"icon"`
Type int `json:"type"` // 1 menu, 2 button/api
Path string `json:"path"`
Component string `json:"component"`
Permission string `json:"permission"`
Sort int `json:"sort"`
Status int `json:"status"`
Hidden bool `json:"hidden"`
Children []*MenuItem `json:"children,omitempty"`
}
// MenuTreeReq returns the full menu tree for management.
type MenuTreeReq struct {
g.Meta `path:"/menus/tree" method:"get" tags:"Admin/Menu" summary:"Full menu tree"`
}
// MenuTreeRes is the response of MenuTreeReq.
type MenuTreeRes struct {
Tree []*MenuItem `json:"tree"`
}
// MenuCreateReq creates a menu or button node.
type MenuCreateReq struct {
g.Meta `path:"/menus" method:"post" tags:"Admin/Menu" summary:"Create menu"`
ParentId uint64 `json:"parentId"`
Name string `json:"name" v:"required"`
Icon string `json:"icon"`
Type int `json:"type" v:"in:1,2#type must be 1 or 2"`
Path string `json:"path"`
Component string `json:"component"`
Permission string `json:"permission" v:"required"`
Sort int `json:"sort"`
Status int `json:"status"`
Hidden bool `json:"hidden"`
}
// MenuCreateRes is the response of MenuCreateReq.
type MenuCreateRes struct {
Id uint64 `json:"id"`
}
// MenuUpdateReq updates a menu or button node.
type MenuUpdateReq struct {
g.Meta `path:"/menus/{id}" method:"put" tags:"Admin/Menu" summary:"Update menu"`
Id uint64 `json:"id" in:"path" v:"required"`
ParentId uint64 `json:"parentId"`
Name string `json:"name" v:"required"`
Icon string `json:"icon"`
Type int `json:"type" v:"in:1,2#type must be 1 or 2"`
Path string `json:"path"`
Component string `json:"component"`
Permission string `json:"permission" v:"required"`
Sort int `json:"sort"`
Status int `json:"status"`
Hidden bool `json:"hidden"`
}
// MenuUpdateRes is the response of MenuUpdateReq.
type MenuUpdateRes struct{}
// MenuDeleteReq deletes a menu node (soft delete).
type MenuDeleteReq struct {
g.Meta `path:"/menus/{id}" method:"delete" tags:"Admin/Menu" summary:"Delete menu"`
Id uint64 `json:"id" in:"path" v:"required"`
}
// MenuDeleteRes is the response of MenuDeleteReq.
type MenuDeleteRes struct{}

62
api/admin/v1/role.go Normal file
View File

@ -0,0 +1,62 @@
package v1
import "github.com/gogf/gf/v2/frame/g"
// RoleItem is one row of the role list.
type RoleItem struct {
Id uint64 `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Status int `json:"status"`
MenuIds []uint64 `json:"menuIds"` // bound menu ids (for editing)
CreatedAt string `json:"createdAt"`
}
// RoleListReq pages the roles.
type RoleListReq struct {
g.Meta `path:"/roles" method:"get" tags:"Admin/Role" summary:"Role list"`
Page int `json:"page" d:"1" v:"min:1"`
Size int `json:"size" d:"10" v:"min:1|max:100"`
Keyword string `json:"keyword"` // matches code or name
}
// RoleListRes is the response of RoleListReq.
type RoleListRes struct {
List []*RoleItem `json:"list"`
Total int `json:"total"`
}
// RoleCreateReq creates a role and binds menu ids.
type RoleCreateReq struct {
g.Meta `path:"/roles" method:"post" tags:"Admin/Role" summary:"Create role"`
Code string `json:"code" v:"required"`
Name string `json:"name" v:"required"`
Status int `json:"status"`
MenuIds []uint64 `json:"menuIds"`
}
// RoleCreateRes is the response of RoleCreateReq.
type RoleCreateRes struct {
Id uint64 `json:"id"`
}
// RoleUpdateReq updates a role and rebinds menu ids.
type RoleUpdateReq struct {
g.Meta `path:"/roles/{id}" method:"put" tags:"Admin/Role" summary:"Update role"`
Id uint64 `json:"id" in:"path" v:"required"`
Name string `json:"name" v:"required"`
Status int `json:"status"`
MenuIds []uint64 `json:"menuIds"`
}
// RoleUpdateRes is the response of RoleUpdateReq.
type RoleUpdateRes struct{}
// RoleDeleteReq deletes a role (soft delete).
type RoleDeleteReq struct {
g.Meta `path:"/roles/{id}" method:"delete" tags:"Admin/Role" summary:"Delete role"`
Id uint64 `json:"id" in:"path" v:"required"`
}
// RoleDeleteRes is the response of RoleDeleteReq.
type RoleDeleteRes struct{}

5
go.mod
View File

@ -4,6 +4,11 @@ go 1.23.0
require github.com/gogf/gf/v2 v2.10.2
require (
github.com/go-sql-driver/mysql v1.7.1 // indirect
github.com/gogf/gf/contrib/drivers/mysql/v2 v2.10.2 // indirect
)
require (
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect

4
go.sum
View File

@ -15,6 +15,10 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/gogf/gf/contrib/drivers/mysql/v2 v2.10.2 h1:UdUV+7GhwYLpkwz7VrwIVO/1ZYodyzSL5is25NET24A=
github.com/gogf/gf/contrib/drivers/mysql/v2 v2.10.2/go.mod h1:eKc+0i3Il7efS2BBjmpy7T9wvN9NGRd67ZV94r9behA=
github.com/gogf/gf/v2 v2.10.2 h1:46IO0Uc8e85/FqdftJFskfDejJLBL0JBnGS5qOftUu8=
github.com/gogf/gf/v2 v2.10.2/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=

View File

@ -6,6 +6,8 @@ import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gcmd"
"github.com/gogf/gf/v2/os/gcfg"
"github.com/gogf/gf/v2/os/genv"
adminctl "service.xpcool.com/internal/controller/admin"
"service.xpcool.com/internal/controller/hello"
@ -16,16 +18,38 @@ import (
"service.xpcool.com/internal/service"
)
// injectEnv 手动把关键环境变量写入配置系统。
// 注意gf v2.10.2 起配置不再自动替换 ${ENV} 占位符,需在此显式注入,
// 否则 config.dev.yaml 中的 ${DB_DSN}/${JWT_SECRET} 会原样传给数据库与 JWT。
func injectEnv(ctx context.Context) {
adapter, ok := g.Cfg().GetAdapter().(*gcfg.AdapterFile)
if !ok {
return
}
if v := genv.Get("DB_DSN"); !v.IsEmpty() {
_ = adapter.Set("database.default.link", v.String())
}
if v := genv.Get("JWT_SECRET"); !v.IsEmpty() {
_ = adapter.Set("jwt.secret", v.String())
}
}
var (
Main = gcmd.Command{
Name: "main",
Usage: "main",
Brief: "start http server",
Func: func(ctx context.Context, parser *gcmd.Parser) (err error) {
injectEnv(ctx)
s := g.Server()
tokens := jwt.New(ctx)
service.RegisterUserAuth(service.NewUserAuth(tokens, nil, nil))
service.RegisterAdminAuth(service.NewAdminAuth(tokens))
service.RegisterAdminMenu(service.NewAdminMenu())
service.RegisterAdminManage(service.NewAdminManage())
service.RegisterRoleManage(service.NewRoleManage())
service.RegisterMenuManage(service.NewMenuManage())
service.RegisterLogManage(service.NewLogManage())
service.RegisterAdminAudit(service.NewAdminAudit())
s.Group("/", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.Recover, middleware.CORS)
@ -45,11 +69,19 @@ var (
})
s.Group("/admin/v1", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse)
group.Bind(adminctl.New()) // Admin login remains public; protected controllers mount separately.
group.Bind(adminctl.NewAuth()) // Public: login only.
group.Group("/", func(profile *ghttp.RouterGroup) {
// Login-only endpoints: own profile / access codes / menu routes.
profile.Middleware(middleware.AdminAuthOnly(tokens))
profile.Bind(adminctl.NewProfile())
})
group.Group("/", func(protected *ghttp.RouterGroup) {
protected.Middleware(middleware.AdminAuth(tokens, service.AdminAuth().HasPermission, func(ctx context.Context, id uint64, permission, method, path, ip, param string, duration, status int) {
// Permission-protected endpoints: RBAC management, logs, ...
// 权限由后端按「方法+路径」自动匹配,无需前端传 X-Permission。
protected.Middleware(middleware.AdminAuth(tokens, service.AdminAuth().PermissionForPath, service.AdminAuth().HasPermission, func(ctx context.Context, id uint64, permission, method, path, ip, param string, duration, status int) {
service.AdminAudit().Record(ctx, service.AuditEvent{AdminID: id, Permission: permission, Method: method, Path: path, IP: ip, Param: param, DurationMS: duration, StatusCode: status})
}))
protected.Bind(adminctl.New())
})
})
s.Run()

View File

@ -0,0 +1,60 @@
package admin
import (
"context"
adminv1 "service.xpcool.com/api/admin/v1"
"service.xpcool.com/internal/model/dto"
"service.xpcool.com/internal/service"
)
// AdminList pages the administrators.
func (c *Controller) AdminList(ctx context.Context, req *adminv1.AdminListReq) (res *adminv1.AdminListRes, err error) {
items, total, err := service.AdminManage().List(ctx, dto.PageQuery{Page: req.Page, Size: req.Size, Keyword: req.Keyword})
if err != nil {
return nil, err
}
list := make([]*adminv1.AdminItem, 0, len(items))
for _, it := range items {
list = append(list, &adminv1.AdminItem{
Id: it.Id, Username: it.Username, Nickname: it.Nickname, Status: it.Status,
RoleIds: it.RoleIds, RoleNames: it.RoleNames, CreatedAt: it.CreatedAt,
})
}
return &adminv1.AdminListRes{List: list, Total: total}, nil
}
// AdminCreate creates an administrator.
func (c *Controller) AdminCreate(ctx context.Context, req *adminv1.AdminCreateReq) (res *adminv1.AdminCreateRes, err error) {
id, err := service.AdminManage().Create(ctx, dto.AdminCreateInput{
Username: req.Username, Password: req.Password, Nickname: req.Nickname, RoleIds: req.RoleIds,
})
if err != nil {
return nil, err
}
return &adminv1.AdminCreateRes{Id: id}, nil
}
// AdminUpdate updates an administrator.
func (c *Controller) AdminUpdate(ctx context.Context, req *adminv1.AdminUpdateReq) (res *adminv1.AdminUpdateRes, err error) {
if err = service.AdminManage().Update(ctx, dto.AdminUpdateInput{Id: req.Id, Nickname: req.Nickname, Status: req.Status, RoleIds: req.RoleIds}); err != nil {
return nil, err
}
return &adminv1.AdminUpdateRes{}, nil
}
// AdminResetPwd resets an administrator's password.
func (c *Controller) AdminResetPwd(ctx context.Context, req *adminv1.AdminResetPwdReq) (res *adminv1.AdminResetPwdRes, err error) {
if err = service.AdminManage().ResetPassword(ctx, req.Id, req.Password); err != nil {
return nil, err
}
return &adminv1.AdminResetPwdRes{}, nil
}
// AdminDelete deletes an administrator.
func (c *Controller) AdminDelete(ctx context.Context, req *adminv1.AdminDeleteReq) (res *adminv1.AdminDeleteRes, err error) {
if err = service.AdminManage().Delete(ctx, req.Id); err != nil {
return nil, err
}
return &adminv1.AdminDeleteRes{}, nil
}

View File

@ -2,15 +2,20 @@ package admin
import (
"context"
adminv1 "service.xpcool.com/api/admin/v1"
"service.xpcool.com/internal/model/dto"
"service.xpcool.com/internal/service"
)
type Controller struct{}
// AuthController exposes only the public login endpoint.
type AuthController struct{}
func New() *Controller { return &Controller{} }
func (c *Controller) Login(ctx context.Context, req *adminv1.LoginReq) (res *adminv1.LoginRes, err error) {
// NewAuth creates the public admin auth controller (login only).
func NewAuth() *AuthController { return &AuthController{} }
// Login authenticates an administrator and issues a token pair.
func (c *AuthController) Login(ctx context.Context, req *adminv1.LoginReq) (res *adminv1.LoginRes, err error) {
p, id, err := service.AdminAuth().Login(ctx, dto.AdminLoginInput{Username: req.Username, Password: req.Password})
if err != nil {
return nil, err

View File

@ -0,0 +1,21 @@
package admin
import (
"context"
"github.com/gogf/gf/v2/frame/g"
"service.xpcool.com/internal/middleware"
)
// Controller implements the permission-protected /admin/v1 endpoints
// (RBAC management, logs, etc.). Bind behind AdminAuth (X-Permission).
type Controller struct{}
// New creates the protected admin controller.
func New() *Controller { return &Controller{} }
// adminID returns the authenticated admin id stored by the auth middleware.
func adminID(ctx context.Context) uint64 {
return g.RequestFromCtx(ctx).GetCtxVar(middleware.AdminIDKey).Uint64()
}

View File

@ -0,0 +1,30 @@
package admin
import (
"context"
adminv1 "service.xpcool.com/api/admin/v1"
"service.xpcool.com/internal/service"
)
// LogFiles lists the server log files.
func (c *Controller) LogFiles(ctx context.Context, req *adminv1.LogFilesReq) (res *adminv1.LogFilesRes, err error) {
dir, files, err := service.LogManage().Files(ctx)
if err != nil {
return nil, err
}
list := make([]*adminv1.LogFile, 0, len(files))
for _, f := range files {
list = append(list, &adminv1.LogFile{Name: f.Name, Path: f.Path, Size: f.Size, ModTime: f.ModTime})
}
return &adminv1.LogFilesRes{Dir: dir, Files: list}, nil
}
// LogTail reads the tail of a log file with optional keyword filter.
func (c *Controller) LogTail(ctx context.Context, req *adminv1.LogTailReq) (res *adminv1.LogTailRes, err error) {
lines, err := service.LogManage().Tail(ctx, req.File, req.Lines, req.Keyword)
if err != nil {
return nil, err
}
return &adminv1.LogTailRes{Lines: lines}, nil
}

View File

@ -0,0 +1,64 @@
package admin
import (
"context"
adminv1 "service.xpcool.com/api/admin/v1"
"service.xpcool.com/internal/model/dto"
"service.xpcool.com/internal/service"
)
// MenuTree returns the full menu tree (menus + button permissions).
func (c *Controller) MenuTree(ctx context.Context, req *adminv1.MenuTreeReq) (res *adminv1.MenuTreeRes, err error) {
tree, err := service.MenuManage().Tree(ctx)
if err != nil {
return nil, err
}
out := make([]*adminv1.MenuItem, 0, len(tree))
for _, n := range tree {
out = append(out, menuNodeToV1(n))
}
return &adminv1.MenuTreeRes{Tree: out}, nil
}
// MenuCreate creates a menu or button node.
func (c *Controller) MenuCreate(ctx context.Context, req *adminv1.MenuCreateReq) (res *adminv1.MenuCreateRes, err error) {
id, err := service.MenuManage().Create(ctx, dto.MenuCreateInput{
ParentId: req.ParentId, Name: req.Name, Icon: req.Icon, Type: req.Type, Path: req.Path,
Component: req.Component, Permission: req.Permission, Sort: req.Sort, Status: req.Status, Hidden: req.Hidden,
})
if err != nil {
return nil, err
}
return &adminv1.MenuCreateRes{Id: id}, nil
}
// MenuUpdate updates a menu or button node.
func (c *Controller) MenuUpdate(ctx context.Context, req *adminv1.MenuUpdateReq) (res *adminv1.MenuUpdateRes, err error) {
if err = service.MenuManage().Update(ctx, dto.MenuUpdateInput{
Id: req.Id, ParentId: req.ParentId, Name: req.Name, Icon: req.Icon, Type: req.Type, Path: req.Path,
Component: req.Component, Permission: req.Permission, Sort: req.Sort, Status: req.Status, Hidden: req.Hidden,
}); err != nil {
return nil, err
}
return &adminv1.MenuUpdateRes{}, nil
}
// MenuDelete deletes a menu node.
func (c *Controller) MenuDelete(ctx context.Context, req *adminv1.MenuDeleteReq) (res *adminv1.MenuDeleteRes, err error) {
if err = service.MenuManage().Delete(ctx, req.Id); err != nil {
return nil, err
}
return &adminv1.MenuDeleteRes{}, nil
}
func menuNodeToV1(n *dto.MenuNode) *adminv1.MenuItem {
item := &adminv1.MenuItem{
Id: n.Id, ParentId: n.ParentId, Name: n.Name, Icon: n.Icon, Type: n.Type, Path: n.Path,
Component: n.Component, Permission: n.Permission, Sort: n.Sort, Status: n.Status, Hidden: n.Hidden,
}
for _, c := range n.Children {
item.Children = append(item.Children, menuNodeToV1(c))
}
return item
}

View File

@ -0,0 +1,67 @@
package admin
import (
"context"
adminv1 "service.xpcool.com/api/admin/v1"
"service.xpcool.com/internal/model/dto"
"service.xpcool.com/internal/service"
)
// ProfileController exposes the logged-in admin's own profile endpoints
// (login-only, no X-Permission required). Bind behind AdminAuthOnly.
type ProfileController struct{}
// NewProfile creates the profile controller.
func NewProfile() *ProfileController { return &ProfileController{} }
// Info returns the current administrator profile.
func (c *ProfileController) Info(ctx context.Context, req *adminv1.InfoReq) (res *adminv1.InfoRes, err error) {
info, err := service.AdminAuth().Info(ctx, adminID(ctx))
if err != nil {
return nil, err
}
return &adminv1.InfoRes{AdminID: info.AdminID, Username: info.Username, Nickname: info.Nickname, Roles: info.Roles}, nil
}
// Codes returns the button-level permission codes of the current administrator.
func (c *ProfileController) Codes(ctx context.Context, req *adminv1.CodesReq) (res *adminv1.CodesRes, err error) {
codes, err := service.AdminAuth().Codes(ctx, adminID(ctx))
if err != nil {
return nil, err
}
return &adminv1.CodesRes{Codes: codes}, nil
}
// Routes returns the current administrator's visible menu tree as vben routes.
func (c *ProfileController) Routes(ctx context.Context, req *adminv1.MenuRoutesReq) (res *adminv1.MenuRoutesRes, err error) {
routes, err := service.AdminMenu().Routes(ctx, adminID(ctx))
if err != nil {
return nil, err
}
out := make([]*adminv1.RouteItem, 0, len(routes))
for _, r := range routes {
out = append(out, toV1Route(r))
}
return &adminv1.MenuRoutesRes{Routes: out}, nil
}
// toV1Route converts a dto route tree into the v1 API shape.
func toV1Route(r *dto.RouteItem) *adminv1.RouteItem {
item := &adminv1.RouteItem{
Name: r.Name,
Path: r.Path,
Component: r.Component,
Meta: adminv1.RouteMeta{
Title: r.Meta.Title,
Icon: r.Meta.Icon,
Order: r.Meta.Order,
Authority: r.Meta.Authority,
HideInMenu: r.Meta.HideInMenu,
},
}
for _, c := range r.Children {
item.Children = append(item.Children, toV1Route(c))
}
return item
}

View File

@ -0,0 +1,49 @@
package admin
import (
"context"
adminv1 "service.xpcool.com/api/admin/v1"
"service.xpcool.com/internal/model/dto"
"service.xpcool.com/internal/service"
)
// RoleList pages the roles.
func (c *Controller) RoleList(ctx context.Context, req *adminv1.RoleListReq) (res *adminv1.RoleListRes, err error) {
items, total, err := service.RoleManage().List(ctx, dto.PageQuery{Page: req.Page, Size: req.Size, Keyword: req.Keyword})
if err != nil {
return nil, err
}
list := make([]*adminv1.RoleItem, 0, len(items))
for _, it := range items {
list = append(list, &adminv1.RoleItem{
Id: it.Id, Code: it.Code, Name: it.Name, Status: it.Status, MenuIds: it.MenuIds, CreatedAt: it.CreatedAt,
})
}
return &adminv1.RoleListRes{List: list, Total: total}, nil
}
// RoleCreate creates a role.
func (c *Controller) RoleCreate(ctx context.Context, req *adminv1.RoleCreateReq) (res *adminv1.RoleCreateRes, err error) {
id, err := service.RoleManage().Create(ctx, dto.RoleCreateInput{Code: req.Code, Name: req.Name, Status: req.Status, MenuIds: req.MenuIds})
if err != nil {
return nil, err
}
return &adminv1.RoleCreateRes{Id: id}, nil
}
// RoleUpdate updates a role.
func (c *Controller) RoleUpdate(ctx context.Context, req *adminv1.RoleUpdateReq) (res *adminv1.RoleUpdateRes, err error) {
if err = service.RoleManage().Update(ctx, dto.RoleUpdateInput{Id: req.Id, Name: req.Name, Status: req.Status, MenuIds: req.MenuIds}); err != nil {
return nil, err
}
return &adminv1.RoleUpdateRes{}, nil
}
// RoleDelete deletes a role.
func (c *Controller) RoleDelete(ctx context.Context, req *adminv1.RoleDeleteReq) (res *adminv1.RoleDeleteRes, err error) {
if err = service.RoleManage().Delete(ctx, req.Id); err != nil {
return nil, err
}
return &adminv1.RoleDeleteRes{}, nil
}

View File

@ -42,8 +42,23 @@ func UserAuth(s *jwt.Service) ghttp.HandlerFunc {
r.Middleware.Next()
}
}
func AdminAuth(s *jwt.Service, permissionCheck func(context.Context, uint64, string) (bool, error), audit func(context.Context, uint64, string, string, string, string, string, int, int)) ghttp.HandlerFunc {
// 管理端在令牌通过后继续校验 X-Permission 对应的 RBAC 权限,并在请求结束后记审计日志。
func AdminAuthOnly(s *jwt.Service) ghttp.HandlerFunc {
// 仅校验 admin access token不要求 X-Permission用于登录后即可访问的
// 个人资料/权限码/菜单路由等接口,如 /auth/info、/auth/codes、/menu/routes。
return func(r *ghttp.Request) {
c, err := s.Parse(bearer(r), "access", "admin")
if err != nil {
response.JSON(r, consts.CodeUnauthorized, "admin login required", nil)
return
}
r.SetCtxVar(AdminIDKey, c.Subject)
r.Middleware.Next()
}
}
func AdminAuth(s *jwt.Service, permissionLookup func(context.Context, string, string) (string, error), permissionCheck func(context.Context, uint64, string) (bool, error), audit func(context.Context, uint64, string, string, string, string, string, int, int)) ghttp.HandlerFunc {
// 管理端接口鉴权:先解析 admin token再按「请求方法+路径」反查所需权限码
// admin_menu type=2 行的 path 映射),最后校验该管理员是否拥有该权限码。
// 未配置映射的接口一律拒绝,防止用任意已拥有权限码越权访问。
return func(r *ghttp.Request) {
start := time.Now()
c, err := s.Parse(bearer(r), "access", "admin")
@ -51,9 +66,9 @@ func AdminAuth(s *jwt.Service, permissionCheck func(context.Context, uint64, str
response.JSON(r, consts.CodeUnauthorized, "admin login required", nil)
return
}
permission := r.Header.Get("X-Permission")
if permission == "" {
response.JSON(r, consts.CodeForbidden, "permission identifier required", nil)
permission, err := permissionLookup(r.Context(), r.Method, r.URL.Path)
if err != nil || permission == "" {
response.JSON(r, consts.CodeForbidden, "permission mapping not configured", nil)
return
}
ok, err := permissionCheck(r.Context(), c.Subject, permission)

View File

@ -15,11 +15,14 @@ type AdminMenu struct {
Id any //
ParentId any //
Name any //
Icon any // menu icon (iconify name)
Type any // 1 menu,2 api
Path any //
Component any // vue component path, empty for top-level dir
Permission any //
Sort any //
Status any //
Hidden any // 0 show,1 hide in menu
CreatedAt *gtime.Time //
UpdatedAt *gtime.Time //
DeletedAt *gtime.Time //

View File

@ -0,0 +1,20 @@
// Package dto defines service boundary types for the admin RBAC domain.
package dto
// RouteItem mirrors the vben admin dynamic-route shape (backend access mode).
type RouteItem struct {
Name string
Path string
Component string
Meta RouteMeta
Children []*RouteItem
}
// RouteMeta is the route metadata consumed by vben.
type RouteMeta struct {
Title string
Icon string
Order int
Authority []string // role codes
HideInMenu bool
}

View File

@ -0,0 +1,98 @@
// Package dto — RBAC management inputs/outputs.
package dto
type PageQuery struct {
Page int
Size int
Keyword string
}
type AdminItem struct {
Id uint64
Username string
Nickname string
Status int
RoleIds []uint64
RoleNames []string
CreatedAt string
}
type AdminCreateInput struct {
Username string
Password string
Nickname string
RoleIds []uint64
}
type AdminUpdateInput struct {
Id uint64
Nickname string
Status int
RoleIds []uint64
}
type RoleItem struct {
Id uint64
Code string
Name string
Status int
MenuIds []uint64
CreatedAt string
}
type RoleCreateInput struct {
Code string
Name string
Status int
MenuIds []uint64
}
type RoleUpdateInput struct {
Id uint64
Name string
Status int
MenuIds []uint64
}
// MenuNode is the full menu tree node used by menu management.
type MenuNode struct {
Id uint64
ParentId uint64
Name string
Icon string
Type int
Path string
Component string
Permission string
Sort int
Status int
Hidden bool
Children []*MenuNode
}
type MenuCreateInput struct {
ParentId uint64
Name string
Icon string
Type int
Path string
Component string
Permission string
Sort int
Status int
Hidden bool
}
type MenuUpdateInput struct {
Id uint64
ParentId uint64
Name string
Icon string
Type int
Path string
Component string
Permission string
Sort int
Status int
Hidden bool
}

View File

@ -7,3 +7,9 @@ type TokenPair struct {
RefreshToken string `json:"refreshToken"`
ExpiresIn int64 `json:"expiresIn"`
}
type AdminInfo struct {
AdminID uint64
Username string
Nickname string
Roles []string // 角色码,供 vben authority 使用
}

10
internal/model/dto/log.go Normal file
View File

@ -0,0 +1,10 @@
// Package dto — log monitoring types.
package dto
// LogFile describes one server log file.
type LogFile struct {
Name string
Path string
Size int64
ModTime string
}

View File

@ -10,15 +10,18 @@ import (
// AdminMenu is the golang structure for table admin_menu.
type AdminMenu struct {
Id uint64 `json:"id" orm:"id" description:""` //
ParentId uint64 `json:"parentId" orm:"parent_id" description:""` //
Name string `json:"name" orm:"name" description:""` //
Type int `json:"type" orm:"type" description:"1 menu,2 api"` // 1 menu,2 api
Path string `json:"path" orm:"path" description:""` //
Permission string `json:"permission" orm:"permission" description:""` //
Sort int `json:"sort" orm:"sort" description:""` //
Status int `json:"status" orm:"status" description:""` //
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""` //
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""` //
DeletedAt *gtime.Time `json:"deletedAt" orm:"deleted_at" description:""` //
Id uint64 `json:"id" orm:"id" description:""` //
ParentId uint64 `json:"parentId" orm:"parent_id" description:""` //
Name string `json:"name" orm:"name" description:""` //
Icon string `json:"icon" orm:"icon" description:"menu icon (iconify name)"` // menu icon (iconify name)
Type int `json:"type" orm:"type" description:"1 menu,2 api"` // 1 menu,2 api
Path string `json:"path" orm:"path" description:""` //
Component string `json:"component" orm:"component" description:"vue component path, empty for top-level dir"` // vue component path, empty for top-level dir
Permission string `json:"permission" orm:"permission" description:""` //
Sort int `json:"sort" orm:"sort" description:""` //
Status int `json:"status" orm:"status" description:""` //
Hidden bool `json:"hidden" orm:"hidden" description:"0 show,1 hide in menu"` // 0 show,1 hide in menu
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""` //
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""` //
DeletedAt *gtime.Time `json:"deletedAt" orm:"deleted_at" description:""` //
}

View File

@ -2,6 +2,9 @@ package service
import (
"context"
"regexp"
"strings"
"github.com/gogf/gf/v2/errors/gerror"
"golang.org/x/crypto/bcrypt"
"service.xpcool.com/internal/consts"
@ -42,3 +45,85 @@ func (s *adminAuth) HasPermission(ctx context.Context, adminID uint64, permissio
}
return count > 0, nil
}
func (s *adminAuth) Info(ctx context.Context, adminID uint64) (*dto.AdminInfo, error) {
var a entity.AdminUser
if err := dao.AdminUser.Ctx(ctx).Where(do.AdminUser{Id: adminID}).Scan(&a); err != nil {
return nil, gerror.Wrap(err, "query admin info")
}
if a.Id == 0 {
return nil, response.Error(consts.CodeAdminNotFound, "administrator not found")
}
roles, err := s.roleCodes(ctx, adminID)
if err != nil {
return nil, err
}
return &dto.AdminInfo{AdminID: a.Id, Username: a.Username, Nickname: a.Nickname, Roles: roles}, nil
}
func (s *adminAuth) Codes(ctx context.Context, adminID uint64) ([]string, error) {
// 权限码 = 该管理员所有启用角色绑定的菜单 permission含菜单与按钮级
// 同时用作 vben 前端按钮权限码与后端 X-Permission 校验标识。
list, err := dao.AdminUserRole.Ctx(ctx).As("ur").LeftJoin("admin_role_menu rm", "ur.role_id=rm.role_id").LeftJoin("admin_menu m", "rm.menu_id=m.id").Where("ur.admin_user_id", adminID).Where("m.status", 1).WhereGT("m.permission", "").Fields("DISTINCT m.permission").Array()
if err != nil {
return nil, gerror.Wrap(err, "query access codes")
}
codes := make([]string, 0, len(list))
for _, v := range list {
codes = append(codes, v.String())
}
return codes, nil
}
func (s *adminAuth) roleCodes(ctx context.Context, adminID uint64) ([]string, error) {
list, err := dao.AdminUserRole.Ctx(ctx).As("ur").LeftJoin("admin_role r", "ur.role_id=r.id").Where("ur.admin_user_id", adminID).Where("r.status", 1).Fields("DISTINCT r.code").Array()
if err != nil {
return nil, gerror.Wrap(err, "query admin roles")
}
codes := make([]string, 0, len(list))
for _, v := range list {
codes = append(codes, v.String())
}
return codes, nil
}
func (s *adminAuth) PermissionForPath(ctx context.Context, method, path string) (string, error) {
// 从菜单表 type=2按钮/API行反查当前请求所需的权限码。
// 未配置映射的接口一律拒绝访问(返回空则中间件拦截)。
var list []entity.AdminMenu
if err := dao.AdminMenu.Ctx(ctx).Where(do.AdminMenu{Type: 2, Status: 1}).Scan(&list); err != nil {
return "", gerror.Wrap(err, "query permission mappings")
}
req := method + " " + path
for _, m := range list {
if matchRoute(m.Path, req) {
return m.Permission, nil
}
}
return "", nil
}
// matchRoute 匹配 "METHOD /path" 模式,{id} 视为动态段。
func matchRoute(pattern, req string) bool {
if pattern == "" {
return false
}
var b strings.Builder
b.WriteByte('^')
for i := 0; i < len(pattern); i++ {
c := pattern[i]
switch {
case c == '{':
if j := strings.IndexByte(pattern[i:], '}'); j > 0 {
b.WriteString("[^/]+")
i += j
} else {
b.WriteString(regexp.QuoteMeta(string(c)))
}
case c == ' ' || c == '/' || c == '-' || c == '_' || c == '.' ||
(c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'):
b.WriteByte(c)
default:
b.WriteString(regexp.QuoteMeta(string(c)))
}
}
b.WriteByte('$')
ok, err := regexp.MatchString(b.String(), req)
return err == nil && ok
}

View File

@ -0,0 +1,90 @@
package service
import (
"context"
"sort"
"strings"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/text/gstr"
"service.xpcool.com/internal/dao"
"service.xpcool.com/internal/model/dto"
"service.xpcool.com/internal/model/entity"
)
type IAdminMenu interface {
// Routes returns the visible menu tree of an admin as vben route items.
Routes(context.Context, uint64) ([]*dto.RouteItem, error)
}
type adminMenu struct{}
func NewAdminMenu() IAdminMenu { return &adminMenu{} }
var localAdminMenu IAdminMenu
func AdminMenu() IAdminMenu {
if localAdminMenu == nil {
panic("AdminMenu implementation not registered")
}
return localAdminMenu
}
func RegisterAdminMenu(i IAdminMenu) { localAdminMenu = i }
// routeName converts a permission code like "system:admin" to a unique
// vben route name, e.g. "SystemAdmin".
func routeName(permission string) string {
if permission == "" {
return ""
}
return gstr.CaseCamel(strings.ReplaceAll(permission, ":", "_"))
}
func (s *adminMenu) Routes(ctx context.Context, adminID uint64) ([]*dto.RouteItem, error) {
// 当前管理员所有启用角色可见的菜单type=1含按钮行但不作为路由节点。
var list []entity.AdminMenu
if err := dao.AdminUserRole.Ctx(ctx).As("ur").
LeftJoin("admin_role_menu rm", "ur.role_id=rm.role_id").
LeftJoin("admin_menu m", "rm.menu_id=m.id").
Where("ur.admin_user_id", adminID).
Where("m.type", 1).
Where("m.status", 1).
Where("m.deleted_at IS NULL").
Fields("m.*").
Scan(&list); err != nil {
return nil, gerror.Wrap(err, "query admin menu routes")
}
byID := make(map[uint64]*dto.RouteItem, len(list))
for i := range list {
m := &list[i]
byID[m.Id] = &dto.RouteItem{
Name: routeName(m.Permission),
Path: m.Path,
Component: m.Component,
Meta: dto.RouteMeta{Title: m.Name, Icon: m.Icon, Order: m.Sort, HideInMenu: m.Hidden},
}
}
var roots []*dto.RouteItem
for i := range list {
m := &list[i]
item := byID[m.Id]
if m.ParentId == 0 {
roots = append(roots, item)
} else if p, ok := byID[m.ParentId]; ok {
p.Children = append(p.Children, item)
}
}
sort.Slice(roots, func(i, j int) bool { return roots[i].Meta.Order < roots[j].Meta.Order })
for _, r := range roots {
sortRouteChildren(r)
}
return roots, nil
}
func sortRouteChildren(r *dto.RouteItem) {
sort.Slice(r.Children, func(i, j int) bool { return r.Children[i].Meta.Order < r.Children[j].Meta.Order })
for _, c := range r.Children {
sortRouteChildren(c)
}
}

View File

@ -0,0 +1,383 @@
package service
import (
"context"
"sort"
"github.com/gogf/gf/v2/errors/gerror"
"golang.org/x/crypto/bcrypt"
"service.xpcool.com/internal/consts"
"service.xpcool.com/internal/dao"
"service.xpcool.com/internal/library/response"
"service.xpcool.com/internal/model/do"
"service.xpcool.com/internal/model/dto"
"service.xpcool.com/internal/model/entity"
)
// IAdminManage manages administrators.
type IAdminManage interface {
List(context.Context, dto.PageQuery) ([]*dto.AdminItem, int, error)
Create(context.Context, dto.AdminCreateInput) (uint64, error)
Update(context.Context, dto.AdminUpdateInput) error
ResetPassword(context.Context, uint64, string) error
Delete(context.Context, uint64) error
}
// IRoleManage manages roles and their menu bindings.
type IRoleManage interface {
List(context.Context, dto.PageQuery) ([]*dto.RoleItem, int, error)
Create(context.Context, dto.RoleCreateInput) (uint64, error)
Update(context.Context, dto.RoleUpdateInput) error
Delete(context.Context, uint64) error
}
// IMenuManage manages the menu tree (menus + button permissions).
type IMenuManage interface {
Tree(context.Context) ([]*dto.MenuNode, error)
Create(context.Context, dto.MenuCreateInput) (uint64, error)
Update(context.Context, dto.MenuUpdateInput) error
Delete(context.Context, uint64) error
}
type adminManage struct{}
type roleManage struct{}
type menuManage struct{}
func NewAdminManage() IAdminManage { return &adminManage{} }
func NewRoleManage() IRoleManage { return &roleManage{} }
func NewMenuManage() IMenuManage { return &menuManage{} }
var (
localAdminManage IAdminManage
localRoleManage IRoleManage
localMenuManage IMenuManage
)
func AdminManage() IAdminManage {
if localAdminManage == nil {
panic("AdminManage implementation not registered")
}
return localAdminManage
}
func RegisterAdminManage(i IAdminManage) { localAdminManage = i }
func RoleManage() IRoleManage {
if localRoleManage == nil {
panic("RoleManage implementation not registered")
}
return localRoleManage
}
func RegisterRoleManage(i IRoleManage) { localRoleManage = i }
func MenuManage() IMenuManage {
if localMenuManage == nil {
panic("MenuManage implementation not registered")
}
return localMenuManage
}
func RegisterMenuManage(i IMenuManage) { localMenuManage = i }
// ---------------------- Admin manage ----------------------
func (s *adminManage) List(ctx context.Context, q dto.PageQuery) ([]*dto.AdminItem, int, error) {
total, err := dao.AdminUser.Ctx(ctx).Count()
if err != nil {
return nil, 0, gerror.Wrap(err, "count admins")
}
if total == 0 {
return nil, 0, nil
}
var list []entity.AdminUser
if err = dao.AdminUser.Ctx(ctx).Page(q.Page, q.Size).OrderDesc("id").Scan(&list); err != nil {
return nil, 0, gerror.Wrap(err, "query admins")
}
items := make([]*dto.AdminItem, 0, len(list))
for i := range list {
a := &list[i]
roleIds, roleNames, err := s.roles(ctx, a.Id)
if err != nil {
return nil, 0, err
}
items = append(items, &dto.AdminItem{
Id: a.Id, Username: a.Username, Nickname: a.Nickname, Status: a.Status,
RoleIds: roleIds, RoleNames: roleNames,
CreatedAt: a.CreatedAt.Layout("2006-01-02 15:04:05"),
})
}
return items, total, nil
}
func (s *adminManage) roles(ctx context.Context, adminID uint64) ([]uint64, []string, error) {
var rels []entity.AdminUserRole
if err := dao.AdminUserRole.Ctx(ctx).Where(do.AdminUserRole{AdminUserId: adminID}).Scan(&rels); err != nil {
return nil, nil, gerror.Wrap(err, "query admin roles")
}
roleIds := make([]uint64, 0, len(rels))
for _, r := range rels {
roleIds = append(roleIds, r.RoleId)
}
if len(roleIds) == 0 {
return roleIds, nil, nil
}
var roles []entity.AdminRole
if err := dao.AdminRole.Ctx(ctx).WhereIn("id", roleIds).Scan(&roles); err != nil {
return nil, nil, gerror.Wrap(err, "query roles")
}
names := make([]string, 0, len(roles))
for _, r := range roles {
names = append(names, r.Name)
}
return roleIds, names, nil
}
func (s *adminManage) Create(ctx context.Context, in dto.AdminCreateInput) (uint64, error) {
count, err := dao.AdminUser.Ctx(ctx).Where(do.AdminUser{Username: in.Username}).Count()
if err != nil {
return 0, gerror.Wrap(err, "check username")
}
if count > 0 {
return 0, response.Error(consts.CodeInvalidParam, "username already exists")
}
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
if err != nil {
return 0, gerror.Wrap(err, "hash password")
}
id, err := dao.AdminUser.Ctx(ctx).Data(do.AdminUser{
Username: in.Username, PasswordHash: string(hash), Nickname: in.Nickname, Status: 1,
}).InsertAndGetId()
if err != nil {
return 0, gerror.Wrap(err, "insert admin")
}
if err = bindAdminRoles(ctx, uint64(id), in.RoleIds); err != nil {
return 0, err
}
return uint64(id), nil
}
func (s *adminManage) Update(ctx context.Context, in dto.AdminUpdateInput) error {
data := do.AdminUser{Nickname: in.Nickname, Status: in.Status}
if _, err := dao.AdminUser.Ctx(ctx).Where(do.AdminUser{Id: in.Id}).Data(data).Update(); err != nil {
return gerror.Wrap(err, "update admin")
}
return bindAdminRoles(ctx, in.Id, in.RoleIds)
}
func (s *adminManage) ResetPassword(ctx context.Context, id uint64, password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return gerror.Wrap(err, "hash password")
}
if _, err = dao.AdminUser.Ctx(ctx).Where(do.AdminUser{Id: id}).Data(do.AdminUser{PasswordHash: string(hash)}).Update(); err != nil {
return gerror.Wrap(err, "reset password")
}
return nil
}
func (s *adminManage) Delete(ctx context.Context, id uint64) error {
if _, err := dao.AdminUser.Ctx(ctx).Where(do.AdminUser{Id: id}).Delete(); err != nil {
return gerror.Wrap(err, "delete admin")
}
// 关联关系物理删除,避免唯一键残留。
if _, err := dao.AdminUserRole.Ctx(ctx).Unscoped().Where(do.AdminUserRole{AdminUserId: id}).Delete(); err != nil {
return gerror.Wrap(err, "delete admin role bindings")
}
return nil
}
// bindAdminRoles 全量重绑管理员-角色关系。
func bindAdminRoles(ctx context.Context, adminID uint64, roleIds []uint64) error {
if _, err := dao.AdminUserRole.Ctx(ctx).Unscoped().Where(do.AdminUserRole{AdminUserId: adminID}).Delete(); err != nil {
return gerror.Wrap(err, "clear admin roles")
}
for _, rid := range roleIds {
if _, err := dao.AdminUserRole.Ctx(ctx).Data(do.AdminUserRole{AdminUserId: adminID, RoleId: rid}).Insert(); err != nil {
return gerror.Wrap(err, "bind admin role")
}
}
return nil
}
// ---------------------- Role manage ----------------------
func (s *roleManage) List(ctx context.Context, q dto.PageQuery) ([]*dto.RoleItem, int, error) {
total, err := dao.AdminRole.Ctx(ctx).Count()
if err != nil {
return nil, 0, gerror.Wrap(err, "count roles")
}
if total == 0 {
return nil, 0, nil
}
var list []entity.AdminRole
if err = dao.AdminRole.Ctx(ctx).Page(q.Page, q.Size).OrderAsc("id").Scan(&list); err != nil {
return nil, 0, gerror.Wrap(err, "query roles")
}
items := make([]*dto.RoleItem, 0, len(list))
for i := range list {
r := &list[i]
menuIds, err := roleMenuIDs(ctx, r.Id)
if err != nil {
return nil, 0, err
}
items = append(items, &dto.RoleItem{
Id: r.Id, Code: r.Code, Name: r.Name, Status: r.Status, MenuIds: menuIds,
CreatedAt: r.CreatedAt.Layout("2006-01-02 15:04:05"),
})
}
return items, total, nil
}
func (s *roleManage) Create(ctx context.Context, in dto.RoleCreateInput) (uint64, error) {
count, err := dao.AdminRole.Ctx(ctx).Where(do.AdminRole{Code: in.Code}).Count()
if err != nil {
return 0, gerror.Wrap(err, "check role code")
}
if count > 0 {
return 0, response.Error(consts.CodeInvalidParam, "role code already exists")
}
id, err := dao.AdminRole.Ctx(ctx).Data(do.AdminRole{Code: in.Code, Name: in.Name, Status: in.Status}).InsertAndGetId()
if err != nil {
return 0, gerror.Wrap(err, "insert role")
}
if err = bindRoleMenus(ctx, uint64(id), in.MenuIds); err != nil {
return 0, err
}
return uint64(id), nil
}
func (s *roleManage) Update(ctx context.Context, in dto.RoleUpdateInput) error {
if _, err := dao.AdminRole.Ctx(ctx).Where(do.AdminRole{Id: in.Id}).Data(do.AdminRole{Name: in.Name, Status: in.Status}).Update(); err != nil {
return gerror.Wrap(err, "update role")
}
return bindRoleMenus(ctx, in.Id, in.MenuIds)
}
func (s *roleManage) Delete(ctx context.Context, id uint64) error {
if _, err := dao.AdminRole.Ctx(ctx).Where(do.AdminRole{Id: id}).Delete(); err != nil {
return gerror.Wrap(err, "delete role")
}
if _, err := dao.AdminRoleMenu.Ctx(ctx).Unscoped().Where(do.AdminRoleMenu{RoleId: id}).Delete(); err != nil {
return gerror.Wrap(err, "delete role menu bindings")
}
if _, err := dao.AdminUserRole.Ctx(ctx).Unscoped().Where(do.AdminUserRole{RoleId: id}).Delete(); err != nil {
return gerror.Wrap(err, "delete user role bindings")
}
return nil
}
func roleMenuIDs(ctx context.Context, roleID uint64) ([]uint64, error) {
var rels []entity.AdminRoleMenu
if err := dao.AdminRoleMenu.Ctx(ctx).Where(do.AdminRoleMenu{RoleId: roleID}).Scan(&rels); err != nil {
return nil, gerror.Wrap(err, "query role menus")
}
ids := make([]uint64, 0, len(rels))
for _, r := range rels {
ids = append(ids, r.MenuId)
}
return ids, nil
}
// bindRoleMenus 全量重绑角色-菜单关系。
func bindRoleMenus(ctx context.Context, roleID uint64, menuIds []uint64) error {
if _, err := dao.AdminRoleMenu.Ctx(ctx).Unscoped().Where(do.AdminRoleMenu{RoleId: roleID}).Delete(); err != nil {
return gerror.Wrap(err, "clear role menus")
}
for _, mid := range menuIds {
if _, err := dao.AdminRoleMenu.Ctx(ctx).Data(do.AdminRoleMenu{RoleId: roleID, MenuId: mid}).Insert(); err != nil {
return gerror.Wrap(err, "bind role menu")
}
}
return nil
}
// ---------------------- Menu manage ----------------------
func (s *menuManage) Tree(ctx context.Context) ([]*dto.MenuNode, error) {
var list []entity.AdminMenu
if err := dao.AdminMenu.Ctx(ctx).OrderAsc("sort").Scan(&list); err != nil {
return nil, gerror.Wrap(err, "query menus")
}
byID := make(map[uint64]*dto.MenuNode, len(list))
for i := range list {
m := &list[i]
byID[m.Id] = &dto.MenuNode{
Id: m.Id, ParentId: m.ParentId, Name: m.Name, Icon: m.Icon, Type: m.Type,
Path: m.Path, Component: m.Component, Permission: m.Permission,
Sort: m.Sort, Status: m.Status, Hidden: m.Hidden,
}
}
var roots []*dto.MenuNode
for i := range list {
m := &list[i]
node := byID[m.Id]
if m.ParentId == 0 {
roots = append(roots, node)
} else if p, ok := byID[m.ParentId]; ok {
p.Children = append(p.Children, node)
}
}
sort.Slice(roots, func(i, j int) bool { return roots[i].Sort < roots[j].Sort })
for _, r := range roots {
sortMenuChildren(r)
}
return roots, nil
}
func sortMenuChildren(n *dto.MenuNode) {
sort.Slice(n.Children, func(i, j int) bool { return n.Children[i].Sort < n.Children[j].Sort })
for _, c := range n.Children {
sortMenuChildren(c)
}
}
func (s *menuManage) Create(ctx context.Context, in dto.MenuCreateInput) (uint64, error) {
count, err := dao.AdminMenu.Ctx(ctx).Where(do.AdminMenu{Permission: in.Permission}).Count()
if err != nil {
return 0, gerror.Wrap(err, "check menu permission")
}
if count > 0 {
return 0, response.Error(consts.CodeInvalidParam, "permission already exists")
}
id, err := dao.AdminMenu.Ctx(ctx).Data(do.AdminMenu{
ParentId: in.ParentId, Name: in.Name, Icon: in.Icon, Type: in.Type, Path: in.Path,
Component: in.Component, Permission: in.Permission, Sort: in.Sort, Status: in.Status, Hidden: in.Hidden,
}).InsertAndGetId()
if err != nil {
return 0, gerror.Wrap(err, "insert menu")
}
return uint64(id), nil
}
func (s *menuManage) Update(ctx context.Context, in dto.MenuUpdateInput) error {
count, err := dao.AdminMenu.Ctx(ctx).Where("permission = ? AND id != ?", in.Permission, in.Id).Count()
if err != nil {
return gerror.Wrap(err, "check menu permission")
}
if count > 0 {
return response.Error(consts.CodeInvalidParam, "permission already exists")
}
_, err = dao.AdminMenu.Ctx(ctx).Where(do.AdminMenu{Id: in.Id}).Data(do.AdminMenu{
ParentId: in.ParentId, Name: in.Name, Icon: in.Icon, Type: in.Type, Path: in.Path,
Component: in.Component, Permission: in.Permission, Sort: in.Sort, Status: in.Status, Hidden: in.Hidden,
}).Update()
if err != nil {
return gerror.Wrap(err, "update menu")
}
return nil
}
func (s *menuManage) Delete(ctx context.Context, id uint64) error {
// 删除前检查是否存在子节点。
cnt, err := dao.AdminMenu.Ctx(ctx).Where(do.AdminMenu{ParentId: id}).Count()
if err != nil {
return gerror.Wrap(err, "check menu children")
}
if cnt > 0 {
return response.Error(consts.CodeInvalidParam, "delete children first")
}
if _, err = dao.AdminMenu.Ctx(ctx).Where(do.AdminMenu{Id: id}).Delete(); err != nil {
return gerror.Wrap(err, "delete menu")
}
if _, err = dao.AdminRoleMenu.Ctx(ctx).Unscoped().Where(do.AdminRoleMenu{MenuId: id}).Delete(); err != nil {
return gerror.Wrap(err, "delete role menu bindings")
}
return nil
}

View File

@ -12,6 +12,11 @@ type IUserAuth interface {
type IAdminAuth interface {
Login(context.Context, dto.AdminLoginInput) (*dto.TokenPair, uint64, error)
HasPermission(context.Context, uint64, string) (bool, error)
Info(context.Context, uint64) (*dto.AdminInfo, error)
Codes(context.Context, uint64) ([]string, error)
// PermissionForPath resolves the permission code required by an endpoint
// from admin_menu (type=2 rows) by matching "<METHOD> <path>".
PermissionForPath(context.Context, string, string) (string, error)
}
type AuditEvent struct {
AdminID uint64

View File

@ -0,0 +1,167 @@
package service
import (
"bytes"
"context"
"os"
"path/filepath"
"sort"
"strings"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gfile"
"service.xpcool.com/internal/consts"
"service.xpcool.com/internal/library/response"
"service.xpcool.com/internal/model/dto"
)
// ILogManage reads server log files for the monitoring page.
type ILogManage interface {
Files(context.Context) (string, []*dto.LogFile, error)
Tail(context.Context, string, int, string) ([]string, error)
}
type logManage struct{}
func NewLogManage() ILogManage { return &logManage{} }
var localLogManage ILogManage
func LogManage() ILogManage {
if localLogManage == nil {
panic("LogManage implementation not registered")
}
return localLogManage
}
func RegisterLogManage(i ILogManage) { localLogManage = i }
// logDir returns the configured log directory (config logger.path), default "log".
func logDir(ctx context.Context) string {
dir := g.Cfg().MustGet(ctx, "logger.path").String()
if dir == "" {
dir = "log"
}
return dir
}
// resolvePath 校验 file 为日志目录内文件,防止路径穿越。
func resolvePath(ctx context.Context, file string) (string, error) {
dir := logDir(ctx)
absDir, err := filepath.Abs(dir)
if err != nil {
return "", gerror.Wrap(err, "resolve log dir")
}
full := filepath.Join(absDir, filepath.Clean(file))
if !strings.HasPrefix(full, absDir+string(os.PathSeparator)) && full != absDir {
return "", response.Error(consts.CodeInvalidParam, "invalid log file path")
}
if !gfile.Exists(full) || gfile.IsDir(full) {
return "", response.Error(consts.CodeInvalidParam, "log file not found")
}
return full, nil
}
func (s *logManage) Files(ctx context.Context) (string, []*dto.LogFile, error) {
dir := logDir(ctx)
if !gfile.Exists(dir) {
return dir, nil, nil
}
paths, err := gfile.ScanDirFile(dir, "*.log", true)
if err != nil {
return "", nil, gerror.Wrap(err, "scan log files")
}
files := make([]*dto.LogFile, 0, len(paths))
for _, p := range paths {
info, err := os.Stat(p)
if err != nil {
continue
}
files = append(files, &dto.LogFile{
Name: info.Name(),
Path: strings.TrimPrefix(filepath.ToSlash(p), filepath.ToSlash(dir)+"/"),
Size: info.Size(),
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
})
}
sort.Slice(files, func(i, j int) bool { return files[i].ModTime > files[j].ModTime })
return dir, files, nil
}
func (s *logManage) Tail(ctx context.Context, file string, lines int, keyword string) ([]string, error) {
full, err := resolvePath(ctx, file)
if err != nil {
return nil, err
}
content, err := tailLines(full, lines)
if err != nil {
return nil, gerror.Wrap(err, "read log tail")
}
if keyword != "" {
var filtered []string
for _, l := range content {
if strings.Contains(l, keyword) {
filtered = append(filtered, l)
}
}
return filtered, nil
}
return content, nil
}
// tailLines reads the last n lines of a file efficiently (reverse chunk scan).
func tailLines(path string, n int) ([]string, error) {
if n <= 0 {
n = 200
}
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return nil, err
}
size := info.Size()
if size == 0 {
return nil, nil
}
const chunkSize = 8 << 10 // 8KB
var (
offset int64 = size
buf []byte
lines []string
)
for offset > 0 && len(lines) < n {
readSize := int64(chunkSize)
if offset < readSize {
readSize = offset
}
offset -= readSize
chunk := make([]byte, readSize)
if _, err = f.ReadAt(chunk, offset); err != nil {
return nil, err
}
combined := append(chunk, buf...)
buf = nil
idx := len(combined) - 1
for idx >= 0 && len(lines) < n {
j := bytes.LastIndexByte(combined[:idx+1], '\n')
if j < 0 {
buf = append([]byte(nil), combined[:idx+1]...)
break
}
lines = append(lines, string(combined[j+1:idx+1]))
idx = j - 1
}
}
if len(buf) > 0 && len(lines) < n {
lines = append(lines, string(buf))
}
for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 {
lines[i], lines[j] = lines[j], lines[i]
}
return lines, nil
}

View File

@ -43,8 +43,18 @@ var AdminMenu = map[string]*gdb.TableField{
Extra: "",
Comment: "",
},
"type": {
"icon": {
Index: 3,
Name: "icon",
Type: "varchar(64)",
Null: false,
Key: "",
Default: "",
Extra: "",
Comment: "menu icon (iconify name)",
},
"type": {
Index: 4,
Name: "type",
Type: "tinyint",
Null: false,
@ -54,7 +64,7 @@ var AdminMenu = map[string]*gdb.TableField{
Comment: "1 menu,2 api",
},
"path": {
Index: 4,
Index: 5,
Name: "path",
Type: "varchar(255)",
Null: false,
@ -63,8 +73,18 @@ var AdminMenu = map[string]*gdb.TableField{
Extra: "",
Comment: "",
},
"component": {
Index: 6,
Name: "component",
Type: "varchar(255)",
Null: false,
Key: "",
Default: "",
Extra: "",
Comment: "vue component path, empty for top-level dir",
},
"permission": {
Index: 5,
Index: 7,
Name: "permission",
Type: "varchar(128)",
Null: false,
@ -74,7 +94,7 @@ var AdminMenu = map[string]*gdb.TableField{
Comment: "",
},
"sort": {
Index: 6,
Index: 8,
Name: "sort",
Type: "int",
Null: false,
@ -84,7 +104,7 @@ var AdminMenu = map[string]*gdb.TableField{
Comment: "",
},
"status": {
Index: 7,
Index: 9,
Name: "status",
Type: "tinyint",
Null: false,
@ -93,8 +113,18 @@ var AdminMenu = map[string]*gdb.TableField{
Extra: "",
Comment: "",
},
"hidden": {
Index: 10,
Name: "hidden",
Type: "tinyint",
Null: false,
Key: "",
Default: "0",
Extra: "",
Comment: "0 show,1 hide in menu",
},
"created_at": {
Index: 8,
Index: 11,
Name: "created_at",
Type: "datetime",
Null: false,
@ -104,7 +134,7 @@ var AdminMenu = map[string]*gdb.TableField{
Comment: "",
},
"updated_at": {
Index: 9,
Index: 12,
Name: "updated_at",
Type: "datetime",
Null: false,
@ -114,7 +144,7 @@ var AdminMenu = map[string]*gdb.TableField{
Comment: "",
},
"deleted_at": {
Index: 10,
Index: 13,
Name: "deleted_at",
Type: "datetime",
Null: true,

View File

@ -1,6 +1,8 @@
package main
import (
_ "github.com/gogf/gf/contrib/drivers/mysql/v2" // MySQL driver, required by gdb
"github.com/gogf/gf/v2/os/gctx"
"service.xpcool.com/internal/cmd"

View File

@ -2,7 +2,11 @@ server:
address: ":8000"
openapiPath: "/api.json"
swaggerPath: "/swagger"
logger: { level: "all", stdout: true }
logger:
level: "all"
stdout: true
# 日志落盘目录服务器日志管理功能读取此目录
path: "log"
database:
default:
link: "${DB_DSN}"

View File

@ -0,0 +1,8 @@
-- 003_schema_ext.sql
-- Extend admin_menu for vben admin dynamic routing (backend access mode).
-- icon: iconify icon name; component: vue view path (empty for top-level dir);
-- hidden: 0 show in menu, 1 hide (route-only page).
ALTER TABLE admin_menu
ADD COLUMN icon VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'menu icon (iconify name)' AFTER name,
ADD COLUMN component VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'vue component path, empty for top-level dir' AFTER path,
ADD COLUMN hidden TINYINT NOT NULL DEFAULT 0 COMMENT '0 show,1 hide in menu' AFTER status;

50
manifest/sql/004_seed.sql Normal file
View File

@ -0,0 +1,50 @@
-- 004_seed.sql
-- Initial seed: super admin, super_admin role, system menu tree (with button
-- permission codes) and role/menu bindings. Run after 003_schema_ext.sql.
-- Default admin password: admin123
INSERT INTO admin_user (id, username, password_hash, nickname, status, created_at, updated_at) VALUES
(1, 'admin', '$2a$10$TaPfjTcy7nY1kyEcwRJBwOmrvjzoRZ48orMIO5pAdUN8qj.AAbUpG', '超级管理员', 1, NOW(), NOW());
INSERT INTO admin_role (id, code, name, status, created_at, updated_at) VALUES
(1, 'super_admin', '超级管理员', 1, NOW(), NOW());
-- Menu tree. type: 1 menu, 2 button/api. permission doubles as X-Permission
-- header value (backend) and vben access code (frontend).
INSERT INTO admin_menu (id, parent_id, name, icon, type, path, component, permission, sort, status, hidden, created_at, updated_at) VALUES
-- 系统管理
(1, 0, '系统管理', 'mdi:settings-outline', 1, '/system', '', 'system', 1, 1, 0, NOW(), NOW()),
-- 管理员管理
(2, 1, '管理员管理', 'mdi:account-group-outline', 1, 'admin', 'system/admin/index', 'system:admin', 1, 1, 0, NOW(), NOW()),
(21, 2, '查询', '', 2, '', '', 'system:admin:list', 1, 1, 0, NOW(), NOW()),
(22, 2, '新增', '', 2, '', '', 'system:admin:create', 2, 1, 0, NOW(), NOW()),
(23, 2, '编辑', '', 2, '', '', 'system:admin:update', 3, 1, 0, NOW(), NOW()),
(24, 2, '删除', '', 2, '', '', 'system:admin:delete', 4, 1, 0, NOW(), NOW()),
(25, 2, '重置密码', '', 2, '', '', 'system:admin:resetPwd', 5, 1, 0, NOW(), NOW()),
-- 角色管理
(3, 1, '角色管理', 'mdi:shield-account-outline', 1, 'role', 'system/role/index', 'system:role', 2, 1, 0, NOW(), NOW()),
(31, 3, '查询', '', 2, '', '', 'system:role:list', 1, 1, 0, NOW(), NOW()),
(32, 3, '新增', '', 2, '', '', 'system:role:create', 2, 1, 0, NOW(), NOW()),
(33, 3, '编辑', '', 2, '', '', 'system:role:update', 3, 1, 0, NOW(), NOW()),
(34, 3, '删除', '', 2, '', '', 'system:role:delete', 4, 1, 0, NOW(), NOW()),
(35, 3, '分配菜单', '', 2, '', '', 'system:role:assignMenu', 5, 1, 0, NOW(), NOW()),
-- 菜单管理
(4, 1, '菜单管理', 'mdi:menu-outline', 1, 'menu', 'system/menu/index', 'system:menu', 3, 1, 0, NOW(), NOW()),
(41, 4, '查询', '', 2, '', '', 'system:menu:list', 1, 1, 0, NOW(), NOW()),
(42, 4, '新增', '', 2, '', '', 'system:menu:create', 2, 1, 0, NOW(), NOW()),
(43, 4, '编辑', '', 2, '', '', 'system:menu:update', 3, 1, 0, NOW(), NOW()),
(44, 4, '删除', '', 2, '', '', 'system:menu:delete', 4, 1, 0, NOW(), NOW()),
-- 系统监控
(5, 0, '系统监控', 'mdi:monitor-dashboard', 1, '/monitor', '', 'monitor', 2, 1, 0, NOW(), NOW()),
-- 服务器日志
(6, 5, '服务器日志', 'mdi:file-document-outline', 1, 'log', 'monitor/log/index', 'monitor:log', 1, 1, 0, NOW(), NOW()),
(61, 6, '查看', '', 2, '', '', 'monitor:log:view', 1, 1, 0, NOW(), NOW()),
(62, 6, '实时监控', '', 2, '', '', 'monitor:log:tail', 2, 1, 0, NOW(), NOW());
-- super_admin role binds every menu (1,2,3,4,5,6 and all button rows).
INSERT INTO admin_role_menu (role_id, menu_id, created_at, updated_at)
SELECT 1, id, NOW(), NOW() FROM admin_menu WHERE deleted_at IS NULL;
-- admin user -> super_admin role.
INSERT INTO admin_user_role (admin_user_id, role_id, created_at, updated_at) VALUES
(1, 1, NOW(), NOW());

View File

@ -0,0 +1,21 @@
-- 005_menu_paths.sql
-- Bind protected API endpoints to their required permission codes.
-- The AdminAuth middleware resolves the permission by matching
-- "<METHOD> <path>" against admin_menu.type=2 rows, then verifies the admin
-- owns it. Dynamic path params use {id} placeholders.
UPDATE admin_menu SET path='GET /admin/v1/admins' WHERE id=21; -- system:admin:list
UPDATE admin_menu SET path='POST /admin/v1/admins' WHERE id=22; -- system:admin:create
UPDATE admin_menu SET path='PUT /admin/v1/admins/{id}' WHERE id=23; -- system:admin:update
UPDATE admin_menu SET path='DELETE /admin/v1/admins/{id}' WHERE id=24; -- system:admin:delete
UPDATE admin_menu SET path='PUT /admin/v1/admins/{id}/password' WHERE id=25; -- system:admin:resetPwd
UPDATE admin_menu SET path='GET /admin/v1/roles' WHERE id=31; -- system:role:list
UPDATE admin_menu SET path='POST /admin/v1/roles' WHERE id=32; -- system:role:create
UPDATE admin_menu SET path='PUT /admin/v1/roles/{id}' WHERE id=33; -- system:role:update
UPDATE admin_menu SET path='DELETE /admin/v1/roles/{id}' WHERE id=34; -- system:role:delete
UPDATE admin_menu SET path='PUT /admin/v1/roles/{id}/menus' WHERE id=35; -- system:role:assignMenu
UPDATE admin_menu SET path='GET /admin/v1/menus/tree' WHERE id=41; -- system:menu:list
UPDATE admin_menu SET path='POST /admin/v1/menus' WHERE id=42; -- system:menu:create
UPDATE admin_menu SET path='PUT /admin/v1/menus/{id}' WHERE id=43; -- system:menu:update
UPDATE admin_menu SET path='DELETE /admin/v1/menus/{id}' WHERE id=44; -- system:menu:delete
UPDATE admin_menu SET path='GET /admin/v1/log/files' WHERE id=61; -- monitor:log:view
UPDATE admin_menu SET path='GET /admin/v1/log/tail' WHERE id=62; -- monitor:log:tail