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,
|
||||
adminDashboardHandler *handler.AdminDashboardHandler,
|
||||
adminLogHandler *handler.AdminLogHandler,
|
||||
qrcodeHandler *handler.QRCodeHandler,
|
||||
logService *service.LogService,
|
||||
activityService service.UserActivityService,
|
||||
casbinService service.CasbinService,
|
||||
@@ -71,6 +72,7 @@ func ProvideRouter(
|
||||
adminGroupHandler,
|
||||
adminDashboardHandler,
|
||||
adminLogHandler,
|
||||
qrcodeHandler,
|
||||
logService,
|
||||
activityService,
|
||||
casbinService,
|
||||
|
||||
@@ -46,9 +46,10 @@ func InitializeApp() (*App, error) {
|
||||
openaiClient := wire.ProvideOpenAIClient(config)
|
||||
postAIService := wire.ProvidePostAIService(openaiClient)
|
||||
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)
|
||||
commentService := wire.ProvideCommentService(commentRepository, postRepository, systemMessageService, gorseClient, postAIService, cache)
|
||||
commentService := wire.ProvideCommentService(commentRepository, postRepository, systemMessageService, gorseClient, postAIService, cache, manager)
|
||||
operationLogRepository := repository.NewOperationLogRepository(db)
|
||||
loginLogRepository := repository.NewLoginLogRepository(db)
|
||||
dataChangeLogRepository := repository.NewDataChangeLogRepository(db)
|
||||
@@ -108,7 +109,9 @@ func InitializeApp() (*App, error) {
|
||||
adminDashboardService := wire.ProvideAdminDashboardService(db, userRepository, postRepository, commentRepository, groupRepository, userActivityRepository)
|
||||
adminDashboardHandler := handler.NewAdminDashboardHandler(adminDashboardService)
|
||||
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)
|
||||
return app, nil
|
||||
}
|
||||
@@ -138,6 +141,7 @@ func ProvideRouter(
|
||||
adminGroupHandler *handler.AdminGroupHandler,
|
||||
adminDashboardHandler *handler.AdminDashboardHandler,
|
||||
adminLogHandler *handler.AdminLogHandler,
|
||||
qrcodeHandler *handler.QRCodeHandler,
|
||||
logService *service.LogService,
|
||||
activityService service.UserActivityService,
|
||||
casbinService service.CasbinService,
|
||||
@@ -164,6 +168,7 @@ func ProvideRouter(
|
||||
adminGroupHandler,
|
||||
adminDashboardHandler,
|
||||
adminLogHandler,
|
||||
qrcodeHandler,
|
||||
logService,
|
||||
activityService,
|
||||
casbinService,
|
||||
|
||||
2
go.mod
2
go.mod
@@ -6,6 +6,7 @@ require (
|
||||
github.com/alicebob/miniredis/v2 v2.37.0
|
||||
github.com/casbin/casbin/v3 v3.10.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/gin-gonic/gin v1.12.0
|
||||
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/loader v0.5.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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // 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/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/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.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
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"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrKeyNotFound = errors.New("key not found")
|
||||
|
||||
// Cache 缓存接口
|
||||
type Cache interface {
|
||||
// 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.unread_count_ttl", 30)
|
||||
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.access_token_expire", 86400)
|
||||
viper.SetDefault("jwt.refresh_token_expire", 604800)
|
||||
|
||||
@@ -24,6 +24,15 @@ type CacheConfig struct {
|
||||
JitterRatio float64 `mapstructure:"jitter_ratio"`
|
||||
DisableFlushDB bool `mapstructure:"disable_flushdb"`
|
||||
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 配置
|
||||
|
||||
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"
|
||||
LoginTypeEmail LoginType = "email"
|
||||
LoginTypeOAuth LoginType = "oauth"
|
||||
LoginTypeQRCode LoginType = "qrcode"
|
||||
)
|
||||
|
||||
// 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
|
||||
adminDashboardHandler *handler.AdminDashboardHandler
|
||||
adminLogHandler *handler.AdminLogHandler
|
||||
qrcodeHandler *handler.QRCodeHandler
|
||||
logService *service.LogService
|
||||
jwtService *service.JWTService
|
||||
casbinService service.CasbinService
|
||||
@@ -60,6 +61,7 @@ func New(
|
||||
adminGroupHandler *handler.AdminGroupHandler,
|
||||
adminDashboardHandler *handler.AdminDashboardHandler,
|
||||
adminLogHandler *handler.AdminLogHandler,
|
||||
qrcodeHandler *handler.QRCodeHandler,
|
||||
logService *service.LogService,
|
||||
activityService service.UserActivityService,
|
||||
casbinService service.CasbinService,
|
||||
@@ -91,6 +93,7 @@ func New(
|
||||
adminGroupHandler: adminGroupHandler,
|
||||
adminDashboardHandler: adminDashboardHandler,
|
||||
adminLogHandler: adminLogHandler,
|
||||
qrcodeHandler: qrcodeHandler,
|
||||
logService: logService,
|
||||
jwtService: jwtService,
|
||||
casbinService: casbinService,
|
||||
@@ -183,6 +186,24 @@ func (r *Router) setupRoutes() {
|
||||
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")
|
||||
{
|
||||
|
||||
@@ -2,19 +2,18 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"carrot_bbs/internal/cache"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/gorse"
|
||||
"carrot_bbs/internal/pkg/hook"
|
||||
"carrot_bbs/internal/repository"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// CommentService 评论服务
|
||||
type CommentService struct {
|
||||
commentRepo *repository.CommentRepository
|
||||
postRepo *repository.PostRepository
|
||||
@@ -23,10 +22,10 @@ type CommentService struct {
|
||||
gorseClient gorse.Client
|
||||
postAIService *PostAIService
|
||||
logService *LogService
|
||||
hookManager *hook.Manager
|
||||
}
|
||||
|
||||
// NewCommentService 创建评论服务
|
||||
func NewCommentService(commentRepo *repository.CommentRepository, postRepo *repository.PostRepository, systemMessageService SystemMessageService, gorseClient gorse.Client, postAIService *PostAIService, cacheBackend cache.Cache) *CommentService {
|
||||
func NewCommentService(commentRepo *repository.CommentRepository, postRepo *repository.PostRepository, systemMessageService SystemMessageService, gorseClient gorse.Client, postAIService *PostAIService, cacheBackend cache.Cache, hookManager *hook.Manager) *CommentService {
|
||||
return &CommentService{
|
||||
commentRepo: commentRepo,
|
||||
postRepo: postRepo,
|
||||
@@ -35,6 +34,7 @@ func NewCommentService(commentRepo *repository.CommentRepository, postRepo *repo
|
||||
gorseClient: gorseClient,
|
||||
postAIService: postAIService,
|
||||
logService: nil,
|
||||
hookManager: hookManager,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,10 +113,9 @@ func (s *CommentService) reviewCommentAsync(
|
||||
parentUserID string,
|
||||
postOwnerID string,
|
||||
) {
|
||||
// 未启用AI时,直接通过审核并发送后续通知
|
||||
if s.postAIService == nil || !s.postAIService.IsEnabled() {
|
||||
if s.hookManager == 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),
|
||||
)
|
||||
return
|
||||
@@ -132,40 +131,38 @@ func (s *CommentService) reviewCommentAsync(
|
||||
return
|
||||
}
|
||||
|
||||
err := s.postAIService.ModerateComment(context.Background(), content, imageURLs)
|
||||
if err != nil {
|
||||
var rejectedErr *CommentModerationRejectedError
|
||||
if errors.As(err, &rejectedErr) {
|
||||
var authorID uint
|
||||
fmt.Sscanf(userID, "%d", &authorID)
|
||||
|
||||
result := &hook.ModerationResult{}
|
||||
s.hookManager.TriggerWithMetadata(context.Background(), hook.HookCommentPreModerate, authorID, &hook.CommentModerateHookData{
|
||||
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.Error(delErr),
|
||||
)
|
||||
}
|
||||
s.notifyCommentModerationRejected(userID, rejectedErr.Reason)
|
||||
return
|
||||
}
|
||||
|
||||
// 审核服务异常时降级放行,避免评论长期pending
|
||||
if updateErr := s.commentRepo.UpdateModerationStatus(commentID, model.CommentStatusPublished); updateErr != nil {
|
||||
zap.L().Warn("Failed to publish comment after moderation error",
|
||||
zap.String("commentID", commentID),
|
||||
zap.Error(updateErr),
|
||||
)
|
||||
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)
|
||||
zap.L().Warn("Comment moderation failed, fallback publish comment",
|
||||
zap.String("commentID", commentID),
|
||||
zap.Error(err),
|
||||
)
|
||||
s.afterCommentPublished(userID, postID, commentID, parentID, parentUserID, postOwnerID)
|
||||
s.notifyCommentModerationRejected(userID, result.RejectReason)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
@@ -12,6 +11,7 @@ import (
|
||||
"carrot_bbs/internal/cache"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/gorse"
|
||||
"carrot_bbs/internal/pkg/hook"
|
||||
"carrot_bbs/internal/repository"
|
||||
)
|
||||
|
||||
@@ -73,10 +73,10 @@ type postServiceImpl struct {
|
||||
postAIService *PostAIService
|
||||
txManager repository.TransactionManager
|
||||
logService *LogService
|
||||
hookManager *hook.Manager
|
||||
}
|
||||
|
||||
// NewPostService 创建帖子服务
|
||||
func NewPostService(postRepo *repository.PostRepository, systemMessageService SystemMessageService, gorseClient gorse.Client, postAIService *PostAIService, cacheBackend cache.Cache, txManager repository.TransactionManager) PostService {
|
||||
func NewPostService(postRepo *repository.PostRepository, systemMessageService SystemMessageService, gorseClient gorse.Client, postAIService *PostAIService, cacheBackend cache.Cache, txManager repository.TransactionManager, hookManager *hook.Manager) PostService {
|
||||
return &postServiceImpl{
|
||||
postRepo: postRepo,
|
||||
systemMessageService: systemMessageService,
|
||||
@@ -85,6 +85,7 @@ func NewPostService(postRepo *repository.PostRepository, systemMessageService Sy
|
||||
postAIService: postAIService,
|
||||
txManager: txManager,
|
||||
logService: nil,
|
||||
hookManager: hookManager,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,40 +148,48 @@ func (s *postServiceImpl) reviewPostAsync(postID, userID, title, content string,
|
||||
}
|
||||
}()
|
||||
|
||||
// 未启用AI时,直接发布
|
||||
if s.postAIService == nil || !s.postAIService.IsEnabled() {
|
||||
if s.hookManager == 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 {
|
||||
s.invalidatePostCaches(postID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
err := s.postAIService.ModeratePost(context.Background(), title, content, images)
|
||||
if err != nil {
|
||||
var rejectedErr *PostModerationRejectedError
|
||||
if errors.As(err, &rejectedErr) {
|
||||
if updateErr := s.updateModerationStatusWithRetry(postID, model.PostStatusRejected, rejectedErr.UserMessage(), "ai"); updateErr != nil {
|
||||
var authorID uint
|
||||
fmt.Sscanf(userID, "%d", &authorID)
|
||||
|
||||
result := &hook.ModerationResult{}
|
||||
s.hookManager.TriggerWithMetadata(context.Background(), hook.HookPostPreModerate, authorID, &hook.PostModerateHookData{
|
||||
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 {
|
||||
s.invalidatePostCaches(postID)
|
||||
}
|
||||
s.notifyModerationRejected(userID, rejectedErr.Reason)
|
||||
s.notifyModerationRejected(userID, result.RejectReason)
|
||||
return
|
||||
}
|
||||
|
||||
// 规则审核不可用时,降级为发布,避免长时间pending
|
||||
if updateErr := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "system"); updateErr != nil {
|
||||
log.Printf("[WARN] Failed to publish post %s after moderation error: %v", postID, updateErr)
|
||||
} else {
|
||||
s.invalidatePostCaches(postID)
|
||||
}
|
||||
log.Printf("[WARN] Post moderation failed, fallback publish post=%s err=%v", postID, err)
|
||||
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)
|
||||
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.NewAdminDashboardHandler,
|
||||
handler.NewAdminLogHandler,
|
||||
handler.NewQRCodeHandler,
|
||||
|
||||
// 需要特殊处理的 Handler
|
||||
ProvideUserHandler,
|
||||
|
||||
@@ -9,11 +9,13 @@ import (
|
||||
"carrot_bbs/internal/pkg/crypto"
|
||||
"carrot_bbs/internal/pkg/email"
|
||||
"carrot_bbs/internal/pkg/gorse"
|
||||
"carrot_bbs/internal/pkg/hook"
|
||||
"carrot_bbs/internal/pkg/openai"
|
||||
"carrot_bbs/internal/pkg/redis"
|
||||
"carrot_bbs/internal/pkg/s3"
|
||||
"carrot_bbs/internal/pkg/sse"
|
||||
"carrot_bbs/internal/repository"
|
||||
"carrot_bbs/internal/service"
|
||||
|
||||
"github.com/google/wire"
|
||||
"go.uber.org/zap"
|
||||
@@ -38,6 +40,11 @@ var InfrastructureSet = wire.NewSet(
|
||||
ProvideRedisClient,
|
||||
ProvideCache,
|
||||
|
||||
// 钩子系统
|
||||
ProvideHookManager,
|
||||
ProvideBuiltinHooks,
|
||||
ProvideModerationHooks,
|
||||
|
||||
// SSE Hub
|
||||
ProvideSSEHub,
|
||||
|
||||
@@ -88,7 +95,18 @@ func ProvideCache(cfg *config.Config, redisClient *redis.Client) cache.Cache {
|
||||
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 {
|
||||
zap.L().Warn("Redis client is nil, cache will not be available")
|
||||
return nil
|
||||
@@ -99,6 +117,47 @@ func ProvideCache(cfg *config.Config, redisClient *redis.Client) cache.Cache {
|
||||
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
|
||||
func ProvideSSEHub() *sse.Hub {
|
||||
return sse.NewHub()
|
||||
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
"carrot_bbs/internal/grpc/runner"
|
||||
"carrot_bbs/internal/pkg/email"
|
||||
"carrot_bbs/internal/pkg/gorse"
|
||||
"carrot_bbs/internal/pkg/hook"
|
||||
"carrot_bbs/internal/pkg/openai"
|
||||
"carrot_bbs/internal/pkg/redis"
|
||||
"carrot_bbs/internal/pkg/s3"
|
||||
"carrot_bbs/internal/pkg/sse"
|
||||
"carrot_bbs/internal/repository"
|
||||
@@ -50,6 +52,7 @@ var ServiceSet = wire.NewSet(
|
||||
ProvideAdminCommentService,
|
||||
ProvideAdminGroupService,
|
||||
ProvideAdminDashboardService,
|
||||
ProvideQRCodeLoginService,
|
||||
|
||||
// 日志服务
|
||||
ProvideAsyncLogManager,
|
||||
@@ -100,7 +103,6 @@ func ProvideSystemMessageService(
|
||||
return service.NewSystemMessageService(notifyRepo, pushService, userRepo, postRepo, cacheBackend)
|
||||
}
|
||||
|
||||
// ProvidePostService 提供帖子服务
|
||||
func ProvidePostService(
|
||||
postRepo *repository.PostRepository,
|
||||
systemMessageService service.SystemMessageService,
|
||||
@@ -108,11 +110,11 @@ func ProvidePostService(
|
||||
postAIService *service.PostAIService,
|
||||
cacheBackend cache.Cache,
|
||||
txManager repository.TransactionManager,
|
||||
hookManager *hook.Manager,
|
||||
) service.PostService {
|
||||
return service.NewPostService(postRepo, systemMessageService, gorseClient, postAIService, cacheBackend, txManager)
|
||||
return service.NewPostService(postRepo, systemMessageService, gorseClient, postAIService, cacheBackend, txManager, hookManager)
|
||||
}
|
||||
|
||||
// ProvideCommentService 提供评论服务
|
||||
func ProvideCommentService(
|
||||
commentRepo *repository.CommentRepository,
|
||||
postRepo *repository.PostRepository,
|
||||
@@ -120,8 +122,9 @@ func ProvideCommentService(
|
||||
gorseClient gorse.Client,
|
||||
postAIService *service.PostAIService,
|
||||
cacheBackend cache.Cache,
|
||||
hookManager *hook.Manager,
|
||||
) *service.CommentService {
|
||||
return service.NewCommentService(commentRepo, postRepo, systemMessageService, gorseClient, postAIService, cacheBackend)
|
||||
return service.NewCommentService(commentRepo, postRepo, systemMessageService, gorseClient, postAIService, cacheBackend, hookManager)
|
||||
}
|
||||
|
||||
// ProvideMessageService 提供消息服务
|
||||
@@ -343,6 +346,18 @@ func ProvideLogService(
|
||||
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 解析时长字符串
|
||||
func parseDuration(s string) (time.Duration, error) {
|
||||
return time.ParseDuration(s)
|
||||
|
||||
Reference in New Issue
Block a user