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:
parent
5cd8f0f551
commit
8fdb25e6bd
2
.gitignore
vendored
2
.gitignore
vendored
@ -20,3 +20,5 @@ bin
|
||||
|
||||
# WorkBuddy 本地记忆(不入库,跨账号上下文见 AGENTS.md 与 docs/change-log/)
|
||||
.workbuddy/
|
||||
# server runtime logs
|
||||
log/
|
||||
|
||||
73
api/admin/v1/admin.go
Normal file
73
api/admin/v1/admin.go
Normal 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{}
|
||||
@ -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
35
api/admin/v1/log.go
Normal 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
33
api/admin/v1/menu.go
Normal 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"`
|
||||
}
|
||||
77
api/admin/v1/menu_manage.go
Normal file
77
api/admin/v1/menu_manage.go
Normal 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
62
api/admin/v1/role.go
Normal 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
5
go.mod
@ -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
4
go.sum
@ -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=
|
||||
|
||||
@ -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()
|
||||
|
||||
60
internal/controller/admin/admin.go
Normal file
60
internal/controller/admin/admin.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
|
||||
21
internal/controller/admin/controller.go
Normal file
21
internal/controller/admin/controller.go
Normal 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()
|
||||
}
|
||||
30
internal/controller/admin/log.go
Normal file
30
internal/controller/admin/log.go
Normal 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
|
||||
}
|
||||
64
internal/controller/admin/menu.go
Normal file
64
internal/controller/admin/menu.go
Normal 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
|
||||
}
|
||||
67
internal/controller/admin/profile.go
Normal file
67
internal/controller/admin/profile.go
Normal 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
|
||||
}
|
||||
49
internal/controller/admin/role.go
Normal file
49
internal/controller/admin/role.go
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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 //
|
||||
|
||||
20
internal/model/dto/admin_menu.go
Normal file
20
internal/model/dto/admin_menu.go
Normal 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
|
||||
}
|
||||
98
internal/model/dto/admin_rbac.go
Normal file
98
internal/model/dto/admin_rbac.go
Normal 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
|
||||
}
|
||||
@ -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
10
internal/model/dto/log.go
Normal 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
|
||||
}
|
||||
@ -13,11 +13,14 @@ 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:""` //
|
||||
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:""` //
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
90
internal/service/admin_menu.go
Normal file
90
internal/service/admin_menu.go
Normal 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)
|
||||
}
|
||||
}
|
||||
383
internal/service/admin_rbac.go
Normal file
383
internal/service/admin_rbac.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
|
||||
167
internal/service/log_manage.go
Normal file
167
internal/service/log_manage.go
Normal 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
|
||||
}
|
||||
@ -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,
|
||||
|
||||
2
main.go
2
main.go
@ -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"
|
||||
|
||||
@ -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}"
|
||||
|
||||
8
manifest/sql/003_schema_ext.sql
Normal file
8
manifest/sql/003_schema_ext.sql
Normal 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
50
manifest/sql/004_seed.sql
Normal 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());
|
||||
21
manifest/sql/005_menu_paths.sql
Normal file
21
manifest/sql/005_menu_paths.sql
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user