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

499 lines
15 KiB
Markdown
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.
# 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。