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:
356
cmd/test_casbin/main.go
Normal file
356
cmd/test_casbin/main.go
Normal 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
14
configs/casbin/model.conf
Normal 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 == "*")
|
||||
@@ -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
10
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
|
||||
|
||||
23
go.sum
23
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=
|
||||
|
||||
22
internal/config/casbin.go
Normal file
22
internal/config/casbin.go
Normal 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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 包装错误
|
||||
|
||||
204
internal/handler/role_handler.go
Normal file
204
internal/handler/role_handler.go
Normal 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": "操作失败",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
130
internal/middleware/casbin.go
Normal file
130
internal/middleware/casbin.go
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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
57
internal/model/role.go
Normal 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"
|
||||
)
|
||||
49
internal/model/user_active_log.go
Normal file
49
internal/model/user_active_log.go
Normal 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"
|
||||
}
|
||||
97
internal/repository/role_repo.go
Normal file
97
internal/repository/role_repo.go
Normal 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
|
||||
}
|
||||
225
internal/repository/user_activity_repo.go
Normal file
225
internal/repository/user_activity_repo.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 审核回调处理
|
||||
|
||||
225
internal/service/casbin_service.go
Normal file
225
internal/service/casbin_service.go
Normal 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
|
||||
}
|
||||
116
internal/service/role_service.go
Normal file
116
internal/service/role_service.go
Normal 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)
|
||||
}
|
||||
223
internal/service/user_activity_service.go
Normal file
223
internal/service/user_activity_service.go
Normal 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
54
internal/wire/casbin.go
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -5,6 +5,7 @@ import "github.com/google/wire"
|
||||
// AllSet 包含所有 Provider Set
|
||||
var AllSet = wire.NewSet(
|
||||
InfrastructureSet,
|
||||
CasbinSet,
|
||||
RepositorySet,
|
||||
ServiceSet,
|
||||
HandlerSet,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user