rbac
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
package rbac
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"portal-mini-go/internal/model/admin"
|
||||
"portal-mini-go/internal/pkg/auth"
|
||||
"portal-mini-go/internal/pkg/errors"
|
||||
adminrepo "portal-mini-go/internal/repository/admin"
|
||||
)
|
||||
|
||||
// AdminService 管理员账号业务:CRUD、角色分配、密码重置。
|
||||
type AdminService struct {
|
||||
repo UserRepository
|
||||
invalidator CacheInvalidator
|
||||
}
|
||||
|
||||
func NewAdminService(repo UserRepository, invalidator CacheInvalidator) *AdminService {
|
||||
return &AdminService{repo: repo, invalidator: invalidator}
|
||||
}
|
||||
|
||||
// CreateReq 新建管理员请求。
|
||||
type CreateAdminReq struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Nickname string `json:"nickname"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Status int8 `json:"status"`
|
||||
}
|
||||
|
||||
// Create 新建管理员,用户名唯一,密码以 bcrypt 存储。
|
||||
func (s *AdminService) Create(ctx context.Context, req CreateAdminReq) (*admin.User, error) {
|
||||
if req.Status == 0 {
|
||||
req.Status = admin.UserStatusEnabled
|
||||
}
|
||||
if req.Status != admin.UserStatusEnabled && req.Status != admin.UserStatusDisabled {
|
||||
return nil, errors.New(40000, "非法的管理员状态")
|
||||
}
|
||||
if _, err := s.repo.GetByUsername(ctx, req.Username); err == nil {
|
||||
return nil, errors.New(40900, "用户名已存在")
|
||||
} else if !errors.Is(err, errors.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(50000, "hash password", err)
|
||||
}
|
||||
u := &admin.User{
|
||||
Username: req.Username,
|
||||
Nickname: req.Nickname,
|
||||
PasswordHash: hash,
|
||||
Status: req.Status,
|
||||
}
|
||||
if err := s.repo.Create(ctx, u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// AdminListReq 管理员列表过滤条件。
|
||||
type AdminListReq struct {
|
||||
Keyword string
|
||||
Status int8
|
||||
Page int
|
||||
Size int
|
||||
}
|
||||
|
||||
// List 分页查询管理员。
|
||||
func (s *AdminService) List(ctx context.Context, req AdminListReq) ([]admin.User, int64, error) {
|
||||
page, size := req.Page, req.Size
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 {
|
||||
size = 20
|
||||
}
|
||||
return s.repo.List(ctx, adminrepo.UserListFilter{
|
||||
Keyword: req.Keyword,
|
||||
Status: req.Status,
|
||||
Page: page,
|
||||
Size: size,
|
||||
})
|
||||
}
|
||||
|
||||
// GetByID 按 ID 查询管理员。
|
||||
func (s *AdminService) GetByID(ctx context.Context, id uint64) (*admin.User, error) {
|
||||
return s.repo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// UpdateAdminReq 编辑管理员请求;指针区分“未提供”与“置空”。
|
||||
type UpdateAdminReq struct {
|
||||
Nickname *string `json:"nickname"`
|
||||
Status *int8 `json:"status"`
|
||||
}
|
||||
|
||||
// Update 局部更新管理员昵称与状态。
|
||||
func (s *AdminService) Update(ctx context.Context, id uint64, req UpdateAdminReq) (*admin.User, error) {
|
||||
cols := map[string]interface{}{}
|
||||
if req.Nickname != nil {
|
||||
cols["nickname"] = *req.Nickname
|
||||
}
|
||||
if req.Status != nil {
|
||||
if *req.Status != admin.UserStatusEnabled && *req.Status != admin.UserStatusDisabled {
|
||||
return nil, errors.New(40000, "非法的管理员状态")
|
||||
}
|
||||
cols["status"] = *req.Status
|
||||
}
|
||||
if len(cols) == 0 {
|
||||
return s.repo.GetByID(ctx, id)
|
||||
}
|
||||
if err := s.repo.Update(ctx, id, cols); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.invalidator.Invalidate(ctx, id)
|
||||
return s.repo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// Delete 删除管理员(同时清理其角色关联,并失效缓存)。
|
||||
func (s *AdminService) Delete(ctx context.Context, id uint64) error {
|
||||
if err := s.repo.Delete(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
s.invalidator.Invalidate(ctx, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetRoles 为管理员分配角色(全量替换)。
|
||||
func (s *AdminService) SetRoles(ctx context.Context, id uint64, roleIDs []uint64) error {
|
||||
if err := s.repo.SetRoles(ctx, id, roleIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
s.invalidator.Invalidate(ctx, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetPassword 重置管理员密码。
|
||||
func (s *AdminService) ResetPassword(ctx context.Context, id uint64, newPassword string) error {
|
||||
hash, err := auth.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return errors.Wrap(50000, "hash password", err)
|
||||
}
|
||||
if err := s.repo.Update(ctx, id, map[string]interface{}{"password_hash": hash}); err != nil {
|
||||
return err
|
||||
}
|
||||
s.invalidator.Invalidate(ctx, id)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package rbac
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"portal-mini-go/internal/model/admin"
|
||||
"portal-mini-go/internal/pkg/errors"
|
||||
adminrepo "portal-mini-go/internal/repository/admin"
|
||||
)
|
||||
|
||||
// PermissionService 权限(接口 / 菜单)业务:CRUD。
|
||||
type PermissionService struct {
|
||||
repo PermissionRepository
|
||||
invalidator CacheInvalidator
|
||||
}
|
||||
|
||||
func NewPermissionService(repo PermissionRepository, invalidator CacheInvalidator) *PermissionService {
|
||||
return &PermissionService{repo: repo, invalidator: invalidator}
|
||||
}
|
||||
|
||||
// CreatePermReq 新建权限请求。
|
||||
// Type 取 admin.PermTypeAPI / admin.PermTypeMenu;菜单需指定 ParentID 与 Sort。
|
||||
type CreatePermReq struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
ParentID uint64 `json:"parent_id"`
|
||||
Sort int `json:"sort"`
|
||||
}
|
||||
|
||||
// Create 新建权限,code 唯一。
|
||||
func (s *PermissionService) Create(ctx context.Context, req CreatePermReq) (*admin.Permission, error) {
|
||||
if req.Type != admin.PermTypeAPI && req.Type != admin.PermTypeMenu {
|
||||
return nil, errors.New(40000, "非法的权限类型")
|
||||
}
|
||||
p := &admin.Permission{
|
||||
Code: req.Code,
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
Method: req.Method,
|
||||
Path: req.Path,
|
||||
ParentID: req.ParentID,
|
||||
Sort: req.Sort,
|
||||
}
|
||||
if err := s.repo.Create(ctx, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.invalidator.InvalidateAll(ctx)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// PermListReq 权限列表过滤条件。
|
||||
type PermListReq struct {
|
||||
Type string
|
||||
Keyword string
|
||||
Page int
|
||||
Size int
|
||||
}
|
||||
|
||||
// List 分页查询权限,Type 为空表示全部类型。
|
||||
func (s *PermissionService) List(ctx context.Context, req PermListReq) ([]admin.Permission, int64, error) {
|
||||
page, size := req.Page, req.Size
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 {
|
||||
size = 20
|
||||
}
|
||||
return s.repo.List(ctx, adminrepo.PermListFilter{
|
||||
Type: req.Type,
|
||||
Keyword: req.Keyword,
|
||||
Page: page,
|
||||
Size: size,
|
||||
})
|
||||
}
|
||||
|
||||
// GetByID 按 ID 查询权限。
|
||||
func (s *PermissionService) GetByID(ctx context.Context, id uint64) (*admin.Permission, error) {
|
||||
return s.repo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// UpdatePermReq 编辑权限请求;指针区分“未提供”与“置空”。
|
||||
type UpdatePermReq struct {
|
||||
Name *string `json:"name"`
|
||||
Type *string `json:"type"`
|
||||
Method *string `json:"method"`
|
||||
Path *string `json:"path"`
|
||||
ParentID *uint64 `json:"parent_id"`
|
||||
Sort *int `json:"sort"`
|
||||
}
|
||||
|
||||
// Update 局部更新权限。
|
||||
func (s *PermissionService) Update(ctx context.Context, id uint64, req UpdatePermReq) (*admin.Permission, error) {
|
||||
cols := map[string]interface{}{}
|
||||
if req.Name != nil {
|
||||
cols["name"] = *req.Name
|
||||
}
|
||||
if req.Type != nil {
|
||||
if *req.Type != admin.PermTypeAPI && *req.Type != admin.PermTypeMenu {
|
||||
return nil, errors.New(40000, "非法的权限类型")
|
||||
}
|
||||
cols["type"] = *req.Type
|
||||
}
|
||||
if req.Method != nil {
|
||||
cols["method"] = *req.Method
|
||||
}
|
||||
if req.Path != nil {
|
||||
cols["path"] = *req.Path
|
||||
}
|
||||
if req.ParentID != nil {
|
||||
cols["parent_id"] = *req.ParentID
|
||||
}
|
||||
if req.Sort != nil {
|
||||
cols["sort"] = *req.Sort
|
||||
}
|
||||
if len(cols) == 0 {
|
||||
return s.repo.GetByID(ctx, id)
|
||||
}
|
||||
if err := s.repo.Update(ctx, id, cols); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.invalidator.InvalidateAll(ctx)
|
||||
return s.repo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// Delete 删除权限(清理关联并失效全部缓存)。
|
||||
func (s *PermissionService) Delete(ctx context.Context, id uint64) error {
|
||||
if err := s.repo.Delete(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
s.invalidator.InvalidateAll(ctx)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
// Package rbac 提供后台 RBAC 的权限聚合、菜单树组装、缓存与种子初始化,
|
||||
// 以及管理员 / 角色 / 权限(接口、菜单)的 CRUD 业务。
|
||||
//
|
||||
// 依赖方向:handler → 本包 service → repository/admin → model/admin。
|
||||
package rbac
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"portal-mini-go/internal/model/admin"
|
||||
"portal-mini-go/internal/pkg/auth"
|
||||
"portal-mini-go/internal/pkg/errors"
|
||||
adminrepo "portal-mini-go/internal/repository/admin"
|
||||
)
|
||||
|
||||
// UserRepository 是 Provider 与管理员业务所需的用户仓储接口(由 repository/admin 实现)。
|
||||
type UserRepository interface {
|
||||
GetByUsername(ctx context.Context, username string) (*admin.User, error)
|
||||
GetByID(ctx context.Context, id uint64) (*admin.User, error)
|
||||
Create(ctx context.Context, u *admin.User) error
|
||||
Count(ctx context.Context) (int64, error)
|
||||
List(ctx context.Context, f adminrepo.UserListFilter) ([]admin.User, int64, error)
|
||||
Update(ctx context.Context, id uint64, cols map[string]interface{}) error
|
||||
Delete(ctx context.Context, id uint64) error
|
||||
SetRoles(ctx context.Context, adminID uint64, roleIDs []uint64) error
|
||||
RoleCodesOf(ctx context.Context, adminID uint64) ([]string, error)
|
||||
PermissionCodesOf(ctx context.Context, adminID uint64) ([]string, error)
|
||||
}
|
||||
|
||||
// RoleRepository 是 Provider 与角色业务所需的角色仓储接口。
|
||||
type RoleRepository interface {
|
||||
GetByID(ctx context.Context, id uint64) (*admin.Role, error)
|
||||
GetByCode(ctx context.Context, code string) (*admin.Role, error)
|
||||
Create(ctx context.Context, m *admin.Role) error
|
||||
List(ctx context.Context, f adminrepo.RoleListFilter) ([]admin.Role, int64, error)
|
||||
Update(ctx context.Context, id uint64, cols map[string]interface{}) error
|
||||
Delete(ctx context.Context, id uint64) error
|
||||
SetPermissions(ctx context.Context, roleID uint64, permIDs []uint64) error
|
||||
PermissionCodesOf(ctx context.Context, roleID uint64) ([]string, error)
|
||||
}
|
||||
|
||||
// PermissionRepository 是 Provider 与权限业务所需的权限仓储接口。
|
||||
type PermissionRepository interface {
|
||||
GetByID(ctx context.Context, id uint64) (*admin.Permission, error)
|
||||
Create(ctx context.Context, m *admin.Permission) error
|
||||
List(ctx context.Context, f adminrepo.PermListFilter) ([]admin.Permission, int64, error)
|
||||
Update(ctx context.Context, id uint64, cols map[string]interface{}) error
|
||||
Delete(ctx context.Context, id uint64) error
|
||||
AllMenuPermissions(ctx context.Context) ([]admin.Permission, error)
|
||||
AllCodes(ctx context.Context) ([]string, error)
|
||||
}
|
||||
|
||||
// CacheInvalidator 用于写操作后失效权限缓存,由 Provider 实现并被各业务服务依赖。
|
||||
type CacheInvalidator interface {
|
||||
Invalidate(ctx context.Context, adminID uint64)
|
||||
InvalidateAll(ctx context.Context)
|
||||
}
|
||||
|
||||
// PermissionCache 是管理员权限集的缓存抽象。默认实现为内存版;
|
||||
// 生产环境若接入 Redis,可实现该接口替换(见 NewRedisCache 预留说明)。
|
||||
type PermissionCache interface {
|
||||
Get(ctx context.Context, adminID uint64) (roles, perms []string, ok bool)
|
||||
Set(ctx context.Context, adminID uint64, roles, perms []string)
|
||||
Invalidate(ctx context.Context, adminID uint64)
|
||||
InvalidateAll(ctx context.Context)
|
||||
}
|
||||
|
||||
// Provider 聚合管理员权限数据,实现 middleware.PermissionProvider。
|
||||
type Provider struct {
|
||||
userRepo UserRepository
|
||||
roleRepo RoleRepository
|
||||
permRepo PermissionRepository
|
||||
cache PermissionCache
|
||||
}
|
||||
|
||||
// NewProvider 构造权限 Provider。
|
||||
func NewProvider(
|
||||
userRepo UserRepository,
|
||||
roleRepo RoleRepository,
|
||||
permRepo PermissionRepository,
|
||||
cache PermissionCache,
|
||||
) *Provider {
|
||||
return &Provider{userRepo: userRepo, roleRepo: roleRepo, permRepo: permRepo, cache: cache}
|
||||
}
|
||||
|
||||
// Load 返回管理员的角色与权限 code 集合,优先读缓存。
|
||||
// 若含超级管理员角色,则权限集为全部权限 code(短路放行)。
|
||||
func (p *Provider) Load(ctx context.Context, adminID uint64) (roles, perms []string, err error) {
|
||||
if r, pp, ok := p.cache.Get(ctx, adminID); ok {
|
||||
return r, pp, nil
|
||||
}
|
||||
roles, err = p.userRepo.RoleCodesOf(ctx, adminID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if contains(roles, admin.SuperAdminRoleCode) {
|
||||
all, e := p.permRepo.AllCodes(ctx)
|
||||
if e != nil {
|
||||
return nil, nil, e
|
||||
}
|
||||
perms = all
|
||||
} else {
|
||||
perms, err = p.userRepo.PermissionCodesOf(ctx, adminID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
p.cache.Set(ctx, adminID, roles, perms)
|
||||
return roles, perms, nil
|
||||
}
|
||||
|
||||
// RolesOf 返回管理员角色 code 列表。
|
||||
func (p *Provider) RolesOf(ctx context.Context, adminID uint64) ([]string, error) {
|
||||
roles, _, err := p.Load(ctx, adminID)
|
||||
return roles, err
|
||||
}
|
||||
|
||||
// PermissionsOf 返回管理员权限 code 列表。
|
||||
func (p *Provider) PermissionsOf(ctx context.Context, adminID uint64) ([]string, error) {
|
||||
_, perms, err := p.Load(ctx, adminID)
|
||||
return perms, err
|
||||
}
|
||||
|
||||
// MenuTree 返回该管理员可见的菜单树(按权限过滤,超级管理员返回完整树)。
|
||||
func (p *Provider) MenuTree(ctx context.Context, adminID uint64) ([]admin.MenuNode, error) {
|
||||
roles, perms, err := p.Load(ctx, adminID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
menus, err := p.permRepo.AllMenuPermissions(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allowed := make(map[string]struct{}, len(perms))
|
||||
for _, c := range perms {
|
||||
allowed[c] = struct{}{}
|
||||
}
|
||||
return buildMenuTree(menus, contains(roles, admin.SuperAdminRoleCode), allowed), nil
|
||||
}
|
||||
|
||||
// Invalidate 失效指定管理员的权限缓存。
|
||||
func (p *Provider) Invalidate(ctx context.Context, adminID uint64) { p.cache.Invalidate(ctx, adminID) }
|
||||
|
||||
// InvalidateAll 失效全部管理员的权限缓存(角色 / 权限变更后调用)。
|
||||
func (p *Provider) InvalidateAll(ctx context.Context) { p.cache.InvalidateAll(ctx) }
|
||||
|
||||
// Seed 初始化超级管理员角色与首个管理员账号(若表为空)。
|
||||
// defaultUser / defaultPwd 来自环境变量,缺失时使用安全默认值。
|
||||
func (p *Provider) Seed(ctx context.Context, defaultUser, defaultPwd string) error {
|
||||
if _, err := p.roleRepo.GetByCode(ctx, admin.SuperAdminRoleCode); err != nil {
|
||||
if !errors.Is(err, errors.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
if err := p.roleRepo.Create(ctx, &admin.Role{
|
||||
Code: admin.SuperAdminRoleCode,
|
||||
Name: "超级管理员",
|
||||
Status: admin.UserStatusEnabled,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
n, err := p.userRepo.Count(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return nil
|
||||
}
|
||||
hash, err := auth.HashPassword(defaultPwd)
|
||||
if err != nil {
|
||||
return errors.Wrap(50000, "hash seed password", err)
|
||||
}
|
||||
u := &admin.User{
|
||||
Username: defaultUser,
|
||||
PasswordHash: hash,
|
||||
Nickname: "超级管理员",
|
||||
Status: admin.UserStatusEnabled,
|
||||
}
|
||||
if err := p.userRepo.Create(ctx, u); err != nil {
|
||||
return err
|
||||
}
|
||||
role, err := p.roleRepo.GetByCode(ctx, admin.SuperAdminRoleCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.userRepo.SetRoles(ctx, u.ID, []uint64{role.ID})
|
||||
}
|
||||
|
||||
// buildMenuTree 依据菜单权限列表与管理员可见集合组装树。
|
||||
// 节点被保留的条件:自身 code 在 allowed 中(超级管理员恒为真),或存在被保留的后代。
|
||||
func buildMenuTree(menus []admin.Permission, isSuper bool, allowed map[string]struct{}) []admin.MenuNode {
|
||||
byID := make(map[uint64]admin.Permission, len(menus))
|
||||
childrenOf := make(map[uint64][]uint64)
|
||||
for _, m := range menus {
|
||||
byID[m.ID] = m
|
||||
childrenOf[m.ParentID] = append(childrenOf[m.ParentID], m.ID)
|
||||
}
|
||||
kept := make(map[uint64]bool, len(menus))
|
||||
var mark func(id uint64) bool
|
||||
mark = func(id uint64) bool {
|
||||
if v, ok := kept[id]; ok {
|
||||
return v
|
||||
}
|
||||
p := byID[id]
|
||||
self := isSuper
|
||||
if !isSuper {
|
||||
if _, ok := allowed[p.Code]; ok {
|
||||
self = true
|
||||
}
|
||||
}
|
||||
childKept := false
|
||||
for _, cid := range childrenOf[id] {
|
||||
if mark(cid) {
|
||||
childKept = true
|
||||
}
|
||||
}
|
||||
keep := self || childKept
|
||||
kept[id] = keep
|
||||
return keep
|
||||
}
|
||||
for id := range byID {
|
||||
mark(id)
|
||||
}
|
||||
|
||||
var build func(parentID uint64) []admin.MenuNode
|
||||
build = func(parentID uint64) []admin.MenuNode {
|
||||
ids := childrenOf[parentID]
|
||||
out := make([]admin.MenuNode, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if !kept[id] {
|
||||
continue
|
||||
}
|
||||
node := admin.MenuNode{Permission: byID[id]}
|
||||
node.Children = build(id)
|
||||
out = append(out, node)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Sort < out[j].Sort })
|
||||
return out
|
||||
}
|
||||
return build(0)
|
||||
}
|
||||
|
||||
func contains(s []string, target string) bool {
|
||||
for _, v := range s {
|
||||
if v == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MemoryCache 进程内权限缓存,适用于无 Redis 的本地 / 测试环境。
|
||||
// 多实例部署时各进程不共享,仅供单实例使用;生产可替换为 RedisCache。
|
||||
type MemoryCache struct {
|
||||
mu sync.RWMutex
|
||||
data map[uint64]memItem
|
||||
ttl time.Duration
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type memItem struct {
|
||||
roles []string
|
||||
perms []string
|
||||
expireAt time.Time
|
||||
}
|
||||
|
||||
// NewMemoryCache 创建内存权限缓存,ttl 为兜底过期时间(0 表示永不过期,仅靠失效)。
|
||||
func NewMemoryCache(ttl time.Duration) *MemoryCache {
|
||||
return &MemoryCache{data: make(map[uint64]memItem), ttl: ttl, now: time.Now}
|
||||
}
|
||||
|
||||
// Get 读取缓存的权限集。
|
||||
func (m *MemoryCache) Get(_ context.Context, adminID uint64) (roles, perms []string, ok bool) {
|
||||
m.mu.RLock()
|
||||
e, found := m.data[adminID]
|
||||
m.mu.RUnlock()
|
||||
if !found {
|
||||
return nil, nil, false
|
||||
}
|
||||
if m.ttl > 0 && m.now().After(e.expireAt) {
|
||||
m.mu.Lock()
|
||||
delete(m.data, adminID)
|
||||
m.mu.Unlock()
|
||||
return nil, nil, false
|
||||
}
|
||||
return e.roles, e.perms, true
|
||||
}
|
||||
|
||||
// Set 写入权限集。
|
||||
func (m *MemoryCache) Set(_ context.Context, adminID uint64, roles, perms []string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
e := memItem{roles: roles, perms: perms}
|
||||
if m.ttl > 0 {
|
||||
e.expireAt = m.now().Add(m.ttl)
|
||||
}
|
||||
m.data[adminID] = e
|
||||
}
|
||||
|
||||
// Invalidate 失效单个管理员缓存。
|
||||
func (m *MemoryCache) Invalidate(_ context.Context, adminID uint64) {
|
||||
m.mu.Lock()
|
||||
delete(m.data, adminID)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// InvalidateAll 清空全部缓存。
|
||||
func (m *MemoryCache) InvalidateAll(_ context.Context) {
|
||||
m.mu.Lock()
|
||||
m.data = make(map[uint64]memItem)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// NewRedisCache 预留:生产环境接入 Redis 时实现此构造,
|
||||
// 满足 PermissionCache 接口即可在 NewProvider 处替换 MemoryCache。
|
||||
// 当前未启用(项目暂未引入 go-redis 依赖),保留接口以明确扩展点。
|
||||
func NewRedisCache(_ interface{}) PermissionCache {
|
||||
// TODO: 接入 go-redis 后实现 Get/Set/Invalidate/InvalidateAll。
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package rbac
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"portal-mini-go/internal/model/admin"
|
||||
"portal-mini-go/internal/pkg/errors"
|
||||
adminrepo "portal-mini-go/internal/repository/admin"
|
||||
)
|
||||
|
||||
// ---- fakes ----
|
||||
|
||||
type fakeCache struct{ m map[uint64]memPair }
|
||||
|
||||
type memPair struct{ roles, perms []string }
|
||||
|
||||
func newFakeCache() *fakeCache { return &fakeCache{m: map[uint64]memPair{}} }
|
||||
|
||||
func (f *fakeCache) Get(_ context.Context, id uint64) ([]string, []string, bool) {
|
||||
p, ok := f.m[id]
|
||||
return p.roles, p.perms, ok
|
||||
}
|
||||
func (f *fakeCache) Set(_ context.Context, id uint64, roles, perms []string) {
|
||||
f.m[id] = memPair{roles: roles, perms: perms}
|
||||
}
|
||||
func (f *fakeCache) Invalidate(_ context.Context, id uint64) { delete(f.m, id) }
|
||||
func (f *fakeCache) InvalidateAll(_ context.Context) { f.m = map[uint64]memPair{} }
|
||||
|
||||
type fakeUserRepo struct {
|
||||
roles map[uint64][]string
|
||||
perms map[uint64][]string
|
||||
count int64
|
||||
created *admin.User
|
||||
setRoles map[uint64][]uint64
|
||||
}
|
||||
|
||||
func (r *fakeUserRepo) GetByUsername(context.Context, string) (*admin.User, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
func (r *fakeUserRepo) GetByID(context.Context, uint64) (*admin.User, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
func (r *fakeUserRepo) Create(_ context.Context, u *admin.User) error { r.created = u; return nil }
|
||||
func (r *fakeUserRepo) Count(context.Context) (int64, error) { return r.count, nil }
|
||||
func (r *fakeUserRepo) List(context.Context, adminrepo.UserListFilter) ([]admin.User, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
func (r *fakeUserRepo) Update(context.Context, uint64, map[string]interface{}) error { return nil }
|
||||
func (r *fakeUserRepo) Delete(context.Context, uint64) error { return nil }
|
||||
func (r *fakeUserRepo) SetRoles(_ context.Context, id uint64, roleIDs []uint64) error {
|
||||
if r.setRoles == nil {
|
||||
r.setRoles = map[uint64][]uint64{}
|
||||
}
|
||||
r.setRoles[id] = roleIDs
|
||||
return nil
|
||||
}
|
||||
func (r *fakeUserRepo) RoleCodesOf(_ context.Context, id uint64) ([]string, error) {
|
||||
return r.roles[id], nil
|
||||
}
|
||||
func (r *fakeUserRepo) PermissionCodesOf(_ context.Context, id uint64) ([]string, error) {
|
||||
return r.perms[id], nil
|
||||
}
|
||||
|
||||
type fakeRoleRepo struct {
|
||||
byCode map[string]*admin.Role
|
||||
created *admin.Role
|
||||
}
|
||||
|
||||
func newFakeRoleRepo() *fakeRoleRepo { return &fakeRoleRepo{byCode: map[string]*admin.Role{}} }
|
||||
|
||||
func (r *fakeRoleRepo) GetByID(context.Context, uint64) (*admin.Role, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
func (r *fakeRoleRepo) GetByCode(_ context.Context, code string) (*admin.Role, error) {
|
||||
if m, ok := r.byCode[code]; ok {
|
||||
return m, nil
|
||||
}
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
func (r *fakeRoleRepo) Create(_ context.Context, m *admin.Role) error {
|
||||
r.created = m
|
||||
r.byCode[m.Code] = m
|
||||
return nil
|
||||
}
|
||||
func (r *fakeRoleRepo) List(context.Context, adminrepo.RoleListFilter) ([]admin.Role, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
func (r *fakeRoleRepo) Update(context.Context, uint64, map[string]interface{}) error { return nil }
|
||||
func (r *fakeRoleRepo) Delete(context.Context, uint64) error { return nil }
|
||||
func (r *fakeRoleRepo) SetPermissions(context.Context, uint64, []uint64) error { return nil }
|
||||
func (r *fakeRoleRepo) PermissionCodesOf(context.Context, uint64) ([]string, error) { return nil, nil }
|
||||
|
||||
type fakePermRepo struct {
|
||||
menus []admin.Permission
|
||||
codes []string
|
||||
}
|
||||
|
||||
func (r *fakePermRepo) GetByID(context.Context, uint64) (*admin.Permission, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
func (r *fakePermRepo) Create(context.Context, *admin.Permission) error { return nil }
|
||||
func (r *fakePermRepo) List(context.Context, adminrepo.PermListFilter) ([]admin.Permission, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
func (r *fakePermRepo) Update(context.Context, uint64, map[string]interface{}) error { return nil }
|
||||
func (r *fakePermRepo) Delete(context.Context, uint64) error { return nil }
|
||||
func (r *fakePermRepo) AllMenuPermissions(context.Context) ([]admin.Permission, error) {
|
||||
return r.menus, nil
|
||||
}
|
||||
func (r *fakePermRepo) AllCodes(context.Context) ([]string, error) { return r.codes, nil }
|
||||
|
||||
// ---- tests ----
|
||||
|
||||
func TestProvider_Load_Normal(t *testing.T) {
|
||||
ur := &fakeUserRepo{
|
||||
roles: map[uint64][]string{1: {"editor"}},
|
||||
perms: map[uint64][]string{1: {"user:view"}},
|
||||
}
|
||||
pr := &fakePermRepo{codes: []string{"user:view", "user:manage", "mini:view"}}
|
||||
p := NewProvider(ur, newFakeRoleRepo(), pr, newFakeCache())
|
||||
|
||||
roles, perms, err := p.Load(context.Background(), 1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"editor"}, roles)
|
||||
require.Equal(t, []string{"user:view"}, perms)
|
||||
}
|
||||
|
||||
func TestProvider_Load_SuperAdmin(t *testing.T) {
|
||||
ur := &fakeUserRepo{
|
||||
roles: map[uint64][]string{1: {admin.SuperAdminRoleCode}},
|
||||
perms: map[uint64][]string{1: {}},
|
||||
}
|
||||
pr := &fakePermRepo{codes: []string{"user:view", "user:manage"}}
|
||||
p := NewProvider(ur, newFakeRoleRepo(), pr, newFakeCache())
|
||||
|
||||
_, perms, err := p.Load(context.Background(), 1)
|
||||
require.NoError(t, err)
|
||||
// 超级管理员短路:权限集为全部权限 code(忽略聚合结果)。
|
||||
require.ElementsMatch(t, []string{"user:view", "user:manage"}, perms)
|
||||
}
|
||||
|
||||
func TestProvider_MenuTree_Normal(t *testing.T) {
|
||||
menus := []admin.Permission{
|
||||
{ID: 1, Code: "dash", Type: admin.PermTypeMenu, ParentID: 0, Sort: 1},
|
||||
{ID: 2, Code: "user", Type: admin.PermTypeMenu, ParentID: 1, Sort: 1},
|
||||
{ID: 3, Code: "user:view", Type: admin.PermTypeMenu, ParentID: 2, Sort: 1},
|
||||
{ID: 4, Code: "sys", Type: admin.PermTypeMenu, ParentID: 0, Sort: 2}, // 无权限且无后代
|
||||
}
|
||||
ur := &fakeUserRepo{
|
||||
roles: map[uint64][]string{1: {"editor"}},
|
||||
perms: map[uint64][]string{1: {"user:view"}},
|
||||
}
|
||||
pr := &fakePermRepo{menus: menus, codes: nil}
|
||||
p := NewProvider(ur, newFakeRoleRepo(), pr, newFakeCache())
|
||||
|
||||
tree, err := p.MenuTree(context.Background(), 1)
|
||||
require.NoError(t, err)
|
||||
// 仅保留 dash -> user -> user:view;sys 被过滤。
|
||||
require.Len(t, tree, 1)
|
||||
require.Equal(t, uint64(1), tree[0].ID)
|
||||
require.Len(t, tree[0].Children, 1)
|
||||
require.Equal(t, uint64(2), tree[0].Children[0].ID)
|
||||
require.Len(t, tree[0].Children[0].Children, 1)
|
||||
require.Equal(t, uint64(3), tree[0].Children[0].Children[0].ID)
|
||||
}
|
||||
|
||||
func TestProvider_MenuTree_SuperAdmin(t *testing.T) {
|
||||
menus := []admin.Permission{
|
||||
{ID: 1, Code: "dash", Type: admin.PermTypeMenu, ParentID: 0},
|
||||
{ID: 4, Code: "sys", Type: admin.PermTypeMenu, ParentID: 0},
|
||||
}
|
||||
ur := &fakeUserRepo{roles: map[uint64][]string{1: {admin.SuperAdminRoleCode}}}
|
||||
pr := &fakePermRepo{menus: menus}
|
||||
p := NewProvider(ur, newFakeRoleRepo(), pr, newFakeCache())
|
||||
|
||||
tree, err := p.MenuTree(context.Background(), 1)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, tree, 2) // 超级管理员返回完整树
|
||||
}
|
||||
|
||||
func TestProvider_Seed(t *testing.T) {
|
||||
ur := &fakeUserRepo{count: 0}
|
||||
rr := newFakeRoleRepo()
|
||||
pr := &fakePermRepo{codes: nil}
|
||||
p := NewProvider(ur, rr, pr, newFakeCache())
|
||||
|
||||
require.NoError(t, p.Seed(context.Background(), "admin", "Admin@123456"))
|
||||
require.NotNil(t, rr.created)
|
||||
require.Equal(t, admin.SuperAdminRoleCode, rr.created.Code)
|
||||
require.NotNil(t, ur.created)
|
||||
require.Equal(t, "admin", ur.created.Username)
|
||||
require.NotEmpty(t, ur.created.PasswordHash)
|
||||
require.Equal(t, []uint64{rr.created.ID}, ur.setRoles[ur.created.ID])
|
||||
|
||||
// 已有管理员时不再创建。
|
||||
ur2 := &fakeUserRepo{count: 3}
|
||||
p2 := NewProvider(ur2, newFakeRoleRepo(), pr, newFakeCache())
|
||||
require.NoError(t, p2.Seed(context.Background(), "admin", "x"))
|
||||
require.Nil(t, ur2.created)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package rbac
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"portal-mini-go/internal/model/admin"
|
||||
"portal-mini-go/internal/pkg/errors"
|
||||
adminrepo "portal-mini-go/internal/repository/admin"
|
||||
)
|
||||
|
||||
// RoleService 角色业务:CRUD 与权限分配。
|
||||
type RoleService struct {
|
||||
repo RoleRepository
|
||||
invalidator CacheInvalidator
|
||||
}
|
||||
|
||||
func NewRoleService(repo RoleRepository, invalidator CacheInvalidator) *RoleService {
|
||||
return &RoleService{repo: repo, invalidator: invalidator}
|
||||
}
|
||||
|
||||
// CreateRoleReq 新建角色请求。
|
||||
type CreateRoleReq struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Status int8 `json:"status"`
|
||||
}
|
||||
|
||||
// Create 新建角色,code 唯一。
|
||||
func (s *RoleService) Create(ctx context.Context, req CreateRoleReq) (*admin.Role, error) {
|
||||
if req.Status == 0 {
|
||||
req.Status = admin.UserStatusEnabled
|
||||
}
|
||||
if req.Status != admin.UserStatusEnabled && req.Status != admin.UserStatusDisabled {
|
||||
return nil, errors.New(40000, "非法的角色状态")
|
||||
}
|
||||
if _, err := s.repo.GetByCode(ctx, req.Code); err == nil {
|
||||
return nil, errors.New(40900, "角色 code 已存在")
|
||||
} else if !errors.Is(err, errors.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
r := &admin.Role{Code: req.Code, Name: req.Name, Description: req.Description, Status: req.Status}
|
||||
if err := s.repo.Create(ctx, r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// RoleListReq 角色列表过滤条件。
|
||||
type RoleListReq struct {
|
||||
Keyword string
|
||||
Status int8
|
||||
Page int
|
||||
Size int
|
||||
}
|
||||
|
||||
// List 分页查询角色。
|
||||
func (s *RoleService) List(ctx context.Context, req RoleListReq) ([]admin.Role, int64, error) {
|
||||
page, size := req.Page, req.Size
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 {
|
||||
size = 20
|
||||
}
|
||||
return s.repo.List(ctx, adminrepo.RoleListFilter{
|
||||
Keyword: req.Keyword,
|
||||
Status: req.Status,
|
||||
Page: page,
|
||||
Size: size,
|
||||
})
|
||||
}
|
||||
|
||||
// GetByID 按 ID 查询角色。
|
||||
func (s *RoleService) GetByID(ctx context.Context, id uint64) (*admin.Role, error) {
|
||||
return s.repo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// UpdateRoleReq 编辑角色请求;指针区分“未提供”与“置空”。
|
||||
type UpdateRoleReq struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Status *int8 `json:"status"`
|
||||
}
|
||||
|
||||
// Update 局部更新角色。
|
||||
func (s *RoleService) Update(ctx context.Context, id uint64, req UpdateRoleReq) (*admin.Role, error) {
|
||||
cols := map[string]interface{}{}
|
||||
if req.Name != nil {
|
||||
cols["name"] = *req.Name
|
||||
}
|
||||
if req.Description != nil {
|
||||
cols["description"] = *req.Description
|
||||
}
|
||||
if req.Status != nil {
|
||||
if *req.Status != admin.UserStatusEnabled && *req.Status != admin.UserStatusDisabled {
|
||||
return nil, errors.New(40000, "非法的角色状态")
|
||||
}
|
||||
cols["status"] = *req.Status
|
||||
}
|
||||
if len(cols) == 0 {
|
||||
return s.repo.GetByID(ctx, id)
|
||||
}
|
||||
if err := s.repo.Update(ctx, id, cols); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.invalidator.InvalidateAll(ctx)
|
||||
return s.repo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// Delete 删除角色(清理关联并失效全部缓存)。
|
||||
func (s *RoleService) Delete(ctx context.Context, id uint64) error {
|
||||
if err := s.repo.Delete(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
s.invalidator.InvalidateAll(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetPermissions 为角色分配权限(全量替换)。
|
||||
func (s *RoleService) SetPermissions(ctx context.Context, id uint64, permIDs []uint64) error {
|
||||
if err := s.repo.SetPermissions(ctx, id, permIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
s.invalidator.InvalidateAll(ctx)
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user