# 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。 ### 实现方案 ```go // 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 SQL**:GORM 不原生支持 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 传递用户信息** 实现自动数据权限过滤。 ### 实现方案 ```go // 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) } } ``` ### 使用示例 ```go // 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 设计 ```go // pkg/constants/redis.go func RedisAccountSubordinatesKey(accountID uint) string { return fmt.Sprintf("account:subordinates:%d", accountID) } ``` #### 3.2 缓存写入 ```go // 在 GetSubordinateIDs 中写入缓存(见上文) data, _ := sonic.Marshal(ids) s.redis.Set(ctx, cacheKey, data, 30*time.Minute) ``` #### 3.3 缓存清除 ```go // 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 触发缓存清除的时机 ```go // 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 主函数重构 ```go // 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 路由模块化 ```go // 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) } ``` ```go // 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 要求避免安全漏洞 ### 实现方案 ```go // 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。