feat: 实现 RBAC 权限系统和数据权限控制 (004-rbac-data-permission)

主要功能:
- 实现完整的 RBAC 权限系统(账号、角色、权限的多对多关联)
- 基于 owner_id + shop_id 的自动数据权限过滤
- 使用 PostgreSQL WITH RECURSIVE 查询下级账号
- Redis 缓存优化下级账号查询性能(30分钟过期)
- 支持多租户数据隔离和层级权限管理

技术实现:
- 新增 Account、Role、Permission 模型及关联关系表
- 实现 GORM Scopes 自动应用数据权限过滤
- 添加数据库迁移脚本(000002_rbac_data_permission、000003_add_owner_id_shop_id)
- 完善错误码定义(1010-1027 为 RBAC 相关错误)
- 重构 main.go 采用函数拆分提高可读性

测试覆盖:
- 添加 Account、Role、Permission 的集成测试
- 添加数据权限过滤的单元测试和集成测试
- 添加下级账号查询和缓存的单元测试
- 添加 API 回归测试确保向后兼容

文档更新:
- 更新 README.md 添加 RBAC 功能说明
- 更新 CLAUDE.md 添加技术栈和开发原则
- 添加 docs/004-rbac-data-permission/ 功能总结和使用指南

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-18 16:44:06 +08:00
parent e8eb5766cb
commit eaa70ac255
86 changed files with 15395 additions and 245 deletions

View File

@@ -0,0 +1,78 @@
package postgres
import (
"context"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
)
// AccountRoleStore 账号-角色关联数据访问层
type AccountRoleStore struct {
db *gorm.DB
}
// NewAccountRoleStore 创建账号-角色关联 Store
func NewAccountRoleStore(db *gorm.DB) *AccountRoleStore {
return &AccountRoleStore{db: db}
}
// Create 创建账号-角色关联
func (s *AccountRoleStore) Create(ctx context.Context, ar *model.AccountRole) error {
return s.db.WithContext(ctx).Create(ar).Error
}
// BatchCreate 批量创建账号-角色关联
func (s *AccountRoleStore) BatchCreate(ctx context.Context, ars []*model.AccountRole) error {
return s.db.WithContext(ctx).Create(&ars).Error
}
// Delete 软删除账号-角色关联
func (s *AccountRoleStore) Delete(ctx context.Context, accountID, roleID uint) error {
return s.db.WithContext(ctx).
Where("account_id = ? AND role_id = ?", accountID, roleID).
Delete(&model.AccountRole{}).Error
}
// DeleteByAccountID 删除账号的所有角色关联
func (s *AccountRoleStore) DeleteByAccountID(ctx context.Context, accountID uint) error {
return s.db.WithContext(ctx).
Where("account_id = ?", accountID).
Delete(&model.AccountRole{}).Error
}
// GetByAccountID 获取账号的所有角色关联
func (s *AccountRoleStore) GetByAccountID(ctx context.Context, accountID uint) ([]*model.AccountRole, error) {
var ars []*model.AccountRole
if err := s.db.WithContext(ctx).
Where("account_id = ?", accountID).
Find(&ars).Error; err != nil {
return nil, err
}
return ars, nil
}
// GetRoleIDsByAccountID 获取账号的所有角色 ID
func (s *AccountRoleStore) GetRoleIDsByAccountID(ctx context.Context, accountID uint) ([]uint, error) {
var roleIDs []uint
if err := s.db.WithContext(ctx).
Model(&model.AccountRole{}).
Where("account_id = ?", accountID).
Pluck("role_id", &roleIDs).Error; err != nil {
return nil, err
}
return roleIDs, nil
}
// Exists 检查账号-角色关联是否存在
func (s *AccountRoleStore) Exists(ctx context.Context, accountID, roleID uint) (bool, error) {
var count int64
if err := s.db.WithContext(ctx).
Model(&model.AccountRole{}).
Where("account_id = ? AND role_id = ?", accountID, roleID).
Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}

View File

