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>
This commit is contained in:
2025-11-18 16:44:06 +08:00
parent e8eb5766cb
commit eaa70ac255
86 changed files with 15395 additions and 245 deletions

View File

@@ -5,7 +5,6 @@ import (
"fmt"
"sync/atomic"
"time"
"unsafe"
)
// globalConfig 保存当前配置,支持原子访问
@@ -178,13 +177,3 @@ func Set(cfg *Config) error {
globalConfig.Store(cfg)
return nil
}
// unsafeSet 无验证设置配置(仅供热重载内部使用)
func unsafeSet(cfg *Config) {
globalConfig.Store(cfg)
}
// atomicSwap 原子地交换配置
func atomicSwap(new *Config) *Config {
return (*Config)(atomic.SwapPointer((*unsafe.Pointer)(unsafe.Pointer(&globalConfig)), unsafe.Pointer(new)))
}

View File

@@ -112,7 +112,7 @@ middleware:
{
name: "environment-specific config (dev)",
setupEnv: func() {
os.Setenv(constants.EnvConfigEnv, "dev")
_ = os.Setenv(constants.EnvConfigEnv, "dev")
},
cleanupEnv: func() {
_ = os.Unsetenv(constants.EnvConfigEnv)
@@ -202,8 +202,8 @@ middleware:
{
name: "invalid YAML syntax",
setupEnv: func() {
os.Setenv(constants.EnvConfigPath, "")
os.Setenv(constants.EnvConfigEnv, "")
_ = os.Setenv(constants.EnvConfigPath, "")
_ = os.Setenv(constants.EnvConfigEnv, "")
},
cleanupEnv: func() {
_ = os.Unsetenv(constants.EnvConfigPath)
@@ -230,7 +230,7 @@ server:
{
name: "validation error - invalid server address",
setupEnv: func() {
os.Setenv(constants.EnvConfigPath, "")
_ = os.Setenv(constants.EnvConfigPath, "")
},
cleanupEnv: func() {
_ = os.Unsetenv(constants.EnvConfigPath)
@@ -287,7 +287,7 @@ middleware:
{
name: "validation error - timeout out of range",
setupEnv: func() {
os.Setenv(constants.EnvConfigPath, "")
_ = os.Setenv(constants.EnvConfigPath, "")
},
cleanupEnv: func() {
_ = os.Unsetenv(constants.EnvConfigPath)
@@ -344,7 +344,7 @@ middleware:
{
name: "validation error - invalid redis port",
setupEnv: func() {
os.Setenv(constants.EnvConfigPath, "")
_ = os.Setenv(constants.EnvConfigPath, "")
},
cleanupEnv: func() {
_ = os.Unsetenv(constants.EnvConfigPath)

View File

@@ -71,8 +71,8 @@ middleware:
}
// Set config path
os.Setenv(constants.EnvConfigPath, configFile)
defer os.Unsetenv(constants.EnvConfigPath)
_ = os.Setenv(constants.EnvConfigPath, configFile)
defer func() { _ = os.Unsetenv(constants.EnvConfigPath) }()
// Load initial config
cfg, err := Load()
@@ -226,8 +226,8 @@ middleware:
}
// Set config path
os.Setenv(constants.EnvConfigPath, configFile)
defer os.Unsetenv(constants.EnvConfigPath)
_ = os.Setenv(constants.EnvConfigPath, configFile)
defer func() { _ = os.Unsetenv(constants.EnvConfigPath) }()
// Load initial config
cfg, err := Load()
@@ -385,8 +385,8 @@ middleware:
t.Fatalf("failed to create config file: %v", err)
}
os.Setenv(constants.EnvConfigPath, configFile)
defer os.Unsetenv(constants.EnvConfigPath)
_ = os.Setenv(constants.EnvConfigPath, configFile)
defer func() { _ = os.Unsetenv(constants.EnvConfigPath) }()
// Load config
_, err := Load()

View File

@@ -4,9 +4,12 @@ import "time"
// Fiber Locals 的上下文键
const (
ContextKeyRequestID = "requestid"
ContextKeyUserID = "user_id"
ContextKeyStartTime = "start_time"
ContextKeyRequestID = "requestid" // 请求记录ID
ContextKeyStartTime = "start_time" //请求开始时间
ContextKeyUserID = "user_id" // 用户ID
ContextKeyUserType = "user_type" //用户类型
ContextKeyShopID = "shop_id" //店铺ID
ContextKeyUserInfo = "user_info" //完整的用户信息
)
// 配置环境变量
@@ -47,6 +50,33 @@ const (
UserStatusSuspended = "suspended" // 暂停
)
// RBAC 用户类型常量
const (
UserTypeRoot = 1 // root 用户(跳过数据权限过滤)
UserTypePlatform = 2 // 平台用户
UserTypeAgent = 3 // 代理用户
UserTypeEnterprise = 4 // 企业用户
)
// RBAC 角色类型常量
const (
RoleTypeSuper = 1 // 超级角色
RoleTypeAgent = 2 // 代理角色
RoleTypeEnterprise = 3 // 企业角色
)
// RBAC 权限类型常量
const (
PermissionTypeMenu = 1 // 菜单权限
PermissionTypeButton = 2 // 按钮权限
)
// RBAC 状态常量
const (
StatusDisabled = 0 // 禁用
StatusEnabled = 1 // 启用
)
// 订单状态常量
const (
OrderStatusPending = "pending" // 待支付

View File

@@ -25,3 +25,10 @@ func RedisTaskLockKey(requestID string) string {
func RedisTaskStatusKey(taskID string) string {
return fmt.Sprintf("task:status:%s", taskID)
}
// RedisAccountSubordinatesKey 生成账号下级 ID 列表的 Redis 键
// 用途:缓存递归查询的下级账号 ID 列表
// 过期时间30 分钟
func RedisAccountSubordinatesKey(accountID uint) string {
return fmt.Sprintf("account:subordinates:%d", accountID)
}

View File

@@ -16,6 +16,26 @@ const (
CodeTooManyRequests = 1008 // 请求过多
CodeRequestTooLarge = 1009 // 请求体过大
// RBAC 相关错误 (1010-1099)
CodeAccountNotFound = 1010 // 账号不存在
CodeAccountDisabled = 1011 // 账号已禁用
CodeAccountDeleted = 1012 // 账号已删除
CodeUsernameExists = 1013 // 用户名已存在
CodePhoneExists = 1014 // 手机号已存在
CodeInvalidPassword = 1015 // 密码格式不正确
CodePasswordTooWeak = 1016 // 密码强度不足
CodeParentIDRequired = 1017 // 非 root 用户必须提供上级账号
CodeInvalidParentID = 1018 // 上级账号不存在或无效
CodeCannotModifyParent = 1019 // 禁止修改上级账号
CodeCannotModifyUserType = 1020 // 禁止修改用户类型
CodeRoleNotFound = 1021 // 角色不存在
CodeRoleNameExists = 1022 // 角色名称已存在
CodePermissionNotFound = 1023 // 权限不存在
CodePermCodeExists = 1024 // 权限编码已存在
CodeInvalidPermCode = 1025 // 权限编码格式不正确
CodeRoleAlreadyAssigned = 1026 // 角色已分配
CodePermAlreadyAssigned = 1027 // 权限已分配
// 服务端错误 (2000-2999) -> 5xx HTTP 状态码
CodeInternalError = 2001 // 内部服务器错误
CodeDatabaseError = 2002 // 数据库错误
@@ -31,22 +51,40 @@ const (
// errorMessages 错误消息映射表(中文)
var errorMessages = map[int]string{
CodeSuccess: "成功",
CodeInvalidParam: "参数验证失败",
CodeMissingToken: "缺失认证令牌",
CodeInvalidToken: "无效或过期的令牌",
CodeUnauthorized: "未授权访问",
CodeForbidden: "禁止访问",
CodeNotFound: "资源未找到",
CodeConflict: "资源冲突",
CodeTooManyRequests: "请求过多,请稍后重试",
CodeRequestTooLarge: "请求体过大",
CodeInternalError: "内部服务器错误",
CodeDatabaseError: "数据库错误",
CodeRedisError: "缓存服务错误",
CodeServiceUnavailable: "服务暂时不可用",
CodeTimeout: "请求超时",
CodeTaskQueueError: "任务队列错误",
CodeSuccess: "成功",
CodeInvalidParam: "参数验证失败",
CodeMissingToken: "缺失认证令牌",
CodeInvalidToken: "无效或过期的令牌",
CodeUnauthorized: "未授权访问",
CodeForbidden: "禁止访问",
CodeNotFound: "资源未找到",
CodeConflict: "资源冲突",
CodeTooManyRequests: "请求过多,请稍后重试",
CodeRequestTooLarge: "请求体过大",
CodeAccountNotFound: "账号不存在",
CodeAccountDisabled: "账号已禁用",
CodeAccountDeleted: "账号已删除",
CodeUsernameExists: "用户名已存在",
CodePhoneExists: "手机号已存在",
CodeInvalidPassword: "密码格式不正确",
CodePasswordTooWeak: "密码强度不足",
CodeParentIDRequired: "非 root 用户必须提供上级账号",
CodeInvalidParentID: "上级账号不存在或无效",
CodeCannotModifyParent: "禁止修改上级账号",
CodeCannotModifyUserType: "禁止修改用户类型",
CodeRoleNotFound: "角色不存在",
CodeRoleNameExists: "角色名称已存在",
CodePermissionNotFound: "权限不存在",
CodePermCodeExists: "权限编码已存在",
CodeInvalidPermCode: "权限编码格式不正确(应为 module:action 格式)",
CodeRoleAlreadyAssigned: "角色已分配",
CodePermAlreadyAssigned: "权限已分配",
CodeInternalError: "内部服务器错误",
CodeDatabaseError: "数据库错误",
CodeRedisError: "缓存服务错误",
CodeServiceUnavailable: "服务暂时不可用",
CodeTimeout: "请求超时",
CodeTaskQueueError: "任务队列错误",
}
// GetMessage 获取错误码对应的消息

View File

@@ -43,7 +43,7 @@ func FromFiberContext(c *fiber.Ctx) *ErrorContext {
}
// 提取 User ID如果已认证
if uid := c.Locals("user_id"); uid != nil {
if uid := c.Locals(constants.ContextKeyUserID); uid != nil {
if userID, ok := uid.(string); ok {
ctx.UserID = userID
}

View File

@@ -12,7 +12,7 @@ import (
// TestSafeErrorHandler 测试 SafeErrorHandler 基本功能
func TestSafeErrorHandler(t *testing.T) {
logger, _ := zap.NewProduction()
defer logger.Sync()
defer func() { _ = logger.Sync() }()
handler := SafeErrorHandler(logger)
tests := []struct {
@@ -156,7 +156,7 @@ func TestAppErrorUnwrap(t *testing.T) {
// BenchmarkSafeErrorHandler 基准测试错误处理性能
func BenchmarkSafeErrorHandler(b *testing.B) {
logger, _ := zap.NewProduction()
defer logger.Sync()
defer func() { _ = logger.Sync() }()
_ = SafeErrorHandler(logger) // 避免未使用变量警告
testErrors := []error{
@@ -330,7 +330,7 @@ func TestErrorMessageSanitization(t *testing.T) {
// TestConcurrentErrorHandling 测试并发场景下的错误处理
func TestConcurrentErrorHandling(t *testing.T) {
logger, _ := zap.NewProduction()
defer logger.Sync()
defer func() { _ = logger.Sync() }()
handler := SafeErrorHandler(logger)
if handler == nil {
t.Fatal("SafeErrorHandler returned nil")

View File

@@ -124,7 +124,7 @@ func TestMaxBackups(t *testing.T) {
zap.Int("iteration", i),
)
}
Sync()
_ = Sync()
time.Sleep(100 * time.Millisecond)
}
@@ -198,7 +198,7 @@ func TestCompressionConfig(t *testing.T) {
)
}
Sync()
_ = Sync()
time.Sleep(100 * time.Millisecond)
// 验证日志文件存在
@@ -239,7 +239,7 @@ func TestMaxAge(t *testing.T) {
// 写入日志
logger.Info("test max age", zap.String("config", "maxage=1"))
Sync()
_ = Sync()
// 验证配置已应用无法在单元测试中验证实际的清理行为因为需要等待1天
// 这里只验证初始化没有错误

149
pkg/middleware/auth.go Normal file
View File

@@ -0,0 +1,149 @@
package middleware
import (
"context"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/gofiber/fiber/v2"
)
// SetUserContext 将用户信息设置到 context 中
// 在 Auth 中间件认证成功后调用
func SetUserContext(ctx context.Context, userID uint, userType int, shopID uint) context.Context {
ctx = context.WithValue(ctx, constants.ContextKeyUserID, userID)
ctx = context.WithValue(ctx, constants.ContextKeyUserType, userType)
ctx = context.WithValue(ctx, constants.ContextKeyShopID, shopID)
return ctx
}
// GetUserIDFromContext 从 context 中提取用户 ID
// 如果未设置,返回 0
func GetUserIDFromContext(ctx context.Context) uint {
if ctx == nil {
return 0
}
if userID, ok := ctx.Value(constants.ContextKeyUserID).(uint); ok {
return userID
}
return 0
}
// GetUserTypeFromContext 从 context 中提取用户类型
// 如果未设置,返回 0
func GetUserTypeFromContext(ctx context.Context) int {
if ctx == nil {
return 0
}
if userType, ok := ctx.Value(constants.ContextKeyUserID).(int); ok {
return userType
}
return 0
}
// GetShopIDFromContext 从 context 中提取店铺 ID
// 如果未设置,返回 0
func GetShopIDFromContext(ctx context.Context) uint {
if ctx == nil {
return 0
}
if shopID, ok := ctx.Value(constants.ContextKeyShopID).(uint); ok {
return shopID
}
return 0
}
// IsRootUser 检查当前用户是否为 root 用户
// root 用户跳过数据权限过滤
func IsRootUser(ctx context.Context) bool {
userType := GetUserTypeFromContext(ctx)
return userType == constants.UserTypeRoot
}
// SetUserToFiberContext 将用户信息设置到 Fiber context 的 Locals 中
// 同时也设置到标准 context 中,便于 GORM 查询使用
func SetUserToFiberContext(c *fiber.Ctx, userID uint, userType int, shopID uint) {
// 设置到 Fiber Locals
c.Locals(constants.ContextKeyUserID, userID)
c.Locals(constants.ContextKeyUserType, userType)
c.Locals(constants.ContextKeyShopID, shopID)
// 设置到标准 context用于 GORM 数据权限过滤)
ctx := SetUserContext(c.UserContext(), userID, userType, shopID)
c.SetUserContext(ctx)
}
// AuthConfig Auth 中间件配置
type AuthConfig struct {
// TokenExtractor 自定义 token 提取函数
// 如果为空,默认从 Authorization header 提取 Bearer token
TokenExtractor func(c *fiber.Ctx) string
// TokenValidator token 验证函数
// 验证成功返回用户 ID、用户类型、店铺 ID
// 验证失败返回 error
TokenValidator func(token string) (userID uint, userType int, shopID uint, err error)
// Skip 跳过认证的路径
Skip []string
}
// Auth 认证中间件
// 从请求中提取 token验证后将用户信息设置到 context
func Auth(config AuthConfig) fiber.Handler {
return func(c *fiber.Ctx) error {
// 检查是否跳过认证
path := c.Path()
for _, skipPath := range config.Skip {
if path == skipPath {
return c.Next()
}
}
// 提取 token
var token string
if config.TokenExtractor != nil {
token = config.TokenExtractor(c)
} else {
// 默认从 Authorization header 提取 Bearer token
token = extractBearerToken(c)
}
if token == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"code": errors.CodeUnauthorized,
"message": "未提供认证令牌",
})
}
// 验证 token
if config.TokenValidator == nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"code": errors.CodeInternalError,
"message": "认证验证器未配置",
})
}
userID, userType, shopID, err := config.TokenValidator(token)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"code": errors.CodeUnauthorized,
"message": "认证令牌无效",
})
}
// 将用户信息设置到 context
SetUserToFiberContext(c, userID, userType, shopID)
return c.Next()
}
}
// extractBearerToken 从 Authorization header 提取 Bearer token
func extractBearerToken(c *fiber.Ctx) string {
auth := c.Get("Authorization")
if len(auth) > 7 && auth[:7] == "Bearer " {
return auth[7:]
}
return ""
}

View File

@@ -44,3 +44,26 @@ func SuccessWithMessage(c *fiber.Ctx, data any, message string) error {
Timestamp: time.Now().Format(time.RFC3339),
})
}
// PaginationData 分页数据结构
type PaginationData struct {
Items any `json:"items"` // 数据列表
Total int64 `json:"total"` // 总数
Page int `json:"page"` // 当前页码
Size int `json:"size"` // 每页大小
}
// SuccessWithPagination 返回分页响应
func SuccessWithPagination(c *fiber.Ctx, items any, total int64, page, size int) error {
return c.JSON(Response{
Code: errors.CodeSuccess,
Data: PaginationData{
Items: items,
Total: total,
Page: page,
Size: size,
},
Message: "success",
Timestamp: time.Now().Format(time.RFC3339),
})
}

View File

@@ -428,7 +428,7 @@ func TestMultipleResponses(t *testing.T) {
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
_ = resp.Body.Close()
var response Response
if err := sonic.Unmarshal(body, &response); err != nil {
@@ -454,7 +454,7 @@ func TestTimestampFormat(t *testing.T) {
if err != nil {
t.Fatalf("Failed to execute request: %v", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
var response Response