diff --git a/.gitignore b/.gitignore index f943a49..7ef8d1e 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,6 @@ bin **/config/config.yaml # WorkBuddy 本地记忆(不入库,跨账号上下文见 AGENTS.md 与 docs/change-log/) -.workbuddy/ \ No newline at end of file +.workbuddy/ +# server runtime logs +log/ diff --git a/api/admin/v1/admin.go b/api/admin/v1/admin.go new file mode 100644 index 0000000..df58927 --- /dev/null +++ b/api/admin/v1/admin.go @@ -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{} diff --git a/api/admin/v1/auth.go b/api/admin/v1/auth.go index 5428d2f..bfa4e62 100644 --- a/api/admin/v1/auth.go +++ b/api/admin/v1/auth.go @@ -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"` +} diff --git a/api/admin/v1/log.go b/api/admin/v1/log.go new file mode 100644 index 0000000..949ca65 --- /dev/null +++ b/api/admin/v1/log.go @@ -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"` +} diff --git a/api/admin/v1/menu.go b/api/admin/v1/menu.go new file mode 100644 index 0000000..81a8bf3 --- /dev/null +++ b/api/admin/v1/menu.go @@ -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"` +} diff --git a/api/admin/v1/menu_manage.go b/api/admin/v1/menu_manage.go new file mode 100644 index 0000000..6d1fca1 --- /dev/null +++ b/api/admin/v1/menu_manage.go @@ -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{} diff --git a/api/admin/v1/role.go b/api/admin/v1/role.go new file mode 100644 index 0000000..163021b --- /dev/null +++ b/api/admin/v1/role.go @@ -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{} diff --git a/go.mod b/go.mod index 478f118..b3d4128 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index d74f4f0..dba8ac4 100644 --- a/go.sum +++ b/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= diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 5a39561..2830924 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -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() diff --git a/internal/controller/admin/admin.go b/internal/controller/admin/admin.go new file mode 100644 index 0000000..fc2ae78 --- /dev/null +++ b/internal/controller/admin/admin.go @@ -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 +} diff --git a/internal/controller/admin/auth.go b/internal/controller/admin/auth.go index eb94516..c96eeef 100644 --- a/internal/controller/admin/auth.go +++ b/internal/controller/admin/auth.go @@ -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 diff --git a/internal/controller/admin/controller.go b/internal/controller/admin/controller.go new file mode 100644 index 0000000..011966a --- /dev/null +++ b/internal/controller/admin/controller.go @@ -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() +} diff --git a/internal/controller/admin/log.go b/internal/controller/admin/log.go new file mode 100644 index 0000000..639f0d7 --- /dev/null +++ b/internal/controller/admin/log.go @@ -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 +} diff --git a/internal/controller/admin/menu.go b/internal/controller/admin/menu.go new file mode 100644 index 0000000..f8dc5cf --- /dev/null +++ b/internal/controller/admin/menu.go @@ -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 +} diff --git a/internal/controller/admin/profile.go b/internal/controller/admin/profile.go new file mode 100644 index 0000000..ade3edf --- /dev/null +++ b/internal/controller/admin/profile.go @@ -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 +} diff --git a/internal/controller/admin/role.go b/internal/controller/admin/role.go new file mode 100644 index 0000000..7eab956 --- /dev/null +++ b/internal/controller/admin/role.go @@ -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 +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index ca0f25f..04c52ea 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -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) diff --git a/internal/model/do/admin_menu.go b/internal/model/do/admin_menu.go index 20d066c..4f71771 100644 --- a/internal/model/do/admin_menu.go +++ b/internal/model/do/admin_menu.go @@ -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 // diff --git a/internal/model/dto/admin_menu.go b/internal/model/dto/admin_menu.go new file mode 100644 index 0000000..358f8b5 --- /dev/null +++ b/internal/model/dto/admin_menu.go @@ -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 +} diff --git a/internal/model/dto/admin_rbac.go b/internal/model/dto/admin_rbac.go new file mode 100644 index 0000000..dd41de7 --- /dev/null +++ b/internal/model/dto/admin_rbac.go @@ -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 +} diff --git a/internal/model/dto/auth.go b/internal/model/dto/auth.go index fe4fbae..72744e5 100644 --- a/internal/model/dto/auth.go +++ b/internal/model/dto/auth.go @@ -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 使用 +} diff --git a/internal/model/dto/log.go b/internal/model/dto/log.go new file mode 100644 index 0000000..18f6cc6 --- /dev/null +++ b/internal/model/dto/log.go @@ -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 +} diff --git a/internal/model/entity/admin_menu.go b/internal/model/entity/admin_menu.go index 10ccf16..42b6f3f 100644 --- a/internal/model/entity/admin_menu.go +++ b/internal/model/entity/admin_menu.go @@ -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:""` // } diff --git a/internal/service/admin_auth.go b/internal/service/admin_auth.go index a602ccb..9897757 100644 --- a/internal/service/admin_auth.go +++ b/internal/service/admin_auth.go @@ -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 +} diff --git a/internal/service/admin_menu.go b/internal/service/admin_menu.go new file mode 100644 index 0000000..0e5b19c --- /dev/null +++ b/internal/service/admin_menu.go @@ -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) + } +} diff --git a/internal/service/admin_rbac.go b/internal/service/admin_rbac.go new file mode 100644 index 0000000..e6867b1 --- /dev/null +++ b/internal/service/admin_rbac.go @@ -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 +} diff --git a/internal/service/auth.go b/internal/service/auth.go index aa3a68c..e073661 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -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 " ". + PermissionForPath(context.Context, string, string) (string, error) } type AuditEvent struct { AdminID uint64 diff --git a/internal/service/log_manage.go b/internal/service/log_manage.go new file mode 100644 index 0000000..60288dd --- /dev/null +++ b/internal/service/log_manage.go @@ -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 +} diff --git a/internal/table/admin_menu.go b/internal/table/admin_menu.go index 8d8cc81..62b4be0 100644 --- a/internal/table/admin_menu.go +++ b/internal/table/admin_menu.go @@ -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, diff --git a/main.go b/main.go index df43a1a..066e453 100644 --- a/main.go +++ b/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" diff --git a/manifest/config/config.dev.yaml b/manifest/config/config.dev.yaml index 39c4469..1d8dba2 100644 --- a/manifest/config/config.dev.yaml +++ b/manifest/config/config.dev.yaml @@ -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}" diff --git a/manifest/sql/003_schema_ext.sql b/manifest/sql/003_schema_ext.sql new file mode 100644 index 0000000..fb32a59 --- /dev/null +++ b/manifest/sql/003_schema_ext.sql @@ -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; diff --git a/manifest/sql/004_seed.sql b/manifest/sql/004_seed.sql new file mode 100644 index 0000000..a86353d --- /dev/null +++ b/manifest/sql/004_seed.sql @@ -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()); diff --git a/manifest/sql/005_menu_paths.sql b/manifest/sql/005_menu_paths.sql new file mode 100644 index 0000000..6b8111d --- /dev/null +++ b/manifest/sql/005_menu_paths.sql @@ -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 +-- " " 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