@@ -0,0 +1,183 @@
package postgres
import (
"context"
"fmt"
"time"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/bytedance/sonic"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// AccountStore 账号数据访问层
type AccountStore struct {
db *gorm.DB
redis *redis.Client
}
// NewAccountStore 创建账号 Store
func NewAccountStore(db *gorm.DB, redis *redis.Client) *AccountStore {
return &AccountStore{
db: db,
redis: redis,
}
}
// Create 创建账号
func (s *AccountStore) Create(ctx context.Context, account *model.Account) error {
return s.db.WithContext(ctx).Create(account).Error
}
// GetByID 根据 ID 获取账号
func (s *AccountStore) GetByID(ctx context.Context, id uint) (*model.Account, error) {
var account model.Account
if err := s.db.WithContext(ctx).First(&account, id).Error; err != nil {
return nil, err
}
return &account, nil
}
// GetByUsername 根据用户名获取账号
func (s *AccountStore) GetByUsername(ctx context.Context, username string) (*model.Account, error) {
var account model.Account
if err := s.db.WithContext(ctx).Where("username = ?", username).First(&account).Error; err != nil {
return nil, err
}
return &account, nil
}
// GetByPhone 根据手机号获取账号
func (s *AccountStore) GetByPhone(ctx context.Context, phone string) (*model.Account, error) {
var account model.Account
if err := s.db.WithContext(ctx).Where("phone = ?", phone).First(&account).Error; err != nil {
return nil, err
}
return &account, nil
}
// Update 更新账号
func (s *AccountStore) Update(ctx context.Context, account *model.Account) error {
return s.db.WithContext(ctx).Save(account).Error
}
// Delete 软删除账号
func (s *AccountStore) Delete(ctx context.Context, id uint) error {
return s.db.WithContext(ctx).Delete(&model.Account{}, id).Error
}
// List 查询账号列表
func (s *AccountStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]interface{}) ([]*model.Account, int64, error) {
var accounts []*model.Account
var total int64
query := s.db.WithContext(ctx).Model(&model.Account{})
// 应用过滤条件
if username, ok := filters["username"].(string); ok && username != "" {
query = query.Where("username LIKE ?", "%"+username+"%")
}
if phone, ok := filters["phone"].(string); ok && phone != "" {
query = query.Where("phone LIKE ?", "%"+phone+"%")
}
if userType, ok := filters["user_type"].(int); ok {
query = query.Where("user_type = ?", userType)
}
if status, ok := filters["status"].(int); ok {
query = query.Where("status = ?", status)
}
// 计算总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// 分页
if opts == nil {
opts = store.DefaultQueryOptions()
}
offset := (opts.Page - 1) * opts.PageSize
query = query.Offset(offset).Limit(opts.PageSize)
// 排序
if opts.OrderBy != "" {
query = query.Order(opts.OrderBy)
}
// 执行查询
if err := query.Find(&accounts).Error; err != nil {
return nil, 0, err
}
return accounts, total, nil
}
// GetSubordinateIDs 获取用户的所有下级 ID包含自己
// 使用 Redis 缓存优化性能,缓存 30 分钟
func (s *AccountStore) GetSubordinateIDs(ctx context.Context, accountID uint) ([]uint, error) {
// 1. 尝试从 Redis 缓存读取
cacheKey := constants.RedisAccountSubordinatesKey(accountID)
cached, err := s.redis.Get(ctx, cacheKey).Result()
if err == nil {
var ids []uint
if err := sonic.Unmarshal([]byte(cached), &ids); err == nil {
return ids, nil
}
}
// 2. 缓存未命中,执行递归查询
query := `
WITH RECURSIVE subordinates AS (
-- 基础查询:选择当前账号
SELECT id FROM tb_account WHERE id = ? AND deleted_at IS NULL
UNION ALL
-- 递归查询:选择所有下级(包括软删除的账号,因为它们的数据仍需对上级可见)
SELECT a.id
FROM tb_account a
INNER JOIN subordinates s ON a.parent_id = s.id
)
SELECT id FROM subordinates
`
var ids []uint
if err := s.db.WithContext(ctx).Raw(query, accountID).Scan(&ids).Error; err != nil {
return nil, fmt.Errorf("递归查询下级 ID 失败: %w", err)
}
// 3. 写入 Redis 缓存30 分钟过期)
data, _ := sonic.Marshal(ids)
s.redis.Set(ctx, cacheKey, data, 30*time.Minute)
return ids, nil
}
// ClearSubordinatesCache 清除指定账号的下级 ID 缓存
func (s *AccountStore) ClearSubordinatesCache(ctx context.Context, accountID uint) error {
cacheKey := constants.RedisAccountSubordinatesKey(accountID)
return s.redis.Del(ctx, cacheKey).Err()
}
// ClearSubordinatesCacheForParents 递归清除所有上级账号的缓存
func (s *AccountStore) ClearSubordinatesCacheForParents(ctx context.Context, accountID uint) error {
// 查询当前账号
var account model.Account
if err := s.db.WithContext(ctx).First(&account, accountID).Error; err != nil {
return err
}
// 清除当前账号的缓存
if err := s.ClearSubordinatesCache(ctx, accountID); err != nil {
return err
}
// 如果有上级,递归清除上级的缓存
if account.ParentID != nil && *account.ParentID != 0 {
return s.ClearSubordinatesCacheForParents(ctx, *account.ParentID)
}
return nil
}

