主要功能: - 实现完整的 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>
499 lines
15 KiB
Markdown
499 lines
15 KiB
Markdown
# 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。
|