# 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。
