feat: add hook system, QR code login, and layered cache
All checks were successful
Build Backend / build (push) Successful in 5m54s
Build Backend / build-docker (push) Successful in 4m7s

- 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:
lafay
2026-03-20 12:23:28 +08:00
parent bdfcbdadea
commit 98f0c9f2b6
23 changed files with 2726 additions and 76 deletions

View File

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

View File

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

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

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

View File

@@ -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
View 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
View 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, &current)
}
}
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
View 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)

View File

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

View File

@@ -17,13 +17,22 @@ type RedisConfig struct {
// CacheConfig 缓存配置
type CacheConfig struct {
Enabled bool `mapstructure:"enabled"`
KeyPrefix string `mapstructure:"key_prefix"`
DefaultTTL int `mapstructure:"default_ttl"`
NullTTL int `mapstructure:"null_ttl"`
JitterRatio float64 `mapstructure:"jitter_ratio"`
DisableFlushDB bool `mapstructure:"disable_flushdb"`
Modules CacheModuleTTL `mapstructure:"modules"`
Enabled bool `mapstructure:"enabled"`
KeyPrefix string `mapstructure:"key_prefix"`
DefaultTTL int `mapstructure:"default_ttl"`
NullTTL int `mapstructure:"null_ttl"`
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 配置

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

View File

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

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

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

View File

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

View File

@@ -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) {
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
}
var authorID uint
fmt.Sscanf(userID, "%d", &authorID)
// 审核服务异常时降级放行避免评论长期pending
if updateErr := s.commentRepo.UpdateModerationStatus(commentID, model.CommentStatusPublished); updateErr != nil {
zap.L().Warn("Failed to publish comment after moderation error",
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(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),
zap.Error(delErr),
)
}
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
}

View File

@@ -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 {
log.Printf("[WARN] Failed to reject post %s: %v", postID, updateErr)
} else {
s.invalidatePostCaches(postID)
}
s.notifyModerationRejected(userID, rejectedErr.Reason)
return
}
var authorID uint
fmt.Sscanf(userID, "%d", &authorID)
// 规则审核不可用时降级为发布避免长时间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)
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)
}
log.Printf("[WARN] Post moderation failed, fallback publish post=%s err=%v", postID, err)
s.notifyModerationRejected(userID, result.RejectReason)
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
}

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

View File

@@ -28,6 +28,7 @@ var HandlerSet = wire.NewSet(
handler.NewAdminGroupHandler,
handler.NewAdminDashboardHandler,
handler.NewAdminLogHandler,
handler.NewQRCodeHandler,
// 需要特殊处理的 Handler
ProvideUserHandler,

View File

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

View File

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