View File

@@ -0,0 +1,122 @@
package postgres
import (
"context"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store"
)
// PermissionStore 权限数据访问层
type PermissionStore struct {
db *gorm.DB
}
// NewPermissionStore 创建权限 Store
func NewPermissionStore(db *gorm.DB) *PermissionStore {
return &PermissionStore{db: db}
}
// Create 创建权限
func (s *PermissionStore) Create(ctx context.Context, permission *model.Permission) error {
return s.db.WithContext(ctx).Create(permission).Error
}
// GetByID 根据 ID 获取权限
func (s *PermissionStore) GetByID(ctx context.Context, id uint) (*model.Permission, error) {
var permission model.Permission
if err := s.db.WithContext(ctx).First(&permission, id).Error; err != nil {
return nil, err
}
return &permission, nil
}
// GetByCode 根据权限编码获取权限
func (s *PermissionStore) GetByCode(ctx context.Context, code string) (*model.Permission, error) {
var permission model.Permission
if err := s.db.WithContext(ctx).Where("perm_code = ?", code).First(&permission).Error; err != nil {
return nil, err
}
return &permission, nil
}
// Update 更新权限
func (s *PermissionStore) Update(ctx context.Context, permission *model.Permission) error {
return s.db.WithContext(ctx).Save(permission).Error
}
// Delete 软删除权限
func (s *PermissionStore) Delete(ctx context.Context, id uint) error {
return s.db.WithContext(ctx).Delete(&model.Permission{}, id).Error
}
// List 查询权限列表
func (s *PermissionStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]interface{}) ([]*model.Permission, int64, error) {
var permissions []*model.Permission
var total int64
query := s.db.WithContext(ctx).Model(&model.Permission{})
// 应用过滤条件
if name, ok := filters["perm_name"].(string); ok && name != "" {
query = query.Where("perm_name LIKE ?", "%"+name+"%")
}
if code, ok := filters["perm_code"].(string); ok && code != "" {
query = query.Where("perm_code LIKE ?", "%"+code+"%")
}
if permType, ok := filters["perm_type"].(int); ok {
query = query.Where("perm_type = ?", permType)
}
if parentID, ok := filters["parent_id"].(uint); ok {
query = query.Where("parent_id = ?", parentID)
}
if status, ok := filters["status"].(int); ok {
query = query.Where("status = ?", status)
}
// 计算总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// 分页
if opts == nil {
opts = store.DefaultQueryOptions()
}
offset := (opts.Page - 1) * opts.PageSize
query = query.Offset(offset).Limit(opts.PageSize)
// 排序
if opts.OrderBy != "" {
query = query.Order(opts.OrderBy)
} else {
query = query.Order("sort ASC, id ASC")
}
// 执行查询
if err := query.Find(&permissions).Error; err != nil {
return nil, 0, err
}
return permissions, total, nil
}
// GetByIDs 根据 ID 列表获取权限
func (s *PermissionStore) GetByIDs(ctx context.Context, ids []uint) ([]*model.Permission, error) {
var permissions []*model.Permission
if err := s.db.WithContext(ctx).Where("id IN ?", ids).Find(&permissions).Error; err != nil {
return nil, err
}
return permissions, nil
}
// GetAll 获取所有权限(用于构建权限树)
func (s *PermissionStore) GetAll(ctx context.Context) ([]*model.Permission, error) {
var permissions []*model.Permission
if err := s.db.WithContext(ctx).Order("sort ASC, id ASC").Find(&permissions).Error; err != nil {
return nil, err
}
return permissions, nil
}

