From ae5b997924ab45fee2c9444ca8c0e50459980aa7 Mon Sep 17 00:00:00 2001 From: lan Date: Sat, 14 Mar 2026 02:09:38 +0800 Subject: [PATCH] feat(auth): add Casbin RBAC and user activity tracking Integrate Casbin for role-based access control with admin routes for role management. Add user activity logging service to track login and refresh events. Refactor audit service to use AI-based content moderation. --- cmd/test_casbin/main.go | 356 ++++++++++++ configs/casbin/model.conf | 14 + configs/config.yaml | 19 + go.mod | 10 +- go.sum | 23 + internal/config/casbin.go | 22 + internal/config/config.go | 14 + internal/errors/app_errors.go | 9 + internal/handler/role_handler.go | 204 +++++++ internal/handler/user_handler.go | 39 +- internal/middleware/casbin.go | 130 +++++ internal/model/init.go | 9 + internal/model/role.go | 57 ++ internal/model/user_active_log.go | 49 ++ internal/repository/role_repo.go | 97 ++++ internal/repository/user_activity_repo.go | 225 ++++++++ internal/router/router.go | 31 + internal/service/audit_service.go | 664 ++++++++-------------- internal/service/casbin_service.go | 225 ++++++++ internal/service/role_service.go | 116 ++++ internal/service/user_activity_service.go | 223 ++++++++ internal/wire/casbin.go | 54 ++ internal/wire/handler.go | 11 +- internal/wire/provider_set.go | 1 + internal/wire/repository.go | 8 + internal/wire/service.go | 15 + scripts/test_moderation.go | 263 --------- 27 files changed, 2197 insertions(+), 691 deletions(-) create mode 100644 cmd/test_casbin/main.go create mode 100644 configs/casbin/model.conf create mode 100644 internal/config/casbin.go create mode 100644 internal/handler/role_handler.go create mode 100644 internal/middleware/casbin.go create mode 100644 internal/model/role.go create mode 100644 internal/model/user_active_log.go create mode 100644 internal/repository/role_repo.go create mode 100644 internal/repository/user_activity_repo.go create mode 100644 internal/service/casbin_service.go create mode 100644 internal/service/role_service.go create mode 100644 internal/service/user_activity_service.go create mode 100644 internal/wire/casbin.go delete mode 100644 scripts/test_moderation.go diff --git a/cmd/test_casbin/main.go b/cmd/test_casbin/main.go new file mode 100644 index 0000000..2cacbe5 --- /dev/null +++ b/cmd/test_casbin/main.go @@ -0,0 +1,356 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "strings" + + "carrot_bbs/internal/config" + "carrot_bbs/internal/model" + "carrot_bbs/internal/repository" + "carrot_bbs/internal/service" + + "github.com/casbin/casbin/v2" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func main() { + configPath := flag.String("config", "configs/config.yaml", "配置文件路径") + flag.Parse() + + fmt.Println("============================================") + fmt.Println("Casbin RBAC1 权限系统测试") + fmt.Println("============================================") + + // 加载配置 + cfg, err := config.Load(*configPath) + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + fmt.Println("✓ 配置加载成功") + + // 连接数据库 + dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + cfg.Database.Postgres.Host, + cfg.Database.Postgres.Port, + cfg.Database.Postgres.User, + cfg.Database.Postgres.Password, + cfg.Database.Postgres.DBName, + ) + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + log.Fatalf("Failed to connect database: %v", err) + } + fmt.Println("✓ 数据库连接成功") + + // 自动迁移 + if err := db.AutoMigrate(&model.Role{}, &model.UserRole{}, &model.CasbinRule{}); err != nil { + log.Fatalf("Failed to migrate: %v", err) + } + fmt.Println("✓ 数据库迁移完成") + + // 初始化角色数据 + if err := initRoles(db); err != nil { + log.Printf("[WARNING] Failed to init roles: %v", err) + } + + // 初始化 Casbin 策略数据 + if err := initCasbinPolicies(db); err != nil { + log.Printf("[WARNING] Failed to init casbin policies: %v", err) + } + + // 创建 Casbin Enforcer + enforcer, err := casbin.NewEnforcer(cfg.Casbin.ModelPath) + if err != nil { + log.Fatalf("Failed to create enforcer: %v", err) + } + fmt.Println("✓ Casbin enforcer 创建成功") + + // 创建角色仓储 + roleRepo := repository.NewRoleRepository(db) + + // 创建 Casbin 服务 + casbinSvc := service.NewCasbinService(enforcer, nil, roleRepo, &cfg.Casbin) + fmt.Println("✓ Casbin 服务创建成功") + + ctx := context.Background() + + // 测试权限检查 + fmt.Println("\n=== 测试权限检查 ===") + + testCases := []struct { + sub string + obj string + act string + expected bool + }{ + // banned 用户权限 + {"banned", "/api/v1/posts", "GET", true}, + {"banned", "/api/v1/posts", "POST", false}, + {"banned", "/api/v1/admin/users", "GET", false}, + + // user 用户权限 + {"user", "/api/v1/posts", "GET", true}, + {"user", "/api/v1/posts", "POST", true}, + {"user", "/api/v1/posts/123", "PUT", true}, + {"user", "/api/v1/posts/123", "DELETE", true}, + {"user", "/api/v1/comments", "POST", true}, + {"user", "/api/v1/users/me", "GET", true}, + {"user", "/api/v1/admin/users", "GET", false}, + + // moderator 权限 (继承 user) + {"moderator", "/api/v1/posts", "POST", true}, + {"moderator", "/api/v1/admin/posts/123", "DELETE", true}, + {"moderator", "/api/v1/admin/comments/456", "DELETE", true}, + {"moderator", "/api/v1/admin/audit", "GET", true}, + {"moderator", "/api/v1/admin/users", "GET", false}, + + // admin 权限 (继承 moderator) + {"admin", "/api/v1/posts", "POST", true}, + {"admin", "/api/v1/admin/posts/123", "DELETE", true}, + {"admin", "/api/v1/admin/users", "GET", true}, + {"admin", "/api/v1/admin/roles", "GET", true}, + {"admin", "/api/v1/some/unknown/path", "GET", false}, + + // super_admin 权限 (完全访问) + {"super_admin", "/api/v1/admin/anything", "DELETE", true}, + {"super_admin", "/api/v1/admin/users", "GET", true}, + {"super_admin", "/any/path", "ANY", true}, + } + + passed := 0 + failed := 0 + + for _, tc := range testCases { + allowed, err := casbinSvc.Enforce(ctx, tc.sub, tc.obj, tc.act) + if err != nil { + fmt.Printf("✗ 错误: %s -> %s %s: %v\n", tc.sub, tc.obj, tc.act, err) + failed++ + continue + } + + status := "✗ DENIED" + if allowed { + status = "✓ ALLOWED" + } + + // 检查结果是否符合预期 + resultMatch := "" + if allowed != tc.expected { + resultMatch = fmt.Sprintf(" (预期: %v)", tc.expected) + failed++ + } else { + passed++ + } + + fmt.Printf("%s: %s -> %s %s%s\n", status, tc.sub, tc.obj, tc.act, resultMatch) + } + + // 测试角色继承 + fmt.Println("\n=== 测试角色继承 ===") + + // moderator 应该继承 user 的权限 + allowed, _ := casbinSvc.Enforce(ctx, "moderator", "/api/v1/posts", "POST") + fmt.Printf("Moderator 继承 user 权限 - 可以发帖: %v\n", allowed) + + // admin 应该继承 moderator 的权限 + allowed, _ = casbinSvc.Enforce(ctx, "admin", "/api/v1/admin/posts/123", "DELETE") + fmt.Printf("Admin 继承 moderator 权限 - 可以删帖: %v\n", allowed) + + // 测试用户角色管理 + fmt.Println("\n=== 测试用户角色管理 ===") + + testUserID := "test_user_123" + + // 获取用户当前角色 + roles, err := casbinSvc.GetRolesForUser(ctx, testUserID) + if err != nil { + fmt.Printf("获取用户角色失败: %v\n", err) + } else { + fmt.Printf("用户 %s 当前角色: %v\n", testUserID, roles) + } + + // 添加角色 + err = casbinSvc.AddRoleForUser(ctx, testUserID, "moderator") + if err != nil { + fmt.Printf("添加角色失败: %v\n", err) + } else { + fmt.Printf("✓ 为用户 %s 添加 moderator 角色\n", testUserID) + } + + // 再次获取角色 + roles, err = casbinSvc.GetRolesForUser(ctx, testUserID) + if err != nil { + fmt.Printf("获取用户角色失败: %v\n", err) + } else { + fmt.Printf("用户 %s 当前角色: %v\n", testUserID, roles) + } + + // 检查用户是否有指定角色 + hasRole, err := casbinSvc.HasRoleForUser(ctx, testUserID, "moderator") + if err != nil { + fmt.Printf("检查角色失败: %v\n", err) + } else { + fmt.Printf("用户 %s 是否有 moderator 角色: %v\n", testUserID, hasRole) + } + + // 测试用户权限检查 + allowed, err = casbinSvc.EnforceForUser(ctx, testUserID, "/api/v1/admin/posts/123", "DELETE") + if err != nil { + fmt.Printf("用户权限检查失败: %v\n", err) + } else { + fmt.Printf("用户 %s 可以删除管理后台帖子: %v\n", testUserID, allowed) + } + + // 移除角色 + err = casbinSvc.DeleteRoleForUser(ctx, testUserID, "moderator") + if err != nil { + fmt.Printf("移除角色失败: %v\n", err) + } else { + fmt.Printf("✓ 移除用户 %s 的 moderator 角色\n", testUserID) + } + + // 清理测试数据 + cleanupTestData(db, testUserID) + + // 输出测试结果 + fmt.Println("\n============================================") + fmt.Printf("测试结果: %d 通过, %d 失败\n", passed, failed) + fmt.Println("============================================") + + if failed > 0 { + os.Exit(1) + } +} + +// initRoles 初始化角色数据 +func initRoles(db *gorm.DB) error { + roles := []model.Role{ + {Name: "banned", DisplayName: "被封禁用户", Description: "仅可浏览公开内容,无法交互", Priority: 0}, + {Name: "user", DisplayName: "普通用户", Description: "注册用户,拥有基本功能权限", ParentRole: strPtr("banned"), Priority: 10}, + {Name: "moderator", DisplayName: "版主", Description: "内容审核员,管理帖子和评论", ParentRole: strPtr("user"), Priority: 20}, + {Name: "admin", DisplayName: "管理员", Description: "系统管理员,管理用户和内容", ParentRole: strPtr("moderator"), Priority: 30}, + {Name: "super_admin", DisplayName: "超级管理员", Description: "系统最高权限者", ParentRole: strPtr("admin"), Priority: 40}, + } + + for _, role := range roles { + result := db.FirstOrCreate(&role, model.Role{Name: role.Name}) + if result.Error != nil { + return result.Error + } + } + + fmt.Println("✓ 角色数据初始化完成") + return nil +} + +// initCasbinPolicies 初始化 Casbin 策略数据 +func initCasbinPolicies(db *gorm.DB) error { + // 角色继承关系 (g 策略) + gPolicies := []model.CasbinRule{ + {Ptype: "g", V0: "user", V1: "banned"}, + {Ptype: "g", V0: "moderator", V1: "user"}, + {Ptype: "g", V0: "admin", V1: "moderator"}, + {Ptype: "g", V0: "super_admin", V1: "admin"}, + } + + // 权限策略 (p 策略) + pPolicies := []model.CasbinRule{ + // banned 用户权限 (仅允许 GET 请求) + {Ptype: "p", V0: "banned", V1: "/api/v1/posts", V2: "GET"}, + {Ptype: "p", V0: "banned", V1: "/api/v1/posts/*", V2: "GET"}, + {Ptype: "p", V0: "banned", V1: "/api/v1/comments/*", V2: "GET"}, + {Ptype: "p", V0: "banned", V1: "/api/v1/users/*", V2: "GET"}, + + // user 用户权限 (基本操作) + {Ptype: "p", V0: "user", V1: "/api/v1/posts", V2: "POST"}, + {Ptype: "p", V0: "user", V1: "/api/v1/posts/*", V2: "PUT"}, + {Ptype: "p", V0: "user", V1: "/api/v1/posts/*", V2: "DELETE"}, + {Ptype: "p", V0: "user", V1: "/api/v1/posts/*/like", V2: "POST"}, + {Ptype: "p", V0: "user", V1: "/api/v1/posts/*/like", V2: "DELETE"}, + {Ptype: "p", V0: "user", V1: "/api/v1/posts/*/favorite", V2: "POST"}, + {Ptype: "p", V0: "user", V1: "/api/v1/posts/*/favorite", V2: "DELETE"}, + {Ptype: "p", V0: "user", V1: "/api/v1/comments", V2: "POST"}, + {Ptype: "p", V0: "user", V1: "/api/v1/comments/*", V2: "PUT"}, + {Ptype: "p", V0: "user", V1: "/api/v1/comments/*", V2: "DELETE"}, + {Ptype: "p", V0: "user", V1: "/api/v1/users/me", V2: "*"}, + {Ptype: "p", V0: "user", V1: "/api/v1/users/me/*", V2: "*"}, + {Ptype: "p", V0: "user", V1: "/api/v1/conversations", V2: "*"}, + {Ptype: "p", V0: "user", V1: "/api/v1/conversations/*", V2: "*"}, + {Ptype: "p", V0: "user", V1: "/api/v1/messages/*", V2: "*"}, + {Ptype: "p", V0: "user", V1: "/api/v1/notifications", V2: "*"}, + {Ptype: "p", V0: "user", V1: "/api/v1/notifications/*", V2: "*"}, + {Ptype: "p", V0: "user", V1: "/api/v1/uploads/*", V2: "POST"}, + {Ptype: "p", V0: "user", V1: "/api/v1/groups", V2: "*"}, + {Ptype: "p", V0: "user", V1: "/api/v1/groups/*", V2: "*"}, + {Ptype: "p", V0: "user", V1: "/api/v1/stickers", V2: "*"}, + {Ptype: "p", V0: "user", V1: "/api/v1/schedule", V2: "*"}, + {Ptype: "p", V0: "user", V1: "/api/v1/schedule/*", V2: "*"}, + + // moderator 权限 (内容管理) + {Ptype: "p", V0: "moderator", V1: "/api/v1/admin/posts/*", V2: "*"}, + {Ptype: "p", V0: "moderator", V1: "/api/v1/admin/comments/*", V2: "*"}, + {Ptype: "p", V0: "moderator", V1: "/api/v1/admin/audit/*", V2: "GET"}, + + // admin 权限 (用户管理) + {Ptype: "p", V0: "admin", V1: "/api/v1/admin/users/*", V2: "*"}, + {Ptype: "p", V0: "admin", V1: "/api/v1/admin/roles", V2: "GET"}, + {Ptype: "p", V0: "admin", V1: "/api/v1/admin/roles/*", V2: "GET"}, + + // super_admin 权限 (完全访问) + {Ptype: "p", V0: "super_admin", V1: "/*", V2: "*"}, + } + + // 插入 g 策略 + for _, policy := range gPolicies { + var existing model.CasbinRule + result := db.Where("ptype = ? AND v0 = ? AND v1 = ?", policy.Ptype, policy.V0, policy.V1).First(&existing) + if result.Error == gorm.ErrRecordNotFound { + if err := db.Create(&policy).Error; err != nil { + return err + } + } + } + + // 插入 p 策略 + for _, policy := range pPolicies { + var existing model.CasbinRule + result := db.Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ?", policy.Ptype, policy.V0, policy.V1, policy.V2).First(&existing) + if result.Error == gorm.ErrRecordNotFound { + if err := db.Create(&policy).Error; err != nil { + return err + } + } + } + + fmt.Println("✓ Casbin 策略数据初始化完成") + return nil +} + +// cleanupTestData 清理测试数据 +func cleanupTestData(db *gorm.DB, userID string) { + db.Where("user_id = ?", userID).Delete(&model.UserRole{}) + fmt.Printf("✓ 清理测试用户 %s 的数据\n", userID) +} + +// strPtr 返回字符串指针 +func strPtr(s string) *string { + return &s +} + +// 辅助函数:检查字符串是否包含通配符匹配 +func matchPattern(pattern, str string) bool { + if pattern == "/*" { + return true + } + if strings.HasSuffix(pattern, "/*") { + prefix := strings.TrimSuffix(pattern, "/*") + return strings.HasPrefix(str, prefix+"/") || str == prefix + } + return pattern == str +} diff --git a/configs/casbin/model.conf b/configs/casbin/model.conf new file mode 100644 index 0000000..907f24e --- /dev/null +++ b/configs/casbin/model.conf @@ -0,0 +1,14 @@ +[request_definition] +r = sub, obj, act + +[policy_definition] +p = sub, obj, act + +[role_definition] +g = _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && (r.act == p.act || p.act == "*") diff --git a/configs/config.yaml b/configs/config.yaml index e819a51..b6809ba 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -201,3 +201,22 @@ grpc: tls_enabled: false tls_cert_file: "" tls_key_file: "" + +# Casbin RBAC权限系统配置 +# 环境变量: +# APP_CASBIN_MODEL_PATH, APP_CASBIN_AUTO_SAVE, APP_CASBIN_AUTO_LOAD +# APP_CASBIN_ENABLE_CACHE, APP_CASBIN_CACHE_TTL +casbin: + model_path: "./configs/casbin/model.conf" + auto_save: true + auto_load: true + enable_cache: true + cache_ttl: 300 + skip_routes: + - /health + - /api/v1/auth/login + - /api/v1/auth/register + - /api/v1/auth/check-username + - /api/v1/auth/password/send-code + - /api/v1/auth/password/reset + - /api/v1/auth/refresh diff --git a/go.mod b/go.mod index c8606c7..5d6f166 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/alicebob/miniredis/v2 v2.31.0 github.com/gin-gonic/gin v1.9.1 github.com/golang-jwt/jwt/v5 v5.2.0 - github.com/google/uuid v1.5.0 + github.com/google/uuid v1.6.0 github.com/google/wire v0.7.0 github.com/gorse-io/gorse-go v0.5.0-alpha.3 github.com/minio/minio-go/v7 v7.0.66 @@ -25,7 +25,10 @@ require ( require ( github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect + github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect github.com/bytedance/sonic v1.9.1 // indirect + github.com/casbin/casbin/v2 v2.135.0 // indirect + github.com/casbin/govaluate v1.3.0 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect @@ -41,6 +44,7 @@ require ( github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/golang/protobuf v1.5.3 // indirect + github.com/google/subcommands v1.2.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect @@ -60,6 +64,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rs/xid v1.5.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect @@ -79,9 +84,12 @@ require ( go.uber.org/multierr v1.10.0 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/mod v0.20.0 // indirect golang.org/x/net v0.28.0 // indirect + golang.org/x/sync v0.11.0 // indirect golang.org/x/sys v0.23.0 // indirect golang.org/x/text v0.22.0 // indirect + golang.org/x/tools v0.24.1 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/go.sum b/go.sum index 75168ec..902d29e 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZp github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= github.com/alicebob/miniredis/v2 v2.31.0 h1:ObEFUNlJwoIiyjxdrYF0QIDE7qXcLc7D3WpSH4c22PU= github.com/alicebob/miniredis/v2 v2.31.0/go.mod h1:UB/T2Uztp7MlFSDakaX1sTXUv5CASoprx0wulRT6HBg= +github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= +github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -10,6 +12,10 @@ github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0 github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk= +github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18= +github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc= +github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= @@ -56,6 +62,7 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= @@ -63,8 +70,12 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= github.com/gorse-io/gorse-go v0.5.0-alpha.3 h1:GR/OWzq016VyFyTTxgQWeayGahRVzB1cGFIW/AaShC4= @@ -177,22 +188,34 @@ go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= +golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= +golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.24.1 h1:vxuHLTNS3Np5zrYoPRpcheASHX/7KiGo+8Y4ZM1J2O8= +golang.org/x/tools v0.24.1/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f h1:ultW7fxlIvee4HYrtnaRPon9HpEgFk5zYpmfMgtKB5I= google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f/go.mod h1:L9KNLi232K1/xB6f7AlSX692koaRnKaWSR0stBki0Yc= diff --git a/internal/config/casbin.go b/internal/config/casbin.go new file mode 100644 index 0000000..62aca55 --- /dev/null +++ b/internal/config/casbin.go @@ -0,0 +1,22 @@ +package config + +// CasbinConfig Casbin 配置 +type CasbinConfig struct { + // ModelPath Casbin 模型文件路径 + ModelPath string `mapstructure:"model_path"` + + // AutoSave 是否自动保存策略到数据库 + AutoSave bool `mapstructure:"auto_save"` + + // AutoLoad 是否自动从数据库加载策略 + AutoLoad bool `mapstructure:"auto_load"` + + // EnableCache 是否启用 Redis 缓存 + EnableCache bool `mapstructure:"enable_cache"` + + // CacheTTL 缓存过期时间 (秒) + CacheTTL int `mapstructure:"cache_ttl"` + + // SkipRoutes 跳过权限检查的路由 + SkipRoutes []string `mapstructure:"skip_routes"` +} diff --git a/internal/config/config.go b/internal/config/config.go index b1145e5..d3114a8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,6 +26,7 @@ type Config struct { Email EmailConfig `mapstructure:"email"` ConversationCache ConversationCacheConfig `mapstructure:"conversation_cache"` GRPC GRPCConfig `mapstructure:"grpc"` + Casbin CasbinConfig `mapstructure:"casbin"` } // Load 加载配置文件 @@ -134,6 +135,13 @@ func Load(configPath string) (*Config, error) { viper.SetDefault("grpc.tls_enabled", false) viper.SetDefault("grpc.tls_cert_file", "") viper.SetDefault("grpc.tls_key_file", "") + // Casbin 默认值 + viper.SetDefault("casbin.model_path", "./configs/casbin/model.conf") + viper.SetDefault("casbin.auto_save", true) + viper.SetDefault("casbin.auto_load", true) + viper.SetDefault("casbin.enable_cache", true) + viper.SetDefault("casbin.cache_ttl", 300) + viper.SetDefault("casbin.skip_routes", []string{"/health", "/api/v1/auth/login", "/api/v1/auth/register"}) if err := viper.ReadInConfig(); err != nil { return nil, fmt.Errorf("failed to read config: %w", err) @@ -215,6 +223,12 @@ func Load(configPath string) (*Config, error) { cfg.GRPC.TLSEnabled, _ = strconv.ParseBool(getEnvOrDefault("APP_GRPC_TLS_ENABLED", fmt.Sprintf("%t", cfg.GRPC.TLSEnabled))) cfg.GRPC.TLSCertFile = getEnvOrDefault("APP_GRPC_TLS_CERT_FILE", cfg.GRPC.TLSCertFile) cfg.GRPC.TLSKeyFile = getEnvOrDefault("APP_GRPC_TLS_KEY_FILE", cfg.GRPC.TLSKeyFile) + // Casbin 环境变量覆盖 + cfg.Casbin.ModelPath = getEnvOrDefault("APP_CASBIN_MODEL_PATH", cfg.Casbin.ModelPath) + cfg.Casbin.AutoSave, _ = strconv.ParseBool(getEnvOrDefault("APP_CASBIN_AUTO_SAVE", fmt.Sprintf("%t", cfg.Casbin.AutoSave))) + cfg.Casbin.AutoLoad, _ = strconv.ParseBool(getEnvOrDefault("APP_CASBIN_AUTO_LOAD", fmt.Sprintf("%t", cfg.Casbin.AutoLoad))) + cfg.Casbin.EnableCache, _ = strconv.ParseBool(getEnvOrDefault("APP_CASBIN_ENABLE_CACHE", fmt.Sprintf("%t", cfg.Casbin.EnableCache))) + cfg.Casbin.CacheTTL, _ = strconv.Atoi(getEnvOrDefault("APP_CASBIN_CACHE_TTL", fmt.Sprintf("%d", cfg.Casbin.CacheTTL))) return &cfg, nil } diff --git a/internal/errors/app_errors.go b/internal/errors/app_errors.go index 80b48e8..26341b2 100644 --- a/internal/errors/app_errors.go +++ b/internal/errors/app_errors.go @@ -90,6 +90,15 @@ var ( // 通知相关错误 ErrUnauthorizedNotification = &AppError{Code: "UNAUTHORIZED_NOTIFICATION", Message: "无权删除此通知"} + + // Casbin 相关错误 + ErrPermissionDenied = &AppError{Code: "PERMISSION_DENIED", Message: "权限不足"} + ErrRoleNotFound = &AppError{Code: "ROLE_NOT_FOUND", Message: "角色不存在"} + ErrRoleAlreadyAssigned = &AppError{Code: "ROLE_ALREADY_ASSIGNED", Message: "用户已拥有该角色"} + ErrRoleNotAssigned = &AppError{Code: "ROLE_NOT_ASSIGNED", Message: "用户未拥有该角色"} + ErrCannotModifyOwnRole = &AppError{Code: "CANNOT_MODIFY_OWN_ROLE", Message: "不能修改自己的角色"} + ErrCannotModifySuperAdmin = &AppError{Code: "CANNOT_MODIFY_SUPER_ADMIN", Message: "不能修改超级管理员角色"} + ErrCasbinInternal = &AppError{Code: "CASBIN_INTERNAL_ERROR", Message: "权限系统内部错误"} ) // Wrap 包装错误 diff --git a/internal/handler/role_handler.go b/internal/handler/role_handler.go new file mode 100644 index 0000000..757b9db --- /dev/null +++ b/internal/handler/role_handler.go @@ -0,0 +1,204 @@ +package handler + +import ( + "errors" + "net/http" + + apperrors "carrot_bbs/internal/errors" + "carrot_bbs/internal/model" + "carrot_bbs/internal/service" + + "github.com/gin-gonic/gin" +) + +// RoleHandler 角色管理处理器 +type RoleHandler struct { + roleService service.RoleService +} + +// NewRoleHandler 创建角色处理器 +func NewRoleHandler(roleService service.RoleService) *RoleHandler { + return &RoleHandler{ + roleService: roleService, + } +} + +// GetAllRoles 获取所有角色 +// GET /api/v1/admin/roles +func (h *RoleHandler) GetAllRoles(c *gin.Context) { + roles, err := h.roleService.GetAllRoles(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "code": "INTERNAL_ERROR", + "message": "获取角色列表失败", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "data": h.toRoleListResponse(roles), + }) +} + +// GetUserRoles 获取用户的角色 +// GET /api/v1/admin/users/:id/roles +func (h *RoleHandler) GetUserRoles(c *gin.Context) { + userID := c.Param("id") + if userID == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "code": "BAD_REQUEST", + "message": "缺少用户ID", + }) + return + } + + roles, err := h.roleService.GetUserRoles(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "code": "INTERNAL_ERROR", + "message": "获取用户角色失败", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "data": h.toRoleListResponse(roles), + }) +} + +// AssignRoleRequest 分配角色请求 +type AssignRoleRequest struct { + Role string `json:"role" binding:"required"` +} + +// AssignRole 为用户分配角色 +// POST /api/v1/admin/users/:id/roles +func (h *RoleHandler) AssignRole(c *gin.Context) { + targetUserID := c.Param("id") + if targetUserID == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "code": "BAD_REQUEST", + "message": "缺少用户ID", + }) + return + } + + var req AssignRoleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "code": "BAD_REQUEST", + "message": "请求参数错误", + }) + return + } + + // 获取操作者ID + operatorID, _ := c.Get("user_id") + + err := h.roleService.AssignRole(c.Request.Context(), operatorID.(string), targetUserID, req.Role) + if err != nil { + h.handleError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "角色分配成功", + }) +} + +// RemoveRole 移除用户角色 +// DELETE /api/v1/admin/users/:id/roles/:role +func (h *RoleHandler) RemoveRole(c *gin.Context) { + targetUserID := c.Param("id") + role := c.Param("role") + + if targetUserID == "" || role == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "code": "BAD_REQUEST", + "message": "缺少用户ID或角色名", + }) + return + } + + // 获取操作者ID + operatorID, _ := c.Get("user_id") + + err := h.roleService.RemoveRole(c.Request.Context(), operatorID.(string), targetUserID, role) + if err != nil { + h.handleError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "角色移除成功", + }) +} + +// GetMyRoles 获取当前用户的角色 +// GET /api/v1/users/me/roles +func (h *RoleHandler) GetMyRoles(c *gin.Context) { + userID, _ := c.Get("user_id") + + roles, err := h.roleService.GetUserRoles(c.Request.Context(), userID.(string)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "code": "INTERNAL_ERROR", + "message": "获取用户角色失败", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "data": h.toRoleListResponse(roles), + }) +} + +// toRoleListResponse 转换为响应格式 +func (h *RoleHandler) toRoleListResponse(roles []model.Role) []gin.H { + result := make([]gin.H, len(roles)) + for i, role := range roles { + result[i] = gin.H{ + "name": role.Name, + "display_name": role.DisplayName, + "description": role.Description, + "priority": role.Priority, + } + } + return result +} + +// handleError 处理错误 +func (h *RoleHandler) handleError(c *gin.Context, err error) { + switch { + case errors.Is(err, apperrors.ErrCannotModifyOwnRole): + c.JSON(http.StatusForbidden, gin.H{ + "code": "CANNOT_MODIFY_OWN_ROLE", + "message": "不能修改自己的角色", + }) + case errors.Is(err, apperrors.ErrCannotModifySuperAdmin): + c.JSON(http.StatusForbidden, gin.H{ + "code": "CANNOT_MODIFY_SUPER_ADMIN", + "message": "不能修改超级管理员角色", + }) + case errors.Is(err, apperrors.ErrRoleAlreadyAssigned): + c.JSON(http.StatusConflict, gin.H{ + "code": "ROLE_ALREADY_ASSIGNED", + "message": "用户已拥有该角色", + }) + case errors.Is(err, apperrors.ErrRoleNotAssigned): + c.JSON(http.StatusConflict, gin.H{ + "code": "ROLE_NOT_ASSIGNED", + "message": "用户未拥有该角色", + }) + case errors.Is(err, apperrors.ErrRoleNotFound): + c.JSON(http.StatusNotFound, gin.H{ + "code": "ROLE_NOT_FOUND", + "message": "角色不存在", + }) + default: + c.JSON(http.StatusInternalServerError, gin.H{ + "code": "INTERNAL_ERROR", + "message": "操作失败", + }) + } +} diff --git a/internal/handler/user_handler.go b/internal/handler/user_handler.go index a80565a..8d02523 100644 --- a/internal/handler/user_handler.go +++ b/internal/handler/user_handler.go @@ -1,25 +1,29 @@ package handler import ( + "context" "strconv" "github.com/gin-gonic/gin" "carrot_bbs/internal/dto" + "carrot_bbs/internal/model" "carrot_bbs/internal/pkg/response" "carrot_bbs/internal/service" ) // UserHandler 用户处理器 type UserHandler struct { - userService service.UserService - jwtService *service.JWTService + userService service.UserService + activityService service.UserActivityService // 用户活跃统计服务 + jwtService *service.JWTService } // NewUserHandler 创建用户处理器 -func NewUserHandler(userService service.UserService) *UserHandler { +func NewUserHandler(userService service.UserService, activityService service.UserActivityService) *UserHandler { return &UserHandler{ - userService: userService, + userService: userService, + activityService: activityService, } } @@ -90,6 +94,17 @@ func (h *UserHandler) Login(c *gin.Context) { accessToken, _ := h.jwtService.GenerateAccessToken(user.ID, user.Username) refreshToken, _ := h.jwtService.GenerateRefreshToken(user.ID, user.Username) + // 记录用户活跃 + if h.activityService != nil { + go func() { + ctx := context.Background() + loginType := model.LoginTypeLogin + ip := c.ClientIP() + userAgent := c.GetHeader("User-Agent") + h.activityService.RecordUserActive(ctx, user.ID, loginType, ip, userAgent) + }() + } + response.Success(c, gin.H{ "user": dto.ConvertUserToResponse(user), "token": accessToken, @@ -353,6 +368,17 @@ func (h *UserHandler) RefreshToken(c *gin.Context) { accessToken, _ := h.jwtService.GenerateAccessToken(claims.UserID, claims.Username) refreshToken, _ := h.jwtService.GenerateRefreshToken(claims.UserID, claims.Username) + // 记录用户活跃 + if h.activityService != nil { + go func() { + ctx := context.Background() + loginType := model.LoginTypeRefresh + ip := c.ClientIP() + userAgent := c.GetHeader("User-Agent") + h.activityService.RecordUserActive(ctx, claims.UserID, loginType, ip, userAgent) + }() + } + response.Success(c, gin.H{ "token": accessToken, "refresh_token": refreshToken, @@ -364,6 +390,11 @@ func (h *UserHandler) SetJWTService(jwtSvc *service.JWTService) { h.jwtService = jwtSvc } +// SetActivityService 设置活跃统计服务 +func (h *UserHandler) SetActivityService(activityService service.UserActivityService) { + h.activityService = activityService +} + // FollowUser 关注用户 func (h *UserHandler) FollowUser(c *gin.Context) { userID := c.Param("id") diff --git a/internal/middleware/casbin.go b/internal/middleware/casbin.go new file mode 100644 index 0000000..54a5896 --- /dev/null +++ b/internal/middleware/casbin.go @@ -0,0 +1,130 @@ +package middleware + +import ( + "strings" + + "carrot_bbs/internal/service" + + "github.com/gin-gonic/gin" +) + +// CasbinAuth Casbin 权限中间件 +// 需要在 Auth 中间件之后使用 +func CasbinAuth(casbinService service.CasbinService) gin.HandlerFunc { + return func(c *gin.Context) { + // 获取请求路径和方法 + path := c.Request.URL.Path + method := c.Request.Method + + // 从上下文获取用户ID (由 Auth 中间件设置) + userID, exists := c.Get("user_id") + + // 如果用户未登录,检查是否是公开路由 + if !exists { + // 对于未登录用户,使用匿名角色检查权限 + allowed, err := casbinService.Enforce(c.Request.Context(), "anonymous", path, method) + if err != nil { + c.AbortWithStatusJSON(500, gin.H{ + "code": "INTERNAL_ERROR", + "message": "权限检查失败", + }) + return + } + + if !allowed { + c.AbortWithStatusJSON(401, gin.H{ + "code": "UNAUTHORIZED", + "message": "请先登录", + }) + return + } + + c.Next() + return + } + + // 检查用户权限 + allowed, err := casbinService.EnforceForUser(c.Request.Context(), userID.(string), path, method) + if err != nil { + c.AbortWithStatusJSON(500, gin.H{ + "code": "INTERNAL_ERROR", + "message": "权限检查失败", + }) + return + } + + if !allowed { + c.AbortWithStatusJSON(403, gin.H{ + "code": "FORBIDDEN", + "message": "权限不足", + }) + return + } + + c.Next() + } +} + +// OptionalCasbinAuth 可选的 Casbin 权限检查 +// 不阻止请求,但会设置权限标志 +func OptionalCasbinAuth(casbinService service.CasbinService) gin.HandlerFunc { + return func(c *gin.Context) { + userID, exists := c.Get("user_id") + if exists { + path := c.Request.URL.Path + method := c.Request.Method + allowed, err := casbinService.EnforceForUser(c.Request.Context(), userID.(string), path, method) + if err == nil { + c.Set("permission_granted", allowed) + } + } + c.Next() + } +} + +// RequireRole 要求特定角色的中间件 +func RequireRole(casbinService service.CasbinService, requiredRoles ...string) gin.HandlerFunc { + return func(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.AbortWithStatusJSON(401, gin.H{ + "code": "UNAUTHORIZED", + "message": "请先登录", + }) + return + } + + userRoles, err := casbinService.GetRolesForUser(c.Request.Context(), userID.(string)) + if err != nil { + c.AbortWithStatusJSON(500, gin.H{ + "code": "INTERNAL_ERROR", + "message": "获取用户角色失败", + }) + return + } + + // 检查用户是否拥有任一所需角色 + roleMap := make(map[string]bool) + for _, role := range userRoles { + roleMap[role] = true + } + + hasRole := false + for _, required := range requiredRoles { + if roleMap[required] { + hasRole = true + break + } + } + + if !hasRole { + c.AbortWithStatusJSON(403, gin.H{ + "code": "FORBIDDEN", + "message": "需要以下角色之一: " + strings.Join(requiredRoles, ", "), + }) + return + } + + c.Next() + } +} diff --git a/internal/model/init.go b/internal/model/init.go index 03007ac..122e390 100644 --- a/internal/model/init.go +++ b/internal/model/init.go @@ -141,6 +141,15 @@ func autoMigrate(db *gorm.DB) error { // 课表 &ScheduleCourse{}, + + // 用户活跃相关 + &UserActiveLog{}, + &UserActivityStat{}, + + // 角色和权限相关 + &Role{}, + &UserRole{}, + &CasbinRule{}, ) if err != nil { return err diff --git a/internal/model/role.go b/internal/model/role.go new file mode 100644 index 0000000..f560055 --- /dev/null +++ b/internal/model/role.go @@ -0,0 +1,57 @@ +package model + +import "time" + +// Role 角色定义 +type Role struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"uniqueIndex;size:64;not null" json:"name"` + DisplayName string `gorm:"size:128;not null" json:"display_name"` + Description string `json:"description"` + ParentRole *string `gorm:"size:64" json:"parent_role"` + Priority int `gorm:"default:0" json:"priority"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (Role) TableName() string { + return "roles" +} + +// UserRole 用户角色关联 +type UserRole struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID string `gorm:"index;size:64;not null" json:"user_id"` + Role string `gorm:"index;size:64;not null" json:"role"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (UserRole) TableName() string { + return "user_roles" +} + +// CasbinRule Casbin 策略规则 (对应 casbin_rule 表) +type CasbinRule struct { + ID uint `gorm:"primaryKey;autoIncrement"` + Ptype string `gorm:"size:255;not null;index"` + V0 string `gorm:"size:255;not null;index"` + V1 string `gorm:"size:255;not null;index"` + V2 string `gorm:"size:255;default:''"` + V3 string `gorm:"size:255;default:''"` + V4 string `gorm:"size:255;default:''"` + V5 string `gorm:"size:255;default:''"` +} + +func (CasbinRule) TableName() string { + return "casbin_rule" +} + +// 预定义角色常量 +const ( + RoleSuperAdmin = "super_admin" + RoleAdmin = "admin" + RoleModerator = "moderator" + RoleUser = "user" + RoleBanned = "banned" +) diff --git a/internal/model/user_active_log.go b/internal/model/user_active_log.go new file mode 100644 index 0000000..591b109 --- /dev/null +++ b/internal/model/user_active_log.go @@ -0,0 +1,49 @@ +package model + +import ( + "time" +) + +// 登录类型常量 +const ( + LoginTypeLogin = "login" // 登录 + LoginTypeRefresh = "refresh" // 刷新Token +) + +// UserActiveLog 用户活跃日志 +type UserActiveLog struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + UserID string `gorm:"type:varchar(36);not null;index:idx_user_date,priority:1;index" json:"user_id"` // 用户ID (UUID) + ActiveDate time.Time `gorm:"type:date;not null;index:idx_user_date,priority:2;index" json:"active_date"` // 活跃日期 + LoginType string `gorm:"type:varchar(20);not null" json:"login_type"` // 登录类型: login/refresh + LoginIP string `gorm:"type:varchar(45)" json:"login_ip"` // 登录IP + UserAgent string `gorm:"type:varchar(500)" json:"user_agent"` // 用户代理 + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + + // 关联 + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` +} + +// TableName 指定表名 +func (UserActiveLog) TableName() string { + return "user_active_logs" +} + +// UserActivityStat 用户活跃统计快照 +type UserActivityStat struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + StatType string `gorm:"type:varchar(10);not null;uniqueIndex:idx_type_date" json:"stat_type"` // dau/wau/mau + StatDate time.Time `gorm:"type:date;not null;uniqueIndex:idx_type_date" json:"stat_date"` // 统计日期 + ActiveCount int64 `gorm:"not null" json:"active_count"` // 活跃用户数 + NewCount int64 `gorm:"default:0" json:"new_count"` // 新增用户数 + Retention1 float64 `gorm:"type:decimal(5,2)" json:"retention_1"` // 次日留存率 + Retention7 float64 `gorm:"type:decimal(5,2)" json:"retention_7"` // 7日留存率 + Retention30 float64 `gorm:"type:decimal(5,2)" json:"retention_30"` // 30日留存率 + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +// TableName 指定表名 +func (UserActivityStat) TableName() string { + return "user_activity_stats" +} diff --git a/internal/repository/role_repo.go b/internal/repository/role_repo.go new file mode 100644 index 0000000..8949649 --- /dev/null +++ b/internal/repository/role_repo.go @@ -0,0 +1,97 @@ +package repository + +import ( + "context" + + "carrot_bbs/internal/model" + + "gorm.io/gorm" +) + +// RoleRepository 角色仓储接口 +type RoleRepository interface { + // GetUserRoles 获取用户的所有角色 + GetUserRoles(ctx context.Context, userID string) ([]string, error) + + // AddRoleForUser 为用户添加角色 + AddRoleForUser(ctx context.Context, userID, role string) error + + // DeleteRoleForUser 移除用户的角色 + DeleteRoleForUser(ctx context.Context, userID, role string) error + + // DeleteAllRolesForUser 移除用户的所有角色 + DeleteAllRolesForUser(ctx context.Context, userID string) error + + // HasRole 检查用户是否拥有指定角色 + HasRole(ctx context.Context, userID, role string) (bool, error) + + // GetRole 获取角色信息 + GetRole(ctx context.Context, name string) (*model.Role, error) + + // GetAllRoles 获取所有角色 + GetAllRoles(ctx context.Context) ([]model.Role, error) +} + +type roleRepository struct { + db *gorm.DB +} + +func NewRoleRepository(db *gorm.DB) RoleRepository { + return &roleRepository{db: db} +} + +func (r *roleRepository) GetUserRoles(ctx context.Context, userID string) ([]string, error) { + var userRoles []model.UserRole + if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&userRoles).Error; err != nil { + return nil, err + } + roles := make([]string, len(userRoles)) + for i, ur := range userRoles { + roles[i] = ur.Role + } + return roles, nil +} + +func (r *roleRepository) AddRoleForUser(ctx context.Context, userID, role string) error { + userRole := model.UserRole{ + UserID: userID, + Role: role, + } + return r.db.WithContext(ctx).Create(&userRole).Error +} + +func (r *roleRepository) DeleteRoleForUser(ctx context.Context, userID, role string) error { + return r.db.WithContext(ctx). + Where("user_id = ? AND role = ?", userID, role). + Delete(&model.UserRole{}).Error +} + +func (r *roleRepository) DeleteAllRolesForUser(ctx context.Context, userID string) error { + return r.db.WithContext(ctx). + Where("user_id = ?", userID). + Delete(&model.UserRole{}).Error +} + +func (r *roleRepository) HasRole(ctx context.Context, userID, role string) (bool, error) { + var count int64 + err := r.db.WithContext(ctx). + Model(&model.UserRole{}). + Where("user_id = ? AND role = ?", userID, role). + Count(&count).Error + return count > 0, err +} + +func (r *roleRepository) GetRole(ctx context.Context, name string) (*model.Role, error) { + var role model.Role + err := r.db.WithContext(ctx).Where("name = ?", name).First(&role).Error + if err != nil { + return nil, err + } + return &role, nil +} + +func (r *roleRepository) GetAllRoles(ctx context.Context) ([]model.Role, error) { + var roles []model.Role + err := r.db.WithContext(ctx).Order("priority DESC").Find(&roles).Error + return roles, err +} diff --git a/internal/repository/user_activity_repo.go b/internal/repository/user_activity_repo.go new file mode 100644 index 0000000..dcd3956 --- /dev/null +++ b/internal/repository/user_activity_repo.go @@ -0,0 +1,225 @@ +package repository + +import ( + "context" + "fmt" + "time" + + "carrot_bbs/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// Redis Key 前缀常量(遵循项目命名规范) +const ( + // DAUKeyPrefix DAU Key: stats:dau:YYYYMMDD + DAUKeyPrefix = "stats:dau:" + // WAUKeyPrefix WAU Key: stats:wau:YYYYWW (年-周) + WAUKeyPrefix = "stats:wau:" + // MAUKeyPrefix MAU Key: stats:mau:YYYYMM + MAUKeyPrefix = "stats:mau:" +) + +// TTL 常量 +const ( + DAUTTL = 45 * 24 * time.Hour // DAU: 45天 + WAUTTL = 120 * 24 * time.Hour // WAU: 120天 + MAUTTL = 400 * 24 * time.Hour // MAU: 400天 +) + +// activityCache 用户活跃缓存接口(避免循环导入) +type activityCache interface { + ZAdd(ctx context.Context, key string, score float64, member string) error + ZCard(ctx context.Context, key string) (int64, error) + ZRangeByScore(ctx context.Context, key string, min, max string, offset, count int64) ([]string, error) + Expire(ctx context.Context, key string, ttl time.Duration) error +} + +// UserActivityRepository 用户活跃数据仓储 +type UserActivityRepository struct { + db *gorm.DB + cache activityCache +} + +// NewUserActivityRepository 创建用户活跃数据仓储 +func NewUserActivityRepository(db *gorm.DB, cache activityCache) *UserActivityRepository { + return &UserActivityRepository{db: db, cache: cache} +} + +// ==================== 数据库操作方法 ==================== + +// RecordActivity 记录用户活跃(写入数据库) +func (r *UserActivityRepository) RecordActivity(ctx context.Context, userID, loginType, ip, userAgent string) error { + log := &model.UserActiveLog{ + UserID: userID, + ActiveDate: time.Now(), + LoginType: loginType, + LoginIP: ip, + UserAgent: userAgent, + } + return r.db.WithContext(ctx).Create(log).Error +} + +// GetDAU 获取指定日期的日活用户数(从数据库) +func (r *UserActivityRepository) GetDAU(ctx context.Context, date time.Time) (int64, error) { + var count int64 + startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + endOfDay := startOfDay.Add(24 * time.Hour) + + err := r.db.WithContext(ctx). + Model(&model.UserActiveLog{}). + Where("active_date >= ? AND active_date < ?", startOfDay, endOfDay). + Distinct("user_id"). + Count(&count).Error + + return count, err +} + +// GetActiveUserIDs 获取指定日期的活跃用户ID列表 +func (r *UserActivityRepository) GetActiveUserIDs(ctx context.Context, date time.Time) ([]string, error) { + var userIDs []string + startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + endOfDay := startOfDay.Add(24 * time.Hour) + + err := r.db.WithContext(ctx). + Model(&model.UserActiveLog{}). + Select("DISTINCT user_id"). + Where("active_date >= ? AND active_date < ?", startOfDay, endOfDay). + Pluck("user_id", &userIDs).Error + + return userIDs, err +} + +// GetUserActivityCount 获取用户在指定日期范围内的活跃天数 +func (r *UserActivityRepository) GetUserActivityCount(ctx context.Context, userID string, startDate, endDate time.Time) (int64, error) { + var count int64 + startOfDay := time.Date(startDate.Year(), startDate.Month(), startDate.Day(), 0, 0, 0, 0, startDate.Location()) + endOfDay := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 23, 59, 59, 0, endDate.Location()) + + err := r.db.WithContext(ctx). + Model(&model.UserActiveLog{}). + Where("user_id = ? AND active_date >= ? AND active_date <= ?", userID, startOfDay, endOfDay). + Distinct("DATE(active_date)"). + Count(&count).Error + + return count, err +} + +// SaveStat 保存统计数据快照 +func (r *UserActivityRepository) SaveStat(ctx context.Context, stat *model.UserActivityStat) error { + return r.db.WithContext(ctx). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "stat_type"}, {Name: "stat_date"}}, + DoUpdates: clause.AssignmentColumns([]string{"active_count", "new_count", "retention_1", "retention_7", "retention_30", "updated_at"}), + }). + Create(stat).Error +} + +// GetStat 获取统计数据快照 +func (r *UserActivityRepository) GetStat(ctx context.Context, statType string, date time.Time) (*model.UserActivityStat, error) { + var stat model.UserActivityStat + startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + + err := r.db.WithContext(ctx). + Where("stat_type = ? AND stat_date = ?", statType, startOfDay). + First(&stat).Error + if err != nil { + return nil, err + } + return &stat, nil +} + +// ==================== Redis 操作方法 ==================== + +// RecordActivityToRedis 记录用户活跃到 Redis Sorted Set +// score 为时间戳,member 为用户ID +func (r *UserActivityRepository) RecordActivityToRedis(ctx context.Context, userID string) error { + now := time.Now() + score := float64(now.Unix()) + + // 记录到 DAU + dauKey := formatDAUKey(now) + if err := r.cache.ZAdd(ctx, dauKey, score, userID); err != nil { + return fmt.Errorf("failed to record DAU: %w", err) + } + r.cache.Expire(ctx, dauKey, DAUTTL) + + // 记录到 WAU + year, week := now.ISOWeek() + wauKey := formatWAUKey(year, week) + if err := r.cache.ZAdd(ctx, wauKey, score, userID); err != nil { + return fmt.Errorf("failed to record WAU: %w", err) + } + r.cache.Expire(ctx, wauKey, WAUTTL) + + // 记录到 MAU + mauKey := formatMAUKey(now.Year(), int(now.Month())) + if err := r.cache.ZAdd(ctx, mauKey, score, userID); err != nil { + return fmt.Errorf("failed to record MAU: %w", err) + } + r.cache.Expire(ctx, mauKey, MAUTTL) + + return nil +} + +// GetDAUFromRedis 从 Redis 获取日活用户数 +func (r *UserActivityRepository) GetDAUFromRedis(ctx context.Context, date time.Time) (int64, error) { + key := formatDAUKey(date) + count, err := r.cache.ZCard(ctx, key) + if err != nil { + return 0, fmt.Errorf("failed to get DAU from redis: %w", err) + } + return count, nil +} + +// GetWAUFromRedis 从 Redis 获取周活用户数 +func (r *UserActivityRepository) GetWAUFromRedis(ctx context.Context, year int, week int) (int64, error) { + key := formatWAUKey(year, week) + count, err := r.cache.ZCard(ctx, key) + if err != nil { + return 0, fmt.Errorf("failed to get WAU from redis: %w", err) + } + return count, nil +} + +// GetMAUFromRedis 从 Redis 获取月活用户数 +func (r *UserActivityRepository) GetMAUFromRedis(ctx context.Context, year int, month int) (int64, error) { + key := formatMAUKey(year, month) + count, err := r.cache.ZCard(ctx, key) + if err != nil { + return 0, fmt.Errorf("failed to get MAU from redis: %w", err) + } + return count, nil +} + +// GetDAUUserIDsFromRedis 获取日活用户ID列表 +func (r *UserActivityRepository) GetDAUUserIDsFromRedis(ctx context.Context, date time.Time) ([]string, error) { + key := formatDAUKey(date) + // 获取所有成员(从最小分数到最大分数) + members, err := r.cache.ZRangeByScore(ctx, key, "-inf", "+inf", 0, -1) + if err != nil { + return nil, fmt.Errorf("failed to get DAU user IDs from redis: %w", err) + } + return members, nil +} + +// ==================== 辅助方法 ==================== + +// formatDAUKey 格式化 DAU Key +// 格式: stats:dau:YYYYMMDD +func formatDAUKey(date time.Time) string { + return fmt.Sprintf("%s%s", DAUKeyPrefix, date.Format("20060102")) +} + +// formatWAUKey 格式化 WAU Key +// 格式: stats:wau:YYYYWW (年-周,周数补零到2位) +func formatWAUKey(year int, week int) string { + return fmt.Sprintf("%s%04d%02d", WAUKeyPrefix, year, week) +} + +// formatMAUKey 格式化 MAU Key +// 格式: stats:mau:YYYYMM +func formatMAUKey(year int, month int) string { + return fmt.Sprintf("%s%04d%02d", MAUKeyPrefix, year, month) +} diff --git a/internal/router/router.go b/internal/router/router.go index 3afb9d4..369f1bc 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -5,6 +5,7 @@ import ( "carrot_bbs/internal/handler" "carrot_bbs/internal/middleware" + "carrot_bbs/internal/model" "carrot_bbs/internal/service" ) @@ -24,7 +25,9 @@ type Router struct { gorseHandler *handler.GorseHandler voteHandler *handler.VoteHandler scheduleHandler *handler.ScheduleHandler + roleHandler *handler.RoleHandler jwtService *service.JWTService + casbinService service.CasbinService } // New 创建路由 @@ -43,9 +46,14 @@ func New( gorseHandler *handler.GorseHandler, voteHandler *handler.VoteHandler, scheduleHandler *handler.ScheduleHandler, + roleHandler *handler.RoleHandler, + activityService service.UserActivityService, + casbinService service.CasbinService, ) *Router { // 设置JWT服务 userHandler.SetJWTService(jwtService) + // 设置活跃统计服务 + userHandler.SetActivityService(activityService) r := &Router{ engine: gin.Default(), @@ -62,7 +70,9 @@ func New( gorseHandler: gorseHandler, voteHandler: voteHandler, scheduleHandler: scheduleHandler, + roleHandler: roleHandler, jwtService: jwtService, + casbinService: casbinService, } r.setupRoutes() @@ -95,6 +105,8 @@ func (r *Router) setupRoutes() { // 需要认证的路由 authMiddleware := middleware.Auth(r.jwtService) + // Casbin 权限中间件(暂不全局启用,可根据需要在特定路由组上使用) + // casbinMiddleware := middleware.CasbinAuth(r.casbinService) // 用户路由 users := v1.Group("/users") @@ -109,6 +121,11 @@ func (r *Router) setupRoutes() { users.POST("/change-password/send-code", authMiddleware, r.userHandler.SendChangePasswordCode) users.POST("/change-password", authMiddleware, r.userHandler.ChangePassword) + // 当前用户角色 + if r.roleHandler != nil { + users.GET("/me/roles", authMiddleware, r.roleHandler.GetMyRoles) + } + // 搜索用户 users.GET("/search", middleware.OptionalAuth(r.jwtService), r.userHandler.Search) @@ -339,6 +356,20 @@ func (r *Router) setupRoutes() { gorseGroup.POST("/import", r.gorseHandler.ImportData) } } + + // 管理路由(需要管理员权限) + if r.roleHandler != nil { + admin := v1.Group("/admin") + admin.Use(authMiddleware) + admin.Use(middleware.RequireRole(r.casbinService, model.RoleAdmin, model.RoleSuperAdmin)) + { + // 角色管理 + admin.GET("/roles", r.roleHandler.GetAllRoles) + admin.GET("/users/:id/roles", r.roleHandler.GetUserRoles) + admin.POST("/users/:id/roles", r.roleHandler.AssignRole) + admin.DELETE("/users/:id/roles/:role", r.roleHandler.RemoveRole) + } + } } } diff --git a/internal/service/audit_service.go b/internal/service/audit_service.go index efebbe0..7b37e77 100644 --- a/internal/service/audit_service.go +++ b/internal/service/audit_service.go @@ -10,22 +10,13 @@ import ( "time" "carrot_bbs/internal/model" + "carrot_bbs/internal/pkg/openai" "gorm.io/gorm" ) // ==================== 内容审核服务接口和实现 ==================== -// AuditServiceProvider 内容审核服务提供商接口 -type AuditServiceProvider interface { - // AuditText 审核文本 - AuditText(ctx context.Context, text string, scene string) (*AuditResult, error) - // AuditImage 审核图片 - AuditImage(ctx context.Context, imageURL string) (*AuditResult, error) - // GetName 获取提供商名称 - GetName() string -} - // AuditResult 审核结果 type AuditResult struct { Pass bool `json:"pass"` // 是否通过 @@ -42,169 +33,293 @@ type AuditService interface { AuditText(ctx context.Context, text string, auditType string) (*AuditResult, error) // AuditImage 审核图片 AuditImage(ctx context.Context, imageURL string) (*AuditResult, error) + // AuditPost 审核帖子 + AuditPost(ctx context.Context, title, content string, images []string) (*AuditResult, error) + // AuditComment 审核评论 + AuditComment(ctx context.Context, content string, images []string) (*AuditResult, error) // GetAuditResult 获取审核结果 GetAuditResult(ctx context.Context, auditID string) (*AuditResult, error) - // SetProvider 设置审核服务提供商 - SetProvider(provider AuditServiceProvider) - // GetProvider 获取当前审核服务提供商 - GetProvider() AuditServiceProvider + // IsEnabled 检查审核服务是否启用 + IsEnabled() bool } // auditServiceImpl 内容审核服务实现 type auditServiceImpl struct { - db *gorm.DB - provider AuditServiceProvider - config *AuditConfig - mu sync.RWMutex + db *gorm.DB + openaiClient openai.Client + config *AuditConfig + mu sync.RWMutex } // AuditConfig 内容审核服务配置 type AuditConfig struct { - Enabled bool `mapstructure:"enabled" yaml:"enabled"` - // 审核服务提供商: local, aliyun, tencent, baidu - Provider string `mapstructure:"provider" yaml:"provider"` - // 阿里云配置 - AliyunAccessKey string `mapstructure:"aliyun_access_key" yaml:"aliyun_access_key"` - AliyunSecretKey string `mapstructure:"aliyun_secret_key" yaml:"aliyun_secret_key"` - AliyunRegion string `mapstructure:"aliyun_region" yaml:"aliyun_region"` - // 腾讯云配置 - TencentSecretID string `mapstructure:"tencent_secret_id" yaml:"tencent_secret_id"` - TencentSecretKey string `mapstructure:"tencent_secret_key" yaml:"tencent_secret_key"` - // 百度云配置 - BaiduAPIKey string `mapstructure:"baidu_api_key" yaml:"baidu_api_key"` - BaiduSecretKey string `mapstructure:"baidu_secret_key" yaml:"baidu_secret_key"` - // 是否自动审核 - AutoAudit bool `mapstructure:"auto_audit" yaml:"auto_audit"` - // 审核超时时间(秒) - Timeout int `mapstructure:"timeout" yaml:"timeout"` + Enabled bool `mapstructure:"enabled" yaml:"enabled"` + StrictModeration bool `mapstructure:"strict_moderation" yaml:"strict_moderation"` // 严格模式:审核失败时拒绝;非严格模式:审核失败时放行 + Timeout int `mapstructure:"timeout" yaml:"timeout"` } // NewAuditService 创建内容审核服务 -func NewAuditService(db *gorm.DB, config *AuditConfig) AuditService { - s := &auditServiceImpl{ - db: db, - config: config, +func NewAuditService(db *gorm.DB, openaiClient openai.Client, config *AuditConfig) AuditService { + return &auditServiceImpl{ + db: db, + openaiClient: openaiClient, + config: config, } - - // 根据配置初始化提供商 - if config.Enabled { - provider := s.initProvider(config.Provider) - s.provider = provider - } - - return s } -// initProvider 根据配置初始化审核服务提供商 -func (s *auditServiceImpl) initProvider(providerType string) AuditServiceProvider { - switch strings.ToLower(providerType) { - case "aliyun": - return NewAliyunAuditProvider(s.config.AliyunAccessKey, s.config.AliyunSecretKey, s.config.AliyunRegion) - case "tencent": - return NewTencentAuditProvider(s.config.TencentSecretID, s.config.TencentSecretKey) - case "baidu": - return NewBaiduAuditProvider(s.config.BaiduAPIKey, s.config.BaiduSecretKey) - case "local": - fallthrough - default: - // 默认使用本地审核服务 - return NewLocalAuditProvider() - } +// IsEnabled 检查审核服务是否启用 +func (s *auditServiceImpl) IsEnabled() bool { + return s != nil && s.config != nil && s.config.Enabled && s.openaiClient != nil && s.openaiClient.IsEnabled() } // AuditText 审核文本 func (s *auditServiceImpl) AuditText(ctx context.Context, text string, auditType string) (*AuditResult, error) { - if !s.config.Enabled { - // 如果审核服务未启用,直接返回通过 + if !s.IsEnabled() { return &AuditResult{ - Pass: true, - Risk: "low", - Suggest: "pass", - Detail: "Audit service disabled", + Pass: true, + Risk: "low", + Suggest: "pass", + Detail: "Audit service disabled", + Provider: "ai", }, nil } if text == "" { return &AuditResult{ - Pass: true, - Risk: "low", - Suggest: "pass", - Detail: "Empty text", + Pass: true, + Risk: "low", + Suggest: "pass", + Detail: "Empty text", + Provider: "ai", }, nil } - var result *AuditResult - var err error - - // 使用提供商审核 - if s.provider != nil { - result, err = s.provider.AuditText(ctx, text, auditType) - } else { - // 如果没有设置提供商,使用本地审核 - localProvider := NewLocalAuditProvider() - result, err = localProvider.AuditText(ctx, text, auditType) + // 使用 AI 审核文本 + approved, reason, err := s.openaiClient.ModerateComment(ctx, text, nil) + if err != nil { + log.Printf("AI audit text error: %v", err) + if s.config.StrictModeration { + return &AuditResult{ + Pass: false, + Risk: "high", + Suggest: "review", + Detail: fmt.Sprintf("Audit error: %v", err), + Provider: "ai", + }, err + } + // 非严格模式下,审核失败时放行 + return &AuditResult{ + Pass: true, + Risk: "low", + Suggest: "pass", + Detail: fmt.Sprintf("Audit fallback pass due to error: %v", err), + Provider: "ai", + }, nil } - if err != nil { - log.Printf("Audit text error: %v", err) - return &AuditResult{ - Pass: false, - Risk: "high", - Suggest: "review", - Detail: fmt.Sprintf("Audit error: %v", err), - }, err + result := &AuditResult{ + Pass: approved, + Provider: "ai", + Detail: reason, + } + + if approved { + result.Risk = "low" + result.Suggest = "pass" + } else { + result.Risk = "high" + result.Suggest = "block" + result.Labels = []string{"ai_rejected"} } // 记录审核日志 - go s.saveAuditLog(ctx, "text", "", text, auditType, result) + go s.saveAuditLog(ctx, "text", text, "", auditType, result) return result, nil } // AuditImage 审核图片 func (s *auditServiceImpl) AuditImage(ctx context.Context, imageURL string) (*AuditResult, error) { - if !s.config.Enabled { + if !s.IsEnabled() { return &AuditResult{ - Pass: true, - Risk: "low", - Suggest: "pass", - Detail: "Audit service disabled", + Pass: true, + Risk: "low", + Suggest: "pass", + Detail: "Audit service disabled", + Provider: "ai", }, nil } if imageURL == "" { return &AuditResult{ - Pass: true, - Risk: "low", - Suggest: "pass", - Detail: "Empty image URL", + Pass: true, + Risk: "low", + Suggest: "pass", + Detail: "Empty image URL", + Provider: "ai", }, nil } - var result *AuditResult - var err error - - // 使用提供商审核 - if s.provider != nil { - result, err = s.provider.AuditImage(ctx, imageURL) - } else { - // 如果没有设置提供商,使用本地审核 - localProvider := NewLocalAuditProvider() - result, err = localProvider.AuditImage(ctx, imageURL) + // 使用 AI 审核图片 + approved, reason, err := s.openaiClient.ModerateComment(ctx, "", []string{imageURL}) + if err != nil { + log.Printf("AI audit image error: %v", err) + if s.config.StrictModeration { + return &AuditResult{ + Pass: false, + Risk: "high", + Suggest: "review", + Detail: fmt.Sprintf("Audit error: %v", err), + Provider: "ai", + }, err + } + // 非严格模式下,审核失败时放行 + return &AuditResult{ + Pass: true, + Risk: "low", + Suggest: "pass", + Detail: fmt.Sprintf("Audit fallback pass due to error: %v", err), + Provider: "ai", + }, nil } - if err != nil { - log.Printf("Audit image error: %v", err) - return &AuditResult{ - Pass: false, - Risk: "high", - Suggest: "review", - Detail: fmt.Sprintf("Audit error: %v", err), - }, err + result := &AuditResult{ + Pass: approved, + Provider: "ai", + Detail: reason, + } + + if approved { + result.Risk = "low" + result.Suggest = "pass" + } else { + result.Risk = "high" + result.Suggest = "block" + result.Labels = []string{"ai_rejected"} } // 记录审核日志 - go s.saveAuditLog(ctx, "image", "", "", "image", result) + go s.saveAuditLog(ctx, "image", "", imageURL, "image", result) + + return result, nil +} + +// AuditPost 审核帖子 +func (s *auditServiceImpl) AuditPost(ctx context.Context, title, content string, images []string) (*AuditResult, error) { + if !s.IsEnabled() { + return &AuditResult{ + Pass: true, + Risk: "low", + Suggest: "pass", + Detail: "Audit service disabled", + Provider: "ai", + }, nil + } + + // 使用 AI 审核帖子 + approved, reason, err := s.openaiClient.ModeratePost(ctx, title, content, images) + if err != nil { + log.Printf("AI audit post error: %v", err) + if s.config.StrictModeration { + return &AuditResult{ + Pass: false, + Risk: "high", + Suggest: "review", + Detail: fmt.Sprintf("Audit error: %v", err), + Provider: "ai", + }, err + } + // 非严格模式下,审核失败时放行 + return &AuditResult{ + Pass: true, + Risk: "low", + Suggest: "pass", + Detail: fmt.Sprintf("Audit fallback pass due to error: %v", err), + Provider: "ai", + }, nil + } + + result := &AuditResult{ + Pass: approved, + Provider: "ai", + Detail: reason, + } + + if approved { + result.Risk = "low" + result.Suggest = "pass" + } else { + result.Risk = "high" + result.Suggest = "block" + result.Labels = []string{"ai_rejected"} + } + + // 记录审核日志 + contentSummary := fmt.Sprintf("标题: %s, 内容: %s", title, content) + if len(content) > 200 { + contentSummary = fmt.Sprintf("标题: %s, 内容: %s...", title, content[:200]) + } + go s.saveAuditLog(ctx, "post", contentSummary, strings.Join(images, ","), "post", result) + + return result, nil +} + +// AuditComment 审核评论 +func (s *auditServiceImpl) AuditComment(ctx context.Context, content string, images []string) (*AuditResult, error) { + if !s.IsEnabled() { + return &AuditResult{ + Pass: true, + Risk: "low", + Suggest: "pass", + Detail: "Audit service disabled", + Provider: "ai", + }, nil + } + + // 使用 AI 审核评论 + approved, reason, err := s.openaiClient.ModerateComment(ctx, content, images) + if err != nil { + log.Printf("AI audit comment error: %v", err) + if s.config.StrictModeration { + return &AuditResult{ + Pass: false, + Risk: "high", + Suggest: "review", + Detail: fmt.Sprintf("Audit error: %v", err), + Provider: "ai", + }, err + } + // 非严格模式下,审核失败时放行 + return &AuditResult{ + Pass: true, + Risk: "low", + Suggest: "pass", + Detail: fmt.Sprintf("Audit fallback pass due to error: %v", err), + Provider: "ai", + }, nil + } + + result := &AuditResult{ + Pass: approved, + Provider: "ai", + Detail: reason, + } + + if approved { + result.Risk = "low" + result.Suggest = "pass" + } else { + result.Risk = "high" + result.Suggest = "block" + result.Labels = []string{"ai_rejected"} + } + + // 记录审核日志 + contentSummary := content + if len(content) > 200 { + contentSummary = content[:200] + "..." + } + go s.saveAuditLog(ctx, "comment", contentSummary, strings.Join(images, ","), "comment", result) return result, nil } @@ -235,32 +350,25 @@ func (s *auditServiceImpl) GetAuditResult(ctx context.Context, auditID string) ( return result, nil } -// SetProvider 设置审核服务提供商 -func (s *auditServiceImpl) SetProvider(provider AuditServiceProvider) { - s.mu.Lock() - defer s.mu.Unlock() - s.provider = provider -} - -// GetProvider 获取当前审核服务提供商 -func (s *auditServiceImpl) GetProvider() AuditServiceProvider { - s.mu.RLock() - defer s.mu.RUnlock() - return s.provider -} - // saveAuditLog 保存审核日志 func (s *auditServiceImpl) saveAuditLog(ctx context.Context, contentType, content, imageURL, auditType string, result *AuditResult) { if s.db == nil { return } + labelsJSON := "" + if len(result.Labels) > 0 { + if data, err := json.Marshal(result.Labels); err == nil { + labelsJSON = string(data) + } + } + auditLog := model.AuditLog{ ContentType: contentType, Content: content, ContentURL: imageURL, AuditType: auditType, - Labels: strings.Join(result.Labels, ","), + Labels: labelsJSON, Suggestion: result.Suggest, Detail: result.Detail, Source: model.AuditSourceAuto, @@ -291,294 +399,6 @@ func (s *auditServiceImpl) saveAuditLog(ctx context.Context, contentType, conten } } -// ==================== 本地审核服务提供商 ==================== - -// localAuditProvider 本地审核服务提供商 -type localAuditProvider struct { - // 可以注入敏感词服务进行本地审核 - sensitiveService SensitiveService -} - -// NewLocalAuditProvider 创建本地审核服务提供商 -func NewLocalAuditProvider() AuditServiceProvider { - return &localAuditProvider{ - sensitiveService: nil, - } -} - -// GetName 获取提供商名称 -func (p *localAuditProvider) GetName() string { - return "local" -} - -// AuditText 审核文本 -func (p *localAuditProvider) AuditText(ctx context.Context, text string, scene string) (*AuditResult, error) { - // 本地审核逻辑 - // 1. 敏感词检查 - // 2. 规则匹配 - // 3. 简单的关键词检测 - - result := &AuditResult{ - Pass: true, - Risk: "low", - Suggest: "pass", - Labels: []string{}, - Provider: "local", - } - - // 如果有敏感词服务,使用它进行检测 - if p.sensitiveService != nil { - hasSensitive, words := p.sensitiveService.Check(ctx, text) - if hasSensitive { - result.Pass = false - result.Risk = "high" - result.Suggest = "block" - result.Detail = fmt.Sprintf("包含敏感词: %s", strings.Join(words, ",")) - result.Labels = append(result.Labels, "sensitive") - } - } - - // 简单的关键词检测规则 - // 实际项目中应该从数据库加载 - suspiciousPatterns := []string{ - "诈骗", - "钓鱼", - "木马", - "病毒", - } - - for _, pattern := range suspiciousPatterns { - if strings.Contains(text, pattern) { - result.Pass = false - result.Risk = "high" - result.Suggest = "block" - result.Labels = append(result.Labels, "suspicious") - if result.Detail == "" { - result.Detail = fmt.Sprintf("包含可疑内容: %s", pattern) - } else { - result.Detail += fmt.Sprintf(", %s", pattern) - } - } - } - - return result, nil -} - -// AuditImage 审核图片 -func (p *localAuditProvider) AuditImage(ctx context.Context, imageURL string) (*AuditResult, error) { - // 本地图片审核逻辑 - // 1. 图片URL合法性检查 - // 2. 图片格式检查 - // 3. 可以扩展接入本地图片识别服务 - - result := &AuditResult{ - Pass: true, - Risk: "low", - Suggest: "pass", - Labels: []string{}, - Provider: "local", - } - - // 检查URL是否为空 - if imageURL == "" { - result.Detail = "Empty image URL" - return result, nil - } - - // 检查是否为支持的图片URL格式 - validPrefixes := []string{"http://", "https://", "s3://", "oss://", "cos://"} - isValid := false - for _, prefix := range validPrefixes { - if strings.HasPrefix(strings.ToLower(imageURL), prefix) { - isValid = true - break - } - } - - if !isValid { - result.Pass = false - result.Risk = "medium" - result.Suggest = "review" - result.Detail = "Invalid image URL format" - result.Labels = append(result.Labels, "invalid_url") - } - - return result, nil -} - -// SetSensitiveService 设置敏感词服务 -func (p *localAuditProvider) SetSensitiveService(ss SensitiveService) { - p.sensitiveService = ss -} - -// ==================== 阿里云审核服务提供商 ==================== - -// aliyunAuditProvider 阿里云审核服务提供商 -type aliyunAuditProvider struct { - accessKey string - secretKey string - region string -} - -// NewAliyunAuditProvider 创建阿里云审核服务提供商 -func NewAliyunAuditProvider(accessKey, secretKey, region string) AuditServiceProvider { - return &aliyunAuditProvider{ - accessKey: accessKey, - secretKey: secretKey, - region: region, - } -} - -// GetName 获取提供商名称 -func (p *aliyunAuditProvider) GetName() string { - return "aliyun" -} - -// AuditText 审核文本 -func (p *aliyunAuditProvider) AuditText(ctx context.Context, text string, scene string) (*AuditResult, error) { - // 阿里云内容安全API调用 - // 实际项目中需要实现阿里云SDK调用 - // 这里预留接口 - - result := &AuditResult{ - Pass: true, - Risk: "low", - Suggest: "pass", - Labels: []string{}, - Provider: "aliyun", - Detail: "Aliyun audit not implemented, using pass", - } - - // TODO: 实现阿里云内容安全API调用 - // 具体参考: https://help.aliyun.com/document_detail/28417.html - - return result, nil -} - -// AuditImage 审核图片 -func (p *aliyunAuditProvider) AuditImage(ctx context.Context, imageURL string) (*AuditResult, error) { - result := &AuditResult{ - Pass: true, - Risk: "low", - Suggest: "pass", - Labels: []string{}, - Provider: "aliyun", - Detail: "Aliyun image audit not implemented, using pass", - } - - // TODO: 实现阿里云图片审核API调用 - - return result, nil -} - -// ==================== 腾讯云审核服务提供商 ==================== - -// tencentAuditProvider 腾讯云审核服务提供商 -type tencentAuditProvider struct { - secretID string - secretKey string -} - -// NewTencentAuditProvider 创建腾讯云审核服务提供商 -func NewTencentAuditProvider(secretID, secretKey string) AuditServiceProvider { - return &tencentAuditProvider{ - secretID: secretID, - secretKey: secretKey, - } -} - -// GetName 获取提供商名称 -func (p *tencentAuditProvider) GetName() string { - return "tencent" -} - -// AuditText 审核文本 -func (p *tencentAuditProvider) AuditText(ctx context.Context, text string, scene string) (*AuditResult, error) { - result := &AuditResult{ - Pass: true, - Risk: "low", - Suggest: "pass", - Labels: []string{}, - Provider: "tencent", - Detail: "Tencent audit not implemented, using pass", - } - - // TODO: 实现腾讯云内容审核API调用 - // 具体参考: https://cloud.tencent.com/document/product/1124/64508 - - return result, nil -} - -// AuditImage 审核图片 -func (p *tencentAuditProvider) AuditImage(ctx context.Context, imageURL string) (*AuditResult, error) { - result := &AuditResult{ - Pass: true, - Risk: "low", - Suggest: "pass", - Labels: []string{}, - Provider: "tencent", - Detail: "Tencent image audit not implemented, using pass", - } - - // TODO: 实现腾讯云图片审核API调用 - - return result, nil -} - -// ==================== 百度云审核服务提供商 ==================== - -// baiduAuditProvider 百度云审核服务提供商 -type baiduAuditProvider struct { - apiKey string - secretKey string -} - -// NewBaiduAuditProvider 创建百度云审核服务提供商 -func NewBaiduAuditProvider(apiKey, secretKey string) AuditServiceProvider { - return &baiduAuditProvider{ - apiKey: apiKey, - secretKey: secretKey, - } -} - -// GetName 获取提供商名称 -func (p *baiduAuditProvider) GetName() string { - return "baidu" -} - -// AuditText 审核文本 -func (p *baiduAuditProvider) AuditText(ctx context.Context, text string, scene string) (*AuditResult, error) { - result := &AuditResult{ - Pass: true, - Risk: "low", - Suggest: "pass", - Labels: []string{}, - Provider: "baidu", - Detail: "Baidu audit not implemented, using pass", - } - - // TODO: 实现百度云内容审核API调用 - // 具体参考: https://cloud.baidu.com/doc/ANTISPAM/s/Jjw0r1iF6 - - return result, nil -} - -// AuditImage 审核图片 -func (p *baiduAuditProvider) AuditImage(ctx context.Context, imageURL string) (*AuditResult, error) { - result := &AuditResult{ - Pass: true, - Risk: "low", - Suggest: "pass", - Labels: []string{}, - Provider: "baidu", - Detail: "Baidu image audit not implemented, using pass", - } - - // TODO: 实现百度云图片审核API调用 - - return result, nil -} - // ==================== 审核结果回调处理 ==================== // AuditCallback 审核回调处理 diff --git a/internal/service/casbin_service.go b/internal/service/casbin_service.go new file mode 100644 index 0000000..cc1eb9d --- /dev/null +++ b/internal/service/casbin_service.go @@ -0,0 +1,225 @@ +package service + +import ( + "context" + "fmt" + "time" + + "carrot_bbs/internal/cache" + "carrot_bbs/internal/config" + "carrot_bbs/internal/repository" + + "github.com/casbin/casbin/v2" +) + +// CasbinService Casbin 权限服务接口 +type CasbinService interface { + // Enforce 检查权限 + Enforce(ctx context.Context, sub, obj, act string) (bool, error) + + // EnforceForUser 检查用户权限 (自动获取用户角色) + EnforceForUser(ctx context.Context, userID, obj, act string) (bool, error) + + // AddRoleForUser 为用户添加角色 + AddRoleForUser(ctx context.Context, userID, role string) error + + // DeleteRoleForUser 移除用户角色 + DeleteRoleForUser(ctx context.Context, userID, role string) error + + // GetRolesForUser 获取用户所有角色 + GetRolesForUser(ctx context.Context, userID string) ([]string, error) + + // HasRoleForUser 检查用户是否拥有指定角色 + HasRoleForUser(ctx context.Context, userID, role string) (bool, error) + + // AddPolicy 添加策略 + AddPolicy(ctx context.Context, role, resource, action string) error + + // RemovePolicy 移除策略 + RemovePolicy(ctx context.Context, role, resource, action string) error + + // LoadPolicy 从数据库重新加载策略 + LoadPolicy(ctx context.Context) error + + // ClearCache 清除权限缓存 + ClearCache(ctx context.Context) error +} + +type casbinService struct { + enforcer *casbin.Enforcer + cache cache.Cache + cacheTTL time.Duration + roleRepo repository.RoleRepository + skipRoutes map[string]bool +} + +func NewCasbinService( + enforcer *casbin.Enforcer, + cache cache.Cache, + roleRepo repository.RoleRepository, + cfg *config.CasbinConfig, +) CasbinService { + skipRoutes := make(map[string]bool) + for _, route := range cfg.SkipRoutes { + skipRoutes[route] = true + } + + return &casbinService{ + enforcer: enforcer, + cache: cache, + cacheTTL: time.Duration(cfg.CacheTTL) * time.Second, + roleRepo: roleRepo, + skipRoutes: skipRoutes, + } +} + +func (s *casbinService) Enforce(ctx context.Context, sub, obj, act string) (bool, error) { + // 检查是否跳过权限检查 + if s.skipRoutes[obj] { + return true, nil + } + + // 检查缓存 + if s.cache != nil { + cacheKey := fmt.Sprintf("casbin:check:%s:%s:%s", sub, obj, act) + if cached, ok := s.cache.Get(cacheKey); ok { + if allowed, ok := cached.(bool); ok { + return allowed, nil + } + } + } + + // 执行权限检查 + allowed, err := s.enforcer.Enforce(sub, obj, act) + if err != nil { + return false, err + } + + // 缓存结果 + if s.cache != nil { + cacheKey := fmt.Sprintf("casbin:check:%s:%s:%s", sub, obj, act) + s.cache.Set(cacheKey, allowed, s.cacheTTL) + } + + return allowed, nil +} + +func (s *casbinService) EnforceForUser(ctx context.Context, userID, obj, act string) (bool, error) { + // 获取用户角色 + roles, err := s.GetRolesForUser(ctx, userID) + if err != nil { + return false, err + } + + // 如果用户没有角色,使用默认角色 + if len(roles) == 0 { + roles = []string{"user"} // 默认角色 + } + + // 检查每个角色的权限 + for _, role := range roles { + allowed, err := s.Enforce(ctx, role, obj, act) + if err != nil { + return false, err + } + if allowed { + return true, nil + } + } + + return false, nil +} + +func (s *casbinService) AddRoleForUser(ctx context.Context, userID, role string) error { + // 添加到数据库 + if err := s.roleRepo.AddRoleForUser(ctx, userID, role); err != nil { + return err + } + + // 添加到 Casbin + if _, err := s.enforcer.AddRoleForUser(userID, role); err != nil { + return err + } + + // 清除缓存 + return s.clearUserCache(ctx, userID) +} + +func (s *casbinService) DeleteRoleForUser(ctx context.Context, userID, role string) error { + // 从数据库删除 + if err := s.roleRepo.DeleteRoleForUser(ctx, userID, role); err != nil { + return err + } + + // 从 Casbin 删除 + if _, err := s.enforcer.DeleteRoleForUser(userID, role); err != nil { + return err + } + + // 清除缓存 + return s.clearUserCache(ctx, userID) +} + +func (s *casbinService) GetRolesForUser(ctx context.Context, userID string) ([]string, error) { + // 检查缓存 + if s.cache != nil { + cacheKey := fmt.Sprintf("casbin:user:roles:%s", userID) + if cached, ok := s.cache.Get(cacheKey); ok { + if roles, ok := cached.([]string); ok { + return roles, nil + } + } + } + + // 从数据库获取 + roles, err := s.roleRepo.GetUserRoles(ctx, userID) + if err != nil { + return nil, err + } + + // 缓存结果 + if s.cache != nil { + cacheKey := fmt.Sprintf("casbin:user:roles:%s", userID) + s.cache.Set(cacheKey, roles, s.cacheTTL) + } + + return roles, nil +} + +func (s *casbinService) HasRoleForUser(ctx context.Context, userID, role string) (bool, error) { + return s.roleRepo.HasRole(ctx, userID, role) +} + +func (s *casbinService) AddPolicy(ctx context.Context, role, resource, action string) error { + if _, err := s.enforcer.AddPolicy(role, resource, action); err != nil { + return err + } + return s.ClearCache(ctx) +} + +func (s *casbinService) RemovePolicy(ctx context.Context, role, resource, action string) error { + if _, err := s.enforcer.RemovePolicy(role, resource, action); err != nil { + return err + } + return s.ClearCache(ctx) +} + +func (s *casbinService) LoadPolicy(ctx context.Context) error { + return s.enforcer.LoadPolicy() +} + +func (s *casbinService) ClearCache(ctx context.Context) error { + if s.cache != nil { + // 清除所有 casbin 相关缓存 + s.cache.DeleteByPrefix("casbin:") + } + return nil +} + +func (s *casbinService) clearUserCache(ctx context.Context, userID string) error { + if s.cache != nil { + cacheKey := fmt.Sprintf("casbin:user:roles:%s", userID) + s.cache.Delete(cacheKey) + } + return nil +} diff --git a/internal/service/role_service.go b/internal/service/role_service.go new file mode 100644 index 0000000..d3fcf2d --- /dev/null +++ b/internal/service/role_service.go @@ -0,0 +1,116 @@ +package service + +import ( + "context" + + apperrors "carrot_bbs/internal/errors" + "carrot_bbs/internal/model" + "carrot_bbs/internal/repository" +) + +// RoleService 角色管理服务接口 +type RoleService interface { + // GetUserRoles 获取用户的所有角色 + GetUserRoles(ctx context.Context, userID string) ([]model.Role, error) + + // AssignRole 为用户分配角色 + AssignRole(ctx context.Context, operatorID, targetUserID, role string) error + + // RemoveRole 移除用户的角色 + RemoveRole(ctx context.Context, operatorID, targetUserID, role string) error + + // GetAllRoles 获取所有角色定义 + GetAllRoles(ctx context.Context) ([]model.Role, error) + + // GetRole 获取角色详情 + GetRole(ctx context.Context, name string) (*model.Role, error) +} + +type roleService struct { + roleRepo repository.RoleRepository + casbinSvc CasbinService +} + +// NewRoleService 创建角色管理服务 +func NewRoleService( + roleRepo repository.RoleRepository, + casbinSvc CasbinService, +) RoleService { + return &roleService{ + roleRepo: roleRepo, + casbinSvc: casbinSvc, + } +} + +func (s *roleService) GetUserRoles(ctx context.Context, userID string) ([]model.Role, error) { + roleNames, err := s.casbinSvc.GetRolesForUser(ctx, userID) + if err != nil { + return nil, err + } + + var roles []model.Role + for _, name := range roleNames { + role, err := s.roleRepo.GetRole(ctx, name) + if err != nil { + continue // 忽略找不到的角色 + } + roles = append(roles, *role) + } + return roles, nil +} + +func (s *roleService) AssignRole(ctx context.Context, operatorID, targetUserID, role string) error { + // 不能修改自己的角色 + if operatorID == targetUserID { + return apperrors.ErrCannotModifyOwnRole + } + + // 检查目标用户是否已有该角色 + hasRole, err := s.casbinSvc.HasRoleForUser(ctx, targetUserID, role) + if err != nil { + return err + } + if hasRole { + return apperrors.ErrRoleAlreadyAssigned + } + + // 检查角色是否存在 + _, err = s.roleRepo.GetRole(ctx, role) + if err != nil { + return apperrors.ErrRoleNotFound + } + + // 添加角色 + return s.casbinSvc.AddRoleForUser(ctx, targetUserID, role) +} + +func (s *roleService) RemoveRole(ctx context.Context, operatorID, targetUserID, role string) error { + // 不能修改自己的角色 + if operatorID == targetUserID { + return apperrors.ErrCannotModifyOwnRole + } + + // 不能修改超级管理员角色 + if role == model.RoleSuperAdmin { + return apperrors.ErrCannotModifySuperAdmin + } + + // 检查用户是否有该角色 + hasRole, err := s.casbinSvc.HasRoleForUser(ctx, targetUserID, role) + if err != nil { + return err + } + if !hasRole { + return apperrors.ErrRoleNotAssigned + } + + return s.casbinSvc.DeleteRoleForUser(ctx, targetUserID, role) +} + +func (s *roleService) GetAllRoles(ctx context.Context) ([]model.Role, error) { + return s.roleRepo.GetAllRoles(ctx) +} + +func (s *roleService) GetRole(ctx context.Context, name string) (*model.Role, error) { + return s.roleRepo.GetRole(ctx, name) +} diff --git a/internal/service/user_activity_service.go b/internal/service/user_activity_service.go new file mode 100644 index 0000000..d5054e5 --- /dev/null +++ b/internal/service/user_activity_service.go @@ -0,0 +1,223 @@ +package service + +import ( + "context" + "fmt" + "log" + "time" + + "carrot_bbs/internal/repository" +) + +// UserActivityService 用户活跃统计服务接口 +type UserActivityService interface { + // RecordUserActive 记录用户活跃(核心方法) + RecordUserActive(ctx context.Context, userID, loginType, ip, userAgent string) error + + // GetDAU 获取日活用户数 + GetDAU(ctx context.Context, date time.Time) (int64, error) + + // GetWAU 获取周活用户数 + GetWAU(ctx context.Context, year int, week int) (int64, error) + + // GetMAU 获取月活用户数 + GetMAU(ctx context.Context, year int, month int) (int64, error) + + // GetRetention 计算留存率 + // day1: 基准日期, dayN: N天后的日期 + // 返回: day1的活跃用户在dayN仍然活跃的比例 + GetRetention(ctx context.Context, day1, dayN time.Time) (float64, error) + + // GetActivityStats 获取综合统计数据 + GetActivityStats(ctx context.Context) (*ActivityStatsResponse, error) +} + +// userActivityService 实现 +type userActivityService struct { + repo *repository.UserActivityRepository +} + +// NewUserActivityService 创建用户活跃统计服务 +func NewUserActivityService(repo *repository.UserActivityRepository) UserActivityService { + return &userActivityService{repo: repo} +} + +// ActivityStatsResponse 活跃统计响应 +type ActivityStatsResponse struct { + Today DailyStats `json:"today"` + Yesterday DailyStats `json:"yesterday"` + ThisWeek PeriodStats `json:"this_week"` + ThisMonth PeriodStats `json:"this_month"` + Retention RetentionStats `json:"retention"` +} + +// DailyStats 日统计 +type DailyStats struct { + Date string `json:"date"` + DAU int64 `json:"dau"` + NewUsers int64 `json:"new_users"` +} + +// PeriodStats 周期统计 +type PeriodStats struct { + Period string `json:"period"` + ActiveUsers int64 `json:"active_users"` +} + +// RetentionStats 留存统计 +type RetentionStats struct { + Day1 float64 `json:"day_1"` // 次日留存 + Day7 float64 `json:"day_7"` // 7日留存 + Day30 float64 `json:"day_30"` // 30日留存 +} + +// RecordUserActive 记录用户活跃 +func (s *userActivityService) RecordUserActive(ctx context.Context, userID, loginType, ip, userAgent string) error { + // 1. 记录到 Redis(同步,快速) + if err := s.repo.RecordActivityToRedis(ctx, userID); err != nil { + // 记录日志但不阻断流程 + log.Printf("failed to record activity to redis: %v", err) + } + + // 2. 异步写入数据库(可以使用 goroutine 或消息队列) + go func() { + bgCtx := context.Background() + if err := s.repo.RecordActivity(bgCtx, userID, loginType, ip, userAgent); err != nil { + log.Printf("failed to record activity to db: %v", err) + } + }() + + return nil +} + +// GetDAU 获取日活用户数 +func (s *userActivityService) GetDAU(ctx context.Context, date time.Time) (int64, error) { + return s.repo.GetDAUFromRedis(ctx, date) +} + +// GetWAU 获取周活用户数 +func (s *userActivityService) GetWAU(ctx context.Context, year int, week int) (int64, error) { + return s.repo.GetWAUFromRedis(ctx, year, week) +} + +// GetMAU 获取月活用户数 +func (s *userActivityService) GetMAU(ctx context.Context, year int, month int) (int64, error) { + return s.repo.GetMAUFromRedis(ctx, year, month) +} + +// GetRetention 计算留存率 +func (s *userActivityService) GetRetention(ctx context.Context, day1, dayN time.Time) (float64, error) { + // 1. 获取 day1 的活跃用户列表 + day1Users, err := s.repo.GetDAUUserIDsFromRedis(ctx, day1) + if err != nil { + return 0, err + } + + if len(day1Users) == 0 { + return 0, nil + } + + // 2. 获取 dayN 的活跃用户列表 + dayNUsers, err := s.repo.GetDAUUserIDsFromRedis(ctx, dayN) + if err != nil { + return 0, err + } + + // 3. 计算交集 + day1Set := make(map[string]bool) + for _, u := range day1Users { + day1Set[u] = true + } + + retained := 0 + for _, u := range dayNUsers { + if day1Set[u] { + retained++ + } + } + + // 4. 计算留存率(返回百分比形式 0-100) + retention := float64(retained) / float64(len(day1Users)) * 100 + return retention, nil +} + +// GetActivityStats 获取综合统计数据 +func (s *userActivityService) GetActivityStats(ctx context.Context) (*ActivityStatsResponse, error) { + now := time.Now() + + // 获取今日和昨日的日期(零点) + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + yesterday := today.AddDate(0, 0, -1) + + // 获取当前年周 + year, week := now.ISOWeek() + + // 并发获取各项统计数据 + resp := &ActivityStatsResponse{} + + // 获取今日 DAU + todayDAU, err := s.GetDAU(ctx, today) + if err != nil { + log.Printf("failed to get today DAU: %v", err) + } + resp.Today = DailyStats{ + Date: today.Format("2006-01-02"), + DAU: todayDAU, + } + + // 获取昨日 DAU + yesterdayDAU, err := s.GetDAU(ctx, yesterday) + if err != nil { + log.Printf("failed to get yesterday DAU: %v", err) + } + resp.Yesterday = DailyStats{ + Date: yesterday.Format("2006-01-02"), + DAU: yesterdayDAU, + } + + // 获取本周 WAU + wau, err := s.GetWAU(ctx, year, week) + if err != nil { + log.Printf("failed to get WAU: %v", err) + } + resp.ThisWeek = PeriodStats{ + Period: fmt.Sprintf("%d-W%02d", year, week), + ActiveUsers: wau, + } + + // 获取本月 MAU + mau, err := s.GetMAU(ctx, now.Year(), int(now.Month())) + if err != nil { + log.Printf("failed to get MAU: %v", err) + } + resp.ThisMonth = PeriodStats{ + Period: now.Format("2006-01"), + ActiveUsers: mau, + } + + // 获取留存率 + // 次日留存 + day1Retention, err := s.GetRetention(ctx, yesterday, today) + if err != nil { + log.Printf("failed to get day 1 retention: %v", err) + } + resp.Retention.Day1 = day1Retention + + // 7日留存 + day7Date := today.AddDate(0, 0, -7) + day7Retention, err := s.GetRetention(ctx, day7Date, today) + if err != nil { + log.Printf("failed to get day 7 retention: %v", err) + } + resp.Retention.Day7 = day7Retention + + // 30日留存 + day30Date := today.AddDate(0, 0, -30) + day30Retention, err := s.GetRetention(ctx, day30Date, today) + if err != nil { + log.Printf("failed to get day 30 retention: %v", err) + } + resp.Retention.Day30 = day30Retention + + return resp, nil +} diff --git a/internal/wire/casbin.go b/internal/wire/casbin.go new file mode 100644 index 0000000..b2c7036 --- /dev/null +++ b/internal/wire/casbin.go @@ -0,0 +1,54 @@ +package wire + +import ( + "log" + + "carrot_bbs/internal/cache" + "carrot_bbs/internal/config" + "carrot_bbs/internal/repository" + "carrot_bbs/internal/service" + + "github.com/casbin/casbin/v2" + "github.com/google/wire" + "gorm.io/gorm" +) + +// CasbinSet Casbin 相关 Provider Set +var CasbinSet = wire.NewSet( + ProvideCasbinEnforcer, + ProvideCasbinService, + ProvideRoleRepository, +) + +// ProvideCasbinEnforcer 提供 Casbin Enforcer +func ProvideCasbinEnforcer(cfg *config.Config, db *gorm.DB) *casbin.Enforcer { + // 创建 Casbin Enforcer + // 注意:这里使用文件适配器作为示例,实际使用时需要替换为数据库适配器 + enforcer, err := casbin.NewEnforcer(cfg.Casbin.ModelPath) + if err != nil { + log.Fatalf("Failed to create Casbin enforcer: %v", err) + } + + // 加载策略 + if err := enforcer.LoadPolicy(); err != nil { + log.Printf("[WARNING] Failed to load Casbin policy: %v", err) + } + + log.Println("[Wire] Initialized Casbin enforcer") + return enforcer +} + +// ProvideCasbinService 提供 Casbin 服务 +func ProvideCasbinService( + enforcer *casbin.Enforcer, + cache cache.Cache, + roleRepo repository.RoleRepository, + cfg *config.Config, +) service.CasbinService { + return service.NewCasbinService(enforcer, cache, roleRepo, &cfg.Casbin) +} + +// ProvideRoleRepository 提供角色仓储 +func ProvideRoleRepository(db *gorm.DB) repository.RoleRepository { + return repository.NewRoleRepository(db) +} diff --git a/internal/wire/handler.go b/internal/wire/handler.go index ce7d6f3..f73545b 100644 --- a/internal/wire/handler.go +++ b/internal/wire/handler.go @@ -15,15 +15,16 @@ import ( // HandlerSet Handler 层 Provider Set var HandlerSet = wire.NewSet( // 直接使用构造函数的 Handler - handler.NewUserHandler, handler.NewCommentHandler, handler.NewNotificationHandler, handler.NewUploadHandler, handler.NewPushHandler, handler.NewStickerHandler, handler.NewVoteHandler, + handler.NewRoleHandler, // 需要特殊处理的 Handler + ProvideUserHandler, ProvidePostHandler, ProvideMessageHandler, ProvideSystemMessageHandler, @@ -32,6 +33,14 @@ var HandlerSet = wire.NewSet( ProvideScheduleHandler, ) +// ProvideUserHandler 提供用户处理器 +func ProvideUserHandler( + userService service.UserService, + activityService service.UserActivityService, +) *handler.UserHandler { + return handler.NewUserHandler(userService, activityService) +} + // ProvidePostHandler 提供帖子处理器 func ProvidePostHandler( postService service.PostService, diff --git a/internal/wire/provider_set.go b/internal/wire/provider_set.go index 55ba0d3..142a29e 100644 --- a/internal/wire/provider_set.go +++ b/internal/wire/provider_set.go @@ -5,6 +5,7 @@ import "github.com/google/wire" // AllSet 包含所有 Provider Set var AllSet = wire.NewSet( InfrastructureSet, + CasbinSet, RepositorySet, ServiceSet, HandlerSet, diff --git a/internal/wire/repository.go b/internal/wire/repository.go index 1c62275..0eff102 100644 --- a/internal/wire/repository.go +++ b/internal/wire/repository.go @@ -1,9 +1,11 @@ package wire import ( + "carrot_bbs/internal/cache" "carrot_bbs/internal/repository" "github.com/google/wire" + "gorm.io/gorm" ) // RepositorySet Repository 层 Provider Set @@ -20,8 +22,14 @@ var RepositorySet = wire.NewSet( repository.NewStickerRepository, repository.NewVoteRepository, repository.NewScheduleRepository, + ProvideUserActivityRepository, ) +// ProvideUserActivityRepository 提供用户活跃数据仓储 +func ProvideUserActivityRepository(db *gorm.DB, cache cache.Cache) *repository.UserActivityRepository { + return repository.NewUserActivityRepository(db, cache) +} + // Note: 以下 Repository 返回接口类型而非指针,需要单独处理 // - ScheduleRepository (repository.ScheduleRepository) // - StickerRepository (repository.StickerRepository) diff --git a/internal/wire/service.go b/internal/wire/service.go index 101dc81..f986177 100644 --- a/internal/wire/service.go +++ b/internal/wire/service.go @@ -41,6 +41,8 @@ var ServiceSet = wire.NewSet( ProvideScheduleSyncService, ProvideGroupService, ProvideUploadService, + ProvideUserActivityService, + ProvideRoleService, ) // ProvideJWTService 提供 JWT 服务 @@ -199,3 +201,16 @@ func ProvideUploadService( ) *service.UploadService { return service.NewUploadService(s3Client, userService) } + +// ProvideUserActivityService 提供用户活跃统计服务 +func ProvideUserActivityService(repo *repository.UserActivityRepository) service.UserActivityService { + return service.NewUserActivityService(repo) +} + +// ProvideRoleService 提供角色管理服务 +func ProvideRoleService( + roleRepo repository.RoleRepository, + casbinSvc service.CasbinService, +) service.RoleService { + return service.NewRoleService(roleRepo, casbinSvc) +} diff --git a/scripts/test_moderation.go b/scripts/test_moderation.go deleted file mode 100644 index f06b818..0000000 --- a/scripts/test_moderation.go +++ /dev/null @@ -1,263 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "flag" - "fmt" - "io" - "net/http" - "strings" - "time" -) - -const moderationSystemPrompt = `你是中文社区的内容审核助手,负责对"帖子标题、正文、配图"做联合审核。目标是平衡社区安全与正常交流:必须拦截高风险违规内容,但不要误伤正常玩梗、二创、吐槽和轻度调侃。请只输出指定JSON。 - -审核流程: -1) 先判断是否命中硬性违规; -2) 再判断语境(玩笑/自嘲/朋友间互动/作品讨论); -3) 做文图交叉判断(文本+图片合并理解); -4) 给出 approved 与简短 reason。 - -硬性违规(命中任一项必须 approved=false): -A. 宣传对立与煽动撕裂: -- 明确煽动群体对立、地域对立、性别对立、民族宗教对立,鼓动仇恨、排斥、报复。 -B. 严重人身攻击与网暴引导: -- 持续性侮辱贬损、羞辱人格、号召围攻/骚扰/挂人/线下冲突。 -C. 开盒/人肉/隐私暴露: -- 故意公开、拼接、索取他人可识别隐私信息(姓名+联系方式、身份证号、住址、学校单位、车牌、定位轨迹等); -- 图片/截图中出现可识别隐私信息并伴随曝光意图,也按违规处理。 -D. 其他高危违规: -- 违法犯罪、暴力威胁、极端仇恨、色情低俗、诈骗引流、恶意广告等。 - -放行规则(以下通常 approved=true): -- 正常玩梗、表情包、谐音梗、二次创作、无恶意的吐槽; -- 非定向、轻度口语化吐槽(无明确攻击对象、无网暴号召、无隐私暴露); -- 对社会事件/作品的理性讨论、观点争论(即使语气尖锐,但未煽动对立或人身攻击)。 - -边界判定: -- 若只是"梗文化表达"且不指向现实伤害,优先通过; -- 若存在明确伤害意图(煽动、围攻、曝光隐私),必须拒绝; -- 对模糊内容不因个别粗口直接拒绝,需结合对象、意图、号召性和可执行性综合判断。 - -reason 要求: -- approved=false 时:中文10-30字,说明核心违规点; -- approved=true 时:reason 为空字符串。 - -输出格式(严格): -仅输出一行JSON对象,不要Markdown,不要额外解释: -{"approved": true/false, "reason": "..."}` - -type chatMessage struct { - Role string `json:"role"` - Content interface{} `json:"content"` -} - -type contentPart struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` -} - -type chatCompletionsRequest struct { - Model string `json:"model"` - Messages []chatMessage `json:"messages"` - Temperature float64 `json:"temperature,omitempty"` - MaxTokens int `json:"max_tokens,omitempty"` - EnableThinking *bool `json:"enable_thinking,omitempty"` // qwen3.5思考模式控制 - ThinkingBudget *int `json:"thinking_budget,omitempty"` // 思考过程最大token数 - ResponseFormat *responseFormatConfig `json:"response_format,omitempty"` // 响应格式 -} - -type responseFormatConfig struct { - Type string `json:"type"` // "text" or "json_object" -} - -type chatCompletionsResponse struct { - Choices []struct { - Message struct { - Content string `json:"content"` - } `json:"message"` - FinishReason string `json:"finish_reason"` - } `json:"choices"` - Usage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` - } `json:"usage"` -} - -func main() { - baseURL := flag.String("url", "https://api.littlelan.cn/", "API base URL") - apiKey := flag.String("key", "", "API key") - model := flag.String("model", "qwen3.5-plus", "Model name") - maxTokens := flag.Int("max-tokens", 220, "Max tokens for completion") - enableThinking := flag.Bool("enable-thinking", false, "Enable thinking mode for qwen3.5") - flag.Parse() - - if *apiKey == "" { - fmt.Println("Error: API key is required. Use -key flag") - return - } - - // 测试用例 - testCases := []struct { - name string - content string - }{ - { - name: "简单正常内容", - content: "帖子标题:今天天气真好\n帖子内容:出门散步,心情愉快!", - }, - { - name: "中等长度内容", - content: "帖子标题:分享我的学习经验\n帖子内容:最近在学习Go语言,发现这门语言真的很适合后端开发。并发处理特别方便,goroutine和channel的设计非常优雅。有一起学习的小伙伴吗?", - }, - { - name: "较长内容", - content: "帖子标题:关于校园生活的一些思考\n帖子内容:大学四年转眼就过去了,回想起来有很多感慨。刚入学的时候什么都不懂,现在感觉自己成长了很多。在这里想分享一些自己的经验,希望能对学弟学妹们有所帮助。首先是学习方面,一定要认真听课,做好笔记。其次是社交方面,多参加社团活动,结交志同道合的朋友。最后是规划方面,早点想清楚自己想做什么,为之努力。", - }, - } - - client := &http.Client{Timeout: 120 * time.Second} - - fmt.Println("============================================") - fmt.Printf("模型: %s\n", *model) - fmt.Printf("API URL: %s\n", *baseURL) - fmt.Printf("MaxTokens 设置: %d\n", *maxTokens) - fmt.Printf("EnableThinking: %v\n", *enableThinking) - fmt.Println("============================================") - - for _, tc := range testCases { - fmt.Printf("\n========== 测试: %s ==========\n", tc.name) - fmt.Printf("内容长度: %d 字符\n", len(tc.content)) - - userPrompt := fmt.Sprintf("%s\n图片批次:1/1(本次仅提供当前批次图片)", tc.content) - - reqBody := chatCompletionsRequest{ - Model: *model, - Messages: []chatMessage{ - {Role: "system", Content: moderationSystemPrompt}, - {Role: "user", Content: []contentPart{{Type: "text", Text: userPrompt}}}, - }, - Temperature: 0.1, - MaxTokens: *maxTokens, - } - // 设置思考模式 - if !*enableThinking { - reqBody.EnableThinking = enableThinking - // 设置思考预算为0,完全禁用思考 - zero := 0 - reqBody.ThinkingBudget = &zero - } - // 使用JSON输出格式 - reqBody.ResponseFormat = &responseFormatConfig{Type: "json_object"} - - data, err := json.Marshal(reqBody) - if err != nil { - fmt.Printf("Error marshaling request: %v\n", err) - continue - } - - endpoint := strings.TrimRight(*baseURL, "/") + "/v1/chat/completions" - if strings.HasSuffix(strings.TrimRight(*baseURL, "/"), "/v1") { - endpoint = strings.TrimRight(*baseURL, "/") + "/chat/completions" - } - - req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(data)) - if err != nil { - fmt.Printf("Error creating request: %v\n", err) - continue - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+*apiKey) - - start := time.Now() - resp, err := client.Do(req) - if err != nil { - fmt.Printf("Error sending request: %v\n", err) - continue - } - - body, err := io.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - fmt.Printf("Error reading response: %v\n", err) - continue - } - - elapsed := time.Since(start) - - if resp.StatusCode >= 400 { - fmt.Printf("API Error: status=%d, body=%s\n", resp.StatusCode, string(body)) - continue - } - - var parsed chatCompletionsResponse - if err := json.Unmarshal(body, &parsed); err != nil { - fmt.Printf("Error parsing response: %v\n", err) - fmt.Printf("Raw response: %s\n", string(body)) - continue - } - - if len(parsed.Choices) == 0 { - fmt.Println("No choices in response") - fmt.Printf("Raw response: %s\n", string(body)) - continue - } - - fmt.Printf("响应时间: %v\n", elapsed) - fmt.Printf("Finish Reason: %s\n", parsed.Choices[0].FinishReason) - fmt.Printf("Token使用情况:\n") - fmt.Printf(" - PromptTokens: %d\n", parsed.Usage.PromptTokens) - fmt.Printf(" - CompletionTokens: %d\n", parsed.Usage.CompletionTokens) - fmt.Printf(" - TotalTokens: %d\n", parsed.Usage.TotalTokens) - - output := parsed.Choices[0].Message.Content - fmt.Printf("输出内容长度: %d 字符\n", len(output)) - - // 检查输出是否符合预期 - if parsed.Usage.CompletionTokens > *maxTokens { - fmt.Printf("\n⚠️ 警告: CompletionTokens (%d) 超过了 max_tokens 设置 (%d)!\n", - parsed.Usage.CompletionTokens, *maxTokens) - } - - if len(output) > 500 { - fmt.Printf("\n⚠️ 警告: 输出内容过长! 长度=%d\n", len(output)) - fmt.Printf("前500字符:\n%s...\n", output[:min(500, len(output))]) - } else { - fmt.Printf("输出内容: %s\n", output) - } - - // 尝试解析JSON - extractJSONObject := func(raw string) string { - text := strings.TrimSpace(raw) - start := strings.Index(text, "{") - end := strings.LastIndex(text, "}") - if start >= 0 && end > start { - return text[start : end+1] - } - return text - } - - jsonStr := extractJSONObject(output) - var result struct { - Approved bool `json:"approved"` - Reason string `json:"reason"` - } - if err := json.Unmarshal([]byte(jsonStr), &result); err != nil { - fmt.Printf("\n⚠️ 警告: 无法解析JSON输出: %v\n", err) - fmt.Printf("提取的JSON: %s\n", jsonStr) - } else { - fmt.Printf("\n✓ 解析成功: approved=%v, reason=\"%s\"\n", result.Approved, result.Reason) - } - } - - fmt.Println("\n========== 测试完成 ==========") -} - -func min(a, b int) int { - if a < b { - return a - } - return b -}