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.
This commit is contained in:
2026-03-14 02:09:38 +08:00
parent c561a0bb0c
commit ae5b997924
27 changed files with 2197 additions and 691 deletions

356
cmd/test_casbin/main.go Normal file
View File

@@ -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
}

14
configs/casbin/model.conf Normal file
View File

@@ -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 == "*")

View File

@@ -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

10
go.mod
View File

@@ -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

23
go.sum
View File

@@ -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=

22
internal/config/casbin.go Normal file
View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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 包装错误

View File

@@ -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": "操作失败",
})
}
}

View File

@@ -1,11 +1,13 @@
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"
)
@@ -13,13 +15,15 @@ import (
// UserHandler 用户处理器
type UserHandler struct {
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,
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")

View File

@@ -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()
}
}

View File

@@ -141,6 +141,15 @@ func autoMigrate(db *gorm.DB) error {
// 课表
&ScheduleCourse{},
// 用户活跃相关
&UserActiveLog{},
&UserActivityStat{},
// 角色和权限相关
&Role{},
&UserRole{},
&CasbinRule{},
)
if err != nil {
return err

57
internal/model/role.go Normal file
View File

@@ -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"
)

View File

@@ -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"
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}
}
}
}

View File

@@ -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,18 +33,20 @@ 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
openaiClient openai.Client
config *AuditConfig
mu sync.RWMutex
}
@@ -61,66 +54,33 @@ type auditServiceImpl struct {
// 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"`
// 审核超时时间(秒)
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{
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",
Provider: "ai",
}, nil
}
@@ -130,45 +90,63 @@ func (s *auditServiceImpl) AuditText(ctx context.Context, text string, auditType
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("Audit text error: %v", err)
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
}
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",
Provider: "ai",
}, nil
}
@@ -178,33 +156,170 @@ func (s *auditServiceImpl) AuditImage(ctx context.Context, imageURL string) (*Au
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("Audit image error: %v", err)
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
}
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 审核回调处理

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

54
internal/wire/casbin.go Normal file
View File

@@ -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)
}

View File

@@ -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,

View File

@@ -5,6 +5,7 @@ import "github.com/google/wire"
// AllSet 包含所有 Provider Set
var AllSet = wire.NewSet(
InfrastructureSet,
CasbinSet,
RepositorySet,
ServiceSet,
HandlerSet,

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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
}