From 1458fc279cf8cd7538de74569f6f23495a85830b Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 27 May 2026 05:41:59 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=9D=83=E9=99=90=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/middleware/permission.go | 70 ++++++ backend/internal/model/role.go | 44 ++++ backend/internal/modules/adminauth/dto.go | 8 + .../internal/modules/adminauth/repository.go | 37 ++- backend/internal/modules/adminmgr/dto.go | 45 ++++ backend/internal/modules/adminmgr/handler.go | 149 +++++++++++ .../internal/modules/adminmgr/repository.go | 234 ++++++++++++++++++ backend/internal/modules/adminmgr/service.go | 74 ++++++ backend/internal/modules/adminrole/dto.go | 37 +++ backend/internal/modules/adminrole/handler.go | 133 ++++++++++ .../internal/modules/adminrole/repository.go | 170 +++++++++++++ backend/internal/modules/adminrole/service.go | 65 +++++ backend/internal/router/router.go | 89 +++++-- backend/migrations/000002_rbac_seed.sql | 58 +++++ frontend/components.d.ts | 2 + frontend/src/api/adminAuth.ts | 8 + frontend/src/api/adminMgr.ts | 76 ++++++ frontend/src/api/adminRoles.ts | 70 ++++++ frontend/src/layouts/AdminLayout.vue | 43 +++- frontend/src/router/adminRoutes.ts | 12 + frontend/src/router/index.ts | 7 + frontend/src/stores/adminSession.ts | 19 +- .../src/views/admin/AdminMgrUsersView.vue | 205 +++++++++++++++ frontend/src/views/admin/AdminRolesView.vue | 108 ++++++++ .../admin/components/AdminUserDialog.vue | 124 ++++++++++ .../components/AssignPermissionsDialog.vue | 177 +++++++++++++ .../admin/components/AssignRolesDialog.vue | 115 +++++++++ .../src/views/admin/components/RoleDialog.vue | 116 +++++++++ scripts/dev.sh | 27 +- 29 files changed, 2276 insertions(+), 46 deletions(-) create mode 100644 backend/internal/middleware/permission.go create mode 100644 backend/internal/model/role.go create mode 100644 backend/internal/modules/adminmgr/dto.go create mode 100644 backend/internal/modules/adminmgr/handler.go create mode 100644 backend/internal/modules/adminmgr/repository.go create mode 100644 backend/internal/modules/adminmgr/service.go create mode 100644 backend/internal/modules/adminrole/dto.go create mode 100644 backend/internal/modules/adminrole/handler.go create mode 100644 backend/internal/modules/adminrole/repository.go create mode 100644 backend/internal/modules/adminrole/service.go create mode 100644 backend/migrations/000002_rbac_seed.sql create mode 100644 frontend/src/api/adminMgr.ts create mode 100644 frontend/src/api/adminRoles.ts create mode 100644 frontend/src/views/admin/AdminMgrUsersView.vue create mode 100644 frontend/src/views/admin/AdminRolesView.vue create mode 100644 frontend/src/views/admin/components/AdminUserDialog.vue create mode 100644 frontend/src/views/admin/components/AssignPermissionsDialog.vue create mode 100644 frontend/src/views/admin/components/AssignRolesDialog.vue create mode 100644 frontend/src/views/admin/components/RoleDialog.vue diff --git a/backend/internal/middleware/permission.go b/backend/internal/middleware/permission.go new file mode 100644 index 0000000..3f59de2 --- /dev/null +++ b/backend/internal/middleware/permission.go @@ -0,0 +1,70 @@ +package middleware + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" +) + +// RequirePermission 检查当前管理员是否拥有指定权限。 +// 超级管理员(拥有 super_admin 角色的管理员)自动放行。 +func RequirePermission(permCode string, rdb *redis.Client) gin.HandlerFunc { + return func(c *gin.Context) { + value, ok := c.Get(ContextAdminID) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + c.Abort() + return + } + adminID, ok := value.(uint64) + if !ok { + response.Unauthorized(c, "管理员上下文无效") + c.Abort() + return + } + + codes, err := getPermCodes(c, rdb, adminID) + if err != nil { + response.Error(c, http.StatusInternalServerError, "perm_check_failed", "权限校验服务暂时不可用") + c.Abort() + return + } + + for _, code := range codes { + if code == permCode || code == "*" { + c.Next() + return + } + } + + response.Error(c, http.StatusForbidden, "forbidden", "没有操作权限") + c.Abort() + } +} + +func getPermCodes(c *gin.Context, rdb *redis.Client, adminID uint64) ([]string, error) { + if rdb == nil { + return nil, errors.New("redis unavailable") + } + ctx := context.Background() + key := fmt.Sprintf("admin:perms:%d", adminID) + raw, err := rdb.Get(ctx, key).Result() + if errors.Is(err, redis.Nil) { + return nil, nil // 缓存未命中,视为无权限 + } + if err != nil { + return nil, err + } + var codes []string + if err := json.Unmarshal([]byte(raw), &codes); err != nil { + return nil, err + } + return codes, nil +} diff --git a/backend/internal/model/role.go b/backend/internal/model/role.go new file mode 100644 index 0000000..b4eb6b4 --- /dev/null +++ b/backend/internal/model/role.go @@ -0,0 +1,44 @@ +package model + +import "time" + +type Role struct { + ID uint64 `gorm:"primaryKey" json:"id"` + Code string `gorm:"size:64;not null;uniqueIndex" json:"code"` + Name string `gorm:"size:64;not null" json:"name"` + Description string `gorm:"size:255;not null;default:''" json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (Role) TableName() string { return "roles" } + +type Permission struct { + ID uint64 `gorm:"primaryKey" json:"id"` + Code string `gorm:"size:128;not null;uniqueIndex" json:"code"` + Name string `gorm:"size:64;not null" json:"name"` + Resource string `gorm:"size:128;not null" json:"resource"` + Action string `gorm:"size:64;not null" json:"action"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (Permission) TableName() string { return "permissions" } + +type AdminUserRole struct { + ID uint64 `gorm:"primaryKey" json:"id"` + AdminID uint64 `gorm:"column:admin_user_id;not null;uniqueIndex:uk_admin_user_roles_user_role" json:"admin_id"` + RoleID uint64 `gorm:"column:role_id;not null;uniqueIndex:uk_admin_user_roles_user_role" json:"role_id"` + CreatedAt time.Time `json:"created_at"` +} + +func (AdminUserRole) TableName() string { return "admin_user_roles" } + +type RolePermission struct { + ID uint64 `gorm:"primaryKey" json:"id"` + RoleID uint64 `gorm:"column:role_id;not null;uniqueIndex:uk_role_permissions_role_permission" json:"role_id"` + PermissionID uint64 `gorm:"column:permission_id;not null;uniqueIndex:uk_role_permissions_role_permission" json:"permission_id"` + CreatedAt time.Time `json:"created_at"` +} + +func (RolePermission) TableName() string { return "role_permissions" } diff --git a/backend/internal/modules/adminauth/dto.go b/backend/internal/modules/adminauth/dto.go index 4eb3a9d..320b622 100644 --- a/backend/internal/modules/adminauth/dto.go +++ b/backend/internal/modules/adminauth/dto.go @@ -11,9 +11,17 @@ type AdminDTO struct { Username string `json:"username"` Nickname string `json:"nickname"` Status string `json:"status"` + Roles []RoleDTO `json:"roles"` + Permissions []string `json:"permissions"` LastLoginAt *time.Time `json:"last_login_at"` } +type RoleDTO struct { + ID uint64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` +} + type LoginRequest struct { Username string `json:"username" binding:"required"` Password string `json:"password" binding:"required"` diff --git a/backend/internal/modules/adminauth/repository.go b/backend/internal/modules/adminauth/repository.go index bfff456..a50fa3b 100644 --- a/backend/internal/modules/adminauth/repository.go +++ b/backend/internal/modules/adminauth/repository.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "encoding/base64" + "encoding/json" "errors" "fmt" "html" @@ -87,7 +88,9 @@ func (r *Repository) Login(username string, password string, captchaID string, c if err != nil { return LoginResult{}, err } - return LoginResult{Admin: toDTO(admin), Tokens: tokens}, nil + dto := toDTO(admin) + r.loadRolesAndPerms(&dto) + return LoginResult{Admin: dto, Tokens: tokens}, nil } func (r *Repository) verifyCaptcha(captchaID string, captchaCode string) error { @@ -119,6 +122,7 @@ func (r *Repository) FindByID(id uint64) (*AdminDTO, error) { return nil, ErrAdminDisabled } dto := toDTO(admin) + r.loadRolesAndPerms(&dto) return &dto, nil } @@ -153,6 +157,37 @@ func toDTO(admin model.AdminUser) AdminDTO { } } +func (r *Repository) loadRolesAndPerms(dto *AdminDTO) { + if r.db == nil { + return + } + // 加载角色 + var roles []RoleDTO + r.db.Table("roles"). + Joins("JOIN admin_user_roles aur ON aur.role_id = roles.id"). + Where("aur.admin_user_id = ?", dto.ID). + Find(&roles) + dto.Roles = roles + + // 加载权限 + var permCodes []string + r.db.Table("permissions"). + Select("DISTINCT permissions.code"). + Joins("JOIN role_permissions rp ON rp.permission_id = permissions.id"). + Joins("JOIN admin_user_roles aur ON aur.role_id = rp.role_id"). + Where("aur.admin_user_id = ?", dto.ID). + Pluck("code", &permCodes) + dto.Permissions = permCodes + + // 缓存权限到 Redis + if r.redis != nil && len(permCodes) > 0 { + ctx := context.Background() + key := fmt.Sprintf("admin:perms:%d", dto.ID) + raw, _ := json.Marshal(permCodes) + r.redis.Set(ctx, key, string(raw), 2*time.Hour) + } +} + func captchaKey(id string) string { return "admin:captcha:" + id } diff --git a/backend/internal/modules/adminmgr/dto.go b/backend/internal/modules/adminmgr/dto.go new file mode 100644 index 0000000..dde44d2 --- /dev/null +++ b/backend/internal/modules/adminmgr/dto.go @@ -0,0 +1,45 @@ +package adminmgr + +import ( + "time" + + "hfb_sys/backend/internal/modules/adminrole" +) + +type AdminUserDTO struct { + ID uint64 `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Status string `json:"status"` + Roles []adminrole.RoleDTO `json:"roles"` + LastLoginAt *time.Time `json:"last_login_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type PaginatedResult struct { + Items []AdminUserDTO `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type CreateAdminRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + Nickname string `json:"nickname"` +} + +type UpdateAdminRequest struct { + Nickname string `json:"nickname"` + Status string `json:"status"` +} + +type AssignRolesRequest struct { + RoleIDs []uint64 `json:"role_ids"` +} + +type ChangePasswordRequest struct { + OldPassword string `json:"old_password" binding:"required"` + NewPassword string `json:"new_password" binding:"required"` +} diff --git a/backend/internal/modules/adminmgr/handler.go b/backend/internal/modules/adminmgr/handler.go new file mode 100644 index 0000000..08f2d3f --- /dev/null +++ b/backend/internal/modules/adminmgr/handler.go @@ -0,0 +1,149 @@ +package adminmgr + +import ( + "errors" + "net/http" + "strconv" + + "hfb_sys/backend/internal/middleware" + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +func (h *Handler) List(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + result, err := h.service.List(page, pageSize) + if err != nil { + writeError(c, err) + return + } + response.OK(c, result) +} + +func (h *Handler) FindByID(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + item, err := h.service.FindByID(id) + if err != nil { + writeError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) Create(c *gin.Context) { + var req CreateAdminRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "用户名和密码不能为空") + return + } + item, err := h.service.Create(req) + if err != nil { + writeError(c, err) + return + } + response.Created(c, item) +} + +func (h *Handler) Update(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req UpdateAdminRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请求格式不正确") + return + } + item, err := h.service.Update(id, req) + if err != nil { + writeError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) Delete(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + currentAdminID, _ := c.Get(middleware.ContextAdminID) + if err := h.service.Delete(id, currentAdminID.(uint64)); err != nil { + writeError(c, err) + return + } + response.OK(c, gin.H{"deleted": true}) +} + +func (h *Handler) AssignRoles(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req AssignRolesRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请求格式不正确") + return + } + if err := h.service.AssignRoles(id, req); err != nil { + writeError(c, err) + return + } + response.OK(c, gin.H{"updated": true}) +} + +func (h *Handler) ChangePassword(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req ChangePasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "密码不能为空") + return + } + if err := h.service.ChangePassword(id, req); err != nil { + writeError(c, err) + return + } + response.OK(c, gin.H{"updated": true}) +} + +func parseID(c *gin.Context) (uint64, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + response.BadRequest(c, "ID 不正确") + return 0, false + } + return id, true +} + +func writeError(c *gin.Context, err error) { + switch { + case errors.Is(err, ErrDependencyUnavailable): + response.ServiceUnavailable(c, "数据库未连接") + case errors.Is(err, ErrCannotDeleteSelf): + response.BadRequest(c, "不能删除自己的账号") + case errors.Is(err, ErrLastSuperAdmin): + response.BadRequest(c, "不能删除最后一个超级管理员") + case errors.Is(err, ErrWrongPassword): + response.BadRequest(c, "原密码错误") + case IsNotFound(err): + response.Error(c, http.StatusNotFound, "not_found", "管理员不存在") + default: + response.Error(c, http.StatusInternalServerError, "internal_error", "管理员管理服务暂时不可用") + } +} diff --git a/backend/internal/modules/adminmgr/repository.go b/backend/internal/modules/adminmgr/repository.go new file mode 100644 index 0000000..f63c3b5 --- /dev/null +++ b/backend/internal/modules/adminmgr/repository.go @@ -0,0 +1,234 @@ +package adminmgr + +import ( + "context" + "errors" + "fmt" + + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/adminrole" + + "github.com/redis/go-redis/v9" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type Repository struct { + db *gorm.DB + redis *redis.Client +} + +func NewRepository(db *gorm.DB, redis *redis.Client) *Repository { + return &Repository{db: db, redis: redis} +} + +func (r *Repository) List(page, pageSize int) (*PaginatedResult, error) { + var total int64 + if err := r.db.Model(&model.AdminUser{}).Count(&total).Error; err != nil { + return nil, err + } + offset := (page - 1) * pageSize + var admins []model.AdminUser + if err := r.db.Order("id DESC").Offset(offset).Limit(pageSize).Find(&admins).Error; err != nil { + return nil, err + } + items := make([]AdminUserDTO, 0, len(admins)) + for _, admin := range admins { + roles, _ := r.getAdminRoles(admin.ID) + items = append(items, AdminUserDTO{ + ID: admin.ID, + Username: admin.Username, + Nickname: admin.Nickname, + Status: admin.Status, + Roles: roles, + LastLoginAt: admin.LastLoginAt, + CreatedAt: admin.CreatedAt, + UpdatedAt: admin.UpdatedAt, + }) + } + return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil +} + +func (r *Repository) FindByID(id uint64) (*AdminUserDTO, error) { + var admin model.AdminUser + if err := r.db.First(&admin, id).Error; err != nil { + return nil, err + } + roles, _ := r.getAdminRoles(admin.ID) + dto := AdminUserDTO{ + ID: admin.ID, + Username: admin.Username, + Nickname: admin.Nickname, + Status: admin.Status, + Roles: roles, + LastLoginAt: admin.LastLoginAt, + CreatedAt: admin.CreatedAt, + UpdatedAt: admin.UpdatedAt, + } + return &dto, nil +} + +func (r *Repository) Create(req CreateAdminRequest) (*AdminUserDTO, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + admin := model.AdminUser{ + Username: req.Username, + PasswordHash: string(hash), + Nickname: req.Nickname, + Status: "active", + } + if err := r.db.Create(&admin).Error; err != nil { + return nil, err + } + return r.FindByID(admin.ID) +} + +func (r *Repository) Update(id uint64, req UpdateAdminRequest) (*AdminUserDTO, error) { + var admin model.AdminUser + if err := r.db.First(&admin, id).Error; err != nil { + return nil, err + } + if req.Nickname != "" { + admin.Nickname = req.Nickname + } + if req.Status != "" { + admin.Status = req.Status + } + if err := r.db.Save(&admin).Error; err != nil { + return nil, err + } + return r.FindByID(id) +} + +func (r *Repository) Delete(id uint64, currentAdminID uint64) error { + if id == currentAdminID { + return ErrCannotDeleteSelf + } + var admin model.AdminUser + if err := r.db.First(&admin, id).Error; err != nil { + return err + } + // 检查是否是最后一个 super_admin + isLastSuper, err := r.isLastSuperAdmin(id) + if err != nil { + return err + } + if isLastSuper { + return ErrLastSuperAdmin + } + return r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("admin_user_id = ?", id).Delete(&model.AdminUserRole{}).Error; err != nil { + return err + } + if err := tx.Delete(&model.AdminUser{}, id).Error; err != nil { + return err + } + r.invalidatePermCache(id) + return nil + }) +} + +func (r *Repository) AssignRoles(adminID uint64, roleIDs []uint64) error { + var admin model.AdminUser + if err := r.db.First(&admin, adminID).Error; err != nil { + return err + } + err := r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("admin_user_id = ?", adminID).Delete(&model.AdminUserRole{}).Error; err != nil { + return err + } + for _, rid := range roleIDs { + aur := model.AdminUserRole{AdminID: adminID, RoleID: rid} + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&aur).Error; err != nil { + return err + } + } + return nil + }) + if err != nil { + return err + } + r.invalidatePermCache(adminID) + return nil +} + +func (r *Repository) ChangePassword(id uint64, oldPwd, newPwd string) error { + var admin model.AdminUser + if err := r.db.First(&admin, id).Error; err != nil { + return err + } + if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(oldPwd)); err != nil { + return ErrWrongPassword + } + hash, err := bcrypt.GenerateFromPassword([]byte(newPwd), bcrypt.DefaultCost) + if err != nil { + return err + } + return r.db.Model(&admin).Update("password_hash", string(hash)).Error +} + +func (r *Repository) GetPermissionCodes(adminID uint64) ([]string, error) { + var codes []string + err := r.db.Table("admin_user_roles aur"). + Select("DISTINCT p.code"). + Joins("JOIN role_permissions rp ON rp.role_id = aur.role_id"). + Joins("JOIN permissions p ON p.id = rp.permission_id"). + Where("aur.admin_user_id = ?", adminID). + Pluck("code", &codes).Error + return codes, err +} + +func (r *Repository) getAdminRoles(adminID uint64) ([]adminrole.RoleDTO, error) { + var roles []model.Role + err := r.db.Table("roles"). + Joins("JOIN admin_user_roles aur ON aur.role_id = roles.id"). + Where("aur.admin_user_id = ?", adminID). + Find(&roles).Error + if err != nil { + return nil, err + } + result := make([]adminrole.RoleDTO, 0, len(roles)) + for _, role := range roles { + result = append(result, adminrole.RoleDTO{ + ID: role.ID, Code: role.Code, Name: role.Name, Description: role.Description, + }) + } + return result, nil +} + +func (r *Repository) isLastSuperAdmin(adminID uint64) (bool, error) { + var superRole model.Role + if err := r.db.Where("code = ?", "super_admin").First(&superRole).Error; err != nil { + return false, nil // 没有 super_admin 角色,不限制 + } + var count int64 + err := r.db.Model(&model.AdminUserRole{}). + Where("role_id = ? AND admin_user_id != ?", superRole.ID, adminID). + Count(&count).Error + return count == 0, err +} + +func (r *Repository) invalidatePermCache(adminID uint64) { + if r.redis == nil { + return + } + ctx := context.Background() + r.redis.Del(ctx, permCacheKey(adminID)) +} + +func permCacheKey(adminID uint64) string { + return fmt.Sprintf("admin:perms:%d", adminID) +} + +var ( + ErrCannotDeleteSelf = errors.New("cannot delete self") + ErrLastSuperAdmin = errors.New("cannot delete the last super admin") + ErrWrongPassword = errors.New("wrong password") +) + +func IsNotFound(err error) bool { + return errors.Is(err, gorm.ErrRecordNotFound) +} diff --git a/backend/internal/modules/adminmgr/service.go b/backend/internal/modules/adminmgr/service.go new file mode 100644 index 0000000..c2866a6 --- /dev/null +++ b/backend/internal/modules/adminmgr/service.go @@ -0,0 +1,74 @@ +package adminmgr + +import "errors" + +var ErrDependencyUnavailable = errors.New("dependency unavailable") + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) List(page, pageSize int) (*PaginatedResult, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + return s.repo.List(page, pageSize) +} + +func (s *Service) FindByID(id uint64) (*AdminUserDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.FindByID(id) +} + +func (s *Service) Create(req CreateAdminRequest) (*AdminUserDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if len(req.Password) < 6 { + return nil, errors.New("password too short") + } + return s.repo.Create(req) +} + +func (s *Service) Update(id uint64, req UpdateAdminRequest) (*AdminUserDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.Update(id, req) +} + +func (s *Service) Delete(id uint64, currentAdminID uint64) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + return s.repo.Delete(id, currentAdminID) +} + +func (s *Service) AssignRoles(adminID uint64, req AssignRolesRequest) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + return s.repo.AssignRoles(adminID, req.RoleIDs) +} + +func (s *Service) ChangePassword(id uint64, req ChangePasswordRequest) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + if len(req.NewPassword) < 6 { + return errors.New("new password too short") + } + return s.repo.ChangePassword(id, req.OldPassword, req.NewPassword) +} diff --git a/backend/internal/modules/adminrole/dto.go b/backend/internal/modules/adminrole/dto.go new file mode 100644 index 0000000..c3eb56a --- /dev/null +++ b/backend/internal/modules/adminrole/dto.go @@ -0,0 +1,37 @@ +package adminrole + +import "time" + +type RoleDTO struct { + ID uint64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + PermCount int `json:"perm_count"` + Permissions []PermissionDTO `json:"permissions,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type PermissionDTO struct { + ID uint64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Resource string `json:"resource"` + Action string `json:"action"` +} + +type CreateRoleRequest struct { + Code string `json:"code" binding:"required"` + Name string `json:"name" binding:"required"` + Description string `json:"description"` +} + +type UpdateRoleRequest struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` +} + +type AssignPermissionsRequest struct { + PermissionIDs []uint64 `json:"permission_ids"` +} diff --git a/backend/internal/modules/adminrole/handler.go b/backend/internal/modules/adminrole/handler.go new file mode 100644 index 0000000..28aa41a --- /dev/null +++ b/backend/internal/modules/adminrole/handler.go @@ -0,0 +1,133 @@ +package adminrole + +import ( + "errors" + "net/http" + "strconv" + + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +func (h *Handler) List(c *gin.Context) { + items, err := h.service.List() + if err != nil { + writeError(c, err) + return + } + response.OK(c, items) +} + +func (h *Handler) FindByID(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + item, err := h.service.FindByID(id) + if err != nil { + writeError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) Create(c *gin.Context) { + var req CreateRoleRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "角色编码和名称不能为空") + return + } + item, err := h.service.Create(req) + if err != nil { + writeError(c, err) + return + } + response.Created(c, item) +} + +func (h *Handler) Update(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req UpdateRoleRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "角色名称不能为空") + return + } + item, err := h.service.Update(id, req) + if err != nil { + writeError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) Delete(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + if err := h.service.Delete(id); err != nil { + writeError(c, err) + return + } + response.OK(c, gin.H{"deleted": true}) +} + +func (h *Handler) AssignPermissions(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req AssignPermissionsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请求格式不正确") + return + } + if err := h.service.AssignPermissions(id, req); err != nil { + writeError(c, err) + return + } + response.OK(c, gin.H{"updated": true}) +} + +func (h *Handler) ListPermissions(c *gin.Context) { + items, err := h.service.ListPermissions() + if err != nil { + writeError(c, err) + return + } + response.OK(c, items) +} + +func parseID(c *gin.Context) (uint64, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + response.BadRequest(c, "ID 不正确") + return 0, false + } + return id, true +} + +func writeError(c *gin.Context, err error) { + switch { + case errors.Is(err, ErrDependencyUnavailable): + response.ServiceUnavailable(c, "数据库未连接") + case errors.Is(err, ErrProtectedRole): + response.BadRequest(c, "该角色为系统内置角色,不可删除") + case IsNotFound(err): + response.Error(c, http.StatusNotFound, "not_found", "角色不存在") + default: + response.Error(c, http.StatusInternalServerError, "internal_error", "角色管理服务暂时不可用") + } +} diff --git a/backend/internal/modules/adminrole/repository.go b/backend/internal/modules/adminrole/repository.go new file mode 100644 index 0000000..56a464e --- /dev/null +++ b/backend/internal/modules/adminrole/repository.go @@ -0,0 +1,170 @@ +package adminrole + +import ( + "errors" + + "hfb_sys/backend/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) List() ([]RoleDTO, error) { + var roles []model.Role + if err := r.db.Order("id ASC").Find(&roles).Error; err != nil { + return nil, err + } + result := make([]RoleDTO, 0, len(roles)) + for _, role := range roles { + var count int64 + r.db.Model(&model.RolePermission{}).Where("role_id = ?", role.ID).Count(&count) + result = append(result, RoleDTO{ + ID: role.ID, + Code: role.Code, + Name: role.Name, + Description: role.Description, + PermCount: int(count), + CreatedAt: role.CreatedAt, + UpdatedAt: role.UpdatedAt, + }) + } + return result, nil +} + +func (r *Repository) FindByID(id uint64) (*RoleDTO, error) { + var role model.Role + if err := r.db.First(&role, id).Error; err != nil { + return nil, err + } + perms, err := r.getRolePermissions(id) + if err != nil { + return nil, err + } + dto := RoleDTO{ + ID: role.ID, + Code: role.Code, + Name: role.Name, + Description: role.Description, + PermCount: len(perms), + Permissions: perms, + CreatedAt: role.CreatedAt, + UpdatedAt: role.UpdatedAt, + } + return &dto, nil +} + +func (r *Repository) Create(req CreateRoleRequest) (*RoleDTO, error) { + role := model.Role{ + Code: req.Code, + Name: req.Name, + Description: req.Description, + } + if err := r.db.Create(&role).Error; err != nil { + return nil, err + } + return r.FindByID(role.ID) +} + +func (r *Repository) Update(id uint64, req UpdateRoleRequest) (*RoleDTO, error) { + var role model.Role + if err := r.db.First(&role, id).Error; err != nil { + return nil, err + } + role.Name = req.Name + role.Description = req.Description + if err := r.db.Save(&role).Error; err != nil { + return nil, err + } + return r.FindByID(id) +} + +func (r *Repository) Delete(id uint64) error { + var role model.Role + if err := r.db.First(&role, id).Error; err != nil { + return err + } + if role.Code == "super_admin" { + return ErrProtectedRole + } + return r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("role_id = ?", id).Delete(&model.RolePermission{}).Error; err != nil { + return err + } + if err := tx.Where("role_id = ?", id).Delete(&model.AdminUserRole{}).Error; err != nil { + return err + } + return tx.Delete(&model.Role{}, id).Error + }) +} + +func (r *Repository) AssignPermissions(roleID uint64, permIDs []uint64) error { + var role model.Role + if err := r.db.First(&role, roleID).Error; err != nil { + return err + } + return r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("role_id = ?", roleID).Delete(&model.RolePermission{}).Error; err != nil { + return err + } + for _, pid := range permIDs { + rp := model.RolePermission{RoleID: roleID, PermissionID: pid} + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&rp).Error; err != nil { + return err + } + } + return nil + }) +} + +func (r *Repository) ListPermissions() ([]PermissionDTO, error) { + var perms []model.Permission + if err := r.db.Order("resource ASC, action ASC").Find(&perms).Error; err != nil { + return nil, err + } + result := make([]PermissionDTO, 0, len(perms)) + for _, p := range perms { + result = append(result, PermissionDTO{ + ID: p.ID, Code: p.Code, Name: p.Name, Resource: p.Resource, Action: p.Action, + }) + } + return result, nil +} + +func (r *Repository) getRolePermissions(roleID uint64) ([]PermissionDTO, error) { + var rps []model.RolePermission + if err := r.db.Where("role_id = ?", roleID).Find(&rps).Error; err != nil { + return nil, err + } + if len(rps) == 0 { + return nil, nil + } + ids := make([]uint64, 0, len(rps)) + for _, rp := range rps { + ids = append(ids, rp.PermissionID) + } + var perms []model.Permission + if err := r.db.Where("id IN ?", ids).Find(&perms).Error; err != nil { + return nil, err + } + result := make([]PermissionDTO, 0, len(perms)) + for _, p := range perms { + result = append(result, PermissionDTO{ + ID: p.ID, Code: p.Code, Name: p.Name, Resource: p.Resource, Action: p.Action, + }) + } + return result, nil +} + +var ErrProtectedRole = errors.New("protected role") + +func IsNotFound(err error) bool { + return errors.Is(err, gorm.ErrRecordNotFound) +} diff --git a/backend/internal/modules/adminrole/service.go b/backend/internal/modules/adminrole/service.go new file mode 100644 index 0000000..c831136 --- /dev/null +++ b/backend/internal/modules/adminrole/service.go @@ -0,0 +1,65 @@ +package adminrole + +import "errors" + +var ErrDependencyUnavailable = errors.New("dependency unavailable") + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) List() ([]RoleDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.List() +} + +func (s *Service) FindByID(id uint64) (*RoleDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.FindByID(id) +} + +func (s *Service) Create(req CreateRoleRequest) (*RoleDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if req.Code == "" || req.Name == "" { + return nil, errors.New("code and name are required") + } + return s.repo.Create(req) +} + +func (s *Service) Update(id uint64, req UpdateRoleRequest) (*RoleDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.Update(id, req) +} + +func (s *Service) Delete(id uint64) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + return s.repo.Delete(id) +} + +func (s *Service) AssignPermissions(roleID uint64, req AssignPermissionsRequest) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + return s.repo.AssignPermissions(roleID, req.PermissionIDs) +} + +func (s *Service) ListPermissions() ([]PermissionDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.ListPermissions() +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index d52b428..bb93f35 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -7,6 +7,8 @@ import ( "hfb_sys/backend/internal/modules/adminaudit" "hfb_sys/backend/internal/modules/adminauth" "hfb_sys/backend/internal/modules/admindashboard" + "hfb_sys/backend/internal/modules/adminmgr" + "hfb_sys/backend/internal/modules/adminrole" "hfb_sys/backend/internal/modules/adminuser" "hfb_sys/backend/internal/modules/auth" "hfb_sys/backend/internal/modules/chat" @@ -114,6 +116,18 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } systemConfigService := systemconfig.NewService(systemConfigRepo) systemConfigHandler := systemconfig.NewHandler(systemConfigService) + var adminRoleRepo *adminrole.Repository + if deps.DB != nil { + adminRoleRepo = adminrole.NewRepository(deps.DB) + } + adminRoleService := adminrole.NewService(adminRoleRepo) + adminRoleHandler := adminrole.NewHandler(adminRoleService) + var adminMgrRepo *adminmgr.Repository + if deps.DB != nil { + adminMgrRepo = adminmgr.NewRepository(deps.DB, deps.Redis) + } + adminMgrService := adminmgr.NewService(adminMgrRepo) + adminMgrHandler := adminmgr.NewHandler(adminMgrService) var fileStorage *filemodule.Storage if cfg.Storage.Endpoint != "" && cfg.Storage.Bucket != "" { var err error @@ -129,6 +143,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { requireAuth := middleware.Auth(jwtManager) requireAdmin := middleware.AdminAuth(jwtManager) requireRealname := middleware.RequireRealname(userRepo) + requirePerm := func(code string) gin.HandlerFunc { + return middleware.RequirePermission(code, deps.Redis) + } api := engine.Group("/api") { @@ -240,34 +257,52 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/me", adminAuthHandler.Me) adminRoutes.POST("/auth/logout", adminAuthHandler.Logout) adminRoutes.POST("/files/upload", fileHandler.Upload) - adminRoutes.GET("/dashboard", adminDashboardHandler.Summary) + adminRoutes.GET("/dashboard", requirePerm("dashboard:view"), adminDashboardHandler.Summary) adminRoutes.GET("/files/object", fileHandler.Object) - adminRoutes.GET("/users", adminUserHandler.List) - adminRoutes.POST("/users/:id/freeze", adminUserHandler.Freeze) - adminRoutes.POST("/users/:id/unfreeze", adminUserHandler.Unfreeze) - adminRoutes.GET("/orders", orderHandler.AdminList) - adminRoutes.GET("/orders/:id", orderHandler.AdminDetail) - adminRoutes.GET("/orders/:id/handoff-records", orderHandler.AdminHandoffRecords) - adminRoutes.POST("/orders/:id/close", orderHandler.AdminClose) - adminRoutes.POST("/orders/:id/mark-abnormal", orderHandler.AdminMarkAbnormal) - adminRoutes.GET("/listings", listingHandler.ListAdmin) - adminRoutes.GET("/listings/pending", listingHandler.ListPendingReview) - adminRoutes.GET("/listings/:id", listingHandler.FindAdmin) - adminRoutes.POST("/listings/:id/approve", listingHandler.Approve) - adminRoutes.POST("/listings/:id/reject", listingHandler.Reject) - adminRoutes.POST("/listings/:id/offline", listingHandler.AdminOffline) - adminRoutes.POST("/listings/:id/mark-abnormal", listingHandler.AdminMarkAbnormal) - adminRoutes.GET("/disputes", disputeHandler.AdminList) - adminRoutes.POST("/disputes/:id/arbitrate", disputeHandler.AdminArbitrate) - adminRoutes.GET("/wallet/ledger", walletHandler.AdminLedger) - adminRoutes.GET("/system-configs", systemConfigHandler.List) - adminRoutes.PUT("/system-configs/:key", systemConfigHandler.Update) - adminRoutes.GET("/audit-logs", adminAuditHandler.List) - adminRoutes.GET("/chats", chatHandler.AdminList) - adminRoutes.GET("/chats/:id", chatHandler.AdminDetail) - adminRoutes.GET("/chats/:id/messages", chatHandler.AdminMessages) - adminRoutes.POST("/chats/:id/messages", chatHandler.AdminSend) - adminRoutes.POST("/chats/:id/read", chatHandler.AdminMarkRead) + adminRoutes.GET("/users", requirePerm("user:view"), adminUserHandler.List) + adminRoutes.POST("/users/:id/freeze", requirePerm("user:freeze"), adminUserHandler.Freeze) + adminRoutes.POST("/users/:id/unfreeze", requirePerm("user:unfreeze"), adminUserHandler.Unfreeze) + adminRoutes.GET("/orders", requirePerm("order:view"), orderHandler.AdminList) + adminRoutes.GET("/orders/:id", requirePerm("order:view"), orderHandler.AdminDetail) + adminRoutes.GET("/orders/:id/handoff-records", requirePerm("order:view"), orderHandler.AdminHandoffRecords) + adminRoutes.POST("/orders/:id/close", requirePerm("order:close"), orderHandler.AdminClose) + adminRoutes.POST("/orders/:id/mark-abnormal", requirePerm("order:mark_abnormal"), orderHandler.AdminMarkAbnormal) + adminRoutes.GET("/listings", requirePerm("listing:view"), listingHandler.ListAdmin) + adminRoutes.GET("/listings/pending", requirePerm("listing:approve"), listingHandler.ListPendingReview) + adminRoutes.GET("/listings/:id", requirePerm("listing:view"), listingHandler.FindAdmin) + adminRoutes.POST("/listings/:id/approve", requirePerm("listing:approve"), listingHandler.Approve) + adminRoutes.POST("/listings/:id/reject", requirePerm("listing:reject"), listingHandler.Reject) + adminRoutes.POST("/listings/:id/offline", requirePerm("listing:offline"), listingHandler.AdminOffline) + adminRoutes.POST("/listings/:id/mark-abnormal", requirePerm("listing:offline"), listingHandler.AdminMarkAbnormal) + adminRoutes.GET("/disputes", requirePerm("dispute:view"), disputeHandler.AdminList) + adminRoutes.POST("/disputes/:id/arbitrate", requirePerm("dispute:arbitrate"), disputeHandler.AdminArbitrate) + adminRoutes.GET("/wallet/ledger", requirePerm("wallet:view"), walletHandler.AdminLedger) + adminRoutes.GET("/system-configs", requirePerm("system_config:view"), systemConfigHandler.List) + adminRoutes.PUT("/system-configs/:key", requirePerm("system_config:update"), systemConfigHandler.Update) + adminRoutes.GET("/audit-logs", requirePerm("audit_log:view"), adminAuditHandler.List) + adminRoutes.GET("/chats", requirePerm("chat:view"), chatHandler.AdminList) + adminRoutes.GET("/chats/:id", requirePerm("chat:view"), chatHandler.AdminDetail) + adminRoutes.GET("/chats/:id/messages", requirePerm("chat:view"), chatHandler.AdminMessages) + adminRoutes.POST("/chats/:id/messages", requirePerm("chat:send"), chatHandler.AdminSend) + adminRoutes.POST("/chats/:id/read", requirePerm("chat:view"), chatHandler.AdminMarkRead) + + // 角色管理 + adminRoutes.GET("/roles", requirePerm("role:manage"), adminRoleHandler.List) + adminRoutes.GET("/roles/:id", requirePerm("role:manage"), adminRoleHandler.FindByID) + adminRoutes.POST("/roles", requirePerm("role:manage"), adminRoleHandler.Create) + adminRoutes.PUT("/roles/:id", requirePerm("role:manage"), adminRoleHandler.Update) + adminRoutes.DELETE("/roles/:id", requirePerm("role:manage"), adminRoleHandler.Delete) + adminRoutes.PUT("/roles/:id/permissions", requirePerm("role:manage"), adminRoleHandler.AssignPermissions) + adminRoutes.GET("/permissions", requirePerm("role:manage"), adminRoleHandler.ListPermissions) + + // 管理员管理 + adminRoutes.GET("/admin-users", requirePerm("admin_user:manage"), adminMgrHandler.List) + adminRoutes.GET("/admin-users/:id", requirePerm("admin_user:manage"), adminMgrHandler.FindByID) + adminRoutes.POST("/admin-users", requirePerm("admin_user:manage"), adminMgrHandler.Create) + adminRoutes.PUT("/admin-users/:id", requirePerm("admin_user:manage"), adminMgrHandler.Update) + adminRoutes.DELETE("/admin-users/:id", requirePerm("admin_user:manage"), adminMgrHandler.Delete) + adminRoutes.PUT("/admin-users/:id/roles", requirePerm("admin_user:manage"), adminMgrHandler.AssignRoles) + adminRoutes.PUT("/admin-users/:id/password", adminMgrHandler.ChangePassword) } } diff --git a/backend/migrations/000002_rbac_seed.sql b/backend/migrations/000002_rbac_seed.sql new file mode 100644 index 0000000..22b4bbf --- /dev/null +++ b/backend/migrations/000002_rbac_seed.sql @@ -0,0 +1,58 @@ +-- RBAC seed: 默认角色和权限 +SET NAMES utf8mb4; + +-- 插入权限 +INSERT INTO permissions (code, name, resource, action) VALUES +('dashboard:view', '查看仪表盘', 'dashboard', 'view'), +('user:view', '查看用户', 'user', 'view'), +('user:freeze', '冻结用户', 'user', 'freeze'), +('user:unfreeze', '解冻用户', 'user', 'unfreeze'), +('order:view', '查看订单', 'order', 'view'), +('order:close', '关闭订单', 'order', 'close'), +('order:mark_abnormal', '标记异常订单', 'order', 'mark_abnormal'), +('listing:view', '查看商品', 'listing', 'view'), +('listing:approve', '审核通过商品', 'listing', 'approve'), +('listing:reject', '审核拒绝商品', 'listing', 'reject'), +('listing:offline', '下架商品', 'listing', 'offline'), +('dispute:view', '查看纠纷', 'dispute', 'view'), +('dispute:arbitrate', '仲裁纠纷', 'dispute', 'arbitrate'), +('chat:view', '查看客服群聊', 'chat', 'view'), +('chat:send', '发送客服消息', 'chat', 'send'), +('wallet:view', '查看资金流水', 'wallet', 'view'), +('admin_user:manage', '管理员管理', 'admin_user', 'manage'), +('role:manage', '角色管理', 'role', 'manage'), +('system_config:view', '查看系统配置', 'system_config', 'view'), +('system_config:update', '修改系统配置', 'system_config', 'update'), +('audit_log:view', '查看审计日志', 'audit_log', 'view') +ON DUPLICATE KEY UPDATE name = VALUES(name); + +-- 插入角色 +INSERT INTO roles (code, name, description) VALUES +('super_admin', '超级管理员', '拥有所有权限,不可被删除'), +('cs', '客服', '处理用户咨询、群聊、纠纷仲裁'), +('ops', '运营', '管理商品审核、订单管理、用户管理'), +('finance', '财务', '查看资金流水、订单信息') +ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description); + +-- super_admin 拥有所有权限 +INSERT IGNORE INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id FROM roles r CROSS JOIN permissions p WHERE r.code = 'super_admin'; + +-- cs 角色权限 +INSERT IGNORE INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id FROM roles r, permissions p +WHERE r.code = 'cs' AND p.code IN ('dashboard:view', 'user:view', 'dispute:view', 'dispute:arbitrate', 'chat:view', 'chat:send', 'order:view'); + +-- ops 角色权限 +INSERT IGNORE INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id FROM roles r, permissions p +WHERE r.code = 'ops' AND p.code IN ('dashboard:view', 'user:view', 'user:freeze', 'user:unfreeze', 'order:view', 'order:close', 'order:mark_abnormal', 'listing:view', 'listing:approve', 'listing:reject', 'listing:offline', 'dispute:view', 'audit_log:view'); + +-- finance 角色权限 +INSERT IGNORE INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id FROM roles r, permissions p +WHERE r.code = 'finance' AND p.code IN ('dashboard:view', 'wallet:view', 'order:view'); + +-- 默认 admin 用户自动绑定 super_admin 角色 +INSERT IGNORE INTO admin_user_roles (admin_user_id, role_id) +SELECT au.id, r.id FROM admin_users au, roles r WHERE au.username = 'admin' AND r.code = 'super_admin'; diff --git a/frontend/components.d.ts b/frontend/components.d.ts index ff84c15..bc854e0 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -15,6 +15,8 @@ declare module 'vue' { ElButton: typeof import('element-plus/es')['ElButton'] ElCarousel: typeof import('element-plus/es')['ElCarousel'] ElCarouselItem: typeof import('element-plus/es')['ElCarouselItem'] + ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] + ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] ElDialog: typeof import('element-plus/es')['ElDialog'] ElEmpty: typeof import('element-plus/es')['ElEmpty'] ElForm: typeof import('element-plus/es')['ElForm'] diff --git a/frontend/src/api/adminAuth.ts b/frontend/src/api/adminAuth.ts index 0156981..afb9cb8 100644 --- a/frontend/src/api/adminAuth.ts +++ b/frontend/src/api/adminAuth.ts @@ -5,11 +5,19 @@ import type { ApiResponse } from './types' import { getRefreshToken, setAuthTokens } from '@/utils/authStorage' import type { UserStatus } from '@/types/status' +export interface AdminRole { + id: number + code: string + name: string +} + export interface AdminUser { id: number username: string nickname: string status: UserStatus + roles: AdminRole[] + permissions: string[] last_login_at?: string } diff --git a/frontend/src/api/adminMgr.ts b/frontend/src/api/adminMgr.ts new file mode 100644 index 0000000..adba43c --- /dev/null +++ b/frontend/src/api/adminMgr.ts @@ -0,0 +1,76 @@ +import { apiClient } from './client' + +import type { ApiResponse, PaginatedResult } from './types' + +export interface AdminRole { + id: number + code: string + name: string + description: string +} + +export interface AdminMgrUser { + id: number + username: string + nickname: string + status: string + roles: AdminRole[] + last_login_at?: string + created_at: string + updated_at: string +} + +export interface CreateAdminRequest { + username: string + password: string + nickname?: string +} + +export interface UpdateAdminRequest { + nickname?: string + status?: string +} + +export interface ChangePasswordRequest { + old_password: string + new_password: string +} + +export async function fetchAdminMgrUsers(page = 1, pageSize = 20) { + const { data } = await apiClient.get>>('/admin/admin-users', { + params: { page, page_size: pageSize }, + }) + return data.data +} + +export async function fetchAdminMgrUser(id: number) { + const { data } = await apiClient.get>(`/admin/admin-users/${id}`) + return data.data +} + +export async function createAdminMgrUser(req: CreateAdminRequest) { + const { data } = await apiClient.post>('/admin/admin-users', req) + return data.data +} + +export async function updateAdminMgrUser(id: number, req: UpdateAdminRequest) { + const { data } = await apiClient.put>(`/admin/admin-users/${id}`, req) + return data.data +} + +export async function deleteAdminMgrUser(id: number) { + const { data } = await apiClient.delete>(`/admin/admin-users/${id}`) + return data.data +} + +export async function assignAdminRoles(id: number, roleIds: number[]) { + const { data } = await apiClient.put>(`/admin/admin-users/${id}/roles`, { + role_ids: roleIds, + }) + return data.data +} + +export async function changeAdminPassword(id: number, req: ChangePasswordRequest) { + const { data } = await apiClient.put>(`/admin/admin-users/${id}/password`, req) + return data.data +} diff --git a/frontend/src/api/adminRoles.ts b/frontend/src/api/adminRoles.ts new file mode 100644 index 0000000..21f6916 --- /dev/null +++ b/frontend/src/api/adminRoles.ts @@ -0,0 +1,70 @@ +import { apiClient } from './client' + +import type { ApiResponse } from './types' + +export interface Permission { + id: number + code: string + name: string + resource: string + action: string +} + +export interface Role { + id: number + code: string + name: string + description: string + perm_count: number + permissions?: Permission[] + created_at: string + updated_at: string +} + +export interface CreateRoleRequest { + code: string + name: string + description?: string +} + +export interface UpdateRoleRequest { + name: string + description?: string +} + +export async function fetchRoles() { + const { data } = await apiClient.get>('/admin/roles') + return data.data +} + +export async function fetchRole(id: number) { + const { data } = await apiClient.get>(`/admin/roles/${id}`) + return data.data +} + +export async function createRole(req: CreateRoleRequest) { + const { data } = await apiClient.post>('/admin/roles', req) + return data.data +} + +export async function updateRole(id: number, req: UpdateRoleRequest) { + const { data } = await apiClient.put>(`/admin/roles/${id}`, req) + return data.data +} + +export async function deleteRole(id: number) { + const { data } = await apiClient.delete>(`/admin/roles/${id}`) + return data.data +} + +export async function assignRolePermissions(roleId: number, permissionIds: number[]) { + const { data } = await apiClient.put>(`/admin/roles/${roleId}/permissions`, { + permission_ids: permissionIds, + }) + return data.data +} + +export async function fetchPermissions() { + const { data } = await apiClient.get>('/admin/permissions') + return data.data +} diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index 6624fb1..b064c9c 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -12,7 +12,9 @@ import { SwitchButton, Tickets, User, - Wallet + UserFilled, + Wallet, + Setting } from '@element-plus/icons-vue' import { ElMessage } from 'element-plus' import { computed, ref } from 'vue' @@ -25,19 +27,36 @@ const router = useRouter() const adminSession = useAdminSessionStore() const isCollapsed = ref(false) -const navItems = [ - { label: '仪表盘', to: '/admin/dashboard', icon: DataLine }, - { label: '用户管理', to: '/admin/users', icon: User }, - { label: '订单管理', to: '/admin/orders', icon: Tickets }, - { label: '商品管理', to: '/admin/listings', icon: Shop }, - { label: '商品审核', to: '/admin/listings/review', icon: DocumentChecked }, - { label: '仲裁中心', to: '/admin/disputes', icon: ScaleToOriginal }, - { label: '客服群聊', to: '/admin/chats', icon: ChatDotRound }, - { label: '资金流水', to: '/admin/wallet-ledger', icon: Wallet }, - { label: '系统配置', to: '/admin/system-configs', icon: Operation }, - { label: '审计日志', to: '/admin/audit-logs', icon: Document }, +interface NavItem { + label: string + to: string + icon: any + permission?: string +} + +const allNavItems: NavItem[] = [ + { label: '仪表盘', to: '/admin/dashboard', icon: DataLine, permission: 'dashboard:view' }, + { label: '用户管理', to: '/admin/users', icon: User, permission: 'user:view' }, + { label: '订单管理', to: '/admin/orders', icon: Tickets, permission: 'order:view' }, + { label: '商品管理', to: '/admin/listings', icon: Shop, permission: 'listing:view' }, + { label: '商品审核', to: '/admin/listings/review', icon: DocumentChecked, permission: 'listing:approve' }, + { label: '仲裁中心', to: '/admin/disputes', icon: ScaleToOriginal, permission: 'dispute:view' }, + { label: '客服群聊', to: '/admin/chats', icon: ChatDotRound, permission: 'chat:view' }, + { label: '资金流水', to: '/admin/wallet-ledger', icon: Wallet, permission: 'wallet:view' }, + { label: '系统配置', to: '/admin/system-configs', icon: Operation, permission: 'system_config:view' }, + { label: '审计日志', to: '/admin/audit-logs', icon: Document, permission: 'audit_log:view' }, + { label: '管理员管理', to: '/admin/admin-users', icon: UserFilled, permission: 'admin_user:manage' }, + { label: '角色管理', to: '/admin/roles', icon: Setting, permission: 'role:manage' }, ] +const navItems = computed(() => { + if (adminSession.isSuperAdmin) return allNavItems + return allNavItems.filter((item) => { + if (!item.permission) return true + return adminSession.hasPermission(item.permission) + }) +}) + const adminName = computed(() => adminSession.nickname || adminSession.username || '管理员') async function handleLogout() { diff --git a/frontend/src/router/adminRoutes.ts b/frontend/src/router/adminRoutes.ts index 1356cbe..fe5e936 100644 --- a/frontend/src/router/adminRoutes.ts +++ b/frontend/src/router/adminRoutes.ts @@ -82,4 +82,16 @@ export const adminRoutes: RouteRecordRaw[] = [ component: () => import('@/views/admin/AdminAuditLogsView.vue'), meta: adminMeta, }, + { + path: '/admin/admin-users', + name: 'admin-admin-users', + component: () => import('@/views/admin/AdminMgrUsersView.vue'), + meta: adminMeta, + }, + { + path: '/admin/roles', + name: 'admin-roles', + component: () => import('@/views/admin/AdminRolesView.vue'), + meta: adminMeta, + }, ] diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 91da7a9..3e8c418 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -84,6 +84,13 @@ router.beforeEach(async (to) => { if (!adminSession.token) { return '/admin/login' } + if (adminSession.permissions.length === 0) { + try { + await adminSession.loadMe() + } catch { + // 权限加载失败,仍然允许访问(降级为无权限状态) + } + } } return true }) diff --git a/frontend/src/stores/adminSession.ts b/frontend/src/stores/adminSession.ts index cc32545..ad66bfd 100644 --- a/frontend/src/stores/adminSession.ts +++ b/frontend/src/stores/adminSession.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia' -import { fetchAdminMe, loginAdmin, type AdminUser } from '@/api/adminAuth' +import { fetchAdminMe, loginAdmin, type AdminRole, type AdminUser } from '@/api/adminAuth' import { clearAuthStorage, getAccessToken, getRefreshToken, setAuthTokens } from '@/utils/authStorage' export const useAdminSessionStore = defineStore('adminSession', { @@ -10,7 +10,20 @@ export const useAdminSessionStore = defineStore('adminSession', { adminId: Number(localStorage.getItem('admin_id') || 0), username: localStorage.getItem('admin_username') || '', nickname: '', + roles: [] as AdminRole[], + permissions: [] as string[], }), + getters: { + hasPermission: (state) => { + return (code: string) => state.permissions.includes(code) || state.permissions.includes('*') + }, + hasRole: (state) => { + return (code: string) => state.roles.some((r) => r.code === code) + }, + isSuperAdmin: (state) => { + return state.roles.some((r) => r.code === 'super_admin') || state.permissions.includes('*') + }, + }, actions: { async login(username: string, password: string, captchaId: string, captchaCode: string) { const result = await loginAdmin(username, password, captchaId, captchaCode) @@ -28,6 +41,8 @@ export const useAdminSessionStore = defineStore('adminSession', { this.adminId = 0 this.username = '' this.nickname = '' + this.roles = [] + this.permissions = [] clearAuthStorage('admin') }, syncFromStorage() { @@ -49,6 +64,8 @@ export const useAdminSessionStore = defineStore('adminSession', { this.adminId = admin.id this.username = admin.username this.nickname = admin.nickname + this.roles = admin.roles || [] + this.permissions = admin.permissions || [] localStorage.setItem('admin_id', String(admin.id)) localStorage.setItem('admin_username', admin.username) }, diff --git a/frontend/src/views/admin/AdminMgrUsersView.vue b/frontend/src/views/admin/AdminMgrUsersView.vue new file mode 100644 index 0000000..234c24b --- /dev/null +++ b/frontend/src/views/admin/AdminMgrUsersView.vue @@ -0,0 +1,205 @@ + + + + + diff --git a/frontend/src/views/admin/AdminRolesView.vue b/frontend/src/views/admin/AdminRolesView.vue new file mode 100644 index 0000000..4d67323 --- /dev/null +++ b/frontend/src/views/admin/AdminRolesView.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/frontend/src/views/admin/components/AdminUserDialog.vue b/frontend/src/views/admin/components/AdminUserDialog.vue new file mode 100644 index 0000000..07cddd6 --- /dev/null +++ b/frontend/src/views/admin/components/AdminUserDialog.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/frontend/src/views/admin/components/AssignPermissionsDialog.vue b/frontend/src/views/admin/components/AssignPermissionsDialog.vue new file mode 100644 index 0000000..400b3d7 --- /dev/null +++ b/frontend/src/views/admin/components/AssignPermissionsDialog.vue @@ -0,0 +1,177 @@ + + + + + diff --git a/frontend/src/views/admin/components/AssignRolesDialog.vue b/frontend/src/views/admin/components/AssignRolesDialog.vue new file mode 100644 index 0000000..aaaa5d8 --- /dev/null +++ b/frontend/src/views/admin/components/AssignRolesDialog.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/frontend/src/views/admin/components/RoleDialog.vue b/frontend/src/views/admin/components/RoleDialog.vue new file mode 100644 index 0000000..c5cad91 --- /dev/null +++ b/frontend/src/views/admin/components/RoleDialog.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/scripts/dev.sh b/scripts/dev.sh index 29528f8..dcd879c 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -74,20 +74,37 @@ init_database() { local migration for migration in "${ROOT_DIR}"/backend/migrations/*.sql; do log "执行迁移:$(basename "${migration}")" - docker exec -i hfb-mysql mysql -uhfb -psecret hfb_sys < "${migration}" + docker exec -i hfb-mysql mysql -uhfb -psecret --default-character-set=utf8mb4 hfb_sys < "${migration}" done else local chat_table_count chat_table_count="$( docker exec hfb-mysql mysql -uhfb -psecret -N -s -e \ "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'hfb_sys' AND table_name = 'chat_conversations';" 2>/dev/null - )" + )" || chat_table_count="0" if [[ "${chat_table_count}" == "0" ]]; then log "补充聊天表迁移:000002_chat.sql" - docker exec -i hfb-mysql mysql -uhfb -psecret hfb_sys < "${ROOT_DIR}/backend/migrations/000002_chat.sql" - else - log "数据库结构已存在,跳过迁移" + docker exec -i hfb-mysql mysql -uhfb -psecret --default-character-set=utf8mb4 hfb_sys < "${ROOT_DIR}/backend/migrations/000002_chat.sql" fi + + local role_count + role_count="$( + docker exec hfb-mysql mysql -uhfb -psecret -N -s -e \ + "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'hfb_sys' AND table_name = 'roles';" 2>/dev/null + )" || role_count="0" + if [[ "${role_count}" != "0" ]]; then + local seed_count + seed_count="$( + docker exec hfb-mysql mysql -uhfb -psecret -N -s -e \ + "SELECT COUNT(*) FROM roles;" 2>/dev/null + )" || seed_count="0" + if [[ "${seed_count}" == "0" ]]; then + log "补充 RBAC 种子数据:000002_rbac_seed.sql" + docker exec -i hfb-mysql mysql -uhfb -psecret --default-character-set=utf8mb4 hfb_sys < "${ROOT_DIR}/backend/migrations/000002_rbac_seed.sql" || true + fi + fi + + log "数据库迁移检查完成" fi }