View File

@@ -0,0 +1,91 @@
package postgres
import (
"context"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
)
// RolePermissionStore 角色-权限关联数据访问层
type RolePermissionStore struct {
db *gorm.DB
}
// NewRolePermissionStore 创建角色-权限关联 Store
func NewRolePermissionStore(db *gorm.DB) *RolePermissionStore {
return &RolePermissionStore{db: db}
}
// Create 创建角色-权限关联
func (s *RolePermissionStore) Create(ctx context.Context, rp *model.RolePermission) error {
return s.db.WithContext(ctx).Create(rp).Error
}
// BatchCreate 批量创建角色-权限关联
func (s *RolePermissionStore) BatchCreate(ctx context.Context, rps []*model.RolePermission) error {
return s.db.WithContext(ctx).Create(&rps).Error
}
// Delete 软删除角色-权限关联
func (s *RolePermissionStore) Delete(ctx context.Context, roleID, permID uint) error {
return s.db.WithContext(ctx).
Where("role_id = ? AND perm_id = ?", roleID, permID).
Delete(&model.RolePermission{}).Error
}
// DeleteByRoleID 删除角色的所有权限关联
func (s *RolePermissionStore) DeleteByRoleID(ctx context.Context, roleID uint) error {
return s.db.WithContext(ctx).
Where("role_id = ?", roleID).
Delete(&model.RolePermission{}).Error
}
// GetByRoleID 获取角色的所有权限关联
func (s *RolePermissionStore) GetByRoleID(ctx context.Context, roleID uint) ([]*model.RolePermission, error) {
var rps []*model.RolePermission
if err := s.db.WithContext(ctx).
Where("role_id = ?", roleID).
Find(&rps).Error; err != nil {
return nil, err
}
return rps, nil
}
// GetPermIDsByRoleID 获取角色的所有权限 ID
func (s *RolePermissionStore) GetPermIDsByRoleID(ctx context.Context, roleID uint) ([]uint, error) {
var permIDs []uint
if err := s.db.WithContext(ctx).
Model(&model.RolePermission{}).
Where("role_id = ?", roleID).
Pluck("perm_id", &permIDs).Error; err != nil {
return nil, err
}
return permIDs, nil
}
// GetPermIDsByRoleIDs 获取多个角色的所有权限 ID
func (s *RolePermissionStore) GetPermIDsByRoleIDs(ctx context.Context, roleIDs []uint) ([]uint, error) {
var permIDs []uint
if err := s.db.WithContext(ctx).
Model(&model.RolePermission{}).
Where("role_id IN ?", roleIDs).
Distinct().
Pluck("perm_id", &permIDs).Error; err != nil {
return nil, err
}
return permIDs, nil
}
// Exists 检查角色-权限关联是否存在
func (s *RolePermissionStore) Exists(ctx context.Context, roleID, permID uint) (bool, error) {
var count int64
if err := s.db.WithContext(ctx).
Model(&model.RolePermission{}).
Where("role_id = ? AND perm_id = ?", roleID, permID).
Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}

View File

