feat: add hook system, QR code login, and layered cache
- Implement extensible hook system for content moderation with builtin and AI-powered moderation hooks - Add QR code login feature with SSE for real-time status updates and scan/confirm/cancel workflow - Introduce layered cache with local LRU + Redis backend for improved read performance - Refactor post and comment services to use hook-based moderation instead of direct AI service calls - Add local cache configuration options (size, buckets, ttl)
This commit is contained in:
@@ -45,6 +45,7 @@ func ProvideRouter(
|
|||||||
adminGroupHandler *handler.AdminGroupHandler,
|
adminGroupHandler *handler.AdminGroupHandler,
|
||||||
adminDashboardHandler *handler.AdminDashboardHandler,
|
adminDashboardHandler *handler.AdminDashboardHandler,
|
||||||
adminLogHandler *handler.AdminLogHandler,
|
adminLogHandler *handler.AdminLogHandler,
|
||||||
|
qrcodeHandler *handler.QRCodeHandler,
|
||||||
logService *service.LogService,
|
logService *service.LogService,
|
||||||
activityService service.UserActivityService,
|
activityService service.UserActivityService,
|
||||||
casbinService service.CasbinService,
|
casbinService service.CasbinService,
|
||||||
@@ -71,6 +72,7 @@ func ProvideRouter(
|
|||||||
adminGroupHandler,
|
adminGroupHandler,
|
||||||
adminDashboardHandler,
|
adminDashboardHandler,
|
||||||
adminLogHandler,
|
adminLogHandler,
|
||||||
|
qrcodeHandler,
|
||||||
logService,
|
logService,
|
||||||
activityService,
|
activityService,
|
||||||
casbinService,
|
casbinService,
|
||||||
|
|||||||
@@ -46,9 +46,10 @@ func InitializeApp() (*App, error) {
|
|||||||
openaiClient := wire.ProvideOpenAIClient(config)
|
openaiClient := wire.ProvideOpenAIClient(config)
|
||||||
postAIService := wire.ProvidePostAIService(openaiClient)
|
postAIService := wire.ProvidePostAIService(openaiClient)
|
||||||
transactionManager := wire.ProvideTransactionManager(db)
|
transactionManager := wire.ProvideTransactionManager(db)
|
||||||
postService := wire.ProvidePostService(postRepository, systemMessageService, gorseClient, postAIService, cache, transactionManager)
|
manager := wire.ProvideHookManager()
|
||||||
|
postService := wire.ProvidePostService(postRepository, systemMessageService, gorseClient, postAIService, cache, transactionManager, manager)
|
||||||
commentRepository := repository.NewCommentRepository(db)
|
commentRepository := repository.NewCommentRepository(db)
|
||||||
commentService := wire.ProvideCommentService(commentRepository, postRepository, systemMessageService, gorseClient, postAIService, cache)
|
commentService := wire.ProvideCommentService(commentRepository, postRepository, systemMessageService, gorseClient, postAIService, cache, manager)
|
||||||
operationLogRepository := repository.NewOperationLogRepository(db)
|
operationLogRepository := repository.NewOperationLogRepository(db)
|
||||||
loginLogRepository := repository.NewLoginLogRepository(db)
|
loginLogRepository := repository.NewLoginLogRepository(db)
|
||||||
dataChangeLogRepository := repository.NewDataChangeLogRepository(db)
|
dataChangeLogRepository := repository.NewDataChangeLogRepository(db)
|
||||||
@@ -108,7 +109,9 @@ func InitializeApp() (*App, error) {
|
|||||||
adminDashboardService := wire.ProvideAdminDashboardService(db, userRepository, postRepository, commentRepository, groupRepository, userActivityRepository)
|
adminDashboardService := wire.ProvideAdminDashboardService(db, userRepository, postRepository, commentRepository, groupRepository, userActivityRepository)
|
||||||
adminDashboardHandler := handler.NewAdminDashboardHandler(adminDashboardService)
|
adminDashboardHandler := handler.NewAdminDashboardHandler(adminDashboardService)
|
||||||
adminLogHandler := handler.NewAdminLogHandler(operationLogService, loginLogService, dataChangeLogService)
|
adminLogHandler := handler.NewAdminLogHandler(operationLogService, loginLogService, dataChangeLogService)
|
||||||
router := ProvideRouter(userHandler, postHandler, commentHandler, messageHandler, notificationHandler, uploadHandler, jwtService, pushHandler, systemMessageHandler, groupHandler, stickerHandler, gorseHandler, voteHandler, scheduleHandler, roleHandler, adminUserHandler, adminPostHandler, adminCommentHandler, adminGroupHandler, adminDashboardHandler, adminLogHandler, logService, userActivityService, casbinService)
|
qrCodeLoginService := wire.ProvideQRCodeLoginService(client, hub, jwtService, userService, userActivityService, logService)
|
||||||
|
qrCodeHandler := handler.NewQRCodeHandler(qrCodeLoginService)
|
||||||
|
router := ProvideRouter(userHandler, postHandler, commentHandler, messageHandler, notificationHandler, uploadHandler, jwtService, pushHandler, systemMessageHandler, groupHandler, stickerHandler, gorseHandler, voteHandler, scheduleHandler, roleHandler, adminUserHandler, adminPostHandler, adminCommentHandler, adminGroupHandler, adminDashboardHandler, adminLogHandler, qrCodeHandler, logService, userActivityService, casbinService)
|
||||||
app := NewApp(config, db, router, pushService, server, logger)
|
app := NewApp(config, db, router, pushService, server, logger)
|
||||||
return app, nil
|
return app, nil
|
||||||
}
|
}
|
||||||
@@ -138,6 +141,7 @@ func ProvideRouter(
|
|||||||
adminGroupHandler *handler.AdminGroupHandler,
|
adminGroupHandler *handler.AdminGroupHandler,
|
||||||
adminDashboardHandler *handler.AdminDashboardHandler,
|
adminDashboardHandler *handler.AdminDashboardHandler,
|
||||||
adminLogHandler *handler.AdminLogHandler,
|
adminLogHandler *handler.AdminLogHandler,
|
||||||
|
qrcodeHandler *handler.QRCodeHandler,
|
||||||
logService *service.LogService,
|
logService *service.LogService,
|
||||||
activityService service.UserActivityService,
|
activityService service.UserActivityService,
|
||||||
casbinService service.CasbinService,
|
casbinService service.CasbinService,
|
||||||
@@ -164,6 +168,7 @@ func ProvideRouter(
|
|||||||
adminGroupHandler,
|
adminGroupHandler,
|
||||||
adminDashboardHandler,
|
adminDashboardHandler,
|
||||||
adminLogHandler,
|
adminLogHandler,
|
||||||
|
qrcodeHandler,
|
||||||
logService,
|
logService,
|
||||||
activityService,
|
activityService,
|
||||||
casbinService,
|
casbinService,
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -6,6 +6,7 @@ require (
|
|||||||
github.com/alicebob/miniredis/v2 v2.37.0
|
github.com/alicebob/miniredis/v2 v2.37.0
|
||||||
github.com/casbin/casbin/v3 v3.10.0
|
github.com/casbin/casbin/v3 v3.10.0
|
||||||
github.com/casbin/gorm-adapter/v3 v3.41.0
|
github.com/casbin/gorm-adapter/v3 v3.41.0
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0
|
||||||
github.com/disintegration/imaging v1.6.2
|
github.com/disintegration/imaging v1.6.2
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
@@ -33,7 +34,6 @@ require (
|
|||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
github.com/casbin/govaluate v1.10.0 // indirect
|
github.com/casbin/govaluate v1.10.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -118,6 +118,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
|
|||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
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.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
|||||||
3
internal/cache/cache.go
vendored
3
internal/cache/cache.go
vendored
@@ -4,11 +4,14 @@ import (
|
|||||||
"cmp"
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var ErrKeyNotFound = errors.New("key not found")
|
||||||
|
|
||||||
// Cache 缓存接口
|
// Cache 缓存接口
|
||||||
type Cache interface {
|
type Cache interface {
|
||||||
// Set 设置缓存值,支持TTL
|
// Set 设置缓存值,支持TTL
|
||||||
|
|||||||
470
internal/cache/layered_cache.go
vendored
Normal file
470
internal/cache/layered_cache.go
vendored
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
redisPkg "carrot_bbs/internal/pkg/redis"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LayeredCache struct {
|
||||||
|
local *LRUStriped
|
||||||
|
redis *RedisCache
|
||||||
|
mu sync.RWMutex
|
||||||
|
stats CacheStats
|
||||||
|
enabled bool
|
||||||
|
keyPrefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CacheStats struct {
|
||||||
|
LocalHits int64
|
||||||
|
RedisHits int64
|
||||||
|
Misses int64
|
||||||
|
Sets int64
|
||||||
|
Invalidates int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type LayeredCacheOptions struct {
|
||||||
|
LocalSize int
|
||||||
|
LocalBuckets int
|
||||||
|
LocalDefaultTTL time.Duration
|
||||||
|
KeyPrefix string
|
||||||
|
Enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLayeredCache(redisClient *redisPkg.Client, opts *LayeredCacheOptions) *LayeredCache {
|
||||||
|
if opts == nil {
|
||||||
|
opts = &LayeredCacheOptions{
|
||||||
|
LocalSize: 10000,
|
||||||
|
LocalBuckets: 0,
|
||||||
|
LocalDefaultTTL: 5 * time.Minute,
|
||||||
|
KeyPrefix: "",
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.LocalSize <= 0 {
|
||||||
|
opts.LocalSize = 10000
|
||||||
|
}
|
||||||
|
if opts.LocalDefaultTTL <= 0 {
|
||||||
|
opts.LocalDefaultTTL = 5 * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
localCache := NewLRUStriped(&LRUStripedOptions{
|
||||||
|
Size: opts.LocalSize,
|
||||||
|
DefaultExpiry: opts.LocalDefaultTTL,
|
||||||
|
Buckets: opts.LocalBuckets,
|
||||||
|
Name: "layered_local",
|
||||||
|
})
|
||||||
|
|
||||||
|
var redisCache *RedisCache
|
||||||
|
if redisClient != nil {
|
||||||
|
redisCache = NewRedisCache(redisClient)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LayeredCache{
|
||||||
|
local: localCache,
|
||||||
|
redis: redisCache,
|
||||||
|
enabled: opts.Enabled,
|
||||||
|
keyPrefix: opts.KeyPrefix,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) Set(key string, value any, ttl time.Duration) {
|
||||||
|
if !c.enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.stats.Sets++
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
c.local.Set(key, value, ttl)
|
||||||
|
|
||||||
|
if c.redis != nil {
|
||||||
|
c.redis.Set(key, value, ttl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) Get(key string) (any, bool) {
|
||||||
|
if !c.enabled {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if val, ok := c.local.Get(key); ok {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.stats.LocalHits++
|
||||||
|
c.mu.Unlock()
|
||||||
|
return val, true
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.redis != nil {
|
||||||
|
if val, ok := c.redis.Get(key); ok {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.stats.RedisHits++
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if ttl := c.getLocalTTL(); ttl > 0 {
|
||||||
|
c.local.Set(key, val, ttl)
|
||||||
|
}
|
||||||
|
return val, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.stats.Misses++
|
||||||
|
c.mu.Unlock()
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) getLocalTTL() time.Duration {
|
||||||
|
return 30 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) Delete(key string) {
|
||||||
|
if !c.enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.stats.Invalidates++
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
c.local.Delete(key)
|
||||||
|
if c.redis != nil {
|
||||||
|
c.redis.Delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) DeleteByPrefix(prefix string) {
|
||||||
|
if !c.enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.stats.Invalidates++
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
c.local.DeleteByPrefix(prefix)
|
||||||
|
if c.redis != nil {
|
||||||
|
c.redis.DeleteByPrefix(prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) Clear() {
|
||||||
|
c.local.Clear()
|
||||||
|
if c.redis != nil {
|
||||||
|
c.redis.Clear()
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.stats = CacheStats{}
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) Exists(key string) bool {
|
||||||
|
if !c.enabled {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.local.Exists(key) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.redis != nil {
|
||||||
|
return c.redis.Exists(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) Increment(key string) int64 {
|
||||||
|
if !c.enabled {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.redis != nil {
|
||||||
|
val := c.redis.Increment(key)
|
||||||
|
c.local.Delete(key)
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.local.Increment(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) IncrementBy(key string, value int64) int64 {
|
||||||
|
if !c.enabled {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.redis != nil {
|
||||||
|
val := c.redis.IncrementBy(key, value)
|
||||||
|
c.local.Delete(key)
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.local.IncrementBy(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) GetStats() CacheStats {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.stats
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) GetHitRate() float64 {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
total := c.stats.LocalHits + c.stats.RedisHits + c.stats.Misses
|
||||||
|
if total == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return float64(c.stats.LocalHits+c.stats.RedisHits) / float64(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) HSet(ctx context.Context, key string, field string, value any) error {
|
||||||
|
if !c.enabled || c.redis == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.local.Delete(key)
|
||||||
|
return c.redis.HSet(ctx, key, field, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) HMSet(ctx context.Context, key string, values map[string]any) error {
|
||||||
|
if !c.enabled || c.redis == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.local.Delete(key)
|
||||||
|
return c.redis.HMSet(ctx, key, values)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) HGet(ctx context.Context, key string, field string) (string, error) {
|
||||||
|
if !c.enabled || c.redis == nil {
|
||||||
|
return "", ErrKeyNotFound
|
||||||
|
}
|
||||||
|
return c.redis.HGet(ctx, key, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) HMGet(ctx context.Context, key string, fields ...string) ([]any, error) {
|
||||||
|
if !c.enabled || c.redis == nil {
|
||||||
|
return nil, ErrKeyNotFound
|
||||||
|
}
|
||||||
|
return c.redis.HMGet(ctx, key, fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) HGetAll(ctx context.Context, key string) (map[string]string, error) {
|
||||||
|
if !c.enabled || c.redis == nil {
|
||||||
|
return nil, ErrKeyNotFound
|
||||||
|
}
|
||||||
|
return c.redis.HGetAll(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) HDel(ctx context.Context, key string, fields ...string) error {
|
||||||
|
if !c.enabled || c.redis == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.local.Delete(key)
|
||||||
|
return c.redis.HDel(ctx, key, fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) ZAdd(ctx context.Context, key string, score float64, member string) error {
|
||||||
|
if !c.enabled || c.redis == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.local.Delete(key)
|
||||||
|
return c.redis.ZAdd(ctx, key, score, member)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) ZRangeByScore(ctx context.Context, key string, min, max string, offset, count int64) ([]string, error) {
|
||||||
|
if !c.enabled || c.redis == nil {
|
||||||
|
return nil, ErrKeyNotFound
|
||||||
|
}
|
||||||
|
return c.redis.ZRangeByScore(ctx, key, min, max, offset, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) ZRevRangeByScore(ctx context.Context, key string, max, min string, offset, count int64) ([]string, error) {
|
||||||
|
if !c.enabled || c.redis == nil {
|
||||||
|
return nil, ErrKeyNotFound
|
||||||
|
}
|
||||||
|
return c.redis.ZRevRangeByScore(ctx, key, max, min, offset, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) ZRem(ctx context.Context, key string, members ...any) error {
|
||||||
|
if !c.enabled || c.redis == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.local.Delete(key)
|
||||||
|
return c.redis.ZRem(ctx, key, members...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) ZCard(ctx context.Context, key string) (int64, error) {
|
||||||
|
if !c.enabled || c.redis == nil {
|
||||||
|
return 0, ErrKeyNotFound
|
||||||
|
}
|
||||||
|
return c.redis.ZCard(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) Incr(ctx context.Context, key string) (int64, error) {
|
||||||
|
if !c.enabled || c.redis == nil {
|
||||||
|
return 0, ErrKeyNotFound
|
||||||
|
}
|
||||||
|
c.local.Delete(key)
|
||||||
|
return c.redis.Incr(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) Expire(ctx context.Context, key string, ttl time.Duration) error {
|
||||||
|
if !c.enabled || c.redis == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.redis.Expire(ctx, key, ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) SetLocalOnly(key string, value any, ttl time.Duration) {
|
||||||
|
if !c.enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.local.Set(key, value, ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) GetLocalOnly(key string) (any, bool) {
|
||||||
|
if !c.enabled {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return c.local.Get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) SetRedisOnly(key string, value any, ttl time.Duration) {
|
||||||
|
if !c.enabled || c.redis == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.redis.Set(key, value, ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) GetRedisOnly(key string) (any, bool) {
|
||||||
|
if !c.enabled || c.redis == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return c.redis.Get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) GetRedisClient() *redisPkg.Client {
|
||||||
|
if c.redis == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.redis.GetRedisClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTypedLayered[T any](c *LayeredCache, key string) (T, bool) {
|
||||||
|
var zero T
|
||||||
|
if !c.enabled {
|
||||||
|
return zero, false
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, ok := c.Get(key)
|
||||||
|
if !ok {
|
||||||
|
return zero, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if typed, ok := raw.(T); ok {
|
||||||
|
return typed, true
|
||||||
|
}
|
||||||
|
|
||||||
|
if str, ok := raw.(string); ok {
|
||||||
|
var out T
|
||||||
|
if err := json.Unmarshal([]byte(str), &out); err != nil {
|
||||||
|
return zero, false
|
||||||
|
}
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
|
|
||||||
|
if data, err := json.Marshal(raw); err == nil {
|
||||||
|
var out T
|
||||||
|
if err := json.Unmarshal(data, &out); err != nil {
|
||||||
|
return zero, false
|
||||||
|
}
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return zero, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetOrLoadLayered[T any](
|
||||||
|
c *LayeredCache,
|
||||||
|
key string,
|
||||||
|
ttl time.Duration,
|
||||||
|
loader func() (T, error),
|
||||||
|
) (T, error) {
|
||||||
|
if cached, ok := GetTypedLayered[T](c, key); ok {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := loader()
|
||||||
|
if err != nil {
|
||||||
|
var zero T
|
||||||
|
return zero, err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set(key, loaded, ttl)
|
||||||
|
return loaded, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) InvalidateUserCache(userID uint) {
|
||||||
|
c.DeleteByPrefix(cacheKeyPrefixUser + ":")
|
||||||
|
c.Delete(buildUserDetailKey(userID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) InvalidatePostCache(postID uint) {
|
||||||
|
c.Delete(buildPostDetailKey(postID))
|
||||||
|
c.DeleteByPrefix(cacheKeyPrefixPostList)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) InvalidateGroupCache(groupID uint) {
|
||||||
|
c.Delete(buildGroupDetailKey(groupID))
|
||||||
|
c.Delete(buildGroupMembersKey(groupID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LayeredCache) InvalidateAllLists() {
|
||||||
|
c.DeleteByPrefix(cacheKeyPrefixPostList)
|
||||||
|
c.DeleteByPrefix(cacheKeyPrefixUserActivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
cacheKeyPrefixUser = "user"
|
||||||
|
cacheKeyPrefixPostList = "post_list"
|
||||||
|
cacheKeyPrefixUserActivity = "user_activity"
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildUserDetailKey(userID uint) string {
|
||||||
|
return "user:detail:" + strconv.FormatUint(uint64(userID), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPostDetailKey(postID uint) string {
|
||||||
|
return "post:detail:" + strconv.FormatUint(uint64(postID), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildGroupDetailKey(groupID uint) string {
|
||||||
|
return "group:detail:" + strconv.FormatUint(uint64(groupID), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildGroupMembersKey(groupID uint) string {
|
||||||
|
return "group:members:" + strconv.FormatUint(uint64(groupID), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Cache = (*LayeredCache)(nil)
|
||||||
|
|
||||||
|
func (c *LayeredCache) logStats() {
|
||||||
|
stats := c.GetStats()
|
||||||
|
zap.L().Debug("LayeredCache stats",
|
||||||
|
zap.Int64("local_hits", stats.LocalHits),
|
||||||
|
zap.Int64("redis_hits", stats.RedisHits),
|
||||||
|
zap.Int64("misses", stats.Misses),
|
||||||
|
zap.Int64("sets", stats.Sets),
|
||||||
|
zap.Int64("invalidates", stats.Invalidates),
|
||||||
|
zap.Float64("hit_rate", c.GetHitRate()),
|
||||||
|
)
|
||||||
|
}
|
||||||
205
internal/cache/lru.go
vendored
Normal file
205
internal/cache/lru.go
vendored
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"container/list"
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LRUOptions struct {
|
||||||
|
Size int
|
||||||
|
DefaultExpiry time.Duration
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
type LRU struct {
|
||||||
|
lock sync.RWMutex
|
||||||
|
size int
|
||||||
|
len int
|
||||||
|
currentGeneration int64
|
||||||
|
evictList *list.List
|
||||||
|
items map[string]*list.Element
|
||||||
|
defaultExpiry time.Duration
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
type lruEntry struct {
|
||||||
|
key string
|
||||||
|
value []byte
|
||||||
|
expires time.Time
|
||||||
|
generation int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLRU(opts *LRUOptions) *LRU {
|
||||||
|
if opts.Size <= 0 {
|
||||||
|
opts.Size = 1000
|
||||||
|
}
|
||||||
|
return &LRU{
|
||||||
|
name: opts.Name,
|
||||||
|
size: opts.Size,
|
||||||
|
evictList: list.New(),
|
||||||
|
items: make(map[string]*list.Element, opts.Size),
|
||||||
|
defaultExpiry: opts.DefaultExpiry,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LRU) Set(key string, value any, ttl time.Duration) {
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = l.defaultExpiry
|
||||||
|
}
|
||||||
|
l.set(key, value, ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LRU) set(key string, value any, ttl time.Duration) {
|
||||||
|
var expires time.Time
|
||||||
|
if ttl > 0 {
|
||||||
|
expires = time.Now().Add(ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l.lock.Lock()
|
||||||
|
defer l.lock.Unlock()
|
||||||
|
|
||||||
|
if ent, ok := l.items[key]; ok {
|
||||||
|
l.evictList.MoveToFront(ent)
|
||||||
|
e := ent.Value.(*lruEntry)
|
||||||
|
e.value = buf
|
||||||
|
e.expires = expires
|
||||||
|
if e.generation != l.currentGeneration {
|
||||||
|
e.generation = l.currentGeneration
|
||||||
|
l.len++
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ent := &lruEntry{key, buf, expires, l.currentGeneration}
|
||||||
|
entry := l.evictList.PushFront(ent)
|
||||||
|
l.items[key] = entry
|
||||||
|
l.len++
|
||||||
|
|
||||||
|
if l.evictList.Len() > l.size {
|
||||||
|
l.removeElement(l.evictList.Back())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LRU) Get(key string) (any, bool) {
|
||||||
|
val, err := l.getItem(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return val, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LRU) getItem(key string) ([]byte, error) {
|
||||||
|
l.lock.Lock()
|
||||||
|
defer l.lock.Unlock()
|
||||||
|
|
||||||
|
ent, ok := l.items[key]
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrKeyNotFound
|
||||||
|
}
|
||||||
|
e := ent.Value.(*lruEntry)
|
||||||
|
if e.generation != l.currentGeneration || (!e.expires.IsZero() && time.Now().After(e.expires)) {
|
||||||
|
l.removeElement(ent)
|
||||||
|
return nil, ErrKeyNotFound
|
||||||
|
}
|
||||||
|
l.evictList.MoveToFront(ent)
|
||||||
|
return e.value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LRU) Delete(key string) {
|
||||||
|
l.lock.Lock()
|
||||||
|
defer l.lock.Unlock()
|
||||||
|
|
||||||
|
if ent, ok := l.items[key]; ok {
|
||||||
|
l.removeElement(ent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LRU) DeleteByPrefix(prefix string) {
|
||||||
|
l.lock.Lock()
|
||||||
|
defer l.lock.Unlock()
|
||||||
|
|
||||||
|
for key, ent := range l.items {
|
||||||
|
if len(key) >= len(prefix) && key[:len(prefix)] == prefix {
|
||||||
|
l.removeElement(ent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LRU) Clear() {
|
||||||
|
l.lock.Lock()
|
||||||
|
defer l.lock.Unlock()
|
||||||
|
|
||||||
|
l.len = 0
|
||||||
|
l.currentGeneration++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LRU) Exists(key string) bool {
|
||||||
|
l.lock.RLock()
|
||||||
|
defer l.lock.RUnlock()
|
||||||
|
|
||||||
|
ent, ok := l.items[key]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
e := ent.Value.(*lruEntry)
|
||||||
|
return e.generation == l.currentGeneration && (e.expires.IsZero() || !time.Now().After(e.expires))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LRU) Increment(key string) int64 {
|
||||||
|
return l.IncrementBy(key, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LRU) IncrementBy(key string, value int64) int64 {
|
||||||
|
l.lock.Lock()
|
||||||
|
defer l.lock.Unlock()
|
||||||
|
|
||||||
|
var current int64
|
||||||
|
if ent, ok := l.items[key]; ok {
|
||||||
|
e := ent.Value.(*lruEntry)
|
||||||
|
if e.generation == l.currentGeneration && (e.expires.IsZero() || !time.Now().After(e.expires)) {
|
||||||
|
json.Unmarshal(e.value, ¤t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
current += value
|
||||||
|
buf, _ := json.Marshal(current)
|
||||||
|
|
||||||
|
if ent, ok := l.items[key]; ok {
|
||||||
|
e := ent.Value.(*lruEntry)
|
||||||
|
e.value = buf
|
||||||
|
l.evictList.MoveToFront(ent)
|
||||||
|
} else {
|
||||||
|
ent := &lruEntry{key, buf, time.Time{}, l.currentGeneration}
|
||||||
|
entry := l.evictList.PushFront(ent)
|
||||||
|
l.items[key] = entry
|
||||||
|
l.len++
|
||||||
|
}
|
||||||
|
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LRU) Len() int {
|
||||||
|
l.lock.RLock()
|
||||||
|
defer l.lock.RUnlock()
|
||||||
|
return l.len
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LRU) Name() string {
|
||||||
|
return l.name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LRU) removeElement(e *list.Element) {
|
||||||
|
l.evictList.Remove(e)
|
||||||
|
kv := e.Value.(*lruEntry)
|
||||||
|
if kv.generation == l.currentGeneration {
|
||||||
|
l.len--
|
||||||
|
}
|
||||||
|
delete(l.items, kv.key)
|
||||||
|
}
|
||||||
167
internal/cache/lru_striped.go
vendored
Normal file
167
internal/cache/lru_striped.go
vendored
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/cespare/xxhash/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LRUStriped struct {
|
||||||
|
buckets []*LRU
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
type LRUStripedOptions struct {
|
||||||
|
Size int
|
||||||
|
DefaultExpiry time.Duration
|
||||||
|
Name string
|
||||||
|
Buckets int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLRUStriped(opts *LRUStripedOptions) *LRUStriped {
|
||||||
|
if opts.Buckets <= 0 {
|
||||||
|
opts.Buckets = runtime.NumCPU()
|
||||||
|
}
|
||||||
|
if opts.Buckets < 4 {
|
||||||
|
opts.Buckets = 4
|
||||||
|
}
|
||||||
|
if opts.Size < opts.Buckets {
|
||||||
|
opts.Size = opts.Buckets * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.Size += int(math.Ceil(float64(opts.Size) * 10.0 / 100.0))
|
||||||
|
bucketSize := (opts.Size / opts.Buckets) + (opts.Size % opts.Buckets)
|
||||||
|
|
||||||
|
buckets := make([]*LRU, opts.Buckets)
|
||||||
|
for i := 0; i < opts.Buckets; i++ {
|
||||||
|
buckets[i] = NewLRU(&LRUOptions{
|
||||||
|
Size: bucketSize,
|
||||||
|
DefaultExpiry: opts.DefaultExpiry,
|
||||||
|
Name: opts.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LRUStriped{
|
||||||
|
buckets: buckets,
|
||||||
|
name: opts.Name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LRUStriped) hashKey(key string) uint64 {
|
||||||
|
return xxhash.Sum64String(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LRUStriped) keyBucket(key string) *LRU {
|
||||||
|
return l.buckets[l.hashKey(key)%uint64(len(l.buckets))]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LRUStriped) Set(key string, value any, ttl time.Duration) {
|
||||||
|
l.keyBucket(key).Set(key, value, ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LRUStriped) Get(key string) (any, bool) {
|
||||||
|
return l.keyBucket(key).Get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LRUStriped) Delete(key string) {
|
||||||
|
l.keyBucket(key).Delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LRUStriped) DeleteByPrefix(prefix string) {
|
||||||
|
for _, bucket := range l.buckets {
|
||||||
|
bucket.DeleteByPrefix(prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LRUStriped) Clear() {
|
||||||
|
for _, bucket := range l.buckets {
|
||||||
|
bucket.Clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LRUStriped) Exists(key string) bool {
|
||||||
|
return l.keyBucket(key).Exists(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LRUStriped) Increment(key string) int64 {
|
||||||
|
return l.keyBucket(key).Increment(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LRUStriped) IncrementBy(key string, value int64) int64 {
|
||||||
|
return l.keyBucket(key).IncrementBy(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LRUStriped) Len() int {
|
||||||
|
var total int
|
||||||
|
for _, bucket := range l.buckets {
|
||||||
|
total += bucket.Len()
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LRUStriped) Name() string {
|
||||||
|
return l.name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LRUStriped) DeleteMulti(keys []string) error {
|
||||||
|
var err error
|
||||||
|
for _, key := range keys {
|
||||||
|
l.Delete(key)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LRUStriped) GetMulti(keys []string, values []any) []error {
|
||||||
|
errs := make([]error, len(values))
|
||||||
|
for i, key := range keys {
|
||||||
|
if val, ok := l.Get(key); ok {
|
||||||
|
values[i] = val
|
||||||
|
} else {
|
||||||
|
errs[i] = ErrKeyNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LRUStriped) SetMulti(items map[string]any, ttl time.Duration) error {
|
||||||
|
for key, value := range items {
|
||||||
|
l.Set(key, value, ttl)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LRUStriped) Keys() []string {
|
||||||
|
keys := make([]string, 0)
|
||||||
|
for _, bucket := range l.buckets {
|
||||||
|
bucket.lock.RLock()
|
||||||
|
for key, ent := range bucket.items {
|
||||||
|
e := ent.Value.(*lruEntry)
|
||||||
|
if e.generation == bucket.currentGeneration && (e.expires.IsZero() || !time.Now().After(e.expires)) {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bucket.lock.RUnlock()
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
type LRUStripedInterface interface {
|
||||||
|
Set(key string, value any, ttl time.Duration)
|
||||||
|
Get(key string) (any, bool)
|
||||||
|
Delete(key string)
|
||||||
|
DeleteByPrefix(prefix string)
|
||||||
|
Clear()
|
||||||
|
Exists(key string) bool
|
||||||
|
Increment(key string) int64
|
||||||
|
IncrementBy(key string, value int64) int64
|
||||||
|
Len() int
|
||||||
|
Name() string
|
||||||
|
DeleteMulti(keys []string) error
|
||||||
|
GetMulti(keys []string, values []any) []error
|
||||||
|
SetMulti(items map[string]any, ttl time.Duration) error
|
||||||
|
Keys() []string
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ LRUStripedInterface = (*LRUStriped)(nil)
|
||||||
@@ -72,6 +72,10 @@ func Load(configPath string) (*Config, error) {
|
|||||||
viper.SetDefault("cache.modules.conversation_ttl", 60)
|
viper.SetDefault("cache.modules.conversation_ttl", 60)
|
||||||
viper.SetDefault("cache.modules.unread_count_ttl", 30)
|
viper.SetDefault("cache.modules.unread_count_ttl", 30)
|
||||||
viper.SetDefault("cache.modules.group_members_ttl", 120)
|
viper.SetDefault("cache.modules.group_members_ttl", 120)
|
||||||
|
viper.SetDefault("cache.local.enabled", true)
|
||||||
|
viper.SetDefault("cache.local.size", 10000)
|
||||||
|
viper.SetDefault("cache.local.buckets", 0)
|
||||||
|
viper.SetDefault("cache.local.default_ttl", 300)
|
||||||
viper.SetDefault("jwt.secret", "your-jwt-secret-key-change-in-production")
|
viper.SetDefault("jwt.secret", "your-jwt-secret-key-change-in-production")
|
||||||
viper.SetDefault("jwt.access_token_expire", 86400)
|
viper.SetDefault("jwt.access_token_expire", 86400)
|
||||||
viper.SetDefault("jwt.refresh_token_expire", 604800)
|
viper.SetDefault("jwt.refresh_token_expire", 604800)
|
||||||
|
|||||||
@@ -17,13 +17,22 @@ type RedisConfig struct {
|
|||||||
|
|
||||||
// CacheConfig 缓存配置
|
// CacheConfig 缓存配置
|
||||||
type CacheConfig struct {
|
type CacheConfig struct {
|
||||||
Enabled bool `mapstructure:"enabled"`
|
Enabled bool `mapstructure:"enabled"`
|
||||||
KeyPrefix string `mapstructure:"key_prefix"`
|
KeyPrefix string `mapstructure:"key_prefix"`
|
||||||
DefaultTTL int `mapstructure:"default_ttl"`
|
DefaultTTL int `mapstructure:"default_ttl"`
|
||||||
NullTTL int `mapstructure:"null_ttl"`
|
NullTTL int `mapstructure:"null_ttl"`
|
||||||
JitterRatio float64 `mapstructure:"jitter_ratio"`
|
JitterRatio float64 `mapstructure:"jitter_ratio"`
|
||||||
DisableFlushDB bool `mapstructure:"disable_flushdb"`
|
DisableFlushDB bool `mapstructure:"disable_flushdb"`
|
||||||
Modules CacheModuleTTL `mapstructure:"modules"`
|
Modules CacheModuleTTL `mapstructure:"modules"`
|
||||||
|
Local LocalCacheConfig `mapstructure:"local"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalCacheConfig 本地缓存配置
|
||||||
|
type LocalCacheConfig struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
|
Size int `mapstructure:"size"`
|
||||||
|
Buckets int `mapstructure:"buckets"`
|
||||||
|
DefaultTTL int `mapstructure:"default_ttl"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CacheModuleTTL 缓存模块 TTL 配置
|
// CacheModuleTTL 缓存模块 TTL 配置
|
||||||
|
|||||||
237
internal/handler/qrcode_handler.go
Normal file
237
internal/handler/qrcode_handler.go
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"carrot_bbs/internal/pkg/response"
|
||||||
|
"carrot_bbs/internal/pkg/sse"
|
||||||
|
"carrot_bbs/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// QRCodeHandler 二维码登录处理器
|
||||||
|
type QRCodeHandler struct {
|
||||||
|
qrcodeService *service.QRCodeLoginService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewQRCodeHandler 创建二维码登录处理器
|
||||||
|
func NewQRCodeHandler(qrcodeService *service.QRCodeLoginService) *QRCodeHandler {
|
||||||
|
return &QRCodeHandler{
|
||||||
|
qrcodeService: qrcodeService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetQRCode 获取二维码
|
||||||
|
// GET /api/v1/auth/qrcode
|
||||||
|
func (h *QRCodeHandler) GetQRCode(c *gin.Context) {
|
||||||
|
session, err := h.qrcodeService.CreateSession(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
response.InternalServerError(c, "failed to create qrcode session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成二维码URL (deeplink格式)
|
||||||
|
qrcodeURL := fmt.Sprintf("carrotbbs://qrcode/login?session_id=%s", session.SessionID)
|
||||||
|
|
||||||
|
response.Success(c, gin.H{
|
||||||
|
"session_id": session.SessionID,
|
||||||
|
"qrcode_url": qrcodeURL,
|
||||||
|
"expires_in": 300, // 5分钟
|
||||||
|
"expires_at": session.ExpiresAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSEEvents SSE事件流
|
||||||
|
// GET /api/v1/auth/qrcode/events?session_id=xxx
|
||||||
|
func (h *QRCodeHandler) SSEEvents(c *gin.Context) {
|
||||||
|
sessionID := c.Query("session_id")
|
||||||
|
if sessionID == "" {
|
||||||
|
response.BadRequest(c, "session_id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ch, cancel, replay := h.qrcodeService.GetSSEHub().Subscribe(sessionID, 0)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
w := c.Writer
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
response.InternalServerError(c, "streaming unsupported")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
writeEvent := func(ev sse.Event) bool {
|
||||||
|
data, err := sse.EncodeData(ev)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", ev.ID, ev.Event, data); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送历史事件
|
||||||
|
for _, ev := range replay {
|
||||||
|
if !writeEvent(ev) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 心跳
|
||||||
|
heartbeat := time.NewTicker(25 * time.Second)
|
||||||
|
defer heartbeat.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.Request.Context().Done():
|
||||||
|
return
|
||||||
|
case ev, ok := <-ch:
|
||||||
|
if !ok || !writeEvent(ev) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-heartbeat.C:
|
||||||
|
if _, err := fmt.Fprint(w, "event: heartbeat\ndata: {}\n\n"); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan 扫描二维码
|
||||||
|
// POST /api/v1/auth/qrcode/scan
|
||||||
|
func (h *QRCodeHandler) Scan(c *gin.Context) {
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
if userID == "" {
|
||||||
|
response.Unauthorized(c, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScanRequest struct {
|
||||||
|
SessionID string `json:"session_id" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var req ScanRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.qrcodeService.Scan(c.Request.Context(), req.SessionID, userID); err != nil {
|
||||||
|
if err.Error() == "qrcode already scanned" {
|
||||||
|
response.BadRequest(c, "qrcode already scanned by others")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err.Error() == "session not found or expired" {
|
||||||
|
response.NotFound(c, "qrcode expired")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.InternalServerError(c, "failed to scan qrcode")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户信息返回
|
||||||
|
user, err := h.qrcodeService.GetUserService().GetUserByID(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
response.InternalServerError(c, "failed to get user info")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, gin.H{
|
||||||
|
"user": gin.H{
|
||||||
|
"id": user.ID,
|
||||||
|
"nickname": user.Nickname,
|
||||||
|
"avatar": user.Avatar,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm 确认登录
|
||||||
|
// POST /api/v1/auth/qrcode/confirm
|
||||||
|
func (h *QRCodeHandler) Confirm(c *gin.Context) {
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
if userID == "" {
|
||||||
|
response.Unauthorized(c, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfirmRequest struct {
|
||||||
|
SessionID string `json:"session_id" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var req ConfirmRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := c.ClientIP()
|
||||||
|
userAgent := c.GetHeader("User-Agent")
|
||||||
|
|
||||||
|
result, err := h.qrcodeService.Confirm(c.Request.Context(), req.SessionID, userID, ip, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "unauthorized" {
|
||||||
|
response.Unauthorized(c, "not authorized to confirm this login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err.Error() == "session not found or expired" {
|
||||||
|
response.NotFound(c, "qrcode expired")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.InternalServerError(c, "failed to confirm login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"user": gin.H{
|
||||||
|
"id": result.User.ID,
|
||||||
|
"username": result.User.Username,
|
||||||
|
"nickname": result.User.Nickname,
|
||||||
|
"avatar": result.User.Avatar,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel 取消登录
|
||||||
|
// POST /api/v1/auth/qrcode/cancel
|
||||||
|
func (h *QRCodeHandler) Cancel(c *gin.Context) {
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
if userID == "" {
|
||||||
|
response.Unauthorized(c, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type CancelRequest struct {
|
||||||
|
SessionID string `json:"session_id" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var req CancelRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.qrcodeService.Cancel(c.Request.Context(), req.SessionID, userID); err != nil {
|
||||||
|
if err.Error() == "unauthorized" {
|
||||||
|
response.Unauthorized(c, "not authorized to cancel this login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.InternalServerError(c, "failed to cancel login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, gin.H{"success": true})
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ const (
|
|||||||
LoginTypeSMS LoginType = "sms"
|
LoginTypeSMS LoginType = "sms"
|
||||||
LoginTypeEmail LoginType = "email"
|
LoginTypeEmail LoginType = "email"
|
||||||
LoginTypeOAuth LoginType = "oauth"
|
LoginTypeOAuth LoginType = "oauth"
|
||||||
|
LoginTypeQRCode LoginType = "qrcode"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LoginMethod 登录方式
|
// LoginMethod 登录方式
|
||||||
|
|||||||
405
internal/pkg/hook/hook.go
Normal file
405
internal/pkg/hook/hook.go
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
package hook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HookType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
HookPostCreated HookType = "post.created"
|
||||||
|
HookPostUpdated HookType = "post.updated"
|
||||||
|
HookPostDeleted HookType = "post.deleted"
|
||||||
|
HookPostPreModerate HookType = "post.pre_moderate"
|
||||||
|
HookPostModerated HookType = "post.moderated"
|
||||||
|
HookCommentCreated HookType = "comment.created"
|
||||||
|
HookCommentDeleted HookType = "comment.deleted"
|
||||||
|
HookCommentPreModerate HookType = "comment.pre_moderate"
|
||||||
|
HookCommentModerated HookType = "comment.moderated"
|
||||||
|
HookUserCreated HookType = "user.created"
|
||||||
|
HookUserUpdated HookType = "user.updated"
|
||||||
|
HookUserDeleted HookType = "user.deleted"
|
||||||
|
HookUserLogin HookType = "user.login"
|
||||||
|
HookUserLogout HookType = "user.logout"
|
||||||
|
HookGroupCreated HookType = "group.created"
|
||||||
|
HookGroupUpdated HookType = "group.updated"
|
||||||
|
HookGroupDeleted HookType = "group.deleted"
|
||||||
|
HookGroupJoined HookType = "group.joined"
|
||||||
|
HookGroupLeft HookType = "group.left"
|
||||||
|
HookMessageSent HookType = "message.sent"
|
||||||
|
HookMessageRead HookType = "message.read"
|
||||||
|
HookNotificationSent HookType = "notification.sent"
|
||||||
|
HookVoteCreated HookType = "vote.created"
|
||||||
|
HookVoteUpdated HookType = "vote.updated"
|
||||||
|
HookFileUploaded HookType = "file.uploaded"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Priority int
|
||||||
|
|
||||||
|
const (
|
||||||
|
PriorityLowest Priority = 0
|
||||||
|
PriorityLow Priority = 25
|
||||||
|
PriorityNormal Priority = 50
|
||||||
|
PriorityHigh Priority = 75
|
||||||
|
PriorityHighest Priority = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
type HookContext struct {
|
||||||
|
Ctx context.Context
|
||||||
|
HookType HookType
|
||||||
|
UserID uint
|
||||||
|
Data any
|
||||||
|
Metadata map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
type HookFunc func(ctx *HookContext) error
|
||||||
|
|
||||||
|
type Hook struct {
|
||||||
|
Name string
|
||||||
|
HookType HookType
|
||||||
|
Func HookFunc
|
||||||
|
Priority Priority
|
||||||
|
Async bool
|
||||||
|
Timeout time.Duration
|
||||||
|
Enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type HookStats struct {
|
||||||
|
TotalCalls int64
|
||||||
|
FailedCalls int64
|
||||||
|
TotalTime time.Duration
|
||||||
|
AverageTime time.Duration
|
||||||
|
LastCalledAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type hookEntry struct {
|
||||||
|
hook *Hook
|
||||||
|
stats HookStats
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type Manager struct {
|
||||||
|
hooks map[HookType][]*hookEntry
|
||||||
|
mu sync.RWMutex
|
||||||
|
asyncPool chan struct{}
|
||||||
|
enabled bool
|
||||||
|
metrics *Metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManagerOptions struct {
|
||||||
|
MaxConcurrentAsync int
|
||||||
|
Enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager(opts *ManagerOptions) *Manager {
|
||||||
|
if opts == nil {
|
||||||
|
opts = &ManagerOptions{
|
||||||
|
MaxConcurrentAsync: 100,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.MaxConcurrentAsync <= 0 {
|
||||||
|
opts.MaxConcurrentAsync = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Manager{
|
||||||
|
hooks: make(map[HookType][]*hookEntry),
|
||||||
|
asyncPool: make(chan struct{}, opts.MaxConcurrentAsync),
|
||||||
|
enabled: opts.Enabled,
|
||||||
|
metrics: NewMetrics(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Register(hook *Hook) {
|
||||||
|
if hook == nil || hook.Func == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if hook.Priority == 0 {
|
||||||
|
hook.Priority = PriorityNormal
|
||||||
|
}
|
||||||
|
if hook.Timeout == 0 {
|
||||||
|
hook.Timeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
hook.Enabled = true
|
||||||
|
|
||||||
|
entry := &hookEntry{
|
||||||
|
hook: hook,
|
||||||
|
stats: HookStats{},
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
hooks := m.hooks[hook.HookType]
|
||||||
|
hooks = append(hooks, entry)
|
||||||
|
|
||||||
|
for i := len(hooks) - 1; i > 0; i-- {
|
||||||
|
if hooks[i].hook.Priority > hooks[i-1].hook.Priority {
|
||||||
|
hooks[i], hooks[i-1] = hooks[i-1], hooks[i]
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.hooks[hook.HookType] = hooks
|
||||||
|
zap.L().Debug("Hook registered",
|
||||||
|
zap.String("name", hook.Name),
|
||||||
|
zap.String("type", string(hook.HookType)),
|
||||||
|
zap.Int("priority", int(hook.Priority)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) RegisterFunc(hookType HookType, name string, fn HookFunc, opts ...HookOption) {
|
||||||
|
hook := &Hook{
|
||||||
|
Name: name,
|
||||||
|
HookType: hookType,
|
||||||
|
Func: fn,
|
||||||
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(hook)
|
||||||
|
}
|
||||||
|
m.Register(hook)
|
||||||
|
}
|
||||||
|
|
||||||
|
type HookOption func(*Hook)
|
||||||
|
|
||||||
|
func WithPriority(p Priority) HookOption {
|
||||||
|
return func(h *Hook) {
|
||||||
|
h.Priority = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithAsync() HookOption {
|
||||||
|
return func(h *Hook) {
|
||||||
|
h.Async = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithTimeout(timeout time.Duration) HookOption {
|
||||||
|
return func(h *Hook) {
|
||||||
|
h.Timeout = timeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Trigger(ctx context.Context, hookType HookType, userID uint, data any) error {
|
||||||
|
return m.TriggerWithMetadata(ctx, hookType, userID, data, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) TriggerWithMetadata(ctx context.Context, hookType HookType, userID uint, data any, metadata map[string]any) error {
|
||||||
|
if !m.enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.RLock()
|
||||||
|
entries := m.hooks[hookType]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hookCtx := &HookContext{
|
||||||
|
Ctx: ctx,
|
||||||
|
HookType: hookType,
|
||||||
|
UserID: userID,
|
||||||
|
Data: data,
|
||||||
|
Metadata: metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
var asyncHooks []*hookEntry
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.hook.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.hook.Async {
|
||||||
|
asyncHooks = append(asyncHooks, entry)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.executeHook(entry, hookCtx); err != nil {
|
||||||
|
zap.L().Error("Hook execution failed",
|
||||||
|
zap.String("name", entry.hook.Name),
|
||||||
|
zap.String("type", string(hookType)),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range asyncHooks {
|
||||||
|
entry := entry
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case m.asyncPool <- struct{}{}:
|
||||||
|
defer func() { <-m.asyncPool }()
|
||||||
|
if err := m.executeHook(entry, hookCtx); err != nil {
|
||||||
|
zap.L().Error("Async hook execution failed",
|
||||||
|
zap.String("name", entry.hook.Name),
|
||||||
|
zap.String("type", string(hookType)),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
zap.L().Warn("Async hook pool full, skipping hook",
|
||||||
|
zap.String("name", entry.hook.Name),
|
||||||
|
zap.String("type", string(hookType)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) executeHook(entry *hookEntry, ctx *HookContext) error {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if entry.hook.Timeout > 0 {
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
zap.L().Error("Hook panic recovered",
|
||||||
|
zap.String("name", entry.hook.Name),
|
||||||
|
zap.Any("panic", r),
|
||||||
|
)
|
||||||
|
done <- nil
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
done <- entry.hook.Func(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err = <-done:
|
||||||
|
case <-time.After(entry.hook.Timeout):
|
||||||
|
err = context.DeadlineExceeded
|
||||||
|
zap.L().Warn("Hook timeout",
|
||||||
|
zap.String("name", entry.hook.Name),
|
||||||
|
zap.Duration("timeout", entry.hook.Timeout),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
err = entry.hook.Func(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
duration := time.Since(start)
|
||||||
|
|
||||||
|
entry.mu.Lock()
|
||||||
|
entry.stats.TotalCalls++
|
||||||
|
entry.stats.TotalTime += duration
|
||||||
|
entry.stats.AverageTime = time.Duration(int64(entry.stats.TotalTime) / entry.stats.TotalCalls)
|
||||||
|
entry.stats.LastCalledAt = time.Now()
|
||||||
|
if err != nil {
|
||||||
|
entry.stats.FailedCalls++
|
||||||
|
}
|
||||||
|
entry.mu.Unlock()
|
||||||
|
|
||||||
|
m.metrics.RecordHookExecution(entry.hook.Name, entry.hook.HookType, duration, err == nil)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Unregister(hookType HookType, name string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
entries := m.hooks[hookType]
|
||||||
|
for i, entry := range entries {
|
||||||
|
if entry.hook.Name == name {
|
||||||
|
m.hooks[hookType] = append(entries[:i], entries[i+1:]...)
|
||||||
|
zap.L().Debug("Hook unregistered",
|
||||||
|
zap.String("name", name),
|
||||||
|
zap.String("type", string(hookType)),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Enable(hookType HookType, name string) {
|
||||||
|
m.mu.RLock()
|
||||||
|
entries := m.hooks[hookType]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.hook.Name == name {
|
||||||
|
entry.hook.Enabled = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Disable(hookType HookType, name string) {
|
||||||
|
m.mu.RLock()
|
||||||
|
entries := m.hooks[hookType]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.hook.Name == name {
|
||||||
|
entry.hook.Enabled = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) GetStats(hookType HookType, name string) *HookStats {
|
||||||
|
m.mu.RLock()
|
||||||
|
entries := m.hooks[hookType]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.hook.Name == name {
|
||||||
|
entry.mu.Lock()
|
||||||
|
stats := entry.stats
|
||||||
|
entry.mu.Unlock()
|
||||||
|
return &stats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) GetAllStats() map[HookType]map[string]HookStats {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make(map[HookType]map[string]HookStats)
|
||||||
|
for hookType, entries := range m.hooks {
|
||||||
|
result[hookType] = make(map[string]HookStats)
|
||||||
|
for _, entry := range entries {
|
||||||
|
entry.mu.Lock()
|
||||||
|
result[hookType][entry.hook.Name] = entry.stats
|
||||||
|
entry.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) GetMetrics() *Metrics {
|
||||||
|
return m.metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Clear(hookType HookType) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
delete(m.hooks, hookType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) ClearAll() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.hooks = make(map[HookType][]*hookEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) SetEnabled(enabled bool) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.enabled = enabled
|
||||||
|
}
|
||||||
122
internal/pkg/hook/metrics.go
Normal file
122
internal/pkg/hook/metrics.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package hook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Metrics struct {
|
||||||
|
totalCalls atomic.Int64
|
||||||
|
failedCalls atomic.Int64
|
||||||
|
totalTime atomic.Int64
|
||||||
|
hookMetrics map[string]*hookMetric
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type hookMetric struct {
|
||||||
|
calls atomic.Int64
|
||||||
|
failures atomic.Int64
|
||||||
|
totalTime atomic.Int64
|
||||||
|
lastCalled atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMetrics() *Metrics {
|
||||||
|
return &Metrics{
|
||||||
|
hookMetrics: make(map[string]*hookMetric),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Metrics) RecordHookExecution(name string, hookType HookType, duration time.Duration, success bool) {
|
||||||
|
m.totalCalls.Add(1)
|
||||||
|
m.totalTime.Add(int64(duration))
|
||||||
|
|
||||||
|
if !success {
|
||||||
|
m.failedCalls.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := string(hookType) + ":" + name
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
metric, ok := m.hookMetrics[key]
|
||||||
|
if !ok {
|
||||||
|
metric = &hookMetric{}
|
||||||
|
m.hookMetrics[key] = metric
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
metric.calls.Add(1)
|
||||||
|
metric.totalTime.Add(int64(duration))
|
||||||
|
metric.lastCalled.Store(time.Now().Unix())
|
||||||
|
|
||||||
|
if !success {
|
||||||
|
metric.failures.Add(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetricSnapshot struct {
|
||||||
|
Name string
|
||||||
|
Type HookType
|
||||||
|
Calls int64
|
||||||
|
Failures int64
|
||||||
|
TotalTime time.Duration
|
||||||
|
AvgTime time.Duration
|
||||||
|
LastCalled time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Metrics) GetTotalCalls() int64 {
|
||||||
|
return m.totalCalls.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Metrics) GetFailedCalls() int64 {
|
||||||
|
return m.failedCalls.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Metrics) GetTotalTime() time.Duration {
|
||||||
|
return time.Duration(m.totalTime.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Metrics) GetHookMetrics() []MetricSnapshot {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
var snapshots []MetricSnapshot
|
||||||
|
for key, metric := range m.hookMetrics {
|
||||||
|
calls := metric.calls.Load()
|
||||||
|
var avgTime time.Duration
|
||||||
|
if calls > 0 {
|
||||||
|
avgTime = time.Duration(metric.totalTime.Load() / calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
hookType, name := parseKey(key)
|
||||||
|
snapshots = append(snapshots, MetricSnapshot{
|
||||||
|
Name: name,
|
||||||
|
Type: hookType,
|
||||||
|
Calls: calls,
|
||||||
|
Failures: metric.failures.Load(),
|
||||||
|
TotalTime: time.Duration(metric.totalTime.Load()),
|
||||||
|
AvgTime: avgTime,
|
||||||
|
LastCalled: time.Unix(metric.lastCalled.Load(), 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return snapshots
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseKey(key string) (HookType, string) {
|
||||||
|
for i := 0; i < len(key); i++ {
|
||||||
|
if key[i] == ':' {
|
||||||
|
return HookType(key[:i]), key[i+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return HookType(key), ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Metrics) Reset() {
|
||||||
|
m.totalCalls.Store(0)
|
||||||
|
m.failedCalls.Store(0)
|
||||||
|
m.totalTime.Store(0)
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
m.hookMetrics = make(map[string]*hookMetric)
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
353
internal/pkg/hook/moderation_hooks.go
Normal file
353
internal/pkg/hook/moderation_hooks.go
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
package hook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"carrot_bbs/internal/repository"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PostModerationHook struct {
|
||||||
|
name string
|
||||||
|
postAIService PostAIService
|
||||||
|
postRepo *repository.PostRepository
|
||||||
|
strictMode bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type PostAIService interface {
|
||||||
|
IsEnabled() bool
|
||||||
|
ModeratePost(ctx context.Context, title, content string, images []string) error
|
||||||
|
ModerateComment(ctx context.Context, content string, images []string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPostModerationHook(postAIService PostAIService, postRepo *repository.PostRepository, strictMode bool) *PostModerationHook {
|
||||||
|
return &PostModerationHook{
|
||||||
|
name: "moderation.post.ai",
|
||||||
|
postAIService: postAIService,
|
||||||
|
postRepo: postRepo,
|
||||||
|
strictMode: strictMode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PostModerationHook) Register(manager *Manager) {
|
||||||
|
manager.Register(&Hook{
|
||||||
|
Name: h.name,
|
||||||
|
HookType: HookPostPreModerate,
|
||||||
|
Priority: PriorityHighest,
|
||||||
|
Func: h.Execute,
|
||||||
|
Async: false, // 必须同步执行,因为调用方需要审核结果
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PostModerationHook) Execute(ctx *HookContext) error {
|
||||||
|
data, ok := ctx.Data.(*PostModerateHookData)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, ok := ctx.Metadata["result"].(*ModerationResult)
|
||||||
|
if !ok || result == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.postAIService == nil || !h.postAIService.IsEnabled() {
|
||||||
|
result.Approved = true
|
||||||
|
result.ReviewedBy = "system"
|
||||||
|
zap.L().Debug("AI moderation disabled, auto approve post",
|
||||||
|
zap.String("post_id", data.PostID),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.postAIService.ModeratePost(ctx.Ctx, data.Title, data.Content, data.Images)
|
||||||
|
if err != nil {
|
||||||
|
if rejectErr, ok := err.(interface{ UserMessage() string }); ok {
|
||||||
|
result.Approved = false
|
||||||
|
result.RejectReason = rejectErr.UserMessage()
|
||||||
|
result.ReviewedBy = "ai"
|
||||||
|
zap.L().Info("Post rejected by AI moderation",
|
||||||
|
zap.String("post_id", data.PostID),
|
||||||
|
zap.String("reason", result.RejectReason),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.strictMode {
|
||||||
|
result.Approved = false
|
||||||
|
result.Error = err
|
||||||
|
result.ReviewedBy = "ai"
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Approved = true
|
||||||
|
result.ReviewedBy = "system"
|
||||||
|
zap.L().Warn("AI moderation error, fallback approve",
|
||||||
|
zap.String("post_id", data.PostID),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Approved = true
|
||||||
|
result.ReviewedBy = "ai"
|
||||||
|
zap.L().Debug("Post approved by AI moderation",
|
||||||
|
zap.String("post_id", data.PostID),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommentModerationHook struct {
|
||||||
|
name string
|
||||||
|
postAIService PostAIService
|
||||||
|
commentRepo *repository.CommentRepository
|
||||||
|
strictMode bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCommentModerationHook(postAIService PostAIService, commentRepo *repository.CommentRepository, strictMode bool) *CommentModerationHook {
|
||||||
|
return &CommentModerationHook{
|
||||||
|
name: "moderation.comment.ai",
|
||||||
|
postAIService: postAIService,
|
||||||
|
commentRepo: commentRepo,
|
||||||
|
strictMode: strictMode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CommentModerationHook) Register(manager *Manager) {
|
||||||
|
manager.Register(&Hook{
|
||||||
|
Name: h.name,
|
||||||
|
HookType: HookCommentPreModerate,
|
||||||
|
Priority: PriorityHighest,
|
||||||
|
Func: h.Execute,
|
||||||
|
Async: false, // 必须同步执行,因为调用方需要审核结果
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CommentModerationHook) Execute(ctx *HookContext) error {
|
||||||
|
data, ok := ctx.Data.(*CommentModerateHookData)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, ok := ctx.Metadata["result"].(*ModerationResult)
|
||||||
|
if !ok || result == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.postAIService == nil || !h.postAIService.IsEnabled() {
|
||||||
|
result.Approved = true
|
||||||
|
result.ReviewedBy = "system"
|
||||||
|
zap.L().Debug("AI moderation disabled, auto approve comment",
|
||||||
|
zap.String("comment_id", data.CommentID),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.postAIService.ModerateComment(ctx.Ctx, data.Content, data.Images)
|
||||||
|
if err != nil {
|
||||||
|
if rejectErr, ok := err.(interface{ UserMessage() string }); ok {
|
||||||
|
result.Approved = false
|
||||||
|
result.RejectReason = rejectErr.UserMessage()
|
||||||
|
result.ReviewedBy = "ai"
|
||||||
|
zap.L().Info("Comment rejected by AI moderation",
|
||||||
|
zap.String("comment_id", data.CommentID),
|
||||||
|
zap.String("reason", result.RejectReason),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.strictMode {
|
||||||
|
result.Approved = false
|
||||||
|
result.Error = err
|
||||||
|
result.ReviewedBy = "ai"
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Approved = true
|
||||||
|
result.ReviewedBy = "system"
|
||||||
|
zap.L().Warn("AI comment moderation error, fallback approve",
|
||||||
|
zap.String("comment_id", data.CommentID),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Approved = true
|
||||||
|
result.ReviewedBy = "ai"
|
||||||
|
zap.L().Debug("Comment approved by AI moderation",
|
||||||
|
zap.String("comment_id", data.CommentID),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type SensitiveWordHook struct {
|
||||||
|
name string
|
||||||
|
sensitive SensitiveService
|
||||||
|
replaceStr string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SensitiveService interface {
|
||||||
|
Check(ctx context.Context, text string) (bool, []string)
|
||||||
|
Replace(ctx context.Context, text string, repl string) string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSensitiveWordHook(sensitive SensitiveService, replaceStr string) *SensitiveWordHook {
|
||||||
|
return &SensitiveWordHook{
|
||||||
|
name: "moderation.sensitive_word",
|
||||||
|
sensitive: sensitive,
|
||||||
|
replaceStr: replaceStr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SensitiveWordHook) RegisterPostHook(manager *Manager) {
|
||||||
|
manager.Register(&Hook{
|
||||||
|
Name: h.name + ".post",
|
||||||
|
HookType: HookPostPreModerate,
|
||||||
|
Priority: PriorityHigh,
|
||||||
|
Func: h.executePost,
|
||||||
|
Async: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SensitiveWordHook) RegisterCommentHook(manager *Manager) {
|
||||||
|
manager.Register(&Hook{
|
||||||
|
Name: h.name + ".comment",
|
||||||
|
HookType: HookCommentPreModerate,
|
||||||
|
Priority: PriorityHigh,
|
||||||
|
Func: h.executeComment,
|
||||||
|
Async: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SensitiveWordHook) executePost(ctx *HookContext) error {
|
||||||
|
if h.sensitive == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := ctx.Data.(*PostModerateHookData)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, ok := ctx.Metadata["result"].(*ModerationResult)
|
||||||
|
if !ok || result == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.RejectReason != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSensitive, words := h.sensitive.Check(ctx.Ctx, data.Title+" "+data.Content)
|
||||||
|
if hasSensitive {
|
||||||
|
result.Approved = false
|
||||||
|
result.RejectReason = "内容包含敏感词: " + strings.Join(words, ", ")
|
||||||
|
result.ReviewedBy = "sensitive_word"
|
||||||
|
zap.L().Info("Post rejected by sensitive word filter",
|
||||||
|
zap.String("post_id", data.PostID),
|
||||||
|
zap.Strings("words", words),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SensitiveWordHook) executeComment(ctx *HookContext) error {
|
||||||
|
if h.sensitive == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := ctx.Data.(*CommentModerateHookData)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, ok := ctx.Metadata["result"].(*ModerationResult)
|
||||||
|
if !ok || result == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.RejectReason != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSensitive, words := h.sensitive.Check(ctx.Ctx, data.Content)
|
||||||
|
if hasSensitive {
|
||||||
|
result.Approved = false
|
||||||
|
result.RejectReason = "内容包含敏感词: " + strings.Join(words, ", ")
|
||||||
|
result.ReviewedBy = "sensitive_word"
|
||||||
|
zap.L().Info("Comment rejected by sensitive word filter",
|
||||||
|
zap.String("comment_id", data.CommentID),
|
||||||
|
zap.Strings("words", words),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModerationHooks struct {
|
||||||
|
postAIService PostAIService
|
||||||
|
sensitiveService SensitiveService
|
||||||
|
postRepo *repository.PostRepository
|
||||||
|
commentRepo *repository.CommentRepository
|
||||||
|
strictMode bool
|
||||||
|
replaceStr string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewModerationHooks(
|
||||||
|
postAIService PostAIService,
|
||||||
|
sensitiveService SensitiveService,
|
||||||
|
postRepo *repository.PostRepository,
|
||||||
|
commentRepo *repository.CommentRepository,
|
||||||
|
strictMode bool,
|
||||||
|
replaceStr string,
|
||||||
|
) *ModerationHooks {
|
||||||
|
return &ModerationHooks{
|
||||||
|
postAIService: postAIService,
|
||||||
|
sensitiveService: sensitiveService,
|
||||||
|
postRepo: postRepo,
|
||||||
|
commentRepo: commentRepo,
|
||||||
|
strictMode: strictMode,
|
||||||
|
replaceStr: replaceStr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ModerationHooks) RegisterAll(manager *Manager) {
|
||||||
|
postHook := NewPostModerationHook(m.postAIService, m.postRepo, m.strictMode)
|
||||||
|
postHook.Register(manager)
|
||||||
|
|
||||||
|
commentHook := NewCommentModerationHook(m.postAIService, m.commentRepo, m.strictMode)
|
||||||
|
commentHook.Register(manager)
|
||||||
|
|
||||||
|
if m.sensitiveService != nil {
|
||||||
|
sensitiveHook := NewSensitiveWordHook(m.sensitiveService, m.replaceStr)
|
||||||
|
sensitiveHook.RegisterPostHook(manager)
|
||||||
|
sensitiveHook.RegisterCommentHook(manager)
|
||||||
|
}
|
||||||
|
|
||||||
|
zap.L().Info("Registered moderation hooks",
|
||||||
|
zap.Bool("ai_enabled", m.postAIService != nil && m.postAIService.IsEnabled()),
|
||||||
|
zap.Bool("sensitive_enabled", m.sensitiveService != nil),
|
||||||
|
zap.Bool("strict_mode", m.strictMode),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ModerationHooks) ModeratePost(ctx context.Context, manager *Manager, data *PostModerateHookData) *ModerationResult {
|
||||||
|
result := &ModerationResult{}
|
||||||
|
manager.TriggerWithMetadata(ctx, HookPostPreModerate, data.AuthorID, data, map[string]any{
|
||||||
|
"result": result,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ModerationHooks) ModerateComment(ctx context.Context, manager *Manager, data *CommentModerateHookData) *ModerationResult {
|
||||||
|
result := &ModerationResult{}
|
||||||
|
manager.TriggerWithMetadata(ctx, HookCommentPreModerate, data.AuthorID, data, map[string]any{
|
||||||
|
"result": result,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
272
internal/pkg/hook/types.go
Normal file
272
internal/pkg/hook/types.go
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
package hook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PostHookData struct {
|
||||||
|
PostID uint
|
||||||
|
Title string
|
||||||
|
Content string
|
||||||
|
AuthorID uint
|
||||||
|
GroupID *uint
|
||||||
|
IsPublished bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommentHookData struct {
|
||||||
|
CommentID uint
|
||||||
|
PostID uint
|
||||||
|
Content string
|
||||||
|
AuthorID uint
|
||||||
|
ParentID *uint
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserHookData struct {
|
||||||
|
UserID uint
|
||||||
|
Username string
|
||||||
|
Email string
|
||||||
|
Nickname string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupHookData struct {
|
||||||
|
GroupID uint
|
||||||
|
Name string
|
||||||
|
CreatorID uint
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageHookData struct {
|
||||||
|
MessageID uint
|
||||||
|
SenderID uint
|
||||||
|
ReceiverID uint
|
||||||
|
Content string
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotificationHookData struct {
|
||||||
|
NotificationID uint
|
||||||
|
UserID uint
|
||||||
|
Type string
|
||||||
|
Title string
|
||||||
|
Content string
|
||||||
|
}
|
||||||
|
|
||||||
|
type VoteHookData struct {
|
||||||
|
VoteID uint
|
||||||
|
PostID uint
|
||||||
|
VoterID uint
|
||||||
|
OptionID uint
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileHookData struct {
|
||||||
|
FileID uint
|
||||||
|
FileName string
|
||||||
|
FileSize int64
|
||||||
|
Uploader uint
|
||||||
|
MimeType string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PostModerateHookData struct {
|
||||||
|
PostID string
|
||||||
|
Title string
|
||||||
|
Content string
|
||||||
|
Images []string
|
||||||
|
AuthorID uint
|
||||||
|
GroupID *uint
|
||||||
|
}
|
||||||
|
|
||||||
|
type PostModeratedHookData struct {
|
||||||
|
PostID string
|
||||||
|
AuthorID uint
|
||||||
|
Approved bool
|
||||||
|
RejectReason string
|
||||||
|
ReviewedBy string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommentModerateHookData struct {
|
||||||
|
CommentID string
|
||||||
|
PostID string
|
||||||
|
Content string
|
||||||
|
Images []string
|
||||||
|
AuthorID uint
|
||||||
|
ParentID *string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommentModeratedHookData struct {
|
||||||
|
CommentID string
|
||||||
|
PostID string
|
||||||
|
AuthorID uint
|
||||||
|
Approved bool
|
||||||
|
RejectReason string
|
||||||
|
ReviewedBy string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModerationResult struct {
|
||||||
|
Approved bool
|
||||||
|
RejectReason string
|
||||||
|
ReviewedBy string
|
||||||
|
Error error
|
||||||
|
}
|
||||||
|
|
||||||
|
type BuiltinHooks struct {
|
||||||
|
manager *Manager
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBuiltinHooks(manager *Manager) *BuiltinHooks {
|
||||||
|
return &BuiltinHooks{manager: manager}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BuiltinHooks) RegisterAll() {
|
||||||
|
b.registerLoggingHooks()
|
||||||
|
b.registerCacheInvalidationHooks()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BuiltinHooks) registerLoggingHooks() {
|
||||||
|
b.manager.RegisterFunc(HookPostCreated, "log.post.created", func(ctx *HookContext) error {
|
||||||
|
if data, ok := ctx.Data.(*PostHookData); ok {
|
||||||
|
zap.L().Info("Post created",
|
||||||
|
zap.Uint("post_id", data.PostID),
|
||||||
|
zap.Uint("author_id", data.AuthorID),
|
||||||
|
zap.String("title", data.Title),
|
||||||
|
zap.Uint("user_id", ctx.UserID),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, WithAsync(), WithPriority(PriorityLowest))
|
||||||
|
|
||||||
|
b.manager.RegisterFunc(HookUserLogin, "log.user.login", func(ctx *HookContext) error {
|
||||||
|
if data, ok := ctx.Data.(*UserHookData); ok {
|
||||||
|
zap.L().Info("User logged in",
|
||||||
|
zap.Uint("user_id", data.UserID),
|
||||||
|
zap.String("username", data.Username),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, WithAsync())
|
||||||
|
|
||||||
|
b.manager.RegisterFunc(HookCommentCreated, "log.comment.created", func(ctx *HookContext) error {
|
||||||
|
if data, ok := ctx.Data.(*CommentHookData); ok {
|
||||||
|
zap.L().Info("Comment created",
|
||||||
|
zap.Uint("comment_id", data.CommentID),
|
||||||
|
zap.Uint("post_id", data.PostID),
|
||||||
|
zap.Uint("author_id", data.AuthorID),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, WithAsync(), WithPriority(PriorityLowest))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BuiltinHooks) registerCacheInvalidationHooks() {
|
||||||
|
}
|
||||||
|
|
||||||
|
type HookTriggerHelper struct {
|
||||||
|
manager *Manager
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHookTriggerHelper(manager *Manager) *HookTriggerHelper {
|
||||||
|
return &HookTriggerHelper{manager: manager}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HookTriggerHelper) TriggerPostCreated(ctx context.Context, userID uint, data *PostHookData) {
|
||||||
|
h.manager.Trigger(ctx, HookPostCreated, userID, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HookTriggerHelper) TriggerPostUpdated(ctx context.Context, userID uint, data *PostHookData) {
|
||||||
|
h.manager.Trigger(ctx, HookPostUpdated, userID, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HookTriggerHelper) TriggerPostDeleted(ctx context.Context, userID uint, postID uint) {
|
||||||
|
h.manager.Trigger(ctx, HookPostDeleted, userID, postID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HookTriggerHelper) TriggerCommentCreated(ctx context.Context, userID uint, data *CommentHookData) {
|
||||||
|
h.manager.Trigger(ctx, HookCommentCreated, userID, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HookTriggerHelper) TriggerCommentDeleted(ctx context.Context, userID uint, commentID uint) {
|
||||||
|
h.manager.Trigger(ctx, HookCommentDeleted, userID, commentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HookTriggerHelper) TriggerUserCreated(ctx context.Context, data *UserHookData) {
|
||||||
|
h.manager.Trigger(ctx, HookUserCreated, data.UserID, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HookTriggerHelper) TriggerUserUpdated(ctx context.Context, userID uint, data *UserHookData) {
|
||||||
|
h.manager.Trigger(ctx, HookUserUpdated, userID, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HookTriggerHelper) TriggerUserDeleted(ctx context.Context, userID uint) {
|
||||||
|
h.manager.Trigger(ctx, HookUserDeleted, userID, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HookTriggerHelper) TriggerUserLogin(ctx context.Context, userID uint, data *UserHookData) {
|
||||||
|
h.manager.Trigger(ctx, HookUserLogin, userID, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HookTriggerHelper) TriggerUserLogout(ctx context.Context, userID uint) {
|
||||||
|
h.manager.Trigger(ctx, HookUserLogout, userID, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HookTriggerHelper) TriggerGroupCreated(ctx context.Context, userID uint, data *GroupHookData) {
|
||||||
|
h.manager.Trigger(ctx, HookGroupCreated, userID, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HookTriggerHelper) TriggerGroupUpdated(ctx context.Context, userID uint, data *GroupHookData) {
|
||||||
|
h.manager.Trigger(ctx, HookGroupUpdated, userID, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HookTriggerHelper) TriggerGroupDeleted(ctx context.Context, userID uint, groupID uint) {
|
||||||
|
h.manager.Trigger(ctx, HookGroupDeleted, userID, groupID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HookTriggerHelper) TriggerGroupJoined(ctx context.Context, userID uint, groupID uint) {
|
||||||
|
h.manager.Trigger(ctx, HookGroupJoined, userID, groupID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HookTriggerHelper) TriggerGroupLeft(ctx context.Context, userID uint, groupID uint) {
|
||||||
|
h.manager.Trigger(ctx, HookGroupLeft, userID, groupID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HookTriggerHelper) TriggerMessageSent(ctx context.Context, userID uint, data *MessageHookData) {
|
||||||
|
h.manager.Trigger(ctx, HookMessageSent, userID, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HookTriggerHelper) TriggerMessageRead(ctx context.Context, userID uint, messageID uint) {
|
||||||
|
h.manager.Trigger(ctx, HookMessageRead, userID, messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HookTriggerHelper) TriggerNotificationSent(ctx context.Context, userID uint, data *NotificationHookData) {
|
||||||
|
h.manager.Trigger(ctx, HookNotificationSent, userID, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HookTriggerHelper) TriggerVoteCreated(ctx context.Context, userID uint, data *VoteHookData) {
|
||||||
|
h.manager.Trigger(ctx, HookVoteCreated, userID, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HookTriggerHelper) TriggerVoteUpdated(ctx context.Context, userID uint, data *VoteHookData) {
|
||||||
|
h.manager.Trigger(ctx, HookVoteUpdated, userID, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HookTriggerHelper) TriggerFileUploaded(ctx context.Context, userID uint, data *FileHookData) {
|
||||||
|
h.manager.Trigger(ctx, HookFileUploaded, userID, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HookTriggerHelper) TriggerPostPreModerate(ctx context.Context, userID uint, data *PostModerateHookData, result *ModerationResult) {
|
||||||
|
h.manager.TriggerWithMetadata(ctx, HookPostPreModerate, userID, data, map[string]any{
|
||||||
|
"result": result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HookTriggerHelper) TriggerPostModerated(ctx context.Context, userID uint, data *PostModeratedHookData) {
|
||||||
|
h.manager.Trigger(ctx, HookPostModerated, userID, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HookTriggerHelper) TriggerCommentPreModerate(ctx context.Context, userID uint, data *CommentModerateHookData, result *ModerationResult) {
|
||||||
|
h.manager.TriggerWithMetadata(ctx, HookCommentPreModerate, userID, data, map[string]any{
|
||||||
|
"result": result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HookTriggerHelper) TriggerCommentModerated(ctx context.Context, userID uint, data *CommentModeratedHookData) {
|
||||||
|
h.manager.Trigger(ctx, HookCommentModerated, userID, data)
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ type Router struct {
|
|||||||
adminGroupHandler *handler.AdminGroupHandler
|
adminGroupHandler *handler.AdminGroupHandler
|
||||||
adminDashboardHandler *handler.AdminDashboardHandler
|
adminDashboardHandler *handler.AdminDashboardHandler
|
||||||
adminLogHandler *handler.AdminLogHandler
|
adminLogHandler *handler.AdminLogHandler
|
||||||
|
qrcodeHandler *handler.QRCodeHandler
|
||||||
logService *service.LogService
|
logService *service.LogService
|
||||||
jwtService *service.JWTService
|
jwtService *service.JWTService
|
||||||
casbinService service.CasbinService
|
casbinService service.CasbinService
|
||||||
@@ -60,6 +61,7 @@ func New(
|
|||||||
adminGroupHandler *handler.AdminGroupHandler,
|
adminGroupHandler *handler.AdminGroupHandler,
|
||||||
adminDashboardHandler *handler.AdminDashboardHandler,
|
adminDashboardHandler *handler.AdminDashboardHandler,
|
||||||
adminLogHandler *handler.AdminLogHandler,
|
adminLogHandler *handler.AdminLogHandler,
|
||||||
|
qrcodeHandler *handler.QRCodeHandler,
|
||||||
logService *service.LogService,
|
logService *service.LogService,
|
||||||
activityService service.UserActivityService,
|
activityService service.UserActivityService,
|
||||||
casbinService service.CasbinService,
|
casbinService service.CasbinService,
|
||||||
@@ -91,6 +93,7 @@ func New(
|
|||||||
adminGroupHandler: adminGroupHandler,
|
adminGroupHandler: adminGroupHandler,
|
||||||
adminDashboardHandler: adminDashboardHandler,
|
adminDashboardHandler: adminDashboardHandler,
|
||||||
adminLogHandler: adminLogHandler,
|
adminLogHandler: adminLogHandler,
|
||||||
|
qrcodeHandler: qrcodeHandler,
|
||||||
logService: logService,
|
logService: logService,
|
||||||
jwtService: jwtService,
|
jwtService: jwtService,
|
||||||
casbinService: casbinService,
|
casbinService: casbinService,
|
||||||
@@ -183,6 +186,24 @@ func (r *Router) setupRoutes() {
|
|||||||
users.GET("/:id/favorites", middleware.OptionalAuth(r.jwtService), r.postHandler.GetFavorites)
|
users.GET("/:id/favorites", middleware.OptionalAuth(r.jwtService), r.postHandler.GetFavorites)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 二维码登录(公开)
|
||||||
|
if r.qrcodeHandler != nil {
|
||||||
|
qrcode := v1.Group("/auth/qrcode")
|
||||||
|
{
|
||||||
|
qrcode.GET("", r.qrcodeHandler.GetQRCode)
|
||||||
|
qrcode.GET("/events", r.qrcodeHandler.SSEEvents)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 二维码操作(需认证)
|
||||||
|
qrcodeAuth := v1.Group("/auth/qrcode")
|
||||||
|
qrcodeAuth.Use(authMiddleware)
|
||||||
|
{
|
||||||
|
qrcodeAuth.POST("/scan", r.qrcodeHandler.Scan)
|
||||||
|
qrcodeAuth.POST("/confirm", r.qrcodeHandler.Confirm)
|
||||||
|
qrcodeAuth.POST("/cancel", r.qrcodeHandler.Cancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 认证路由(公开)
|
// 认证路由(公开)
|
||||||
authPublic := v1.Group("/auth")
|
authPublic := v1.Group("/auth")
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,19 +2,18 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"carrot_bbs/internal/cache"
|
"carrot_bbs/internal/cache"
|
||||||
"carrot_bbs/internal/model"
|
"carrot_bbs/internal/model"
|
||||||
"carrot_bbs/internal/pkg/gorse"
|
"carrot_bbs/internal/pkg/gorse"
|
||||||
|
"carrot_bbs/internal/pkg/hook"
|
||||||
"carrot_bbs/internal/repository"
|
"carrot_bbs/internal/repository"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CommentService 评论服务
|
|
||||||
type CommentService struct {
|
type CommentService struct {
|
||||||
commentRepo *repository.CommentRepository
|
commentRepo *repository.CommentRepository
|
||||||
postRepo *repository.PostRepository
|
postRepo *repository.PostRepository
|
||||||
@@ -23,10 +22,10 @@ type CommentService struct {
|
|||||||
gorseClient gorse.Client
|
gorseClient gorse.Client
|
||||||
postAIService *PostAIService
|
postAIService *PostAIService
|
||||||
logService *LogService
|
logService *LogService
|
||||||
|
hookManager *hook.Manager
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCommentService 创建评论服务
|
func NewCommentService(commentRepo *repository.CommentRepository, postRepo *repository.PostRepository, systemMessageService SystemMessageService, gorseClient gorse.Client, postAIService *PostAIService, cacheBackend cache.Cache, hookManager *hook.Manager) *CommentService {
|
||||||
func NewCommentService(commentRepo *repository.CommentRepository, postRepo *repository.PostRepository, systemMessageService SystemMessageService, gorseClient gorse.Client, postAIService *PostAIService, cacheBackend cache.Cache) *CommentService {
|
|
||||||
return &CommentService{
|
return &CommentService{
|
||||||
commentRepo: commentRepo,
|
commentRepo: commentRepo,
|
||||||
postRepo: postRepo,
|
postRepo: postRepo,
|
||||||
@@ -35,6 +34,7 @@ func NewCommentService(commentRepo *repository.CommentRepository, postRepo *repo
|
|||||||
gorseClient: gorseClient,
|
gorseClient: gorseClient,
|
||||||
postAIService: postAIService,
|
postAIService: postAIService,
|
||||||
logService: nil,
|
logService: nil,
|
||||||
|
hookManager: hookManager,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,10 +113,9 @@ func (s *CommentService) reviewCommentAsync(
|
|||||||
parentUserID string,
|
parentUserID string,
|
||||||
postOwnerID string,
|
postOwnerID string,
|
||||||
) {
|
) {
|
||||||
// 未启用AI时,直接通过审核并发送后续通知
|
if s.hookManager == nil {
|
||||||
if s.postAIService == nil || !s.postAIService.IsEnabled() {
|
|
||||||
if err := s.commentRepo.UpdateModerationStatus(commentID, model.CommentStatusPublished); err != nil {
|
if err := s.commentRepo.UpdateModerationStatus(commentID, model.CommentStatusPublished); err != nil {
|
||||||
zap.L().Warn("Failed to publish comment without AI moderation",
|
zap.L().Warn("Failed to publish comment without hook manager",
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -132,40 +131,38 @@ func (s *CommentService) reviewCommentAsync(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := s.postAIService.ModerateComment(context.Background(), content, imageURLs)
|
var authorID uint
|
||||||
if err != nil {
|
fmt.Sscanf(userID, "%d", &authorID)
|
||||||
var rejectedErr *CommentModerationRejectedError
|
|
||||||
if errors.As(err, &rejectedErr) {
|
|
||||||
if delErr := s.commentRepo.Delete(commentID); delErr != nil {
|
|
||||||
zap.L().Warn("Failed to delete rejected comment",
|
|
||||||
zap.String("commentID", commentID),
|
|
||||||
zap.Error(delErr),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
s.notifyCommentModerationRejected(userID, rejectedErr.Reason)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 审核服务异常时降级放行,避免评论长期pending
|
result := &hook.ModerationResult{}
|
||||||
if updateErr := s.commentRepo.UpdateModerationStatus(commentID, model.CommentStatusPublished); updateErr != nil {
|
s.hookManager.TriggerWithMetadata(context.Background(), hook.HookCommentPreModerate, authorID, &hook.CommentModerateHookData{
|
||||||
zap.L().Warn("Failed to publish comment after moderation error",
|
CommentID: commentID,
|
||||||
|
PostID: postID,
|
||||||
|
Content: content,
|
||||||
|
Images: imageURLs,
|
||||||
|
AuthorID: authorID,
|
||||||
|
ParentID: parentID,
|
||||||
|
}, map[string]interface{}{
|
||||||
|
"result": result,
|
||||||
|
})
|
||||||
|
|
||||||
|
s.hookManager.Trigger(context.Background(), hook.HookCommentModerated, authorID, &hook.CommentModeratedHookData{
|
||||||
|
CommentID: commentID,
|
||||||
|
PostID: postID,
|
||||||
|
AuthorID: authorID,
|
||||||
|
Approved: result.Approved,
|
||||||
|
RejectReason: result.RejectReason,
|
||||||
|
ReviewedBy: result.ReviewedBy,
|
||||||
|
})
|
||||||
|
|
||||||
|
if !result.Approved {
|
||||||
|
if delErr := s.commentRepo.Delete(commentID); delErr != nil {
|
||||||
|
zap.L().Warn("Failed to delete rejected comment",
|
||||||
zap.String("commentID", commentID),
|
zap.String("commentID", commentID),
|
||||||
zap.Error(updateErr),
|
zap.Error(delErr),
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if statsErr := s.applyCommentPublishedStats(commentID); statsErr != nil {
|
|
||||||
zap.L().Warn("Failed to apply published stats for comment",
|
|
||||||
zap.String("commentID", commentID),
|
|
||||||
zap.Error(statsErr),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
s.invalidatePostCaches(postID)
|
s.notifyCommentModerationRejected(userID, result.RejectReason)
|
||||||
zap.L().Warn("Comment moderation failed, fallback publish comment",
|
|
||||||
zap.String("commentID", commentID),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
s.afterCommentPublished(userID, postID, commentID, parentID, parentUserID, postOwnerID)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
@@ -12,6 +11,7 @@ import (
|
|||||||
"carrot_bbs/internal/cache"
|
"carrot_bbs/internal/cache"
|
||||||
"carrot_bbs/internal/model"
|
"carrot_bbs/internal/model"
|
||||||
"carrot_bbs/internal/pkg/gorse"
|
"carrot_bbs/internal/pkg/gorse"
|
||||||
|
"carrot_bbs/internal/pkg/hook"
|
||||||
"carrot_bbs/internal/repository"
|
"carrot_bbs/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -73,10 +73,10 @@ type postServiceImpl struct {
|
|||||||
postAIService *PostAIService
|
postAIService *PostAIService
|
||||||
txManager repository.TransactionManager
|
txManager repository.TransactionManager
|
||||||
logService *LogService
|
logService *LogService
|
||||||
|
hookManager *hook.Manager
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPostService 创建帖子服务
|
func NewPostService(postRepo *repository.PostRepository, systemMessageService SystemMessageService, gorseClient gorse.Client, postAIService *PostAIService, cacheBackend cache.Cache, txManager repository.TransactionManager, hookManager *hook.Manager) PostService {
|
||||||
func NewPostService(postRepo *repository.PostRepository, systemMessageService SystemMessageService, gorseClient gorse.Client, postAIService *PostAIService, cacheBackend cache.Cache, txManager repository.TransactionManager) PostService {
|
|
||||||
return &postServiceImpl{
|
return &postServiceImpl{
|
||||||
postRepo: postRepo,
|
postRepo: postRepo,
|
||||||
systemMessageService: systemMessageService,
|
systemMessageService: systemMessageService,
|
||||||
@@ -85,6 +85,7 @@ func NewPostService(postRepo *repository.PostRepository, systemMessageService Sy
|
|||||||
postAIService: postAIService,
|
postAIService: postAIService,
|
||||||
txManager: txManager,
|
txManager: txManager,
|
||||||
logService: nil,
|
logService: nil,
|
||||||
|
hookManager: hookManager,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,40 +148,48 @@ func (s *postServiceImpl) reviewPostAsync(postID, userID, title, content string,
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// 未启用AI时,直接发布
|
if s.hookManager == nil {
|
||||||
if s.postAIService == nil || !s.postAIService.IsEnabled() {
|
|
||||||
if err := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "system"); err != nil {
|
if err := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "system"); err != nil {
|
||||||
log.Printf("[WARN] Failed to publish post without AI moderation: %v", err)
|
log.Printf("[WARN] Failed to publish post without hook manager: %v", err)
|
||||||
} else {
|
} else {
|
||||||
s.invalidatePostCaches(postID)
|
s.invalidatePostCaches(postID)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := s.postAIService.ModeratePost(context.Background(), title, content, images)
|
var authorID uint
|
||||||
if err != nil {
|
fmt.Sscanf(userID, "%d", &authorID)
|
||||||
var rejectedErr *PostModerationRejectedError
|
|
||||||
if errors.As(err, &rejectedErr) {
|
|
||||||
if updateErr := s.updateModerationStatusWithRetry(postID, model.PostStatusRejected, rejectedErr.UserMessage(), "ai"); updateErr != nil {
|
|
||||||
log.Printf("[WARN] Failed to reject post %s: %v", postID, updateErr)
|
|
||||||
} else {
|
|
||||||
s.invalidatePostCaches(postID)
|
|
||||||
}
|
|
||||||
s.notifyModerationRejected(userID, rejectedErr.Reason)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 规则审核不可用时,降级为发布,避免长时间pending
|
result := &hook.ModerationResult{}
|
||||||
if updateErr := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "system"); updateErr != nil {
|
s.hookManager.TriggerWithMetadata(context.Background(), hook.HookPostPreModerate, authorID, &hook.PostModerateHookData{
|
||||||
log.Printf("[WARN] Failed to publish post %s after moderation error: %v", postID, updateErr)
|
PostID: postID,
|
||||||
|
Title: title,
|
||||||
|
Content: content,
|
||||||
|
Images: images,
|
||||||
|
AuthorID: authorID,
|
||||||
|
}, map[string]interface{}{
|
||||||
|
"result": result,
|
||||||
|
})
|
||||||
|
|
||||||
|
s.hookManager.Trigger(context.Background(), hook.HookPostModerated, authorID, &hook.PostModeratedHookData{
|
||||||
|
PostID: postID,
|
||||||
|
AuthorID: authorID,
|
||||||
|
Approved: result.Approved,
|
||||||
|
RejectReason: result.RejectReason,
|
||||||
|
ReviewedBy: result.ReviewedBy,
|
||||||
|
})
|
||||||
|
|
||||||
|
if !result.Approved {
|
||||||
|
if updateErr := s.updateModerationStatusWithRetry(postID, model.PostStatusRejected, result.RejectReason, result.ReviewedBy); updateErr != nil {
|
||||||
|
log.Printf("[WARN] Failed to reject post %s: %v", postID, updateErr)
|
||||||
} else {
|
} else {
|
||||||
s.invalidatePostCaches(postID)
|
s.invalidatePostCaches(postID)
|
||||||
}
|
}
|
||||||
log.Printf("[WARN] Post moderation failed, fallback publish post=%s err=%v", postID, err)
|
s.notifyModerationRejected(userID, result.RejectReason)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "ai"); err != nil {
|
if err := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", result.ReviewedBy); err != nil {
|
||||||
log.Printf("[WARN] Failed to publish post %s: %v", postID, err)
|
log.Printf("[WARN] Failed to publish post %s: %v", postID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
291
internal/service/qrcode_login_service.go
Normal file
291
internal/service/qrcode_login_service.go
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"carrot_bbs/internal/model"
|
||||||
|
"carrot_bbs/internal/pkg/redis"
|
||||||
|
"carrot_bbs/internal/pkg/sse"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Lua 脚本:原子性地检查状态并更新为 scanned
|
||||||
|
const scanScript = `
|
||||||
|
local key = KEYS[1]
|
||||||
|
local status = redis.call('HGET', key, 'status')
|
||||||
|
if status == 'pending' then
|
||||||
|
redis.call('HMSET', key, 'status', 'scanned', 'user_id', ARGV[1])
|
||||||
|
return 1
|
||||||
|
elseif status == 'scanned' then
|
||||||
|
return 2
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
`
|
||||||
|
|
||||||
|
const (
|
||||||
|
qrcodeSessionPrefix = "qrcode:session:"
|
||||||
|
qrcodeSessionTTL = 5 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// QRCodeStatus 二维码状态
|
||||||
|
type QRCodeStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
QRCodeStatusPending QRCodeStatus = "pending"
|
||||||
|
QRCodeStatusScanned QRCodeStatus = "scanned"
|
||||||
|
QRCodeStatusConfirmed QRCodeStatus = "confirmed"
|
||||||
|
QRCodeStatusCancelled QRCodeStatus = "cancelled"
|
||||||
|
QRCodeStatusExpired QRCodeStatus = "expired"
|
||||||
|
)
|
||||||
|
|
||||||
|
// QRCodeSession 二维码会话
|
||||||
|
type QRCodeSession struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
Status QRCodeStatus `json:"status"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
ExpiresAt int64 `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QRCodeLoginService 二维码登录服务
|
||||||
|
type QRCodeLoginService struct {
|
||||||
|
redis *redis.Client
|
||||||
|
sseHub *sse.Hub
|
||||||
|
jwtService *JWTService
|
||||||
|
userService UserService
|
||||||
|
activityService UserActivityService
|
||||||
|
logService *LogService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewQRCodeLoginService 创建二维码登录服务
|
||||||
|
func NewQRCodeLoginService(redis *redis.Client, sseHub *sse.Hub, jwtService *JWTService, userService UserService, activityService UserActivityService, logService *LogService) *QRCodeLoginService {
|
||||||
|
return &QRCodeLoginService{
|
||||||
|
redis: redis,
|
||||||
|
sseHub: sseHub,
|
||||||
|
jwtService: jwtService,
|
||||||
|
userService: userService,
|
||||||
|
activityService: activityService,
|
||||||
|
logService: logService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSession 创建二维码会话
|
||||||
|
func (s *QRCodeLoginService) CreateSession(ctx context.Context) (*QRCodeSession, error) {
|
||||||
|
sessionID := uuid.New().String()
|
||||||
|
now := time.Now()
|
||||||
|
expiresAt := now.Add(qrcodeSessionTTL)
|
||||||
|
|
||||||
|
session := &QRCodeSession{
|
||||||
|
SessionID: sessionID,
|
||||||
|
Status: QRCodeStatusPending,
|
||||||
|
CreatedAt: now.Unix(),
|
||||||
|
ExpiresAt: expiresAt.Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
key := qrcodeSessionPrefix + sessionID
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"status": string(session.Status),
|
||||||
|
"user_id": "",
|
||||||
|
"created_at": session.CreatedAt,
|
||||||
|
"expires_at": session.ExpiresAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.redis.HMSet(ctx, key, data); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.redis.Expire(ctx, key, qrcodeSessionTTL); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to set session ttl: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSession 获取会话
|
||||||
|
func (s *QRCodeLoginService) GetSession(ctx context.Context, sessionID string) (*QRCodeSession, error) {
|
||||||
|
key := qrcodeSessionPrefix + sessionID
|
||||||
|
data, err := s.redis.HGetAll(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil, fmt.Errorf("session not found or expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
session := &QRCodeSession{
|
||||||
|
SessionID: sessionID,
|
||||||
|
Status: QRCodeStatus(data["status"]),
|
||||||
|
UserID: data["user_id"],
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析时间戳
|
||||||
|
if v := data["created_at"]; v != "" {
|
||||||
|
fmt.Sscanf(v, "%d", &session.CreatedAt)
|
||||||
|
}
|
||||||
|
if v := data["expires_at"]; v != "" {
|
||||||
|
fmt.Sscanf(v, "%d", &session.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否过期
|
||||||
|
if time.Now().Unix() > session.ExpiresAt {
|
||||||
|
session.Status = QRCodeStatusExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan 扫描二维码
|
||||||
|
func (s *QRCodeLoginService) Scan(ctx context.Context, sessionID, userID string) error {
|
||||||
|
key := qrcodeSessionPrefix + sessionID
|
||||||
|
|
||||||
|
// 使用 Lua 脚本实现原子性检查和更新,避免竞态条件
|
||||||
|
result, err := s.redis.GetClient().Eval(ctx, scanScript, []string{key}, userID).Int()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to scan: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case 0:
|
||||||
|
return fmt.Errorf("session not found or expired")
|
||||||
|
case 2:
|
||||||
|
return fmt.Errorf("qrcode already scanned")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户信息用于推送
|
||||||
|
user, err := s.userService.GetUserByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get user info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 推送扫码事件
|
||||||
|
payload := map[string]any{
|
||||||
|
"user": map[string]any{
|
||||||
|
"id": user.ID,
|
||||||
|
"nickname": user.Nickname,
|
||||||
|
"avatar": user.Avatar,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
s.sseHub.PublishToUser(sessionID, "scanned", payload)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm 确认登录
|
||||||
|
func (s *QRCodeLoginService) Confirm(ctx context.Context, sessionID, userID string, ip, userAgent string) (*LoginResponse, error) {
|
||||||
|
session, err := s.GetSession(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.Status != QRCodeStatusScanned {
|
||||||
|
return nil, fmt.Errorf("invalid session status")
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.UserID != userID {
|
||||||
|
return nil, fmt.Errorf("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
user, err := s.userService.GetUserByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("user not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复用JWT生成逻辑
|
||||||
|
accessToken, err := s.jwtService.GenerateAccessToken(user.ID, user.Username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate access token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken, err := s.jwtService.GenerateRefreshToken(user.ID, user.Username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate refresh token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新状态
|
||||||
|
key := qrcodeSessionPrefix + sessionID
|
||||||
|
if err := s.redis.HSet(ctx, key, "status", string(QRCodeStatusConfirmed)); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to update status: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复用活跃记录
|
||||||
|
if s.activityService != nil {
|
||||||
|
go func() {
|
||||||
|
s.activityService.RecordUserActive(context.Background(), userID, model.LoginTypeLogin, ip, userAgent)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复用登录日志
|
||||||
|
if s.logService != nil {
|
||||||
|
go func() {
|
||||||
|
s.logService.LoginLog.RecordLogin(&model.LoginLog{
|
||||||
|
UserID: user.ID,
|
||||||
|
UserName: user.Username,
|
||||||
|
NickName: user.Nickname,
|
||||||
|
LoginType: string(model.LoginTypeQRCode),
|
||||||
|
Event: string(model.LoginEventLogin),
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: userAgent,
|
||||||
|
Result: string(model.LoginResultSuccess),
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 推送确认事件
|
||||||
|
response := &LoginResponse{
|
||||||
|
Token: accessToken,
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
User: user,
|
||||||
|
}
|
||||||
|
s.sseHub.PublishToUser(sessionID, "confirmed", response)
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginResponse 登录响应
|
||||||
|
type LoginResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
User *model.User `json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel 取消登录
|
||||||
|
func (s *QRCodeLoginService) Cancel(ctx context.Context, sessionID, userID string) error {
|
||||||
|
session, err := s.GetSession(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.Status != QRCodeStatusScanned {
|
||||||
|
return fmt.Errorf("invalid session status")
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.UserID != userID {
|
||||||
|
return fmt.Errorf("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
key := qrcodeSessionPrefix + sessionID
|
||||||
|
if err := s.redis.HSet(ctx, key, "status", string(QRCodeStatusCancelled)); err != nil {
|
||||||
|
return fmt.Errorf("failed to update status: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 推送取消事件
|
||||||
|
s.sseHub.PublishToUser(sessionID, "cancelled", map[string]interface{}{})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSSEHub 获取SSE Hub
|
||||||
|
func (s *QRCodeLoginService) GetSSEHub() *sse.Hub {
|
||||||
|
return s.sseHub
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserService 获取用户服务
|
||||||
|
func (s *QRCodeLoginService) GetUserService() UserService {
|
||||||
|
return s.userService
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ var HandlerSet = wire.NewSet(
|
|||||||
handler.NewAdminGroupHandler,
|
handler.NewAdminGroupHandler,
|
||||||
handler.NewAdminDashboardHandler,
|
handler.NewAdminDashboardHandler,
|
||||||
handler.NewAdminLogHandler,
|
handler.NewAdminLogHandler,
|
||||||
|
handler.NewQRCodeHandler,
|
||||||
|
|
||||||
// 需要特殊处理的 Handler
|
// 需要特殊处理的 Handler
|
||||||
ProvideUserHandler,
|
ProvideUserHandler,
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ import (
|
|||||||
"carrot_bbs/internal/pkg/crypto"
|
"carrot_bbs/internal/pkg/crypto"
|
||||||
"carrot_bbs/internal/pkg/email"
|
"carrot_bbs/internal/pkg/email"
|
||||||
"carrot_bbs/internal/pkg/gorse"
|
"carrot_bbs/internal/pkg/gorse"
|
||||||
|
"carrot_bbs/internal/pkg/hook"
|
||||||
"carrot_bbs/internal/pkg/openai"
|
"carrot_bbs/internal/pkg/openai"
|
||||||
"carrot_bbs/internal/pkg/redis"
|
"carrot_bbs/internal/pkg/redis"
|
||||||
"carrot_bbs/internal/pkg/s3"
|
"carrot_bbs/internal/pkg/s3"
|
||||||
"carrot_bbs/internal/pkg/sse"
|
"carrot_bbs/internal/pkg/sse"
|
||||||
"carrot_bbs/internal/repository"
|
"carrot_bbs/internal/repository"
|
||||||
|
"carrot_bbs/internal/service"
|
||||||
|
|
||||||
"github.com/google/wire"
|
"github.com/google/wire"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -38,6 +40,11 @@ var InfrastructureSet = wire.NewSet(
|
|||||||
ProvideRedisClient,
|
ProvideRedisClient,
|
||||||
ProvideCache,
|
ProvideCache,
|
||||||
|
|
||||||
|
// 钩子系统
|
||||||
|
ProvideHookManager,
|
||||||
|
ProvideBuiltinHooks,
|
||||||
|
ProvideModerationHooks,
|
||||||
|
|
||||||
// SSE Hub
|
// SSE Hub
|
||||||
ProvideSSEHub,
|
ProvideSSEHub,
|
||||||
|
|
||||||
@@ -88,7 +95,18 @@ func ProvideCache(cfg *config.Config, redisClient *redis.Client) cache.Cache {
|
|||||||
DisableFlushDB: cfg.Cache.DisableFlushDB,
|
DisableFlushDB: cfg.Cache.DisableFlushDB,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 直接创建 RedisCache 实例,不依赖全局变量
|
if cfg.Cache.Local.Enabled {
|
||||||
|
layeredCache := cache.NewLayeredCache(redisClient, &cache.LayeredCacheOptions{
|
||||||
|
LocalSize: cfg.Cache.Local.Size,
|
||||||
|
LocalBuckets: cfg.Cache.Local.Buckets,
|
||||||
|
LocalDefaultTTL: time.Duration(cfg.Cache.Local.DefaultTTL) * time.Second,
|
||||||
|
KeyPrefix: cfg.Cache.KeyPrefix,
|
||||||
|
Enabled: cfg.Cache.Enabled,
|
||||||
|
})
|
||||||
|
zap.L().Info("Initialized layered cache (local LRU + Redis)")
|
||||||
|
return layeredCache
|
||||||
|
}
|
||||||
|
|
||||||
if redisClient == nil {
|
if redisClient == nil {
|
||||||
zap.L().Warn("Redis client is nil, cache will not be available")
|
zap.L().Warn("Redis client is nil, cache will not be available")
|
||||||
return nil
|
return nil
|
||||||
@@ -99,6 +117,47 @@ func ProvideCache(cfg *config.Config, redisClient *redis.Client) cache.Cache {
|
|||||||
return cacheInstance
|
return cacheInstance
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProvideHookManager 提供钩子管理器
|
||||||
|
func ProvideHookManager() *hook.Manager {
|
||||||
|
return hook.NewManager(&hook.ManagerOptions{
|
||||||
|
MaxConcurrentAsync: 100,
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProvideBuiltinHooks 注册内置钩子
|
||||||
|
func ProvideBuiltinHooks(manager *hook.Manager) *hook.BuiltinHooks {
|
||||||
|
builtin := hook.NewBuiltinHooks(manager)
|
||||||
|
builtin.RegisterAll()
|
||||||
|
zap.L().Info("Registered builtin hooks")
|
||||||
|
return builtin
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProvideModerationHooks 注册审核钩子
|
||||||
|
func ProvideModerationHooks(
|
||||||
|
manager *hook.Manager,
|
||||||
|
postAIService *service.PostAIService,
|
||||||
|
postRepo *repository.PostRepository,
|
||||||
|
commentRepo *repository.CommentRepository,
|
||||||
|
cfg *config.Config,
|
||||||
|
) *hook.ModerationHooks {
|
||||||
|
strictMode := cfg.OpenAI.StrictModeration
|
||||||
|
|
||||||
|
moderationHooks := hook.NewModerationHooks(
|
||||||
|
postAIService,
|
||||||
|
nil,
|
||||||
|
postRepo,
|
||||||
|
commentRepo,
|
||||||
|
strictMode,
|
||||||
|
"***",
|
||||||
|
)
|
||||||
|
moderationHooks.RegisterAll(manager)
|
||||||
|
zap.L().Info("Registered moderation hooks",
|
||||||
|
zap.Bool("strict_mode", strictMode),
|
||||||
|
)
|
||||||
|
return moderationHooks
|
||||||
|
}
|
||||||
|
|
||||||
// ProvideSSEHub 提供 SSE Hub
|
// ProvideSSEHub 提供 SSE Hub
|
||||||
func ProvideSSEHub() *sse.Hub {
|
func ProvideSSEHub() *sse.Hub {
|
||||||
return sse.NewHub()
|
return sse.NewHub()
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import (
|
|||||||
"carrot_bbs/internal/grpc/runner"
|
"carrot_bbs/internal/grpc/runner"
|
||||||
"carrot_bbs/internal/pkg/email"
|
"carrot_bbs/internal/pkg/email"
|
||||||
"carrot_bbs/internal/pkg/gorse"
|
"carrot_bbs/internal/pkg/gorse"
|
||||||
|
"carrot_bbs/internal/pkg/hook"
|
||||||
"carrot_bbs/internal/pkg/openai"
|
"carrot_bbs/internal/pkg/openai"
|
||||||
|
"carrot_bbs/internal/pkg/redis"
|
||||||
"carrot_bbs/internal/pkg/s3"
|
"carrot_bbs/internal/pkg/s3"
|
||||||
"carrot_bbs/internal/pkg/sse"
|
"carrot_bbs/internal/pkg/sse"
|
||||||
"carrot_bbs/internal/repository"
|
"carrot_bbs/internal/repository"
|
||||||
@@ -50,6 +52,7 @@ var ServiceSet = wire.NewSet(
|
|||||||
ProvideAdminCommentService,
|
ProvideAdminCommentService,
|
||||||
ProvideAdminGroupService,
|
ProvideAdminGroupService,
|
||||||
ProvideAdminDashboardService,
|
ProvideAdminDashboardService,
|
||||||
|
ProvideQRCodeLoginService,
|
||||||
|
|
||||||
// 日志服务
|
// 日志服务
|
||||||
ProvideAsyncLogManager,
|
ProvideAsyncLogManager,
|
||||||
@@ -100,7 +103,6 @@ func ProvideSystemMessageService(
|
|||||||
return service.NewSystemMessageService(notifyRepo, pushService, userRepo, postRepo, cacheBackend)
|
return service.NewSystemMessageService(notifyRepo, pushService, userRepo, postRepo, cacheBackend)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvidePostService 提供帖子服务
|
|
||||||
func ProvidePostService(
|
func ProvidePostService(
|
||||||
postRepo *repository.PostRepository,
|
postRepo *repository.PostRepository,
|
||||||
systemMessageService service.SystemMessageService,
|
systemMessageService service.SystemMessageService,
|
||||||
@@ -108,11 +110,11 @@ func ProvidePostService(
|
|||||||
postAIService *service.PostAIService,
|
postAIService *service.PostAIService,
|
||||||
cacheBackend cache.Cache,
|
cacheBackend cache.Cache,
|
||||||
txManager repository.TransactionManager,
|
txManager repository.TransactionManager,
|
||||||
|
hookManager *hook.Manager,
|
||||||
) service.PostService {
|
) service.PostService {
|
||||||
return service.NewPostService(postRepo, systemMessageService, gorseClient, postAIService, cacheBackend, txManager)
|
return service.NewPostService(postRepo, systemMessageService, gorseClient, postAIService, cacheBackend, txManager, hookManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvideCommentService 提供评论服务
|
|
||||||
func ProvideCommentService(
|
func ProvideCommentService(
|
||||||
commentRepo *repository.CommentRepository,
|
commentRepo *repository.CommentRepository,
|
||||||
postRepo *repository.PostRepository,
|
postRepo *repository.PostRepository,
|
||||||
@@ -120,8 +122,9 @@ func ProvideCommentService(
|
|||||||
gorseClient gorse.Client,
|
gorseClient gorse.Client,
|
||||||
postAIService *service.PostAIService,
|
postAIService *service.PostAIService,
|
||||||
cacheBackend cache.Cache,
|
cacheBackend cache.Cache,
|
||||||
|
hookManager *hook.Manager,
|
||||||
) *service.CommentService {
|
) *service.CommentService {
|
||||||
return service.NewCommentService(commentRepo, postRepo, systemMessageService, gorseClient, postAIService, cacheBackend)
|
return service.NewCommentService(commentRepo, postRepo, systemMessageService, gorseClient, postAIService, cacheBackend, hookManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvideMessageService 提供消息服务
|
// ProvideMessageService 提供消息服务
|
||||||
@@ -343,6 +346,18 @@ func ProvideLogService(
|
|||||||
return service.NewLogService(operationLogService, loginLogService, dataChangeLogService)
|
return service.NewLogService(operationLogService, loginLogService, dataChangeLogService)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProvideQRCodeLoginService 提供二维码登录服务
|
||||||
|
func ProvideQRCodeLoginService(
|
||||||
|
redisClient *redis.Client,
|
||||||
|
sseHub *sse.Hub,
|
||||||
|
jwtService *service.JWTService,
|
||||||
|
userService service.UserService,
|
||||||
|
activityService service.UserActivityService,
|
||||||
|
logService *service.LogService,
|
||||||
|
) *service.QRCodeLoginService {
|
||||||
|
return service.NewQRCodeLoginService(redisClient, sseHub, jwtService, userService, activityService, logService)
|
||||||
|
}
|
||||||
|
|
||||||
// parseDuration 解析时长字符串
|
// parseDuration 解析时长字符串
|
||||||
func parseDuration(s string) (time.Duration, error) {
|
func parseDuration(s string) (time.Duration, error) {
|
||||||
return time.ParseDuration(s)
|
return time.ParseDuration(s)
|
||||||
|
|||||||
Reference in New Issue
Block a user