Files
junhong_cmp_fiber/specs/004-rbac-data-permission/research.md
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

15 KiB
Raw Blame History

Research: RBAC 表结构与 GORM 数据权限过滤

Feature: 004-rbac-data-permission Date: 2025-11-18 Researcher: AI Assistant

研究目标

本功能需要实现三个核心技术点:

  1. GORM 递归查询:使用 PostgreSQL WITH RECURSIVE 查询用户的所有下级 ID
  2. GORM Scopes 数据权限过滤:自动为查询添加 WHERE owner_id IN (...) AND shop_id = ? 条件
  3. Redis 缓存优化缓存递归查询结果30 分钟过期,支持主动清除
  4. 主函数重构和路由模块化:将 main 函数拆分为多个初始化函数,路由按模块拆分

1. PostgreSQL WITH RECURSIVE 递归查询

决策 (Decision)

使用 GORM 原生 SQL 执行 + WITH RECURSIVE CTE公共表表达式 实现递归查询用户的所有下级 ID。

实现方案

// internal/store/postgres/account_store.go

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 WHERE id != ?
    `

    var ids []uint
    if err := s.db.WithContext(ctx).Raw(query, accountID, accountID).Scan(&ids).Error; err != nil {
        return nil, fmt.Errorf("递归查询下级 ID 失败: %w", err)
    }

    // 包含当前用户自己的 ID
    ids = append([]uint{accountID}, ids...)

    // 3. 写入 Redis 缓存30 分钟过期)
    data, _ := sonic.Marshal(ids)
    s.redis.Set(ctx, cacheKey, data, 30*time.Minute)

    return ids, nil
}

理由 (Rationale)

  1. WITH RECURSIVE 是 PostgreSQL 标准:高效处理层级数据,性能优于多次查询
  2. 包含软删除账号:递归查询不过滤 deleted_at,确保软删除账号的数据对上级仍可见
  3. Redis 缓存优化:递归查询成本较高(多层 JOIN缓存 30 分钟显著降低数据库负载
  4. GORM Raw SQLGORM 不原生支持 WITH RECURSIVE使用 Raw 查询直接执行 SQL

替代方案 (Alternatives Considered)

  • 方案 A使用 GORM 预加载Preload递归查询

    • 拒绝原因GORM Preload 只支持一层关联,无法递归多层
    • 违反宪章原则 IX禁止使用 GORM 关联标签
  • 方案 B使用闭包表Closure Table存储所有上下级关系

    • 拒绝原因:需要额外的关联表和触发器维护,增加复杂度
    • 违反宪章原则 IX禁止使用数据库触发器
  • 方案 C在代码中循环查询每一层

    • 拒绝原因5 层层级需要 5 次查询,性能远低于单次 WITH RECURSIVE
    • 不符合性能要求(< 50ms

2. GORM Scopes 数据权限过滤

决策 (Decision)

使用 GORM Scopes + Context 传递用户信息 实现自动数据权限过滤。

实现方案

// internal/store/postgres/scopes.go

func DataPermissionScope(accountStore *AccountStore) func(db *gorm.DB) *gorm.DB {
    return func(db *gorm.DB) *gorm.DB {
        ctx := db.Statement.Context
        if ctx == nil {
            return db
        }

        // 1. 从 context 提取用户 ID 和 shop_id
        userID := middleware.GetUserIDFromContext(ctx)
        shopID := middleware.GetShopIDFromContext(ctx)
        if userID == 0 {
            return db // 无用户信息,不过滤(可能是系统任务)
        }

        // 2. 检查是否为 root 用户
        if middleware.IsRootUser(ctx) {
            return db // root 用户跳过过滤
        }

        // 3. 获取用户的所有下级 ID含缓存
        subordinateIDs, err := accountStore.GetSubordinateIDs(ctx, userID)
        if err != nil {
            // 查询失败时,只返回自己的数据(降级策略)
            subordinateIDs = []uint{userID}
        }

        // 4. 应用双重过滤owner_id IN (...) AND shop_id = ?
        return db.Where("owner_id IN ? AND shop_id = ?", subordinateIDs, shopID)
    }
}

使用示例

// internal/store/postgres/user_store.go

func (s *UserStore) List(ctx context.Context, opts *store.QueryOptions) ([]*model.User, error) {
    query := s.db.WithContext(ctx)

    // 应用数据权限过滤 Scope
    if !opts.WithoutDataFilter {
        query = query.Scopes(DataPermissionScope(s.accountStore))
    }

    var users []*model.User
    if err := query.Find(&users).Error; err != nil {
        return nil, err
    }
    return users, nil
}

理由 (Rationale)

  1. GORM Scopes 是官方推荐模式:复用查询逻辑,自动应用到所有查询
  2. Context 传递用户信息:符合 Go 惯用法线程安全Fiber 请求级隔离
  3. 双重过滤保证安全owner_id数据归属+ shop_id店铺隔离
  4. 降级策略:查询下级 ID 失败时返回自己的数据,避免数据泄露

替代方案 (Alternatives Considered)

  • 方案 A在每个 Store 方法中手动添加 WHERE 条件

    • 拒绝原因:代码重复,容易遗漏,维护成本高
  • 方案 B使用 GORM Callbacks钩子函数

    • 拒绝原因全局生效无法灵活跳过WithoutDataFilter
  • 方案 C使用数据库视图View限制数据访问

    • 拒绝原因:无法动态适配不同用户,需要为每个用户创建视图
    • 违反宪章原则:业务逻辑应在代码层控制

3. Redis 缓存策略

决策 (Decision)

使用 Redis String 类型 + JSON 序列化 + 30 分钟过期 + 主动清除 缓存下级 ID 列表。

实现方案

3.1 缓存 Key 设计

// pkg/constants/redis.go

func RedisAccountSubordinatesKey(accountID uint) string {
    return fmt.Sprintf("account:subordinates:%d", accountID)
}

3.2 缓存写入

// 在 GetSubordinateIDs 中写入缓存(见上文)
data, _ := sonic.Marshal(ids)
s.redis.Set(ctx, cacheKey, data, 30*time.Minute)

3.3 缓存清除

// internal/store/postgres/account_store.go

// 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
}

3.4 触发缓存清除的时机

// internal/service/account/service.go

func (s *Service) Create(ctx context.Context, req *CreateAccountRequest) (*model.Account, error) {
    // ... 创建账号逻辑 ...

    // 清除父账号的下级 ID 缓存(新增了下级)
    if account.ParentID != nil {
        _ = s.store.ClearSubordinatesCacheForParents(ctx, *account.ParentID)
    }

    return account, nil
}

func (s *Service) Delete(ctx context.Context, id uint) error {
    // ... 软删除逻辑 ...

    // 清除该账号和所有上级的下级 ID 缓存
    _ = s.store.ClearSubordinatesCacheForParents(ctx, id)

    return nil
}

理由 (Rationale)

  1. 30 分钟过期平衡性能和一致性账号层级关系变更频率低30 分钟足够
  2. 主动清除保证一致性:账号创建/删除时立即清除缓存,避免脏数据
  3. sonic JSON 序列化:符合宪章要求,性能优于标准库 encoding/json
  4. 递归清除上级缓存:子账号变更影响所有上级的下级列表

替代方案 (Alternatives Considered)

  • 方案 A使用 Redis Hash 存储账号 ID 为 field

    • 拒绝原因:查询时需要 HGETALL 再过滤,不如 String 类型直接反序列化
  • 方案 B永久缓存 + 事件驱动清除

    • 拒绝原因:增加复杂度(需要消息队列),且 Redis 内存压力大
  • 方案 C使用 Redis Set 存储下级 ID

    • 拒绝原因:需要多次 SADD 操作,不如单次 SET 高效

4. 主函数重构和路由模块化

决策 (Decision)

main() 函数拆分为 8 个独立的初始化函数,路由注册拆分到 internal/routes/ 目录下的独立模块文件。

实现方案

4.1 主函数重构

// cmd/api/main.go

func main() {
    // 编排初始化流程≤100 行)
    cfg := initConfig()
    logger := initLogger(cfg)
    db := initDatabase(cfg, logger)
    redis := initRedis(cfg, logger)
    queue := initQueue(cfg, logger, redis)
    services := initServices(db, redis, queue, logger)

    app := fiber.New(fiber.Config{/* ... */})
    initMiddleware(app, logger)
    initRoutes(app, services)

    startServer(app, cfg, logger)
}

func initConfig() *config.Config {
    // 加载配置文件
    return config.Load()
}

func initLogger(cfg *config.Config) *zap.Logger {
    // 初始化 Zap + Lumberjack
    return logger.New(cfg.Log)
}

func initDatabase(cfg *config.Config, logger *zap.Logger) *gorm.DB {
    // 连接 PostgreSQL
    return postgres.Connect(cfg.DB, logger)
}

func initRedis(cfg *config.Config, logger *zap.Logger) *redis.Client {
    // 连接 Redis
    return redis.NewClient(&redis.Options{/* ... */})
}

func initQueue(cfg *config.Config, logger *zap.Logger, rdb *redis.Client) *asynq.Client {
    // 初始化 Asynq
    return asynq.NewClient(asynq.RedisClientOpt{Addr: cfg.Redis.Addr})
}

func initServices(db *gorm.DB, rdb *redis.Client, queue *asynq.Client, logger *zap.Logger) *routes.Services {
    // 初始化所有 Service 和 Store
    return &routes.Services{
        Account: accountService,
        Role: roleService,
        // ...
    }
}

func initMiddleware(app *fiber.App, logger *zap.Logger) {
    // 注册全局中间件
    app.Use(middleware.Recover(logger))
    app.Use(requestid.New())
    app.Use(loggerMiddleware.Middleware())
    // ...
}

func initRoutes(app *fiber.App, services *routes.Services) {
    // 调用路由总入口
    routes.RegisterRoutes(app, services)
}

func startServer(app *fiber.App, cfg *config.Config, logger *zap.Logger) {
    // 启动服务器
    addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
    logger.Info("服务启动", zap.String("addr", addr))
    if err := app.Listen(addr); err != nil {
        logger.Fatal("服务启动失败", zap.Error(err))
    }
}

4.2 路由模块化

// internal/routes/routes.go

type Services struct {
    Account    *accountService.Service
    Role       *roleService.Service
    Permission *permissionService.Service
    User       *userService.Service
    Order      *orderService.Service
}

func RegisterRoutes(app *fiber.App, services *Services) {
    api := app.Group("/api/v1")

    // 注册各模块路由
    registerHealthRoutes(app)
    registerAccountRoutes(api, services.Account)
    registerRoleRoutes(api, services.Role)
    registerPermissionRoutes(api, services.Permission)
    registerUserRoutes(api, services.User)
    registerOrderRoutes(api, services.Order)
    registerTaskRoutes(api)
}
// internal/routes/account.go

func registerAccountRoutes(api fiber.Router, service *accountService.Service) {
    handler := accountHandler.New(service)

    accounts := api.Group("/accounts")
    accounts.Post("/", handler.Create)
    accounts.Get("/:id", handler.Get)
    accounts.Put("/:id", handler.Update)
    accounts.Delete("/:id", handler.Delete)
    accounts.Get("/", handler.List)

    // 账号-角色关联路由
    accounts.Post("/:id/roles", handler.AssignRoles)
    accounts.Get("/:id/roles", handler.GetRoles)
    accounts.Delete("/:account_id/roles/:role_id", handler.RemoveRole)
}

理由 (Rationale)

  1. 单一职责原则:每个初始化函数只负责一件事,易于测试和维护
  2. main 函数编排清晰:一眼看清整个启动流程,不陷入实现细节
  3. 路由模块化便于扩展:新增模块只需添加一个路由文件和注册调用
  4. 符合 Go 惯用法:简单直接,不引入复杂的 DI 框架

替代方案 (Alternatives Considered)

  • 方案 A使用 uber/fx 或 google/wire DI 框架

    • 拒绝原因:违反宪章原则 VI过度 DI 框架),增加学习成本
  • 方案 B保持 main 函数集中式

    • 拒绝原因:违反宪章原则 II函数复杂度 > 100 行)
  • 方案 C使用全局变量存储 Service

    • 拒绝原因:违反宪章原则(依赖注入通过结构体字段)

5. 密码哈希策略(安全性考虑)

决策 (Decision)

建议修改规格:将密码哈希从 MD5 改为 bcrypt

理由 (Rationale)

  1. MD5 已被密码学界废弃:易受彩虹表攻击,不适合密码存储
  2. bcrypt 是行业标准:内置盐值,自适应成本,抗暴力破解
  3. 符合宪章安全原则Constitution Principle II 要求避免安全漏洞

实现方案

// internal/service/account/service.go

import "golang.org/x/crypto/bcrypt"

func (s *Service) Create(ctx context.Context, req *CreateAccountRequest) (*model.Account, error) {
    // 使用 bcrypt 哈希密码
    hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
    if err != nil {
        return nil, fmt.Errorf("密码哈希失败: %w", err)
    }

    account := &model.Account{
        Username: req.Username,
        Password: string(hashedPassword),
        // ...
    }

    // ...
}

func (s *Service) ValidatePassword(plainPassword, hashedPassword string) bool {
    err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(plainPassword))
    return err == nil
}

替代方案

  • 方案 A保留 MD5

    • ⚠️ 如果是历史遗留系统兼容需求,需在规格中明确说明
    • ⚠️ 应该在 Clarifications 中记录安全风险
  • 方案 B使用 argon2

    • 更安全但配置复杂bcrypt 已足够

总结

技术点 决策 核心依赖
递归查询 PostgreSQL WITH RECURSIVE + GORM Raw database/sql, gorm.io/gorm
数据权限过滤 GORM Scopes + Context 传递 gorm.io/gorm, context
缓存策略 Redis String + sonic JSON + 30min 过期 github.com/redis/go-redis/v9, github.com/bytedance/sonic
主函数重构 8 个初始化函数 + 编排模式 标准库
路由模块化 internal/routes/ 目录分文件注册 github.com/gofiber/fiber/v2
密码哈希 bcrypt建议替换 MD5 golang.org/x/crypto/bcrypt

下一步:进入 Phase 1生成 data-model.md 和 API contracts。