主要功能: - 实现完整的 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>
15 KiB
15 KiB
Research: RBAC 表结构与 GORM 数据权限过滤
Feature: 004-rbac-data-permission Date: 2025-11-18 Researcher: AI Assistant
研究目标
本功能需要实现三个核心技术点:
- GORM 递归查询:使用 PostgreSQL WITH RECURSIVE 查询用户的所有下级 ID
- GORM Scopes 数据权限过滤:自动为查询添加 WHERE owner_id IN (...) AND shop_id = ? 条件
- Redis 缓存优化:缓存递归查询结果,30 分钟过期,支持主动清除
- 主函数重构和路由模块化:将 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)
- WITH RECURSIVE 是 PostgreSQL 标准:高效处理层级数据,性能优于多次查询
- 包含软删除账号:递归查询不过滤
deleted_at,确保软删除账号的数据对上级仍可见 - Redis 缓存优化:递归查询成本较高(多层 JOIN),缓存 30 分钟显著降低数据库负载
- 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 传递用户信息 实现自动数据权限过滤。
实现方案
// 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)
- GORM Scopes 是官方推荐模式:复用查询逻辑,自动应用到所有查询
- Context 传递用户信息:符合 Go 惯用法,线程安全,Fiber 请求级隔离
- 双重过滤保证安全:owner_id(数据归属)+ shop_id(店铺隔离)
- 降级策略:查询下级 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)
- 30 分钟过期平衡性能和一致性:账号层级关系变更频率低,30 分钟足够
- 主动清除保证一致性:账号创建/删除时立即清除缓存,避免脏数据
- sonic JSON 序列化:符合宪章要求,性能优于标准库 encoding/json
- 递归清除上级缓存:子账号变更影响所有上级的下级列表
替代方案 (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)
- 单一职责原则:每个初始化函数只负责一件事,易于测试和维护
- main 函数编排清晰:一眼看清整个启动流程,不陷入实现细节
- 路由模块化便于扩展:新增模块只需添加一个路由文件和注册调用
- 符合 Go 惯用法:简单直接,不引入复杂的 DI 框架
替代方案 (Alternatives Considered)
-
方案 A:使用 uber/fx 或 google/wire DI 框架
- ❌ 拒绝原因:违反宪章原则 VI(过度 DI 框架),增加学习成本
-
方案 B:保持 main 函数集中式
- ❌ 拒绝原因:违反宪章原则 II(函数复杂度 > 100 行)
-
方案 C:使用全局变量存储 Service
- ❌ 拒绝原因:违反宪章原则(依赖注入通过结构体字段)
5. 密码哈希策略(安全性考虑)
决策 (Decision)
建议修改规格:将密码哈希从 MD5 改为 bcrypt。
理由 (Rationale)
- MD5 已被密码学界废弃:易受彩虹表攻击,不适合密码存储
- bcrypt 是行业标准:内置盐值,自适应成本,抗暴力破解
- 符合宪章安全原则: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。