Files
junhong_cmp_fiber/internal/store/postgres/account_store.go
huang eaa70ac255 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>
2025-11-18 16:44:06 +08:00

184 lines
5.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}