@@ -0,0 +1,105 @@
package postgres
import (
"context"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store"
)
// RoleStore 角色数据访问层
type RoleStore struct {
db *gorm.DB
}
// NewRoleStore 创建角色 Store
func NewRoleStore(db *gorm.DB) *RoleStore {
return &RoleStore{db: db}
}
// Create 创建角色
func (s *RoleStore) Create(ctx context.Context, role *model.Role) error {
return s.db.WithContext(ctx).Create(role).Error
}
// GetByID 根据 ID 获取角色
func (s *RoleStore) GetByID(ctx context.Context, id uint) (*model.Role, error) {
var role model.Role
if err := s.db.WithContext(ctx).First(&role, id).Error; err != nil {
return nil, err
}
return &role, nil
}
// GetByName 根据名称获取角色
func (s *RoleStore) GetByName(ctx context.Context, name string) (*model.Role, error) {
var role model.Role
if err := s.db.WithContext(ctx).Where("role_name = ?", name).First(&role).Error; err != nil {
return nil, err
}
return &role, nil
}
// Update 更新角色
func (s *RoleStore) Update(ctx context.Context, role *model.Role) error {
return s.db.WithContext(ctx).Save(role).Error
}
// Delete 软删除角色
func (s *RoleStore) Delete(ctx context.Context, id uint) error {
return s.db.WithContext(ctx).Delete(&model.Role{}, id).Error
}
// List 查询角色列表
func (s *RoleStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]interface{}) ([]*model.Role, int64, error) {
var roles []*model.Role
var total int64
query := s.db.WithContext(ctx).Model(&model.Role{})
// 应用过滤条件
if name, ok := filters["role_name"].(string); ok && name != "" {
query = query.Where("role_name LIKE ?", "%"+name+"%")
}
if roleType, ok := filters["role_type"].(int); ok {
query = query.Where("role_type = ?", roleType)
}
if status, ok := filters["status"].(int); ok {
query = query.Where("status = ?", status)
}
// 计算总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// 分页
if opts == nil {
opts = store.DefaultQueryOptions()
}
offset := (opts.Page - 1) * opts.PageSize
query = query.Offset(offset).Limit(opts.PageSize)
// 排序
if opts.OrderBy != "" {
query = query.Order(opts.OrderBy)
}
// 执行查询
if err := query.Find(&roles).Error; err != nil {
return nil, 0, err
}
return roles, total, nil
}
// GetByIDs 根据 ID 列表获取角色
func (s *RoleStore) GetByIDs(ctx context.Context, ids []uint) ([]*model.Role, error) {
var roles []*model.Role
if err := s.db.WithContext(ctx).Where("id IN ?", ids).Find(&roles).Error; err != nil {
return nil, err
}
return roles, nil
}

View File

@@ -0,0 +1,86 @@
package postgres
import (
"context"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"go.uber.org/zap"
"gorm.io/gorm"
)
// DataPermissionScope 数据权限过滤 Scope
// 根据 context 中的用户信息自动过滤数据
// - root 用户跳过过滤
// - 普通用户只能查看自己和下级的数据
// - 同时限制 shop_id 相同
func DataPermissionScope(ctx context.Context, accountStore *AccountStore) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
// 1. 检查是否为 root 用户root 用户跳过数据权限过滤
if middleware.IsRootUser(ctx) {
return db
}
// 2. 获取当前用户 ID
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
// 未登录用户返回空结果
logger.GetAppLogger().Warn("数据权限过滤:未获取到用户 ID")
return db.Where("1 = 0")
}
// 3. 获取当前用户的 shop_id
shopID := middleware.GetShopIDFromContext(ctx)
// 4. 获取当前用户及所有下级的 ID
subordinateIDs, err := accountStore.GetSubordinateIDs(ctx, userID)
if err != nil {
// 查询失败时,降级为只能看自己的数据
logger.GetAppLogger().Error("数据权限过滤:获取下级 ID 失败",
zap.Uint("user_id", userID),
zap.Error(err))
subordinateIDs = []uint{userID}
}
// 5. 应用数据权限过滤条件
// owner_id IN (用户自己及所有下级) AND shop_id = 当前用户 shop_id
if len(subordinateIDs) == 0 {
subordinateIDs = []uint{userID}
}
// 根据是否有 shop_id 过滤条件决定 SQL
if shopID != 0 {
return db.Where("owner_id IN ? AND shop_id = ?", subordinateIDs, shopID)
}
// 如果 shop_id 为 0只根据 owner_id 过滤
return db.Where("owner_id IN ?", subordinateIDs)
}
}
// WithoutDataPermission 跳过数据权限过滤的 Scope
// 用于需要查询所有数据的场景(如管理后台统计、系统任务等)
func WithoutDataPermission() func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
// 什么都不做,直接返回原 db
return db
}
}
// SoftDeleteScope 软删除过滤 ScopeGORM 默认已支持,此处作为示例)
// 只查询未软删除的记录
func SoftDeleteScope() func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Where("deleted_at IS NULL")
}
}
// StatusEnabledScope 状态启用过滤 Scope
// 只查询状态为启用的记录
func StatusEnabledScope() func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Where("status = ?", constants.StatusEnabled)
}
}